diff --git a/src/tool-parallel.test.ts b/src/tool-parallel.test.ts index e2b7a54..dbe695d 100644 --- a/src/tool-parallel.test.ts +++ b/src/tool-parallel.test.ts @@ -2,16 +2,23 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createSubagentTool } from "./tool.ts"; -test("parallel mode runs each task and uses the top-level model unless a task overrides it", async () => { +test("parallel mode runs each task and resolves model from preset unless a task overrides it", async () => { const requestedModels: Array = []; const tool = createSubagentTool({ + discoverSubagentPresets: () => ({ + presets: [ + { name: "openai-preset", description: "openai", model: "openai/gpt-5", systemPrompt: "", source: "project", filePath: "/repo/.pi/subagents/openai.md" }, + { name: "anthropic-preset", description: "anthropic", model: "anthropic/claude-opus-4-5", systemPrompt: "", source: "project", filePath: "/repo/.pi/subagents/anthropic.md" }, + ], + projectPresetsDir: "/repo/.pi/subagents", + }), resolveChildModel: ({ taskModel, topLevelModel }: any) => ({ requestedModel: taskModel ?? topLevelModel, resolvedModel: taskModel ?? topLevelModel, }), runSingleTask: async ({ meta }: any) => { - requestedModels.push(meta.requestedModel); + requestedModels.push(meta.requestedModel as string | undefined); return { runId: `run-${requestedModels.length}`, task: meta.task, @@ -26,10 +33,9 @@ test("parallel mode runs each task and uses the top-level model unless a task ov const result = await tool.execute( "tool-1", { - model: "openai/gpt-5", tasks: [ - { task: "find auth code" }, - { task: "review auth code", model: "anthropic/claude-opus-4-5" }, + { preset: "openai-preset", task: "find auth code" }, + { preset: "anthropic-preset", task: "review auth code", model: "anthropic/claude-opus-4-5" }, ], }, undefined, @@ -57,6 +63,12 @@ test("parallel mode rejects per-task model overrides that are not currently avai let didRun = false; const tool = createSubagentTool({ + discoverSubagentPresets: () => ({ + presets: [ + { name: "auth-preset", description: "auth", model: "anthropic/claude-sonnet-4-5", systemPrompt: "", source: "project", filePath: "/repo/.pi/subagents/auth.md" }, + ], + projectPresetsDir: "/repo/.pi/subagents", + }), runSingleTask: async () => { didRun = true; throw new Error("should not run"); @@ -66,8 +78,7 @@ test("parallel mode rejects per-task model overrides that are not currently avai const result = await tool.execute( "tool-1", { - model: "anthropic/claude-sonnet-4-5", - tasks: [{ task: "find auth code", model: "openai/gpt-5" }], + tasks: [{ preset: "auth-preset", task: "find auth code", model: "openai/gpt-5" }], }, undefined, undefined, diff --git a/src/tool.test.ts b/src/tool.test.ts index d0f97fd..744f058 100644 --- a/src/tool.test.ts +++ b/src/tool.test.ts @@ -2,10 +2,24 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createSubagentTool } from "./tool.ts"; -test("single-mode subagent uses the required top-level model and emits humanized live progress", async () => { +test("single-mode subagent uses preset default model and emits humanized live progress", async () => { const updates: string[] = []; const tool = createSubagentTool({ + discoverSubagentPresets: () => ({ + presets: [ + { + name: "auth-inspector", + description: "inspect auth", + model: "anthropic/claude-sonnet-4-5", + tools: ["read"], + systemPrompt: "", + source: "project", + filePath: "/repo/.pi/subagents/auth.md", + }, + ], + projectPresetsDir: "/repo/.pi/subagents", + }), runSingleTask: async ({ onEvent, meta }: any) => { onEvent?.({ type: "assistant_text", text: "Inspecting auth flow" }); onEvent?.({ type: "tool_call", toolName: "read", args: { path: "src/auth.ts" } }); @@ -27,8 +41,8 @@ test("single-mode subagent uses the required top-level model and emits humanized const result = await tool.execute( "tool-1", { + preset: "auth-inspector", task: "inspect auth", - model: "anthropic/claude-sonnet-4-5", }, undefined, (partial: any) => { @@ -50,7 +64,6 @@ test("single-mode subagent uses the required top-level model and emits humanized assert.equal(result.details.results[0]?.task, "inspect auth"); assert.equal(result.details.results[0]?.paneId, "%3"); assert.equal(result.details.results[0]?.requestedModel, "anthropic/claude-sonnet-4-5"); - assert.equal("agent" in (result.details.results[0] ?? {}), false); assert.deepEqual(updates, ["Inspecting auth flow", "Reading src/auth.ts", "Finished reading src/auth.ts"]); assert.doesNotMatch(updates.join("\n"), /tool_call|tool_result/); }); @@ -59,6 +72,20 @@ test("single-mode subagent ignores blank assistant text and falls back to tool a const updates: string[] = []; const tool = createSubagentTool({ + discoverSubagentPresets: () => ({ + presets: [ + { + name: "auth-inspector", + description: "inspect auth", + model: "anthropic/claude-sonnet-4-5", + tools: ["grep"], + systemPrompt: "", + source: "project", + filePath: "/repo/.pi/subagents/auth.md", + }, + ], + projectPresetsDir: "/repo/.pi/subagents", + }), runSingleTask: async ({ onEvent, meta }: any) => { onEvent?.({ type: "assistant_text", text: " " }); onEvent?.({ type: "tool_call", toolName: "grep", args: { pattern: "auth" } }); @@ -76,8 +103,8 @@ test("single-mode subagent ignores blank assistant text and falls back to tool a await tool.execute( "tool-1", { + preset: "auth-inspector", task: "inspect auth", - model: "anthropic/claude-sonnet-4-5", }, undefined, (partial: any) => { @@ -96,10 +123,22 @@ test("single-mode subagent ignores blank assistant text and falls back to tool a assert.deepEqual(updates, ["Searching code for auth"]); }); -test("single-mode subagent requires a top-level model even when execute is called directly", async () => { +test("single-mode subagent requires a model (either explicit or preset default) when execute is called directly", async () => { let didRun = false; const tool = createSubagentTool({ + discoverSubagentPresets: () => ({ + presets: [ + { + name: "bare-preset", + description: "no model", + systemPrompt: "", + source: "project", + filePath: "/repo/.pi/subagents/bare.md", + }, + ], + projectPresetsDir: "/repo/.pi/subagents", + }), runSingleTask: async () => { didRun = true; throw new Error("should not run"); @@ -108,7 +147,7 @@ test("single-mode subagent requires a top-level model even when execute is calle const result = await tool.execute( "tool-1", - { task: "inspect auth" }, + { preset: "bare-preset", task: "inspect auth" }, undefined, undefined, { @@ -122,13 +161,25 @@ test("single-mode subagent requires a top-level model even when execute is calle assert.equal(didRun, false); assert.equal(result.isError, true); - assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /top-level model/i); + assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /no model provided/i); }); test("single-mode subagent rejects models that are not currently available", async () => { let didRun = false; const tool = createSubagentTool({ + discoverSubagentPresets: () => ({ + presets: [ + { + name: "auth-inspector", + description: "inspect auth", + systemPrompt: "", + source: "project", + filePath: "/repo/.pi/subagents/auth.md", + }, + ], + projectPresetsDir: "/repo/.pi/subagents", + }), runSingleTask: async () => { didRun = true; throw new Error("should not run"); @@ -138,6 +189,7 @@ test("single-mode subagent rejects models that are not currently available", asy const result = await tool.execute( "tool-1", { + preset: "auth-inspector", task: "inspect auth", model: "openai/gpt-5", }, @@ -163,9 +215,9 @@ test("subagent rejects requests that combine single and parallel modes", async ( const result = await tool.execute( "tool-1", { + preset: "auth-inspector", task: "inspect auth", - model: "anthropic/claude-sonnet-4-5", - tasks: [{ task: "review auth" }], + tasks: [{ preset: "auth-inspector", task: "review auth" }], }, undefined, undefined, diff --git a/src/tool.ts b/src/tool.ts index eb185c7..fd7bb05 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -4,6 +4,7 @@ import { normalizeAvailableModelReference, resolveChildModel, } from "./models.ts"; +import { discoverSubagentPresets } from "./presets.ts"; import { SubagentParamsSchema, type SubagentRunResult, @@ -39,14 +40,11 @@ function isFailure(result: Pick) { return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; } -function makeDetails( - mode: "single" | "parallel" | "chain", - results: SubagentRunResult[], -): SubagentToolDetails { +function makeDetails(mode: "single" | "parallel", results: SubagentRunResult[]): SubagentToolDetails { return { mode, results }; } -function makeErrorResult(text: string, mode: "single" | "parallel" | "chain") { +function makeErrorResult(text: string, mode: "single" | "parallel") { return { content: [{ type: "text" as const, text }], details: makeDetails(mode, []), @@ -69,6 +67,7 @@ export function createSubagentTool(deps: { resolveChildModel?: | ((input: { callModel?: string; presetModel?: string; taskModel?: string; topLevelModel?: string }) => ModelSelection) | typeof resolveChildModel; + discoverSubagentPresets?: typeof discoverSubagentPresets; runSingleTask?: (input: { cwd: string; meta: Record; @@ -83,12 +82,11 @@ export function createSubagentTool(deps: { async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, onUpdate: any, ctx: any) { const hasSingle = Boolean(params.task); const hasParallel = Boolean(params.tasks?.length); - const hasChain = Boolean(params.chain?.length); - const modeCount = Number(hasSingle) + Number(hasParallel) + Number(hasChain); - const mode = hasParallel ? "parallel" : hasChain ? "chain" : "single"; + const modeCount = Number(hasSingle) + Number(hasParallel); + const mode = hasParallel ? "parallel" : "single"; if (modeCount !== 1) { - return makeErrorResult("Provide exactly one mode: single, parallel, or chain.", "single"); + return makeErrorResult("Provide exactly one mode: single or parallel.", "single"); } const availableModelReferences = (deps.listAvailableModelReferences ?? listAvailableModelReferences)(ctx.modelRegistry); @@ -103,41 +101,8 @@ export function createSubagentTool(deps: { ); } - const topLevelModel = normalizeModelReference(params.model); - if (!topLevelModel) { - const message = - typeof params.model !== "string" || params.model.trim().length === 0 - ? `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}`; - return makeErrorResult(message, mode); - } - params.model = topLevelModel; - - for (const [index, task] of (params.tasks ?? []).entries()) { - if (task.model === undefined) continue; - - const normalizedTaskModel = normalizeModelReference(task.model); - if (!normalizedTaskModel) { - return makeErrorResult( - `Invalid model for parallel task ${index + 1}: "${task.model}". Choose one of the available models: ${availableModelsText}`, - mode, - ); - } - task.model = normalizedTaskModel; - } - - for (const [index, step] of (params.chain ?? []).entries()) { - if (step.model === undefined) continue; - - const normalizedStepModel = normalizeModelReference(step.model); - if (!normalizedStepModel) { - return makeErrorResult( - `Invalid model for chain step ${index + 1}: "${step.model}". Choose one of the available models: ${availableModelsText}`, - mode, - ); - } - step.model = normalizedStepModel; - } + const discovery = (deps.discoverSubagentPresets ?? discoverSubagentPresets)(ctx.cwd); + const presets = discovery.presets; // Adapter: accept only the flattened shape { callModel?, presetModel? } // to keep the tool logic simple. If a resolver was injected with the @@ -160,12 +125,35 @@ export function createSubagentTool(deps: { const runTask = async (input: { task: string; cwd?: string; - taskModel?: string; + taskModel?: string | undefined; taskIndex?: number; - step?: number; - mode: "single" | "parallel" | "chain"; + preset: typeof presets[number]; + mode: "single" | "parallel"; }) => { - const model = callResolveChildModel({ callModel: input.taskModel, presetModel: params.model }); + // Normalize explicit/task model and preset default model against available models + const normalizedCallModel = normalizeModelReference(input.taskModel); + const normalizedPresetModel = normalizeModelReference(input.preset.model); + + // Use the resolver to allow custom selection logic, but fall back to + // explicit then preset default. We pass normalized values to the + // resolver so it always sees canonical available-model strings. + const selection = callResolveChildModel({ callModel: normalizedCallModel, presetModel: normalizedPresetModel }); + + const requestedModel = selection.requestedModel ?? normalizedCallModel ?? normalizedPresetModel; + const resolvedModel = selection.resolvedModel ?? requestedModel; + + if (!requestedModel) { + return Promise.resolve({ + runId: "", + task: input.task, + requestedModel: undefined, + resolvedModel: undefined, + exitCode: 1, + finalText: "", + stopReason: "error", + errorMessage: `No model provided for preset "${input.preset.name}". Choose one of the available models: ${availableModelsText}`, + } as SubagentRunResult); + } const progressFormatter = createProgressFormatter(); @@ -182,20 +170,72 @@ export function createSubagentTool(deps: { meta: { mode: input.mode, taskIndex: input.taskIndex, - step: input.step, task: input.task, cwd: input.cwd ?? ctx.cwd, - requestedModel: model.requestedModel, - resolvedModel: model.resolvedModel, + preset: input.preset.name, + presetSource: input.preset.source, + systemPrompt: input.preset.systemPrompt, + tools: input.preset.tools, + requestedModel, + resolvedModel, }, }) as Promise; }; if (hasSingle) { + if (typeof params.preset !== "string" || params.preset.trim() === "") { + return makeErrorResult("Single mode requires a 'preset' string and a 'task'.", "single"); + } + + const preset = presets.find((p) => p.name === params.preset); + if (!preset) { + const names = presets.map((p) => p.name).join(", ") || "(none)"; + return makeErrorResult(`Unknown preset "${params.preset}". Available presets: ${names}`, "single"); + } + + // Validate explicit model if provided + if (params.model !== undefined) { + const normalized = normalizeModelReference(params.model); + if (!normalized) { + return makeErrorResult( + typeof params.model !== "string" || params.model.trim().length === 0 + ? `Single-mode requires a model chosen from the available models: ${availableModelsText}` + : `Invalid model "${params.model}". Choose one of the available models: ${availableModelsText}`, + "single", + ); + } + params.model = normalized; + } + + // Validate preset default model if present + if (preset.model !== undefined) { + const normalizedPresetModel = normalizeModelReference(preset.model); + if (!normalizedPresetModel) { + return makeErrorResult( + `Preset "${preset.name}" specifies an invalid model "${preset.model}". Choose one of the available models: ${availableModelsText}`, + "single", + ); + } + // Use canonical preset model + preset.model = normalizedPresetModel; + } + + // Ensure an effective model exists for this run (explicit override wins) + const singleSelection = callResolveChildModel({ callModel: params.model, presetModel: preset.model }); + const singleRequested = singleSelection.requestedModel ?? params.model ?? preset.model; + if (!singleRequested) { + return makeErrorResult( + `No model provided for preset "${preset.name}". Choose one of the available models: ${availableModelsText}`, + "single", + ); + } + try { const result = await runTask({ task: params.task, cwd: params.cwd, + preset, + taskModel: params.model, mode: "single", }); @@ -213,90 +253,93 @@ export function createSubagentTool(deps: { } } - if (hasParallel) { - if (params.tasks.length > MAX_PARALLEL_TASKS) { - return { - content: [ - { - type: "text" as const, - text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`, - }, - ], - details: makeDetails("parallel", []), - isError: true, - }; + // Parallel + // Validate tasks and presets first + for (const [index, t] of (params.tasks ?? []).entries()) { + if (typeof t.preset !== "string" || t.preset.trim() === "") { + return makeErrorResult(`Parallel task ${index + 1} missing required 'preset'`, "parallel"); + } + const preset = presets.find((p) => p.name === t.preset); + if (!preset) { + const names = presets.map((p) => p.name).join(", ") || "(none)"; + return makeErrorResult(`Unknown preset "${t.preset}" for task ${index + 1}. Available presets: ${names}`, "parallel"); } - const liveResults: SubagentRunResult[] = []; - const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (task: any, index) => { - const result = await runTask({ - task: task.task, - cwd: task.cwd, - taskModel: task.model, - taskIndex: index, - mode: "parallel", - }); - liveResults[index] = result; - onUpdate?.({ - content: [{ type: "text", text: `Parallel: ${liveResults.filter(Boolean).length}/${params.tasks.length} finished` }], - details: makeDetails("parallel", liveResults.filter(Boolean)), - }); - return result; - }); + if (t.model !== undefined) { + const normalized = normalizeModelReference(t.model); + if (!normalized) { + return makeErrorResult(`Invalid model for parallel task ${index + 1}: "${t.model}". Choose one of the available models: ${availableModelsText}`, "parallel"); + } + t.model = normalized; + } - const successCount = results.filter((result) => !isFailure(result)).length; - const summary = results - .map((result, index) => `[task ${index + 1}] ${isFailure(result) ? "failed" : "completed"}: ${result.finalText || "(no output)"}`) - .join("\n\n"); + if (preset.model !== undefined) { + const normalizedPresetModel = normalizeModelReference(preset.model); + if (!normalizedPresetModel) { + return makeErrorResult( + `Preset "${preset.name}" specifies an invalid model "${preset.model}". Choose one of the available models: ${availableModelsText}`, + "parallel", + ); + } + preset.model = normalizedPresetModel; + } + // Ensure an effective model exists for this task + const sel = callResolveChildModel({ callModel: t.model, presetModel: preset.model }); + const requested = sel.requestedModel ?? t.model ?? preset.model; + if (!requested) { + return makeErrorResult( + `Parallel task ${index + 1} has no model. Provide an explicit 'model' or set a default model on preset "${preset.name}". Available models: ${availableModelsText}`, + "parallel", + ); + } + } + + if (params.tasks.length > MAX_PARALLEL_TASKS) { return { - content: [{ type: "text" as const, text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summary}` }], - details: makeDetails("parallel", results), - isError: successCount !== results.length, + content: [ + { + type: "text" as const, + text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`, + }, + ], + details: makeDetails("parallel", []), + isError: true, }; } - const results: SubagentRunResult[] = []; - let previous = ""; - for (let index = 0; index < params.chain.length; index += 1) { - const item = params.chain[index]; - const task = item.task.replaceAll("{previous}", previous); + const liveResults: SubagentRunResult[] = []; + const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (task: any, index) => { + const preset = presets.find((p) => p.name === task.preset)!; const result = await runTask({ - task, - cwd: item.cwd, - taskModel: item.model, - step: index + 1, - mode: "chain", + task: task.task, + cwd: task.cwd, + taskModel: task.model, + taskIndex: index, + preset, + mode: "parallel", }); + liveResults[index] = result; onUpdate?.({ - content: [{ type: "text", text: `Chain: completed step ${index + 1}/${params.chain.length}` }], - details: makeDetails("chain", [...results, result]), + content: [{ type: "text", text: `Parallel: ${liveResults.filter(Boolean).length}/${params.tasks.length} finished` }], + details: makeDetails("parallel", liveResults.filter(Boolean)), }); - results.push(result); - if (isFailure(result)) { - return { - content: [ - { - type: "text" as const, - text: `Chain stopped at step ${index + 1}: ${result.finalText || result.stopReason || "failed"}`, - }, - ], - details: makeDetails("chain", results), - isError: true, - }; - } - previous = result.finalText; - } + return result; + }); + + const successCount = results.filter((result) => !isFailure(result)).length; + const summary = results + .map((result, index) => `[task ${index + 1}] ${isFailure(result) ? "failed" : "completed"}: ${result.finalText || "(no output)"}`) + .join("\n\n"); - const finalResult = results[results.length - 1]; return { - content: [{ type: "text" as const, text: finalResult?.finalText ?? "" }], - details: makeDetails("chain", results), + content: [{ type: "text" as const, text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summary}` }], + details: makeDetails("parallel", results), + isError: successCount !== results.length, }; }, renderCall(args: any) { 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); return new Text("subagent", 0, 0); }, renderResult(result: { content: Array<{ type: string; text?: string }> }) {