/** * Subagent Tool - Delegate tasks to specialized agents * * Spawns a separate `atomic` process for each subagent invocation, * giving it an isolated context window. * * Supports two modes: * - Single: { agent: "name", task: "..." } * - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] } * * Supports direct single-agent and parallel delegation. */ import * as path from "node:path"; import type { ExtensionAPI } from "@bastani/atomic"; import { CONFIG_DIR_NAME, getAgentDir } from "@bastani/atomic"; import { Text } from "@earendil-works/pi-tui"; import type { AgentScope } from "./agents.ts"; import { discoverAgents } from "./agents.ts"; import { getFinalOutput } from "./display.js"; import { renderSubagentResult } from "./render.js"; import { mapWithConcurrencyLimit, runSingleAgent } from "./runner.js"; import { MAX_PARALLEL_TASKS, SubagentParams } from "./schemas.js"; import type { SingleResult, SubagentDetails } from "./types.js"; const MAX_CONCURRENCY = 4; export default function (pi: ExtensionAPI) { pi.registerTool({ name: "subagent", label: "Subagent", description: [ "Delegate tasks to specialized subagents with isolated context.", "Modes: single (agent + task) or parallel (tasks array).", `Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`, `To enable project-local agents in ${CONFIG_DIR_NAME}/agents (legacy .pi/agents also works), set agentScope: "both" (or "project").`, ].join(" "), parameters: SubagentParams, async execute(_toolCallId, params, signal, onUpdate, ctx) { const agentScope: AgentScope = params.agentScope ?? "user"; const discovery = discoverAgents(ctx.cwd, agentScope); const agents = discovery.agents; const confirmProjectAgents = params.confirmProjectAgents ?? true; const hasTasks = (params.tasks?.length ?? 0) > 0; const hasSingle = Boolean(params.agent && params.task); const modeCount = Number(hasTasks) + Number(hasSingle); const makeDetails = (mode: "single" | "parallel") => (results: SingleResult[]): SubagentDetails => ({ mode, agentScope, projectAgentsDir: discovery.projectAgentsDir, results, }); if (modeCount !== 1) { const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"; return { content: [ { type: "text", text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}`, }, ], details: makeDetails("single")([]), }; } if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) { const requestedAgentNames = new Set(); if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent); if (params.agent) requestedAgentNames.add(params.agent); const projectAgentsRequested = Array.from(requestedAgentNames) .map((name) => agents.find((a) => a.name === name)) .filter((a): a is AgentConfig => a?.source === "project"); if (projectAgentsRequested.length > 0) { const names = projectAgentsRequested.map((a) => a.name).join(", "); const dir = discovery.projectAgentsDir ?? "(unknown)"; const ok = await ctx.ui.confirm( "Run project-local agents?", `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`, ); if (!ok) return { content: [ { type: "text", text: "Canceled: project-local agents not approved.", }, ], details: makeDetails(hasTasks ? "parallel" : "single")([]), }; } } if (params.tasks && params.tasks.length > 0) { if (params.tasks.length > MAX_PARALLEL_TASKS) return { content: [ { type: "text", text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`, }, ], details: makeDetails("parallel")([]), }; // Track all results for streaming updates const allResults: SingleResult[] = new Array(params.tasks.length); // Initialize placeholder results for (let i = 0; i < params.tasks.length; i++) { allResults[i] = { agent: params.tasks[i].agent, agentSource: "unknown", task: params.tasks[i].task, exitCode: -1, // -1 = still running messages: [], stderr: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0, }, }; } const emitParallelUpdate = () => { if (onUpdate) { const running = allResults.filter((r) => r.exitCode === -1).length; const done = allResults.filter((r) => r.exitCode !== -1).length; onUpdate({ content: [ { type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...`, }, ], details: makeDetails("parallel")([...allResults]), }); } }; const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => { const result = await runSingleAgent( ctx.cwd, agents, t.agent, t.task, t.cwd, signal, // Per-task update callback (partial) => { if (partial.details?.results[0]) { allResults[index] = partial.details.results[0]; emitParallelUpdate(); } }, makeDetails("parallel"), ); allResults[index] = result; emitParallelUpdate(); return result; }); const successCount = results.filter((r) => r.exitCode === 0).length; const summaries = results.map((r) => { const output = getFinalOutput(r.messages); const preview = output.slice(0, 100) + (output.length > 100 ? "..." : ""); return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`; }); return { content: [ { type: "text", text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`, }, ], details: makeDetails("parallel")(results), }; } if (params.agent && params.task) { const result = await runSingleAgent( ctx.cwd, agents, params.agent, params.task, params.cwd, signal, onUpdate, makeDetails("single"), ); const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; if (isError) { const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; return { content: [ { type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}`, }, ], details: makeDetails("single")([result]), isError: true, }; } return { content: [ { type: "text", text: getFinalOutput(result.messages) || "(no output)", }, ], details: makeDetails("single")([result]), }; } const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"; return { content: [ { type: "text", text: `Invalid parameters. Available agents: ${available}`, }, ], details: makeDetails("single")([]), }; }, renderCall(args, theme, _context) { const scope: AgentScope = args.agentScope ?? "user"; if (args.tasks && args.tasks.length > 0) { let text = theme.fg("toolTitle", theme.bold("subagent ")) + theme.fg("accent", `parallel (${args.tasks.length} tasks)`) + theme.fg("muted", ` [${scope}]`); for (const t of args.tasks.slice(0, 3)) { const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task; text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`; } if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`; return new Text(text, 0, 0); } const agentName = args.agent || "..."; const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "..."; let text = theme.fg("toolTitle", theme.bold("subagent ")) + theme.fg("accent", agentName) + theme.fg("muted", ` [${scope}]`); text += `\n ${theme.fg("dim", preview)}`; return new Text(text, 0, 0); }, renderResult(result, state, theme, _context) { return renderSubagentResult(result as Parameters[0], state, theme); }, }); }