@signalridge/pi-workflows 1.3.2 → 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/CHANGELOG.md +5 -0
- package/README.md +40 -11
- package/package.json +3 -2
- package/skills/workflow-authoring/SKILL.md +166 -18
- package/skills/workflow-authoring/examples/named-task-graph.js +34 -0
- package/skills/workflow-authoring/references/capabilities.md +2 -1
- package/skills/workflow-authoring/references/capability-details.md +22 -4
- 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 +1 -1
- package/skills/workflow-authoring/references/review.md +7 -5
- package/skills/workflow-authoring/references/runtime.md +4 -3
- package/skills/workflow-patterns/SKILL.md +72 -33
- package/skills/workflow-review/SKILL.md +70 -0
- package/src/capabilities.ts +28 -3
- package/src/engine.ts +384 -124
- package/src/index.ts +126 -9
- package/src/journal.ts +266 -27
- package/src/orchestrator.ts +478 -0
- package/src/runtime.ts +332 -123
package/src/runtime.ts
CHANGED
|
@@ -24,6 +24,12 @@ import vm from "node:vm";
|
|
|
24
24
|
import { parse } from "acorn";
|
|
25
25
|
import { checkRuntimeBindings } from "./capabilities.js";
|
|
26
26
|
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
27
|
+
import {
|
|
28
|
+
executeOrchestration,
|
|
29
|
+
normalizeOrchestration,
|
|
30
|
+
type OrchestrationResult,
|
|
31
|
+
type OrchestrationTaskContext,
|
|
32
|
+
} from "./orchestrator.js";
|
|
27
33
|
import { SharedStore } from "./shared-store.js";
|
|
28
34
|
|
|
29
35
|
// ── Meta contract ────────────────────────────────────────────────────────────
|
|
@@ -59,6 +65,10 @@ export interface JournalEntry {
|
|
|
59
65
|
hash: string;
|
|
60
66
|
result: unknown;
|
|
61
67
|
storeDelta?: Record<string, unknown>;
|
|
68
|
+
/** Generation for a nested workflow replay boundary. */
|
|
69
|
+
generation?: number;
|
|
70
|
+
/** Logical agents represented by a nested workflow replay boundary. */
|
|
71
|
+
agentCount?: number;
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
export interface AgentUsage {
|
|
@@ -72,11 +82,12 @@ export interface AgentUsage {
|
|
|
72
82
|
|
|
73
83
|
export interface SharedRuntime {
|
|
74
84
|
limiter: Limiter;
|
|
85
|
+
/** Logical calls, including replayed calls, used for reporting/labels. */
|
|
75
86
|
agentCount: number;
|
|
87
|
+
/** Real dispatches only; replayed journal values do not consume maxAgents. */
|
|
88
|
+
physicalAgentCount: number;
|
|
76
89
|
spent: number;
|
|
77
90
|
tokenUsage: AgentUsage;
|
|
78
|
-
depth: number;
|
|
79
|
-
nestedCallSeq: number;
|
|
80
91
|
runFatalController: AbortController;
|
|
81
92
|
inFlight: Set<Promise<unknown>>;
|
|
82
93
|
activeThreads: Set<string>;
|
|
@@ -102,6 +113,8 @@ export interface AgentRunOptions {
|
|
|
102
113
|
cwd?: string;
|
|
103
114
|
/** Named subagent type; resolved by pi-subagents at dispatch. */
|
|
104
115
|
agentType?: string;
|
|
116
|
+
/** The runtime frame that owns this call (used to namespace nested workflows). */
|
|
117
|
+
runId?: string;
|
|
105
118
|
/** Lexical call index — engine-side spawnKey identity (A4). */
|
|
106
119
|
callIndex?: number;
|
|
107
120
|
/** Stable hash of the call inputs — engine-side resume comparison (A4). */
|
|
@@ -145,7 +158,7 @@ export interface CheckpointOptions {
|
|
|
145
158
|
}
|
|
146
159
|
|
|
147
160
|
export interface WorkflowRuntimeEvent {
|
|
148
|
-
type: "phase" | "workflow" | "quality" | "control-attempt";
|
|
161
|
+
type: "phase" | "workflow" | "quality" | "control-attempt" | "task";
|
|
149
162
|
[key: string]: unknown;
|
|
150
163
|
}
|
|
151
164
|
|
|
@@ -154,6 +167,10 @@ export interface WorkflowRunOptions {
|
|
|
154
167
|
agent?: WorkflowAgentRunner;
|
|
155
168
|
/** The session's main model (provider/id), shown for default agents. */
|
|
156
169
|
mainModel?: string;
|
|
170
|
+
/** Engine-owned execution nonce used to isolate stale nested managed calls. */
|
|
171
|
+
executionNonce?: string;
|
|
172
|
+
/** Runtime frame depth; siblings inherit the same depth and may run concurrently. */
|
|
173
|
+
workflowDepth?: number;
|
|
157
174
|
cwd?: string;
|
|
158
175
|
concurrency?: number;
|
|
159
176
|
agentRetries?: number;
|
|
@@ -167,6 +184,8 @@ export interface WorkflowRunOptions {
|
|
|
167
184
|
toolset?: string;
|
|
168
185
|
excludeTools?: string[];
|
|
169
186
|
onAgentJournal?: (entry: JournalEntry) => void;
|
|
187
|
+
/** Journal one completed nested workflow as a stable parent call. */
|
|
188
|
+
onWorkflowJournal?: (entry: JournalEntry) => void;
|
|
170
189
|
onRetrySpend?: (tokens: number) => void;
|
|
171
190
|
sharedRuntime?: SharedRuntime;
|
|
172
191
|
initialTokenUsage?: AgentUsage;
|
|
@@ -212,10 +231,16 @@ export interface RuntimeState {
|
|
|
212
231
|
currentPhase?: string;
|
|
213
232
|
phaseBudgets: Map<string, { budget: number; startSpent: number; warned: boolean }>;
|
|
214
233
|
callSeq: number;
|
|
234
|
+
/** Live generation counters for nested workflow replay boundaries. */
|
|
235
|
+
workflowGenerations: Map<number, number>;
|
|
215
236
|
firstMiss: number;
|
|
216
237
|
}
|
|
217
238
|
|
|
218
239
|
export const MAX_AGENTS_PER_RUN = 1000;
|
|
240
|
+
/** Maximum journal-compatible lexical calls in one workflow frame (indices 0..999). */
|
|
241
|
+
export const MAX_CALLS_PER_WORKFLOW = 1_000;
|
|
242
|
+
/** Per-call item cap, matching the bounded batch contract used by reference engines. */
|
|
243
|
+
export const MAX_FANOUT_ITEMS = 4_096;
|
|
219
244
|
export const MAX_CONCURRENCY = 16;
|
|
220
245
|
export const MAX_AGENT_RETRIES = 3;
|
|
221
246
|
export const DEFAULT_AGENT_TIMEOUT_MS: number | null = 300_000;
|
|
@@ -223,7 +248,55 @@ export const MAX_SCRIPT_SYNC_MS = 1_000;
|
|
|
223
248
|
|
|
224
249
|
// ── Determinism guardrail ────────────────────────────────────────────────────
|
|
225
250
|
|
|
226
|
-
|
|
251
|
+
interface AstRecord {
|
|
252
|
+
readonly [key: string]: unknown;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isAstRecord(value: unknown): value is AstRecord {
|
|
256
|
+
return typeof value === "object" && value !== null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Detect actual nondeterministic expressions, not words inside user prompt strings. */
|
|
260
|
+
function containsNondeterminism(value: unknown, seen = new WeakSet<object>()): boolean {
|
|
261
|
+
if (Array.isArray(value)) return value.some((item) => containsNondeterminism(item, seen));
|
|
262
|
+
if (!isAstRecord(value)) return false;
|
|
263
|
+
if (seen.has(value)) return false;
|
|
264
|
+
seen.add(value);
|
|
265
|
+
|
|
266
|
+
const type = value.type;
|
|
267
|
+
if (type === "MemberExpression") {
|
|
268
|
+
const object = value.object;
|
|
269
|
+
const property = value.property;
|
|
270
|
+
const objectName =
|
|
271
|
+
isAstRecord(object) && object.type === "Identifier" && typeof object.name === "string" ? object.name : undefined;
|
|
272
|
+
// Leave computed properties to the in-realm stubs so the runtime still
|
|
273
|
+
// guards aliases such as Math["random"] while this precheck ignores text
|
|
274
|
+
// embedded in prompt literals.
|
|
275
|
+
const propertyName =
|
|
276
|
+
isAstRecord(property) && property.type === "Identifier" && typeof property.name === "string"
|
|
277
|
+
? property.name
|
|
278
|
+
: undefined;
|
|
279
|
+
if ((objectName === "Date" && propertyName === "now") || (objectName === "Math" && propertyName === "random")) {
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (
|
|
284
|
+
type === "NewExpression" &&
|
|
285
|
+
isAstRecord(value.callee) &&
|
|
286
|
+
value.callee.type === "Identifier" &&
|
|
287
|
+
value.callee.name === "Date" &&
|
|
288
|
+
Array.isArray(value.arguments) &&
|
|
289
|
+
value.arguments.length === 0
|
|
290
|
+
) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const [key, child] of Object.entries(value)) {
|
|
295
|
+
if (key === "loc" || key === "start" || key === "end") continue;
|
|
296
|
+
if (containsNondeterminism(child, seen)) return true;
|
|
297
|
+
}
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
227
300
|
|
|
228
301
|
/**
|
|
229
302
|
* Run inside the vm realm BEFORE the user script. Neutered builtins would break
|
|
@@ -264,14 +337,6 @@ const DETERMINISM_PRELUDE = [
|
|
|
264
337
|
* is rejected so the meta is fully static and deterministic.
|
|
265
338
|
*/
|
|
266
339
|
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
340
|
const ast = parse(script, {
|
|
276
341
|
ecmaVersion: "latest",
|
|
277
342
|
sourceType: "module",
|
|
@@ -279,6 +344,13 @@ export function parseWorkflowScript(script: string): { meta: WorkflowMeta; body:
|
|
|
279
344
|
allowReturnOutsideFunction: true,
|
|
280
345
|
ranges: false,
|
|
281
346
|
});
|
|
347
|
+
if (containsNondeterminism(ast)) {
|
|
348
|
+
throw new WorkflowError(
|
|
349
|
+
"Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable",
|
|
350
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
351
|
+
{ recoverable: false },
|
|
352
|
+
);
|
|
353
|
+
}
|
|
282
354
|
|
|
283
355
|
const first = ast.body?.[0] as { type?: string } | undefined;
|
|
284
356
|
if (first?.type !== "ExportNamedDeclaration") {
|
|
@@ -477,6 +549,8 @@ function hashAgentCall(
|
|
|
477
549
|
isolation: options.isolation ?? null,
|
|
478
550
|
toolset: options.toolset ?? null,
|
|
479
551
|
excludeTools: options.excludeTools ?? null,
|
|
552
|
+
timeoutMs: options.timeoutMs ?? null,
|
|
553
|
+
retries: options.retries ?? null,
|
|
480
554
|
});
|
|
481
555
|
}
|
|
482
556
|
|
|
@@ -526,30 +600,30 @@ function estimateTokens(value: unknown): number {
|
|
|
526
600
|
return Math.ceil(JSON.stringify(value ?? "").length / 4);
|
|
527
601
|
}
|
|
528
602
|
|
|
529
|
-
function normalizeConcurrency(value: unknown): number {
|
|
603
|
+
export function normalizeConcurrency(value: unknown): number {
|
|
530
604
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1;
|
|
531
605
|
return Math.min(MAX_CONCURRENCY, Math.floor(value));
|
|
532
606
|
}
|
|
533
607
|
|
|
534
|
-
function normalizeMaxAgents(value: unknown): number {
|
|
608
|
+
export function normalizeMaxAgents(value: unknown): number {
|
|
535
609
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return MAX_AGENTS_PER_RUN;
|
|
536
610
|
return Math.min(MAX_AGENTS_PER_RUN, Math.floor(value));
|
|
537
611
|
}
|
|
538
612
|
|
|
539
|
-
function normalizeAgentTimeout(value: unknown): number | null {
|
|
613
|
+
export function normalizeAgentTimeout(value: unknown): number | null {
|
|
540
614
|
if (value === null) return null;
|
|
541
615
|
if (value === undefined) return DEFAULT_AGENT_TIMEOUT_MS;
|
|
542
616
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return DEFAULT_AGENT_TIMEOUT_MS;
|
|
543
617
|
return Math.floor(value);
|
|
544
618
|
}
|
|
545
619
|
|
|
546
|
-
function normalizeTokenBudget(value: unknown): number | null {
|
|
620
|
+
export function normalizeTokenBudget(value: unknown): number | null {
|
|
547
621
|
if (value === null || value === undefined) return null;
|
|
548
622
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return null;
|
|
549
623
|
return Math.floor(value);
|
|
550
624
|
}
|
|
551
625
|
|
|
552
|
-
function normalizeAgentRetries(value: unknown): number {
|
|
626
|
+
export function normalizeAgentRetries(value: unknown): number {
|
|
553
627
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
|
|
554
628
|
return Math.min(MAX_AGENT_RETRIES, Math.floor(value));
|
|
555
629
|
}
|
|
@@ -802,6 +876,7 @@ export interface WorkflowRuntimeBindings {
|
|
|
802
876
|
items: unknown[],
|
|
803
877
|
...stages: Array<(prev: unknown, original: unknown, index: number) => unknown>
|
|
804
878
|
) => Promise<Array<unknown | null>>;
|
|
879
|
+
orchestrate: (tasks: unknown, options?: unknown) => Promise<OrchestrationResult>;
|
|
805
880
|
workflow: (savedName: string, childArgs?: unknown) => Promise<unknown>;
|
|
806
881
|
verify: (
|
|
807
882
|
item: unknown,
|
|
@@ -840,6 +915,8 @@ export interface WorkflowRuntimeBindings {
|
|
|
840
915
|
|
|
841
916
|
/** Fan-out cancellation scope: parallel()/pipeline() batches mark themselves. */
|
|
842
917
|
const fanoutScope = new AsyncLocalStorage<{ cancelled: boolean }>();
|
|
918
|
+
/** Task-local phase attribution for named orchestration callbacks. */
|
|
919
|
+
const orchestrationScope = new AsyncLocalStorage<{ taskId: string; phase?: string }>();
|
|
843
920
|
|
|
844
921
|
// ── The run ──────────────────────────────────────────────────────────────────
|
|
845
922
|
|
|
@@ -863,6 +940,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
863
940
|
currentPhase: meta.phases?.[0]?.title,
|
|
864
941
|
phaseBudgets: new Map(),
|
|
865
942
|
callSeq: 0,
|
|
943
|
+
workflowGenerations: new Map(),
|
|
866
944
|
firstMiss: Number.POSITIVE_INFINITY,
|
|
867
945
|
};
|
|
868
946
|
|
|
@@ -879,12 +957,11 @@ export async function runWorkflow<T = unknown>(
|
|
|
879
957
|
const shared: SharedRuntime = options.sharedRuntime ?? {
|
|
880
958
|
limiter: createLimiter(concurrency),
|
|
881
959
|
agentCount: 0,
|
|
960
|
+
physicalAgentCount: 0,
|
|
882
961
|
spent: options.initialTokenUsage?.total ?? 0,
|
|
883
962
|
tokenUsage: options.initialTokenUsage
|
|
884
963
|
? { ...options.initialTokenUsage }
|
|
885
964
|
: { input: 0, output: 0, total: 0, cost: 0, cacheRead: 0, cacheWrite: 0 },
|
|
886
|
-
depth: 0,
|
|
887
|
-
nestedCallSeq: 0,
|
|
888
965
|
runFatalController: new AbortController(),
|
|
889
966
|
inFlight: new Set<Promise<unknown>>(),
|
|
890
967
|
activeThreads: new Set<string>(),
|
|
@@ -892,6 +969,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
892
969
|
const limiter = shared.limiter;
|
|
893
970
|
const isTopLevelRun = !options.sharedRuntime;
|
|
894
971
|
const store: SharedStore = options.sharedStore ?? new SharedStore();
|
|
972
|
+
let frameAgentCount = 0;
|
|
973
|
+
/** agent() promises owned by this runWorkflow frame only. */
|
|
974
|
+
const frameInFlight = new Set<Promise<unknown>>();
|
|
895
975
|
|
|
896
976
|
const log = (message: unknown) => {
|
|
897
977
|
const text = String(message);
|
|
@@ -899,6 +979,14 @@ export async function runWorkflow<T = unknown>(
|
|
|
899
979
|
options.onLog?.(text);
|
|
900
980
|
};
|
|
901
981
|
|
|
982
|
+
const emitRuntimeEvent = (event: WorkflowRuntimeEvent): void => {
|
|
983
|
+
try {
|
|
984
|
+
options.onRuntimeEvent?.(event);
|
|
985
|
+
} catch {
|
|
986
|
+
// Runtime observers are diagnostic surfaces and must not change execution.
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
902
990
|
const phase = (title: string, phaseOptions?: { budget?: number }) => {
|
|
903
991
|
state.currentPhase = title;
|
|
904
992
|
if (!state.phases.includes(title)) state.phases.push(title);
|
|
@@ -908,7 +996,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
908
996
|
state.phaseBudgets.set(title, { budget: phaseOptions.budget, startSpent: shared.spent, warned: false });
|
|
909
997
|
}
|
|
910
998
|
options.onPhase?.(title);
|
|
911
|
-
|
|
999
|
+
emitRuntimeEvent({
|
|
912
1000
|
type: "phase",
|
|
913
1001
|
title,
|
|
914
1002
|
budget: typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0 ? phaseOptions.budget : null,
|
|
@@ -923,7 +1011,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
923
1011
|
|
|
924
1012
|
const agentLimitError = () =>
|
|
925
1013
|
new WorkflowError(
|
|
926
|
-
`Agent limit exceeded (${shared.
|
|
1014
|
+
`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
1015
|
WorkflowErrorCode.AGENT_LIMIT_EXCEEDED,
|
|
928
1016
|
{ recoverable: false },
|
|
929
1017
|
);
|
|
@@ -936,6 +1024,17 @@ export async function runWorkflow<T = unknown>(
|
|
|
936
1024
|
}
|
|
937
1025
|
};
|
|
938
1026
|
|
|
1027
|
+
const nextCallIndex = (): number => {
|
|
1028
|
+
if (state.callSeq >= MAX_CALLS_PER_WORKFLOW) {
|
|
1029
|
+
throw new WorkflowError(
|
|
1030
|
+
`Workflow call limit exceeded (${MAX_CALLS_PER_WORKFLOW} agent/workflow/checkpoint calls)`,
|
|
1031
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1032
|
+
{ recoverable: false },
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
return state.callSeq++;
|
|
1036
|
+
};
|
|
1037
|
+
|
|
939
1038
|
const agent = (promptText: string, agentOptions: AgentOptions = {}): Promise<unknown> => {
|
|
940
1039
|
const rawThread = agentOptions.thread;
|
|
941
1040
|
const thread = rawThread === undefined ? undefined : typeof rawThread === "string" ? rawThread.trim() : "";
|
|
@@ -962,11 +1061,16 @@ export async function runWorkflow<T = unknown>(
|
|
|
962
1061
|
const normalizedOptions = thread && thread !== rawThread ? { ...agentOptions, thread } : agentOptions;
|
|
963
1062
|
let call = agentImpl(promptText, normalizedOptions);
|
|
964
1063
|
if (thread) call = call.finally(() => shared.activeThreads.delete(thread));
|
|
965
|
-
// Track every call (awaited or not)
|
|
966
|
-
//
|
|
967
|
-
//
|
|
1064
|
+
// Track every call (awaited or not) both globally and in its owning frame.
|
|
1065
|
+
// A nested workflow drains only its own forgotten calls before exposing its
|
|
1066
|
+
// result/journal boundary; the top-level frame still drains all shared work.
|
|
968
1067
|
shared.inFlight.add(call);
|
|
969
|
-
|
|
1068
|
+
frameInFlight.add(call);
|
|
1069
|
+
const removeSettledCall = () => {
|
|
1070
|
+
shared.inFlight.delete(call);
|
|
1071
|
+
frameInFlight.delete(call);
|
|
1072
|
+
};
|
|
1073
|
+
void call.then(removeSettledCall, removeSettledCall);
|
|
970
1074
|
return call;
|
|
971
1075
|
};
|
|
972
1076
|
|
|
@@ -977,40 +1081,8 @@ export async function runWorkflow<T = unknown>(
|
|
|
977
1081
|
// synchronously, before suspending on the limiter below.
|
|
978
1082
|
const batch = fanoutScope.getStore();
|
|
979
1083
|
|
|
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
|
-
}
|
|
1084
|
+
const taskScope = orchestrationScope.getStore();
|
|
1085
|
+
const assignedPhase = agentOptions.phase ?? taskScope?.phase ?? state.currentPhase;
|
|
1014
1086
|
|
|
1015
1087
|
const requestedLabel = agentOptions.label?.trim();
|
|
1016
1088
|
// agentType resolution lives in pi-subagents; the name is the observable key.
|
|
@@ -1026,26 +1098,25 @@ export async function runWorkflow<T = unknown>(
|
|
|
1026
1098
|
const modelSpec = agentOptions.model ?? (agentOptions.tier === undefined ? phaseModel : undefined);
|
|
1027
1099
|
|
|
1028
1100
|
// Deterministic resume key: assigned at lexical call time, before the limiter.
|
|
1029
|
-
const callIndex =
|
|
1101
|
+
const callIndex = nextCallIndex();
|
|
1030
1102
|
const callHash = hashAgentCall(promptText, modelSpec, assignedPhase, agentOptions, agentOptions.agentType ?? null);
|
|
1031
1103
|
// Store delta key: runId-namespaced so a nested workflow() call's
|
|
1032
1104
|
// callIndex-0 can never collide with the parent's (see SharedStore doc).
|
|
1033
1105
|
const deltaKey = `${runId}:${callIndex}`;
|
|
1034
1106
|
|
|
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);
|
|
1107
|
+
const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount + 1);
|
|
1040
1108
|
|
|
1041
1109
|
// Longest-unchanged-prefix resume: replay a cached result only while the
|
|
1042
1110
|
// prefix is still intact. Once any call misses, it AND everything after it
|
|
1043
1111
|
// run live, so an edited upstream call never leaves stale downstream
|
|
1044
|
-
// results served from the journal.
|
|
1112
|
+
// results served from the journal. Replay is logical work, not a real
|
|
1113
|
+
// dispatch: it consumes neither the agent cap nor token/phase budget.
|
|
1045
1114
|
const cached = agentOptions.thread ? undefined : options.resumeJournal?.get(deltaKey);
|
|
1046
1115
|
const hashMatches = cached != null && cached.hash === callHash;
|
|
1047
1116
|
const cachedEmptyOutput = hashMatches && isEmptyTextAgentResult(cached.result, agentOptions.schema);
|
|
1048
1117
|
if (hashMatches && !cachedEmptyOutput && callIndex < state.firstMiss) {
|
|
1118
|
+
shared.agentCount++;
|
|
1119
|
+
frameAgentCount++;
|
|
1049
1120
|
options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt: promptText });
|
|
1050
1121
|
options.onAgentEnd?.({
|
|
1051
1122
|
id: deltaKey,
|
|
@@ -1060,6 +1131,36 @@ export async function runWorkflow<T = unknown>(
|
|
|
1060
1131
|
if (!hashMatches || cachedEmptyOutput || agentOptions.thread)
|
|
1061
1132
|
state.firstMiss = Math.min(state.firstMiss, callIndex);
|
|
1062
1133
|
|
|
1134
|
+
// Reserve a real dispatch synchronously — no await occurs between this
|
|
1135
|
+
// check and the increments, so a parallel() burst cannot overshoot the cap.
|
|
1136
|
+
if (shared.physicalAgentCount >= maxAgents) throw agentLimitError();
|
|
1137
|
+
if (budget.total !== null && budget.remaining() <= 0) {
|
|
1138
|
+
throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
|
|
1139
|
+
recoverable: false,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
// Per-phase soft sub-budget gate: warns once at ~80%, throws at 100%.
|
|
1143
|
+
if (assignedPhase) {
|
|
1144
|
+
const pb = state.phaseBudgets.get(assignedPhase);
|
|
1145
|
+
if (pb) {
|
|
1146
|
+
const phaseSpent = shared.spent - pb.startSpent;
|
|
1147
|
+
if (phaseSpent >= pb.budget) {
|
|
1148
|
+
throw new WorkflowError(
|
|
1149
|
+
`phase "${assignedPhase}" token sub-budget exhausted (${pb.budget})`,
|
|
1150
|
+
WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED,
|
|
1151
|
+
{ recoverable: false },
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
if (!pb.warned && phaseSpent >= pb.budget * 0.8) {
|
|
1155
|
+
pb.warned = true;
|
|
1156
|
+
log(`phase "${assignedPhase}" at ${Math.round((phaseSpent / pb.budget) * 100)}% of its token sub-budget`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
shared.agentCount++;
|
|
1161
|
+
frameAgentCount++;
|
|
1162
|
+
shared.physicalAgentCount++;
|
|
1163
|
+
|
|
1063
1164
|
return limiter(async () => {
|
|
1064
1165
|
const timeout = agentOptions.timeoutMs !== undefined ? agentOptions.timeoutMs : agentTimeoutMs;
|
|
1065
1166
|
const retryAttempts = normalizeAgentRetries(agentOptions.retries ?? options.agentRetries ?? 0);
|
|
@@ -1097,6 +1198,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1097
1198
|
schema !== undefined
|
|
1098
1199
|
? `\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
1200
|
: "";
|
|
1201
|
+
let repairFeedback = "";
|
|
1100
1202
|
|
|
1101
1203
|
try {
|
|
1102
1204
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -1128,7 +1230,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1128
1230
|
);
|
|
1129
1231
|
}
|
|
1130
1232
|
|
|
1131
|
-
const runPromise = agentRunner.run(promptText + schemaInstruction, {
|
|
1233
|
+
const runPromise = agentRunner.run(promptText + schemaInstruction + repairFeedback, {
|
|
1132
1234
|
label,
|
|
1133
1235
|
sessionName: agentOptions.thread
|
|
1134
1236
|
? `workflow:${runId} thread:${agentOptions.thread}`
|
|
@@ -1142,6 +1244,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1142
1244
|
isolation: resolvedIsolation,
|
|
1143
1245
|
toolset: options.toolset ?? agentOptions.toolset,
|
|
1144
1246
|
excludeTools: options.excludeTools ?? agentOptions.excludeTools,
|
|
1247
|
+
runId,
|
|
1145
1248
|
callIndex,
|
|
1146
1249
|
callHash,
|
|
1147
1250
|
attempt,
|
|
@@ -1186,7 +1289,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1186
1289
|
if (errors.length > 0) {
|
|
1187
1290
|
const detail = errors.join("; ");
|
|
1188
1291
|
if (attempt < maxAttempts) {
|
|
1189
|
-
// Repair: re-dispatch with the validation errors
|
|
1292
|
+
// Repair: re-dispatch with the concrete validation errors so
|
|
1293
|
+
// the next model attempt can correct the actual failure.
|
|
1294
|
+
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
1295
|
throw new WorkflowError(
|
|
1191
1296
|
`Subagent reply did not match the schema: ${detail}`,
|
|
1192
1297
|
WorkflowErrorCode.AGENT_EXECUTION_ERROR,
|
|
@@ -1275,31 +1380,45 @@ export async function runWorkflow<T = unknown>(
|
|
|
1275
1380
|
const parallel = async (thunks: Array<() => Promise<unknown>>) => {
|
|
1276
1381
|
throwIfAborted();
|
|
1277
1382
|
if (!Array.isArray(thunks)) throw new TypeError("parallel() expects an array of functions");
|
|
1383
|
+
if (thunks.length > MAX_FANOUT_ITEMS) {
|
|
1384
|
+
throw new WorkflowError(
|
|
1385
|
+
`parallel() accepts at most ${MAX_FANOUT_ITEMS} items`,
|
|
1386
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1387
|
+
{ recoverable: false },
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1278
1390
|
if (thunks.some((thunk) => typeof thunk !== "function")) {
|
|
1279
1391
|
throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
|
|
1280
1392
|
}
|
|
1281
1393
|
// Batch-scoped cancellation: a breach in THIS fan-out flips `cancelled` so
|
|
1282
1394
|
// its own still-queued agents bail, without touching a sibling or enclosing
|
|
1283
|
-
// fan-out.
|
|
1395
|
+
// fan-out. Await all wrappers before surfacing a fatal error so the barrier
|
|
1396
|
+
// never returns while a sibling is still mutating the run.
|
|
1284
1397
|
const batch = { cancelled: false };
|
|
1285
|
-
|
|
1398
|
+
const outcomes = await fanoutScope.run(batch, () =>
|
|
1286
1399
|
Promise.all(
|
|
1287
|
-
thunks.map(async (thunk, index) => {
|
|
1400
|
+
thunks.map(async (thunk, index): Promise<{ value: unknown } | { fatal: unknown }> => {
|
|
1288
1401
|
try {
|
|
1289
|
-
return await thunk();
|
|
1402
|
+
return { value: await thunk() };
|
|
1290
1403
|
} catch (error) {
|
|
1291
|
-
if (isAborted()) throw error;
|
|
1292
1404
|
const workflowError = wrapError(error);
|
|
1293
|
-
if (
|
|
1405
|
+
if (
|
|
1406
|
+
isAborted() ||
|
|
1407
|
+
!workflowError.recoverable ||
|
|
1408
|
+
workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT
|
|
1409
|
+
) {
|
|
1294
1410
|
if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) batch.cancelled = true;
|
|
1295
|
-
|
|
1411
|
+
return { fatal: workflowError };
|
|
1296
1412
|
}
|
|
1297
1413
|
log(`parallel[${index}] failed: ${workflowError.message}`);
|
|
1298
|
-
return null;
|
|
1414
|
+
return { value: null };
|
|
1299
1415
|
}
|
|
1300
1416
|
}),
|
|
1301
1417
|
),
|
|
1302
1418
|
);
|
|
1419
|
+
const fatal = outcomes.find((outcome): outcome is { fatal: unknown } => "fatal" in outcome);
|
|
1420
|
+
if (fatal) throw fatal.fatal;
|
|
1421
|
+
return outcomes.map((outcome) => ("value" in outcome ? outcome.value : null));
|
|
1303
1422
|
};
|
|
1304
1423
|
|
|
1305
1424
|
const pipeline = async (
|
|
@@ -1308,13 +1427,20 @@ export async function runWorkflow<T = unknown>(
|
|
|
1308
1427
|
) => {
|
|
1309
1428
|
throwIfAborted();
|
|
1310
1429
|
if (!Array.isArray(items)) throw new TypeError("pipeline() expects an array as the first argument");
|
|
1430
|
+
if (items.length > MAX_FANOUT_ITEMS) {
|
|
1431
|
+
throw new WorkflowError(
|
|
1432
|
+
`pipeline() accepts at most ${MAX_FANOUT_ITEMS} items`,
|
|
1433
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1434
|
+
{ recoverable: false },
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1311
1437
|
if (stages.some((stage) => typeof stage !== "function")) {
|
|
1312
1438
|
throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
|
|
1313
1439
|
}
|
|
1314
1440
|
const batch = { cancelled: false };
|
|
1315
|
-
|
|
1441
|
+
const outcomes = await fanoutScope.run(batch, () =>
|
|
1316
1442
|
Promise.all(
|
|
1317
|
-
items.map(async (item, index) => {
|
|
1443
|
+
items.map(async (item, index): Promise<{ value: unknown } | { fatal: unknown }> => {
|
|
1318
1444
|
let value: unknown = item;
|
|
1319
1445
|
for (const stage of stages) {
|
|
1320
1446
|
try {
|
|
@@ -1322,58 +1448,138 @@ export async function runWorkflow<T = unknown>(
|
|
|
1322
1448
|
value = await stage(value, item, index);
|
|
1323
1449
|
throwIfAborted();
|
|
1324
1450
|
} catch (error) {
|
|
1325
|
-
if (isAborted()) throw error;
|
|
1326
1451
|
const workflowError = wrapError(error);
|
|
1327
|
-
if (
|
|
1452
|
+
if (
|
|
1453
|
+
isAborted() ||
|
|
1454
|
+
!workflowError.recoverable ||
|
|
1455
|
+
workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT
|
|
1456
|
+
) {
|
|
1328
1457
|
if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) batch.cancelled = true;
|
|
1329
|
-
|
|
1458
|
+
return { fatal: workflowError };
|
|
1330
1459
|
}
|
|
1331
1460
|
log(`pipeline[${index}] failed: ${workflowError.message}`);
|
|
1332
|
-
return null;
|
|
1461
|
+
return { value: null };
|
|
1333
1462
|
}
|
|
1334
1463
|
}
|
|
1335
|
-
return value;
|
|
1464
|
+
return { value };
|
|
1336
1465
|
}),
|
|
1337
1466
|
),
|
|
1338
1467
|
);
|
|
1468
|
+
const fatal = outcomes.find((outcome): outcome is { fatal: unknown } => "fatal" in outcome);
|
|
1469
|
+
if (fatal) throw fatal.fatal;
|
|
1470
|
+
return outcomes.map((outcome) => ("value" in outcome ? outcome.value : null));
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Run a named dependency graph in deterministic declaration-order layers.
|
|
1475
|
+
* The graph is deliberately a runtime primitive rather than a second top-level
|
|
1476
|
+
* engine: agent/session policy still belongs to the enclosing workflow and
|
|
1477
|
+
* pi-subagents, while this helper owns only task dependencies and results.
|
|
1478
|
+
*/
|
|
1479
|
+
const orchestrate = async (rawTasks: unknown, rawOptions?: unknown): Promise<OrchestrationResult> => {
|
|
1480
|
+
throwIfAborted();
|
|
1481
|
+
const normalized = normalizeOrchestration(rawTasks, rawOptions);
|
|
1482
|
+
const tasks = normalized.tasks.map((task) => ({
|
|
1483
|
+
...task,
|
|
1484
|
+
run: (context: OrchestrationTaskContext) =>
|
|
1485
|
+
orchestrationScope.run({ taskId: task.id, ...(task.phase === undefined ? {} : { phase: task.phase }) }, () =>
|
|
1486
|
+
task.run(context),
|
|
1487
|
+
),
|
|
1488
|
+
}));
|
|
1489
|
+
return executeOrchestration(tasks, normalized.options, {
|
|
1490
|
+
throwIfAborted,
|
|
1491
|
+
isFatal: (error: unknown) => {
|
|
1492
|
+
if (isAborted()) return true;
|
|
1493
|
+
const workflowError = wrapError(error);
|
|
1494
|
+
return !workflowError.recoverable || workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT;
|
|
1495
|
+
},
|
|
1496
|
+
formatError: (error: unknown) => wrapError(error).message,
|
|
1497
|
+
onEvent: emitRuntimeEvent,
|
|
1498
|
+
});
|
|
1339
1499
|
};
|
|
1340
1500
|
|
|
1341
1501
|
// Nested workflow(): run a saved workflow (or raw script) inline, sharing this
|
|
1342
|
-
// run's limiter/counters/budget. One level deep only.
|
|
1502
|
+
// run's limiter/counters/budget. One level deep only. The whole child result is
|
|
1503
|
+
// journaled as one stable parent call so resume does not silently re-spend every
|
|
1504
|
+
// nested agent when the parent prefix is unchanged.
|
|
1343
1505
|
const workflowFn = async (nameOrScript: string, childArgs?: unknown) => {
|
|
1344
1506
|
throwIfAborted();
|
|
1345
|
-
|
|
1507
|
+
const workflowDepth = options.workflowDepth ?? 0;
|
|
1508
|
+
if (workflowDepth >= 1) {
|
|
1346
1509
|
throw new WorkflowError("workflow() can nest only one level deep", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
1347
1510
|
recoverable: false,
|
|
1348
1511
|
});
|
|
1349
1512
|
}
|
|
1513
|
+
const callIndex = nextCallIndex();
|
|
1350
1514
|
const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
|
|
1351
1515
|
const childScript = resolved ?? String(nameOrScript);
|
|
1352
1516
|
const workflowName = String(nameOrScript);
|
|
1353
|
-
|
|
1354
|
-
|
|
1517
|
+
const argsPresent = childArgs !== undefined;
|
|
1518
|
+
const callHash = hashIdentity({
|
|
1519
|
+
kind: "workflow",
|
|
1520
|
+
name: workflowName,
|
|
1521
|
+
script: childScript,
|
|
1522
|
+
argsPresent,
|
|
1523
|
+
args: argsPresent ? childArgs : null,
|
|
1524
|
+
});
|
|
1525
|
+
const cached = options.resumeJournal?.get(`${runId}:${callIndex}`);
|
|
1526
|
+
if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
|
|
1527
|
+
state.workflowGenerations.set(callIndex, cached.generation ?? 1);
|
|
1528
|
+
if (cached.storeDelta) store.applyDelta(cached.storeDelta);
|
|
1529
|
+
const replayedAgentCount = cached.agentCount ?? 0;
|
|
1530
|
+
shared.agentCount += replayedAgentCount;
|
|
1531
|
+
frameAgentCount += replayedAgentCount;
|
|
1532
|
+
return cached.result;
|
|
1533
|
+
}
|
|
1534
|
+
if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
|
|
1535
|
+
const generation = (state.workflowGenerations.get(callIndex) ?? cached?.generation ?? 0) + 1;
|
|
1536
|
+
state.workflowGenerations.set(callIndex, generation);
|
|
1537
|
+
emitRuntimeEvent({ type: "workflow", stage: "start", name: workflowName, args: childArgs });
|
|
1355
1538
|
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;
|
|
1539
|
+
const childDeltas = new Map<number, Record<string, unknown>>();
|
|
1362
1540
|
const child = await runWorkflow(childScript, {
|
|
1363
1541
|
...options,
|
|
1364
1542
|
args: childArgs,
|
|
1365
1543
|
sharedRuntime: shared,
|
|
1366
1544
|
sharedStore: store,
|
|
1367
|
-
|
|
1545
|
+
workflowDepth: workflowDepth + 1,
|
|
1546
|
+
// The parent call caches the complete child value. Replaying child
|
|
1547
|
+
// call results here would duplicate nested calls and make the synthetic
|
|
1548
|
+
// parent journal entry ambiguous.
|
|
1549
|
+
resumeJournal: undefined,
|
|
1368
1550
|
resumeFromRunId: undefined,
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1551
|
+
onWorkflowJournal: undefined,
|
|
1552
|
+
onAgentJournal: (entry) => {
|
|
1553
|
+
if (entry.storeDelta) childDeltas.set(entry.index, entry.storeDelta);
|
|
1554
|
+
// Preserve nested lifecycle visibility for direct runtime consumers;
|
|
1555
|
+
// the engine's top-level agent journal intentionally ignores child ids.
|
|
1556
|
+
options.onAgentJournal?.(entry);
|
|
1557
|
+
},
|
|
1558
|
+
// The call index + generation keeps changed nested calls distinct across
|
|
1559
|
+
// resume. The engine nonce additionally fences an interrupted child whose
|
|
1560
|
+
// result was never durable before a process/branch restart.
|
|
1561
|
+
runId: `${runId}-nested-call-${callIndex}-g${generation}${options.executionNonce ? `-${options.executionNonce}` : ""}`,
|
|
1372
1562
|
});
|
|
1373
|
-
|
|
1563
|
+
const normalizedChildResult = child.result === undefined ? null : child.result;
|
|
1564
|
+
const storeDelta: Record<string, unknown> = {};
|
|
1565
|
+
for (const delta of [...childDeltas.entries()].sort(([a], [b]) => a - b).map(([, value]) => value)) {
|
|
1566
|
+
Object.assign(storeDelta, delta);
|
|
1567
|
+
}
|
|
1568
|
+
// Do not append a nested result after pause/stop has claimed the parent;
|
|
1569
|
+
// terminal recovery must remain the last durable fact for that execution.
|
|
1570
|
+
throwIfAborted();
|
|
1571
|
+
options.onWorkflowJournal?.({
|
|
1572
|
+
index: callIndex,
|
|
1573
|
+
runId,
|
|
1574
|
+
hash: callHash,
|
|
1575
|
+
result: normalizedChildResult,
|
|
1576
|
+
generation,
|
|
1577
|
+
agentCount: child.agentCount,
|
|
1578
|
+
...(Object.keys(storeDelta).length > 0 ? { storeDelta } : {}),
|
|
1579
|
+
});
|
|
1580
|
+
return normalizedChildResult;
|
|
1374
1581
|
} finally {
|
|
1375
|
-
|
|
1376
|
-
options.onRuntimeEvent?.({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
|
|
1582
|
+
emitRuntimeEvent({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
|
|
1377
1583
|
}
|
|
1378
1584
|
};
|
|
1379
1585
|
|
|
@@ -1393,7 +1599,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1393
1599
|
total: number;
|
|
1394
1600
|
votes: Array<{ real: boolean; reason?: string }>;
|
|
1395
1601
|
}> => {
|
|
1396
|
-
|
|
1602
|
+
emitRuntimeEvent({ type: "quality", stage: "start", helper: "verify" });
|
|
1397
1603
|
const reviewers = Math.max(1, opts.reviewers ?? 2);
|
|
1398
1604
|
const threshold = opts.threshold ?? 0.5;
|
|
1399
1605
|
const lenses = opts.lens ? (Array.isArray(opts.lens) ? opts.lens : [opts.lens]) : [];
|
|
@@ -1430,9 +1636,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1430
1636
|
): Promise<
|
|
1431
1637
|
{ index: number; attempt: unknown; score: number; judgments: Array<{ score: number; reason?: string }> } | undefined
|
|
1432
1638
|
> => {
|
|
1433
|
-
|
|
1639
|
+
emitRuntimeEvent({ type: "quality", stage: "start", helper: "judgePanel" });
|
|
1434
1640
|
if (!Array.isArray(attempts) || attempts.length === 0) {
|
|
1435
|
-
|
|
1641
|
+
emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
|
|
1436
1642
|
return undefined;
|
|
1437
1643
|
}
|
|
1438
1644
|
const judges = Math.min(16, Math.max(1, Math.floor(opts.judges ?? 3)));
|
|
@@ -1473,12 +1679,12 @@ export async function runWorkflow<T = unknown>(
|
|
|
1473
1679
|
judgments: s.judgments.map((j) => ({ score: Number(j?.score) || 0, reason: j?.reason })),
|
|
1474
1680
|
}));
|
|
1475
1681
|
if (normalized.length === 0) {
|
|
1476
|
-
|
|
1682
|
+
emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
|
|
1477
1683
|
return undefined;
|
|
1478
1684
|
}
|
|
1479
1685
|
let best = normalized[0];
|
|
1480
1686
|
for (const s of normalized) if (s.score > best.score || (s.score === best.score && s.index < best.index)) best = s;
|
|
1481
|
-
|
|
1687
|
+
emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
|
|
1482
1688
|
return best;
|
|
1483
1689
|
};
|
|
1484
1690
|
|
|
@@ -1530,12 +1736,12 @@ export async function runWorkflow<T = unknown>(
|
|
|
1530
1736
|
taskArgs: unknown,
|
|
1531
1737
|
results: unknown,
|
|
1532
1738
|
): Promise<{ complete: boolean; missing?: string[] } | null> => {
|
|
1533
|
-
|
|
1739
|
+
emitRuntimeEvent({ type: "quality", stage: "start", helper: "completenessCheck" });
|
|
1534
1740
|
const verdict = await agent(
|
|
1535
1741
|
`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
1742
|
{ label: "completeness critic", schema: COMPLETENESS_SCHEMA },
|
|
1537
1743
|
);
|
|
1538
|
-
|
|
1744
|
+
emitRuntimeEvent({ type: "quality", stage: "end", helper: "completenessCheck" });
|
|
1539
1745
|
return verdict as { complete: boolean; missing?: string[] } | null;
|
|
1540
1746
|
};
|
|
1541
1747
|
|
|
@@ -1549,7 +1755,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1549
1755
|
throwIfAborted();
|
|
1550
1756
|
last = await thunk(i);
|
|
1551
1757
|
const accepted = !opts.until || opts.until(last);
|
|
1552
|
-
|
|
1758
|
+
emitRuntimeEvent({ type: "control-attempt", helper: "retry", attempt: i + 1, accepted });
|
|
1553
1759
|
if (accepted) return last;
|
|
1554
1760
|
}
|
|
1555
1761
|
return last;
|
|
@@ -1568,7 +1774,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1568
1774
|
last = await thunk(feedback, i);
|
|
1569
1775
|
const verdict = await validator(last);
|
|
1570
1776
|
const accepted = Boolean(verdict?.ok);
|
|
1571
|
-
|
|
1777
|
+
emitRuntimeEvent({ type: "control-attempt", helper: "gate", attempt: i + 1, accepted });
|
|
1572
1778
|
if (accepted) return { ok: true, value: last, attempts: i + 1 };
|
|
1573
1779
|
feedback = verdict?.feedback;
|
|
1574
1780
|
}
|
|
@@ -1581,19 +1787,20 @@ export async function runWorkflow<T = unknown>(
|
|
|
1581
1787
|
const checkpoint = async (promptText: string, checkpointOptions: CheckpointOptions = {}) => {
|
|
1582
1788
|
throwIfAborted();
|
|
1583
1789
|
if (typeof promptText !== "string") throw new TypeError("checkpoint(promptText, options?) needs a prompt string");
|
|
1584
|
-
|
|
1585
|
-
throw agentLimitError();
|
|
1586
|
-
}
|
|
1587
|
-
const callIndex = state.callSeq++;
|
|
1790
|
+
const callIndex = nextCallIndex();
|
|
1588
1791
|
const callHash = hashCheckpoint(promptText, checkpointOptions);
|
|
1589
1792
|
const journalKey = `${runId}:${callIndex}`;
|
|
1590
1793
|
const cached = options.resumeJournal?.get(journalKey);
|
|
1591
1794
|
if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
|
|
1592
1795
|
shared.agentCount++;
|
|
1593
|
-
|
|
1796
|
+
frameAgentCount++;
|
|
1797
|
+
return cached.result; // replay the journaled human reply without spending a slot
|
|
1594
1798
|
}
|
|
1595
1799
|
if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
|
|
1800
|
+
if (shared.physicalAgentCount >= maxAgents) throw agentLimitError();
|
|
1596
1801
|
shared.agentCount++;
|
|
1802
|
+
frameAgentCount++;
|
|
1803
|
+
shared.physicalAgentCount++;
|
|
1597
1804
|
|
|
1598
1805
|
let reply: unknown;
|
|
1599
1806
|
if (options.confirm) {
|
|
@@ -1616,6 +1823,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1616
1823
|
agent,
|
|
1617
1824
|
parallel,
|
|
1618
1825
|
pipeline,
|
|
1826
|
+
orchestrate,
|
|
1619
1827
|
workflow: workflowFn,
|
|
1620
1828
|
verify,
|
|
1621
1829
|
judgePanel,
|
|
@@ -1681,7 +1889,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1681
1889
|
result: result as T,
|
|
1682
1890
|
logs: state.logs,
|
|
1683
1891
|
phases: state.phases,
|
|
1684
|
-
agentCount: shared.agentCount,
|
|
1892
|
+
agentCount: isTopLevelRun ? shared.agentCount : frameAgentCount,
|
|
1685
1893
|
durationMs: Date.now() - started,
|
|
1686
1894
|
runId,
|
|
1687
1895
|
tokenUsage: shared.tokenUsage,
|
|
@@ -1695,14 +1903,15 @@ export async function runWorkflow<T = unknown>(
|
|
|
1695
1903
|
if (isTopLevelRun) shared.runFatalController.abort();
|
|
1696
1904
|
throw error;
|
|
1697
1905
|
} finally {
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1906
|
+
const ownedInFlight = isTopLevelRun ? shared.inFlight : frameInFlight;
|
|
1907
|
+
if (ownedInFlight.size > 0) {
|
|
1908
|
+
log(
|
|
1909
|
+
`waiting for ${ownedInFlight.size} outstanding agent() call(s) to settle before this workflow frame completes`,
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
while (ownedInFlight.size > 0) {
|
|
1913
|
+
await Promise.allSettled(Array.from(ownedInFlight));
|
|
1706
1914
|
}
|
|
1915
|
+
if (isTopLevelRun) store.dispose();
|
|
1707
1916
|
}
|
|
1708
1917
|
}
|