@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.
Files changed (51) hide show
  1. package/README.md +159 -0
  2. package/dist/adversarial-review.d.ts +20 -0
  3. package/dist/adversarial-review.js +87 -0
  4. package/dist/agent.d.ts +29 -0
  5. package/dist/agent.js +90 -0
  6. package/dist/auto-workflow.d.ts +26 -0
  7. package/dist/auto-workflow.js +121 -0
  8. package/dist/config.d.ts +17 -0
  9. package/dist/config.js +17 -0
  10. package/dist/deep-research.d.ts +22 -0
  11. package/dist/deep-research.js +110 -0
  12. package/dist/display.d.ts +62 -0
  13. package/dist/display.js +163 -0
  14. package/dist/errors.d.ts +41 -0
  15. package/dist/errors.js +63 -0
  16. package/dist/index.d.ts +28 -0
  17. package/dist/index.js +15 -0
  18. package/dist/logger.d.ts +21 -0
  19. package/dist/logger.js +67 -0
  20. package/dist/model-routing.d.ts +33 -0
  21. package/dist/model-routing.js +57 -0
  22. package/dist/run-persistence.d.ts +53 -0
  23. package/dist/run-persistence.js +78 -0
  24. package/dist/structured-output.d.ts +19 -0
  25. package/dist/structured-output.js +30 -0
  26. package/dist/workflow-manager.d.ts +74 -0
  27. package/dist/workflow-manager.js +241 -0
  28. package/dist/workflow-saved.d.ts +35 -0
  29. package/dist/workflow-saved.js +91 -0
  30. package/dist/workflow-tool.d.ts +22 -0
  31. package/dist/workflow-tool.js +216 -0
  32. package/dist/workflow.d.ts +75 -0
  33. package/dist/workflow.js +364 -0
  34. package/extensions/workflow.ts +14 -0
  35. package/package.json +70 -0
  36. package/src/adversarial-review.ts +107 -0
  37. package/src/agent.ts +135 -0
  38. package/src/auto-workflow.ts +146 -0
  39. package/src/config.ts +24 -0
  40. package/src/deep-research.ts +128 -0
  41. package/src/display.ts +236 -0
  42. package/src/errors.ts +85 -0
  43. package/src/index.ts +55 -0
  44. package/src/logger.ts +89 -0
  45. package/src/model-routing.ts +80 -0
  46. package/src/run-persistence.ts +132 -0
  47. package/src/structured-output.ts +47 -0
  48. package/src/workflow-manager.ts +294 -0
  49. package/src/workflow-saved.ts +131 -0
  50. package/src/workflow-tool.ts +254 -0
  51. package/src/workflow.ts +492 -0
