@quintinshaw/pi-dynamic-workflows 1.7.1 → 1.9.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/src/workflow.ts CHANGED
@@ -32,6 +32,19 @@ export interface JournalEntry {
32
32
  result: unknown;
33
33
  }
34
34
 
35
+ /**
36
+ * Global resources shared across a run and any workflow() nested inside it, so
37
+ * the 16-concurrent / 1000-total caps and the token budget hold across nesting
38
+ * instead of each level getting its own limiter and counters.
39
+ */
40
+ export interface SharedRuntime {
41
+ limiter: <T>(fn: () => Promise<T>) => Promise<T>;
42
+ agentCount: number;
43
+ spent: number;
44
+ tokenUsage: { input: number; output: number; total: number; cost: number };
45
+ depth: number;
46
+ }
47
+
35
48
  export interface WorkflowRunOptions extends WorkflowAgentOptions {
36
49
  args?: unknown;
37
50
  agent?: Pick<WorkflowAgent, "run">;
@@ -52,6 +65,10 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
52
65
  resumeFromRunId?: string;
53
66
  /** Called after each live agent completes so the caller can persist the journal. */
54
67
  onAgentJournal?: (entry: JournalEntry) => void;
68
+ /** Internal: shared runtime inherited by a nested workflow() call. */
69
+ sharedRuntime?: SharedRuntime;
70
+ /** Resolve a saved-workflow name to its script, enabling `workflow('name', args)`. */
71
+ loadSavedWorkflow?: (name: string) => string | undefined;
55
72
  onLog?: (message: string) => void;
56
73
  onPhase?: (title: string) => void;
57
74
  onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
@@ -90,16 +107,8 @@ interface RuntimeState {
90
107
  currentPhase?: string;
91
108
  logs: string[];
92
109
  phases: string[];
93
- agentCount: number;
94
110
  /** Monotonic, assigned at lexical agent() call time — the stable resume key. */
95
111
  callSeq: number;
96
- spent: number;
97
- tokenUsage: {
98
- input: number;
99
- output: number;
100
- total: number;
101
- cost: number;
102
- };
103
112
  }
104
113
 
105
114
  type AnyNode = Node & { [key: string]: any; start: number; end: number };
@@ -130,10 +139,7 @@ export async function runWorkflow<T = unknown>(
130
139
  const state: RuntimeState = {
131
140
  logs: [],
132
141
  phases: [],
133
- agentCount: 0,
134
142
  callSeq: 0,
135
- spent: 0,
136
- tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
137
143
  };
138
144
 
139
145
  const agentRunner = options.agent ?? new WorkflowAgent(options);
@@ -141,7 +147,15 @@ export async function runWorkflow<T = unknown>(
141
147
  1,
142
148
  Math.min(options.concurrency ?? Math.max(1, (globalThis.navigator?.hardwareConcurrency ?? 8) - 2), MAX_CONCURRENCY),
143
149
  );
144
- const limiter = createLimiter(concurrency);
150
+ // Global caps + budget are shared with any nested workflow() so they hold across nesting.
151
+ const shared: SharedRuntime = options.sharedRuntime ?? {
152
+ limiter: createLimiter(concurrency),
153
+ agentCount: 0,
154
+ spent: 0,
155
+ tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
156
+ depth: 0,
157
+ };
158
+ const limiter = shared.limiter;
145
159
 
146
160
  const log = (message: string) => {
147
161
  const text = String(message);
@@ -157,8 +171,8 @@ export async function runWorkflow<T = unknown>(
157
171
 
158
172
  const budget = Object.freeze({
159
173
  total: options.tokenBudget ?? null,
160
- spent: () => state.spent,
161
- remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget - state.spent)),
174
+ spent: () => shared.spent,
175
+ remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget - shared.spent)),
162
176
  });
163
177
 
