refactor: simplify subagent tool contract

This commit is contained in:
pi
2026-04-12 06:54:48 +01:00
parent 4c91b70a4e
commit 4a0f78f9fb
6 changed files with 57 additions and 194 deletions

View File

@@ -37,10 +37,16 @@ test("the extension entrypoint registers the subagent tool with the currently av
assert.equal(registeredTools.length, 1); assert.equal(registeredTools.length, 1);
assert.equal(registeredTools[0]?.name, "subagent"); assert.equal(registeredTools[0]?.name, "subagent");
assert.deepEqual(registeredTools[0]?.parameters.required, ["model"]); assert.deepEqual(registeredTools[0]?.parameters.required, ["model"]);
assert.equal("agent" in registeredTools[0]?.parameters.properties, false);
assert.equal("agentScope" in registeredTools[0]?.parameters.properties, false);
assert.equal("confirmProjectAgents" in registeredTools[0]?.parameters.properties, false);
assert.equal("task" in registeredTools[0]?.parameters.properties, true);
assert.deepEqual(registeredTools[0]?.parameters.properties.model.enum, [ assert.deepEqual(registeredTools[0]?.parameters.properties.model.enum, [
"anthropic/claude-sonnet-4-5", "anthropic/claude-sonnet-4-5",
"openai/gpt-5", "openai/gpt-5",
]); ]);
assert.equal("agent" in registeredTools[0]?.parameters.properties.tasks.items.properties, false);
assert.equal("task" in registeredTools[0]?.parameters.properties.tasks.items.properties, true);
assert.deepEqual(registeredTools[0]?.parameters.properties.tasks.items.properties.model.enum, [ assert.deepEqual(registeredTools[0]?.parameters.properties.tasks.items.properties.model.enum, [
"anthropic/claude-sonnet-4-5", "anthropic/claude-sonnet-4-5",
"openai/gpt-5", "openai/gpt-5",

View File

@@ -11,7 +11,6 @@ function createTaskModelSchema(availableModels: readonly string[]) {
export function createTaskItemSchema(availableModels: readonly string[]) { export function createTaskItemSchema(availableModels: readonly string[]) {
return Type.Object({ return Type.Object({
agent: Type.String({ description: "Name of the agent to invoke" }),
task: Type.String({ description: "Task to delegate to the child agent" }), task: Type.String({ description: "Task to delegate to the child agent" }),
model: createTaskModelSchema(availableModels), model: createTaskModelSchema(availableModels),
cwd: Type.Optional(Type.String({ description: "Optional working directory override" })), cwd: Type.Optional(Type.String({ description: "Optional working directory override" })),
@@ -20,7 +19,6 @@ export function createTaskItemSchema(availableModels: readonly string[]) {
export function createChainItemSchema(availableModels: readonly string[]) { export function createChainItemSchema(availableModels: readonly string[]) {
return Type.Object({ return Type.Object({
agent: Type.String({ description: "Name of the agent to invoke" }),
task: Type.String({ description: "Task with optional {previous} placeholder" }), task: Type.String({ description: "Task with optional {previous} placeholder" }),
model: createTaskModelSchema(availableModels), model: createTaskModelSchema(availableModels),
cwd: Type.Optional(Type.String({ description: "Optional working directory override" })), cwd: Type.Optional(Type.String({ description: "Optional working directory override" })),
@@ -30,22 +28,14 @@ export function createChainItemSchema(availableModels: readonly string[]) {
export const TaskItemSchema = createTaskItemSchema([]); export const TaskItemSchema = createTaskItemSchema([]);
export const ChainItemSchema = createChainItemSchema([]); export const ChainItemSchema = createChainItemSchema([]);
export const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
description: "Which markdown agent sources to use",
default: "user",
});
export function createSubagentParamsSchema(availableModels: readonly string[]) { export function createSubagentParamsSchema(availableModels: readonly string[]) {
return Type.Object({ return Type.Object({
agent: Type.Optional(Type.String({ description: "Single-mode agent name" })),
task: Type.Optional(Type.String({ description: "Single-mode delegated task" })), task: Type.Optional(Type.String({ description: "Single-mode delegated task" })),
model: StringEnum(availableModels, { model: StringEnum(availableModels, {
description: "Required top-level child model. Must be one of the currently available models.", description: "Required top-level child model. Must be one of the currently available models.",
}), }),
tasks: Type.Optional(Type.Array(createTaskItemSchema(availableModels), { description: "Parallel tasks" })), tasks: Type.Optional(Type.Array(createTaskItemSchema(availableModels), { description: "Parallel tasks" })),
chain: Type.Optional(Type.Array(createChainItemSchema(availableModels), { description: "Sequential tasks" })), chain: Type.Optional(Type.Array(createChainItemSchema(availableModels), { description: "Sequential tasks" })),
agentScope: Type.Optional(AgentScopeSchema),
confirmProjectAgents: Type.Optional(Type.Boolean({ default: true })),
cwd: Type.Optional(Type.String({ description: "Single-mode working directory override" })), cwd: Type.Optional(Type.String({ description: "Single-mode working directory override" })),
}); });
} }
@@ -55,12 +45,9 @@ export const SubagentParamsSchema = createSubagentParamsSchema([]);
export type TaskItem = Static<typeof TaskItemSchema>; export type TaskItem = Static<typeof TaskItemSchema>;
export type ChainItem = Static<typeof ChainItemSchema>; export type ChainItem = Static<typeof ChainItemSchema>;
export type SubagentParams = Static<typeof SubagentParamsSchema>; export type SubagentParams = Static<typeof SubagentParamsSchema>;
export type AgentScope = Static<typeof AgentScopeSchema>;
export interface SubagentRunResult { export interface SubagentRunResult {
runId: string; runId: string;
agent: string;
agentSource: "builtin" | "user" | "project" | "unknown";
task: string; task: string;
requestedModel?: string; requestedModel?: string;
resolvedModel?: string; resolvedModel?: string;
@@ -80,7 +67,5 @@ export interface SubagentRunResult {
export interface SubagentToolDetails { export interface SubagentToolDetails {
mode: "single" | "parallel" | "chain"; mode: "single" | "parallel" | "chain";
agentScope: AgentScope;
projectAgentsDir: string | null;
results: SubagentRunResult[]; results: SubagentRunResult[];
} }

View File

@@ -6,22 +6,13 @@ test("chain mode substitutes {previous} into the next task", async () => {
const seenTasks: string[] = []; const seenTasks: string[] = [];
const tool = createSubagentTool({ const tool = createSubagentTool({
discoverAgents: () => ({
agents: [
{ name: "scout", description: "Scout", source: "builtin", systemPrompt: "Scout prompt" },
{ name: "planner", description: "Planner", source: "builtin", systemPrompt: "Planner prompt" },
],
projectAgentsDir: null,
}),
runSingleTask: async ({ meta }: any) => { runSingleTask: async ({ meta }: any) => {
seenTasks.push(meta.task); seenTasks.push(meta.task);
return { return {
runId: `${meta.agent}-${seenTasks.length}`, runId: `run-${seenTasks.length}`,
agent: meta.agent,
agentSource: meta.agentSource,
task: meta.task, task: meta.task,
exitCode: 0, exitCode: 0,
finalText: meta.agent === "scout" ? "Scout output" : "Plan output", finalText: seenTasks.length === 1 ? "Inspection output" : "Plan output",
}; };
}, },
} as any); } as any);
@@ -31,8 +22,8 @@ test("chain mode substitutes {previous} into the next task", async () => {
{ {
model: "anthropic/claude-sonnet-4-5", model: "anthropic/claude-sonnet-4-5",
chain: [ chain: [
{ agent: "scout", task: "inspect auth" }, { task: "inspect auth" },
{ agent: "planner", task: "use this context: {previous}" }, { task: "use this context: {previous}" },
], ],
}, },
undefined, undefined,
@@ -46,25 +37,16 @@ test("chain mode substitutes {previous} into the next task", async () => {
} as any, } as any,
); );
assert.deepEqual(seenTasks, ["inspect auth", "use this context: Scout output"]); assert.deepEqual(seenTasks, ["inspect auth", "use this context: Inspection output"]);
assert.equal(result.content[0]?.type === "text" ? result.content[0].text : "", "Plan output"); assert.equal(result.content[0]?.type === "text" ? result.content[0].text : "", "Plan output");
}); });
test("chain mode stops on the first failed step", async () => { test("chain mode stops on the first failed step", async () => {
const tool = createSubagentTool({ const tool = createSubagentTool({
discoverAgents: () => ({
agents: [
{ name: "scout", description: "Scout", source: "builtin", systemPrompt: "Scout prompt" },
{ name: "planner", description: "Planner", source: "builtin", systemPrompt: "Planner prompt" },
],
projectAgentsDir: null,
}),
runSingleTask: async ({ meta }: any) => { runSingleTask: async ({ meta }: any) => {
if (meta.agent === "planner") { if (meta.task.includes("Inspection output")) {
return { return {
runId: "planner-2", runId: "run-2",
agent: meta.agent,
agentSource: meta.agentSource,
task: meta.task, task: meta.task,
exitCode: 1, exitCode: 1,
finalText: "", finalText: "",
@@ -72,12 +54,10 @@ test("chain mode stops on the first failed step", async () => {
}; };
} }
return { return {
runId: "scout-1", runId: "run-1",
agent: meta.agent,
agentSource: meta.agentSource,
task: meta.task, task: meta.task,
exitCode: 0, exitCode: 0,
finalText: "Scout output", finalText: "Inspection output",
}; };
}, },
} as any); } as any);
@@ -87,8 +67,8 @@ test("chain mode stops on the first failed step", async () => {
{ {
model: "anthropic/claude-sonnet-4-5", model: "anthropic/claude-sonnet-4-5",
chain: [ chain: [
{ agent: "scout", task: "inspect auth" }, { task: "inspect auth" },
{ agent: "planner", task: "use this context: {previous}" }, { task: "use this context: {previous}" },
], ],
}, },
undefined, undefined,

View File

@@ -6,13 +6,6 @@ test("parallel mode runs each task and uses the top-level model unless a task ov
const requestedModels: Array<string | undefined> = []; const requestedModels: Array<string | undefined> = [];
const tool = createSubagentTool({ const tool = createSubagentTool({
discoverAgents: () => ({
agents: [
{ name: "scout", description: "Scout", source: "builtin", systemPrompt: "Scout prompt" },
{ name: "reviewer", description: "Reviewer", source: "builtin", systemPrompt: "Reviewer prompt" },
],
projectAgentsDir: null,
}),
resolveChildModel: ({ taskModel, topLevelModel }: any) => ({ resolveChildModel: ({ taskModel, topLevelModel }: any) => ({
requestedModel: taskModel ?? topLevelModel, requestedModel: taskModel ?? topLevelModel,
resolvedModel: taskModel ?? topLevelModel, resolvedModel: taskModel ?? topLevelModel,
@@ -20,14 +13,12 @@ test("parallel mode runs each task and uses the top-level model unless a task ov
runSingleTask: async ({ meta }: any) => { runSingleTask: async ({ meta }: any) => {
requestedModels.push(meta.requestedModel); requestedModels.push(meta.requestedModel);
return { return {
runId: `${meta.agent}-${meta.task}`, runId: `run-${requestedModels.length}`,
agent: meta.agent,
agentSource: meta.agentSource,
task: meta.task, task: meta.task,
requestedModel: meta.requestedModel, requestedModel: meta.requestedModel,
resolvedModel: meta.requestedModel, resolvedModel: meta.requestedModel,
exitCode: 0, exitCode: 0,
finalText: `${meta.agent}:${meta.task}`, finalText: `done:${meta.task}`,
}; };
}, },
} as any); } as any);
@@ -37,8 +28,8 @@ test("parallel mode runs each task and uses the top-level model unless a task ov
{ {
model: "openai/gpt-5", model: "openai/gpt-5",
tasks: [ tasks: [
{ agent: "scout", task: "find auth code" }, { task: "find auth code" },
{ agent: "reviewer", task: "review auth code", model: "anthropic/claude-opus-4-5" }, { task: "review auth code", model: "anthropic/claude-opus-4-5" },
], ],
}, },
undefined, undefined,
@@ -57,6 +48,8 @@ test("parallel mode runs each task and uses the top-level model unless a task ov
const text = result.content[0]?.type === "text" ? result.content[0].text : ""; const text = result.content[0]?.type === "text" ? result.content[0].text : "";
assert.match(text, /2\/2 succeeded/); assert.match(text, /2\/2 succeeded/);
assert.match(text, /\[task 1\] completed: done:find auth code/);
assert.match(text, /\[task 2\] completed: done:review auth code/);
assert.deepEqual(requestedModels, ["openai/gpt-5", "anthropic/claude-opus-4-5"]); assert.deepEqual(requestedModels, ["openai/gpt-5", "anthropic/claude-opus-4-5"]);
}); });
@@ -64,10 +57,6 @@ test("parallel mode rejects per-task model overrides that are not currently avai
let didRun = false; let didRun = false;
const tool = createSubagentTool({ const tool = createSubagentTool({
discoverAgents: () => ({
agents: [{ name: "scout", description: "Scout", source: "builtin", systemPrompt: "Scout prompt" }],
projectAgentsDir: null,
}),
runSingleTask: async () => { runSingleTask: async () => {
didRun = true; didRun = true;
throw new Error("should not run"); throw new Error("should not run");
@@ -78,7 +67,7 @@ test("parallel mode rejects per-task model overrides that are not currently avai
"tool-1", "tool-1",
{ {
model: "anthropic/claude-sonnet-4-5", model: "anthropic/claude-sonnet-4-5",
tasks: [{ agent: "scout", task: "find auth code", model: "openai/gpt-5" }], tasks: [{ task: "find auth code", model: "openai/gpt-5" }],
}, },
undefined, undefined,
undefined, undefined,

View File

@@ -6,24 +6,10 @@ test("single-mode subagent uses the required top-level model, emits progress, an
const updates: string[] = []; const updates: string[] = [];
const tool = createSubagentTool({ const tool = createSubagentTool({
discoverAgents: () => ({
agents: [
{
name: "scout",
description: "Scout",
model: "claude-haiku-4-5",
systemPrompt: "Scout prompt",
source: "builtin",
},
],
projectAgentsDir: null,
}),
runSingleTask: async ({ onEvent, meta }: any) => { runSingleTask: async ({ onEvent, meta }: any) => {
onEvent?.({ type: "tool_call", toolName: "read", args: { path: "src/auth.ts" } }); onEvent?.({ type: "tool_call", toolName: "read", args: { path: "src/auth.ts" } });
return { return {
runId: "run-1", runId: "run-1",
agent: "scout",
agentSource: "builtin",
task: "inspect auth", task: "inspect auth",
requestedModel: meta.requestedModel, requestedModel: meta.requestedModel,
resolvedModel: meta.resolvedModel, resolvedModel: meta.resolvedModel,
@@ -39,7 +25,6 @@ test("single-mode subagent uses the required top-level model, emits progress, an
const result = await tool.execute( const result = await tool.execute(
"tool-1", "tool-1",
{ {
agent: "scout",
task: "inspect auth", task: "inspect auth",
model: "anthropic/claude-sonnet-4-5", model: "anthropic/claude-sonnet-4-5",
}, },
@@ -59,19 +44,18 @@ test("single-mode subagent uses the required top-level model, emits progress, an
const text = result.content[0]?.type === "text" ? result.content[0].text : ""; const text = result.content[0]?.type === "text" ? result.content[0].text : "";
assert.equal(text, "Auth code is in src/auth.ts"); assert.equal(text, "Auth code is in src/auth.ts");
assert.equal(result.details.mode, "single");
assert.equal(result.details.results[0]?.task, "inspect auth");
assert.equal(result.details.results[0]?.paneId, "%3"); assert.equal(result.details.results[0]?.paneId, "%3");
assert.equal(result.details.results[0]?.requestedModel, "anthropic/claude-sonnet-4-5"); assert.equal(result.details.results[0]?.requestedModel, "anthropic/claude-sonnet-4-5");
assert.match(updates.join("\n"), /Running scout/); assert.equal("agent" in (result.details.results[0] ?? {}), false);
assert.match(updates.join("\n"), /Running subagent/);
}); });
test("single-mode subagent requires a top-level model even when execute is called directly", async () => { test("single-mode subagent requires a top-level model even when execute is called directly", async () => {
let didRun = false; let didRun = false;
const tool = createSubagentTool({ const tool = createSubagentTool({
discoverAgents: () => ({
agents: [{ name: "scout", description: "Scout", systemPrompt: "Scout prompt", source: "builtin" }],
projectAgentsDir: null,
}),
runSingleTask: async () => { runSingleTask: async () => {
didRun = true; didRun = true;
throw new Error("should not run"); throw new Error("should not run");
@@ -80,7 +64,7 @@ test("single-mode subagent requires a top-level model even when execute is calle
const result = await tool.execute( const result = await tool.execute(
"tool-1", "tool-1",
{ agent: "scout", task: "inspect auth" }, { task: "inspect auth" },
undefined, undefined,
undefined, undefined,
{ {
@@ -101,10 +85,6 @@ test("single-mode subagent rejects models that are not currently available", asy
let didRun = false; let didRun = false;
const tool = createSubagentTool({ const tool = createSubagentTool({
discoverAgents: () => ({
agents: [{ name: "scout", description: "Scout", systemPrompt: "Scout prompt", source: "builtin" }],
projectAgentsDir: null,
}),
runSingleTask: async () => { runSingleTask: async () => {
didRun = true; didRun = true;
throw new Error("should not run"); throw new Error("should not run");
@@ -114,7 +94,6 @@ test("single-mode subagent rejects models that are not currently available", asy
const result = await tool.execute( const result = await tool.execute(
"tool-1", "tool-1",
{ {
agent: "scout",
task: "inspect auth", task: "inspect auth",
model: "openai/gpt-5", model: "openai/gpt-5",
}, },
@@ -134,31 +113,15 @@ test("single-mode subagent rejects models that are not currently available", asy
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /available models/i); assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /available models/i);
}); });
test("single-mode subagent asks before running a project-local agent", async () => { test("subagent rejects requests that combine single and parallel modes", async () => {
const tool = createSubagentTool({ const tool = createSubagentTool();
discoverAgents: () => ({
agents: [
{
name: "reviewer",
description: "Reviewer",
systemPrompt: "Review prompt",
source: "project",
},
],
projectAgentsDir: "/repo/.pi/agents",
}),
runSingleTask: async () => {
throw new Error("should not run");
},
} as any);
const result = await tool.execute( const result = await tool.execute(
"tool-1", "tool-1",
{ {
agent: "reviewer", task: "inspect auth",
task: "review auth",
model: "anthropic/claude-sonnet-4-5", model: "anthropic/claude-sonnet-4-5",
agentScope: "both", tasks: [{ task: "review auth" }],
}, },
undefined, undefined,
undefined, undefined,
@@ -167,11 +130,10 @@ test("single-mode subagent asks before running a project-local agent", async ()
modelRegistry: { modelRegistry: {
getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }], getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }],
}, },
hasUI: true, hasUI: false,
ui: { confirm: async () => false },
} as any, } as any,
); );
assert.equal(result.isError, true); assert.equal(result.isError, true);
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /not approved/); assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /exactly one mode/i);
}); });

View File

@@ -1,5 +1,4 @@
import { Text } from "@mariozechner/pi-tui"; import { Text } from "@mariozechner/pi-tui";
import { discoverAgents } from "./agents.ts";
import { import {
listAvailableModelReferences, listAvailableModelReferences,
normalizeAvailableModelReference, normalizeAvailableModelReference,
@@ -7,7 +6,6 @@ import {
} from "./models.ts"; } from "./models.ts";
import { import {
SubagentParamsSchema, SubagentParamsSchema,
type AgentScope,
type SubagentRunResult, type SubagentRunResult,
type SubagentToolDetails, type SubagentToolDetails,
} from "./schema.ts"; } from "./schema.ts";
@@ -42,28 +40,20 @@ function isFailure(result: Pick<SubagentRunResult, "exitCode" | "stopReason">) {
function makeDetails( function makeDetails(
mode: "single" | "parallel" | "chain", mode: "single" | "parallel" | "chain",
agentScope: AgentScope,
projectAgentsDir: string | null,
results: SubagentRunResult[], results: SubagentRunResult[],
): SubagentToolDetails { ): SubagentToolDetails {
return { mode, agentScope, projectAgentsDir, results }; return { mode, results };
} }
function makeErrorResult( function makeErrorResult(text: string, mode: "single" | "parallel" | "chain") {
text: string,
mode: "single" | "parallel" | "chain",
agentScope: AgentScope,
projectAgentsDir: string | null,
) {
return { return {
content: [{ type: "text" as const, text }], content: [{ type: "text" as const, text }],
details: makeDetails(mode, agentScope, projectAgentsDir, []), details: makeDetails(mode, []),
isError: true, isError: true,
}; };
} }
export function createSubagentTool(deps: { export function createSubagentTool(deps: {
discoverAgents?: typeof discoverAgents;
listAvailableModelReferences?: typeof listAvailableModelReferences; listAvailableModelReferences?: typeof listAvailableModelReferences;
normalizeAvailableModelReference?: typeof normalizeAvailableModelReference; normalizeAvailableModelReference?: typeof normalizeAvailableModelReference;
parameters?: typeof SubagentParamsSchema; parameters?: typeof SubagentParamsSchema;
@@ -77,21 +67,19 @@ export function createSubagentTool(deps: {
return { return {
name: "subagent", name: "subagent",
label: "Subagent", label: "Subagent",
description: "Delegate tasks to specialized agents running in separate child sessions.", description: "Delegate tasks to generic subagents running in separate child sessions.",
parameters: deps.parameters ?? SubagentParamsSchema, parameters: deps.parameters ?? SubagentParamsSchema,
async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, onUpdate: any, ctx: any) { async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, onUpdate: any, ctx: any) {
const hasSingle = Boolean(params.agent && params.task); const hasSingle = Boolean(params.task);
const hasParallel = Boolean(params.tasks?.length); const hasParallel = Boolean(params.tasks?.length);
const hasChain = Boolean(params.chain?.length); const hasChain = Boolean(params.chain?.length);
const modeCount = Number(hasSingle) + Number(hasParallel) + Number(hasChain); const modeCount = Number(hasSingle) + Number(hasParallel) + Number(hasChain);
const mode = hasParallel ? "parallel" : hasChain ? "chain" : "single"; const mode = hasParallel ? "parallel" : hasChain ? "chain" : "single";
const agentScope = (params.agentScope ?? "user") as AgentScope;
if (modeCount !== 1) { if (modeCount !== 1) {
return makeErrorResult("Provide exactly one mode: single, parallel, or chain.", "single", agentScope, null); return makeErrorResult("Provide exactly one mode: single, parallel, or chain.", "single");
} }
const discovery = (deps.discoverAgents ?? discoverAgents)(ctx.cwd, { scope: agentScope });
const availableModelReferences = (deps.listAvailableModelReferences ?? listAvailableModelReferences)(ctx.modelRegistry); const availableModelReferences = (deps.listAvailableModelReferences ?? listAvailableModelReferences)(ctx.modelRegistry);
const availableModelsText = availableModelReferences.join(", ") || "(none)"; const availableModelsText = availableModelReferences.join(", ") || "(none)";
const normalizeModelReference = (requestedModel?: string) => const normalizeModelReference = (requestedModel?: string) =>
@@ -101,8 +89,6 @@ export function createSubagentTool(deps: {
return makeErrorResult( return makeErrorResult(
"No available models are configured. Configure at least one model before using subagent.", "No available models are configured. Configure at least one model before using subagent.",
mode, mode,
agentScope,
discovery.projectAgentsDir,
); );
} }
@@ -112,7 +98,7 @@ export function createSubagentTool(deps: {
typeof params.model !== "string" || params.model.trim().length === 0 typeof params.model !== "string" || params.model.trim().length === 0
? `Subagent requires a top-level model chosen from the available models: ${availableModelsText}` ? `Subagent requires a top-level model chosen from the available models: ${availableModelsText}`
: `Invalid top-level model "${params.model}". Choose one of the available models: ${availableModelsText}`; : `Invalid top-level model "${params.model}". Choose one of the available models: ${availableModelsText}`;
return makeErrorResult(message, mode, agentScope, discovery.projectAgentsDir); return makeErrorResult(message, mode);
} }
params.model = topLevelModel; params.model = topLevelModel;
@@ -122,10 +108,8 @@ export function createSubagentTool(deps: {
const normalizedTaskModel = normalizeModelReference(task.model); const normalizedTaskModel = normalizeModelReference(task.model);
if (!normalizedTaskModel) { if (!normalizedTaskModel) {
return makeErrorResult( return makeErrorResult(
`Invalid model for parallel task ${index + 1} (${task.agent}): "${task.model}". Choose one of the available models: ${availableModelsText}`, `Invalid model for parallel task ${index + 1}: "${task.model}". Choose one of the available models: ${availableModelsText}`,
mode, mode,
agentScope,
discovery.projectAgentsDir,
); );
} }
task.model = normalizedTaskModel; task.model = normalizedTaskModel;
@@ -137,49 +121,14 @@ export function createSubagentTool(deps: {
const normalizedStepModel = normalizeModelReference(step.model); const normalizedStepModel = normalizeModelReference(step.model);
if (!normalizedStepModel) { if (!normalizedStepModel) {
return makeErrorResult( return makeErrorResult(
`Invalid model for chain step ${index + 1} (${step.agent}): "${step.model}". Choose one of the available models: ${availableModelsText}`, `Invalid model for chain step ${index + 1}: "${step.model}". Choose one of the available models: ${availableModelsText}`,
mode, mode,
agentScope,
discovery.projectAgentsDir,
); );
} }
step.model = normalizedStepModel; step.model = normalizedStepModel;
} }
const requestedAgentNames = [
...(hasSingle ? [params.agent] : []),
...((params.tasks ?? []).map((task: any) => task.agent)),
...((params.chain ?? []).map((step: any) => step.agent)),
];
const projectAgents = requestedAgentNames
.map((name) => discovery.agents.find((candidate) => candidate.name === name))
.filter((agent): agent is NonNullable<typeof agent> => Boolean(agent && agent.source === "project"));
if (projectAgents.length > 0 && (params.confirmProjectAgents ?? true) && ctx.hasUI) {
const ok = await ctx.ui.confirm(
"Run project-local agents?",
`Agents: ${projectAgents.map((agent) => agent.name).join(", ")}\nSource: ${
discovery.projectAgentsDir ?? "(unknown)"
}`,
);
if (!ok) {
return makeErrorResult(
"Canceled: project-local agents not approved.",
mode,
agentScope,
discovery.projectAgentsDir,
);
}
}
const resolveAgent = (name: string) => {
const agent = discovery.agents.find((candidate) => candidate.name === name);
if (!agent) throw new Error(`Unknown agent: ${name}`);
return agent;
};
const runTask = async (input: { const runTask = async (input: {
agentName: string;
task: string; task: string;
cwd?: string; cwd?: string;
taskModel?: string; taskModel?: string;
@@ -187,7 +136,6 @@ export function createSubagentTool(deps: {
step?: number; step?: number;
mode: "single" | "parallel" | "chain"; mode: "single" | "parallel" | "chain";
}) => { }) => {
const agent = resolveAgent(input.agentName);
const model = (deps.resolveChildModel ?? resolveChildModel)({ const model = (deps.resolveChildModel ?? resolveChildModel)({
taskModel: input.taskModel, taskModel: input.taskModel,
topLevelModel: params.model, topLevelModel: params.model,
@@ -197,22 +145,18 @@ export function createSubagentTool(deps: {
cwd: input.cwd ?? ctx.cwd, cwd: input.cwd ?? ctx.cwd,
onEvent(event) { onEvent(event) {
onUpdate?.({ onUpdate?.({
content: [{ type: "text", text: `Running ${input.agentName}: ${event.type}` }], content: [{ type: "text", text: `Running subagent: ${event.type}` }],
details: makeDetails(input.mode, agentScope, discovery.projectAgentsDir, []), details: makeDetails(input.mode, []),
}); });
}, },
meta: { meta: {
mode: input.mode, mode: input.mode,
taskIndex: input.taskIndex, taskIndex: input.taskIndex,
step: input.step, step: input.step,
agent: agent.name,
agentSource: agent.source,
task: input.task, task: input.task,
cwd: input.cwd ?? ctx.cwd, cwd: input.cwd ?? ctx.cwd,
requestedModel: model.requestedModel, requestedModel: model.requestedModel,
resolvedModel: model.resolvedModel, resolvedModel: model.resolvedModel,
systemPrompt: agent.systemPrompt,
tools: agent.tools,
}, },
}) as Promise<SubagentRunResult>; }) as Promise<SubagentRunResult>;
}; };
@@ -220,7 +164,6 @@ export function createSubagentTool(deps: {
if (hasSingle) { if (hasSingle) {
try { try {
const result = await runTask({ const result = await runTask({
agentName: params.agent,
task: params.task, task: params.task,
cwd: params.cwd, cwd: params.cwd,
mode: "single", mode: "single",
@@ -228,13 +171,13 @@ export function createSubagentTool(deps: {
return { return {
content: [{ type: "text" as const, text: result.finalText }], content: [{ type: "text" as const, text: result.finalText }],
details: makeDetails("single", agentScope, discovery.projectAgentsDir, [result]), details: makeDetails("single", [result]),
isError: isFailure(result), isError: isFailure(result),
}; };
} catch (error) { } catch (error) {
return { return {
content: [{ type: "text" as const, text: (error as Error).message }], content: [{ type: "text" as const, text: (error as Error).message }],
details: makeDetails("single", agentScope, discovery.projectAgentsDir, []), details: makeDetails("single", []),
isError: true, isError: true,
}; };
} }
@@ -249,7 +192,7 @@ export function createSubagentTool(deps: {
text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`, text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
}, },
], ],
details: makeDetails("parallel", agentScope, discovery.projectAgentsDir, []), details: makeDetails("parallel", []),
isError: true, isError: true,
}; };
} }
@@ -257,7 +200,6 @@ export function createSubagentTool(deps: {
const liveResults: SubagentRunResult[] = []; const liveResults: SubagentRunResult[] = [];
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (task: any, index) => { const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (task: any, index) => {
const result = await runTask({ const result = await runTask({
agentName: task.agent,
task: task.task, task: task.task,
cwd: task.cwd, cwd: task.cwd,
taskModel: task.model, taskModel: task.model,
@@ -267,19 +209,19 @@ export function createSubagentTool(deps: {
liveResults[index] = result; liveResults[index] = result;
onUpdate?.({ onUpdate?.({
content: [{ type: "text", text: `Parallel: ${liveResults.filter(Boolean).length}/${params.tasks.length} finished` }], content: [{ type: "text", text: `Parallel: ${liveResults.filter(Boolean).length}/${params.tasks.length} finished` }],
details: makeDetails("parallel", agentScope, discovery.projectAgentsDir, liveResults.filter(Boolean)), details: makeDetails("parallel", liveResults.filter(Boolean)),
}); });
return result; return result;
}); });
const successCount = results.filter((result) => !isFailure(result)).length; const successCount = results.filter((result) => !isFailure(result)).length;
const summary = results const summary = results
.map((result) => `[${result.agent}] ${isFailure(result) ? "failed" : "completed"}: ${result.finalText || "(no output)"}`) .map((result, index) => `[task ${index + 1}] ${isFailure(result) ? "failed" : "completed"}: ${result.finalText || "(no output)"}`)
.join("\n\n"); .join("\n\n");
return { return {
content: [{ type: "text" as const, text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summary}` }], content: [{ type: "text" as const, text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summary}` }],
details: makeDetails("parallel", agentScope, discovery.projectAgentsDir, results), details: makeDetails("parallel", results),
isError: successCount !== results.length, isError: successCount !== results.length,
}; };
} }
@@ -290,7 +232,6 @@ export function createSubagentTool(deps: {
const item = params.chain[index]; const item = params.chain[index];
const task = item.task.replaceAll("{previous}", previous); const task = item.task.replaceAll("{previous}", previous);
const result = await runTask({ const result = await runTask({
agentName: item.agent,
task, task,
cwd: item.cwd, cwd: item.cwd,
taskModel: item.model, taskModel: item.model,
@@ -299,7 +240,7 @@ export function createSubagentTool(deps: {
}); });
onUpdate?.({ onUpdate?.({
content: [{ type: "text", text: `Chain: completed step ${index + 1}/${params.chain.length}` }], content: [{ type: "text", text: `Chain: completed step ${index + 1}/${params.chain.length}` }],
details: makeDetails("chain", agentScope, discovery.projectAgentsDir, [...results, result]), details: makeDetails("chain", [...results, result]),
}); });
results.push(result); results.push(result);
if (isFailure(result)) { if (isFailure(result)) {
@@ -307,10 +248,10 @@ export function createSubagentTool(deps: {
content: [ content: [
{ {
type: "text" as const, type: "text" as const,
text: `Chain stopped at step ${index + 1} (${item.agent}): ${result.finalText || result.stopReason || "failed"}`, text: `Chain stopped at step ${index + 1}: ${result.finalText || result.stopReason || "failed"}`,
}, },
], ],
details: makeDetails("chain", agentScope, discovery.projectAgentsDir, results), details: makeDetails("chain", results),
isError: true, isError: true,
}; };
} }
@@ -320,13 +261,13 @@ export function createSubagentTool(deps: {
const finalResult = results[results.length - 1]; const finalResult = results[results.length - 1];
return { return {
content: [{ type: "text" as const, text: finalResult?.finalText ?? "" }], content: [{ type: "text" as const, text: finalResult?.finalText ?? "" }],
details: makeDetails("chain", agentScope, discovery.projectAgentsDir, results), details: makeDetails("chain", results),
}; };
}, },
renderCall(args: any) { renderCall(args: any) {
if (args.tasks?.length) return new Text(`subagent parallel (${args.tasks.length} tasks)`, 0, 0); if (args.tasks?.length) return new Text(`subagent parallel (${args.tasks.length} tasks)`, 0, 0);
if (args.chain?.length) return new Text(`subagent chain (${args.chain.length} steps)`, 0, 0); if (args.chain?.length) return new Text(`subagent chain (${args.chain.length} steps)`, 0, 0);
return new Text(`subagent ${args.agent ?? ""}`.trim(), 0, 0); return new Text("subagent", 0, 0);
}, },
renderResult(result: { content: Array<{ type: string; text?: string }> }) { renderResult(result: { content: Array<{ type: string; text?: string }> }) {
const first = result.content[0]; const first = result.content[0];