@@ -0,0 +1,492 @@
1
+ import vm from "node:vm";
2
+ import type { Node } from "acorn";
3
+ import { parse } from "acorn";
4
+ import type { TSchema } from "typebox";
5
+ import { WorkflowAgent, type WorkflowAgentOptions } from "./agent.js";
6
+ import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from "./config.js";
7
+ import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
8
+ import { createWorkflowLogger } from "./logger.js";
9
+
10
+ export interface WorkflowMetaPhase {
11
+ title: string;
12
+ detail?: string;
13
+ model?: string;
14
+ }
15
+
16
+ export interface WorkflowMeta {
17
+ name: string;
18
+ description: string;
19
+ whenToUse?: string;
20
+ phases?: WorkflowMetaPhase[];
21
+ }
22
+
23
+ export interface WorkflowRunOptions extends WorkflowAgentOptions {
24
+ args?: unknown;
25
+ agent?: Pick<WorkflowAgent, "run">;
26
+ concurrency?: number;
27
+ tokenBudget?: number | null;
28
+ signal?: AbortSignal;
29
+ /** Maximum number of agents allowed in this run. Default: 1000 */
30
+ maxAgents?: number;
31
+ /** Timeout per agent in milliseconds. Default: 5 minutes */
32
+ agentTimeoutMs?: number;
33
+ /** Whether to persist logs to disk. Default: true */
34
+ persistLogs?: boolean;
35
+ /** Run ID for persistence. Auto-generated if not provided. */
36
+ runId?: string;
37
+ onLog?: (message: string) => void;
38
+ onPhase?: (title: string) => void;
39
+ onAgentStart?: (event: { label: string; phase?: string; prompt: string }) => void;
40
+ onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number }) => void;
41
+ onTokenUsage?: (usage: { input: number; output: number; total: number }) => void;
42
+ }
43
+
44
+ export interface WorkflowRunResult<T = unknown> {
45
+ meta: WorkflowMeta;
46
+ result: T;
47
+ logs: string[];
48
+ phases: string[];
49
+ agentCount: number;
50
+ durationMs: number;
51
+ runId?: string;
52
+ tokenUsage?: {
53
+ input: number;
54
+ output: number;
55
+ total: number;
56
+ };
57
+ }
58
+
59
+ export interface AgentOptions<TSchemaDef extends TSchema | undefined = TSchema | undefined> {
60
+ label?: string;
61
+ phase?: string;
62
+ schema?: TSchemaDef;
63
+ model?: string;
64
+ isolation?: "worktree";
65
+ agentType?: string;
66
+ /** Override timeout for this specific agent. */
67
+ timeoutMs?: number;
68
+ }
69
+
70
+ interface RuntimeState {
71
+ currentPhase?: string;
72
+ logs: string[];
73
+ phases: string[];
74
+ agentCount: number;
75
+ spent: number;
76
+ tokenUsage: {
77
+ input: number;
78
+ output: number;
79
+ total: number;
80
+ };
81
+ }
82
+
83
+ type AnyNode = Node & { [key: string]: any; start: number; end: number };
84
+
85
+ const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
86
+
87
+ export async function runWorkflow<T = unknown>(
88
+ script: string,
89
+ options: WorkflowRunOptions = {},
90
+ ): Promise<WorkflowRunResult<T>> {
91
+ const started = Date.now();
92
+ const { meta, body } = parseWorkflowScript(script);
93
+ const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
94
+ const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
95
+ const runId = options.runId ?? `run-${started.toString(36)}`;
96
+
97
+ // Initialize logger
98
+ const logger = createWorkflowLogger({
99
+ runId,
100
+ cwd: options.cwd ?? process.cwd(),
101
+ persist: options.persistLogs ?? true,
102
+ onLog: options.onLog,
103
+ });
104
+
105
+ const state: RuntimeState = {
106
+ logs: [],
107
+ phases: [],
108
+ agentCount: 0,
109
+ spent: 0,
110
+ tokenUsage: { input: 0, output: 0, total: 0 },
111
+ };
112
+
113
+ const agentRunner = options.agent ?? new WorkflowAgent(options);
114
+ const concurrency = Math.max(
115
+ 1,
116
+ Math.min(options.concurrency ?? Math.max(1, (globalThis.navigator?.hardwareConcurrency ?? 8) - 2), MAX_CONCURRENCY),
117
+ );
118
+ const limiter = createLimiter(concurrency);
119
+
120
+ const log = (message: string) => {
121
+ const text = String(message);
122
+ state.logs.push(text);
123
+ logger.log(text);
124
+ };
125
+
126
+ const phase = (title: string) => {
127
+ state.currentPhase = title;
128
+ if (!state.phases.includes(title)) state.phases.push(title);
129
+ options.onPhase?.(title);
130
+ };
131
+
132
+ const budget = Object.freeze({
133
+ total: options.tokenBudget ?? null,
134
+ spent: () => state.spent,
135
+ remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget - state.spent)),
136
+ });
137
+
138
+ const throwIfAborted = () => {
139
+ if (options.signal?.aborted) {
140
+ throw new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true });
141
+ }
142
+ };
143
+
144
+ const agent = async (prompt: string, agentOptions: AgentOptions = {}) => {
145
+ throwIfAborted();
146
+
147
+ // Check agent limit
148
+ if (state.agentCount >= maxAgents) {
149
+ throw new WorkflowError(
150
+ `Agent limit exceeded (${maxAgents}). Use maxAgents option to increase the limit.`,
151
+ WorkflowErrorCode.AGENT_LIMIT_EXCEEDED,
152
+ { recoverable: false },
153
+ );
154
+ }
155
+
156
+ if (budget.total !== null && budget.remaining() <= 0) {
157
+ throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
158
+ recoverable: false,
159
+ });
160
+ }
161
+
162
+ const assignedPhase = agentOptions.phase ?? state.currentPhase;
163
+ const requestedLabel = agentOptions.label?.trim();
164
+
165
+ return limiter(async () => {
166
+ state.agentCount++;
167
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
168
+ const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
169
+
170
+ options.onAgentStart?.({ label, phase: assignedPhase, prompt });
171
+
172
+ try {
173
+ throwIfAborted();
174
+
175
+ // Run agent with timeout
176
+ const result = await withTimeout(
177
+ agentRunner.run(prompt, {
178
+ label,
179
+ schema: agentOptions.schema,
180
+ signal: options.signal,
181
+ instructions: buildAgentInstructions(assignedPhase, agentOptions),
182
+ } as any),
183
+ timeout,
184
+ `Agent "${label}" timed out after ${timeout}ms`,
185
+ );
186
+
187
+ throwIfAborted();
188
+
189
+ // Estimate token usage
190
+ const tokens = estimateTokens(result) + estimateTokens(prompt);
191
+ state.spent += tokens;
192
+ state.tokenUsage.total += tokens;
193
+
194
+ options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
195
+ return result;
196
+ } catch (error) {
197
+ if (options.signal?.aborted) throw error;
198
+
199
+ const workflowError = wrapError(error, { agentLabel: label });
200
+ logger.error(`agent ${label} failed: ${workflowError.message}`);
201
+ const errorTokens = estimateTokens(prompt);
202
+ state.tokenUsage.total += errorTokens;
203
+ options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens: errorTokens });
204
+
205
+ // Return null for recoverable errors
206
+ if (workflowError.recoverable) {
207
+ return null;
208
+ }
209
+ throw workflowError;
210
+ }
211
+ });
212
+ };
213
+
214
+ const parallel = async (thunks: Array<() => Promise<unknown>>) => {
215
+ throwIfAborted();
216
+ if (!Array.isArray(thunks)) throw new TypeError("parallel() expects an array of functions");
217
+ if (thunks.some((thunk) => typeof thunk !== "function")) {
218
+ throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
219
+ }
220
+ return Promise.all(
221
+ thunks.map(async (thunk, index) => {
222
+ try {
223
+ return await thunk();
224
+ } catch (error) {
225
+ if (options.signal?.aborted) throw error;
226
+ const workflowError = wrapError(error);
227
+ log(`parallel[${index}] failed: ${workflowError.message}`);
228
+ return null;
229
+ }
230
+ }),
231
+ );
232
+ };
233
+
234
+ const pipeline = async (
235
+ items: unknown[],
236
+ ...stages: Array<(prev: unknown, original: unknown, index: number) => unknown>
237
+ ) => {
238
+ throwIfAborted();
239
+ if (!Array.isArray(items)) throw new TypeError("pipeline() expects an array as the first argument");
240
+ if (stages.some((stage) => typeof stage !== "function")) {
241
+ throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
242
+ }
243
+ return Promise.all(
244
+ items.map(async (item, index) => {
245
+ let value: unknown = item;
246
+ for (const stage of stages) {
247
+ try {
248
+ throwIfAborted();
249
+ value = await stage(value, item, index);
250
+ throwIfAborted();
251
+ } catch (error) {
252
+ if (options.signal?.aborted) throw error;
253
+ const workflowError = wrapError(error);
254
+ log(`pipeline[${index}] failed: ${workflowError.message}`);
255
+ return null;
256
+ }
257
+ }
258
+ return value;
259
+ }),
260
+ );
261
+ };
262
+
263
+ const context = vm.createContext({
264
+ agent,
265
+ parallel,
266
+ pipeline,
267
+ log,
268
+ phase,
269
+ args: options.args,
270
+ cwd: options.cwd ?? process.cwd(),
271
+ process: Object.freeze({ cwd: () => options.cwd ?? process.cwd() }),
272
+ budget,
273
+ console: {
274
+ log,
275
+ info: log,
276
+ warn: (m: unknown) => log(`[warn] ${String(m)}`),
277
+ error: (m: unknown) => log(`[error] ${String(m)}`),
278
+ },
279
+ JSON,
280
+ Math,
281
+ Array,
282
+ Object,
283
+ String,
284
+ Number,
285
+ Boolean,
286
+ Set,
287
+ Map,
288
+ Promise,
289
+ });
290
+
291
+ const wrapped = `(async () => {\n${body}\n})()`;
292
+ const result = await new vm.Script(wrapped, { filename: `${meta.name || "workflow"}.js` }).runInContext(context);
293
+
294
+ // Persist logs
295
+ const logFile = logger.persist();
296
+ if (logFile) {
297
+ log(`Logs persisted to ${logFile}`);
298
+ }
299
+
300
+ // Emit final token usage
301
+ options.onTokenUsage?.(state.tokenUsage);
302
+
303
+ return {
304
+ meta,
305
+ result: result as T,
306
+ logs: state.logs,
307
+ phases: state.phases,
308
+ agentCount: state.agentCount,
309
+ durationMs: Date.now() - started,
310
+ runId,
311
+ tokenUsage: state.tokenUsage,
312
+ };
313
+ }
314
+
315
+ export function parseWorkflowScript(script: string): { meta: WorkflowMeta; body: string } {
316
+ if (DETERMINISM_BLOCKLIST.test(script)) {
317
+ throw new WorkflowError(
318
+ "Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable",
319
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
320
+ { recoverable: false },
321
+ );
322
+ }
323
+
324
+ const ast = parse(script, {
325
+ ecmaVersion: "latest",
326
+ sourceType: "module",
327
+ allowAwaitOutsideFunction: true,
328
+ allowReturnOutsideFunction: true,
329
+ ranges: false,
330
+ }) as AnyNode;
331
+
332
+ const first = ast.body?.[0] as AnyNode | undefined;
333
+ if (first?.type !== "ExportNamedDeclaration") {
334
+ throw new WorkflowError(
335
+ "`export const meta = { name, description, phases }` must be the first statement in the script",
336
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
337
+ { recoverable: false },
338
+ );
339
+ }
340
+
341
+ const declaration = first.declaration as AnyNode | null;
342
+ if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") {
343
+ throw new WorkflowError(
344
+ "meta export must be `export const meta = ...`",
345
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
346
+ {
347
+ recoverable: false,
348
+ },
349
+ );
350
+ }
351
+ if (declaration.declarations.length !== 1) {
352
+ throw new WorkflowError("meta export must declare only `meta`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
353
+ recoverable: false,
354
+ });
355
+ }
356
+
357
+ const declarator = declaration.declarations[0] as AnyNode;
358
+ if (declarator.id?.type !== "Identifier" || declarator.id.name !== "meta") {
359
+ throw new WorkflowError("meta export must declare `meta`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
360
+ recoverable: false,
361
+ });
362
+ }
363
+ if (!declarator.init)
364
+ throw new WorkflowError("meta must have a literal value", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
365
+ recoverable: false,
366
+ });
367
+
368
+ const meta = evaluateLiteral(declarator.init, "meta");
369
+ validateMeta(meta);
370
+
371
+ return {
372
+ meta,
373
+ body: script.slice(0, first.start) + script.slice(first.end),
374
+ };
375
+ }
376
+
377
+ function evaluateLiteral(node: AnyNode, path: string): unknown {
378
+ switch (node.type) {
379
+ case "ObjectExpression": {
380
+ const out: Record<string, unknown> = {};
381
+ for (const prop of node.properties as AnyNode[]) {
382
+ if (prop.type === "SpreadElement") throw new Error(`spread not allowed in ${path}`);
383
+ if (prop.type !== "Property") throw new Error(`only plain properties allowed in ${path}`);
384
+ if (prop.computed) throw new Error(`computed keys not allowed in ${path}`);
385
+ if (prop.kind !== "init" || prop.method) throw new Error(`methods/accessors not allowed in ${path}`);
386
+ const key = propertyKey(prop.key as AnyNode, path);
387
+ if (key === "__proto__" || key === "constructor" || key === "prototype") {
388
+ throw new Error(`reserved key name not allowed in ${path}: ${key}`);
389
+ }
390
+ out[key] = evaluateLiteral(prop.value as AnyNode, `${path}.${key}`);
391
+ }
392
+ return out;
393
+ }
394
+ case "ArrayExpression":
395
+ return (node.elements as Array<AnyNode | null>).map((element, index) => {
396
+ if (!element) throw new Error(`sparse arrays not allowed in ${path}`);
397
+ if (element.type === "SpreadElement") throw new Error(`spread not allowed in ${path}`);
398
+ return evaluateLiteral(element, `${path}[${index}]`);
399
+ });
400
+ case "Literal":
401
+ return node.value;
402
+ case "TemplateLiteral":
403
+ if (node.expressions.length > 0) throw new Error(`template interpolation not allowed in ${path}`);
404
+ return node.quasis.map((quasi: AnyNode) => quasi.value.cooked ?? quasi.value.raw).join("");
405
+ case "UnaryExpression":
406
+ if (node.operator === "-" && node.argument?.type === "Literal" && typeof node.argument.value === "number") {
407
+ return -node.argument.value;
408
+ }
409
+ throw new Error(`only negative-number unary allowed in ${path}`);
410
+ default:
411
+ throw new Error(`non-literal node type in ${path}: ${node.type}`);
412
+ }
413
+ }
414
+
415
+ function propertyKey(node: AnyNode, path: string): string {
416
+ if (node.type === "Identifier") return node.name;
417
+ if (node.type === "Literal" && (typeof node.value === "string" || typeof node.value === "number"))
418
+ return String(node.value);
419
+ throw new Error(`unsupported key type in ${path}: ${node.type}`);
420
+ }
421
+
422
+ function validateMeta(meta: unknown): asserts meta is WorkflowMeta {
423
+ if (!meta || typeof meta !== "object") throw new Error("meta must be an object");
424
+ const value = meta as WorkflowMeta;
425
+ if (typeof value.name !== "string" || !value.name.trim()) throw new Error("meta.name must be a non-empty string");
426
+ if (typeof value.description !== "string" || !value.description.trim())
427
+ throw new Error("meta.description must be a non-empty string");
428
+ if (value.whenToUse !== undefined && typeof value.whenToUse !== "string")
429
+ throw new Error("meta.whenToUse must be a string");
430
+ if (value.phases !== undefined) {
431
+ if (!Array.isArray(value.phases)) throw new Error("meta.phases must be an array");
432
+ for (const phase of value.phases) {
433
+ if (!phase || typeof phase !== "object" || typeof (phase as WorkflowMetaPhase).title !== "string") {
434
+ throw new Error("each meta phase must have a title string");
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ function createLimiter(limit: number) {
441
+ let active = 0;
442
+ const queue: Array<() => void> = [];
443
+ const next = () => {
444
+ active--;
445
+ queue.shift()?.();
446
+ };
447
+ return async <T>(fn: () => Promise<T>): Promise<T> => {
448
+ if (active >= limit) await new Promise<void>((resolve) => queue.push(resolve));
449
+ active++;
450
+ try {
451
+ return await fn();
452
+ } finally {
453
+ next();
454
+ }
455
+ };
456
+ }
457
+
458
+ function defaultAgentLabel(phase: string | undefined, index: number): string {
459
+ return phase ? `${phase} agent ${index}` : `agent ${index}`;
460
+ }
461
+
462
+ function buildAgentInstructions(phase: string | undefined, options: AgentOptions): string | undefined {
463
+ const lines = [];
464
+ if (phase) lines.push(`Workflow phase: ${phase}`);
465
+ if (options.agentType) lines.push(`Act as workflow subagent type: ${options.agentType}`);
466
+ if (options.isolation) lines.push(`Requested isolation: ${options.isolation}`);
467
+ if (options.model) lines.push(`Requested model: ${options.model}`);
468
+ return lines.length ? lines.join("\n") : undefined;
469
+ }
470
+
471
+ function estimateTokens(value: unknown): number {
472
+ return Math.ceil(JSON.stringify(value ?? "").length / 4);
473
+ }
474
+
475
+ /**
476
+ * Run a promise with a timeout.
477
+ */
478
+ async function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
479
+ let timeoutId: NodeJS.Timeout | undefined;
480
+
481
+ const timeoutPromise = new Promise<never>((_, reject) => {
482
+ timeoutId = setTimeout(() => {
483
+ reject(new WorkflowError(message, WorkflowErrorCode.AGENT_TIMEOUT, { recoverable: true }));
484
+ }, ms);
485
+ });
486
+
487
+ try {
488
+ return await Promise.race([promise, timeoutPromise]);
489
+ } finally {
490
+ if (timeoutId) clearTimeout(timeoutId);
491
+ }
492
+ }