@yycholla/pi-dynamic-workflows 3.3.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/LICENSE +22 -0
- package/README.md +325 -0
- package/assets/readme/hero.png +0 -0
- package/assets/readme/hero.svg +70 -0
- package/assets/readme/package-cover.png +0 -0
- package/assets/readme/package-cover.svg +66 -0
- package/assets/readme/workflow.png +0 -0
- package/assets/readme/workflow.svg +77 -0
- package/dist/accept-workflow-guidance.d.ts +14 -0
- package/dist/accept-workflow-guidance.js +53 -0
- package/dist/adversarial-review.d.ts +30 -0
- package/dist/adversarial-review.js +107 -0
- package/dist/agent-history.d.ts +20 -0
- package/dist/agent-history.js +122 -0
- package/dist/agent-registry.d.ts +83 -0
- package/dist/agent-registry.js +190 -0
- package/dist/agent.d.ts +364 -0
- package/dist/agent.js +714 -0
- package/dist/builtin-commands.d.ts +19 -0
- package/dist/builtin-commands.js +251 -0
- package/dist/builtin-workflows.d.ts +45 -0
- package/dist/builtin-workflows.js +121 -0
- package/dist/code-review.d.ts +26 -0
- package/dist/code-review.js +181 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +44 -0
- package/dist/deep-research.d.ts +30 -0
- package/dist/deep-research.js +124 -0
- package/dist/display.d.ts +134 -0
- package/dist/display.js +248 -0
- package/dist/effort-command.d.ts +28 -0
- package/dist/effort-command.js +68 -0
- package/dist/enums.d.ts +69 -0
- package/dist/enums.js +78 -0
- package/dist/errors.d.ts +113 -0
- package/dist/errors.js +140 -0
- package/dist/extension-reload.d.ts +37 -0
- package/dist/extension-reload.js +78 -0
- package/dist/fs-persistence.d.ts +63 -0
- package/dist/fs-persistence.js +102 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +35 -0
- package/dist/logger.d.ts +21 -0
- package/dist/logger.js +66 -0
- package/dist/model-routing.d.ts +30 -0
- package/dist/model-routing.js +50 -0
- package/dist/model-spec.d.ts +29 -0
- package/dist/model-spec.js +252 -0
- package/dist/model-tier-config.d.ts +133 -0
- package/dist/model-tier-config.js +249 -0
- package/dist/run-persistence.d.ts +180 -0
- package/dist/run-persistence.js +294 -0
- package/dist/saved-commands.d.ts +28 -0
- package/dist/saved-commands.js +100 -0
- package/dist/shared-store.d.ts +98 -0
- package/dist/shared-store.js +212 -0
- package/dist/structured-output.d.ts +19 -0
- package/dist/structured-output.js +30 -0
- package/dist/task-panel.d.ts +61 -0
- package/dist/task-panel.js +422 -0
- package/dist/usage-limit-scheduler.d.ts +145 -0
- package/dist/usage-limit-scheduler.js +368 -0
- package/dist/web-tools.d.ts +20 -0
- package/dist/web-tools.js +120 -0
- package/dist/workflow-authoring-coverage.d.ts +70 -0
- package/dist/workflow-authoring-coverage.js +421 -0
- package/dist/workflow-authoring-reference.d.ts +20 -0
- package/dist/workflow-authoring-reference.js +156 -0
- package/dist/workflow-capability-contract.d.ts +131 -0
- package/dist/workflow-capability-contract.js +604 -0
- package/dist/workflow-commands.d.ts +18 -0
- package/dist/workflow-commands.js +260 -0
- package/dist/workflow-comprehension.d.ts +133 -0
- package/dist/workflow-comprehension.js +1321 -0
- package/dist/workflow-context-measurement.d.ts +72 -0
- package/dist/workflow-context-measurement.js +213 -0
- package/dist/workflow-control-tool.d.ts +30 -0
- package/dist/workflow-control-tool.js +176 -0
- package/dist/workflow-delivery-choice.d.ts +20 -0
- package/dist/workflow-delivery-choice.js +48 -0
- package/dist/workflow-editor.d.ts +93 -0
- package/dist/workflow-editor.js +363 -0
- package/dist/workflow-manager.d.ts +492 -0
- package/dist/workflow-manager.js +1124 -0
- package/dist/workflow-paths.d.ts +22 -0
- package/dist/workflow-paths.js +46 -0
- package/dist/workflow-release-gate.d.ts +39 -0
- package/dist/workflow-release-gate.js +309 -0
- package/dist/workflow-saved.d.ts +38 -0
- package/dist/workflow-saved.js +126 -0
- package/dist/workflow-settings.d.ts +70 -0
- package/dist/workflow-settings.js +131 -0
- package/dist/workflow-tool.d.ts +71 -0
- package/dist/workflow-tool.js +367 -0
- package/dist/workflow-ui.d.ts +182 -0
- package/dist/workflow-ui.js +1587 -0
- package/dist/workflow.d.ts +333 -0
- package/dist/workflow.js +1151 -0
- package/dist/workflows-models-command.d.ts +31 -0
- package/dist/workflows-models-command.js +156 -0
- package/dist/worktree.d.ts +25 -0
- package/dist/worktree.js +61 -0
- package/extensions/workflow.ts +151 -0
- package/package.json +104 -0
- 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/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 +41 -0
- package/skills/workflow-authoring/references/capability-details.md +357 -0
- package/skills/workflow-authoring/references/common-helpers.md +4 -0
- package/skills/workflow-authoring/references/debugging.md +27 -0
- package/skills/workflow-authoring/references/focused-recipes.md +11 -0
- package/skills/workflow-authoring/references/helpers.md +9 -0
- package/skills/workflow-authoring/references/lifecycle.md +33 -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 +15 -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 +27 -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/accept-workflow-guidance.ts +71 -0
- package/src/adversarial-review.ts +120 -0
- package/src/agent-history.ts +157 -0
- package/src/agent-registry.ts +221 -0
- package/src/agent.ts +929 -0
- package/src/builtin-commands.ts +286 -0
- package/src/builtin-workflows.ts +155 -0
- package/src/code-review.ts +183 -0
- package/src/config.ts +55 -0
- package/src/deep-research.ts +135 -0
- package/src/display.ts +367 -0
- package/src/effort-command.ts +87 -0
- package/src/enums.ts +77 -0
- package/src/errors.ts +199 -0
- package/src/extension-reload.ts +100 -0
- package/src/fs-persistence.ts +124 -0
- package/src/index.ts +176 -0
- package/src/logger.ts +88 -0
- package/src/model-routing.ts +73 -0
- package/src/model-spec.ts +309 -0
- package/src/model-tier-config.ts +296 -0
- package/src/run-persistence.ts +484 -0
- package/src/saved-commands.ts +115 -0
- package/src/shared-store.ts +228 -0
- package/src/structured-output.ts +47 -0
- package/src/task-panel.ts +490 -0
- package/src/usage-limit-scheduler.ts +432 -0
- package/src/web-tools.ts +124 -0
- package/src/workflow-authoring-coverage.ts +486 -0
- package/src/workflow-authoring-reference.ts +186 -0
- package/src/workflow-capability-contract.ts +806 -0
- package/src/workflow-commands.ts +287 -0
- package/src/workflow-comprehension.ts +1673 -0
- package/src/workflow-context-measurement.ts +262 -0
- package/src/workflow-control-tool.ts +238 -0
- package/src/workflow-delivery-choice.ts +69 -0
- package/src/workflow-editor.ts +444 -0
- package/src/workflow-manager.ts +1405 -0
- package/src/workflow-paths.ts +63 -0
- package/src/workflow-release-gate.ts +529 -0
- package/src/workflow-saved.ts +180 -0
- package/src/workflow-settings.ts +194 -0
- package/src/workflow-tool.ts +464 -0
- package/src/workflow-ui.ts +1789 -0
- package/src/workflow.ts +1615 -0
- package/src/workflows-models-command.ts +211 -0
- package/src/worktree.ts +76 -0
package/dist/workflow.js
ADDED
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import vm from "node:vm";
|
|
4
|
+
import { parse } from "acorn";
|
|
5
|
+
import { WorkflowAgent } from "./agent.js";
|
|
6
|
+
import { agentDefinitionKey, loadAgentRegistry, resolveAgentType, } from "./agent-registry.js";
|
|
7
|
+
import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENT_RETRIES, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from "./config.js";
|
|
8
|
+
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
9
|
+
import { createWorkflowLogger } from "./logger.js";
|
|
10
|
+
import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
|
|
11
|
+
import { createAgentStoreTools, SharedStore } from "./shared-store.js";
|
|
12
|
+
import { WORKFLOW_CAPABILITY_CONTRACT } from "./workflow-capability-contract.js";
|
|
13
|
+
import { createWorktree, removeWorktree } from "./worktree.js";
|
|
14
|
+
/**
|
|
15
|
+
* Batch-scoped cancellation for a single parallel()/pipeline() fan-out. When a
|
|
16
|
+
* fan-out's agent() calls reserve past maxAgents, the breaching call throws and
|
|
17
|
+
* the whole fan-out rejects — but agents already reserved and queued behind the
|
|
18
|
+
* limiter would otherwise keep draining and spending. parallel()/pipeline()
|
|
19
|
+
* establish a fresh store per call via fanoutScope.run(); agent() captures the
|
|
20
|
+
* nearest enclosing store synchronously (before suspending on the limiter) so a
|
|
21
|
+
* still-queued agent can bail once ITS OWN fan-out breaches, without touching
|
|
22
|
+
* sibling fan-outs running concurrently or an enclosing fan-out when this one is
|
|
23
|
+
* nested inside it (each nesting level gets its own store via ALS scoping).
|
|
24
|
+
*
|
|
25
|
+
* Scope note: cancellation is bounded PER breaching fan-out, not run-global — a
|
|
26
|
+
* deliberate tradeoff. Deep-sixing the earlier run-global flag was required
|
|
27
|
+
* because it wrongly cancelled an innocent, independently-caught sibling batch.
|
|
28
|
+
* The consequence: if one fan-out breaches while an unrelated in-cap sibling or
|
|
29
|
+
* a nested inner fan-out is mid-flight, that other batch is NOT cancelled and
|
|
30
|
+
* finishes its already-reserved agents (still capped at maxAgents total). Only
|
|
31
|
+
* the breaching fan-out's own queue is short-circuited.
|
|
32
|
+
*/
|
|
33
|
+
const fanoutScope = new AsyncLocalStorage();
|
|
34
|
+
// Parse-time author hint (fast feedback). The real enforcement is DETERMINISM_PRELUDE.
|
|
35
|
+
const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
|
|
36
|
+
/**
|
|
37
|
+
* Runtime determinism hardening, run inside the vm realm BEFORE the user script.
|
|
38
|
+
* It neuters the nondeterministic builtins that would break resume (they'd make a
|
|
39
|
+
* re-run produce different values than the cached journal):
|
|
40
|
+
* - Math.random() -> throws
|
|
41
|
+
* - Date.now() -> throws
|
|
42
|
+
* - Date() / new Date() -> throws (no-arg); new Date(arg) still works
|
|
43
|
+
* Using the vm realm's own Math/Date/Reflect (not host objects) means this adds
|
|
44
|
+
* no host-`Function` escape. Note: vm is not a security sandbox — an injected
|
|
45
|
+
* bridge function's `.constructor` is still the host Function, so a determined
|
|
46
|
+
* script could bypass this. The guard is best-effort against ACCIDENTAL
|
|
47
|
+
* nondeterminism from trusted (user / guided-LLM) scripts, not a security wall.
|
|
48
|
+
*/
|
|
49
|
+
const DETERMINISM_PRELUDE = [
|
|
50
|
+
'"use strict";',
|
|
51
|
+
'Math.random = () => { throw new Error("Math.random() is unavailable in a workflow (it breaks resume); pass randomness via args or vary by index"); };',
|
|
52
|
+
"{",
|
|
53
|
+
" const RealDate = Date;",
|
|
54
|
+
' const fail = (w) => { throw new Error(w + " is unavailable in a workflow (it breaks resume); pass a timestamp via args"); };',
|
|
55
|
+
" const SafeDate = function (...a) {",
|
|
56
|
+
' if (!new.target) fail("Date()");',
|
|
57
|
+
' if (a.length === 0) fail("new Date()");',
|
|
58
|
+
" return Reflect.construct(RealDate, a, SafeDate);",
|
|
59
|
+
" };",
|
|
60
|
+
" SafeDate.UTC = RealDate.UTC;",
|
|
61
|
+
" SafeDate.parse = RealDate.parse;",
|
|
62
|
+
' SafeDate.now = () => fail("Date.now()");',
|
|
63
|
+
" SafeDate.prototype = RealDate.prototype;",
|
|
64
|
+
" globalThis.Date = SafeDate;",
|
|
65
|
+
"}",
|
|
66
|
+
].join("\n");
|
|
67
|
+
export async function runWorkflow(script, options = {}) {
|
|
68
|
+
const started = Date.now();
|
|
69
|
+
const { meta, body } = parseWorkflowScript(script);
|
|
70
|
+
// Per-phase model routing from meta.phases[].model, with meta.model as the default.
|
|
71
|
+
const routingConfig = parseModelRoutingFromMeta(meta.phases, meta.model);
|
|
72
|
+
const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
|
|
73
|
+
const agentTimeoutMs = options.agentTimeoutMs !== undefined ? options.agentTimeoutMs : DEFAULT_AGENT_TIMEOUT_MS;
|
|
74
|
+
const runId = options.runId ?? `run-${started.toString(36)}`;
|
|
75
|
+
const baseCwd = options.cwd ?? process.cwd();
|
|
76
|
+
// Snapshot the agentType registry ONCE per run so two agent() calls can't
|
|
77
|
+
// observe a mid-run edit (determinism); a later resume re-reads it.
|
|
78
|
+
const agentRegistry = options.agentRegistry ?? loadAgentRegistry(baseCwd);
|
|
79
|
+
// Initialize logger
|
|
80
|
+
const logger = createWorkflowLogger({
|
|
81
|
+
runId,
|
|
82
|
+
cwd: options.cwd ?? process.cwd(),
|
|
83
|
+
persist: options.persistLogs ?? true,
|
|
84
|
+
onLog: options.onLog,
|
|
85
|
+
});
|
|
86
|
+
const state = {
|
|
87
|
+
logs: [],
|
|
88
|
+
// When the script declares meta.phases, default the current phase to the
|
|
89
|
+
// first one so agents created before any explicit phase() call still group
|
|
90
|
+
// under a declared phase instead of an orphan "(no phase)" bucket. An
|
|
91
|
+
// explicit phase() (or agent({ phase })) overrides this.
|
|
92
|
+
phases: meta.phases?.[0]?.title ? [meta.phases[0].title] : [],
|
|
93
|
+
currentPhase: meta.phases?.[0]?.title,
|
|
94
|
+
phaseBudgets: new Map(),
|
|
95
|
+
callSeq: 0,
|
|
96
|
+
firstMiss: Number.POSITIVE_INFINITY,
|
|
97
|
+
};
|
|
98
|
+
const agentRunner = options.agent ?? new WorkflowAgent(options);
|
|
99
|
+
const concurrency = normalizeConcurrency(options.concurrency ?? Math.max(1, (globalThis.navigator?.hardwareConcurrency ?? 8) - 2));
|
|
100
|
+
// Global caps + budget are shared with any nested workflow() so they hold across nesting.
|
|
101
|
+
// options.initialTokenUsage (resume() only) seeds spent/tokenUsage so the
|
|
102
|
+
// tokenBudget ceiling holds cumulatively across a pause/resume cycle instead
|
|
103
|
+
// of resetting to zero (see WorkflowRunOptions.initialTokenUsage). Deliberately
|
|
104
|
+
// NOT applied when options.sharedRuntime is supplied — that branch inherits a
|
|
105
|
+
// parent workflow()'s already-live counters, which must not be re-seeded.
|
|
106
|
+
//
|
|
107
|
+
// agentCount is NOT seeded here, unlike spent/tokenUsage — and doesn't need
|
|
108
|
+
// to be: resume() always replays the whole script from callIndex 0, and
|
|
109
|
+
// agent()'s `shared.agentCount++` fires unconditionally for every call
|
|
110
|
+
// (cache-hit replay or live) before the replay-vs-live branch runs. That
|
|
111
|
+
// replay alone reconstructs the correct cumulative count in this fresh
|
|
112
|
+
// SharedRuntime by the time any new live agent executes, so maxAgents stays
|
|
113
|
+
// a genuine cumulative cap across resume with no extra seeding. Token spend
|
|
114
|
+
// needs seeding precisely because its cache-hit branch deliberately does NOT
|
|
115
|
+
// re-run recordTokens() (to avoid double-counting already-spent tokens) —
|
|
116
|
+
// there is no replay-based reconstruction for it the way there is for count.
|
|
117
|
+
const shared = options.sharedRuntime ?? {
|
|
118
|
+
limiter: createLimiter(concurrency),
|
|
119
|
+
agentCount: 0,
|
|
120
|
+
spent: options.initialTokenUsage?.total ?? 0,
|
|
121
|
+
tokenUsage: options.initialTokenUsage
|
|
122
|
+
? { ...options.initialTokenUsage }
|
|
123
|
+
: { input: 0, output: 0, total: 0, cost: 0, cacheRead: 0, cacheWrite: 0 },
|
|
124
|
+
depth: 0,
|
|
125
|
+
nestedCallSeq: 0,
|
|
126
|
+
runFatalController: new AbortController(),
|
|
127
|
+
inFlight: new Set(),
|
|
128
|
+
};
|
|
129
|
+
const limiter = shared.limiter;
|
|
130
|
+
// This frame created `shared` fresh (rather than inheriting a parent
|
|
131
|
+
// workflow()'s) — i.e. it's the true top-level run, the only frame allowed
|
|
132
|
+
// to declare the run's fate sealed (see SharedRuntime.runFatalController) or
|
|
133
|
+
// drain/dispose the SharedStore. A nested workflow() call always passes both
|
|
134
|
+
// sharedRuntime and sharedStore together (see workflowFn below), so this is
|
|
135
|
+
// equivalent to `!options.sharedStore` — used at both choke points below.
|
|
136
|
+
const isTopLevelRun = !options.sharedRuntime;
|
|
137
|
+
// One store instance per run; nested workflow() calls inherit the parent's store
|
|
138
|
+
// so all agents across nesting levels share the same key-value space.
|
|
139
|
+
const store = options.sharedStore ?? new SharedStore();
|
|
140
|
+
const log = (message) => {
|
|
141
|
+
const text = String(message);
|
|
142
|
+
state.logs.push(text);
|
|
143
|
+
logger.log(text);
|
|
144
|
+
};
|
|
145
|
+
const phase = (title, phaseOptions) => {
|
|
146
|
+
state.currentPhase = title;
|
|
147
|
+
if (!state.phases.includes(title))
|
|
148
|
+
state.phases.push(title);
|
|
149
|
+
// Carve a soft sub-budget from the run total for work done under this phase.
|
|
150
|
+
// Re-declaring re-bases from the current spent (idempotent across resume: the
|
|
151
|
+
// script re-runs phase() and the ceiling is recomputed from live spent).
|
|
152
|
+
if (typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0) {
|
|
153
|
+
state.phaseBudgets.set(title, { budget: phaseOptions.budget, startSpent: shared.spent, warned: false });
|
|
154
|
+
}
|
|
155
|
+
options.onPhase?.(title);
|
|
156
|
+
options.onRuntimeEvent?.({
|
|
157
|
+
type: "phase",
|
|
158
|
+
title,
|
|
159
|
+
budget: typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0 ? phaseOptions.budget : null,
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
const budget = Object.freeze({
|
|
163
|
+
total: options.tokenBudget ?? null,
|
|
164
|
+
spent: () => shared.spent,
|
|
165
|
+
remaining: () => (options.tokenBudget == null ? Infinity : Math.max(0, options.tokenBudget - shared.spent)),
|
|
166
|
+
});
|
|
167
|
+
const agentLimitError = () => new WorkflowError(`Agent limit exceeded (${maxAgents}). Use maxAgents option to increase the limit.`, WorkflowErrorCode.AGENT_LIMIT_EXCEEDED, { recoverable: false });
|
|
168
|
+
// True on an intentional external abort (pause/stop/Esc, via options.signal)
|
|
169
|
+
// OR once this run's fate has been sealed (shared.runFatalController — see
|
|
170
|
+
// its doc comment). Every abort check in this file goes through this so the
|
|
171
|
+
// two sources compose identically everywhere instead of only some call
|
|
172
|
+
// sites remembering to check the second one.
|
|
173
|
+
const isAborted = () => Boolean(options.signal?.aborted || shared.runFatalController.signal.aborted);
|
|
174
|
+
const throwIfAborted = () => {
|
|
175
|
+
if (isAborted()) {
|
|
176
|
+
throw new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true });
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const agent = (prompt, agentOptions = {}) => {
|
|
180
|
+
// Track every call (awaited or not) so the top-level run can drain
|
|
181
|
+
// outstanding calls before completing (see SharedRuntime.inFlight and the
|
|
182
|
+
// drain in the finally below) — this is what stops a forgotten `await`
|
|
183
|
+
// from letting an agent mutate state after the run is torn down.
|
|
184
|
+
const call = agentImpl(prompt, agentOptions);
|
|
185
|
+
shared.inFlight.add(call);
|
|
186
|
+
// Attaching a handler here (independent of whatever the script itself does
|
|
187
|
+
// with the returned promise) also means an un-awaited call's eventual
|
|
188
|
+
// rejection never becomes a process-crashing unhandled rejection.
|
|
189
|
+
call.catch(() => { }).finally(() => shared.inFlight.delete(call));
|
|
190
|
+
return call;
|
|
191
|
+
};
|
|
192
|
+
const agentImpl = async (prompt, agentOptions = {}) => {
|
|
193
|
+
throwIfAborted();
|
|
194
|
+
// Capture the enclosing parallel()/pipeline() fan-out's cancellation batch
|
|
195
|
+
// (if any) synchronously, while the ALS context of the caller is still
|
|
196
|
+
// active — i.e. before suspending on the limiter below. The limiter body
|
|
197
|
+
// closes over this so a still-queued agent can bail once its OWN fan-out
|
|
198
|
+
// breaches the cap, without affecting sibling or outer fan-outs.
|
|
199
|
+
const batch = fanoutScope.getStore();
|
|
200
|
+
// Check agent limit. A fan-out that overshoots the cap has already reserved
|
|
201
|
+
// and queued up to `maxAgents` agents; the breaching call throws here, and
|
|
202
|
+
// parallel()/pipeline() mark their own batch cancelled so the already-queued
|
|
203
|
+
// agents short-circuit before their real API call (see the limiter body).
|
|
204
|
+
if (shared.agentCount >= maxAgents) {
|
|
205
|
+
throw agentLimitError();
|
|
206
|
+
}
|
|
207
|
+
if (budget.total !== null && budget.remaining() <= 0) {
|
|
208
|
+
throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
|
|
209
|
+
recoverable: false,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const assignedPhase = agentOptions.phase ?? state.currentPhase;
|
|
213
|
+
// Per-phase soft sub-budget gate: a noisy phase can exhaust its own ceiling
|
|
214
|
+
// without touching the run's overall budget. Soft (spent accrues post-agent),
|
|
215
|
+
// warns once at ~80%, throws at 100%. Scripts can try/catch around a phase's
|
|
216
|
+
// work so later phases still proceed.
|
|
217
|
+
if (assignedPhase) {
|
|
218
|
+
const pb = state.phaseBudgets.get(assignedPhase);
|
|
219
|
+
if (pb) {
|
|
220
|
+
const phaseSpent = shared.spent - pb.startSpent;
|
|
221
|
+
if (phaseSpent >= pb.budget) {
|
|
222
|
+
throw new WorkflowError(`phase "${assignedPhase}" token sub-budget exhausted (${pb.budget})`, WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, { recoverable: false });
|
|
223
|
+
}
|
|
224
|
+
if (!pb.warned && phaseSpent >= pb.budget * 0.8) {
|
|
225
|
+
pb.warned = true;
|
|
226
|
+
log(`phase "${assignedPhase}" at ${Math.round((phaseSpent / pb.budget) * 100)}% of its token sub-budget`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const requestedLabel = agentOptions.label?.trim();
|
|
231
|
+
// Resolve a named agentType to its bound definition (tools/model/prompt).
|
|
232
|
+
const agentDef = resolveAgentType(agentOptions.agentType, agentRegistry);
|
|
233
|
+
if (agentOptions.agentType && !agentDef) {
|
|
234
|
+
log(`unknown agentType "${agentOptions.agentType}"; using default tools/model`);
|
|
235
|
+
}
|
|
236
|
+
// Model precedence: explicit agentOptions.model > agentType.model > tier > phase model.
|
|
237
|
+
// The "explicit-level" model is opts.model, else the definition's model — either
|
|
238
|
+
// beats tier/phase. When only a tier is set, pass undefined here so the tier (not
|
|
239
|
+
// the phase model) decides inside WorkflowAgent.run().
|
|
240
|
+
const explicitModel = agentOptions.model ?? agentDef?.model;
|
|
241
|
+
const modelSpec = explicitModel ?? (agentOptions.tier ? undefined : resolveModelForPhase(assignedPhase, routingConfig));
|
|
242
|
+
// For display in /workflows: the model this agent runs on — its explicit/phase
|
|
243
|
+
// spec, else the session's main model. The real resolved id overrides this via
|
|
244
|
+
// onModelResolved once the subagent session is created.
|
|
245
|
+
let displayModel = modelSpec ?? options.mainModel;
|
|
246
|
+
// Deterministic resume key: assigned at lexical call time, before the limiter,
|
|
247
|
+
// so parallel()/pipeline() fan-out is reproducible for a fixed script.
|
|
248
|
+
const callIndex = state.callSeq++;
|
|
249
|
+
const callHash = hashAgentCall(prompt, modelSpec, assignedPhase, agentOptions, agentDefinitionKey(agentDef));
|
|
250
|
+
// Store delta key: callIndex alone is NOT run-unique. A nested workflow()
|
|
251
|
+
// call (see workflowFn below) shares this run's SharedStore instance but
|
|
252
|
+
// restarts its own callSeq at 0, so a parent agent and a concurrently
|
|
253
|
+
// running nested-run agent — or two SEQUENTIAL sibling nested runs, whose
|
|
254
|
+
// depth alone would otherwise repeat — can both get callIndex 0 and
|
|
255
|
+
// collide in SharedStore.agentDeltas — whichever commits last
|
|
256
|
+
// steals/overwrites the other's journaled delta (and, via this same
|
|
257
|
+
// deltaKey doubling as the onAgentStart/onAgentEnd/onAgentHistory event
|
|
258
|
+
// id, misattributes one agent's events to the other — see item 2's
|
|
259
|
+
// identity model). Composing the run's own runId (unique per top-level
|
|
260
|
+
// run AND per nested run, see `${runId}-nested${++shared.nestedCallSeq}`
|
|
261
|
+
// below) with callIndex makes the key unique across the whole store.
|
|
262
|
+
const deltaKey = `${runId}:${callIndex}`;
|
|
263
|
+
// Reserve the agent slot synchronously — atomic with the limit/budget gate
|
|
264
|
+
// above (no await in between) — so a parallel() fan-out can't all observe the
|
|
265
|
+
// same agentCount and overshoot maxAgents. (Token budget stays a soft gate:
|
|
266
|
+
// spent accrues after each agent, matching Claude Code; in-flight agents may
|
|
267
|
+
// push slightly past total, then further agent() calls throw.)
|
|
268
|
+
shared.agentCount++;
|
|
269
|
+
const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
|
|
270
|
+
// Longest-unchanged-prefix resume: replay a cached result only while the
|
|
271
|
+
// prefix is still intact — this call's index is before the first changed/new
|
|
272
|
+
// call. Once any call misses, it AND everything after it run live (matching
|
|
273
|
+
// Claude Code's contract), so an edited upstream call never leaves stale
|
|
274
|
+
// downstream results served from the journal.
|
|
275
|
+
// Namespaced the same way as SharedStore's deltaKey (deltaKey IS this
|
|
276
|
+
// exact `${runId}:${callIndex}` string) so a nested workflow()'s
|
|
277
|
+
// callIndex-0 can never accidentally replay the parent's callIndex-0
|
|
278
|
+
// entry, or vice versa (see JournalEntry.runId).
|
|
279
|
+
const cached = options.resumeJournal?.get(deltaKey);
|
|
280
|
+
const hashMatches = cached != null && cached.hash === callHash;
|
|
281
|
+
const cachedEmptyOutput = hashMatches && isEmptyTextAgentResult(cached.result, agentOptions.schema);
|
|
282
|
+
if (hashMatches && !cachedEmptyOutput && callIndex < state.firstMiss) {
|
|
283
|
+
options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt, model: displayModel });
|
|
284
|
+
options.onAgentEnd?.({
|
|
285
|
+
id: deltaKey,
|
|
286
|
+
label,
|
|
287
|
+
phase: assignedPhase,
|
|
288
|
+
result: cached.result,
|
|
289
|
+
tokens: 0,
|
|
290
|
+
model: displayModel,
|
|
291
|
+
});
|
|
292
|
+
// Apply this agent's write delta so live agents later in the run see a
|
|
293
|
+
// consistent store. Additive apply preserves parallel-agent writes that
|
|
294
|
+
// came from higher-callIndex agents finishing before this one.
|
|
295
|
+
if (cached.storeDelta)
|
|
296
|
+
store.applyDelta(cached.storeDelta);
|
|
297
|
+
return cached.result;
|
|
298
|
+
}
|
|
299
|
+
// A genuine miss (no journal entry, or the hash changed) marks where the
|
|
300
|
+
// unchanged prefix ends; this call and every later one then run live.
|
|
301
|
+
if (!hashMatches || cachedEmptyOutput)
|
|
302
|
+
state.firstMiss = Math.min(state.firstMiss, callIndex);
|
|
303
|
+
return limiter(async () => {
|
|
304
|
+
const timeout = agentOptions.timeoutMs !== undefined ? agentOptions.timeoutMs : agentTimeoutMs;
|
|
305
|
+
const retryAttempts = normalizeAgentRetries(agentOptions.retries ?? options.agentRetries ?? 0);
|
|
306
|
+
const maxAttempts = retryAttempts + 1;
|
|
307
|
+
options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt, model: displayModel });
|
|
308
|
+
// Optional per-agent worktree isolation (deterministic name -> stable resume keys).
|
|
309
|
+
// Precedence: explicit call-site isolation > agentDef isolation.
|
|
310
|
+
// Note: passing { isolation: undefined } falls through ?? to the def's value — there
|
|
311
|
+
// is no sentinel to suppress a def's isolation at the call site. Remove the agentType
|
|
312
|
+
// or override with a def that has no isolation field if opt-out is needed.
|
|
313
|
+
let worktree;
|
|
314
|
+
const resolvedIsolation = agentOptions.isolation ?? agentDef?.isolation;
|
|
315
|
+
if (resolvedIsolation === "worktree") {
|
|
316
|
+
worktree = await createWorktree(baseCwd, `${runId}-${callIndex}-${label}`);
|
|
317
|
+
if (!worktree.isolated)
|
|
318
|
+
log(`isolation ignored for "${label}" (${worktree.reason})`);
|
|
319
|
+
}
|
|
320
|
+
const runCwd = worktree?.isolated ? worktree.cwd : undefined;
|
|
321
|
+
// Captured from the subagent's real session usage; falls back to an
|
|
322
|
+
// estimate when the provider reports no usage (total === 0). Usage is reset
|
|
323
|
+
// per retry attempt so a failed attempt does not double-count the next one.
|
|
324
|
+
let usage;
|
|
325
|
+
const recordTokens = (result) => {
|
|
326
|
+
const tokens = usage && usage.total > 0 ? usage.total : estimateTokens(result) + estimateTokens(prompt);
|
|
327
|
+
if (usage) {
|
|
328
|
+
shared.tokenUsage.input += usage.input;
|
|
329
|
+
shared.tokenUsage.output += usage.output;
|
|
330
|
+
shared.tokenUsage.cost += usage.cost;
|
|
331
|
+
shared.tokenUsage.cacheRead += usage.cacheRead;
|
|
332
|
+
shared.tokenUsage.cacheWrite += usage.cacheWrite;
|
|
333
|
+
}
|
|
334
|
+
shared.tokenUsage.total += tokens;
|
|
335
|
+
shared.spent += tokens;
|
|
336
|
+
return tokens;
|
|
337
|
+
};
|
|
338
|
+
try {
|
|
339
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
340
|
+
usage = undefined;
|
|
341
|
+
const externalSignal = options.signal;
|
|
342
|
+
let onExternalAbort;
|
|
343
|
+
let onRunFatal;
|
|
344
|
+
try {
|
|
345
|
+
throwIfAborted();
|
|
346
|
+
// This agent's own fan-out already breached maxAgents while this
|
|
347
|
+
// call sat queued behind the limiter; bail before spending on the
|
|
348
|
+
// real API call instead of draining the whole reserved queue.
|
|
349
|
+
if (batch?.cancelled)
|
|
350
|
+
throw agentLimitError();
|
|
351
|
+
// Per-attempt abort: on timeout we abort THIS agent so its session is
|
|
352
|
+
// disposed and its heavy state (messages, etc.) released, instead of
|
|
353
|
+
// leaving it streaming in the background — retries would otherwise
|
|
354
|
+
// stack live sessions on top of each other (#109). Linked to BOTH the
|
|
355
|
+
// run's external signal (outer abort — pause/stop/Esc) AND
|
|
356
|
+
// shared.runFatalController (this run's fate has been sealed by a
|
|
357
|
+
// sibling's non-recoverable error escaping the top-level script — see
|
|
358
|
+
// SharedRuntime.runFatalController) so an in-flight sibling actually
|
|
359
|
+
// winds down instead of running to completion on a doomed run. Both
|
|
360
|
+
// links are torn down per attempt in finally so listeners don't accrue.
|
|
361
|
+
const agentController = new AbortController();
|
|
362
|
+
if (isAborted()) {
|
|
363
|
+
agentController.abort();
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
if (externalSignal) {
|
|
367
|
+
onExternalAbort = () => agentController.abort();
|
|
368
|
+
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
|
369
|
+
}
|
|
370
|
+
onRunFatal = () => agentController.abort();
|
|
371
|
+
shared.runFatalController.signal.addEventListener("abort", onRunFatal, { once: true });
|
|
372
|
+
}
|
|
373
|
+
const runPromise = agentRunner.run(prompt, {
|
|
374
|
+
label,
|
|
375
|
+
// Identifiable name for persisted sessions (persistAgentSessions).
|
|
376
|
+
sessionName: `workflow:${runId} ${label}`,
|
|
377
|
+
schema: agentOptions.schema,
|
|
378
|
+
signal: agentController.signal,
|
|
379
|
+
instructions: buildAgentInstructions(assignedPhase, agentOptions, agentDef, resolvedIsolation),
|
|
380
|
+
model: modelSpec,
|
|
381
|
+
tier: agentOptions.tier,
|
|
382
|
+
modelRegistry: options.modelRegistry,
|
|
383
|
+
toolNames: agentDef?.tools,
|
|
384
|
+
disallowedToolNames: agentDef?.disallowedTools,
|
|
385
|
+
// Per-agent store tools track this agent's writes by the
|
|
386
|
+
// run-unique deltaKey so the delta can be journaled and replayed
|
|
387
|
+
// correctly on resume, even when a nested workflow() run shares
|
|
388
|
+
// this store concurrently with the parent run.
|
|
389
|
+
systemTools: createAgentStoreTools(store, deltaKey),
|
|
390
|
+
cwd: runCwd,
|
|
391
|
+
onModelResolved: (id) => {
|
|
392
|
+
displayModel = id;
|
|
393
|
+
},
|
|
394
|
+
onModelFallback: (spec) => {
|
|
395
|
+
// Make the silent degrade visible in /workflows, not just console.
|
|
396
|
+
log(`${label}: model "${spec}" unavailable — using the session default`);
|
|
397
|
+
},
|
|
398
|
+
onUsage: (u) => {
|
|
399
|
+
usage = u;
|
|
400
|
+
},
|
|
401
|
+
onHistory: (history) => {
|
|
402
|
+
options.onAgentHistory?.({ id: deltaKey, label, phase: assignedPhase, history });
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
// After a timeout the run() promise still settles later, rejecting with
|
|
406
|
+
// "aborted" once agentController fires; the race has already resolved,
|
|
407
|
+
// so swallow that to avoid an unhandled rejection.
|
|
408
|
+
runPromise.catch(() => { });
|
|
409
|
+
const result = await withTimeout(runPromise, timeout, label, () => agentController.abort());
|
|
410
|
+
throwIfAborted();
|
|
411
|
+
if (isEmptyTextAgentResult(result, agentOptions.schema)) {
|
|
412
|
+
throw new WorkflowError("Subagent produced no assistant output", WorkflowErrorCode.AGENT_EMPTY_OUTPUT, {
|
|
413
|
+
recoverable: true,
|
|
414
|
+
agentLabel: label,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
const tokens = recordTokens(result);
|
|
418
|
+
options.onAgentJournal?.({
|
|
419
|
+
index: callIndex,
|
|
420
|
+
runId,
|
|
421
|
+
hash: callHash,
|
|
422
|
+
result,
|
|
423
|
+
storeDelta: store.commitDelta(deltaKey),
|
|
424
|
+
});
|
|
425
|
+
options.onAgentEnd?.({
|
|
426
|
+
id: deltaKey,
|
|
427
|
+
label,
|
|
428
|
+
phase: assignedPhase,
|
|
429
|
+
result,
|
|
430
|
+
tokens,
|
|
431
|
+
tokenUsage: usage,
|
|
432
|
+
worktree: runCwd,
|
|
433
|
+
model: displayModel,
|
|
434
|
+
});
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
if (isAborted())
|
|
439
|
+
throw error;
|
|
440
|
+
const workflowError = wrapError(error, { agentLabel: label });
|
|
441
|
+
logger.error(`agent ${label} attempt ${attempt}/${maxAttempts} failed: ${workflowError.message}`);
|
|
442
|
+
const tokens = recordTokens(null);
|
|
443
|
+
// This attempt's store writes must not survive it — a failed
|
|
444
|
+
// attempt shares this call's deltaKey with every other attempt
|
|
445
|
+
// (retried or not), so without rolling back here its writes would
|
|
446
|
+
// stay live in the store (visible to concurrently-running sibling
|
|
447
|
+
// agents) and merge into whatever a later, successful attempt
|
|
448
|
+
// commits — corrupting both the live run's state and the delta
|
|
449
|
+
// that resume replay reconstructs from. Unconditional: this
|
|
450
|
+
// covers the about-to-retry case AND the exhausted/non-recoverable
|
|
451
|
+
// case, since neither leaves behind a call that "produced" a
|
|
452
|
+
// result this attempt's writes should be attributed to.
|
|
453
|
+
store.discardDelta(deltaKey);
|
|
454
|
+
if (workflowError.recoverable && attempt < maxAttempts) {
|
|
455
|
+
log(`agent "${label}" attempt ${attempt}/${maxAttempts} failed: ${workflowError.code} ${workflowError.message}; retrying`);
|
|
456
|
+
// This attempt's spend already accrued into shared.spent/tokenUsage
|
|
457
|
+
// above (recordTokens) — but it will never reach onAgentEnd (only
|
|
458
|
+
// the final attempt does), so report it on the dedicated channel
|
|
459
|
+
// instead (see WorkflowRunOptions.onRetrySpend).
|
|
460
|
+
options.onRetrySpend?.(tokens);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
options.onAgentEnd?.({
|
|
464
|
+
id: deltaKey,
|
|
465
|
+
label,
|
|
466
|
+
phase: assignedPhase,
|
|
467
|
+
result: null,
|
|
468
|
+
tokens,
|
|
469
|
+
tokenUsage: usage,
|
|
470
|
+
worktree: runCwd,
|
|
471
|
+
model: displayModel,
|
|
472
|
+
error: workflowError.message,
|
|
473
|
+
errorCode: workflowError.code,
|
|
474
|
+
recoverable: workflowError.recoverable,
|
|
475
|
+
});
|
|
476
|
+
if (workflowError.recoverable) {
|
|
477
|
+
log(`agent "${label}" exhausted ${maxAttempts} attempt${maxAttempts === 1 ? "" : "s"}: ${workflowError.code} ${workflowError.message}`);
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
throw workflowError;
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
// Drop this attempt's abort listeners so they don't accrue one entry
|
|
484
|
+
// per attempt on the run's signal / runFatalController for the whole
|
|
485
|
+
// run (#109 hygiene).
|
|
486
|
+
if (onExternalAbort)
|
|
487
|
+
externalSignal?.removeEventListener("abort", onExternalAbort);
|
|
488
|
+
if (onRunFatal)
|
|
489
|
+
shared.runFatalController.signal.removeEventListener("abort", onRunFatal);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
// Always tear down the worktree, even on timeout/abort.
|
|
496
|
+
if (worktree?.isolated)
|
|
497
|
+
await removeWorktree(worktree);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
};
|
|
501
|
+
const parallel = async (thunks) => {
|
|
502
|
+
throwIfAborted();
|
|
503
|
+
if (!Array.isArray(thunks))
|
|
504
|
+
throw new TypeError("parallel() expects an array of functions");
|
|
505
|
+
if (thunks.some((thunk) => typeof thunk !== "function")) {
|
|
506
|
+
throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
|
|
507
|
+
}
|
|
508
|
+
// Batch-scoped cancellation: agent() calls made (directly or transitively)
|
|
509
|
+
// from these thunks see this store via fanoutScope.getStore(). A breach in
|
|
510
|
+
// THIS fan-out flips `cancelled` so its own still-queued agents bail, without
|
|
511
|
+
// touching a sibling fan-out running concurrently or an enclosing one.
|
|
512
|
+
const batch = { cancelled: false };
|
|
513
|
+
return fanoutScope.run(batch, () => Promise.all(thunks.map(async (thunk, index) => {
|
|
514
|
+
try {
|
|
515
|
+
return await thunk();
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
if (isAborted())
|
|
519
|
+
throw error;
|
|
520
|
+
const workflowError = wrapError(error);
|
|
521
|
+
// Non-recoverable failures (token budget / agent limit exhausted) must
|
|
522
|
+
// halt the whole run, exactly like a directly-awaited agent() — not be
|
|
523
|
+
// swallowed into a null in the result array.
|
|
524
|
+
if (!workflowError.recoverable) {
|
|
525
|
+
// Only a breached agent cap cancels the rest of this batch; the
|
|
526
|
+
// token budget stays a soft gate by design (in-flight agents may
|
|
527
|
+
// finish past it), and other non-recoverable errors don't imply
|
|
528
|
+
// the rest of the batch is doomed.
|
|
529
|
+
if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED)
|
|
530
|
+
batch.cancelled = true;
|
|
531
|
+
throw workflowError;
|
|
532
|
+
}
|
|
533
|
+
log(`parallel[${index}] failed: ${workflowError.message}`);
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
})));
|
|
537
|
+
};
|
|
538
|
+
const pipeline = async (items, ...stages) => {
|
|
539
|
+
throwIfAborted();
|
|
540
|
+
if (!Array.isArray(items))
|
|
541
|
+
throw new TypeError("pipeline() expects an array as the first argument");
|
|
542
|
+
if (stages.some((stage) => typeof stage !== "function")) {
|
|
543
|
+
throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
|
|
544
|
+
}
|
|
545
|
+
// Batch-scoped cancellation — see parallel() for the rationale.
|
|
546
|
+
const batch = { cancelled: false };
|
|
547
|
+
return fanoutScope.run(batch, () => Promise.all(items.map(async (item, index) => {
|
|
548
|
+
let value = item;
|
|
549
|
+
for (const stage of stages) {
|
|
550
|
+
try {
|
|
551
|
+
throwIfAborted();
|
|
552
|
+
value = await stage(value, item, index);
|
|
553
|
+
throwIfAborted();
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
if (isAborted())
|
|
557
|
+
throw error;
|
|
558
|
+
const workflowError = wrapError(error);
|
|
559
|
+
// Non-recoverable failures halt the whole run (see parallel()).
|
|
560
|
+
if (!workflowError.recoverable) {
|
|
561
|
+
if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED)
|
|
562
|
+
batch.cancelled = true;
|
|
563
|
+
throw workflowError;
|
|
564
|
+
}
|
|
565
|
+
log(`pipeline[${index}] failed: ${workflowError.message}`);
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return value;
|
|
570
|
+
})));
|
|
571
|
+
};
|
|
572
|
+
// Nested workflow(): run a saved workflow (or a raw script) inline, sharing this
|
|
573
|
+
// run's limiter/counters/budget so the global caps hold. One level deep only.
|
|
574
|
+
const workflowFn = async (nameOrScript, childArgs) => {
|
|
575
|
+
throwIfAborted();
|
|
576
|
+
if (shared.depth >= 1) {
|
|
577
|
+
throw new WorkflowError("workflow() can nest only one level deep", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
578
|
+
recoverable: false,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
|
|
582
|
+
const childScript = resolved ?? String(nameOrScript);
|
|
583
|
+
const workflowName = String(nameOrScript);
|
|
584
|
+
options.onRuntimeEvent?.({ type: "workflow", stage: "start", name: workflowName, args: childArgs });
|
|
585
|
+
shared.depth++;
|
|
586
|
+
try {
|
|
587
|
+
// Propagate the resumeJournal into the child frame ONLY while the
|
|
588
|
+
// parent's own longest-unchanged-prefix is still intact at the moment
|
|
589
|
+
// of this workflow() call (state.firstMiss === Infinity, i.e. every
|
|
590
|
+
// parent agent()/checkpoint() call BEFORE this one was a cache hit).
|
|
591
|
+
// This is namespacing-safe (see JournalEntry.runId) but namespacing
|
|
592
|
+
// alone is NOT sufficient: SharedStore content itself is not part of
|
|
593
|
+
// any call's hash, so a cached child result was computed against
|
|
594
|
+
// whatever store state the UPSTREAM parent calls had written at the
|
|
595
|
+
// time it originally ran live. If an upstream parent call misses
|
|
596
|
+
// (edited script) and re-runs live, it may write different store
|
|
597
|
+
// values than it did originally — a child cached under the OLD store
|
|
598
|
+
// state would then be replaying a result that's stale with respect to
|
|
599
|
+
// the NEW live state, even though the child's own hash still matches.
|
|
600
|
+
// The prefix contract already treats "this call sits after a miss" as
|
|
601
|
+
// "must run live" for calls within one frame; a nested workflow() is
|
|
602
|
+
// no exception; once anything upstream in the parent has missed, cut
|
|
603
|
+
// the child off from the journal entirely so it runs fully live.
|
|
604
|
+
const prefixIntact = state.firstMiss === Number.POSITIVE_INFINITY;
|
|
605
|
+
const child = await runWorkflow(childScript, {
|
|
606
|
+
...options,
|
|
607
|
+
args: childArgs,
|
|
608
|
+
sharedRuntime: shared,
|
|
609
|
+
// Propagate the parent's store so nested agents share the same key-value space.
|
|
610
|
+
sharedStore: store,
|
|
611
|
+
resumeJournal: prefixIntact ? options.resumeJournal : undefined,
|
|
612
|
+
resumeFromRunId: undefined,
|
|
613
|
+
// shared.nestedCallSeq, not shared.depth — see its doc comment: depth
|
|
614
|
+
// returns to 0 between sequential sibling calls, which would otherwise
|
|
615
|
+
// mint the same child runId (and hence colliding deltaKeys/event ids)
|
|
616
|
+
// for two different children.
|
|
617
|
+
runId: `${runId}-nested${++shared.nestedCallSeq}`,
|
|
618
|
+
persistLogs: false,
|
|
619
|
+
});
|
|
620
|
+
return child.result;
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
shared.depth--;
|
|
624
|
+
options.onRuntimeEvent?.({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
// ── Quality-pattern stdlib: reusable, deterministic helpers built purely on
|
|
628
|
+
// agent()/parallel() (so callSeq ordering stays stable and resume keeps working).
|
|
629
|
+
// Injected as globals so workflow scripts compose them directly. ──
|
|
630
|
+
const VERIFY_SCHEMA = {
|
|
631
|
+
type: "object",
|
|
632
|
+
properties: { real: { type: "boolean" }, reason: { type: "string" } },
|
|
633
|
+
required: ["real"],
|
|
634
|
+
};
|
|
635
|
+
const verify = async (item, opts = {}) => {
|
|
636
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "verify" });
|
|
637
|
+
const reviewers = Math.max(1, opts.reviewers ?? 2);
|
|
638
|
+
const threshold = opts.threshold ?? 0.5;
|
|
639
|
+
const lenses = opts.lens ? (Array.isArray(opts.lens) ? opts.lens : [opts.lens]) : [];
|
|
640
|
+
const claim = typeof item === "string" ? item : JSON.stringify(item);
|
|
641
|
+
const votes = (await parallel(Array.from({ length: reviewers }, (_v, i) => () => agent(`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}`, { label: `verify ${i + 1}`, schema: VERIFY_SCHEMA })))).filter(Boolean);
|
|
642
|
+
const realCount = votes.filter((v) => v?.real).length;
|
|
643
|
+
const verdict = {
|
|
644
|
+
real: votes.length > 0 && realCount / votes.length >= threshold,
|
|
645
|
+
realCount,
|
|
646
|
+
total: votes.length,
|
|
647
|
+
votes,
|
|
648
|
+
};
|
|
649
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "verify" });
|
|
650
|
+
return verdict;
|
|
651
|
+
};
|
|
652
|
+
const JUDGE_SCHEMA = {
|
|
653
|
+
type: "object",
|
|
654
|
+
properties: { score: { type: "number" }, reason: { type: "string" } },
|
|
655
|
+
required: ["score"],
|
|
656
|
+
};
|
|
657
|
+
const judgePanel = async (attempts, opts = {}) => {
|
|
658
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "judgePanel" });
|
|
659
|
+
const judges = Math.max(1, opts.judges ?? 3);
|
|
660
|
+
const rubric = opts.rubric ?? "overall quality and correctness";
|
|
661
|
+
const scored = (await parallel((Array.isArray(attempts) ? attempts : []).map((att, idx) => async () => {
|
|
662
|
+
const text = typeof att === "string" ? att : JSON.stringify(att);
|
|
663
|
+
const js = (await parallel(Array.from({ length: judges }, (_v, j) => () => agent(`Score this candidate from 0 to 1 on: ${rubric}. Reply with the score.\n\nCandidate:\n${text}`, {
|
|
664
|
+
label: `judge ${idx + 1}.${j + 1}`,
|
|
665
|
+
schema: JUDGE_SCHEMA,
|
|
666
|
+
})))).filter(Boolean);
|
|
667
|
+
const score = js.length ? js.reduce((s, v) => s + (Number(v?.score) || 0), 0) / js.length : 0;
|
|
668
|
+
return { index: idx, attempt: att, score, judgments: js };
|
|
669
|
+
}))).filter(Boolean);
|
|
670
|
+
// Highest mean score; stable tie-break by input index.
|
|
671
|
+
let best = scored[0];
|
|
672
|
+
for (const s of scored)
|
|
673
|
+
if (s.score > best.score || (s.score === best.score && s.index < best.index))
|
|
674
|
+
best = s;
|
|
675
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "judgePanel" });
|
|
676
|
+
return best;
|
|
677
|
+
};
|
|
678
|
+
const loopUntilDry = async (opts) => {
|
|
679
|
+
if (!opts || typeof opts.round !== "function")
|
|
680
|
+
throw new TypeError("loopUntilDry requires { round: (i) => items[] }");
|
|
681
|
+
const key = opts.key ?? ((x) => JSON.stringify(x));
|
|
682
|
+
const consecutiveEmpty = Math.max(1, opts.consecutiveEmpty ?? 2);
|
|
683
|
+
const maxRounds = opts.maxRounds ?? 50;
|
|
684
|
+
const seen = new Set();
|
|
685
|
+
const all = [];
|
|
686
|
+
let dry = 0;
|
|
687
|
+
for (let r = 0; r < maxRounds && dry < consecutiveEmpty; r++) {
|
|
688
|
+
let items;
|
|
689
|
+
try {
|
|
690
|
+
items = (await opts.round(r)) ?? [];
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
// Budget / agent-limit exhaustion: return the partial result, don't abort.
|
|
694
|
+
const code = error?.code;
|
|
695
|
+
if (code === WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED || code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED)
|
|
696
|
+
break;
|
|
697
|
+
throw error;
|
|
698
|
+
}
|
|
699
|
+
const fresh = (Array.isArray(items) ? items : []).filter((x) => x != null && !seen.has(key(x)));
|
|
700
|
+
if (!fresh.length) {
|
|
701
|
+
dry++;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
dry = 0;
|
|
705
|
+
for (const x of fresh) {
|
|
706
|
+
seen.add(key(x));
|
|
707
|
+
all.push(x);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return all;
|
|
711
|
+
};
|
|
712
|
+
const COMPLETENESS_SCHEMA = {
|
|
713
|
+
type: "object",
|
|
714
|
+
properties: { complete: { type: "boolean" }, missing: { type: "array", items: { type: "string" } } },
|
|
715
|
+
required: ["complete"],
|
|
716
|
+
};
|
|
717
|
+
const completenessCheck = async (taskArgs, results) => {
|
|
718
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "completenessCheck" });
|
|
719
|
+
const verdict = await agent(`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)}`, { label: "completeness critic", schema: COMPLETENESS_SCHEMA });
|
|
720
|
+
options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "completenessCheck" });
|
|
721
|
+
return verdict;
|
|
722
|
+
};
|
|
723
|
+
// Thin bounded-retry / validation-gate combinators. Sugar over the for-loop +
|
|
724
|
+
// agent() pattern, but each attempt is a real agent() call so it auto-journals
|
|
725
|
+
// under a stable callSeq (resume-safe). No backoff: there is no timer in the vm
|
|
726
|
+
// and a delay has no resume value. NOTE: attempt N+1's call hash depends on N's
|
|
727
|
+
// live result, so a retry/gate chain cache-miss-cascades on resume (correct).
|
|
728
|
+
const retry = async (thunk, opts = {}) => {
|
|
729
|
+
const attempts = Math.max(1, opts.attempts ?? 3);
|
|
730
|
+
let last;
|
|
731
|
+
for (let i = 0; i < attempts; i++) {
|
|
732
|
+
last = await thunk(i);
|
|
733
|
+
const accepted = !opts.until || opts.until(last);
|
|
734
|
+
options.onRuntimeEvent?.({ type: "control-attempt", helper: "retry", attempt: i + 1, accepted });
|
|
735
|
+
if (accepted)
|
|
736
|
+
return last;
|
|
737
|
+
}
|
|
738
|
+
return last; // attempts exhausted — return the last result (caller inspects it)
|
|
739
|
+
};
|
|
740
|
+
const gate = async (thunk, validator, opts = {}) => {
|
|
741
|
+
const attempts = Math.max(1, opts.attempts ?? 3);
|
|
742
|
+
let feedback;
|
|
743
|
+
let last;
|
|
744
|
+
for (let i = 0; i < attempts; i++) {
|
|
745
|
+
last = await thunk(feedback, i);
|
|
746
|
+
const verdict = await validator(last);
|
|
747
|
+
const accepted = Boolean(verdict?.ok);
|
|
748
|
+
options.onRuntimeEvent?.({ type: "control-attempt", helper: "gate", attempt: i + 1, accepted });
|
|
749
|
+
if (accepted)
|
|
750
|
+
return { ok: true, value: last, attempts: i + 1 };
|
|
751
|
+
feedback = verdict?.feedback; // fed into the next attempt
|
|
752
|
+
}
|
|
753
|
+
return { ok: false, value: last, attempts };
|
|
754
|
+
};
|
|
755
|
+
// Deterministic, journaled, replayable human checkpoint. Spends no tokens, so it
|
|
756
|
+
// is gated on the agent counter + abort (not budget). On resume the human's reply
|
|
757
|
+
// replays by callIndex exactly like a cached agent() — the genuine edge over CC,
|
|
758
|
+
// whose steering is in-session only. Headless (no UI threaded in): takes the
|
|
759
|
+
// declared default and journals THAT, so a detached/background run never hangs.
|
|
760
|
+
const checkpoint = async (promptText, checkpointOptions = {}) => {
|
|
761
|
+
throwIfAborted();
|
|
762
|
+
if (typeof promptText !== "string")
|
|
763
|
+
throw new TypeError("checkpoint(promptText, options?) needs a prompt string");
|
|
764
|
+
if (shared.agentCount >= maxAgents) {
|
|
765
|
+
throw agentLimitError();
|
|
766
|
+
}
|
|
767
|
+
const callIndex = state.callSeq++;
|
|
768
|
+
const callHash = hashCheckpoint(promptText, checkpointOptions);
|
|
769
|
+
// Namespaced by runId like agent()'s deltaKey — see JournalEntry.runId.
|
|
770
|
+
const journalKey = `${runId}:${callIndex}`;
|
|
771
|
+
const cached = options.resumeJournal?.get(journalKey);
|
|
772
|
+
if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
|
|
773
|
+
shared.agentCount++;
|
|
774
|
+
return cached.result; // replay the journaled human reply
|
|
775
|
+
}
|
|
776
|
+
if (cached == null || cached.hash !== callHash)
|
|
777
|
+
state.firstMiss = Math.min(state.firstMiss, callIndex);
|
|
778
|
+
shared.agentCount++;
|
|
779
|
+
let reply;
|
|
780
|
+
if (options.confirm) {
|
|
781
|
+
reply = await options.confirm(promptText, checkpointOptions);
|
|
782
|
+
}
|
|
783
|
+
else if (checkpointOptions.headless === "abort") {
|
|
784
|
+
throw new WorkflowError(`checkpoint "${promptText}" needs human input but none is available (headless run)`, WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: false });
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
reply = checkpointOptions.default ?? true;
|
|
788
|
+
}
|
|
789
|
+
throwIfAborted();
|
|
790
|
+
options.onAgentJournal?.({ index: callIndex, runId, hash: callHash, result: reply });
|
|
791
|
+
return reply;
|
|
792
|
+
};
|
|
793
|
+
const runtimeImplementations = {
|
|
794
|
+
agent,
|
|
795
|
+
parallel,
|
|
796
|
+
pipeline,
|
|
797
|
+
workflow: workflowFn,
|
|
798
|
+
verify,
|
|
799
|
+
judgePanel,
|
|
800
|
+
loopUntilDry,
|
|
801
|
+
completenessCheck,
|
|
802
|
+
retry,
|
|
803
|
+
gate,
|
|
804
|
+
checkpoint,
|
|
805
|
+
log,
|
|
806
|
+
phase,
|
|
807
|
+
args: options.args,
|
|
808
|
+
cwd: options.cwd ?? process.cwd(),
|
|
809
|
+
process: Object.freeze({ cwd: () => options.cwd ?? process.cwd() }),
|
|
810
|
+
budget,
|
|
811
|
+
console: {
|
|
812
|
+
log,
|
|
813
|
+
info: log,
|
|
814
|
+
warn: (m) => log(`[warn] ${String(m)}`),
|
|
815
|
+
error: (m) => log(`[error] ${String(m)}`),
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
const { globals: projectGlobals, diagnostics: bindingDiagnostics } = WORKFLOW_CAPABILITY_CONTRACT.assembleRuntimeBindings(runtimeImplementations);
|
|
819
|
+
for (const diagnostic of bindingDiagnostics)
|
|
820
|
+
logger.warn(diagnostic.message);
|
|
821
|
+
const context = vm.createContext({
|
|
822
|
+
...projectGlobals,
|
|
823
|
+
// Object/Array/JSON/Math/Date/Promise/Set/Map/etc. come from the vm realm
|
|
824
|
+
// itself — we deliberately do NOT inject host built-ins, whose .constructor
|
|
825
|
+
// would be the host Function (a determinism-guard bypass). Math/Date are
|
|
826
|
+
// neutered in-realm by DETERMINISM_PRELUDE below.
|
|
827
|
+
});
|
|
828
|
+
const wrapped = `${DETERMINISM_PRELUDE}\n(async () => {\n${body}\n})()`;
|
|
829
|
+
try {
|
|
830
|
+
const result = await new vm.Script(wrapped, { filename: `${meta.name || "workflow"}.js` }).runInContext(context);
|
|
831
|
+
// Persist logs
|
|
832
|
+
const logFile = logger.persist();
|
|
833
|
+
if (logFile) {
|
|
834
|
+
log(`Logs persisted to ${logFile}`);
|
|
835
|
+
}
|
|
836
|
+
// Emit final token usage
|
|
837
|
+
options.onTokenUsage?.(shared.tokenUsage);
|
|
838
|
+
return {
|
|
839
|
+
meta,
|
|
840
|
+
result: result,
|
|
841
|
+
logs: state.logs,
|
|
842
|
+
phases: state.phases,
|
|
843
|
+
agentCount: shared.agentCount,
|
|
844
|
+
durationMs: Date.now() - started,
|
|
845
|
+
runId,
|
|
846
|
+
tokenUsage: shared.tokenUsage,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
// This error just escaped THIS frame's own vm script execution completely
|
|
851
|
+
// uncaught. For the top-level frame that means nothing anywhere in the
|
|
852
|
+
// whole call chain (this script, any enclosing try/catch around a nested
|
|
853
|
+
// workflow()/parallel()/agent()) caught it — the run's fate is genuinely
|
|
854
|
+
// sealed now (see SharedRuntime.runFatalController). Sealing it here, not
|
|
855
|
+
// inside agent()/parallel(), is what preserves parallel()'s "a thrown
|
|
856
|
+
// thunk resolves to null without failing the others" contract and a
|
|
857
|
+
// script's own try/catch around agent()/workflow(): both those cases are
|
|
858
|
+
// swallowed well before an error would ever reach this catch. A NESTED
|
|
859
|
+
// frame reaching here does NOT seal anything — the parent script may still
|
|
860
|
+
// catch workflow()'s rejection and continue, so only isTopLevelRun acts.
|
|
861
|
+
// Idempotent: if this is already an intentional pause/stop (options.signal
|
|
862
|
+
// aborted) or a second escape after the fatal signal already fired,
|
|
863
|
+
// aborting an already-aborted controller is a no-op.
|
|
864
|
+
//
|
|
865
|
+
// This also fires on a PROVIDER_USAGE_LIMIT escape (a quota/rate-limit
|
|
866
|
+
// hit), not just a genuine bug — that error is non-recoverable too (see
|
|
867
|
+
// errors.ts), so it escapes exactly like any other run-fatal error and
|
|
868
|
+
// seals the same way. Deliberate tradeoff: any sibling still in flight
|
|
869
|
+
// when the quota was hit gets aborted rather than allowed to finish and
|
|
870
|
+
// journal — this stops burning an already-exhausted budget right now, at
|
|
871
|
+
// the cost of that sibling's work being thrown away and re-run live when
|
|
872
|
+
// the paused run resumes (it was never journaled, so it isn't cached).
|
|
873
|
+
if (isTopLevelRun)
|
|
874
|
+
shared.runFatalController.abort();
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
877
|
+
finally {
|
|
878
|
+
// Only the top-level frame drains/disposes (see isTopLevelRun) — a nested
|
|
879
|
+
// workflow()'s in-flight agents are still tracked in this SAME shared set
|
|
880
|
+
// and get drained once, here, when the whole run finishes.
|
|
881
|
+
if (isTopLevelRun) {
|
|
882
|
+
// Wait out every agent() call spawned anywhere in this run — including
|
|
883
|
+
// ones the script never awaited — before the store goes away. Without
|
|
884
|
+
// this, a forgotten `await agent(...)` could keep mutating store/journal
|
|
885
|
+
// state after the run is marked complete/failed and torn down. Loop
|
|
886
|
+
// (not a single Promise.allSettled) because draining can itself let a
|
|
887
|
+
// still-running call schedule further work that adds to the set.
|
|
888
|
+
//
|
|
889
|
+
// Caveat: this can block indefinitely. A run-fatal abort (see the catch
|
|
890
|
+
// above) aborts the AbortSignal passed to each in-flight agent, but that
|
|
891
|
+
// is cooperative — an agent runner that ignores its signal (or one still
|
|
892
|
+
// waiting out a real subagent process that won't die) never settles on
|
|
893
|
+
// its own. Combined with agentTimeoutMs: null (no hard timeout, the
|
|
894
|
+
// default), a single hung, signal-ignoring, un-awaited agent() call can
|
|
895
|
+
// wedge this drain — and therefore the whole run's completion — forever.
|
|
896
|
+
// Configure a finite agentTimeoutMs (run- or per-agent-level) for any
|
|
897
|
+
// workflow where this is a real risk; there is no drain-side timeout.
|
|
898
|
+
if (shared.inFlight.size > 0) {
|
|
899
|
+
log(`waiting for ${shared.inFlight.size} outstanding agent() call(s) to settle before this run completes`);
|
|
900
|
+
}
|
|
901
|
+
while (shared.inFlight.size > 0) {
|
|
902
|
+
await Promise.allSettled(Array.from(shared.inFlight));
|
|
903
|
+
}
|
|
904
|
+
store.dispose();
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
export function parseWorkflowScript(script) {
|
|
909
|
+
if (DETERMINISM_BLOCKLIST.test(script)) {
|
|
910
|
+
throw new WorkflowError("Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false });
|
|
911
|
+
}
|
|
912
|
+
const ast = parse(script, {
|
|
913
|
+
ecmaVersion: "latest",
|
|
914
|
+
sourceType: "module",
|
|
915
|
+
allowAwaitOutsideFunction: true,
|
|
916
|
+
allowReturnOutsideFunction: true,
|
|
917
|
+
ranges: false,
|
|
918
|
+
});
|
|
919
|
+
const first = ast.body?.[0];
|
|
920
|
+
if (first?.type !== "ExportNamedDeclaration") {
|
|
921
|
+
throw new WorkflowError("`export const meta = { name, description, phases }` must be the first statement in the script", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false });
|
|
922
|
+
}
|
|
923
|
+
const declaration = first.declaration;
|
|
924
|
+
if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") {
|
|
925
|
+
throw new WorkflowError("meta export must be `export const meta = ...`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
926
|
+
recoverable: false,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
if (declaration.declarations.length !== 1) {
|
|
930
|
+
throw new WorkflowError("meta export must declare only `meta`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
931
|
+
recoverable: false,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
const declarator = declaration.declarations[0];
|
|
935
|
+
if (declarator.id?.type !== "Identifier" || declarator.id.name !== "meta") {
|
|
936
|
+
throw new WorkflowError("meta export must declare `meta`", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
937
|
+
recoverable: false,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
if (!declarator.init)
|
|
941
|
+
throw new WorkflowError("meta must have a literal value", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
|
|
942
|
+
recoverable: false,
|
|
943
|
+
});
|
|
944
|
+
const meta = evaluateLiteral(declarator.init, "meta");
|
|
945
|
+
validateMeta(meta);
|
|
946
|
+
return {
|
|
947
|
+
meta,
|
|
948
|
+
body: script.slice(0, first.start) + script.slice(first.end),
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
function evaluateLiteral(node, path) {
|
|
952
|
+
switch (node.type) {
|
|
953
|
+
case "ObjectExpression": {
|
|
954
|
+
const out = {};
|
|
955
|
+
for (const prop of node.properties) {
|
|
956
|
+
if (prop.type === "SpreadElement")
|
|
957
|
+
throw new Error(`spread not allowed in ${path}`);
|
|
958
|
+
if (prop.type !== "Property")
|
|
959
|
+
throw new Error(`only plain properties allowed in ${path}`);
|
|
960
|
+
if (prop.computed)
|
|
961
|
+
throw new Error(`computed keys not allowed in ${path}`);
|
|
962
|
+
if (prop.kind !== "init" || prop.method)
|
|
963
|
+
throw new Error(`methods/accessors not allowed in ${path}`);
|
|
964
|
+
const key = propertyKey(prop.key, path);
|
|
965
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
966
|
+
throw new Error(`reserved key name not allowed in ${path}: ${key}`);
|
|
967
|
+
}
|
|
968
|
+
out[key] = evaluateLiteral(prop.value, `${path}.${key}`);
|
|
969
|
+
}
|
|
970
|
+
return out;
|
|
971
|
+
}
|
|
972
|
+
case "ArrayExpression":
|
|
973
|
+
return node.elements.map((element, index) => {
|
|
974
|
+
if (!element)
|
|
975
|
+
throw new Error(`sparse arrays not allowed in ${path}`);
|
|
976
|
+
if (element.type === "SpreadElement")
|
|
977
|
+
throw new Error(`spread not allowed in ${path}`);
|
|
978
|
+
return evaluateLiteral(element, `${path}[${index}]`);
|
|
979
|
+
});
|
|
980
|
+
case "Literal":
|
|
981
|
+
return node.value;
|
|
982
|
+
case "TemplateLiteral":
|
|
983
|
+
if (node.expressions.length > 0)
|
|
984
|
+
throw new Error(`template interpolation not allowed in ${path}`);
|
|
985
|
+
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join("");
|
|
986
|
+
case "UnaryExpression":
|
|
987
|
+
if (node.operator === "-" && node.argument?.type === "Literal" && typeof node.argument.value === "number") {
|
|
988
|
+
return -node.argument.value;
|
|
989
|
+
}
|
|
990
|
+
throw new Error(`only negative-number unary allowed in ${path}`);
|
|
991
|
+
default:
|
|
992
|
+
throw new Error(`non-literal node type in ${path}: ${node.type}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
function propertyKey(node, path) {
|
|
996
|
+
if (node.type === "Identifier")
|
|
997
|
+
return node.name;
|
|
998
|
+
if (node.type === "Literal" && (typeof node.value === "string" || typeof node.value === "number"))
|
|
999
|
+
return String(node.value);
|
|
1000
|
+
throw new Error(`unsupported key type in ${path}: ${node.type}`);
|
|
1001
|
+
}
|
|
1002
|
+
function validateMeta(meta) {
|
|
1003
|
+
if (!meta || typeof meta !== "object")
|
|
1004
|
+
throw new Error("meta must be an object");
|
|
1005
|
+
const value = meta;
|
|
1006
|
+
if (typeof value.name !== "string" || !value.name.trim())
|
|
1007
|
+
throw new Error("meta.name must be a non-empty string");
|
|
1008
|
+
if (typeof value.description !== "string" || !value.description.trim())
|
|
1009
|
+
throw new Error("meta.description must be a non-empty string");
|
|
1010
|
+
if (value.model !== undefined && typeof value.model !== "string")
|
|
1011
|
+
throw new Error("meta.model must be a string");
|
|
1012
|
+
if (value.phases !== undefined) {
|
|
1013
|
+
if (!Array.isArray(value.phases))
|
|
1014
|
+
throw new Error("meta.phases must be an array");
|
|
1015
|
+
for (const phase of value.phases) {
|
|
1016
|
+
if (!phase || typeof phase !== "object" || typeof phase.title !== "string") {
|
|
1017
|
+
throw new Error("each meta phase must have a title string");
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
function createLimiter(limit) {
|
|
1023
|
+
let active = 0;
|
|
1024
|
+
const queue = [];
|
|
1025
|
+
const next = () => {
|
|
1026
|
+
active--;
|
|
1027
|
+
queue.shift()?.();
|
|
1028
|
+
};
|
|
1029
|
+
return async (fn) => {
|
|
1030
|
+
if (active >= limit)
|
|
1031
|
+
await new Promise((resolve) => queue.push(resolve));
|
|
1032
|
+
active++;
|
|
1033
|
+
try {
|
|
1034
|
+
return await fn();
|
|
1035
|
+
}
|
|
1036
|
+
finally {
|
|
1037
|
+
next();
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
function defaultAgentLabel(phase, index) {
|
|
1042
|
+
return phase ? `${phase} agent ${index}` : `agent ${index}`;
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Stable identity hash for a checkpoint() call — a cache miss on resume when
|
|
1046
|
+
* anything that could change its outcome changes. Must cover every
|
|
1047
|
+
* CheckpointOptions field that participates in the outcome, not just
|
|
1048
|
+
* promptText/kind/choices:
|
|
1049
|
+
* - `default` and `headless` decide the reply in the headless (no `confirm`
|
|
1050
|
+
* threaded in) path — a script edited to change either must not resume
|
|
1051
|
+
* with the OLD default/behavior's stale journaled reply.
|
|
1052
|
+
* - `timeoutMs` bounds the interactive prompt; a host `confirm` may itself
|
|
1053
|
+
* fall back to `default` when the human doesn't answer in time, so it can
|
|
1054
|
+
* also affect the outcome and is included for the same reason.
|
|
1055
|
+
* NOTE: widening this hash is a one-time invalidation of any checkpoint
|
|
1056
|
+
* answers already persisted under the old (narrower) hash — on the first
|
|
1057
|
+
* resume after upgrading, those checkpoints will cache-miss and re-prompt (or
|
|
1058
|
+
* re-apply the default) once, live. That's intentional: a silently-stale
|
|
1059
|
+
* cached decision from before the identity surface was fixed is worse than a
|
|
1060
|
+
* one-time re-ask.
|
|
1061
|
+
*/
|
|
1062
|
+
function hashCheckpoint(promptText, options) {
|
|
1063
|
+
const identity = JSON.stringify({
|
|
1064
|
+
promptText,
|
|
1065
|
+
kind: options.kind ?? "confirm",
|
|
1066
|
+
choices: options.choices ?? null,
|
|
1067
|
+
default: options.default ?? null,
|
|
1068
|
+
headless: options.headless ?? "default",
|
|
1069
|
+
timeoutMs: options.timeoutMs ?? null,
|
|
1070
|
+
});
|
|
1071
|
+
return createHash("sha256").update(identity).digest("hex");
|
|
1072
|
+
}
|
|
1073
|
+
function hashAgentCall(prompt, model, phase, options, agentDefKey) {
|
|
1074
|
+
const identity = JSON.stringify({
|
|
1075
|
+
prompt,
|
|
1076
|
+
model: model ?? null,
|
|
1077
|
+
tier: options.tier ?? null,
|
|
1078
|
+
phase: phase ?? null,
|
|
1079
|
+
agentType: options.agentType ?? null,
|
|
1080
|
+
// Resolved definition (tools/model/prompt) so editing an agent .md invalidates
|
|
1081
|
+
// this call's cached result on a later resume.
|
|
1082
|
+
agentDef: agentDefKey,
|
|
1083
|
+
schema: options.schema ?? null,
|
|
1084
|
+
});
|
|
1085
|
+
return createHash("sha256").update(identity).digest("hex");
|
|
1086
|
+
}
|
|
1087
|
+
function buildAgentInstructions(phase, options, def, resolvedIsolation) {
|
|
1088
|
+
const lines = [];
|
|
1089
|
+
// A resolved agentType binds a real role prompt (the definition body). Only
|
|
1090
|
+
// fall back to the prose hint when the agentType named no known definition.
|
|
1091
|
+
if (def?.prompt)
|
|
1092
|
+
lines.push(def.prompt);
|
|
1093
|
+
else if (options.agentType)
|
|
1094
|
+
lines.push(`Act as workflow subagent type: ${options.agentType}`);
|
|
1095
|
+
if (phase)
|
|
1096
|
+
lines.push(`Workflow phase: ${phase}`);
|
|
1097
|
+
// Use resolvedIsolation so the annotation fires whether isolation came from
|
|
1098
|
+
// the call site or from the agentDef's isolation field.
|
|
1099
|
+
if (resolvedIsolation)
|
|
1100
|
+
lines.push(`Requested isolation: ${resolvedIsolation}`);
|
|
1101
|
+
// Note: options.model is applied for real via the session, not injected as prose.
|
|
1102
|
+
return lines.length ? lines.join("\n\n") : undefined;
|
|
1103
|
+
}
|
|
1104
|
+
function isEmptyTextAgentResult(result, schema) {
|
|
1105
|
+
return schema === undefined && typeof result === "string" && result.trim().length === 0;
|
|
1106
|
+
}
|
|
1107
|
+
function estimateTokens(value) {
|
|
1108
|
+
return Math.ceil(JSON.stringify(value ?? "").length / 4);
|
|
1109
|
+
}
|
|
1110
|
+
function normalizeConcurrency(value) {
|
|
1111
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1)
|
|
1112
|
+
return 1;
|
|
1113
|
+
return Math.min(MAX_CONCURRENCY, Math.floor(value));
|
|
1114
|
+
}
|
|
1115
|
+
function normalizeAgentRetries(value) {
|
|
1116
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
|
1117
|
+
return 0;
|
|
1118
|
+
return Math.min(MAX_AGENT_RETRIES, Math.floor(value));
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Run a promise with a timeout.
|
|
1122
|
+
*
|
|
1123
|
+
* `onTimeout` fires when the deadline hits, BEFORE the timeout rejection wins the
|
|
1124
|
+
* race — the caller uses it to abort the underlying work (e.g. the subagent
|
|
1125
|
+
* session) so it can release its resources instead of streaming on in the
|
|
1126
|
+
* background with the whole session graph (messages, etc.) retained (#109). The
|
|
1127
|
+
* losing promise still settles later; the caller must swallow its rejection.
|
|
1128
|
+
*/
|
|
1129
|
+
async function withTimeout(promise, ms, label, onTimeout) {
|
|
1130
|
+
if (ms === null)
|
|
1131
|
+
return promise;
|
|
1132
|
+
let timeoutId;
|
|
1133
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1134
|
+
timeoutId = setTimeout(() => {
|
|
1135
|
+
try {
|
|
1136
|
+
onTimeout?.();
|
|
1137
|
+
}
|
|
1138
|
+
catch {
|
|
1139
|
+
// Best-effort cleanup; never let it mask the timeout error.
|
|
1140
|
+
}
|
|
1141
|
+
reject(new WorkflowError(`Agent "${label}" timed out after ${ms}ms; raise or omit timeoutMs/agentTimeoutMs to allow longer runs`, WorkflowErrorCode.AGENT_TIMEOUT, { recoverable: true }));
|
|
1142
|
+
}, ms);
|
|
1143
|
+
});
|
|
1144
|
+
try {
|
|
1145
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
1146
|
+
}
|
|
1147
|
+
finally {
|
|
1148
|
+
if (timeoutId)
|
|
1149
|
+
clearTimeout(timeoutId);
|
|
1150
|
+
}
|
|
1151
|
+
}
|