diff --git a/src/tools/agent-teams/delegation-consistency.test.ts b/src/tools/agent-teams/delegation-consistency.test.ts new file mode 100644 index 000000000..01fd6bf46 --- /dev/null +++ b/src/tools/agent-teams/delegation-consistency.test.ts @@ -0,0 +1,189 @@ +/// +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { BackgroundManager } from "../../features/background-agent" +import { createAgentTeamsTools } from "./tools" + +interface LaunchCall { + description: string + prompt: string + agent: string + parentSessionID: string + parentMessageID: string + parentAgent?: string + parentModel?: { + providerID: string + modelID: string + variant?: string + } +} + +interface ResumeCall { + sessionId: string + prompt: string + parentSessionID: string + parentMessageID: string + parentAgent?: string + parentModel?: { + providerID: string + modelID: string + variant?: string + } +} + +interface ToolContextLike { + sessionID: string + messageID: string + abort: AbortSignal + agent?: string +} + +function createMockManager(): { + manager: BackgroundManager + launchCalls: LaunchCall[] + resumeCalls: ResumeCall[] +} { + const launchCalls: LaunchCall[] = [] + const resumeCalls: ResumeCall[] = [] + const launchedTasks = new Map() + let launchCount = 0 + + const manager = { + launch: async (args: LaunchCall) => { + launchCount += 1 + launchCalls.push(args) + const task = { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` } + launchedTasks.set(task.id, task) + return task + }, + getTask: (taskId: string) => launchedTasks.get(taskId), + resume: async (args: ResumeCall) => { + resumeCalls.push(args) + return { id: `resume-${resumeCalls.length}` } + }, + } as unknown as BackgroundManager + + return { manager, launchCalls, resumeCalls } +} + +async function executeJsonTool( + tools: ReturnType, + toolName: keyof ReturnType, + args: Record, + context: ToolContextLike, +): Promise { + const output = await tools[toolName].execute(args, context as any) + return JSON.parse(output) +} + +describe("agent-teams delegation consistency", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-consistency-")) + process.chdir(tempProjectDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tempProjectDir, { recursive: true, force: true }) + }) + + test("team delegation forwards parent context like normal delegate-task", async () => { + //#given + const { manager, launchCalls, resumeCalls } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext: ToolContextLike = { + sessionID: "ses-main", + messageID: "msg-main", + abort: new AbortController().signal, + } + + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + + //#when + const spawnResult = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + leadContext, + ) as { error?: string } + + //#then + expect(spawnResult.error).toBeUndefined() + expect(launchCalls).toHaveLength(1) + expect(launchCalls[0].parentAgent).toBe("sisyphus") + expect("parentModel" in launchCalls[0]).toBe(true) + + //#when + const messageResult = await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + recipient: "worker_1", + summary: "sync", + content: "Please update status.", + }, + leadContext, + ) as { error?: string } + + //#then + expect(messageResult.error).toBeUndefined() + expect(resumeCalls).toHaveLength(1) + expect(resumeCalls[0].parentAgent).toBe("sisyphus") + expect("parentModel" in resumeCalls[0]).toBe(true) + }) + + test("send_message accepts teammate agent_id as recipient", async () => { + //#given + const { manager, resumeCalls } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext: ToolContextLike = { + sessionID: "ses-main", + messageID: "msg-main", + abort: new AbortController().signal, + } + + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + leadContext, + ) + + //#when + const messageResult = await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + recipient: "worker_1@core", + summary: "sync", + content: "Please update status.", + }, + leadContext, + ) as { error?: string } + + //#then + expect(messageResult.error).toBeUndefined() + expect(resumeCalls).toHaveLength(1) + }) +}) diff --git a/src/tools/agent-teams/teammate-parent-context.ts b/src/tools/agent-teams/teammate-parent-context.ts new file mode 100644 index 000000000..caeace6c9 --- /dev/null +++ b/src/tools/agent-teams/teammate-parent-context.ts @@ -0,0 +1,13 @@ +import type { ParentContext } from "../delegate-task/executor" +import { resolveParentContext } from "../delegate-task/executor" +import type { ToolContextWithMetadata } from "../delegate-task/types" +import type { TeamToolContext } from "./types" + +export function resolveTeamParentContext(context: TeamToolContext): ParentContext { + return resolveParentContext({ + sessionID: context.sessionID, + messageID: context.messageID, + agent: context.agent ?? "sisyphus", + abort: new AbortController().signal, + } as ToolContextWithMetadata) +} diff --git a/src/tools/agent-teams/teammate-prompts.ts b/src/tools/agent-teams/teammate-prompts.ts new file mode 100644 index 000000000..051995967 --- /dev/null +++ b/src/tools/agent-teams/teammate-prompts.ts @@ -0,0 +1,28 @@ +export function buildLaunchPrompt( + teamName: string, + teammateName: string, + userPrompt: string, + categoryPromptAppend?: string, +): string { + const sections = [ + `You are teammate "${teammateName}" in team "${teamName}".`, + `When you need updates, call read_inbox with team_name="${teamName}" and agent_name="${teammateName}".`, + "Initial assignment:", + userPrompt, + ] + + if (categoryPromptAppend) { + sections.push("Category guidance:", categoryPromptAppend) + } + + return sections.join("\n\n") +} + +export function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string { + return [ + `New team message for "${teammateName}" in team "${teamName}".`, + `Summary: ${summary}`, + "Content:", + content, + ].join("\n\n") +} diff --git a/src/tools/agent-teams/teammate-runtime.ts b/src/tools/agent-teams/teammate-runtime.ts index 484233d0f..38b87ffdc 100644 --- a/src/tools/agent-teams/teammate-runtime.ts +++ b/src/tools/agent-teams/teammate-runtime.ts @@ -1,25 +1,10 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import type { CategoriesConfig } from "../../config/schema" import type { BackgroundManager } from "../../features/background-agent" -import { resolveCategoryExecution, resolveParentContext } from "../delegate-task/executor" -import type { DelegateTaskArgs, ToolContextWithMetadata } from "../delegate-task/types" import { clearInbox, ensureInbox, sendPlainInboxMessage } from "./inbox-store" import { assignNextColor, getTeamMember, removeTeammate, updateTeamConfig, upsertTeammate } from "./team-config-store" import type { TeamTeammateMember, TeamToolContext } from "./types" - -function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined { - if (!model) { - return undefined - } - const separatorIndex = model.indexOf("/") - if (separatorIndex <= 0 || separatorIndex >= model.length - 1) { - throw new Error("invalid_model_override_format") - } - - const providerID = model.slice(0, separatorIndex) - const modelID = model.slice(separatorIndex + 1) - return { providerID, modelID } -} +import { resolveTeamParentContext } from "./teammate-parent-context" +import { buildDeliveryPrompt, buildLaunchPrompt } from "./teammate-prompts" +import { resolveSpawnExecution, type TeamCategoryContext } from "./teammate-spawn-execution" function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -37,35 +22,6 @@ function resolveLaunchFailureMessage(status: string | undefined, error: string | return "teammate_launch_timeout" } -function buildLaunchPrompt( - teamName: string, - teammateName: string, - userPrompt: string, - categoryPromptAppend?: string, -): string { - const sections = [ - `You are teammate "${teammateName}" in team "${teamName}".`, - `When you need updates, call read_inbox with team_name="${teamName}" and agent_name="${teammateName}".`, - "Initial assignment:", - userPrompt, - ] - - if (categoryPromptAppend) { - sections.push("Category guidance:", categoryPromptAppend) - } - - return sections.join("\n\n") -} - -function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string { - return [ - `New team message for "${teammateName}" in team "${teamName}".`, - `Summary: ${summary}`, - "Content:", - content, - ].join("\n\n") -} - export interface SpawnTeammateParams { teamName: string name: string @@ -76,99 +32,24 @@ export interface SpawnTeammateParams { planModeRequired: boolean context: TeamToolContext manager: BackgroundManager - categoryContext?: { - client: PluginInput["client"] - userCategories?: CategoriesConfig - sisyphusJuniorModel?: string - } -} - -interface SpawnExecution { - agentType: string - teammateModel: string - launchModel?: { providerID: string; modelID: string; variant?: string } - categoryPromptAppend?: string -} - -async function getSystemDefaultModel(client: PluginInput["client"]): Promise { - try { - const openCodeConfig = await client.config.get() - return (openCodeConfig as { data?: { model?: string } })?.data?.model - } catch { - return undefined - } -} - -async function resolveSpawnExecution(params: SpawnTeammateParams): Promise { - if (params.model) { - const launchModel = parseModel(params.model) - return { - agentType: params.subagentType, - teammateModel: params.model, - ...(launchModel ? { launchModel } : {}), - } - } - - if (!params.categoryContext?.client) { - return { - agentType: params.subagentType, - teammateModel: "native", - } - } - - const parentContext = resolveParentContext({ - sessionID: params.context.sessionID, - messageID: params.context.messageID, - agent: params.context.agent ?? "sisyphus", - abort: new AbortController().signal, - } as ToolContextWithMetadata) - - const inheritedModel = parentContext.model - ? `${parentContext.model.providerID}/${parentContext.model.modelID}` - : undefined - - const systemDefaultModel = await getSystemDefaultModel(params.categoryContext.client) - - const delegateArgs: DelegateTaskArgs = { - description: `[team:${params.teamName}] ${params.name}`, - prompt: params.prompt, - category: params.category, - subagent_type: "sisyphus-junior", - run_in_background: true, - load_skills: [], - } - - const resolution = await resolveCategoryExecution( - delegateArgs, - { - manager: params.manager, - client: params.categoryContext.client, - directory: process.cwd(), - userCategories: params.categoryContext.userCategories, - sisyphusJuniorModel: params.categoryContext.sisyphusJuniorModel, - }, - inheritedModel, - systemDefaultModel, - ) - - if (resolution.error) { - throw new Error(resolution.error) - } - - if (!resolution.categoryModel) { - throw new Error("category_model_not_resolved") - } - - return { - agentType: resolution.agentToUse, - teammateModel: `${resolution.categoryModel.providerID}/${resolution.categoryModel.modelID}`, - launchModel: resolution.categoryModel, - categoryPromptAppend: resolution.categoryPromptAppend, - } + categoryContext?: TeamCategoryContext } export async function spawnTeammate(params: SpawnTeammateParams): Promise { - const execution = await resolveSpawnExecution(params) + const parentContext = resolveTeamParentContext(params.context) + const execution = await resolveSpawnExecution( + { + teamName: params.teamName, + name: params.name, + prompt: params.prompt, + category: params.category, + subagentType: params.subagentType, + model: params.model, + manager: params.manager, + categoryContext: params.categoryContext, + }, + parentContext, + ) let teammate: TeamTeammateMember | undefined let launchedTaskID: string | undefined @@ -209,11 +90,12 @@ export async function spawnTeammate(params: SpawnTeammateParams): Promise= model.length - 1) { + throw new Error("invalid_model_override_format") + } + + return { + providerID: model.slice(0, separatorIndex), + modelID: model.slice(separatorIndex + 1), + } +} + +async function getSystemDefaultModel(client: PluginInput["client"]): Promise { + try { + const openCodeConfig = await client.config.get() + return (openCodeConfig as { data?: { model?: string } })?.data?.model + } catch { + return undefined + } +} + +export interface TeamCategoryContext { + client: PluginInput["client"] + userCategories?: CategoriesConfig + sisyphusJuniorModel?: string +} + +export interface SpawnExecutionRequest { + teamName: string + name: string + prompt: string + category: string + subagentType: string + model?: string + manager: BackgroundManager + categoryContext?: TeamCategoryContext +} + +export interface SpawnExecutionResult { + agentType: string + teammateModel: string + launchModel?: { providerID: string; modelID: string; variant?: string } + categoryPromptAppend?: string +} + +export async function resolveSpawnExecution( + request: SpawnExecutionRequest, + parentContext: ParentContext, +): Promise { + if (request.model) { + const launchModel = parseModel(request.model) + return { + agentType: request.subagentType, + teammateModel: request.model, + ...(launchModel ? { launchModel } : {}), + } + } + + if (!request.categoryContext?.client) { + return { + agentType: request.subagentType, + teammateModel: "native", + } + } + + const inheritedModel = parentContext.model + ? `${parentContext.model.providerID}/${parentContext.model.modelID}` + : undefined + + const systemDefaultModel = await getSystemDefaultModel(request.categoryContext.client) + + const delegateArgs: DelegateTaskArgs = { + description: `[team:${request.teamName}] ${request.name}`, + prompt: request.prompt, + category: request.category, + subagent_type: "sisyphus-junior", + run_in_background: true, + load_skills: [], + } + + const resolution = await resolveCategoryExecution( + delegateArgs, + { + manager: request.manager, + client: request.categoryContext.client, + directory: process.cwd(), + userCategories: request.categoryContext.userCategories, + sisyphusJuniorModel: request.categoryContext.sisyphusJuniorModel, + }, + inheritedModel, + systemDefaultModel, + ) + + if (resolution.error) { + throw new Error(resolution.error) + } + + if (!resolution.categoryModel) { + throw new Error("category_model_not_resolved") + } + + return { + agentType: resolution.agentToUse, + teammateModel: `${resolution.categoryModel.providerID}/${resolution.categoryModel.modelID}`, + launchModel: resolution.categoryModel, + categoryPromptAppend: resolution.categoryPromptAppend, + } +} diff --git a/src/tools/agent-teams/types.ts b/src/tools/agent-teams/types.ts index 89296c578..099dcb856 100644 --- a/src/tools/agent-teams/types.ts +++ b/src/tools/agent-teams/types.ts @@ -115,10 +115,26 @@ export const TeamSpawnInputSchema = z.object({ plan_mode_required: z.boolean().optional(), }) +function normalizeTeamRecipient(recipient: string): string { + const trimmed = recipient.trim() + const atIndex = trimmed.indexOf("@") + if (atIndex <= 0) { + return trimmed + } + + return trimmed.slice(0, atIndex) +} + export const TeamSendMessageInputSchema = z.object({ team_name: z.string(), type: TeamSendMessageTypeSchema, - recipient: z.string().optional(), + recipient: z.string().optional().transform((value) => { + if (value === undefined) { + return undefined + } + + return normalizeTeamRecipient(value) + }), content: z.string().optional(), summary: z.string().optional(), request_id: z.string().optional(),