diff --git a/internal/agent/resolver_helpers.go b/internal/agent/resolver_helpers.go index b394b1c2..057da8ef 100644 --- a/internal/agent/resolver_helpers.go +++ b/internal/agent/resolver_helpers.go @@ -142,18 +142,24 @@ func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uu } } - // Workflow guidance + // Workflow guidance — version-aware to match backend behavior. + // V2: spawn auto-creates tasks, leads should NOT manually create. + // V1: leads must create tasks first, then spawn with team_task_id. sb.WriteString("\n## Workflow\n\n") if selfRole == store.TeamRoleLead { - sb.WriteString("**ONE delegation = ONE spawn call.** System auto-creates a tracking task per delegation.\n") - sb.WriteString("The `label` parameter sets the task title (keep it short). Tasks auto-complete when delegation finishes.\n\n") - sb.WriteString("Rules:\n") - sb.WriteString("- Call all spawns first, then briefly tell the user what you delegated\n") - sb.WriteString("- Do NOT add confirmations (\"Done!\", \"Got it!\") — just state what was assigned\n") - sb.WriteString("- Parallel results arrive in a single combined notification — do NOT present partial results\n") - sb.WriteString("- For dependency chains: `team_tasks` create with blocked_by, then `spawn` with team_task_id\n") - if isV2 { + sb.WriteString("**NEVER use `team_tasks create` before spawning.** The system handles task creation automatically.\n\n") + sb.WriteString("WRONG: `team_tasks(action=\"create\", ...)` → `spawn(team_task_id=...)`\n") + sb.WriteString("CORRECT: `spawn(agent=\"...\", task=\"...\", label=\"short title\")`\n\n") + sb.WriteString("Just call `spawn` — the system auto-creates one tracking task per delegation.\n") + sb.WriteString("The `label` parameter sets the auto-created task title. Tasks auto-complete when delegation finishes.\n\n") + sb.WriteString("Rules:\n") + sb.WriteString("- Each spawn call creates its own task — never pass the same `team_task_id` to multiple spawns\n") + sb.WriteString("- Call all spawns first, then briefly tell the user what you delegated\n") + sb.WriteString("- Do NOT add confirmations (\"Done!\", \"Got it!\") — just state what was assigned\n") + sb.WriteString("- Parallel results arrive in a single combined notification — do NOT present partial results\n") + sb.WriteString("- `team_tasks create` is ONLY for dependency chains (tasks with `blocked_by`), then `spawn` with that `team_task_id`\n") + sb.WriteString("\n## Orchestration Patterns\n\n") sb.WriteString("- **Sequential**: A finishes → review → delegate to B with A's output\n") sb.WriteString("- **Iterative**: A drafts → B reviews → A revises with feedback\n") @@ -164,6 +170,14 @@ func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uu sb.WriteString("\n## Follow-up Reminders\n\n") sb.WriteString("When waiting for user reply: create+claim task, then `await_reply` with text=.\n") sb.WriteString("System auto-sends reminders. Call `clear_followup` when user replies.\n") + } else { + sb.WriteString("Create a task with `team_tasks` first, then `spawn` with that `team_task_id`.\n") + sb.WriteString("Tasks auto-complete when delegation finishes.\n\n") + sb.WriteString("Rules:\n") + sb.WriteString("- Each task should have exactly one spawn (1:1 mapping)\n") + sb.WriteString("- Call all spawns first, then briefly tell the user what you delegated\n") + sb.WriteString("- Do NOT add confirmations (\"Done!\", \"Got it!\") — just state what was assigned\n") + sb.WriteString("- Parallel results arrive in a single combined notification — do NOT present partial results\n") } sb.WriteString("\nFor simple questions about team composition, answer directly from the member list above.\n") diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index 36ac3190..c5f1dbc0 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -85,7 +85,7 @@ var coreToolSummaries = map[string]string{ "handoff": "Transfer conversation to another agent (ONLY when user explicitly asks to switch agents — NOT for task delegation)", "evaluate_loop": "Run a generate→evaluate→revise loop between two agents for quality-critical tasks", "delegate_search": "Search for agents by expertise to find the right delegation target", - "team_tasks": "Manage team task board (list, create, complete, cancel tasks)", + "team_tasks": "Team task board — track progress, manage dependencies (spawn auto-creates delegation tasks)", "team_message": "Send messages to teammates (progress updates, questions)", "workspace_write": "Write files to the team shared workspace (visible to all team members)", "workspace_read": "Read, list, delete, pin, tag files in the team shared workspace", diff --git a/internal/tools/delegate_prep.go b/internal/tools/delegate_prep.go index 1540e231..40d415a8 100644 --- a/internal/tools/delegate_prep.go +++ b/internal/tools/delegate_prep.go @@ -116,6 +116,18 @@ func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateO opts.TeamTaskID, teamTask.Status, ownerLabel) } + // Guard: reject in-progress tasks — prevent multiple delegations on the same task. + // This catches the pattern where an LLM reuses a team_task_id across parallel spawns. + if teamTask.Status == store.TeamTaskStatusInProgress { + ownerLabel := "another delegation" + if teamTask.OwnerAgentKey != "" { + ownerLabel = teamTask.OwnerAgentKey + } + return nil, nil, fmt.Errorf( + "team_task_id %s is already in progress (claimed by %q). Each spawn needs its own task — omit team_task_id to auto-create.", + opts.TeamTaskID, ownerLabel) + } + if team != nil { if teamTask.TeamID != team.ID { return nil, nil, fmt.Errorf("team_task_id does not belong to your team") @@ -141,7 +153,10 @@ func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateO // tasks that are already running. The ClaimTask in autoCompleteTeamTask() // will harmlessly fail (WHERE status='pending' won't match). if team != nil && IsTeamV2(team) { - _ = dm.teamStore.ClaimTask(ctx, opts.TeamTaskID, targetAgent.ID, teamTask.TeamID) + if err := dm.teamStore.ClaimTask(ctx, opts.TeamTaskID, targetAgent.ID, teamTask.TeamID); err != nil { + slog.Warn("delegate: task claim race — another delegation may have claimed this task", + "task_id", opts.TeamTaskID, "target", opts.TargetAgentKey, "error", err) + } } } diff --git a/internal/tools/subagent_spawn_tool.go b/internal/tools/subagent_spawn_tool.go index 7af5924d..99fb9164 100644 --- a/internal/tools/subagent_spawn_tool.go +++ b/internal/tools/subagent_spawn_tool.go @@ -90,7 +90,7 @@ func (t *SpawnTool) Parameters() map[string]any { } props["team_task_id"] = map[string]any{ "type": "string", - "description": "Team task ID to auto-complete when task finishes (for team workflows)", + "description": "Omit to auto-create a task (recommended). Only set for dependency chains with blocked_by tasks.", } props["estimated_duration"] = map[string]any{ "type": "number",