From e2e2436eb87e1ee4eceb12869513b9f8e50c0fb7 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Tue, 25 Aug 2026 23:39:15 -0700 Subject: [PATCH] feat(crossagent-mcp): support batch defaults for spawned tasks - Add task-level selection overrides for provider, model, reasoning, fast, and permissions - Test inherited and overridden batch selections --- .../crossagentMcp/toolRegistry.test.ts | 75 +++++++++++++++++++ src/supervisor/crossagentMcp/toolRegistry.ts | 23 +++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/supervisor/crossagentMcp/toolRegistry.test.ts b/src/supervisor/crossagentMcp/toolRegistry.test.ts index dfb5c5a68..720ec83dd 100644 --- a/src/supervisor/crossagentMcp/toolRegistry.test.ts +++ b/src/supervisor/crossagentMcp/toolRegistry.test.ts @@ -604,6 +604,9 @@ describe("subagent tool registration", () => { ); const byName = new Map(TOOLS.map((tool) => [tool.name, tool])); expect(byName.get("spawn_agent")!.description).toContain("never spawn before it"); + expect(byName.get("spawn_agent")!.description).toContain( + "root provider/model/reasoning/fast/permissions are batch defaults", + ); }); it("asks parents to give every spawned run a descriptive task label", () => { @@ -680,6 +683,78 @@ describe("subagent tool registration", () => { ]); }); + it("applies a batch-level selection to tasks unless a task overrides it", async () => { + const { ctx } = makeToolContext(); + const received: unknown[] = []; + const listSpawnableAgents = ctx.listSpawnableAgents; + ctx.listSpawnableAgents = async (tags) => + (await listSpawnableAgents(tags)).map((agent) => ({ + ...agent, + models: agent.models.map((model) => ({ + ...model, + reasoning: { values: ["low", "high"], default: "low" }, + fast: { available: true }, + })), + ...(agent.preference + ? { preference: { ...agent.preference, reasoning: "low", fast: false } } + : {}), + })); + ctx.runManager = { + spawnMany: (_parentThreadId: string, requests: unknown[]) => { + received.push(...requests); + return requests.map((_, index) => ({ runId: `run-${index + 1}` })); + }, + } as unknown as SubagentRunManager; + + await dispatchTool( + "spawn_agent", + { + provider: "claude", + model: "sonnet", + reasoning: "high", + fast: true, + permissions: "full-access", + tasks: [ + { + name: "inherited", + provider: "", + model: null, + reasoning: "", + permissions: null, + prompt: "inspect", + }, + { + name: "overridden", + provider: "codex", + model: "gpt-5.5", + reasoning: "low", + fast: false, + prompt: "review", + }, + ], + }, + ctx, + ); + + expect(received).toEqual([ + { + agent: "claude", + model: "sonnet", + effort: "high", + fast: true, + prompt: "inspect", + name: "inherited", + }, + { + agent: "codex", + model: "gpt-5.5", + effort: "low", + prompt: "review", + name: "overridden", + }, + ]); + }); + it("rejects oversized task batches before resolving their providers", async () => { const { ctx } = makeToolContext(); const listSpawnableAgents = vi.fn(ctx.listSpawnableAgents); diff --git a/src/supervisor/crossagentMcp/toolRegistry.ts b/src/supervisor/crossagentMcp/toolRegistry.ts index ca1435952..f0249c354 100644 --- a/src/supervisor/crossagentMcp/toolRegistry.ts +++ b/src/supervisor/crossagentMcp/toolRegistry.ts @@ -179,7 +179,7 @@ const RAW_TOOLS: ToolSpec[] = [ { name: "spawn_agent", description: - "Call only after the user has explicitly asked to delegate work to another agent in this thread (for example via an @Crossagents mention); that ask covers the rest of the thread, but never spawn before it. Spawn one task-tagged agent and wait for its result by default. Omitted selection fields resolve from contextual rank. Set background=true to return a run_id immediately, or pass tasks=[...] to launch several agents in parallel.", + "Call only after the user has explicitly asked to delegate work to another agent in this thread (for example via an @Crossagents mention); that ask covers the rest of the thread, but never spawn before it. Spawn one task-tagged agent and wait for its result by default. Omitted selection fields resolve from contextual rank. With tasks=[...], root provider/model/reasoning/fast/permissions are batch defaults and each task may override them. Set background=true to return a run_id immediately, or pass tasks=[...] to launch several agents in parallel.", inputSchema: { type: "object", properties: { @@ -584,12 +584,31 @@ async function spawnAgent( if (tasks.length > MAX_CONCURRENT_CHILDREN_PER_PARENT) { return errorResult(`tasks supports at most ${MAX_CONCURRENT_CHILDREN_PER_PARENT} entries`); } + const batchSelection = { + ...(typeof args.provider === "string" ? { provider: args.provider } : {}), + ...(typeof args.model === "string" ? { model: args.model } : {}), + ...(typeof args.reasoning === "string" ? { reasoning: args.reasoning } : {}), + ...(typeof args.fast === "boolean" ? { fast: args.fast } : {}), + ...(typeof args.permissions === "string" ? { permissions: args.permissions } : {}), + }; const resolvedTasks: Array = await Promise.all( tasks.map(async (task) => { if (!task || typeof task !== "object" || Array.isArray(task)) { return null; } - const taskArgs = task as Record; + const { provider, model, reasoning, fast, permissions, ...taskProperties } = task as Record< + string, + unknown + >; + const taskArgs = { + ...batchSelection, + ...taskProperties, + ...(typeof provider === "string" && provider.length > 0 ? { provider } : {}), + ...(typeof model === "string" && model.length > 0 ? { model } : {}), + ...(typeof reasoning === "string" && reasoning.length > 0 ? { reasoning } : {}), + ...(typeof fast === "boolean" ? { fast } : {}), + ...(typeof permissions === "string" ? { permissions } : {}), + }; return resolveSelectionArgs(taskArgs, await agentsFor(taskArgs)); }), );