@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 +2 -6
- package/dist/index.d.ts +1 -1
- package/dist/workflow-manager.d.ts +3 -0
- package/dist/workflow-manager.js +3 -0
- package/dist/workflow-tool.js +4 -1
- package/dist/workflow.d.ts +21 -0
- package/dist/workflow.js +54 -19
- package/extensions/workflow.ts +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/workflow-manager.ts +5 -0
- package/src/workflow-tool.ts +4 -1
- package/src/workflow.ts +71 -27
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.
|
package/dist/workflow-manager.js
CHANGED
|
@@ -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) => {
|
package/dist/workflow-tool.js
CHANGED
|
@@ -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
|
|
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();
|
package/dist/workflow.d.ts
CHANGED
|
@@ -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
|
-
|
|
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: () =>
|
|
52
|
-
remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget -
|
|
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 (
|
|
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
|
-
|
|
83
|
-
const label = requestedLabel || defaultAgentLabel(assignedPhase,
|
|
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
|
-
|
|
90
|
-
const label = requestedLabel || defaultAgentLabel(assignedPhase,
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
shared.tokenUsage.input += usage.input;
|
|
113
|
+
shared.tokenUsage.output += usage.output;
|
|
114
|
+
shared.tokenUsage.cost += usage.cost;
|
|
110
115
|
}
|
|
111
|
-
|
|
112
|
-
|
|
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?.(
|
|
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:
|
|
277
|
+
agentCount: shared.agentCount,
|
|
243
278
|
durationMs: Date.now() - started,
|
|
244
279
|
runId,
|
|
245
|
-
tokenUsage:
|
|
280
|
+
tokenUsage: shared.tokenUsage,
|
|
246
281
|
};
|
|
247
282
|
}
|
|
248
283
|
export function parseWorkflowScript(script) {
|
package/extensions/workflow.ts
CHANGED
|
@@ -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
package/src/index.ts
CHANGED
package/src/workflow-manager.ts
CHANGED
|
@@ -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) => {
|
package/src/workflow-tool.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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: () =>
|
|
161
|
-
remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget -
|
|
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 (
|
|
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
|
-
|
|
203
|
-
const label = requestedLabel || defaultAgentLabel(assignedPhase,
|
|
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
|
-
|
|
211
|
-
const label = requestedLabel || defaultAgentLabel(assignedPhase,
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
244
|
+
shared.tokenUsage.input += usage.input;
|
|
245
|
+
shared.tokenUsage.output += usage.output;
|
|
246
|
+
shared.tokenUsage.cost += usage.cost;
|
|
233
247
|
}
|
|
234
|
-
|
|
235
|
-
|
|
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?.(
|
|
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:
|
|
423
|
+
agentCount: shared.agentCount,
|
|
380
424
|
durationMs: Date.now() - started,
|
|
381
425
|
runId,
|
|
382
|
-
tokenUsage:
|
|
426
|
+
tokenUsage: shared.tokenUsage,
|
|
383
427
|
};
|
|
384
428
|
}
|
|
385
429
|
|