/** `agi_delegate` (§9.3), `agi_workers` (§9.4), `agi_worker` (§9.5). */ import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { StringEnum } from "@earendil-works/pi-ai"; import { type ExtensionAPI, type ExtensionContext, defineTool } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { validateAgentName } from "../state.ts"; import type { Supervisor } from "../worker/supervisor.ts"; import { activeRunEntries, type RegistryEntry, latestRunEntries, latestRunForName, loadAllRuns, } from "../worker/registry.ts"; import { truncateReport } from "../worker/report.ts"; import { classifyWorkerOutcome, noResultOutcomeText, readableResultAvailable, resultDispositionText, } from "../worker/outcome.ts"; import { isCompletionOutcomeState, isTerminalState, patchStatus, readFileIfExists } from "../worker/status.ts"; import type { WorkerConfig } from "../worker/config.ts"; import { modelFacingAttentionEvidence } from "../scheduler/attention.ts"; import { readWorkerTraceSequence } from "../worker/trace.ts"; import { sanitizeInspectionField } from "../worker/trajectory.ts"; const WorkerFilter = StringEnum(["active", "all", "unconsumed"]); const WorkerView = StringEnum(["status", "result"]); export interface DelegateDeps { supervisor: () => Supervisor | undefined; config: (ctx: ExtensionContext) => WorkerConfig; /** True when this session may not mutate shared state (observe mode). */ readOnlySession: () => boolean; /** * R-SLEEP-13: reading a result inside the 200ms nudge hold cancels the pending * completion wake, so the orchestrator is never woken to be told something it just * read. */ onResultConsumed?: (runId: string) => void; /** Successful delegation immediately ends the turn in a bounded worker wait. */ waitForWorker: () => void; } export interface DelegateDetails { agiDelegate: { name: string; state: string }; } export interface WorkersDetails { agiWorkers: { count: number; filter: string }; } export interface WorkerDetails { agiWorker: { name: string; view: string; state: string }; } /** P34 / R-TOOL-8. Minimal result; the harness owns the wait lifecycle. */ export function formatDelegateResult(spawned: { name: string; queued: boolean }): string { return `Delegated ${spawned.name}${spawned.queued ? "; queued" : ""}. Waiting for agent.`; } function stateGlyph(entry: RegistryEntry): string { switch (entry.status.state) { case "complete": return "✓ complete"; case "failed": return "✗ failed"; case "stopped": return "■ stopped"; case "timedOut": return "⏱ timedOut"; case "orphaned": return "? orphaned"; case "paused": return "‖ paused"; case "queued": return "… queued"; case "unknown": return "? unknown"; default: return entry.status.state; } } function modelFacingError(entry: RegistryEntry): string | null { switch (entry.status.state) { case "timedOut": return "worker timed out"; case "failed": return "worker failed; inspect trace.log"; case "stopped": return null; case "paused": return "worker paused"; case "orphaned": return "worker became orphaned; inspect trace.log"; case "unknown": return "worker state is unknown; inspect trace.log"; default: return entry.status.error === null ? null : "worker recorded a harness warning; inspect trace.log"; } } function modelFacingStopReason(entry: RegistryEntry): string | null { if (entry.status.state !== "stopped") return null; return safeEventLabel(entry.status.stop?.reason ?? entry.status.error, 160); } export function formatWorkerTable(entries: RegistryEntry[]): string { if (entries.length === 0) return "No agents match."; const rows = entries.map((entry) => { const status = entry.status; const outcome = classifyWorkerOutcome(status, readableResultAvailable(entry.paths.result)); const tool = safeEventLabel(status.activity.currentTool, 60); const target = safeEventLabel(status.activity.currentPath, 120); const activity = tool !== null ? `${tool}${target === null ? "" : ` ${target}`}` : (safeEventLabel(status.activity.lastAssistantPreview, 160) ?? (isTerminalState(status.state) ? "" : "thinking…")); const attention = status.attention === null ? "" : ` [attention: ${status.attention.reason}]`; const unread = isTerminalState(status.state) && !status.resultConsumed ? (outcome.readResult ? " (result unread)" : " (outcome unread)") : ""; const detached = status.detached === true ? " (detached)" : ""; const evidence = isTerminalState(status.state) ? ` — ${outcome.qualifier}` : ""; return `${status.name} ${status.agent} ${stateGlyph(entry)} ${activity}${evidence}${attention}${unread}${detached}`; }); return rows.join("\n"); } export function registerWorkerTools(pi: ExtensionAPI, deps: DelegateDeps): void { pi.registerTool( defineTool({ name: "agi_delegate", label: "AGI Delegate", description: "Delegate one task to one detached worker process. The harness ends this turn and waits for the worker " + "for completion before waking you for a trajectory review. Use this by default for substantial work " + "that needs repeated reasoning or tool cycles, changes code, runs a solver/search/retry loop, takes several " + "minutes, or would fill your own context. Keep only genuinely quick single-step reads, edits, checks, or answers local. " + "The default worker inherits the parent session's exact model and effective thinking level.", promptSnippet: "agi_delegate: spawn one worker for the next task", parameters: Type.Object({ name: Type.String({ description: "Stable agent name using lowercase letters, digits, and underscores." }), agent: Type.Optional(Type.String({ description: "Agent profile name. Default 'worker'." })), prompt: Type.String({ minLength: 1, description: "The task text, sent to the agent verbatim." }), cwd: Type.Optional(Type.String()), }), execute: async (_id, params, _signal, _onUpdate, ctx): Promise> => { if (deps.readOnlySession()) { throw new Error( "agi_delegate: this session is read-only because another orchestrator holds the lock for this working directory. Delegation is refused.", ); } const supervisor = deps.supervisor(); if (supervisor === undefined) throw new Error("agi_delegate: AGI mode is not enabled."); const request = { name: validateAgentName(params.name), agent: params.agent ?? "worker", prompt: params.prompt, ...(params.cwd === undefined ? {} : { cwd: params.cwd }), }; const { entries } = loadAllRuns(ctx.cwd); if (entries.some((entry) => entry.status.name === request.name)) { throw new Error( `Agent '${request.name}' already exists. Continue its saved conversation with ` + `agi_control({action:"resume", name:"${request.name}", message:"..."}), or choose a different name for a fresh agent.`, ); } supervisor.validate(request, entries); const result = supervisor.start(request); const acceptance = await result.acceptance; if (!acceptance.ok) throw new Error(`agi_delegate: agent launcher was not accepted (${acceptance.reason}).`); deps.waitForWorker(); return { content: [ { type: "text", text: formatDelegateResult({ name: result.status.name, queued: result.queued }), }, ], details: { agiDelegate: { name: result.status.name, state: result.status.state } }, terminate: true, }; }, }), ); pi.registerTool( defineTool({ name: "agi_workers", label: "AGI Workers", description: "List worker runs and their live state. Cheap and read-only. Use 'unconsumed' after a restart to " + "find what finished while you were gone. Use wait_for_agent for active waiting.", promptSnippet: "agi_workers: list worker runs", parameters: Type.Object({ filter: Type.Optional(WorkerFilter), name: Type.Optional(Type.String()), }), execute: async (_id, params, _signal, _onUpdate, ctx): Promise> => { const { entries, problems } = loadAllRuns(ctx.cwd); const filter = params.filter === "all" || params.filter === "unconsumed" ? params.filter : "active"; let selected = latestRunEntries(entries); if (filter === "active") selected = latestRunEntries(activeRunEntries(entries)); // R-TOOL-13: `unconsumed` is what the orchestrator checks after a restart. else if (filter === "unconsumed") { selected = latestRunEntries(entries.filter((entry) => isCompletionOutcomeState(entry.status.state) && !entry.status.resultConsumed)); } if (params.name !== undefined) { const name = params.name.trim(); selected = selected.filter((entry) => entry.status.name === name); } const table = formatWorkerTable(selected); const notes = problems.length === 0 ? "" : `\n\n${problems.length} private worker record${problems.length === 1 ? " is" : "s are"} unreadable and left untouched.`; return { content: [{ type: "text", text: `${table}${notes}` }], details: { agiWorkers: { count: selected.length, filter } }, }; }, }), ); pi.registerTool( defineTool({ name: "agi_worker", label: "AGI Worker", description: "Inspect one worker run's concise status or final report. Live semantic activity is stored in the run's trace.log for incremental reads with normal filesystem tools. " + "Reading view 'result' marks the result as consumed.", promptSnippet: "agi_worker: inspect one run's concise status or final result", parameters: Type.Object({ name: Type.String({ description: "Agent name returned by agi_delegate." }), view: Type.Optional(WorkerView), }), execute: async (_id, params, _signal, _onUpdate, ctx): Promise> => { const { entries } = loadAllRuns(ctx.cwd); const name = validateAgentName(params.name); const entry = latestRunForName(entries, name); const view = params.view === "result" ? params.view : "status"; const config = deps.config(ctx); if (view === "status") { return { content: [{ type: "text", text: renderStatusDetail(entry) }], details: { agiWorker: { name, view, state: entry.status.state } }, }; } const raw = readableResultAvailable(entry.paths.result) ? readFileIfExists(entry.paths.result) : undefined; if (raw === undefined) { const outcome = classifyWorkerOutcome(entry.status, false); // A failed/stopped/timed-out worker normally has no result.md. Asking for // its result still reviews the terminal outcome, so consume it here instead // of leaving an unread run that recovery injects on every future session. // A supposedly complete run with no report remains unread because that is // an inconsistent/corrupt outcome which may still become repairable. if (isTerminalState(entry.status.state) && entry.status.state !== "complete") { if (!entry.status.resultConsumed) { try { patchStatus(entry.paths, (current) => ({ ...current, resultConsumed: true })); } catch { // Failing to mark it costs a duplicate recovery notice, never correctness. } deps.onResultConsumed?.(entry.runId); } const stopReason = modelFacingStopReason(entry); const visibleError = modelFacingError(entry); const text = `Agent ${name} is ${entry.status.state}. ${noResultOutcomeText(outcome)}` + `${stopReason === null ? "" : ` Stop reason: ${stopReason}.`}` + `${visibleError === null ? "" : ` Error: ${visibleError}.`}` + ` The terminal outcome is now marked reviewed. Inspect ${tracePath(entry)} for its evidence.`; return { content: [{ type: "text", text }], details: { agiWorker: { name, view, state: entry.status.state } }, }; } const stopReason = modelFacingStopReason(entry); const visibleError = modelFacingError(entry); throw new Error( `Agent ${name} is ${entry.status.state}. ${noResultOutcomeText(outcome)}` + `${stopReason === null ? "" : ` Stop reason: ${stopReason}.`}` + `${visibleError === null ? "" : ` Error: ${visibleError}.`}` + ` Inspect ${tracePath(entry)} for its evidence.`, ); } // R-TOOL-15: reading the result marks it consumed, so the wake scheduler does // not fire a redundant notification for something already read. if (isTerminalState(entry.status.state) && !entry.status.resultConsumed) { try { patchStatus(entry.paths, (current) => ({ ...current, resultConsumed: true })); } catch { // Failing to mark it costs a duplicate wake, never correctness. } deps.onResultConsumed?.(entry.runId); } const header = `Agent ${entry.status.name} is ${entry.status.state}.`; const stopReason = modelFacingStopReason(entry); const stop = stopReason === null ? "" : `\nStop reason: ${stopReason}`; const outcome = classifyWorkerOutcome(entry.status, true); const disposition = resultDispositionText(outcome); const visibleError = modelFacingError(entry); const error = visibleError === null ? "" : `\nError: ${visibleError}`; const body = truncateReport(raw, config.maxResultBytes, entry.paths.result); return { content: [{ type: "text", text: `${header}\nOutcome evidence: ${outcome.qualifier}.${disposition === undefined ? "" : `\n${disposition}`}${stop}${error}\n\n${body}` }], details: { agiWorker: { name, view, state: entry.status.state } }, }; }, }), ); } function renderStatusDetail(entry: RegistryEntry): string { const status = entry.status; const resultAvailable = readableResultAvailable(entry.paths.result); const outcome = classifyWorkerOutcome(status, resultAvailable); const lines = [ `name: ${status.name}`, `agent: ${status.agent}${status.readOnly ? " (read-only)" : ""}`, `state: ${status.state}${status.detached === true ? " (adopted; filesystem control only)" : ""}`, `outcome evidence: ${outcome.qualifier}`, ]; const activeTools = (status.activity.activeTools ?? []).filter((tool) => tool.logPath !== undefined); if (activeTools.length > 0) { for (const active of activeTools.sort((left, right) => left.startedAt.localeCompare(right.startedAt))) { const tool = sanitizeInspectionField(active.tool, 60) ?? "tool"; const target = conciseTarget(active.target); const elapsed = activeElapsed(active.startedAt); lines.push(`running · ${tool}${target === null ? "" : ` ${target}`} · ${elapsed} · ${active.logPath}`); } } else if (status.activity.currentTool !== null) { const tool = safeEventLabel(status.activity.currentTool, 60) ?? "unknown"; const target = safeEventLabel(status.activity.currentPath, 120); lines.push(`current: ${tool}${target === null ? "" : ` ${target}`}`); } const preview = safeEventLabel(status.activity.lastAssistantPreview, 160); if (preview !== null) lines.push(`last said: ${preview}`); if (status.attention !== null) { lines.push(`attention: ${status.attention.reason} — ${modelFacingAttentionEvidence({ trigger: status.attention.reason, detail: status.attention.detail }, status)}`); } const stopReason = modelFacingStopReason(entry); if (stopReason !== null) lines.push(`stop reason: ${stopReason}`); const visibleError = modelFacingError(entry); if (visibleError !== null) lines.push(`error: ${visibleError}`); if (resultAvailable) { lines.push(`result: ${status.resultConsumed ? "read" : "unread"}; agi_worker({name:"${status.name}", view:"result"})`); } else if (status.resultPath !== null) { lines.push("result: unavailable; the recorded result path is missing, unreadable, or inconsistent"); } const late = status.lateToolResults?.at(-1); if (late !== undefined) { const tool = safeEventLabel(late.tool, 60) ?? "unknown tool"; lines.push(`late tool evidence: ${tool} returned during shutdown; inspect trace.log or raw status for the bounded preview`); } lines.push(`trace: ${tracePath(entry)}; latest sequence ${readWorkerTraceSequence(entry.paths)}`); return lines.join("\n"); } function activeElapsed(startedAt: string, now = Date.now()): string { const start = Date.parse(startedAt); const total = Math.max(0, Math.floor((now - (Number.isNaN(start) ? now : start)) / 1000)); if (total < 60) return `${total}s`; const minutes = Math.floor(total / 60); const seconds = total % 60; if (minutes < 60) return seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`; const hours = Math.floor(minutes / 60); return minutes % 60 === 0 ? `${hours}h` : `${hours}h${minutes % 60}m`; } function conciseTarget(value: string | null): string | null { const target = sanitizeInspectionField(value, 160); if (target === null) return null; const pathLike = target.match(/(?:^|\s)(?:\/|\.\.?\/)?(?:[^\s/]+\/)*([^\s/]+\.(?:py|js|mjs|cjs|ts|tsx|sh|rb|go|rs))(?:\s|$)/iu); return (pathLike?.[1] ?? target).slice(0, 80); } function tracePath(entry: RegistryEntry): string { return `.pi/agi/.runtime/agents/${entry.status.name}/trace.log`; } function safeEventLabel(value: unknown, max = 120): string | null { if (typeof value !== "string" || value.length === 0) return null; const oneLine = value .replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ") .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, "[time]") .replace(/\$\s*\d+(?:\.\d+)?/g, "[cost]") .replace(/\b\d+(?:\.\d+)?%/g, "[percentage]") .replace(/\b\d+(?:\.\d+)?\s*(?:turns?|tool calls?|tool errors?|compactions?|tokens?|cycles?)\b/gi, "[metric]") .replace(/\d+/g, "#") .replace(/\s+/g, " ") .trim(); return oneLine.length === 0 ? null : oneLine.slice(0, max); }