From e984ce74931531c444df78f3a1a0b006842a9290 Mon Sep 17 00:00:00 2001 From: Nguyen Khac Trung Kien Date: Sun, 8 Feb 2026 10:42:57 +0700 Subject: [PATCH] feat(agent-teams): support category-based teammate spawning --- src/tools/agent-teams/teammate-runtime.ts | 123 ++++++++++++++++-- src/tools/agent-teams/teammate-tools.ts | 35 ++++- .../agent-teams/tools.functional.test.ts | 91 +++++++++++++ src/tools/agent-teams/tools.ts | 19 ++- src/tools/agent-teams/types.ts | 2 + 5 files changed, 257 insertions(+), 13 deletions(-) diff --git a/src/tools/agent-teams/teammate-runtime.ts b/src/tools/agent-teams/teammate-runtime.ts index ace04047..a3f38369 100644 --- a/src/tools/agent-teams/teammate-runtime.ts +++ b/src/tools/agent-teams/teammate-runtime.ts @@ -1,4 +1,8 @@ +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" @@ -33,13 +37,24 @@ function resolveLaunchFailureMessage(status: string | undefined, error: string | return "teammate_launch_timeout" } -function buildLaunchPrompt(teamName: string, teammateName: string, userPrompt: string): string { - return [ +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, - ].join("\n\n") + ] + + if (categoryPromptAppend) { + sections.push("Category guidance:", categoryPromptAppend) + } + + return sections.join("\n\n") } function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string { @@ -55,14 +70,103 @@ export interface SpawnTeammateParams { teamName: string name: string prompt: string + category?: string subagentType: string model?: string 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.category) { + const launchModel = parseModel(params.model) + return { + agentType: params.subagentType, + teammateModel: params.model ?? "native", + ...(launchModel ? { launchModel } : {}), + } + } + + if (!params.categoryContext?.client) { + throw new Error("category_requires_client_context") + } + + 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, + } } export async function spawnTeammate(params: SpawnTeammateParams): Promise { + const execution = await resolveSpawnExecution(params) + let teammate: TeamTeammateMember | undefined let launchedTaskID: string | undefined @@ -74,8 +178,9 @@ export async function spawnTeammate(params: SpawnTeammateParams): Promise ({ data: { model: "openai/gpt-5.3-codex" } }), + }, + provider: { + list: async () => ({ data: { connected: ["openai", "anthropic"] } }), + }, + model: { + list: async () => ({ + data: [ + { provider: "openai", id: "gpt-5.3-codex" }, + { provider: "anthropic", id: "claude-haiku-4-5" }, + ], + }), + }, + } as unknown as PluginInput["client"] +} + function createContext(sessionID = "ses-main"): TestToolContext { return { sessionID, @@ -275,6 +297,75 @@ describe("agent-teams tools functional", () => { expect(clearedOwnerTask.owner).toBeUndefined() }) + test("spawns teammate using category resolution like delegate-task", async () => { + //#given + const { manager, launchCalls } = createMockManager() + const tools = createAgentTeamsTools(manager, { client: createCategoryClientMock() }) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + //#when + const spawned = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + context, + ) as { name?: string; error?: string } + + //#then + expect(spawned.error).toBeUndefined() + expect(spawned.name).toBe("worker_1") + expect(launchCalls).toHaveLength(1) + expect(launchCalls[0].agent).toBe("sisyphus-junior") + expect(launchCalls[0].category).toBe("quick") + expect(launchCalls[0].model).toBeDefined() + const resolvedModel = launchCalls[0].model! + expect(launchCalls[0].prompt).toContain("Category guidance:") + + //#when + const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, context) as { + members: Array<{ name: string; category?: string; model?: string }> + } + + //#then + const teammate = config.members.find((member) => member.name === "worker_1") + expect(teammate).toBeDefined() + expect(teammate?.category).toBe("quick") + expect(teammate?.model).toBe(`${resolvedModel.providerID}/${resolvedModel.modelID}`) + }) + + test("rejects category with incompatible subagent_type", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager, { client: createCategoryClientMock() }) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + //#when + const result = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + subagent_type: "oracle", + }, + context, + ) as { error?: string } + + //#then + expect(result.error).toBe("category_conflicts_with_subagent_type") + }) + test("rejects invalid task id input for task_get", async () => { //#given const { manager } = createMockManager() diff --git a/src/tools/agent-teams/tools.ts b/src/tools/agent-teams/tools.ts index 904f636f..ff32496f 100644 --- a/src/tools/agent-teams/tools.ts +++ b/src/tools/agent-teams/tools.ts @@ -1,16 +1,31 @@ import type { ToolDefinition } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" +import type { PluginInput } from "@opencode-ai/plugin" +import type { CategoriesConfig } from "../../config/schema" import { createReadInboxTool, createSendMessageTool } from "./messaging-tools" import { createTeamCreateTool, createTeamDeleteTool, createTeamReadConfigTool } from "./team-lifecycle-tools" import { createTeamTaskCreateTool, createTeamTaskGetTool, createTeamTaskListTool } from "./team-task-tools" import { createTeamTaskUpdateTool } from "./team-task-update-tool" import { createForceKillTeammateTool, createProcessShutdownTool, createSpawnTeammateTool } from "./teammate-tools" -export function createAgentTeamsTools(manager: BackgroundManager): Record { +export interface AgentTeamsToolOptions { + client?: PluginInput["client"] + userCategories?: CategoriesConfig + sisyphusJuniorModel?: string +} + +export function createAgentTeamsTools( + manager: BackgroundManager, + options?: AgentTeamsToolOptions, +): Record { return { team_create: createTeamCreateTool(), team_delete: createTeamDeleteTool(), - spawn_teammate: createSpawnTeammateTool(manager), + spawn_teammate: createSpawnTeammateTool(manager, { + client: options?.client, + userCategories: options?.userCategories, + sisyphusJuniorModel: options?.sisyphusJuniorModel, + }), send_message: createSendMessageTool(manager), read_inbox: createReadInboxTool(), read_config: createTeamReadConfigTool(), diff --git a/src/tools/agent-teams/types.ts b/src/tools/agent-teams/types.ts index 3883238c..c540fefb 100644 --- a/src/tools/agent-teams/types.ts +++ b/src/tools/agent-teams/types.ts @@ -30,6 +30,7 @@ export const TeamTeammateMemberSchema = z.object({ agentType: z.string().refine((value) => value !== "team-lead", { message: "agent_type_reserved", }), + category: z.string().optional(), model: z.string(), prompt: z.string(), color: z.string(), @@ -108,6 +109,7 @@ export const TeamSpawnInputSchema = z.object({ team_name: z.string(), name: z.string(), prompt: z.string(), + category: z.string().optional(), subagent_type: z.string().optional(), model: z.string().optional(), plan_mode_required: z.boolean().optional(),