@herbertgao/pi-subagents 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +427 -120
- package/docs/rpc.md +184 -0
- package/docs/workflows.md +466 -0
- package/examples/agent-tool-description.md +6 -6
- package/examples/workflows/compose.js +52 -0
- package/examples/workflows/fan-out-audit.js +56 -0
- package/examples/workflows/gated-fix.js +60 -0
- package/examples/workflows/lib/count-child.js +30 -0
- package/examples/workflows/review-panel.js +68 -0
- package/examples/workflows/structured-findings.js +81 -0
- package/package.json +12 -9
- package/src/agent-file-toggle.ts +52 -12
- package/src/agent-manager.ts +837 -146
- package/src/agent-runner.ts +213 -39
- package/src/cross-extension-rpc.ts +73 -14
- package/src/custom-agents.ts +101 -47
- package/src/index.ts +2249 -914
- package/src/invocation-config.ts +13 -0
- package/src/mention-clone.ts +215 -0
- package/src/mention.ts +147 -0
- package/src/model-resolver.ts +9 -1
- package/src/nested-tools.ts +40 -26
- package/src/output-file.ts +18 -8
- package/src/prompts.ts +46 -9
- package/src/schedule.ts +21 -16
- package/src/settings.ts +137 -7
- package/src/structured-output.ts +136 -0
- package/src/types.ts +126 -8
- package/src/ui/agent-mention.ts +274 -0
- package/src/ui/agent-widget.ts +20 -5
- package/src/ui/conversation-viewer.ts +14 -1
- package/src/ui/fleet-list.ts +167 -22
- package/src/ui/workflow-card.ts +555 -0
- package/src/ui/workflow-dialog.ts +1304 -0
- package/src/ui/workflow-menu.ts +226 -0
- package/src/workflow/collisions.ts +122 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +463 -0
- package/src/workflow/journal.ts +164 -0
- package/src/workflow/json-schema.ts +142 -0
- package/src/workflow/meta.ts +401 -0
- package/src/workflow/progress.ts +622 -0
- package/src/workflow/runtime.ts +1399 -0
- package/src/workflow/saved.ts +230 -0
- package/src/workflow/task.ts +333 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +781 -0
- package/src/worktree.ts +97 -95
- package/src/xml.ts +13 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worker-source.ts — the JavaScript that runs inside the workflow worker thread.
|
|
3
|
+
*
|
|
4
|
+
* The host spawns this with `new Worker(WORKER_SOURCE, { eval: true })`, so the
|
|
5
|
+
* source has to be an inlined string: `AGENTS.md` forbids dynamic `import()`,
|
|
6
|
+
* and a file path would have to survive bundling. Keeping it as a template
|
|
7
|
+
* literal costs editor tooling but nothing else — the worker is plain CommonJS
|
|
8
|
+
* JavaScript and never sees the TypeScript pipeline.
|
|
9
|
+
*
|
|
10
|
+
* Two boundaries stack here, and they are not the same boundary:
|
|
11
|
+
*
|
|
12
|
+
* host thread ←postMessage→ worker thread ←vm context→ workflow script
|
|
13
|
+
*
|
|
14
|
+
* The worker/host split exists for *killability*: `worker.terminate()` stops a
|
|
15
|
+
* runaway script mid-loop, which an in-process `vm` timeout cannot do once the
|
|
16
|
+
* script is inside an `await`. The vm context exists for *determinism and
|
|
17
|
+
* accident-avoidance*, not security — see the note on `codeGeneration` below.
|
|
18
|
+
*
|
|
19
|
+
* ## Why the context gets no host built-ins
|
|
20
|
+
*
|
|
21
|
+
* `vm.createContext(sandbox)` gives the script a fresh realm that already owns
|
|
22
|
+
* `Object`, `Array`, `JSON`, `Math`, `Date`, `Promise`, `Map`, `Set`. We inject
|
|
23
|
+
* *only* our own globals on top. Injecting host built-ins instead would hand the
|
|
24
|
+
* script `Object.constructor` → the **host** `Function`, i.e. a compiler for
|
|
25
|
+
* arbitrary host-realm code.
|
|
26
|
+
*
|
|
27
|
+
* That said: our injected globals are themselves host closures, so
|
|
28
|
+
* `agent.constructor` is still the host `Function`. The hygiene shrinks the
|
|
29
|
+
* surface; it does not close the hole. **`codeGeneration: { strings: false }` is
|
|
30
|
+
* the load-bearing defense** — it makes `Function("…")` and `eval("…")` throw
|
|
31
|
+
* `EvalError`, so a captured host `Function` cannot compile anything. Treat this
|
|
32
|
+
* as a determinism boundary, not a security boundary against a hostile script.
|
|
33
|
+
*
|
|
34
|
+
* ## Why determinism is a prelude and not a stub
|
|
35
|
+
*
|
|
36
|
+
* Because `Date` and `Math` come *from the realm*, they cannot be neutered by
|
|
37
|
+
* injection — there is nothing to inject over. So the compiled source is
|
|
38
|
+
* prefixed with a prelude that runs inside the realm and reassigns `Date.now`
|
|
39
|
+
* and `Math.random` in place, then lexically shadows `Date` with a subclass
|
|
40
|
+
* whose zero-argument constructor throws. Lexical shadowing rather than a global
|
|
41
|
+
* assignment because a `const` in the IIFE scope cannot be reached around.
|
|
42
|
+
*
|
|
43
|
+
* Determinism is enforced because a workflow's journal is replayed by prefix on
|
|
44
|
+
* resume: a script that reads the clock produces a different prefix on the
|
|
45
|
+
* second run and the replay silently diverges.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Runs inside the realm, ahead of the script body, on a single line.
|
|
50
|
+
*
|
|
51
|
+
* One line matters: the body is compiled at `\n` + line 1, and the host passes
|
|
52
|
+
* `lineOffset: -1` so reported line numbers match the file the author wrote. Any
|
|
53
|
+
* newline in here shifts every stack frame in every workflow script.
|
|
54
|
+
*/
|
|
55
|
+
const DETERMINISM_PRELUDE =
|
|
56
|
+
"const Date = (function () {" +
|
|
57
|
+
" const RealDate = globalThis.Date;" +
|
|
58
|
+
" const die = function (what) {" +
|
|
59
|
+
' throw new Error(what + " is unavailable in workflow scripts (breaks resume).' +
|
|
60
|
+
' Stamp results after the workflow returns, or pass timestamps via `args`.");' +
|
|
61
|
+
" };" +
|
|
62
|
+
' RealDate.now = function () { return die("Date.now()"); };' +
|
|
63
|
+
' Math.random = function () { return die("Math.random()"); };' +
|
|
64
|
+
" return class WorkflowDate extends RealDate {" +
|
|
65
|
+
' constructor() { if (arguments.length === 0) die("new Date()"); super(...arguments); }' +
|
|
66
|
+
" };" +
|
|
67
|
+
"})();"
|
|
68
|
+
|
|
69
|
+
export const WORKER_SOURCE = `"use strict";
|
|
70
|
+
|
|
71
|
+
const { parentPort, workerData } = require("node:worker_threads");
|
|
72
|
+
const vm = require("node:vm");
|
|
73
|
+
|
|
74
|
+
const port = parentPort;
|
|
75
|
+
const ITEM_CAP = workerData.itemCap;
|
|
76
|
+
const PRELUDE = ${JSON.stringify(DETERMINISM_PRELUDE)};
|
|
77
|
+
|
|
78
|
+
/* ------------------------------------------------------------------ *
|
|
79
|
+
* RPC to the host
|
|
80
|
+
*
|
|
81
|
+
* The script never touches the agent manager. Every effect leaves as a
|
|
82
|
+
* "call" message and comes back as a "response", so the host owns the
|
|
83
|
+
* semaphore, the caps, and the abort story.
|
|
84
|
+
* ------------------------------------------------------------------ */
|
|
85
|
+
|
|
86
|
+
let nextCallId = 1;
|
|
87
|
+
const pendingCalls = new Map();
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Output tokens this run has spent, as last reported by the host.
|
|
91
|
+
*
|
|
92
|
+
* A mirror, not a tally: every response carries the host's current total, so
|
|
93
|
+
* there is exactly one counter and it cannot drift. Between responses it cannot
|
|
94
|
+
* be stale in any way the script could observe — tokens only accrue through
|
|
95
|
+
* agents, and an agent's response is the only thing the script waits on.
|
|
96
|
+
*/
|
|
97
|
+
let spentOutput = 0;
|
|
98
|
+
|
|
99
|
+
function callHost(method, payload) {
|
|
100
|
+
// Drain first, so the phase() that named this agent reaches the host ahead of
|
|
101
|
+
// the agent entry rather than a tick behind it.
|
|
102
|
+
flushProgress();
|
|
103
|
+
return new Promise(function (resolve, reject) {
|
|
104
|
+
const callId = nextCallId++;
|
|
105
|
+
pendingCalls.set(callId, { resolve: resolve, reject: reject });
|
|
106
|
+
port.postMessage({ type: "call", callId: callId, method: method, payload: payload });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
port.on("message", function (message) {
|
|
111
|
+
if (!message || message.type !== "response") return;
|
|
112
|
+
if (typeof message.spent === "number") spentOutput = message.spent;
|
|
113
|
+
const waiter = pendingCalls.get(message.callId);
|
|
114
|
+
if (!waiter) return;
|
|
115
|
+
pendingCalls.delete(message.callId);
|
|
116
|
+
if (message.ok) {
|
|
117
|
+
waiter.resolve(message.value);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const error = new Error(message.error || "The workflow host rejected the call.");
|
|
121
|
+
// Fatal errors are the run's, not the item's: parallel() and pipeline()
|
|
122
|
+
// swallow ordinary failures into null, and a cap breach must not be
|
|
123
|
+
// silently absorbed that way.
|
|
124
|
+
if (message.fatal) error.workflowFatal = true;
|
|
125
|
+
waiter.reject(error);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
function isFatal(error) {
|
|
129
|
+
return !!(error && typeof error === "object" && error.workflowFatal === true);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ------------------------------------------------------------------ *
|
|
133
|
+
* Progress entries
|
|
134
|
+
* ------------------------------------------------------------------ */
|
|
135
|
+
|
|
136
|
+
let progressQueue = [];
|
|
137
|
+
let flushTimer = null;
|
|
138
|
+
|
|
139
|
+
function emit(entry) {
|
|
140
|
+
progressQueue.push(entry);
|
|
141
|
+
// Batched on a macrotask: a fan-out emits a burst of phase/log entries in one
|
|
142
|
+
// turn, and the host renders once per batch rather than once per entry.
|
|
143
|
+
if (flushTimer === null) flushTimer = setTimeout(flushProgress, 0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function flushProgress() {
|
|
147
|
+
if (flushTimer !== null) {
|
|
148
|
+
clearTimeout(flushTimer);
|
|
149
|
+
flushTimer = null;
|
|
150
|
+
}
|
|
151
|
+
if (progressQueue.length === 0) return;
|
|
152
|
+
const batch = progressQueue;
|
|
153
|
+
progressQueue = [];
|
|
154
|
+
port.postMessage({ type: "progress", entries: batch });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ------------------------------------------------------------------ *
|
|
158
|
+
* The JSON boundary
|
|
159
|
+
*
|
|
160
|
+
* Checked here rather than relying on structured clone, which happily
|
|
161
|
+
* carries cycles, BigInt and Maps that the progress log and the resume
|
|
162
|
+
* journal cannot represent. Rejecting loudly beats writing a journal
|
|
163
|
+
* that will not replay.
|
|
164
|
+
* ------------------------------------------------------------------ */
|
|
165
|
+
|
|
166
|
+
let realmObjectPrototype = null;
|
|
167
|
+
/**
|
|
168
|
+
* The realm's own \`JSON.parse\`.
|
|
169
|
+
*
|
|
170
|
+
* Module-scope, not local to main(), because \`agent({ schema })\` parses its
|
|
171
|
+
* result here — outside main's closure — and the object has to carry the
|
|
172
|
+
* *script's* Object.prototype, not the worker's, or \`instanceof Object\` fails
|
|
173
|
+
* inside the script it was handed to.
|
|
174
|
+
*/
|
|
175
|
+
let realmParse = null;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The top-level script's scope.
|
|
179
|
+
*
|
|
180
|
+
* Module-scope because a nested \`workflow()\` needs the realm-native function
|
|
181
|
+
* compiler that \`main()\` builds, and because the compiled child function is
|
|
182
|
+
* cached per body — see {@link workflowIn}.
|
|
183
|
+
*/
|
|
184
|
+
let rootScope = null;
|
|
185
|
+
/**
|
|
186
|
+
* The vm context every script runs in.
|
|
187
|
+
*
|
|
188
|
+
* Held so a nested \`workflow()\` can compile its child there. Compiled from
|
|
189
|
+
* *outside* the realm, with \`vm.Script\`, because the context itself has
|
|
190
|
+
* \`codeGeneration.strings\` off — the script cannot build code, but the worker
|
|
191
|
+
* that owns it still can.
|
|
192
|
+
*/
|
|
193
|
+
let realmContext = null;
|
|
194
|
+
/** Nested invocations made so far, against \`workerData.nestedCap\`. */
|
|
195
|
+
let nestedCount = 0;
|
|
196
|
+
|
|
197
|
+
function boundaryError(what, path) {
|
|
198
|
+
return new Error(
|
|
199
|
+
"Cannot pass " + what + " across the workflow VM boundary (at " + path + ")."
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function assertBoundary(value, path, seen) {
|
|
204
|
+
if (value === null) return;
|
|
205
|
+
const kind = typeof value;
|
|
206
|
+
if (kind === "string" || kind === "boolean") return;
|
|
207
|
+
if (kind === "number") {
|
|
208
|
+
if (!Number.isFinite(value)) throw boundaryError("a non-finite number", path);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (kind === "undefined") {
|
|
212
|
+
if (path === "the workflow result") return;
|
|
213
|
+
throw boundaryError("undefined", path);
|
|
214
|
+
}
|
|
215
|
+
if (kind === "bigint") throw boundaryError("a BigInt", path);
|
|
216
|
+
if (kind === "symbol") throw boundaryError("a symbol", path);
|
|
217
|
+
if (kind === "function") throw boundaryError("a function", path);
|
|
218
|
+
if (kind !== "object") throw boundaryError("a " + kind, path);
|
|
219
|
+
|
|
220
|
+
if (seen.has(value)) throw boundaryError("a circular structure", path);
|
|
221
|
+
seen.add(value);
|
|
222
|
+
|
|
223
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
224
|
+
throw boundaryError("an object with symbol keys", path);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (Array.isArray(value)) {
|
|
228
|
+
const length = value.length;
|
|
229
|
+
for (let i = 0; i < length; i++) {
|
|
230
|
+
// A sparse array round-trips through JSON as nulls, which silently
|
|
231
|
+
// changes the data. Reject instead.
|
|
232
|
+
if (!Object.prototype.hasOwnProperty.call(value, i)) {
|
|
233
|
+
throw boundaryError("a sparse array", path + "[" + i + "]");
|
|
234
|
+
}
|
|
235
|
+
assertBoundary(value[i], path + "[" + i + "]", seen);
|
|
236
|
+
}
|
|
237
|
+
seen.delete(value);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const prototype = Object.getPrototypeOf(value);
|
|
242
|
+
// Two prototypes are legitimate: the realm's own Object.prototype (anything
|
|
243
|
+
// the script built) and the worker's (arrays we hand back from parallel).
|
|
244
|
+
// Everything else — Map, Set, Date, a class instance — loses meaning here.
|
|
245
|
+
if (prototype !== null && prototype !== realmObjectPrototype && prototype !== Object.prototype) {
|
|
246
|
+
throw boundaryError("a non-plain object", path);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const keys = Object.keys(value);
|
|
250
|
+
for (let i = 0; i < keys.length; i++) {
|
|
251
|
+
assertBoundary(value[keys[i]], path + "." + keys[i], seen);
|
|
252
|
+
}
|
|
253
|
+
seen.delete(value);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function checkBoundary(value, path) {
|
|
257
|
+
assertBoundary(value, path, new Set());
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* ------------------------------------------------------------------ *
|
|
262
|
+
* Realm helpers
|
|
263
|
+
*
|
|
264
|
+
* parallel() and pipeline() build their result arrays in worker code, but
|
|
265
|
+
* the script should get an array its own realm recognises — otherwise
|
|
266
|
+
* \`result instanceof Array\` is false and \`Array.isArray\` is the only thing
|
|
267
|
+
* that works. Item values are moved across untouched.
|
|
268
|
+
* ------------------------------------------------------------------ */
|
|
269
|
+
|
|
270
|
+
let realmNewArray = null;
|
|
271
|
+
let realmPush = null;
|
|
272
|
+
|
|
273
|
+
function toRealmArray(items) {
|
|
274
|
+
const array = realmNewArray();
|
|
275
|
+
for (let i = 0; i < items.length; i++) realmPush(array, items[i]);
|
|
276
|
+
return array;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function toList(value, what) {
|
|
280
|
+
if (!Array.isArray(value)) throw new Error(what + " expects an array.");
|
|
281
|
+
const length = value.length >>> 0;
|
|
282
|
+
if (length > ITEM_CAP) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
what + " was given " + length + " items, over the limit of " + ITEM_CAP + "."
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const out = [];
|
|
288
|
+
for (let i = 0; i < length; i++) out.push(value[i]);
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function requireText(value, what) {
|
|
293
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
294
|
+
throw new Error(what + " requires a non-empty string.");
|
|
295
|
+
}
|
|
296
|
+
return value;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function optionalText(value, what) {
|
|
300
|
+
if (value === undefined || value === null) return undefined;
|
|
301
|
+
return requireText(value, what);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Reasoning effort a child may be spawned under — pi's \`ThinkingLevel\`.
|
|
306
|
+
*
|
|
307
|
+
* A superset of Claude Code's five, so a script written there runs here; the
|
|
308
|
+
* extra \`minimal\` is pi's own. Validated in the worker rather than the host
|
|
309
|
+
* because a typo should stop the script at the call that made it, not surface
|
|
310
|
+
* later as an agent that quietly ran at the wrong depth.
|
|
311
|
+
*/
|
|
312
|
+
const EFFORT_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"];
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Every option \`agent()\` understands.
|
|
316
|
+
*
|
|
317
|
+
* Checked rather than ignored, because the alternative is the worst failure a
|
|
318
|
+
* ported script can have. Claude Code's \`agent()\` also takes \`schema\`, and its
|
|
319
|
+
* own canonical example uses it; quietly dropping it hands the script the
|
|
320
|
+
* agent's raw text where it expected a validated object, and the run then dies
|
|
321
|
+
* several lines later reading a field off a string. A typo behaves the same
|
|
322
|
+
* way. Naming the option costs one error message and no model calls.
|
|
323
|
+
*/
|
|
324
|
+
const AGENT_OPTIONS = [
|
|
325
|
+
"label",
|
|
326
|
+
"phase",
|
|
327
|
+
"model",
|
|
328
|
+
"agentType",
|
|
329
|
+
"isolation",
|
|
330
|
+
"gate",
|
|
331
|
+
"resume",
|
|
332
|
+
"effort",
|
|
333
|
+
"schema",
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
/** Claude Code options this runtime does not have, and why. */
|
|
337
|
+
const UNSUPPORTED_AGENT_OPTIONS = {};
|
|
338
|
+
|
|
339
|
+
/* ------------------------------------------------------------------ *
|
|
340
|
+
* Script globals
|
|
341
|
+
* ------------------------------------------------------------------ */
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Phase indices are allocated once for the whole run, so parent and child never
|
|
345
|
+
* collide. What is per-scope is the *title to index* map: a child's
|
|
346
|
+
* \`phase("Scan")\` must not resolve to the parent's "Scan".
|
|
347
|
+
*/
|
|
348
|
+
let nextPhaseIndex = 0;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* One script's view of the world.
|
|
352
|
+
*
|
|
353
|
+
* A nested \`workflow()\` runs in this same worker and this same vm context —
|
|
354
|
+
* which is what makes it share the run's semaphore, agent counter, journal,
|
|
355
|
+
* abort signal and budget without any of them being plumbed anywhere. What it
|
|
356
|
+
* must NOT share is ambient phase state, so that lives here and the child's
|
|
357
|
+
* globals are closures over its own scope.
|
|
358
|
+
*/
|
|
359
|
+
function makeScope(name, depth) {
|
|
360
|
+
const scope = {
|
|
361
|
+
name: name,
|
|
362
|
+
depth: depth,
|
|
363
|
+
// Prefixed into every phase title the child defines, which is the whole of
|
|
364
|
+
// how a nested run reads as its own group in the progress tree — no new
|
|
365
|
+
// entry type, no renderer change.
|
|
366
|
+
prefix: name === undefined ? "" : "\u25b8 " + name,
|
|
367
|
+
ambientPhaseIndex: undefined,
|
|
368
|
+
ambientPhaseTitle: undefined,
|
|
369
|
+
phaseIndexByTitle: new Map(),
|
|
370
|
+
};
|
|
371
|
+
scope.agent = function (prompt, opts) {
|
|
372
|
+
return agentIn(scope, prompt, opts);
|
|
373
|
+
};
|
|
374
|
+
scope.phase = function (title) {
|
|
375
|
+
return phaseIn(scope, title);
|
|
376
|
+
};
|
|
377
|
+
scope.log = function (message) {
|
|
378
|
+
return logIn(scope, message);
|
|
379
|
+
};
|
|
380
|
+
scope.workflow = function (ref, args) {
|
|
381
|
+
return workflowIn(scope, ref, args);
|
|
382
|
+
};
|
|
383
|
+
scope.console = makeConsole(scope);
|
|
384
|
+
return scope;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** A scope's title for a phase: the child's own group, or the parent's bare title. */
|
|
388
|
+
function scopedTitle(scope, title) {
|
|
389
|
+
if (scope.prefix === "") return title;
|
|
390
|
+
return title === undefined ? scope.prefix : scope.prefix + " \u203a " + title;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function definePhaseIn(scope, title) {
|
|
394
|
+
let index = scope.phaseIndexByTitle.get(title);
|
|
395
|
+
if (index !== undefined) return index;
|
|
396
|
+
index = nextPhaseIndex++;
|
|
397
|
+
scope.phaseIndexByTitle.set(title, index);
|
|
398
|
+
emit({ type: "workflow_phase", index: index, title: scopedTitle(scope, title) });
|
|
399
|
+
return index;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function phaseIn(scope, title) {
|
|
403
|
+
const text = requireText(title, "phase(title)");
|
|
404
|
+
scope.ambientPhaseIndex = definePhaseIn(scope, text);
|
|
405
|
+
scope.ambientPhaseTitle = scopedTitle(scope, text);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function describe(value) {
|
|
409
|
+
if (typeof value === "string") return value;
|
|
410
|
+
// Duck-typed, not \`instanceof Error\`: an error thrown by the script belongs
|
|
411
|
+
// to the vm realm, so it fails an instanceof check against the worker's.
|
|
412
|
+
if (value && typeof value === "object" && typeof value.message === "string" && typeof value.stack === "string") {
|
|
413
|
+
return value.message;
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
const json = JSON.stringify(value);
|
|
417
|
+
if (json !== undefined) return json;
|
|
418
|
+
} catch {
|
|
419
|
+
/* cycles and BigInt fall through to String() */
|
|
420
|
+
}
|
|
421
|
+
return String(value);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Attribute a line to the child that wrote it; logs carry no phase of their own. */
|
|
425
|
+
function logPrefix(scope) {
|
|
426
|
+
return scope.prefix === "" ? "" : scope.prefix + ": ";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function logIn(scope, message) {
|
|
430
|
+
emit({ type: "workflow_log", message: logPrefix(scope) + describe(message) });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function makeConsole(scope) {
|
|
434
|
+
const write = function () {
|
|
435
|
+
const parts = [];
|
|
436
|
+
for (let i = 0; i < arguments.length; i++) parts.push(describe(arguments[i]));
|
|
437
|
+
emit({ type: "workflow_log", message: logPrefix(scope) + parts.join(" ") });
|
|
438
|
+
};
|
|
439
|
+
return { log: write, info: write, warn: write, error: write, debug: write };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function agentIn(scope, prompt, opts) {
|
|
443
|
+
const text = requireText(prompt, "agent(prompt)");
|
|
444
|
+
const options = opts === undefined || opts === null ? {} : opts;
|
|
445
|
+
if (typeof options !== "object" || Array.isArray(options)) {
|
|
446
|
+
throw new Error("agent(prompt, opts) expects opts to be an object.");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (const key of Object.keys(options)) {
|
|
450
|
+
if (AGENT_OPTIONS.indexOf(key) !== -1) continue;
|
|
451
|
+
const why = UNSUPPORTED_AGENT_OPTIONS[key];
|
|
452
|
+
throw new Error(
|
|
453
|
+
why !== undefined
|
|
454
|
+
? "agent() opts." + key + " is not supported here: " + why
|
|
455
|
+
: "agent() opts." + key + " is not a recognised option. Supported: " + AGENT_OPTIONS.join(", ") + "."
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const label = optionalText(options.label, "agent() opts.label");
|
|
460
|
+
const phaseName = optionalText(options.phase, "agent() opts.phase");
|
|
461
|
+
const model = optionalText(options.model, "agent() opts.model");
|
|
462
|
+
const agentType = optionalText(options.agentType, "agent() opts.agentType");
|
|
463
|
+
const isolation = optionalText(options.isolation, "agent() opts.isolation");
|
|
464
|
+
if (isolation !== undefined && isolation !== "worktree") {
|
|
465
|
+
throw new Error("agent() opts.isolation must be \\"worktree\\".");
|
|
466
|
+
}
|
|
467
|
+
const gate = optionalText(options.gate, "agent() opts.gate");
|
|
468
|
+
const resume = optionalText(options.resume, "agent() opts.resume");
|
|
469
|
+
const effort = optionalText(options.effort, "agent() opts.effort");
|
|
470
|
+
const schema = options.schema;
|
|
471
|
+
if (schema !== undefined) {
|
|
472
|
+
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
|
|
473
|
+
throw new Error("agent() opts.schema must be a JSON Schema object.");
|
|
474
|
+
}
|
|
475
|
+
// Structured clone would happily carry a Map or a cycle that neither the
|
|
476
|
+
// journal key nor the tool's parameters can survive. Same check the return
|
|
477
|
+
// value gets.
|
|
478
|
+
checkBoundary(schema, "agent() opts.schema");
|
|
479
|
+
}
|
|
480
|
+
if (effort !== undefined && EFFORT_LEVELS.indexOf(effort) === -1) {
|
|
481
|
+
throw new Error("agent() opts.effort must be one of: " + EFFORT_LEVELS.join(", ") + ".");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// resume revives a child that already exists, so anything describing how to
|
|
485
|
+
// *start* one is not a thing this call gets to decide — the revived child
|
|
486
|
+
// keeps the agent, model and tool contract it was started with. Rejecting is
|
|
487
|
+
// the point: silently ignoring these opts would look like they applied.
|
|
488
|
+
if (resume !== undefined) {
|
|
489
|
+
if (agentType !== undefined) {
|
|
490
|
+
throw new Error(
|
|
491
|
+
"agent() opts.resume and opts.agentType are mutually exclusive: a resumed agent keeps the agent type it was started with."
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
if (model !== undefined) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
"agent() opts.resume and opts.model are mutually exclusive: a resumed agent keeps the model it was started with."
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
if (isolation !== undefined) {
|
|
500
|
+
throw new Error(
|
|
501
|
+
"agent() opts.resume and opts.isolation are mutually exclusive: a resumed agent keeps the working tree it was started in."
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
if (effort !== undefined) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
"agent() opts.resume and opts.effort are mutually exclusive: a resumed agent keeps the reasoning effort it was started with."
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
if (schema !== undefined) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
"agent() opts.resume and opts.schema are mutually exclusive: a resumed child re-prompts the session it "
|
|
512
|
+
+ "already had, whose tool set was fixed when it started — it has no StructuredOutput tool to answer through."
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
if (gate !== undefined) {
|
|
516
|
+
throw new Error("agent() opts.gate cannot be combined with opts.resume.");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// An explicit opts.phase files this agent under that phase without moving
|
|
521
|
+
// the ambient one, so a stray verify step does not re-point the phases that
|
|
522
|
+
// follow it.
|
|
523
|
+
const phaseIndex = phaseName !== undefined ? definePhaseIn(scope, phaseName) : scope.ambientPhaseIndex;
|
|
524
|
+
const phaseTitle = phaseName !== undefined ? scopedTitle(scope, phaseName) : scope.ambientPhaseTitle;
|
|
525
|
+
|
|
526
|
+
const result = await callHost("agent", {
|
|
527
|
+
prompt: text,
|
|
528
|
+
label: label,
|
|
529
|
+
model: model,
|
|
530
|
+
agentType: agentType,
|
|
531
|
+
isolation: isolation,
|
|
532
|
+
phaseIndex: phaseIndex,
|
|
533
|
+
phaseTitle: phaseTitle,
|
|
534
|
+
gate: gate,
|
|
535
|
+
resume: resume,
|
|
536
|
+
effort: effort,
|
|
537
|
+
schema: schema,
|
|
538
|
+
});
|
|
539
|
+
if (result === undefined || result === null) return null;
|
|
540
|
+
if (schema === undefined) return result;
|
|
541
|
+
// Parsed with the realm's own JSON.parse so the script gets an object whose
|
|
542
|
+
// prototype is its own — \`x instanceof Object\` and \`x.list instanceof Array\`
|
|
543
|
+
// both hold, and it survives assertBoundary if the script returns it.
|
|
544
|
+
try {
|
|
545
|
+
return realmParse(result);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
logIn(scope, "agent(): the host returned a structured result that is not JSON");
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* A barrier: every thunk starts now, and nothing past the await runs until all
|
|
554
|
+
* of them have settled. A thunk that throws resolves to null rather than
|
|
555
|
+
* failing its siblings — the script filters, it does not try/catch.
|
|
556
|
+
*/
|
|
557
|
+
async function parallel(thunks) {
|
|
558
|
+
const list = toList(thunks, "parallel(thunks)");
|
|
559
|
+
for (let i = 0; i < list.length; i++) {
|
|
560
|
+
if (typeof list[i] !== "function") {
|
|
561
|
+
throw new Error("parallel(thunks) expects an array of functions; item " + i + " is not one.");
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const settled = await Promise.all(
|
|
565
|
+
list.map(async function (thunk) {
|
|
566
|
+
try {
|
|
567
|
+
return await thunk();
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (isFatal(error)) throw error;
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
})
|
|
573
|
+
);
|
|
574
|
+
return toRealmArray(settled);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* No barrier between stages. Each item walks its own chain, so item A can be
|
|
579
|
+
* in stage 3 while item B is still in stage 1 — which is the whole point:
|
|
580
|
+
* a barrier makes every stage wait on its slowest sibling, and with agents in
|
|
581
|
+
* the stages that latency is measured in minutes.
|
|
582
|
+
*
|
|
583
|
+
* A stage that throws drops that item to null and skips its remaining stages.
|
|
584
|
+
* Every stage sees (previousResult, originalItem, index).
|
|
585
|
+
*/
|
|
586
|
+
async function pipeline(items, ...stages) {
|
|
587
|
+
const list = toList(items, "pipeline(items, ...stages)");
|
|
588
|
+
for (let i = 0; i < stages.length; i++) {
|
|
589
|
+
if (typeof stages[i] !== "function") {
|
|
590
|
+
throw new Error("pipeline(items, ...stages) expects stages to be functions; stage " + i + " is not one.");
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const settled = await Promise.all(
|
|
594
|
+
list.map(async function (item, index) {
|
|
595
|
+
let value = item;
|
|
596
|
+
for (let s = 0; s < stages.length; s++) {
|
|
597
|
+
try {
|
|
598
|
+
value = await stages[s](value, item, index);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
if (isFatal(error)) throw error;
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return value;
|
|
605
|
+
})
|
|
606
|
+
);
|
|
607
|
+
return toRealmArray(settled);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The \`workflow(nameOrRef, args?)\` global.
|
|
612
|
+
*
|
|
613
|
+
* Runs another workflow inline. The child executes in *this* worker and *this*
|
|
614
|
+
* vm context, as a function whose parameters shadow the globals — which is why
|
|
615
|
+
* it shares the run's concurrency cap, agent counter, abort signal, journal and
|
|
616
|
+
* budget without any of them being passed anywhere: there is only ever one of
|
|
617
|
+
* each. What it does not share is ambient phase state, which lives on the scope.
|
|
618
|
+
*
|
|
619
|
+
* One level only, as in Claude Code. The child's \`workflow\` is present and
|
|
620
|
+
* throws rather than absent, so the error names the limit instead of reading
|
|
621
|
+
* \`workflow is not defined\`.
|
|
622
|
+
*/
|
|
623
|
+
async function workflowIn(scope, nameOrRef, args) {
|
|
624
|
+
if (scope.depth > 0) {
|
|
625
|
+
throw new Error(
|
|
626
|
+
"workflow() cannot be nested more than one level deep — you are already inside the workflow '" +
|
|
627
|
+
scope.name + "'. Call the agents inline instead."
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
let ref;
|
|
632
|
+
if (typeof nameOrRef === "string") {
|
|
633
|
+
if (nameOrRef.trim() === "") throw new Error("workflow(nameOrRef) expects a non-empty name.");
|
|
634
|
+
ref = { name: nameOrRef };
|
|
635
|
+
} else if (nameOrRef && typeof nameOrRef === "object" && !Array.isArray(nameOrRef)) {
|
|
636
|
+
const scriptPath = optionalText(nameOrRef.scriptPath, "workflow() scriptPath");
|
|
637
|
+
const name = optionalText(nameOrRef.name, "workflow() name");
|
|
638
|
+
if (scriptPath === undefined && name === undefined) {
|
|
639
|
+
throw new Error("workflow({ ... }) expects a \`name\` or a \`scriptPath\`.");
|
|
640
|
+
}
|
|
641
|
+
ref = { name: name, scriptPath: scriptPath };
|
|
642
|
+
} else {
|
|
643
|
+
throw new Error("workflow(nameOrRef) expects a saved workflow name or { scriptPath }.");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const label = ref.name !== undefined ? ref.name : ref.scriptPath;
|
|
647
|
+
if (args !== undefined) checkBoundary(args, 'workflow("' + label + '") args');
|
|
648
|
+
|
|
649
|
+
if (nestedCount >= workerData.nestedCap) {
|
|
650
|
+
// Fatal, like the agent cap: a limit that silently drops work would be
|
|
651
|
+
// worse than no limit.
|
|
652
|
+
const error = new Error(
|
|
653
|
+
"Workflow exceeded its cap of " + workerData.nestedCap + " nested workflow() calls."
|
|
654
|
+
);
|
|
655
|
+
error.workflowFatal = true;
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
658
|
+
nestedCount++;
|
|
659
|
+
|
|
660
|
+
let loaded;
|
|
661
|
+
try {
|
|
662
|
+
loaded = await callHost("workflow", ref);
|
|
663
|
+
} catch (error) {
|
|
664
|
+
// Resolution failures are the script's to handle — Claude Code documents
|
|
665
|
+
// workflow() as throwing on an unknown name so a script can catch it.
|
|
666
|
+
// Attributed, so a caught error says which reference failed.
|
|
667
|
+
if (isFatal(error)) throw error;
|
|
668
|
+
throw new Error('workflow("' + label + '"): ' + describe(error));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const child = makeScope(loaded.name, scope.depth + 1);
|
|
672
|
+
// The child's own group, defined before its first agent so a child that never
|
|
673
|
+
// calls phase() still reads as its own section rather than falling into the
|
|
674
|
+
// parent's un-phased bucket.
|
|
675
|
+
child.ambientPhaseIndex = definePhaseIn(child, undefined);
|
|
676
|
+
child.ambientPhaseTitle = scopedTitle(child, undefined);
|
|
677
|
+
|
|
678
|
+
let run;
|
|
679
|
+
try {
|
|
680
|
+
const compiled = new vm.Script(
|
|
681
|
+
// \`meta\` is deliberately not a parameter: the body still opens with its
|
|
682
|
+
// own \`const meta = { ... }\` (extractMeta strips only the \`export\`), so a
|
|
683
|
+
// parameter of that name would collide with it.
|
|
684
|
+
"(async (agent, phase, log, workflow, console, args) => {" + PRELUDE + "\\n" + loaded.body + "\\n})",
|
|
685
|
+
{ filename: "workflow:" + loaded.name + ".js", lineOffset: -1 }
|
|
686
|
+
);
|
|
687
|
+
run = compiled.runInContext(realmContext);
|
|
688
|
+
} catch (error) {
|
|
689
|
+
throw new Error('workflow("' + label + '"): ' + describe(error));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const value = await run(child.agent, child.phase, child.log, child.workflow, child.console, args);
|
|
693
|
+
checkBoundary(value, 'the result of workflow("' + label + '")');
|
|
694
|
+
return value;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* The \`budget\` global.
|
|
699
|
+
*
|
|
700
|
+
* \`total\` is permanently null, and that is the honest answer rather than a
|
|
701
|
+
* stub: Claude Code fills it from the user's "+500k" directive and pi has no
|
|
702
|
+
* such directive, so "no target set" is the state this runtime is always in.
|
|
703
|
+
* Every pattern Claude Code documents guards on exactly that — \`while
|
|
704
|
+
* (budget.total && ...)\`, \`budget.total ? ... : 5\` — so those scripts run here
|
|
705
|
+
* unchanged and take the branch they were written for. Leaving \`budget\`
|
|
706
|
+
* undefined instead would turn a graceful guard into a ReferenceError.
|
|
707
|
+
*
|
|
708
|
+
* \`spent()\` is real. It differs from Claude Code's in scope: theirs pools the
|
|
709
|
+
* main loop and every workflow in the turn, ours counts this run's agents.
|
|
710
|
+
*/
|
|
711
|
+
function makeBudget() {
|
|
712
|
+
return {
|
|
713
|
+
total: null,
|
|
714
|
+
spent: function () {
|
|
715
|
+
return spentOutput;
|
|
716
|
+
},
|
|
717
|
+
remaining: function () {
|
|
718
|
+
// Infinity, not a number, because there is no target to subtract from.
|
|
719
|
+
return Infinity;
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/* ------------------------------------------------------------------ *
|
|
725
|
+
* Run
|
|
726
|
+
* ------------------------------------------------------------------ */
|
|
727
|
+
|
|
728
|
+
async function main() {
|
|
729
|
+
rootScope = makeScope(undefined, 0);
|
|
730
|
+
const sandbox = {
|
|
731
|
+
agent: rootScope.agent,
|
|
732
|
+
parallel: parallel,
|
|
733
|
+
pipeline: pipeline,
|
|
734
|
+
phase: rootScope.phase,
|
|
735
|
+
log: rootScope.log,
|
|
736
|
+
workflow: rootScope.workflow,
|
|
737
|
+
budget: makeBudget(),
|
|
738
|
+
console: rootScope.console,
|
|
739
|
+
};
|
|
740
|
+
const context = vm.createContext(sandbox, {
|
|
741
|
+
name: "workflow",
|
|
742
|
+
codeGeneration: { strings: false, wasm: false },
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
realmObjectPrototype = vm.runInContext("Object.prototype", context);
|
|
746
|
+
realmNewArray = vm.runInContext("(function () { return []; })", context);
|
|
747
|
+
realmPush = vm.runInContext("(function (array, value) { array.push(value); })", context);
|
|
748
|
+
realmParse = vm.runInContext("JSON.parse", context);
|
|
749
|
+
realmContext = context;
|
|
750
|
+
|
|
751
|
+
// meta and args are materialised *inside* the realm rather than injected, so
|
|
752
|
+
// the script sees objects whose prototype is its own Object.prototype and
|
|
753
|
+
// whose .constructor is its own Function.
|
|
754
|
+
sandbox.meta = realmParse(workerData.metaJson);
|
|
755
|
+
sandbox.args = workerData.argsJson === undefined ? undefined : realmParse(workerData.argsJson);
|
|
756
|
+
|
|
757
|
+
const script = new vm.Script("(async () => {" + PRELUDE + "\\n" + workerData.body + "\\n})()", {
|
|
758
|
+
filename: "workflow.js",
|
|
759
|
+
// The wrapper adds exactly one line above the body; undo it so a thrown
|
|
760
|
+
// error points at the line the author wrote.
|
|
761
|
+
lineOffset: -1,
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const value = await script.runInContext(context);
|
|
765
|
+
checkBoundary(value, "the workflow result");
|
|
766
|
+
flushProgress();
|
|
767
|
+
port.postMessage({
|
|
768
|
+
type: "complete",
|
|
769
|
+
resultJson: value === undefined ? undefined : JSON.stringify(value),
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
main().catch(function (error) {
|
|
774
|
+
flushProgress();
|
|
775
|
+
port.postMessage({
|
|
776
|
+
type: "error",
|
|
777
|
+
message: error && error.message ? String(error.message) : String(error),
|
|
778
|
+
stack: error && error.stack ? String(error.stack) : undefined,
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
`
|