@signalridge/pi-workflows 1.2.1 → 1.3.2
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 +41 -0
- package/README.md +142 -17
- package/package.json +14 -8
- package/skills/workflow-authoring/SKILL.md +30 -0
- package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
- package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
- package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
- package/skills/workflow-authoring/examples/defensive-json-parsing.js +63 -0
- package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
- package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
- package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
- package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
- package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
- package/skills/workflow-authoring/examples/structured-output.js +37 -0
- package/skills/workflow-authoring/examples/tournament.js +81 -0
- package/skills/workflow-authoring/examples/validated-gate.js +63 -0
- package/skills/workflow-authoring/references/capabilities.md +44 -0
- package/skills/workflow-authoring/references/capability-details.md +294 -0
- package/skills/workflow-authoring/references/common-helpers.md +4 -0
- package/skills/workflow-authoring/references/debugging.md +29 -0
- package/skills/workflow-authoring/references/focused-recipes.md +12 -0
- package/skills/workflow-authoring/references/helpers.md +9 -0
- package/skills/workflow-authoring/references/lifecycle.md +37 -0
- package/skills/workflow-authoring/references/pattern-selection.md +15 -0
- package/skills/workflow-authoring/references/quality-helpers.md +8 -0
- package/skills/workflow-authoring/references/registry-ownership.md +17 -0
- package/skills/workflow-authoring/references/retry-helper.md +5 -0
- package/skills/workflow-authoring/references/review.md +43 -0
- package/skills/workflow-authoring/references/runtime.md +29 -0
- package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
- package/skills/workflow-authoring/references/versions.md +13 -0
- package/skills/workflow-patterns/SKILL.md +51 -0
- package/src/arming.ts +69 -0
- package/src/builtins.ts +331 -0
- package/src/capabilities.ts +318 -0
- package/src/code-review-scope.ts +144 -0
- package/src/engine.ts +1158 -2561
- package/src/errors.ts +136 -0
- package/src/index.ts +750 -103
- package/src/journal.ts +594 -1001
- package/src/rpc-client.ts +95 -24
- package/src/runtime.ts +1708 -0
- package/src/saved-workflows.ts +121 -0
- package/src/shared-store.ts +132 -0
- package/src/ui.ts +79 -42
- package/src/workflow-settings.ts +100 -0
- package/src/schema.ts +0 -215
- package/src/state-machine.ts +0 -79
- package/src/synthesis.ts +0 -140
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,1708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime.ts — JS orchestration runtime for pi-workflows.
|
|
3
|
+
*
|
|
4
|
+
* A workflow is a raw JavaScript module. The runtime parses it under an acorn
|
|
5
|
+
* gate (deterministic literal `export const meta` first, no nondeterministic
|
|
6
|
+
* builtins), then executes the body inside a `node:vm` realm with a bounded set
|
|
7
|
+
* of orchestration globals (`agent`, `parallel`, `pipeline`, `workflow`,
|
|
8
|
+
* quality/control helpers, `phase`, `log`, `args`, `cwd`, restricted
|
|
9
|
+
* `process`, `budget`). The vm is a determinism guardrail, not a security
|
|
10
|
+
* boundary: scripts are generated by the user's own LLM and run on the user's
|
|
11
|
+
* own machine.
|
|
12
|
+
*
|
|
13
|
+
* Dispatch goes through the injected `WorkflowAgentRunner`, which the engine
|
|
14
|
+
* wires to the `subagents:rpc:spawn-managed` protocol — this file never talks
|
|
15
|
+
* to pi-subagents directly. Everything upstream of the actual subagent session
|
|
16
|
+
* (call sequencing, hashing, resume replay, concurrency limiting, budget,
|
|
17
|
+
* fan-out cancellation, journaling hooks) lives here and is unit-testable with
|
|
18
|
+
* a fake runner.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import vm from "node:vm";
|
|
24
|
+
import { parse } from "acorn";
|
|
25
|
+
import { checkRuntimeBindings } from "./capabilities.js";
|
|
26
|
+
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
27
|
+
import { SharedStore } from "./shared-store.js";
|
|
28
|
+
|
|
29
|
+
// ── Meta contract ────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export interface WorkflowMetaPhase {
|
|
32
|
+
title: string;
|
|
33
|
+
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
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WorkflowMeta {
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
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
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A resolved, journaled agent() call. */
|
|
55
|
+
export interface JournalEntry {
|
|
56
|
+
/** The top-level run id; missing on entries persisted before namespacing. */
|
|
57
|
+
runId?: string;
|
|
58
|
+
index: number;
|
|
59
|
+
hash: string;
|
|
60
|
+
result: unknown;
|
|
61
|
+
storeDelta?: Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AgentUsage {
|
|
65
|
+
input: number;
|
|
66
|
+
output: number;
|
|
67
|
+
total: number;
|
|
68
|
+
cost: number;
|
|
69
|
+
cacheRead: number;
|
|
70
|
+
cacheWrite: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SharedRuntime {
|
|
74
|
+
limiter: Limiter;
|
|
75
|
+
agentCount: number;
|
|
76
|
+
spent: number;
|
|
77
|
+
tokenUsage: AgentUsage;
|
|
78
|
+
depth: number;
|
|
79
|
+
nestedCallSeq: number;
|
|
80
|
+
runFatalController: AbortController;
|
|
81
|
+
inFlight: Set<Promise<unknown>>;
|
|
82
|
+
activeThreads: Set<string>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Counting semaphore with a FIFO resolve queue. */
|
|
86
|
+
export type Limiter = <T>(fn: () => Promise<T>) => Promise<T>;
|
|
87
|
+
|
|
88
|
+
/** The dispatch boundary the engine implements over spawn-managed RPC. */
|
|
89
|
+
export interface WorkflowAgentRunner {
|
|
90
|
+
run(prompt: string, options?: AgentRunOptions): Promise<unknown>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface AgentRunOptions {
|
|
94
|
+
label?: string;
|
|
95
|
+
sessionName?: string;
|
|
96
|
+
/** Plain JSON Schema the result must satisfy; the runtime parses+validates client-side. */
|
|
97
|
+
schema?: unknown;
|
|
98
|
+
signal?: AbortSignal;
|
|
99
|
+
instructions?: string;
|
|
100
|
+
model?: string;
|
|
101
|
+
tier?: string;
|
|
102
|
+
cwd?: string;
|
|
103
|
+
/** Named subagent type; resolved by pi-subagents at dispatch. */
|
|
104
|
+
agentType?: string;
|
|
105
|
+
/** Lexical call index — engine-side spawnKey identity (A4). */
|
|
106
|
+
callIndex?: number;
|
|
107
|
+
/** Stable hash of the call inputs — engine-side resume comparison (A4). */
|
|
108
|
+
callHash?: string;
|
|
109
|
+
/** One-based attempt number within this call's retry loop. */
|
|
110
|
+
attempt?: number;
|
|
111
|
+
thread?: string;
|
|
112
|
+
/** Per-call worktree request forwarded to pi-subagents. */
|
|
113
|
+
isolation?: "worktree";
|
|
114
|
+
/** Named toolset hint forwarded to pi-subagents. */
|
|
115
|
+
toolset?: string;
|
|
116
|
+
/** Additional tool names denied for this call. */
|
|
117
|
+
excludeTools?: string[];
|
|
118
|
+
onModelResolved?: (id: string) => void;
|
|
119
|
+
onModelFallback?: (info: { tier: string; requestedSpec: string }) => void;
|
|
120
|
+
onUsage?: (usage: AgentUsage) => void;
|
|
121
|
+
onHistory?: (history: unknown[]) => void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface AgentOptions {
|
|
125
|
+
label?: string;
|
|
126
|
+
phase?: string;
|
|
127
|
+
schema?: unknown;
|
|
128
|
+
model?: string;
|
|
129
|
+
tier?: string;
|
|
130
|
+
isolation?: "worktree";
|
|
131
|
+
agentType?: string;
|
|
132
|
+
timeoutMs?: number | null;
|
|
133
|
+
retries?: number;
|
|
134
|
+
thread?: string;
|
|
135
|
+
toolset?: string;
|
|
136
|
+
excludeTools?: string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface CheckpointOptions {
|
|
140
|
+
default?: unknown;
|
|
141
|
+
headless?: "default" | "abort";
|
|
142
|
+
kind?: "confirm" | "input" | "select";
|
|
143
|
+
choices?: string[];
|
|
144
|
+
timeoutMs?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface WorkflowRuntimeEvent {
|
|
148
|
+
type: "phase" | "workflow" | "quality" | "control-attempt";
|
|
149
|
+
[key: string]: unknown;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface WorkflowRunOptions {
|
|
153
|
+
args?: unknown;
|
|
154
|
+
agent?: WorkflowAgentRunner;
|
|
155
|
+
/** The session's main model (provider/id), shown for default agents. */
|
|
156
|
+
mainModel?: string;
|
|
157
|
+
cwd?: string;
|
|
158
|
+
concurrency?: number;
|
|
159
|
+
agentRetries?: number;
|
|
160
|
+
tokenBudget?: number | null;
|
|
161
|
+
signal?: AbortSignal;
|
|
162
|
+
maxAgents?: number;
|
|
163
|
+
agentTimeoutMs?: number | null;
|
|
164
|
+
runId?: string;
|
|
165
|
+
resumeJournal?: Map<string, JournalEntry>;
|
|
166
|
+
resumeFromRunId?: string;
|
|
167
|
+
toolset?: string;
|
|
168
|
+
excludeTools?: string[];
|
|
169
|
+
onAgentJournal?: (entry: JournalEntry) => void;
|
|
170
|
+
onRetrySpend?: (tokens: number) => void;
|
|
171
|
+
sharedRuntime?: SharedRuntime;
|
|
172
|
+
initialTokenUsage?: AgentUsage;
|
|
173
|
+
sharedStore?: SharedStore;
|
|
174
|
+
loadSavedWorkflow?: (name: string) => string | undefined;
|
|
175
|
+
confirm?: (promptText: string, options: CheckpointOptions) => Promise<unknown>;
|
|
176
|
+
onLog?: (message: string) => void;
|
|
177
|
+
onPhase?: (title: string) => void;
|
|
178
|
+
onRuntimeEvent?: (event: WorkflowRuntimeEvent) => void;
|
|
179
|
+
onAgentStart?: (event: { id: string; label: string; phase?: string; prompt: string; model?: string }) => void;
|
|
180
|
+
onAgentEnd?: (event: {
|
|
181
|
+
id: string;
|
|
182
|
+
label: string;
|
|
183
|
+
phase?: string;
|
|
184
|
+
result: unknown;
|
|
185
|
+
tokens?: number;
|
|
186
|
+
tokenUsage?: AgentUsage;
|
|
187
|
+
worktree?: string;
|
|
188
|
+
model?: string;
|
|
189
|
+
error?: string;
|
|
190
|
+
errorCode?: WorkflowErrorCode;
|
|
191
|
+
recoverable?: boolean;
|
|
192
|
+
}) => void;
|
|
193
|
+
onAgentHistory?: (event: { id: string; label: string; phase?: string; history: unknown[] }) => void;
|
|
194
|
+
onTokenUsage?: (usage: AgentUsage) => void;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface WorkflowRunResult<T = unknown> {
|
|
198
|
+
meta: WorkflowMeta;
|
|
199
|
+
result: T;
|
|
200
|
+
logs: string[];
|
|
201
|
+
phases: string[];
|
|
202
|
+
agentCount: number;
|
|
203
|
+
durationMs: number;
|
|
204
|
+
runId?: string;
|
|
205
|
+
tokenUsage?: AgentUsage;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** How agent() results that are plain text vs schema-structured are journaled. */
|
|
209
|
+
export interface RuntimeState {
|
|
210
|
+
logs: string[];
|
|
211
|
+
phases: string[];
|
|
212
|
+
currentPhase?: string;
|
|
213
|
+
phaseBudgets: Map<string, { budget: number; startSpent: number; warned: boolean }>;
|
|
214
|
+
callSeq: number;
|
|
215
|
+
firstMiss: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export const MAX_AGENTS_PER_RUN = 1000;
|
|
219
|
+
export const MAX_CONCURRENCY = 16;
|
|
220
|
+
export const MAX_AGENT_RETRIES = 3;
|
|
221
|
+
export const DEFAULT_AGENT_TIMEOUT_MS: number | null = 300_000;
|
|
222
|
+
export const MAX_SCRIPT_SYNC_MS = 1_000;
|
|
223
|
+
|
|
224
|
+
// ── Determinism guardrail ────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Run inside the vm realm BEFORE the user script. Neutered builtins would break
|
|
230
|
+
* resume (a re-run would produce different values than the cached journal):
|
|
231
|
+
* - Math.random() -> throws
|
|
232
|
+
* - Date.now() -> throws
|
|
233
|
+
* - Date() / new Date() -> throws (no-arg); new Date(arg) still works
|
|
234
|
+
* Using the vm realm's own Math/Date/Reflect (not host objects) adds no
|
|
235
|
+
* host-`Function` escape. vm is not a security sandbox — best-effort guard
|
|
236
|
+
* against ACCIDENTAL nondeterminism from trusted scripts, not a security wall.
|
|
237
|
+
*/
|
|
238
|
+
const DETERMINISM_PRELUDE = [
|
|
239
|
+
'"use strict";',
|
|
240
|
+
'Math.random = () => { throw new Error("Math.random() is unavailable in a workflow (it breaks resume); pass randomness via args or vary by index"); };',
|
|
241
|
+
"{",
|
|
242
|
+
" const RealDate = Date;",
|
|
243
|
+
' const fail = (w) => { throw new Error(w + " is unavailable in a workflow (it breaks resume); pass a timestamp via args"); };',
|
|
244
|
+
" const SafeDate = function (...a) {",
|
|
245
|
+
' if (!new.target) fail("Date()");',
|
|
246
|
+
' if (a.length === 0) fail("new Date()");',
|
|
247
|
+
" return Reflect.construct(RealDate, a, SafeDate);",
|
|
248
|
+
" };",
|
|
249
|
+
" SafeDate.UTC = RealDate.UTC;",
|
|
250
|
+
" SafeDate.parse = RealDate.parse;",
|
|
251
|
+
' SafeDate.now = () => fail("Date.now()");',
|
|
252
|
+
" SafeDate.prototype = RealDate.prototype;",
|
|
253
|
+
" globalThis.Date = SafeDate;",
|
|
254
|
+
"}",
|
|
255
|
+
].join("\n");
|
|
256
|
+
|
|
257
|
+
// ── Script parsing gate ──────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Parse the meta contract out of a workflow script and return the executable
|
|
261
|
+
* body (the script with the meta statement removed). The meta statement must
|
|
262
|
+
* be the first statement and must be a literal-only object; anything
|
|
263
|
+
* non-literal (spread, computed keys, methods, interpolation, reserved keys)
|
|
264
|
+
* is rejected so the meta is fully static and deterministic.
|
|
265
|
+
*/
|
|
266
|
+
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
|
+
const ast = parse(script, {
|
|
276
|
+
ecmaVersion: "latest",
|
|
277
|
+
sourceType: "module",
|
|
278
|
+
allowAwaitOutsideFunction: true,
|
|
279
|
+
allowReturnOutsideFunction: true,
|
|
280
|
+
ranges: false,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const first = ast.body?.[0] as { type?: string } | undefined;
|
|
284
|
+
if (first?.type !== "ExportNamedDeclaration") {
|
|
285
|
+
throw new WorkflowError(
|
|
286
|
+
"`export const meta = { name, description, phases }` must be the first statement in the script",
|
|
287
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
288
|
+
{ recoverable: false },
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const declaration = (first as { declaration?: unknown }).declaration as
|
|
293
|
+
| { type?: string; kind?: string; declarations?: unknown[] }
|
|
294
|
+
| null
|
|
295
|
+
| undefined;
|
|
296
|
+
if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") {
|
|
297
|
+
throw new WorkflowError(
|
|
298
|
+
"meta export must be `export const meta = ...`",
|
|
299
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
300
|
+
{
|
|
301
|
+
recoverable: false,
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (declaration.declarations?.length !== 1) {
|
|
306
|
+
throw new WorkflowError("meta export must declare only `meta`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
307
|
+
recoverable: false,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const declarator = declaration.declarations[0] as {
|
|
312
|
+
id?: { type?: string; name?: string } | null;
|
|
313
|
+
init?: unknown;
|
|
314
|
+
};
|
|
315
|
+
if (declarator.id?.type !== "Identifier" || declarator.id.name !== "meta") {
|
|
316
|
+
throw new WorkflowError("meta export must declare `meta`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
317
|
+
recoverable: false,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (!declarator.init) {
|
|
321
|
+
throw new WorkflowError("meta must have a literal value", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
322
|
+
recoverable: false,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const meta = evaluateLiteral(declarator.init, "meta");
|
|
327
|
+
validateMeta(meta);
|
|
328
|
+
const start = (first as { start?: number }).start ?? 0;
|
|
329
|
+
const end = (first as { end?: number }).end ?? 0;
|
|
330
|
+
return { meta, body: script.slice(0, start) + script.slice(end) };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
interface LiteralNode {
|
|
334
|
+
type: string;
|
|
335
|
+
value?: unknown;
|
|
336
|
+
properties?: unknown[];
|
|
337
|
+
elements?: Array<unknown | null>;
|
|
338
|
+
expressions?: unknown[];
|
|
339
|
+
quasis?: Array<{ value?: { cooked?: string; raw?: string } }>;
|
|
340
|
+
argument?: unknown;
|
|
341
|
+
operator?: string;
|
|
342
|
+
key?: unknown;
|
|
343
|
+
computed?: boolean;
|
|
344
|
+
kind?: string;
|
|
345
|
+
method?: boolean;
|
|
346
|
+
name?: string;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function evaluateLiteral(node: unknown, path: string): unknown {
|
|
350
|
+
const n = node as LiteralNode;
|
|
351
|
+
switch (n.type) {
|
|
352
|
+
case "ObjectExpression": {
|
|
353
|
+
const out: Record<string, unknown> = {};
|
|
354
|
+
for (const rawProp of n.properties as unknown[]) {
|
|
355
|
+
const prop = rawProp as LiteralNode;
|
|
356
|
+
if (prop.type === "SpreadElement") throw new Error(`spread not allowed in ${path}`);
|
|
357
|
+
if (prop.type !== "Property") throw new Error(`only plain properties allowed in ${path}`);
|
|
358
|
+
if (prop.computed) throw new Error(`computed keys not allowed in ${path}`);
|
|
359
|
+
if (prop.kind !== "init" || prop.method) throw new Error(`methods/accessors not allowed in ${path}`);
|
|
360
|
+
const key = propertyKey(prop.key, path);
|
|
361
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
362
|
+
throw new Error(`reserved key name not allowed in ${path}: ${key}`);
|
|
363
|
+
}
|
|
364
|
+
out[key] = evaluateLiteral((prop as { value?: unknown }).value, `${path}.${key}`);
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
case "ArrayExpression":
|
|
369
|
+
return (n.elements as Array<unknown | null>).map((element, index) => {
|
|
370
|
+
if (!element) throw new Error(`sparse arrays not allowed in ${path}`);
|
|
371
|
+
if ((element as LiteralNode).type === "SpreadElement") throw new Error(`spread not allowed in ${path}`);
|
|
372
|
+
return evaluateLiteral(element, `${path}[${index}]`);
|
|
373
|
+
});
|
|
374
|
+
case "Literal":
|
|
375
|
+
return n.value;
|
|
376
|
+
case "TemplateLiteral":
|
|
377
|
+
if ((n.expressions?.length ?? 0) > 0) throw new Error(`template interpolation not allowed in ${path}`);
|
|
378
|
+
return (n.quasis ?? [])
|
|
379
|
+
.map((quasi: { value?: { cooked?: string; raw?: string } }) => quasi.value?.cooked ?? quasi.value?.raw ?? "")
|
|
380
|
+
.join("");
|
|
381
|
+
case "UnaryExpression":
|
|
382
|
+
if (
|
|
383
|
+
n.operator === "-" &&
|
|
384
|
+
(n.argument as LiteralNode)?.type === "Literal" &&
|
|
385
|
+
typeof (n.argument as LiteralNode).value === "number"
|
|
386
|
+
) {
|
|
387
|
+
return (n.argument as { value: number }).value * -1;
|
|
388
|
+
}
|
|
389
|
+
throw new Error(`only negative-number unary allowed in ${path}`);
|
|
390
|
+
default:
|
|
391
|
+
throw new Error(`non-literal node type in ${path}: ${n.type}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function propertyKey(node: unknown, path: string): string {
|
|
396
|
+
const n = node as LiteralNode;
|
|
397
|
+
if (n.type === "Identifier") return n.name as string;
|
|
398
|
+
if (n.type === "Literal" && (typeof n.value === "string" || typeof n.value === "number")) return String(n.value);
|
|
399
|
+
throw new Error(`unsupported key type in ${path}: ${n.type}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function validateMeta(meta: unknown): asserts meta is WorkflowMeta {
|
|
403
|
+
if (!meta || typeof meta !== "object") throw new Error("meta must be an object");
|
|
404
|
+
const value = meta as WorkflowMeta;
|
|
405
|
+
if (typeof value.name !== "string" || !value.name.trim() || value.name.length > 512)
|
|
406
|
+
throw new Error("meta.name must be a non-empty string of at most 512 characters");
|
|
407
|
+
if (typeof value.description !== "string" || !value.description.trim() || value.description.length > 100_000)
|
|
408
|
+
throw new Error("meta.description must be a non-empty string of at most 100000 characters");
|
|
409
|
+
if (value.model !== undefined && typeof value.model !== "string") throw new Error("meta.model must be a string");
|
|
410
|
+
if (value.phases !== undefined) {
|
|
411
|
+
if (!Array.isArray(value.phases)) throw new Error("meta.phases must be an array");
|
|
412
|
+
for (const phase of value.phases) {
|
|
413
|
+
if (
|
|
414
|
+
!phase ||
|
|
415
|
+
typeof phase !== "object" ||
|
|
416
|
+
typeof phase.title !== "string" ||
|
|
417
|
+
!phase.title.trim() ||
|
|
418
|
+
phase.title.length > 512
|
|
419
|
+
) {
|
|
420
|
+
throw new Error("each meta phase must have a title string");
|
|
421
|
+
}
|
|
422
|
+
if ((phase as WorkflowMetaPhase).model !== undefined && typeof (phase as WorkflowMetaPhase).model !== "string") {
|
|
423
|
+
throw new Error("each meta phase model must be a string when present");
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Concurrency limiter ──────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
function createLimiter(limit: number): Limiter {
|
|
432
|
+
let active = 0;
|
|
433
|
+
const queue: Array<() => void> = [];
|
|
434
|
+
const next = () => {
|
|
435
|
+
active--;
|
|
436
|
+
queue.shift()?.();
|
|
437
|
+
};
|
|
438
|
+
return async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
439
|
+
if (active >= limit) await new Promise<void>((resolve) => queue.push(resolve));
|
|
440
|
+
active++;
|
|
441
|
+
try {
|
|
442
|
+
return await fn();
|
|
443
|
+
} finally {
|
|
444
|
+
next();
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── Hashing ──────────────────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
function hashIdentity(value: unknown): string {
|
|
452
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Stable identity hash for an agent() call. Must cover every input that can
|
|
457
|
+
* change the outcome so a cache-miss is never served on resume. The agent
|
|
458
|
+
* definition is represented by the agentType name (the definition itself lives
|
|
459
|
+
* in pi-subagents; a name-only key is the honest observable).
|
|
460
|
+
*/
|
|
461
|
+
function hashAgentCall(
|
|
462
|
+
prompt: string,
|
|
463
|
+
model: string | undefined,
|
|
464
|
+
phase: string | undefined,
|
|
465
|
+
options: AgentOptions,
|
|
466
|
+
agentDefKey: string | null,
|
|
467
|
+
): string {
|
|
468
|
+
return hashIdentity({
|
|
469
|
+
prompt,
|
|
470
|
+
model: model ?? null,
|
|
471
|
+
tier: options.tier ?? null,
|
|
472
|
+
phase: phase ?? null,
|
|
473
|
+
agentType: options.agentType ?? null,
|
|
474
|
+
thread: options.thread ?? null,
|
|
475
|
+
agentDef: agentDefKey,
|
|
476
|
+
schema: options.schema ?? null,
|
|
477
|
+
isolation: options.isolation ?? null,
|
|
478
|
+
toolset: options.toolset ?? null,
|
|
479
|
+
excludeTools: options.excludeTools ?? null,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Stable identity hash for a checkpoint() call. Must cover every
|
|
485
|
+
* CheckpointOptions field that participates in the outcome, not just
|
|
486
|
+
* promptText/kind/choices: `default`/`headless` decide the reply in the
|
|
487
|
+
* headless path, and `timeoutMs` bounds the interactive prompt.
|
|
488
|
+
*/
|
|
489
|
+
function hashCheckpoint(promptText: string, options: CheckpointOptions): string {
|
|
490
|
+
return hashIdentity({
|
|
491
|
+
promptText,
|
|
492
|
+
kind: options.kind ?? "confirm",
|
|
493
|
+
choices: options.choices ?? null,
|
|
494
|
+
default: options.default ?? null,
|
|
495
|
+
headless: options.headless ?? "default",
|
|
496
|
+
timeoutMs: options.timeoutMs ?? null,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── Small helpers ────────────────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
function defaultAgentLabel(phase: string | undefined, index: number): string {
|
|
503
|
+
return phase ? `${phase} agent ${index}` : `agent ${index}`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function buildAgentInstructions(
|
|
507
|
+
phase: string | undefined,
|
|
508
|
+
options: AgentOptions,
|
|
509
|
+
resolvedIsolation?: "worktree",
|
|
510
|
+
): string | undefined {
|
|
511
|
+
const lines: string[] = [];
|
|
512
|
+
if (options.agentType) lines.push(`Act as workflow subagent type: ${options.agentType}`);
|
|
513
|
+
if (phase) lines.push(`Workflow phase: ${phase}`);
|
|
514
|
+
// Per-call isolation is not routable through spawn-managed (see README,
|
|
515
|
+
// "worktree isolation"); the annotation is best-effort and the actual
|
|
516
|
+
// isolation comes from the agent definition pi-subagents resolves.
|
|
517
|
+
if (resolvedIsolation) lines.push(`Requested isolation: ${resolvedIsolation}`);
|
|
518
|
+
return lines.length ? lines.join("\n\n") : undefined;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function isEmptyTextAgentResult(result: unknown, schema: unknown): boolean {
|
|
522
|
+
return schema === undefined && typeof result === "string" && result.trim().length === 0;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function estimateTokens(value: unknown): number {
|
|
526
|
+
return Math.ceil(JSON.stringify(value ?? "").length / 4);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function normalizeConcurrency(value: unknown): number {
|
|
530
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1;
|
|
531
|
+
return Math.min(MAX_CONCURRENCY, Math.floor(value));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function normalizeMaxAgents(value: unknown): number {
|
|
535
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return MAX_AGENTS_PER_RUN;
|
|
536
|
+
return Math.min(MAX_AGENTS_PER_RUN, Math.floor(value));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function normalizeAgentTimeout(value: unknown): number | null {
|
|
540
|
+
if (value === null) return null;
|
|
541
|
+
if (value === undefined) return DEFAULT_AGENT_TIMEOUT_MS;
|
|
542
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return DEFAULT_AGENT_TIMEOUT_MS;
|
|
543
|
+
return Math.floor(value);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function normalizeTokenBudget(value: unknown): number | null {
|
|
547
|
+
if (value === null || value === undefined) return null;
|
|
548
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return null;
|
|
549
|
+
return Math.floor(value);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function normalizeAgentRetries(value: unknown): number {
|
|
553
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
|
|
554
|
+
return Math.min(MAX_AGENT_RETRIES, Math.floor(value));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function withTimeout<T>(
|
|
558
|
+
promise: Promise<T>,
|
|
559
|
+
ms: number | null,
|
|
560
|
+
label: string,
|
|
561
|
+
onTimeout?: () => void,
|
|
562
|
+
): Promise<T> {
|
|
563
|
+
if (ms === null) return promise;
|
|
564
|
+
|
|
565
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
566
|
+
|
|
567
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
568
|
+
timeoutId = setTimeout(() => {
|
|
569
|
+
try {
|
|
570
|
+
onTimeout?.();
|
|
571
|
+
} catch {
|
|
572
|
+
// Best-effort cleanup; never mask the timeout error.
|
|
573
|
+
}
|
|
574
|
+
reject(
|
|
575
|
+
new WorkflowError(
|
|
576
|
+
`Agent "${label}" timed out after ${ms}ms; raise or omit timeoutMs/agentTimeoutMs to allow longer runs`,
|
|
577
|
+
WorkflowErrorCode.AGENT_TIMEOUT,
|
|
578
|
+
{ recoverable: true },
|
|
579
|
+
),
|
|
580
|
+
);
|
|
581
|
+
}, ms);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
586
|
+
} finally {
|
|
587
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Extract the first balanced JSON value ({...} or [...]) from text. */
|
|
592
|
+
export function findJsonBlock(text: string): string | undefined {
|
|
593
|
+
for (let start = 0; start < text.length; start += 1) {
|
|
594
|
+
if (text[start] !== "{" && text[start] !== "[") continue;
|
|
595
|
+
const stack: string[] = [];
|
|
596
|
+
let inString = false;
|
|
597
|
+
let escaped = false;
|
|
598
|
+
for (let i = start; i < text.length; i += 1) {
|
|
599
|
+
const char = text[i];
|
|
600
|
+
if (inString) {
|
|
601
|
+
if (escaped) escaped = false;
|
|
602
|
+
else if (char === "\\") escaped = true;
|
|
603
|
+
else if (char === '"') inString = false;
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (char === '"') {
|
|
607
|
+
inString = true;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (char === "{" || char === "[") stack.push(char === "{" ? "}" : "]");
|
|
611
|
+
else if (char === "}" || char === "]") {
|
|
612
|
+
if (stack.pop() !== char) break;
|
|
613
|
+
if (stack.length === 0) return text.slice(start, i + 1);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return undefined;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ── Minimal plain-JSON-Schema validator ──────────────────────────────────────
|
|
621
|
+
// The `schema` agent option is a plain JSON Schema. The runtime validates the
|
|
622
|
+
// agent's JSON reply against the bounded subset scripts actually use:
|
|
623
|
+
// type/properties/required/items/enum/min/max/minLength/maxLength/const. A
|
|
624
|
+
// schema outside the subset is rejected at call time rather than mis-validated.
|
|
625
|
+
|
|
626
|
+
type SchemaNode = { [key: string]: unknown };
|
|
627
|
+
|
|
628
|
+
function validateValue(value: unknown, schema: SchemaNode, path: string): string[] {
|
|
629
|
+
const errors: string[] = [];
|
|
630
|
+
if (schema.const !== undefined) {
|
|
631
|
+
if (JSON.stringify(value) !== JSON.stringify(schema.const))
|
|
632
|
+
errors.push(`${path}: expected const ${JSON.stringify(schema.const)}`);
|
|
633
|
+
return errors;
|
|
634
|
+
}
|
|
635
|
+
if (schema.enum !== undefined) {
|
|
636
|
+
if (!(schema.enum as unknown[]).some((e) => JSON.stringify(e) === JSON.stringify(value))) {
|
|
637
|
+
errors.push(`${path}: value not in enum`);
|
|
638
|
+
}
|
|
639
|
+
return errors;
|
|
640
|
+
}
|
|
641
|
+
const type = schema.type;
|
|
642
|
+
if (type !== undefined) {
|
|
643
|
+
const types = Array.isArray(type) ? type : [type];
|
|
644
|
+
const typeOf = (v: unknown): string => (v === null ? "null" : Array.isArray(v) ? "array" : typeof v);
|
|
645
|
+
if (!types.includes(typeOf(value))) {
|
|
646
|
+
errors.push(`${path}: expected type ${types.join("/")}, got ${typeOf(value)}`);
|
|
647
|
+
return errors;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (type === "object" || (typeof value === "object" && value !== null && !Array.isArray(value))) {
|
|
651
|
+
const obj = value as Record<string, unknown>;
|
|
652
|
+
if (Array.isArray(schema.required)) {
|
|
653
|
+
for (const key of schema.required as string[]) {
|
|
654
|
+
if (!(key in obj)) errors.push(`${path}: missing required property "${key}"`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const props = (schema.properties ?? {}) as Record<string, unknown>;
|
|
658
|
+
for (const [key, propSchema] of Object.entries(props)) {
|
|
659
|
+
if (!(key in obj)) continue;
|
|
660
|
+
errors.push(...validateValue(obj[key], propSchema as SchemaNode, `${path}.${key}`));
|
|
661
|
+
}
|
|
662
|
+
if (schema.additionalProperties === false) {
|
|
663
|
+
for (const key of Object.keys(obj)) {
|
|
664
|
+
if (!Object.hasOwn(props, key)) errors.push(`${path}: unexpected property "${key}"`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
} else if (type === "array" || Array.isArray(value)) {
|
|
668
|
+
const arr = value as unknown[];
|
|
669
|
+
if (typeof schema.minItems === "number" && arr.length < schema.minItems) {
|
|
670
|
+
errors.push(`${path}: fewer than ${schema.minItems} items`);
|
|
671
|
+
}
|
|
672
|
+
if (typeof schema.maxItems === "number" && arr.length > schema.maxItems) {
|
|
673
|
+
errors.push(`${path}: more than ${schema.maxItems} items`);
|
|
674
|
+
}
|
|
675
|
+
const items = schema.items as SchemaNode | undefined;
|
|
676
|
+
if (items) {
|
|
677
|
+
for (let i = 0; i < arr.length; i++) {
|
|
678
|
+
errors.push(...validateValue(arr[i], items, `${path}[${i}]`));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
} else if (typeof value === "string") {
|
|
682
|
+
if (typeof schema.minLength === "number" && value.length < schema.minLength)
|
|
683
|
+
errors.push(`${path}: shorter than ${schema.minLength}`);
|
|
684
|
+
if (typeof schema.maxLength === "number" && value.length > schema.maxLength)
|
|
685
|
+
errors.push(`${path}: longer than ${schema.maxLength}`);
|
|
686
|
+
} else if (typeof value === "number") {
|
|
687
|
+
if (typeof schema.minimum === "number" && value < schema.minimum)
|
|
688
|
+
errors.push(`${path}: below minimum ${schema.minimum}`);
|
|
689
|
+
if (typeof schema.maximum === "number" && value > schema.maximum)
|
|
690
|
+
errors.push(`${path}: above maximum ${schema.maximum}`);
|
|
691
|
+
}
|
|
692
|
+
return errors;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Validate a value against a plain JSON Schema; returns the error list. */
|
|
696
|
+
export function validateJsonSchema(value: unknown, schema: unknown): string[] {
|
|
697
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
698
|
+
throw new WorkflowError(
|
|
699
|
+
"agent({ schema }) must be a plain JSON Schema object",
|
|
700
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
701
|
+
{ recoverable: false },
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
validateSchemaShape(schema, "$schema");
|
|
705
|
+
const allowed = new Set([
|
|
706
|
+
"type",
|
|
707
|
+
"properties",
|
|
708
|
+
"required",
|
|
709
|
+
"items",
|
|
710
|
+
"enum",
|
|
711
|
+
"const",
|
|
712
|
+
"minItems",
|
|
713
|
+
"maxItems",
|
|
714
|
+
"minLength",
|
|
715
|
+
"maxLength",
|
|
716
|
+
"minimum",
|
|
717
|
+
"maximum",
|
|
718
|
+
"description",
|
|
719
|
+
"title",
|
|
720
|
+
"additionalProperties",
|
|
721
|
+
]);
|
|
722
|
+
for (const key of Object.keys(schema)) {
|
|
723
|
+
if (!allowed.has(key)) {
|
|
724
|
+
throw new WorkflowError(
|
|
725
|
+
`agent({ schema }) uses unsupported JSON Schema keyword "${key}"`,
|
|
726
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
727
|
+
{ recoverable: false },
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return validateValue(value, schema as SchemaNode, "$");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function validateSchemaShape(schema: unknown, path: string, seen = new WeakSet<object>()): void {
|
|
735
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
736
|
+
throw new WorkflowError(`invalid JSON Schema at ${path}`, WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
737
|
+
recoverable: false,
|
|
738
|
+
});
|
|
739
|
+
if (seen.has(schema))
|
|
740
|
+
throw new WorkflowError(`cyclic JSON Schema at ${path}`, WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
741
|
+
recoverable: false,
|
|
742
|
+
});
|
|
743
|
+
seen.add(schema);
|
|
744
|
+
const node = schema as SchemaNode;
|
|
745
|
+
const allowed = new Set([
|
|
746
|
+
"type",
|
|
747
|
+
"properties",
|
|
748
|
+
"required",
|
|
749
|
+
"items",
|
|
750
|
+
"enum",
|
|
751
|
+
"const",
|
|
752
|
+
"minItems",
|
|
753
|
+
"maxItems",
|
|
754
|
+
"minLength",
|
|
755
|
+
"maxLength",
|
|
756
|
+
"minimum",
|
|
757
|
+
"maximum",
|
|
758
|
+
"description",
|
|
759
|
+
"title",
|
|
760
|
+
"additionalProperties",
|
|
761
|
+
]);
|
|
762
|
+
for (const key of Object.keys(node))
|
|
763
|
+
if (!allowed.has(key))
|
|
764
|
+
throw new WorkflowError(
|
|
765
|
+
`agent({ schema }) uses unsupported JSON Schema keyword "${key}" at ${path}`,
|
|
766
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
767
|
+
{ recoverable: false },
|
|
768
|
+
);
|
|
769
|
+
if (node.additionalProperties !== undefined && typeof node.additionalProperties !== "boolean")
|
|
770
|
+
throw new WorkflowError(
|
|
771
|
+
`additionalProperties must be boolean at ${path}`,
|
|
772
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
773
|
+
{ recoverable: false },
|
|
774
|
+
);
|
|
775
|
+
if (
|
|
776
|
+
node.required !== undefined &&
|
|
777
|
+
(!Array.isArray(node.required) || !node.required.every((key) => typeof key === "string"))
|
|
778
|
+
)
|
|
779
|
+
throw new WorkflowError(
|
|
780
|
+
`required must be an array of strings at ${path}`,
|
|
781
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
782
|
+
{ recoverable: false },
|
|
783
|
+
);
|
|
784
|
+
if (node.properties !== undefined) {
|
|
785
|
+
if (!node.properties || typeof node.properties !== "object" || Array.isArray(node.properties))
|
|
786
|
+
throw new WorkflowError(`properties must be an object at ${path}`, WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
787
|
+
recoverable: false,
|
|
788
|
+
});
|
|
789
|
+
for (const [key, child] of Object.entries(node.properties as Record<string, unknown>))
|
|
790
|
+
validateSchemaShape(child, `${path}.${key}`, seen);
|
|
791
|
+
}
|
|
792
|
+
if (node.items !== undefined) validateSchemaShape(node.items, `${path}[]`, seen);
|
|
793
|
+
seen.delete(schema);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ── Runtime globals shape ────────────────────────────────────────────────────
|
|
797
|
+
|
|
798
|
+
export interface WorkflowRuntimeBindings {
|
|
799
|
+
agent: (prompt: string, options?: AgentOptions) => Promise<unknown>;
|
|
800
|
+
parallel: (thunks: Array<() => Promise<unknown>>) => Promise<Array<unknown | null>>;
|
|
801
|
+
pipeline: (
|
|
802
|
+
items: unknown[],
|
|
803
|
+
...stages: Array<(prev: unknown, original: unknown, index: number) => unknown>
|
|
804
|
+
) => Promise<Array<unknown | null>>;
|
|
805
|
+
workflow: (savedName: string, childArgs?: unknown) => Promise<unknown>;
|
|
806
|
+
verify: (
|
|
807
|
+
item: unknown,
|
|
808
|
+
options?: { reviewers?: number; threshold?: number; lens?: string | string[] },
|
|
809
|
+
) => Promise<{ real: boolean; realCount: number; total: number; votes: Array<{ real: boolean; reason?: string }> }>;
|
|
810
|
+
judgePanel: (
|
|
811
|
+
attempts: unknown[],
|
|
812
|
+
options?: { judges?: number; rubric?: string },
|
|
813
|
+
) => Promise<
|
|
814
|
+
{ index: number; attempt: unknown; score: number; judgments: Array<{ score: number; reason?: string }> } | undefined
|
|
815
|
+
>;
|
|
816
|
+
loopUntilDry: (options: {
|
|
817
|
+
round: (roundIndex: number) => unknown[] | Promise<unknown[]>;
|
|
818
|
+
key?: (item: unknown) => string;
|
|
819
|
+
consecutiveEmpty?: number;
|
|
820
|
+
maxRounds?: number;
|
|
821
|
+
}) => Promise<unknown[]>;
|
|
822
|
+
completenessCheck: (taskArgs: unknown, results: unknown) => Promise<{ complete: boolean; missing?: string[] } | null>;
|
|
823
|
+
retry: (
|
|
824
|
+
thunk: (attempt: number) => unknown | Promise<unknown>,
|
|
825
|
+
options?: { attempts?: number; until?: (result: unknown) => boolean },
|
|
826
|
+
) => Promise<unknown>;
|
|
827
|
+
gate: (
|
|
828
|
+
thunk: (feedback: string | undefined, attempt: number) => unknown | Promise<unknown>,
|
|
829
|
+
validator: (value: unknown) => { ok: boolean; feedback?: string } | Promise<{ ok: boolean; feedback?: string }>,
|
|
830
|
+
options?: { attempts?: number },
|
|
831
|
+
) => Promise<{ ok: boolean; value: unknown; attempts: number }>;
|
|
832
|
+
checkpoint: (prompt: string, options?: CheckpointOptions) => Promise<unknown>;
|
|
833
|
+
log: (message: unknown) => void;
|
|
834
|
+
phase: (title: string, options?: { budget?: number }) => void;
|
|
835
|
+
args: unknown;
|
|
836
|
+
cwd: string;
|
|
837
|
+
process: { cwd(): string };
|
|
838
|
+
budget: { total: number | null; spent(): number; remaining(): number };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Fan-out cancellation scope: parallel()/pipeline() batches mark themselves. */
|
|
842
|
+
const fanoutScope = new AsyncLocalStorage<{ cancelled: boolean }>();
|
|
843
|
+
|
|
844
|
+
// ── The run ──────────────────────────────────────────────────────────────────
|
|
845
|
+
|
|
846
|
+
export async function runWorkflow<T = unknown>(
|
|
847
|
+
script: string,
|
|
848
|
+
options: WorkflowRunOptions = {},
|
|
849
|
+
): Promise<WorkflowRunResult<T>> {
|
|
850
|
+
const started = Date.now();
|
|
851
|
+
const { meta, body } = parseWorkflowScript(script);
|
|
852
|
+
const maxAgents = normalizeMaxAgents(options.maxAgents ?? MAX_AGENTS_PER_RUN);
|
|
853
|
+
const agentTimeoutMs = normalizeAgentTimeout(options.agentTimeoutMs);
|
|
854
|
+
const runId = options.runId ?? `run-${started.toString(36)}`;
|
|
855
|
+
const baseCwd = options.cwd ?? process.cwd();
|
|
856
|
+
|
|
857
|
+
const state: RuntimeState = {
|
|
858
|
+
logs: [],
|
|
859
|
+
// When the script declares meta.phases, default the current phase to the
|
|
860
|
+
// first one so agents created before any explicit phase() call still group
|
|
861
|
+
// under a declared phase instead of an orphan bucket.
|
|
862
|
+
phases: meta.phases?.[0]?.title ? [meta.phases[0].title] : [],
|
|
863
|
+
currentPhase: meta.phases?.[0]?.title,
|
|
864
|
+
phaseBudgets: new Map(),
|
|
865
|
+
callSeq: 0,
|
|
866
|
+
firstMiss: Number.POSITIVE_INFINITY,
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const agentRunner = options.agent;
|
|
870
|
+
const concurrency = normalizeConcurrency(
|
|
871
|
+
options.concurrency ??
|
|
872
|
+
Math.max(
|
|
873
|
+
1,
|
|
874
|
+
(globalThis as { navigator?: { hardwareConcurrency?: number } }).navigator?.hardwareConcurrency ?? 8,
|
|
875
|
+
) - 2,
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
// Global caps + budget shared with any nested workflow() so they hold across nesting.
|
|
879
|
+
const shared: SharedRuntime = options.sharedRuntime ?? {
|
|
880
|
+
limiter: createLimiter(concurrency),
|
|
881
|
+
agentCount: 0,
|
|
882
|
+
spent: options.initialTokenUsage?.total ?? 0,
|
|
883
|
+
tokenUsage: options.initialTokenUsage
|
|
884
|
+
? { ...options.initialTokenUsage }
|
|
885
|
+
: { input: 0, output: 0, total: 0, cost: 0, cacheRead: 0, cacheWrite: 0 },
|
|
886
|
+
depth: 0,
|
|
887
|
+
nestedCallSeq: 0,
|
|
888
|
+
runFatalController: new AbortController(),
|
|
889
|
+
inFlight: new Set<Promise<unknown>>(),
|
|
890
|
+
activeThreads: new Set<string>(),
|
|
891
|
+
};
|
|
892
|
+
const limiter = shared.limiter;
|
|
893
|
+
const isTopLevelRun = !options.sharedRuntime;
|
|
894
|
+
const store: SharedStore = options.sharedStore ?? new SharedStore();
|
|
895
|
+
|
|
896
|
+
const log = (message: unknown) => {
|
|
897
|
+
const text = String(message);
|
|
898
|
+
state.logs.push(text);
|
|
899
|
+
options.onLog?.(text);
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
const phase = (title: string, phaseOptions?: { budget?: number }) => {
|
|
903
|
+
state.currentPhase = title;
|
|
904
|
+
if (!state.phases.includes(title)) state.phases.push(title);
|
|
905
|
+
// Soft sub-budget carved from the run total; re-declaring re-bases from the
|
|
906
|
+
// current spent (idempotent across resume).
|
|
907
|
+
if (typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0) {
|
|
908
|
+
state.phaseBudgets.set(title, { budget: phaseOptions.budget, startSpent: shared.spent, warned: false });
|
|
909
|
+
}
|
|
910
|
+
options.onPhase?.(title);
|
|
911
|
+
options.onRuntimeEvent?.({
|
|
912
|
+
type: "phase",
|
|
913
|
+
title,
|
|
914
|
+
budget: typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0 ? phaseOptions.budget : null,
|
|
915
|
+
});
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
const budget = Object.freeze({
|
|
919
|
+
total: normalizeTokenBudget(options.tokenBudget),
|
|
920
|
+
spent: () => shared.spent,
|
|
921
|
+
remaining: () => (budget.total == null ? Infinity : Math.max(0, budget.total - shared.spent)),
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
const agentLimitError = () =>
|
|
925
|
+
new WorkflowError(
|
|
926
|
+
`Agent limit exceeded (${shared.agentCount}/${maxAgents}). 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
|
+
WorkflowErrorCode.AGENT_LIMIT_EXCEEDED,
|
|
928
|
+
{ recoverable: false },
|
|
929
|
+
);
|
|
930
|
+
|
|
931
|
+
const isAborted = () => Boolean(options.signal?.aborted || shared.runFatalController.signal.aborted);
|
|
932
|
+
|
|
933
|
+
const throwIfAborted = () => {
|
|
934
|
+
if (isAborted()) {
|
|
935
|
+
throw new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true });
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const agent = (promptText: string, agentOptions: AgentOptions = {}): Promise<unknown> => {
|
|
940
|
+
const rawThread = agentOptions.thread;
|
|
941
|
+
const thread = rawThread === undefined ? undefined : typeof rawThread === "string" ? rawThread.trim() : "";
|
|
942
|
+
if (rawThread !== undefined && !thread) {
|
|
943
|
+
throw new WorkflowError("agent() thread must be a non-empty string", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
944
|
+
recoverable: false,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
if (thread && agentOptions.isolation === "worktree") {
|
|
948
|
+
throw new WorkflowError(
|
|
949
|
+
`agent thread "${thread}" cannot use worktree isolation because worktrees are removed after each call`,
|
|
950
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
951
|
+
{ recoverable: false },
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
if (thread && shared.activeThreads.has(thread)) {
|
|
955
|
+
throw new WorkflowError(
|
|
956
|
+
`agent thread "${thread}" is already running; same-thread calls must be sequential`,
|
|
957
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
958
|
+
{ recoverable: false },
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
if (thread) shared.activeThreads.add(thread);
|
|
962
|
+
const normalizedOptions = thread && thread !== rawThread ? { ...agentOptions, thread } : agentOptions;
|
|
963
|
+
let call = agentImpl(promptText, normalizedOptions);
|
|
964
|
+
if (thread) call = call.finally(() => shared.activeThreads.delete(thread));
|
|
965
|
+
// Track every call (awaited or not) so the top-level run can drain
|
|
966
|
+
// outstanding calls before completing — a forgotten `await` cannot let an
|
|
967
|
+
// agent mutate state after the run is torn down.
|
|
968
|
+
shared.inFlight.add(call);
|
|
969
|
+
call.catch(() => {}).finally(() => shared.inFlight.delete(call));
|
|
970
|
+
return call;
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
const agentImpl = async (promptText: string, agentOptions: AgentOptions = {}) => {
|
|
974
|
+
throwIfAborted();
|
|
975
|
+
|
|
976
|
+
// Capture the enclosing parallel()/pipeline() fan-out's cancellation batch
|
|
977
|
+
// synchronously, before suspending on the limiter below.
|
|
978
|
+
const batch = fanoutScope.getStore();
|
|
979
|
+
|
|
980
|
+
// Check agent limit. A fan-out that overshoots the cap has already reserved
|
|
981
|
+
// and queued up to `maxAgents` agents; the breaching call throws here, and
|
|
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
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const requestedLabel = agentOptions.label?.trim();
|
|
1016
|
+
// agentType resolution lives in pi-subagents; the name is the observable key.
|
|
1017
|
+
if (agentOptions.agentType) {
|
|
1018
|
+
log(`agentType "${agentOptions.agentType}" resolves in pi-subagents at dispatch`);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Model precedence: explicit per-call model > phase/run model. A tier is
|
|
1022
|
+
// deliberately left to pi-subagents so its configured profile wins over the
|
|
1023
|
+
// phase default. The model string may carry a `:thinking` suffix understood by
|
|
1024
|
+
// the managed protocol.
|
|
1025
|
+
const phaseModel = meta.phases?.find((candidate) => candidate.title === assignedPhase)?.model ?? meta.model;
|
|
1026
|
+
const modelSpec = agentOptions.model ?? (agentOptions.tier === undefined ? phaseModel : undefined);
|
|
1027
|
+
|
|
1028
|
+
// Deterministic resume key: assigned at lexical call time, before the limiter.
|
|
1029
|
+
const callIndex = state.callSeq++;
|
|
1030
|
+
const callHash = hashAgentCall(promptText, modelSpec, assignedPhase, agentOptions, agentOptions.agentType ?? null);
|
|
1031
|
+
// Store delta key: runId-namespaced so a nested workflow() call's
|
|
1032
|
+
// callIndex-0 can never collide with the parent's (see SharedStore doc).
|
|
1033
|
+
const deltaKey = `${runId}:${callIndex}`;
|
|
1034
|
+
|
|
1035
|
+
// Reserve the agent slot synchronously — atomic with the limit/budget gate
|
|
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);
|
|
1040
|
+
|
|
1041
|
+
// Longest-unchanged-prefix resume: replay a cached result only while the
|
|
1042
|
+
// prefix is still intact. Once any call misses, it AND everything after it
|
|
1043
|
+
// run live, so an edited upstream call never leaves stale downstream
|
|
1044
|
+
// results served from the journal.
|
|
1045
|
+
const cached = agentOptions.thread ? undefined : options.resumeJournal?.get(deltaKey);
|
|
1046
|
+
const hashMatches = cached != null && cached.hash === callHash;
|
|
1047
|
+
const cachedEmptyOutput = hashMatches && isEmptyTextAgentResult(cached.result, agentOptions.schema);
|
|
1048
|
+
if (hashMatches && !cachedEmptyOutput && callIndex < state.firstMiss) {
|
|
1049
|
+
options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt: promptText });
|
|
1050
|
+
options.onAgentEnd?.({
|
|
1051
|
+
id: deltaKey,
|
|
1052
|
+
label,
|
|
1053
|
+
phase: assignedPhase,
|
|
1054
|
+
result: cached.result,
|
|
1055
|
+
tokens: 0,
|
|
1056
|
+
});
|
|
1057
|
+
if (cached.storeDelta) store.applyDelta(cached.storeDelta);
|
|
1058
|
+
return cached.result;
|
|
1059
|
+
}
|
|
1060
|
+
if (!hashMatches || cachedEmptyOutput || agentOptions.thread)
|
|
1061
|
+
state.firstMiss = Math.min(state.firstMiss, callIndex);
|
|
1062
|
+
|
|
1063
|
+
return limiter(async () => {
|
|
1064
|
+
const timeout = agentOptions.timeoutMs !== undefined ? agentOptions.timeoutMs : agentTimeoutMs;
|
|
1065
|
+
const retryAttempts = normalizeAgentRetries(agentOptions.retries ?? options.agentRetries ?? 0);
|
|
1066
|
+
const maxAttempts = retryAttempts + 1;
|
|
1067
|
+
|
|
1068
|
+
options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt: promptText });
|
|
1069
|
+
|
|
1070
|
+
// Worktree creation and cleanup remain owned by pi-subagents; the request
|
|
1071
|
+
// carries the explicit per-call policy without touching the host filesystem here.
|
|
1072
|
+
const resolvedIsolation = agentOptions.isolation;
|
|
1073
|
+
|
|
1074
|
+
// Captured from the subagent's real session usage; falls back to an
|
|
1075
|
+
// estimate when the provider reports none. Reset per retry attempt so a
|
|
1076
|
+
// failed attempt does not double-count the next one.
|
|
1077
|
+
let usage: AgentUsage | undefined;
|
|
1078
|
+
const recordTokens = (result: unknown): number => {
|
|
1079
|
+
const tokens = usage && usage.total > 0 ? usage.total : estimateTokens(result) + estimateTokens(promptText);
|
|
1080
|
+
if (usage) {
|
|
1081
|
+
shared.tokenUsage.input += usage.input;
|
|
1082
|
+
shared.tokenUsage.output += usage.output;
|
|
1083
|
+
shared.tokenUsage.cost += usage.cost;
|
|
1084
|
+
shared.tokenUsage.cacheRead += usage.cacheRead;
|
|
1085
|
+
shared.tokenUsage.cacheWrite += usage.cacheWrite;
|
|
1086
|
+
}
|
|
1087
|
+
shared.tokenUsage.total += tokens;
|
|
1088
|
+
shared.spent += tokens;
|
|
1089
|
+
return tokens;
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
// Schema agents: the runner's text reply is parsed as JSON and validated.
|
|
1093
|
+
// Bounded repair: an invalid reply is fed back into the next attempt as
|
|
1094
|
+
// retry feedback; exhaustion throws SCHEMA_NONCOMPLIANCE (non-recoverable).
|
|
1095
|
+
const schema = agentOptions.schema;
|
|
1096
|
+
const schemaInstruction =
|
|
1097
|
+
schema !== undefined
|
|
1098
|
+
? `\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
|
+
: "";
|
|
1100
|
+
|
|
1101
|
+
try {
|
|
1102
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1103
|
+
usage = undefined;
|
|
1104
|
+
const externalSignal = options.signal;
|
|
1105
|
+
let onExternalAbort: (() => void) | undefined;
|
|
1106
|
+
let onRunFatal: (() => void) | undefined;
|
|
1107
|
+
try {
|
|
1108
|
+
throwIfAborted();
|
|
1109
|
+
if (batch?.cancelled) throw agentLimitError();
|
|
1110
|
+
|
|
1111
|
+
const agentController = new AbortController();
|
|
1112
|
+
if (isAborted()) {
|
|
1113
|
+
agentController.abort();
|
|
1114
|
+
} else {
|
|
1115
|
+
if (externalSignal) {
|
|
1116
|
+
onExternalAbort = () => agentController.abort();
|
|
1117
|
+
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
|
1118
|
+
}
|
|
1119
|
+
onRunFatal = () => agentController.abort();
|
|
1120
|
+
shared.runFatalController.signal.addEventListener("abort", onRunFatal, { once: true });
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (!agentRunner) {
|
|
1124
|
+
throw new WorkflowError(
|
|
1125
|
+
"workflow dispatch is unavailable: no agent runner is wired to this run",
|
|
1126
|
+
WorkflowErrorCode.AGENT_EXECUTION_ERROR,
|
|
1127
|
+
{ recoverable: false },
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const runPromise = agentRunner.run(promptText + schemaInstruction, {
|
|
1132
|
+
label,
|
|
1133
|
+
sessionName: agentOptions.thread
|
|
1134
|
+
? `workflow:${runId} thread:${agentOptions.thread}`
|
|
1135
|
+
: `workflow:${runId} ${label}`,
|
|
1136
|
+
schema,
|
|
1137
|
+
signal: agentController.signal,
|
|
1138
|
+
instructions: buildAgentInstructions(assignedPhase, agentOptions, resolvedIsolation),
|
|
1139
|
+
model: modelSpec,
|
|
1140
|
+
tier: agentOptions.tier,
|
|
1141
|
+
agentType: agentOptions.agentType,
|
|
1142
|
+
isolation: resolvedIsolation,
|
|
1143
|
+
toolset: options.toolset ?? agentOptions.toolset,
|
|
1144
|
+
excludeTools: options.excludeTools ?? agentOptions.excludeTools,
|
|
1145
|
+
callIndex,
|
|
1146
|
+
callHash,
|
|
1147
|
+
attempt,
|
|
1148
|
+
thread: agentOptions.thread,
|
|
1149
|
+
onModelResolved: (id: string) => {
|
|
1150
|
+
void id; // display model tracking is engine-side
|
|
1151
|
+
},
|
|
1152
|
+
onUsage: (u: AgentUsage) => {
|
|
1153
|
+
usage = u;
|
|
1154
|
+
},
|
|
1155
|
+
onHistory: (history: unknown[]) => {
|
|
1156
|
+
options.onAgentHistory?.({ id: deltaKey, label, phase: assignedPhase, history });
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
runPromise.catch(() => {});
|
|
1160
|
+
let result = await withTimeout(runPromise, timeout, label, () => agentController.abort());
|
|
1161
|
+
|
|
1162
|
+
throwIfAborted();
|
|
1163
|
+
if (isEmptyTextAgentResult(result, schema)) {
|
|
1164
|
+
throw new WorkflowError("Subagent produced no assistant output", WorkflowErrorCode.AGENT_EMPTY_OUTPUT, {
|
|
1165
|
+
recoverable: true,
|
|
1166
|
+
agentLabel: label,
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Client-side schema validation with bounded repair across attempts.
|
|
1171
|
+
if (schema !== undefined) {
|
|
1172
|
+
const text = typeof result === "string" ? result : JSON.stringify(result);
|
|
1173
|
+
const json = findJsonBlock(text);
|
|
1174
|
+
let parsed: unknown;
|
|
1175
|
+
if (json === undefined) {
|
|
1176
|
+
parsed = undefined;
|
|
1177
|
+
} else {
|
|
1178
|
+
try {
|
|
1179
|
+
parsed = JSON.parse(json);
|
|
1180
|
+
} catch {
|
|
1181
|
+
parsed = undefined;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
const errors =
|
|
1185
|
+
parsed === undefined ? ["no JSON value found in the reply"] : validateJsonSchema(parsed, schema);
|
|
1186
|
+
if (errors.length > 0) {
|
|
1187
|
+
const detail = errors.join("; ");
|
|
1188
|
+
if (attempt < maxAttempts) {
|
|
1189
|
+
// Repair: re-dispatch with the validation errors as feedback.
|
|
1190
|
+
throw new WorkflowError(
|
|
1191
|
+
`Subagent reply did not match the schema: ${detail}`,
|
|
1192
|
+
WorkflowErrorCode.AGENT_EXECUTION_ERROR,
|
|
1193
|
+
{ recoverable: true, agentLabel: label },
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
throw new WorkflowError(
|
|
1197
|
+
`Subagent did not produce a schema-valid result after ${maxAttempts} attempt(s): ${detail}`,
|
|
1198
|
+
WorkflowErrorCode.SCHEMA_NONCOMPLIANCE,
|
|
1199
|
+
{ recoverable: false, agentLabel: label },
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
result = parsed;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const tokens = recordTokens(result);
|
|
1206
|
+
options.onAgentJournal?.({
|
|
1207
|
+
index: callIndex,
|
|
1208
|
+
runId,
|
|
1209
|
+
hash: callHash,
|
|
1210
|
+
result,
|
|
1211
|
+
storeDelta: store.commitDelta(deltaKey),
|
|
1212
|
+
});
|
|
1213
|
+
options.onAgentEnd?.({
|
|
1214
|
+
id: deltaKey,
|
|
1215
|
+
label,
|
|
1216
|
+
phase: assignedPhase,
|
|
1217
|
+
result,
|
|
1218
|
+
tokens,
|
|
1219
|
+
tokenUsage: usage,
|
|
1220
|
+
model: modelSpec,
|
|
1221
|
+
});
|
|
1222
|
+
return result;
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
if (isAborted()) throw error;
|
|
1225
|
+
|
|
1226
|
+
const workflowError = wrapError(error, { agentLabel: label });
|
|
1227
|
+
const tokens = recordTokens(null);
|
|
1228
|
+
// This attempt's store writes must not survive it — a failed attempt
|
|
1229
|
+
// shares this call's deltaKey with every other attempt, so without
|
|
1230
|
+
// rolling back here its writes would stay live in the store and merge
|
|
1231
|
+
// into whatever a later, successful attempt commits.
|
|
1232
|
+
store.discardDelta(deltaKey);
|
|
1233
|
+
if (workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) throw workflowError;
|
|
1234
|
+
|
|
1235
|
+
if (workflowError.recoverable && attempt < maxAttempts) {
|
|
1236
|
+
log(
|
|
1237
|
+
`agent "${label}" attempt ${attempt}/${maxAttempts} failed: ${workflowError.code} ${workflowError.message}; retrying`,
|
|
1238
|
+
);
|
|
1239
|
+
options.onRetrySpend?.(tokens);
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
options.onAgentEnd?.({
|
|
1244
|
+
id: deltaKey,
|
|
1245
|
+
label,
|
|
1246
|
+
phase: assignedPhase,
|
|
1247
|
+
result: null,
|
|
1248
|
+
tokens,
|
|
1249
|
+
tokenUsage: usage,
|
|
1250
|
+
model: modelSpec,
|
|
1251
|
+
error: workflowError.message,
|
|
1252
|
+
errorCode: workflowError.code,
|
|
1253
|
+
recoverable: workflowError.recoverable,
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
if (workflowError.recoverable) {
|
|
1257
|
+
log(
|
|
1258
|
+
`agent "${label}" exhausted ${maxAttempts} attempt${maxAttempts === 1 ? "" : "s"}: ${workflowError.code} ${workflowError.message}`,
|
|
1259
|
+
);
|
|
1260
|
+
return null;
|
|
1261
|
+
}
|
|
1262
|
+
throw workflowError;
|
|
1263
|
+
} finally {
|
|
1264
|
+
if (onExternalAbort) externalSignal?.removeEventListener("abort", onExternalAbort);
|
|
1265
|
+
if (onRunFatal) shared.runFatalController.signal.removeEventListener("abort", onRunFatal);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return null;
|
|
1269
|
+
} finally {
|
|
1270
|
+
// No per-call worktree to tear down under spawn-managed.
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
const parallel = async (thunks: Array<() => Promise<unknown>>) => {
|
|
1276
|
+
throwIfAborted();
|
|
1277
|
+
if (!Array.isArray(thunks)) throw new TypeError("parallel() expects an array of functions");
|
|
1278
|
+
if (thunks.some((thunk) => typeof thunk !== "function")) {
|
|
1279
|
+
throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
|
|
1280
|
+
}
|
|
1281
|
+
// Batch-scoped cancellation: a breach in THIS fan-out flips `cancelled` so
|
|
1282
|
+
// its own still-queued agents bail, without touching a sibling or enclosing
|
|
1283
|
+
// fan-out.
|
|
1284
|
+
const batch = { cancelled: false };
|
|
1285
|
+
return fanoutScope.run(batch, () =>
|
|
1286
|
+
Promise.all(
|
|
1287
|
+
thunks.map(async (thunk, index) => {
|
|
1288
|
+
try {
|
|
1289
|
+
return await thunk();
|
|
1290
|
+
} catch (error) {
|
|
1291
|
+
if (isAborted()) throw error;
|
|
1292
|
+
const workflowError = wrapError(error);
|
|
1293
|
+
if (!workflowError.recoverable || workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) {
|
|
1294
|
+
if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) batch.cancelled = true;
|
|
1295
|
+
throw workflowError;
|
|
1296
|
+
}
|
|
1297
|
+
log(`parallel[${index}] failed: ${workflowError.message}`);
|
|
1298
|
+
return null;
|
|
1299
|
+
}
|
|
1300
|
+
}),
|
|
1301
|
+
),
|
|
1302
|
+
);
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
const pipeline = async (
|
|
1306
|
+
items: unknown[],
|
|
1307
|
+
...stages: Array<(prev: unknown, original: unknown, index: number) => unknown>
|
|
1308
|
+
) => {
|
|
1309
|
+
throwIfAborted();
|
|
1310
|
+
if (!Array.isArray(items)) throw new TypeError("pipeline() expects an array as the first argument");
|
|
1311
|
+
if (stages.some((stage) => typeof stage !== "function")) {
|
|
1312
|
+
throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
|
|
1313
|
+
}
|
|
1314
|
+
const batch = { cancelled: false };
|
|
1315
|
+
return fanoutScope.run(batch, () =>
|
|
1316
|
+
Promise.all(
|
|
1317
|
+
items.map(async (item, index) => {
|
|
1318
|
+
let value: unknown = item;
|
|
1319
|
+
for (const stage of stages) {
|
|
1320
|
+
try {
|
|
1321
|
+
throwIfAborted();
|
|
1322
|
+
value = await stage(value, item, index);
|
|
1323
|
+
throwIfAborted();
|
|
1324
|
+
} catch (error) {
|
|
1325
|
+
if (isAborted()) throw error;
|
|
1326
|
+
const workflowError = wrapError(error);
|
|
1327
|
+
if (!workflowError.recoverable || workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) {
|
|
1328
|
+
if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) batch.cancelled = true;
|
|
1329
|
+
throw workflowError;
|
|
1330
|
+
}
|
|
1331
|
+
log(`pipeline[${index}] failed: ${workflowError.message}`);
|
|
1332
|
+
return null;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
return value;
|
|
1336
|
+
}),
|
|
1337
|
+
),
|
|
1338
|
+
);
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
// Nested workflow(): run a saved workflow (or raw script) inline, sharing this
|
|
1342
|
+
// run's limiter/counters/budget. One level deep only.
|
|
1343
|
+
const workflowFn = async (nameOrScript: string, childArgs?: unknown) => {
|
|
1344
|
+
throwIfAborted();
|
|
1345
|
+
if (shared.depth >= 1) {
|
|
1346
|
+
throw new WorkflowError("workflow() can nest only one level deep", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
1347
|
+
recoverable: false,
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
|
|
1351
|
+
const childScript = resolved ?? String(nameOrScript);
|
|
1352
|
+
const workflowName = String(nameOrScript);
|
|
1353
|
+
options.onRuntimeEvent?.({ type: "workflow", stage: "start", name: workflowName, args: childArgs });
|
|
1354
|
+
shared.depth++;
|
|
1355
|
+
try {
|
|
1356
|
+
// Propagate the resume journal into the child frame ONLY while the
|
|
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;
|
|
1362
|
+
const child = await runWorkflow(childScript, {
|
|
1363
|
+
...options,
|
|
1364
|
+
args: childArgs,
|
|
1365
|
+
sharedRuntime: shared,
|
|
1366
|
+
sharedStore: store,
|
|
1367
|
+
resumeJournal: prefixIntact ? options.resumeJournal : undefined,
|
|
1368
|
+
resumeFromRunId: undefined,
|
|
1369
|
+
// shared.nestedCallSeq, not depth: depth returns to 0 between sequential
|
|
1370
|
+
// sibling calls, which would otherwise mint colliding child runIds.
|
|
1371
|
+
runId: `${runId}-nested${++shared.nestedCallSeq}`,
|
|
1372
|
+
});
|
|
1373
|
+
return child.result;
|
|
1374
|
+
} finally {
|
|
1375
|
+
shared.depth--;
|
|
1376
|
+
options.onRuntimeEvent?.({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1380
|
+
// ── Quality-pattern stdlib ──────────────────────────────────────────────────
|
|
1381
|
+
|
|
1382
|
+
const VERIFY_SCHEMA = {
|
|
1383
|
+
type: "object",
|
|
1384
|
+
properties: { real: { type: "boolean" }, reason: { type: "string" } },
|
|
1385
|
+
required: ["real"],
|
|
1386
|
+
};
|
|
1387
|
+
const verify = async (
|
|
1388
|
+
item: unknown,
|
|
1389
|
+
opts: { reviewers?: number; threshold?: number; lens?: string | string[] } = {},
|
|
1390
|
+
): Promise<{
|
|
1391
|
+
real: boolean;
|
|
1392
|
+
realCount: number;
|
|
1393
|
+
total: number;
|
|
1394
|
+
votes: Array<{ real: boolean; reason?: string }>;
|
|
1395
|
+
}> => {
|
|
1396
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "verify" });
|
|
1397
|
+
const reviewers = Math.max(1, opts.reviewers ?? 2);
|
|
1398
|
+
const threshold = opts.threshold ?? 0.5;
|
|
1399
|
+
const lenses = opts.lens ? (Array.isArray(opts.lens) ? opts.lens : [opts.lens]) : [];
|
|
1400
|
+
const claim = typeof item === "string" ? item : JSON.stringify(item);
|
|
1401
|
+
const votes = (
|
|
1402
|
+
await parallel(
|
|
1403
|
+
Array.from(
|
|
1404
|
+
{ length: reviewers },
|
|
1405
|
+
(_v, i) => () =>
|
|
1406
|
+
agent(
|
|
1407
|
+
`Adversarially review whether the following is REAL/correct. Try to refute it; default to real=false if unsure.${lenses.length ? ` Focus lens: ${lenses[i % lenses.length]}.` : ""}\n\n${claim}`,
|
|
1408
|
+
{ label: `verify ${i + 1}`, schema: VERIFY_SCHEMA },
|
|
1409
|
+
),
|
|
1410
|
+
),
|
|
1411
|
+
)
|
|
1412
|
+
).filter(Boolean) as Array<{ real?: boolean; reason?: string }>;
|
|
1413
|
+
const realCount = votes.filter((v) => v?.real).length;
|
|
1414
|
+
return {
|
|
1415
|
+
real: votes.length > 0 && realCount / votes.length >= threshold,
|
|
1416
|
+
realCount,
|
|
1417
|
+
total: votes.length,
|
|
1418
|
+
votes: votes.map((v) => ({ real: Boolean(v?.real), reason: v?.reason })),
|
|
1419
|
+
};
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
const JUDGE_SCHEMA = {
|
|
1423
|
+
type: "object",
|
|
1424
|
+
properties: { score: { type: "number" }, reason: { type: "string" } },
|
|
1425
|
+
required: ["score"],
|
|
1426
|
+
};
|
|
1427
|
+
const judgePanel = async (
|
|
1428
|
+
attempts: unknown[],
|
|
1429
|
+
opts: { judges?: number; rubric?: string } = {},
|
|
1430
|
+
): Promise<
|
|
1431
|
+
{ index: number; attempt: unknown; score: number; judgments: Array<{ score: number; reason?: string }> } | undefined
|
|
1432
|
+
> => {
|
|
1433
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "judgePanel" });
|
|
1434
|
+
if (!Array.isArray(attempts) || attempts.length === 0) {
|
|
1435
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "judgePanel" });
|
|
1436
|
+
return undefined;
|
|
1437
|
+
}
|
|
1438
|
+
const judges = Math.min(16, Math.max(1, Math.floor(opts.judges ?? 3)));
|
|
1439
|
+
const rubric = opts.rubric ?? "overall quality and correctness";
|
|
1440
|
+
const scored = (
|
|
1441
|
+
await parallel(
|
|
1442
|
+
(Array.isArray(attempts) ? attempts : []).map((att, idx) => async () => {
|
|
1443
|
+
const text = typeof att === "string" ? att : JSON.stringify(att);
|
|
1444
|
+
const js = (
|
|
1445
|
+
await parallel(
|
|
1446
|
+
Array.from(
|
|
1447
|
+
{ length: judges },
|
|
1448
|
+
(_v, j) => () =>
|
|
1449
|
+
agent(
|
|
1450
|
+
`Score this candidate from 0 to 1 on: ${rubric}. Reply with the score.\n\nCandidate:\n${text}`,
|
|
1451
|
+
{
|
|
1452
|
+
label: `judge ${idx + 1}.${j + 1}`,
|
|
1453
|
+
schema: JUDGE_SCHEMA,
|
|
1454
|
+
},
|
|
1455
|
+
),
|
|
1456
|
+
),
|
|
1457
|
+
)
|
|
1458
|
+
).filter(Boolean) as Array<{ score?: number }>;
|
|
1459
|
+
const score = js.length ? js.reduce((s, v) => s + (Number(v?.score) || 0), 0) / js.length : 0;
|
|
1460
|
+
return { index: idx, attempt: att, score, judgments: js };
|
|
1461
|
+
}),
|
|
1462
|
+
)
|
|
1463
|
+
).filter(Boolean) as Array<{
|
|
1464
|
+
index: number;
|
|
1465
|
+
attempt: unknown;
|
|
1466
|
+
score: number;
|
|
1467
|
+
judgments: Array<{ score?: number; reason?: string }>;
|
|
1468
|
+
}>;
|
|
1469
|
+
const normalized = scored.map((s) => ({
|
|
1470
|
+
index: s.index,
|
|
1471
|
+
attempt: s.attempt,
|
|
1472
|
+
score: s.score,
|
|
1473
|
+
judgments: s.judgments.map((j) => ({ score: Number(j?.score) || 0, reason: j?.reason })),
|
|
1474
|
+
}));
|
|
1475
|
+
if (normalized.length === 0) {
|
|
1476
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "judgePanel" });
|
|
1477
|
+
return undefined;
|
|
1478
|
+
}
|
|
1479
|
+
let best = normalized[0];
|
|
1480
|
+
for (const s of normalized) if (s.score > best.score || (s.score === best.score && s.index < best.index)) best = s;
|
|
1481
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "judgePanel" });
|
|
1482
|
+
return best;
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
const loopUntilDry = async (opts: {
|
|
1486
|
+
round: (roundIndex: number) => Promise<unknown[]> | unknown[];
|
|
1487
|
+
key?: (item: unknown) => string;
|
|
1488
|
+
consecutiveEmpty?: number;
|
|
1489
|
+
maxRounds?: number;
|
|
1490
|
+
}) => {
|
|
1491
|
+
if (!opts || typeof opts.round !== "function")
|
|
1492
|
+
throw new TypeError("loopUntilDry requires { round: (i) => items[] }");
|
|
1493
|
+
const key = opts.key ?? ((x: unknown) => JSON.stringify(x));
|
|
1494
|
+
const consecutiveEmpty = Math.max(1, opts.consecutiveEmpty ?? 2);
|
|
1495
|
+
const maxRounds = opts.maxRounds ?? 50;
|
|
1496
|
+
const seen = new Set<string>();
|
|
1497
|
+
const all: unknown[] = [];
|
|
1498
|
+
let dry = 0;
|
|
1499
|
+
for (let r = 0; r < maxRounds && dry < consecutiveEmpty; r++) {
|
|
1500
|
+
throwIfAborted();
|
|
1501
|
+
let items: unknown[];
|
|
1502
|
+
try {
|
|
1503
|
+
items = (await opts.round(r)) ?? [];
|
|
1504
|
+
} catch (error) {
|
|
1505
|
+
// Budget / agent-limit exhaustion: return the partial result, don't abort.
|
|
1506
|
+
const code = (error as { code?: string })?.code;
|
|
1507
|
+
if (code === WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED || code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) break;
|
|
1508
|
+
throw error;
|
|
1509
|
+
}
|
|
1510
|
+
const fresh = (Array.isArray(items) ? items : []).filter((x) => x != null && !seen.has(key(x)));
|
|
1511
|
+
if (!fresh.length) {
|
|
1512
|
+
dry++;
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
dry = 0;
|
|
1516
|
+
for (const x of fresh) {
|
|
1517
|
+
seen.add(key(x));
|
|
1518
|
+
all.push(x);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
return all;
|
|
1522
|
+
};
|
|
1523
|
+
|
|
1524
|
+
const COMPLETENESS_SCHEMA = {
|
|
1525
|
+
type: "object",
|
|
1526
|
+
properties: { complete: { type: "boolean" }, missing: { type: "array", items: { type: "string" } } },
|
|
1527
|
+
required: ["complete"],
|
|
1528
|
+
};
|
|
1529
|
+
const completenessCheck = async (
|
|
1530
|
+
taskArgs: unknown,
|
|
1531
|
+
results: unknown,
|
|
1532
|
+
): Promise<{ complete: boolean; missing?: string[] } | null> => {
|
|
1533
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "completenessCheck" });
|
|
1534
|
+
const verdict = await agent(
|
|
1535
|
+
`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
|
+
{ label: "completeness critic", schema: COMPLETENESS_SCHEMA },
|
|
1537
|
+
);
|
|
1538
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "completenessCheck" });
|
|
1539
|
+
return verdict as { complete: boolean; missing?: string[] } | null;
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
const retry = async (
|
|
1543
|
+
thunk: (attempt: number) => Promise<unknown> | unknown,
|
|
1544
|
+
opts: { attempts?: number; until?: (r: unknown) => boolean } = {},
|
|
1545
|
+
) => {
|
|
1546
|
+
const attempts = Math.max(1, opts.attempts ?? 3);
|
|
1547
|
+
let last: unknown;
|
|
1548
|
+
for (let i = 0; i < attempts; i++) {
|
|
1549
|
+
throwIfAborted();
|
|
1550
|
+
last = await thunk(i);
|
|
1551
|
+
const accepted = !opts.until || opts.until(last);
|
|
1552
|
+
options.onRuntimeEvent?.({ type: "control-attempt", helper: "retry", attempt: i + 1, accepted });
|
|
1553
|
+
if (accepted) return last;
|
|
1554
|
+
}
|
|
1555
|
+
return last;
|
|
1556
|
+
};
|
|
1557
|
+
|
|
1558
|
+
const gate = async (
|
|
1559
|
+
thunk: (feedback: string | undefined, attempt: number) => Promise<unknown> | unknown,
|
|
1560
|
+
validator: (r: unknown) => Promise<{ ok: boolean; feedback?: string }> | { ok: boolean; feedback?: string },
|
|
1561
|
+
opts: { attempts?: number } = {},
|
|
1562
|
+
) => {
|
|
1563
|
+
const attempts = Math.max(1, opts.attempts ?? 3);
|
|
1564
|
+
let feedback: string | undefined;
|
|
1565
|
+
let last: unknown;
|
|
1566
|
+
for (let i = 0; i < attempts; i++) {
|
|
1567
|
+
throwIfAborted();
|
|
1568
|
+
last = await thunk(feedback, i);
|
|
1569
|
+
const verdict = await validator(last);
|
|
1570
|
+
const accepted = Boolean(verdict?.ok);
|
|
1571
|
+
options.onRuntimeEvent?.({ type: "control-attempt", helper: "gate", attempt: i + 1, accepted });
|
|
1572
|
+
if (accepted) return { ok: true, value: last, attempts: i + 1 };
|
|
1573
|
+
feedback = verdict?.feedback;
|
|
1574
|
+
}
|
|
1575
|
+
return { ok: false, value: last, attempts };
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
// Deterministic, journaled, replayable human checkpoint. Spends no tokens, so
|
|
1579
|
+
// it is gated on the agent counter + abort (not budget). On resume the
|
|
1580
|
+
// human's reply replays by callIndex exactly like a cached agent().
|
|
1581
|
+
const checkpoint = async (promptText: string, checkpointOptions: CheckpointOptions = {}) => {
|
|
1582
|
+
throwIfAborted();
|
|
1583
|
+
if (typeof promptText !== "string") throw new TypeError("checkpoint(promptText, options?) needs a prompt string");
|
|
1584
|
+
if (shared.agentCount >= maxAgents) {
|
|
1585
|
+
throw agentLimitError();
|
|
1586
|
+
}
|
|
1587
|
+
const callIndex = state.callSeq++;
|
|
1588
|
+
const callHash = hashCheckpoint(promptText, checkpointOptions);
|
|
1589
|
+
const journalKey = `${runId}:${callIndex}`;
|
|
1590
|
+
const cached = options.resumeJournal?.get(journalKey);
|
|
1591
|
+
if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
|
|
1592
|
+
shared.agentCount++;
|
|
1593
|
+
return cached.result; // replay the journaled human reply
|
|
1594
|
+
}
|
|
1595
|
+
if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
|
|
1596
|
+
shared.agentCount++;
|
|
1597
|
+
|
|
1598
|
+
let reply: unknown;
|
|
1599
|
+
if (options.confirm) {
|
|
1600
|
+
reply = await options.confirm(promptText, checkpointOptions);
|
|
1601
|
+
} else if (checkpointOptions.headless === "abort") {
|
|
1602
|
+
throw new WorkflowError(
|
|
1603
|
+
`checkpoint "${promptText}" needs human input but none is available (headless run)`,
|
|
1604
|
+
WorkflowErrorCode.WORKFLOW_ABORTED,
|
|
1605
|
+
{ recoverable: false },
|
|
1606
|
+
);
|
|
1607
|
+
} else {
|
|
1608
|
+
reply = checkpointOptions.default ?? true;
|
|
1609
|
+
}
|
|
1610
|
+
throwIfAborted();
|
|
1611
|
+
options.onAgentJournal?.({ index: callIndex, runId, hash: callHash, result: reply });
|
|
1612
|
+
return reply;
|
|
1613
|
+
};
|
|
1614
|
+
|
|
1615
|
+
const runtimeBindings: WorkflowRuntimeBindings & { console: Record<string, (m: unknown) => void> } = {
|
|
1616
|
+
agent,
|
|
1617
|
+
parallel,
|
|
1618
|
+
pipeline,
|
|
1619
|
+
workflow: workflowFn,
|
|
1620
|
+
verify,
|
|
1621
|
+
judgePanel,
|
|
1622
|
+
loopUntilDry,
|
|
1623
|
+
completenessCheck,
|
|
1624
|
+
retry,
|
|
1625
|
+
gate,
|
|
1626
|
+
checkpoint,
|
|
1627
|
+
log,
|
|
1628
|
+
phase,
|
|
1629
|
+
args: options.args,
|
|
1630
|
+
cwd: baseCwd,
|
|
1631
|
+
process: Object.freeze({ cwd: () => baseCwd }),
|
|
1632
|
+
budget,
|
|
1633
|
+
console: {
|
|
1634
|
+
log,
|
|
1635
|
+
info: log,
|
|
1636
|
+
warn: (m: unknown) => log(`[warn] ${String(m)}`),
|
|
1637
|
+
error: (m: unknown) => log(`[error] ${String(m)}`),
|
|
1638
|
+
},
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
// Capability contract (A11): the runtime face must match the machine-readable
|
|
1642
|
+
// declaration so docs and reality cannot drift. A mismatch fails loudly at
|
|
1643
|
+
// run start rather than surfacing as a mystery ReferenceError in the vm.
|
|
1644
|
+
const bindingProblems = checkRuntimeBindings(runtimeBindings);
|
|
1645
|
+
if (bindingProblems.length > 0) {
|
|
1646
|
+
throw new WorkflowError(
|
|
1647
|
+
`workflow capability contract violated: ${bindingProblems.join("; ")}`,
|
|
1648
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1649
|
+
{ recoverable: false },
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const context = vm.createContext({
|
|
1654
|
+
...runtimeBindings,
|
|
1655
|
+
// Object/Array/JSON/Math/Date/Promise/Set/Map/etc. come from the vm realm
|
|
1656
|
+
// itself — we deliberately do NOT inject host built-ins, whose
|
|
1657
|
+
// .constructor would be the host Function (a determinism-guard bypass).
|
|
1658
|
+
// Math/Date are neutered in-realm by DETERMINISM_PRELUDE below.
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
const wrapped = `${DETERMINISM_PRELUDE}\n(async () => {\n${body}\n})()`;
|
|
1662
|
+
try {
|
|
1663
|
+
const result = await new vm.Script(wrapped, { filename: `${meta.name || "workflow"}.js` }).runInContext(context, {
|
|
1664
|
+
timeout: MAX_SCRIPT_SYNC_MS,
|
|
1665
|
+
});
|
|
1666
|
+
try {
|
|
1667
|
+
const serialized = JSON.stringify(result);
|
|
1668
|
+
if (result !== undefined && serialized === undefined) throw new Error("workflow result is not JSON-serializable");
|
|
1669
|
+
} catch (error: unknown) {
|
|
1670
|
+
throw new WorkflowError(
|
|
1671
|
+
`workflow result is not JSON-serializable: ${error instanceof Error ? error.message : String(error)}`,
|
|
1672
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1673
|
+
{ recoverable: false },
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
options.onTokenUsage?.(shared.tokenUsage);
|
|
1678
|
+
|
|
1679
|
+
return {
|
|
1680
|
+
meta,
|
|
1681
|
+
result: result as T,
|
|
1682
|
+
logs: state.logs,
|
|
1683
|
+
phases: state.phases,
|
|
1684
|
+
agentCount: shared.agentCount,
|
|
1685
|
+
durationMs: Date.now() - started,
|
|
1686
|
+
runId,
|
|
1687
|
+
tokenUsage: shared.tokenUsage,
|
|
1688
|
+
};
|
|
1689
|
+
} catch (error) {
|
|
1690
|
+
// This error escaped THIS frame's vm execution uncaught. For the top-level
|
|
1691
|
+
// frame that means the run's fate is genuinely sealed — a nested frame
|
|
1692
|
+
// reaching here does NOT seal anything (the parent may still catch
|
|
1693
|
+
// workflow()'s rejection). Idempotent: aborting an already-aborted
|
|
1694
|
+
// controller is a no-op.
|
|
1695
|
+
if (isTopLevelRun) shared.runFatalController.abort();
|
|
1696
|
+
throw error;
|
|
1697
|
+
} finally {
|
|
1698
|
+
if (isTopLevelRun) {
|
|
1699
|
+
if (shared.inFlight.size > 0) {
|
|
1700
|
+
log(`waiting for ${shared.inFlight.size} outstanding agent() call(s) to settle before this run completes`);
|
|
1701
|
+
}
|
|
1702
|
+
while (shared.inFlight.size > 0) {
|
|
1703
|
+
await Promise.allSettled(Array.from(shared.inFlight));
|
|
1704
|
+
}
|
|
1705
|
+
store.dispose();
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
}
|