@signalridge/pi-workflows 1.3.2 → 1.5.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/CHANGELOG.md +140 -0
- package/README.md +93 -22
- package/package.json +5 -4
- package/skills/workflow-authoring/SKILL.md +171 -18
- package/skills/workflow-authoring/examples/named-task-graph.js +34 -0
- package/skills/workflow-authoring/references/capabilities.md +4 -3
- package/skills/workflow-authoring/references/capability-details.md +28 -8
- package/skills/workflow-authoring/references/debugging.md +2 -2
- package/skills/workflow-authoring/references/focused-recipes.md +1 -0
- package/skills/workflow-authoring/references/lifecycle.md +3 -3
- package/skills/workflow-authoring/references/pattern-selection.md +1 -0
- package/skills/workflow-authoring/references/registry-ownership.md +6 -4
- package/skills/workflow-authoring/references/review.md +12 -7
- package/skills/workflow-authoring/references/runtime.md +6 -5
- package/skills/workflow-authoring/references/versions.md +1 -1
- package/skills/workflow-patterns/SKILL.md +72 -33
- package/skills/workflow-review/SKILL.md +70 -0
- package/src/builtins.ts +23 -23
- package/src/capabilities.ts +39 -7
- package/src/engine.ts +582 -142
- package/src/index.ts +413 -154
- package/src/journal.ts +447 -169
- package/src/orchestrator.ts +478 -0
- package/src/rpc-client.ts +37 -48
- package/src/runtime.ts +508 -167
- package/src/ui.ts +29 -4
package/src/runtime.ts
CHANGED
|
@@ -21,9 +21,17 @@
|
|
|
21
21
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
23
|
import vm from "node:vm";
|
|
24
|
+
import type { ManagedRoutingPolicy } from "@signalridge/pi-subagents-protocol";
|
|
25
|
+
import { agentTierPolicyIdentity, isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
|
|
24
26
|
import { parse } from "acorn";
|
|
25
27
|
import { checkRuntimeBindings } from "./capabilities.js";
|
|
26
28
|
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
29
|
+
import {
|
|
30
|
+
executeOrchestration,
|
|
31
|
+
normalizeOrchestration,
|
|
32
|
+
type OrchestrationResult,
|
|
33
|
+
type OrchestrationTaskContext,
|
|
34
|
+
} from "./orchestrator.js";
|
|
27
35
|
import { SharedStore } from "./shared-store.js";
|
|
28
36
|
|
|
29
37
|
// ── Meta contract ────────────────────────────────────────────────────────────
|
|
@@ -31,24 +39,12 @@ import { SharedStore } from "./shared-store.js";
|
|
|
31
39
|
export interface WorkflowMetaPhase {
|
|
32
40
|
title: string;
|
|
33
41
|
detail?: string;
|
|
34
|
-
/**
|
|
35
|
-
* Phase-level model preference — currently validated but not routed.
|
|
36
|
-
* Per-call model routing requires a protocol extension (see README
|
|
37
|
-
* "Intentional divergences"); use agent({ tier }) instead.
|
|
38
|
-
*/
|
|
39
|
-
model?: string;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export interface WorkflowMeta {
|
|
43
45
|
name: string;
|
|
44
46
|
description: string;
|
|
45
47
|
phases?: WorkflowMetaPhase[];
|
|
46
|
-
/**
|
|
47
|
-
* Run-level model preference — currently validated but not routed.
|
|
48
|
-
* Per-call model routing requires a protocol extension (see README
|
|
49
|
-
* "Intentional divergences"); use agent({ tier }) instead.
|
|
50
|
-
*/
|
|
51
|
-
model?: string;
|
|
52
48
|
}
|
|
53
49
|
|
|
54
50
|
/** A resolved, journaled agent() call. */
|
|
@@ -59,6 +55,12 @@ export interface JournalEntry {
|
|
|
59
55
|
hash: string;
|
|
60
56
|
result: unknown;
|
|
61
57
|
storeDelta?: Record<string, unknown>;
|
|
58
|
+
/** Agent tier this call resolved to, as the host reported it. */
|
|
59
|
+
tier?: string;
|
|
60
|
+
/** Generation for a nested workflow replay boundary. */
|
|
61
|
+
generation?: number;
|
|
62
|
+
/** Logical agents represented by a nested workflow replay boundary. */
|
|
63
|
+
agentCount?: number;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
export interface AgentUsage {
|
|
@@ -72,11 +74,12 @@ export interface AgentUsage {
|
|
|
72
74
|
|
|
73
75
|
export interface SharedRuntime {
|
|
74
76
|
limiter: Limiter;
|
|
77
|
+
/** Logical calls, including replayed calls, used for reporting/labels. */
|
|
75
78
|
agentCount: number;
|
|
79
|
+
/** Real dispatches only; replayed journal values do not consume maxAgents. */
|
|
80
|
+
physicalAgentCount: number;
|
|
76
81
|
spent: number;
|
|
77
82
|
tokenUsage: AgentUsage;
|
|
78
|
-
depth: number;
|
|
79
|
-
nestedCallSeq: number;
|
|
80
83
|
runFatalController: AbortController;
|
|
81
84
|
inFlight: Set<Promise<unknown>>;
|
|
82
85
|
activeThreads: Set<string>;
|
|
@@ -97,11 +100,12 @@ export interface AgentRunOptions {
|
|
|
97
100
|
schema?: unknown;
|
|
98
101
|
signal?: AbortSignal;
|
|
99
102
|
instructions?: string;
|
|
100
|
-
model?: string;
|
|
101
103
|
tier?: string;
|
|
102
104
|
cwd?: string;
|
|
103
105
|
/** Named subagent type; resolved by pi-subagents at dispatch. */
|
|
104
106
|
agentType?: string;
|
|
107
|
+
/** The runtime frame that owns this call (used to namespace nested workflows). */
|
|
108
|
+
runId?: string;
|
|
105
109
|
/** Lexical call index — engine-side spawnKey identity (A4). */
|
|
106
110
|
callIndex?: number;
|
|
107
111
|
/** Stable hash of the call inputs — engine-side resume comparison (A4). */
|
|
@@ -116,7 +120,14 @@ export interface AgentRunOptions {
|
|
|
116
120
|
/** Additional tool names denied for this call. */
|
|
117
121
|
excludeTools?: string[];
|
|
118
122
|
onModelResolved?: (id: string) => void;
|
|
119
|
-
|
|
123
|
+
/**
|
|
124
|
+
* The tier the host actually selected, which may be one this call did not
|
|
125
|
+
* name — an agent's own tier, or the host's default. A callback rather than a
|
|
126
|
+
* mutation of `tier`: the dispatcher does not own this object, and reading a
|
|
127
|
+
* reply back out of an input field would only work while every layer happened
|
|
128
|
+
* to preserve its identity.
|
|
129
|
+
*/
|
|
130
|
+
onTierResolved?: (tier: string) => void;
|
|
120
131
|
onUsage?: (usage: AgentUsage) => void;
|
|
121
132
|
onHistory?: (history: unknown[]) => void;
|
|
122
133
|
}
|
|
@@ -125,7 +136,12 @@ export interface AgentOptions {
|
|
|
125
136
|
label?: string;
|
|
126
137
|
phase?: string;
|
|
127
138
|
schema?: unknown;
|
|
128
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Agent tier: a key from the host's `agentTiers` catalogue. This is the only
|
|
141
|
+
* model control a workflow has — there is no per-call model or thinking, so
|
|
142
|
+
* a script cannot pin a vendor the host did not choose. Omitting it uses the
|
|
143
|
+
* agent's own tier, else the host's configured default.
|
|
144
|
+
*/
|
|
129
145
|
tier?: string;
|
|
130
146
|
isolation?: "worktree";
|
|
131
147
|
agentType?: string;
|
|
@@ -145,7 +161,7 @@ export interface CheckpointOptions {
|
|
|
145
161
|
}
|
|
146
162
|
|
|
147
163
|
export interface WorkflowRuntimeEvent {
|
|
148
|
-
type: "phase" | "workflow" | "quality" | "control-attempt";
|
|
164
|
+
type: "phase" | "workflow" | "quality" | "control-attempt" | "task";
|
|
149
165
|
[key: string]: unknown;
|
|
150
166
|
}
|
|
151
167
|
|
|
@@ -154,6 +170,31 @@ export interface WorkflowRunOptions {
|
|
|
154
170
|
agent?: WorkflowAgentRunner;
|
|
155
171
|
/** The session's main model (provider/id), shown for default agents. */
|
|
156
172
|
mainModel?: string;
|
|
173
|
+
/** The host's Agent-tier catalogue, captured fresh for this start/resume. */
|
|
174
|
+
routingPolicy?: ManagedRoutingPolicy;
|
|
175
|
+
/**
|
|
176
|
+
* This script ships with the package rather than being written here.
|
|
177
|
+
*
|
|
178
|
+
* The tier catalogue belongs to the user, names included. A script we ship
|
|
179
|
+
* cannot assert that `low` exists on someone whose tiers are called
|
|
180
|
+
* `cheap`/`deep`, so its tier is a preference: an undefined one falls back to
|
|
181
|
+
* the host's default with a warning. A script the user wrote names tiers from
|
|
182
|
+
* the user's own catalogue, so an undefined one there is a typo and fails
|
|
183
|
+
* closed rather than quietly running a different model.
|
|
184
|
+
*/
|
|
185
|
+
shippedScript?: boolean;
|
|
186
|
+
/**
|
|
187
|
+
* Whole-catalogue identity, used wherever a per-tier one would be dishonest:
|
|
188
|
+
* a nested `workflow()` boundary, where the parent caches one value for a
|
|
189
|
+
* child whose per-call tiers it cannot see, and an `agent()` call that names
|
|
190
|
+
* no tier, whose real tier may come from the agent's frontmatter. A call that
|
|
191
|
+
* names its tier keys on that tier's policy instead.
|
|
192
|
+
*/
|
|
193
|
+
routingPolicyFingerprint?: string;
|
|
194
|
+
/** Engine-owned execution nonce used to isolate stale nested managed calls. */
|
|
195
|
+
executionNonce?: string;
|
|
196
|
+
/** Runtime frame depth; siblings inherit the same depth and may run concurrently. */
|
|
197
|
+
workflowDepth?: number;
|
|
157
198
|
cwd?: string;
|
|
158
199
|
concurrency?: number;
|
|
159
200
|
agentRetries?: number;
|
|
@@ -167,20 +208,35 @@ export interface WorkflowRunOptions {
|
|
|
167
208
|
toolset?: string;
|
|
168
209
|
excludeTools?: string[];
|
|
169
210
|
onAgentJournal?: (entry: JournalEntry) => void;
|
|
211
|
+
/** Journal one completed nested workflow as a stable parent call. */
|
|
212
|
+
onWorkflowJournal?: (entry: JournalEntry) => void;
|
|
170
213
|
onRetrySpend?: (tokens: number) => void;
|
|
171
214
|
sharedRuntime?: SharedRuntime;
|
|
172
215
|
initialTokenUsage?: AgentUsage;
|
|
173
216
|
sharedStore?: SharedStore;
|
|
174
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Resolve a nested `workflow(name)` reference, reporting whether the script
|
|
219
|
+
* it returned ships with this package. Shipped-ness travels with the script,
|
|
220
|
+
* not with the frame that called it — see `shippedScript`.
|
|
221
|
+
*/
|
|
222
|
+
loadSavedWorkflow?: (name: string) => { script: string; shippedScript?: boolean } | undefined;
|
|
175
223
|
confirm?: (promptText: string, options: CheckpointOptions) => Promise<unknown>;
|
|
176
224
|
onLog?: (message: string) => void;
|
|
177
225
|
onPhase?: (title: string) => void;
|
|
178
226
|
onRuntimeEvent?: (event: WorkflowRuntimeEvent) => void;
|
|
179
|
-
onAgentStart?: (event: {
|
|
227
|
+
onAgentStart?: (event: {
|
|
228
|
+
id: string;
|
|
229
|
+
label: string;
|
|
230
|
+
phase?: string;
|
|
231
|
+
prompt: string;
|
|
232
|
+
model?: string;
|
|
233
|
+
tier?: string;
|
|
234
|
+
}) => void;
|
|
180
235
|
onAgentEnd?: (event: {
|
|
181
236
|
id: string;
|
|
182
237
|
label: string;
|
|
183
238
|
phase?: string;
|
|
239
|
+
tier?: string;
|
|
184
240
|
result: unknown;
|
|
185
241
|
tokens?: number;
|
|
186
242
|
tokenUsage?: AgentUsage;
|
|
@@ -212,10 +268,16 @@ export interface RuntimeState {
|
|
|
212
268
|
currentPhase?: string;
|
|
213
269
|
phaseBudgets: Map<string, { budget: number; startSpent: number; warned: boolean }>;
|
|
214
270
|
callSeq: number;
|
|
271
|
+
/** Live generation counters for nested workflow replay boundaries. */
|
|
272
|
+
workflowGenerations: Map<number, number>;
|
|
215
273
|
firstMiss: number;
|
|
216
274
|
}
|
|
217
275
|
|
|
218
276
|
export const MAX_AGENTS_PER_RUN = 1000;
|
|
277
|
+
/** Maximum journal-compatible lexical calls in one workflow frame (indices 0..999). */
|
|
278
|
+
export const MAX_CALLS_PER_WORKFLOW = 1_000;
|
|
279
|
+
/** Per-call item cap, matching the bounded batch contract used by reference engines. */
|
|
280
|
+
export const MAX_FANOUT_ITEMS = 4_096;
|
|
219
281
|
export const MAX_CONCURRENCY = 16;
|
|
220
282
|
export const MAX_AGENT_RETRIES = 3;
|
|
221
283
|
export const DEFAULT_AGENT_TIMEOUT_MS: number | null = 300_000;
|
|
@@ -223,7 +285,55 @@ export const MAX_SCRIPT_SYNC_MS = 1_000;
|
|
|
223
285
|
|
|
224
286
|
// ── Determinism guardrail ────────────────────────────────────────────────────
|
|
225
287
|
|
|
226
|
-
|
|
288
|
+
interface AstRecord {
|
|
289
|
+
readonly [key: string]: unknown;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isAstRecord(value: unknown): value is AstRecord {
|
|
293
|
+
return typeof value === "object" && value !== null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Detect actual nondeterministic expressions, not words inside user prompt strings. */
|
|
297
|
+
function containsNondeterminism(value: unknown, seen = new WeakSet<object>()): boolean {
|
|
298
|
+
if (Array.isArray(value)) return value.some((item) => containsNondeterminism(item, seen));
|
|
299
|
+
if (!isAstRecord(value)) return false;
|
|
300
|
+
if (seen.has(value)) return false;
|
|
301
|
+
seen.add(value);
|
|
302
|
+
|
|
303
|
+
const type = value.type;
|
|
304
|
+
if (type === "MemberExpression") {
|
|
305
|
+
const object = value.object;
|
|
306
|
+
const property = value.property;
|
|
307
|
+
const objectName =
|
|
308
|
+
isAstRecord(object) && object.type === "Identifier" && typeof object.name === "string" ? object.name : undefined;
|
|
309
|
+
// Leave computed properties to the in-realm stubs so the runtime still
|
|
310
|
+
// guards aliases such as Math["random"] while this precheck ignores text
|
|
311
|
+
// embedded in prompt literals.
|
|
312
|
+
const propertyName =
|
|
313
|
+
isAstRecord(property) && property.type === "Identifier" && typeof property.name === "string"
|
|
314
|
+
? property.name
|
|
315
|
+
: undefined;
|
|
316
|
+
if ((objectName === "Date" && propertyName === "now") || (objectName === "Math" && propertyName === "random")) {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (
|
|
321
|
+
type === "NewExpression" &&
|
|
322
|
+
isAstRecord(value.callee) &&
|
|
323
|
+
value.callee.type === "Identifier" &&
|
|
324
|
+
value.callee.name === "Date" &&
|
|
325
|
+
Array.isArray(value.arguments) &&
|
|
326
|
+
value.arguments.length === 0
|
|
327
|
+
) {
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const [key, child] of Object.entries(value)) {
|
|
332
|
+
if (key === "loc" || key === "start" || key === "end") continue;
|
|
333
|
+
if (containsNondeterminism(child, seen)) return true;
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
227
337
|
|
|
228
338
|
/**
|
|
229
339
|
* Run inside the vm realm BEFORE the user script. Neutered builtins would break
|
|
@@ -264,14 +374,6 @@ const DETERMINISM_PRELUDE = [
|
|
|
264
374
|
* is rejected so the meta is fully static and deterministic.
|
|
265
375
|
*/
|
|
266
376
|
export function parseWorkflowScript(script: string): { meta: WorkflowMeta; body: string } {
|
|
267
|
-
if (DETERMINISM_BLOCKLIST.test(script)) {
|
|
268
|
-
throw new WorkflowError(
|
|
269
|
-
"Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable",
|
|
270
|
-
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
271
|
-
{ recoverable: false },
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
377
|
const ast = parse(script, {
|
|
276
378
|
ecmaVersion: "latest",
|
|
277
379
|
sourceType: "module",
|
|
@@ -279,6 +381,13 @@ export function parseWorkflowScript(script: string): { meta: WorkflowMeta; body:
|
|
|
279
381
|
allowReturnOutsideFunction: true,
|
|
280
382
|
ranges: false,
|
|
281
383
|
});
|
|
384
|
+
if (containsNondeterminism(ast)) {
|
|
385
|
+
throw new WorkflowError(
|
|
386
|
+
"Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable",
|
|
387
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
388
|
+
{ recoverable: false },
|
|
389
|
+
);
|
|
390
|
+
}
|
|
282
391
|
|
|
283
392
|
const first = ast.body?.[0] as { type?: string } | undefined;
|
|
284
393
|
if (first?.type !== "ExportNamedDeclaration") {
|
|
@@ -401,26 +510,29 @@ function propertyKey(node: unknown, path: string): string {
|
|
|
401
510
|
|
|
402
511
|
function validateMeta(meta: unknown): asserts meta is WorkflowMeta {
|
|
403
512
|
if (!meta || typeof meta !== "object") throw new Error("meta must be an object");
|
|
404
|
-
const value = meta as WorkflowMeta
|
|
513
|
+
const value = meta as WorkflowMeta & Record<string, unknown>;
|
|
405
514
|
if (typeof value.name !== "string" || !value.name.trim() || value.name.length > 512)
|
|
406
515
|
throw new Error("meta.name must be a non-empty string of at most 512 characters");
|
|
407
516
|
if (typeof value.description !== "string" || !value.description.trim() || value.description.length > 100_000)
|
|
408
517
|
throw new Error("meta.description must be a non-empty string of at most 100000 characters");
|
|
409
|
-
if (
|
|
518
|
+
if (Object.hasOwn(value, "model") || Object.hasOwn(value, "thinking")) {
|
|
519
|
+
throw new Error("workflow meta model/thinking fields are unsupported; use agent({ tier })");
|
|
520
|
+
}
|
|
410
521
|
if (value.phases !== undefined) {
|
|
411
522
|
if (!Array.isArray(value.phases)) throw new Error("meta.phases must be an array");
|
|
412
523
|
for (const phase of value.phases) {
|
|
413
524
|
if (
|
|
414
525
|
!phase ||
|
|
415
526
|
typeof phase !== "object" ||
|
|
416
|
-
typeof phase.title !== "string" ||
|
|
417
|
-
!phase.title.trim() ||
|
|
418
|
-
phase.title.length > 512
|
|
527
|
+
typeof (phase as { title?: unknown }).title !== "string" ||
|
|
528
|
+
!(phase as { title: string }).title.trim() ||
|
|
529
|
+
(phase as { title: string }).title.length > 512
|
|
419
530
|
) {
|
|
420
531
|
throw new Error("each meta phase must have a title string");
|
|
421
532
|
}
|
|
422
|
-
|
|
423
|
-
|
|
533
|
+
const phaseRecord = phase as unknown as Record<string, unknown>;
|
|
534
|
+
if (Object.hasOwn(phaseRecord, "model") || Object.hasOwn(phaseRecord, "thinking")) {
|
|
535
|
+
throw new Error("workflow phase model/thinking fields are unsupported; use agent({ tier })");
|
|
424
536
|
}
|
|
425
537
|
}
|
|
426
538
|
}
|
|
@@ -455,28 +567,48 @@ function hashIdentity(value: unknown): string {
|
|
|
455
567
|
/**
|
|
456
568
|
* Stable identity hash for an agent() call. Must cover every input that can
|
|
457
569
|
* change the outcome so a cache-miss is never served on resume. The agent
|
|
458
|
-
* definition is represented by the agentType name
|
|
459
|
-
* in pi-subagents
|
|
570
|
+
* definition is represented by the agentType name — the definition itself lives
|
|
571
|
+
* in pi-subagents, so a name-only key is the honest observable, and there is no
|
|
572
|
+
* second agent-definition field to hash beside it.
|
|
460
573
|
*/
|
|
461
574
|
function hashAgentCall(
|
|
462
575
|
prompt: string,
|
|
463
|
-
model: string | undefined,
|
|
464
576
|
phase: string | undefined,
|
|
465
577
|
options: AgentOptions,
|
|
466
|
-
|
|
578
|
+
routingPolicy: ManagedRoutingPolicy | undefined,
|
|
579
|
+
routingPolicyFingerprint: string | undefined,
|
|
467
580
|
): string {
|
|
581
|
+
// A call that names its tier is keyed on that tier's policy alone, so
|
|
582
|
+
// defining or editing an unrelated tier cannot invalidate it.
|
|
583
|
+
//
|
|
584
|
+
// A call that names none cannot be keyed that way, and keying it on
|
|
585
|
+
// `defaultTier` would be worse than coarse — it would be wrong. The host
|
|
586
|
+
// resolves an unnamed tier against the agent's own frontmatter tier first and
|
|
587
|
+
// only then the default, and frontmatter is invisible here: the agent
|
|
588
|
+
// definition lives in pi-subagents and reaches this side as a name. Keyed on
|
|
589
|
+
// the default, editing the tier the agent actually declares would replay
|
|
590
|
+
// stale work, while editing the default would invalidate work that never
|
|
591
|
+
// touched it. The whole-catalogue fingerprint is the coarse answer that is at
|
|
592
|
+
// least sound, and it is stable across the original run and its resume, which
|
|
593
|
+
// the tier the host selected is not — that one is only known after dispatch,
|
|
594
|
+
// and this hash is computed before it.
|
|
595
|
+
const tierPolicy =
|
|
596
|
+
options.tier === undefined
|
|
597
|
+
? (routingPolicyFingerprint ?? null)
|
|
598
|
+
: agentTierPolicyIdentity(routingPolicy, options.tier);
|
|
468
599
|
return hashIdentity({
|
|
469
600
|
prompt,
|
|
470
|
-
model: model ?? null,
|
|
471
601
|
tier: options.tier ?? null,
|
|
602
|
+
tierPolicy,
|
|
472
603
|
phase: phase ?? null,
|
|
473
604
|
agentType: options.agentType ?? null,
|
|
474
605
|
thread: options.thread ?? null,
|
|
475
|
-
agentDef: agentDefKey,
|
|
476
606
|
schema: options.schema ?? null,
|
|
477
607
|
isolation: options.isolation ?? null,
|
|
478
608
|
toolset: options.toolset ?? null,
|
|
479
609
|
excludeTools: options.excludeTools ?? null,
|
|
610
|
+
timeoutMs: options.timeoutMs ?? null,
|
|
611
|
+
retries: options.retries ?? null,
|
|
480
612
|
});
|
|
481
613
|
}
|
|
482
614
|
|
|
@@ -526,30 +658,30 @@ function estimateTokens(value: unknown): number {
|
|
|
526
658
|
return Math.ceil(JSON.stringify(value ?? "").length / 4);
|
|
527
659
|
}
|
|
528
660
|
|
|
529
|
-
function normalizeConcurrency(value: unknown): number {
|
|
661
|
+
export function normalizeConcurrency(value: unknown): number {
|
|
530
662
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1;
|
|
531
663
|
return Math.min(MAX_CONCURRENCY, Math.floor(value));
|
|
532
664
|
}
|
|
533
665
|
|
|
534
|
-
function normalizeMaxAgents(value: unknown): number {
|
|
666
|
+
export function normalizeMaxAgents(value: unknown): number {
|
|
535
667
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return MAX_AGENTS_PER_RUN;
|
|
536
668
|
return Math.min(MAX_AGENTS_PER_RUN, Math.floor(value));
|
|
537
669
|
}
|
|
538
670
|
|
|
539
|
-
function normalizeAgentTimeout(value: unknown): number | null {
|
|
671
|
+
export function normalizeAgentTimeout(value: unknown): number | null {
|
|
540
672
|
if (value === null) return null;
|
|
541
673
|
if (value === undefined) return DEFAULT_AGENT_TIMEOUT_MS;
|
|
542
674
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return DEFAULT_AGENT_TIMEOUT_MS;
|
|
543
675
|
return Math.floor(value);
|
|
544
676
|
}
|
|
545
677
|
|
|
546
|
-
function normalizeTokenBudget(value: unknown): number | null {
|
|
678
|
+
export function normalizeTokenBudget(value: unknown): number | null {
|
|
547
679
|
if (value === null || value === undefined) return null;
|
|
548
680
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return null;
|
|
549
681
|
return Math.floor(value);
|
|
550
682
|
}
|
|
551
683
|
|
|
552
|
-
function normalizeAgentRetries(value: unknown): number {
|
|
684
|
+
export function normalizeAgentRetries(value: unknown): number {
|
|
553
685
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
|
|
554
686
|
return Math.min(MAX_AGENT_RETRIES, Math.floor(value));
|
|
555
687
|
}
|
|
@@ -802,6 +934,7 @@ export interface WorkflowRuntimeBindings {
|
|
|
802
934
|
items: unknown[],
|
|
803
935
|
...stages: Array<(prev: unknown, original: unknown, index: number) => unknown>
|
|
804
936
|
) => Promise<Array<unknown | null>>;
|
|
937
|
+
orchestrate: (tasks: unknown, options?: unknown) => Promise<OrchestrationResult>;
|
|
805
938
|
workflow: (savedName: string, childArgs?: unknown) => Promise<unknown>;
|
|
806
939
|
verify: (
|
|
807
940
|
item: unknown,
|
|
@@ -840,6 +973,8 @@ export interface WorkflowRuntimeBindings {
|
|
|
840
973
|
|
|
841
974
|
/** Fan-out cancellation scope: parallel()/pipeline() batches mark themselves. */
|
|
842
975
|
const fanoutScope = new AsyncLocalStorage<{ cancelled: boolean }>();
|
|
976
|
+
/** Task-local phase attribution for named orchestration callbacks. */
|
|
977
|
+
const orchestrationScope = new AsyncLocalStorage<{ taskId: string; phase?: string }>();
|
|
843
978
|
|
|
844
979
|
// ── The run ──────────────────────────────────────────────────────────────────
|
|
845
980
|
|
|
@@ -853,6 +988,11 @@ export async function runWorkflow<T = unknown>(
|
|
|
853
988
|
const agentTimeoutMs = normalizeAgentTimeout(options.agentTimeoutMs);
|
|
854
989
|
const runId = options.runId ?? `run-${started.toString(36)}`;
|
|
855
990
|
const baseCwd = options.cwd ?? process.cwd();
|
|
991
|
+
// The host's catalogue, known before the first call. A tier name that is not
|
|
992
|
+
// in it is a typo, and catching it here beats discovering it on dispatch.
|
|
993
|
+
// Undefined when no policy was supplied (in-process tests, nested frames that
|
|
994
|
+
// inherit the parent's checks) — then only the key shape is enforced.
|
|
995
|
+
const knownTiers = options.routingPolicy ? new Set(Object.keys(options.routingPolicy.profiles)) : undefined;
|
|
856
996
|
|
|
857
997
|
const state: RuntimeState = {
|
|
858
998
|
logs: [],
|
|
@@ -863,6 +1003,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
863
1003
|
currentPhase: meta.phases?.[0]?.title,
|
|
864
1004
|
phaseBudgets: new Map(),
|
|
865
1005
|
callSeq: 0,
|
|
1006
|
+
workflowGenerations: new Map(),
|
|
866
1007
|
firstMiss: Number.POSITIVE_INFINITY,
|
|
867
1008
|
};
|
|
868
1009
|
|
|
@@ -879,12 +1020,11 @@ export async function runWorkflow<T = unknown>(
|
|
|
879
1020
|
const shared: SharedRuntime = options.sharedRuntime ?? {
|
|
880
1021
|
limiter: createLimiter(concurrency),
|
|
881
1022
|
agentCount: 0,
|
|
1023
|
+
physicalAgentCount: 0,
|
|
882
1024
|
spent: options.initialTokenUsage?.total ?? 0,
|
|
883
1025
|
tokenUsage: options.initialTokenUsage
|
|
884
1026
|
? { ...options.initialTokenUsage }
|
|
885
1027
|
: { input: 0, output: 0, total: 0, cost: 0, cacheRead: 0, cacheWrite: 0 },
|
|
886
|
-
depth: 0,
|
|
887
|
-
nestedCallSeq: 0,
|
|
888
1028
|
runFatalController: new AbortController(),
|
|
889
1029
|
inFlight: new Set<Promise<unknown>>(),
|
|
890
1030
|
activeThreads: new Set<string>(),
|
|
@@ -892,6 +1032,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
892
1032
|
const limiter = shared.limiter;
|
|
893
1033
|
const isTopLevelRun = !options.sharedRuntime;
|
|
894
1034
|
const store: SharedStore = options.sharedStore ?? new SharedStore();
|
|
1035
|
+
let frameAgentCount = 0;
|
|
1036
|
+
/** agent() promises owned by this runWorkflow frame only. */
|
|
1037
|
+
const frameInFlight = new Set<Promise<unknown>>();
|
|
895
1038
|
|
|
896
1039
|
const log = (message: unknown) => {
|
|
897
1040
|
const text = String(message);
|
|
@@ -899,6 +1042,14 @@ export async function runWorkflow<T = unknown>(
|
|
|
899
1042
|
options.onLog?.(text);
|
|
900
1043
|
};
|
|
901
1044
|
|
|
1045
|
+
const emitRuntimeEvent = (event: WorkflowRuntimeEvent): void => {
|
|
1046
|
+
try {
|
|
1047
|
+
options.onRuntimeEvent?.(event);
|
|
1048
|
+
} catch {
|
|
1049
|
+
// Runtime observers are diagnostic surfaces and must not change execution.
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
|
|
902
1053
|
const phase = (title: string, phaseOptions?: { budget?: number }) => {
|
|
903
1054
|
state.currentPhase = title;
|
|
904
1055
|
if (!state.phases.includes(title)) state.phases.push(title);
|
|
@@ -908,7 +1059,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
908
1059
|
state.phaseBudgets.set(title, { budget: phaseOptions.budget, startSpent: shared.spent, warned: false });
|
|
909
1060
|
}
|
|
910
1061
|
options.onPhase?.(title);
|
|
911
|
-
|
|
1062
|
+
emitRuntimeEvent({
|
|
912
1063
|
type: "phase",
|
|
913
1064
|
title,
|
|
914
1065
|
budget: typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0 ? phaseOptions.budget : null,
|
|
@@ -923,7 +1074,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
923
1074
|
|
|
924
1075
|
const agentLimitError = () =>
|
|
925
1076
|
new WorkflowError(
|
|
926
|
-
`Agent limit exceeded (${shared.
|
|
1077
|
+
`Agent limit exceeded (${shared.physicalAgentCount}/${maxAgents} real dispatches). Re-call workflow with resumeFromRunId="${runId}", the same script, and maxAgents: N (N>${maxAgents}) — journaled prefix replays free. /workflows resume alone cannot raise the cap.`,
|
|
927
1078
|
WorkflowErrorCode.AGENT_LIMIT_EXCEEDED,
|
|
928
1079
|
{ recoverable: false },
|
|
929
1080
|
);
|
|
@@ -936,6 +1087,17 @@ export async function runWorkflow<T = unknown>(
|
|
|
936
1087
|
}
|
|
937
1088
|
};
|
|
938
1089
|
|
|
1090
|
+
const nextCallIndex = (): number => {
|
|
1091
|
+
if (state.callSeq >= MAX_CALLS_PER_WORKFLOW) {
|
|
1092
|
+
throw new WorkflowError(
|
|
1093
|
+
`Workflow call limit exceeded (${MAX_CALLS_PER_WORKFLOW} agent/workflow/checkpoint calls)`,
|
|
1094
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1095
|
+
{ recoverable: false },
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
return state.callSeq++;
|
|
1099
|
+
};
|
|
1100
|
+
|
|
939
1101
|
const agent = (promptText: string, agentOptions: AgentOptions = {}): Promise<unknown> => {
|
|
940
1102
|
const rawThread = agentOptions.thread;
|
|
941
1103
|
const thread = rawThread === undefined ? undefined : typeof rawThread === "string" ? rawThread.trim() : "";
|
|
@@ -962,11 +1124,16 @@ export async function runWorkflow<T = unknown>(
|
|
|
962
1124
|
const normalizedOptions = thread && thread !== rawThread ? { ...agentOptions, thread } : agentOptions;
|
|
963
1125
|
let call = agentImpl(promptText, normalizedOptions);
|
|
964
1126
|
if (thread) call = call.finally(() => shared.activeThreads.delete(thread));
|
|
965
|
-
// Track every call (awaited or not)
|
|
966
|
-
//
|
|
967
|
-
//
|
|
1127
|
+
// Track every call (awaited or not) both globally and in its owning frame.
|
|
1128
|
+
// A nested workflow drains only its own forgotten calls before exposing its
|
|
1129
|
+
// result/journal boundary; the top-level frame still drains all shared work.
|
|
968
1130
|
shared.inFlight.add(call);
|
|
969
|
-
|
|
1131
|
+
frameInFlight.add(call);
|
|
1132
|
+
const removeSettledCall = () => {
|
|
1133
|
+
shared.inFlight.delete(call);
|
|
1134
|
+
frameInFlight.delete(call);
|
|
1135
|
+
};
|
|
1136
|
+
void call.then(removeSettledCall, removeSettledCall);
|
|
970
1137
|
return call;
|
|
971
1138
|
};
|
|
972
1139
|
|
|
@@ -977,40 +1144,8 @@ export async function runWorkflow<T = unknown>(
|
|
|
977
1144
|
// synchronously, before suspending on the limiter below.
|
|
978
1145
|
const batch = fanoutScope.getStore();
|
|
979
1146
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
// parallel()/pipeline() mark their own batch cancelled so the already-queued
|
|
983
|
-
// agents short-circuit before their real API call (see the limiter body).
|
|
984
|
-
if (shared.agentCount >= maxAgents) {
|
|
985
|
-
throw agentLimitError();
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
if (budget.total !== null && budget.remaining() <= 0) {
|
|
989
|
-
throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
|
|
990
|
-
recoverable: false,
|
|
991
|
-
});
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
const assignedPhase = agentOptions.phase ?? state.currentPhase;
|
|
995
|
-
|
|
996
|
-
// Per-phase soft sub-budget gate: warns once at ~80%, throws at 100%.
|
|
997
|
-
if (assignedPhase) {
|
|
998
|
-
const pb = state.phaseBudgets.get(assignedPhase);
|
|
999
|
-
if (pb) {
|
|
1000
|
-
const phaseSpent = shared.spent - pb.startSpent;
|
|
1001
|
-
if (phaseSpent >= pb.budget) {
|
|
1002
|
-
throw new WorkflowError(
|
|
1003
|
-
`phase "${assignedPhase}" token sub-budget exhausted (${pb.budget})`,
|
|
1004
|
-
WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED,
|
|
1005
|
-
{ recoverable: false },
|
|
1006
|
-
);
|
|
1007
|
-
}
|
|
1008
|
-
if (!pb.warned && phaseSpent >= pb.budget * 0.8) {
|
|
1009
|
-
pb.warned = true;
|
|
1010
|
-
log(`phase "${assignedPhase}" at ${Math.round((phaseSpent / pb.budget) * 100)}% of its token sub-budget`);
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1147
|
+
const taskScope = orchestrationScope.getStore();
|
|
1148
|
+
const assignedPhase = agentOptions.phase ?? taskScope?.phase ?? state.currentPhase;
|
|
1014
1149
|
|
|
1015
1150
|
const requestedLabel = agentOptions.label?.trim();
|
|
1016
1151
|
// agentType resolution lives in pi-subagents; the name is the observable key.
|
|
@@ -1018,39 +1153,85 @@ export async function runWorkflow<T = unknown>(
|
|
|
1018
1153
|
log(`agentType "${agentOptions.agentType}" resolves in pi-subagents at dispatch`);
|
|
1019
1154
|
}
|
|
1020
1155
|
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
// the
|
|
1025
|
-
|
|
1026
|
-
|
|
1156
|
+
// A tier is a key in the host's catalogue, so the only shape check this
|
|
1157
|
+
// side can make is the key shape. An unknown key is the host's call to
|
|
1158
|
+
// refuse — it is the one that knows what is defined, and its error names
|
|
1159
|
+
// the available tiers.
|
|
1160
|
+
if (agentOptions.tier !== undefined && !isManagedAgentTier(agentOptions.tier)) {
|
|
1161
|
+
throw new WorkflowError(
|
|
1162
|
+
`agent tier must be a non-empty, whitespace-free key (received ${JSON.stringify(agentOptions.tier)})`,
|
|
1163
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1164
|
+
{ recoverable: false },
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
let effectiveTier = agentOptions.tier;
|
|
1168
|
+
if (effectiveTier !== undefined && knownTiers && !knownTiers.has(effectiveTier)) {
|
|
1169
|
+
if (options.shippedScript) {
|
|
1170
|
+
// Our own script naming a tier this host does not define. Drop it and
|
|
1171
|
+
// let the host's default decide, which is the user's policy either way.
|
|
1172
|
+
log(`agent tier ${JSON.stringify(effectiveTier)} is not defined here; using the host default`);
|
|
1173
|
+
effectiveTier = undefined;
|
|
1174
|
+
} else {
|
|
1175
|
+
// A typo would otherwise surface only when this call reaches dispatch,
|
|
1176
|
+
// possibly deep into a run. The catalogue is known at start; check now.
|
|
1177
|
+
throw new WorkflowError(
|
|
1178
|
+
`unknown agent tier ${JSON.stringify(effectiveTier)}; this host defines: ${
|
|
1179
|
+
[...knownTiers].sort().join(", ") || "(none)"
|
|
1180
|
+
}`,
|
|
1181
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1182
|
+
{ recoverable: false },
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
// Everything downstream — the resume hash, the dispatch, the journal —
|
|
1187
|
+
// works from the tier that will actually be requested, not the one the
|
|
1188
|
+
// script wrote, so a dropped name cannot make a cached call look different
|
|
1189
|
+
// from the live one.
|
|
1190
|
+
const tieredOptions: AgentOptions =
|
|
1191
|
+
effectiveTier === agentOptions.tier ? agentOptions : { ...agentOptions, tier: effectiveTier };
|
|
1027
1192
|
|
|
1028
1193
|
// Deterministic resume key: assigned at lexical call time, before the limiter.
|
|
1029
|
-
const callIndex =
|
|
1030
|
-
const callHash = hashAgentCall(
|
|
1194
|
+
const callIndex = nextCallIndex();
|
|
1195
|
+
const callHash = hashAgentCall(
|
|
1196
|
+
promptText,
|
|
1197
|
+
assignedPhase,
|
|
1198
|
+
tieredOptions,
|
|
1199
|
+
options.routingPolicy,
|
|
1200
|
+
options.routingPolicyFingerprint,
|
|
1201
|
+
);
|
|
1031
1202
|
// Store delta key: runId-namespaced so a nested workflow() call's
|
|
1032
1203
|
// callIndex-0 can never collide with the parent's (see SharedStore doc).
|
|
1033
1204
|
const deltaKey = `${runId}:${callIndex}`;
|
|
1034
1205
|
|
|
1035
|
-
|
|
1036
|
-
// above (no await in between) so a parallel() fan-out cannot all observe the
|
|
1037
|
-
// same agentCount and overshoot maxAgents.
|
|
1038
|
-
shared.agentCount++;
|
|
1039
|
-
const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
|
|
1206
|
+
const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount + 1);
|
|
1040
1207
|
|
|
1041
1208
|
// Longest-unchanged-prefix resume: replay a cached result only while the
|
|
1042
1209
|
// prefix is still intact. Once any call misses, it AND everything after it
|
|
1043
1210
|
// run live, so an edited upstream call never leaves stale downstream
|
|
1044
|
-
// results served from the journal.
|
|
1211
|
+
// results served from the journal. Replay is logical work, not a real
|
|
1212
|
+
// dispatch: it consumes neither the agent cap nor token/phase budget.
|
|
1045
1213
|
const cached = agentOptions.thread ? undefined : options.resumeJournal?.get(deltaKey);
|
|
1046
1214
|
const hashMatches = cached != null && cached.hash === callHash;
|
|
1047
1215
|
const cachedEmptyOutput = hashMatches && isEmptyTextAgentResult(cached.result, agentOptions.schema);
|
|
1048
1216
|
if (hashMatches && !cachedEmptyOutput && callIndex < state.firstMiss) {
|
|
1049
|
-
|
|
1217
|
+
shared.agentCount++;
|
|
1218
|
+
frameAgentCount++;
|
|
1219
|
+
// The journal holds the tier the host actually selected, which may be a
|
|
1220
|
+
// default the script never named. Replay reports that durable identity
|
|
1221
|
+
// rather than re-deriving one from the script.
|
|
1222
|
+
const replayTier = cached.tier ?? effectiveTier;
|
|
1223
|
+
options.onAgentStart?.({
|
|
1224
|
+
id: deltaKey,
|
|
1225
|
+
label,
|
|
1226
|
+
phase: assignedPhase,
|
|
1227
|
+
prompt: promptText,
|
|
1228
|
+
...(replayTier === undefined ? {} : { tier: replayTier }),
|
|
1229
|
+
});
|
|
1050
1230
|
options.onAgentEnd?.({
|
|
1051
1231
|
id: deltaKey,
|
|
1052
1232
|
label,
|
|
1053
1233
|
phase: assignedPhase,
|
|
1234
|
+
tier: replayTier,
|
|
1054
1235
|
result: cached.result,
|
|
1055
1236
|
tokens: 0,
|
|
1056
1237
|
});
|
|
@@ -1060,12 +1241,48 @@ export async function runWorkflow<T = unknown>(
|
|
|
1060
1241
|
if (!hashMatches || cachedEmptyOutput || agentOptions.thread)
|
|
1061
1242
|
state.firstMiss = Math.min(state.firstMiss, callIndex);
|
|
1062
1243
|
|
|
1244
|
+
// Reserve a real dispatch synchronously — no await occurs between this
|
|
1245
|
+
// check and the increments, so a parallel() burst cannot overshoot the cap.
|
|
1246
|
+
if (shared.physicalAgentCount >= maxAgents) throw agentLimitError();
|
|
1247
|
+
if (budget.total !== null && budget.remaining() <= 0) {
|
|
1248
|
+
throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
|
|
1249
|
+
recoverable: false,
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
// Per-phase soft sub-budget gate: warns once at ~80%, throws at 100%.
|
|
1253
|
+
if (assignedPhase) {
|
|
1254
|
+
const pb = state.phaseBudgets.get(assignedPhase);
|
|
1255
|
+
if (pb) {
|
|
1256
|
+
const phaseSpent = shared.spent - pb.startSpent;
|
|
1257
|
+
if (phaseSpent >= pb.budget) {
|
|
1258
|
+
throw new WorkflowError(
|
|
1259
|
+
`phase "${assignedPhase}" token sub-budget exhausted (${pb.budget})`,
|
|
1260
|
+
WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED,
|
|
1261
|
+
{ recoverable: false },
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
if (!pb.warned && phaseSpent >= pb.budget * 0.8) {
|
|
1265
|
+
pb.warned = true;
|
|
1266
|
+
log(`phase "${assignedPhase}" at ${Math.round((phaseSpent / pb.budget) * 100)}% of its token sub-budget`);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
shared.agentCount++;
|
|
1271
|
+
frameAgentCount++;
|
|
1272
|
+
shared.physicalAgentCount++;
|
|
1273
|
+
|
|
1063
1274
|
return limiter(async () => {
|
|
1064
1275
|
const timeout = agentOptions.timeoutMs !== undefined ? agentOptions.timeoutMs : agentTimeoutMs;
|
|
1065
1276
|
const retryAttempts = normalizeAgentRetries(agentOptions.retries ?? options.agentRetries ?? 0);
|
|
1066
1277
|
const maxAttempts = retryAttempts + 1;
|
|
1067
1278
|
|
|
1068
|
-
options.onAgentStart?.({
|
|
1279
|
+
options.onAgentStart?.({
|
|
1280
|
+
id: deltaKey,
|
|
1281
|
+
label,
|
|
1282
|
+
phase: assignedPhase,
|
|
1283
|
+
prompt: promptText,
|
|
1284
|
+
...(effectiveTier === undefined ? {} : { tier: effectiveTier }),
|
|
1285
|
+
});
|
|
1069
1286
|
|
|
1070
1287
|
// Worktree creation and cleanup remain owned by pi-subagents; the request
|
|
1071
1288
|
// carries the explicit per-call policy without touching the host filesystem here.
|
|
@@ -1097,6 +1314,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1097
1314
|
schema !== undefined
|
|
1098
1315
|
? `\n\nReply with a single raw JSON value (object or array) matching exactly this JSON Schema:\n${JSON.stringify(schema)}\nDo not wrap it in prose or markdown fences.`
|
|
1099
1316
|
: "";
|
|
1317
|
+
let repairFeedback = "";
|
|
1100
1318
|
|
|
1101
1319
|
try {
|
|
1102
1320
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -1104,6 +1322,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1104
1322
|
const externalSignal = options.signal;
|
|
1105
1323
|
let onExternalAbort: (() => void) | undefined;
|
|
1106
1324
|
let onRunFatal: (() => void) | undefined;
|
|
1325
|
+
// Filled in from the host's reply, which may name a tier this call
|
|
1326
|
+
// did not: an agent's own tier, or the configured default.
|
|
1327
|
+
let resolvedTier = effectiveTier;
|
|
1107
1328
|
try {
|
|
1108
1329
|
throwIfAborted();
|
|
1109
1330
|
if (batch?.cancelled) throw agentLimitError();
|
|
@@ -1128,7 +1349,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1128
1349
|
);
|
|
1129
1350
|
}
|
|
1130
1351
|
|
|
1131
|
-
const runPromise = agentRunner.run(promptText + schemaInstruction, {
|
|
1352
|
+
const runPromise = agentRunner.run(promptText + schemaInstruction + repairFeedback, {
|
|
1132
1353
|
label,
|
|
1133
1354
|
sessionName: agentOptions.thread
|
|
1134
1355
|
? `workflow:${runId} thread:${agentOptions.thread}`
|
|
@@ -1136,12 +1357,12 @@ export async function runWorkflow<T = unknown>(
|
|
|
1136
1357
|
schema,
|
|
1137
1358
|
signal: agentController.signal,
|
|
1138
1359
|
instructions: buildAgentInstructions(assignedPhase, agentOptions, resolvedIsolation),
|
|
1139
|
-
|
|
1140
|
-
tier: agentOptions.tier,
|
|
1360
|
+
tier: effectiveTier,
|
|
1141
1361
|
agentType: agentOptions.agentType,
|
|
1142
1362
|
isolation: resolvedIsolation,
|
|
1143
1363
|
toolset: options.toolset ?? agentOptions.toolset,
|
|
1144
1364
|
excludeTools: options.excludeTools ?? agentOptions.excludeTools,
|
|
1365
|
+
runId,
|
|
1145
1366
|
callIndex,
|
|
1146
1367
|
callHash,
|
|
1147
1368
|
attempt,
|
|
@@ -1149,6 +1370,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1149
1370
|
onModelResolved: (id: string) => {
|
|
1150
1371
|
void id; // display model tracking is engine-side
|
|
1151
1372
|
},
|
|
1373
|
+
onTierResolved: (tier: string) => {
|
|
1374
|
+
resolvedTier = tier;
|
|
1375
|
+
},
|
|
1152
1376
|
onUsage: (u: AgentUsage) => {
|
|
1153
1377
|
usage = u;
|
|
1154
1378
|
},
|
|
@@ -1186,7 +1410,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1186
1410
|
if (errors.length > 0) {
|
|
1187
1411
|
const detail = errors.join("; ");
|
|
1188
1412
|
if (attempt < maxAttempts) {
|
|
1189
|
-
// Repair: re-dispatch with the validation errors
|
|
1413
|
+
// Repair: re-dispatch with the concrete validation errors so
|
|
1414
|
+
// the next model attempt can correct the actual failure.
|
|
1415
|
+
repairFeedback = `\n\nThe previous reply was invalid. Correct these validation errors and return only the corrected JSON value:\n${detail.slice(0, 4_000)}`;
|
|
1190
1416
|
throw new WorkflowError(
|
|
1191
1417
|
`Subagent reply did not match the schema: ${detail}`,
|
|
1192
1418
|
WorkflowErrorCode.AGENT_EXECUTION_ERROR,
|
|
@@ -1208,6 +1434,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1208
1434
|
runId,
|
|
1209
1435
|
hash: callHash,
|
|
1210
1436
|
result,
|
|
1437
|
+
...(resolvedTier === undefined ? {} : { tier: resolvedTier }),
|
|
1211
1438
|
storeDelta: store.commitDelta(deltaKey),
|
|
1212
1439
|
});
|
|
1213
1440
|
options.onAgentEnd?.({
|
|
@@ -1215,9 +1442,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1215
1442
|
label,
|
|
1216
1443
|
phase: assignedPhase,
|
|
1217
1444
|
result,
|
|
1445
|
+
tier: resolvedTier,
|
|
1218
1446
|
tokens,
|
|
1219
1447
|
tokenUsage: usage,
|
|
1220
|
-
model: modelSpec,
|
|
1221
1448
|
});
|
|
1222
1449
|
return result;
|
|
1223
1450
|
} catch (error) {
|
|
@@ -1245,9 +1472,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1245
1472
|
label,
|
|
1246
1473
|
phase: assignedPhase,
|
|
1247
1474
|
result: null,
|
|
1475
|
+
tier: resolvedTier,
|
|
1248
1476
|
tokens,
|
|
1249
1477
|
tokenUsage: usage,
|
|
1250
|
-
model: modelSpec,
|
|
1251
1478
|
error: workflowError.message,
|
|
1252
1479
|
errorCode: workflowError.code,
|
|
1253
1480
|
recoverable: workflowError.recoverable,
|
|
@@ -1275,31 +1502,45 @@ export async function runWorkflow<T = unknown>(
|
|
|
1275
1502
|
const parallel = async (thunks: Array<() => Promise<unknown>>) => {
|
|
1276
1503
|
throwIfAborted();
|
|
1277
1504
|
if (!Array.isArray(thunks)) throw new TypeError("parallel() expects an array of functions");
|
|
1505
|
+
if (thunks.length > MAX_FANOUT_ITEMS) {
|
|
1506
|
+
throw new WorkflowError(
|
|
1507
|
+
`parallel() accepts at most ${MAX_FANOUT_ITEMS} items`,
|
|
1508
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1509
|
+
{ recoverable: false },
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1278
1512
|
if (thunks.some((thunk) => typeof thunk !== "function")) {
|
|
1279
1513
|
throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
|
|
1280
1514
|
}
|
|
1281
1515
|
// Batch-scoped cancellation: a breach in THIS fan-out flips `cancelled` so
|
|
1282
1516
|
// its own still-queued agents bail, without touching a sibling or enclosing
|
|
1283
|
-
// fan-out.
|
|
1517
|
+
// fan-out. Await all wrappers before surfacing a fatal error so the barrier
|
|
1518
|
+
// never returns while a sibling is still mutating the run.
|
|
1284
1519
|
const batch = { cancelled: false };
|
|
1285
|
-
|
|
1520
|
+
const outcomes = await fanoutScope.run(batch, () =>
|
|
1286
1521
|
Promise.all(
|
|
1287
|
-
thunks.map(async (thunk, index) => {
|
|
1522
|
+
thunks.map(async (thunk, index): Promise<{ value: unknown } | { fatal: unknown }> => {
|
|
1288
1523
|
try {
|
|
1289
|
-
return await thunk();
|
|
1524
|
+
return { value: await thunk() };
|
|
1290
1525
|
} catch (error) {
|
|
1291
|
-
if (isAborted()) throw error;
|
|
1292
1526
|
const workflowError = wrapError(error);
|
|
1293
|
-
if (
|
|
1527
|
+
if (
|
|
1528
|
+
isAborted() ||
|
|
1529
|
+
!workflowError.recoverable ||
|
|
1530
|
+
workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT
|
|
1531
|
+
) {
|
|
1294
1532
|
if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) batch.cancelled = true;
|
|
1295
|
-
|
|
1533
|
+
return { fatal: workflowError };
|
|
1296
1534
|
}
|
|
1297
1535
|
log(`parallel[${index}] failed: ${workflowError.message}`);
|
|
1298
|
-
return null;
|
|
1536
|
+
return { value: null };
|
|
1299
1537
|
}
|
|
1300
1538
|
}),
|
|
1301
1539
|
),
|
|
1302
1540
|
);
|
|
1541
|
+
const fatal = outcomes.find((outcome): outcome is { fatal: unknown } => "fatal" in outcome);
|
|
1542
|
+
if (fatal) throw fatal.fatal;
|
|
1543
|
+
return outcomes.map((outcome) => ("value" in outcome ? outcome.value : null));
|
|
1303
1544
|
};
|
|
1304
1545
|
|
|
1305
1546
|
const pipeline = async (
|
|
@@ -1308,13 +1549,20 @@ export async function runWorkflow<T = unknown>(
|
|
|
1308
1549
|
) => {
|
|
1309
1550
|
throwIfAborted();
|
|
1310
1551
|
if (!Array.isArray(items)) throw new TypeError("pipeline() expects an array as the first argument");
|
|
1552
|
+
if (items.length > MAX_FANOUT_ITEMS) {
|
|
1553
|
+
throw new WorkflowError(
|
|
1554
|
+
`pipeline() accepts at most ${MAX_FANOUT_ITEMS} items`,
|
|
1555
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1556
|
+
{ recoverable: false },
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1311
1559
|
if (stages.some((stage) => typeof stage !== "function")) {
|
|
1312
1560
|
throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
|
|
1313
1561
|
}
|
|
1314
1562
|
const batch = { cancelled: false };
|
|
1315
|
-
|
|
1563
|
+
const outcomes = await fanoutScope.run(batch, () =>
|
|
1316
1564
|
Promise.all(
|
|
1317
|
-
items.map(async (item, index) => {
|
|
1565
|
+
items.map(async (item, index): Promise<{ value: unknown } | { fatal: unknown }> => {
|
|
1318
1566
|
let value: unknown = item;
|
|
1319
1567
|
for (const stage of stages) {
|
|
1320
1568
|
try {
|
|
@@ -1322,58 +1570,148 @@ export async function runWorkflow<T = unknown>(
|
|
|
1322
1570
|
value = await stage(value, item, index);
|
|
1323
1571
|
throwIfAborted();
|
|
1324
1572
|
} catch (error) {
|
|
1325
|
-
if (isAborted()) throw error;
|
|
1326
1573
|
const workflowError = wrapError(error);
|
|
1327
|
-
if (
|
|
1574
|
+
if (
|
|
1575
|
+
isAborted() ||
|
|
1576
|
+
!workflowError.recoverable ||
|
|
1577
|
+
workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT
|
|
1578
|
+
) {
|
|
1328
1579
|
if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) batch.cancelled = true;
|
|
1329
|
-
|
|
1580
|
+
return { fatal: workflowError };
|
|
1330
1581
|
}
|
|
1331
1582
|
log(`pipeline[${index}] failed: ${workflowError.message}`);
|
|
1332
|
-
return null;
|
|
1583
|
+
return { value: null };
|
|
1333
1584
|
}
|
|
1334
1585
|
}
|
|
1335
|
-
return value;
|
|
1586
|
+
return { value };
|
|
1336
1587
|
}),
|
|
1337
1588
|
),
|
|
1338
1589
|
);
|
|
1590
|
+
const fatal = outcomes.find((outcome): outcome is { fatal: unknown } => "fatal" in outcome);
|
|
1591
|
+
if (fatal) throw fatal.fatal;
|
|
1592
|
+
return outcomes.map((outcome) => ("value" in outcome ? outcome.value : null));
|
|
1593
|
+
};
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* Run a named dependency graph in deterministic declaration-order layers.
|
|
1597
|
+
* The graph is deliberately a runtime primitive rather than a second top-level
|
|
1598
|
+
* engine: agent/session policy still belongs to the enclosing workflow and
|
|
1599
|
+
* pi-subagents, while this helper owns only task dependencies and results.
|
|
1600
|
+
*/
|
|
1601
|
+
const orchestrate = async (rawTasks: unknown, rawOptions?: unknown): Promise<OrchestrationResult> => {
|
|
1602
|
+
throwIfAborted();
|
|
1603
|
+
const normalized = normalizeOrchestration(rawTasks, rawOptions);
|
|
1604
|
+
const tasks = normalized.tasks.map((task) => ({
|
|
1605
|
+
...task,
|
|
1606
|
+
run: (context: OrchestrationTaskContext) =>
|
|
1607
|
+
orchestrationScope.run({ taskId: task.id, ...(task.phase === undefined ? {} : { phase: task.phase }) }, () =>
|
|
1608
|
+
task.run(context),
|
|
1609
|
+
),
|
|
1610
|
+
}));
|
|
1611
|
+
return executeOrchestration(tasks, normalized.options, {
|
|
1612
|
+
throwIfAborted,
|
|
1613
|
+
isFatal: (error: unknown) => {
|
|
1614
|
+
if (isAborted()) return true;
|
|
1615
|
+
const workflowError = wrapError(error);
|
|
1616
|
+
return !workflowError.recoverable || workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT;
|
|
1617
|
+
},
|
|
1618
|
+
formatError: (error: unknown) => wrapError(error).message,
|
|
1619
|
+
onEvent: emitRuntimeEvent,
|
|
1620
|
+
});
|
|
1339
1621
|
};
|
|
1340
1622
|
|
|
1341
1623
|
// Nested workflow(): run a saved workflow (or raw script) inline, sharing this
|
|
1342
|
-
// run's limiter/counters/budget. One level deep only.
|
|
1624
|
+
// run's limiter/counters/budget. One level deep only. The whole child result is
|
|
1625
|
+
// journaled as one stable parent call so resume does not silently re-spend every
|
|
1626
|
+
// nested agent when the parent prefix is unchanged.
|
|
1343
1627
|
const workflowFn = async (nameOrScript: string, childArgs?: unknown) => {
|
|
1344
1628
|
throwIfAborted();
|
|
1345
|
-
|
|
1629
|
+
const workflowDepth = options.workflowDepth ?? 0;
|
|
1630
|
+
if (workflowDepth >= 1) {
|
|
1346
1631
|
throw new WorkflowError("workflow() can nest only one level deep", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
1347
1632
|
recoverable: false,
|
|
1348
1633
|
});
|
|
1349
1634
|
}
|
|
1635
|
+
const callIndex = nextCallIndex();
|
|
1350
1636
|
const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
|
|
1351
|
-
const childScript = resolved ?? String(nameOrScript);
|
|
1637
|
+
const childScript = resolved?.script ?? String(nameOrScript);
|
|
1638
|
+
// A named script carries its own shipped-ness; a raw script literal is the
|
|
1639
|
+
// caller's own text and shares the caller's. Inheriting the frame's flag for
|
|
1640
|
+
// a resolved script would let a user script run a built-in under the strict
|
|
1641
|
+
// tier check — a hard failure on any host that renamed the shipped tiers.
|
|
1642
|
+
const childShippedScript = resolved ? resolved.shippedScript === true : options.shippedScript === true;
|
|
1352
1643
|
const workflowName = String(nameOrScript);
|
|
1353
|
-
|
|
1354
|
-
|
|
1644
|
+
const argsPresent = childArgs !== undefined;
|
|
1645
|
+
const callHash = hashIdentity({
|
|
1646
|
+
kind: "workflow",
|
|
1647
|
+
name: workflowName,
|
|
1648
|
+
script: childScript,
|
|
1649
|
+
argsPresent,
|
|
1650
|
+
args: argsPresent ? childArgs : null,
|
|
1651
|
+
// Part of the identity because it decides how the child treats a tier
|
|
1652
|
+
// name the host does not define: dropped, or fatal.
|
|
1653
|
+
shippedScript: childShippedScript,
|
|
1654
|
+
routingPolicyFingerprint: options.routingPolicyFingerprint ?? null,
|
|
1655
|
+
});
|
|
1656
|
+
const cached = options.resumeJournal?.get(`${runId}:${callIndex}`);
|
|
1657
|
+
if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
|
|
1658
|
+
state.workflowGenerations.set(callIndex, cached.generation ?? 1);
|
|
1659
|
+
if (cached.storeDelta) store.applyDelta(cached.storeDelta);
|
|
1660
|
+
const replayedAgentCount = cached.agentCount ?? 0;
|
|
1661
|
+
shared.agentCount += replayedAgentCount;
|
|
1662
|
+
frameAgentCount += replayedAgentCount;
|
|
1663
|
+
return cached.result;
|
|
1664
|
+
}
|
|
1665
|
+
if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
|
|
1666
|
+
const generation = (state.workflowGenerations.get(callIndex) ?? cached?.generation ?? 0) + 1;
|
|
1667
|
+
state.workflowGenerations.set(callIndex, generation);
|
|
1668
|
+
emitRuntimeEvent({ type: "workflow", stage: "start", name: workflowName, args: childArgs });
|
|
1355
1669
|
try {
|
|
1356
|
-
|
|
1357
|
-
// parent's own prefix is still intact (state.firstMiss === Infinity).
|
|
1358
|
-
// SharedStore content is not part of any call's hash, so a cached child
|
|
1359
|
-
// result computed against OLD store state would be stale once an upstream
|
|
1360
|
-
// parent call misses and re-runs live with different store writes.
|
|
1361
|
-
const prefixIntact = state.firstMiss === Number.POSITIVE_INFINITY;
|
|
1670
|
+
const childDeltas = new Map<number, Record<string, unknown>>();
|
|
1362
1671
|
const child = await runWorkflow(childScript, {
|
|
1363
1672
|
...options,
|
|
1364
1673
|
args: childArgs,
|
|
1674
|
+
shippedScript: childShippedScript,
|
|
1365
1675
|
sharedRuntime: shared,
|
|
1366
1676
|
sharedStore: store,
|
|
1367
|
-
|
|
1677
|
+
workflowDepth: workflowDepth + 1,
|
|
1678
|
+
// The parent call caches the complete child value. Replaying child
|
|
1679
|
+
// call results here would duplicate nested calls and make the synthetic
|
|
1680
|
+
// parent journal entry ambiguous.
|
|
1681
|
+
resumeJournal: undefined,
|
|
1368
1682
|
resumeFromRunId: undefined,
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1683
|
+
onWorkflowJournal: undefined,
|
|
1684
|
+
onAgentJournal: (entry) => {
|
|
1685
|
+
if (entry.storeDelta) childDeltas.set(entry.index, entry.storeDelta);
|
|
1686
|
+
// Preserve nested lifecycle visibility for direct runtime consumers;
|
|
1687
|
+
// the engine's top-level agent journal intentionally ignores child ids.
|
|
1688
|
+
options.onAgentJournal?.(entry);
|
|
1689
|
+
},
|
|
1690
|
+
// The call index + generation keeps changed nested calls distinct across
|
|
1691
|
+
// resume. The engine nonce additionally fences an interrupted child whose
|
|
1692
|
+
// result was never durable before a process/branch restart.
|
|
1693
|
+
runId: `${runId}-nested-call-${callIndex}-g${generation}${options.executionNonce ? `-${options.executionNonce}` : ""}`,
|
|
1694
|
+
});
|
|
1695
|
+
const normalizedChildResult = child.result === undefined ? null : child.result;
|
|
1696
|
+
const storeDelta: Record<string, unknown> = {};
|
|
1697
|
+
for (const delta of [...childDeltas.entries()].sort(([a], [b]) => a - b).map(([, value]) => value)) {
|
|
1698
|
+
Object.assign(storeDelta, delta);
|
|
1699
|
+
}
|
|
1700
|
+
// Do not append a nested result after pause/stop has claimed the parent;
|
|
1701
|
+
// terminal recovery must remain the last durable fact for that execution.
|
|
1702
|
+
throwIfAborted();
|
|
1703
|
+
options.onWorkflowJournal?.({
|
|
1704
|
+
index: callIndex,
|
|
1705
|
+
runId,
|
|
1706
|
+
hash: callHash,
|
|
1707
|
+
result: normalizedChildResult,
|
|
1708
|
+
generation,
|
|
1709
|
+
agentCount: child.agentCount,
|
|
1710
|
+
...(Object.keys(storeDelta).length > 0 ? { storeDelta } : {}),
|
|
1372
1711
|
});
|
|
1373
|
-
return
|
|
1712
|
+
return normalizedChildResult;
|
|
1374
1713
|
} finally {
|
|
1375
|
-
|
|
1376
|
-
options.onRuntimeEvent?.({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
|
|
1714
|
+
emitRuntimeEvent({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
|
|
1377
1715
|
}
|
|
1378
1716
|
};
|
|
1379
1717
|
|
|
@@ -1393,7 +1731,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1393
1731
|
total: number;
|
|
1394
1732
|
votes: Array<{ real: boolean; reason?: string }>;
|
|
1395
1733
|
}> => {
|
|
1396
|
-
|
|
1734
|
+
emitRuntimeEvent({ type: "quality", stage: "start", helper: "verify" });
|
|
1397
1735
|
const reviewers = Math.max(1, opts.reviewers ?? 2);
|
|
1398
1736
|
const threshold = opts.threshold ?? 0.5;
|
|
1399
1737
|
const lenses = opts.lens ? (Array.isArray(opts.lens) ? opts.lens : [opts.lens]) : [];
|
|
@@ -1430,9 +1768,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1430
1768
|
): Promise<
|
|
1431
1769
|
{ index: number; attempt: unknown; score: number; judgments: Array<{ score: number; reason?: string }> } | undefined
|
|
1432
1770
|
> => {
|
|
1433
|
-
|
|
1771
|
+
emitRuntimeEvent({ type: "quality", stage: "start", helper: "judgePanel" });
|
|
1434
1772
|
if (!Array.isArray(attempts) || attempts.length === 0) {
|
|
1435
|
-
|
|
1773
|
+
emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
|
|
1436
1774
|
return undefined;
|
|
1437
1775
|
}
|
|
1438
1776
|
const judges = Math.min(16, Math.max(1, Math.floor(opts.judges ?? 3)));
|
|
@@ -1473,12 +1811,12 @@ export async function runWorkflow<T = unknown>(
|
|
|
1473
1811
|
judgments: s.judgments.map((j) => ({ score: Number(j?.score) || 0, reason: j?.reason })),
|
|
1474
1812
|
}));
|
|
1475
1813
|
if (normalized.length === 0) {
|
|
1476
|
-
|
|
1814
|
+
emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
|
|
1477
1815
|
return undefined;
|
|
1478
1816
|
}
|
|
1479
1817
|
let best = normalized[0];
|
|
1480
1818
|
for (const s of normalized) if (s.score > best.score || (s.score === best.score && s.index < best.index)) best = s;
|
|
1481
|
-
|
|
1819
|
+
emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
|
|
1482
1820
|
return best;
|
|
1483
1821
|
};
|
|
1484
1822
|
|
|
@@ -1530,12 +1868,12 @@ export async function runWorkflow<T = unknown>(
|
|
|
1530
1868
|
taskArgs: unknown,
|
|
1531
1869
|
results: unknown,
|
|
1532
1870
|
): Promise<{ complete: boolean; missing?: string[] } | null> => {
|
|
1533
|
-
|
|
1871
|
+
emitRuntimeEvent({ type: "quality", stage: "start", helper: "completenessCheck" });
|
|
1534
1872
|
const verdict = await agent(
|
|
1535
1873
|
`Given the task and the results gathered so far, list what is still MISSING (modalities not covered, claims unverified, gaps). Be specific and concise.\n\nTask:\n${JSON.stringify(taskArgs)}\n\nResults so far:\n${JSON.stringify(results).slice(0, 4000)}`,
|
|
1536
1874
|
{ label: "completeness critic", schema: COMPLETENESS_SCHEMA },
|
|
1537
1875
|
);
|
|
1538
|
-
|
|
1876
|
+
emitRuntimeEvent({ type: "quality", stage: "end", helper: "completenessCheck" });
|
|
1539
1877
|
return verdict as { complete: boolean; missing?: string[] } | null;
|
|
1540
1878
|
};
|
|
1541
1879
|
|
|
@@ -1549,7 +1887,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1549
1887
|
throwIfAborted();
|
|
1550
1888
|
last = await thunk(i);
|
|
1551
1889
|
const accepted = !opts.until || opts.until(last);
|
|
1552
|
-
|
|
1890
|
+
emitRuntimeEvent({ type: "control-attempt", helper: "retry", attempt: i + 1, accepted });
|
|
1553
1891
|
if (accepted) return last;
|
|
1554
1892
|
}
|
|
1555
1893
|
return last;
|
|
@@ -1568,7 +1906,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1568
1906
|
last = await thunk(feedback, i);
|
|
1569
1907
|
const verdict = await validator(last);
|
|
1570
1908
|
const accepted = Boolean(verdict?.ok);
|
|
1571
|
-
|
|
1909
|
+
emitRuntimeEvent({ type: "control-attempt", helper: "gate", attempt: i + 1, accepted });
|
|
1572
1910
|
if (accepted) return { ok: true, value: last, attempts: i + 1 };
|
|
1573
1911
|
feedback = verdict?.feedback;
|
|
1574
1912
|
}
|
|
@@ -1581,19 +1919,20 @@ export async function runWorkflow<T = unknown>(
|
|
|
1581
1919
|
const checkpoint = async (promptText: string, checkpointOptions: CheckpointOptions = {}) => {
|
|
1582
1920
|
throwIfAborted();
|
|
1583
1921
|
if (typeof promptText !== "string") throw new TypeError("checkpoint(promptText, options?) needs a prompt string");
|
|
1584
|
-
|
|
1585
|
-
throw agentLimitError();
|
|
1586
|
-
}
|
|
1587
|
-
const callIndex = state.callSeq++;
|
|
1922
|
+
const callIndex = nextCallIndex();
|
|
1588
1923
|
const callHash = hashCheckpoint(promptText, checkpointOptions);
|
|
1589
1924
|
const journalKey = `${runId}:${callIndex}`;
|
|
1590
1925
|
const cached = options.resumeJournal?.get(journalKey);
|
|
1591
1926
|
if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
|
|
1592
1927
|
shared.agentCount++;
|
|
1593
|
-
|
|
1928
|
+
frameAgentCount++;
|
|
1929
|
+
return cached.result; // replay the journaled human reply without spending a slot
|
|
1594
1930
|
}
|
|
1595
1931
|
if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
|
|
1932
|
+
if (shared.physicalAgentCount >= maxAgents) throw agentLimitError();
|
|
1596
1933
|
shared.agentCount++;
|
|
1934
|
+
frameAgentCount++;
|
|
1935
|
+
shared.physicalAgentCount++;
|
|
1597
1936
|
|
|
1598
1937
|
let reply: unknown;
|
|
1599
1938
|
if (options.confirm) {
|
|
@@ -1616,6 +1955,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1616
1955
|
agent,
|
|
1617
1956
|
parallel,
|
|
1618
1957
|
pipeline,
|
|
1958
|
+
orchestrate,
|
|
1619
1959
|
workflow: workflowFn,
|
|
1620
1960
|
verify,
|
|
1621
1961
|
judgePanel,
|
|
@@ -1681,7 +2021,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1681
2021
|
result: result as T,
|
|
1682
2022
|
logs: state.logs,
|
|
1683
2023
|
phases: state.phases,
|
|
1684
|
-
agentCount: shared.agentCount,
|
|
2024
|
+
agentCount: isTopLevelRun ? shared.agentCount : frameAgentCount,
|
|
1685
2025
|
durationMs: Date.now() - started,
|
|
1686
2026
|
runId,
|
|
1687
2027
|
tokenUsage: shared.tokenUsage,
|
|
@@ -1695,14 +2035,15 @@ export async function runWorkflow<T = unknown>(
|
|
|
1695
2035
|
if (isTopLevelRun) shared.runFatalController.abort();
|
|
1696
2036
|
throw error;
|
|
1697
2037
|
} finally {
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
2038
|
+
const ownedInFlight = isTopLevelRun ? shared.inFlight : frameInFlight;
|
|
2039
|
+
if (ownedInFlight.size > 0) {
|
|
2040
|
+
log(
|
|
2041
|
+
`waiting for ${ownedInFlight.size} outstanding agent() call(s) to settle before this workflow frame completes`,
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
while (ownedInFlight.size > 0) {
|
|
2045
|
+
await Promise.allSettled(Array.from(ownedInFlight));
|
|
1706
2046
|
}
|
|
2047
|
+
if (isTopLevelRun) store.dispose();
|
|
1707
2048
|
}
|
|
1708
2049
|
}
|