pi-extensible-workflows 0.3.0 → 1.0.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/README.md +12 -3
- package/dist/src/agent-execution.d.ts +81 -8
- package/dist/src/agent-execution.js +421 -71
- package/dist/src/doctor.d.ts +4 -1
- package/dist/src/doctor.js +56 -11
- package/dist/src/index.d.ts +217 -22
- package/dist/src/index.js +1416 -337
- package/dist/src/persistence.d.ts +26 -1
- package/dist/src/persistence.js +100 -6
- package/dist/src/session-inspector.d.ts +13 -1
- package/dist/src/session-inspector.js +22 -4
- package/dist/src/workflow-evals.js +1 -1
- package/package.json +1 -1
- package/skills/pi-extensible-workflows/SKILL.md +9 -5
- package/src/agent-execution.ts +350 -83
- package/src/doctor.ts +59 -12
- package/src/index.ts +1231 -360
- package/src/persistence.ts +76 -7
- package/src/session-inspector.ts +25 -7
- package/src/workflow-evals.ts +1 -1
package/dist/src/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { fork } from "node:child_process";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { basename, dirname, extname, join } from "node:path";
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import * as acorn from "acorn";
|
|
9
9
|
import { Script } from "node:vm";
|
|
@@ -13,17 +13,27 @@ import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, SessionM
|
|
|
13
13
|
import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
|
|
14
14
|
import { transcriptLines } from "./session-inspector.js";
|
|
15
15
|
import { acquireSessionLease, listRunIds, RunStore, structuralPath as operationPath } from "./persistence.js";
|
|
16
|
-
export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted"];
|
|
16
|
+
export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted", "budget_exhausted"];
|
|
17
17
|
export const AGENT_STATES = ["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"];
|
|
18
18
|
export const WORKFLOW_ASYNC_STARTED_EVENT = "workflow:async-started";
|
|
19
19
|
export const WORKFLOW_ASYNC_COMPLETE_EVENT = "workflow:async-complete";
|
|
20
|
+
export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
|
|
21
|
+
export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
|
|
22
|
+
export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
|
|
23
|
+
export const WORKFLOW_RUN_COMPLETED_EVENT = "workflow:run-completed";
|
|
24
|
+
export const WORKFLOW_RUN_FAILED_EVENT = "workflow:run-failed";
|
|
25
|
+
export const WORKFLOW_AGENT_STATE_CHANGED_EVENT = "workflow:agent-state-changed";
|
|
26
|
+
export const WORKFLOW_PHASE_CHANGED_EVENT = "workflow:phase-changed";
|
|
27
|
+
export const WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT = "workflow:checkpoint-state-changed";
|
|
28
|
+
export const WORKFLOW_BUDGET_EVENT = "workflow:budget-event";
|
|
29
|
+
export const WORKFLOW_WORKTREE_CREATED_EVENT = "workflow:worktree-created";
|
|
20
30
|
const SETTLED_AGENT_STATES = new Set(["completed", "failed", "cancelled"]);
|
|
21
31
|
export const ERROR_CODES = [
|
|
22
|
-
"INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
|
|
23
|
-
"
|
|
24
|
-
"CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "INTERNAL_ERROR",
|
|
32
|
+
"CONFIG_ERROR", "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
|
|
33
|
+
"RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
|
|
34
|
+
"CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "BUDGET_EXHAUSTED", "INTERNAL_ERROR",
|
|
25
35
|
];
|
|
26
|
-
const LAUNCH_SNAPSHOT_IDENTITY_VERSION =
|
|
36
|
+
export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 3;
|
|
27
37
|
export class WorkflowError extends Error {
|
|
28
38
|
code;
|
|
29
39
|
constructor(code, message) {
|
|
@@ -53,6 +63,7 @@ function workflowDetail(message) {
|
|
|
53
63
|
return detail || "No further details were provided";
|
|
54
64
|
}
|
|
55
65
|
const WORKFLOW_ERROR_PROSE = {
|
|
66
|
+
CONFIG_ERROR: (detail) => `The workflow configuration is invalid: ${detail}.`,
|
|
56
67
|
INVALID_SETTINGS: (detail) => `The workflow settings are invalid: ${detail}.`,
|
|
57
68
|
INVALID_SYNTAX: (detail) => `The workflow source is invalid: ${detail}.`,
|
|
58
69
|
INVALID_METADATA: (detail) => `The workflow metadata is invalid: ${detail}.`,
|
|
@@ -64,7 +75,6 @@ const WORKFLOW_ERROR_PROSE = {
|
|
|
64
75
|
UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
|
|
65
76
|
UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
|
|
66
77
|
UNKNOWN_AGENT_TYPE: (detail) => `The workflow requested the unavailable agent role ${detail.replace(/^Unknown agent role:\s*/, "")}.`,
|
|
67
|
-
RUN_LIMIT_EXCEEDED: (detail) => `The workflow exceeded its agent launch limit: ${detail.replace(/^Run\s+exceeded\s+/i, "")}.`,
|
|
68
78
|
RUN_OWNED: (detail) => /already owned|active ownership/.test(detail) ? "The workflow session is already in use." : `The workflow session is already in use: ${detail}.`,
|
|
69
79
|
RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
|
|
70
80
|
AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
|
|
@@ -74,6 +84,7 @@ const WORKFLOW_ERROR_PROSE = {
|
|
|
74
84
|
WORKER_UNRESPONSIVE: (detail) => `The workflow worker stopped responding: ${detail}.`,
|
|
75
85
|
WORKTREE_FAILED: (detail) => `The workflow worktree operation failed: ${detail}.`,
|
|
76
86
|
RESUME_INCOMPATIBLE: (detail) => `The workflow cannot resume this run: ${detail}.`,
|
|
87
|
+
BUDGET_EXHAUSTED: (detail) => `The workflow budget was exhausted: ${detail}.`,
|
|
77
88
|
INTERNAL_ERROR: (detail) => `The workflow encountered an internal error: ${detail}.`,
|
|
78
89
|
};
|
|
79
90
|
export function formatWorkflowFailure(error) {
|
|
@@ -123,7 +134,7 @@ export class RunLifecycle {
|
|
|
123
134
|
if (this.#active > 0)
|
|
124
135
|
this.#active -= 1;
|
|
125
136
|
if (this.#state === "pausing" && this.#active === 0)
|
|
126
|
-
await this.#set("paused");
|
|
137
|
+
await this.#set("paused", "pause");
|
|
127
138
|
}
|
|
128
139
|
async enterAwaitingInput() {
|
|
129
140
|
while (this.#state === "pausing" || this.#state === "paused")
|
|
@@ -132,26 +143,26 @@ export class RunLifecycle {
|
|
|
132
143
|
return;
|
|
133
144
|
if (this.#state !== "running")
|
|
134
145
|
throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot await input for ${this.#state} run`);
|
|
135
|
-
await this.#set("awaiting_input");
|
|
146
|
+
await this.#set("awaiting_input", "awaiting_input");
|
|
136
147
|
}
|
|
137
148
|
async resolveAwaitingInput() {
|
|
138
149
|
if (this.#state !== "awaiting_input")
|
|
139
150
|
return;
|
|
140
|
-
await this.#set("running");
|
|
151
|
+
await this.#set("running", "checkpoint_resolved");
|
|
141
152
|
for (const resolve of this.#waiters.splice(0))
|
|
142
153
|
resolve();
|
|
143
154
|
}
|
|
144
155
|
async pause() {
|
|
145
156
|
if (this.#state !== "running")
|
|
146
157
|
throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot pause ${this.#state} run`);
|
|
147
|
-
await this.#set("pausing");
|
|
158
|
+
await this.#set("pausing", "pause");
|
|
148
159
|
if (this.#active === 0)
|
|
149
|
-
await this.#set("paused");
|
|
160
|
+
await this.#set("paused", "pause");
|
|
150
161
|
}
|
|
151
162
|
async resume() {
|
|
152
|
-
if (this.#state !== "paused" && this.#state !== "interrupted")
|
|
163
|
+
if (this.#state !== "paused" && this.#state !== "interrupted" && this.#state !== "budget_exhausted")
|
|
153
164
|
throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
|
|
154
|
-
await this.#set("running");
|
|
165
|
+
await this.#set("running", "resume");
|
|
155
166
|
for (const resolve of this.#waiters.splice(0))
|
|
156
167
|
resolve();
|
|
157
168
|
}
|
|
@@ -161,32 +172,290 @@ export class RunLifecycle {
|
|
|
161
172
|
await this.pause();
|
|
162
173
|
await this.enter();
|
|
163
174
|
}
|
|
164
|
-
async terminal(state) {
|
|
175
|
+
async terminal(state, reason) {
|
|
165
176
|
if (["completed", "failed", "stopped"].includes(this.#state))
|
|
166
177
|
throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
|
|
167
|
-
await this.#set(state);
|
|
178
|
+
await this.#set(state, reason ?? state);
|
|
168
179
|
for (const resolve of this.#waiters.splice(0))
|
|
169
180
|
resolve();
|
|
170
181
|
}
|
|
171
|
-
async #set(state) {
|
|
182
|
+
async #set(state, reason) {
|
|
183
|
+
const previousState = this.#state;
|
|
184
|
+
this.#state = state;
|
|
185
|
+
await this.changed?.(state, previousState, reason);
|
|
186
|
+
}
|
|
172
187
|
}
|
|
173
|
-
export const DEFAULT_SETTINGS = Object.freeze({ concurrency: 8
|
|
188
|
+
export const DEFAULT_SETTINGS = Object.freeze({ concurrency: 8 });
|
|
174
189
|
function fail(code, message) { throw new WorkflowError(code, message); }
|
|
175
190
|
function object(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
191
|
+
function nonNegativeInteger(value) { return Number.isInteger(value) && value >= 0; }
|
|
192
|
+
function nonNegativeFinite(value) { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
|
|
193
|
+
export function validateBudget(value) {
|
|
194
|
+
if (value === undefined)
|
|
195
|
+
return undefined;
|
|
196
|
+
if (!object(value))
|
|
197
|
+
fail("INVALID_METADATA", "budget must be an object");
|
|
198
|
+
const result = {};
|
|
199
|
+
for (const [dimension, raw] of Object.entries(value)) {
|
|
200
|
+
if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension))
|
|
201
|
+
fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
|
|
202
|
+
if (!object(raw))
|
|
203
|
+
fail("INVALID_METADATA", `${dimension} budget must be an object`);
|
|
204
|
+
if (Object.keys(raw).some((key) => key !== "soft" && key !== "hard"))
|
|
205
|
+
fail("INVALID_METADATA", `${dimension} budget has an unknown limit`);
|
|
206
|
+
const isCost = dimension === "costUsd";
|
|
207
|
+
for (const key of ["soft", "hard"])
|
|
208
|
+
if (raw[key] !== undefined && !(isCost ? nonNegativeFinite(raw[key]) : nonNegativeInteger(raw[key])))
|
|
209
|
+
fail("INVALID_METADATA", `${dimension}.${key} must be a non-negative ${isCost ? "finite number" : "integer"}`);
|
|
210
|
+
if (raw.soft !== undefined && raw.soft !== null && raw.hard !== undefined && raw.hard !== null && raw.soft >= raw.hard)
|
|
211
|
+
fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
|
|
212
|
+
const limits = {};
|
|
213
|
+
if (raw.soft !== undefined)
|
|
214
|
+
limits.soft = raw.soft;
|
|
215
|
+
if (raw.hard !== undefined)
|
|
216
|
+
limits.hard = raw.hard;
|
|
217
|
+
if (Object.keys(limits).length)
|
|
218
|
+
result[dimension] = limits;
|
|
219
|
+
}
|
|
220
|
+
return Object.freeze(result);
|
|
221
|
+
}
|
|
222
|
+
export function validateBudgetPatch(value) {
|
|
223
|
+
if (!object(value))
|
|
224
|
+
fail("INVALID_METADATA", "budget patch must be an object");
|
|
225
|
+
const result = {};
|
|
226
|
+
for (const [dimension, raw] of Object.entries(value)) {
|
|
227
|
+
if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension))
|
|
228
|
+
fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
|
|
229
|
+
if (raw === null) {
|
|
230
|
+
result[dimension] = null;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!object(raw) || Object.keys(raw).some((key) => key !== "soft" && key !== "hard"))
|
|
234
|
+
fail("INVALID_METADATA", `${dimension} budget patch must contain only soft and hard`);
|
|
235
|
+
const limits = {};
|
|
236
|
+
for (const key of ["soft", "hard"])
|
|
237
|
+
if (Object.prototype.hasOwnProperty.call(raw, key)) {
|
|
238
|
+
if (raw[key] === null)
|
|
239
|
+
limits[key] = null;
|
|
240
|
+
else {
|
|
241
|
+
const checked = validateBudget({ [dimension]: { [key]: raw[key] } })?.[dimension];
|
|
242
|
+
if (checked?.[key] !== undefined)
|
|
243
|
+
limits[key] = checked[key];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (limits.soft !== null && limits.hard !== null && limits.soft !== undefined && limits.hard !== undefined && limits.soft >= limits.hard)
|
|
247
|
+
fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
|
|
248
|
+
result[dimension] = limits;
|
|
249
|
+
}
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
function budgetUsage(value) { return { tokens: value?.tokens ?? 0, costUsd: value?.costUsd ?? 0, durationMs: value?.durationMs ?? 0, agentLaunches: value?.agentLaunches ?? 0 }; }
|
|
253
|
+
export class WorkflowBudgetRuntime {
|
|
254
|
+
budget;
|
|
255
|
+
version;
|
|
256
|
+
#now;
|
|
257
|
+
#onChange;
|
|
258
|
+
#injected = new Set();
|
|
259
|
+
#seen = new Set();
|
|
260
|
+
#active;
|
|
261
|
+
#activeSince;
|
|
262
|
+
#usage;
|
|
263
|
+
#events;
|
|
264
|
+
#turnAccounting;
|
|
265
|
+
constructor(budget, version = 1, usage, events = [], options = {}) {
|
|
266
|
+
this.budget = budget;
|
|
267
|
+
this.version = version;
|
|
268
|
+
this.#now = options.now ?? (() => Date.now());
|
|
269
|
+
this.#onChange = options.onChange;
|
|
270
|
+
this.#active = options.active ?? true;
|
|
271
|
+
this.#activeSince = this.#active ? this.#now() : undefined;
|
|
272
|
+
this.#usage = budgetUsage(usage);
|
|
273
|
+
this.#events = [...events];
|
|
274
|
+
for (const event of events)
|
|
275
|
+
if (event.budgetVersion === version)
|
|
276
|
+
this.#seen.add(event.type);
|
|
277
|
+
}
|
|
278
|
+
get usage() { this.#syncDuration(); return { ...this.#usage }; }
|
|
279
|
+
get events() { return this.#events; }
|
|
280
|
+
get hardExhausted() { return this.#events.some((event) => event.type === "hard_exhausted" && event.budgetVersion === this.version); }
|
|
281
|
+
checkAgentLaunch() { this.#checkHard(["agentLaunches"]); }
|
|
282
|
+
beforeAttempt() { this.#checkHard(["agentLaunches"]); this.#usage.agentLaunches += 1; this.#evaluate(); }
|
|
283
|
+
beforeTurn() { this.#syncDuration(); this.#evaluate(); this.#checkHard(["tokens", "costUsd", "durationMs"]); }
|
|
284
|
+
afterTurn(accounting, final) { this.#syncDuration(); this.#applyTurn(accounting, final, this.#turnAccounting); this.#turnAccounting = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }
|
|
285
|
+
#applyTurn(accounting, final, previous = { input: 0, output: 0, cost: 0 }) {
|
|
286
|
+
this.#usage.tokens += Math.max(0, accounting.input - previous.input) + Math.max(0, accounting.output - previous.output);
|
|
287
|
+
this.#usage.costUsd += Math.max(0, accounting.cost - previous.cost);
|
|
288
|
+
this.#evaluate();
|
|
289
|
+
if (!final)
|
|
290
|
+
this.#checkHard(["tokens", "costUsd", "durationMs"]);
|
|
291
|
+
}
|
|
292
|
+
instruction(agentId = "agent") {
|
|
293
|
+
if (!this.#hasSoftCrossed() || this.#injected.has(agentId))
|
|
294
|
+
return undefined;
|
|
295
|
+
this.#injected.add(agentId);
|
|
296
|
+
return `The workflow budget soft limit has been reached. Finish the requested output now, preserving any required output schema. Current usage: ${JSON.stringify(this.usage)}. Do not start additional model work unless it is required to produce the final requested result.`;
|
|
297
|
+
}
|
|
298
|
+
forAgent(agentId) {
|
|
299
|
+
let attempt = 0;
|
|
300
|
+
let previous;
|
|
301
|
+
return {
|
|
302
|
+
beforeAttempt: () => { attempt += 1; previous = undefined; this.beforeAttempt(); },
|
|
303
|
+
beforeTurn: () => { this.beforeTurn(); },
|
|
304
|
+
afterTurn: (accounting, final) => { this.#applyTurn(accounting, final, previous); previous = { input: accounting.input, output: accounting.output, cost: accounting.cost }; },
|
|
305
|
+
instruction: () => this.instruction(`${agentId}:${String(attempt + 1)}`),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
transition(state) {
|
|
309
|
+
const active = state === "running";
|
|
310
|
+
if (active === this.#active)
|
|
311
|
+
return;
|
|
312
|
+
if (active) {
|
|
313
|
+
this.#active = true;
|
|
314
|
+
this.#activeSince = this.#now();
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
this.#syncDuration();
|
|
318
|
+
this.#evaluate();
|
|
319
|
+
this.#active = false;
|
|
320
|
+
this.#activeSince = undefined;
|
|
321
|
+
}
|
|
322
|
+
this.#onChange?.();
|
|
323
|
+
}
|
|
324
|
+
#syncDuration() { if (this.#active && this.#activeSince !== undefined) {
|
|
325
|
+
const now = this.#now();
|
|
326
|
+
this.#usage.durationMs += Math.max(0, now - this.#activeSince);
|
|
327
|
+
this.#activeSince = now;
|
|
328
|
+
} }
|
|
329
|
+
#hasSoftCrossed() { return !!this.budget && Object.entries(this.budget).some(([dimension, limits]) => limits.soft !== undefined && this.#usage[dimension] >= limits.soft); }
|
|
330
|
+
#checkHard(dimensions) {
|
|
331
|
+
const exhausted = dimensions.filter((dimension) => { const hard = this.budget?.[dimension]?.hard; return hard !== undefined && this.#usage[dimension] >= hard; });
|
|
332
|
+
if (!exhausted.length)
|
|
333
|
+
return;
|
|
334
|
+
this.#record("hard_exhausted", exhausted);
|
|
335
|
+
const detail = exhausted.map((dimension) => `${dimension} usage=${String(this.#usage[dimension])} hard=${String(this.budget?.[dimension]?.hard)}`).join(", ");
|
|
336
|
+
throw new WorkflowError("BUDGET_EXHAUSTED", `Budget version ${String(this.version)} exhausted: ${detail}`);
|
|
337
|
+
}
|
|
338
|
+
#evaluate() {
|
|
339
|
+
const budget = this.budget;
|
|
340
|
+
if (!budget)
|
|
341
|
+
return;
|
|
342
|
+
const soft = Object.keys(budget).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.soft !== undefined && this.#usage[dimension] >= limits.soft; });
|
|
343
|
+
if (soft.length)
|
|
344
|
+
this.#record("soft_crossed", soft);
|
|
345
|
+
const overrun = Object.keys(budget).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && this.#usage[dimension] > limits.hard; });
|
|
346
|
+
if (overrun.length)
|
|
347
|
+
this.#record("hard_overrun", overrun);
|
|
348
|
+
}
|
|
349
|
+
#record(type, dimensions) { if (this.#seen.has(type))
|
|
350
|
+
return; this.#seen.add(type); this.#events.push({ type, budgetVersion: this.version, dimensions: [...dimensions], usage: this.usage, limits: structuredClone(this.budget ?? {}), at: this.#now() }); this.#onChange?.(); }
|
|
351
|
+
recordEvent(event) { this.#events.push(structuredClone(event)); }
|
|
352
|
+
snapshot() { return { usage: this.usage, budgetEvents: [...this.#events] }; }
|
|
353
|
+
}
|
|
354
|
+
export function mergeBudget(budget, patch) {
|
|
355
|
+
const merged = structuredClone(budget ?? {});
|
|
356
|
+
for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"])
|
|
357
|
+
if (Object.prototype.hasOwnProperty.call(patch, dimension)) {
|
|
358
|
+
const value = patch[dimension];
|
|
359
|
+
if (value === null) {
|
|
360
|
+
Reflect.deleteProperty(merged, dimension);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const next = { ...(merged[dimension] ?? {}) };
|
|
364
|
+
for (const key of ["soft", "hard"])
|
|
365
|
+
if (value && Object.prototype.hasOwnProperty.call(value, key)) {
|
|
366
|
+
const limit = value[key];
|
|
367
|
+
if (limit === null)
|
|
368
|
+
Reflect.deleteProperty(next, key);
|
|
369
|
+
else if (limit !== undefined)
|
|
370
|
+
next[key] = limit;
|
|
371
|
+
}
|
|
372
|
+
if (Object.keys(next).length)
|
|
373
|
+
merged[dimension] = next;
|
|
374
|
+
else
|
|
375
|
+
Reflect.deleteProperty(merged, dimension);
|
|
376
|
+
}
|
|
377
|
+
return validateBudget(merged);
|
|
378
|
+
}
|
|
379
|
+
export function budgetRelaxed(previous, next) { for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"]) {
|
|
380
|
+
const oldLimit = previous?.[dimension];
|
|
381
|
+
const newLimit = next?.[dimension];
|
|
382
|
+
for (const key of ["soft", "hard"])
|
|
383
|
+
if ((oldLimit?.[key] !== undefined && newLimit?.[key] === undefined) || (oldLimit?.[key] !== undefined && newLimit?.[key] !== undefined && newLimit[key] > oldLimit[key]))
|
|
384
|
+
return true;
|
|
385
|
+
} return false; }
|
|
386
|
+
export function exhaustedBudgetDimensions(budget, usage) {
|
|
387
|
+
if (!budget)
|
|
388
|
+
return [];
|
|
389
|
+
return Object.keys(budget).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && usage[dimension] >= limits.hard; });
|
|
390
|
+
}
|
|
391
|
+
export function resumeBudgetAllowed(budget, usage) { return exhaustedBudgetDimensions(budget, usage).length === 0; }
|
|
176
392
|
function positiveInteger(value) { return Number.isInteger(value) && value > 0; }
|
|
393
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
394
|
+
const MODEL_ALIAS_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
177
395
|
export function parseModelReference(value) {
|
|
178
396
|
const match = /^([^/:\s]+)\/([^:\s]+)(?::([^:\s]+))?$/.exec(value);
|
|
179
397
|
if (!match?.[1] || !match[2])
|
|
180
398
|
fail("UNKNOWN_MODEL", `Invalid model spec: ${value}`);
|
|
181
399
|
const thinking = match[3];
|
|
182
|
-
if (thinking && !
|
|
400
|
+
if (thinking && !THINKING_LEVELS.includes(thinking))
|
|
183
401
|
fail("UNKNOWN_MODEL", `Invalid thinking level: ${thinking}`);
|
|
184
402
|
return { provider: match[1], model: match[2], ...(thinking ? { thinking: thinking } : {}) };
|
|
185
403
|
}
|
|
186
|
-
function
|
|
187
|
-
|
|
404
|
+
function aliasError(message, settingsPath) { fail("CONFIG_ERROR", `${message} (settings: ${settingsPath})`); }
|
|
405
|
+
export function validateModelAliases(value, settingsPath = "workflow settings") {
|
|
406
|
+
if (!object(value))
|
|
407
|
+
aliasError("modelAliases must be an object", settingsPath);
|
|
408
|
+
const aliases = {};
|
|
409
|
+
for (const [name, target] of Object.entries(value)) {
|
|
410
|
+
if (!MODEL_ALIAS_NAME.test(name))
|
|
411
|
+
aliasError(`Invalid model alias name: ${name}`, settingsPath);
|
|
412
|
+
if (typeof target !== "string" || !target.trim())
|
|
413
|
+
aliasError(`Invalid model alias target for ${name}`, settingsPath);
|
|
414
|
+
try {
|
|
415
|
+
parseModelReference(target);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
aliasError(`Invalid model alias target for ${name}: ${errorText(error)}`, settingsPath);
|
|
419
|
+
}
|
|
420
|
+
aliases[name] = target;
|
|
421
|
+
}
|
|
422
|
+
return Object.freeze(aliases);
|
|
423
|
+
}
|
|
424
|
+
function unknownModel(value, target, settingsPath) {
|
|
425
|
+
const resolved = target ? ` resolved to ${target}` : "";
|
|
426
|
+
const path = settingsPath ? ` (settings: ${settingsPath})` : "";
|
|
427
|
+
fail("UNKNOWN_MODEL", `Unknown model${target ? " alias" : ""} ${value}${resolved}${path}`);
|
|
428
|
+
}
|
|
429
|
+
export function resolveModelReference(value, aliases = {}, knownModels, settingsPath) {
|
|
430
|
+
const target = Object.prototype.hasOwnProperty.call(aliases, value) ? aliases[value] : undefined;
|
|
431
|
+
if (target !== undefined) {
|
|
432
|
+
try {
|
|
433
|
+
return parseModelReference(target);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
unknownModel(value, target, settingsPath);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (value.includes("/"))
|
|
440
|
+
return parseModelReference(value);
|
|
441
|
+
const match = /^([^:\s]+)(?::([^:\s]+))?$/.exec(value);
|
|
442
|
+
const thinking = match?.[2];
|
|
443
|
+
if (!match?.[1] || thinking && !THINKING_LEVELS.includes(thinking))
|
|
444
|
+
unknownModel(value, undefined, settingsPath);
|
|
445
|
+
const candidates = [...(knownModels ?? [])].filter((model) => model.slice(model.indexOf("/") + 1) === match[1]);
|
|
446
|
+
if (candidates.length === 1) {
|
|
447
|
+
const parsed = parseModelReference(candidates[0]);
|
|
448
|
+
return thinking ? { ...parsed, thinking: thinking } : parsed;
|
|
449
|
+
}
|
|
450
|
+
unknownModel(value, undefined, settingsPath);
|
|
451
|
+
}
|
|
452
|
+
function modelCapability(value, aliases, knownModels, settingsPath) {
|
|
453
|
+
const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
|
|
188
454
|
return `${parsed.provider}/${parsed.model}`;
|
|
189
455
|
}
|
|
456
|
+
function aliasDrift(previous, current) {
|
|
457
|
+
return [...new Set([...Object.keys(previous), ...Object.keys(current)])].sort().flatMap((name) => previous[name] === current[name] ? [] : [`${name}: ${previous[name] ?? "(missing)"} -> ${current[name] ?? "(missing)"}`]);
|
|
458
|
+
}
|
|
190
459
|
export function validateCheckpoint(value) {
|
|
191
460
|
if (!object(value) || Object.keys(value).some((key) => !["name", "prompt", "context"].includes(key)) || typeof value.name !== "string" || value.name.trim() === "" || typeof value.prompt !== "string" || !jsonValue(value.context))
|
|
192
461
|
fail("INVALID_METADATA", "checkpoint requires only name, prompt, and JSON context");
|
|
@@ -197,6 +466,60 @@ export function validateCheckpoint(value) {
|
|
|
197
466
|
return { name: value.name, prompt: value.prompt, context: value.context };
|
|
198
467
|
}
|
|
199
468
|
export function workflowSettingsPath(agentDir = getAgentDir()) { return join(agentDir, ROLE_DIRECTORY, "settings.json"); }
|
|
469
|
+
export function workflowProjectSettingsPath(cwd) { return join(cwd, ".pi", ROLE_DIRECTORY, "settings.json"); }
|
|
470
|
+
const EMPTY_AGENT_RESOURCE_EXCLUSIONS = Object.freeze({ skills: [], extensions: [] });
|
|
471
|
+
function normalizedResourcePath(value, settingsPath) {
|
|
472
|
+
let expanded = value === "~" ? homedir() : value.startsWith("~/") || value.startsWith("~\\") ? join(homedir(), value.slice(2)) : value;
|
|
473
|
+
if (expanded.startsWith("file://"))
|
|
474
|
+
expanded = fileURLToPath(expanded);
|
|
475
|
+
const resolved = resolve(dirname(settingsPath), expanded);
|
|
476
|
+
try {
|
|
477
|
+
return realpathSync(resolved);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return resolved;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
export function mergeAgentResourceExclusions(...values) {
|
|
484
|
+
return { skills: [...new Set(values.flatMap((value) => value?.skills ?? []))], extensions: [...new Set(values.flatMap((value) => value?.extensions ?? []))] };
|
|
485
|
+
}
|
|
486
|
+
function validateAgentResourceExclusions(value, settingsPath, errorCode = "INVALID_SETTINGS") {
|
|
487
|
+
if (value === undefined)
|
|
488
|
+
return undefined;
|
|
489
|
+
const base = `${settingsPath}.disabledAgentResources`;
|
|
490
|
+
if (!object(value))
|
|
491
|
+
fail(errorCode, `${base} must be an object`);
|
|
492
|
+
for (const key of Object.keys(value))
|
|
493
|
+
if (key !== "skills" && key !== "extensions")
|
|
494
|
+
fail(errorCode, `${base}.${key} is not supported`);
|
|
495
|
+
const normalized = { skills: [], extensions: [] };
|
|
496
|
+
for (const kind of ["skills", "extensions"]) {
|
|
497
|
+
const entries = value[kind];
|
|
498
|
+
if (entries === undefined)
|
|
499
|
+
continue;
|
|
500
|
+
if (!Array.isArray(entries))
|
|
501
|
+
fail(errorCode, `${base}.${kind} must be an array`);
|
|
502
|
+
const seen = new Set();
|
|
503
|
+
for (const [index, entry] of entries.entries()) {
|
|
504
|
+
if (typeof entry !== "string" || !entry.trim())
|
|
505
|
+
fail(errorCode, `${base}.${kind}[${String(index)}] must be a non-empty string`);
|
|
506
|
+
let selector = entry.trim();
|
|
507
|
+
if (kind === "extensions") {
|
|
508
|
+
try {
|
|
509
|
+
selector = normalizedResourcePath(selector, settingsPath);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (!seen.has(selector)) {
|
|
516
|
+
seen.add(selector);
|
|
517
|
+
normalized[kind].push(selector);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return Object.freeze({ skills: Object.freeze(normalized.skills), extensions: Object.freeze(normalized.extensions) });
|
|
522
|
+
}
|
|
200
523
|
export function loadSettings(path = workflowSettingsPath()) {
|
|
201
524
|
let parsed;
|
|
202
525
|
try {
|
|
@@ -205,23 +528,45 @@ export function loadSettings(path = workflowSettingsPath()) {
|
|
|
205
528
|
catch (error) {
|
|
206
529
|
if (error.code === "ENOENT")
|
|
207
530
|
return DEFAULT_SETTINGS;
|
|
208
|
-
fail("
|
|
531
|
+
fail("CONFIG_ERROR", `Invalid workflow settings JSON at ${path}: ${errorText(error)}`);
|
|
209
532
|
}
|
|
210
533
|
if (!object(parsed))
|
|
211
|
-
fail("INVALID_SETTINGS",
|
|
212
|
-
const allowed = new Set(["concurrency", "
|
|
534
|
+
fail("INVALID_SETTINGS", `Workflow settings at ${path} must be an object`);
|
|
535
|
+
const allowed = new Set(["concurrency", "modelAliases", "disabledAgentResources"]);
|
|
213
536
|
const unknown = Object.keys(parsed).find((key) => !allowed.has(key));
|
|
214
537
|
if (unknown)
|
|
215
|
-
fail("INVALID_SETTINGS", `Unknown workflow setting: ${unknown}`);
|
|
538
|
+
fail("INVALID_SETTINGS", `Unknown workflow setting at ${path}: ${unknown}`);
|
|
216
539
|
const concurrency = parsed.concurrency === undefined ? DEFAULT_SETTINGS.concurrency : parsed.concurrency;
|
|
217
|
-
const maxAgentLaunches = parsed.maxAgentLaunches === undefined ? DEFAULT_SETTINGS.maxAgentLaunches : parsed.maxAgentLaunches;
|
|
218
540
|
if (!positiveInteger(concurrency) || concurrency > 16)
|
|
219
|
-
fail("INVALID_SETTINGS",
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return Object.freeze({ concurrency,
|
|
541
|
+
fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
|
|
542
|
+
const modelAliases = parsed.modelAliases === undefined ? undefined : validateModelAliases(parsed.modelAliases, path);
|
|
543
|
+
const disabledAgentResources = validateAgentResourceExclusions(parsed.disabledAgentResources, path);
|
|
544
|
+
return Object.freeze({ concurrency, ...(modelAliases ? { modelAliases } : {}), ...(disabledAgentResources ? { disabledAgentResources } : {}) });
|
|
545
|
+
}
|
|
546
|
+
export function resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath = workflowSettingsPath()) {
|
|
547
|
+
const projectSettingsPath = workflowProjectSettingsPath(cwd);
|
|
548
|
+
const global = loadSettings(globalSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS;
|
|
549
|
+
const project = projectTrusted ? loadSettings(projectSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS : EMPTY_AGENT_RESOURCE_EXCLUSIONS;
|
|
550
|
+
const effective = mergeAgentResourceExclusions(global, project);
|
|
551
|
+
return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [] };
|
|
552
|
+
}
|
|
553
|
+
export function saveModelAliases(path = workflowSettingsPath(), aliases = {}) {
|
|
554
|
+
const normalized = validateModelAliases(aliases, path);
|
|
555
|
+
let parsed = {};
|
|
556
|
+
try {
|
|
557
|
+
loadSettings(path);
|
|
558
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
if (error.code !== "ENOENT")
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
565
|
+
const temporary = `${path}.${String(process.pid)}.${randomUUID()}.tmp`;
|
|
566
|
+
writeFileSync(temporary, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, { mode: 0o600 });
|
|
567
|
+
renameSync(temporary, path);
|
|
223
568
|
}
|
|
224
|
-
export function parseRoleMarkdown(content, strict = false) {
|
|
569
|
+
export function parseRoleMarkdown(content, strict = false, rolePath) {
|
|
225
570
|
if (!strict) {
|
|
226
571
|
if (!content.startsWith("---\n"))
|
|
227
572
|
return { prompt: content };
|
|
@@ -261,7 +606,7 @@ export function parseRoleMarkdown(content, strict = false) {
|
|
|
261
606
|
}
|
|
262
607
|
if (!object(parsed.frontmatter))
|
|
263
608
|
fail("INVALID_METADATA", "Role frontmatter must be an object");
|
|
264
|
-
const { model, thinking, tools, description } = parsed.frontmatter;
|
|
609
|
+
const { model, thinking, tools, description, disabledAgentResources } = parsed.frontmatter;
|
|
265
610
|
if (model !== undefined && (typeof model !== "string" || model.trim() === ""))
|
|
266
611
|
fail("INVALID_METADATA", "Role model must be a non-empty string");
|
|
267
612
|
if (thinking !== undefined && (typeof thinking !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking)))
|
|
@@ -270,7 +615,8 @@ export function parseRoleMarkdown(content, strict = false) {
|
|
|
270
615
|
fail("INVALID_METADATA", "Role description must be a non-empty single-line string of at most 1024 characters");
|
|
271
616
|
if (tools !== undefined && (!Array.isArray(tools) || tools.some((tool) => typeof tool !== "string" || tool.trim() === "")))
|
|
272
617
|
fail("INVALID_METADATA", "Role tools must be an array of non-empty strings");
|
|
273
|
-
|
|
618
|
+
const normalizedResources = validateAgentResourceExclusions(disabledAgentResources, rolePath ?? "<role>", "INVALID_METADATA");
|
|
619
|
+
return { prompt: parsed.body, ...(typeof description === "string" ? { description: description.trim() } : {}), ...(typeof model === "string" ? { model: model.trim() } : {}), ...(typeof thinking === "string" ? { thinking: thinking } : {}), ...(Array.isArray(tools) ? { tools: tools.map((tool) => tool.trim()) } : {}), ...(normalizedResources ? { disabledAgentResources: normalizedResources } : {}) };
|
|
274
620
|
}
|
|
275
621
|
const ROLE_DIRECTORY = "pi-extensible-workflows";
|
|
276
622
|
export function workflowRoleDirectories(agentDir = getAgentDir()) {
|
|
@@ -283,7 +629,7 @@ function readAgentDefinitions(dir) {
|
|
|
283
629
|
try {
|
|
284
630
|
return Object.fromEntries(readdirSync(dir, { withFileTypes: true })
|
|
285
631
|
.filter((entry) => entry.isFile() && extname(entry.name) === ".md")
|
|
286
|
-
.map((entry) => [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(
|
|
632
|
+
.map((entry) => { const path = join(dir, entry.name); return [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }));
|
|
287
633
|
}
|
|
288
634
|
catch (error) {
|
|
289
635
|
if (error.code === "ENOENT")
|
|
@@ -297,13 +643,19 @@ function readRoleDefinitions(dirs) {
|
|
|
297
643
|
export function loadAgentDefinitions(cwd, agentDir = getAgentDir(), projectTrusted = true) {
|
|
298
644
|
return deepFreeze({ ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
|
|
299
645
|
}
|
|
300
|
-
function validateRolePolicies(definitions, roles, availableModels, rootTools) {
|
|
646
|
+
function validateRolePolicies(definitions, roles, availableModels, rootTools, aliases = {}, knownModels = availableModels, settingsPath) {
|
|
301
647
|
for (const role of roles) {
|
|
302
648
|
const definition = definitions[role];
|
|
303
649
|
if (!definition)
|
|
304
650
|
continue;
|
|
305
|
-
if (definition.model !== undefined
|
|
306
|
-
|
|
651
|
+
if (definition.model !== undefined) {
|
|
652
|
+
const resolved = modelCapability(definition.model, aliases, knownModels, settingsPath);
|
|
653
|
+
if (!availableModels.has(resolved)) {
|
|
654
|
+
if (Object.prototype.hasOwnProperty.call(aliases, definition.model))
|
|
655
|
+
unknownModel(definition.model, resolved, settingsPath);
|
|
656
|
+
fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${resolved}`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
307
659
|
const missingTool = (definition.tools ?? [...rootTools]).find((tool) => !rootTools.has(tool));
|
|
308
660
|
if (missingTool)
|
|
309
661
|
fail("UNKNOWN_TOOL", `Unknown tool for role ${role}: ${missingTool}`);
|
|
@@ -351,7 +703,7 @@ function astNode(value) {
|
|
|
351
703
|
function workflowCalls(program) {
|
|
352
704
|
const calls = [];
|
|
353
705
|
const visit = (node) => {
|
|
354
|
-
if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name))
|
|
706
|
+
if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name))
|
|
355
707
|
calls.push(node);
|
|
356
708
|
for (const value of Object.values(node)) {
|
|
357
709
|
if (Array.isArray(value)) {
|
|
@@ -376,7 +728,7 @@ function workflowCallsWithStructure(program) {
|
|
|
376
728
|
if (scope?.key === null && key)
|
|
377
729
|
current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
|
|
378
730
|
}
|
|
379
|
-
if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) {
|
|
731
|
+
if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) {
|
|
380
732
|
const call = node;
|
|
381
733
|
const operation = call.callee.name;
|
|
382
734
|
const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
|
|
@@ -428,6 +780,7 @@ function hasIdentifier(node, name) {
|
|
|
428
780
|
return Object.values(node).some((value) => Array.isArray(value) ? value.some((child) => astNode(child) && hasIdentifier(child, name)) : astNode(value) && hasIdentifier(value, name));
|
|
429
781
|
}
|
|
430
782
|
const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
|
|
783
|
+
const INTERNAL_CONVERSATION_NAME = "__pi_extensible_workflows_conversation";
|
|
431
784
|
const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
|
|
432
785
|
function callHasTrailingComma(source, call) {
|
|
433
786
|
let previous;
|
|
@@ -445,14 +798,16 @@ function instrumentWorkflow(script) {
|
|
|
445
798
|
const program = parseWorkflow(body);
|
|
446
799
|
if (hasIdentifier(program, INTERNAL_AGENT_NAME))
|
|
447
800
|
fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
|
|
801
|
+
if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME))
|
|
802
|
+
fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
|
|
448
803
|
if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
|
|
449
804
|
fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
|
|
450
|
-
const calls = workflowCalls(program).filter((call) => ["agent", "withWorktree"].includes(call.callee.name));
|
|
805
|
+
const calls = workflowCalls(program).filter((call) => ["agent", "conversation", "withWorktree"].includes(call.callee.name));
|
|
451
806
|
const edits = calls.flatMap((call) => {
|
|
452
807
|
const callSite = `${String(call.start)}:${String(call.end)}`;
|
|
453
808
|
const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
|
|
454
809
|
return [
|
|
455
|
-
{ start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : INTERNAL_WORKTREE_NAME },
|
|
810
|
+
{ start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : call.callee.name === "conversation" ? INTERNAL_CONVERSATION_NAME : INTERNAL_WORKTREE_NAME },
|
|
456
811
|
{ start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` },
|
|
457
812
|
];
|
|
458
813
|
}).sort((left, right) => right.start - left.start);
|
|
@@ -544,16 +899,17 @@ function validateSchema(schema, at = "schema") {
|
|
|
544
899
|
fail("INVALID_SCHEMA", `${at}.properties must be an object`);
|
|
545
900
|
}
|
|
546
901
|
const AGENT_OPTION_KEYS = new Set(["label", "model", "thinking", "tools", "role", "outputSchema", "retries", "timeoutMs"]);
|
|
547
|
-
function validateAgentOption(key, value) {
|
|
902
|
+
function validateAgentOption(key, value, aliases, knownModels, settingsPath) {
|
|
548
903
|
switch (key) {
|
|
549
904
|
case "label":
|
|
550
905
|
if (typeof value !== "string" || !value.trim())
|
|
551
906
|
fail("INVALID_METADATA", "agent label must be a non-empty string");
|
|
552
907
|
break;
|
|
553
908
|
case "model":
|
|
554
|
-
if (typeof value !== "string")
|
|
555
|
-
fail("INVALID_METADATA", "agent model must be a string");
|
|
556
|
-
|
|
909
|
+
if (typeof value !== "string" || !value.trim())
|
|
910
|
+
fail("INVALID_METADATA", "agent model must be a non-empty string");
|
|
911
|
+
if (aliases !== undefined)
|
|
912
|
+
resolveModelReference(value, aliases, knownModels, settingsPath);
|
|
557
913
|
break;
|
|
558
914
|
case "thinking":
|
|
559
915
|
if (typeof value !== "string" || !parseThinking(value))
|
|
@@ -583,11 +939,9 @@ function validateAgentOption(key, value) {
|
|
|
583
939
|
function validateAgentOptions(value) {
|
|
584
940
|
if (!object(value) || !jsonValue(value))
|
|
585
941
|
fail("INVALID_METADATA", "agent options must be a JSON object");
|
|
586
|
-
const unknown = Object.keys(value).find((key) => !AGENT_OPTION_KEYS.has(key));
|
|
587
|
-
if (unknown)
|
|
588
|
-
fail("INVALID_METADATA", `Unknown agent option: ${unknown}`);
|
|
589
942
|
for (const [key, option] of Object.entries(value))
|
|
590
|
-
|
|
943
|
+
if (AGENT_OPTION_KEYS.has(key))
|
|
944
|
+
validateAgentOption(key, option);
|
|
591
945
|
if (typeof value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(value, key)))
|
|
592
946
|
fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
593
947
|
return value;
|
|
@@ -642,7 +996,7 @@ export function inspectWorkflowScript(script) {
|
|
|
642
996
|
const first = callArgument(call, 0);
|
|
643
997
|
const options = callArgument(call, 1);
|
|
644
998
|
const placement = { execution, structure };
|
|
645
|
-
if (kind === "agent") {
|
|
999
|
+
if (kind === "agent" || kind === "conversation") {
|
|
646
1000
|
const retries = staticValue(propertyNode(options, "retries"));
|
|
647
1001
|
const outputSchema = staticValue(propertyNode(options, "outputSchema"));
|
|
648
1002
|
const optionKeys = options?.type === "ObjectExpression" ? options.properties.flatMap((property) => {
|
|
@@ -652,7 +1006,7 @@ export function inspectWorkflowScript(script) {
|
|
|
652
1006
|
return key ? [key] : [];
|
|
653
1007
|
}) : [];
|
|
654
1008
|
const knownOptions = Object.fromEntries(optionKeys.flatMap((key) => { const value = staticValue(propertyNode(options, key)); return value.known && jsonValue(value.value) ? [[key, value.value]] : []; }));
|
|
655
|
-
const base = { ...placement, kind, start: call.start, end: call.end, name: null, prompt: staticString(first), model: staticString(propertyNode(options, "model")), label: staticString(propertyNode(options, "label")), role: staticString(propertyNode(options, "role")) };
|
|
1009
|
+
const base = { ...placement, kind, start: call.start, end: call.end, name: kind === "conversation" ? staticString(first) : null, prompt: kind === "conversation" ? null : staticString(first), model: staticString(propertyNode(options, "model")), label: staticString(propertyNode(options, "label")), role: staticString(propertyNode(options, "role")) };
|
|
656
1010
|
return { ...base, ...(retries.known && typeof retries.value === "number" ? { retries: retries.value } : {}), ...(outputSchema.known && object(outputSchema.value) ? { outputSchema: outputSchema.value } : {}), ...(optionKeys.length ? { options: knownOptions, optionKeys } : {}) };
|
|
657
1011
|
}
|
|
658
1012
|
if (kind === "checkpoint")
|
|
@@ -660,27 +1014,17 @@ export function inspectWorkflowScript(script) {
|
|
|
660
1014
|
return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
|
|
661
1015
|
});
|
|
662
1016
|
}
|
|
663
|
-
function validateStaticAgentOptions(node) {
|
|
1017
|
+
function validateStaticAgentOptions(node, aliases = {}, knownModels, settingsPath) {
|
|
664
1018
|
if (node?.type !== "ObjectExpression")
|
|
665
1019
|
return;
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
continue;
|
|
669
|
-
const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
|
|
670
|
-
if (key && !AGENT_OPTION_KEYS.has(key))
|
|
671
|
-
fail("INVALID_METADATA", `Unknown agent option: ${key}`);
|
|
672
|
-
}
|
|
673
|
-
const role = propertyNode(node, "role");
|
|
674
|
-
if (role && ["model", "thinking", "tools"].some((key) => propertyNode(node, key) !== undefined))
|
|
1020
|
+
const options = staticValue(node);
|
|
1021
|
+
if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value, key)))
|
|
675
1022
|
fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
676
1023
|
for (const key of AGENT_OPTION_KEYS) {
|
|
677
1024
|
const value = staticValue(propertyNode(node, key));
|
|
678
1025
|
if (value.known)
|
|
679
|
-
validateAgentOption(key, value.value);
|
|
1026
|
+
validateAgentOption(key, value.value, aliases, knownModels, settingsPath);
|
|
680
1027
|
}
|
|
681
|
-
const options = staticValue(node);
|
|
682
|
-
if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value, key)))
|
|
683
|
-
fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
684
1028
|
}
|
|
685
1029
|
function validateStaticWithWorktree(call) {
|
|
686
1030
|
if (call.arguments.some((argument) => argument.type === "SpreadElement"))
|
|
@@ -696,113 +1040,134 @@ function validateStaticWithWorktree(call) {
|
|
|
696
1040
|
fail("INVALID_METADATA", "withWorktree name must be a non-empty string");
|
|
697
1041
|
}
|
|
698
1042
|
}
|
|
699
|
-
const RESERVED_GLOBALS = new Set(["agent", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
|
|
1043
|
+
const RESERVED_GLOBALS = new Set(["agent", "conversation", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
|
|
700
1044
|
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
701
1045
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
702
1046
|
export class WorkflowRegistry {
|
|
703
|
-
#extensions = new
|
|
1047
|
+
#extensions = new Set();
|
|
704
1048
|
#globals = new Map();
|
|
1049
|
+
#workflows = new Map();
|
|
1050
|
+
#hooks = new Map();
|
|
705
1051
|
#frozen = false;
|
|
706
1052
|
get frozen() { return this.#frozen; }
|
|
707
1053
|
freeze() { this.#frozen = true; }
|
|
708
1054
|
register(extension) {
|
|
709
1055
|
if (this.#frozen)
|
|
710
1056
|
fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
|
|
711
|
-
if (!object(extension) || Object.keys(extension).some((key) => !["
|
|
712
|
-
fail("INVALID_METADATA", "Workflow extensions require a
|
|
713
|
-
if (this.#extensions.has(extension.namespace))
|
|
714
|
-
fail("DUPLICATE_NAME", `Workflow extension already registered: ${extension.namespace}`);
|
|
1057
|
+
if (!object(extension) || Object.keys(extension).some((key) => !["version", "headline", "description", "functions", "variables", "workflows", "agentSetupHooks"].includes(key)) || typeof extension.version !== "string" || !SEMVER.test(extension.version) || typeof extension.headline !== "string" || !extension.headline.trim() || typeof extension.description !== "string" || !extension.description.trim())
|
|
1058
|
+
fail("INVALID_METADATA", "Workflow extensions require a semantic version, headline, and description");
|
|
715
1059
|
const functions = extension.functions ?? {};
|
|
716
1060
|
const variables = extension.variables ?? {};
|
|
717
1061
|
const workflows = extension.workflows ?? {};
|
|
718
|
-
|
|
719
|
-
|
|
1062
|
+
const agentSetupHooks = extension.agentSetupHooks ?? {};
|
|
1063
|
+
if (!object(functions) || !object(variables) || !object(workflows) || !object(agentSetupHooks) || (Object.keys(functions).length === 0 && Object.keys(variables).length === 0 && Object.keys(workflows).length === 0 && Object.keys(agentSetupHooks).length === 0))
|
|
1064
|
+
fail("INVALID_METADATA", "Workflow extensions require functions, variables, workflows, or agent setup hooks");
|
|
720
1065
|
const names = [...Object.keys(functions), ...Object.keys(variables)];
|
|
721
1066
|
if (new Set(names).size !== names.length)
|
|
722
|
-
fail("GLOBAL_COLLISION",
|
|
1067
|
+
fail("GLOBAL_COLLISION", "Global name collision inside extension");
|
|
723
1068
|
for (const name of names) {
|
|
724
1069
|
if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_"))
|
|
725
|
-
fail("INVALID_METADATA", `Invalid global name: ${
|
|
1070
|
+
fail("INVALID_METADATA", `Invalid global name: ${name}`);
|
|
726
1071
|
if (RESERVED_GLOBALS.has(name))
|
|
727
1072
|
fail("GLOBAL_COLLISION", `Global name is reserved: ${name}`);
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
fail("GLOBAL_COLLISION", `Global name ${name} is already owned by ${owner}; cannot register ${extension.namespace}.${name}`);
|
|
1073
|
+
if (this.#globals.has(name))
|
|
1074
|
+
fail("GLOBAL_COLLISION", `Global name is already registered: ${name}`);
|
|
731
1075
|
}
|
|
732
1076
|
for (const [name, fn] of Object.entries(functions)) {
|
|
733
1077
|
if (!object(fn) || Object.keys(fn).some((key) => !["description", "input", "output", "run"].includes(key)) || typeof fn.description !== "string" || !fn.description.trim() || typeof fn.run !== "function")
|
|
734
|
-
fail("INVALID_METADATA", `Invalid workflow function: ${
|
|
735
|
-
validateSchema(fn.input, `${
|
|
736
|
-
validateSchema(fn.output, `${
|
|
1078
|
+
fail("INVALID_METADATA", `Invalid workflow function: ${name}`);
|
|
1079
|
+
validateSchema(fn.input, `${name} input`);
|
|
1080
|
+
validateSchema(fn.output, `${name} output`);
|
|
737
1081
|
if (fn.input.type !== "object")
|
|
738
|
-
fail("INVALID_SCHEMA", `${
|
|
1082
|
+
fail("INVALID_SCHEMA", `${name} input must describe one object`);
|
|
739
1083
|
}
|
|
740
1084
|
for (const [name, variable] of Object.entries(variables)) {
|
|
741
1085
|
if (!object(variable) || Object.keys(variable).some((key) => !["description", "schema", "resolve"].includes(key)) || typeof variable.description !== "string" || !variable.description.trim() || typeof variable.resolve !== "function")
|
|
742
|
-
fail("INVALID_METADATA", `Invalid workflow variable: ${
|
|
743
|
-
validateSchema(variable.schema, `${
|
|
1086
|
+
fail("INVALID_METADATA", `Invalid workflow variable: ${name}`);
|
|
1087
|
+
validateSchema(variable.schema, `${name} schema`);
|
|
744
1088
|
}
|
|
745
1089
|
for (const [name, workflow] of Object.entries(workflows)) {
|
|
746
1090
|
if (!IDENTIFIER.test(name) || !object(workflow) || Object.keys(workflow).some((key) => !["description", "script"].includes(key)) || typeof workflow.description !== "string" || !workflow.description.trim() || typeof workflow.script !== "string" || !workflow.script.trim())
|
|
747
|
-
fail("INVALID_METADATA", `Invalid workflow script: ${
|
|
1091
|
+
fail("INVALID_METADATA", `Invalid workflow script: ${name}`);
|
|
1092
|
+
if (this.#workflows.has(name))
|
|
1093
|
+
fail("DUPLICATE_NAME", `Reusable workflow already registered: ${name}`);
|
|
748
1094
|
parseWorkflow(workflow.script);
|
|
749
1095
|
}
|
|
750
|
-
|
|
751
|
-
|
|
1096
|
+
for (const [name, hook] of Object.entries(agentSetupHooks)) {
|
|
1097
|
+
if (!IDENTIFIER.test(name) || !object(hook) || Object.keys(hook).some((key) => !["priority", "setup"].includes(key)) || typeof hook.setup !== "function" || hook.priority !== undefined && (typeof hook.priority !== "number" || !Number.isFinite(hook.priority)))
|
|
1098
|
+
fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
|
|
1099
|
+
if (this.#hooks.has(name))
|
|
1100
|
+
fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
|
|
1101
|
+
}
|
|
1102
|
+
const stored = deepFreeze({ ...extension, functions, variables, workflows, agentSetupHooks });
|
|
1103
|
+
this.#extensions.add(stored);
|
|
752
1104
|
for (const name of names)
|
|
753
|
-
this.#globals.set(name,
|
|
1105
|
+
this.#globals.set(name, name);
|
|
1106
|
+
for (const [name, workflow] of Object.entries(workflows))
|
|
1107
|
+
this.#workflows.set(name, workflow);
|
|
1108
|
+
for (const [name, hook] of Object.entries(agentSetupHooks))
|
|
1109
|
+
this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
|
|
754
1110
|
}
|
|
755
1111
|
workflow(name) {
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
const workflow = this.#extensions.get(name.slice(0, separator))?.workflows?.[name.slice(separator + 1)];
|
|
1112
|
+
if (!IDENTIFIER.test(name))
|
|
1113
|
+
fail("MISSING_WORKFLOW", `Registered workflows require an unqualified name: ${name}`);
|
|
1114
|
+
const workflow = this.#workflows.get(name);
|
|
760
1115
|
if (!workflow)
|
|
761
1116
|
fail("MISSING_WORKFLOW", `Workflow is unavailable: ${name}`);
|
|
762
1117
|
return workflow;
|
|
763
1118
|
}
|
|
764
1119
|
workflows() {
|
|
765
|
-
return Object.freeze(Object.fromEntries(
|
|
1120
|
+
return Object.freeze(Object.fromEntries(this.#workflows));
|
|
766
1121
|
}
|
|
767
1122
|
catalog() {
|
|
768
1123
|
const functions = [];
|
|
769
1124
|
const variables = [];
|
|
770
1125
|
const workflows = [];
|
|
771
|
-
for (const
|
|
1126
|
+
for (const extension of this.#extensions) {
|
|
772
1127
|
for (const [name, fn] of Object.entries(extension.functions ?? {}))
|
|
773
|
-
functions.push({ name,
|
|
1128
|
+
functions.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
|
|
774
1129
|
for (const [name, variable] of Object.entries(extension.variables ?? {}))
|
|
775
|
-
variables.push({ name,
|
|
1130
|
+
variables.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
|
|
776
1131
|
for (const [name, workflow] of Object.entries(extension.workflows ?? {}))
|
|
777
|
-
workflows.push({ name
|
|
1132
|
+
workflows.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
|
|
1133
|
+
}
|
|
1134
|
+
let aliases;
|
|
1135
|
+
try {
|
|
1136
|
+
aliases = loadSettings().modelAliases;
|
|
778
1137
|
}
|
|
779
|
-
|
|
780
|
-
|
|
1138
|
+
catch {
|
|
1139
|
+
aliases = undefined;
|
|
1140
|
+
}
|
|
1141
|
+
const sort = (left, right) => left.name.localeCompare(right.name);
|
|
1142
|
+
return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), workflows: workflows.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
|
|
781
1143
|
}
|
|
782
1144
|
globals() {
|
|
783
|
-
return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((
|
|
1145
|
+
return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.keys(extension.functions ?? {}).map((name) => [name, { name }]))));
|
|
784
1146
|
}
|
|
785
|
-
async invokeFunction(
|
|
786
|
-
const fn = this.#extensions.
|
|
1147
|
+
async invokeFunction(name, input, context, path, journal) {
|
|
1148
|
+
const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
|
|
787
1149
|
if (!fn)
|
|
788
|
-
fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${
|
|
1150
|
+
fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${name}`);
|
|
789
1151
|
if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input))
|
|
790
|
-
fail("RESULT_INVALID", `Invalid input for ${
|
|
1152
|
+
fail("RESULT_INVALID", `Invalid input for ${name}`);
|
|
791
1153
|
const replayed = journal.get(path);
|
|
792
1154
|
if (replayed !== undefined) {
|
|
793
1155
|
if (!jsonValue(replayed) || !Value.Check(fn.output, replayed))
|
|
794
|
-
fail("RESULT_INVALID", `Invalid replay for ${
|
|
1156
|
+
fail("RESULT_INVALID", `Invalid replay for ${name}`);
|
|
795
1157
|
return structuredClone(replayed);
|
|
796
1158
|
}
|
|
797
1159
|
const result = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, agent: context.agent, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
|
|
798
1160
|
if (!jsonValue(result) || !Value.Check(fn.output, result))
|
|
799
|
-
fail("RESULT_INVALID", `Invalid output from ${
|
|
1161
|
+
fail("RESULT_INVALID", `Invalid output from ${name}`);
|
|
800
1162
|
const stored = structuredClone(result);
|
|
801
1163
|
journal.put(path, stored);
|
|
802
1164
|
return structuredClone(stored);
|
|
803
1165
|
}
|
|
804
1166
|
variables() {
|
|
805
|
-
return [...this.#extensions].flatMap((
|
|
1167
|
+
return [...this.#extensions].flatMap((extension) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ name, variable })));
|
|
1168
|
+
}
|
|
1169
|
+
agentSetupHooks() {
|
|
1170
|
+
return [...this.#hooks.values()].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
806
1171
|
}
|
|
807
1172
|
}
|
|
808
1173
|
const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
|
|
@@ -818,6 +1183,7 @@ function createWorkflowRegistryApi(registry) {
|
|
|
818
1183
|
globals: () => registry.globals(),
|
|
819
1184
|
invokeFunction: (...args) => registry.invokeFunction(...args),
|
|
820
1185
|
variables: () => registry.variables(),
|
|
1186
|
+
agentSetupHooks: () => registry.agentSetupHooks(),
|
|
821
1187
|
};
|
|
822
1188
|
}
|
|
823
1189
|
function workflowRegistryHost() {
|
|
@@ -848,11 +1214,11 @@ export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
|
|
|
848
1214
|
name: Type.Optional(Type.String({ description: "Workflow name for inline scripts" })),
|
|
849
1215
|
description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
|
|
850
1216
|
script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
|
|
851
|
-
workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as
|
|
1217
|
+
workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as an unqualified name" })),
|
|
852
1218
|
args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
|
|
853
1219
|
foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
|
|
854
1220
|
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
|
|
855
|
-
|
|
1221
|
+
budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
|
|
856
1222
|
});
|
|
857
1223
|
function hasDynamicAgentRole(node) {
|
|
858
1224
|
if (!node)
|
|
@@ -874,17 +1240,23 @@ export function preflight(script, capabilities, schemas = [], metadata = { name:
|
|
|
874
1240
|
const program = parseWorkflow(script);
|
|
875
1241
|
if (hasIdentifier(program, INTERNAL_AGENT_NAME))
|
|
876
1242
|
fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
|
|
1243
|
+
if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME))
|
|
1244
|
+
fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
|
|
877
1245
|
if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
|
|
878
1246
|
fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
|
|
879
1247
|
validateDirectPrimitiveReferences(program, "withWorktree");
|
|
1248
|
+
validateDirectPrimitiveReferences(program, "conversation");
|
|
880
1249
|
for (const [index, schema] of schemas.entries())
|
|
881
1250
|
validateSchema(schema, `schema[${String(index)}]`);
|
|
882
1251
|
const calls = workflowCalls(program);
|
|
883
1252
|
const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase) => phase !== undefined);
|
|
884
1253
|
for (const call of calls) {
|
|
885
1254
|
const operation = call.callee.name;
|
|
886
|
-
if (operation === "agent")
|
|
887
|
-
|
|
1255
|
+
if (operation === "agent" || operation === "conversation") {
|
|
1256
|
+
if (operation === "conversation" && (!literalString(call.arguments[0])?.trim() || call.arguments.length > 2))
|
|
1257
|
+
fail("INVALID_METADATA", "conversation requires a stable name and optional options object");
|
|
1258
|
+
validateStaticAgentOptions(call.arguments[1], capabilities.modelAliases ?? {}, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath);
|
|
1259
|
+
}
|
|
888
1260
|
if (operation === "withWorktree")
|
|
889
1261
|
validateStaticWithWorktree(call);
|
|
890
1262
|
if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement"))
|
|
@@ -896,21 +1268,25 @@ export function preflight(script, capabilities, schemas = [], metadata = { name:
|
|
|
896
1268
|
if (operation === "pipeline" && (call.arguments.length !== 3 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression" || call.arguments[2]?.type !== "ObjectExpression"))
|
|
897
1269
|
fail("INVALID_METADATA", "pipeline requires an operation name string, items record, and stages record");
|
|
898
1270
|
}
|
|
899
|
-
const agentCalls = calls.filter((call) => call.callee.name === "agent");
|
|
1271
|
+
const agentCalls = calls.filter((call) => call.callee.name === "agent" || call.callee.name === "conversation");
|
|
900
1272
|
const dynamicAgentRoles = agentCalls.some((call) => hasDynamicAgentRole(call.arguments[1]));
|
|
901
1273
|
const staticSchemas = agentCalls.flatMap((call) => { const value = staticValue(propertyNode(call.arguments[1], "outputSchema")); return value.known ? [value.value] : []; });
|
|
902
1274
|
for (const [index, schema] of staticSchemas.entries())
|
|
903
1275
|
validateSchema(schema, `agent outputSchema[${String(index)}]`);
|
|
904
1276
|
const checkedSchemas = [...schemas, ...staticSchemas];
|
|
905
|
-
const
|
|
1277
|
+
const modelRefs = agentCalls.flatMap((call) => { const requested = literalString(propertyNode(call.arguments[1], "model")); return requested === undefined ? [] : [{ requested, resolved: modelCapability(requested, capabilities.modelAliases, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath) }]; });
|
|
1278
|
+
const models = modelRefs.map(({ resolved }) => resolved);
|
|
906
1279
|
const tools = agentCalls.flatMap((call) => {
|
|
907
1280
|
const value = propertyNode(call.arguments[1], "tools");
|
|
908
1281
|
return value?.type === "ArrayExpression" ? value.elements.flatMap((element) => { const tool = element && element.type !== "SpreadElement" ? literalString(element) : undefined; return tool === undefined ? [] : [tool]; }) : [];
|
|
909
1282
|
});
|
|
910
1283
|
const agentTypes = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "role")); return value === undefined ? [] : [value]; });
|
|
911
|
-
const missingModel =
|
|
912
|
-
if (missingModel)
|
|
913
|
-
|
|
1284
|
+
const missingModel = capabilities.skipModelAvailability ? undefined : modelRefs.find(({ resolved }) => !capabilities.models.has(resolved));
|
|
1285
|
+
if (missingModel) {
|
|
1286
|
+
if (Object.prototype.hasOwnProperty.call(capabilities.modelAliases ?? {}, missingModel.requested))
|
|
1287
|
+
unknownModel(missingModel.requested, missingModel.resolved, capabilities.settingsPath);
|
|
1288
|
+
fail("UNKNOWN_MODEL", `Unknown model: ${missingModel.resolved}`);
|
|
1289
|
+
}
|
|
914
1290
|
const missingTool = tools.find((tool) => !capabilities.tools.has(tool));
|
|
915
1291
|
if (missingTool)
|
|
916
1292
|
fail("UNKNOWN_TOOL", `Unknown tool: ${missingTool}`);
|
|
@@ -923,6 +1299,8 @@ export function validateWorkflowLaunch(params, context) {
|
|
|
923
1299
|
return validateWorkflowLaunchWithRegistry(params, context, loadingRegistry());
|
|
924
1300
|
}
|
|
925
1301
|
function validateWorkflowLaunchWithRegistry(params, context, registry) {
|
|
1302
|
+
if (Object.prototype.hasOwnProperty.call(params, "maxAgentLaunches"))
|
|
1303
|
+
fail("INVALID_METADATA", "maxAgentLaunches has been removed; use budget.agentLaunches");
|
|
926
1304
|
if (params.script !== undefined && params.workflow !== undefined)
|
|
927
1305
|
fail("INVALID_METADATA", "Provide either script or workflow, not both");
|
|
928
1306
|
const definition = typeof params.workflow === "string" ? registry.workflow(params.workflow) : undefined;
|
|
@@ -936,9 +1314,11 @@ function validateWorkflowLaunchWithRegistry(params, context, registry) {
|
|
|
936
1314
|
const globalAgentDefinitions = loadAgentDefinitions(context.cwd, undefined, false);
|
|
937
1315
|
const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
|
|
938
1316
|
const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
|
|
939
|
-
const
|
|
1317
|
+
const aliases = context.modelAliases ?? {};
|
|
1318
|
+
const knownModels = context.knownModels ?? context.availableModels;
|
|
1319
|
+
const checked = preflight(script, { models: context.availableModels, tools: context.rootTools, agentTypes: new Set(Object.keys(agentDefinitions)), modelAliases: aliases, knownModels, ...(context.settingsPath ? { settingsPath: context.settingsPath } : {}) }, [], metadata);
|
|
940
1320
|
const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
|
|
941
|
-
validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools);
|
|
1321
|
+
validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools, aliases, knownModels, context.settingsPath);
|
|
942
1322
|
return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames };
|
|
943
1323
|
}
|
|
944
1324
|
function deepFreeze(value) {
|
|
@@ -953,6 +1333,8 @@ export function createLaunchSnapshot(input) {
|
|
|
953
1333
|
return deepFreeze(structuredClone({ ...input, identityVersion: input.identityVersion ?? LAUNCH_SNAPSHOT_IDENTITY_VERSION }));
|
|
954
1334
|
}
|
|
955
1335
|
export function loadLaunchSnapshot(input) {
|
|
1336
|
+
if (object(input.settings) && Object.prototype.hasOwnProperty.call(input.settings, "maxAgentLaunches"))
|
|
1337
|
+
fail("RESUME_INCOMPATIBLE", "Persisted workflow settings contain removed maxAgentLaunches");
|
|
956
1338
|
return deepFreeze(structuredClone(input));
|
|
957
1339
|
}
|
|
958
1340
|
export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
|
|
@@ -1020,6 +1402,7 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
|
|
|
1020
1402
|
const path = (...names) => names.map(encodeURIComponent).join("/");
|
|
1021
1403
|
const inheritedAgentPath = new AsyncLocalStorage();
|
|
1022
1404
|
const agentOccurrences = new Map();
|
|
1405
|
+
const conversationOccurrences = new Map();
|
|
1023
1406
|
const worktreeOwners = new AsyncLocalStorage();
|
|
1024
1407
|
const worktreeOccurrences = new Map();
|
|
1025
1408
|
const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
|
|
@@ -1043,6 +1426,44 @@ const internalWithWorktree = async (...values) => {
|
|
|
1043
1426
|
}
|
|
1044
1427
|
return await worktreeOwners.run(owner, callback);
|
|
1045
1428
|
};
|
|
1429
|
+
const internalConversation = (...values) => {
|
|
1430
|
+
const callSite = values.pop();
|
|
1431
|
+
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow conversation call-site identity");
|
|
1432
|
+
const name = values[0];
|
|
1433
|
+
if (typeof name !== "string" || !name.trim()) throw workError("INVALID_METADATA", "conversation requires a non-empty name");
|
|
1434
|
+
const conversationOptions = values.length < 2 || values[1] === undefined ? {} : values[1];
|
|
1435
|
+
if (!conversationOptions || typeof conversationOptions !== "object" || Array.isArray(conversationOptions)) throw workError("INVALID_METADATA", "conversation options must be a JSON object");
|
|
1436
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
1437
|
+
const occurrenceKey = JSON.stringify([inherited, callSite, name]);
|
|
1438
|
+
const occurrence = (conversationOccurrences.get(occurrenceKey) || 0) + 1;
|
|
1439
|
+
conversationOccurrences.set(occurrenceKey, occurrence);
|
|
1440
|
+
const fixedOptions = structuredClone(conversationOptions);
|
|
1441
|
+
const defaultTimeout = fixedOptions.timeoutMs;
|
|
1442
|
+
const defaultRetries = fixedOptions.retries;
|
|
1443
|
+
delete fixedOptions.timeoutMs;
|
|
1444
|
+
delete fixedOptions.retries;
|
|
1445
|
+
const worktreeOwner = worktreeOwners.getStore();
|
|
1446
|
+
let turn = 0;
|
|
1447
|
+
let active = false;
|
|
1448
|
+
return Object.freeze({
|
|
1449
|
+
run(prompt, turnOptions = {}) {
|
|
1450
|
+
if (typeof prompt !== "string") throw workError("INVALID_METADATA", "conversation.run prompt must be a string");
|
|
1451
|
+
if (!turnOptions || typeof turnOptions !== "object" || Array.isArray(turnOptions) || Object.keys(turnOptions).some(key => key !== "timeoutMs" && key !== "retries")) throw workError("INVALID_METADATA", "conversation.run options only support timeoutMs and retries");
|
|
1452
|
+
if (active) throw workError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
|
|
1453
|
+
active = true;
|
|
1454
|
+
const turnNumber = turn + 1;
|
|
1455
|
+
const options = { ...fixedOptions, ...(defaultTimeout !== undefined && turnOptions.timeoutMs === undefined ? { timeoutMs: defaultTimeout } : {}), ...(defaultRetries !== undefined && turnOptions.retries === undefined ? { retries: defaultRetries } : {}), ...turnOptions };
|
|
1456
|
+
const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}), conversation: { name: name.trim(), turn: turnNumber } };
|
|
1457
|
+
const result = rpc("agent", [prompt, options, identity]).then(value => { const unwrapped = unwrap(value); turn = turnNumber; return unwrapped; }).finally(() => { active = false; });
|
|
1458
|
+
Object.defineProperties(result, {
|
|
1459
|
+
toJSON: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before serialization"); } },
|
|
1460
|
+
toString: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
|
|
1461
|
+
[Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
|
|
1462
|
+
});
|
|
1463
|
+
return result;
|
|
1464
|
+
},
|
|
1465
|
+
});
|
|
1466
|
+
};
|
|
1046
1467
|
const internalAgent = (...values) => {
|
|
1047
1468
|
const callSite = values.pop();
|
|
1048
1469
|
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
|
|
@@ -1112,16 +1533,17 @@ const checkpoint = input => rpc("checkpoint", [input]).then(unwrap);
|
|
|
1112
1533
|
const phase = name => rpc("phase", [name]);
|
|
1113
1534
|
const log = message => rpc("log", [message]);
|
|
1114
1535
|
const functionOccurrences = new Map();
|
|
1115
|
-
const functionPath =
|
|
1536
|
+
const functionPath = name => {
|
|
1116
1537
|
const inherited = inheritedAgentPath.getStore() || [];
|
|
1117
|
-
const key = JSON.stringify([inherited,
|
|
1538
|
+
const key = JSON.stringify([inherited, name]);
|
|
1118
1539
|
const occurrence = (functionOccurrences.get(key) || 0) + 1;
|
|
1119
1540
|
functionOccurrences.set(key, occurrence);
|
|
1120
|
-
return path("function", ...inherited,
|
|
1541
|
+
return path("function", ...inherited, name, String(occurrence));
|
|
1121
1542
|
};
|
|
1122
1543
|
const functions = Object.freeze(Object.fromEntries(Object.entries(config.functions || {}).map(([local, target]) => [local, (...values) => {
|
|
1123
1544
|
if (values.length !== 1 || !values[0] || typeof values[0] !== "object" || Array.isArray(values[0])) throw workError("RESULT_INVALID", local + " requires exactly one JSON object argument");
|
|
1124
|
-
const
|
|
1545
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
1546
|
+
const result = rpc("function", [target.name, values[0], functionPath(target.name), worktreeOwners.getStore() || null, inherited]).then(unwrap);
|
|
1125
1547
|
Object.defineProperty(result, "toJSON", { value() { throw workError("INVALID_METADATA", "Workflow function result is a Promise; await it before serialization"); } });
|
|
1126
1548
|
return result;
|
|
1127
1549
|
}])));
|
|
@@ -1182,13 +1604,13 @@ const pipeline = async (operationName, items, stages) => {
|
|
|
1182
1604
|
return Object.fromEntries(results.map(result => [result.name, result.value]));
|
|
1183
1605
|
};
|
|
1184
1606
|
const safeMath = Object.fromEntries(Object.getOwnPropertyNames(Math).filter(name => name !== "random").map(name => [name, Math[name]]));
|
|
1185
|
-
const sandbox = { agent, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
|
|
1607
|
+
const sandbox = { agent, conversation: internalConversation, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
|
|
1186
1608
|
for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
|
|
1187
1609
|
for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
|
|
1188
1610
|
for (const name of ["Date","eval","Function","WebAssembly","process","require","module","exports","console","fetch","XMLHttpRequest","WebSocket","performance","crypto","setTimeout","setInterval","setImmediate","queueMicrotask","Intl","SharedArrayBuffer","Atomics"]) sandbox[name] = undefined;
|
|
1189
1611
|
const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
|
|
1190
1612
|
const body = config.script;
|
|
1191
|
-
Promise.resolve().then(() => new vm.Script("(async(__pi_extensible_workflows_agent,__pi_extensible_workflows_withWorktree)=>{" + body + "\n})", { filename: "workflow.js" }).runInContext(context)(internalAgent, internalWithWorktree))
|
|
1613
|
+
Promise.resolve().then(() => new vm.Script("(async(__pi_extensible_workflows_agent,__pi_extensible_workflows_conversation,__pi_extensible_workflows_withWorktree)=>{" + body + "\n})", { filename: "workflow.js" }).runInContext(context)(internalAgent, internalConversation, internalWithWorktree))
|
|
1192
1614
|
.then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
|
|
1193
1615
|
.catch(error => send({ type: "error", error: workerError(error) }))
|
|
1194
1616
|
.finally(() => clearInterval(heartbeat));
|
|
@@ -1209,13 +1631,25 @@ function readAgentIdentity(value) {
|
|
|
1209
1631
|
const occurrence = value.occurrence;
|
|
1210
1632
|
const worktreeOwner = value.worktreeOwner;
|
|
1211
1633
|
const parentBreadcrumb = value.parentBreadcrumb;
|
|
1212
|
-
|
|
1634
|
+
const conversation = value.conversation;
|
|
1635
|
+
const parsedConversation = object(conversation) && typeof conversation.name === "string" && Boolean(conversation.name.trim()) && positiveInteger(conversation.turn) ? { name: conversation.name, turn: conversation.turn } : undefined;
|
|
1636
|
+
if (!Array.isArray(structuralPath) || !structuralPath.every((part) => typeof part === "string" && Boolean(part.trim())) || typeof callSite !== "string" || !callSite || !positiveInteger(occurrence) || parentBreadcrumb !== undefined && (typeof parentBreadcrumb !== "string" || !parentBreadcrumb.trim()) || worktreeOwner !== undefined && (typeof worktreeOwner !== "string" || !worktreeOwner) || conversation !== undefined && !parsedConversation)
|
|
1213
1637
|
fail("INTERNAL_ERROR", "Invalid workflow agent identity");
|
|
1214
|
-
return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}) };
|
|
1638
|
+
return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}), ...(parsedConversation ? { conversation: parsedConversation } : {}) };
|
|
1215
1639
|
}
|
|
1216
1640
|
function agentIdentityPath(identity) {
|
|
1217
1641
|
return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
|
|
1218
1642
|
}
|
|
1643
|
+
function conversationIdentityPath(identity) {
|
|
1644
|
+
if (!identity.conversation)
|
|
1645
|
+
throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
|
|
1646
|
+
return operationPath("conversation", identity.conversation.name, ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
|
|
1647
|
+
}
|
|
1648
|
+
function conversationTurnPath(identity) {
|
|
1649
|
+
if (!identity.conversation)
|
|
1650
|
+
throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
|
|
1651
|
+
return operationPath(conversationIdentityPath(identity), `turn:${String(identity.conversation.turn)}`);
|
|
1652
|
+
}
|
|
1219
1653
|
function agentWorktree(identity) {
|
|
1220
1654
|
return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
|
|
1221
1655
|
}
|
|
@@ -1340,12 +1774,15 @@ export function runWorkflow(script, args = null, bridge = {}, signal) {
|
|
|
1340
1774
|
}
|
|
1341
1775
|
}
|
|
1342
1776
|
else if (method === "function") {
|
|
1343
|
-
const worktreeOwner = values[
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1777
|
+
const worktreeOwner = values[3] === undefined || values[3] === null ? undefined : typeof values[3] === "string" && values[3] ? values[3] : fail("INTERNAL_ERROR", "function worktree scope is invalid");
|
|
1778
|
+
const structuralPath = values[4] === undefined ? [] : values[4];
|
|
1779
|
+
if (!Array.isArray(structuralPath) || !structuralPath.every((part) => typeof part === "string" && Boolean(part.trim())))
|
|
1780
|
+
fail("INTERNAL_ERROR", "function structural scope is invalid");
|
|
1781
|
+
if (!bridge.function || typeof values[0] !== "string" || !object(values[1]) || typeof values[2] !== "string")
|
|
1782
|
+
fail("INTERNAL_ERROR", "function requires an available bridge, name, object input, and path");
|
|
1783
|
+
const name = values[0];
|
|
1347
1784
|
try {
|
|
1348
|
-
const result = await bridge.function(values[0], values[1], values[2],
|
|
1785
|
+
const result = await bridge.function(values[0], values[1], values[2], controller.signal, worktreeOwner, structuralPath);
|
|
1349
1786
|
value = branded({ name, ok: true, value: result ?? null });
|
|
1350
1787
|
}
|
|
1351
1788
|
catch (error) {
|
|
@@ -1397,8 +1834,9 @@ export async function persistActiveAgentAttempt(store, id, active) {
|
|
|
1397
1834
|
if (!agent)
|
|
1398
1835
|
throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
|
|
1399
1836
|
const accounting = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
1837
|
+
const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), { ...active, accounting }];
|
|
1400
1838
|
const nativeSessions = run.nativeSessions.some(({ sessionId }) => sessionId === active.sessionId) ? run.nativeSessions : [...run.nativeSessions, nativeSessionReference(active)];
|
|
1401
|
-
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: active.attempt, attemptDetails:
|
|
1839
|
+
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), nativeSessions };
|
|
1402
1840
|
});
|
|
1403
1841
|
}
|
|
1404
1842
|
export async function persistAgentAttempts(store, id, attempts) {
|
|
@@ -1407,26 +1845,53 @@ export async function persistAgentAttempts(store, id, attempts) {
|
|
|
1407
1845
|
if (!agent)
|
|
1408
1846
|
throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
|
|
1409
1847
|
const total = attempts.reduce((sum, attempt) => ({ input: sum.input + attempt.accounting.input, output: sum.output + attempt.accounting.output, cacheRead: sum.cacheRead + attempt.accounting.cacheRead, cacheWrite: sum.cacheWrite + attempt.accounting.cacheWrite, cost: sum.cost + attempt.accounting.cost }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
|
|
1410
|
-
const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting }));
|
|
1848
|
+
const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting, setup }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting, ...(setup ? { setup } : {}) }));
|
|
1411
1849
|
const sessionIds = new Set(attempts.map(({ sessionId }) => sessionId));
|
|
1412
1850
|
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: attempts.length, attemptDetails, accounting: total } : candidate), nativeSessions: [...run.nativeSessions.filter(({ sessionId }) => !sessionIds.has(sessionId)), ...attempts.map((attempt) => nativeSessionReference(attempt))] };
|
|
1413
1851
|
});
|
|
1414
1852
|
}
|
|
1853
|
+
function agentGroupKey(agent) { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
|
|
1854
|
+
function agentGroupLabel(agents) {
|
|
1855
|
+
const structural = agents[0]?.structuralPath ?? [];
|
|
1856
|
+
const breadcrumbs = [...new Set(agents.map((agent) => agent.parentBreadcrumb).filter((value) => Boolean(value)))];
|
|
1857
|
+
return [...(structural.length ? [structural.join(" > ")] : []), ...(breadcrumbs.length === 1 ? breadcrumbs : breadcrumbs.length ? [breadcrumbs.join(" | ")] : [])].join(" > ") || "Agents";
|
|
1858
|
+
}
|
|
1859
|
+
function agentGroups(agents) {
|
|
1860
|
+
const byId = new Map(agents.map((agent) => [agent.id, agent]));
|
|
1861
|
+
const groups = new Map();
|
|
1862
|
+
for (const [index, agent] of agents.entries()) {
|
|
1863
|
+
let depth = 0;
|
|
1864
|
+
for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId)
|
|
1865
|
+
depth += 1;
|
|
1866
|
+
const key = agentGroupKey(agent);
|
|
1867
|
+
const group = groups.get(key) ?? { agents: [] };
|
|
1868
|
+
group.agents.push({ agent, index, depth });
|
|
1869
|
+
groups.set(key, group);
|
|
1870
|
+
}
|
|
1871
|
+
return [...groups].map(([, group]) => ({ label: agentGroupLabel(group.agents.map(({ agent }) => agent)), entries: group.agents }));
|
|
1872
|
+
}
|
|
1873
|
+
function renderGroupedAgents(agents, render) {
|
|
1874
|
+
const groups = agentGroups(agents);
|
|
1875
|
+
const grouped = groups.length > 1 || groups.some(({ label }) => label !== "Agents");
|
|
1876
|
+
return groups.flatMap((group) => [
|
|
1877
|
+
...(grouped ? [` ${group.label}`] : []),
|
|
1878
|
+
...group.entries.map((entry) => render(entry, grouped)),
|
|
1879
|
+
]);
|
|
1880
|
+
}
|
|
1415
1881
|
export function formatWorkflowProgress(run, spinner = "◇") {
|
|
1416
1882
|
const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
|
|
1417
|
-
const lines = [`${run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? spinner : "◆"} Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`];
|
|
1883
|
+
const lines = [`${run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? spinner : "◆"} Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`];
|
|
1418
1884
|
if (run.phase)
|
|
1419
1885
|
lines.push(` Phase: ${run.phase}`);
|
|
1886
|
+
lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${line}`));
|
|
1420
1887
|
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
1421
|
-
|
|
1422
|
-
let depth = 0;
|
|
1423
|
-
for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId)
|
|
1424
|
-
depth += 1;
|
|
1888
|
+
lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
|
|
1425
1889
|
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
|
|
1426
|
-
const indent = " ".repeat(
|
|
1890
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
1427
1891
|
const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner);
|
|
1428
|
-
|
|
1429
|
-
|
|
1892
|
+
const name = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
|
|
1893
|
+
return `${indent}#${String(index + 1)} ${icon} ${name} [${agent.state}]${activity ? ` ${activity}` : ""}`;
|
|
1894
|
+
}));
|
|
1430
1895
|
return lines.join("\n");
|
|
1431
1896
|
}
|
|
1432
1897
|
function workflowToolUpdate(run) {
|
|
@@ -1450,7 +1915,31 @@ function workflowProgressBlock(run) {
|
|
|
1450
1915
|
invalidate() { },
|
|
1451
1916
|
};
|
|
1452
1917
|
}
|
|
1453
|
-
|
|
1918
|
+
export function formatBudgetStatus(run) {
|
|
1919
|
+
const usage = budgetUsage(run.usage);
|
|
1920
|
+
if (!run.budget || !Object.keys(run.budget).length)
|
|
1921
|
+
return ["Budget: unlimited"];
|
|
1922
|
+
const lines = [`Budget version ${String(run.budgetVersion ?? 1)}`];
|
|
1923
|
+
for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"]) {
|
|
1924
|
+
const limits = run.budget[dimension];
|
|
1925
|
+
if (!limits || (limits.soft === undefined && limits.hard === undefined))
|
|
1926
|
+
continue;
|
|
1927
|
+
const limit = limits.hard ?? limits.soft;
|
|
1928
|
+
const percent = limit === undefined ? "" : ` ${limit === 0 ? "100.0" : ((usage[dimension] / limit) * 100).toFixed(1)}%`;
|
|
1929
|
+
const state = (run.budgetEvents ?? []).filter((event) => event.dimensions.includes(dimension)).at(-1)?.type;
|
|
1930
|
+
lines.push(` ${dimension}: ${String(usage[dimension])}${limits.soft !== undefined ? ` soft=${String(limits.soft)}` : ""}${limits.hard !== undefined ? ` hard=${String(limits.hard)}` : ""}${percent}${state ? ` state=${state}` : ""}`);
|
|
1931
|
+
}
|
|
1932
|
+
const events = run.budgetEvents ?? [];
|
|
1933
|
+
if (events.length)
|
|
1934
|
+
lines.push(` events: ${events.map((event) => `${event.type}@v${String(event.budgetVersion)}`).join(", ")}`);
|
|
1935
|
+
return lines;
|
|
1936
|
+
}
|
|
1937
|
+
function formatCompactBudgetStatus(run) {
|
|
1938
|
+
if (!Object.values(run.budget ?? {}).some((limits) => limits.soft !== undefined || limits.hard !== undefined))
|
|
1939
|
+
return [];
|
|
1940
|
+
return formatBudgetStatus(run);
|
|
1941
|
+
}
|
|
1942
|
+
const ATTENTION_ORDER = { awaiting_input: 0, budget_exhausted: 1, running: 2, pausing: 3, paused: 4, interrupted: 5, failed: 6, queued: 7, stopped: 8, completed: 9 };
|
|
1454
1943
|
function navigatorAttentionSort(entries) {
|
|
1455
1944
|
return [...entries].sort((a, b) => (ATTENTION_ORDER[a.loaded.run.state] ?? 9) - (ATTENTION_ORDER[b.loaded.run.state] ?? 9));
|
|
1456
1945
|
}
|
|
@@ -1460,7 +1949,7 @@ function navigatorRunLabels(entries) {
|
|
|
1460
1949
|
nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
|
|
1461
1950
|
return entries.map(({ store, loaded: { run } }) => {
|
|
1462
1951
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
1463
|
-
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1952
|
+
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1464
1953
|
const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
|
|
1465
1954
|
const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
|
|
1466
1955
|
const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
|
|
@@ -1469,7 +1958,7 @@ function navigatorRunLabels(entries) {
|
|
|
1469
1958
|
}
|
|
1470
1959
|
function agentBreadcrumb(agent, byId) {
|
|
1471
1960
|
const name = agent.label ?? agent.name;
|
|
1472
|
-
const parts = [
|
|
1961
|
+
const parts = agent.parentBreadcrumb ? [agent.parentBreadcrumb] : [];
|
|
1473
1962
|
const seen = new Set([agent.id]);
|
|
1474
1963
|
for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
|
|
1475
1964
|
if (seen.has(parentId))
|
|
@@ -1477,10 +1966,11 @@ function agentBreadcrumb(agent, byId) {
|
|
|
1477
1966
|
seen.add(parentId);
|
|
1478
1967
|
const parent = byId.get(parentId);
|
|
1479
1968
|
if (parent)
|
|
1480
|
-
parts.
|
|
1969
|
+
parts.push(parent.label ?? parent.name);
|
|
1481
1970
|
else
|
|
1482
1971
|
break;
|
|
1483
1972
|
}
|
|
1973
|
+
parts.push(name);
|
|
1484
1974
|
return parts.length > 1 ? parts.join(" > ") : name;
|
|
1485
1975
|
}
|
|
1486
1976
|
function formatAgentActivity(agent, spinner) {
|
|
@@ -1498,53 +1988,49 @@ function formatAccounting(accounting) {
|
|
|
1498
1988
|
return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
|
|
1499
1989
|
}
|
|
1500
1990
|
export function formatNavigatorDashboard(run, checkpoints, worktrees) {
|
|
1991
|
+
void worktrees;
|
|
1501
1992
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
1502
1993
|
const totalAccounting = run.agents.reduce((sum, a) => ({ input: sum.input + (a.accounting?.input ?? 0), output: sum.output + (a.accounting?.output ?? 0), cacheRead: sum.cacheRead + (a.accounting?.cacheRead ?? 0), cacheWrite: sum.cacheWrite + (a.accounting?.cacheWrite ?? 0), cost: sum.cost + (a.accounting?.cost ?? 0) }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
|
|
1503
1994
|
const hasAccounting = run.agents.some((a) => a.accounting);
|
|
1504
|
-
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1995
|
+
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1505
1996
|
const header = `${glyph} ${run.workflowName}`;
|
|
1506
1997
|
const meta = [run.state, run.phase ? `phase: ${run.phase}` : "", `${String(done)}/${String(run.agents.length)} agents`, hasAccounting ? formatAccounting(totalAccounting) : "", totalAccounting.cost > 0 ? `$${totalAccounting.cost.toFixed(2)}` : ""].filter(Boolean).join(" · ");
|
|
1507
|
-
const lines = [header, meta];
|
|
1998
|
+
const lines = [header, meta, ...formatCompactBudgetStatus(run)];
|
|
1508
1999
|
if (run.error)
|
|
1509
2000
|
lines.push(`Error: ${run.error.code}: ${run.error.message}`);
|
|
2001
|
+
if (run.events?.length)
|
|
2002
|
+
lines.push(...run.events.map((event) => `Warning: ${event.message}`));
|
|
1510
2003
|
lines.push("");
|
|
1511
2004
|
const byId = new Map(run.agents.map((a) => [a.id, a]));
|
|
1512
|
-
const
|
|
1513
|
-
const prioritised = [...run.agents].sort((a, b) => {
|
|
1514
|
-
const aActive = activeStates.has(a.state) || a.state === "failed" ? 0 : 1;
|
|
1515
|
-
const bActive = activeStates.has(b.state) || b.state === "failed" ? 0 : 1;
|
|
1516
|
-
return aActive - bActive;
|
|
1517
|
-
});
|
|
1518
|
-
for (const agent of prioritised) {
|
|
2005
|
+
const render = ({ agent, depth }, grouped) => {
|
|
1519
2006
|
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
|
|
1520
|
-
const breadcrumb = agentBreadcrumb(agent, byId);
|
|
1521
|
-
const
|
|
1522
|
-
const
|
|
2007
|
+
const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
|
|
2008
|
+
const setup = agent.attemptDetails?.at(-1)?.setup;
|
|
2009
|
+
const thinking = setup?.model.thinking ?? agent.model.thinking;
|
|
2010
|
+
const model = `${setup?.model.provider ?? agent.model.provider}/${setup?.model.model ?? agent.model.model}${thinking ? `:${thinking}` : ""}`;
|
|
2011
|
+
const policy = [`model=${model}`, agent.requestedModel ? `requested=${agent.requestedModel}` : "", `tools=${(setup?.tools ?? agent.tools).join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
|
|
1523
2012
|
const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
|
|
1524
|
-
const
|
|
1525
|
-
|
|
2013
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
2014
|
+
const result = [`${indent}${icon} ${breadcrumb} · ${agent.state} · ${policy.filter(Boolean).join(" · ")}${tokens ? ` · ${tokens}` : ""}`];
|
|
1526
2015
|
if (agent.state === "failed" && agent.attemptDetails?.length) {
|
|
1527
2016
|
const last = agent.attemptDetails[agent.attemptDetails.length - 1];
|
|
1528
2017
|
if (last?.error)
|
|
1529
|
-
|
|
2018
|
+
result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
|
|
1530
2019
|
}
|
|
1531
2020
|
const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
|
|
1532
2021
|
if (activity)
|
|
1533
|
-
|
|
1534
|
-
|
|
2022
|
+
result.push(`${indent} ${activity}`);
|
|
2023
|
+
return result.join("\n");
|
|
2024
|
+
};
|
|
2025
|
+
lines.push(...renderGroupedAgents(run.agents, render));
|
|
1535
2026
|
if (checkpoints.length) {
|
|
1536
2027
|
lines.push("");
|
|
1537
2028
|
for (const cp of checkpoints)
|
|
1538
2029
|
lines.push(`● checkpoint ${cp.name}: ${cp.prompt}`);
|
|
1539
2030
|
}
|
|
1540
|
-
if (worktrees.length) {
|
|
1541
|
-
lines.push("");
|
|
1542
|
-
for (const wt of worktrees)
|
|
1543
|
-
lines.push(`branch ${wt.branch} (${wt.path})`);
|
|
1544
|
-
}
|
|
1545
2031
|
return lines.join("\n");
|
|
1546
2032
|
}
|
|
1547
|
-
export function formatNavigatorRun(loaded, checkpoints,
|
|
2033
|
+
export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
|
|
1548
2034
|
const { run, snapshot } = loaded;
|
|
1549
2035
|
const lines = [
|
|
1550
2036
|
`Workflow: ${run.workflowName}`,
|
|
@@ -1552,39 +2038,40 @@ export function formatNavigatorRun(loaded, checkpoints, worktrees) {
|
|
|
1552
2038
|
`Status: ${run.state}`,
|
|
1553
2039
|
`Phase: ${run.phase ?? "(none)"}`,
|
|
1554
2040
|
`Launch cwd: ${run.cwd}`,
|
|
2041
|
+
...formatCompactBudgetStatus(run),
|
|
1555
2042
|
`Launch models: ${snapshot.models.join(", ") || "(none)"}`,
|
|
1556
2043
|
];
|
|
1557
2044
|
if (run.error)
|
|
1558
2045
|
lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
|
|
2046
|
+
if (run.events?.length)
|
|
2047
|
+
lines.push(...run.events.map((event) => `Warning: ${event.message}`));
|
|
2048
|
+
const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
|
|
2049
|
+
if (aliases && Object.keys(aliases).length)
|
|
2050
|
+
lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
|
|
1559
2051
|
lines.push("Agents / ownership:");
|
|
1560
2052
|
if (!run.agents.length)
|
|
1561
2053
|
lines.push(" (none)");
|
|
1562
|
-
|
|
2054
|
+
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
2055
|
+
lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
|
|
1563
2056
|
const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
|
|
1564
2057
|
const role = agent.role ? ` role=${agent.role}` : "";
|
|
1565
2058
|
const tools = ` tools=${agent.tools.join(",") || "(none)"}`;
|
|
1566
2059
|
const accounting = agent.accounting ? ` input=${String(agent.accounting.input)} output=${String(agent.accounting.output)} cache-read=${String(agent.accounting.cacheRead)} cache-write=${String(agent.accounting.cacheWrite)} cost=${String(agent.accounting.cost)}` : "";
|
|
1567
|
-
|
|
2060
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
2061
|
+
const result = [`${indent}#${String(index + 1)} ${grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId)} state=${agent.state} model=${model}${agent.requestedModel ? ` requested=${agent.requestedModel}` : ""}${role}${tools} attempts=${String(agent.attempts)} retries=${String(Math.max(0, agent.attempts - 1))}${accounting}`];
|
|
1568
2062
|
for (const attempt of agent.attemptDetails ?? [])
|
|
1569
|
-
|
|
2063
|
+
result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
|
|
1570
2064
|
for (const call of agent.toolCalls ?? [])
|
|
1571
|
-
|
|
1572
|
-
|
|
2065
|
+
result.push(`${indent} tool ${call.name} state=${call.state}`);
|
|
2066
|
+
return result.join("\n");
|
|
2067
|
+
}));
|
|
1573
2068
|
lines.push("Checkpoints:");
|
|
1574
2069
|
if (!checkpoints.length)
|
|
1575
2070
|
lines.push(" (none)");
|
|
1576
2071
|
for (const checkpoint of checkpoints)
|
|
1577
2072
|
lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
|
|
1578
|
-
lines.push(
|
|
1579
|
-
|
|
1580
|
-
lines.push(" (none)");
|
|
1581
|
-
for (const worktree of worktrees)
|
|
1582
|
-
lines.push(` ${worktree.owner}: branch=${worktree.branch} path=${worktree.path} cwd=${worktree.cwd}`);
|
|
1583
|
-
lines.push("Native Pi transcript paths:");
|
|
1584
|
-
if (!run.nativeSessions.length)
|
|
1585
|
-
lines.push(" (none)");
|
|
1586
|
-
for (const session of run.nativeSessions)
|
|
1587
|
-
lines.push(` ${session.sessionId}: ${session.sessionFile}`);
|
|
2073
|
+
lines.push(`Worktrees: ${String(_worktrees.length)}`);
|
|
2074
|
+
lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
|
|
1588
2075
|
return lines.join("\n");
|
|
1589
2076
|
}
|
|
1590
2077
|
function formatCheckpointReview(checkpoint) {
|
|
@@ -1616,6 +2103,98 @@ function deliver(pi, content) {
|
|
|
1616
2103
|
function deliverFailure(pi, name, error) {
|
|
1617
2104
|
deliver(pi, `Workflow ${name} failed: ${formatWorkflowFailure(error)}`);
|
|
1618
2105
|
}
|
|
2106
|
+
function safeEventError(error) {
|
|
2107
|
+
const code = errorCode(error) ?? "INTERNAL_ERROR";
|
|
2108
|
+
return { code, message: `Workflow execution failed (${code})` };
|
|
2109
|
+
}
|
|
2110
|
+
class WorkflowEventPublisher {
|
|
2111
|
+
sink;
|
|
2112
|
+
#queues = new Map();
|
|
2113
|
+
#budgetEvents = new Map();
|
|
2114
|
+
#worktrees = new Map();
|
|
2115
|
+
constructor(sink) {
|
|
2116
|
+
this.sink = sink;
|
|
2117
|
+
}
|
|
2118
|
+
seedBudget(runId, events) {
|
|
2119
|
+
const seen = this.#budgetEvents.get(runId) ?? new Set();
|
|
2120
|
+
for (const event of events ?? [])
|
|
2121
|
+
seen.add(this.budgetKey(event));
|
|
2122
|
+
this.#budgetEvents.set(runId, seen);
|
|
2123
|
+
}
|
|
2124
|
+
async runStarted(store, metadata, background) {
|
|
2125
|
+
await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {});
|
|
2126
|
+
if (background)
|
|
2127
|
+
await this.legacyStarted(store, metadata);
|
|
2128
|
+
}
|
|
2129
|
+
async legacyStarted(store, metadata) { await this.#publish(store, metadata, WORKFLOW_ASYNC_STARTED_EVENT, { id: store.runId, runId: store.runId, pid: process.pid, sessionId: store.sessionId, asyncDir: store.directory, agent: metadata.name }); }
|
|
2130
|
+
async runResumed(store, metadata) { await this.#publish(store, metadata, WORKFLOW_RUN_RESUMED_EVENT, {}); }
|
|
2131
|
+
async runState(store, metadata, previousState, state, reason) {
|
|
2132
|
+
await this.#publish(store, metadata, WORKFLOW_RUN_STATE_CHANGED_EVENT, { previousState, state, ...(reason ? { reason } : {}), ...(ERROR_CODES.includes(reason) ? { errorCode: reason } : {}) });
|
|
2133
|
+
if ((previousState === "paused" || previousState === "interrupted" || previousState === "budget_exhausted") && state === "running")
|
|
2134
|
+
await this.runResumed(store, metadata);
|
|
2135
|
+
}
|
|
2136
|
+
async runCompleted(store, metadata, resultPath, background) {
|
|
2137
|
+
await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath });
|
|
2138
|
+
if (background)
|
|
2139
|
+
await this.#publish(store, metadata, WORKFLOW_ASYNC_COMPLETE_EVENT, { id: store.runId, runId: store.runId, sessionId: store.sessionId, asyncDir: store.directory, success: true, state: "complete" });
|
|
2140
|
+
}
|
|
2141
|
+
async runFailed(store, metadata, error, background, state) {
|
|
2142
|
+
if (state === "failed")
|
|
2143
|
+
await this.#publish(store, metadata, WORKFLOW_RUN_FAILED_EVENT, { error: safeEventError(error) });
|
|
2144
|
+
if (background) {
|
|
2145
|
+
const legacyState = state === "interrupted" ? "failed" : state;
|
|
2146
|
+
await this.#publish(store, metadata, WORKFLOW_ASYNC_COMPLETE_EVENT, { id: store.runId, runId: store.runId, sessionId: store.sessionId, asyncDir: store.directory, success: false, state: legacyState, ...(state === "stopped" ? { stopped: true } : {}), ...(legacyState === "budget_exhausted" || legacyState === "failed" ? { error: safeEventError(error).message } : {}) });
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
async agentState(store, metadata, previous, agent) {
|
|
2150
|
+
await this.#publish(store, metadata, WORKFLOW_AGENT_STATE_CHANGED_EVENT, { agentId: agent.id, displayLabel: agent.label ?? agent.name, ...(agent.role ? { role: agent.role } : {}), structuralPath: [...(agent.structuralPath ?? [])], ...(agent.parentId ? { parentId: agent.parentId } : {}), ...(agent.parentBreadcrumb ? { parentBreadcrumb: agent.parentBreadcrumb } : {}), ...(agent.worktreeOwner ? { worktreeOwner: agent.worktreeOwner } : {}), ...(previous ? { previousState: previous.state } : {}), state: agent.state, attempt: agent.attempts });
|
|
2151
|
+
}
|
|
2152
|
+
async agentStates(store, metadata, previous, current) {
|
|
2153
|
+
const previousById = new Map(previous.map((agent) => [agent.id, agent]));
|
|
2154
|
+
for (const agent of current) {
|
|
2155
|
+
const old = previousById.get(agent.id);
|
|
2156
|
+
if (!old || old.state !== agent.state || old.attempts !== agent.attempts)
|
|
2157
|
+
await this.agentState(store, metadata, old, agent);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
async phase(store, metadata, previousPhase, phase) {
|
|
2161
|
+
if (previousPhase !== phase)
|
|
2162
|
+
await this.#publish(store, metadata, WORKFLOW_PHASE_CHANGED_EVENT, { ...(previousPhase !== undefined ? { previousPhase } : {}), phase });
|
|
2163
|
+
}
|
|
2164
|
+
async checkpoint(store, metadata, name, state) { await this.#publish(store, metadata, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, { name, state }); }
|
|
2165
|
+
async budget(store, metadata, run) {
|
|
2166
|
+
const seen = this.#budgetEvents.get(store.runId) ?? new Set();
|
|
2167
|
+
this.#budgetEvents.set(store.runId, seen);
|
|
2168
|
+
for (const event of run.budgetEvents ?? []) {
|
|
2169
|
+
const key = this.budgetKey(event);
|
|
2170
|
+
if (seen.has(key))
|
|
2171
|
+
continue;
|
|
2172
|
+
seen.add(key);
|
|
2173
|
+
await this.#publish(store, metadata, WORKFLOW_BUDGET_EVENT, { ...event, timestamp: event.at });
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
async worktree(store, metadata, worktree) {
|
|
2177
|
+
const seen = this.#worktrees.get(store.runId) ?? new Set();
|
|
2178
|
+
this.#worktrees.set(store.runId, seen);
|
|
2179
|
+
if (seen.has(worktree.owner))
|
|
2180
|
+
return;
|
|
2181
|
+
seen.add(worktree.owner);
|
|
2182
|
+
await this.#publish(store, metadata, WORKFLOW_WORKTREE_CREATED_EVENT, { owner: worktree.owner, branch: worktree.branch, path: worktree.path, base: worktree.base });
|
|
2183
|
+
}
|
|
2184
|
+
async #publish(store, metadata, name, payload) {
|
|
2185
|
+
const base = { runId: store.runId, sessionId: store.sessionId, workflowName: metadata.name, cwd: store.cwd, runDirectory: store.directory, timestamp: Date.now() };
|
|
2186
|
+
const previous = this.#queues.get(store.runId) ?? Promise.resolve();
|
|
2187
|
+
const next = previous.then(() => {
|
|
2188
|
+
try {
|
|
2189
|
+
void Promise.resolve(this.sink?.emit(name, { ...base, ...payload })).catch(() => undefined);
|
|
2190
|
+
}
|
|
2191
|
+
catch { /* Best effort: listeners cannot affect a run. */ }
|
|
2192
|
+
});
|
|
2193
|
+
this.#queues.set(store.runId, next.catch(() => undefined));
|
|
2194
|
+
await next;
|
|
2195
|
+
}
|
|
2196
|
+
budgetKey(event) { return `${String(event.budgetVersion)}:${event.type}:${event.proposalId ?? ""}`; }
|
|
2197
|
+
}
|
|
1619
2198
|
const inheritedHostAgentPath = new AsyncLocalStorage();
|
|
1620
2199
|
const inheritedHostWorktreeOwner = new AsyncLocalStorage();
|
|
1621
2200
|
function namedRecord(value, kind) {
|
|
@@ -1649,15 +2228,15 @@ function workflowRunContext(cwd, sessionId, runId, workflow, args, signal) {
|
|
|
1649
2228
|
}
|
|
1650
2229
|
async function resolveWorkflowVariables(run, controller, registry) {
|
|
1651
2230
|
let first;
|
|
1652
|
-
const tasks = registry.variables().map(async ({
|
|
2231
|
+
const tasks = registry.variables().map(async ({ name, variable }) => {
|
|
1653
2232
|
try {
|
|
1654
2233
|
const result = await variable.resolve(run);
|
|
1655
2234
|
if (!jsonValue(result) || !Value.Check(variable.schema, result))
|
|
1656
|
-
fail("RESULT_INVALID", `Invalid output from ${
|
|
2235
|
+
fail("RESULT_INVALID", `Invalid output from ${name}`);
|
|
1657
2236
|
return [name, deepFreeze(structuredClone(result))];
|
|
1658
2237
|
}
|
|
1659
2238
|
catch (error) {
|
|
1660
|
-
const typed = errorCode(error) ? new WorkflowError(errorCode(error), `${
|
|
2239
|
+
const typed = errorCode(error) ? new WorkflowError(errorCode(error), `${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${name}: ${errorText(error)}`);
|
|
1661
2240
|
if (!first) {
|
|
1662
2241
|
first = typed;
|
|
1663
2242
|
controller.abort();
|
|
@@ -1742,7 +2321,7 @@ function nextNamedOccurrence(counters, label) {
|
|
|
1742
2321
|
function withWorkflowFunctions(bridge, store, runContext, variables, registry) {
|
|
1743
2322
|
const functionAgentOccurrences = new Map();
|
|
1744
2323
|
const functionWorktreeOccurrences = new Map();
|
|
1745
|
-
return { ...bridge, functions: registry.globals(), variables, function: async (
|
|
2324
|
+
return { ...bridge, functions: registry.globals(), variables, function: async (name, input, path, signal, worktreeOwner, structuralPath = []) => {
|
|
1746
2325
|
const replayed = await store.replay(path);
|
|
1747
2326
|
let stored;
|
|
1748
2327
|
const sideEffects = [];
|
|
@@ -1753,11 +2332,11 @@ function withWorkflowFunctions(bridge, store, runContext, variables, registry) {
|
|
|
1753
2332
|
fail("AGENT_FAILED", "No agent bridge is available");
|
|
1754
2333
|
const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
|
|
1755
2334
|
const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
|
|
1756
|
-
const
|
|
1757
|
-
const key = `${path}\0${JSON.stringify(
|
|
2335
|
+
const inherited = inheritedHostAgentPath.getStore() ?? [];
|
|
2336
|
+
const key = `${path}\0${JSON.stringify(inherited)}`;
|
|
1758
2337
|
const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
|
|
1759
2338
|
functionAgentOccurrences.set(key, occurrence);
|
|
1760
|
-
return bridge.agent(args[0], options, signal, { structuralPath: [...
|
|
2339
|
+
return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: name, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
|
|
1761
2340
|
},
|
|
1762
2341
|
prompt: workflowPrompt,
|
|
1763
2342
|
parallel: (...args) => hostParallel(args[0], args[1]),
|
|
@@ -1771,7 +2350,7 @@ function withWorkflowFunctions(bridge, store, runContext, variables, registry) {
|
|
|
1771
2350
|
phase: (name) => { sideEffects.push(Promise.resolve(bridge.phase?.(name))); },
|
|
1772
2351
|
log: (message) => { sideEffects.push(Promise.resolve(bridge.log?.(message))); },
|
|
1773
2352
|
};
|
|
1774
|
-
const result = await registry.invokeFunction(
|
|
2353
|
+
const result = await inheritedHostAgentPath.run([...structuralPath], async () => registry.invokeFunction(name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } }));
|
|
1775
2354
|
await Promise.all(sideEffects);
|
|
1776
2355
|
if (!replayed)
|
|
1777
2356
|
await store.complete(path, stored ?? result);
|
|
@@ -1811,7 +2390,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1811
2390
|
await lifecycle.leave();
|
|
1812
2391
|
}
|
|
1813
2392
|
};
|
|
1814
|
-
const
|
|
2393
|
+
const eventPublisher = new WorkflowEventPublisher(pi.events);
|
|
1815
2394
|
pi.on("resources_discover", () => {
|
|
1816
2395
|
if (!pi.getActiveTools().includes("workflow"))
|
|
1817
2396
|
return;
|
|
@@ -1819,13 +2398,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1819
2398
|
const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
|
|
1820
2399
|
return skillPath ? { skillPaths: [skillPath] } : undefined;
|
|
1821
2400
|
});
|
|
1822
|
-
const emitAsyncStarted = (store, metadata) => {
|
|
1823
|
-
events?.emit(WORKFLOW_ASYNC_STARTED_EVENT, { id: store.runId, runId: store.runId, pid: process.pid, sessionId: store.sessionId, asyncDir: store.directory, agent: metadata.name });
|
|
1824
|
-
};
|
|
1825
|
-
const emitAsyncComplete = (store, state, error) => {
|
|
1826
|
-
events?.emit(WORKFLOW_ASYNC_COMPLETE_EVENT, { id: store.runId, runId: store.runId, sessionId: store.sessionId, asyncDir: store.directory, success: state === "complete", state, ...(state === "stopped" ? { stopped: true } : {}), ...(error ? { error: error.message } : {}) });
|
|
1827
|
-
};
|
|
1828
2401
|
const runs = new Map();
|
|
2402
|
+
const conversationLocks = new Set();
|
|
2403
|
+
const terminalRunStates = new Map();
|
|
1829
2404
|
let sessionLease;
|
|
1830
2405
|
let sessionLeasePromise;
|
|
1831
2406
|
const ensureSessionLease = async (cwd, sessionId) => {
|
|
@@ -1846,30 +2421,46 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1846
2421
|
sessionLeasePromise = undefined;
|
|
1847
2422
|
await lease?.release();
|
|
1848
2423
|
};
|
|
1849
|
-
const
|
|
1850
|
-
const
|
|
1851
|
-
|
|
2424
|
+
const persistRunState = async (store, metadata, update) => {
|
|
2425
|
+
const persisted = await store.updateState(update);
|
|
2426
|
+
await eventPublisher.budget(store, metadata, persisted);
|
|
2427
|
+
return persisted;
|
|
2428
|
+
};
|
|
2429
|
+
const persistWorktree = async (store, metadata, owner) => {
|
|
2430
|
+
const existing = (await store.worktrees()).some((worktree) => worktree.owner === owner);
|
|
2431
|
+
const worktree = await store.worktree(owner);
|
|
2432
|
+
if (!existing)
|
|
2433
|
+
await eventPublisher.worktree(store, metadata, worktree);
|
|
2434
|
+
return worktree;
|
|
2435
|
+
};
|
|
2436
|
+
const lifecycleFor = (store, state, budget, metadata) => new RunLifecycle(state, async (next, previous, reason) => {
|
|
2437
|
+
if (next !== "pausing")
|
|
2438
|
+
budget.transition(next);
|
|
2439
|
+
const persisted = await persistRunState(store, metadata, (current) => {
|
|
2440
|
+
const nextRun = { ...current, state: next, ...budget.snapshot() };
|
|
1852
2441
|
if (next === "running" || next === "completed")
|
|
1853
2442
|
delete nextRun.error;
|
|
1854
2443
|
return nextRun;
|
|
1855
2444
|
});
|
|
1856
|
-
|
|
2445
|
+
await eventPublisher.runState(store, metadata, previous, next, reason);
|
|
2446
|
+
runs.get(store.runId)?.update?.(workflowToolUpdate(persisted));
|
|
1857
2447
|
});
|
|
1858
2448
|
const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
|
|
1859
2449
|
const run = runs.get(runId);
|
|
1860
2450
|
if (!run)
|
|
1861
2451
|
throw new WorkflowError("INTERNAL_ERROR", `Unknown production run: ${runId}`);
|
|
1862
2452
|
try {
|
|
2453
|
+
const budget = run.budget.forAgent(id);
|
|
1863
2454
|
const onProgress = async (progress) => {
|
|
1864
2455
|
let runState;
|
|
1865
2456
|
if (progress.persist) {
|
|
1866
|
-
runState = await run.store.
|
|
2457
|
+
runState = await persistRunState(run.store, run.metadata, (current) => current.agents.some((agent) => agent.id === id) ? { ...current, ...run.budget.snapshot(), agents: current.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) } : current);
|
|
1867
2458
|
}
|
|
1868
2459
|
else {
|
|
1869
2460
|
const loaded = await run.store.load();
|
|
1870
2461
|
if (!loaded.run.agents.some((agent) => agent.id === id))
|
|
1871
2462
|
return;
|
|
1872
|
-
runState = { ...loaded.run, agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
|
|
2463
|
+
runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
|
|
1873
2464
|
}
|
|
1874
2465
|
if (!runState.agents.some((agent) => agent.id === id))
|
|
1875
2466
|
return;
|
|
@@ -1877,17 +2468,34 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1877
2468
|
};
|
|
1878
2469
|
const onAttempt = async (attempt) => {
|
|
1879
2470
|
await scheduler.flush();
|
|
2471
|
+
scheduler.attemptStarted(id);
|
|
2472
|
+
await scheduler.flush();
|
|
2473
|
+
const before = (await run.store.load()).run;
|
|
1880
2474
|
await persistActiveAgentAttempt(run.store, id, attempt);
|
|
1881
|
-
|
|
2475
|
+
const active = (await run.store.load()).run;
|
|
2476
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
|
|
2477
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2478
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
1882
2479
|
};
|
|
1883
|
-
const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); });
|
|
2480
|
+
const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, budget, ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.agentOptions ? { agentOptions: options.agentOptions } : {}), ...(options.agentIdentity ? { agentIdentity: options.agentIdentity } : {}), ...(options.conversation ? { conversation: options.conversation } : {}) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); scheduler.retry(id); });
|
|
2481
|
+
const before = (await run.store.load()).run;
|
|
1884
2482
|
await persistAgentAttempts(run.store, id, result.attempts);
|
|
2483
|
+
const completed = (await run.store.load()).run;
|
|
2484
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
|
|
2485
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2486
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
1885
2487
|
return result.value;
|
|
1886
2488
|
}
|
|
1887
2489
|
catch (error) {
|
|
1888
2490
|
const attempts = error.attempts;
|
|
1889
|
-
if (attempts?.length)
|
|
2491
|
+
if (attempts?.length) {
|
|
2492
|
+
const before = (await run.store.load()).run;
|
|
1890
2493
|
await persistAgentAttempts(run.store, id, attempts);
|
|
2494
|
+
const failed = (await run.store.load()).run;
|
|
2495
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, failed.agents);
|
|
2496
|
+
}
|
|
2497
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2498
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
1891
2499
|
throw error;
|
|
1892
2500
|
}
|
|
1893
2501
|
}, 16, async (runId, ownership) => {
|
|
@@ -1895,7 +2503,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1895
2503
|
if (!run)
|
|
1896
2504
|
return;
|
|
1897
2505
|
await run.store.saveOwnership(ownership);
|
|
1898
|
-
|
|
2506
|
+
let previousAgents = [];
|
|
2507
|
+
const runState = await persistRunState(run.store, run.metadata, (current) => {
|
|
2508
|
+
previousAgents = current.agents;
|
|
1899
2509
|
const existing = new Map(current.agents.map((agent) => [agent.id, agent]));
|
|
1900
2510
|
const agents = ownership.map((node) => {
|
|
1901
2511
|
const previous = existing.get(node.id);
|
|
@@ -1905,14 +2515,30 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1905
2515
|
effective = run.executor.resolve(requested);
|
|
1906
2516
|
}
|
|
1907
2517
|
catch {
|
|
1908
|
-
effective = previous ? { model: previous.model, tools: previous.tools } : { model: node.options.model ? modelSpec(node.options.model, run.model) : { ...run.model, ...(node.options.thinking ? { thinking: node.options.thinking } : {}) }, tools: node.options.tools };
|
|
2518
|
+
effective = previous ? { model: previous.model, ...(previous.requestedModel ? { requestedModel: previous.requestedModel } : {}), tools: previous.tools } : { model: node.options.model ? modelSpec(node.options.model, run.model) : { ...run.model, ...(node.options.thinking ? { thinking: node.options.thinking } : {}) }, ...(node.options.model ? { requestedModel: node.options.model } : {}), tools: node.options.tools };
|
|
1909
2519
|
}
|
|
1910
|
-
return { id: node.id, name: node.label, ...(node.options.requestedLabel ? { label: node.options.requestedLabel } : {}), path: node.id, state: node.state, ...(node.parentId ? { parentId: node.parentId } : {}), ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.role ? { role: node.options.role } : {}), model: effective.model, tools: effective.tools, attempts: previous?.attempts ?? 0, ...(previous?.attemptDetails ? { attemptDetails: previous.attemptDetails } : {}), ...(previous?.accounting ? { accounting: previous.accounting } : {}), ...(previous?.toolCalls ? { toolCalls: previous.toolCalls } : {}), ...(previous?.activity ? { activity: previous.activity } : {}) };
|
|
2520
|
+
return { id: node.id, name: node.label, ...(node.options.requestedLabel ? { label: node.options.requestedLabel } : {}), path: node.id, state: node.state, ...(node.parentId ? { parentId: node.parentId } : {}), structuralPath: [...(node.options.agentIdentity?.structuralPath ?? [])], ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.worktreeOwner ? { worktreeOwner: node.options.worktreeOwner } : {}), ...(node.options.role ? { role: node.options.role } : {}), ...(effective.requestedModel ? { requestedModel: effective.requestedModel } : {}), model: effective.model, tools: effective.tools, attempts: previous?.attempts ?? 0, ...(previous?.attemptDetails ? { attemptDetails: previous.attemptDetails } : {}), ...(previous?.accounting ? { accounting: previous.accounting } : {}), ...(previous?.toolCalls ? { toolCalls: previous.toolCalls } : {}), ...(previous?.activity ? { activity: previous.activity } : {}) };
|
|
1911
2521
|
});
|
|
1912
2522
|
return { ...current, agents };
|
|
1913
2523
|
});
|
|
2524
|
+
await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
|
|
1914
2525
|
run.update?.(workflowToolUpdate(runState));
|
|
1915
2526
|
});
|
|
2527
|
+
const stopWorkflowRun = async (runId) => {
|
|
2528
|
+
const run = runs.get(runId);
|
|
2529
|
+
const terminalState = terminalRunStates.get(runId);
|
|
2530
|
+
if (!run)
|
|
2531
|
+
return terminalState ? { runId, state: terminalState, stopped: false, reason: "already_terminal" } : { runId, state: "unknown", stopped: false, reason: "unknown_run" };
|
|
2532
|
+
const state = run.lifecycle.state;
|
|
2533
|
+
if (state === "completed" || state === "failed" || state === "stopped")
|
|
2534
|
+
return { runId, state, stopped: false, reason: "already_terminal" };
|
|
2535
|
+
await run.lifecycle.terminal("stopped");
|
|
2536
|
+
run.abortController.abort();
|
|
2537
|
+
run.execution?.cancel();
|
|
2538
|
+
await scheduler.cancelRun(run.store.runId);
|
|
2539
|
+
await scheduler.flush();
|
|
2540
|
+
return { runId, state: "stopped", stopped: true };
|
|
2541
|
+
};
|
|
1916
2542
|
const answerCheckpoint = async (runId, name, approved, silent = false) => {
|
|
1917
2543
|
const run = runs.get(runId);
|
|
1918
2544
|
if (!run)
|
|
@@ -1920,6 +2546,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1920
2546
|
const checkpoint = await run.store.answerCheckpoint(name, approved);
|
|
1921
2547
|
if (!checkpoint)
|
|
1922
2548
|
return false;
|
|
2549
|
+
await eventPublisher.checkpoint(run.store, run.metadata, checkpoint.name, approved ? "approved" : "rejected");
|
|
1923
2550
|
if ((await run.store.awaitingCheckpoints()).length === 0)
|
|
1924
2551
|
await run.lifecycle.resolveAwaitingInput();
|
|
1925
2552
|
run.checkpointResolvers.get(checkpoint.path)?.(approved);
|
|
@@ -1928,6 +2555,26 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1928
2555
|
deliver(pi, `Workflow ${run.metadata.name} checkpoint ${name}: ${approved ? "Approved" : "Rejected"}.`);
|
|
1929
2556
|
return true;
|
|
1930
2557
|
};
|
|
2558
|
+
const budgetDecisionDelivery = (metadata, request) => `Workflow ${metadata.name} budget adjustment ${request.proposalId} for run ${request.runId} requires approval. Consumed usage: ${JSON.stringify(request.consumed)}. Previous limits: ${JSON.stringify(request.previous)}. Proposed limits: ${JSON.stringify(request.proposed)}. Respond with workflow_respond using proposalId ${request.proposalId}.`;
|
|
2559
|
+
const appendBudgetDecisionEvent = async (run, request, type) => {
|
|
2560
|
+
run.budget.recordEvent({ type, budgetVersion: request.budgetVersion, dimensions: [], usage: structuredClone(request.consumed), limits: structuredClone(request.proposed), at: Date.now(), proposalId: request.proposalId, previous: structuredClone(request.previous), proposed: structuredClone(request.proposed) });
|
|
2561
|
+
await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2562
|
+
};
|
|
2563
|
+
const answerBudgetDecision = async (runId, proposalId, approved, silent = false) => {
|
|
2564
|
+
const run = runs.get(runId);
|
|
2565
|
+
if (!run)
|
|
2566
|
+
return undefined;
|
|
2567
|
+
const request = await run.store.answerWorkflowDecision(proposalId, approved);
|
|
2568
|
+
if (!request)
|
|
2569
|
+
return undefined;
|
|
2570
|
+
await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
|
|
2571
|
+
const result = await applyBudgetDecision(request, approved);
|
|
2572
|
+
run.budgetResolvers.get(proposalId)?.(result);
|
|
2573
|
+
run.budgetResolvers.delete(proposalId);
|
|
2574
|
+
if (!silent)
|
|
2575
|
+
deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
|
|
2576
|
+
return result;
|
|
2577
|
+
};
|
|
1931
2578
|
const checkpointBridge = (runId, store, metadata, foreground, ui) => {
|
|
1932
2579
|
const checkpointCounters = new Map();
|
|
1933
2580
|
return async (raw, signal) => {
|
|
@@ -1940,6 +2587,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1940
2587
|
const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
|
|
1941
2588
|
if (replayed !== undefined)
|
|
1942
2589
|
return replayed;
|
|
2590
|
+
if (!alreadyAwaiting)
|
|
2591
|
+
await eventPublisher.checkpoint(store, metadata, label, "awaiting");
|
|
1943
2592
|
const run = runs.get(runId);
|
|
1944
2593
|
await run?.lifecycle.enterAwaitingInput();
|
|
1945
2594
|
if (!alreadyAwaiting && !ui?.select)
|
|
@@ -1965,7 +2614,6 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1965
2614
|
if (!choice) {
|
|
1966
2615
|
if (foreground)
|
|
1967
2616
|
continue; // foreground: retry until answered
|
|
1968
|
-
// Background resume: user dismissed UI, fall back to LLM
|
|
1969
2617
|
deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
|
|
1970
2618
|
return;
|
|
1971
2619
|
}
|
|
@@ -1979,12 +2627,37 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1979
2627
|
pi.registerTool({
|
|
1980
2628
|
name: "workflow_respond",
|
|
1981
2629
|
label: "Workflow Respond",
|
|
1982
|
-
description: "Approve or reject one pending workflow checkpoint",
|
|
1983
|
-
parameters: Type.Object({ runId: Type.String(), name: Type.String(), approved: Type.Boolean() }, { additionalProperties: false }),
|
|
2630
|
+
description: "Approve or reject one pending workflow checkpoint or budget decision",
|
|
2631
|
+
parameters: Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
|
|
1984
2632
|
async execute(_id, params) {
|
|
1985
2633
|
try {
|
|
2634
|
+
if (params.proposalId) {
|
|
2635
|
+
const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved);
|
|
2636
|
+
if (!result) {
|
|
2637
|
+
const denied = { state: "budget_exhausted", approved: false, reason: "proposal_not_pending" };
|
|
2638
|
+
return { content: [{ type: "text", text: JSON.stringify(denied) }], details: denied };
|
|
2639
|
+
}
|
|
2640
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: { ...result, reason: params.approved ? "approved" : "rejected" } };
|
|
2641
|
+
}
|
|
2642
|
+
if (!params.name)
|
|
2643
|
+
throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
|
|
1986
2644
|
const accepted = await answerCheckpoint(params.runId, params.name, params.approved);
|
|
1987
|
-
return { content: [{ type: "text", text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted } };
|
|
2645
|
+
return { content: [{ type: "text", text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted, state: accepted ? "checkpoint_answered" : "not_pending", approved: params.approved, reason: "checkpoint" } };
|
|
2646
|
+
}
|
|
2647
|
+
catch (error) {
|
|
2648
|
+
throw mainAgentError(error);
|
|
2649
|
+
}
|
|
2650
|
+
},
|
|
2651
|
+
});
|
|
2652
|
+
pi.registerTool({
|
|
2653
|
+
name: "workflow_stop",
|
|
2654
|
+
label: "Workflow Stop",
|
|
2655
|
+
description: "Stop an active workflow run by ID",
|
|
2656
|
+
parameters: Type.Object({ runId: Type.String() }, { additionalProperties: false }),
|
|
2657
|
+
async execute(_id, params) {
|
|
2658
|
+
try {
|
|
2659
|
+
const result = await stopWorkflowRun(params.runId);
|
|
2660
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
1988
2661
|
}
|
|
1989
2662
|
catch (error) {
|
|
1990
2663
|
throw mainAgentError(error);
|
|
@@ -1997,7 +2670,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1997
2670
|
if (catalogRegistered || !pi.getActiveTools().includes("workflow"))
|
|
1998
2671
|
return;
|
|
1999
2672
|
const catalog = registry.catalog();
|
|
2000
|
-
|
|
2673
|
+
const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
|
|
2674
|
+
if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length && !hasAliases)
|
|
2001
2675
|
return;
|
|
2002
2676
|
pi.registerTool({
|
|
2003
2677
|
name: "workflow_catalog",
|
|
@@ -2008,7 +2682,33 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2008
2682
|
});
|
|
2009
2683
|
catalogRegistered = true;
|
|
2010
2684
|
};
|
|
2011
|
-
const
|
|
2685
|
+
const refreshPausedRunAliases = async (run, context) => {
|
|
2686
|
+
const loaded = await run.store.load();
|
|
2687
|
+
const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
|
|
2688
|
+
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
2689
|
+
if (missing)
|
|
2690
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
2691
|
+
const settingsPath = workflowSettingsPath();
|
|
2692
|
+
const currentSettings = loadSettings(settingsPath);
|
|
2693
|
+
resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
|
|
2694
|
+
const currentAliases = currentSettings.modelAliases ?? {};
|
|
2695
|
+
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
2696
|
+
const modelRegistry = context?.modelRegistry;
|
|
2697
|
+
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
|
|
2698
|
+
if (context?.model)
|
|
2699
|
+
knownModels.add(`${context.model.provider}/${context.model.id}`);
|
|
2700
|
+
const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
|
|
2701
|
+
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2702
|
+
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2703
|
+
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
|
|
2704
|
+
await run.store.saveSnapshot(snapshot);
|
|
2705
|
+
run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
|
|
2706
|
+
run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
|
|
2707
|
+
const drift = aliasDrift(previousAliases, currentAliases);
|
|
2708
|
+
if (drift.length)
|
|
2709
|
+
await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
2710
|
+
};
|
|
2711
|
+
const coldResumeRun = async (run, hasUI, ui, trustedProject, context) => {
|
|
2012
2712
|
const loaded = await run.store.load();
|
|
2013
2713
|
if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION)
|
|
2014
2714
|
throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
|
|
@@ -2019,14 +2719,34 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2019
2719
|
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
2020
2720
|
if (missingRole)
|
|
2021
2721
|
throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
|
|
2022
|
-
const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_catalog"));
|
|
2722
|
+
const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
|
|
2023
2723
|
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
2024
2724
|
if (missing)
|
|
2025
2725
|
throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
2026
|
-
|
|
2726
|
+
const settingsPath = workflowSettingsPath();
|
|
2727
|
+
const currentSettings = loadSettings(settingsPath);
|
|
2728
|
+
resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
|
|
2729
|
+
const currentAliases = currentSettings.modelAliases ?? {};
|
|
2730
|
+
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
2731
|
+
const modelRegistry = context?.modelRegistry;
|
|
2732
|
+
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
|
|
2733
|
+
if (context?.model)
|
|
2734
|
+
knownModels.add(`${context.model.provider}/${context.model.id}`);
|
|
2735
|
+
const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
|
|
2736
|
+
const resumeAliases = { ...previousAliases, ...currentAliases };
|
|
2737
|
+
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2738
|
+
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2739
|
+
preflight(loaded.snapshot.script, { models: resumeModels, tools: active, agentTypes: new Set(loaded.snapshot.agentTypes), modelAliases: resumeAliases, knownModels: resumeModels, settingsPath, skipModelAvailability: true }, loaded.snapshot.schemas, loaded.snapshot.metadata);
|
|
2740
|
+
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
|
|
2741
|
+
await run.store.saveSnapshot(snapshot);
|
|
2742
|
+
run.executor = new WorkflowAgentExecutor({ cwd: run.store.cwd, model: run.model, tools: new Set(snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: resumeModels, knownModels: resumeModels, modelAliases: currentAliases, blockedAliases, blockedAliasTargets, settingsPath, agentDefinitions: snapshot.roles ?? {}, runStore: run.store, providerPause: async () => { deliver(pi, `Workflow ${snapshot.metadata.name} paused: provider limit.`); await run.lifecycle.providerPause(); }, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath) }, createSession);
|
|
2743
|
+
const drift = aliasDrift(previousAliases, currentAliases);
|
|
2744
|
+
if (drift.length)
|
|
2745
|
+
await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
2027
2746
|
const controller = new AbortController();
|
|
2028
2747
|
run.abortController = controller;
|
|
2029
2748
|
const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
|
|
2749
|
+
run.executor.setRunContext(runContext);
|
|
2030
2750
|
let variables;
|
|
2031
2751
|
try {
|
|
2032
2752
|
variables = await resolveWorkflowVariables(runContext, controller, registry);
|
|
@@ -2034,8 +2754,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2034
2754
|
catch (error) {
|
|
2035
2755
|
const typed = asWorkflowError(error);
|
|
2036
2756
|
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
|
|
2037
|
-
await run.lifecycle.terminal("failed").catch(() => undefined);
|
|
2038
|
-
await run.store.
|
|
2757
|
+
await run.lifecycle.terminal("failed", typed.code).catch(() => undefined);
|
|
2758
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } }));
|
|
2759
|
+
await eventPublisher.runFailed(run.store, run.metadata, typed, true, run.lifecycle.state === "interrupted" ? "interrupted" : "failed");
|
|
2760
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
2039
2761
|
}
|
|
2040
2762
|
throw typed;
|
|
2041
2763
|
}
|
|
@@ -2043,13 +2765,29 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2043
2765
|
await run.lifecycle.resume();
|
|
2044
2766
|
const execution = runWorkflow(loaded.snapshot.script, loaded.snapshot.args, withWorkflowFunctions({ agent: async (prompt, options, signal, identity) => {
|
|
2045
2767
|
await run.lifecycle.enter();
|
|
2768
|
+
const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
|
|
2769
|
+
const conversationLock = conversationId ? `${run.store.runId}:${conversationId}` : "";
|
|
2046
2770
|
try {
|
|
2047
|
-
const path = agentIdentityPath(identity);
|
|
2771
|
+
const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
|
|
2048
2772
|
const replayed = await run.store.replay(path);
|
|
2049
|
-
if (replayed)
|
|
2773
|
+
if (replayed) {
|
|
2774
|
+
if (conversationId) {
|
|
2775
|
+
const conversation = await run.store.conversation(conversationId);
|
|
2776
|
+
if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0))
|
|
2777
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
|
|
2778
|
+
}
|
|
2050
2779
|
return replayed.value;
|
|
2780
|
+
}
|
|
2781
|
+
if (conversationId) {
|
|
2782
|
+
if (conversationLocks.has(conversationLock))
|
|
2783
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
|
|
2784
|
+
const conversation = await run.store.conversation(conversationId);
|
|
2785
|
+
if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1)
|
|
2786
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
|
|
2787
|
+
conversationLocks.add(conversationLock);
|
|
2788
|
+
}
|
|
2051
2789
|
const worktree = agentWorktree(identity);
|
|
2052
|
-
const cwd = worktree.worktreeOwner ? (await run.store.worktree
|
|
2790
|
+
const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
|
|
2053
2791
|
const role = typeof options.role === "string" ? options.role : undefined;
|
|
2054
2792
|
const model = typeof options.model === "string" ? options.model : undefined;
|
|
2055
2793
|
const thinking = parseThinking(options.thinking);
|
|
@@ -2058,7 +2796,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2058
2796
|
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
2059
2797
|
const tools = resolved.tools;
|
|
2060
2798
|
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
2061
|
-
const spawned = scheduler.spawn(run.store.runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}) });
|
|
2799
|
+
const spawned = scheduler.spawn(run.store.runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), ...(conversationId ? { conversation: { id: conversationId, turn: identity.conversation?.turn ?? 0 } } : {}), agentOptions: options, agentIdentity: identity });
|
|
2062
2800
|
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
2063
2801
|
signal.addEventListener("abort", cancel, { once: true });
|
|
2064
2802
|
const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
|
|
@@ -2068,22 +2806,116 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2068
2806
|
return outcome.value;
|
|
2069
2807
|
}
|
|
2070
2808
|
finally {
|
|
2809
|
+
if (conversationLock)
|
|
2810
|
+
conversationLocks.delete(conversationLock);
|
|
2071
2811
|
await run.lifecycle.leave();
|
|
2072
2812
|
}
|
|
2073
2813
|
}, checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try {
|
|
2074
|
-
|
|
2814
|
+
let previousPhase;
|
|
2815
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
|
|
2816
|
+
await eventPublisher.phase(run.store, run.metadata, previousPhase, phase);
|
|
2817
|
+
runs.get(run.store.runId)?.update?.(workflowToolUpdate(persisted));
|
|
2075
2818
|
}
|
|
2076
2819
|
finally {
|
|
2077
2820
|
await run.lifecycle.leave();
|
|
2078
2821
|
} }, log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
|
|
2079
2822
|
run.execution = execution;
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2823
|
+
const completion = execution.result.then(async (value) => {
|
|
2824
|
+
await scheduler.flush();
|
|
2825
|
+
if (run.budget.hardExhausted)
|
|
2826
|
+
throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
|
|
2827
|
+
const resultPath = await run.store.saveResult(value);
|
|
2828
|
+
await run.lifecycle.terminal("completed", "completed");
|
|
2829
|
+
await eventPublisher.runCompleted(run.store, run.metadata, resultPath, true);
|
|
2830
|
+
return { value, resultPath };
|
|
2831
|
+
}).catch(async (error) => {
|
|
2832
|
+
await scheduler.flush();
|
|
2833
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
|
|
2834
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state))
|
|
2835
|
+
await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
2836
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
|
|
2837
|
+
const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
|
|
2838
|
+
await eventPublisher.runFailed(run.store, run.metadata, typed, true, state);
|
|
2839
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
2840
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state))
|
|
2841
|
+
deliverFailure(pi, run.metadata.name, error);
|
|
2842
|
+
});
|
|
2085
2843
|
void completion;
|
|
2086
2844
|
};
|
|
2845
|
+
const applyBudgetDecision = async (request, approved) => {
|
|
2846
|
+
const run = runs.get(request.runId);
|
|
2847
|
+
if (!run)
|
|
2848
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
|
|
2849
|
+
if (!approved)
|
|
2850
|
+
return { state: "budget_exhausted", approved: false };
|
|
2851
|
+
const nextBudget = validateBudget(request.proposed);
|
|
2852
|
+
const nextVersion = request.budgetVersion + 1;
|
|
2853
|
+
const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
|
|
2854
|
+
run.budget = runtime;
|
|
2855
|
+
await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget)
|
|
2856
|
+
next.budget = nextBudget;
|
|
2857
|
+
else
|
|
2858
|
+
delete next.budget; return next; });
|
|
2859
|
+
await coldResumeRun(run, false, {}, true);
|
|
2860
|
+
return { state: "running", approved: true };
|
|
2861
|
+
};
|
|
2862
|
+
const resumeWorkflowRun = async (runId, rawPatch) => {
|
|
2863
|
+
const run = runs.get(runId);
|
|
2864
|
+
if (!run)
|
|
2865
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
|
|
2866
|
+
const loaded = await run.store.load();
|
|
2867
|
+
if (loaded.run.state !== "budget_exhausted")
|
|
2868
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
|
|
2869
|
+
const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
2870
|
+
const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
|
|
2871
|
+
const nextBudget = mergeBudget(currentBudget, patch);
|
|
2872
|
+
const usage = budgetUsage(loaded.run.usage);
|
|
2873
|
+
if (!resumeBudgetAllowed(nextBudget, usage))
|
|
2874
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Every exhausted hard budget must be raised above retained usage or removed");
|
|
2875
|
+
if (budgetRelaxed(currentBudget, nextBudget)) {
|
|
2876
|
+
const proposalId = randomUUID();
|
|
2877
|
+
const request = { kind: "budget", proposalId, runId, consumed: usage, previous: currentBudget ?? {}, proposed: nextBudget ?? {}, budgetVersion: loaded.run.budgetVersion ?? 1 };
|
|
2878
|
+
const decision = new Promise((resolve) => { run.budgetResolvers.set(proposalId, resolve); });
|
|
2879
|
+
try {
|
|
2880
|
+
await run.store.requestWorkflowDecision(request);
|
|
2881
|
+
await appendBudgetDecisionEvent(run, request, "adjustment_requested");
|
|
2882
|
+
}
|
|
2883
|
+
catch (error) {
|
|
2884
|
+
run.budgetResolvers.delete(proposalId);
|
|
2885
|
+
throw error;
|
|
2886
|
+
}
|
|
2887
|
+
deliver(pi, budgetDecisionDelivery(run.metadata, request));
|
|
2888
|
+
const decisionResult = await decision;
|
|
2889
|
+
return { state: decisionResult.state };
|
|
2890
|
+
}
|
|
2891
|
+
const changed = JSON.stringify(currentBudget ?? {}) !== JSON.stringify(nextBudget ?? {});
|
|
2892
|
+
if (changed) {
|
|
2893
|
+
const nextVersion = (loaded.run.budgetVersion ?? 1) + 1;
|
|
2894
|
+
const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, usage, loaded.run.budgetEvents, { active: false });
|
|
2895
|
+
run.budget = runtime;
|
|
2896
|
+
await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget)
|
|
2897
|
+
next.budget = nextBudget;
|
|
2898
|
+
else
|
|
2899
|
+
delete next.budget; return next; });
|
|
2900
|
+
}
|
|
2901
|
+
await coldResumeRun(run, false, {}, true);
|
|
2902
|
+
return { state: "running" };
|
|
2903
|
+
};
|
|
2904
|
+
pi.registerTool({
|
|
2905
|
+
name: "workflow_resume",
|
|
2906
|
+
label: "Workflow Resume",
|
|
2907
|
+
description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
|
|
2908
|
+
parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
|
|
2909
|
+
async execute(_id, params) {
|
|
2910
|
+
try {
|
|
2911
|
+
const result = await resumeWorkflowRun(params.runId, params.budget);
|
|
2912
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
2913
|
+
}
|
|
2914
|
+
catch (error) {
|
|
2915
|
+
throw mainAgentError(error);
|
|
2916
|
+
}
|
|
2917
|
+
},
|
|
2918
|
+
});
|
|
2087
2919
|
pi.on("session_start", async (_event, ctx) => {
|
|
2088
2920
|
if (sessionStarted)
|
|
2089
2921
|
return;
|
|
@@ -2105,20 +2937,30 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2105
2937
|
await store.delete(true).catch(() => undefined);
|
|
2106
2938
|
continue;
|
|
2107
2939
|
}
|
|
2108
|
-
if (
|
|
2940
|
+
if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") {
|
|
2941
|
+
terminalRunStates.set(runId, loaded.run.state);
|
|
2109
2942
|
continue;
|
|
2110
|
-
|
|
2111
|
-
|
|
2943
|
+
}
|
|
2944
|
+
if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
|
|
2945
|
+
const previousState = loaded.run.state;
|
|
2946
|
+
await store.updateState((current) => ["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state) ? current : { ...current, state: "interrupted" });
|
|
2947
|
+
loaded = { ...loaded, run: (await store.load()).run };
|
|
2948
|
+
await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
|
|
2112
2949
|
loaded = { ...loaded, run: (await store.load()).run };
|
|
2113
2950
|
}
|
|
2114
2951
|
const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
|
|
2115
|
-
const
|
|
2952
|
+
const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
2953
|
+
eventPublisher.seedBudget(runId, loaded.run.budgetEvents);
|
|
2954
|
+
const budgetRuntime = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents, { active: loaded.run.state === "running" });
|
|
2955
|
+
const lifecycle = lifecycleFor(store, loaded.run.state, budgetRuntime, loaded.snapshot.metadata);
|
|
2116
2956
|
const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
2117
2957
|
const roleDefinitions = loaded.snapshot.roles ?? {};
|
|
2118
|
-
runs.set(runId, { executor: new WorkflowAgentExecutor({ cwd: ctx.cwd, model, tools: new Set(loaded.snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: new Set(loaded.snapshot.models), agentDefinitions: roleDefinitions, runStore: store, providerPause }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, abortController: new AbortController(), checkpointResolvers: new Map() });
|
|
2958
|
+
runs.set(runId, { executor: new WorkflowAgentExecutor({ cwd: ctx.cwd, model, tools: new Set(loaded.snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: new Set(loaded.snapshot.models), knownModels: new Set(loaded.snapshot.models), ...(loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ? { modelAliases: loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases } : {}), ...(loaded.snapshot.settingsPath ? { settingsPath: loaded.snapshot.settingsPath } : {}), agentDefinitions: roleDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(store.cwd, projectTrusted(ctx)) }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, budget: budgetRuntime, abortController: new AbortController(), projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), budgetResolvers: new Map() });
|
|
2119
2959
|
for (const checkpoint of await store.awaitingCheckpoints())
|
|
2120
2960
|
deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
|
|
2121
|
-
|
|
2961
|
+
for (const decision of await store.pendingWorkflowDecisions())
|
|
2962
|
+
deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
|
|
2963
|
+
scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
2122
2964
|
}
|
|
2123
2965
|
const resumeSelect = ctx.ui?.select;
|
|
2124
2966
|
if (ctx.hasUI && resumeSelect) {
|
|
@@ -2131,7 +2973,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2131
2973
|
const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
|
|
2132
2974
|
for (const run of toResume) {
|
|
2133
2975
|
try {
|
|
2134
|
-
await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx));
|
|
2976
|
+
await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx);
|
|
2135
2977
|
ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info");
|
|
2136
2978
|
}
|
|
2137
2979
|
catch (err) {
|
|
@@ -2164,18 +3006,23 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2164
3006
|
parameters: WORKFLOW_TOOL_PARAMETERS,
|
|
2165
3007
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
2166
3008
|
try {
|
|
3009
|
+
const settingsPath = workflowSettingsPath();
|
|
3010
|
+
const defaults = loadSettings(settingsPath);
|
|
2167
3011
|
if (!ctx.model)
|
|
2168
3012
|
throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
|
|
2169
|
-
const
|
|
2170
|
-
const
|
|
3013
|
+
const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
|
|
3014
|
+
const budget = validateBudget(params.budget);
|
|
2171
3015
|
const rootModel = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
|
|
2172
3016
|
const rootModelName = `${rootModel.provider}/${rootModel.model}`;
|
|
2173
3017
|
const modelRegistry = ctx.modelRegistry;
|
|
2174
|
-
const
|
|
2175
|
-
|
|
2176
|
-
const
|
|
3018
|
+
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
|
|
3019
|
+
knownModels.add(rootModelName);
|
|
3020
|
+
const availableModels = knownModels;
|
|
3021
|
+
const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_stop" && name !== "workflow_catalog");
|
|
2177
3022
|
const trustedProject = projectTrusted(ctx);
|
|
2178
|
-
|
|
3023
|
+
if (typeof ctx.cwd === "string")
|
|
3024
|
+
resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
|
|
3025
|
+
const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases: defaults.modelAliases ?? {}, knownModels, settingsPath }, registry);
|
|
2179
3026
|
const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames } = validated;
|
|
2180
3027
|
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
2181
3028
|
const runId = randomUUID();
|
|
@@ -2191,28 +3038,46 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2191
3038
|
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
2192
3039
|
const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]]));
|
|
2193
3040
|
const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
|
|
2194
|
-
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model)] : []; });
|
|
3041
|
+
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
|
|
2195
3042
|
const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
|
|
2196
|
-
const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
|
|
2197
|
-
|
|
2198
|
-
const
|
|
3043
|
+
const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, settingsPath, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}), ...(budget ? { budget } : {}), models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
|
|
3044
|
+
const budgetRuntime = new WorkflowBudgetRuntime(budget);
|
|
3045
|
+
const initialBudget = budgetRuntime.snapshot();
|
|
3046
|
+
await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", agents: [], nativeSessions: [], ...(budget ? { budget } : {}), budgetVersion: 1, ...initialBudget }, snapshot);
|
|
3047
|
+
const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
|
|
2199
3048
|
const background = !params.foreground;
|
|
2200
3049
|
const providerPause = async () => { if (background)
|
|
2201
3050
|
deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
2202
|
-
const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, agentDefinitions, runStore: store, providerPause }, createSession);
|
|
2203
|
-
runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, abortController: runController, checkpointResolvers: new Map(), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
|
|
3051
|
+
const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, knownModels, modelAliases: defaults.modelAliases ?? {}, settingsPath, agentDefinitions, runStore: store, providerPause, agentSetupHooks: registry.agentSetupHooks(), agentResourcePolicy: () => resolveAgentResourcePolicy(ctx.cwd, projectTrusted(ctx)), runContext }, createSession);
|
|
3052
|
+
runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, budget: budgetRuntime, abortController: runController, projectTrusted: () => projectTrusted(ctx), checkpointResolvers: new Map(), budgetResolvers: new Map(), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
|
|
2204
3053
|
if (params.foreground && onUpdate)
|
|
2205
3054
|
onUpdate(workflowToolUpdate((await store.load()).run));
|
|
2206
|
-
scheduler.addRun(runId, settings.concurrency,
|
|
3055
|
+
scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
2207
3056
|
const execution = runWorkflow(script, args, withWorkflowFunctions({ agent: async (prompt, options, agentSignal, identity) => {
|
|
2208
3057
|
await lifecycle.enter();
|
|
3058
|
+
const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
|
|
3059
|
+
const conversationLock = conversationId ? `${runId}:${conversationId}` : "";
|
|
2209
3060
|
try {
|
|
2210
|
-
const path = agentIdentityPath(identity);
|
|
3061
|
+
const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
|
|
2211
3062
|
const replayed = await store.replay(path);
|
|
2212
|
-
if (replayed)
|
|
3063
|
+
if (replayed) {
|
|
3064
|
+
if (conversationId) {
|
|
3065
|
+
const conversation = await store.conversation(conversationId);
|
|
3066
|
+
if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0))
|
|
3067
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
|
|
3068
|
+
}
|
|
2213
3069
|
return replayed.value;
|
|
3070
|
+
}
|
|
3071
|
+
if (conversationId) {
|
|
3072
|
+
if (conversationLocks.has(conversationLock))
|
|
3073
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
|
|
3074
|
+
const conversation = await store.conversation(conversationId);
|
|
3075
|
+
if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1)
|
|
3076
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
|
|
3077
|
+
conversationLocks.add(conversationLock);
|
|
3078
|
+
}
|
|
2214
3079
|
const worktree = agentWorktree(identity);
|
|
2215
|
-
const cwd = worktree.worktreeOwner ? (await store.worktree
|
|
3080
|
+
const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
|
|
2216
3081
|
const role = typeof options.role === "string" ? options.role : undefined;
|
|
2217
3082
|
const model = typeof options.model === "string" ? options.model : undefined;
|
|
2218
3083
|
const thinking = parseThinking(options.thinking);
|
|
@@ -2221,7 +3086,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2221
3086
|
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
2222
3087
|
const tools = resolved.tools;
|
|
2223
3088
|
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
2224
|
-
const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number"
|
|
3089
|
+
const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}), ...(conversationId ? { conversation: { id: conversationId, turn: identity.conversation?.turn ?? 0 } } : {}), agentOptions: options, agentIdentity: identity });
|
|
2225
3090
|
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
2226
3091
|
if (agentSignal.aborted)
|
|
2227
3092
|
cancel();
|
|
@@ -2234,29 +3099,47 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2234
3099
|
return outcome.value;
|
|
2235
3100
|
}
|
|
2236
3101
|
finally {
|
|
3102
|
+
if (conversationLock)
|
|
3103
|
+
conversationLocks.delete(conversationLock);
|
|
2237
3104
|
await lifecycle.leave();
|
|
2238
3105
|
}
|
|
2239
3106
|
}, checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined), phase: async (phase) => {
|
|
2240
3107
|
await lifecycle.enter();
|
|
2241
3108
|
try {
|
|
2242
|
-
|
|
2243
|
-
|
|
3109
|
+
let previousPhase;
|
|
3110
|
+
const persisted = await persistRunState(store, checked.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
|
|
3111
|
+
await eventPublisher.phase(store, checked.metadata, previousPhase, phase);
|
|
3112
|
+
runs.get(runId)?.update?.(workflowToolUpdate(persisted));
|
|
2244
3113
|
}
|
|
2245
3114
|
finally {
|
|
2246
3115
|
await lifecycle.leave();
|
|
2247
3116
|
}
|
|
2248
3117
|
}, log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
|
|
2249
3118
|
runs.get(runId).execution = execution;
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
3119
|
+
await eventPublisher.runStarted(store, checked.metadata, background);
|
|
3120
|
+
const finish = execution.result.then(async (value) => {
|
|
3121
|
+
await scheduler.flush();
|
|
3122
|
+
if (budgetRuntime.hardExhausted)
|
|
3123
|
+
throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
|
|
3124
|
+
const resultPath = await store.saveResult(value);
|
|
3125
|
+
await lifecycle.terminal("completed", "completed");
|
|
3126
|
+
await eventPublisher.runCompleted(store, checked.metadata, resultPath, background);
|
|
3127
|
+
return { value, resultPath };
|
|
3128
|
+
}).catch(async (error) => {
|
|
3129
|
+
await scheduler.flush();
|
|
3130
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
|
|
3131
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state))
|
|
3132
|
+
await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
3133
|
+
await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
|
|
3134
|
+
const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
|
|
3135
|
+
await eventPublisher.runFailed(store, checked.metadata, typed, background, state);
|
|
3136
|
+
throw typed;
|
|
3137
|
+
});
|
|
2254
3138
|
runs.get(runId).completion = finish;
|
|
2255
3139
|
if (background) {
|
|
2256
3140
|
void finish.then(async ({ value, resultPath }) => {
|
|
2257
|
-
emitAsyncComplete(store, "complete");
|
|
2258
3141
|
deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
|
|
2259
|
-
}, (error) => {
|
|
3142
|
+
}, (error) => { deliverFailure(pi, checked.metadata.name, error); });
|
|
2260
3143
|
return { content: [{ type: "text", text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
|
|
2261
3144
|
}
|
|
2262
3145
|
const { value } = await finish;
|
|
@@ -2313,7 +3196,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2313
3196
|
return entries.filter((entry) => entry !== undefined);
|
|
2314
3197
|
};
|
|
2315
3198
|
let stores = await loadStores();
|
|
2316
|
-
const usage = "Usage: /workflow [doctor], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint]";
|
|
3199
|
+
const usage = "Usage: /workflow [doctor|model-aliases], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint or proposal-id]. Use workflow_resume for budget patches.";
|
|
2317
3200
|
const setWorkflowStatus = (text) => {
|
|
2318
3201
|
const setStatus = ctx.ui.setStatus;
|
|
2319
3202
|
setStatus?.call(ctx.ui, "workflow-stop", text);
|
|
@@ -2329,6 +3212,11 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2329
3212
|
ctx.ui.notify(accepted ? `${action === "approve" ? "Approved" : "Rejected"} checkpoint ${rest.join(" ")}.` : "Checkpoint is not awaiting a response.", accepted ? "info" : "warning");
|
|
2330
3213
|
return keepContext ? "dashboard" : "done";
|
|
2331
3214
|
}
|
|
3215
|
+
if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
|
|
3216
|
+
const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true);
|
|
3217
|
+
ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
|
|
3218
|
+
return keepContext ? "dashboard" : "done";
|
|
3219
|
+
}
|
|
2332
3220
|
if (action === "delete" && stored) {
|
|
2333
3221
|
if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) {
|
|
2334
3222
|
ctx.ui.notify("Stop the workflow before deleting it.", "warning");
|
|
@@ -2338,6 +3226,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2338
3226
|
return keepContext ? "dashboard" : "done";
|
|
2339
3227
|
await stored.store.delete(true);
|
|
2340
3228
|
runs.delete(stored.store.runId);
|
|
3229
|
+
terminalRunStates.delete(stored.store.runId);
|
|
2341
3230
|
ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info");
|
|
2342
3231
|
return keepContext ? "picker" : "done";
|
|
2343
3232
|
}
|
|
@@ -2347,11 +3236,29 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2347
3236
|
return keepContext ? "dashboard" : "done";
|
|
2348
3237
|
}
|
|
2349
3238
|
if (action === "resume" && run) {
|
|
2350
|
-
if (run.lifecycle.state === "
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
3239
|
+
if (run.lifecycle.state === "budget_exhausted") {
|
|
3240
|
+
const patch = rest.length ? JSON.parse(rest.join(" ")) : undefined;
|
|
3241
|
+
const result = await resumeWorkflowRun(run.store.runId, patch);
|
|
3242
|
+
ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
|
|
3243
|
+
}
|
|
3244
|
+
else {
|
|
3245
|
+
if (run.lifecycle.state === "interrupted")
|
|
3246
|
+
await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
|
|
3247
|
+
else {
|
|
3248
|
+
if (run.lifecycle.state === "paused")
|
|
3249
|
+
await refreshPausedRunAliases(run, ctx);
|
|
3250
|
+
await run.lifecycle.resume();
|
|
3251
|
+
}
|
|
3252
|
+
ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
|
|
3253
|
+
}
|
|
3254
|
+
return keepContext ? "dashboard" : "done";
|
|
3255
|
+
}
|
|
3256
|
+
if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
|
|
3257
|
+
const input = await ctx.ui.input?.("Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}");
|
|
3258
|
+
if (input === undefined)
|
|
3259
|
+
return keepContext ? "dashboard" : "done";
|
|
3260
|
+
const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
|
|
3261
|
+
ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
|
|
2355
3262
|
return keepContext ? "dashboard" : "done";
|
|
2356
3263
|
}
|
|
2357
3264
|
if (action === "stop" && run) {
|
|
@@ -2360,11 +3267,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2360
3267
|
return "dashboard";
|
|
2361
3268
|
if (keepContext)
|
|
2362
3269
|
status(`Stopping workflow ${workflowName}...`);
|
|
2363
|
-
await run.
|
|
2364
|
-
run.abortController.abort();
|
|
2365
|
-
run.execution?.cancel();
|
|
2366
|
-
await scheduler.cancelRun(run.store.runId);
|
|
2367
|
-
await scheduler.flush();
|
|
3270
|
+
await stopWorkflowRun(run.store.runId);
|
|
2368
3271
|
if (keepContext)
|
|
2369
3272
|
status(`Workflow ${run.store.runId} stopped.`);
|
|
2370
3273
|
ctx.ui.notify(`Stopped workflow ${run.store.runId}.`, "info");
|
|
@@ -2387,13 +3290,117 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2387
3290
|
return "dashboard";
|
|
2388
3291
|
}
|
|
2389
3292
|
};
|
|
2390
|
-
|
|
3293
|
+
const manageAliases = async () => {
|
|
3294
|
+
const settingsPath = workflowSettingsPath();
|
|
3295
|
+
const modelRegistry = ctx.modelRegistry;
|
|
3296
|
+
const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
|
|
3297
|
+
const selectTarget = async () => {
|
|
3298
|
+
const models = available();
|
|
3299
|
+
const choice = await ctx.ui.select("Model alias target", [...models, "Manual model ID", "Back"]);
|
|
3300
|
+
if (!choice || choice === "Back")
|
|
3301
|
+
return undefined;
|
|
3302
|
+
if (choice !== "Manual model ID")
|
|
3303
|
+
return choice;
|
|
3304
|
+
return (await ctx.ui.input("Manual model ID", "provider/model[:thinking]"))?.trim() || undefined;
|
|
3305
|
+
};
|
|
3306
|
+
const save = (aliases) => {
|
|
3307
|
+
try {
|
|
3308
|
+
saveModelAliases(settingsPath, aliases);
|
|
3309
|
+
ctx.ui.notify(`Saved model aliases to ${settingsPath}.`, "info");
|
|
3310
|
+
return true;
|
|
3311
|
+
}
|
|
3312
|
+
catch (error) {
|
|
3313
|
+
ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
3314
|
+
return false;
|
|
3315
|
+
}
|
|
3316
|
+
};
|
|
2391
3317
|
for (;;) {
|
|
2392
|
-
|
|
2393
|
-
|
|
3318
|
+
let aliases;
|
|
3319
|
+
try {
|
|
3320
|
+
aliases = loadSettings(settingsPath).modelAliases ?? {};
|
|
3321
|
+
}
|
|
3322
|
+
catch (error) {
|
|
3323
|
+
ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2394
3324
|
return;
|
|
2395
3325
|
}
|
|
3326
|
+
const names = Object.keys(aliases).sort();
|
|
3327
|
+
const listing = names.length ? names.map((name) => `${name} = ${aliases[name] ?? ""}`).join("\n") : "(none)";
|
|
3328
|
+
const options = ["Add alias", ...names.map((name) => `Edit ${name}`), ...names.map((name) => `Delete ${name}`), "Back"];
|
|
3329
|
+
const choice = await ctx.ui.select(`Model aliases\n${listing}`, options);
|
|
3330
|
+
if (!choice || choice === "Back")
|
|
3331
|
+
return;
|
|
3332
|
+
if (choice === "Add alias") {
|
|
3333
|
+
const name = (await ctx.ui.input("Alias name", "reviewer-model"))?.trim();
|
|
3334
|
+
if (!name)
|
|
3335
|
+
continue;
|
|
3336
|
+
if (Object.prototype.hasOwnProperty.call(aliases, name)) {
|
|
3337
|
+
ctx.ui.notify(`Alias ${name} already exists; choose Edit ${name}.`, "warning");
|
|
3338
|
+
continue;
|
|
3339
|
+
}
|
|
3340
|
+
const target = await selectTarget();
|
|
3341
|
+
if (!target)
|
|
3342
|
+
continue;
|
|
3343
|
+
const next = { ...aliases, [name]: target };
|
|
3344
|
+
try {
|
|
3345
|
+
validateModelAliases(next, settingsPath);
|
|
3346
|
+
}
|
|
3347
|
+
catch (error) {
|
|
3348
|
+
ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
3349
|
+
continue;
|
|
3350
|
+
}
|
|
3351
|
+
const parsed = parseModelReference(target);
|
|
3352
|
+
if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
|
|
3353
|
+
ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
|
|
3354
|
+
if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?"))
|
|
3355
|
+
continue;
|
|
3356
|
+
}
|
|
3357
|
+
save(next);
|
|
3358
|
+
continue;
|
|
3359
|
+
}
|
|
3360
|
+
const edit = /^Edit (.+)$/.exec(choice);
|
|
3361
|
+
if (edit?.[1]) {
|
|
3362
|
+
const target = await selectTarget();
|
|
3363
|
+
if (!target)
|
|
3364
|
+
continue;
|
|
3365
|
+
const next = { ...aliases, [edit[1]]: target };
|
|
3366
|
+
try {
|
|
3367
|
+
validateModelAliases(next, settingsPath);
|
|
3368
|
+
}
|
|
3369
|
+
catch (error) {
|
|
3370
|
+
ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
3371
|
+
continue;
|
|
3372
|
+
}
|
|
3373
|
+
const parsed = parseModelReference(target);
|
|
3374
|
+
if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
|
|
3375
|
+
ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
|
|
3376
|
+
if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?"))
|
|
3377
|
+
continue;
|
|
3378
|
+
}
|
|
3379
|
+
save(next);
|
|
3380
|
+
continue;
|
|
3381
|
+
}
|
|
3382
|
+
const deletion = /^Delete (.+)$/.exec(choice);
|
|
3383
|
+
if (deletion?.[1] && await ctx.ui.confirm("Delete model alias?", `Delete ${deletion[1]}? Future workflow resumes using this alias may fail.`)) {
|
|
3384
|
+
const next = Object.fromEntries(Object.entries(aliases).filter(([name]) => name !== deletion[1]));
|
|
3385
|
+
save(next);
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
};
|
|
3389
|
+
if (command === "model-aliases") {
|
|
3390
|
+
if (!ctx.hasUI) {
|
|
3391
|
+
ctx.ui.notify("Model alias management requires UI.", "warning");
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
await manageAliases();
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3397
|
+
if (!command) {
|
|
3398
|
+
for (;;) {
|
|
2396
3399
|
if (!ctx.hasUI) {
|
|
3400
|
+
if (!stores.length) {
|
|
3401
|
+
ctx.ui.notify("No workflow runs in this session.", "info");
|
|
3402
|
+
return;
|
|
3403
|
+
}
|
|
2397
3404
|
const details = await Promise.all(stores.map(async ({ store, loaded }) => formatNavigatorRun(loaded, await store.awaitingCheckpoints(), await store.worktrees())));
|
|
2398
3405
|
ctx.ui.notify(details.join("\n\n"), "info");
|
|
2399
3406
|
return;
|
|
@@ -2402,10 +3409,15 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2402
3409
|
const labels = navigatorRunLabels(sorted);
|
|
2403
3410
|
const terminalStates = new Set(["completed", "failed", "stopped"]);
|
|
2404
3411
|
const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
|
|
2405
|
-
const pickerOptions = [...labels, ...(hasCompleted ? ["Delete all completed"] : [])
|
|
3412
|
+
const pickerOptions = [...labels, "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
|
|
2406
3413
|
const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
|
|
2407
3414
|
if (!runChoice || runChoice === "Close")
|
|
2408
3415
|
return;
|
|
3416
|
+
if (runChoice === "Model aliases") {
|
|
3417
|
+
await manageAliases();
|
|
3418
|
+
stores = await loadStores();
|
|
3419
|
+
continue;
|
|
3420
|
+
}
|
|
2409
3421
|
if (runChoice === "Delete all completed") {
|
|
2410
3422
|
if (!await ctx.ui.confirm("Delete completed runs?", "Delete all completed workflow runs and their artifacts? This cannot be undone."))
|
|
2411
3423
|
continue;
|
|
@@ -2413,6 +3425,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2413
3425
|
if (entry.loaded.run.state === "completed") {
|
|
2414
3426
|
await entry.store.delete(true);
|
|
2415
3427
|
runs.delete(entry.store.runId);
|
|
3428
|
+
terminalRunStates.delete(entry.store.runId);
|
|
2416
3429
|
}
|
|
2417
3430
|
}
|
|
2418
3431
|
ctx.ui.notify("Deleted all completed workflow runs.", "info");
|
|
@@ -2435,6 +3448,49 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2435
3448
|
ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2436
3449
|
}
|
|
2437
3450
|
};
|
|
3451
|
+
const openTranscript = async (transcript) => {
|
|
3452
|
+
try {
|
|
3453
|
+
const entries = SessionManager.open(transcript).buildContextEntries();
|
|
3454
|
+
if (ctx.mode !== "tui") {
|
|
3455
|
+
ctx.ui.notify(`Transcript: ${transcript}`, "info");
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
3459
|
+
let offset = 0;
|
|
3460
|
+
let renderedLines = [];
|
|
3461
|
+
const viewport = () => Math.max(1, ((tui.terminal?.rows) ?? 24) - 3);
|
|
3462
|
+
const move = (delta) => { offset = Math.max(0, Math.min(Math.max(0, renderedLines.length - viewport()), offset + delta)); };
|
|
3463
|
+
return {
|
|
3464
|
+
render(width) {
|
|
3465
|
+
renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
|
|
3466
|
+
offset = Math.min(offset, Math.max(0, renderedLines.length - viewport()));
|
|
3467
|
+
return [theme.fg("accent", "Native Pi transcript"), ...renderedLines.slice(offset, offset + viewport()), "", theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close")];
|
|
3468
|
+
},
|
|
3469
|
+
invalidate() { },
|
|
3470
|
+
handleInput(data) {
|
|
3471
|
+
if (keybindings.matches(data, "tui.select.up"))
|
|
3472
|
+
move(-1);
|
|
3473
|
+
else if (keybindings.matches(data, "tui.select.down"))
|
|
3474
|
+
move(1);
|
|
3475
|
+
else if (keybindings.matches(data, "tui.select.pageUp"))
|
|
3476
|
+
move(-viewport());
|
|
3477
|
+
else if (keybindings.matches(data, "tui.select.pageDown"))
|
|
3478
|
+
move(viewport());
|
|
3479
|
+
else if (keybindings.matches(data, "tui.editor.cursorLineStart"))
|
|
3480
|
+
offset = 0;
|
|
3481
|
+
else if (keybindings.matches(data, "tui.editor.cursorLineEnd"))
|
|
3482
|
+
offset = Math.max(0, renderedLines.length - viewport());
|
|
3483
|
+
else if (keybindings.matches(data, "tui.select.cancel"))
|
|
3484
|
+
done(undefined);
|
|
3485
|
+
tui.requestRender();
|
|
3486
|
+
},
|
|
3487
|
+
};
|
|
3488
|
+
}, { overlay: true });
|
|
3489
|
+
}
|
|
3490
|
+
catch (error) {
|
|
3491
|
+
ctx.ui.notify(`Cannot open transcript: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
3492
|
+
}
|
|
3493
|
+
};
|
|
2438
3494
|
const loadDashboard = async () => {
|
|
2439
3495
|
const loaded = await store.load();
|
|
2440
3496
|
const checkpoints = await store.awaitingCheckpoints();
|
|
@@ -2448,6 +3504,15 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2448
3504
|
add("Pause", "pause");
|
|
2449
3505
|
if (["paused", "interrupted"].includes(loaded.run.state))
|
|
2450
3506
|
add("Resume", "resume");
|
|
3507
|
+
if (loaded.run.state === "budget_exhausted") {
|
|
3508
|
+
actions.set("Resume unchanged", `resume ${store.runId}`);
|
|
3509
|
+
actions.set("Adjust budget", `adjust ${store.runId}`);
|
|
3510
|
+
}
|
|
3511
|
+
for (const decision of await store.pendingWorkflowDecisions()) {
|
|
3512
|
+
const id = decision.proposalId.slice(0, 8);
|
|
3513
|
+
actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
|
|
3514
|
+
actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
|
|
3515
|
+
}
|
|
2451
3516
|
if (!terminalStates.has(loaded.run.state))
|
|
2452
3517
|
add("Stop", "stop");
|
|
2453
3518
|
for (const cp of checkpoints) {
|
|
@@ -2466,21 +3531,78 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2466
3531
|
else
|
|
2467
3532
|
actions.set("View script", "view-script");
|
|
2468
3533
|
const transcripts = [...new Set([...loaded.run.agents.flatMap((agent) => (agent.attemptDetails ?? []).map((attempt) => attempt.sessionFile)), ...loaded.run.nativeSessions.map(({ sessionFile }) => sessionFile)])];
|
|
2469
|
-
if (
|
|
3534
|
+
if (loaded.run.agents.length)
|
|
3535
|
+
actions.set("Agents...", "agents");
|
|
3536
|
+
if (!loaded.run.agents.length && ctx.mode === "tui" && transcripts.length)
|
|
2470
3537
|
actions.set("View transcript", "view-transcript");
|
|
2471
|
-
if (transcripts.length)
|
|
3538
|
+
if (!loaded.run.agents.length && transcripts.length)
|
|
2472
3539
|
actions.set("Transcript paths", "transcripts");
|
|
2473
3540
|
if (terminalStates.has(loaded.run.state))
|
|
2474
3541
|
add("Delete", "delete");
|
|
2475
3542
|
if (ctx.mode === "tui") {
|
|
2476
3543
|
addCopy("Copy run path", store.directory, "run path");
|
|
2477
3544
|
addCopy("Copy run ID", store.runId, "run ID");
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
3545
|
+
}
|
|
3546
|
+
return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees };
|
|
3547
|
+
};
|
|
3548
|
+
const selectAgent = async (dashboard) => {
|
|
3549
|
+
const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
|
|
3550
|
+
const title = (agent) => {
|
|
3551
|
+
const parents = [];
|
|
3552
|
+
for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
|
|
3553
|
+
const parent = byId.get(parentId);
|
|
3554
|
+
if (!parent)
|
|
3555
|
+
break;
|
|
3556
|
+
parents.unshift(parent.label ?? parent.name);
|
|
3557
|
+
}
|
|
3558
|
+
return [...((agent.structuralPath ?? []).length ? [(agent.structuralPath ?? []).join(" > ")] : []), ...(agent.parentBreadcrumb ? [agent.parentBreadcrumb] : []), ...parents, agent.label ?? agent.name].join(" > ");
|
|
3559
|
+
};
|
|
3560
|
+
const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
|
|
3561
|
+
const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
|
|
3562
|
+
const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
|
|
3563
|
+
const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
|
|
3564
|
+
if (!selected)
|
|
3565
|
+
return;
|
|
3566
|
+
const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
|
|
3567
|
+
const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
|
|
3568
|
+
const actions = [
|
|
3569
|
+
...(attempts.length ? ["View transcript", "Copy transcript path"] : []),
|
|
3570
|
+
...(worktree ? ["Copy branch", "Copy worktree path"] : []),
|
|
3571
|
+
"Copy agent ID",
|
|
3572
|
+
"Back",
|
|
3573
|
+
];
|
|
3574
|
+
const chooseAttempt = async () => {
|
|
3575
|
+
const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
|
|
3576
|
+
const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Transcript attempts", [...choices, "Back"]);
|
|
3577
|
+
const index = choice ? choices.indexOf(choice) : -1;
|
|
3578
|
+
return index >= 0 ? attempts[index] : undefined;
|
|
3579
|
+
};
|
|
3580
|
+
for (;;) {
|
|
3581
|
+
const action = await ctx.ui.select(title(selected), actions);
|
|
3582
|
+
if (!action || action === "Back")
|
|
3583
|
+
return;
|
|
3584
|
+
if (action === "Copy agent ID") {
|
|
3585
|
+
await copyArtifact(selected.id, "agent ID");
|
|
3586
|
+
continue;
|
|
3587
|
+
}
|
|
3588
|
+
if (action === "Copy branch" && worktree) {
|
|
3589
|
+
await copyArtifact(worktree.branch, "branch");
|
|
3590
|
+
continue;
|
|
3591
|
+
}
|
|
3592
|
+
if (action === "Copy worktree path" && worktree) {
|
|
3593
|
+
await copyArtifact(worktree.path, "worktree path");
|
|
3594
|
+
continue;
|
|
3595
|
+
}
|
|
3596
|
+
if (action === "View transcript" || action === "Copy transcript path") {
|
|
3597
|
+
const attempt = await chooseAttempt();
|
|
3598
|
+
if (!attempt)
|
|
3599
|
+
continue;
|
|
3600
|
+
if (action === "Copy transcript path")
|
|
3601
|
+
await copyArtifact(attempt.sessionFile, "transcript path");
|
|
3602
|
+
else
|
|
3603
|
+
await openTranscript(attempt.sessionFile);
|
|
2481
3604
|
}
|
|
2482
3605
|
}
|
|
2483
|
-
return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script };
|
|
2484
3606
|
};
|
|
2485
3607
|
for (;;) {
|
|
2486
3608
|
let view = await loadDashboard();
|
|
@@ -2600,6 +3722,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2600
3722
|
: await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
|
|
2601
3723
|
if (!actionChoice || actionChoice === "Close")
|
|
2602
3724
|
return;
|
|
3725
|
+
if (actionChoice === "Agents...") {
|
|
3726
|
+
await selectAgent(view);
|
|
3727
|
+
continue;
|
|
3728
|
+
}
|
|
2603
3729
|
if (actionChoice === "Refresh")
|
|
2604
3730
|
continue;
|
|
2605
3731
|
if (actionChoice === "View script") {
|
|
@@ -2644,55 +3770,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2644
3770
|
}
|
|
2645
3771
|
if (actionChoice === "View transcript") {
|
|
2646
3772
|
const transcript = await ctx.ui.select("Native Pi transcripts", [...view.transcripts, "Back"]);
|
|
2647
|
-
if (transcript && transcript !== "Back")
|
|
2648
|
-
|
|
2649
|
-
const entries = SessionManager.open(transcript).buildContextEntries();
|
|
2650
|
-
await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
2651
|
-
let offset;
|
|
2652
|
-
let renderedLines = [];
|
|
2653
|
-
const terminalRows = () => Math.max(1, (tui.terminal?.rows) ?? 24);
|
|
2654
|
-
const viewport = () => Math.max(1, terminalRows() - 3);
|
|
2655
|
-
const move = (delta) => {
|
|
2656
|
-
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2657
|
-
offset = Math.max(0, Math.min(maxOffset, (offset ?? maxOffset) + delta));
|
|
2658
|
-
};
|
|
2659
|
-
return {
|
|
2660
|
-
render(width) {
|
|
2661
|
-
renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
|
|
2662
|
-
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2663
|
-
offset = Math.min(offset ?? maxOffset, maxOffset);
|
|
2664
|
-
return [
|
|
2665
|
-
theme.fg("accent", "Native Pi transcript"),
|
|
2666
|
-
...renderedLines.slice(offset, offset + viewport()),
|
|
2667
|
-
"",
|
|
2668
|
-
theme.fg("dim", "↑↓/pgup/pgdn scroll · Home/End jump · esc close"),
|
|
2669
|
-
];
|
|
2670
|
-
},
|
|
2671
|
-
invalidate() { },
|
|
2672
|
-
handleInput(data) {
|
|
2673
|
-
if (keybindings.matches(data, "tui.select.up"))
|
|
2674
|
-
move(-1);
|
|
2675
|
-
else if (keybindings.matches(data, "tui.select.down"))
|
|
2676
|
-
move(1);
|
|
2677
|
-
else if (keybindings.matches(data, "tui.select.pageUp"))
|
|
2678
|
-
move(-viewport());
|
|
2679
|
-
else if (keybindings.matches(data, "tui.select.pageDown"))
|
|
2680
|
-
move(viewport());
|
|
2681
|
-
else if (keybindings.matches(data, "tui.editor.cursorLineStart"))
|
|
2682
|
-
offset = 0;
|
|
2683
|
-
else if (keybindings.matches(data, "tui.editor.cursorLineEnd"))
|
|
2684
|
-
offset = Math.max(0, renderedLines.length - viewport());
|
|
2685
|
-
else if (keybindings.matches(data, "tui.select.cancel"))
|
|
2686
|
-
done(undefined);
|
|
2687
|
-
tui.requestRender();
|
|
2688
|
-
},
|
|
2689
|
-
};
|
|
2690
|
-
}, { overlay: true });
|
|
2691
|
-
}
|
|
2692
|
-
catch (error) {
|
|
2693
|
-
ctx.ui.notify(`Cannot open transcript ${transcript}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2694
|
-
}
|
|
2695
|
-
}
|
|
3773
|
+
if (transcript && transcript !== "Back")
|
|
3774
|
+
await openTranscript(transcript);
|
|
2696
3775
|
continue;
|
|
2697
3776
|
}
|
|
2698
3777
|
if (actionChoice === "Transcript paths") {
|
|
@@ -2796,16 +3875,16 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2796
3875
|
pi.on("session_shutdown", async () => {
|
|
2797
3876
|
try {
|
|
2798
3877
|
await Promise.all([...runs.entries()].map(async ([runId, run]) => {
|
|
2799
|
-
if (["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
|
|
3878
|
+
if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
|
|
2800
3879
|
await run.completion?.catch(() => undefined);
|
|
2801
3880
|
return;
|
|
2802
3881
|
}
|
|
2803
|
-
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
|
|
3882
|
+
if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
|
|
2804
3883
|
try {
|
|
2805
3884
|
await run.lifecycle.terminal("interrupted");
|
|
2806
3885
|
}
|
|
2807
3886
|
catch (error) {
|
|
2808
|
-
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state))
|
|
3887
|
+
if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state))
|
|
2809
3888
|
throw error;
|
|
2810
3889
|
}
|
|
2811
3890
|
run.abortController.abort();
|