164
178
  const throwIfAborted = () => {
@@ -171,7 +185,7 @@ export async function runWorkflow<T = unknown>(
171
185
  throwIfAborted();
172
186
 
173
187
  // Check agent limit
174
- if (state.agentCount >= maxAgents) {
188
+ if (shared.agentCount >= maxAgents) {
175
189
  throw new WorkflowError(
176
190
  `Agent limit exceeded (${maxAgents}). Use maxAgents option to increase the limit.`,
177
191
  WorkflowErrorCode.AGENT_LIMIT_EXCEEDED,
@@ -199,16 +213,16 @@ export async function runWorkflow<T = unknown>(
199
213
  // consuming a concurrency slot, tokens, or a real subagent run.
200
214
  const cached = options.resumeJournal?.get(callIndex);
201
215
  if (cached && cached.hash === callHash) {
202
- state.agentCount++;
203
- const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
216
+ shared.agentCount++;
217
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
204
218
  options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
205
219
  options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
206
220
  return cached.result;
207
221
  }
208
222
 
209
223
  return limiter(async () => {
210
- state.agentCount++;
211
- const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
224
+ shared.agentCount++;
225
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
212
226
  const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
213
227
 
214
228
  options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
@@ -227,12 +241,12 @@ export async function runWorkflow<T = unknown>(
227
241
  const recordTokens = (result: unknown): number => {
228
242
  const tokens = usage && usage.total > 0 ? usage.total : estimateTokens(result) + estimateTokens(prompt);
229
243
  if (usage) {
230
- state.tokenUsage.input += usage.input;
231
- state.tokenUsage.output += usage.output;
232
- state.tokenUsage.cost += usage.cost;
244
+ shared.tokenUsage.input += usage.input;
245
+ shared.tokenUsage.output += usage.output;
246
+ shared.tokenUsage.cost += usage.cost;
233
247
  }
234
- state.tokenUsage.total += tokens;
235
- state.spent += tokens;
248
+ shared.tokenUsage.total += tokens;
249
+ shared.spent += tokens;
236
250
  return tokens;
237
251
  };
238
252
 
@@ -331,10 +345,40 @@ export async function runWorkflow<T = unknown>(
331
345
  );
332
346
  };
333
347
 
348
+ // Nested workflow(): run a saved workflow (or a raw script) inline, sharing this
349
+ // run's limiter/counters/budget so the global caps hold. One level deep only.
350
+ const workflowFn = async (nameOrScript: string, childArgs?: unknown) => {
351
+ throwIfAborted();
352
+ if (shared.depth >= 1) {
353
+ throw new WorkflowError("workflow() can nest only one level deep", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
354
+ recoverable: false,
355
+ });
356
+ }
357
+ const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
358
+ const childScript = resolved ?? String(nameOrScript);
359
+ shared.depth++;
360
+ try {
361
+ const child = await runWorkflow(childScript, {
362
+ ...options,
363
+ args: childArgs,
364
+ sharedRuntime: shared,
365
+ // A nested run is its own script; never reuse the parent's resume journal.
366
+ resumeJournal: undefined,
367
+ resumeFromRunId: undefined,
368
+ runId: `${runId}-nested${shared.depth}`,
369
+ persistLogs: false,
370
+ });
371
+ return child.result;
372
+ } finally {
373
+ shared.depth--;
374
+ }
375
+ };
376
+
334
377
  const context = vm.createContext({
335
378
  agent,
336
379
  parallel,
337
380
  pipeline,
381
+ workflow: workflowFn,
338
382
  log,
339
383
  phase,
340
384
  args: options.args,
@@ -369,17 +413,17 @@ export async function runWorkflow<T = unknown>(
369
413
  }
370
414
 
371
415
  // Emit final token usage
372
- options.onTokenUsage?.(state.tokenUsage);
416
+ options.onTokenUsage?.(shared.tokenUsage);
373
417
 
374
418
  return {
375
419
  meta,
376
420
  result: result as T,
377
421
  logs: state.logs,
378
422
  phases: state.phases,
379
- agentCount: state.agentCount,
423
+ agentCount: shared.agentCount,
380
424
  durationMs: Date.now() - started,
381
425
  runId,
382
- tokenUsage: state.tokenUsage,
426
+ tokenUsage: shared.tokenUsage,
383
427
  };
384
428
  }
385
429