import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { BUILTIN_WORKFLOW_NAMES, resolveWorkflowInvocation } from "./builtin-workflows.js"; import { createToolUpdateWorkflowDisplay, createWorkflowSnapshot, fmtCost, fmtFull, fmtTokenSegment, recomputeWorkflowSnapshot, renderWorkflowText, tokenFigures, type WorkflowSnapshot, } from "./display.js"; import { WorkflowError, WorkflowErrorCode } from "./errors.js"; import type { WorkflowExecutor } from "./executor.js"; import { parseWorkflowScript, type WorkflowRunResult } from "./workflow.js"; import { WorkflowManager } from "./workflow-manager.js"; import { createWorkflowStorage, type WorkflowStorage } from "./workflow-saved.js"; import { loadWorkflowSettings } from "./workflow-settings.js"; /** The single always-on gate that authorizes workflow use without forcing it. */ export const WORKFLOW_GATE_GUIDELINE = "The `workflow` tool runs multi-agent orchestration — it fans decomposable work out across subagents, and fits tasks shaped like: repo-wide inspection, independent parallel research/checks, multi-perspective review, or fan-out/fan-in synthesis. ONLY call it when the user explicitly opts in — via the workflow trigger word, `/workflows run`, or their own words (e.g. 'run a workflow', 'fan this out', '并行审一遍'). For any other task — even one that would clearly benefit — do not call it; you may briefly offer it (with a rough cost) as an option instead."; const workflowToolSchema = Type.Object({ script: Type.Optional( Type.String({ description: [ "Raw JavaScript workflow script; no Markdown fences. Required unless `name` is given.", "First statement: export const meta = { name: 'short_snake_case', description: 'non-empty' }. Add phases: only when the workflow has named phases; declare only phases it will use. With multiple phases, call phase('Exact Title') or set `phase` in agent options.", "Use `await workflow(savedName, childArgs)` for a saved workflow inline; nesting is one level and shares the parent run's concurrency, agent, and token limits.", "Optional quality helpers include verify(), judgePanel(), loopUntilDry(), and completenessCheck().", "Optional control helpers include retry() and gate(); budget exposes total, spent(), and remaining(); phase('Name', { budget: N }) sets a phase token limit.", "`executor` selects `pi` (default), `codex`, or `claude-code` independently from `model`; external executors keep cwd/instructions but not Pi tools. `agentType` selects a context-provided Pi role/tool/model definition; explicit `model` wins. With `required: true`, exhausted recoverable failure must throw after retries instead of returning null.", "Use plain JavaScript only; imports, require(), filesystem modules, Date.now(), Math.random(), and new Date() are unavailable.", "Globals: phase('Name'), agent(prompt, opts), parallel(arrayOfFunctions), pipeline(items, ...stages), log(message), args, cwd, process.cwd(), and budget. Must call agent() at least once.", "parallel() requires functions, not promises, and returns results in input order: await parallel(items.map(item => () => agent(...))).", "pipeline(items, ...stages) runs stages sequentially for each item while items proceed concurrently; each stage receives (previousValue, originalItem, index).", ].join(" "), }), ), name: Type.Optional( Type.String({ description: "Run saved/built-in `name`; input goes in `args`. " + `Built-ins: ${BUILTIN_WORKFLOW_NAMES.join(", ")} — see workflow-patterns for args. ` + "Saved wins. Cannot combine with resumeFromRunId or forkFromRunId.", }), ), args: Type.Optional( // Must be an explicitly typed object schema, not Type.Any(). Type.Any() // compiles to a schema with no "type" keyword at all (just // `{ description }`), and at least one MCP/tool-calling bridge observed // in the wild does not treat a typeless property as "accept any JSON // value" — it coerces/flattens it before the handler ever sees it, so // `args.scope` (etc.) arrives as `undefined` and every built-in pattern // that requires an args field fails validation regardless of what the // caller actually sent. Every built-in pattern's `args` is a JSON object // at the top level, so declaring `type: "object"` is lossless and fixes // the coercion. Type.Unsafe keeps the emitted schema minimal (no // `properties`/`additionalProperties` boilerplate — JSON Schema already // allows additional properties by default) to stay inside the // provider-visible tool definition's byte budget. Type.Unsafe>({ type: "object", description: "Optional JSON value exposed to the workflow script as global `args`.", }), ), background: Type.Optional( Type.Boolean({ description: "Default: true runs in background and returns a run ID immediately; the result is delivered back when it finishes. Set false only when the result must be inline in the same turn (blocks until completion).", }), ), maxAgents: Type.Optional( Type.Number({ description: "Maximum agents. Default 1000: a safety ceiling, not a target. Set a lower limit for dynamic or exploratory fan-out; large fan-outs require explicit user intent.", }), ), concurrency: Type.Optional( Type.Number({ description: "Maximum concurrent agents; clamped to the runtime maximum.", }), ), agentRetries: Type.Optional( Type.Number({ description: "Retry attempts after recoverable failures. Default 0 unless configured.", }), ), agentTimeoutMs: Type.Optional( Type.Number({ description: "Per-agent timeout in ms. Omit to use configured `defaultAgentTimeoutMs`; without one there is no hard timeout. Set only when the user asks.", }), ), tokenBudget: Type.Optional( Type.Number({ description: "Optional user-requested soft spend gate, not a planning target. Do not set `tokenBudget` unless the user explicitly supplies a cap or asks you to choose one; never infer or invent one from task size. If omitted, the configured `defaultTokenBudget` applies; without one, the run is unlimited. Reaching the gate blocks later `agent()` calls; concurrent in-flight work can overshoot.", }), ), resumeFromRunId: Type.Optional( Type.String({ description: "Resume a paused or failed run in place under the same run ID, optionally with an edited script. Replay the longest unchanged journal prefix; the first changed, new, missing, or unusable call and its suffix run live. Always background.", }), ), forkFromRunId: Type.Optional( Type.String({ description: "Fork a completed run to a new immutable revision ID; the source stays unchanged. Omitted script/args reuse it. Replay the longest unchanged journal prefix; the first changed, new, missing, or unusable call and its suffix run live. Always background.", }), ), }); export type WorkflowToolInput = { script?: string; name?: string; args?: Record; background?: boolean; maxAgents?: number; concurrency?: number; agentRetries?: number; agentTimeoutMs?: number; tokenBudget?: number; resumeFromRunId?: string; forkFromRunId?: string; }; export interface WorkflowToolOptions { cwd?: string; concurrency?: number; /** Shared manager so background runs are reachable from the `/workflows` command. */ manager?: WorkflowManager; /** Shared saved-workflow storage. */ storage?: WorkflowStorage; /** Default per-agent timeout for runs created by this tool. null means no hard timeout. */ defaultAgentTimeoutMs?: number | null; /** Default max concurrent agents when no tool-level concurrency is passed. */ defaultConcurrency?: number; /** Default retry attempts after recoverable agent failures. */ defaultAgentRetries?: number; /** Default executor for agent() calls that omit one. */ defaultExecutor?: WorkflowExecutor; } export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefinition { const storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd()); const cwd = options.cwd ?? process.cwd(); const defaults = resolveWorkflowToolDefaults(options, cwd); const manager = options.manager ?? new WorkflowManager({ cwd: options.cwd, concurrency: defaults.concurrency, loadSavedWorkflow: (name: string) => storage.load(name)?.script, defaultAgentTimeoutMs: defaults.agentTimeoutMs, defaultAgentRetries: defaults.agentRetries, defaultExecutor: defaults.executor, }); return defineTool({ name: "workflow", label: "Workflow", description: "Run a JavaScript workflow that delegates work to subagents with agent(), optionally composing calls with parallel() and pipeline().", promptSnippet: "Delegate substantive independent or staged work to subagents with a JavaScript workflow, optionally composing agent calls with parallel(), pipeline(), or both", get promptGuidelines() { return [WORKFLOW_GATE_GUIDELINE]; }, parameters: workflowToolSchema, prepareArguments(args) { return normalizeWorkflowToolArgs(args); }, async execute(_toolCallId, params, signal, onUpdate, ctx) { // `name` resolves through the same registry the built-in slash commands // and saved-workflow commands use (see builtin-workflows.ts / // workflow-saved.ts): a project/user saved workflow of that name wins on // a collision, else one of the 5 curated built-in patterns. This lets the // model reach a curated pattern by name instead of having to author an // equivalent script from scratch (and, for patterns that need it, the // right exec context — e.g. deep-research's web tools — travels with it). if (params.resumeFromRunId && params.forkFromRunId) { throw new Error("workflow: `resumeFromRunId` and `forkFromRunId` are mutually exclusive."); } if (params.forkFromRunId) { if (params.name) { throw new Error( "workflow: `name` cannot be combined with `forkFromRunId`; pass an edited script or reuse the source script.", ); } const sourceRunId = params.forkFromRunId; const source = manager.getPersistence().load(sourceRunId); const editedScript = params.script ? normalizeWorkflowScript(params.script) : undefined; if (editedScript) parseWorkflowScript(editedScript); const fork = manager.startFork(sourceRunId, { script: editedScript, args: params.args }); const workflowName = editedScript ? parseWorkflowScript(editedScript).meta.name : (source?.workflowName ?? "workflow"); return { content: [{ type: "text", text: forkedText(workflowName, fork.runId, sourceRunId) }], details: { runId: fork.runId, background: true, forkedFromRunId: sourceRunId }, }; } let invocationTools: ToolDefinition[] | undefined; let invocationToolset: string | undefined; let script: string; if (params.name) { if (params.resumeFromRunId) { throw new Error( "workflow: `name` cannot be combined with `resumeFromRunId` — resume with an edited `script` instead.", ); } const resolved = resolveWorkflowInvocation(params.name, params.args, { storage, cwd }); if (!resolved) { throw new Error( `workflow: no saved or built-in workflow named "${params.name}". Built-in names: ${BUILTIN_WORKFLOW_NAMES.join(", ")}.`, ); } script = normalizeWorkflowScript(resolved.script); invocationTools = resolved.tools; invocationToolset = resolved.toolset; } else { if (!params.script) throw new Error("workflow requires either `script` or `name`"); script = normalizeWorkflowScript(params.script); } const parsed = parseWorkflowScript(script); // Iteration / cached-prefix reuse: resume a prior run with THIS (edited) // script instead of creating a brand-new run. Unchanged agent() calls // replay from the prior run's journal; the first edited/new call and // everything after it re-run live. Always background (the resumed run is // detached and its result is delivered back into the conversation). if (params.resumeFromRunId) { const runId = params.resumeFromRunId; const resumed = await manager.resume(runId, { script, args: params.args }); if (!resumed) { throw new Error(resumeFailureText(manager, runId)); } return { content: [{ type: "text", text: resumedText(parsed.meta.name, runId) }], details: { runId, background: true, resumedFrom: runId }, }; } // checkpoint() reaches the human only on a UI-bearing foreground run; a // background run is detached, so checkpoint() falls back to its headless // default. Map a checkpoint to ctx.ui.confirm (a yes/no gate) when available. const uiCtx = ctx as | { hasUI?: boolean; ui?: { confirm?(title: string, message: string): Promise } } | undefined; const uiConfirm = uiCtx?.hasUI ? uiCtx.ui?.confirm : undefined; const confirm = uiConfirm ? (promptText: string) => uiConfirm.call(uiCtx?.ui, "Workflow checkpoint", promptText) : undefined; // Background execution is the default: return immediately so the turn ends // and the user isn't blocked. The result is delivered back into the // conversation when the run finishes (see installResultDelivery). Only an // explicit `background: false` blocks for the result inline. if (params.background ?? true) { const { runId } = manager.startInBackground(script, params.args, { maxAgents: params.maxAgents, concurrency: params.concurrency, agentRetries: params.agentRetries, agentTimeoutMs: params.agentTimeoutMs, tokenBudget: params.tokenBudget, tools: invocationTools, toolset: invocationToolset, }); return { content: [{ type: "text", text: backgroundStartedText(parsed.meta.name, runId) }], details: { runId, background: true }, }; } // Synchronous execution (blocking) — but routed through the manager so the // run shows up live in the /workflows navigator and the task panel while it // runs, then stays in history afterwards. We still block on the result and // return it inline, so the model gets the full output in the same turn. let snapshot: WorkflowSnapshot = createWorkflowSnapshot(parsed.meta); const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, { key: "workflow", streamToolUpdates: true, maxAgents: 4, showResultPreviews: false, }); let result: WorkflowRunResult; try { result = await manager.runSync(script, params.args, { maxAgents: params.maxAgents, concurrency: params.concurrency, agentRetries: params.agentRetries, agentTimeoutMs: params.agentTimeoutMs, tokenBudget: params.tokenBudget, tools: invocationTools, toolset: invocationToolset, confirm, externalSignal: signal, onProgress(live) { snapshot = recomputeWorkflowSnapshot(live); display.update(snapshot); }, }); } catch (error) { if (signal?.aborted || (error instanceof WorkflowError && error.code === WorkflowErrorCode.WORKFLOW_ABORTED)) { for (const agent of snapshot.agents) { if (agent.status === "running") { agent.status = "skipped"; agent.error = "aborted"; } } snapshot = recomputeWorkflowSnapshot(snapshot); display.complete(snapshot); throw new Error("Workflow was aborted"); } throw error; } if (result.agentCount === 0) { throw new Error( "workflow scripts must call agent() at least once; this workflow declared phases but did not run any subagents", ); } snapshot.result = result.result; snapshot.durationMs = result.durationMs; snapshot = recomputeWorkflowSnapshot(snapshot); display.complete(snapshot); // Format token usage (include cost when the provider reports it) const tokenSegment = fmtTokenSegment(tokenFigures(result.tokenUsage), fmtFull); const tokenInfo = tokenSegment ? `\n\nToken usage: ${tokenSegment}${result.tokenUsage?.cost ? ` (${fmtCost(result.tokenUsage.cost)})` : ""}` : ""; const formattedResult = result.result !== undefined ? `\n\`\`\`json\n${JSON.stringify(result.result, null, 2)}\n\`\`\`` : ""; return { content: [ { type: "text", text: `Workflow **${result.meta.name}** completed with **${result.agentCount}** agent(s).${tokenInfo}\n\n## Result${formattedResult}\n\n${reviseHint(result.runId)}`, }, ], details: { ...snapshot, meta: result.meta, phases: result.phases, logs: result.logs, result: result.result, durationMs: result.durationMs, tokenUsage: result.tokenUsage, runId: result.runId, }, }; }, renderCall(_args, theme) { return new Text(theme.fg("toolTitle", theme.bold("workflow")), 0, 0); }, renderResult(result, { isPartial }, theme) { const snapshot = result.details as WorkflowSnapshot | undefined; if (snapshot?.name) { return new Text(renderWorkflowText(snapshot, !isPartial), 0, 0); } // Fallback: strip markdown syntax so the TUI doesn't display raw asterisks/hashes. // The `content` field is for the LLM (where markdown is preserved), but the TUI // renderer (Text component) shows text literally — so we strip markdown here. const text = result.content?.[0]; const raw = text?.type === "text" ? text.text : theme.fg("muted", "workflow"); const clean = raw .replace(/\*\*/g, "") .replace(/```[a-z]*\n/g, "") .replace(/```/g, "") .replace(/^##+\s*/gm, "") .trim(); return new Text(clean || theme.fg("muted", "workflow"), 0, 0); }, }); } function resolveWorkflowToolDefaults( options: WorkflowToolOptions, cwd: string, ): { agentTimeoutMs: number | null; concurrency?: number; agentRetries: number; executor: WorkflowExecutor } { const settings = loadWorkflowSettings({ cwd }); return { agentTimeoutMs: options.defaultAgentTimeoutMs !== undefined ? options.defaultAgentTimeoutMs : (settings.defaultAgentTimeoutMs ?? null), concurrency: options.defaultConcurrency ?? options.concurrency ?? settings.defaultConcurrency, agentRetries: options.defaultAgentRetries ?? settings.defaultAgentRetries ?? 0, executor: options.defaultExecutor ?? settings.defaultExecutor ?? "pi", }; } /** * The tool result returned when a workflow starts in the background. It both * informs the model and tells it to reassure the user: the run continues on its * own and the conversation will resume automatically when it finishes, so the * user can just wait here (or go do something else). */ export function backgroundStartedText(name: string, runId: string): string { return [ `Workflow "${name}" started in the background.`, `Run ID: ${runId}`, "It keeps running on its own. When it finishes, the result is delivered back", "here and the conversation continues automatically — the user does not need to", "do anything. Tell the user they can simply wait here for it to finish (it will", "resume the conversation by itself), or keep chatting / working on other things", "in the meantime; either way the result will come back to this conversation.", `They can also track or cancel it with /workflows status ${runId} or /workflows stop ${runId}.`, reviseHint(runId), ].join("\n"); } /** * One-line hint telling the model how to branch a completed run into an * immutable revision. The source run stays unchanged; the new run replays the * longest usable journal prefix before executing the remaining suffix live. */ export function reviseHint(runId: string | undefined): string { if (!runId) return ""; return `For a completed-run revision, re-call workflow with forkFromRunId="${runId}" and optionally edited script/args — it creates a new run ID, leaves the source unchanged, replays the longest unchanged journal prefix, then runs the first changed, new, missing, or unusable call and all later calls live.`; } /** * The tool result returned when the model resumes a run with an edited script. * The resumed run is always background, so its result is delivered back later. */ export function resumedText(name: string, runId: string): string { return [ `Workflow "${name}" resumed paused/failed run ${runId} in place with your edited script.`, "The longest unchanged journal prefix replays from cache; the first changed,", "new, missing, or unusable call — and everything after it — runs live.", "It runs in the background under the same run ID; the result is delivered back", "here when it finishes. The user can wait or keep working.", `Track or cancel it with /workflows status ${runId} or /workflows stop ${runId}.`, ].join("\n"); } export function forkedText(name: string, runId: string, sourceRunId: string): string { return [ `Workflow "${name}" forked into revision ${runId} from completed run ${sourceRunId}.`, "The source run remains unchanged. Its longest unchanged journal prefix replays;", "the first changed, new, missing, or unusable call and every later call run live.", "The revision runs in the background and receives a fresh usage/budget ledger.", `Track or cancel it with /workflows status ${runId} or /workflows stop ${runId}.`, ].join("\n"); } /** * Explain why a resumeFromRunId could not be resumed, so the model gets a clear * tool error instead of a silent failure. Inspects live + persisted state to * name the concrete reason (not found / running / completed / stopped). */ export function resumeFailureText(manager: WorkflowManager, runId: string): string { const active = manager.getRun(runId); if (active?.status === "running") { return `Cannot resume workflow run "${runId}": it is still running. Wait for it to pause or fail before using resumeFromRunId. If it completes, create an immutable revision with forkFromRunId="${runId}"; stopping it would abort the run and make it non-resumable.`; } const persisted = manager.getPersistence().load(runId); if (!persisted) { return `Cannot resume workflow run "${runId}": no run with that ID was found. Use the runId from a prior workflow result, or omit resumeFromRunId to start a new run.`; } if (persisted.status === "completed") { return `Cannot resume workflow run "${runId}": completed runs are immutable. Use forkFromRunId="${runId}" to create a new revision ID while replaying its unchanged journal prefix.`; } if (persisted.status === "aborted" || active?.status === "aborted") { return `Cannot resume workflow run "${runId}": it was stopped/aborted and is not resumable. Start a new run instead (omit resumeFromRunId).`; } if (!persisted.script) { return `Cannot resume workflow run "${runId}": it has no persisted script to resume. Start a new run instead (omit resumeFromRunId).`; } return `Cannot resume workflow run "${runId}": it is not currently resumable (it may be busy under another process). Try again shortly, or start a new run.`; } function normalizeWorkflowToolArgs(args: unknown): WorkflowToolInput { if (!args || typeof args !== "object") throw new Error("workflow requires an object argument with a `script` string or a `name`"); const value = args as Record; if (typeof value.forkFromRunId === "string" && value.forkFromRunId.trim()) { if (value.script !== undefined && typeof value.script !== "string") { throw new Error("workflow's `script` must be a string when provided alongside `forkFromRunId`"); } return { ...value, forkFromRunId: value.forkFromRunId.trim(), script: typeof value.script === "string" ? normalizeWorkflowScript(value.script) : undefined, } as WorkflowToolInput; } // `name` resolves a saved/built-in workflow at execute() time, so `script` is // optional here — but if `script` is present at all it must still be a // string (same requirement as the script-only path below), so a caller // passing a malformed `script` alongside `name` gets a clear error instead // of it being silently dropped. if (typeof value.name === "string" && value.name.trim()) { if (value.script !== undefined && typeof value.script !== "string") { throw new Error("workflow's `script` must be a string when provided alongside `name`"); } return { ...value, name: value.name.trim(), script: typeof value.script === "string" ? normalizeWorkflowScript(value.script) : undefined, } as WorkflowToolInput; } if (typeof value.script !== "string") throw new Error("workflow requires either `script` or `name` to be a string"); return { ...value, script: normalizeWorkflowScript(value.script) } as WorkflowToolInput; } function normalizeWorkflowScript(script: string): string { let text = script.trim(); const fence = text.match(/^```(?:js|javascript)?\s*\n([\s\S]*?)\n```$/i); if (fence) text = fence[1].trim(); return text; } function _isAbortError(error: unknown): boolean { if (!(error instanceof Error)) return false; return /\babort(?:ed)?\b/i.test(error.message); }