@quintinshaw/pi-dynamic-workflows 1.3.0 → 1.4.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 -2
- package/dist/run-persistence.d.ts +6 -0
- package/dist/workflow-manager.d.ts +8 -2
- package/dist/workflow-manager.js +50 -6
- package/dist/workflow.d.ts +13 -0
- package/dist/workflow.js +28 -0
- package/package.json +1 -1
- package/src/run-persistence.ts +2 -0
- package/src/workflow-manager.ts +62 -7
- package/src/workflow.ts +52 -0
package/README.md
CHANGED
|
@@ -133,7 +133,8 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
|
|
|
133
133
|
- **Structured output** — JSON-Schema-validated subagent results
|
|
134
134
|
- **Real token & cost accounting** — read from each subagent's SDK session (input / output / total / cost), with a character estimate only as fallback when a provider reports no usage; `budget` gates on the real total
|
|
135
135
|
- **Real per-agent / per-phase model routing** — `opts.model` and `meta.phases[].model` actually select the model (resolved against your authed model registry), with graceful fallback
|
|
136
|
-
- **`/workflows` command** — list, inspect, stop, pause, and remove background runs; runs started with `background: true` are reachable from the command
|
|
136
|
+
- **`/workflows` command** — list, inspect, stop, pause, **resume**, and remove background runs; runs started with `background: true` are reachable from the command
|
|
137
|
+
- **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
|
|
137
138
|
- **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
|
|
138
139
|
- **Live progress + token/cost display**, `Esc` to abort
|
|
139
140
|
- **Log persistence** to `.pi/workflows/runs/`
|
|
@@ -142,7 +143,6 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
|
|
|
142
143
|
|
|
143
144
|
Tracked toward closer parity with Claude Code dynamic workflows:
|
|
144
145
|
|
|
145
|
-
- **Resume** — journaled results, replay the unchanged prefix, run the rest live
|
|
146
146
|
- **Worktree isolation** for parallel edits, and **bundled `/deep-research`**
|
|
147
147
|
- **Saved workflows** as `/<name>` slash commands
|
|
148
148
|
|
|
@@ -33,6 +33,12 @@ export interface PersistedRunState {
|
|
|
33
33
|
output: number;
|
|
34
34
|
total: number;
|
|
35
35
|
};
|
|
36
|
+
/** Cached agent results for resume, keyed by deterministic call index. */
|
|
37
|
+
journal?: Array<{
|
|
38
|
+
index: number;
|
|
39
|
+
hash: string;
|
|
40
|
+
result: unknown;
|
|
41
|
+
}>;
|
|
36
42
|
}
|
|
37
43
|
export interface RunPersistence {
|
|
38
44
|
/** Save current run state. */
|
|
@@ -5,7 +5,7 @@ import { EventEmitter } from "node:events";
|
|
|
5
5
|
import type { WorkflowSnapshot } from "./display.js";
|
|
6
6
|
import { WorkflowError } from "./errors.js";
|
|
7
7
|
import { type PersistedRunState, type RunPersistence, type RunStatus } from "./run-persistence.js";
|
|
8
|
-
import { type WorkflowRunResult } from "./workflow.js";
|
|
8
|
+
import { type JournalEntry, type WorkflowRunResult } from "./workflow.js";
|
|
9
9
|
export interface ManagedRun {
|
|
10
10
|
runId: string;
|
|
11
11
|
status: RunStatus;
|
|
@@ -14,6 +14,11 @@ export interface ManagedRun {
|
|
|
14
14
|
error?: WorkflowError;
|
|
15
15
|
controller: AbortController;
|
|
16
16
|
startedAt: Date;
|
|
17
|
+
/** The real script, kept so the run can be resumed. */
|
|
18
|
+
script: string;
|
|
19
|
+
args?: unknown;
|
|
20
|
+
/** Accumulated agent results for resume (deterministic call index -> result). */
|
|
21
|
+
journal: JournalEntry[];
|
|
17
22
|
}
|
|
18
23
|
export interface WorkflowManagerOptions {
|
|
19
24
|
cwd?: string;
|
|
@@ -44,7 +49,8 @@ export declare class WorkflowManager extends EventEmitter {
|
|
|
44
49
|
*/
|
|
45
50
|
pause(runId: string): boolean;
|
|
46
51
|
/**
|
|
47
|
-
* Resume
|
|
52
|
+
* Resume an interrupted run: replay journaled results for the unchanged prefix
|
|
53
|
+
* and run the rest live. Returns false if there is nothing resumable.
|
|
48
54
|
*/
|
|
49
55
|
resume(runId: string): Promise<boolean>;
|
|
50
56
|
/**
|
package/dist/workflow-manager.js
CHANGED
|
@@ -40,6 +40,9 @@ export class WorkflowManager extends EventEmitter {
|
|
|
40
40
|
},
|
|
41
41
|
controller,
|
|
42
42
|
startedAt: new Date(),
|
|
43
|
+
script,
|
|
44
|
+
args,
|
|
45
|
+
journal: [],
|
|
43
46
|
};
|
|
44
47
|
this.runs.set(runId, managed);
|
|
45
48
|
// Persist initial state
|
|
@@ -82,17 +85,28 @@ export class WorkflowManager extends EventEmitter {
|
|
|
82
85
|
},
|
|
83
86
|
controller,
|
|
84
87
|
startedAt: new Date(),
|
|
88
|
+
script,
|
|
89
|
+
args,
|
|
90
|
+
journal: [],
|
|
85
91
|
};
|
|
86
92
|
this.runs.set(runId, managed);
|
|
87
93
|
return this.executeRun(managed, script, args);
|
|
88
94
|
}
|
|
89
|
-
async executeRun(managed, script, args) {
|
|
95
|
+
async executeRun(managed, script, args, resumeJournal) {
|
|
90
96
|
try {
|
|
91
97
|
const result = await runWorkflow(script, {
|
|
92
98
|
cwd: this.cwd,
|
|
93
99
|
args,
|
|
94
100
|
signal: managed.controller.signal,
|
|
95
101
|
concurrency: this.concurrency,
|
|
102
|
+
resumeJournal,
|
|
103
|
+
resumeFromRunId: resumeJournal ? managed.runId : undefined,
|
|
104
|
+
onAgentJournal: (entry) => {
|
|
105
|
+
// Append (crash-safe-ish): keep the latest entry per index, then persist.
|
|
106
|
+
managed.journal = managed.journal.filter((e) => e.index !== entry.index);
|
|
107
|
+
managed.journal.push(entry);
|
|
108
|
+
this.persistRun(managed);
|
|
109
|
+
},
|
|
96
110
|
onLog: (message) => {
|
|
97
111
|
managed.snapshot.logs.push(message);
|
|
98
112
|
this.emit("log", { runId: managed.runId, message });
|
|
@@ -152,7 +166,11 @@ export class WorkflowManager extends EventEmitter {
|
|
|
152
166
|
this.persistence.save({
|
|
153
167
|
runId: managed.runId,
|
|
154
168
|
workflowName: managed.snapshot.name,
|
|
155
|
-
script
|
|
169
|
+
// Persist the real script + journal so the run can be resumed. Runs live
|
|
170
|
+
// under .pi/workflows/runs/ — protect via directory permissions, not blanking.
|
|
171
|
+
script: managed.script,
|
|
172
|
+
args: managed.args,
|
|
173
|
+
journal: managed.journal,
|
|
156
174
|
status: managed.status,
|
|
157
175
|
phases: managed.snapshot.phases,
|
|
158
176
|
currentPhase: managed.snapshot.currentPhase,
|
|
@@ -183,15 +201,41 @@ export class WorkflowManager extends EventEmitter {
|
|
|
183
201
|
return true;
|
|
184
202
|
}
|
|
185
203
|
/**
|
|
186
|
-
* Resume
|
|
204
|
+
* Resume an interrupted run: replay journaled results for the unchanged prefix
|
|
205
|
+
* and run the rest live. Returns false if there is nothing resumable.
|
|
187
206
|
*/
|
|
188
207
|
async resume(runId) {
|
|
208
|
+
const active = this.runs.get(runId);
|
|
209
|
+
if (active?.status === "running")
|
|
210
|
+
return false; // already running
|
|
189
211
|
const persisted = this.persistence.load(runId);
|
|
190
|
-
if (persisted?.status
|
|
212
|
+
if (!persisted?.script || persisted.status === "completed")
|
|
191
213
|
return false;
|
|
192
|
-
|
|
193
|
-
|
|
214
|
+
const controller = new AbortController();
|
|
215
|
+
const managed = {
|
|
216
|
+
runId,
|
|
217
|
+
status: "running",
|
|
218
|
+
snapshot: {
|
|
219
|
+
name: persisted.workflowName,
|
|
220
|
+
phases: persisted.phases ?? [],
|
|
221
|
+
logs: persisted.logs ?? [],
|
|
222
|
+
agents: [],
|
|
223
|
+
agentCount: 0,
|
|
224
|
+
runningCount: 0,
|
|
225
|
+
doneCount: 0,
|
|
226
|
+
errorCount: 0,
|
|
227
|
+
},
|
|
228
|
+
controller,
|
|
229
|
+
startedAt: new Date(),
|
|
230
|
+
script: persisted.script,
|
|
231
|
+
args: persisted.args,
|
|
232
|
+
journal: persisted.journal ?? [],
|
|
233
|
+
};
|
|
234
|
+
this.runs.set(runId, managed);
|
|
235
|
+
const resumeJournal = new Map((persisted.journal ?? []).map((e) => [e.index, e]));
|
|
194
236
|
this.emit("resumed", { runId });
|
|
237
|
+
// Run in the background; executeRun records status/errors on the managed run.
|
|
238
|
+
void this.executeRun(managed, persisted.script, persisted.args, resumeJournal).catch(() => { });
|
|
195
239
|
return true;
|
|
196
240
|
}
|
|
197
241
|
/**
|
package/dist/workflow.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ export interface WorkflowMeta {
|
|
|
11
11
|
whenToUse?: string;
|
|
12
12
|
phases?: WorkflowMetaPhase[];
|
|
13
13
|
}
|
|
14
|
+
/** One cached agent() result, keyed by its deterministic call index. */
|
|
15
|
+
export interface JournalEntry {
|
|
16
|
+
index: number;
|
|
17
|
+
/** sha256 of the call's identity (prompt + model + phase + agentType + schema). */
|
|
18
|
+
hash: string;
|
|
19
|
+
result: unknown;
|
|
20
|
+
}
|
|
14
21
|
export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
15
22
|
args?: unknown;
|
|
16
23
|
agent?: Pick<WorkflowAgent, "run">;
|
|
@@ -25,6 +32,12 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
|
25
32
|
persistLogs?: boolean;
|
|
26
33
|
/** Run ID for persistence. Auto-generated if not provided. */
|
|
27
34
|
runId?: string;
|
|
35
|
+
/** Resume: cached agent results keyed by deterministic call index. */
|
|
36
|
+
resumeJournal?: Map<number, JournalEntry>;
|
|
37
|
+
/** Resume: the run being resumed (informational; enables resume mode). */
|
|
38
|
+
resumeFromRunId?: string;
|
|
39
|
+
/** Called after each live agent completes so the caller can persist the journal. */
|
|
40
|
+
onAgentJournal?: (entry: JournalEntry) => void;
|
|
28
41
|
onLog?: (message: string) => void;
|
|
29
42
|
onPhase?: (title: string) => void;
|
|
30
43
|
onAgentStart?: (event: {
|
package/dist/workflow.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import vm from "node:vm";
|
|
2
3
|
import { parse } from "acorn";
|
|
3
4
|
import { WorkflowAgent } from "./agent.js";
|
|
@@ -25,6 +26,7 @@ export async function runWorkflow(script, options = {}) {
|
|
|
25
26
|
logs: [],
|
|
26
27
|
phases: [],
|
|
27
28
|
agentCount: 0,
|
|
29
|
+
callSeq: 0,
|
|
28
30
|
spent: 0,
|
|
29
31
|
tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
|
|
30
32
|
};
|
|
@@ -67,6 +69,20 @@ export async function runWorkflow(script, options = {}) {
|
|
|
67
69
|
const requestedLabel = agentOptions.label?.trim();
|
|
68
70
|
// Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
|
|
69
71
|
const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
|
|
72
|
+
// Deterministic resume key: assigned at lexical call time, before the limiter,
|
|
73
|
+
// so parallel()/pipeline() fan-out is reproducible for a fixed script.
|
|
74
|
+
const callIndex = state.callSeq++;
|
|
75
|
+
const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions);
|
|
76
|
+
// Resume: replay a cached result for an unchanged call (matching hash), without
|
|
77
|
+
// consuming a concurrency slot, tokens, or a real subagent run.
|
|
78
|
+
const cached = options.resumeJournal?.get(callIndex);
|
|
79
|
+
if (cached && cached.hash === callHash) {
|
|
80
|
+
state.agentCount++;
|
|
81
|
+
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
82
|
+
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
|
|
83
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
|
|
84
|
+
return cached.result;
|
|
85
|
+
}
|
|
70
86
|
return limiter(async () => {
|
|
71
87
|
state.agentCount++;
|
|
72
88
|
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
@@ -101,6 +117,7 @@ export async function runWorkflow(script, options = {}) {
|
|
|
101
117
|
}), timeout, `Agent "${label}" timed out after ${timeout}ms`);
|
|
102
118
|
throwIfAborted();
|
|
103
119
|
const tokens = recordTokens(result);
|
|
120
|
+
options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
|
|
104
121
|
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
|
|
105
122
|
return result;
|
|
106
123
|
}
|
|
@@ -348,6 +365,17 @@ function createLimiter(limit) {
|
|
|
348
365
|
function defaultAgentLabel(phase, index) {
|
|
349
366
|
return phase ? `${phase} agent ${index}` : `agent ${index}`;
|
|
350
367
|
}
|
|
368
|
+
/** Stable identity hash for an agent() call — a cache miss on resume when anything changes. */
|
|
369
|
+
function hashAgentCall(prompt, model, phase, options) {
|
|
370
|
+
const identity = JSON.stringify({
|
|
371
|
+
prompt,
|
|
372
|
+
model: model ?? null,
|
|
373
|
+
phase: phase ?? null,
|
|
374
|
+
agentType: options.agentType ?? null,
|
|
375
|
+
schema: options.schema ?? null,
|
|
376
|
+
});
|
|
377
|
+
return createHash("sha256").update(identity).digest("hex");
|
|
378
|
+
}
|
|
351
379
|
function buildAgentInstructions(phase, options) {
|
|
352
380
|
const lines = [];
|
|
353
381
|
if (phase)
|
package/package.json
CHANGED
package/src/run-persistence.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface PersistedRunState {
|
|
|
40
40
|
output: number;
|
|
41
41
|
total: number;
|
|
42
42
|
};
|
|
43
|
+
/** Cached agent results for resume, keyed by deterministic call index. */
|
|
44
|
+
journal?: Array<{ index: number; hash: string; result: unknown }>;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export interface RunPersistence {
|
package/src/workflow-manager.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type RunPersistence,
|
|
13
13
|
type RunStatus,
|
|
14
14
|
} from "./run-persistence.js";
|
|
15
|
-
import { parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
|
|
15
|
+
import { type JournalEntry, parseWorkflowScript, runWorkflow, type WorkflowRunResult } from "./workflow.js";
|
|
16
16
|
|
|
17
17
|
export interface ManagedRun {
|
|
18
18
|
runId: string;
|
|
@@ -22,6 +22,11 @@ export interface ManagedRun {
|
|
|
22
22
|
error?: WorkflowError;
|
|
23
23
|
controller: AbortController;
|
|
24
24
|
startedAt: Date;
|
|
25
|
+
/** The real script, kept so the run can be resumed. */
|
|
26
|
+
script: string;
|
|
27
|
+
args?: unknown;
|
|
28
|
+
/** Accumulated agent results for resume (deterministic call index -> result). */
|
|
29
|
+
journal: JournalEntry[];
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export interface WorkflowManagerOptions {
|
|
@@ -67,6 +72,9 @@ export class WorkflowManager extends EventEmitter {
|
|
|
67
72
|
},
|
|
68
73
|
controller,
|
|
69
74
|
startedAt: new Date(),
|
|
75
|
+
script,
|
|
76
|
+
args,
|
|
77
|
+
journal: [],
|
|
70
78
|
};
|
|
71
79
|
|
|
72
80
|
this.runs.set(runId, managed);
|
|
@@ -115,19 +123,35 @@ export class WorkflowManager extends EventEmitter {
|
|
|
115
123
|
},
|
|
116
124
|
controller,
|
|
117
125
|
startedAt: new Date(),
|
|
126
|
+
script,
|
|
127
|
+
args,
|
|
128
|
+
journal: [],
|
|
118
129
|
};
|
|
119
130
|
|
|
120
131
|
this.runs.set(runId, managed);
|
|
121
132
|
return this.executeRun(managed, script, args);
|
|
122
133
|
}
|
|
123
134
|
|
|
124
|
-
private async executeRun(
|
|
135
|
+
private async executeRun(
|
|
136
|
+
managed: ManagedRun,
|
|
137
|
+
script: string,
|
|
138
|
+
args?: unknown,
|
|
139
|
+
resumeJournal?: Map<number, JournalEntry>,
|
|
140
|
+
): Promise<WorkflowRunResult> {
|
|
125
141
|
try {
|
|
126
142
|
const result = await runWorkflow(script, {
|
|
127
143
|
cwd: this.cwd,
|
|
128
144
|
args,
|
|
129
145
|
signal: managed.controller.signal,
|
|
130
146
|
concurrency: this.concurrency,
|
|
147
|
+
resumeJournal,
|
|
148
|
+
resumeFromRunId: resumeJournal ? managed.runId : undefined,
|
|
149
|
+
onAgentJournal: (entry) => {
|
|
150
|
+
// Append (crash-safe-ish): keep the latest entry per index, then persist.
|
|
151
|
+
managed.journal = managed.journal.filter((e) => e.index !== entry.index);
|
|
152
|
+
managed.journal.push(entry);
|
|
153
|
+
this.persistRun(managed);
|
|
154
|
+
},
|
|
131
155
|
onLog: (message) => {
|
|
132
156
|
managed.snapshot.logs.push(message);
|
|
133
157
|
this.emit("log", { runId: managed.runId, message });
|
|
@@ -197,7 +221,11 @@ export class WorkflowManager extends EventEmitter {
|
|
|
197
221
|
this.persistence.save({
|
|
198
222
|
runId: managed.runId,
|
|
199
223
|
workflowName: managed.snapshot.name,
|
|
200
|
-
script
|
|
224
|
+
// Persist the real script + journal so the run can be resumed. Runs live
|
|
225
|
+
// under .pi/workflows/runs/ — protect via directory permissions, not blanking.
|
|
226
|
+
script: managed.script,
|
|
227
|
+
args: managed.args,
|
|
228
|
+
journal: managed.journal,
|
|
201
229
|
status: managed.status,
|
|
202
230
|
phases: managed.snapshot.phases,
|
|
203
231
|
currentPhase: managed.snapshot.currentPhase,
|
|
@@ -230,15 +258,42 @@ export class WorkflowManager extends EventEmitter {
|
|
|
230
258
|
}
|
|
231
259
|
|
|
232
260
|
/**
|
|
233
|
-
* Resume
|
|
261
|
+
* Resume an interrupted run: replay journaled results for the unchanged prefix
|
|
262
|
+
* and run the rest live. Returns false if there is nothing resumable.
|
|
234
263
|
*/
|
|
235
264
|
async resume(runId: string): Promise<boolean> {
|
|
265
|
+
const active = this.runs.get(runId);
|
|
266
|
+
if (active?.status === "running") return false; // already running
|
|
267
|
+
|
|
236
268
|
const persisted = this.persistence.load(runId);
|
|
237
|
-
if (persisted?.status
|
|
269
|
+
if (!persisted?.script || persisted.status === "completed") return false;
|
|
270
|
+
|
|
271
|
+
const controller = new AbortController();
|
|
272
|
+
const managed: ManagedRun = {
|
|
273
|
+
runId,
|
|
274
|
+
status: "running",
|
|
275
|
+
snapshot: {
|
|
276
|
+
name: persisted.workflowName,
|
|
277
|
+
phases: persisted.phases ?? [],
|
|
278
|
+
logs: persisted.logs ?? [],
|
|
279
|
+
agents: [],
|
|
280
|
+
agentCount: 0,
|
|
281
|
+
runningCount: 0,
|
|
282
|
+
doneCount: 0,
|
|
283
|
+
errorCount: 0,
|
|
284
|
+
},
|
|
285
|
+
controller,
|
|
286
|
+
startedAt: new Date(),
|
|
287
|
+
script: persisted.script,
|
|
288
|
+
args: persisted.args,
|
|
289
|
+
journal: persisted.journal ?? [],
|
|
290
|
+
};
|
|
291
|
+
this.runs.set(runId, managed);
|
|
238
292
|
|
|
239
|
-
|
|
240
|
-
// Full resume would require re-executing the script with cached results
|
|
293
|
+
const resumeJournal = new Map((persisted.journal ?? []).map((e) => [e.index, e] as const));
|
|
241
294
|
this.emit("resumed", { runId });
|
|
295
|
+
// Run in the background; executeRun records status/errors on the managed run.
|
|
296
|
+
void this.executeRun(managed, persisted.script, persisted.args, resumeJournal).catch(() => {});
|
|
242
297
|
return true;
|
|
243
298
|
}
|
|
244
299
|
|
package/src/workflow.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import vm from "node:vm";
|
|
2
3
|
import type { Node } from "acorn";
|
|
3
4
|
import { parse } from "acorn";
|
|
@@ -22,6 +23,14 @@ export interface WorkflowMeta {
|
|
|
22
23
|
phases?: WorkflowMetaPhase[];
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** One cached agent() result, keyed by its deterministic call index. */
|
|
27
|
+
export interface JournalEntry {
|
|
28
|
+
index: number;
|
|
29
|
+
/** sha256 of the call's identity (prompt + model + phase + agentType + schema). */
|
|
30
|
+
hash: string;
|
|
31
|
+
result: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
26
35
|
args?: unknown;
|
|
27
36
|
agent?: Pick<WorkflowAgent, "run">;
|
|
@@ -36,6 +45,12 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
|
36
45
|
persistLogs?: boolean;
|
|
37
46
|
/** Run ID for persistence. Auto-generated if not provided. */
|
|
38
47
|
runId?: string;
|
|
48
|
+
/** Resume: cached agent results keyed by deterministic call index. */
|
|
49
|
+
resumeJournal?: Map<number, JournalEntry>;
|
|
50
|
+
/** Resume: the run being resumed (informational; enables resume mode). */
|
|
51
|
+
resumeFromRunId?: string;
|
|
52
|
+
/** Called after each live agent completes so the caller can persist the journal. */
|
|
53
|
+
onAgentJournal?: (entry: JournalEntry) => void;
|
|
39
54
|
onLog?: (message: string) => void;
|
|
40
55
|
onPhase?: (title: string) => void;
|
|
41
56
|
onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
|
|
@@ -75,6 +90,8 @@ interface RuntimeState {
|
|
|
75
90
|
logs: string[];
|
|
76
91
|
phases: string[];
|
|
77
92
|
agentCount: number;
|
|
93
|
+
/** Monotonic, assigned at lexical agent() call time — the stable resume key. */
|
|
94
|
+
callSeq: number;
|
|
78
95
|
spent: number;
|
|
79
96
|
tokenUsage: {
|
|
80
97
|
input: number;
|
|
@@ -112,6 +129,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
112
129
|
logs: [],
|
|
113
130
|
phases: [],
|
|
114
131
|
agentCount: 0,
|
|
132
|
+
callSeq: 0,
|
|
115
133
|
spent: 0,
|
|
116
134
|
tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
|
|
117
135
|
};
|
|
@@ -170,6 +188,22 @@ export async function runWorkflow<T = unknown>(
|
|
|
170
188
|
// Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
|
|
171
189
|
const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
|
|
172
190
|
|
|
191
|
+
// Deterministic resume key: assigned at lexical call time, before the limiter,
|
|
192
|
+
// so parallel()/pipeline() fan-out is reproducible for a fixed script.
|
|
193
|
+
const callIndex = state.callSeq++;
|
|
194
|
+
const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions);
|
|
195
|
+
|
|
196
|
+
// Resume: replay a cached result for an unchanged call (matching hash), without
|
|
197
|
+
// consuming a concurrency slot, tokens, or a real subagent run.
|
|
198
|
+
const cached = options.resumeJournal?.get(callIndex);
|
|
199
|
+
if (cached && cached.hash === callHash) {
|
|
200
|
+
state.agentCount++;
|
|
201
|
+
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
202
|
+
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
|
|
203
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: cached.result, tokens: 0 });
|
|
204
|
+
return cached.result;
|
|
205
|
+
}
|
|
206
|
+
|
|
173
207
|
return limiter(async () => {
|
|
174
208
|
state.agentCount++;
|
|
175
209
|
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
@@ -214,6 +248,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
214
248
|
throwIfAborted();
|
|
215
249
|
|
|
216
250
|
const tokens = recordTokens(result);
|
|
251
|
+
options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
|
|
217
252
|
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
|
|
218
253
|
return result;
|
|
219
254
|
} catch (error) {
|
|
@@ -481,6 +516,23 @@ function defaultAgentLabel(phase: string | undefined, index: number): string {
|
|
|
481
516
|
return phase ? `${phase} agent ${index}` : `agent ${index}`;
|
|
482
517
|
}
|
|
483
518
|
|
|
519
|
+
/** Stable identity hash for an agent() call — a cache miss on resume when anything changes. */
|
|
520
|
+
function hashAgentCall(
|
|
521
|
+
prompt: string,
|
|
522
|
+
model: string | undefined,
|
|
523
|
+
phase: string | undefined,
|
|
524
|
+
options: AgentOptions,
|
|
525
|
+
): string {
|
|
526
|
+
const identity = JSON.stringify({
|
|
527
|
+
prompt,
|
|
528
|
+
model: model ?? null,
|
|
529
|
+
phase: phase ?? null,
|
|
530
|
+
agentType: options.agentType ?? null,
|
|
531
|
+
schema: options.schema ?? null,
|
|
532
|
+
});
|
|
533
|
+
return createHash("sha256").update(identity).digest("hex");
|
|
534
|
+
}
|
|
535
|
+
|
|
484
536
|
function buildAgentInstructions(phase: string | undefined, options: AgentOptions): string | undefined {
|
|
485
537
|
const lines = [];
|
|
486
538
|
if (phase) lines.push(`Workflow phase: ${phase}`);
|