pi-extensible-workflows 0.3.2 → 1.0.1
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 +9 -0
- package/dist/src/agent-execution.d.ts +81 -8
- package/dist/src/agent-execution.js +465 -75
- package/dist/src/ambient-workflow-evals.js +14 -33
- package/dist/src/doctor.d.ts +4 -1
- package/dist/src/doctor.js +57 -14
- package/dist/src/index.d.ts +222 -24
- package/dist/src/index.js +1548 -410
- package/dist/src/persistence.d.ts +28 -1
- package/dist/src/persistence.js +126 -9
- package/dist/src/session-inspector.d.ts +13 -1
- package/dist/src/session-inspector.js +22 -4
- package/dist/src/workflow-evals.d.ts +1 -0
- package/dist/src/workflow-evals.js +23 -25
- package/package.json +5 -2
- package/skills/pi-extensible-workflows/SKILL.md +14 -6
- package/src/agent-execution.ts +391 -86
- package/src/ambient-workflow-evals.ts +18 -28
- package/src/doctor.ts +60 -16
- package/src/index.ts +1332 -395
- package/src/persistence.ts +98 -10
- package/src/session-inspector.ts +25 -7
- package/src/workflow-evals.ts +13 -16
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, 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";
|
|
@@ -12,18 +12,27 @@ import { Value } from "typebox/value";
|
|
|
12
12
|
import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, SessionManager, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
|
|
14
14
|
import { transcriptLines } from "./session-inspector.js";
|
|
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"];
|
|
15
|
+
import { acquireSessionLease, atomicWriteFile, listRunIds, RunStore, structuralPath as operationPath } from "./persistence.js";
|
|
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
|
-
export const
|
|
19
|
-
export const
|
|
18
|
+
export const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"];
|
|
19
|
+
export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
|
|
20
|
+
export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
|
|
21
|
+
export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
|
|
22
|
+
export const WORKFLOW_RUN_COMPLETED_EVENT = "workflow:run-completed";
|
|
23
|
+
export const WORKFLOW_RUN_FAILED_EVENT = "workflow:run-failed";
|
|
24
|
+
export const WORKFLOW_AGENT_STATE_CHANGED_EVENT = "workflow:agent-state-changed";
|
|
25
|
+
export const WORKFLOW_PHASE_CHANGED_EVENT = "workflow:phase-changed";
|
|
26
|
+
export const WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT = "workflow:checkpoint-state-changed";
|
|
27
|
+
export const WORKFLOW_BUDGET_EVENT = "workflow:budget-event";
|
|
28
|
+
export const WORKFLOW_WORKTREE_CREATED_EVENT = "workflow:worktree-created";
|
|
20
29
|
const SETTLED_AGENT_STATES = new Set(["completed", "failed", "cancelled"]);
|
|
21
30
|
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",
|
|
31
|
+
"CONFIG_ERROR", "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
|
|
32
|
+
"RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
|
|
33
|
+
"CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "BUDGET_EXHAUSTED", "INTERNAL_ERROR",
|
|
25
34
|
];
|
|
26
|
-
const LAUNCH_SNAPSHOT_IDENTITY_VERSION =
|
|
35
|
+
export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 3;
|
|
27
36
|
export class WorkflowError extends Error {
|
|
28
37
|
code;
|
|
29
38
|
constructor(code, message) {
|
|
@@ -53,6 +62,7 @@ function workflowDetail(message) {
|
|
|
53
62
|
return detail || "No further details were provided";
|
|
54
63
|
}
|
|
55
64
|
const WORKFLOW_ERROR_PROSE = {
|
|
65
|
+
CONFIG_ERROR: (detail) => `The workflow configuration is invalid: ${detail}.`,
|
|
56
66
|
INVALID_SETTINGS: (detail) => `The workflow settings are invalid: ${detail}.`,
|
|
57
67
|
INVALID_SYNTAX: (detail) => `The workflow source is invalid: ${detail}.`,
|
|
58
68
|
INVALID_METADATA: (detail) => `The workflow metadata is invalid: ${detail}.`,
|
|
@@ -64,7 +74,6 @@ const WORKFLOW_ERROR_PROSE = {
|
|
|
64
74
|
UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
|
|
65
75
|
UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
|
|
66
76
|
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
77
|
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
78
|
RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
|
|
70
79
|
AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
|
|
@@ -74,6 +83,7 @@ const WORKFLOW_ERROR_PROSE = {
|
|
|
74
83
|
WORKER_UNRESPONSIVE: (detail) => `The workflow worker stopped responding: ${detail}.`,
|
|
75
84
|
WORKTREE_FAILED: (detail) => `The workflow worktree operation failed: ${detail}.`,
|
|
76
85
|
RESUME_INCOMPATIBLE: (detail) => `The workflow cannot resume this run: ${detail}.`,
|
|
86
|
+
BUDGET_EXHAUSTED: (detail) => `The workflow budget was exhausted: ${detail}.`,
|
|
77
87
|
INTERNAL_ERROR: (detail) => `The workflow encountered an internal error: ${detail}.`,
|
|
78
88
|
};
|
|
79
89
|
export function formatWorkflowFailure(error) {
|
|
@@ -123,7 +133,7 @@ export class RunLifecycle {
|
|
|
123
133
|
if (this.#active > 0)
|
|
124
134
|
this.#active -= 1;
|
|
125
135
|
if (this.#state === "pausing" && this.#active === 0)
|
|
126
|
-
await this.#set("paused");
|
|
136
|
+
await this.#set("paused", "pause");
|
|
127
137
|
}
|
|
128
138
|
async enterAwaitingInput() {
|
|
129
139
|
while (this.#state === "pausing" || this.#state === "paused")
|
|
@@ -132,26 +142,26 @@ export class RunLifecycle {
|
|
|
132
142
|
return;
|
|
133
143
|
if (this.#state !== "running")
|
|
134
144
|
throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot await input for ${this.#state} run`);
|
|
135
|
-
await this.#set("awaiting_input");
|
|
145
|
+
await this.#set("awaiting_input", "awaiting_input");
|
|
136
146
|
}
|
|
137
147
|
async resolveAwaitingInput() {
|
|
138
148
|
if (this.#state !== "awaiting_input")
|
|
139
149
|
return;
|
|
140
|
-
await this.#set("running");
|
|
150
|
+
await this.#set("running", "checkpoint_resolved");
|
|
141
151
|
for (const resolve of this.#waiters.splice(0))
|
|
142
152
|
resolve();
|
|
143
153
|
}
|
|
144
154
|
async pause() {
|
|
145
155
|
if (this.#state !== "running")
|
|
146
156
|
throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot pause ${this.#state} run`);
|
|
147
|
-
await this.#set("pausing");
|
|
157
|
+
await this.#set("pausing", "pause");
|
|
148
158
|
if (this.#active === 0)
|
|
149
|
-
await this.#set("paused");
|
|
159
|
+
await this.#set("paused", "pause");
|
|
150
160
|
}
|
|
151
161
|
async resume() {
|
|
152
|
-
if (this.#state !== "paused" && this.#state !== "interrupted")
|
|
162
|
+
if (this.#state !== "paused" && this.#state !== "interrupted" && this.#state !== "budget_exhausted")
|
|
153
163
|
throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
|
|
154
|
-
await this.#set("running");
|
|
164
|
+
await this.#set("running", "resume");
|
|
155
165
|
for (const resolve of this.#waiters.splice(0))
|
|
156
166
|
resolve();
|
|
157
167
|
}
|
|
@@ -161,32 +171,291 @@ export class RunLifecycle {
|
|
|
161
171
|
await this.pause();
|
|
162
172
|
await this.enter();
|
|
163
173
|
}
|
|
164
|
-
async terminal(state) {
|
|
174
|
+
async terminal(state, reason) {
|
|
165
175
|
if (["completed", "failed", "stopped"].includes(this.#state))
|
|
166
176
|
throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
|
|
167
|
-
await this.#set(state);
|
|
177
|
+
await this.#set(state, reason ?? state);
|
|
168
178
|
for (const resolve of this.#waiters.splice(0))
|
|
169
179
|
resolve();
|
|
170
180
|
}
|
|
171
|
-
async #set(state) {
|
|
181
|
+
async #set(state, reason) {
|
|
182
|
+
const previousState = this.#state;
|
|
183
|
+
this.#state = state;
|
|
184
|
+
await this.changed?.(state, previousState, reason);
|
|
185
|
+
}
|
|
172
186
|
}
|
|
173
|
-
export const DEFAULT_SETTINGS = Object.freeze({ concurrency: 8
|
|
187
|
+
export const DEFAULT_SETTINGS = Object.freeze({ concurrency: 8 });
|
|
174
188
|
function fail(code, message) { throw new WorkflowError(code, message); }
|
|
175
189
|
function object(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
190
|
+
export { object as isObject };
|
|
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,43 @@ 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
|
+
atomicWriteFile(path, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, true);
|
|
223
566
|
}
|
|
224
|
-
export function parseRoleMarkdown(content, strict = false) {
|
|
567
|
+
export function parseRoleMarkdown(content, strict = false, rolePath) {
|
|
225
568
|
if (!strict) {
|
|
226
569
|
if (!content.startsWith("---\n"))
|
|
227
570
|
return { prompt: content };
|
|
@@ -261,7 +604,7 @@ export function parseRoleMarkdown(content, strict = false) {
|
|
|
261
604
|
}
|
|
262
605
|
if (!object(parsed.frontmatter))
|
|
263
606
|
fail("INVALID_METADATA", "Role frontmatter must be an object");
|
|
264
|
-
const { model, thinking, tools, description } = parsed.frontmatter;
|
|
607
|
+
const { model, thinking, tools, description, disabledAgentResources } = parsed.frontmatter;
|
|
265
608
|
if (model !== undefined && (typeof model !== "string" || model.trim() === ""))
|
|
266
609
|
fail("INVALID_METADATA", "Role model must be a non-empty string");
|
|
267
610
|
if (thinking !== undefined && (typeof thinking !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking)))
|
|
@@ -270,7 +613,8 @@ export function parseRoleMarkdown(content, strict = false) {
|
|
|
270
613
|
fail("INVALID_METADATA", "Role description must be a non-empty single-line string of at most 1024 characters");
|
|
271
614
|
if (tools !== undefined && (!Array.isArray(tools) || tools.some((tool) => typeof tool !== "string" || tool.trim() === "")))
|
|
272
615
|
fail("INVALID_METADATA", "Role tools must be an array of non-empty strings");
|
|
273
|
-
|
|
616
|
+
const normalizedResources = validateAgentResourceExclusions(disabledAgentResources, rolePath ?? "<role>", "INVALID_METADATA");
|
|
617
|
+
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
618
|
}
|
|
275
619
|
const ROLE_DIRECTORY = "pi-extensible-workflows";
|
|
276
620
|
export function workflowRoleDirectories(agentDir = getAgentDir()) {
|
|
@@ -283,7 +627,7 @@ function readAgentDefinitions(dir) {
|
|
|
283
627
|
try {
|
|
284
628
|
return Object.fromEntries(readdirSync(dir, { withFileTypes: true })
|
|
285
629
|
.filter((entry) => entry.isFile() && extname(entry.name) === ".md")
|
|
286
|
-
.map((entry) => [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(
|
|
630
|
+
.map((entry) => { const path = join(dir, entry.name); return [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }));
|
|
287
631
|
}
|
|
288
632
|
catch (error) {
|
|
289
633
|
if (error.code === "ENOENT")
|
|
@@ -297,13 +641,19 @@ function readRoleDefinitions(dirs) {
|
|
|
297
641
|
export function loadAgentDefinitions(cwd, agentDir = getAgentDir(), projectTrusted = true) {
|
|
298
642
|
return deepFreeze({ ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
|
|
299
643
|
}
|
|
300
|
-
function validateRolePolicies(definitions, roles, availableModels, rootTools) {
|
|
644
|
+
function validateRolePolicies(definitions, roles, availableModels, rootTools, aliases = {}, knownModels = availableModels, settingsPath) {
|
|
301
645
|
for (const role of roles) {
|
|
302
646
|
const definition = definitions[role];
|
|
303
647
|
if (!definition)
|
|
304
648
|
continue;
|
|
305
|
-
if (definition.model !== undefined
|
|
306
|
-
|
|
649
|
+
if (definition.model !== undefined) {
|
|
650
|
+
const resolved = modelCapability(definition.model, aliases, knownModels, settingsPath);
|
|
651
|
+
if (!availableModels.has(resolved)) {
|
|
652
|
+
if (Object.prototype.hasOwnProperty.call(aliases, definition.model))
|
|
653
|
+
unknownModel(definition.model, resolved, settingsPath);
|
|
654
|
+
fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${resolved}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
307
657
|
const missingTool = (definition.tools ?? [...rootTools]).find((tool) => !rootTools.has(tool));
|
|
308
658
|
if (missingTool)
|
|
309
659
|
fail("UNKNOWN_TOOL", `Unknown tool for role ${role}: ${missingTool}`);
|
|
@@ -348,20 +698,32 @@ function parseWorkflow(script) {
|
|
|
348
698
|
function astNode(value) {
|
|
349
699
|
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
|
|
350
700
|
}
|
|
701
|
+
function astChildren(node) {
|
|
702
|
+
const children = [];
|
|
703
|
+
for (const value of Object.values(node)) {
|
|
704
|
+
if (Array.isArray(value)) {
|
|
705
|
+
for (const child of value)
|
|
706
|
+
if (astNode(child))
|
|
707
|
+
children.push(child);
|
|
708
|
+
}
|
|
709
|
+
else if (astNode(value))
|
|
710
|
+
children.push(value);
|
|
711
|
+
}
|
|
712
|
+
return children;
|
|
713
|
+
}
|
|
714
|
+
function workflowCallKind(node) {
|
|
715
|
+
if (node.type !== "CallExpression" || node.callee.type !== "Identifier")
|
|
716
|
+
return undefined;
|
|
717
|
+
const kind = node.callee.name;
|
|
718
|
+
return WORKFLOW_CALL_KINDS.includes(kind) ? kind : undefined;
|
|
719
|
+
}
|
|
351
720
|
function workflowCalls(program) {
|
|
352
721
|
const calls = [];
|
|
353
722
|
const visit = (node) => {
|
|
354
|
-
if (
|
|
723
|
+
if (workflowCallKind(node))
|
|
355
724
|
calls.push(node);
|
|
356
|
-
for (const
|
|
357
|
-
|
|
358
|
-
for (const child of value)
|
|
359
|
-
if (astNode(child))
|
|
360
|
-
visit(child);
|
|
361
|
-
}
|
|
362
|
-
else if (astNode(value))
|
|
363
|
-
visit(value);
|
|
364
|
-
}
|
|
725
|
+
for (const child of astChildren(node))
|
|
726
|
+
visit(child);
|
|
365
727
|
};
|
|
366
728
|
visit(program);
|
|
367
729
|
return calls.sort((left, right) => left.start - right.start);
|
|
@@ -376,9 +738,9 @@ function workflowCallsWithStructure(program) {
|
|
|
376
738
|
if (scope?.key === null && key)
|
|
377
739
|
current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
|
|
378
740
|
}
|
|
379
|
-
|
|
741
|
+
const operation = workflowCallKind(node);
|
|
742
|
+
if (operation) {
|
|
380
743
|
const call = node;
|
|
381
|
-
const operation = call.callee.name;
|
|
382
744
|
const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
|
|
383
745
|
calls.push({ call, execution, structure: current.structure });
|
|
384
746
|
for (const [index, argument] of call.arguments.entries()) {
|
|
@@ -389,15 +751,8 @@ function workflowCallsWithStructure(program) {
|
|
|
389
751
|
}
|
|
390
752
|
return;
|
|
391
753
|
}
|
|
392
|
-
for (const
|
|
393
|
-
|
|
394
|
-
for (const child of value)
|
|
395
|
-
if (astNode(child))
|
|
396
|
-
visit(child, current);
|
|
397
|
-
}
|
|
398
|
-
else if (astNode(value))
|
|
399
|
-
visit(value, current);
|
|
400
|
-
}
|
|
754
|
+
for (const child of astChildren(node))
|
|
755
|
+
visit(child, current);
|
|
401
756
|
};
|
|
402
757
|
visit(program, { execution: "sequential", structure: [] });
|
|
403
758
|
return calls.sort((left, right) => left.call.start - right.call.start);
|
|
@@ -410,24 +765,18 @@ function validateDirectPrimitiveReferences(program, name) {
|
|
|
410
765
|
if (!directCall && !propertyKey)
|
|
411
766
|
fail("INVALID_METADATA", `${name} calls must use a direct ${name}(...) call; aliases and indirect calls are unsupported`);
|
|
412
767
|
}
|
|
413
|
-
for (const
|
|
414
|
-
|
|
415
|
-
for (const child of value)
|
|
416
|
-
if (astNode(child))
|
|
417
|
-
visit(child, node);
|
|
418
|
-
}
|
|
419
|
-
else if (astNode(value))
|
|
420
|
-
visit(value, node);
|
|
421
|
-
}
|
|
768
|
+
for (const child of astChildren(node))
|
|
769
|
+
visit(child, node);
|
|
422
770
|
};
|
|
423
771
|
visit(program);
|
|
424
772
|
}
|
|
425
773
|
function hasIdentifier(node, name) {
|
|
426
774
|
if (node.type === "Identifier" && node.name === name)
|
|
427
775
|
return true;
|
|
428
|
-
return
|
|
776
|
+
return astChildren(node).some((child) => hasIdentifier(child, name));
|
|
429
777
|
}
|
|
430
778
|
const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
|
|
779
|
+
const INTERNAL_CONVERSATION_NAME = "__pi_extensible_workflows_conversation";
|
|
431
780
|
const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
|
|
432
781
|
function callHasTrailingComma(source, call) {
|
|
433
782
|
let previous;
|
|
@@ -445,14 +794,16 @@ function instrumentWorkflow(script) {
|
|
|
445
794
|
const program = parseWorkflow(body);
|
|
446
795
|
if (hasIdentifier(program, INTERNAL_AGENT_NAME))
|
|
447
796
|
fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
|
|
797
|
+
if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME))
|
|
798
|
+
fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
|
|
448
799
|
if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
|
|
449
800
|
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));
|
|
801
|
+
const calls = workflowCalls(program).filter((call) => ["agent", "conversation", "withWorktree"].includes(call.callee.name));
|
|
451
802
|
const edits = calls.flatMap((call) => {
|
|
452
803
|
const callSite = `${String(call.start)}:${String(call.end)}`;
|
|
453
804
|
const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
|
|
454
805
|
return [
|
|
455
|
-
{ start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : INTERNAL_WORKTREE_NAME },
|
|
806
|
+
{ 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
807
|
{ start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` },
|
|
457
808
|
];
|
|
458
809
|
}).sort((left, right) => right.start - left.start);
|
|
@@ -544,16 +895,17 @@ function validateSchema(schema, at = "schema") {
|
|
|
544
895
|
fail("INVALID_SCHEMA", `${at}.properties must be an object`);
|
|
545
896
|
}
|
|
546
897
|
const AGENT_OPTION_KEYS = new Set(["label", "model", "thinking", "tools", "role", "outputSchema", "retries", "timeoutMs"]);
|
|
547
|
-
function validateAgentOption(key, value) {
|
|
898
|
+
function validateAgentOption(key, value, aliases, knownModels, settingsPath) {
|
|
548
899
|
switch (key) {
|
|
549
900
|
case "label":
|
|
550
901
|
if (typeof value !== "string" || !value.trim())
|
|
551
902
|
fail("INVALID_METADATA", "agent label must be a non-empty string");
|
|
552
903
|
break;
|
|
553
904
|
case "model":
|
|
554
|
-
if (typeof value !== "string")
|
|
555
|
-
fail("INVALID_METADATA", "agent model must be a string");
|
|
556
|
-
|
|
905
|
+
if (typeof value !== "string" || !value.trim())
|
|
906
|
+
fail("INVALID_METADATA", "agent model must be a non-empty string");
|
|
907
|
+
if (aliases !== undefined)
|
|
908
|
+
resolveModelReference(value, aliases, knownModels, settingsPath);
|
|
557
909
|
break;
|
|
558
910
|
case "thinking":
|
|
559
911
|
if (typeof value !== "string" || !parseThinking(value))
|
|
@@ -583,11 +935,9 @@ function validateAgentOption(key, value) {
|
|
|
583
935
|
function validateAgentOptions(value) {
|
|
584
936
|
if (!object(value) || !jsonValue(value))
|
|
585
937
|
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
938
|
for (const [key, option] of Object.entries(value))
|
|
590
|
-
|
|
939
|
+
if (AGENT_OPTION_KEYS.has(key))
|
|
940
|
+
validateAgentOption(key, option);
|
|
591
941
|
if (typeof value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(value, key)))
|
|
592
942
|
fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
593
943
|
return value;
|
|
@@ -642,7 +992,7 @@ export function inspectWorkflowScript(script) {
|
|
|
642
992
|
const first = callArgument(call, 0);
|
|
643
993
|
const options = callArgument(call, 1);
|
|
644
994
|
const placement = { execution, structure };
|
|
645
|
-
if (kind === "agent") {
|
|
995
|
+
if (kind === "agent" || kind === "conversation") {
|
|
646
996
|
const retries = staticValue(propertyNode(options, "retries"));
|
|
647
997
|
const outputSchema = staticValue(propertyNode(options, "outputSchema"));
|
|
648
998
|
const optionKeys = options?.type === "ObjectExpression" ? options.properties.flatMap((property) => {
|
|
@@ -652,7 +1002,7 @@ export function inspectWorkflowScript(script) {
|
|
|
652
1002
|
return key ? [key] : [];
|
|
653
1003
|
}) : [];
|
|
654
1004
|
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")) };
|
|
1005
|
+
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
1006
|
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
1007
|
}
|
|
658
1008
|
if (kind === "checkpoint")
|
|
@@ -660,27 +1010,17 @@ export function inspectWorkflowScript(script) {
|
|
|
660
1010
|
return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
|
|
661
1011
|
});
|
|
662
1012
|
}
|
|
663
|
-
function validateStaticAgentOptions(node) {
|
|
1013
|
+
function validateStaticAgentOptions(node, aliases = {}, knownModels, settingsPath) {
|
|
664
1014
|
if (node?.type !== "ObjectExpression")
|
|
665
1015
|
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))
|
|
1016
|
+
const options = staticValue(node);
|
|
1017
|
+
if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value, key)))
|
|
675
1018
|
fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
676
1019
|
for (const key of AGENT_OPTION_KEYS) {
|
|
677
1020
|
const value = staticValue(propertyNode(node, key));
|
|
678
1021
|
if (value.known)
|
|
679
|
-
validateAgentOption(key, value.value);
|
|
1022
|
+
validateAgentOption(key, value.value, aliases, knownModels, settingsPath);
|
|
680
1023
|
}
|
|
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
1024
|
}
|
|
685
1025
|
function validateStaticWithWorktree(call) {
|
|
686
1026
|
if (call.arguments.some((argument) => argument.type === "SpreadElement"))
|
|
@@ -696,113 +1036,134 @@ function validateStaticWithWorktree(call) {
|
|
|
696
1036
|
fail("INVALID_METADATA", "withWorktree name must be a non-empty string");
|
|
697
1037
|
}
|
|
698
1038
|
}
|
|
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"]);
|
|
1039
|
+
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
1040
|
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
701
1041
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
702
1042
|
export class WorkflowRegistry {
|
|
703
|
-
#extensions = new
|
|
1043
|
+
#extensions = new Set();
|
|
704
1044
|
#globals = new Map();
|
|
1045
|
+
#workflows = new Map();
|
|
1046
|
+
#hooks = new Map();
|
|
705
1047
|
#frozen = false;
|
|
706
1048
|
get frozen() { return this.#frozen; }
|
|
707
1049
|
freeze() { this.#frozen = true; }
|
|
708
1050
|
register(extension) {
|
|
709
1051
|
if (this.#frozen)
|
|
710
1052
|
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}`);
|
|
1053
|
+
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())
|
|
1054
|
+
fail("INVALID_METADATA", "Workflow extensions require a semantic version, headline, and description");
|
|
715
1055
|
const functions = extension.functions ?? {};
|
|
716
1056
|
const variables = extension.variables ?? {};
|
|
717
1057
|
const workflows = extension.workflows ?? {};
|
|
718
|
-
|
|
719
|
-
|
|
1058
|
+
const agentSetupHooks = extension.agentSetupHooks ?? {};
|
|
1059
|
+
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))
|
|
1060
|
+
fail("INVALID_METADATA", "Workflow extensions require functions, variables, workflows, or agent setup hooks");
|
|
720
1061
|
const names = [...Object.keys(functions), ...Object.keys(variables)];
|
|
721
1062
|
if (new Set(names).size !== names.length)
|
|
722
|
-
fail("GLOBAL_COLLISION",
|
|
1063
|
+
fail("GLOBAL_COLLISION", "Global name collision inside extension");
|
|
723
1064
|
for (const name of names) {
|
|
724
1065
|
if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_"))
|
|
725
|
-
fail("INVALID_METADATA", `Invalid global name: ${
|
|
1066
|
+
fail("INVALID_METADATA", `Invalid global name: ${name}`);
|
|
726
1067
|
if (RESERVED_GLOBALS.has(name))
|
|
727
1068
|
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}`);
|
|
1069
|
+
if (this.#globals.has(name))
|
|
1070
|
+
fail("GLOBAL_COLLISION", `Global name is already registered: ${name}`);
|
|
731
1071
|
}
|
|
732
1072
|
for (const [name, fn] of Object.entries(functions)) {
|
|
733
1073
|
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, `${
|
|
1074
|
+
fail("INVALID_METADATA", `Invalid workflow function: ${name}`);
|
|
1075
|
+
validateSchema(fn.input, `${name} input`);
|
|
1076
|
+
validateSchema(fn.output, `${name} output`);
|
|
737
1077
|
if (fn.input.type !== "object")
|
|
738
|
-
fail("INVALID_SCHEMA", `${
|
|
1078
|
+
fail("INVALID_SCHEMA", `${name} input must describe one object`);
|
|
739
1079
|
}
|
|
740
1080
|
for (const [name, variable] of Object.entries(variables)) {
|
|
741
1081
|
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, `${
|
|
1082
|
+
fail("INVALID_METADATA", `Invalid workflow variable: ${name}`);
|
|
1083
|
+
validateSchema(variable.schema, `${name} schema`);
|
|
744
1084
|
}
|
|
745
1085
|
for (const [name, workflow] of Object.entries(workflows)) {
|
|
746
1086
|
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: ${
|
|
1087
|
+
fail("INVALID_METADATA", `Invalid workflow script: ${name}`);
|
|
1088
|
+
if (this.#workflows.has(name))
|
|
1089
|
+
fail("DUPLICATE_NAME", `Reusable workflow already registered: ${name}`);
|
|
748
1090
|
parseWorkflow(workflow.script);
|
|
749
1091
|
}
|
|
750
|
-
|
|
751
|
-
|
|
1092
|
+
for (const [name, hook] of Object.entries(agentSetupHooks)) {
|
|
1093
|
+
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)))
|
|
1094
|
+
fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
|
|
1095
|
+
if (this.#hooks.has(name))
|
|
1096
|
+
fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
|
|
1097
|
+
}
|
|
1098
|
+
const stored = deepFreeze({ ...extension, functions, variables, workflows, agentSetupHooks });
|
|
1099
|
+
this.#extensions.add(stored);
|
|
752
1100
|
for (const name of names)
|
|
753
|
-
this.#globals.set(name,
|
|
1101
|
+
this.#globals.set(name, name);
|
|
1102
|
+
for (const [name, workflow] of Object.entries(workflows))
|
|
1103
|
+
this.#workflows.set(name, workflow);
|
|
1104
|
+
for (const [name, hook] of Object.entries(agentSetupHooks))
|
|
1105
|
+
this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
|
|
754
1106
|
}
|
|
755
1107
|
workflow(name) {
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
const workflow = this.#extensions.get(name.slice(0, separator))?.workflows?.[name.slice(separator + 1)];
|
|
1108
|
+
if (!IDENTIFIER.test(name))
|
|
1109
|
+
fail("MISSING_WORKFLOW", `Registered workflows require an unqualified name: ${name}`);
|
|
1110
|
+
const workflow = this.#workflows.get(name);
|
|
760
1111
|
if (!workflow)
|
|
761
1112
|
fail("MISSING_WORKFLOW", `Workflow is unavailable: ${name}`);
|
|
762
1113
|
return workflow;
|
|
763
1114
|
}
|
|
764
1115
|
workflows() {
|
|
765
|
-
return Object.freeze(Object.fromEntries(
|
|
1116
|
+
return Object.freeze(Object.fromEntries(this.#workflows));
|
|
766
1117
|
}
|
|
767
1118
|
catalog() {
|
|
768
1119
|
const functions = [];
|
|
769
1120
|
const variables = [];
|
|
770
1121
|
const workflows = [];
|
|
771
|
-
for (const
|
|
1122
|
+
for (const extension of this.#extensions) {
|
|
772
1123
|
for (const [name, fn] of Object.entries(extension.functions ?? {}))
|
|
773
|
-
functions.push({ name,
|
|
1124
|
+
functions.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
|
|
774
1125
|
for (const [name, variable] of Object.entries(extension.variables ?? {}))
|
|
775
|
-
variables.push({ name,
|
|
1126
|
+
variables.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
|
|
776
1127
|
for (const [name, workflow] of Object.entries(extension.workflows ?? {}))
|
|
777
|
-
workflows.push({ name
|
|
1128
|
+
workflows.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
|
|
1129
|
+
}
|
|
1130
|
+
let aliases;
|
|
1131
|
+
try {
|
|
1132
|
+
aliases = loadSettings().modelAliases;
|
|
778
1133
|
}
|
|
779
|
-
|
|
780
|
-
|
|
1134
|
+
catch {
|
|
1135
|
+
aliases = undefined;
|
|
1136
|
+
}
|
|
1137
|
+
const sort = (left, right) => left.name.localeCompare(right.name);
|
|
1138
|
+
return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), workflows: workflows.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
|
|
781
1139
|
}
|
|
782
1140
|
globals() {
|
|
783
|
-
return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((
|
|
1141
|
+
return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.keys(extension.functions ?? {}).map((name) => [name, { name }]))));
|
|
784
1142
|
}
|
|
785
|
-
async invokeFunction(
|
|
786
|
-
const fn = this.#extensions.
|
|
1143
|
+
async invokeFunction(name, input, context, path, journal) {
|
|
1144
|
+
const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
|
|
787
1145
|
if (!fn)
|
|
788
|
-
fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${
|
|
1146
|
+
fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${name}`);
|
|
789
1147
|
if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input))
|
|
790
|
-
fail("RESULT_INVALID", `Invalid input for ${
|
|
1148
|
+
fail("RESULT_INVALID", `Invalid input for ${name}`);
|
|
791
1149
|
const replayed = journal.get(path);
|
|
792
1150
|
if (replayed !== undefined) {
|
|
793
1151
|
if (!jsonValue(replayed) || !Value.Check(fn.output, replayed))
|
|
794
|
-
fail("RESULT_INVALID", `Invalid replay for ${
|
|
1152
|
+
fail("RESULT_INVALID", `Invalid replay for ${name}`);
|
|
795
1153
|
return structuredClone(replayed);
|
|
796
1154
|
}
|
|
797
|
-
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 }));
|
|
1155
|
+
const result = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, invoke: context.invoke, 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
1156
|
if (!jsonValue(result) || !Value.Check(fn.output, result))
|
|
799
|
-
fail("RESULT_INVALID", `Invalid output from ${
|
|
1157
|
+
fail("RESULT_INVALID", `Invalid output from ${name}`);
|
|
800
1158
|
const stored = structuredClone(result);
|
|
801
1159
|
journal.put(path, stored);
|
|
802
1160
|
return structuredClone(stored);
|
|
803
1161
|
}
|
|
804
1162
|
variables() {
|
|
805
|
-
return [...this.#extensions].flatMap((
|
|
1163
|
+
return [...this.#extensions].flatMap((extension) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ name, variable })));
|
|
1164
|
+
}
|
|
1165
|
+
agentSetupHooks() {
|
|
1166
|
+
return [...this.#hooks.values()].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
806
1167
|
}
|
|
807
1168
|
}
|
|
808
1169
|
const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
|
|
@@ -818,6 +1179,7 @@ function createWorkflowRegistryApi(registry) {
|
|
|
818
1179
|
globals: () => registry.globals(),
|
|
819
1180
|
invokeFunction: (...args) => registry.invokeFunction(...args),
|
|
820
1181
|
variables: () => registry.variables(),
|
|
1182
|
+
agentSetupHooks: () => registry.agentSetupHooks(),
|
|
821
1183
|
};
|
|
822
1184
|
}
|
|
823
1185
|
function workflowRegistryHost() {
|
|
@@ -848,11 +1210,11 @@ export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
|
|
|
848
1210
|
name: Type.Optional(Type.String({ description: "Workflow name for inline scripts" })),
|
|
849
1211
|
description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
|
|
850
1212
|
script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
|
|
851
|
-
workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as
|
|
1213
|
+
workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as an unqualified name" })),
|
|
852
1214
|
args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
|
|
853
1215
|
foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
|
|
854
1216
|
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
|
|
855
|
-
|
|
1217
|
+
budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
|
|
856
1218
|
});
|
|
857
1219
|
function hasDynamicAgentRole(node) {
|
|
858
1220
|
if (!node)
|
|
@@ -874,17 +1236,23 @@ export function preflight(script, capabilities, schemas = [], metadata = { name:
|
|
|
874
1236
|
const program = parseWorkflow(script);
|
|
875
1237
|
if (hasIdentifier(program, INTERNAL_AGENT_NAME))
|
|
876
1238
|
fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
|
|
1239
|
+
if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME))
|
|
1240
|
+
fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
|
|
877
1241
|
if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
|
|
878
1242
|
fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
|
|
879
1243
|
validateDirectPrimitiveReferences(program, "withWorktree");
|
|
1244
|
+
validateDirectPrimitiveReferences(program, "conversation");
|
|
880
1245
|
for (const [index, schema] of schemas.entries())
|
|
881
1246
|
validateSchema(schema, `schema[${String(index)}]`);
|
|
882
1247
|
const calls = workflowCalls(program);
|
|
883
1248
|
const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase) => phase !== undefined);
|
|
884
1249
|
for (const call of calls) {
|
|
885
1250
|
const operation = call.callee.name;
|
|
886
|
-
if (operation === "agent")
|
|
887
|
-
|
|
1251
|
+
if (operation === "agent" || operation === "conversation") {
|
|
1252
|
+
if (operation === "conversation" && (!literalString(call.arguments[0])?.trim() || call.arguments.length > 2))
|
|
1253
|
+
fail("INVALID_METADATA", "conversation requires a stable name and optional options object");
|
|
1254
|
+
validateStaticAgentOptions(call.arguments[1], capabilities.modelAliases ?? {}, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath);
|
|
1255
|
+
}
|
|
888
1256
|
if (operation === "withWorktree")
|
|
889
1257
|
validateStaticWithWorktree(call);
|
|
890
1258
|
if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement"))
|
|
@@ -896,21 +1264,25 @@ export function preflight(script, capabilities, schemas = [], metadata = { name:
|
|
|
896
1264
|
if (operation === "pipeline" && (call.arguments.length !== 3 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression" || call.arguments[2]?.type !== "ObjectExpression"))
|
|
897
1265
|
fail("INVALID_METADATA", "pipeline requires an operation name string, items record, and stages record");
|
|
898
1266
|
}
|
|
899
|
-
const agentCalls = calls.filter((call) => call.callee.name === "agent");
|
|
1267
|
+
const agentCalls = calls.filter((call) => call.callee.name === "agent" || call.callee.name === "conversation");
|
|
900
1268
|
const dynamicAgentRoles = agentCalls.some((call) => hasDynamicAgentRole(call.arguments[1]));
|
|
901
1269
|
const staticSchemas = agentCalls.flatMap((call) => { const value = staticValue(propertyNode(call.arguments[1], "outputSchema")); return value.known ? [value.value] : []; });
|
|
902
1270
|
for (const [index, schema] of staticSchemas.entries())
|
|
903
1271
|
validateSchema(schema, `agent outputSchema[${String(index)}]`);
|
|
904
1272
|
const checkedSchemas = [...schemas, ...staticSchemas];
|
|
905
|
-
const
|
|
1273
|
+
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) }]; });
|
|
1274
|
+
const models = modelRefs.map(({ resolved }) => resolved);
|
|
906
1275
|
const tools = agentCalls.flatMap((call) => {
|
|
907
1276
|
const value = propertyNode(call.arguments[1], "tools");
|
|
908
1277
|
return value?.type === "ArrayExpression" ? value.elements.flatMap((element) => { const tool = element && element.type !== "SpreadElement" ? literalString(element) : undefined; return tool === undefined ? [] : [tool]; }) : [];
|
|
909
1278
|
});
|
|
910
1279
|
const agentTypes = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "role")); return value === undefined ? [] : [value]; });
|
|
911
|
-
const missingModel =
|
|
912
|
-
if (missingModel)
|
|
913
|
-
|
|
1280
|
+
const missingModel = capabilities.skipModelAvailability ? undefined : modelRefs.find(({ resolved }) => !capabilities.models.has(resolved));
|
|
1281
|
+
if (missingModel) {
|
|
1282
|
+
if (Object.prototype.hasOwnProperty.call(capabilities.modelAliases ?? {}, missingModel.requested))
|
|
1283
|
+
unknownModel(missingModel.requested, missingModel.resolved, capabilities.settingsPath);
|
|
1284
|
+
fail("UNKNOWN_MODEL", `Unknown model: ${missingModel.resolved}`);
|
|
1285
|
+
}
|
|
914
1286
|
const missingTool = tools.find((tool) => !capabilities.tools.has(tool));
|
|
915
1287
|
if (missingTool)
|
|
916
1288
|
fail("UNKNOWN_TOOL", `Unknown tool: ${missingTool}`);
|
|
@@ -923,6 +1295,8 @@ export function validateWorkflowLaunch(params, context) {
|
|
|
923
1295
|
return validateWorkflowLaunchWithRegistry(params, context, loadingRegistry());
|
|
924
1296
|
}
|
|
925
1297
|
function validateWorkflowLaunchWithRegistry(params, context, registry) {
|
|
1298
|
+
if (Object.prototype.hasOwnProperty.call(params, "maxAgentLaunches"))
|
|
1299
|
+
fail("INVALID_METADATA", "maxAgentLaunches has been removed; use budget.agentLaunches");
|
|
926
1300
|
if (params.script !== undefined && params.workflow !== undefined)
|
|
927
1301
|
fail("INVALID_METADATA", "Provide either script or workflow, not both");
|
|
928
1302
|
const definition = typeof params.workflow === "string" ? registry.workflow(params.workflow) : undefined;
|
|
@@ -936,9 +1310,11 @@ function validateWorkflowLaunchWithRegistry(params, context, registry) {
|
|
|
936
1310
|
const globalAgentDefinitions = loadAgentDefinitions(context.cwd, undefined, false);
|
|
937
1311
|
const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
|
|
938
1312
|
const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
|
|
939
|
-
const
|
|
1313
|
+
const aliases = context.modelAliases ?? {};
|
|
1314
|
+
const knownModels = context.knownModels ?? context.availableModels;
|
|
1315
|
+
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
1316
|
const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
|
|
941
|
-
validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools);
|
|
1317
|
+
validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools, aliases, knownModels, context.settingsPath);
|
|
942
1318
|
return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames };
|
|
943
1319
|
}
|
|
944
1320
|
function deepFreeze(value) {
|
|
@@ -1020,6 +1396,7 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
|
|
|
1020
1396
|
const path = (...names) => names.map(encodeURIComponent).join("/");
|
|
1021
1397
|
const inheritedAgentPath = new AsyncLocalStorage();
|
|
1022
1398
|
const agentOccurrences = new Map();
|
|
1399
|
+
const conversationOccurrences = new Map();
|
|
1023
1400
|
const worktreeOwners = new AsyncLocalStorage();
|
|
1024
1401
|
const worktreeOccurrences = new Map();
|
|
1025
1402
|
const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
|
|
@@ -1043,6 +1420,44 @@ const internalWithWorktree = async (...values) => {
|
|
|
1043
1420
|
}
|
|
1044
1421
|
return await worktreeOwners.run(owner, callback);
|
|
1045
1422
|
};
|
|
1423
|
+
const internalConversation = (...values) => {
|
|
1424
|
+
const callSite = values.pop();
|
|
1425
|
+
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow conversation call-site identity");
|
|
1426
|
+
const name = values[0];
|
|
1427
|
+
if (typeof name !== "string" || !name.trim()) throw workError("INVALID_METADATA", "conversation requires a non-empty name");
|
|
1428
|
+
const conversationOptions = values.length < 2 || values[1] === undefined ? {} : values[1];
|
|
1429
|
+
if (!conversationOptions || typeof conversationOptions !== "object" || Array.isArray(conversationOptions)) throw workError("INVALID_METADATA", "conversation options must be a JSON object");
|
|
1430
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
1431
|
+
const occurrenceKey = JSON.stringify([inherited, callSite, name]);
|
|
1432
|
+
const occurrence = (conversationOccurrences.get(occurrenceKey) || 0) + 1;
|
|
1433
|
+
conversationOccurrences.set(occurrenceKey, occurrence);
|
|
1434
|
+
const fixedOptions = structuredClone(conversationOptions);
|
|
1435
|
+
const defaultTimeout = fixedOptions.timeoutMs;
|
|
1436
|
+
const defaultRetries = fixedOptions.retries;
|
|
1437
|
+
delete fixedOptions.timeoutMs;
|
|
1438
|
+
delete fixedOptions.retries;
|
|
1439
|
+
const worktreeOwner = worktreeOwners.getStore();
|
|
1440
|
+
let turn = 0;
|
|
1441
|
+
let active = false;
|
|
1442
|
+
return Object.freeze({
|
|
1443
|
+
run(prompt, turnOptions = {}) {
|
|
1444
|
+
if (typeof prompt !== "string") throw workError("INVALID_METADATA", "conversation.run prompt must be a string");
|
|
1445
|
+
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");
|
|
1446
|
+
if (active) throw workError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
|
|
1447
|
+
active = true;
|
|
1448
|
+
const turnNumber = turn + 1;
|
|
1449
|
+
const options = { ...fixedOptions, ...(defaultTimeout !== undefined && turnOptions.timeoutMs === undefined ? { timeoutMs: defaultTimeout } : {}), ...(defaultRetries !== undefined && turnOptions.retries === undefined ? { retries: defaultRetries } : {}), ...turnOptions };
|
|
1450
|
+
const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}), conversation: { name: name.trim(), turn: turnNumber } };
|
|
1451
|
+
const result = rpc("agent", [prompt, options, identity]).then(value => { const unwrapped = unwrap(value); turn = turnNumber; return unwrapped; }).finally(() => { active = false; });
|
|
1452
|
+
Object.defineProperties(result, {
|
|
1453
|
+
toJSON: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before serialization"); } },
|
|
1454
|
+
toString: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
|
|
1455
|
+
[Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
|
|
1456
|
+
});
|
|
1457
|
+
return result;
|
|
1458
|
+
},
|
|
1459
|
+
});
|
|
1460
|
+
};
|
|
1046
1461
|
const internalAgent = (...values) => {
|
|
1047
1462
|
const callSite = values.pop();
|
|
1048
1463
|
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
|
|
@@ -1112,16 +1527,17 @@ const checkpoint = input => rpc("checkpoint", [input]).then(unwrap);
|
|
|
1112
1527
|
const phase = name => rpc("phase", [name]);
|
|
1113
1528
|
const log = message => rpc("log", [message]);
|
|
1114
1529
|
const functionOccurrences = new Map();
|
|
1115
|
-
const functionPath =
|
|
1530
|
+
const functionPath = name => {
|
|
1116
1531
|
const inherited = inheritedAgentPath.getStore() || [];
|
|
1117
|
-
const key = JSON.stringify([inherited,
|
|
1532
|
+
const key = JSON.stringify([inherited, name]);
|
|
1118
1533
|
const occurrence = (functionOccurrences.get(key) || 0) + 1;
|
|
1119
1534
|
functionOccurrences.set(key, occurrence);
|
|
1120
|
-
return path("function", ...inherited,
|
|
1535
|
+
return path("function", ...inherited, name, String(occurrence));
|
|
1121
1536
|
};
|
|
1122
1537
|
const functions = Object.freeze(Object.fromEntries(Object.entries(config.functions || {}).map(([local, target]) => [local, (...values) => {
|
|
1123
1538
|
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
|
|
1539
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
1540
|
+
const result = rpc("function", [target.name, values[0], functionPath(target.name), worktreeOwners.getStore() || null, inherited]).then(unwrap);
|
|
1125
1541
|
Object.defineProperty(result, "toJSON", { value() { throw workError("INVALID_METADATA", "Workflow function result is a Promise; await it before serialization"); } });
|
|
1126
1542
|
return result;
|
|
1127
1543
|
}])));
|
|
@@ -1182,13 +1598,13 @@ const pipeline = async (operationName, items, stages) => {
|
|
|
1182
1598
|
return Object.fromEntries(results.map(result => [result.name, result.value]));
|
|
1183
1599
|
};
|
|
1184
1600
|
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) };
|
|
1601
|
+
const sandbox = { agent, conversation: internalConversation, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
|
|
1186
1602
|
for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
|
|
1187
1603
|
for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
|
|
1188
1604
|
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
1605
|
const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
|
|
1190
1606
|
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))
|
|
1607
|
+
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
1608
|
.then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
|
|
1193
1609
|
.catch(error => send({ type: "error", error: workerError(error) }))
|
|
1194
1610
|
.finally(() => clearInterval(heartbeat));
|
|
@@ -1209,13 +1625,25 @@ function readAgentIdentity(value) {
|
|
|
1209
1625
|
const occurrence = value.occurrence;
|
|
1210
1626
|
const worktreeOwner = value.worktreeOwner;
|
|
1211
1627
|
const parentBreadcrumb = value.parentBreadcrumb;
|
|
1212
|
-
|
|
1628
|
+
const conversation = value.conversation;
|
|
1629
|
+
const parsedConversation = object(conversation) && typeof conversation.name === "string" && Boolean(conversation.name.trim()) && positiveInteger(conversation.turn) ? { name: conversation.name, turn: conversation.turn } : undefined;
|
|
1630
|
+
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
1631
|
fail("INTERNAL_ERROR", "Invalid workflow agent identity");
|
|
1214
|
-
return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}) };
|
|
1632
|
+
return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}), ...(parsedConversation ? { conversation: parsedConversation } : {}) };
|
|
1215
1633
|
}
|
|
1216
1634
|
function agentIdentityPath(identity) {
|
|
1217
1635
|
return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
|
|
1218
1636
|
}
|
|
1637
|
+
function conversationIdentityPath(identity) {
|
|
1638
|
+
if (!identity.conversation)
|
|
1639
|
+
throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
|
|
1640
|
+
return operationPath("conversation", identity.conversation.name, ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
|
|
1641
|
+
}
|
|
1642
|
+
function conversationTurnPath(identity) {
|
|
1643
|
+
if (!identity.conversation)
|
|
1644
|
+
throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
|
|
1645
|
+
return operationPath(conversationIdentityPath(identity), `turn:${String(identity.conversation.turn)}`);
|
|
1646
|
+
}
|
|
1219
1647
|
function agentWorktree(identity) {
|
|
1220
1648
|
return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
|
|
1221
1649
|
}
|
|
@@ -1340,12 +1768,15 @@ export function runWorkflow(script, args = null, bridge = {}, signal) {
|
|
|
1340
1768
|
}
|
|
1341
1769
|
}
|
|
1342
1770
|
else if (method === "function") {
|
|
1343
|
-
const worktreeOwner = values[
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1771
|
+
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");
|
|
1772
|
+
const structuralPath = values[4] === undefined ? [] : values[4];
|
|
1773
|
+
if (!Array.isArray(structuralPath) || !structuralPath.every((part) => typeof part === "string" && Boolean(part.trim())))
|
|
1774
|
+
fail("INTERNAL_ERROR", "function structural scope is invalid");
|
|
1775
|
+
if (!bridge.function || typeof values[0] !== "string" || !object(values[1]) || typeof values[2] !== "string")
|
|
1776
|
+
fail("INTERNAL_ERROR", "function requires an available bridge, name, object input, and path");
|
|
1777
|
+
const name = values[0];
|
|
1347
1778
|
try {
|
|
1348
|
-
const result = await bridge.function(values[0], values[1], values[2],
|
|
1779
|
+
const result = await bridge.function(values[0], values[1], values[2], controller.signal, worktreeOwner, structuralPath);
|
|
1349
1780
|
value = branded({ name, ok: true, value: result ?? null });
|
|
1350
1781
|
}
|
|
1351
1782
|
catch (error) {
|
|
@@ -1397,8 +1828,9 @@ export async function persistActiveAgentAttempt(store, id, active) {
|
|
|
1397
1828
|
if (!agent)
|
|
1398
1829
|
throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
|
|
1399
1830
|
const accounting = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
1831
|
+
const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), { ...active, accounting }];
|
|
1400
1832
|
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:
|
|
1833
|
+
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), nativeSessions };
|
|
1402
1834
|
});
|
|
1403
1835
|
}
|
|
1404
1836
|
export async function persistAgentAttempts(store, id, attempts) {
|
|
@@ -1407,26 +1839,53 @@ export async function persistAgentAttempts(store, id, attempts) {
|
|
|
1407
1839
|
if (!agent)
|
|
1408
1840
|
throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
|
|
1409
1841
|
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 }));
|
|
1842
|
+
const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting, setup }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting, ...(setup ? { setup } : {}) }));
|
|
1411
1843
|
const sessionIds = new Set(attempts.map(({ sessionId }) => sessionId));
|
|
1412
1844
|
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
1845
|
});
|
|
1414
1846
|
}
|
|
1847
|
+
function agentGroupKey(agent) { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
|
|
1848
|
+
function agentGroupLabel(agents) {
|
|
1849
|
+
const structural = agents[0]?.structuralPath ?? [];
|
|
1850
|
+
const breadcrumbs = [...new Set(agents.map((agent) => agent.parentBreadcrumb).filter((value) => Boolean(value)))];
|
|
1851
|
+
return [...(structural.length ? [structural.join(" > ")] : []), ...(breadcrumbs.length === 1 ? breadcrumbs : breadcrumbs.length ? [breadcrumbs.join(" | ")] : [])].join(" > ") || "Agents";
|
|
1852
|
+
}
|
|
1853
|
+
function agentGroups(agents) {
|
|
1854
|
+
const byId = new Map(agents.map((agent) => [agent.id, agent]));
|
|
1855
|
+
const groups = new Map();
|
|
1856
|
+
for (const [index, agent] of agents.entries()) {
|
|
1857
|
+
let depth = 0;
|
|
1858
|
+
for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId)
|
|
1859
|
+
depth += 1;
|
|
1860
|
+
const key = agentGroupKey(agent);
|
|
1861
|
+
const group = groups.get(key) ?? { agents: [] };
|
|
1862
|
+
group.agents.push({ agent, index, depth });
|
|
1863
|
+
groups.set(key, group);
|
|
1864
|
+
}
|
|
1865
|
+
return [...groups].map(([, group]) => ({ label: agentGroupLabel(group.agents.map(({ agent }) => agent)), entries: group.agents }));
|
|
1866
|
+
}
|
|
1867
|
+
function renderGroupedAgents(agents, render) {
|
|
1868
|
+
const groups = agentGroups(agents);
|
|
1869
|
+
const grouped = groups.length > 1 || groups.some(({ label }) => label !== "Agents");
|
|
1870
|
+
return groups.flatMap((group) => [
|
|
1871
|
+
...(grouped ? [` ${group.label}`] : []),
|
|
1872
|
+
...group.entries.map((entry) => render(entry, grouped)),
|
|
1873
|
+
]);
|
|
1874
|
+
}
|
|
1415
1875
|
export function formatWorkflowProgress(run, spinner = "◇") {
|
|
1416
1876
|
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)`];
|
|
1877
|
+
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
1878
|
if (run.phase)
|
|
1419
1879
|
lines.push(` Phase: ${run.phase}`);
|
|
1880
|
+
lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${line}`));
|
|
1420
1881
|
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;
|
|
1882
|
+
lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
|
|
1425
1883
|
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
|
|
1426
|
-
const indent = " ".repeat(
|
|
1884
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
1427
1885
|
const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner);
|
|
1428
|
-
|
|
1429
|
-
|
|
1886
|
+
const name = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
|
|
1887
|
+
return `${indent}#${String(index + 1)} ${icon} ${name} [${agent.state}]${activity ? ` ${activity}` : ""}`;
|
|
1888
|
+
}));
|
|
1430
1889
|
return lines.join("\n");
|
|
1431
1890
|
}
|
|
1432
1891
|
function workflowToolUpdate(run) {
|
|
@@ -1450,7 +1909,31 @@ function workflowProgressBlock(run) {
|
|
|
1450
1909
|
invalidate() { },
|
|
1451
1910
|
};
|
|
1452
1911
|
}
|
|
1453
|
-
|
|
1912
|
+
export function formatBudgetStatus(run) {
|
|
1913
|
+
const usage = budgetUsage(run.usage);
|
|
1914
|
+
if (!run.budget || !Object.keys(run.budget).length)
|
|
1915
|
+
return ["Budget: unlimited"];
|
|
1916
|
+
const lines = [`Budget version ${String(run.budgetVersion ?? 1)}`];
|
|
1917
|
+
for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"]) {
|
|
1918
|
+
const limits = run.budget[dimension];
|
|
1919
|
+
if (!limits || (limits.soft === undefined && limits.hard === undefined))
|
|
1920
|
+
continue;
|
|
1921
|
+
const limit = limits.hard ?? limits.soft;
|
|
1922
|
+
const percent = limit === undefined ? "" : ` ${limit === 0 ? "100.0" : ((usage[dimension] / limit) * 100).toFixed(1)}%`;
|
|
1923
|
+
const state = (run.budgetEvents ?? []).filter((event) => event.dimensions.includes(dimension)).at(-1)?.type;
|
|
1924
|
+
lines.push(` ${dimension}: ${String(usage[dimension])}${limits.soft !== undefined ? ` soft=${String(limits.soft)}` : ""}${limits.hard !== undefined ? ` hard=${String(limits.hard)}` : ""}${percent}${state ? ` state=${state}` : ""}`);
|
|
1925
|
+
}
|
|
1926
|
+
const events = run.budgetEvents ?? [];
|
|
1927
|
+
if (events.length)
|
|
1928
|
+
lines.push(` events: ${events.map((event) => `${event.type}@v${String(event.budgetVersion)}`).join(", ")}`);
|
|
1929
|
+
return lines;
|
|
1930
|
+
}
|
|
1931
|
+
function formatCompactBudgetStatus(run) {
|
|
1932
|
+
if (!Object.values(run.budget ?? {}).some((limits) => limits.soft !== undefined || limits.hard !== undefined))
|
|
1933
|
+
return [];
|
|
1934
|
+
return formatBudgetStatus(run);
|
|
1935
|
+
}
|
|
1936
|
+
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
1937
|
function navigatorAttentionSort(entries) {
|
|
1455
1938
|
return [...entries].sort((a, b) => (ATTENTION_ORDER[a.loaded.run.state] ?? 9) - (ATTENTION_ORDER[b.loaded.run.state] ?? 9));
|
|
1456
1939
|
}
|
|
@@ -1460,7 +1943,7 @@ function navigatorRunLabels(entries) {
|
|
|
1460
1943
|
nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
|
|
1461
1944
|
return entries.map(({ store, loaded: { run } }) => {
|
|
1462
1945
|
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" ? "●" : "◆";
|
|
1946
|
+
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1464
1947
|
const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
|
|
1465
1948
|
const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
|
|
1466
1949
|
const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
|
|
@@ -1469,7 +1952,7 @@ function navigatorRunLabels(entries) {
|
|
|
1469
1952
|
}
|
|
1470
1953
|
function agentBreadcrumb(agent, byId) {
|
|
1471
1954
|
const name = agent.label ?? agent.name;
|
|
1472
|
-
const parts = [
|
|
1955
|
+
const parts = agent.parentBreadcrumb ? [agent.parentBreadcrumb] : [];
|
|
1473
1956
|
const seen = new Set([agent.id]);
|
|
1474
1957
|
for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
|
|
1475
1958
|
if (seen.has(parentId))
|
|
@@ -1477,10 +1960,11 @@ function agentBreadcrumb(agent, byId) {
|
|
|
1477
1960
|
seen.add(parentId);
|
|
1478
1961
|
const parent = byId.get(parentId);
|
|
1479
1962
|
if (parent)
|
|
1480
|
-
parts.
|
|
1963
|
+
parts.push(parent.label ?? parent.name);
|
|
1481
1964
|
else
|
|
1482
1965
|
break;
|
|
1483
1966
|
}
|
|
1967
|
+
parts.push(name);
|
|
1484
1968
|
return parts.length > 1 ? parts.join(" > ") : name;
|
|
1485
1969
|
}
|
|
1486
1970
|
function formatAgentActivity(agent, spinner) {
|
|
@@ -1498,53 +1982,49 @@ function formatAccounting(accounting) {
|
|
|
1498
1982
|
return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
|
|
1499
1983
|
}
|
|
1500
1984
|
export function formatNavigatorDashboard(run, checkpoints, worktrees) {
|
|
1985
|
+
void worktrees;
|
|
1501
1986
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
1502
1987
|
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
1988
|
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" ? "●" : "◆";
|
|
1989
|
+
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1505
1990
|
const header = `${glyph} ${run.workflowName}`;
|
|
1506
1991
|
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];
|
|
1992
|
+
const lines = [header, meta, ...formatCompactBudgetStatus(run)];
|
|
1508
1993
|
if (run.error)
|
|
1509
1994
|
lines.push(`Error: ${run.error.code}: ${run.error.message}`);
|
|
1995
|
+
if (run.events?.length)
|
|
1996
|
+
lines.push(...run.events.map((event) => `Warning: ${event.message}`));
|
|
1510
1997
|
lines.push("");
|
|
1511
1998
|
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) {
|
|
1999
|
+
const render = ({ agent, depth }, grouped) => {
|
|
1519
2000
|
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
|
|
1520
|
-
const breadcrumb = agentBreadcrumb(agent, byId);
|
|
1521
|
-
const
|
|
1522
|
-
const
|
|
2001
|
+
const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
|
|
2002
|
+
const setup = agent.attemptDetails?.at(-1)?.setup;
|
|
2003
|
+
const thinking = setup?.model.thinking ?? agent.model.thinking;
|
|
2004
|
+
const model = `${setup?.model.provider ?? agent.model.provider}/${setup?.model.model ?? agent.model.model}${thinking ? `:${thinking}` : ""}`;
|
|
2005
|
+
const policy = [`model=${model}`, agent.requestedModel ? `requested=${agent.requestedModel}` : "", `tools=${(setup?.tools ?? agent.tools).join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
|
|
1523
2006
|
const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
|
|
1524
|
-
const
|
|
1525
|
-
|
|
2007
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
2008
|
+
const result = [`${indent}${icon} ${breadcrumb} · ${agent.state} · ${policy.filter(Boolean).join(" · ")}${tokens ? ` · ${tokens}` : ""}`];
|
|
1526
2009
|
if (agent.state === "failed" && agent.attemptDetails?.length) {
|
|
1527
2010
|
const last = agent.attemptDetails[agent.attemptDetails.length - 1];
|
|
1528
2011
|
if (last?.error)
|
|
1529
|
-
|
|
2012
|
+
result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
|
|
1530
2013
|
}
|
|
1531
2014
|
const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
|
|
1532
2015
|
if (activity)
|
|
1533
|
-
|
|
1534
|
-
|
|
2016
|
+
result.push(`${indent} ${activity}`);
|
|
2017
|
+
return result.join("\n");
|
|
2018
|
+
};
|
|
2019
|
+
lines.push(...renderGroupedAgents(run.agents, render));
|
|
1535
2020
|
if (checkpoints.length) {
|
|
1536
2021
|
lines.push("");
|
|
1537
2022
|
for (const cp of checkpoints)
|
|
1538
2023
|
lines.push(`● checkpoint ${cp.name}: ${cp.prompt}`);
|
|
1539
2024
|
}
|
|
1540
|
-
if (worktrees.length) {
|
|
1541
|
-
lines.push("");
|
|
1542
|
-
for (const wt of worktrees)
|
|
1543
|
-
lines.push(`branch ${wt.branch} (${wt.path})`);
|
|
1544
|
-
}
|
|
1545
2025
|
return lines.join("\n");
|
|
1546
2026
|
}
|
|
1547
|
-
export function formatNavigatorRun(loaded, checkpoints,
|
|
2027
|
+
export function formatNavigatorRun(loaded, checkpoints, _worktrees) {
|
|
1548
2028
|
const { run, snapshot } = loaded;
|
|
1549
2029
|
const lines = [
|
|
1550
2030
|
`Workflow: ${run.workflowName}`,
|
|
@@ -1552,39 +2032,40 @@ export function formatNavigatorRun(loaded, checkpoints, worktrees) {
|
|
|
1552
2032
|
`Status: ${run.state}`,
|
|
1553
2033
|
`Phase: ${run.phase ?? "(none)"}`,
|
|
1554
2034
|
`Launch cwd: ${run.cwd}`,
|
|
2035
|
+
...formatCompactBudgetStatus(run),
|
|
1555
2036
|
`Launch models: ${snapshot.models.join(", ") || "(none)"}`,
|
|
1556
2037
|
];
|
|
1557
2038
|
if (run.error)
|
|
1558
2039
|
lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
|
|
2040
|
+
if (run.events?.length)
|
|
2041
|
+
lines.push(...run.events.map((event) => `Warning: ${event.message}`));
|
|
2042
|
+
const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
|
|
2043
|
+
if (aliases && Object.keys(aliases).length)
|
|
2044
|
+
lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
|
|
1559
2045
|
lines.push("Agents / ownership:");
|
|
1560
2046
|
if (!run.agents.length)
|
|
1561
2047
|
lines.push(" (none)");
|
|
1562
|
-
|
|
2048
|
+
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
2049
|
+
lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
|
|
1563
2050
|
const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
|
|
1564
2051
|
const role = agent.role ? ` role=${agent.role}` : "";
|
|
1565
2052
|
const tools = ` tools=${agent.tools.join(",") || "(none)"}`;
|
|
1566
2053
|
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
|
-
|
|
2054
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
2055
|
+
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
2056
|
for (const attempt of agent.attemptDetails ?? [])
|
|
1569
|
-
|
|
2057
|
+
result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
|
|
1570
2058
|
for (const call of agent.toolCalls ?? [])
|
|
1571
|
-
|
|
1572
|
-
|
|
2059
|
+
result.push(`${indent} tool ${call.name} state=${call.state}`);
|
|
2060
|
+
return result.join("\n");
|
|
2061
|
+
}));
|
|
1573
2062
|
lines.push("Checkpoints:");
|
|
1574
2063
|
if (!checkpoints.length)
|
|
1575
2064
|
lines.push(" (none)");
|
|
1576
2065
|
for (const checkpoint of checkpoints)
|
|
1577
2066
|
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}`);
|
|
2067
|
+
lines.push(`Worktrees: ${String(_worktrees.length)}`);
|
|
2068
|
+
lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
|
|
1588
2069
|
return lines.join("\n");
|
|
1589
2070
|
}
|
|
1590
2071
|
function formatCheckpointReview(checkpoint) {
|
|
@@ -1616,6 +2097,85 @@ function deliver(pi, content) {
|
|
|
1616
2097
|
function deliverFailure(pi, name, error) {
|
|
1617
2098
|
deliver(pi, `Workflow ${name} failed: ${formatWorkflowFailure(error)}`);
|
|
1618
2099
|
}
|
|
2100
|
+
function safeEventError(error) {
|
|
2101
|
+
const code = errorCode(error) ?? "INTERNAL_ERROR";
|
|
2102
|
+
return { code, message: `Workflow execution failed (${code})` };
|
|
2103
|
+
}
|
|
2104
|
+
class WorkflowEventPublisher {
|
|
2105
|
+
sink;
|
|
2106
|
+
#queues = new Map();
|
|
2107
|
+
#budgetEvents = new Map();
|
|
2108
|
+
#worktrees = new Map();
|
|
2109
|
+
constructor(sink) {
|
|
2110
|
+
this.sink = sink;
|
|
2111
|
+
}
|
|
2112
|
+
seedBudget(runId, events) {
|
|
2113
|
+
const seen = this.#budgetEvents.get(runId) ?? new Set();
|
|
2114
|
+
for (const event of events ?? [])
|
|
2115
|
+
seen.add(this.budgetKey(event));
|
|
2116
|
+
this.#budgetEvents.set(runId, seen);
|
|
2117
|
+
}
|
|
2118
|
+
async runStarted(store, metadata) { await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {}); }
|
|
2119
|
+
async runResumed(store, metadata) { await this.#publish(store, metadata, WORKFLOW_RUN_RESUMED_EVENT, {}); }
|
|
2120
|
+
async runState(store, metadata, previousState, state, reason) {
|
|
2121
|
+
await this.#publish(store, metadata, WORKFLOW_RUN_STATE_CHANGED_EVENT, { previousState, state, ...(reason ? { reason } : {}), ...(ERROR_CODES.includes(reason) ? { errorCode: reason } : {}) });
|
|
2122
|
+
if ((previousState === "paused" || previousState === "interrupted" || previousState === "budget_exhausted") && state === "running")
|
|
2123
|
+
await this.runResumed(store, metadata);
|
|
2124
|
+
}
|
|
2125
|
+
async runCompleted(store, metadata, resultPath) { await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath }); }
|
|
2126
|
+
async runFailed(store, metadata, error, state) {
|
|
2127
|
+
if (state === "failed")
|
|
2128
|
+
await this.#publish(store, metadata, WORKFLOW_RUN_FAILED_EVENT, { error: safeEventError(error) });
|
|
2129
|
+
}
|
|
2130
|
+
async agentState(store, metadata, previous, agent) {
|
|
2131
|
+
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 });
|
|
2132
|
+
}
|
|
2133
|
+
async agentStates(store, metadata, previous, current) {
|
|
2134
|
+
const previousById = new Map(previous.map((agent) => [agent.id, agent]));
|
|
2135
|
+
for (const agent of current) {
|
|
2136
|
+
const old = previousById.get(agent.id);
|
|
2137
|
+
if (!old || old.state !== agent.state || old.attempts !== agent.attempts)
|
|
2138
|
+
await this.agentState(store, metadata, old, agent);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
async phase(store, metadata, previousPhase, phase) {
|
|
2142
|
+
if (previousPhase !== phase)
|
|
2143
|
+
await this.#publish(store, metadata, WORKFLOW_PHASE_CHANGED_EVENT, { ...(previousPhase !== undefined ? { previousPhase } : {}), phase });
|
|
2144
|
+
}
|
|
2145
|
+
async checkpoint(store, metadata, name, state) { await this.#publish(store, metadata, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, { name, state }); }
|
|
2146
|
+
async budget(store, metadata, run) {
|
|
2147
|
+
const seen = this.#budgetEvents.get(store.runId) ?? new Set();
|
|
2148
|
+
this.#budgetEvents.set(store.runId, seen);
|
|
2149
|
+
for (const event of run.budgetEvents ?? []) {
|
|
2150
|
+
const key = this.budgetKey(event);
|
|
2151
|
+
if (seen.has(key))
|
|
2152
|
+
continue;
|
|
2153
|
+
seen.add(key);
|
|
2154
|
+
await this.#publish(store, metadata, WORKFLOW_BUDGET_EVENT, { ...event, timestamp: event.at });
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
async worktree(store, metadata, worktree) {
|
|
2158
|
+
const seen = this.#worktrees.get(store.runId) ?? new Set();
|
|
2159
|
+
this.#worktrees.set(store.runId, seen);
|
|
2160
|
+
if (seen.has(worktree.owner))
|
|
2161
|
+
return;
|
|
2162
|
+
seen.add(worktree.owner);
|
|
2163
|
+
await this.#publish(store, metadata, WORKFLOW_WORKTREE_CREATED_EVENT, { owner: worktree.owner, branch: worktree.branch, path: worktree.path, base: worktree.base });
|
|
2164
|
+
}
|
|
2165
|
+
async #publish(store, metadata, name, payload) {
|
|
2166
|
+
const base = { runId: store.runId, sessionId: store.sessionId, workflowName: metadata.name, cwd: store.cwd, runDirectory: store.directory, timestamp: Date.now() };
|
|
2167
|
+
const previous = this.#queues.get(store.runId) ?? Promise.resolve();
|
|
2168
|
+
const next = previous.then(() => {
|
|
2169
|
+
try {
|
|
2170
|
+
void Promise.resolve(this.sink?.emit(name, { ...base, ...payload })).catch(() => undefined);
|
|
2171
|
+
}
|
|
2172
|
+
catch { /* Best effort: listeners cannot affect a run. */ }
|
|
2173
|
+
});
|
|
2174
|
+
this.#queues.set(store.runId, next.catch(() => undefined));
|
|
2175
|
+
await next;
|
|
2176
|
+
}
|
|
2177
|
+
budgetKey(event) { return `${String(event.budgetVersion)}:${event.type}:${event.proposalId ?? ""}`; }
|
|
2178
|
+
}
|
|
1619
2179
|
const inheritedHostAgentPath = new AsyncLocalStorage();
|
|
1620
2180
|
const inheritedHostWorktreeOwner = new AsyncLocalStorage();
|
|
1621
2181
|
function namedRecord(value, kind) {
|
|
@@ -1649,15 +2209,15 @@ function workflowRunContext(cwd, sessionId, runId, workflow, args, signal) {
|
|
|
1649
2209
|
}
|
|
1650
2210
|
async function resolveWorkflowVariables(run, controller, registry) {
|
|
1651
2211
|
let first;
|
|
1652
|
-
const tasks = registry.variables().map(async ({
|
|
2212
|
+
const tasks = registry.variables().map(async ({ name, variable }) => {
|
|
1653
2213
|
try {
|
|
1654
2214
|
const result = await variable.resolve(run);
|
|
1655
2215
|
if (!jsonValue(result) || !Value.Check(variable.schema, result))
|
|
1656
|
-
fail("RESULT_INVALID", `Invalid output from ${
|
|
2216
|
+
fail("RESULT_INVALID", `Invalid output from ${name}`);
|
|
1657
2217
|
return [name, deepFreeze(structuredClone(result))];
|
|
1658
2218
|
}
|
|
1659
2219
|
catch (error) {
|
|
1660
|
-
const typed = errorCode(error) ? new WorkflowError(errorCode(error), `${
|
|
2220
|
+
const typed = errorCode(error) ? new WorkflowError(errorCode(error), `${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${name}: ${errorText(error)}`);
|
|
1661
2221
|
if (!first) {
|
|
1662
2222
|
first = typed;
|
|
1663
2223
|
controller.abort();
|
|
@@ -1742,46 +2302,100 @@ function nextNamedOccurrence(counters, label) {
|
|
|
1742
2302
|
function withWorkflowFunctions(bridge, store, runContext, variables, registry) {
|
|
1743
2303
|
const functionAgentOccurrences = new Map();
|
|
1744
2304
|
const functionWorktreeOccurrences = new Map();
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
2305
|
+
const functionInvokeOccurrences = new Map();
|
|
2306
|
+
const invokeFunction = async (name, input, path, signal, worktreeOwner, structuralPath = [], breadcrumb) => {
|
|
2307
|
+
const replayed = await store.replay(path);
|
|
2308
|
+
let stored;
|
|
2309
|
+
const sideEffects = [];
|
|
2310
|
+
const functionBreadcrumb = breadcrumb ?? name;
|
|
2311
|
+
const context = {
|
|
2312
|
+
run: runContext,
|
|
2313
|
+
invoke: async (targetName, targetInput) => {
|
|
2314
|
+
const inherited = inheritedHostAgentPath.getStore() ?? structuralPath;
|
|
2315
|
+
const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
|
|
2316
|
+
const key = JSON.stringify([path, inherited, targetName]);
|
|
2317
|
+
const occurrence = (functionInvokeOccurrences.get(key) ?? 0) + 1;
|
|
2318
|
+
functionInvokeOccurrences.set(key, occurrence);
|
|
2319
|
+
const nestedPath = operationPath("function", "nested", path, ...inherited, targetName, `occurrence:${String(occurrence)}`);
|
|
2320
|
+
return invokeFunction(targetName, targetInput, nestedPath, signal, scopedWorktreeOwner, inherited, `${functionBreadcrumb} > ${targetName}`);
|
|
2321
|
+
},
|
|
2322
|
+
agent: async (...args) => {
|
|
2323
|
+
if (!bridge.agent || typeof args[0] !== "string")
|
|
2324
|
+
fail("AGENT_FAILED", "No agent bridge is available");
|
|
2325
|
+
const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
|
|
2326
|
+
const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
|
|
2327
|
+
const inherited = inheritedHostAgentPath.getStore() ?? [];
|
|
2328
|
+
const key = `${path}\0${JSON.stringify(inherited)}`;
|
|
2329
|
+
const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
|
|
2330
|
+
functionAgentOccurrences.set(key, occurrence);
|
|
2331
|
+
return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: functionBreadcrumb, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
|
|
2332
|
+
},
|
|
2333
|
+
prompt: workflowPrompt,
|
|
2334
|
+
parallel: (...args) => hostParallel(args[0], args[1]),
|
|
2335
|
+
pipeline: (...args) => hostPipeline(args[0], args[1], args[2]),
|
|
2336
|
+
withWorktree: (...args) => hostWithWorktree(args, path, functionWorktreeOccurrences),
|
|
2337
|
+
checkpoint: async (...args) => {
|
|
2338
|
+
if (!bridge.checkpoint || !object(args[0]) || !jsonValue(args[0]))
|
|
2339
|
+
fail("INTERNAL_ERROR", "No checkpoint bridge is available");
|
|
2340
|
+
return bridge.checkpoint(args[0], signal);
|
|
2341
|
+
},
|
|
2342
|
+
phase: (name) => { sideEffects.push(Promise.resolve(bridge.phase?.(name))); },
|
|
2343
|
+
log: (message) => { sideEffects.push(Promise.resolve(bridge.log?.(message))); },
|
|
2344
|
+
};
|
|
2345
|
+
const result = await inheritedHostAgentPath.run([...structuralPath], async () => registry.invokeFunction(name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } }));
|
|
2346
|
+
await Promise.all(sideEffects);
|
|
2347
|
+
if (!replayed)
|
|
2348
|
+
await store.complete(path, stored ?? result);
|
|
2349
|
+
return result;
|
|
2350
|
+
};
|
|
2351
|
+
return { ...bridge, functions: registry.globals(), variables, function: invokeFunction };
|
|
1780
2352
|
}
|
|
1781
2353
|
function projectTrusted(ctx) {
|
|
1782
2354
|
const check = object(ctx) ? ctx.isProjectTrusted : undefined;
|
|
1783
2355
|
return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
|
|
1784
2356
|
}
|
|
2357
|
+
function isEntryRenderer(value) { return typeof value === "function"; }
|
|
2358
|
+
function isWorkflowEventSink(value) { return object(value) && typeof value.emit === "function"; }
|
|
2359
|
+
function piHostCapabilities(pi) {
|
|
2360
|
+
if (!object(pi))
|
|
2361
|
+
return {};
|
|
2362
|
+
const registerEntryRenderer = pi.registerEntryRenderer;
|
|
2363
|
+
const events = pi.events;
|
|
2364
|
+
return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
|
|
2365
|
+
}
|
|
2366
|
+
function isModelRegistryGetter(value) { return typeof value === "function"; }
|
|
2367
|
+
function contextHostCapabilities(ctx) {
|
|
2368
|
+
if (!object(ctx) || !object(ctx.modelRegistry))
|
|
2369
|
+
return {};
|
|
2370
|
+
const registry = ctx.modelRegistry;
|
|
2371
|
+
const getAll = registry.getAll;
|
|
2372
|
+
const getAvailable = registry.getAvailable;
|
|
2373
|
+
return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
|
|
2374
|
+
}
|
|
2375
|
+
function isUiSelect(value) { return typeof value === "function"; }
|
|
2376
|
+
function isUiInput(value) { return typeof value === "function"; }
|
|
2377
|
+
function isUiSetStatus(value) { return typeof value === "function"; }
|
|
2378
|
+
function uiHostCapabilities(ui) {
|
|
2379
|
+
if (!object(ui))
|
|
2380
|
+
return undefined;
|
|
2381
|
+
const select = ui.select;
|
|
2382
|
+
const input = ui.input;
|
|
2383
|
+
const setStatus = ui.setStatus;
|
|
2384
|
+
return { ...(isUiSelect(select) ? { select } : {}), ...(isUiInput(input) ? { input } : {}), ...(isUiSetStatus(setStatus) ? { setStatus } : {}) };
|
|
2385
|
+
}
|
|
2386
|
+
function tuiHostCapabilities(tui) {
|
|
2387
|
+
if (!object(tui) || !object(tui.terminal))
|
|
2388
|
+
return {};
|
|
2389
|
+
return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
|
|
2390
|
+
}
|
|
2391
|
+
function tuiRows(tui) { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
|
|
2392
|
+
function isKeybindingGetter(value) { return typeof value === "function"; }
|
|
2393
|
+
function keybindingsHostCapabilities(keybindings) {
|
|
2394
|
+
if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys))
|
|
2395
|
+
return {};
|
|
2396
|
+
return { getKeys: keybindings.getKeys };
|
|
2397
|
+
}
|
|
2398
|
+
function keybindingKeys(keybindings, name) { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
|
|
1785
2399
|
function parseThinking(value) {
|
|
1786
2400
|
switch (value) {
|
|
1787
2401
|
case "off":
|
|
@@ -1797,7 +2411,7 @@ function parseThinking(value) {
|
|
|
1797
2411
|
export default function workflowExtension(pi, home, clipboard = copyToClipboard, createSession = createNativeAgentSession) {
|
|
1798
2412
|
beginWorkflowExtensionLoading();
|
|
1799
2413
|
const registry = loadingRegistry();
|
|
1800
|
-
const registerEntryRenderer = pi.registerEntryRenderer;
|
|
2414
|
+
const registerEntryRenderer = piHostCapabilities(pi).registerEntryRenderer;
|
|
1801
2415
|
registerEntryRenderer?.(WORKFLOW_LOG_ENTRY, (entry) => {
|
|
1802
2416
|
const data = entry.data;
|
|
1803
2417
|
return textBlock(data ? `Workflow ${data.workflowName}: ${data.message}` : "");
|
|
@@ -1811,7 +2425,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1811
2425
|
await lifecycle.leave();
|
|
1812
2426
|
}
|
|
1813
2427
|
};
|
|
1814
|
-
const
|
|
2428
|
+
const eventPublisher = new WorkflowEventPublisher(piHostCapabilities(pi).events);
|
|
1815
2429
|
pi.on("resources_discover", () => {
|
|
1816
2430
|
if (!pi.getActiveTools().includes("workflow"))
|
|
1817
2431
|
return;
|
|
@@ -1819,13 +2433,31 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1819
2433
|
const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
|
|
1820
2434
|
return skillPath ? { skillPaths: [skillPath] } : undefined;
|
|
1821
2435
|
});
|
|
1822
|
-
const
|
|
1823
|
-
|
|
2436
|
+
const runs = new Map();
|
|
2437
|
+
const liveActivities = new Map();
|
|
2438
|
+
const setLiveActivity = (runId, agentId, activity) => {
|
|
2439
|
+
const activities = liveActivities.get(runId);
|
|
2440
|
+
if (activity) {
|
|
2441
|
+
if (activities)
|
|
2442
|
+
activities.set(agentId, activity);
|
|
2443
|
+
else
|
|
2444
|
+
liveActivities.set(runId, new Map([[agentId, activity]]));
|
|
2445
|
+
}
|
|
2446
|
+
else {
|
|
2447
|
+
activities?.delete(agentId);
|
|
2448
|
+
if (activities?.size === 0)
|
|
2449
|
+
liveActivities.delete(runId);
|
|
2450
|
+
}
|
|
1824
2451
|
};
|
|
1825
|
-
const
|
|
1826
|
-
|
|
2452
|
+
const withLiveActivities = (run) => {
|
|
2453
|
+
const activities = liveActivities.get(run.id);
|
|
2454
|
+
return activities?.size ? { ...run, agents: run.agents.map((agent) => {
|
|
2455
|
+
const activity = activities.get(agent.id);
|
|
2456
|
+
return activity ? { ...agent, activity } : agent;
|
|
2457
|
+
}) } : run;
|
|
1827
2458
|
};
|
|
1828
|
-
const
|
|
2459
|
+
const conversationLocks = new Set();
|
|
2460
|
+
const terminalRunStates = new Map();
|
|
1829
2461
|
let sessionLease;
|
|
1830
2462
|
let sessionLeasePromise;
|
|
1831
2463
|
const ensureSessionLease = async (cwd, sessionId) => {
|
|
@@ -1846,48 +2478,84 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1846
2478
|
sessionLeasePromise = undefined;
|
|
1847
2479
|
await lease?.release();
|
|
1848
2480
|
};
|
|
1849
|
-
const
|
|
1850
|
-
const
|
|
1851
|
-
|
|
2481
|
+
const persistRunState = async (store, metadata, update) => {
|
|
2482
|
+
const persisted = await store.updateState(update);
|
|
2483
|
+
await eventPublisher.budget(store, metadata, persisted);
|
|
2484
|
+
return persisted;
|
|
2485
|
+
};
|
|
2486
|
+
const persistWorktree = async (store, metadata, owner) => {
|
|
2487
|
+
const existing = (await store.worktrees()).some((worktree) => worktree.owner === owner);
|
|
2488
|
+
const worktree = await store.worktree(owner);
|
|
2489
|
+
if (!existing)
|
|
2490
|
+
await eventPublisher.worktree(store, metadata, worktree);
|
|
2491
|
+
return worktree;
|
|
2492
|
+
};
|
|
2493
|
+
const lifecycleFor = (store, state, budget, metadata) => new RunLifecycle(state, async (next, previous, reason) => {
|
|
2494
|
+
if (next !== "pausing")
|
|
2495
|
+
budget.transition(next);
|
|
2496
|
+
const persisted = await persistRunState(store, metadata, (current) => {
|
|
2497
|
+
const nextRun = { ...current, state: next, ...budget.snapshot() };
|
|
1852
2498
|
if (next === "running" || next === "completed")
|
|
1853
2499
|
delete nextRun.error;
|
|
1854
2500
|
return nextRun;
|
|
1855
2501
|
});
|
|
1856
|
-
|
|
2502
|
+
await eventPublisher.runState(store, metadata, previous, next, reason);
|
|
2503
|
+
runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1857
2504
|
});
|
|
1858
2505
|
const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
|
|
1859
2506
|
const run = runs.get(runId);
|
|
1860
2507
|
if (!run)
|
|
1861
2508
|
throw new WorkflowError("INTERNAL_ERROR", `Unknown production run: ${runId}`);
|
|
1862
2509
|
try {
|
|
2510
|
+
const budget = run.budget.forAgent(id);
|
|
1863
2511
|
const onProgress = async (progress) => {
|
|
1864
2512
|
let runState;
|
|
1865
2513
|
if (progress.persist) {
|
|
1866
|
-
runState = await run.store.
|
|
2514
|
+
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
2515
|
}
|
|
1868
2516
|
else {
|
|
1869
2517
|
const loaded = await run.store.load();
|
|
1870
2518
|
if (!loaded.run.agents.some((agent) => agent.id === id))
|
|
1871
2519
|
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) };
|
|
2520
|
+
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
2521
|
}
|
|
1874
2522
|
if (!runState.agents.some((agent) => agent.id === id))
|
|
1875
2523
|
return;
|
|
1876
|
-
|
|
2524
|
+
setLiveActivity(runId, id, progress.activity);
|
|
2525
|
+
run.update?.(workflowToolUpdate(withLiveActivities(runState)));
|
|
1877
2526
|
};
|
|
1878
2527
|
const onAttempt = async (attempt) => {
|
|
1879
2528
|
await scheduler.flush();
|
|
2529
|
+
scheduler.attemptStarted(id);
|
|
2530
|
+
await scheduler.flush();
|
|
2531
|
+
const before = (await run.store.load()).run;
|
|
1880
2532
|
await persistActiveAgentAttempt(run.store, id, attempt);
|
|
1881
|
-
|
|
2533
|
+
const active = (await run.store.load()).run;
|
|
2534
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
|
|
2535
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2536
|
+
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1882
2537
|
};
|
|
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); });
|
|
2538
|
+
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); });
|
|
2539
|
+
const before = (await run.store.load()).run;
|
|
1884
2540
|
await persistAgentAttempts(run.store, id, result.attempts);
|
|
2541
|
+
const completed = (await run.store.load()).run;
|
|
2542
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
|
|
2543
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2544
|
+
setLiveActivity(runId, id);
|
|
2545
|
+
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1885
2546
|
return result.value;
|
|
1886
2547
|
}
|
|
1887
2548
|
catch (error) {
|
|
1888
2549
|
const attempts = error.attempts;
|
|
1889
|
-
if (attempts?.length)
|
|
2550
|
+
if (attempts?.length) {
|
|
2551
|
+
const before = (await run.store.load()).run;
|
|
1890
2552
|
await persistAgentAttempts(run.store, id, attempts);
|
|
2553
|
+
const failed = (await run.store.load()).run;
|
|
2554
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, failed.agents);
|
|
2555
|
+
}
|
|
2556
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2557
|
+
setLiveActivity(runId, id);
|
|
2558
|
+
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1891
2559
|
throw error;
|
|
1892
2560
|
}
|
|
1893
2561
|
}, 16, async (runId, ownership) => {
|
|
@@ -1895,7 +2563,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1895
2563
|
if (!run)
|
|
1896
2564
|
return;
|
|
1897
2565
|
await run.store.saveOwnership(ownership);
|
|
1898
|
-
|
|
2566
|
+
let previousAgents = [];
|
|
2567
|
+
const runState = await persistRunState(run.store, run.metadata, (current) => {
|
|
2568
|
+
previousAgents = current.agents;
|
|
1899
2569
|
const existing = new Map(current.agents.map((agent) => [agent.id, agent]));
|
|
1900
2570
|
const agents = ownership.map((node) => {
|
|
1901
2571
|
const previous = existing.get(node.id);
|
|
@@ -1905,14 +2575,30 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1905
2575
|
effective = run.executor.resolve(requested);
|
|
1906
2576
|
}
|
|
1907
2577
|
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 };
|
|
2578
|
+
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
2579
|
}
|
|
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 } : {}) };
|
|
2580
|
+
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
2581
|
});
|
|
1912
2582
|
return { ...current, agents };
|
|
1913
2583
|
});
|
|
1914
|
-
run.
|
|
2584
|
+
await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
|
|
2585
|
+
run.update?.(workflowToolUpdate(withLiveActivities(runState)));
|
|
1915
2586
|
});
|
|
2587
|
+
const stopWorkflowRun = async (runId) => {
|
|
2588
|
+
const run = runs.get(runId);
|
|
2589
|
+
const terminalState = terminalRunStates.get(runId);
|
|
2590
|
+
if (!run)
|
|
2591
|
+
return terminalState ? { runId, state: terminalState, stopped: false, reason: "already_terminal" } : { runId, state: "unknown", stopped: false, reason: "unknown_run" };
|
|
2592
|
+
const state = run.lifecycle.state;
|
|
2593
|
+
if (state === "completed" || state === "failed" || state === "stopped")
|
|
2594
|
+
return { runId, state, stopped: false, reason: "already_terminal" };
|
|
2595
|
+
await run.lifecycle.terminal("stopped");
|
|
2596
|
+
run.abortController.abort();
|
|
2597
|
+
run.execution?.cancel();
|
|
2598
|
+
await scheduler.cancelRun(run.store.runId);
|
|
2599
|
+
await scheduler.flush();
|
|
2600
|
+
return { runId, state: "stopped", stopped: true };
|
|
2601
|
+
};
|
|
1916
2602
|
const answerCheckpoint = async (runId, name, approved, silent = false) => {
|
|
1917
2603
|
const run = runs.get(runId);
|
|
1918
2604
|
if (!run)
|
|
@@ -1920,6 +2606,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1920
2606
|
const checkpoint = await run.store.answerCheckpoint(name, approved);
|
|
1921
2607
|
if (!checkpoint)
|
|
1922
2608
|
return false;
|
|
2609
|
+
await eventPublisher.checkpoint(run.store, run.metadata, checkpoint.name, approved ? "approved" : "rejected");
|
|
1923
2610
|
if ((await run.store.awaitingCheckpoints()).length === 0)
|
|
1924
2611
|
await run.lifecycle.resolveAwaitingInput();
|
|
1925
2612
|
run.checkpointResolvers.get(checkpoint.path)?.(approved);
|
|
@@ -1928,6 +2615,26 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1928
2615
|
deliver(pi, `Workflow ${run.metadata.name} checkpoint ${name}: ${approved ? "Approved" : "Rejected"}.`);
|
|
1929
2616
|
return true;
|
|
1930
2617
|
};
|
|
2618
|
+
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}.`;
|
|
2619
|
+
const appendBudgetDecisionEvent = async (run, request, type) => {
|
|
2620
|
+
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) });
|
|
2621
|
+
await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2622
|
+
};
|
|
2623
|
+
const answerBudgetDecision = async (runId, proposalId, approved, silent = false) => {
|
|
2624
|
+
const run = runs.get(runId);
|
|
2625
|
+
if (!run)
|
|
2626
|
+
return undefined;
|
|
2627
|
+
const request = await run.store.answerWorkflowDecision(proposalId, approved);
|
|
2628
|
+
if (!request)
|
|
2629
|
+
return undefined;
|
|
2630
|
+
await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
|
|
2631
|
+
const result = await applyBudgetDecision(request, approved);
|
|
2632
|
+
run.budgetResolvers.get(proposalId)?.(result);
|
|
2633
|
+
run.budgetResolvers.delete(proposalId);
|
|
2634
|
+
if (!silent)
|
|
2635
|
+
deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
|
|
2636
|
+
return result;
|
|
2637
|
+
};
|
|
1931
2638
|
const checkpointBridge = (runId, store, metadata, foreground, ui) => {
|
|
1932
2639
|
const checkpointCounters = new Map();
|
|
1933
2640
|
return async (raw, signal) => {
|
|
@@ -1940,6 +2647,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1940
2647
|
const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
|
|
1941
2648
|
if (replayed !== undefined)
|
|
1942
2649
|
return replayed;
|
|
2650
|
+
if (!alreadyAwaiting)
|
|
2651
|
+
await eventPublisher.checkpoint(store, metadata, label, "awaiting");
|
|
1943
2652
|
const run = runs.get(runId);
|
|
1944
2653
|
await run?.lifecycle.enterAwaitingInput();
|
|
1945
2654
|
if (!alreadyAwaiting && !ui?.select)
|
|
@@ -1965,7 +2674,6 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1965
2674
|
if (!choice) {
|
|
1966
2675
|
if (foreground)
|
|
1967
2676
|
continue; // foreground: retry until answered
|
|
1968
|
-
// Background resume: user dismissed UI, fall back to LLM
|
|
1969
2677
|
deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
|
|
1970
2678
|
return;
|
|
1971
2679
|
}
|
|
@@ -1979,12 +2687,37 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1979
2687
|
pi.registerTool({
|
|
1980
2688
|
name: "workflow_respond",
|
|
1981
2689
|
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 }),
|
|
2690
|
+
description: "Approve or reject one pending workflow checkpoint or budget decision",
|
|
2691
|
+
parameters: Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
|
|
1984
2692
|
async execute(_id, params) {
|
|
1985
2693
|
try {
|
|
2694
|
+
if (params.proposalId) {
|
|
2695
|
+
const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved);
|
|
2696
|
+
if (!result) {
|
|
2697
|
+
const denied = { state: "budget_exhausted", approved: false, reason: "proposal_not_pending" };
|
|
2698
|
+
return { content: [{ type: "text", text: JSON.stringify(denied) }], details: denied };
|
|
2699
|
+
}
|
|
2700
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: { ...result, reason: params.approved ? "approved" : "rejected" } };
|
|
2701
|
+
}
|
|
2702
|
+
if (!params.name)
|
|
2703
|
+
throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
|
|
1986
2704
|
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 } };
|
|
2705
|
+
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" } };
|
|
2706
|
+
}
|
|
2707
|
+
catch (error) {
|
|
2708
|
+
throw mainAgentError(error);
|
|
2709
|
+
}
|
|
2710
|
+
},
|
|
2711
|
+
});
|
|
2712
|
+
pi.registerTool({
|
|
2713
|
+
name: "workflow_stop",
|
|
2714
|
+
label: "Workflow Stop",
|
|
2715
|
+
description: "Stop an active workflow run by ID",
|
|
2716
|
+
parameters: Type.Object({ runId: Type.String() }, { additionalProperties: false }),
|
|
2717
|
+
async execute(_id, params) {
|
|
2718
|
+
try {
|
|
2719
|
+
const result = await stopWorkflowRun(params.runId);
|
|
2720
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
1988
2721
|
}
|
|
1989
2722
|
catch (error) {
|
|
1990
2723
|
throw mainAgentError(error);
|
|
@@ -1997,7 +2730,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
1997
2730
|
if (catalogRegistered || !pi.getActiveTools().includes("workflow"))
|
|
1998
2731
|
return;
|
|
1999
2732
|
const catalog = registry.catalog();
|
|
2000
|
-
|
|
2733
|
+
const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
|
|
2734
|
+
if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length && !hasAliases)
|
|
2001
2735
|
return;
|
|
2002
2736
|
pi.registerTool({
|
|
2003
2737
|
name: "workflow_catalog",
|
|
@@ -2008,7 +2742,33 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2008
2742
|
});
|
|
2009
2743
|
catalogRegistered = true;
|
|
2010
2744
|
};
|
|
2011
|
-
const
|
|
2745
|
+
const refreshPausedRunAliases = async (run, context) => {
|
|
2746
|
+
const loaded = await run.store.load();
|
|
2747
|
+
const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
|
|
2748
|
+
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
2749
|
+
if (missing)
|
|
2750
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
2751
|
+
const settingsPath = workflowSettingsPath();
|
|
2752
|
+
const currentSettings = loadSettings(settingsPath);
|
|
2753
|
+
resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
|
|
2754
|
+
const currentAliases = currentSettings.modelAliases ?? {};
|
|
2755
|
+
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
2756
|
+
const modelRegistry = context?.modelRegistry;
|
|
2757
|
+
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
|
|
2758
|
+
if (context?.model)
|
|
2759
|
+
knownModels.add(`${context.model.provider}/${context.model.id}`);
|
|
2760
|
+
const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
|
|
2761
|
+
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2762
|
+
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2763
|
+
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
|
|
2764
|
+
await run.store.saveSnapshot(snapshot);
|
|
2765
|
+
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);
|
|
2766
|
+
run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
|
|
2767
|
+
const drift = aliasDrift(previousAliases, currentAliases);
|
|
2768
|
+
if (drift.length)
|
|
2769
|
+
await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
2770
|
+
};
|
|
2771
|
+
const coldResumeRun = async (run, hasUI, ui, trustedProject, context) => {
|
|
2012
2772
|
const loaded = await run.store.load();
|
|
2013
2773
|
if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION)
|
|
2014
2774
|
throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
|
|
@@ -2019,14 +2779,34 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2019
2779
|
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
2020
2780
|
if (missingRole)
|
|
2021
2781
|
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"));
|
|
2782
|
+
const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
|
|
2023
2783
|
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
2024
2784
|
if (missing)
|
|
2025
2785
|
throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
2026
|
-
|
|
2786
|
+
const settingsPath = workflowSettingsPath();
|
|
2787
|
+
const currentSettings = loadSettings(settingsPath);
|
|
2788
|
+
resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
|
|
2789
|
+
const currentAliases = currentSettings.modelAliases ?? {};
|
|
2790
|
+
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
2791
|
+
const modelRegistry = context?.modelRegistry;
|
|
2792
|
+
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
|
|
2793
|
+
if (context?.model)
|
|
2794
|
+
knownModels.add(`${context.model.provider}/${context.model.id}`);
|
|
2795
|
+
const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
|
|
2796
|
+
const resumeAliases = { ...previousAliases, ...currentAliases };
|
|
2797
|
+
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2798
|
+
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2799
|
+
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);
|
|
2800
|
+
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
|
|
2801
|
+
await run.store.saveSnapshot(snapshot);
|
|
2802
|
+
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);
|
|
2803
|
+
const drift = aliasDrift(previousAliases, currentAliases);
|
|
2804
|
+
if (drift.length)
|
|
2805
|
+
await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
2027
2806
|
const controller = new AbortController();
|
|
2028
2807
|
run.abortController = controller;
|
|
2029
2808
|
const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
|
|
2809
|
+
run.executor.setRunContext(runContext);
|
|
2030
2810
|
let variables;
|
|
2031
2811
|
try {
|
|
2032
2812
|
variables = await resolveWorkflowVariables(runContext, controller, registry);
|
|
@@ -2034,8 +2814,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2034
2814
|
catch (error) {
|
|
2035
2815
|
const typed = asWorkflowError(error);
|
|
2036
2816
|
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
|
|
2037
|
-
await run.lifecycle.terminal("failed").catch(() => undefined);
|
|
2038
|
-
await run.store.
|
|
2817
|
+
await run.lifecycle.terminal("failed", typed.code).catch(() => undefined);
|
|
2818
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } }));
|
|
2819
|
+
await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed");
|
|
2820
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
2039
2821
|
}
|
|
2040
2822
|
throw typed;
|
|
2041
2823
|
}
|
|
@@ -2043,13 +2825,29 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2043
2825
|
await run.lifecycle.resume();
|
|
2044
2826
|
const execution = runWorkflow(loaded.snapshot.script, loaded.snapshot.args, withWorkflowFunctions({ agent: async (prompt, options, signal, identity) => {
|
|
2045
2827
|
await run.lifecycle.enter();
|
|
2828
|
+
const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
|
|
2829
|
+
const conversationLock = conversationId ? `${run.store.runId}:${conversationId}` : "";
|
|
2046
2830
|
try {
|
|
2047
|
-
const path = agentIdentityPath(identity);
|
|
2831
|
+
const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
|
|
2048
2832
|
const replayed = await run.store.replay(path);
|
|
2049
|
-
if (replayed)
|
|
2833
|
+
if (replayed) {
|
|
2834
|
+
if (conversationId) {
|
|
2835
|
+
const conversation = await run.store.conversation(conversationId);
|
|
2836
|
+
if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0))
|
|
2837
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
|
|
2838
|
+
}
|
|
2050
2839
|
return replayed.value;
|
|
2840
|
+
}
|
|
2841
|
+
if (conversationId) {
|
|
2842
|
+
if (conversationLocks.has(conversationLock))
|
|
2843
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
|
|
2844
|
+
const conversation = await run.store.conversation(conversationId);
|
|
2845
|
+
if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1)
|
|
2846
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
|
|
2847
|
+
conversationLocks.add(conversationLock);
|
|
2848
|
+
}
|
|
2051
2849
|
const worktree = agentWorktree(identity);
|
|
2052
|
-
const cwd = worktree.worktreeOwner ? (await run.store.worktree
|
|
2850
|
+
const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
|
|
2053
2851
|
const role = typeof options.role === "string" ? options.role : undefined;
|
|
2054
2852
|
const model = typeof options.model === "string" ? options.model : undefined;
|
|
2055
2853
|
const thinking = parseThinking(options.thinking);
|
|
@@ -2058,7 +2856,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2058
2856
|
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
2059
2857
|
const tools = resolved.tools;
|
|
2060
2858
|
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 } : {}) });
|
|
2859
|
+
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
2860
|
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
2063
2861
|
signal.addEventListener("abort", cancel, { once: true });
|
|
2064
2862
|
const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
|
|
@@ -2068,22 +2866,116 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2068
2866
|
return outcome.value;
|
|
2069
2867
|
}
|
|
2070
2868
|
finally {
|
|
2869
|
+
if (conversationLock)
|
|
2870
|
+
conversationLocks.delete(conversationLock);
|
|
2071
2871
|
await run.lifecycle.leave();
|
|
2072
2872
|
}
|
|
2073
2873
|
}, checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try {
|
|
2074
|
-
|
|
2874
|
+
let previousPhase;
|
|
2875
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
|
|
2876
|
+
await eventPublisher.phase(run.store, run.metadata, previousPhase, phase);
|
|
2877
|
+
runs.get(run.store.runId)?.update?.(workflowToolUpdate(persisted));
|
|
2075
2878
|
}
|
|
2076
2879
|
finally {
|
|
2077
2880
|
await run.lifecycle.leave();
|
|
2078
2881
|
} }, log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
|
|
2079
2882
|
run.execution = execution;
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2883
|
+
const completion = execution.result.then(async (value) => {
|
|
2884
|
+
await scheduler.flush();
|
|
2885
|
+
if (run.budget.hardExhausted)
|
|
2886
|
+
throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
|
|
2887
|
+
const resultPath = await run.store.saveResult(value);
|
|
2888
|
+
await run.lifecycle.terminal("completed", "completed");
|
|
2889
|
+
await eventPublisher.runCompleted(run.store, run.metadata, resultPath);
|
|
2890
|
+
return { value, resultPath };
|
|
2891
|
+
}).catch(async (error) => {
|
|
2892
|
+
await scheduler.flush();
|
|
2893
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
|
|
2894
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state))
|
|
2895
|
+
await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
2896
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
|
|
2897
|
+
const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
|
|
2898
|
+
await eventPublisher.runFailed(run.store, run.metadata, typed, state);
|
|
2899
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
2900
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state))
|
|
2901
|
+
deliverFailure(pi, run.metadata.name, error);
|
|
2902
|
+
});
|
|
2085
2903
|
void completion;
|
|
2086
2904
|
};
|
|
2905
|
+
const applyBudgetDecision = async (request, approved) => {
|
|
2906
|
+
const run = runs.get(request.runId);
|
|
2907
|
+
if (!run)
|
|
2908
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
|
|
2909
|
+
if (!approved)
|
|
2910
|
+
return { state: "budget_exhausted", approved: false };
|
|
2911
|
+
const nextBudget = validateBudget(request.proposed);
|
|
2912
|
+
const nextVersion = request.budgetVersion + 1;
|
|
2913
|
+
const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
|
|
2914
|
+
run.budget = runtime;
|
|
2915
|
+
await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget)
|
|
2916
|
+
next.budget = nextBudget;
|
|
2917
|
+
else
|
|
2918
|
+
delete next.budget; return next; });
|
|
2919
|
+
await coldResumeRun(run, false, {}, true);
|
|
2920
|
+
return { state: "running", approved: true };
|
|
2921
|
+
};
|
|
2922
|
+
const resumeWorkflowRun = async (runId, rawPatch) => {
|
|
2923
|
+
const run = runs.get(runId);
|
|
2924
|
+
if (!run)
|
|
2925
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
|
|
2926
|
+
const loaded = await run.store.load();
|
|
2927
|
+
if (loaded.run.state !== "budget_exhausted")
|
|
2928
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
|
|
2929
|
+
const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
2930
|
+
const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
|
|
2931
|
+
const nextBudget = mergeBudget(currentBudget, patch);
|
|
2932
|
+
const usage = budgetUsage(loaded.run.usage);
|
|
2933
|
+
if (!resumeBudgetAllowed(nextBudget, usage))
|
|
2934
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Every exhausted hard budget must be raised above retained usage or removed");
|
|
2935
|
+
if (budgetRelaxed(currentBudget, nextBudget)) {
|
|
2936
|
+
const proposalId = randomUUID();
|
|
2937
|
+
const request = { kind: "budget", proposalId, runId, consumed: usage, previous: currentBudget ?? {}, proposed: nextBudget ?? {}, budgetVersion: loaded.run.budgetVersion ?? 1 };
|
|
2938
|
+
const decision = new Promise((resolve) => { run.budgetResolvers.set(proposalId, resolve); });
|
|
2939
|
+
try {
|
|
2940
|
+
await run.store.requestWorkflowDecision(request);
|
|
2941
|
+
await appendBudgetDecisionEvent(run, request, "adjustment_requested");
|
|
2942
|
+
}
|
|
2943
|
+
catch (error) {
|
|
2944
|
+
run.budgetResolvers.delete(proposalId);
|
|
2945
|
+
throw error;
|
|
2946
|
+
}
|
|
2947
|
+
deliver(pi, budgetDecisionDelivery(run.metadata, request));
|
|
2948
|
+
const decisionResult = await decision;
|
|
2949
|
+
return { state: decisionResult.state };
|
|
2950
|
+
}
|
|
2951
|
+
const changed = JSON.stringify(currentBudget ?? {}) !== JSON.stringify(nextBudget ?? {});
|
|
2952
|
+
if (changed) {
|
|
2953
|
+
const nextVersion = (loaded.run.budgetVersion ?? 1) + 1;
|
|
2954
|
+
const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, usage, loaded.run.budgetEvents, { active: false });
|
|
2955
|
+
run.budget = runtime;
|
|
2956
|
+
await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget)
|
|
2957
|
+
next.budget = nextBudget;
|
|
2958
|
+
else
|
|
2959
|
+
delete next.budget; return next; });
|
|
2960
|
+
}
|
|
2961
|
+
await coldResumeRun(run, false, {}, true);
|
|
2962
|
+
return { state: "running" };
|
|
2963
|
+
};
|
|
2964
|
+
pi.registerTool({
|
|
2965
|
+
name: "workflow_resume",
|
|
2966
|
+
label: "Workflow Resume",
|
|
2967
|
+
description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
|
|
2968
|
+
parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
|
|
2969
|
+
async execute(_id, params) {
|
|
2970
|
+
try {
|
|
2971
|
+
const result = await resumeWorkflowRun(params.runId, params.budget);
|
|
2972
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
2973
|
+
}
|
|
2974
|
+
catch (error) {
|
|
2975
|
+
throw mainAgentError(error);
|
|
2976
|
+
}
|
|
2977
|
+
},
|
|
2978
|
+
});
|
|
2087
2979
|
pi.on("session_start", async (_event, ctx) => {
|
|
2088
2980
|
if (sessionStarted)
|
|
2089
2981
|
return;
|
|
@@ -2105,22 +2997,32 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2105
2997
|
await store.delete(true).catch(() => undefined);
|
|
2106
2998
|
continue;
|
|
2107
2999
|
}
|
|
2108
|
-
if (
|
|
3000
|
+
if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") {
|
|
3001
|
+
terminalRunStates.set(runId, loaded.run.state);
|
|
2109
3002
|
continue;
|
|
2110
|
-
|
|
2111
|
-
|
|
3003
|
+
}
|
|
3004
|
+
if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
|
|
3005
|
+
const previousState = loaded.run.state;
|
|
3006
|
+
await store.updateState((current) => ["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state) ? current : { ...current, state: "interrupted" });
|
|
3007
|
+
loaded = { ...loaded, run: (await store.load()).run };
|
|
3008
|
+
await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
|
|
2112
3009
|
loaded = { ...loaded, run: (await store.load()).run };
|
|
2113
3010
|
}
|
|
2114
3011
|
const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
|
|
2115
|
-
const
|
|
3012
|
+
const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
3013
|
+
eventPublisher.seedBudget(runId, loaded.run.budgetEvents);
|
|
3014
|
+
const budgetRuntime = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents, { active: loaded.run.state === "running" });
|
|
3015
|
+
const lifecycle = lifecycleFor(store, loaded.run.state, budgetRuntime, loaded.snapshot.metadata);
|
|
2116
3016
|
const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
2117
3017
|
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() });
|
|
3018
|
+
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
3019
|
for (const checkpoint of await store.awaitingCheckpoints())
|
|
2120
3020
|
deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
|
|
2121
|
-
|
|
3021
|
+
for (const decision of await store.pendingWorkflowDecisions())
|
|
3022
|
+
deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
|
|
3023
|
+
scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
2122
3024
|
}
|
|
2123
|
-
const resumeSelect = ctx.ui?.select;
|
|
3025
|
+
const resumeSelect = uiHostCapabilities(ctx.ui)?.select;
|
|
2124
3026
|
if (ctx.hasUI && resumeSelect) {
|
|
2125
3027
|
const interrupted = [...runs.values()].filter((r) => r.lifecycle.state === "interrupted");
|
|
2126
3028
|
if (interrupted.length > 0) {
|
|
@@ -2131,7 +3033,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2131
3033
|
const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
|
|
2132
3034
|
for (const run of toResume) {
|
|
2133
3035
|
try {
|
|
2134
|
-
await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx));
|
|
3036
|
+
await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx);
|
|
2135
3037
|
ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info");
|
|
2136
3038
|
}
|
|
2137
3039
|
catch (err) {
|
|
@@ -2164,18 +3066,23 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2164
3066
|
parameters: WORKFLOW_TOOL_PARAMETERS,
|
|
2165
3067
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
2166
3068
|
try {
|
|
3069
|
+
const settingsPath = workflowSettingsPath();
|
|
3070
|
+
const defaults = loadSettings(settingsPath);
|
|
2167
3071
|
if (!ctx.model)
|
|
2168
3072
|
throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
|
|
2169
|
-
const
|
|
2170
|
-
const
|
|
3073
|
+
const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
|
|
3074
|
+
const budget = validateBudget(params.budget);
|
|
2171
3075
|
const rootModel = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
|
|
2172
3076
|
const rootModelName = `${rootModel.provider}/${rootModel.model}`;
|
|
2173
|
-
const modelRegistry = ctx.modelRegistry;
|
|
2174
|
-
const
|
|
2175
|
-
|
|
2176
|
-
const
|
|
3077
|
+
const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
|
|
3078
|
+
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
|
|
3079
|
+
knownModels.add(rootModelName);
|
|
3080
|
+
const availableModels = knownModels;
|
|
3081
|
+
const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_stop" && name !== "workflow_catalog");
|
|
2177
3082
|
const trustedProject = projectTrusted(ctx);
|
|
2178
|
-
|
|
3083
|
+
if (typeof ctx.cwd === "string")
|
|
3084
|
+
resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
|
|
3085
|
+
const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases: defaults.modelAliases ?? {}, knownModels, settingsPath }, registry);
|
|
2179
3086
|
const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames } = validated;
|
|
2180
3087
|
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
2181
3088
|
const runId = randomUUID();
|
|
@@ -2191,28 +3098,46 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2191
3098
|
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
2192
3099
|
const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]]));
|
|
2193
3100
|
const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
|
|
2194
|
-
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model)] : []; });
|
|
3101
|
+
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
|
|
2195
3102
|
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
|
|
3103
|
+
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 });
|
|
3104
|
+
const budgetRuntime = new WorkflowBudgetRuntime(budget);
|
|
3105
|
+
const initialBudget = budgetRuntime.snapshot();
|
|
3106
|
+
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);
|
|
3107
|
+
const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
|
|
2199
3108
|
const background = !params.foreground;
|
|
2200
3109
|
const providerPause = async () => { if (background)
|
|
2201
3110
|
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 } : {}) });
|
|
3111
|
+
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);
|
|
3112
|
+
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
3113
|
if (params.foreground && onUpdate)
|
|
2205
3114
|
onUpdate(workflowToolUpdate((await store.load()).run));
|
|
2206
|
-
scheduler.addRun(runId, settings.concurrency,
|
|
3115
|
+
scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
2207
3116
|
const execution = runWorkflow(script, args, withWorkflowFunctions({ agent: async (prompt, options, agentSignal, identity) => {
|
|
2208
3117
|
await lifecycle.enter();
|
|
3118
|
+
const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
|
|
3119
|
+
const conversationLock = conversationId ? `${runId}:${conversationId}` : "";
|
|
2209
3120
|
try {
|
|
2210
|
-
const path = agentIdentityPath(identity);
|
|
3121
|
+
const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
|
|
2211
3122
|
const replayed = await store.replay(path);
|
|
2212
|
-
if (replayed)
|
|
3123
|
+
if (replayed) {
|
|
3124
|
+
if (conversationId) {
|
|
3125
|
+
const conversation = await store.conversation(conversationId);
|
|
3126
|
+
if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0))
|
|
3127
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
|
|
3128
|
+
}
|
|
2213
3129
|
return replayed.value;
|
|
3130
|
+
}
|
|
3131
|
+
if (conversationId) {
|
|
3132
|
+
if (conversationLocks.has(conversationLock))
|
|
3133
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
|
|
3134
|
+
const conversation = await store.conversation(conversationId);
|
|
3135
|
+
if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1)
|
|
3136
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
|
|
3137
|
+
conversationLocks.add(conversationLock);
|
|
3138
|
+
}
|
|
2214
3139
|
const worktree = agentWorktree(identity);
|
|
2215
|
-
const cwd = worktree.worktreeOwner ? (await store.worktree
|
|
3140
|
+
const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
|
|
2216
3141
|
const role = typeof options.role === "string" ? options.role : undefined;
|
|
2217
3142
|
const model = typeof options.model === "string" ? options.model : undefined;
|
|
2218
3143
|
const thinking = parseThinking(options.thinking);
|
|
@@ -2221,7 +3146,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2221
3146
|
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
2222
3147
|
const tools = resolved.tools;
|
|
2223
3148
|
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"
|
|
3149
|
+
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
3150
|
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
2226
3151
|
if (agentSignal.aborted)
|
|
2227
3152
|
cancel();
|
|
@@ -2234,29 +3159,47 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2234
3159
|
return outcome.value;
|
|
2235
3160
|
}
|
|
2236
3161
|
finally {
|
|
3162
|
+
if (conversationLock)
|
|
3163
|
+
conversationLocks.delete(conversationLock);
|
|
2237
3164
|
await lifecycle.leave();
|
|
2238
3165
|
}
|
|
2239
3166
|
}, checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined), phase: async (phase) => {
|
|
2240
3167
|
await lifecycle.enter();
|
|
2241
3168
|
try {
|
|
2242
|
-
|
|
2243
|
-
|
|
3169
|
+
let previousPhase;
|
|
3170
|
+
const persisted = await persistRunState(store, checked.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
|
|
3171
|
+
await eventPublisher.phase(store, checked.metadata, previousPhase, phase);
|
|
3172
|
+
runs.get(runId)?.update?.(workflowToolUpdate(persisted));
|
|
2244
3173
|
}
|
|
2245
3174
|
finally {
|
|
2246
3175
|
await lifecycle.leave();
|
|
2247
3176
|
}
|
|
2248
3177
|
}, log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
|
|
2249
3178
|
runs.get(runId).execution = execution;
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
3179
|
+
await eventPublisher.runStarted(store, checked.metadata);
|
|
3180
|
+
const finish = execution.result.then(async (value) => {
|
|
3181
|
+
await scheduler.flush();
|
|
3182
|
+
if (budgetRuntime.hardExhausted)
|
|
3183
|
+
throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
|
|
3184
|
+
const resultPath = await store.saveResult(value);
|
|
3185
|
+
await lifecycle.terminal("completed", "completed");
|
|
3186
|
+
await eventPublisher.runCompleted(store, checked.metadata, resultPath);
|
|
3187
|
+
return { value, resultPath };
|
|
3188
|
+
}).catch(async (error) => {
|
|
3189
|
+
await scheduler.flush();
|
|
3190
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
|
|
3191
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state))
|
|
3192
|
+
await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
3193
|
+
await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
|
|
3194
|
+
const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
|
|
3195
|
+
await eventPublisher.runFailed(store, checked.metadata, typed, state);
|
|
3196
|
+
throw typed;
|
|
3197
|
+
});
|
|
2254
3198
|
runs.get(runId).completion = finish;
|
|
2255
3199
|
if (background) {
|
|
2256
3200
|
void finish.then(async ({ value, resultPath }) => {
|
|
2257
|
-
emitAsyncComplete(store, "complete");
|
|
2258
3201
|
deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
|
|
2259
|
-
}, (error) => {
|
|
3202
|
+
}, (error) => { deliverFailure(pi, checked.metadata.name, error); });
|
|
2260
3203
|
return { content: [{ type: "text", text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
|
|
2261
3204
|
}
|
|
2262
3205
|
const { value } = await finish;
|
|
@@ -2313,9 +3256,9 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2313
3256
|
return entries.filter((entry) => entry !== undefined);
|
|
2314
3257
|
};
|
|
2315
3258
|
let stores = await loadStores();
|
|
2316
|
-
const usage = "Usage: /workflow [doctor], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint]";
|
|
3259
|
+
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
3260
|
const setWorkflowStatus = (text) => {
|
|
2318
|
-
const setStatus = ctx.ui
|
|
3261
|
+
const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
|
|
2319
3262
|
setStatus?.call(ctx.ui, "workflow-stop", text);
|
|
2320
3263
|
};
|
|
2321
3264
|
const runAction = async (actionCommand, keepContext, status = setWorkflowStatus) => {
|
|
@@ -2329,6 +3272,11 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2329
3272
|
ctx.ui.notify(accepted ? `${action === "approve" ? "Approved" : "Rejected"} checkpoint ${rest.join(" ")}.` : "Checkpoint is not awaiting a response.", accepted ? "info" : "warning");
|
|
2330
3273
|
return keepContext ? "dashboard" : "done";
|
|
2331
3274
|
}
|
|
3275
|
+
if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
|
|
3276
|
+
const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true);
|
|
3277
|
+
ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
|
|
3278
|
+
return keepContext ? "dashboard" : "done";
|
|
3279
|
+
}
|
|
2332
3280
|
if (action === "delete" && stored) {
|
|
2333
3281
|
if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) {
|
|
2334
3282
|
ctx.ui.notify("Stop the workflow before deleting it.", "warning");
|
|
@@ -2338,6 +3286,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2338
3286
|
return keepContext ? "dashboard" : "done";
|
|
2339
3287
|
await stored.store.delete(true);
|
|
2340
3288
|
runs.delete(stored.store.runId);
|
|
3289
|
+
terminalRunStates.delete(stored.store.runId);
|
|
2341
3290
|
ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info");
|
|
2342
3291
|
return keepContext ? "picker" : "done";
|
|
2343
3292
|
}
|
|
@@ -2347,11 +3296,29 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2347
3296
|
return keepContext ? "dashboard" : "done";
|
|
2348
3297
|
}
|
|
2349
3298
|
if (action === "resume" && run) {
|
|
2350
|
-
if (run.lifecycle.state === "
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
3299
|
+
if (run.lifecycle.state === "budget_exhausted") {
|
|
3300
|
+
const patch = rest.length ? JSON.parse(rest.join(" ")) : undefined;
|
|
3301
|
+
const result = await resumeWorkflowRun(run.store.runId, patch);
|
|
3302
|
+
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");
|
|
3303
|
+
}
|
|
3304
|
+
else {
|
|
3305
|
+
if (run.lifecycle.state === "interrupted")
|
|
3306
|
+
await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
|
|
3307
|
+
else {
|
|
3308
|
+
if (run.lifecycle.state === "paused")
|
|
3309
|
+
await refreshPausedRunAliases(run, ctx);
|
|
3310
|
+
await run.lifecycle.resume();
|
|
3311
|
+
}
|
|
3312
|
+
ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
|
|
3313
|
+
}
|
|
3314
|
+
return keepContext ? "dashboard" : "done";
|
|
3315
|
+
}
|
|
3316
|
+
if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
|
|
3317
|
+
const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}");
|
|
3318
|
+
if (input === undefined)
|
|
3319
|
+
return keepContext ? "dashboard" : "done";
|
|
3320
|
+
const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
|
|
3321
|
+
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
3322
|
return keepContext ? "dashboard" : "done";
|
|
2356
3323
|
}
|
|
2357
3324
|
if (action === "stop" && run) {
|
|
@@ -2360,11 +3327,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2360
3327
|
return "dashboard";
|
|
2361
3328
|
if (keepContext)
|
|
2362
3329
|
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();
|
|
3330
|
+
await stopWorkflowRun(run.store.runId);
|
|
2368
3331
|
if (keepContext)
|
|
2369
3332
|
status(`Workflow ${run.store.runId} stopped.`);
|
|
2370
3333
|
ctx.ui.notify(`Stopped workflow ${run.store.runId}.`, "info");
|
|
@@ -2387,13 +3350,117 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2387
3350
|
return "dashboard";
|
|
2388
3351
|
}
|
|
2389
3352
|
};
|
|
2390
|
-
|
|
3353
|
+
const manageAliases = async () => {
|
|
3354
|
+
const settingsPath = workflowSettingsPath();
|
|
3355
|
+
const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
|
|
3356
|
+
const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
|
|
3357
|
+
const selectTarget = async () => {
|
|
3358
|
+
const models = available();
|
|
3359
|
+
const choice = await ctx.ui.select("Model alias target", [...models, "Manual model ID", "Back"]);
|
|
3360
|
+
if (!choice || choice === "Back")
|
|
3361
|
+
return undefined;
|
|
3362
|
+
if (choice !== "Manual model ID")
|
|
3363
|
+
return choice;
|
|
3364
|
+
return (await ctx.ui.input("Manual model ID", "provider/model[:thinking]"))?.trim() || undefined;
|
|
3365
|
+
};
|
|
3366
|
+
const save = (aliases) => {
|
|
3367
|
+
try {
|
|
3368
|
+
saveModelAliases(settingsPath, aliases);
|
|
3369
|
+
ctx.ui.notify(`Saved model aliases to ${settingsPath}.`, "info");
|
|
3370
|
+
return true;
|
|
3371
|
+
}
|
|
3372
|
+
catch (error) {
|
|
3373
|
+
ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
3374
|
+
return false;
|
|
3375
|
+
}
|
|
3376
|
+
};
|
|
2391
3377
|
for (;;) {
|
|
2392
|
-
|
|
2393
|
-
|
|
3378
|
+
let aliases;
|
|
3379
|
+
try {
|
|
3380
|
+
aliases = loadSettings(settingsPath).modelAliases ?? {};
|
|
3381
|
+
}
|
|
3382
|
+
catch (error) {
|
|
3383
|
+
ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
3384
|
+
return;
|
|
3385
|
+
}
|
|
3386
|
+
const names = Object.keys(aliases).sort();
|
|
3387
|
+
const listing = names.length ? names.map((name) => `${name} = ${aliases[name] ?? ""}`).join("\n") : "(none)";
|
|
3388
|
+
const options = ["Add alias", ...names.map((name) => `Edit ${name}`), ...names.map((name) => `Delete ${name}`), "Back"];
|
|
3389
|
+
const choice = await ctx.ui.select(`Model aliases\n${listing}`, options);
|
|
3390
|
+
if (!choice || choice === "Back")
|
|
2394
3391
|
return;
|
|
3392
|
+
if (choice === "Add alias") {
|
|
3393
|
+
const name = (await ctx.ui.input("Alias name", "reviewer-model"))?.trim();
|
|
3394
|
+
if (!name)
|
|
3395
|
+
continue;
|
|
3396
|
+
if (Object.prototype.hasOwnProperty.call(aliases, name)) {
|
|
3397
|
+
ctx.ui.notify(`Alias ${name} already exists; choose Edit ${name}.`, "warning");
|
|
3398
|
+
continue;
|
|
3399
|
+
}
|
|
3400
|
+
const target = await selectTarget();
|
|
3401
|
+
if (!target)
|
|
3402
|
+
continue;
|
|
3403
|
+
const next = { ...aliases, [name]: target };
|
|
3404
|
+
try {
|
|
3405
|
+
validateModelAliases(next, settingsPath);
|
|
3406
|
+
}
|
|
3407
|
+
catch (error) {
|
|
3408
|
+
ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
3409
|
+
continue;
|
|
3410
|
+
}
|
|
3411
|
+
const parsed = parseModelReference(target);
|
|
3412
|
+
if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
|
|
3413
|
+
ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
|
|
3414
|
+
if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?"))
|
|
3415
|
+
continue;
|
|
3416
|
+
}
|
|
3417
|
+
save(next);
|
|
3418
|
+
continue;
|
|
2395
3419
|
}
|
|
3420
|
+
const edit = /^Edit (.+)$/.exec(choice);
|
|
3421
|
+
if (edit?.[1]) {
|
|
3422
|
+
const target = await selectTarget();
|
|
3423
|
+
if (!target)
|
|
3424
|
+
continue;
|
|
3425
|
+
const next = { ...aliases, [edit[1]]: target };
|
|
3426
|
+
try {
|
|
3427
|
+
validateModelAliases(next, settingsPath);
|
|
3428
|
+
}
|
|
3429
|
+
catch (error) {
|
|
3430
|
+
ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
3431
|
+
continue;
|
|
3432
|
+
}
|
|
3433
|
+
const parsed = parseModelReference(target);
|
|
3434
|
+
if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
|
|
3435
|
+
ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
|
|
3436
|
+
if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?"))
|
|
3437
|
+
continue;
|
|
3438
|
+
}
|
|
3439
|
+
save(next);
|
|
3440
|
+
continue;
|
|
3441
|
+
}
|
|
3442
|
+
const deletion = /^Delete (.+)$/.exec(choice);
|
|
3443
|
+
if (deletion?.[1] && await ctx.ui.confirm("Delete model alias?", `Delete ${deletion[1]}? Future workflow resumes using this alias may fail.`)) {
|
|
3444
|
+
const next = Object.fromEntries(Object.entries(aliases).filter(([name]) => name !== deletion[1]));
|
|
3445
|
+
save(next);
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
};
|
|
3449
|
+
if (command === "model-aliases") {
|
|
3450
|
+
if (!ctx.hasUI) {
|
|
3451
|
+
ctx.ui.notify("Model alias management requires UI.", "warning");
|
|
3452
|
+
return;
|
|
3453
|
+
}
|
|
3454
|
+
await manageAliases();
|
|
3455
|
+
return;
|
|
3456
|
+
}
|
|
3457
|
+
if (!command) {
|
|
3458
|
+
for (;;) {
|
|
2396
3459
|
if (!ctx.hasUI) {
|
|
3460
|
+
if (!stores.length) {
|
|
3461
|
+
ctx.ui.notify("No workflow runs in this session.", "info");
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
2397
3464
|
const details = await Promise.all(stores.map(async ({ store, loaded }) => formatNavigatorRun(loaded, await store.awaitingCheckpoints(), await store.worktrees())));
|
|
2398
3465
|
ctx.ui.notify(details.join("\n\n"), "info");
|
|
2399
3466
|
return;
|
|
@@ -2402,10 +3469,15 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2402
3469
|
const labels = navigatorRunLabels(sorted);
|
|
2403
3470
|
const terminalStates = new Set(["completed", "failed", "stopped"]);
|
|
2404
3471
|
const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
|
|
2405
|
-
const pickerOptions = [...labels, ...(hasCompleted ? ["Delete all completed"] : [])
|
|
3472
|
+
const pickerOptions = [...labels, "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
|
|
2406
3473
|
const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
|
|
2407
3474
|
if (!runChoice || runChoice === "Close")
|
|
2408
3475
|
return;
|
|
3476
|
+
if (runChoice === "Model aliases") {
|
|
3477
|
+
await manageAliases();
|
|
3478
|
+
stores = await loadStores();
|
|
3479
|
+
continue;
|
|
3480
|
+
}
|
|
2409
3481
|
if (runChoice === "Delete all completed") {
|
|
2410
3482
|
if (!await ctx.ui.confirm("Delete completed runs?", "Delete all completed workflow runs and their artifacts? This cannot be undone."))
|
|
2411
3483
|
continue;
|
|
@@ -2413,6 +3485,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2413
3485
|
if (entry.loaded.run.state === "completed") {
|
|
2414
3486
|
await entry.store.delete(true);
|
|
2415
3487
|
runs.delete(entry.store.runId);
|
|
3488
|
+
terminalRunStates.delete(entry.store.runId);
|
|
2416
3489
|
}
|
|
2417
3490
|
}
|
|
2418
3491
|
ctx.ui.notify("Deleted all completed workflow runs.", "info");
|
|
@@ -2435,6 +3508,49 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2435
3508
|
ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2436
3509
|
}
|
|
2437
3510
|
};
|
|
3511
|
+
const openTranscript = async (transcript) => {
|
|
3512
|
+
try {
|
|
3513
|
+
const entries = SessionManager.open(transcript).buildContextEntries();
|
|
3514
|
+
if (ctx.mode !== "tui") {
|
|
3515
|
+
ctx.ui.notify(`Transcript: ${transcript}`, "info");
|
|
3516
|
+
return;
|
|
3517
|
+
}
|
|
3518
|
+
await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
3519
|
+
let offset = 0;
|
|
3520
|
+
let renderedLines = [];
|
|
3521
|
+
const viewport = () => Math.max(1, tuiRows(tui) - 3);
|
|
3522
|
+
const move = (delta) => { offset = Math.max(0, Math.min(Math.max(0, renderedLines.length - viewport()), offset + delta)); };
|
|
3523
|
+
return {
|
|
3524
|
+
render(width) {
|
|
3525
|
+
renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
|
|
3526
|
+
offset = Math.min(offset, Math.max(0, renderedLines.length - viewport()));
|
|
3527
|
+
return [theme.fg("accent", "Native Pi transcript"), ...renderedLines.slice(offset, offset + viewport()), "", theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close")];
|
|
3528
|
+
},
|
|
3529
|
+
invalidate() { },
|
|
3530
|
+
handleInput(data) {
|
|
3531
|
+
if (keybindings.matches(data, "tui.select.up"))
|
|
3532
|
+
move(-1);
|
|
3533
|
+
else if (keybindings.matches(data, "tui.select.down"))
|
|
3534
|
+
move(1);
|
|
3535
|
+
else if (keybindings.matches(data, "tui.select.pageUp"))
|
|
3536
|
+
move(-viewport());
|
|
3537
|
+
else if (keybindings.matches(data, "tui.select.pageDown"))
|
|
3538
|
+
move(viewport());
|
|
3539
|
+
else if (keybindings.matches(data, "tui.editor.cursorLineStart"))
|
|
3540
|
+
offset = 0;
|
|
3541
|
+
else if (keybindings.matches(data, "tui.editor.cursorLineEnd"))
|
|
3542
|
+
offset = Math.max(0, renderedLines.length - viewport());
|
|
3543
|
+
else if (keybindings.matches(data, "tui.select.cancel"))
|
|
3544
|
+
done(undefined);
|
|
3545
|
+
tui.requestRender();
|
|
3546
|
+
},
|
|
3547
|
+
};
|
|
3548
|
+
}, { overlay: true });
|
|
3549
|
+
}
|
|
3550
|
+
catch (error) {
|
|
3551
|
+
ctx.ui.notify(`Cannot open transcript: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
3552
|
+
}
|
|
3553
|
+
};
|
|
2438
3554
|
const loadDashboard = async () => {
|
|
2439
3555
|
const loaded = await store.load();
|
|
2440
3556
|
const checkpoints = await store.awaitingCheckpoints();
|
|
@@ -2448,6 +3564,15 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2448
3564
|
add("Pause", "pause");
|
|
2449
3565
|
if (["paused", "interrupted"].includes(loaded.run.state))
|
|
2450
3566
|
add("Resume", "resume");
|
|
3567
|
+
if (loaded.run.state === "budget_exhausted") {
|
|
3568
|
+
actions.set("Resume unchanged", `resume ${store.runId}`);
|
|
3569
|
+
actions.set("Adjust budget", `adjust ${store.runId}`);
|
|
3570
|
+
}
|
|
3571
|
+
for (const decision of await store.pendingWorkflowDecisions()) {
|
|
3572
|
+
const id = decision.proposalId.slice(0, 8);
|
|
3573
|
+
actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
|
|
3574
|
+
actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
|
|
3575
|
+
}
|
|
2451
3576
|
if (!terminalStates.has(loaded.run.state))
|
|
2452
3577
|
add("Stop", "stop");
|
|
2453
3578
|
for (const cp of checkpoints) {
|
|
@@ -2466,21 +3591,78 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2466
3591
|
else
|
|
2467
3592
|
actions.set("View script", "view-script");
|
|
2468
3593
|
const transcripts = [...new Set([...loaded.run.agents.flatMap((agent) => (agent.attemptDetails ?? []).map((attempt) => attempt.sessionFile)), ...loaded.run.nativeSessions.map(({ sessionFile }) => sessionFile)])];
|
|
2469
|
-
if (
|
|
3594
|
+
if (loaded.run.agents.length)
|
|
3595
|
+
actions.set("Agents...", "agents");
|
|
3596
|
+
if (!loaded.run.agents.length && ctx.mode === "tui" && transcripts.length)
|
|
2470
3597
|
actions.set("View transcript", "view-transcript");
|
|
2471
|
-
if (transcripts.length)
|
|
3598
|
+
if (!loaded.run.agents.length && transcripts.length)
|
|
2472
3599
|
actions.set("Transcript paths", "transcripts");
|
|
2473
3600
|
if (terminalStates.has(loaded.run.state))
|
|
2474
3601
|
add("Delete", "delete");
|
|
2475
3602
|
if (ctx.mode === "tui") {
|
|
2476
3603
|
addCopy("Copy run path", store.directory, "run path");
|
|
2477
3604
|
addCopy("Copy run ID", store.runId, "run ID");
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
3605
|
+
}
|
|
3606
|
+
return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees };
|
|
3607
|
+
};
|
|
3608
|
+
const selectAgent = async (dashboard) => {
|
|
3609
|
+
const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
|
|
3610
|
+
const title = (agent) => {
|
|
3611
|
+
const parents = [];
|
|
3612
|
+
for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
|
|
3613
|
+
const parent = byId.get(parentId);
|
|
3614
|
+
if (!parent)
|
|
3615
|
+
break;
|
|
3616
|
+
parents.unshift(parent.label ?? parent.name);
|
|
3617
|
+
}
|
|
3618
|
+
return [...((agent.structuralPath ?? []).length ? [(agent.structuralPath ?? []).join(" > ")] : []), ...(agent.parentBreadcrumb ? [agent.parentBreadcrumb] : []), ...parents, agent.label ?? agent.name].join(" > ");
|
|
3619
|
+
};
|
|
3620
|
+
const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
|
|
3621
|
+
const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
|
|
3622
|
+
const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
|
|
3623
|
+
const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
|
|
3624
|
+
if (!selected)
|
|
3625
|
+
return;
|
|
3626
|
+
const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
|
|
3627
|
+
const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
|
|
3628
|
+
const actions = [
|
|
3629
|
+
...(attempts.length ? ["View transcript", "Copy transcript path"] : []),
|
|
3630
|
+
...(worktree ? ["Copy branch", "Copy worktree path"] : []),
|
|
3631
|
+
"Copy agent ID",
|
|
3632
|
+
"Back",
|
|
3633
|
+
];
|
|
3634
|
+
const chooseAttempt = async () => {
|
|
3635
|
+
const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
|
|
3636
|
+
const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Transcript attempts", [...choices, "Back"]);
|
|
3637
|
+
const index = choice ? choices.indexOf(choice) : -1;
|
|
3638
|
+
return index >= 0 ? attempts[index] : undefined;
|
|
3639
|
+
};
|
|
3640
|
+
for (;;) {
|
|
3641
|
+
const action = await ctx.ui.select(title(selected), actions);
|
|
3642
|
+
if (!action || action === "Back")
|
|
3643
|
+
return;
|
|
3644
|
+
if (action === "Copy agent ID") {
|
|
3645
|
+
await copyArtifact(selected.id, "agent ID");
|
|
3646
|
+
continue;
|
|
3647
|
+
}
|
|
3648
|
+
if (action === "Copy branch" && worktree) {
|
|
3649
|
+
await copyArtifact(worktree.branch, "branch");
|
|
3650
|
+
continue;
|
|
3651
|
+
}
|
|
3652
|
+
if (action === "Copy worktree path" && worktree) {
|
|
3653
|
+
await copyArtifact(worktree.path, "worktree path");
|
|
3654
|
+
continue;
|
|
3655
|
+
}
|
|
3656
|
+
if (action === "View transcript" || action === "Copy transcript path") {
|
|
3657
|
+
const attempt = await chooseAttempt();
|
|
3658
|
+
if (!attempt)
|
|
3659
|
+
continue;
|
|
3660
|
+
if (action === "Copy transcript path")
|
|
3661
|
+
await copyArtifact(attempt.sessionFile, "transcript path");
|
|
3662
|
+
else
|
|
3663
|
+
await openTranscript(attempt.sessionFile);
|
|
2481
3664
|
}
|
|
2482
3665
|
}
|
|
2483
|
-
return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script };
|
|
2484
3666
|
};
|
|
2485
3667
|
for (;;) {
|
|
2486
3668
|
let view = await loadDashboard();
|
|
@@ -2493,11 +3675,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2493
3675
|
let disposed = false;
|
|
2494
3676
|
let stopRequested = false;
|
|
2495
3677
|
let stopStatus;
|
|
2496
|
-
const terminalRows = () => Math.max(1, (tui
|
|
3678
|
+
const terminalRows = () => Math.max(1, tuiRows(tui));
|
|
2497
3679
|
const keyLabels = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
|
|
2498
3680
|
const keyLabel = (binding, fallback) => {
|
|
2499
|
-
const
|
|
2500
|
-
const keys = getKeys?.call(keybindings, binding);
|
|
3681
|
+
const keys = keybindingKeys(keybindings, binding);
|
|
2501
3682
|
return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
|
|
2502
3683
|
};
|
|
2503
3684
|
const dashboardLayout = () => {
|
|
@@ -2600,6 +3781,10 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2600
3781
|
: await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
|
|
2601
3782
|
if (!actionChoice || actionChoice === "Close")
|
|
2602
3783
|
return;
|
|
3784
|
+
if (actionChoice === "Agents...") {
|
|
3785
|
+
await selectAgent(view);
|
|
3786
|
+
continue;
|
|
3787
|
+
}
|
|
2603
3788
|
if (actionChoice === "Refresh")
|
|
2604
3789
|
continue;
|
|
2605
3790
|
if (actionChoice === "View script") {
|
|
@@ -2607,7 +3792,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2607
3792
|
const highlighted = highlightCode(view.script, "javascript");
|
|
2608
3793
|
let offset = 0;
|
|
2609
3794
|
let renderedLines = [];
|
|
2610
|
-
const viewport = () => Math.max(1, (
|
|
3795
|
+
const viewport = () => Math.max(1, tuiRows(tui) - 3);
|
|
2611
3796
|
const move = (delta) => {
|
|
2612
3797
|
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2613
3798
|
offset = Math.max(0, Math.min(maxOffset, offset + delta));
|
|
@@ -2644,55 +3829,8 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2644
3829
|
}
|
|
2645
3830
|
if (actionChoice === "View transcript") {
|
|
2646
3831
|
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
|
-
}
|
|
3832
|
+
if (transcript && transcript !== "Back")
|
|
3833
|
+
await openTranscript(transcript);
|
|
2696
3834
|
continue;
|
|
2697
3835
|
}
|
|
2698
3836
|
if (actionChoice === "Transcript paths") {
|
|
@@ -2720,7 +3858,7 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2720
3858
|
let offset = 0;
|
|
2721
3859
|
let renderedLines = [];
|
|
2722
3860
|
const layout = () => {
|
|
2723
|
-
const rows = Math.max(1, (
|
|
3861
|
+
const rows = Math.max(1, tuiRows(tui));
|
|
2724
3862
|
const compactControls = rows < 4;
|
|
2725
3863
|
const titleRows = rows >= 5 ? 1 : 0;
|
|
2726
3864
|
const hintRows = rows >= 8 ? 1 : 0;
|
|
@@ -2796,16 +3934,16 @@ export default function workflowExtension(pi, home, clipboard = copyToClipboard,
|
|
|
2796
3934
|
pi.on("session_shutdown", async () => {
|
|
2797
3935
|
try {
|
|
2798
3936
|
await Promise.all([...runs.entries()].map(async ([runId, run]) => {
|
|
2799
|
-
if (["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
|
|
3937
|
+
if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
|
|
2800
3938
|
await run.completion?.catch(() => undefined);
|
|
2801
3939
|
return;
|
|
2802
3940
|
}
|
|
2803
|
-
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
|
|
3941
|
+
if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
|
|
2804
3942
|
try {
|
|
2805
3943
|
await run.lifecycle.terminal("interrupted");
|
|
2806
3944
|
}
|
|
2807
3945
|
catch (error) {
|
|
2808
|
-
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state))
|
|
3946
|
+
if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state))
|
|
2809
3947
|
throw error;
|
|
2810
3948
|
}
|
|
2811
3949
|
run.abortController.abort();
|