import * as fs from "node:fs"; import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { WORKFLOW_DESCRIPTION } from "./description.js"; import type { WorkflowManager } from "./manager.js"; const workflowToolSchema = Type.Object({ script: Type.Optional( Type.String({ description: "Raw JavaScript workflow script (no Markdown fences). First statement must be `export const meta = { name, description, phases? }`. Body uses agent()/pipeline()/parallel()/phase()/log()/workflow(), and the globals args + budget. Must call agent() at least once. Provide this OR scriptPath OR name.", }), ), scriptPath: Type.Optional( Type.String({ description: "Path to a workflow script file to run instead of `script`. Every invocation persists its script and returns the path; pass it back here (with resumeFromRunId, to resume) after editing.", }), ), name: Type.Optional( Type.String({ description: "Name of a saved/named workflow to run instead of an inline script." }), ), args: Type.Optional( Type.Any({ description: "JSON value exposed to the script as the global `args`, verbatim. Pass arrays/objects as actual JSON values, NOT a JSON-encoded string.", }), ), resumeFromRunId: Type.Optional( Type.String({ description: "Run ID of a prior invocation to resume from. Completed agent() calls with unchanged (prompt, opts) replay from the journal instantly; the first edited/new call and everything after it run live. Same script + same args → 100% cache hit.", }), ), // Accepted-but-ignored (set these inside meta), so the model can pass them harmlessly. title: Type.Optional(Type.String({ description: "Ignored — set the workflow title in the script's meta block." })), description: Type.Optional( Type.String({ description: "Ignored — set the workflow description in the script's meta block." }), ), }); export interface WorkflowToolOptions { cwd?: string; manager: WorkflowManager; loadSavedWorkflow?: (name: string) => string | undefined; } export function createWorkflowTool(options: WorkflowToolOptions): ToolDefinition { const { manager } = options; return defineTool({ name: "workflow", label: "Workflow", description: WORKFLOW_DESCRIPTION, promptSnippet: "Execute a deterministic multi-agent workflow. Required script header: export const meta = { name, description, phases? }. Body uses agent()/pipeline()/parallel()/phase()/log(). Background by default; returns a runId. Resume with {scriptPath, resumeFromRunId}.", promptGuidelines: [ "Only call workflow when the user explicitly asked for a workflow / fan-out / multi-agent orchestration in their own words, or invoked a skill/command that says to. A task merely benefiting from parallelism does NOT qualify.", "Pass the script inline in `script` as raw JavaScript — no Markdown fences, no prose around it. The first statement must be `export const meta = { name, description, phases? }`, a pure literal.", "Plain JavaScript only (no TypeScript). The body is async — use await directly. Date.now()/Math.random()/argless new Date() throw; pass timestamps via args and vary randomness by index.", "DEFAULT TO pipeline() for multi-stage work; use parallel() (a barrier) only when a stage genuinely needs ALL prior results together. parallel() takes thunks, not promises: parallel(items.map(x => () => agent(...))).", "Every agent() should carry a short unique opts.label. For machine-readable output pass a plain JSON Schema via opts.schema and agent() returns the validated object.", "Runs are background: the tool returns a runId immediately and the result is delivered back into the conversation when it finishes. Resume after an edit with { scriptPath, resumeFromRunId }.", ], parameters: workflowToolSchema, async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const script = resolveScript(params, options.loadSavedWorkflow); const { runId, scriptPath } = manager.startInBackground(script, params.args, { resumeFromRunId: params.resumeFromRunId, }); const text = [ `Workflow started in the background. Run ID: ${runId}`, `Script persisted at: ${scriptPath}`, "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.", `To iterate, edit the script file and re-invoke with { scriptPath: "${scriptPath}", resumeFromRunId: "${runId}" } — unchanged agent() calls replay from the journal.`, "Track or stop it with /workflows.", ].join("\n"); return { content: [{ type: "text", text }], details: { runId, scriptPath, background: true }, }; }, renderCall(_args, theme) { return new Text(theme.fg("toolTitle", theme.bold("workflow")), 0, 0); }, renderResult(result, _info, theme) { const text = result.content?.[0]; const raw = text?.type === "text" ? text.text : "workflow started"; return new Text(theme.fg("muted", raw), 0, 0); }, }); } function resolveScript( params: { script?: string; scriptPath?: string; name?: string }, loadSavedWorkflow?: (name: string) => string | undefined, ): string { if (typeof params.script === "string" && params.script.trim()) { const text = params.script.trim(); const fence = text.match(/^```(?:js|javascript)?\s*\n([\s\S]*?)\n```$/i); return fence ? fence[1].trim() : text; } if (typeof params.scriptPath === "string" && params.scriptPath.trim()) { try { return fs.readFileSync(params.scriptPath, "utf8"); } catch (err) { throw new Error(`could not read scriptPath "${params.scriptPath}": ${(err as Error).message}`); } } if (typeof params.name === "string" && params.name.trim()) { const saved = loadSavedWorkflow?.(params.name); if (!saved) throw new Error(`unknown saved workflow "${params.name}"`); return saved; } throw new Error("workflow requires one of: `script`, `scriptPath`, or `name`."); }