@quintinshaw/pi-dynamic-workflows 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -0
- package/dist/adversarial-review.d.ts +20 -0
- package/dist/adversarial-review.js +87 -0
- package/dist/agent.d.ts +29 -0
- package/dist/agent.js +90 -0
- package/dist/auto-workflow.d.ts +26 -0
- package/dist/auto-workflow.js +121 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +17 -0
- package/dist/deep-research.d.ts +22 -0
- package/dist/deep-research.js +110 -0
- package/dist/display.d.ts +62 -0
- package/dist/display.js +163 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.js +63 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +15 -0
- package/dist/logger.d.ts +21 -0
- package/dist/logger.js +67 -0
- package/dist/model-routing.d.ts +33 -0
- package/dist/model-routing.js +57 -0
- package/dist/run-persistence.d.ts +53 -0
- package/dist/run-persistence.js +78 -0
- package/dist/structured-output.d.ts +19 -0
- package/dist/structured-output.js +30 -0
- package/dist/workflow-manager.d.ts +74 -0
- package/dist/workflow-manager.js +241 -0
- package/dist/workflow-saved.d.ts +35 -0
- package/dist/workflow-saved.js +91 -0
- package/dist/workflow-tool.d.ts +22 -0
- package/dist/workflow-tool.js +216 -0
- package/dist/workflow.d.ts +75 -0
- package/dist/workflow.js +364 -0
- package/extensions/workflow.ts +14 -0
- package/package.json +70 -0
- package/src/adversarial-review.ts +107 -0
- package/src/agent.ts +135 -0
- package/src/auto-workflow.ts +146 -0
- package/src/config.ts +24 -0
- package/src/deep-research.ts +128 -0
- package/src/display.ts +236 -0
- package/src/errors.ts +85 -0
- package/src/index.ts +55 -0
- package/src/logger.ts +89 -0
- package/src/model-routing.ts +80 -0
- package/src/run-persistence.ts +132 -0
- package/src/structured-output.ts +47 -0
- package/src/workflow-manager.ts +294 -0
- package/src/workflow-saved.ts +131 -0
- package/src/workflow-tool.ts +254 -0
- package/src/workflow.ts +492 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { createToolUpdateWorkflowDisplay, createWorkflowSnapshot, preview, recomputeWorkflowSnapshot, renderWorkflowText, } from "./display.js";
|
|
5
|
+
import { WorkflowError, WorkflowErrorCode } from "./errors.js";
|
|
6
|
+
import { parseWorkflowScript, runWorkflow } from "./workflow.js";
|
|
7
|
+
import { WorkflowManager } from "./workflow-manager.js";
|
|
8
|
+
import { createWorkflowStorage } from "./workflow-saved.js";
|
|
9
|
+
const workflowToolSchema = Type.Object({
|
|
10
|
+
script: Type.String({
|
|
11
|
+
description: [
|
|
12
|
+
"Required raw JavaScript workflow script, with no Markdown fences.",
|
|
13
|
+
"First statement: export const meta = { name: 'short_snake_case', description: 'non-empty description', phases: [{ title: 'Phase' }] }",
|
|
14
|
+
"Use phase('Name'), agent(prompt, opts), parallel(arrayOfFunctions), pipeline(items, ...stages), log(message), args, and budget. The workflow must call agent() at least once.",
|
|
15
|
+
"parallel() requires functions, not promises: await parallel(items.map(item => () => agent(...))).",
|
|
16
|
+
].join(" "),
|
|
17
|
+
}),
|
|
18
|
+
args: Type.Optional(Type.Any({ description: "Optional JSON value exposed to the workflow script as global `args`." })),
|
|
19
|
+
background: Type.Optional(Type.Boolean({
|
|
20
|
+
description: "Run the workflow in the background. Default: false. When true, returns immediately with a run ID.",
|
|
21
|
+
})),
|
|
22
|
+
maxAgents: Type.Optional(Type.Number({
|
|
23
|
+
description: "Maximum number of agents allowed in this run. Default: 1000.",
|
|
24
|
+
})),
|
|
25
|
+
agentTimeoutMs: Type.Optional(Type.Number({
|
|
26
|
+
description: "Timeout per agent in milliseconds. Default: 300000 (5 minutes).",
|
|
27
|
+
})),
|
|
28
|
+
});
|
|
29
|
+
export function createWorkflowTool(options = {}) {
|
|
30
|
+
const manager = new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
|
|
31
|
+
const _storage = createWorkflowStorage(options.cwd ?? process.cwd());
|
|
32
|
+
return defineTool({
|
|
33
|
+
name: "workflow",
|
|
34
|
+
label: "Workflow",
|
|
35
|
+
description: [
|
|
36
|
+
"Execute a deterministic JavaScript workflow that orchestrates multiple subagents with agent(), parallel(), and pipeline().",
|
|
37
|
+
"script is required raw JavaScript. It must start with export const meta = { name, description, phases? } and must call agent() at least once.",
|
|
38
|
+
].join(" "),
|
|
39
|
+
promptSnippet: "Run a deterministic JavaScript workflow. Required script header: export const meta = { name: 'short_snake_case', description: 'non-empty description', phases: [{ title: 'Phase' }] }.",
|
|
40
|
+
promptGuidelines: [
|
|
41
|
+
"Use workflow only when the user explicitly asks for a workflow, workflows, fan-out, or multi-agent orchestration.",
|
|
42
|
+
"For workflow, always pass one raw JavaScript string in the required script parameter; do not include Markdown fences or prose around the script.",
|
|
43
|
+
"For workflow, the script's first statement must be `export const meta = { name: 'short_snake_case', description: 'non-empty human description', phases: [{ title: 'Phase name' }] }`; meta.name and meta.description are required non-empty strings.",
|
|
44
|
+
"For workflow, write plain JavaScript after the meta export. Do not use TypeScript syntax, imports, require(), fs, Date.now(), Math.random(), or new Date().",
|
|
45
|
+
"For workflow, available globals are agent(prompt, opts), parallel(thunks), pipeline(items, ...stages), phase(title), log(message), args, cwd, process.cwd(), and budget. Every workflow must call agent() at least once; do not use workflow only to declare phases or return a static object.",
|
|
46
|
+
"For workflow, prefer it for decomposable work: repository inspection, independent research/checks, multi-perspective review, or fan-out/fan-in synthesis. Do not use it for a single quick file read/edit or when ordinary tools are enough.",
|
|
47
|
+
"For workflow, parallel() takes functions, not promises: use `await parallel(items.map(item => () => agent('...', { label: '...' })))`, never `await parallel(items.map(item => agent(...)))`. Results are returned in input order.",
|
|
48
|
+
"For workflow, pipeline(items, ...stages) runs each item through stages sequentially, while different items may run concurrently. Each stage receives (previousValue, originalItem, index).",
|
|
49
|
+
"For workflow, every agent() call should include a unique short label option, 2-5 words, such as { label: 'repo inventory' } or { label: 'source modules' }; unique labels make live status and error reporting readable.",
|
|
50
|
+
"For workflow, failed agent(), parallel(), or pipeline() branches return null and log the failure unless the workflow is aborted. Check for nulls before synthesizing conclusions.",
|
|
51
|
+
"For workflow, include a final synthesis/assertion agent when combining multiple subagent results; return a compact JSON-serializable value with ok/verdict plus the important outputs.",
|
|
52
|
+
"For workflow, if agent() needs machine-readable output, pass a plain JSON Schema via opts.schema; agent() will return the validated object. Use JSON Schema syntax, not TypeScript or TypeBox constructors.",
|
|
53
|
+
"For workflow, do not assume the parent assistant has repository code context inside subagents; include enough task context and relevant paths in each agent prompt.",
|
|
54
|
+
"For workflow, set background: true to run asynchronously. The workflow will return immediately with a run ID that can be used to check status later.",
|
|
55
|
+
],
|
|
56
|
+
parameters: workflowToolSchema,
|
|
57
|
+
prepareArguments(args) {
|
|
58
|
+
return normalizeWorkflowToolArgs(args);
|
|
59
|
+
},
|
|
60
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
61
|
+
const script = normalizeWorkflowScript(params.script);
|
|
62
|
+
const parsed = parseWorkflowScript(script);
|
|
63
|
+
// Background execution
|
|
64
|
+
if (params.background) {
|
|
65
|
+
const { runId } = manager.startInBackground(script, params.args);
|
|
66
|
+
return {
|
|
67
|
+
content: [
|
|
68
|
+
{
|
|
69
|
+
type: "text",
|
|
70
|
+
text: [
|
|
71
|
+
`Workflow "${parsed.meta.name}" started in background.`,
|
|
72
|
+
`Run ID: ${runId}`,
|
|
73
|
+
`Use /workflow status ${runId} to check progress.`,
|
|
74
|
+
`Use /workflow stop ${runId} to cancel.`,
|
|
75
|
+
].join("\n"),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
details: { runId, background: true },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// Synchronous execution (blocking)
|
|
82
|
+
let snapshot = createWorkflowSnapshot(parsed.meta);
|
|
83
|
+
const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, {
|
|
84
|
+
key: "workflow",
|
|
85
|
+
streamToolUpdates: true,
|
|
86
|
+
maxAgents: 4,
|
|
87
|
+
maxLogs: 1,
|
|
88
|
+
showResultPreviews: false,
|
|
89
|
+
});
|
|
90
|
+
const update = () => {
|
|
91
|
+
snapshot = recomputeWorkflowSnapshot(snapshot);
|
|
92
|
+
display.update(snapshot);
|
|
93
|
+
};
|
|
94
|
+
let result;
|
|
95
|
+
try {
|
|
96
|
+
result = await runWorkflow(script, {
|
|
97
|
+
cwd: options.cwd ?? ctx.cwd,
|
|
98
|
+
args: params.args,
|
|
99
|
+
signal,
|
|
100
|
+
concurrency: options.concurrency,
|
|
101
|
+
maxAgents: params.maxAgents,
|
|
102
|
+
agentTimeoutMs: params.agentTimeoutMs,
|
|
103
|
+
onLog(message) {
|
|
104
|
+
snapshot.logs.push(message);
|
|
105
|
+
update();
|
|
106
|
+
},
|
|
107
|
+
onPhase(title) {
|
|
108
|
+
snapshot.currentPhase = title;
|
|
109
|
+
if (!snapshot.phases.includes(title))
|
|
110
|
+
snapshot.phases.push(title);
|
|
111
|
+
update();
|
|
112
|
+
},
|
|
113
|
+
onAgentStart(event) {
|
|
114
|
+
if (signal?.aborted)
|
|
115
|
+
throw new Error("Workflow was aborted");
|
|
116
|
+
snapshot.agents.push({
|
|
117
|
+
id: snapshot.agents.length + 1,
|
|
118
|
+
label: event.label,
|
|
119
|
+
phase: event.phase,
|
|
120
|
+
prompt: event.prompt,
|
|
121
|
+
status: "running",
|
|
122
|
+
});
|
|
123
|
+
update();
|
|
124
|
+
},
|
|
125
|
+
onAgentEnd(event) {
|
|
126
|
+
const agent = [...snapshot.agents]
|
|
127
|
+
.reverse()
|
|
128
|
+
.find((item) => item.label === event.label && item.status === "running");
|
|
129
|
+
if (agent) {
|
|
130
|
+
agent.status = event.result === null ? "error" : "done";
|
|
131
|
+
agent.resultPreview = preview(event.result);
|
|
132
|
+
agent.tokens = event.tokens;
|
|
133
|
+
}
|
|
134
|
+
update();
|
|
135
|
+
},
|
|
136
|
+
onTokenUsage(usage) {
|
|
137
|
+
snapshot.tokenUsage = usage;
|
|
138
|
+
update();
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (signal?.aborted || (error instanceof WorkflowError && error.code === WorkflowErrorCode.WORKFLOW_ABORTED)) {
|
|
144
|
+
for (const agent of snapshot.agents) {
|
|
145
|
+
if (agent.status === "running") {
|
|
146
|
+
agent.status = "skipped";
|
|
147
|
+
agent.error = "aborted";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
snapshot = recomputeWorkflowSnapshot(snapshot);
|
|
151
|
+
display.complete(snapshot);
|
|
152
|
+
throw new Error("Workflow was aborted");
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
if (result.agentCount === 0) {
|
|
157
|
+
throw new Error("workflow scripts must call agent() at least once; this workflow declared phases but did not run any subagents");
|
|
158
|
+
}
|
|
159
|
+
snapshot.result = result.result;
|
|
160
|
+
snapshot.durationMs = result.durationMs;
|
|
161
|
+
snapshot = recomputeWorkflowSnapshot(snapshot);
|
|
162
|
+
display.complete(snapshot);
|
|
163
|
+
// Format token usage
|
|
164
|
+
const tokenInfo = result.tokenUsage ? `\n\nToken usage: ${result.tokenUsage.total.toLocaleString()} tokens` : "";
|
|
165
|
+
return {
|
|
166
|
+
content: [
|
|
167
|
+
{
|
|
168
|
+
type: "text",
|
|
169
|
+
text: `Workflow ${result.meta.name} completed with ${result.agentCount} agent(s).\n\nResult:\n${JSON.stringify(result.result, null, 2)}${tokenInfo}`,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
details: {
|
|
173
|
+
...snapshot,
|
|
174
|
+
meta: result.meta,
|
|
175
|
+
phases: result.phases,
|
|
176
|
+
logs: result.logs,
|
|
177
|
+
result: result.result,
|
|
178
|
+
durationMs: result.durationMs,
|
|
179
|
+
tokenUsage: result.tokenUsage,
|
|
180
|
+
runId: result.runId,
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
renderCall(_args, theme) {
|
|
185
|
+
return new Text(theme.fg("toolTitle", theme.bold("workflow")), 0, 0);
|
|
186
|
+
},
|
|
187
|
+
renderResult(result, { isPartial }, theme) {
|
|
188
|
+
const snapshot = result.details;
|
|
189
|
+
if (snapshot?.name) {
|
|
190
|
+
return new Text(renderWorkflowText(snapshot, !isPartial), 0, 0);
|
|
191
|
+
}
|
|
192
|
+
const text = result.content?.[0];
|
|
193
|
+
return new Text(text?.type === "text" ? text.text : theme.fg("muted", "workflow"), 0, 0);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function normalizeWorkflowToolArgs(args) {
|
|
198
|
+
if (!args || typeof args !== "object")
|
|
199
|
+
throw new Error("workflow requires an object argument with a script string");
|
|
200
|
+
const value = args;
|
|
201
|
+
if (typeof value.script !== "string")
|
|
202
|
+
throw new Error("workflow requires `script` to be a string");
|
|
203
|
+
return { ...value, script: normalizeWorkflowScript(value.script) };
|
|
204
|
+
}
|
|
205
|
+
function normalizeWorkflowScript(script) {
|
|
206
|
+
let text = script.trim();
|
|
207
|
+
const fence = text.match(/^```(?:js|javascript)?\s*\n([\s\S]*?)\n```$/i);
|
|
208
|
+
if (fence)
|
|
209
|
+
text = fence[1].trim();
|
|
210
|
+
return text;
|
|
211
|
+
}
|
|
212
|
+
function _isAbortError(error) {
|
|
213
|
+
if (!(error instanceof Error))
|
|
214
|
+
return false;
|
|
215
|
+
return /\babort(?:ed)?\b/i.test(error.message);
|
|
216
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { TSchema } from "typebox";
|
|
2
|
+
import { WorkflowAgent, type WorkflowAgentOptions } from "./agent.js";
|
|
3
|
+
export interface WorkflowMetaPhase {
|
|
4
|
+
title: string;
|
|
5
|
+
detail?: string;
|
|
6
|
+
model?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface WorkflowMeta {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
whenToUse?: string;
|
|
12
|
+
phases?: WorkflowMetaPhase[];
|
|
13
|
+
}
|
|
14
|
+
export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
15
|
+
args?: unknown;
|
|
16
|
+
agent?: Pick<WorkflowAgent, "run">;
|
|
17
|
+
concurrency?: number;
|
|
18
|
+
tokenBudget?: number | null;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
/** Maximum number of agents allowed in this run. Default: 1000 */
|
|
21
|
+
maxAgents?: number;
|
|
22
|
+
/** Timeout per agent in milliseconds. Default: 5 minutes */
|
|
23
|
+
agentTimeoutMs?: number;
|
|
24
|
+
/** Whether to persist logs to disk. Default: true */
|
|
25
|
+
persistLogs?: boolean;
|
|
26
|
+
/** Run ID for persistence. Auto-generated if not provided. */
|
|
27
|
+
runId?: string;
|
|
28
|
+
onLog?: (message: string) => void;
|
|
29
|
+
onPhase?: (title: string) => void;
|
|
30
|
+
onAgentStart?: (event: {
|
|
31
|
+
label: string;
|
|
32
|
+
phase?: string;
|
|
33
|
+
prompt: string;
|
|
34
|
+
}) => void;
|
|
35
|
+
onAgentEnd?: (event: {
|
|
36
|
+
label: string;
|
|
37
|
+
phase?: string;
|
|
38
|
+
result: unknown;
|
|
39
|
+
tokens?: number;
|
|
40
|
+
}) => void;
|
|
41
|
+
onTokenUsage?: (usage: {
|
|
42
|
+
input: number;
|
|
43
|
+
output: number;
|
|
44
|
+
total: number;
|
|
45
|
+
}) => void;
|
|
46
|
+
}
|
|
47
|
+
export interface WorkflowRunResult<T = unknown> {
|
|
48
|
+
meta: WorkflowMeta;
|
|
49
|
+
result: T;
|
|
50
|
+
logs: string[];
|
|
51
|
+
phases: string[];
|
|
52
|
+
agentCount: number;
|
|
53
|
+
durationMs: number;
|
|
54
|
+
runId?: string;
|
|
55
|
+
tokenUsage?: {
|
|
56
|
+
input: number;
|
|
57
|
+
output: number;
|
|
58
|
+
total: number;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export interface AgentOptions<TSchemaDef extends TSchema | undefined = TSchema | undefined> {
|
|
62
|
+
label?: string;
|
|
63
|
+
phase?: string;
|
|
64
|
+
schema?: TSchemaDef;
|
|
65
|
+
model?: string;
|
|
66
|
+
isolation?: "worktree";
|
|
67
|
+
agentType?: string;
|
|
68
|
+
/** Override timeout for this specific agent. */
|
|
69
|
+
timeoutMs?: number;
|
|
70
|
+
}
|
|
71
|
+
export declare function runWorkflow<T = unknown>(script: string, options?: WorkflowRunOptions): Promise<WorkflowRunResult<T>>;
|
|
72
|
+
export declare function parseWorkflowScript(script: string): {
|
|
73
|
+
meta: WorkflowMeta;
|
|
74
|
+
body: string;
|
|
75
|
+
};
|
package/dist/workflow.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import vm from "node:vm";
|
|
2
|
+
import { parse } from "acorn";
|
|
3
|
+
import { WorkflowAgent } from "./agent.js";
|
|
4
|
+
import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from "./config.js";
|
|
5
|
+
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
6
|
+
import { createWorkflowLogger } from "./logger.js";
|
|
7
|
+
const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
|
|
8
|
+
export async function runWorkflow(script, options = {}) {
|
|
9
|
+
const started = Date.now();
|
|
10
|
+
const { meta, body } = parseWorkflowScript(script);
|
|
11
|
+
const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
|
|
12
|
+
const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
|
|
13
|
+
const runId = options.runId ?? `run-${started.toString(36)}`;
|
|
14
|
+
// Initialize logger
|
|
15
|
+
const logger = createWorkflowLogger({
|
|
16
|
+
runId,
|
|
17
|
+
cwd: options.cwd ?? process.cwd(),
|
|
18
|
+
persist: options.persistLogs ?? true,
|
|
19
|
+
onLog: options.onLog,
|
|
20
|
+
});
|
|
21
|
+
const state = {
|
|
22
|
+
logs: [],
|
|
23
|
+
phases: [],
|
|
24
|
+
agentCount: 0,
|
|
25
|
+
spent: 0,
|
|
26
|
+
tokenUsage: { input: 0, output: 0, total: 0 },
|
|
27
|
+
};
|
|
28
|
+
const agentRunner = options.agent ?? new WorkflowAgent(options);
|
|
29
|
+
const concurrency = Math.max(1, Math.min(options.concurrency ?? Math.max(1, (globalThis.navigator?.hardwareConcurrency ?? 8) - 2), MAX_CONCURRENCY));
|
|
30
|
+
const limiter = createLimiter(concurrency);
|
|
31
|
+
const log = (message) => {
|
|
32
|
+
const text = String(message);
|
|
33
|
+
state.logs.push(text);
|
|
34
|
+
logger.log(text);
|
|
35
|
+
};
|
|
36
|
+
const phase = (title) => {
|
|
37
|
+
state.currentPhase = title;
|
|
38
|
+
if (!state.phases.includes(title))
|
|
39
|
+
state.phases.push(title);
|
|
40
|
+
options.onPhase?.(title);
|
|
41
|
+
};
|
|
42
|
+
const budget = Object.freeze({
|
|
43
|
+
total: options.tokenBudget ?? null,
|
|
44
|
+
spent: () => state.spent,
|
|
45
|
+
remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget - state.spent)),
|
|
46
|
+
});
|
|
47
|
+
const throwIfAborted = () => {
|
|
48
|
+
if (options.signal?.aborted) {
|
|
49
|
+
throw new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const agent = async (prompt, agentOptions = {}) => {
|
|
53
|
+
throwIfAborted();
|
|
54
|
+
// Check agent limit
|
|
55
|
+
if (state.agentCount >= maxAgents) {
|
|
56
|
+
throw new WorkflowError(`Agent limit exceeded (${maxAgents}). Use maxAgents option to increase the limit.`, WorkflowErrorCode.AGENT_LIMIT_EXCEEDED, { recoverable: false });
|
|
57
|
+
}
|
|
58
|
+
if (budget.total !== null && budget.remaining() <= 0) {
|
|
59
|
+
throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
|
|
60
|
+
recoverable: false,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const assignedPhase = agentOptions.phase ?? state.currentPhase;
|
|
64
|
+
const requestedLabel = agentOptions.label?.trim();
|
|
65
|
+
return limiter(async () => {
|
|
66
|
+
state.agentCount++;
|
|
67
|
+
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
68
|
+
const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
|
|
69
|
+
options.onAgentStart?.({ label, phase: assignedPhase, prompt });
|
|
70
|
+
try {
|
|
71
|
+
throwIfAborted();
|
|
72
|
+
// Run agent with timeout
|
|
73
|
+
const result = await withTimeout(agentRunner.run(prompt, {
|
|
74
|
+
label,
|
|
75
|
+
schema: agentOptions.schema,
|
|
76
|
+
signal: options.signal,
|
|
77
|
+
instructions: buildAgentInstructions(assignedPhase, agentOptions),
|
|
78
|
+
}), timeout, `Agent "${label}" timed out after ${timeout}ms`);
|
|
79
|
+
throwIfAborted();
|
|
80
|
+
// Estimate token usage
|
|
81
|
+
const tokens = estimateTokens(result) + estimateTokens(prompt);
|
|
82
|
+
state.spent += tokens;
|
|
83
|
+
state.tokenUsage.total += tokens;
|
|
84
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (options.signal?.aborted)
|
|
89
|
+
throw error;
|
|
90
|
+
const workflowError = wrapError(error, { agentLabel: label });
|
|
91
|
+
logger.error(`agent ${label} failed: ${workflowError.message}`);
|
|
92
|
+
const errorTokens = estimateTokens(prompt);
|
|
93
|
+
state.tokenUsage.total += errorTokens;
|
|
94
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens: errorTokens });
|
|
95
|
+
// Return null for recoverable errors
|
|
96
|
+
if (workflowError.recoverable) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
throw workflowError;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
const parallel = async (thunks) => {
|
|
104
|
+
throwIfAborted();
|
|
105
|
+
if (!Array.isArray(thunks))
|
|
106
|
+
throw new TypeError("parallel() expects an array of functions");
|
|
107
|
+
if (thunks.some((thunk) => typeof thunk !== "function")) {
|
|
108
|
+
throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
|
|
109
|
+
}
|
|
110
|
+
return Promise.all(thunks.map(async (thunk, index) => {
|
|
111
|
+
try {
|
|
112
|
+
return await thunk();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (options.signal?.aborted)
|
|
116
|
+
throw error;
|
|
117
|
+
const workflowError = wrapError(error);
|
|
118
|
+
log(`parallel[${index}] failed: ${workflowError.message}`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}));
|
|
122
|
+
};
|
|
123
|
+
const pipeline = async (items, ...stages) => {
|
|
124
|
+
throwIfAborted();
|
|
125
|
+
if (!Array.isArray(items))
|
|
126
|
+
throw new TypeError("pipeline() expects an array as the first argument");
|
|
127
|
+
if (stages.some((stage) => typeof stage !== "function")) {
|
|
128
|
+
throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
|
|
129
|
+
}
|
|
130
|
+
return Promise.all(items.map(async (item, index) => {
|
|
131
|
+
let value = item;
|
|
132
|
+
for (const stage of stages) {
|
|
133
|
+
try {
|
|
134
|
+
throwIfAborted();
|
|
135
|
+
value = await stage(value, item, index);
|
|
136
|
+
throwIfAborted();
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (options.signal?.aborted)
|
|
140
|
+
throw error;
|
|
141
|
+
const workflowError = wrapError(error);
|
|
142
|
+
log(`pipeline[${index}] failed: ${workflowError.message}`);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}));
|
|
148
|
+
};
|
|
149
|
+
const context = vm.createContext({
|
|
150
|
+
agent,
|
|
151
|
+
parallel,
|
|
152
|
+
pipeline,
|
|
153
|
+
log,
|
|
154
|
+
phase,
|
|
155
|
+
args: options.args,
|
|
156
|
+
cwd: options.cwd ?? process.cwd(),
|
|
157
|
+
process: Object.freeze({ cwd: () => options.cwd ?? process.cwd() }),
|
|
158
|
+
budget,
|
|
159
|
+
console: {
|
|
160
|
+
log,
|
|
161
|
+
info: log,
|
|
162
|
+
warn: (m) => log(`[warn] ${String(m)}`),
|
|
163
|
+
error: (m) => log(`[error] ${String(m)}`),
|
|
164
|
+
},
|
|
165
|
+
JSON,
|
|
166
|
+
Math,
|
|
167
|
+
Array,
|
|
168
|
+
Object,
|
|
169
|
+
String,
|
|
170
|
+
Number,
|
|
171
|
+
Boolean,
|
|
172
|
+
Set,
|
|
173
|
+
Map,
|
|
174
|
+
Promise,
|
|
175
|
+
});
|
|
176
|
+
const wrapped = `(async () => {\n${body}\n})()`;
|
|
177
|
+
const result = await new vm.Script(wrapped, { filename: `${meta.name || "workflow"}.js` }).runInContext(context);
|
|
178
|
+
// Persist logs
|
|
179
|
+
const logFile = logger.persist();
|
|
180
|
+
if (logFile) {
|
|
181
|
+
log(`Logs persisted to ${logFile}`);
|
|
182
|
+
}
|
|
183
|
+
// Emit final token usage
|
|
184
|
+
options.onTokenUsage?.(state.tokenUsage);
|
|
185
|
+
return {
|
|
186
|
+
meta,
|
|
187
|
+
result: result,
|
|
188
|
+
logs: state.logs,
|
|
189
|
+
phases: state.phases,
|
|
190
|
+
agentCount: state.agentCount,
|
|
191
|
+
durationMs: Date.now() - started,
|
|
192
|
+
runId,
|
|
193
|
+
tokenUsage: state.tokenUsage,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
export function parseWorkflowScript(script) {
|
|
197
|
+
if (DETERMINISM_BLOCKLIST.test(script)) {
|
|
198
|
+
throw new WorkflowError("Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false });
|
|
199
|
+
}
|
|
200
|
+
const ast = parse(script, {
|
|
201
|
+
ecmaVersion: "latest",
|
|
202
|
+
sourceType: "module",
|
|
203
|
+
allowAwaitOutsideFunction: true,
|
|
204
|
+
allowReturnOutsideFunction: true,
|
|
205
|
+
ranges: false,
|
|
206
|
+
});
|
|
207
|
+
const first = ast.body?.[0];
|
|
208
|
+
if (first?.type !== "ExportNamedDeclaration") {
|
|
209
|
+
throw new WorkflowError("`export const meta = { name, description, phases }` must be the first statement in the script", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false });
|
|
210
|
+
}
|
|
211
|
+
const declaration = first.declaration;
|
|
212
|
+
if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") {
|
|
213
|
+
throw new WorkflowError("meta export must be `export const meta = ...`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
214
|
+
recoverable: false,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (declaration.declarations.length !== 1) {
|
|
218
|
+
throw new WorkflowError("meta export must declare only `meta`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
219
|
+
recoverable: false,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const declarator = declaration.declarations[0];
|
|
223
|
+
if (declarator.id?.type !== "Identifier" || declarator.id.name !== "meta") {
|
|
224
|
+
throw new WorkflowError("meta export must declare `meta`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
225
|
+
recoverable: false,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (!declarator.init)
|
|
229
|
+
throw new WorkflowError("meta must have a literal value", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
230
|
+
recoverable: false,
|
|
231
|
+
});
|
|
232
|
+
const meta = evaluateLiteral(declarator.init, "meta");
|
|
233
|
+
validateMeta(meta);
|
|
234
|
+
return {
|
|
235
|
+
meta,
|
|
236
|
+
body: script.slice(0, first.start) + script.slice(first.end),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function evaluateLiteral(node, path) {
|
|
240
|
+
switch (node.type) {
|
|
241
|
+
case "ObjectExpression": {
|
|
242
|
+
const out = {};
|
|
243
|
+
for (const prop of node.properties) {
|
|
244
|
+
if (prop.type === "SpreadElement")
|
|
245
|
+
throw new Error(`spread not allowed in ${path}`);
|
|
246
|
+
if (prop.type !== "Property")
|
|
247
|
+
throw new Error(`only plain properties allowed in ${path}`);
|
|
248
|
+
if (prop.computed)
|
|
249
|
+
throw new Error(`computed keys not allowed in ${path}`);
|
|
250
|
+
if (prop.kind !== "init" || prop.method)
|
|
251
|
+
throw new Error(`methods/accessors not allowed in ${path}`);
|
|
252
|
+
const key = propertyKey(prop.key, path);
|
|
253
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
254
|
+
throw new Error(`reserved key name not allowed in ${path}: ${key}`);
|
|
255
|
+
}
|
|
256
|
+
out[key] = evaluateLiteral(prop.value, `${path}.${key}`);
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
case "ArrayExpression":
|
|
261
|
+
return node.elements.map((element, index) => {
|
|
262
|
+
if (!element)
|
|
263
|
+
throw new Error(`sparse arrays not allowed in ${path}`);
|
|
264
|
+
if (element.type === "SpreadElement")
|
|
265
|
+
throw new Error(`spread not allowed in ${path}`);
|
|
266
|
+
return evaluateLiteral(element, `${path}[${index}]`);
|
|
267
|
+
});
|
|
268
|
+
case "Literal":
|
|
269
|
+
return node.value;
|
|
270
|
+
case "TemplateLiteral":
|
|
271
|
+
if (node.expressions.length > 0)
|
|
272
|
+
throw new Error(`template interpolation not allowed in ${path}`);
|
|
273
|
+
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join("");
|
|
274
|
+
case "UnaryExpression":
|
|
275
|
+
if (node.operator === "-" && node.argument?.type === "Literal" && typeof node.argument.value === "number") {
|
|
276
|
+
return -node.argument.value;
|
|
277
|
+
}
|
|
278
|
+
throw new Error(`only negative-number unary allowed in ${path}`);
|
|
279
|
+
default:
|
|
280
|
+
throw new Error(`non-literal node type in ${path}: ${node.type}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function propertyKey(node, path) {
|
|
284
|
+
if (node.type === "Identifier")
|
|
285
|
+
return node.name;
|
|
286
|
+
if (node.type === "Literal" && (typeof node.value === "string" || typeof node.value === "number"))
|
|
287
|
+
return String(node.value);
|
|
288
|
+
throw new Error(`unsupported key type in ${path}: ${node.type}`);
|
|
289
|
+
}
|
|
290
|
+
function validateMeta(meta) {
|
|
291
|
+
if (!meta || typeof meta !== "object")
|
|
292
|
+
throw new Error("meta must be an object");
|
|
293
|
+
const value = meta;
|
|
294
|
+
if (typeof value.name !== "string" || !value.name.trim())
|
|
295
|
+
throw new Error("meta.name must be a non-empty string");
|
|
296
|
+
if (typeof value.description !== "string" || !value.description.trim())
|
|
297
|
+
throw new Error("meta.description must be a non-empty string");
|
|
298
|
+
if (value.whenToUse !== undefined && typeof value.whenToUse !== "string")
|
|
299
|
+
throw new Error("meta.whenToUse must be a string");
|
|
300
|
+
if (value.phases !== undefined) {
|
|
301
|
+
if (!Array.isArray(value.phases))
|
|
302
|
+
throw new Error("meta.phases must be an array");
|
|
303
|
+
for (const phase of value.phases) {
|
|
304
|
+
if (!phase || typeof phase !== "object" || typeof phase.title !== "string") {
|
|
305
|
+
throw new Error("each meta phase must have a title string");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function createLimiter(limit) {
|
|
311
|
+
let active = 0;
|
|
312
|
+
const queue = [];
|
|
313
|
+
const next = () => {
|
|
314
|
+
active--;
|
|
315
|
+
queue.shift()?.();
|
|
316
|
+
};
|
|
317
|
+
return async (fn) => {
|
|
318
|
+
if (active >= limit)
|
|
319
|
+
await new Promise((resolve) => queue.push(resolve));
|
|
320
|
+
active++;
|
|
321
|
+
try {
|
|
322
|
+
return await fn();
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
next();
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function defaultAgentLabel(phase, index) {
|
|
330
|
+
return phase ? `${phase} agent ${index}` : `agent ${index}`;
|
|
331
|
+
}
|
|
332
|
+
function buildAgentInstructions(phase, options) {
|
|
333
|
+
const lines = [];
|
|
334
|
+
if (phase)
|
|
335
|
+
lines.push(`Workflow phase: ${phase}`);
|
|
336
|
+
if (options.agentType)
|
|
337
|
+
lines.push(`Act as workflow subagent type: ${options.agentType}`);
|
|
338
|
+
if (options.isolation)
|
|
339
|
+
lines.push(`Requested isolation: ${options.isolation}`);
|
|
340
|
+
if (options.model)
|
|
341
|
+
lines.push(`Requested model: ${options.model}`);
|
|
342
|
+
return lines.length ? lines.join("\n") : undefined;
|
|
343
|
+
}
|
|
344
|
+
function estimateTokens(value) {
|
|
345
|
+
return Math.ceil(JSON.stringify(value ?? "").length / 4);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Run a promise with a timeout.
|
|
349
|
+
*/
|
|
350
|
+
async function withTimeout(promise, ms, message) {
|
|
351
|
+
let timeoutId;
|
|
352
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
353
|
+
timeoutId = setTimeout(() => {
|
|
354
|
+
reject(new WorkflowError(message, WorkflowErrorCode.AGENT_TIMEOUT, { recoverable: true }));
|
|
355
|
+
}, ms);
|
|
356
|
+
});
|
|
357
|
+
try {
|
|
358
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
if (timeoutId)
|
|
362
|
+
clearTimeout(timeoutId);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createWorkflowTool } from "../src/index.js";
|
|
3
|
+
|
|
4
|
+
export default function extension(pi: ExtensionAPI) {
|
|
5
|
+
const workflowTool = createWorkflowTool();
|
|
6
|
+
pi.registerTool(workflowTool);
|
|
7
|
+
|
|
8
|
+
pi.on("session_start", () => {
|
|
9
|
+
const active = pi.getActiveTools();
|
|
10
|
+
if (!active.includes(workflowTool.name)) {
|
|
11
|
+
pi.setActiveTools([...active, workflowTool.name]);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|