refactor: simplify subagent tool contract
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
103
src/tool.ts
103
src/tool.ts
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user