@quintinshaw/pi-dynamic-workflows 1.7.1 → 1.8.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 CHANGED
@@ -98,6 +98,7 @@ return { inventory, summary }
98
98
  | `parallel(thunks)` | Run an array of `() => agent(...)` thunks concurrently. Results returned in input order. |
99
99
  | `pipeline(items, ...stages)` | Fan items out through sequential stages. Each stage receives `(prev, original, index)`. |
100
100
  | `phase(title)` | Mark the current phase for the live progress view. |
101
+ | `workflow(name, args)` | Run a saved workflow inline and return its result (one level deep; shares the global caps). |
101
102
  | `log(message)` | Append a workflow-level log line. |
102
103
  | `args` | Optional JSON value passed via the tool's `args` parameter. |
103
104
  | `budget` | `{ total, spent(), remaining() }` token-budget tracker. |
@@ -149,18 +150,13 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
149
150
  - **`/workflows` command** — list, inspect, stop, pause, **resume**, and remove background runs; runs started with `background: true` are reachable from the command
150
151
  - **Bundled `/deep-research` & `/adversarial-review`** — `/deep-research` runs real web searches (via built-in `web_search` / `web_fetch` tools), extracts claims, cross-checks them across sources, and reports only what survived; `/adversarial-review` investigates a task then has independent skeptics try to refute each finding, keeping only those that clear an agreement threshold
151
152
  - **Saved workflows as `/<name>`** — save a run's script with `/workflows save <name>` and it becomes a reusable slash command; arguments are parsed (`key=value` and positionals) and passed through as `args`
153
+ - **Nested `workflow()`** — call `await workflow('saved-name', args)` inside a script to run a saved workflow inline; nesting is one level deep and shares the parent's concurrency limiter, agent counter, and token budget so the global caps hold
152
154
  - **Resume** — each agent result is journaled by a deterministic call index; resuming replays the unchanged prefix from cache (no re-run, no tokens) and runs only new or edited calls live
153
155
  - **Worktree isolation** — `isolation: "worktree"` runs an agent in its own git worktree on a throwaway branch, so parallel agents can edit the same files without conflict; the worktree is torn down after (results are not auto-merged), and it falls back to a logged no-op outside a git repo
154
156
  - **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
155
157
  - **Live progress + token/cost display**, `Esc` to abort
156
158
  - **Log persistence** to `.pi/workflows/runs/`
157
159
 
158
- ## Roadmap
159
-
160
- Tracked toward closer parity with Claude Code dynamic workflows:
161
-
162
- - **Nested `workflow()`** to compose saved workflows inline
163
-
164
160
  ## How it works
165
161
 
