import { defineTool, type AgentToolUpdateCallback, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent"; import type { Conversation, ConversationSnapshot, NestedJoinAttemptSnapshot, RunSnapshot, SteerReceipt } from "./conversation.js"; import { listAgentDefinitions, type AgentRegistry } from "./agents.js"; import type { ConversationId, RunId } from "./identifiers.js"; import { runElapsedMs } from "./run-format.js"; import type { JoinBinding, NestedJoinBinding, SubagentRuntime } from "./runtime.js"; import { parseSubagentInvocation, SubagentParams, type RunRequest, type RunStatus, type SteerRequest, type SubagentAction, type SubagentInvocation, type SubagentInvocationParseError } from "./schema.js"; import type { SubagentSettings } from "./settings.js"; import { renderSubagentCall, renderSubagentResult, type JoinedRunRenderItem, type JoinInvocationRenderItem, type JoinTargetRenderItem, type DispatchTaskRenderItem, type InspectedRunRenderItem, type SubagentToolDetails, } from "./tool-renderer.js"; export interface ActionDeps { runtime: SubagentRuntime; agentRegistry: AgentRegistry; parent?: { conversationId: ConversationId; runId: () => RunId }; } export interface ActionResult { content: Array<{ type: "text"; text: string }>; details: SubagentToolDetails; isError?: boolean; } export interface SubagentResultsEnvelope { action: A; results: T[]; } export interface SubagentErrorEnvelope { action: SubagentAction | "unknown"; error: string; } export type SubagentResponseEnvelope = | SubagentResultsEnvelope | SubagentErrorEnvelope; export type ItemResult> = | { ok: true; data: T } | ({ ok: false; error: string } & I); type InvocationFor = Extract; type OrderedDispatchOutcome = | { readonly ok: true; readonly inputIndex: number; readonly conversationId: ConversationId; readonly runId: RunId; readonly steer?: SteerReceipt } | { readonly ok: false; readonly inputIndex: number; readonly error: string }; type RunReceipt = ItemResult<{ readonly label?: string; readonly conversationId: ConversationId; readonly runId: RunId; }, { readonly agent?: string; readonly label?: string; readonly conversationId?: string; }>; function jsonResult(json: unknown, details: SubagentToolDetails): ActionResult { return { content: [{ type: "text", text: JSON.stringify(json, null, 2) }], details, isError: false, }; } function resultsEnvelope(action: A, results: T[]): SubagentResultsEnvelope { return { action, results }; } function resultsResult(action: A, results: T[], details: SubagentToolDetails): ActionResult { return jsonResult(resultsEnvelope(action, results), details); } export function errorResult(message: string, requestedAction?: SubagentAction): ActionResult { const envelope: SubagentErrorEnvelope = { action: requestedAction ?? "unknown", error: message, }; return { content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }], details: { action: "error", ...(requestedAction ? { requestedAction } : {}), message }, isError: true, }; } export function invocationErrorResult( deps: ActionDeps, parsed: SubagentInvocationParseError, ): ActionResult { const message = parsed.missingAction || parsed.taskCountError ? `${parsed.error}\n\nAvailable agents:\n${deps.agentRegistry.summarizeAgent()}` : parsed.error; return errorResult(message, parsed.action); } export function agentsAction( deps: ActionDeps, _invocation: InvocationFor<"agents">, ): ActionResult { const agents = listAgentDefinitions(deps.agentRegistry); return resultsResult("agents", agents, { action: "agents", agents }); } export function listAction( deps: ActionDeps, invocation: InvocationFor<"list">, ): ActionResult { const conversations = deps.runtime.listConversations().map(conversation => ({ conversationId: conversation.conversationId, agent: conversation.config.name, ...(conversation.label ? { label: conversation.label } : {}), createdAt: conversation.createdAt, canResume: conversation.canResume, runs: conversation.runs .map(run => ({ runId: run.runId, kind: run.kind, status: (run.status.kind === "done" ? run.status.outcome : run.status.kind) as RunStatus, createdAt: run.createdAt, ...deps.runtime.runLineage(run.runId), })) .filter(run => !invocation.status || invocation.status.includes(run.status)), })).filter(conversation => conversation.runs.length > 0); return resultsResult("list", conversations, { action: "list", conversations }); } export async function spawnAction( deps: ActionDeps, invocation: InvocationFor<"spawn">, ctx: ExtensionContext, ): Promise { return startTasks(deps, "spawn", invocation.spawns, ctx); } export async function resumeAction( deps: ActionDeps, invocation: InvocationFor<"resume">, ctx: ExtensionContext, ): Promise { return startTasks(deps, "resume", invocation.resumes, ctx); } async function startTasks( deps: ActionDeps, action: "spawn" | "resume", tasks: InvocationFor<"spawn">["spawns"] | InvocationFor<"resume">["resumes"], ctx: ExtensionContext, ): Promise { const owner = deps.parent ? { conversationId: deps.parent.conversationId, runId: deps.parent.runId() } : undefined; const outcomes: OrderedDispatchOutcome[] = []; for (let inputIndex = 0; inputIndex < tasks.length; inputIndex++) { const task = tasks[inputIndex]; if ("error" in task) { outcomes.push({ ok: false, inputIndex, error: task.error }); continue; } const handle = deps.runtime.startRun(ctx, [task], owner ? { parent: owner } : {}); outcomes.push({ ...handle.starts[0], inputIndex }); } const conversations = conversationSnapshots(deps.runtime); const receipts = outcomes.map((outcome, index) => projectRunReceipt(tasks[index], outcome, conversations)); return resultsResult(action, receipts, { action, tasks: renderDispatchItems(tasks, outcomes, conversations), }); } export async function steerAction( deps: ActionDeps, invocation: InvocationFor<"steer">, ): Promise { const owner = deps.parent ? { conversationId: deps.parent.conversationId, runId: deps.parent.runId() } : undefined; const outcomes: OrderedDispatchOutcome[] = []; for (let inputIndex = 0; inputIndex < invocation.messages.length; inputIndex++) { const steer = invocation.messages[inputIndex]; if ("error" in steer) { outcomes.push({ ok: false, inputIndex, error: steer.error }); continue; } try { const result = await deps.runtime.steerRun(steer.runId, steer.message, owner); outcomes.push({ ok: true, inputIndex, ...result }); } catch (error) { outcomes.push({ ok: false, inputIndex, error: error instanceof Error ? error.message : String(error) }); } } const results = outcomes.map((outcome, index) => outcome.ok ? { ok: true as const, data: { conversationId: outcome.conversationId, runId: outcome.runId, ...(outcome.steer ? { steer: outcome.steer } : {}) } } : { ok: false as const, ...(invocation.messages[index]?.runId ? { runId: invocation.messages[index].runId } : {}), error: outcome.error, }); return resultsResult("steer", results, { action: "steer", tasks: renderDispatchItems(invocation.messages, outcomes, conversationSnapshots(deps.runtime)), }); } export async function cancelAction( deps: ActionDeps, invocation: InvocationFor<"cancel">, ): Promise { const owner = deps.parent ? { conversationId: deps.parent.conversationId, runId: deps.parent.runId() } : undefined; const runs = await Promise.all(invocation.runIds.map(async target => { if (typeof target !== "string") return { runId: target.runId, error: target.error }; try { return await deps.runtime.cancelRun(target, owner); } catch (error) { return { runId: target, error: error instanceof Error ? error.message : String(error) }; } })); const results = runs.map(run => "error" in run ? { ok: false as const, runId: run.runId, error: run.error } : { ok: true as const, data: run }); return resultsResult("cancel", results, { action: "cancel", runs }); } export function inspectAction( deps: ActionDeps, invocation: InvocationFor<"inspect">, ): ActionResult { const owner = deps.parent ? { conversationId: deps.parent.conversationId, runId: deps.parent.runId() } : undefined; const runs = invocation.runIds.map((target, inputIndex) => { if (typeof target !== "string") return { inputIndex, runId: target.runId, error: target.error }; try { const inspected = deps.runtime.inspectRuns([target], owner)[0]; return projectInspection(deps.runtime, inspected.conversationId, inspected.snapshot); } catch (error) { return { inputIndex, runId: target, error: error instanceof Error ? error.message : String(error) }; } }); const results = runs.map(run => "error" in run ? { ok: false as const, runId: run.runId, error: run.error } : { ok: true as const, data: run }); return resultsResult("inspect", results, { action: "inspect", runs }); } export async function joinAction( deps: ActionDeps, invocation: InvocationFor<"join">, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, toolCallId?: string, ): Promise { const owner = deps.parent ? { conversationId: deps.parent.conversationId, runId: deps.parent.runId() } : undefined; const targets = invocation.runIds.map(target => { if (typeof target !== "string") return target; try { deps.runtime.inspectRuns([target], owner); return target; } catch (error) { return { runId: target, error: error instanceof Error ? error.message : String(error) }; } }); const validRunIds = targets.filter((target): target is RunId => typeof target === "string"); if (validRunIds.length === 0) { const result = targets as JoinOutput[]; return resultsResult("join", projectJoinResults(result), { action: "join", runs: renderJoinedRuns(result, deps.runtime, true) }); } let binding: JoinBinding | NestedJoinBinding; try { binding = owner ? deps.runtime.bindNestedJoin(owner, validRunIds, toolCallId) : deps.runtime.bindJoin(validRunIds); } catch (error) { return errorResult(error instanceof Error ? error.message : String(error), "join"); } const output = (): JoinOutput[] => { const entries = binding.project(); let entryIndex = 0; return targets.map(target => typeof target === "string" ? projectJoinedEntry(entries[entryIndex++]) : target); }; const renderDetails = (final = false): SubagentToolDetails => ({ action: "join", runs: renderJoinedRuns(output(), deps.runtime, final), }); const emit = () => onUpdate?.({ content: [{ type: "text", text: JSON.stringify(resultsEnvelope("join", projectJoinResults(output()))) }], details: renderDetails(), }); const unsubscribe = deps.runtime.onConversationUpdate(emit); emit(); let abort: (() => void) | undefined; const cancelled = signal ? new Promise((_, reject) => { abort = () => reject(new Error("Join cancelled by caller.")); if (signal.aborted) abort(); else signal.addEventListener("abort", abort, { once: true }); }) : undefined; try { const wait = () => cancelled ? Promise.race([binding.completion, cancelled]) : binding.completion; await (deps.parent ? deps.runtime.scheduler.suspendAgentSlotDuring(deps.parent.conversationId, wait) : wait()); binding.acknowledge(); const result = output(); return resultsResult("join", projectJoinResults(result), renderDetails(true)); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (owner) (binding as NestedJoinBinding).interrupt(message); return errorResult(message, "join"); } finally { unsubscribe(); binding.release(); if (abort) signal?.removeEventListener("abort", abort); } } function projectJoinedEntry(entry: ReturnType[number]): JoinedOutput { return entry.status.kind === "done" ? { conversationId: entry.conversationId, runId: entry.runId, status: entry.status.outcome, ...(entry.status.output !== undefined ? { output: entry.status.output } : {}), ...(entry.status.error !== undefined ? { error: entry.status.error } : {}), } : { conversationId: entry.conversationId, runId: entry.runId, status: entry.status.kind, }; } export async function removeAction( deps: ActionDeps, invocation: InvocationFor<"remove">, ): Promise { const validIds = invocation.conversationIds.filter((target): target is ConversationId => typeof target === "string"); const removed = await deps.runtime.removeConversations(validIds); const results = invocation.conversationIds.map(target => { if (typeof target !== "string") return { ok: false as const, conversationId: target.conversationId, error: target.error }; if (removed.conversationIds.includes(target)) { return { ok: true as const, data: { conversationId: target, removed: true as const } }; } const failure = removed.errors.find(item => item.conversationId === target); return { ok: false as const, conversationId: target, error: failure?.error ?? `Conversation ${target} was not removed.` }; }); const parseErrors = invocation.conversationIds.flatMap(target => typeof target === "string" ? [] : [{ conversationId: target.conversationId, error: target.error }]); return resultsResult("remove", results, { action: "remove", removed: removed.removed, conversationIds: removed.conversationIds, errors: [...parseErrors, ...removed.errors], }); } function projectRunReceipt( task: RunRequest | { error: string; agent?: string; label?: string; conversationId?: string } | undefined, outcome: OrderedDispatchOutcome, conversations: readonly ConversationSnapshot[], ): RunReceipt { const label = task && "error" in task ? task.label : task?.kind === "spawn" ? task.label : task?.kind === "resume" ? conversations.find(conversation => conversation.conversationId === task.conversationId)?.label : undefined; if (outcome.ok) { return { ok: true, data: { ...(label ? { label } : {}), conversationId: outcome.conversationId, runId: outcome.runId } }; } const identity = !task ? {} : "error" in task ? { ...(task.agent ? { agent: task.agent } : {}), ...(task.label ? { label: task.label } : {}), ...(task.conversationId ? { conversationId: task.conversationId } : {}), } : task.kind === "spawn" ? { agent: task.agent, ...(task.label ? { label: task.label } : {}) } : { conversationId: task.conversationId }; return { ok: false, ...identity, error: outcome.error }; } function renderDispatchItems( tasks: readonly (RunRequest | SteerRequest | { error: string; label?: string })[], starts: readonly OrderedDispatchOutcome[], conversations: readonly ConversationSnapshot[], ): DispatchTaskRenderItem[] { const byConversation = new Map(conversations.map(conversation => [conversation.conversationId, conversation])); return starts.map(start => { const task = tasks[start.inputIndex]; if (!task) return { inputIndex: start.inputIndex, error: "Task was not accepted." }; if ("error" in task) return { inputIndex: start.inputIndex, error: task.error }; const conversationId = start.ok ? start.conversationId : task.kind === "resume" ? task.conversationId : undefined; const conversation = conversationId ? byConversation.get(conversationId) : undefined; return { inputIndex: start.inputIndex, kind: task.kind, agent: task.kind === "spawn" ? task.agent : conversation?.config.name, label: task.kind === "spawn" ? task.label : conversation?.label, prompt: task.kind === "steer" ? task.message : task.prompt, ...(start.ok ? { conversationId: start.conversationId, runId: start.runId, ...(start.steer ? { steer: start.steer } : {}) } : { error: start.error }), }; }); } function projectInspection( runtime: SubagentRuntime, conversationId: ConversationId, run: RunSnapshot, ): InspectedRunRenderItem { const status = run.status.kind === "done" ? run.status.outcome : run.status.kind; const end = run.status.kind === "done" ? run.status.completedAt : Date.now(); const start = run.status.kind === "queued" ? run.status.queuedAt : run.status.kind === "running" ? run.status.startedAt : run.status.startedAt ?? run.createdAt; let display: { agentName?: string; label?: string } = {}; let config: Pick = {}; try { display = runtime.conversationDisplay(conversationId); } catch {} try { const conversation = runtime.conversation(conversationId); config = { ...(conversation.requestedOverrides ? { requestedOverrides: conversation.requestedOverrides } : {}), ...(conversation.effectiveConfig ? { effectiveConfig: conversation.effectiveConfig } : {}), }; } catch {} return { conversationId, runId: run.runId, ...runtime.runLineage(run.runId), ...config, ...(display.agentName ? { agent: display.agentName } : {}), ...(display.label ? { label: display.label } : {}), status, ...(status === "running" ? { phase: run.activity.phase } : {}), elapsedMs: Math.max(0, end - start), turns: run.activity.turns, compactions: run.activity.compactions, ...(status === "running" && run.activity.messageSnippet ? { messageSnippet: truncateInspectionText(run.activity.messageSnippet, 500) } : {}), ...(run.status.kind === "done" && run.status.error ? { errorSnippet: truncateInspectionText(run.status.error, 500) } : {}), recentTools: run.activity.toolHistory.slice(-3).reverse().map(tool => ({ toolCallId: tool.id, tool: tool.name, ...(tool.inputSummary ? { summary: truncateInspectionText(tool.inputSummary, 160) } : {}), status: tool.completedAt === undefined ? run.status.kind === "done" ? "interrupted" : "running" : tool.isError ? "error" : "completed", })), steers: (run.steers ?? []).slice(-5), }; } function truncateInspectionText(value: string, limit: number): string { const compact = value.replace(/\s+/g, " ").trim(); return compact.length <= limit ? compact : `${compact.slice(0, limit - 1)}…`; } function conversationSnapshots(runtime: SubagentRuntime): ConversationSnapshot[] { const source = runtime as SubagentRuntime & { listConversations?: () => ConversationSnapshot[] }; return typeof source.listConversations === "function" ? source.listConversations() : []; } type JoinedOutput = { conversationId: ConversationId; runId: RunId; status: RunStatus; output?: string; error?: string; }; type JoinOutputError = { runId: string; error: string }; type JoinOutput = JoinedOutput | JoinOutputError; function projectJoinResults(output: readonly JoinOutput[]): ItemResult[] { return output.map(value => "status" in value ? { ok: true, data: value } : { ok: false, runId: value.runId, error: value.error }); } function renderJoinedRuns( output: readonly JoinOutput[], runtime: SubagentRuntime, final: boolean, ): JoinedRunRenderItem[] { const conversations = conversationSnapshots(runtime); const byRun = new Map(conversations.flatMap(conversation => conversation.runs.map(run => [run.runId, { conversation, run }] as const))); const snapshot = (runId: RunId): RunSnapshot | undefined => { try { return runtime.runSnapshot?.(runId) ?? byRun.get(runId)?.run; } catch { return byRun.get(runId)?.run; } }; const display = (conversationId: ConversationId | undefined) => { if (!conversationId) return {}; const local = conversations.find(item => item.conversationId === conversationId); if (local) return { agent: local.config.name, ...(local.label ? { label: local.label } : {}) }; try { const value = runtime.conversationDisplay(conversationId); return { ...(value.agentName ? { agent: value.agentName } : {}), ...(value.label ? { label: value.label } : {}) }; } catch { return {}; } }; const status = (run: RunSnapshot): RunStatus => run.status.kind === "done" ? run.status.outcome : run.status.kind; const activity = (run: RunSnapshot) => run.activity.toolHistory.map(tool => ({ toolCallId: tool.id, tool: tool.name, ...(tool.inputSummary ? { summary: tool.inputSummary } : {}), })); const background = (ownerRunId: RunId, ownerLabel?: string) => { let children: readonly { runId: RunId; conversationId: ConversationId }[] = []; try { children = runtime.unjoinedDirectChildren(ownerRunId); } catch { return []; } if (!children.length) return []; return [{ ownerRunId, ...(ownerLabel ? { ownerLabel } : {}), entries: children.map(child => { const childRun = snapshot(child.runId); const childStatus = childRun ? status(childRun) : "running"; return { conversationId: child.conversationId, runId: child.runId, ...display(child.conversationId), status: childStatus, ...(final && (childStatus === "queued" || childStatus === "running") ? { detachedAtFinal: true } : {}) }; }) }]; }; const target = (value: NestedJoinAttemptSnapshot["targets"][number]): JoinTargetRenderItem => { const run = snapshot(value.runId); const targetStatus = (run ? status(run) : value.status ?? "error") as RunStatus; const base: JoinTargetRenderItem = { runId: value.runId, ...(value.conversationId ? { conversationId: value.conversationId, ...display(value.conversationId) } : {}), status: targetStatus }; if (!run) return base; return { ...base, ...runStats(run), activity: activity(run), joins: joins(run), background: background(run.runId, base.label ?? base.agent), ...(run.status.kind === "done" && run.status.error ? { error: run.status.error } : {}), }; }; const joins = (run: RunSnapshot): JoinInvocationRenderItem[] => (run.nestedJoins ?? []).map(attempt => ({ status: (attempt.state === "running" ? "running" : attempt.state === "completed" ? "completed" : attempt.state === "interrupted" ? "interrupted" : "error") as RunStatus, targets: attempt.targets.map(target), ...(attempt.error ? { error: attempt.error } : {}), ...(attempt.toolCallId ? { toolCallId: attempt.toolCallId } : {}), })); return output.map(value => { if (!("status" in value)) return { ...value, status: "error" }; const run = snapshot(value.runId); if (!run) return { ...value }; const info = display(value.conversationId); const represented = (run.nestedJoins ?? []).flatMap(attempt => attempt.toolCallId ? [attempt.toolCallId] : []); return { ...value, ...info, kind: run.kind, prompt: run.prompt, ...runStats(run), activity: activity(run), joins: joins(run), background: background(run.runId, info.label ?? info.agent), joinToolCallIds: represented }; }); } function runStats(run: RunSnapshot): Pick { return { elapsedMs: runElapsedMs(run), turns: run.activity.turns, tokens: run.usage?.totalTokens ?? 0, }; } export interface SubagentToolDeps { runtime: SubagentRuntime; agentRegistry: AgentRegistry; /** * Called at the start of every tool invocation. Root extensions use this to reload settings, * reconfigure display, set max-concurrent, and reload the registry. Child factories provide * a no-op here because the parent's invocation already performed all of those steps. */ prepareInvocation: (ctx: ExtensionContext) => Promise; /** Set on child factories; links spawned conversations and suspends its queue slot while joining. */ parent?: { conversationId: ConversationId; runId: () => RunId }; } export function defineSubagentTool(deps: SubagentToolDeps) { const { runtime, agentRegistry, prepareInvocation, parent } = deps; const actionDeps: ActionDeps = { runtime, agentRegistry, ...(parent ? { parent } : {}) }; return defineTool({ name: "subagent", label: "Subagent", description: [ "Delegate work through context-isolated subagent conversations and runs. Subagents share the working filesystem.", "Conversation IDs use adjective-noun form; run IDs use verb-adverb form.", "Actions:", " agents(): List available agent definitions.", " list(status?): List conversations/runs, optionally including only runs matching a status.", " spawn(spawns): Start asynchronous subagent conversations.", " resume(resumes): Continue existing subagent conversations asynchronously.", " steer(messages): Send messages to running subagents.", " inspect(runIds): Check run status and progress without waiting.", " join(runIds): Wait for the given runs and return their outcomes.", " cancel(runIds): Abort active runs while retaining their conversations and outcomes.", " remove(conversationIds): Delete terminal conversations and their runs. Surviving children are reparented.", ].join("\n"), promptSnippet: "Delegate bounded work to context-isolated subagents", promptGuidelines: [ "Delegate bounded, self-contained units of work to subagent — work that parallelizes cleanly, deserves a specialist, or benefits from a fresh context.", "Skip subagent when delegating costs more than doing, or when you couldn't verify or use the result without repeating the work.", "Write each subagent prompt as if to a stranger sharing only your filesystem: every input, path, and constraint, plus what to report back or produce.", "Run subagent tasks in parallel only when they're independent and won't interact with the same files; join once you depend on their results or have nothing else to do.", "Use subagent inspect only when progress could affect your next step; steer only to communicate newly discovered constraints or correct clear divergence.", "Resume a retained subagent when its context helps the follow-up, spawn fresh when it wouldn't help or would mislead, and permanently remove terminal conversations you no longer need.", //"Call subagent action=agents before choosing an agent unless the user named one explicitly or definitions were already listed.", ], parameters: SubagentParams, renderCall(args, theme) { return renderSubagentCall(args, theme); }, renderResult(result, options, theme) { return renderSubagentResult(result, options, theme); }, async execute(toolCallId, params, signal, onUpdate, ctx) { const settings = await prepareInvocation(ctx); const invocation = parseSubagentInvocation(params, { maxTasks: settings.runtime.maxTasksPerRun }); if ("error" in invocation) return invocationErrorResult(actionDeps, invocation); switch (invocation.action) { case "agents": return agentsAction(actionDeps, invocation); case "list": return listAction(actionDeps, invocation); case "spawn": return spawnAction(actionDeps, invocation, ctx); case "resume": return resumeAction(actionDeps, invocation, ctx); case "steer": return steerAction(actionDeps, invocation); case "cancel": return cancelAction(actionDeps, invocation); case "inspect": return inspectAction(actionDeps, invocation); case "join": return joinAction(actionDeps, invocation, signal, onUpdate, toolCallId); case "remove": return removeAction(actionDeps, invocation); } }, }); } export interface ChildToolDeps { manager: SubagentRuntime; registry: AgentRegistry; parent: Conversation; getCurrentSettings: () => SubagentSettings; } export function makeChildSubagentTool(deps: ChildToolDeps): ToolDefinition { const { manager, registry, parent, getCurrentSettings } = deps; return defineSubagentTool({ runtime: manager, agentRegistry: registry, prepareInvocation: async () => getCurrentSettings(), parent: { conversationId: parent.conversationId, runId: () => parent.requireCurrentRun().runId, }, }); }