166
162
  ```text
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@ export { parseCommandArgs, registerAllSavedWorkflows, registerSavedWorkflow, } f
21
21
  export type { StructuredOutputCapture, StructuredOutputToolOptions } from "./structured-output.js";
22
22
  export { createStructuredOutputTool } from "./structured-output.js";
23
23
  export { createWebFetchTool, createWebSearchTool, createWebTools } from "./web-tools.js";
24
- export type { AgentOptions, JournalEntry, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
24
+ export type { AgentOptions, JournalEntry, SharedRuntime, WorkflowMeta, WorkflowMetaPhase, WorkflowRunOptions, WorkflowRunResult, } from "./workflow.js";
25
25
  export { parseWorkflowScript, runWorkflow } from "./workflow.js";
26
26
  export { registerWorkflowCommands } from "./workflow-commands.js";
27
27
  export type { ManagedRun, WorkflowManagerOptions } from "./workflow-manager.js";
@@ -23,12 +23,15 @@ export interface ManagedRun {
23
23
  export interface WorkflowManagerOptions {
24
24
  cwd?: string;
25
25
  concurrency?: number;
26
+ /** Resolve a saved-workflow name to its script, enabling nested `workflow('name')`. */
27
+ loadSavedWorkflow?: (name: string) => string | undefined;
26
28
  }
27
29
  export declare class WorkflowManager extends EventEmitter {
28
30
  private runs;
29
31
  private persistence;
30
32
  private cwd;
31
33
  private concurrency;
34
+ private loadSavedWorkflow?;
32
35
  constructor(options?: WorkflowManagerOptions);
33
36
  /**
34
37
  * Start a workflow in the background.
@@ -10,10 +10,12 @@ export class WorkflowManager extends EventEmitter {
10
10
  persistence;
11
11
  cwd;
12
12
  concurrency;
13
+ loadSavedWorkflow;
13
14
  constructor(options = {}) {
14
15
  super();
15
16
  this.cwd = options.cwd ?? process.cwd();
16
17
  this.concurrency = options.concurrency ?? 8;
18
+ this.loadSavedWorkflow = options.loadSavedWorkflow;
17
19
  this.persistence = createRunPersistence(this.cwd);
18
20
  }
19
21
  /**
@@ -99,6 +101,7 @@ export class WorkflowManager extends EventEmitter {
99
101
  args,
100
102
  signal: managed.controller.signal,
101
103
  concurrency: this.concurrency,
104
+ loadSavedWorkflow: this.loadSavedWorkflow,
102
105
  resumeJournal,
103
106
  resumeFromRunId: resumeJournal ? managed.runId : undefined,
104
107
  onAgentJournal: (entry) => {
@@ -27,8 +27,9 @@ const workflowToolSchema = Type.Object({
27
27
  })),
28
28
  });
29
29
  export function createWorkflowTool(options = {}) {
30
+ const storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd());
30
31
  const manager = options.manager ?? new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
31
- const _storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd());
32
+ const loadSavedWorkflow = (name) => storage.load(name)?.script;
32
33
  return defineTool({
33
34
  name: "workflow",
34
35
  label: "Workflow",
@@ -52,6 +53,7 @@ export function createWorkflowTool(options = {}) {
52
53
  "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
54
  "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
55
  "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.",
56
+ "For workflow, you may call `await workflow('saved-name', argsObject)` to run a saved workflow inline and use its result; nesting is one level deep only, and the global 16-concurrent / 1000-total caps hold across the nesting.",
55
57
  ],
56
58
  parameters: workflowToolSchema,
57
59
  prepareArguments(args) {
@@ -100,6 +102,7 @@ export function createWorkflowTool(options = {}) {
100
102
  concurrency: options.concurrency,
101
103
  maxAgents: params.maxAgents,
102
104
  agentTimeoutMs: params.agentTimeoutMs,
105
+ loadSavedWorkflow,
103
106
  onLog(message) {
104
107
  snapshot.logs.push(message);
105
108
  update();
@@ -18,6 +18,23 @@ export interface JournalEntry {
18
18
  hash: string;
19
19
  result: unknown;
20
20
  }
21
+ /**
22
+ * Global resources shared across a run and any workflow() nested inside it, so
23
+ * the 16-concurrent / 1000-total caps and the token budget hold across nesting
24
+ * instead of each level getting its own limiter and counters.
25
+ */
26
+ export interface SharedRuntime {
27
+ limiter: <T>(fn: () => Promise<T>) => Promise<T>;
28
+ agentCount: number;
29
+ spent: number;
30
+ tokenUsage: {
31
+ input: number;
32
+ output: number;
33
+ total: number;
34
+ cost: number;
35
+ };
36
+ depth: number;
37
+ }
21
38
  export interface WorkflowRunOptions extends WorkflowAgentOptions {
22
39
  args?: unknown;
23
40
  agent?: Pick<WorkflowAgent, "run">;
@@ -38,6 +55,10 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
38
55
  resumeFromRunId?: string;
39
56
  /** Called after each live agent completes so the caller can persist the journal. */
40
57
  onAgentJournal?: (entry: JournalEntry) => void;
58
+ /** Internal: shared runtime inherited by a nested workflow() call. */
59
+ sharedRuntime?: SharedRuntime;
60
+ /** Resolve a saved-workflow name to its script, enabling `workflow('name', args)`. */
61
+ loadSavedWorkflow?: (name: string) => string | undefined;
41
62
  onLog?: (message: string) => void;
42
63
  onPhase?: (title: string) => void;
43
64
  onAgentStart?: (event: {
package/dist/workflow.js CHANGED
@@ -27,14 +27,19 @@ export async function runWorkflow(script, options = {}) {
27
27
  const state = {
28
28
  logs: [],
29
29
  phases: [],
30
- agentCount: 0,
31
30
  callSeq: 0,
32
- spent: 0,
33
- tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
34
31
  };
35
32
  const agentRunner = options.agent ?? new WorkflowAgent(options);
36
33
  const concurrency = Math.max(1, Math.min(options.concurrency ?? Math.max(1, (globalThis.navigator?.hardwareConcurrency ?? 8) - 2), MAX_CONCURRENCY));
37
- const limiter = createLimiter(concurrency);
34
+ // Global caps + budget are shared with any nested workflow() so they hold across nesting.
35
+ const shared = options.sharedRuntime ?? {
36
+ limiter: createLimiter(concurrency),
37
+ agentCount: 0,
38
+ spent: 0,
39
+ tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
40
+ depth: 0,
41
+ };
42
+ const limiter = shared.limiter;
38
43
  const log = (message) => {
39
44
  const text = String(message);
40
45
  state.logs.push(text);
@@ -48,8 +53,8 @@ export async function runWorkflow(script, options = {}) {
48
53
  };
49
54
  const budget = Object.freeze({
50
55
  total: options.tokenBudget ?? null,
51
- spent: () => state.spent,
52
- remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget - state.spent)),
56
+ spent: () => shared.spent,
57
+ remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget - shared.spent)),
53
58
  });
54
59
  const throwIfAborted = () => {
55
60
  if (options.signal?.aborted) {
@@ -59,7 +64,7 @@ export async function runWorkflow(script, options = {}) {
59
64
  const agent = async (prompt, agentOptions = {}) => {
60
65
  throwIfAborted();
61
66
  // Check agent limit
62
- if (state.agentCount >= maxAgents) {
67
+ if (shared.agentCount >= maxAgents) {
63
68
  throw new WorkflowError(`Agent limit exceeded (${maxAgents}). Use maxAgents option to increase the limit.`, WorkflowErrorCode.AGENT_LIMIT_EXCEEDED, { recoverable: false });
64
69
  }
65
70
  if (budget.total !== null && budget.remaining() <= 0) {
@@ -79,15 +84,15 @@ export async function runWorkflow(script, options = {}) {
79
84
  // consuming a concurrency slot, tokens, or a real subagent run.
80
85
  const cached = options.resumeJournal?.get(callIndex);
81
86
  if (cached && cached.hash === callHash) {
82
- state.agentCount++;
83
- const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
87
+ shared.agentCount++;
88
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
84
89
  options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
85
90
  options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
86
91
  return cached.result;
87
92
  }
88
93
  return limiter(async () => {
89
- state.agentCount++;
90
- const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
94
+ shared.agentCount++;
95
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
91
96
  const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
92
97
  options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
93
98
  // Optional per-agent worktree isolation (deterministic name -> stable resume keys).
@@ -104,12 +109,12 @@ export async function runWorkflow(script, options = {}) {
104
109
  const recordTokens = (result) => {
105
110
  const tokens = usage && usage.total > 0 ? usage.total : estimateTokens(result) + estimateTokens(prompt);
106
111
  if (usage) {
107
- state.tokenUsage.input += usage.input;
108
- state.tokenUsage.output += usage.output;
109
- state.tokenUsage.cost += usage.cost;
112
+ shared.tokenUsage.input += usage.input;
113
+ shared.tokenUsage.output += usage.output;
114
+ shared.tokenUsage.cost += usage.cost;
110
115
  }
111
- state.tokenUsage.total += tokens;
112
- state.spent += tokens;
116
+ shared.tokenUsage.total += tokens;
117
+ shared.spent += tokens;
113
118
  return tokens;
114
119
  };
115
120
  try {
@@ -198,10 +203,40 @@ export async function runWorkflow(script, options = {}) {
198
203
  return value;
199
204
  }));
200
205
  };
206
+ // Nested workflow(): run a saved workflow (or a raw script) inline, sharing this
207
+ // run's limiter/counters/budget so the global caps hold. One level deep only.
208
+ const workflowFn = async (nameOrScript, childArgs) => {
209
+ throwIfAborted();
210
+ if (shared.depth >= 1) {
211
+ throw new WorkflowError("workflow() can nest only one level deep", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
212
+ recoverable: false,
213
+ });
214
+ }
215
+ const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
216
+ const childScript = resolved ?? String(nameOrScript);
217
+ shared.depth++;
218
+ try {
219
+ const child = await runWorkflow(childScript, {
220
+ ...options,
221
+ args: childArgs,
222
+ sharedRuntime: shared,
223
+ // A nested run is its own script; never reuse the parent's resume journal.
224
+ resumeJournal: undefined,
225
+ resumeFromRunId: undefined,
226
+ runId: `${runId}-nested${shared.depth}`,
227
+ persistLogs: false,
228
+ });
229
+ return child.result;
230
+ }
231
+ finally {
232
+ shared.depth--;
233
+ }
234
+ };
201
235
  const context = vm.createContext({
202
236
  agent,
203
237
  parallel,
204
238
  pipeline,
239
+ workflow: workflowFn,
205
240
  log,
206
241
  phase,
207
242
  args: options.args,
@@ -233,16 +268,16 @@ export async function runWorkflow(script, options = {}) {
233
268
  log(`Logs persisted to ${logFile}`);
234
269
  }
235
270
  // Emit final token usage
236
- options.onTokenUsage?.(state.tokenUsage);
271
+ options.onTokenUsage?.(shared.tokenUsage);
237
272
  return {
238
273
  meta,
239
274
  result: result,
240
275
  logs: state.logs,
241
276
  phases: state.phases,
242
- agentCount: state.agentCount,
277
+ agentCount: shared.agentCount,
243
278
  durationMs: Date.now() - started,
244
279
  runId,
245
- tokenUsage: state.tokenUsage,
280
+ tokenUsage: shared.tokenUsage,
246
281
  };
247
282
  }
248
283
  export function parseWorkflowScript(script) {
@@ -12,8 +12,8 @@ export default function extension(pi: ExtensionAPI) {
12
12
  // Single manager/storage shared by the workflow tool and the /workflows command,
13
13
  // so background runs started by the tool are reachable from the command.
14
14
  const cwd = process.cwd();
15
- const manager = new WorkflowManager({ cwd });
16
15
  const storage = createWorkflowStorage(cwd);
16
+ const manager = new WorkflowManager({ cwd, loadSavedWorkflow: (name) => storage.load(name)?.script });
17
17
 
18
18
  const workflowTool = createWorkflowTool({ cwd, manager, storage });
19
19
  pi.registerTool(workflowTool);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quintinshaw/pi-dynamic-workflows",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Claude-Code-style dynamic workflow orchestration for Pi.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export { createWebFetchTool, createWebSearchTool, createWebTools } from "./web-t
49
49
  export type {
50
50
  AgentOptions,
51
51
  JournalEntry,
52
+ SharedRuntime,
52
53
  WorkflowMeta,
53
54
  WorkflowMetaPhase,
54
55
  WorkflowRunOptions,
@@ -32,6 +32,8 @@ export interface ManagedRun {
32
32
  export interface WorkflowManagerOptions {
33
33
  cwd?: string;
34
34
  concurrency?: number;
35
+ /** Resolve a saved-workflow name to its script, enabling nested `workflow('name')`. */
36
+ loadSavedWorkflow?: (name: string) => string | undefined;
35
37
  }
36
38
 
37
39
  export class WorkflowManager extends EventEmitter {
@@ -39,11 +41,13 @@ export class WorkflowManager extends EventEmitter {
39
41
  private persistence: RunPersistence;
40
42
  private cwd: string;
41
43
  private concurrency: number;
44
+ private loadSavedWorkflow?: (name: string) => string | undefined;
42
45
 
43
46
  constructor(options: WorkflowManagerOptions = {}) {
44
47
  super();
45
48
  this.cwd = options.cwd ?? process.cwd();
46
49
  this.concurrency = options.concurrency ?? 8;
50
+ this.loadSavedWorkflow = options.loadSavedWorkflow;
47
51
  this.persistence = createRunPersistence(this.cwd);
48
52
  }
49
53
 
@@ -144,6 +148,7 @@ export class WorkflowManager extends EventEmitter {
144
148
  args,
145
149
  signal: managed.controller.signal,
146
150
  concurrency: this.concurrency,
151
+ loadSavedWorkflow: this.loadSavedWorkflow,
147
152
  resumeJournal,
148
153
  resumeFromRunId: resumeJournal ? managed.runId : undefined,
149
154
  onAgentJournal: (entry) => {
@@ -61,8 +61,9 @@ export interface WorkflowToolOptions {
61
61
  }
62
62
 
63
63
  export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefinition<typeof workflowToolSchema, any> {
64
+ const storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd());
64
65
  const manager = options.manager ?? new WorkflowManager({ cwd: options.cwd, concurrency: options.concurrency });
65
- const _storage = options.storage ?? createWorkflowStorage(options.cwd ?? process.cwd());
66
+ const loadSavedWorkflow = (name: string) => storage.load(name)?.script;
66
67
 
67
68
  return defineTool({
68
69
  name: "workflow",
@@ -88,6 +89,7 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
88
89
  "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.",
89
90
  "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.",
90
91
  "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.",
92
+ "For workflow, you may call `await workflow('saved-name', argsObject)` to run a saved workflow inline and use its result; nesting is one level deep only, and the global 16-concurrent / 1000-total caps hold across the nesting.",
91
93
  ],
92
94
  parameters: workflowToolSchema,
93
95
  prepareArguments(args) {
@@ -140,6 +142,7 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
140
142
  concurrency: options.concurrency,
141
143
  maxAgents: params.maxAgents,
142
144
  agentTimeoutMs: params.agentTimeoutMs,
145
+ loadSavedWorkflow,
143
146
  onLog(message) {
144
147
  snapshot.logs.push(message);
145
148
  update();
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