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/src/index.ts
CHANGED
|
@@ -1,47 +1,80 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { fork, type ChildProcess } 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";
|
|
10
10
|
import { Type } from "@earendil-works/pi-ai";
|
|
11
11
|
import { Value } from "typebox/value";
|
|
12
12
|
import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, SessionManager, truncateToVisualLines, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentProgress, type SessionFactory } from "./agent-execution.js";
|
|
13
|
+
import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAccounting, type AgentAttempt, type AgentBudgetHooks, type AgentDefinition, type AgentProgress, type AgentSetupHook, type RegisteredAgentSetupHook, type SessionFactory } from "./agent-execution.js";
|
|
14
14
|
import { transcriptLines } from "./session-inspector.js";
|
|
15
|
-
import { acquireSessionLease, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
|
|
15
|
+
import { acquireSessionLease, atomicWriteFile, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
|
|
16
16
|
import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
|
|
17
17
|
|
|
18
|
-
export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted"] as const;
|
|
18
|
+
export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted", "budget_exhausted"] as const;
|
|
19
19
|
export const AGENT_STATES = ["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"] as const;
|
|
20
|
-
export const
|
|
21
|
-
export
|
|
20
|
+
export const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"] as const;
|
|
21
|
+
export type WorkflowCallKind = (typeof WORKFLOW_CALL_KINDS)[number];
|
|
22
|
+
export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
|
|
23
|
+
export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
|
|
24
|
+
export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
|
|
25
|
+
export const WORKFLOW_RUN_COMPLETED_EVENT = "workflow:run-completed";
|
|
26
|
+
export const WORKFLOW_RUN_FAILED_EVENT = "workflow:run-failed";
|
|
27
|
+
export const WORKFLOW_AGENT_STATE_CHANGED_EVENT = "workflow:agent-state-changed";
|
|
28
|
+
export const WORKFLOW_PHASE_CHANGED_EVENT = "workflow:phase-changed";
|
|
29
|
+
export const WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT = "workflow:checkpoint-state-changed";
|
|
30
|
+
export const WORKFLOW_BUDGET_EVENT = "workflow:budget-event";
|
|
31
|
+
export const WORKFLOW_WORKTREE_CREATED_EVENT = "workflow:worktree-created";
|
|
22
32
|
const SETTLED_AGENT_STATES: ReadonlySet<AgentState> = new Set(["completed", "failed", "cancelled"]);
|
|
23
33
|
export const ERROR_CODES = [
|
|
24
|
-
"INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
|
|
25
|
-
"
|
|
26
|
-
"CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "INTERNAL_ERROR",
|
|
27
|
-
] as const;
|
|
34
|
+
"CONFIG_ERROR", "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
|
|
35
|
+
"RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
|
|
36
|
+
"CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "BUDGET_EXHAUSTED", "INTERNAL_ERROR",
|
|
37
|
+
] as const;
|
|
28
38
|
|
|
29
39
|
export type RunState = (typeof RUN_STATES)[number];
|
|
30
40
|
export type AgentState = (typeof AGENT_STATES)[number];
|
|
31
41
|
export type WorkflowErrorCode = (typeof ERROR_CODES)[number];
|
|
32
42
|
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
33
43
|
export type JsonSchema = { [key: string]: JsonValue };
|
|
34
|
-
|
|
44
|
+
export type BudgetDimension = "tokens" | "costUsd" | "durationMs" | "agentLaunches";
|
|
45
|
+
export interface BudgetLimits { soft?: number; hard?: number }
|
|
46
|
+
export type WorkflowBudget = Partial<Record<BudgetDimension, BudgetLimits>>;
|
|
47
|
+
export type WorkflowBudgetPatch = Partial<Record<BudgetDimension, BudgetLimits | { soft?: number | null; hard?: number | null } | null>>;
|
|
48
|
+
export interface WorkflowBudgetUsage { tokens: number; costUsd: number; durationMs: number; agentLaunches: number }
|
|
49
|
+
export type BudgetEventType = "soft_crossed" | "hard_overrun" | "hard_exhausted" | "adjustment_requested" | "adjustment_approved" | "adjustment_rejected";
|
|
50
|
+
export interface BudgetEvent { type: BudgetEventType; budgetVersion: number; dimensions: readonly BudgetDimension[]; usage: WorkflowBudgetUsage; limits: WorkflowBudget; at: number; proposalId?: string; previous?: WorkflowBudget; proposed?: WorkflowBudget }
|
|
51
|
+
export interface BudgetApprovalRequest { kind: "budget"; proposalId: string; runId: string; consumed: WorkflowBudgetUsage; previous: WorkflowBudget; proposed: WorkflowBudget; budgetVersion: number }
|
|
35
52
|
export interface WorkflowErrorShape { code: WorkflowErrorCode; message: string }
|
|
53
|
+
export interface WorkflowEventBase { runId: string; sessionId: string; workflowName: string; cwd: string; runDirectory: string; timestamp: number }
|
|
54
|
+
export type WorkflowRunStartedEvent = WorkflowEventBase;
|
|
55
|
+
export type WorkflowRunResumedEvent = WorkflowEventBase;
|
|
56
|
+
export interface WorkflowRunStateChangedEvent extends WorkflowEventBase { previousState: RunState; state: RunState; reason?: string; errorCode?: WorkflowErrorCode }
|
|
57
|
+
export interface WorkflowRunCompletedEvent extends WorkflowEventBase { resultPath: string }
|
|
58
|
+
export interface WorkflowRunFailedEvent extends WorkflowEventBase { error: WorkflowErrorShape }
|
|
59
|
+
export interface WorkflowAgentStateChangedEvent extends WorkflowEventBase { agentId: string; displayLabel: string; role?: string; structuralPath: readonly string[]; parentId?: string; parentBreadcrumb?: string; worktreeOwner?: string; previousState?: AgentState; state: AgentState; attempt: number }
|
|
60
|
+
export interface WorkflowPhaseChangedEvent extends WorkflowEventBase { previousPhase?: string; phase: string }
|
|
61
|
+
export type WorkflowCheckpointState = "awaiting" | "approved" | "rejected";
|
|
62
|
+
export interface WorkflowCheckpointStateChangedEvent extends WorkflowEventBase { name: string; state: WorkflowCheckpointState }
|
|
63
|
+
export interface WorkflowBudgetEvent extends WorkflowEventBase { type: BudgetEventType; budgetVersion: number; dimensions: readonly BudgetDimension[]; usage: WorkflowBudgetUsage; limits: WorkflowBudget; proposalId?: string; previous?: WorkflowBudget; proposed?: WorkflowBudget }
|
|
36
64
|
export interface ModelSpec { provider: string; model: string; thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" }
|
|
37
65
|
export interface WorkflowMetadata { name: string; description?: string }
|
|
38
|
-
export interface WorkflowSettings { concurrency: number;
|
|
39
|
-
export interface
|
|
40
|
-
export interface
|
|
41
|
-
export interface
|
|
42
|
-
|
|
43
|
-
export interface
|
|
44
|
-
export interface
|
|
66
|
+
export interface WorkflowSettings { concurrency: number; modelAliases?: Readonly<Record<string, string>>; disabledAgentResources?: Readonly<AgentResourceExclusions> }
|
|
67
|
+
export interface AgentResourceExclusions { skills: readonly string[]; extensions: readonly string[] }
|
|
68
|
+
export interface AgentResourcePolicy { globalSettingsPath: string; projectSettingsPath: string; projectTrusted: boolean; global: AgentResourceExclusions; project: AgentResourceExclusions; effective: AgentResourceExclusions; unmatchedSkills: string[]; unmatchedExtensions: string[] }
|
|
69
|
+
export interface AgentSetupSummary { hookNames: readonly string[]; model: ModelSpec; tools: readonly string[]; cwd: string; disabledAgentResources?: { skills: readonly string[]; extensions: readonly string[]; unmatchedSkills: readonly string[]; unmatchedExtensions: readonly string[] } }
|
|
70
|
+
export interface AgentAttemptSummary { attempt: number; sessionId: string; sessionFile: string; error?: { code: string; message: string }; accounting: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }; setup?: AgentSetupSummary }
|
|
71
|
+
export interface WorkflowWorktreeCreatedEvent extends WorkflowEventBase { owner: string; branch: string; path: string; base: string }
|
|
72
|
+
export interface AgentRecord { id: string; name: string; label?: string; path: string; state: AgentState; parentId?: string; structuralPath?: readonly string[]; parentBreadcrumb?: string; worktreeOwner?: string; role?: string; requestedModel?: string; model: ModelSpec; tools: readonly string[]; attempts: number; attemptDetails?: readonly AgentAttemptSummary[]; accounting?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }; toolCalls?: readonly { id: string; name: string; state: "running" | "completed" | "failed" }[]; activity?: AgentActivity | undefined }
|
|
73
|
+
export interface WorkflowRunEvent { type: string; message: string }
|
|
74
|
+
export interface RunRecord { id: string; workflowName: string; cwd: string; sessionId: string; state: RunState; phase?: string; agents: readonly AgentRecord[]; error?: WorkflowErrorShape; budget?: WorkflowBudget; budgetVersion?: number; usage?: WorkflowBudgetUsage; budgetEvents?: readonly BudgetEvent[]; events?: readonly WorkflowRunEvent[] }
|
|
75
|
+
export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 3;
|
|
76
|
+
export interface LaunchSnapshot { identityVersion?: number; script: string; args: JsonValue; metadata: WorkflowMetadata; settings: WorkflowSettings; budget?: WorkflowBudget; settingsPath?: string; modelAliases?: Readonly<Record<string, string>>; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[]; roles?: Readonly<Record<string, AgentDefinition>>; projectRoles?: readonly string[]; schemas: readonly JsonSchema[] }
|
|
77
|
+
export interface PreflightCapabilities { models: ReadonlySet<string>; tools: ReadonlySet<string>; agentTypes: ReadonlySet<string>; modelAliases?: Readonly<Record<string, string>>; knownModels?: ReadonlySet<string>; settingsPath?: string; skipModelAvailability?: boolean }
|
|
45
78
|
export interface PreflightResult { metadata: WorkflowMetadata; referenced: { phases: readonly string[]; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[] }; schemas: readonly JsonSchema[]; dynamicAgentRoles: boolean }
|
|
46
79
|
export interface WorkflowOrchestrationContext {
|
|
47
80
|
agent: (...args: readonly unknown[]) => Promise<JsonValue>;
|
|
@@ -54,11 +87,14 @@ export interface WorkflowOrchestrationContext {
|
|
|
54
87
|
log: (message: string) => void;
|
|
55
88
|
}
|
|
56
89
|
export interface WorkflowRunContext { cwd: string; sessionId: string; runId: string; workflow: Readonly<WorkflowMetadata>; args: JsonValue; signal: AbortSignal }
|
|
57
|
-
export interface WorkflowFunctionContext extends WorkflowOrchestrationContext {
|
|
90
|
+
export interface WorkflowFunctionContext extends WorkflowOrchestrationContext {
|
|
91
|
+
run: Readonly<WorkflowRunContext>;
|
|
92
|
+
invoke: (name: string, input: Readonly<Record<string, JsonValue>>) => Promise<JsonValue>;
|
|
93
|
+
}
|
|
58
94
|
export interface WorkflowFunction { description: string; input: JsonSchema; output: JsonSchema; run: (input: Readonly<Record<string, JsonValue>>, context: Readonly<WorkflowFunctionContext>) => Promise<JsonValue> | JsonValue }
|
|
59
95
|
export interface WorkflowVariable { description: string; schema: JsonSchema; resolve: (run: Readonly<WorkflowRunContext>) => Promise<JsonValue> | JsonValue }
|
|
60
96
|
export interface WorkflowScriptDefinition { description: string; script: string }
|
|
61
|
-
export interface WorkflowExtension {
|
|
97
|
+
export interface WorkflowExtension { version: string; headline: string; description: string; functions?: Readonly<Record<string, WorkflowFunction>>; variables?: Readonly<Record<string, WorkflowVariable>>; workflows?: Readonly<Record<string, WorkflowScriptDefinition>>; agentSetupHooks?: Readonly<Record<string, AgentSetupHook>> }
|
|
62
98
|
export interface WorkflowJournal { get(path: string): JsonValue | undefined; put(path: string, value: JsonValue): void }
|
|
63
99
|
|
|
64
100
|
export class WorkflowError extends Error {
|
|
@@ -86,6 +122,7 @@ function workflowDetail(message: string): string {
|
|
|
86
122
|
}
|
|
87
123
|
|
|
88
124
|
const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string> = {
|
|
125
|
+
CONFIG_ERROR: (detail) => `The workflow configuration is invalid: ${detail}.`,
|
|
89
126
|
INVALID_SETTINGS: (detail) => `The workflow settings are invalid: ${detail}.`,
|
|
90
127
|
INVALID_SYNTAX: (detail) => `The workflow source is invalid: ${detail}.`,
|
|
91
128
|
INVALID_METADATA: (detail) => `The workflow metadata is invalid: ${detail}.`,
|
|
@@ -97,7 +134,6 @@ const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string
|
|
|
97
134
|
UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
|
|
98
135
|
UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
|
|
99
136
|
UNKNOWN_AGENT_TYPE: (detail) => `The workflow requested the unavailable agent role ${detail.replace(/^Unknown agent role:\s*/, "")}.`,
|
|
100
|
-
RUN_LIMIT_EXCEEDED: (detail) => `The workflow exceeded its agent launch limit: ${detail.replace(/^Run\s+exceeded\s+/i, "")}.`,
|
|
101
137
|
RUN_OWNED: (detail) => /already owned|active ownership/.test(detail) ? "The workflow session is already in use." : `The workflow session is already in use: ${detail}.`,
|
|
102
138
|
RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
|
|
103
139
|
AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
|
|
@@ -107,6 +143,7 @@ const WORKFLOW_ERROR_PROSE: Record<WorkflowErrorCode, (detail: string) => string
|
|
|
107
143
|
WORKER_UNRESPONSIVE: (detail) => `The workflow worker stopped responding: ${detail}.`,
|
|
108
144
|
WORKTREE_FAILED: (detail) => `The workflow worktree operation failed: ${detail}.`,
|
|
109
145
|
RESUME_INCOMPATIBLE: (detail) => `The workflow cannot resume this run: ${detail}.`,
|
|
146
|
+
BUDGET_EXHAUSTED: (detail) => `The workflow budget was exhausted: ${detail}.`,
|
|
110
147
|
INTERNAL_ERROR: (detail) => `The workflow encountered an internal error: ${detail}.`,
|
|
111
148
|
};
|
|
112
149
|
export function formatWorkflowFailure(error: unknown): string {
|
|
@@ -133,13 +170,12 @@ function mainAgentError(error: unknown): WorkflowError {
|
|
|
133
170
|
return presented;
|
|
134
171
|
}
|
|
135
172
|
|
|
136
|
-
|
|
137
173
|
export class RunLifecycle {
|
|
138
174
|
#state: RunState;
|
|
139
175
|
#active = 0;
|
|
140
176
|
#waiters: Array<() => void> = [];
|
|
141
177
|
|
|
142
|
-
constructor(state: RunState = "running", private readonly changed?: (state: RunState) => void | Promise<void>) { this.#state = state; }
|
|
178
|
+
constructor(state: RunState = "running", private readonly changed?: (state: RunState, previousState: RunState, reason?: string) => void | Promise<void>) { this.#state = state; }
|
|
143
179
|
get state(): RunState { return this.#state; }
|
|
144
180
|
|
|
145
181
|
async enter(): Promise<void> {
|
|
@@ -150,31 +186,31 @@ export class RunLifecycle {
|
|
|
150
186
|
|
|
151
187
|
async leave(): Promise<void> {
|
|
152
188
|
if (this.#active > 0) this.#active -= 1;
|
|
153
|
-
if (this.#state === "pausing" && this.#active === 0) await this.#set("paused");
|
|
189
|
+
if (this.#state === "pausing" && this.#active === 0) await this.#set("paused", "pause");
|
|
154
190
|
}
|
|
155
191
|
|
|
156
192
|
async enterAwaitingInput(): Promise<void> {
|
|
157
193
|
while (this.#state === "pausing" || this.#state === "paused") await new Promise<void>((resolve) => { this.#waiters.push(resolve); });
|
|
158
194
|
if (this.#state === "awaiting_input") return;
|
|
159
195
|
if (this.#state !== "running") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot await input for ${this.#state} run`);
|
|
160
|
-
await this.#set("awaiting_input");
|
|
196
|
+
await this.#set("awaiting_input", "awaiting_input");
|
|
161
197
|
}
|
|
162
198
|
|
|
163
199
|
async resolveAwaitingInput(): Promise<void> {
|
|
164
200
|
if (this.#state !== "awaiting_input") return;
|
|
165
|
-
await this.#set("running");
|
|
201
|
+
await this.#set("running", "checkpoint_resolved");
|
|
166
202
|
for (const resolve of this.#waiters.splice(0)) resolve();
|
|
167
203
|
}
|
|
168
204
|
|
|
169
205
|
async pause(): Promise<void> {
|
|
170
206
|
if (this.#state !== "running") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot pause ${this.#state} run`);
|
|
171
|
-
await this.#set("pausing");
|
|
172
|
-
if (this.#active === 0) await this.#set("paused");
|
|
207
|
+
await this.#set("pausing", "pause");
|
|
208
|
+
if (this.#active === 0) await this.#set("paused", "pause");
|
|
173
209
|
}
|
|
174
210
|
|
|
175
211
|
async resume(): Promise<void> {
|
|
176
|
-
if (this.#state !== "paused" && this.#state !== "interrupted") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
|
|
177
|
-
await this.#set("running");
|
|
212
|
+
if (this.#state !== "paused" && this.#state !== "interrupted" && this.#state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
|
|
213
|
+
await this.#set("running", "resume");
|
|
178
214
|
for (const resolve of this.#waiters.splice(0)) resolve();
|
|
179
215
|
}
|
|
180
216
|
|
|
@@ -184,33 +220,210 @@ export class RunLifecycle {
|
|
|
184
220
|
await this.enter();
|
|
185
221
|
}
|
|
186
222
|
|
|
187
|
-
async terminal(state: "completed" | "failed" | "stopped" | "interrupted"): Promise<void> {
|
|
223
|
+
async terminal(state: "completed" | "failed" | "stopped" | "interrupted" | "budget_exhausted", reason?: string): Promise<void> {
|
|
188
224
|
if (["completed", "failed", "stopped"].includes(this.#state)) throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
|
|
189
|
-
await this.#set(state);
|
|
225
|
+
await this.#set(state, reason ?? state);
|
|
190
226
|
for (const resolve of this.#waiters.splice(0)) resolve();
|
|
191
227
|
}
|
|
192
228
|
|
|
193
|
-
async #set(state: RunState): Promise<void> {
|
|
229
|
+
async #set(state: RunState, reason?: string): Promise<void> {
|
|
230
|
+
const previousState = this.#state;
|
|
231
|
+
this.#state = state;
|
|
232
|
+
await this.changed?.(state, previousState, reason);
|
|
233
|
+
}
|
|
194
234
|
}
|
|
195
235
|
|
|
196
|
-
export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ concurrency: 8
|
|
236
|
+
export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ concurrency: 8 });
|
|
197
237
|
|
|
198
238
|
function fail(code: WorkflowErrorCode, message: string): never { throw new WorkflowError(code, message); }
|
|
199
239
|
function object(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
240
|
+
export { object as isObject };
|
|
241
|
+
function nonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
|
|
242
|
+
function nonNegativeFinite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
|
|
243
|
+
export function validateBudget(value: unknown): WorkflowBudget | undefined {
|
|
244
|
+
if (value === undefined) return undefined;
|
|
245
|
+
if (!object(value)) fail("INVALID_METADATA", "budget must be an object");
|
|
246
|
+
const result: WorkflowBudget = {};
|
|
247
|
+
for (const [dimension, raw] of Object.entries(value)) {
|
|
248
|
+
if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
|
|
249
|
+
if (!object(raw)) fail("INVALID_METADATA", `${dimension} budget must be an object`);
|
|
250
|
+
if (Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget has an unknown limit`);
|
|
251
|
+
const isCost = dimension === "costUsd";
|
|
252
|
+
for (const key of ["soft", "hard"] as const) if (raw[key] !== undefined && !(isCost ? nonNegativeFinite(raw[key]) : nonNegativeInteger(raw[key]))) fail("INVALID_METADATA", `${dimension}.${key} must be a non-negative ${isCost ? "finite number" : "integer"}`);
|
|
253
|
+
if (raw.soft !== undefined && raw.soft !== null && raw.hard !== undefined && raw.hard !== null && raw.soft >= raw.hard) fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
|
|
254
|
+
const limits: BudgetLimits = {};
|
|
255
|
+
if (raw.soft !== undefined) limits.soft = raw.soft as number;
|
|
256
|
+
if (raw.hard !== undefined) limits.hard = raw.hard as number;
|
|
257
|
+
if (Object.keys(limits).length) (result as Record<string, BudgetLimits>)[dimension] = limits;
|
|
258
|
+
}
|
|
259
|
+
return Object.freeze(result);
|
|
260
|
+
}
|
|
261
|
+
export function validateBudgetPatch(value: unknown): WorkflowBudgetPatch {
|
|
262
|
+
if (!object(value)) fail("INVALID_METADATA", "budget patch must be an object");
|
|
263
|
+
const result: WorkflowBudgetPatch = {};
|
|
264
|
+
for (const [dimension, raw] of Object.entries(value)) {
|
|
265
|
+
if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
|
|
266
|
+
if (raw === null) { (result as Record<string, null>)[dimension] = null; continue; }
|
|
267
|
+
if (!object(raw) || Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget patch must contain only soft and hard`);
|
|
268
|
+
const limits: { soft?: number | null; hard?: number | null } = {};
|
|
269
|
+
for (const key of ["soft", "hard"] as const) if (Object.prototype.hasOwnProperty.call(raw, key)) {
|
|
270
|
+
if (raw[key] === null) limits[key] = null;
|
|
271
|
+
else { const checked = validateBudget({ [dimension]: { [key]: raw[key] } })?.[dimension as BudgetDimension]; if (checked?.[key] !== undefined) limits[key] = checked[key]; }
|
|
272
|
+
}
|
|
273
|
+
if (limits.soft !== null && limits.hard !== null && limits.soft !== undefined && limits.hard !== undefined && limits.soft >= limits.hard) fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
|
|
274
|
+
(result as Record<string, { soft?: number | null; hard?: number | null }>)[dimension] = limits;
|
|
275
|
+
}
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
278
|
+
function budgetUsage(value?: Partial<WorkflowBudgetUsage>): WorkflowBudgetUsage { return { tokens: value?.tokens ?? 0, costUsd: value?.costUsd ?? 0, durationMs: value?.durationMs ?? 0, agentLaunches: value?.agentLaunches ?? 0 }; }
|
|
279
|
+
export class WorkflowBudgetRuntime {
|
|
280
|
+
readonly #now: () => number;
|
|
281
|
+
readonly #onChange: (() => void) | undefined;
|
|
282
|
+
readonly #injected = new Set<string>();
|
|
283
|
+
readonly #seen = new Set<string>();
|
|
284
|
+
#active: boolean;
|
|
285
|
+
#activeSince: number | undefined;
|
|
286
|
+
#usage: WorkflowBudgetUsage;
|
|
287
|
+
#events: BudgetEvent[];
|
|
288
|
+
#turnAccounting?: { input: number; output: number; cost: number };
|
|
289
|
+
constructor(readonly budget: WorkflowBudget | undefined, readonly version = 1, usage?: Partial<WorkflowBudgetUsage>, events: readonly BudgetEvent[] = [], options: { now?: () => number; onChange?: () => void; active?: boolean } = {}) {
|
|
290
|
+
this.#now = options.now ?? (() => Date.now());
|
|
291
|
+
this.#onChange = options.onChange;
|
|
292
|
+
this.#active = options.active ?? true;
|
|
293
|
+
this.#activeSince = this.#active ? this.#now() : undefined;
|
|
294
|
+
this.#usage = budgetUsage(usage);
|
|
295
|
+
this.#events = [...events];
|
|
296
|
+
for (const event of events) if (event.budgetVersion === version) this.#seen.add(event.type);
|
|
297
|
+
}
|
|
298
|
+
get usage(): WorkflowBudgetUsage { this.#syncDuration(); return { ...this.#usage }; }
|
|
299
|
+
get events(): readonly BudgetEvent[] { return this.#events; }
|
|
300
|
+
get hardExhausted(): boolean { return this.#events.some((event) => event.type === "hard_exhausted" && event.budgetVersion === this.version); }
|
|
301
|
+
checkAgentLaunch(): void { this.#checkHard(["agentLaunches"]); }
|
|
302
|
+
beforeAttempt(): void { this.#checkHard(["agentLaunches"]); this.#usage.agentLaunches += 1; this.#evaluate(); }
|
|
303
|
+
beforeTurn(): void { this.#syncDuration(); this.#evaluate(); this.#checkHard(["tokens", "costUsd", "durationMs"]); }
|
|
304
|
+
afterTurn(accounting: AgentAccounting, final: boolean): void { this.#syncDuration(); this.#applyTurn(accounting, final, this.#turnAccounting); this.#turnAccounting = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }
|
|
305
|
+
#applyTurn(accounting: AgentAccounting, final: boolean, previous = { input: 0, output: 0, cost: 0 }): void {
|
|
306
|
+
this.#usage.tokens += Math.max(0, accounting.input - previous.input) + Math.max(0, accounting.output - previous.output);
|
|
307
|
+
this.#usage.costUsd += Math.max(0, accounting.cost - previous.cost);
|
|
308
|
+
this.#evaluate();
|
|
309
|
+
if (!final) this.#checkHard(["tokens", "costUsd", "durationMs"]);
|
|
310
|
+
}
|
|
311
|
+
instruction(agentId = "agent"): string | undefined {
|
|
312
|
+
if (!this.#hasSoftCrossed() || this.#injected.has(agentId)) return undefined;
|
|
313
|
+
this.#injected.add(agentId);
|
|
314
|
+
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.`;
|
|
315
|
+
}
|
|
316
|
+
forAgent(agentId: string): AgentBudgetHooks {
|
|
317
|
+
let attempt = 0;
|
|
318
|
+
let previous: { input: number; output: number; cost: number } | undefined;
|
|
319
|
+
return {
|
|
320
|
+
beforeAttempt: () => { attempt += 1; previous = undefined; this.beforeAttempt(); },
|
|
321
|
+
beforeTurn: () => { this.beforeTurn(); },
|
|
322
|
+
afterTurn: (accounting, final) => { this.#applyTurn(accounting, final, previous); previous = { input: accounting.input, output: accounting.output, cost: accounting.cost }; },
|
|
323
|
+
instruction: () => this.instruction(`${agentId}:${String(attempt + 1)}`),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
transition(state: RunState): void {
|
|
327
|
+
const active = state === "running";
|
|
328
|
+
if (active === this.#active) return;
|
|
329
|
+
if (active) { this.#active = true; this.#activeSince = this.#now(); }
|
|
330
|
+
else { this.#syncDuration(); this.#evaluate(); this.#active = false; this.#activeSince = undefined; }
|
|
331
|
+
this.#onChange?.();
|
|
332
|
+
}
|
|
333
|
+
#syncDuration(): void { if (this.#active && this.#activeSince !== undefined) { const now = this.#now(); this.#usage.durationMs += Math.max(0, now - this.#activeSince); this.#activeSince = now; } }
|
|
334
|
+
#hasSoftCrossed(): boolean { return !!this.budget && (Object.entries(this.budget) as [BudgetDimension, BudgetLimits][]).some(([dimension, limits]) => limits.soft !== undefined && this.#usage[dimension] >= limits.soft); }
|
|
335
|
+
#checkHard(dimensions: readonly BudgetDimension[]): void {
|
|
336
|
+
const exhausted = dimensions.filter((dimension) => { const hard = this.budget?.[dimension]?.hard; return hard !== undefined && this.#usage[dimension] >= hard; });
|
|
337
|
+
if (!exhausted.length) return;
|
|
338
|
+
this.#record("hard_exhausted", exhausted);
|
|
339
|
+
const detail = exhausted.map((dimension) => `${dimension} usage=${String(this.#usage[dimension])} hard=${String(this.budget?.[dimension]?.hard)}`).join(", ");
|
|
340
|
+
throw new WorkflowError("BUDGET_EXHAUSTED", `Budget version ${String(this.version)} exhausted: ${detail}`);
|
|
341
|
+
}
|
|
342
|
+
#evaluate(): void {
|
|
343
|
+
const budget = this.budget;
|
|
344
|
+
if (!budget) return;
|
|
345
|
+
const soft = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.soft !== undefined && this.#usage[dimension] >= limits.soft; });
|
|
346
|
+
if (soft.length) this.#record("soft_crossed", soft);
|
|
347
|
+
const overrun = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && this.#usage[dimension] > limits.hard; });
|
|
348
|
+
if (overrun.length) this.#record("hard_overrun", overrun);
|
|
349
|
+
}
|
|
350
|
+
#record(type: BudgetEvent["type"], dimensions: readonly BudgetDimension[]): void { if (this.#seen.has(type)) 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: BudgetEvent): void { this.#events.push(structuredClone(event)); }
|
|
352
|
+
snapshot(): { usage: WorkflowBudgetUsage; budgetEvents: readonly BudgetEvent[] } { return { usage: this.usage, budgetEvents: [...this.#events] }; }
|
|
353
|
+
}
|
|
354
|
+
export function mergeBudget(budget: WorkflowBudget | undefined, patch: WorkflowBudgetPatch): WorkflowBudget | undefined {
|
|
355
|
+
const merged: WorkflowBudget = structuredClone(budget ?? {});
|
|
356
|
+
for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) if (Object.prototype.hasOwnProperty.call(patch, dimension)) {
|
|
357
|
+
const value = patch[dimension];
|
|
358
|
+
if (value === null) { Reflect.deleteProperty(merged, dimension); continue; }
|
|
359
|
+
const next: BudgetLimits = { ...(merged[dimension] ?? {}) };
|
|
360
|
+
for (const key of ["soft", "hard"] as const) if (value && Object.prototype.hasOwnProperty.call(value, key)) { const limit = value[key]; if (limit === null) Reflect.deleteProperty(next, key); else if (limit !== undefined) next[key] = limit; }
|
|
361
|
+
if (Object.keys(next).length) (merged as Record<string, BudgetLimits>)[dimension] = next; else Reflect.deleteProperty(merged, dimension);
|
|
362
|
+
}
|
|
363
|
+
return validateBudget(merged);
|
|
364
|
+
}
|
|
365
|
+
export function budgetRelaxed(previous: WorkflowBudget | undefined, next: WorkflowBudget | undefined): boolean { for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) { const oldLimit = previous?.[dimension]; const newLimit = next?.[dimension]; for (const key of ["soft", "hard"] as const) if ((oldLimit?.[key] !== undefined && newLimit?.[key] === undefined) || (oldLimit?.[key] !== undefined && newLimit?.[key] !== undefined && newLimit[key] > oldLimit[key])) return true; } return false; }
|
|
366
|
+
export function exhaustedBudgetDimensions(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): BudgetDimension[] {
|
|
367
|
+
if (!budget) return [];
|
|
368
|
+
return (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && usage[dimension] >= limits.hard; });
|
|
369
|
+
}
|
|
370
|
+
export function resumeBudgetAllowed(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): boolean { return exhaustedBudgetDimensions(budget, usage).length === 0; }
|
|
200
371
|
function positiveInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) > 0; }
|
|
372
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
373
|
+
const MODEL_ALIAS_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
201
374
|
export function parseModelReference(value: string): ModelSpec {
|
|
202
375
|
const match = /^([^/:\s]+)\/([^:\s]+)(?::([^:\s]+))?$/.exec(value);
|
|
203
376
|
if (!match?.[1] || !match[2]) fail("UNKNOWN_MODEL", `Invalid model spec: ${value}`);
|
|
204
377
|
const thinking = match[3];
|
|
205
|
-
if (thinking && !
|
|
378
|
+
if (thinking && !THINKING_LEVELS.includes(thinking as (typeof THINKING_LEVELS)[number])) fail("UNKNOWN_MODEL", `Invalid thinking level: ${thinking}`);
|
|
206
379
|
return { provider: match[1], model: match[2], ...(thinking ? { thinking: thinking as NonNullable<ModelSpec["thinking"]> } : {}) };
|
|
207
380
|
}
|
|
208
381
|
|
|
209
|
-
function
|
|
210
|
-
|
|
382
|
+
function aliasError(message: string, settingsPath: string): never { fail("CONFIG_ERROR", `${message} (settings: ${settingsPath})`); }
|
|
383
|
+
export function validateModelAliases(value: unknown, settingsPath = "workflow settings"): Readonly<Record<string, string>> {
|
|
384
|
+
if (!object(value)) aliasError("modelAliases must be an object", settingsPath);
|
|
385
|
+
const aliases: Record<string, string> = {};
|
|
386
|
+
for (const [name, target] of Object.entries(value)) {
|
|
387
|
+
if (!MODEL_ALIAS_NAME.test(name)) aliasError(`Invalid model alias name: ${name}`, settingsPath);
|
|
388
|
+
if (typeof target !== "string" || !target.trim()) aliasError(`Invalid model alias target for ${name}`, settingsPath);
|
|
389
|
+
try { parseModelReference(target); } catch (error) { aliasError(`Invalid model alias target for ${name}: ${errorText(error)}`, settingsPath); }
|
|
390
|
+
aliases[name] = target;
|
|
391
|
+
}
|
|
392
|
+
return Object.freeze(aliases);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function unknownModel(value: string, target: string | undefined, settingsPath?: string): never {
|
|
396
|
+
const resolved = target ? ` resolved to ${target}` : "";
|
|
397
|
+
const path = settingsPath ? ` (settings: ${settingsPath})` : "";
|
|
398
|
+
fail("UNKNOWN_MODEL", `Unknown model${target ? " alias" : ""} ${value}${resolved}${path}`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function resolveModelReference(value: string, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
|
|
402
|
+
const target = Object.prototype.hasOwnProperty.call(aliases, value) ? aliases[value] : undefined;
|
|
403
|
+
if (target !== undefined) {
|
|
404
|
+
try { return parseModelReference(target); } catch { unknownModel(value, target, settingsPath); }
|
|
405
|
+
}
|
|
406
|
+
if (value.includes("/")) return parseModelReference(value);
|
|
407
|
+
const match = /^([^:\s]+)(?::([^:\s]+))?$/.exec(value);
|
|
408
|
+
const thinking = match?.[2];
|
|
409
|
+
if (!match?.[1] || thinking && !THINKING_LEVELS.includes(thinking as (typeof THINKING_LEVELS)[number])) unknownModel(value, undefined, settingsPath);
|
|
410
|
+
const candidates = [...(knownModels ?? [])].filter((model) => model.slice(model.indexOf("/") + 1) === match[1]);
|
|
411
|
+
if (candidates.length === 1) {
|
|
412
|
+
const parsed = parseModelReference(candidates[0] as string);
|
|
413
|
+
return thinking ? { ...parsed, thinking: thinking as NonNullable<ModelSpec["thinking"]> } : parsed;
|
|
414
|
+
}
|
|
415
|
+
unknownModel(value, undefined, settingsPath);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function modelCapability(value: string, aliases?: Readonly<Record<string, string>>, knownModels?: ReadonlySet<string>, settingsPath?: string): string {
|
|
419
|
+
const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
|
|
211
420
|
return `${parsed.provider}/${parsed.model}`;
|
|
212
421
|
}
|
|
213
422
|
|
|
423
|
+
function aliasDrift(previous: Readonly<Record<string, string>>, current: Readonly<Record<string, string>>): string[] {
|
|
424
|
+
return [...new Set([...Object.keys(previous), ...Object.keys(current)])].sort().flatMap((name) => previous[name] === current[name] ? [] : [`${name}: ${previous[name] ?? "(missing)"} -> ${current[name] ?? "(missing)"}`]);
|
|
425
|
+
}
|
|
426
|
+
|
|
214
427
|
export interface CheckpointInput { name: string; prompt: string; context: JsonValue }
|
|
215
428
|
export function validateCheckpoint(value: unknown): CheckpointInput {
|
|
216
429
|
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)) fail("INVALID_METADATA", "checkpoint requires only name, prompt, and JSON context");
|
|
@@ -220,25 +433,77 @@ export function validateCheckpoint(value: unknown): CheckpointInput {
|
|
|
220
433
|
}
|
|
221
434
|
|
|
222
435
|
export function workflowSettingsPath(agentDir = getAgentDir()): string { return join(agentDir, ROLE_DIRECTORY, "settings.json"); }
|
|
436
|
+
export function workflowProjectSettingsPath(cwd: string): string { return join(cwd, ".pi", ROLE_DIRECTORY, "settings.json"); }
|
|
437
|
+
const EMPTY_AGENT_RESOURCE_EXCLUSIONS: AgentResourceExclusions = Object.freeze({ skills: [], extensions: [] });
|
|
438
|
+
function normalizedResourcePath(value: string, settingsPath: string): string {
|
|
439
|
+
let expanded = value === "~" ? homedir() : value.startsWith("~/") || value.startsWith("~\\") ? join(homedir(), value.slice(2)) : value;
|
|
440
|
+
if (expanded.startsWith("file://")) expanded = fileURLToPath(expanded);
|
|
441
|
+
const resolved = resolve(dirname(settingsPath), expanded);
|
|
442
|
+
try { return realpathSync(resolved); } catch { return resolved; }
|
|
443
|
+
}
|
|
444
|
+
export function mergeAgentResourceExclusions(...values: (AgentResourceExclusions | undefined)[]): AgentResourceExclusions {
|
|
445
|
+
return { skills: [...new Set(values.flatMap((value) => value?.skills ?? []))], extensions: [...new Set(values.flatMap((value) => value?.extensions ?? []))] };
|
|
446
|
+
}
|
|
447
|
+
function validateAgentResourceExclusions(value: unknown, settingsPath: string, errorCode: "INVALID_SETTINGS" | "INVALID_METADATA" = "INVALID_SETTINGS"): AgentResourceExclusions | undefined {
|
|
448
|
+
if (value === undefined) return undefined;
|
|
449
|
+
const base = `${settingsPath}.disabledAgentResources`;
|
|
450
|
+
if (!object(value)) fail(errorCode, `${base} must be an object`);
|
|
451
|
+
for (const key of Object.keys(value)) if (key !== "skills" && key !== "extensions") fail(errorCode, `${base}.${key} is not supported`);
|
|
452
|
+
const normalized: { skills: string[]; extensions: string[] } = { skills: [], extensions: [] };
|
|
453
|
+
for (const kind of ["skills", "extensions"] as const) {
|
|
454
|
+
const entries = value[kind];
|
|
455
|
+
if (entries === undefined) continue;
|
|
456
|
+
if (!Array.isArray(entries)) fail(errorCode, `${base}.${kind} must be an array`);
|
|
457
|
+
const seen = new Set<string>();
|
|
458
|
+
for (const [index, entry] of entries.entries()) {
|
|
459
|
+
if (typeof entry !== "string" || !entry.trim()) fail(errorCode, `${base}.${kind}[${String(index)}] must be a non-empty string`);
|
|
460
|
+
let selector = entry.trim();
|
|
461
|
+
if (kind === "extensions") {
|
|
462
|
+
try { selector = normalizedResourcePath(selector, settingsPath); } catch (error) { fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`); }
|
|
463
|
+
}
|
|
464
|
+
if (!seen.has(selector)) { seen.add(selector); normalized[kind].push(selector); }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return Object.freeze({ skills: Object.freeze(normalized.skills), extensions: Object.freeze(normalized.extensions) });
|
|
468
|
+
}
|
|
223
469
|
export function loadSettings(path = workflowSettingsPath()): Readonly<WorkflowSettings> {
|
|
224
470
|
let parsed: unknown;
|
|
225
471
|
try { parsed = JSON.parse(readFileSync(path, "utf8")); }
|
|
226
472
|
catch (error) {
|
|
227
473
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return DEFAULT_SETTINGS;
|
|
228
|
-
fail("
|
|
474
|
+
fail("CONFIG_ERROR", `Invalid workflow settings JSON at ${path}: ${errorText(error)}`);
|
|
229
475
|
}
|
|
230
|
-
if (!object(parsed)) fail("INVALID_SETTINGS",
|
|
231
|
-
const allowed = new Set(["concurrency", "
|
|
476
|
+
if (!object(parsed)) fail("INVALID_SETTINGS", `Workflow settings at ${path} must be an object`);
|
|
477
|
+
const allowed = new Set(["concurrency", "modelAliases", "disabledAgentResources"]);
|
|
232
478
|
const unknown = Object.keys(parsed).find((key) => !allowed.has(key));
|
|
233
|
-
if (unknown) fail("INVALID_SETTINGS", `Unknown workflow setting: ${unknown}`);
|
|
479
|
+
if (unknown) fail("INVALID_SETTINGS", `Unknown workflow setting at ${path}: ${unknown}`);
|
|
234
480
|
const concurrency = parsed.concurrency === undefined ? DEFAULT_SETTINGS.concurrency : parsed.concurrency;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return Object.freeze({ concurrency,
|
|
481
|
+
if (!positiveInteger(concurrency) || concurrency > 16) fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
|
|
482
|
+
const modelAliases = parsed.modelAliases === undefined ? undefined : validateModelAliases(parsed.modelAliases, path);
|
|
483
|
+
const disabledAgentResources = validateAgentResourceExclusions(parsed.disabledAgentResources, path);
|
|
484
|
+
return Object.freeze({ concurrency, ...(modelAliases ? { modelAliases } : {}), ...(disabledAgentResources ? { disabledAgentResources } : {}) });
|
|
485
|
+
}
|
|
486
|
+
export function resolveAgentResourcePolicy(cwd: string, projectTrusted: boolean, globalSettingsPath = workflowSettingsPath()): AgentResourcePolicy {
|
|
487
|
+
const projectSettingsPath = workflowProjectSettingsPath(cwd);
|
|
488
|
+
const global = loadSettings(globalSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS;
|
|
489
|
+
const project = projectTrusted ? loadSettings(projectSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS : EMPTY_AGENT_RESOURCE_EXCLUSIONS;
|
|
490
|
+
const effective = mergeAgentResourceExclusions(global, project);
|
|
491
|
+
return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [] };
|
|
492
|
+
}
|
|
493
|
+
export function saveModelAliases(path = workflowSettingsPath(), aliases: Readonly<Record<string, string>> = {}): void {
|
|
494
|
+
const normalized = validateModelAliases(aliases, path);
|
|
495
|
+
let parsed: Record<string, unknown> = {};
|
|
496
|
+
try {
|
|
497
|
+
loadSettings(path);
|
|
498
|
+
parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
501
|
+
}
|
|
502
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
503
|
+
atomicWriteFile(path, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, true);
|
|
239
504
|
}
|
|
240
505
|
|
|
241
|
-
export function parseRoleMarkdown(content: string, strict = false): AgentDefinition {
|
|
506
|
+
export function parseRoleMarkdown(content: string, strict = false, rolePath?: string): AgentDefinition {
|
|
242
507
|
if (!strict) {
|
|
243
508
|
if (!content.startsWith("---\n")) return { prompt: content };
|
|
244
509
|
const end = content.indexOf("\n---", 4);
|
|
@@ -264,12 +529,13 @@ export function parseRoleMarkdown(content: string, strict = false): AgentDefinit
|
|
|
264
529
|
try { parsed = parseFrontmatter(content); }
|
|
265
530
|
catch (error) { fail("INVALID_METADATA", `Invalid role frontmatter: ${errorText(error)}`); }
|
|
266
531
|
if (!object(parsed.frontmatter)) fail("INVALID_METADATA", "Role frontmatter must be an object");
|
|
267
|
-
const { model, thinking, tools, description } = parsed.frontmatter;
|
|
532
|
+
const { model, thinking, tools, description, disabledAgentResources } = parsed.frontmatter;
|
|
268
533
|
if (model !== undefined && (typeof model !== "string" || model.trim() === "")) fail("INVALID_METADATA", "Role model must be a non-empty string");
|
|
269
534
|
if (thinking !== undefined && (typeof thinking !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking))) fail("INVALID_METADATA", `Invalid role thinking level: ${typeof thinking === "string" ? thinking : typeof thinking}`);
|
|
270
535
|
if (description !== undefined && (typeof description !== "string" || description.trim() === "" || description.length > 1024 || /[\r\n]/.test(description))) fail("INVALID_METADATA", "Role description must be a non-empty single-line string of at most 1024 characters");
|
|
271
536
|
if (tools !== undefined && (!Array.isArray(tools) || tools.some((tool) => typeof tool !== "string" || tool.trim() === ""))) fail("INVALID_METADATA", "Role tools must be an array of non-empty strings");
|
|
272
|
-
|
|
537
|
+
const normalizedResources = validateAgentResourceExclusions(disabledAgentResources, rolePath ?? "<role>", "INVALID_METADATA");
|
|
538
|
+
return { prompt: parsed.body, ...(typeof description === "string" ? { description: description.trim() } : {}), ...(typeof model === "string" ? { model: model.trim() } : {}), ...(typeof thinking === "string" ? { thinking: thinking as NonNullable<AgentDefinition["thinking"]> } : {}), ...(Array.isArray(tools) ? { tools: tools.map((tool) => (tool as string).trim()) } : {}), ...(normalizedResources ? { disabledAgentResources: normalizedResources } : {}) };
|
|
273
539
|
}
|
|
274
540
|
|
|
275
541
|
const ROLE_DIRECTORY = "pi-extensible-workflows";
|
|
@@ -286,7 +552,7 @@ function readAgentDefinitions(dir: string): Record<string, AgentDefinition> {
|
|
|
286
552
|
try {
|
|
287
553
|
return Object.fromEntries(readdirSync(dir, { withFileTypes: true })
|
|
288
554
|
.filter((entry) => entry.isFile() && extname(entry.name) === ".md")
|
|
289
|
-
.map((entry) => [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(
|
|
555
|
+
.map((entry) => { const path = join(dir, entry.name); return [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }));
|
|
290
556
|
} catch (error) {
|
|
291
557
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
292
558
|
throw error;
|
|
@@ -300,11 +566,17 @@ function readRoleDefinitions(dirs: readonly string[]): Record<string, AgentDefin
|
|
|
300
566
|
export function loadAgentDefinitions(cwd: string, agentDir = getAgentDir(), projectTrusted = true): Readonly<Record<string, AgentDefinition>> {
|
|
301
567
|
return deepFreeze({ ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
|
|
302
568
|
}
|
|
303
|
-
function validateRolePolicies(definitions: Readonly<Record<string, AgentDefinition>>, roles: readonly string[], availableModels: ReadonlySet<string>, rootTools: ReadonlySet<string
|
|
569
|
+
function validateRolePolicies(definitions: Readonly<Record<string, AgentDefinition>>, roles: readonly string[], availableModels: ReadonlySet<string>, rootTools: ReadonlySet<string>, aliases: Readonly<Record<string, string>> = {}, knownModels = availableModels, settingsPath?: string): void {
|
|
304
570
|
for (const role of roles) {
|
|
305
571
|
const definition = definitions[role];
|
|
306
572
|
if (!definition) continue;
|
|
307
|
-
if (definition.model !== undefined
|
|
573
|
+
if (definition.model !== undefined) {
|
|
574
|
+
const resolved = modelCapability(definition.model, aliases, knownModels, settingsPath);
|
|
575
|
+
if (!availableModels.has(resolved)) {
|
|
576
|
+
if (Object.prototype.hasOwnProperty.call(aliases, definition.model)) unknownModel(definition.model, resolved, settingsPath);
|
|
577
|
+
fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${resolved}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
308
580
|
const missingTool = (definition.tools ?? [...rootTools]).find((tool) => !rootTools.has(tool));
|
|
309
581
|
if (missingTool) fail("UNKNOWN_TOOL", `Unknown tool for role ${role}: ${missingTool}`);
|
|
310
582
|
}
|
|
@@ -343,15 +615,25 @@ type WorkflowCall = acorn.CallExpression & { callee: acorn.Identifier };
|
|
|
343
615
|
function astNode(value: unknown): value is acorn.AnyNode {
|
|
344
616
|
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
|
|
345
617
|
}
|
|
618
|
+
function astChildren(node: acorn.AnyNode): acorn.AnyNode[] {
|
|
619
|
+
const children: acorn.AnyNode[] = [];
|
|
620
|
+
for (const value of Object.values(node) as unknown[]) {
|
|
621
|
+
if (Array.isArray(value)) {
|
|
622
|
+
for (const child of value) if (astNode(child)) children.push(child);
|
|
623
|
+
} else if (astNode(value)) children.push(value);
|
|
624
|
+
}
|
|
625
|
+
return children;
|
|
626
|
+
}
|
|
627
|
+
function workflowCallKind(node: acorn.AnyNode): WorkflowCallKind | undefined {
|
|
628
|
+
if (node.type !== "CallExpression" || node.callee.type !== "Identifier") return undefined;
|
|
629
|
+
const kind = node.callee.name as WorkflowCallKind;
|
|
630
|
+
return WORKFLOW_CALL_KINDS.includes(kind) ? kind : undefined;
|
|
631
|
+
}
|
|
346
632
|
function workflowCalls(program: acorn.Program): WorkflowCall[] {
|
|
347
633
|
const calls: WorkflowCall[] = [];
|
|
348
634
|
const visit = (node: acorn.AnyNode): void => {
|
|
349
|
-
if (
|
|
350
|
-
for (const
|
|
351
|
-
if (Array.isArray(value)) {
|
|
352
|
-
for (const child of value as unknown[]) if (astNode(child)) visit(child);
|
|
353
|
-
} else if (astNode(value)) visit(value);
|
|
354
|
-
}
|
|
635
|
+
if (workflowCallKind(node)) calls.push(node as WorkflowCall);
|
|
636
|
+
for (const child of astChildren(node)) visit(child);
|
|
355
637
|
};
|
|
356
638
|
visit(program);
|
|
357
639
|
return calls.sort((left, right) => left.start - right.start);
|
|
@@ -368,9 +650,9 @@ function workflowCallsWithStructure(program: acorn.Program): Array<{ call: Workf
|
|
|
368
650
|
const key = node.key.type === "Identifier" ? node.key.name : node.key.type === "Literal" ? String(node.key.value) : undefined;
|
|
369
651
|
if (scope?.key === null && key) current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
|
|
370
652
|
}
|
|
371
|
-
|
|
653
|
+
const operation = workflowCallKind(node);
|
|
654
|
+
if (operation) {
|
|
372
655
|
const call = node as WorkflowCall;
|
|
373
|
-
const operation = call.callee.name as StaticWorkflowCall["kind"];
|
|
374
656
|
const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
|
|
375
657
|
calls.push({ call, execution, structure: current.structure });
|
|
376
658
|
for (const [index, argument] of call.arguments.entries()) {
|
|
@@ -380,16 +662,11 @@ function workflowCallsWithStructure(program: acorn.Program): Array<{ call: Workf
|
|
|
380
662
|
}
|
|
381
663
|
return;
|
|
382
664
|
}
|
|
383
|
-
for (const
|
|
384
|
-
if (Array.isArray(value)) {
|
|
385
|
-
for (const child of value as unknown[]) if (astNode(child)) visit(child, current);
|
|
386
|
-
} else if (astNode(value)) visit(value, current);
|
|
387
|
-
}
|
|
665
|
+
for (const child of astChildren(node)) visit(child, current);
|
|
388
666
|
};
|
|
389
667
|
visit(program, { execution: "sequential", structure: [] });
|
|
390
668
|
return calls.sort((left, right) => left.call.start - right.call.start);
|
|
391
669
|
}
|
|
392
|
-
|
|
393
670
|
function validateDirectPrimitiveReferences(program: acorn.AnyNode, name: string): void {
|
|
394
671
|
const visit = (node: acorn.AnyNode, parent?: acorn.AnyNode): void => {
|
|
395
672
|
if (node.type === "Identifier" && node.name === name) {
|
|
@@ -397,21 +674,17 @@ function validateDirectPrimitiveReferences(program: acorn.AnyNode, name: string)
|
|
|
397
674
|
const propertyKey = parent?.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand;
|
|
398
675
|
if (!directCall && !propertyKey) fail("INVALID_METADATA", `${name} calls must use a direct ${name}(...) call; aliases and indirect calls are unsupported`);
|
|
399
676
|
}
|
|
400
|
-
for (const
|
|
401
|
-
if (Array.isArray(value)) {
|
|
402
|
-
for (const child of value as unknown[]) if (astNode(child)) visit(child, node);
|
|
403
|
-
} else if (astNode(value)) visit(value, node);
|
|
404
|
-
}
|
|
677
|
+
for (const child of astChildren(node)) visit(child, node);
|
|
405
678
|
};
|
|
406
679
|
visit(program);
|
|
407
680
|
}
|
|
408
|
-
|
|
409
681
|
function hasIdentifier(node: acorn.AnyNode, name: string): boolean {
|
|
410
682
|
if (node.type === "Identifier" && node.name === name) return true;
|
|
411
|
-
return
|
|
683
|
+
return astChildren(node).some((child) => hasIdentifier(child, name));
|
|
412
684
|
}
|
|
413
685
|
|
|
414
686
|
const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
|
|
687
|
+
const INTERNAL_CONVERSATION_NAME = "__pi_extensible_workflows_conversation";
|
|
415
688
|
const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
|
|
416
689
|
|
|
417
690
|
function callHasTrailingComma(source: string, call: WorkflowCall): boolean {
|
|
@@ -429,13 +702,14 @@ function instrumentWorkflow(script: string): string {
|
|
|
429
702
|
if (!body.trim()) return body;
|
|
430
703
|
const program = parseWorkflow(body);
|
|
431
704
|
if (hasIdentifier(program, INTERNAL_AGENT_NAME)) fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
|
|
705
|
+
if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME)) fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
|
|
432
706
|
if (hasIdentifier(program, INTERNAL_WORKTREE_NAME)) fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
|
|
433
|
-
const calls = workflowCalls(program).filter((call) => ["agent", "withWorktree"].includes(call.callee.name));
|
|
707
|
+
const calls = workflowCalls(program).filter((call) => ["agent", "conversation", "withWorktree"].includes(call.callee.name));
|
|
434
708
|
const edits = calls.flatMap((call) => {
|
|
435
709
|
const callSite = `${String(call.start)}:${String(call.end)}`;
|
|
436
710
|
const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
|
|
437
711
|
return [
|
|
438
|
-
{ start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : INTERNAL_WORKTREE_NAME },
|
|
712
|
+
{ 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 },
|
|
439
713
|
{ start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` },
|
|
440
714
|
];
|
|
441
715
|
}).sort((left, right) => right.start - left.start);
|
|
@@ -512,14 +786,14 @@ function validateSchema(schema: unknown, at = "schema"): asserts schema is JsonS
|
|
|
512
786
|
}
|
|
513
787
|
|
|
514
788
|
const AGENT_OPTION_KEYS = new Set(["label", "model", "thinking", "tools", "role", "outputSchema", "retries", "timeoutMs"]);
|
|
515
|
-
function validateAgentOption(key: string, value: unknown): void {
|
|
789
|
+
function validateAgentOption(key: string, value: unknown, aliases?: Readonly<Record<string, string>>, knownModels?: ReadonlySet<string>, settingsPath?: string): void {
|
|
516
790
|
switch (key) {
|
|
517
791
|
case "label":
|
|
518
792
|
if (typeof value !== "string" || !value.trim()) fail("INVALID_METADATA", "agent label must be a non-empty string");
|
|
519
793
|
break;
|
|
520
794
|
case "model":
|
|
521
|
-
if (typeof value !== "string") fail("INVALID_METADATA", "agent model must be a string");
|
|
522
|
-
|
|
795
|
+
if (typeof value !== "string" || !value.trim()) fail("INVALID_METADATA", "agent model must be a non-empty string");
|
|
796
|
+
if (aliases !== undefined) resolveModelReference(value, aliases, knownModels, settingsPath);
|
|
523
797
|
break;
|
|
524
798
|
case "thinking":
|
|
525
799
|
if (typeof value !== "string" || !parseThinking(value)) fail("INVALID_METADATA", "agent thinking must be off, minimal, low, medium, high, xhigh, or max");
|
|
@@ -541,12 +815,9 @@ function validateAgentOption(key: string, value: unknown): void {
|
|
|
541
815
|
break;
|
|
542
816
|
}
|
|
543
817
|
}
|
|
544
|
-
|
|
545
818
|
function validateAgentOptions(value: unknown): Readonly<Record<string, JsonValue>> {
|
|
546
819
|
if (!object(value) || !jsonValue(value)) fail("INVALID_METADATA", "agent options must be a JSON object");
|
|
547
|
-
const
|
|
548
|
-
if (unknown) fail("INVALID_METADATA", `Unknown agent option: ${unknown}`);
|
|
549
|
-
for (const [key, option] of Object.entries(value)) validateAgentOption(key, option);
|
|
820
|
+
for (const [key, option] of Object.entries(value)) if (AGENT_OPTION_KEYS.has(key)) validateAgentOption(key, option);
|
|
550
821
|
if (typeof value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(value, key))) fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
551
822
|
return value;
|
|
552
823
|
}
|
|
@@ -585,7 +856,7 @@ function staticValue(node: acorn.AnyNode | undefined): StaticValue {
|
|
|
585
856
|
}
|
|
586
857
|
|
|
587
858
|
export interface StaticWorkflowCall {
|
|
588
|
-
kind:
|
|
859
|
+
kind: WorkflowCallKind;
|
|
589
860
|
start: number;
|
|
590
861
|
end: number;
|
|
591
862
|
name: string | null;
|
|
@@ -617,7 +888,7 @@ export function inspectWorkflowScript(script: string): StaticWorkflowCall[] {
|
|
|
617
888
|
const first = callArgument(call, 0);
|
|
618
889
|
const options = callArgument(call, 1);
|
|
619
890
|
const placement = { execution, structure };
|
|
620
|
-
if (kind === "agent") {
|
|
891
|
+
if (kind === "agent" || kind === "conversation") {
|
|
621
892
|
const retries = staticValue(propertyNode(options, "retries"));
|
|
622
893
|
const outputSchema = staticValue(propertyNode(options, "outputSchema"));
|
|
623
894
|
const optionKeys = options?.type === "ObjectExpression" ? options.properties.flatMap((property) => {
|
|
@@ -626,7 +897,7 @@ export function inspectWorkflowScript(script: string): StaticWorkflowCall[] {
|
|
|
626
897
|
return key ? [key] : [];
|
|
627
898
|
}) : [];
|
|
628
899
|
const knownOptions = Object.fromEntries(optionKeys.flatMap((key) => { const value = staticValue(propertyNode(options, key)); return value.known && jsonValue(value.value) ? [[key, value.value]] : []; })) as Record<string, JsonValue>;
|
|
629
|
-
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")) };
|
|
900
|
+
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")) };
|
|
630
901
|
return { ...base, ...(retries.known && typeof retries.value === "number" ? { retries: retries.value } : {}), ...(outputSchema.known && object(outputSchema.value) ? { outputSchema: outputSchema.value as JsonSchema } : {}), ...(optionKeys.length ? { options: knownOptions, optionKeys } : {}) };
|
|
631
902
|
}
|
|
632
903
|
if (kind === "checkpoint") return { ...placement, kind, start: call.start, end: call.end, name: staticString(propertyNode(first, "name")), prompt: staticString(propertyNode(first, "prompt")), model: null, role: null };
|
|
@@ -634,21 +905,14 @@ export function inspectWorkflowScript(script: string): StaticWorkflowCall[] {
|
|
|
634
905
|
});
|
|
635
906
|
}
|
|
636
907
|
|
|
637
|
-
function validateStaticAgentOptions(node: acorn.AnyNode | undefined): void {
|
|
908
|
+
function validateStaticAgentOptions(node: acorn.AnyNode | undefined, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): void {
|
|
638
909
|
if (node?.type !== "ObjectExpression") return;
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
|
|
642
|
-
if (key && !AGENT_OPTION_KEYS.has(key)) fail("INVALID_METADATA", `Unknown agent option: ${key}`);
|
|
643
|
-
}
|
|
644
|
-
const role = propertyNode(node, "role");
|
|
645
|
-
if (role && ["model", "thinking", "tools"].some((key) => propertyNode(node, key) !== undefined)) fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
910
|
+
const options = staticValue(node);
|
|
911
|
+
if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value as Record<string, unknown>, key))) fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
646
912
|
for (const key of AGENT_OPTION_KEYS) {
|
|
647
913
|
const value = staticValue(propertyNode(node, key));
|
|
648
|
-
if (value.known) validateAgentOption(key, value.value);
|
|
914
|
+
if (value.known) validateAgentOption(key, value.value, aliases, knownModels, settingsPath);
|
|
649
915
|
}
|
|
650
|
-
const options = staticValue(node);
|
|
651
|
-
if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value as Record<string, unknown>, key))) fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
652
916
|
}
|
|
653
917
|
|
|
654
918
|
function validateStaticWithWorktree(call: WorkflowCall): void {
|
|
@@ -662,17 +926,19 @@ function validateStaticWithWorktree(call: WorkflowCall): void {
|
|
|
662
926
|
}
|
|
663
927
|
}
|
|
664
928
|
|
|
665
|
-
export interface WorkflowCatalogFunction { name: string;
|
|
666
|
-
export interface WorkflowCatalogVariable { name: string;
|
|
667
|
-
export interface WorkflowCatalogWorkflow { name: string;
|
|
668
|
-
export interface WorkflowCatalog { functions: readonly WorkflowCatalogFunction[]; variables: readonly WorkflowCatalogVariable[]; workflows: readonly WorkflowCatalogWorkflow[] }
|
|
669
|
-
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"]);
|
|
929
|
+
export interface WorkflowCatalogFunction { name: string; version: string; headline: string; extensionDescription: string; description: string; input: JsonSchema; output: JsonSchema }
|
|
930
|
+
export interface WorkflowCatalogVariable { name: string; version: string; headline: string; extensionDescription: string; description: string; schema: JsonSchema }
|
|
931
|
+
export interface WorkflowCatalogWorkflow { name: string; version: string; headline: string; extensionDescription: string; description: string }
|
|
932
|
+
export interface WorkflowCatalog { functions: readonly WorkflowCatalogFunction[]; variables: readonly WorkflowCatalogVariable[]; workflows: readonly WorkflowCatalogWorkflow[]; modelAliases?: Readonly<Record<string, string>> }
|
|
933
|
+
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"]);
|
|
670
934
|
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
671
935
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
672
936
|
|
|
673
937
|
export class WorkflowRegistry {
|
|
674
|
-
readonly #extensions = new
|
|
938
|
+
readonly #extensions = new Set<Readonly<WorkflowExtension>>();
|
|
675
939
|
readonly #globals = new Map<string, string>();
|
|
940
|
+
readonly #workflows = new Map<string, WorkflowScriptDefinition>();
|
|
941
|
+
readonly #hooks = new Map<string, RegisteredAgentSetupHook>();
|
|
676
942
|
#frozen = false;
|
|
677
943
|
|
|
678
944
|
get frozen(): boolean { return this.#frozen; }
|
|
@@ -680,89 +946,99 @@ export class WorkflowRegistry {
|
|
|
680
946
|
|
|
681
947
|
register(extension: WorkflowExtension): void {
|
|
682
948
|
if (this.#frozen) fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
|
|
683
|
-
if (!object(extension) || Object.keys(extension).some((key) => !["
|
|
684
|
-
if (this.#extensions.has(extension.namespace)) fail("DUPLICATE_NAME", `Workflow extension already registered: ${extension.namespace}`);
|
|
949
|
+
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()) fail("INVALID_METADATA", "Workflow extensions require a semantic version, headline, and description");
|
|
685
950
|
const functions = extension.functions ?? {};
|
|
686
951
|
const variables = extension.variables ?? {};
|
|
687
952
|
const workflows = extension.workflows ?? {};
|
|
688
|
-
|
|
953
|
+
const agentSetupHooks = extension.agentSetupHooks ?? {};
|
|
954
|
+
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)) fail("INVALID_METADATA", "Workflow extensions require functions, variables, workflows, or agent setup hooks");
|
|
689
955
|
const names = [...Object.keys(functions), ...Object.keys(variables)];
|
|
690
|
-
if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION",
|
|
956
|
+
if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION", "Global name collision inside extension");
|
|
691
957
|
for (const name of names) {
|
|
692
|
-
if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_")) fail("INVALID_METADATA", `Invalid global name: ${
|
|
958
|
+
if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_")) fail("INVALID_METADATA", `Invalid global name: ${name}`);
|
|
693
959
|
if (RESERVED_GLOBALS.has(name)) fail("GLOBAL_COLLISION", `Global name is reserved: ${name}`);
|
|
694
|
-
|
|
695
|
-
if (owner) fail("GLOBAL_COLLISION", `Global name ${name} is already owned by ${owner}; cannot register ${extension.namespace}.${name}`);
|
|
960
|
+
if (this.#globals.has(name)) fail("GLOBAL_COLLISION", `Global name is already registered: ${name}`);
|
|
696
961
|
}
|
|
697
962
|
for (const [name, fn] of Object.entries(functions)) {
|
|
698
|
-
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") fail("INVALID_METADATA", `Invalid workflow function: ${
|
|
699
|
-
validateSchema(fn.input, `${
|
|
700
|
-
validateSchema(fn.output, `${
|
|
701
|
-
if (fn.input.type !== "object") fail("INVALID_SCHEMA", `${
|
|
963
|
+
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") fail("INVALID_METADATA", `Invalid workflow function: ${name}`);
|
|
964
|
+
validateSchema(fn.input, `${name} input`);
|
|
965
|
+
validateSchema(fn.output, `${name} output`);
|
|
966
|
+
if (fn.input.type !== "object") fail("INVALID_SCHEMA", `${name} input must describe one object`);
|
|
702
967
|
}
|
|
703
968
|
for (const [name, variable] of Object.entries(variables)) {
|
|
704
|
-
if (!object(variable) || Object.keys(variable).some((key) => !["description", "schema", "resolve"].includes(key)) || typeof variable.description !== "string" || !variable.description.trim() || typeof variable.resolve !== "function") fail("INVALID_METADATA", `Invalid workflow variable: ${
|
|
705
|
-
validateSchema(variable.schema, `${
|
|
969
|
+
if (!object(variable) || Object.keys(variable).some((key) => !["description", "schema", "resolve"].includes(key)) || typeof variable.description !== "string" || !variable.description.trim() || typeof variable.resolve !== "function") fail("INVALID_METADATA", `Invalid workflow variable: ${name}`);
|
|
970
|
+
validateSchema(variable.schema, `${name} schema`);
|
|
706
971
|
}
|
|
707
972
|
for (const [name, workflow] of Object.entries(workflows)) {
|
|
708
|
-
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()) fail("INVALID_METADATA", `Invalid workflow script: ${
|
|
973
|
+
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()) fail("INVALID_METADATA", `Invalid workflow script: ${name}`);
|
|
974
|
+
if (this.#workflows.has(name)) fail("DUPLICATE_NAME", `Reusable workflow already registered: ${name}`);
|
|
709
975
|
parseWorkflow(workflow.script);
|
|
710
976
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
977
|
+
for (const [name, hook] of Object.entries(agentSetupHooks)) {
|
|
978
|
+
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))) fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
|
|
979
|
+
if (this.#hooks.has(name)) fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
|
|
980
|
+
}
|
|
981
|
+
const stored = deepFreeze({ ...extension, functions, variables, workflows, agentSetupHooks });
|
|
982
|
+
this.#extensions.add(stored);
|
|
983
|
+
for (const name of names) this.#globals.set(name, name);
|
|
984
|
+
for (const [name, workflow] of Object.entries(workflows)) this.#workflows.set(name, workflow);
|
|
985
|
+
for (const [name, hook] of Object.entries(agentSetupHooks)) this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
|
|
714
986
|
}
|
|
715
987
|
|
|
716
988
|
workflow(name: string): WorkflowScriptDefinition {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const workflow = this.#extensions.get(name.slice(0, separator))?.workflows?.[name.slice(separator + 1)];
|
|
989
|
+
if (!IDENTIFIER.test(name)) fail("MISSING_WORKFLOW", `Registered workflows require an unqualified name: ${name}`);
|
|
990
|
+
const workflow = this.#workflows.get(name);
|
|
720
991
|
if (!workflow) fail("MISSING_WORKFLOW", `Workflow is unavailable: ${name}`);
|
|
721
992
|
return workflow;
|
|
722
993
|
}
|
|
723
994
|
|
|
724
995
|
workflows(): Readonly<Record<string, WorkflowScriptDefinition>> {
|
|
725
|
-
return Object.freeze(Object.fromEntries(
|
|
996
|
+
return Object.freeze(Object.fromEntries(this.#workflows));
|
|
726
997
|
}
|
|
727
998
|
|
|
728
999
|
catalog(): WorkflowCatalog {
|
|
729
1000
|
const functions: WorkflowCatalogFunction[] = [];
|
|
730
1001
|
const variables: WorkflowCatalogVariable[] = [];
|
|
731
1002
|
const workflows: WorkflowCatalogWorkflow[] = [];
|
|
732
|
-
for (const
|
|
733
|
-
for (const [name, fn] of Object.entries(extension.functions ?? {})) functions.push({ name,
|
|
734
|
-
for (const [name, variable] of Object.entries(extension.variables ?? {})) variables.push({ name,
|
|
735
|
-
for (const [name, workflow] of Object.entries(extension.workflows ?? {})) workflows.push({ name
|
|
1003
|
+
for (const extension of this.#extensions) {
|
|
1004
|
+
for (const [name, fn] of Object.entries(extension.functions ?? {})) functions.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
|
|
1005
|
+
for (const [name, variable] of Object.entries(extension.variables ?? {})) variables.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
|
|
1006
|
+
for (const [name, workflow] of Object.entries(extension.workflows ?? {})) workflows.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
|
|
736
1007
|
}
|
|
737
|
-
|
|
738
|
-
|
|
1008
|
+
let aliases: Readonly<Record<string, string>> | undefined;
|
|
1009
|
+
try { aliases = loadSettings().modelAliases; } catch { aliases = undefined; }
|
|
1010
|
+
const sort = (left: { name: string }, right: { name: string }) => left.name.localeCompare(right.name);
|
|
1011
|
+
return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), workflows: workflows.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
|
|
739
1012
|
}
|
|
740
1013
|
|
|
741
|
-
globals(): Readonly<Record<string, {
|
|
742
|
-
return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((
|
|
1014
|
+
globals(): Readonly<Record<string, { name: string }>> {
|
|
1015
|
+
return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.keys(extension.functions ?? {}).map((name) => [name, { name }]))));
|
|
743
1016
|
}
|
|
744
1017
|
|
|
745
|
-
async invokeFunction(
|
|
746
|
-
const fn = this.#extensions.
|
|
747
|
-
if (!fn) fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${
|
|
748
|
-
if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input)) fail("RESULT_INVALID", `Invalid input for ${
|
|
1018
|
+
async invokeFunction(name: string, input: unknown, context: Readonly<WorkflowFunctionContext>, path: string, journal: WorkflowJournal): Promise<JsonValue> {
|
|
1019
|
+
const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
|
|
1020
|
+
if (!fn) fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${name}`);
|
|
1021
|
+
if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input)) fail("RESULT_INVALID", `Invalid input for ${name}`);
|
|
749
1022
|
const replayed = journal.get(path);
|
|
750
1023
|
if (replayed !== undefined) {
|
|
751
|
-
if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${
|
|
1024
|
+
if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${name}`);
|
|
752
1025
|
return structuredClone(replayed);
|
|
753
1026
|
}
|
|
754
|
-
const result: unknown = 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 }));
|
|
755
|
-
if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${
|
|
1027
|
+
const result: unknown = 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 }));
|
|
1028
|
+
if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
|
|
756
1029
|
const stored = structuredClone(result);
|
|
757
1030
|
journal.put(path, stored);
|
|
758
1031
|
return structuredClone(stored);
|
|
759
1032
|
}
|
|
760
1033
|
|
|
761
|
-
variables(): readonly {
|
|
762
|
-
return [...this.#extensions].flatMap((
|
|
1034
|
+
variables(): readonly { name: string; variable: WorkflowVariable }[] {
|
|
1035
|
+
return [...this.#extensions].flatMap((extension) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ name, variable })));
|
|
1036
|
+
}
|
|
1037
|
+
agentSetupHooks(): readonly RegisteredAgentSetupHook[] {
|
|
1038
|
+
return [...this.#hooks.values()].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
763
1039
|
}
|
|
764
1040
|
}
|
|
765
|
-
type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "workflow" | "workflows" | "catalog" | "globals" | "invokeFunction" | "variables">;
|
|
1041
|
+
type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "workflow" | "workflows" | "catalog" | "globals" | "invokeFunction" | "variables" | "agentSetupHooks">;
|
|
766
1042
|
interface WorkflowRegistryHost { api: WorkflowRegistryApi }
|
|
767
1043
|
const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
|
|
768
1044
|
const globalRegistry = globalThis as typeof globalThis & Record<symbol, WorkflowRegistryHost | undefined>;
|
|
@@ -777,6 +1053,7 @@ function createWorkflowRegistryApi(registry: WorkflowRegistry): WorkflowRegistry
|
|
|
777
1053
|
globals: () => registry.globals(),
|
|
778
1054
|
invokeFunction: (...args) => registry.invokeFunction(...args),
|
|
779
1055
|
variables: () => registry.variables(),
|
|
1056
|
+
agentSetupHooks: () => registry.agentSetupHooks(),
|
|
780
1057
|
};
|
|
781
1058
|
}
|
|
782
1059
|
function workflowRegistryHost(): WorkflowRegistryHost {
|
|
@@ -807,11 +1084,11 @@ export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
|
|
|
807
1084
|
name: Type.Optional(Type.String({ description: "Workflow name for inline scripts" })),
|
|
808
1085
|
description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
|
|
809
1086
|
script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
|
|
810
|
-
workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as
|
|
1087
|
+
workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as an unqualified name" })),
|
|
811
1088
|
args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
|
|
812
1089
|
foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
|
|
813
1090
|
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
|
|
814
|
-
|
|
1091
|
+
budget: Type.Optional(Type.Unknown({ description: "Optional aggregate soft and hard run budgets" })),
|
|
815
1092
|
});
|
|
816
1093
|
|
|
817
1094
|
function hasDynamicAgentRole(node: acorn.AnyNode | undefined): boolean {
|
|
@@ -830,33 +1107,42 @@ export function preflight(script: string, capabilities: PreflightCapabilities, s
|
|
|
830
1107
|
const checkedMetadata = validateWorkflowMetadata(metadata);
|
|
831
1108
|
const program = parseWorkflow(script);
|
|
832
1109
|
if (hasIdentifier(program, INTERNAL_AGENT_NAME)) fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
|
|
1110
|
+
if (hasIdentifier(program, INTERNAL_CONVERSATION_NAME)) fail("INVALID_METADATA", `${INTERNAL_CONVERSATION_NAME} is reserved for workflow conversation instrumentation`);
|
|
833
1111
|
if (hasIdentifier(program, INTERNAL_WORKTREE_NAME)) fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
|
|
834
1112
|
validateDirectPrimitiveReferences(program, "withWorktree");
|
|
1113
|
+
validateDirectPrimitiveReferences(program, "conversation");
|
|
835
1114
|
for (const [index, schema] of schemas.entries()) validateSchema(schema, `schema[${String(index)}]`);
|
|
836
1115
|
const calls = workflowCalls(program);
|
|
837
1116
|
const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase): phase is string => phase !== undefined);
|
|
838
1117
|
for (const call of calls) {
|
|
839
1118
|
const operation = call.callee.name;
|
|
840
|
-
if (operation === "agent")
|
|
1119
|
+
if (operation === "agent" || operation === "conversation") {
|
|
1120
|
+
if (operation === "conversation" && (!literalString(call.arguments[0])?.trim() || call.arguments.length > 2)) fail("INVALID_METADATA", "conversation requires a stable name and optional options object");
|
|
1121
|
+
validateStaticAgentOptions(call.arguments[1], capabilities.modelAliases ?? {}, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath);
|
|
1122
|
+
}
|
|
841
1123
|
if (operation === "withWorktree") validateStaticWithWorktree(call);
|
|
842
1124
|
if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement")) continue;
|
|
843
1125
|
if (operation === "checkpoint" && stableName(call.arguments[0]) === false) fail("INVALID_METADATA", `${operation} requires a stable explicit name`);
|
|
844
1126
|
if (operation === "parallel" && (call.arguments.length !== 2 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression")) fail("INVALID_METADATA", "parallel requires an operation name string and tasks record");
|
|
845
1127
|
if (operation === "pipeline" && (call.arguments.length !== 3 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression" || call.arguments[2]?.type !== "ObjectExpression")) fail("INVALID_METADATA", "pipeline requires an operation name string, items record, and stages record");
|
|
846
1128
|
}
|
|
847
|
-
const agentCalls = calls.filter((call) => call.callee.name === "agent");
|
|
1129
|
+
const agentCalls = calls.filter((call) => call.callee.name === "agent" || call.callee.name === "conversation");
|
|
848
1130
|
const dynamicAgentRoles = agentCalls.some((call) => hasDynamicAgentRole(call.arguments[1]));
|
|
849
1131
|
const staticSchemas = agentCalls.flatMap((call) => { const value = staticValue(propertyNode(call.arguments[1], "outputSchema")); return value.known ? [value.value] : []; });
|
|
850
1132
|
for (const [index, schema] of staticSchemas.entries()) validateSchema(schema, `agent outputSchema[${String(index)}]`);
|
|
851
1133
|
const checkedSchemas = [...schemas, ...staticSchemas];
|
|
852
|
-
const
|
|
1134
|
+
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) }]; });
|
|
1135
|
+
const models = modelRefs.map(({ resolved }) => resolved);
|
|
853
1136
|
const tools = agentCalls.flatMap((call) => {
|
|
854
1137
|
const value = propertyNode(call.arguments[1], "tools");
|
|
855
1138
|
return value?.type === "ArrayExpression" ? value.elements.flatMap((element) => { const tool = element && element.type !== "SpreadElement" ? literalString(element) : undefined; return tool === undefined ? [] : [tool]; }) : [];
|
|
856
1139
|
});
|
|
857
1140
|
const agentTypes = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "role")); return value === undefined ? [] : [value]; });
|
|
858
|
-
const missingModel =
|
|
859
|
-
if (missingModel)
|
|
1141
|
+
const missingModel = capabilities.skipModelAvailability ? undefined : modelRefs.find(({ resolved }) => !capabilities.models.has(resolved));
|
|
1142
|
+
if (missingModel) {
|
|
1143
|
+
if (Object.prototype.hasOwnProperty.call(capabilities.modelAliases ?? {}, missingModel.requested)) unknownModel(missingModel.requested, missingModel.resolved, capabilities.settingsPath);
|
|
1144
|
+
fail("UNKNOWN_MODEL", `Unknown model: ${missingModel.resolved}`);
|
|
1145
|
+
}
|
|
860
1146
|
const missingTool = tools.find((tool) => !capabilities.tools.has(tool));
|
|
861
1147
|
if (missingTool) fail("UNKNOWN_TOOL", `Unknown tool: ${missingTool}`);
|
|
862
1148
|
const missingType = agentTypes.find((type) => !capabilities.agentTypes.has(type));
|
|
@@ -876,6 +1162,9 @@ export interface WorkflowValidationContext {
|
|
|
876
1162
|
projectTrusted: boolean;
|
|
877
1163
|
availableModels: ReadonlySet<string>;
|
|
878
1164
|
rootTools: ReadonlySet<string>;
|
|
1165
|
+
modelAliases?: Readonly<Record<string, string>>;
|
|
1166
|
+
knownModels?: ReadonlySet<string>;
|
|
1167
|
+
settingsPath?: string;
|
|
879
1168
|
}
|
|
880
1169
|
|
|
881
1170
|
|
|
@@ -891,6 +1180,7 @@ export function validateWorkflowLaunch(params: WorkflowValidationParameters, con
|
|
|
891
1180
|
return validateWorkflowLaunchWithRegistry(params, context, loadingRegistry());
|
|
892
1181
|
}
|
|
893
1182
|
function validateWorkflowLaunchWithRegistry(params: WorkflowValidationParameters, context: WorkflowValidationContext, registry: WorkflowRegistryApi): ValidatedWorkflowLaunch {
|
|
1183
|
+
if (Object.prototype.hasOwnProperty.call(params, "maxAgentLaunches")) fail("INVALID_METADATA", "maxAgentLaunches has been removed; use budget.agentLaunches");
|
|
894
1184
|
if (params.script !== undefined && params.workflow !== undefined) fail("INVALID_METADATA", "Provide either script or workflow, not both");
|
|
895
1185
|
const definition = typeof params.workflow === "string" ? registry.workflow(params.workflow) : undefined;
|
|
896
1186
|
const script = typeof params.script === "string" && params.script.trim() ? params.script : definition?.script ?? "";
|
|
@@ -901,9 +1191,11 @@ function validateWorkflowLaunchWithRegistry(params: WorkflowValidationParameters
|
|
|
901
1191
|
const globalAgentDefinitions = loadAgentDefinitions(context.cwd, undefined, false);
|
|
902
1192
|
const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
|
|
903
1193
|
const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
|
|
904
|
-
const
|
|
1194
|
+
const aliases = context.modelAliases ?? {};
|
|
1195
|
+
const knownModels = context.knownModels ?? context.availableModels;
|
|
1196
|
+
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);
|
|
905
1197
|
const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
|
|
906
|
-
validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools);
|
|
1198
|
+
validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools, aliases, knownModels, context.settingsPath);
|
|
907
1199
|
return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames };
|
|
908
1200
|
}
|
|
909
1201
|
|
|
@@ -928,12 +1220,12 @@ export function loadLaunchSnapshot(input: LaunchSnapshot): Readonly<LaunchSnapsh
|
|
|
928
1220
|
export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
|
|
929
1221
|
export const HEARTBEAT_TIMEOUT_MS = 5000;
|
|
930
1222
|
|
|
931
|
-
export interface AgentIdentity { structuralPath: readonly string[]; callSite: string; occurrence: number; parentBreadcrumb?: string; worktreeOwner?: string }
|
|
1223
|
+
export interface AgentIdentity { structuralPath: readonly string[]; callSite: string; occurrence: number; parentBreadcrumb?: string; worktreeOwner?: string; conversation?: { name: string; turn: number } }
|
|
932
1224
|
export interface WorkflowBridge {
|
|
933
1225
|
agent?: (prompt: string, options: Readonly<Record<string, JsonValue>>, signal: AbortSignal, identity: AgentIdentity) => Promise<JsonValue>;
|
|
934
1226
|
checkpoint?: (input: Readonly<Record<string, JsonValue>>, signal: AbortSignal) => boolean | Promise<boolean>;
|
|
935
|
-
function?: (
|
|
936
|
-
functions?: Readonly<Record<string, {
|
|
1227
|
+
function?: (name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string, structuralPath?: readonly string[]) => Promise<JsonValue>;
|
|
1228
|
+
functions?: Readonly<Record<string, { name: string }>>;
|
|
937
1229
|
variables?: Readonly<Record<string, JsonValue>>;
|
|
938
1230
|
phase?: (name: string) => void | Promise<void>;
|
|
939
1231
|
log?: (message: string) => void | Promise<void>;
|
|
@@ -1005,6 +1297,7 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
|
|
|
1005
1297
|
const path = (...names) => names.map(encodeURIComponent).join("/");
|
|
1006
1298
|
const inheritedAgentPath = new AsyncLocalStorage();
|
|
1007
1299
|
const agentOccurrences = new Map();
|
|
1300
|
+
const conversationOccurrences = new Map();
|
|
1008
1301
|
const worktreeOwners = new AsyncLocalStorage();
|
|
1009
1302
|
const worktreeOccurrences = new Map();
|
|
1010
1303
|
const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
|
|
@@ -1028,6 +1321,44 @@ const internalWithWorktree = async (...values) => {
|
|
|
1028
1321
|
}
|
|
1029
1322
|
return await worktreeOwners.run(owner, callback);
|
|
1030
1323
|
};
|
|
1324
|
+
const internalConversation = (...values) => {
|
|
1325
|
+
const callSite = values.pop();
|
|
1326
|
+
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow conversation call-site identity");
|
|
1327
|
+
const name = values[0];
|
|
1328
|
+
if (typeof name !== "string" || !name.trim()) throw workError("INVALID_METADATA", "conversation requires a non-empty name");
|
|
1329
|
+
const conversationOptions = values.length < 2 || values[1] === undefined ? {} : values[1];
|
|
1330
|
+
if (!conversationOptions || typeof conversationOptions !== "object" || Array.isArray(conversationOptions)) throw workError("INVALID_METADATA", "conversation options must be a JSON object");
|
|
1331
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
1332
|
+
const occurrenceKey = JSON.stringify([inherited, callSite, name]);
|
|
1333
|
+
const occurrence = (conversationOccurrences.get(occurrenceKey) || 0) + 1;
|
|
1334
|
+
conversationOccurrences.set(occurrenceKey, occurrence);
|
|
1335
|
+
const fixedOptions = structuredClone(conversationOptions);
|
|
1336
|
+
const defaultTimeout = fixedOptions.timeoutMs;
|
|
1337
|
+
const defaultRetries = fixedOptions.retries;
|
|
1338
|
+
delete fixedOptions.timeoutMs;
|
|
1339
|
+
delete fixedOptions.retries;
|
|
1340
|
+
const worktreeOwner = worktreeOwners.getStore();
|
|
1341
|
+
let turn = 0;
|
|
1342
|
+
let active = false;
|
|
1343
|
+
return Object.freeze({
|
|
1344
|
+
run(prompt, turnOptions = {}) {
|
|
1345
|
+
if (typeof prompt !== "string") throw workError("INVALID_METADATA", "conversation.run prompt must be a string");
|
|
1346
|
+
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");
|
|
1347
|
+
if (active) throw workError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
|
|
1348
|
+
active = true;
|
|
1349
|
+
const turnNumber = turn + 1;
|
|
1350
|
+
const options = { ...fixedOptions, ...(defaultTimeout !== undefined && turnOptions.timeoutMs === undefined ? { timeoutMs: defaultTimeout } : {}), ...(defaultRetries !== undefined && turnOptions.retries === undefined ? { retries: defaultRetries } : {}), ...turnOptions };
|
|
1351
|
+
const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}), conversation: { name: name.trim(), turn: turnNumber } };
|
|
1352
|
+
const result = rpc("agent", [prompt, options, identity]).then(value => { const unwrapped = unwrap(value); turn = turnNumber; return unwrapped; }).finally(() => { active = false; });
|
|
1353
|
+
Object.defineProperties(result, {
|
|
1354
|
+
toJSON: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before serialization"); } },
|
|
1355
|
+
toString: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
|
|
1356
|
+
[Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow conversation result is a Promise; await it before interpolation"); } },
|
|
1357
|
+
});
|
|
1358
|
+
return result;
|
|
1359
|
+
},
|
|
1360
|
+
});
|
|
1361
|
+
};
|
|
1031
1362
|
const internalAgent = (...values) => {
|
|
1032
1363
|
const callSite = values.pop();
|
|
1033
1364
|
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
|
|
@@ -1097,16 +1428,17 @@ const checkpoint = input => rpc("checkpoint", [input]).then(unwrap);
|
|
|
1097
1428
|
const phase = name => rpc("phase", [name]);
|
|
1098
1429
|
const log = message => rpc("log", [message]);
|
|
1099
1430
|
const functionOccurrences = new Map();
|
|
1100
|
-
const functionPath =
|
|
1431
|
+
const functionPath = name => {
|
|
1101
1432
|
const inherited = inheritedAgentPath.getStore() || [];
|
|
1102
|
-
const key = JSON.stringify([inherited,
|
|
1433
|
+
const key = JSON.stringify([inherited, name]);
|
|
1103
1434
|
const occurrence = (functionOccurrences.get(key) || 0) + 1;
|
|
1104
1435
|
functionOccurrences.set(key, occurrence);
|
|
1105
|
-
return path("function", ...inherited,
|
|
1436
|
+
return path("function", ...inherited, name, String(occurrence));
|
|
1106
1437
|
};
|
|
1107
1438
|
const functions = Object.freeze(Object.fromEntries(Object.entries(config.functions || {}).map(([local, target]) => [local, (...values) => {
|
|
1108
1439
|
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");
|
|
1109
|
-
const
|
|
1440
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
1441
|
+
const result = rpc("function", [target.name, values[0], functionPath(target.name), worktreeOwners.getStore() || null, inherited]).then(unwrap);
|
|
1110
1442
|
Object.defineProperty(result, "toJSON", { value() { throw workError("INVALID_METADATA", "Workflow function result is a Promise; await it before serialization"); } });
|
|
1111
1443
|
return result;
|
|
1112
1444
|
}])));
|
|
@@ -1167,13 +1499,13 @@ const pipeline = async (operationName, items, stages) => {
|
|
|
1167
1499
|
return Object.fromEntries(results.map(result => [result.name, result.value]));
|
|
1168
1500
|
};
|
|
1169
1501
|
const safeMath = Object.fromEntries(Object.getOwnPropertyNames(Math).filter(name => name !== "random").map(name => [name, Math[name]]));
|
|
1170
|
-
const sandbox = { agent, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
|
|
1502
|
+
const sandbox = { agent, conversation: internalConversation, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
|
|
1171
1503
|
for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
|
|
1172
1504
|
for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
|
|
1173
1505
|
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;
|
|
1174
1506
|
const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
|
|
1175
1507
|
const body = config.script;
|
|
1176
|
-
Promise.resolve().then(() => new vm.Script("(async(__pi_extensible_workflows_agent,__pi_extensible_workflows_withWorktree)=>{" + body + "\n})", { filename: "workflow.js" }).runInContext(context)(internalAgent, internalWithWorktree))
|
|
1508
|
+
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))
|
|
1177
1509
|
.then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
|
|
1178
1510
|
.catch(error => send({ type: "error", error: workerError(error) }))
|
|
1179
1511
|
.finally(() => clearInterval(heartbeat));
|
|
@@ -1193,13 +1525,23 @@ function readAgentIdentity(value: unknown): AgentIdentity {
|
|
|
1193
1525
|
const occurrence = value.occurrence;
|
|
1194
1526
|
const worktreeOwner = value.worktreeOwner;
|
|
1195
1527
|
const parentBreadcrumb = value.parentBreadcrumb;
|
|
1196
|
-
|
|
1197
|
-
|
|
1528
|
+
const conversation = value.conversation;
|
|
1529
|
+
const parsedConversation = object(conversation) && typeof conversation.name === "string" && Boolean(conversation.name.trim()) && positiveInteger(conversation.turn) ? { name: conversation.name, turn: conversation.turn } : undefined;
|
|
1530
|
+
if (!Array.isArray(structuralPath) || !structuralPath.every((part): part is string => 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) fail("INTERNAL_ERROR", "Invalid workflow agent identity");
|
|
1531
|
+
return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}), ...(parsedConversation ? { conversation: parsedConversation } : {}) };
|
|
1198
1532
|
}
|
|
1199
1533
|
|
|
1200
1534
|
function agentIdentityPath(identity: AgentIdentity): string {
|
|
1201
1535
|
return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
|
|
1202
1536
|
}
|
|
1537
|
+
function conversationIdentityPath(identity: AgentIdentity): string {
|
|
1538
|
+
if (!identity.conversation) throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
|
|
1539
|
+
return operationPath("conversation", identity.conversation.name, ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
|
|
1540
|
+
}
|
|
1541
|
+
function conversationTurnPath(identity: AgentIdentity): string {
|
|
1542
|
+
if (!identity.conversation) throw new WorkflowError("INTERNAL_ERROR", "Missing conversation identity");
|
|
1543
|
+
return operationPath(conversationIdentityPath(identity), `turn:${String(identity.conversation.turn)}`);
|
|
1544
|
+
}
|
|
1203
1545
|
function agentWorktree(identity: AgentIdentity): { worktreeOwner?: string } {
|
|
1204
1546
|
return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
|
|
1205
1547
|
}
|
|
@@ -1285,11 +1627,13 @@ export function runWorkflow(script: string, args: JsonValue = null, bridge: Work
|
|
|
1285
1627
|
value = branded({ name, ok: false, failedAt: name, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
|
|
1286
1628
|
}
|
|
1287
1629
|
} else if (method === "function") {
|
|
1288
|
-
const worktreeOwner = values[
|
|
1289
|
-
|
|
1290
|
-
|
|
1630
|
+
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");
|
|
1631
|
+
const structuralPath = values[4] === undefined ? [] : values[4];
|
|
1632
|
+
if (!Array.isArray(structuralPath) || !structuralPath.every((part): part is string => typeof part === "string" && Boolean(part.trim()))) fail("INTERNAL_ERROR", "function structural scope is invalid");
|
|
1633
|
+
if (!bridge.function || typeof values[0] !== "string" || !object(values[1]) || typeof values[2] !== "string") fail("INTERNAL_ERROR", "function requires an available bridge, name, object input, and path");
|
|
1634
|
+
const name = values[0];
|
|
1291
1635
|
try {
|
|
1292
|
-
const result = await bridge.function(values[0], values[1], values[2],
|
|
1636
|
+
const result = await bridge.function(values[0], values[1], values[2], controller.signal, worktreeOwner, structuralPath);
|
|
1293
1637
|
value = branded({ name, ok: true, value: result ?? null });
|
|
1294
1638
|
} catch (error) {
|
|
1295
1639
|
const typed = asWorkflowError(error);
|
|
@@ -1324,13 +1668,14 @@ function nativeSessionReference(attempt: Pick<AgentAttempt, "sessionId" | "sessi
|
|
|
1324
1668
|
return { sessionId: attempt.sessionId, sessionFile: attempt.sessionFile };
|
|
1325
1669
|
}
|
|
1326
1670
|
|
|
1327
|
-
export async function persistActiveAgentAttempt(store: RunStore, id: string, active: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile">): Promise<void> {
|
|
1671
|
+
export async function persistActiveAgentAttempt(store: RunStore, id: string, active: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">): Promise<void> {
|
|
1328
1672
|
await store.updateState((run) => {
|
|
1329
1673
|
const agent = run.agents.find((candidate) => candidate.id === id);
|
|
1330
1674
|
if (!agent) throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
|
|
1331
1675
|
const accounting = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
1676
|
+
const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), { ...active, accounting }];
|
|
1332
1677
|
const nativeSessions = run.nativeSessions.some(({ sessionId }) => sessionId === active.sessionId) ? run.nativeSessions : [...run.nativeSessions, nativeSessionReference(active)];
|
|
1333
|
-
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: active.attempt, attemptDetails:
|
|
1678
|
+
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), nativeSessions };
|
|
1334
1679
|
});
|
|
1335
1680
|
}
|
|
1336
1681
|
|
|
@@ -1339,7 +1684,7 @@ export async function persistAgentAttempts(store: RunStore, id: string, attempts
|
|
|
1339
1684
|
const agent = run.agents.find((candidate) => candidate.id === id);
|
|
1340
1685
|
if (!agent) throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
|
|
1341
1686
|
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 });
|
|
1342
|
-
const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting }));
|
|
1687
|
+
const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting, setup }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting, ...(setup ? { setup } : {}) }));
|
|
1343
1688
|
const sessionIds = new Set(attempts.map(({ sessionId }) => sessionId));
|
|
1344
1689
|
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))] };
|
|
1345
1690
|
});
|
|
@@ -1347,19 +1692,47 @@ export async function persistAgentAttempts(store: RunStore, id: string, attempts
|
|
|
1347
1692
|
|
|
1348
1693
|
type WorkflowToolUpdate = { content: [{ type: "text"; text: string }]; details: { runId: string; run: PersistedRun } };
|
|
1349
1694
|
|
|
1695
|
+
type AgentGroup = { label: string; entries: readonly { agent: AgentRecord; index: number; depth: number }[] };
|
|
1696
|
+
function agentGroupKey(agent: AgentRecord): string { return JSON.stringify([agent.structuralPath ?? [], agent.parentBreadcrumb ?? null]); }
|
|
1697
|
+
function agentGroupLabel(agents: readonly AgentRecord[]): string {
|
|
1698
|
+
const structural = agents[0]?.structuralPath ?? [];
|
|
1699
|
+
const breadcrumbs = [...new Set(agents.map((agent) => agent.parentBreadcrumb).filter((value): value is string => Boolean(value)))];
|
|
1700
|
+
return [...(structural.length ? [structural.join(" > ")] : []), ...(breadcrumbs.length === 1 ? breadcrumbs : breadcrumbs.length ? [breadcrumbs.join(" | ")] : [])].join(" > ") || "Agents";
|
|
1701
|
+
}
|
|
1702
|
+
function agentGroups(agents: readonly AgentRecord[]): AgentGroup[] {
|
|
1703
|
+
const byId = new Map(agents.map((agent) => [agent.id, agent]));
|
|
1704
|
+
const groups = new Map<string, { agents: Array<{ agent: AgentRecord; index: number; depth: number }> }>();
|
|
1705
|
+
for (const [index, agent] of agents.entries()) {
|
|
1706
|
+
let depth = 0;
|
|
1707
|
+
for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) depth += 1;
|
|
1708
|
+
const key = agentGroupKey(agent);
|
|
1709
|
+
const group = groups.get(key) ?? { agents: [] };
|
|
1710
|
+
group.agents.push({ agent, index, depth });
|
|
1711
|
+
groups.set(key, group);
|
|
1712
|
+
}
|
|
1713
|
+
return [...groups].map(([, group]) => ({ label: agentGroupLabel(group.agents.map(({ agent }) => agent)), entries: group.agents }));
|
|
1714
|
+
}
|
|
1715
|
+
function renderGroupedAgents(agents: readonly AgentRecord[], render: (entry: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => string): string[] {
|
|
1716
|
+
const groups = agentGroups(agents);
|
|
1717
|
+
const grouped = groups.length > 1 || groups.some(({ label }) => label !== "Agents");
|
|
1718
|
+
return groups.flatMap((group) => [
|
|
1719
|
+
...(grouped ? [` ${group.label}`] : []),
|
|
1720
|
+
...group.entries.map((entry) => render(entry, grouped)),
|
|
1721
|
+
]);
|
|
1722
|
+
}
|
|
1350
1723
|
export function formatWorkflowProgress(run: PersistedRun, spinner = "◇"): string {
|
|
1351
1724
|
const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
|
|
1352
|
-
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)`];
|
|
1725
|
+
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)`];
|
|
1353
1726
|
if (run.phase) lines.push(` Phase: ${run.phase}`);
|
|
1727
|
+
lines.push(...formatCompactBudgetStatus(run).map((line) => ` ${line}`));
|
|
1354
1728
|
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
1355
|
-
|
|
1356
|
-
let depth = 0;
|
|
1357
|
-
for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId) depth += 1;
|
|
1729
|
+
lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
|
|
1358
1730
|
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
|
|
1359
|
-
const indent = " ".repeat(
|
|
1731
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
1360
1732
|
const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner);
|
|
1361
|
-
|
|
1362
|
-
|
|
1733
|
+
const name = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
|
|
1734
|
+
return `${indent}#${String(index + 1)} ${icon} ${name} [${agent.state}]${activity ? ` ${activity}` : ""}`;
|
|
1735
|
+
}));
|
|
1363
1736
|
return lines.join("\n");
|
|
1364
1737
|
}
|
|
1365
1738
|
|
|
@@ -1387,8 +1760,29 @@ function workflowProgressBlock(run: PersistedRun) {
|
|
|
1387
1760
|
invalidate() {},
|
|
1388
1761
|
};
|
|
1389
1762
|
}
|
|
1763
|
+
export function formatBudgetStatus(run: Pick<PersistedRun, "budget" | "budgetVersion" | "usage" | "budgetEvents">): string[] {
|
|
1764
|
+
const usage = budgetUsage(run.usage);
|
|
1765
|
+
if (!run.budget || !Object.keys(run.budget).length) return ["Budget: unlimited"];
|
|
1766
|
+
const lines = [`Budget version ${String(run.budgetVersion ?? 1)}`];
|
|
1767
|
+
for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) {
|
|
1768
|
+
const limits = run.budget[dimension];
|
|
1769
|
+
if (!limits || (limits.soft === undefined && limits.hard === undefined)) continue;
|
|
1770
|
+
const limit = limits.hard ?? limits.soft;
|
|
1771
|
+
const percent = limit === undefined ? "" : ` ${limit === 0 ? "100.0" : ((usage[dimension] / limit) * 100).toFixed(1)}%`;
|
|
1772
|
+
const state = (run.budgetEvents ?? []).filter((event) => event.dimensions.includes(dimension)).at(-1)?.type;
|
|
1773
|
+
lines.push(` ${dimension}: ${String(usage[dimension])}${limits.soft !== undefined ? ` soft=${String(limits.soft)}` : ""}${limits.hard !== undefined ? ` hard=${String(limits.hard)}` : ""}${percent}${state ? ` state=${state}` : ""}`);
|
|
1774
|
+
}
|
|
1775
|
+
const events = run.budgetEvents ?? [];
|
|
1776
|
+
if (events.length) lines.push(` events: ${events.map((event) => `${event.type}@v${String(event.budgetVersion)}`).join(", ")}`);
|
|
1777
|
+
return lines;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function formatCompactBudgetStatus(run: Pick<PersistedRun, "budget" | "budgetVersion" | "usage" | "budgetEvents">): string[] {
|
|
1781
|
+
if (!Object.values(run.budget ?? {}).some((limits) => limits.soft !== undefined || limits.hard !== undefined)) return [];
|
|
1782
|
+
return formatBudgetStatus(run);
|
|
1783
|
+
}
|
|
1390
1784
|
|
|
1391
|
-
const ATTENTION_ORDER: Record<string, number> = { awaiting_input: 0,
|
|
1785
|
+
const ATTENTION_ORDER: Record<string, number> = { awaiting_input: 0, budget_exhausted: 1, running: 2, pausing: 3, paused: 4, interrupted: 5, failed: 6, queued: 7, stopped: 8, completed: 9 };
|
|
1392
1786
|
|
|
1393
1787
|
function navigatorAttentionSort<T extends { loaded: { run: PersistedRun } }>(entries: readonly T[]): T[] {
|
|
1394
1788
|
return [...entries].sort((a, b) => (ATTENTION_ORDER[a.loaded.run.state] ?? 9) - (ATTENTION_ORDER[b.loaded.run.state] ?? 9));
|
|
@@ -1399,7 +1793,7 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
|
|
|
1399
1793
|
for (const { loaded: { run } } of entries) nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
|
|
1400
1794
|
return entries.map(({ store, loaded: { run } }) => {
|
|
1401
1795
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
1402
|
-
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1796
|
+
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1403
1797
|
const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
|
|
1404
1798
|
const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
|
|
1405
1799
|
const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
|
|
@@ -1409,15 +1803,16 @@ function navigatorRunLabels(entries: readonly { store: RunStore; loaded: { run:
|
|
|
1409
1803
|
|
|
1410
1804
|
function agentBreadcrumb(agent: AgentRecord, byId: Map<string, AgentRecord>): string {
|
|
1411
1805
|
const name = agent.label ?? agent.name;
|
|
1412
|
-
const parts: string[] = [
|
|
1806
|
+
const parts: string[] = agent.parentBreadcrumb ? [agent.parentBreadcrumb] : [];
|
|
1413
1807
|
const seen = new Set<string>([agent.id]);
|
|
1414
1808
|
for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
|
|
1415
1809
|
if (seen.has(parentId)) break; // ponytail: cycle guard for corrupt data
|
|
1416
1810
|
seen.add(parentId);
|
|
1417
1811
|
const parent = byId.get(parentId);
|
|
1418
|
-
if (parent) parts.
|
|
1812
|
+
if (parent) parts.push(parent.label ?? parent.name);
|
|
1419
1813
|
else break;
|
|
1420
1814
|
}
|
|
1815
|
+
parts.push(name);
|
|
1421
1816
|
return parts.length > 1 ? parts.join(" > ") : name;
|
|
1422
1817
|
}
|
|
1423
1818
|
|
|
@@ -1434,44 +1829,44 @@ function formatAccounting(accounting: NonNullable<AgentRecord["accounting"]>): s
|
|
|
1434
1829
|
return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
|
|
1435
1830
|
}
|
|
1436
1831
|
|
|
1832
|
+
|
|
1437
1833
|
export function formatNavigatorDashboard(run: PersistedRun, checkpoints: readonly AwaitingCheckpoint[], worktrees: readonly WorktreeReference[]): string {
|
|
1834
|
+
void worktrees;
|
|
1438
1835
|
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
1439
1836
|
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 });
|
|
1440
1837
|
const hasAccounting = run.agents.some((a) => a.accounting);
|
|
1441
|
-
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1838
|
+
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "budget_exhausted" ? "!" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1442
1839
|
const header = `${glyph} ${run.workflowName}`;
|
|
1443
1840
|
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(" · ");
|
|
1444
|
-
const lines = [header, meta];
|
|
1841
|
+
const lines = [header, meta, ...formatCompactBudgetStatus(run)];
|
|
1445
1842
|
if (run.error) lines.push(`Error: ${run.error.code}: ${run.error.message}`);
|
|
1843
|
+
if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
|
|
1446
1844
|
lines.push("");
|
|
1447
1845
|
const byId = new Map(run.agents.map((a) => [a.id, a]));
|
|
1448
|
-
const
|
|
1449
|
-
const prioritised = [...run.agents].sort((a, b) => {
|
|
1450
|
-
const aActive = activeStates.has(a.state) || a.state === "failed" ? 0 : 1;
|
|
1451
|
-
const bActive = activeStates.has(b.state) || b.state === "failed" ? 0 : 1;
|
|
1452
|
-
return aActive - bActive;
|
|
1453
|
-
});
|
|
1454
|
-
for (const agent of prioritised) {
|
|
1846
|
+
const render = ({ agent, depth }: { agent: AgentRecord; index: number; depth: number }, grouped: boolean) => {
|
|
1455
1847
|
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
|
|
1456
|
-
const breadcrumb = agentBreadcrumb(agent, byId);
|
|
1457
|
-
const
|
|
1458
|
-
const
|
|
1848
|
+
const breadcrumb = grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId);
|
|
1849
|
+
const setup = agent.attemptDetails?.at(-1)?.setup;
|
|
1850
|
+
const thinking = setup?.model.thinking ?? agent.model.thinking;
|
|
1851
|
+
const model = `${setup?.model.provider ?? agent.model.provider}/${setup?.model.model ?? agent.model.model}${thinking ? `:${thinking}` : ""}`;
|
|
1852
|
+
const policy = [`model=${model}`, agent.requestedModel ? `requested=${agent.requestedModel}` : "", `tools=${(setup?.tools ?? agent.tools).join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
|
|
1459
1853
|
const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1854
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
1855
|
+
const result = [`${indent}${icon} ${breadcrumb} · ${agent.state} · ${policy.filter(Boolean).join(" · ")}${tokens ? ` · ${tokens}` : ""}`];
|
|
1462
1856
|
if (agent.state === "failed" && agent.attemptDetails?.length) {
|
|
1463
1857
|
const last = agent.attemptDetails[agent.attemptDetails.length - 1];
|
|
1464
|
-
if (last?.error)
|
|
1858
|
+
if (last?.error) result.push(`${indent} error: ${last.error.code}: ${last.error.message}`);
|
|
1465
1859
|
}
|
|
1466
1860
|
const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
|
|
1467
|
-
if (activity)
|
|
1468
|
-
|
|
1861
|
+
if (activity) result.push(`${indent} ${activity}`);
|
|
1862
|
+
return result.join("\n");
|
|
1863
|
+
};
|
|
1864
|
+
lines.push(...renderGroupedAgents(run.agents, render));
|
|
1469
1865
|
if (checkpoints.length) { lines.push(""); for (const cp of checkpoints) lines.push(`● checkpoint ${cp.name}: ${cp.prompt}`); }
|
|
1470
|
-
if (worktrees.length) { lines.push(""); for (const wt of worktrees) lines.push(`branch ${wt.branch} (${wt.path})`); }
|
|
1471
1866
|
return lines.join("\n");
|
|
1472
1867
|
}
|
|
1473
1868
|
|
|
1474
|
-
export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[],
|
|
1869
|
+
export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }, checkpoints: readonly AwaitingCheckpoint[], _worktrees: readonly WorktreeReference[]): string {
|
|
1475
1870
|
const { run, snapshot } = loaded;
|
|
1476
1871
|
const lines = [
|
|
1477
1872
|
`Workflow: ${run.workflowName}`,
|
|
@@ -1479,29 +1874,32 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
|
|
|
1479
1874
|
`Status: ${run.state}`,
|
|
1480
1875
|
`Phase: ${run.phase ?? "(none)"}`,
|
|
1481
1876
|
`Launch cwd: ${run.cwd}`,
|
|
1877
|
+
...formatCompactBudgetStatus(run),
|
|
1482
1878
|
`Launch models: ${snapshot.models.join(", ") || "(none)"}`,
|
|
1483
1879
|
];
|
|
1484
1880
|
if (run.error) lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
|
|
1881
|
+
if (run.events?.length) lines.push(...run.events.map((event) => `Warning: ${event.message}`));
|
|
1882
|
+
const aliases = snapshot.modelAliases ?? snapshot.settings.modelAliases;
|
|
1883
|
+
if (aliases && Object.keys(aliases).length) lines.push(`Model aliases: ${Object.entries(aliases).map(([name, target]) => `${name}=${target}`).join(", ")}`);
|
|
1485
1884
|
lines.push("Agents / ownership:");
|
|
1486
1885
|
if (!run.agents.length) lines.push(" (none)");
|
|
1487
|
-
|
|
1886
|
+
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
1887
|
+
lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
|
|
1488
1888
|
const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
|
|
1489
1889
|
const role = agent.role ? ` role=${agent.role}` : "";
|
|
1490
1890
|
const tools = ` tools=${agent.tools.join(",") || "(none)"}`;
|
|
1491
1891
|
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)}` : "";
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
for (const
|
|
1495
|
-
}
|
|
1892
|
+
const indent = " ".repeat((grouped ? 2 : 1) + depth);
|
|
1893
|
+
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}`];
|
|
1894
|
+
for (const attempt of agent.attemptDetails ?? []) result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
|
|
1895
|
+
for (const call of agent.toolCalls ?? []) result.push(`${indent} tool ${call.name} state=${call.state}`);
|
|
1896
|
+
return result.join("\n");
|
|
1897
|
+
}));
|
|
1496
1898
|
lines.push("Checkpoints:");
|
|
1497
1899
|
if (!checkpoints.length) lines.push(" (none)");
|
|
1498
1900
|
for (const checkpoint of checkpoints) lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
|
|
1499
|
-
lines.push(
|
|
1500
|
-
|
|
1501
|
-
for (const worktree of worktrees) lines.push(` ${worktree.owner}: branch=${worktree.branch} path=${worktree.path} cwd=${worktree.cwd}`);
|
|
1502
|
-
lines.push("Native Pi transcript paths:");
|
|
1503
|
-
if (!run.nativeSessions.length) lines.push(" (none)");
|
|
1504
|
-
for (const session of run.nativeSessions) lines.push(` ${session.sessionId}: ${session.sessionFile}`);
|
|
1901
|
+
lines.push(`Worktrees: ${String(_worktrees.length)}`);
|
|
1902
|
+
lines.push(`Native Pi transcripts: ${String(run.nativeSessions.length)}`);
|
|
1505
1903
|
return lines.join("\n");
|
|
1506
1904
|
}
|
|
1507
1905
|
function formatCheckpointReview(checkpoint: AwaitingCheckpoint): string {
|
|
@@ -1535,6 +1933,90 @@ function deliver(pi: ExtensionAPI, content: string): void {
|
|
|
1535
1933
|
function deliverFailure(pi: ExtensionAPI, name: string, error: unknown): void {
|
|
1536
1934
|
deliver(pi, `Workflow ${name} failed: ${formatWorkflowFailure(error)}`);
|
|
1537
1935
|
}
|
|
1936
|
+
|
|
1937
|
+
type WorkflowEventSink = { emit: (name: string, payload: unknown) => unknown };
|
|
1938
|
+
|
|
1939
|
+
function safeEventError(error: unknown): WorkflowErrorShape {
|
|
1940
|
+
const code = errorCode(error) ?? "INTERNAL_ERROR";
|
|
1941
|
+
return { code, message: `Workflow execution failed (${code})` };
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
class WorkflowEventPublisher {
|
|
1945
|
+
#queues = new Map<string, Promise<void>>();
|
|
1946
|
+
#budgetEvents = new Map<string, Set<string>>();
|
|
1947
|
+
#worktrees = new Map<string, Set<string>>();
|
|
1948
|
+
|
|
1949
|
+
constructor(private readonly sink: WorkflowEventSink | undefined) {}
|
|
1950
|
+
|
|
1951
|
+
seedBudget(runId: string, events: readonly BudgetEvent[] | undefined): void {
|
|
1952
|
+
const seen = this.#budgetEvents.get(runId) ?? new Set<string>();
|
|
1953
|
+
for (const event of events ?? []) seen.add(this.budgetKey(event));
|
|
1954
|
+
this.#budgetEvents.set(runId, seen);
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
async runStarted(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {}); }
|
|
1958
|
+
async runResumed(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_RESUMED_EVENT, {}); }
|
|
1959
|
+
|
|
1960
|
+
async runState(store: RunStore, metadata: WorkflowMetadata, previousState: RunState, state: RunState, reason?: string): Promise<void> {
|
|
1961
|
+
await this.#publish(store, metadata, WORKFLOW_RUN_STATE_CHANGED_EVENT, { previousState, state, ...(reason ? { reason } : {}), ...(ERROR_CODES.includes(reason as WorkflowErrorCode) ? { errorCode: reason } : {}) });
|
|
1962
|
+
if ((previousState === "paused" || previousState === "interrupted" || previousState === "budget_exhausted") && state === "running") await this.runResumed(store, metadata);
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
async runCompleted(store: RunStore, metadata: WorkflowMetadata, resultPath: string): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath }); }
|
|
1966
|
+
async runFailed(store: RunStore, metadata: WorkflowMetadata, error: unknown, state: "failed" | "stopped" | "interrupted" | "budget_exhausted"): Promise<void> {
|
|
1967
|
+
if (state === "failed") await this.#publish(store, metadata, WORKFLOW_RUN_FAILED_EVENT, { error: safeEventError(error) });
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
async agentState(store: RunStore, metadata: WorkflowMetadata, previous: AgentRecord | undefined, agent: AgentRecord): Promise<void> {
|
|
1971
|
+
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 });
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
async agentStates(store: RunStore, metadata: WorkflowMetadata, previous: readonly AgentRecord[], current: readonly AgentRecord[]): Promise<void> {
|
|
1975
|
+
const previousById = new Map(previous.map((agent) => [agent.id, agent]));
|
|
1976
|
+
for (const agent of current) {
|
|
1977
|
+
const old = previousById.get(agent.id);
|
|
1978
|
+
if (!old || old.state !== agent.state || old.attempts !== agent.attempts) await this.agentState(store, metadata, old, agent);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
async phase(store: RunStore, metadata: WorkflowMetadata, previousPhase: string | undefined, phase: string): Promise<void> {
|
|
1983
|
+
if (previousPhase !== phase) await this.#publish(store, metadata, WORKFLOW_PHASE_CHANGED_EVENT, { ...(previousPhase !== undefined ? { previousPhase } : {}), phase });
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
async checkpoint(store: RunStore, metadata: WorkflowMetadata, name: string, state: WorkflowCheckpointState): Promise<void> { await this.#publish(store, metadata, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, { name, state }); }
|
|
1987
|
+
|
|
1988
|
+
async budget(store: RunStore, metadata: WorkflowMetadata, run: Pick<PersistedRun, "budgetEvents">): Promise<void> {
|
|
1989
|
+
const seen = this.#budgetEvents.get(store.runId) ?? new Set<string>();
|
|
1990
|
+
this.#budgetEvents.set(store.runId, seen);
|
|
1991
|
+
for (const event of run.budgetEvents ?? []) {
|
|
1992
|
+
const key = this.budgetKey(event);
|
|
1993
|
+
if (seen.has(key)) continue;
|
|
1994
|
+
seen.add(key);
|
|
1995
|
+
await this.#publish(store, metadata, WORKFLOW_BUDGET_EVENT, { ...event, timestamp: event.at });
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
async worktree(store: RunStore, metadata: WorkflowMetadata, worktree: WorktreeReference): Promise<void> {
|
|
2000
|
+
const seen = this.#worktrees.get(store.runId) ?? new Set<string>();
|
|
2001
|
+
this.#worktrees.set(store.runId, seen);
|
|
2002
|
+
if (seen.has(worktree.owner)) return;
|
|
2003
|
+
seen.add(worktree.owner);
|
|
2004
|
+
await this.#publish(store, metadata, WORKFLOW_WORKTREE_CREATED_EVENT, { owner: worktree.owner, branch: worktree.branch, path: worktree.path, base: worktree.base });
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
async #publish(store: RunStore, metadata: WorkflowMetadata, name: string, payload: Record<string, unknown>): Promise<void> {
|
|
2008
|
+
const base: WorkflowEventBase = { runId: store.runId, sessionId: store.sessionId, workflowName: metadata.name, cwd: store.cwd, runDirectory: store.directory, timestamp: Date.now() };
|
|
2009
|
+
const previous = this.#queues.get(store.runId) ?? Promise.resolve();
|
|
2010
|
+
const next = previous.then(() => {
|
|
2011
|
+
try { void Promise.resolve(this.sink?.emit(name, { ...base, ...payload })).catch(() => undefined); } catch { /* Best effort: listeners cannot affect a run. */ }
|
|
2012
|
+
});
|
|
2013
|
+
this.#queues.set(store.runId, next.catch(() => undefined));
|
|
2014
|
+
await next;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
private budgetKey(event: BudgetEvent): string { return `${String(event.budgetVersion)}:${event.type}:${event.proposalId ?? ""}`; }
|
|
2018
|
+
}
|
|
2019
|
+
|
|
1538
2020
|
const inheritedHostAgentPath = new AsyncLocalStorage<readonly string[]>();
|
|
1539
2021
|
const inheritedHostWorktreeOwner = new AsyncLocalStorage<string>();
|
|
1540
2022
|
|
|
@@ -1566,13 +2048,13 @@ function workflowRunContext(cwd: string, sessionId: string, runId: string, workf
|
|
|
1566
2048
|
|
|
1567
2049
|
async function resolveWorkflowVariables(run: Readonly<WorkflowRunContext>, controller: AbortController, registry: WorkflowRegistryApi): Promise<Readonly<Record<string, JsonValue>>> {
|
|
1568
2050
|
let first: WorkflowError | undefined;
|
|
1569
|
-
const tasks = registry.variables().map(async ({
|
|
2051
|
+
const tasks = registry.variables().map(async ({ name, variable }) => {
|
|
1570
2052
|
try {
|
|
1571
2053
|
const result: unknown = await variable.resolve(run);
|
|
1572
|
-
if (!jsonValue(result) || !Value.Check(variable.schema, result)) fail("RESULT_INVALID", `Invalid output from ${
|
|
2054
|
+
if (!jsonValue(result) || !Value.Check(variable.schema, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
|
|
1573
2055
|
return [name, deepFreeze(structuredClone(result))] as const;
|
|
1574
2056
|
} catch (error) {
|
|
1575
|
-
const typed = errorCode(error) ? new WorkflowError(errorCode(error) as WorkflowErrorCode, `${
|
|
2057
|
+
const typed = errorCode(error) ? new WorkflowError(errorCode(error) as WorkflowErrorCode, `${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${name}: ${errorText(error)}`);
|
|
1576
2058
|
if (!first) { first = typed; controller.abort(); }
|
|
1577
2059
|
throw typed;
|
|
1578
2060
|
}
|
|
@@ -1639,25 +2121,35 @@ function nextNamedOccurrence(counters: Map<string, number>, label: string): stri
|
|
|
1639
2121
|
return count === 1 ? label : `${label}#${String(count)}`;
|
|
1640
2122
|
}
|
|
1641
2123
|
|
|
1642
|
-
|
|
1643
2124
|
function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runContext: Readonly<WorkflowRunContext>, variables: Readonly<Record<string, JsonValue>>, registry: WorkflowRegistryApi): WorkflowBridge {
|
|
1644
2125
|
const functionAgentOccurrences = new Map<string, number>();
|
|
1645
2126
|
const functionWorktreeOccurrences = new Map<string, number>();
|
|
1646
|
-
|
|
2127
|
+
const functionInvokeOccurrences = new Map<string, number>();
|
|
2128
|
+
const invokeFunction = async (name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string, structuralPath: readonly string[] = [], breadcrumb?: string): Promise<JsonValue> => {
|
|
1647
2129
|
const replayed = await store.replay(path);
|
|
1648
2130
|
let stored: JsonValue | undefined;
|
|
1649
2131
|
const sideEffects: Promise<void>[] = [];
|
|
2132
|
+
const functionBreadcrumb = breadcrumb ?? name;
|
|
1650
2133
|
const context: WorkflowFunctionContext = {
|
|
1651
2134
|
run: runContext,
|
|
2135
|
+
invoke: async (targetName, targetInput) => {
|
|
2136
|
+
const inherited = inheritedHostAgentPath.getStore() ?? structuralPath;
|
|
2137
|
+
const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
|
|
2138
|
+
const key = JSON.stringify([path, inherited, targetName]);
|
|
2139
|
+
const occurrence = (functionInvokeOccurrences.get(key) ?? 0) + 1;
|
|
2140
|
+
functionInvokeOccurrences.set(key, occurrence);
|
|
2141
|
+
const nestedPath = operationPath("function", "nested", path, ...inherited, targetName, `occurrence:${String(occurrence)}`);
|
|
2142
|
+
return invokeFunction(targetName, targetInput, nestedPath, signal, scopedWorktreeOwner, inherited, `${functionBreadcrumb} > ${targetName}`);
|
|
2143
|
+
},
|
|
1652
2144
|
agent: async (...args: readonly unknown[]) => {
|
|
1653
2145
|
if (!bridge.agent || typeof args[0] !== "string") fail("AGENT_FAILED", "No agent bridge is available");
|
|
1654
2146
|
const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
|
|
1655
2147
|
const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
|
|
1656
|
-
const
|
|
1657
|
-
const key = `${path}\0${JSON.stringify(
|
|
2148
|
+
const inherited = inheritedHostAgentPath.getStore() ?? [];
|
|
2149
|
+
const key = `${path}\0${JSON.stringify(inherited)}`;
|
|
1658
2150
|
const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
|
|
1659
2151
|
functionAgentOccurrences.set(key, occurrence);
|
|
1660
|
-
return bridge.agent(args[0], options, signal, { structuralPath: [...
|
|
2152
|
+
return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: functionBreadcrumb, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
|
|
1661
2153
|
},
|
|
1662
2154
|
prompt: workflowPrompt,
|
|
1663
2155
|
parallel: (...args: readonly unknown[]) => hostParallel(args[0], args[1]),
|
|
@@ -1670,18 +2162,66 @@ function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runConte
|
|
|
1670
2162
|
phase: (name: string) => { sideEffects.push(Promise.resolve(bridge.phase?.(name))); },
|
|
1671
2163
|
log: (message: string) => { sideEffects.push(Promise.resolve(bridge.log?.(message))); },
|
|
1672
2164
|
};
|
|
1673
|
-
const result = await registry.invokeFunction(
|
|
2165
|
+
const result = await inheritedHostAgentPath.run([...structuralPath], async () => registry.invokeFunction(name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } }));
|
|
1674
2166
|
await Promise.all(sideEffects);
|
|
1675
2167
|
if (!replayed) await store.complete(path, stored ?? result);
|
|
1676
2168
|
return result;
|
|
1677
|
-
}
|
|
2169
|
+
};
|
|
2170
|
+
return { ...bridge, functions: registry.globals(), variables, function: invokeFunction };
|
|
1678
2171
|
}
|
|
1679
2172
|
|
|
1680
2173
|
function projectTrusted(ctx: unknown): boolean {
|
|
1681
2174
|
const check = object(ctx) ? ctx.isProjectTrusted : undefined;
|
|
1682
2175
|
return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
|
|
1683
2176
|
}
|
|
1684
|
-
|
|
2177
|
+
type PiHostCapabilities = { registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"]; events?: WorkflowEventSink };
|
|
2178
|
+
function isEntryRenderer(value: unknown): value is NonNullable<PiHostCapabilities["registerEntryRenderer"]> { return typeof value === "function"; }
|
|
2179
|
+
function isWorkflowEventSink(value: unknown): value is WorkflowEventSink { return object(value) && typeof value.emit === "function"; }
|
|
2180
|
+
function piHostCapabilities(pi: unknown): PiHostCapabilities {
|
|
2181
|
+
if (!object(pi)) return {};
|
|
2182
|
+
const registerEntryRenderer = pi.registerEntryRenderer;
|
|
2183
|
+
const events = pi.events;
|
|
2184
|
+
return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
|
|
2185
|
+
}
|
|
2186
|
+
type ContextHostCapabilities = { modelRegistry?: ModelRegistryCapability };
|
|
2187
|
+
type ModelSummary = { provider: string; id: string };
|
|
2188
|
+
type ModelRegistryGetter = () => readonly ModelSummary[];
|
|
2189
|
+
type ModelRegistryCapability = { getAll?: ModelRegistryGetter; getAvailable?: ModelRegistryGetter };
|
|
2190
|
+
function isModelRegistryGetter(value: unknown): value is ModelRegistryGetter { return typeof value === "function"; }
|
|
2191
|
+
function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
|
|
2192
|
+
if (!object(ctx) || !object(ctx.modelRegistry)) return {};
|
|
2193
|
+
const registry = ctx.modelRegistry;
|
|
2194
|
+
const getAll = registry.getAll;
|
|
2195
|
+
const getAvailable = registry.getAvailable;
|
|
2196
|
+
return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
|
|
2197
|
+
}
|
|
2198
|
+
type UiSelect = (title: string, options: string[]) => Promise<string | undefined>;
|
|
2199
|
+
type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
|
|
2200
|
+
type UiSetStatus = (key: string, text?: string) => void;
|
|
2201
|
+
type UiHostCapabilities = { select?: UiSelect; input?: UiInput; setStatus?: UiSetStatus };
|
|
2202
|
+
function isUiSelect(value: unknown): value is UiSelect { return typeof value === "function"; }
|
|
2203
|
+
function isUiInput(value: unknown): value is UiInput { return typeof value === "function"; }
|
|
2204
|
+
function isUiSetStatus(value: unknown): value is UiSetStatus { return typeof value === "function"; }
|
|
2205
|
+
function uiHostCapabilities(ui: unknown): UiHostCapabilities | undefined {
|
|
2206
|
+
if (!object(ui)) return undefined;
|
|
2207
|
+
const select = ui.select;
|
|
2208
|
+
const input = ui.input;
|
|
2209
|
+
const setStatus = ui.setStatus;
|
|
2210
|
+
return { ...(isUiSelect(select) ? { select } : {}), ...(isUiInput(input) ? { input } : {}), ...(isUiSetStatus(setStatus) ? { setStatus } : {}) };
|
|
2211
|
+
}
|
|
2212
|
+
type TuiHostCapabilities = { terminal?: { rows?: unknown } };
|
|
2213
|
+
function tuiHostCapabilities(tui: unknown): TuiHostCapabilities {
|
|
2214
|
+
if (!object(tui) || !object(tui.terminal)) return {};
|
|
2215
|
+
return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
|
|
2216
|
+
}
|
|
2217
|
+
function tuiRows(tui: unknown): number { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
|
|
2218
|
+
type KeybindingsHostCapabilities = { getKeys?: (name: string) => readonly string[] };
|
|
2219
|
+
function isKeybindingGetter(value: unknown): value is NonNullable<KeybindingsHostCapabilities["getKeys"]> { return typeof value === "function"; }
|
|
2220
|
+
function keybindingsHostCapabilities(keybindings: unknown): KeybindingsHostCapabilities {
|
|
2221
|
+
if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys)) return {};
|
|
2222
|
+
return { getKeys: keybindings.getKeys };
|
|
2223
|
+
}
|
|
2224
|
+
function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
|
|
1685
2225
|
function parseThinking(value: unknown): ModelSpec["thinking"] | undefined {
|
|
1686
2226
|
switch (value) {
|
|
1687
2227
|
case "off": case "minimal": case "low": case "medium": case "high": case "xhigh": case "max": return value;
|
|
@@ -1692,7 +2232,7 @@ function parseThinking(value: unknown): ModelSpec["thinking"] | undefined {
|
|
|
1692
2232
|
export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession) {
|
|
1693
2233
|
beginWorkflowExtensionLoading();
|
|
1694
2234
|
const registry = loadingRegistry();
|
|
1695
|
-
const registerEntryRenderer = (pi
|
|
2235
|
+
const registerEntryRenderer = piHostCapabilities(pi).registerEntryRenderer;
|
|
1696
2236
|
registerEntryRenderer?.<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, (entry) => {
|
|
1697
2237
|
const data = entry.data;
|
|
1698
2238
|
return textBlock(data ? `Workflow ${data.workflowName}: ${data.message}` : "");
|
|
@@ -1702,20 +2242,35 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1702
2242
|
try { pi.appendEntry<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, { workflowName, message: utf8Prefix(message, DELIVERY_LIMIT_BYTES) }); }
|
|
1703
2243
|
finally { await lifecycle.leave(); }
|
|
1704
2244
|
};
|
|
1705
|
-
const
|
|
2245
|
+
const eventPublisher = new WorkflowEventPublisher(piHostCapabilities(pi).events);
|
|
1706
2246
|
pi.on("resources_discover", () => {
|
|
1707
2247
|
if (!pi.getActiveTools().includes("workflow")) return;
|
|
1708
2248
|
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
1709
2249
|
const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
|
|
1710
2250
|
return skillPath ? { skillPaths: [skillPath] } : undefined;
|
|
1711
2251
|
});
|
|
1712
|
-
|
|
1713
|
-
|
|
2252
|
+
type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
|
|
2253
|
+
const runs = new Map<string, { executor: WorkflowAgentExecutor; store: RunStore; metadata: WorkflowMetadata; model: ModelSpec; lifecycle: RunLifecycle; budget: WorkflowBudgetRuntime; abortController: AbortController; projectTrusted: () => boolean; execution?: WorkflowExecution; completion?: Promise<unknown>; checkpointResolvers: Map<string, (value: boolean) => void>; budgetResolvers: Map<string, (result: BudgetDecisionResult) => void>; update?: (result: WorkflowToolUpdate) => void }>();
|
|
2254
|
+
const liveActivities = new Map<string, Map<string, AgentActivity>>();
|
|
2255
|
+
const setLiveActivity = (runId: string, agentId: string, activity?: AgentActivity) => {
|
|
2256
|
+
const activities = liveActivities.get(runId);
|
|
2257
|
+
if (activity) {
|
|
2258
|
+
if (activities) activities.set(agentId, activity);
|
|
2259
|
+
else liveActivities.set(runId, new Map([[agentId, activity]]));
|
|
2260
|
+
} else {
|
|
2261
|
+
activities?.delete(agentId);
|
|
2262
|
+
if (activities?.size === 0) liveActivities.delete(runId);
|
|
2263
|
+
}
|
|
1714
2264
|
};
|
|
1715
|
-
const
|
|
1716
|
-
|
|
2265
|
+
const withLiveActivities = (run: PersistedRun): PersistedRun => {
|
|
2266
|
+
const activities = liveActivities.get(run.id);
|
|
2267
|
+
return activities?.size ? { ...run, agents: run.agents.map((agent) => {
|
|
2268
|
+
const activity = activities.get(agent.id);
|
|
2269
|
+
return activity ? { ...agent, activity } : agent;
|
|
2270
|
+
}) } : run;
|
|
1717
2271
|
};
|
|
1718
|
-
const
|
|
2272
|
+
const conversationLocks = new Set<string>();
|
|
2273
|
+
const terminalRunStates = new Map<string, "completed" | "failed" | "stopped">();
|
|
1719
2274
|
let sessionLease: SessionLease | undefined;
|
|
1720
2275
|
let sessionLeasePromise: Promise<SessionLease> | undefined;
|
|
1721
2276
|
const ensureSessionLease = async (cwd: string, sessionId: string) => {
|
|
@@ -1730,121 +2285,211 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1730
2285
|
sessionLeasePromise = undefined;
|
|
1731
2286
|
await lease?.release();
|
|
1732
2287
|
};
|
|
1733
|
-
const
|
|
1734
|
-
const
|
|
1735
|
-
|
|
2288
|
+
const persistRunState = async (store: RunStore, metadata: WorkflowMetadata, update: (run: PersistedRun) => PersistedRun | Promise<PersistedRun>): Promise<PersistedRun> => {
|
|
2289
|
+
const persisted = await store.updateState(update);
|
|
2290
|
+
await eventPublisher.budget(store, metadata, persisted);
|
|
2291
|
+
return persisted;
|
|
2292
|
+
};
|
|
2293
|
+
const persistWorktree = async (store: RunStore, metadata: WorkflowMetadata, owner: string): Promise<WorktreeReference> => {
|
|
2294
|
+
const existing = (await store.worktrees()).some((worktree) => worktree.owner === owner);
|
|
2295
|
+
const worktree = await store.worktree(owner);
|
|
2296
|
+
if (!existing) await eventPublisher.worktree(store, metadata, worktree);
|
|
2297
|
+
return worktree;
|
|
2298
|
+
};
|
|
2299
|
+
const lifecycleFor = (store: RunStore, state: RunState, budget: WorkflowBudgetRuntime, metadata: WorkflowMetadata) => new RunLifecycle(state, async (next, previous, reason) => {
|
|
2300
|
+
if (next !== "pausing") budget.transition(next);
|
|
2301
|
+
const persisted = await persistRunState(store, metadata, (current) => {
|
|
2302
|
+
const nextRun = { ...current, state: next, ...budget.snapshot() };
|
|
1736
2303
|
if (next === "running" || next === "completed") delete nextRun.error;
|
|
1737
2304
|
return nextRun;
|
|
1738
2305
|
});
|
|
1739
|
-
|
|
2306
|
+
await eventPublisher.runState(store, metadata, previous, next, reason);
|
|
2307
|
+
runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1740
2308
|
});
|
|
1741
2309
|
const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
|
|
1742
2310
|
const run = runs.get(runId);
|
|
1743
2311
|
if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown production run: ${runId}`);
|
|
1744
2312
|
try {
|
|
2313
|
+
const budget = run.budget.forAgent(id);
|
|
1745
2314
|
const onProgress = async (progress: AgentProgress) => {
|
|
1746
2315
|
let runState: PersistedRun;
|
|
1747
2316
|
if (progress.persist) {
|
|
1748
|
-
runState = await run.store.
|
|
2317
|
+
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);
|
|
1749
2318
|
} else {
|
|
1750
2319
|
const loaded = await run.store.load();
|
|
1751
2320
|
if (!loaded.run.agents.some((agent) => agent.id === id)) return;
|
|
1752
|
-
runState = { ...loaded.run, agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
|
|
2321
|
+
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) };
|
|
1753
2322
|
}
|
|
1754
2323
|
if (!runState.agents.some((agent) => agent.id === id)) return;
|
|
1755
|
-
|
|
2324
|
+
setLiveActivity(runId, id, progress.activity);
|
|
2325
|
+
run.update?.(workflowToolUpdate(withLiveActivities(runState)));
|
|
1756
2326
|
};
|
|
1757
|
-
const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile">) => {
|
|
2327
|
+
const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => {
|
|
1758
2328
|
await scheduler.flush();
|
|
2329
|
+
scheduler.attemptStarted(id);
|
|
2330
|
+
await scheduler.flush();
|
|
2331
|
+
const before = (await run.store.load()).run;
|
|
1759
2332
|
await persistActiveAgentAttempt(run.store, id, attempt);
|
|
1760
|
-
|
|
2333
|
+
const active = (await run.store.load()).run;
|
|
2334
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
|
|
2335
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2336
|
+
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1761
2337
|
};
|
|
1762
|
-
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); });
|
|
2338
|
+
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); });
|
|
2339
|
+
const before = (await run.store.load()).run;
|
|
1763
2340
|
await persistAgentAttempts(run.store, id, result.attempts);
|
|
2341
|
+
const completed = (await run.store.load()).run;
|
|
2342
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
|
|
2343
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2344
|
+
setLiveActivity(runId, id);
|
|
2345
|
+
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1764
2346
|
return result.value;
|
|
1765
2347
|
} catch (error) {
|
|
1766
2348
|
const attempts = (error as WorkflowError & { attempts?: readonly AgentAttempt[] }).attempts;
|
|
1767
|
-
if (attempts?.length)
|
|
2349
|
+
if (attempts?.length) {
|
|
2350
|
+
const before = (await run.store.load()).run;
|
|
2351
|
+
await persistAgentAttempts(run.store, id, attempts);
|
|
2352
|
+
const failed = (await run.store.load()).run;
|
|
2353
|
+
await eventPublisher.agentStates(run.store, run.metadata, before.agents, failed.agents);
|
|
2354
|
+
}
|
|
2355
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2356
|
+
setLiveActivity(runId, id);
|
|
2357
|
+
run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
|
|
1768
2358
|
throw error;
|
|
1769
2359
|
}
|
|
1770
2360
|
}, 16, async (runId, ownership) => {
|
|
1771
2361
|
const run = runs.get(runId);
|
|
1772
2362
|
if (!run) return;
|
|
1773
2363
|
await run.store.saveOwnership(ownership);
|
|
1774
|
-
|
|
2364
|
+
let previousAgents: readonly AgentRecord[] = [];
|
|
2365
|
+
const runState = await persistRunState(run.store, run.metadata, (current) => {
|
|
2366
|
+
previousAgents = current.agents;
|
|
1775
2367
|
const existing = new Map(current.agents.map((agent) => [agent.id, agent]));
|
|
1776
2368
|
const agents = ownership.map((node) => {
|
|
1777
2369
|
const previous = existing.get(node.id);
|
|
1778
2370
|
const requested = { label: node.options.label, workflowName: run.metadata.name, ...(node.options.model ? { model: node.options.model } : {}), ...(node.options.thinking ? { thinking: node.options.thinking } : {}), ...(node.options.role ? { role: node.options.role } : {}), effectiveTools: node.options.tools };
|
|
1779
|
-
let effective: { model: ModelSpec; tools: readonly string[] };
|
|
2371
|
+
let effective: { model: ModelSpec; requestedModel?: string; tools: readonly string[] };
|
|
1780
2372
|
try { effective = run.executor.resolve(requested); }
|
|
1781
|
-
catch { 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 }; }
|
|
1782
|
-
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 } : {}) };
|
|
2373
|
+
catch { 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 }; }
|
|
2374
|
+
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 } : {}) };
|
|
1783
2375
|
});
|
|
1784
2376
|
return { ...current, agents };
|
|
1785
2377
|
});
|
|
1786
|
-
run.
|
|
2378
|
+
await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
|
|
2379
|
+
run.update?.(workflowToolUpdate(withLiveActivities(runState)));
|
|
1787
2380
|
});
|
|
2381
|
+
type WorkflowStopResult = { runId: string; state: RunState | "unknown"; stopped: boolean; reason?: "unknown_run" | "already_terminal" };
|
|
2382
|
+
const stopWorkflowRun = async (runId: string): Promise<WorkflowStopResult> => {
|
|
2383
|
+
const run = runs.get(runId);
|
|
2384
|
+
const terminalState = terminalRunStates.get(runId);
|
|
2385
|
+
if (!run) return terminalState ? { runId, state: terminalState, stopped: false, reason: "already_terminal" } : { runId, state: "unknown", stopped: false, reason: "unknown_run" };
|
|
2386
|
+
const state = run.lifecycle.state;
|
|
2387
|
+
if (state === "completed" || state === "failed" || state === "stopped") return { runId, state, stopped: false, reason: "already_terminal" };
|
|
2388
|
+
await run.lifecycle.terminal("stopped");
|
|
2389
|
+
run.abortController.abort();
|
|
2390
|
+
run.execution?.cancel();
|
|
2391
|
+
await scheduler.cancelRun(run.store.runId);
|
|
2392
|
+
await scheduler.flush();
|
|
2393
|
+
return { runId, state: "stopped", stopped: true };
|
|
2394
|
+
};
|
|
1788
2395
|
const answerCheckpoint = async (runId: string, name: string, approved: boolean, silent = false) => {
|
|
1789
2396
|
const run = runs.get(runId);
|
|
1790
2397
|
if (!run) return false;
|
|
1791
2398
|
const checkpoint = await run.store.answerCheckpoint(name, approved);
|
|
1792
2399
|
if (!checkpoint) return false;
|
|
2400
|
+
await eventPublisher.checkpoint(run.store, run.metadata, checkpoint.name, approved ? "approved" : "rejected");
|
|
1793
2401
|
if ((await run.store.awaitingCheckpoints()).length === 0) await run.lifecycle.resolveAwaitingInput();
|
|
1794
2402
|
run.checkpointResolvers.get(checkpoint.path)?.(approved);
|
|
1795
2403
|
run.checkpointResolvers.delete(checkpoint.path);
|
|
1796
2404
|
if (!silent) deliver(pi, `Workflow ${run.metadata.name} checkpoint ${name}: ${approved ? "Approved" : "Rejected"}.`);
|
|
1797
2405
|
return true;
|
|
1798
2406
|
};
|
|
2407
|
+
const budgetDecisionDelivery = (metadata: WorkflowMetadata, request: BudgetApprovalRequest) => `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}.`;
|
|
2408
|
+
const appendBudgetDecisionEvent = async (run: NonNullable<ReturnType<typeof runs.get>>, request: BudgetApprovalRequest, type: "adjustment_requested" | "adjustment_approved" | "adjustment_rejected") => {
|
|
2409
|
+
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) });
|
|
2410
|
+
await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
|
|
2411
|
+
};
|
|
2412
|
+
const answerBudgetDecision = async (runId: string, proposalId: string, approved: boolean, silent = false): Promise<BudgetDecisionResult | undefined> => {
|
|
2413
|
+
const run = runs.get(runId);
|
|
2414
|
+
if (!run) return undefined;
|
|
2415
|
+
const request = await run.store.answerWorkflowDecision(proposalId, approved);
|
|
2416
|
+
if (!request) return undefined;
|
|
2417
|
+
await appendBudgetDecisionEvent(run, request, approved ? "adjustment_approved" : "adjustment_rejected");
|
|
2418
|
+
const result = await applyBudgetDecision(request, approved);
|
|
2419
|
+
run.budgetResolvers.get(proposalId)?.(result);
|
|
2420
|
+
run.budgetResolvers.delete(proposalId);
|
|
2421
|
+
if (!silent) deliver(pi, `Workflow ${run.metadata.name} budget adjustment ${proposalId}: ${approved ? "Approved" : "Rejected"}.`);
|
|
2422
|
+
return result;
|
|
2423
|
+
};
|
|
1799
2424
|
const checkpointBridge = (runId: string, store: RunStore, metadata: WorkflowMetadata, foreground: boolean, ui?: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }) => {
|
|
1800
2425
|
const checkpointCounters = new Map<string, number>();
|
|
1801
2426
|
return async (raw: Readonly<Record<string, JsonValue>>, signal: AbortSignal): Promise<boolean> => {
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
if (
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
if (ui?.select) void (async () => {
|
|
1824
|
-
while (!signal.aborted && run?.checkpointResolvers.has(path)) {
|
|
1825
|
-
const choice = await ui.select?.(input.prompt, ["Approve", "Reject"]);
|
|
1826
|
-
if (!choice) {
|
|
1827
|
-
if (foreground) continue; // foreground: retry until answered
|
|
1828
|
-
// Background resume: user dismissed UI, fall back to LLM
|
|
1829
|
-
deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
|
|
1830
|
-
return;
|
|
1831
|
-
}
|
|
1832
|
-
if (await answerCheckpoint(runId, label, choice === "Approve", true)) return;
|
|
2427
|
+
const input = validateCheckpoint(raw);
|
|
2428
|
+
const label = nextNamedOccurrence(checkpointCounters, input.name);
|
|
2429
|
+
const path = operationPath("checkpoint", label);
|
|
2430
|
+
if (foreground && !ui?.select) fail("RESUME_INCOMPATIBLE", "Foreground checkpoints require UI");
|
|
2431
|
+
const alreadyAwaiting = (await store.awaitingCheckpoints()).some((checkpoint) => checkpoint.path === path);
|
|
2432
|
+
const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
|
|
2433
|
+
if (replayed !== undefined) return replayed;
|
|
2434
|
+
if (!alreadyAwaiting) await eventPublisher.checkpoint(store, metadata, label, "awaiting");
|
|
2435
|
+
const run = runs.get(runId);
|
|
2436
|
+
await run?.lifecycle.enterAwaitingInput();
|
|
2437
|
+
if (!alreadyAwaiting && !ui?.select) deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
|
|
2438
|
+
const decision = new Promise<boolean>((resolve, reject) => {
|
|
2439
|
+
run?.checkpointResolvers.set(path, resolve);
|
|
2440
|
+
if (signal.aborted) reject(new WorkflowError("CANCELLED", "Workflow cancelled"));
|
|
2441
|
+
else signal.addEventListener("abort", () => { run?.checkpointResolvers.delete(path); reject(new WorkflowError("CANCELLED", "Workflow cancelled")); }, { once: true });
|
|
2442
|
+
});
|
|
2443
|
+
const answered = await store.awaitCheckpoint({ ...input, name: label, path });
|
|
2444
|
+
if (answered !== undefined) {
|
|
2445
|
+
if ((await store.awaitingCheckpoints()).length === 0) await run?.lifecycle.resolveAwaitingInput();
|
|
2446
|
+
run?.checkpointResolvers.get(path)?.(answered);
|
|
2447
|
+
run?.checkpointResolvers.delete(path);
|
|
1833
2448
|
}
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
2449
|
+
if (ui?.select) void (async () => {
|
|
2450
|
+
while (!signal.aborted && run?.checkpointResolvers.has(path)) {
|
|
2451
|
+
const choice = await ui.select?.(input.prompt, ["Approve", "Reject"]);
|
|
2452
|
+
if (!choice) {
|
|
2453
|
+
if (foreground) continue; // foreground: retry until answered
|
|
2454
|
+
deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
2457
|
+
if (await answerCheckpoint(runId, label, choice === "Approve", true)) return;
|
|
2458
|
+
}
|
|
2459
|
+
})().catch(() => undefined);
|
|
2460
|
+
return decision;
|
|
2461
|
+
};
|
|
1837
2462
|
};
|
|
1838
2463
|
|
|
1839
2464
|
pi.registerTool({
|
|
1840
2465
|
name: "workflow_respond",
|
|
1841
2466
|
label: "Workflow Respond",
|
|
1842
|
-
description: "Approve or reject one pending workflow checkpoint",
|
|
1843
|
-
parameters: Type.Object({ runId: Type.String(), name: Type.String(), approved: Type.Boolean() }, { additionalProperties: false }),
|
|
2467
|
+
description: "Approve or reject one pending workflow checkpoint or budget decision",
|
|
2468
|
+
parameters: Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
|
|
1844
2469
|
async execute(_id, params) {
|
|
1845
2470
|
try {
|
|
2471
|
+
if (params.proposalId) {
|
|
2472
|
+
const result = await answerBudgetDecision(params.runId, params.proposalId, params.approved);
|
|
2473
|
+
if (!result) { const denied = { state: "budget_exhausted" as const, approved: false, reason: "proposal_not_pending" }; return { content: [{ type: "text" as const, text: JSON.stringify(denied) }], details: denied }; }
|
|
2474
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: { ...result, reason: params.approved ? "approved" : "rejected" } };
|
|
2475
|
+
}
|
|
2476
|
+
if (!params.name) throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
|
|
1846
2477
|
const accepted = await answerCheckpoint(params.runId, params.name, params.approved);
|
|
1847
|
-
return { content: [{ type: "text" as const, text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted } };
|
|
2478
|
+
return { content: [{ type: "text" as const, text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted, state: accepted ? "checkpoint_answered" : "not_pending", approved: params.approved, reason: "checkpoint" } as never };
|
|
2479
|
+
} catch (error) {
|
|
2480
|
+
throw mainAgentError(error);
|
|
2481
|
+
}
|
|
2482
|
+
},
|
|
2483
|
+
});
|
|
2484
|
+
pi.registerTool({
|
|
2485
|
+
name: "workflow_stop",
|
|
2486
|
+
label: "Workflow Stop",
|
|
2487
|
+
description: "Stop an active workflow run by ID",
|
|
2488
|
+
parameters: Type.Object({ runId: Type.String() }, { additionalProperties: false }),
|
|
2489
|
+
async execute(_id, params) {
|
|
2490
|
+
try {
|
|
2491
|
+
const result = await stopWorkflowRun(params.runId);
|
|
2492
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
|
|
1848
2493
|
} catch (error) {
|
|
1849
2494
|
throw mainAgentError(error);
|
|
1850
2495
|
}
|
|
@@ -1855,7 +2500,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1855
2500
|
const registerCatalog = () => {
|
|
1856
2501
|
if (catalogRegistered || !pi.getActiveTools().includes("workflow")) return;
|
|
1857
2502
|
const catalog = registry.catalog();
|
|
1858
|
-
|
|
2503
|
+
const hasAliases = Object.keys(catalog.modelAliases ?? {}).length > 0;
|
|
2504
|
+
if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length && !hasAliases) return;
|
|
1859
2505
|
pi.registerTool({
|
|
1860
2506
|
name: "workflow_catalog",
|
|
1861
2507
|
label: "Workflow Catalog",
|
|
@@ -1865,37 +2511,92 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1865
2511
|
});
|
|
1866
2512
|
catalogRegistered = true;
|
|
1867
2513
|
};
|
|
1868
|
-
const
|
|
2514
|
+
const refreshPausedRunAliases = async (run: NonNullable<ReturnType<typeof runs.get>>, context?: { model: { provider: string; id: string } | undefined; modelRegistry: { getAll?: () => Array<{ provider: string; id: string }>; getAvailable?: () => Array<{ provider: string; id: string }> } | undefined }) => {
|
|
2515
|
+
const loaded = await run.store.load();
|
|
2516
|
+
const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
|
|
2517
|
+
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
2518
|
+
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
2519
|
+
const settingsPath = workflowSettingsPath();
|
|
2520
|
+
const currentSettings = loadSettings(settingsPath);
|
|
2521
|
+
resolveAgentResourcePolicy(run.store.cwd, run.projectTrusted(), settingsPath);
|
|
2522
|
+
const currentAliases = currentSettings.modelAliases ?? {};
|
|
2523
|
+
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
2524
|
+
const modelRegistry = context?.modelRegistry;
|
|
2525
|
+
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
|
|
2526
|
+
if (context?.model) knownModels.add(`${context.model.provider}/${context.model.id}`);
|
|
2527
|
+
const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
|
|
2528
|
+
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2529
|
+
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2530
|
+
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
|
|
2531
|
+
await run.store.saveSnapshot(snapshot);
|
|
2532
|
+
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);
|
|
2533
|
+
run.executor.setRunContext(workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, run.abortController.signal));
|
|
2534
|
+
const drift = aliasDrift(previousAliases, currentAliases);
|
|
2535
|
+
if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
2536
|
+
};
|
|
2537
|
+
const coldResumeRun = async (run: NonNullable<ReturnType<typeof runs.get>>, hasUI: boolean, ui: { select?: (prompt: string, options: string[]) => Promise<string | undefined> }, trustedProject: boolean, context?: { model: { provider: string; id: string } | undefined; modelRegistry: { getAll?: () => Array<{ provider: string; id: string }>; getAvailable?: () => Array<{ provider: string; id: string }> } | undefined }) => {
|
|
1869
2538
|
const loaded = await run.store.load();
|
|
1870
2539
|
if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
|
|
1871
2540
|
if (loaded.snapshot.roles === undefined) throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow role definitions are missing from the launch snapshot");
|
|
1872
2541
|
if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject) throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
|
|
1873
2542
|
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
1874
2543
|
if (missingRole) throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
|
|
1875
|
-
const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_catalog"));
|
|
2544
|
+
const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_stop" && tool !== "workflow_catalog"));
|
|
1876
2545
|
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
1877
2546
|
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
1878
|
-
|
|
2547
|
+
const settingsPath = workflowSettingsPath();
|
|
2548
|
+
const currentSettings = loadSettings(settingsPath);
|
|
2549
|
+
resolveAgentResourcePolicy(run.store.cwd, trustedProject, settingsPath);
|
|
2550
|
+
const currentAliases = currentSettings.modelAliases ?? {};
|
|
2551
|
+
const previousAliases = loaded.snapshot.modelAliases ?? loaded.snapshot.settings.modelAliases ?? {};
|
|
2552
|
+
const modelRegistry = context?.modelRegistry;
|
|
2553
|
+
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`));
|
|
2554
|
+
if (context?.model) knownModels.add(`${context.model.provider}/${context.model.id}`);
|
|
2555
|
+
const resumeModels = modelRegistry ? knownModels : new Set([...loaded.snapshot.models, ...knownModels]);
|
|
2556
|
+
const resumeAliases = { ...previousAliases, ...currentAliases };
|
|
2557
|
+
const blockedAliases = new Set(Object.keys(previousAliases).filter((name) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2558
|
+
const blockedAliasTargets = Object.fromEntries(Object.entries(previousAliases).filter(([name]) => !Object.prototype.hasOwnProperty.call(currentAliases, name)));
|
|
2559
|
+
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);
|
|
2560
|
+
const snapshot = createLaunchSnapshot({ ...loaded.snapshot, settingsPath, settings: { ...loaded.snapshot.settings, modelAliases: currentAliases }, modelAliases: currentAliases });
|
|
2561
|
+
await run.store.saveSnapshot(snapshot);
|
|
2562
|
+
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);
|
|
2563
|
+
const drift = aliasDrift(previousAliases, currentAliases);
|
|
2564
|
+
if (drift.length) await run.store.appendEvent({ type: "warning", message: `Model alias mappings changed on resume: ${drift.join("; ")}` });
|
|
1879
2565
|
const controller = new AbortController();
|
|
1880
2566
|
run.abortController = controller;
|
|
1881
2567
|
const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
|
|
2568
|
+
run.executor.setRunContext(runContext);
|
|
1882
2569
|
let variables: Readonly<Record<string, JsonValue>>;
|
|
1883
2570
|
try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
|
|
1884
2571
|
catch (error) {
|
|
1885
2572
|
const typed = asWorkflowError(error);
|
|
1886
|
-
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.lifecycle.terminal("failed").catch(() => undefined); await run.store.
|
|
2573
|
+
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); }
|
|
1887
2574
|
throw typed;
|
|
1888
2575
|
}
|
|
1889
2576
|
await scheduler.cancelRun(run.store.runId);
|
|
1890
2577
|
await run.lifecycle.resume();
|
|
1891
2578
|
const execution = runWorkflow(loaded.snapshot.script, loaded.snapshot.args, withWorkflowFunctions({ agent: async (prompt, options, signal, identity) => {
|
|
1892
2579
|
await run.lifecycle.enter();
|
|
2580
|
+
const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
|
|
2581
|
+
const conversationLock = conversationId ? `${run.store.runId}:${conversationId}` : "";
|
|
1893
2582
|
try {
|
|
1894
|
-
const path = agentIdentityPath(identity);
|
|
2583
|
+
const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
|
|
1895
2584
|
const replayed = await run.store.replay(path);
|
|
1896
|
-
if (replayed)
|
|
2585
|
+
if (replayed) {
|
|
2586
|
+
if (conversationId) {
|
|
2587
|
+
const conversation = await run.store.conversation(conversationId);
|
|
2588
|
+
if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
|
|
2589
|
+
}
|
|
2590
|
+
return replayed.value;
|
|
2591
|
+
}
|
|
2592
|
+
if (conversationId) {
|
|
2593
|
+
if (conversationLocks.has(conversationLock)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
|
|
2594
|
+
const conversation = await run.store.conversation(conversationId);
|
|
2595
|
+
if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
|
|
2596
|
+
conversationLocks.add(conversationLock);
|
|
2597
|
+
}
|
|
1897
2598
|
const worktree = agentWorktree(identity);
|
|
1898
|
-
const cwd = worktree.worktreeOwner ? (await run.store.worktree
|
|
2599
|
+
const cwd = worktree.worktreeOwner ? (await persistWorktree(run.store, run.metadata, worktree.worktreeOwner)).cwd : run.store.cwd;
|
|
1899
2600
|
const role = typeof options.role === "string" ? options.role : undefined;
|
|
1900
2601
|
const model = typeof options.model === "string" ? options.model : undefined;
|
|
1901
2602
|
const thinking = parseThinking(options.thinking);
|
|
@@ -1904,21 +2605,86 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1904
2605
|
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
1905
2606
|
const tools = resolved.tools;
|
|
1906
2607
|
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
1907
|
-
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 } : {}) });
|
|
2608
|
+
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 });
|
|
1908
2609
|
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
1909
2610
|
signal.addEventListener("abort", cancel, { once: true });
|
|
1910
2611
|
const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
|
|
1911
2612
|
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
1912
2613
|
await run.store.complete(path, outcome.value);
|
|
1913
2614
|
return outcome.value;
|
|
1914
|
-
} finally { await run.lifecycle.leave(); }
|
|
1915
|
-
}, checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try { await run.store.
|
|
2615
|
+
} finally { if (conversationLock) conversationLocks.delete(conversationLock); await run.lifecycle.leave(); }
|
|
2616
|
+
}, checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try { let previousPhase: string | undefined; const persisted = await persistRunState(run.store, run.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; }); await eventPublisher.phase(run.store, run.metadata, previousPhase, phase); runs.get(run.store.runId)?.update?.(workflowToolUpdate(persisted)); } finally { await run.lifecycle.leave(); } }, log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
|
|
1916
2617
|
run.execution = execution;
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
2618
|
+
const completion = execution.result.then(async (value) => {
|
|
2619
|
+
await scheduler.flush();
|
|
2620
|
+
if (run.budget.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
|
|
2621
|
+
const resultPath = await run.store.saveResult(value);
|
|
2622
|
+
await run.lifecycle.terminal("completed", "completed");
|
|
2623
|
+
await eventPublisher.runCompleted(run.store, run.metadata, resultPath);
|
|
2624
|
+
return { value, resultPath };
|
|
2625
|
+
}).catch(async (error: unknown) => {
|
|
2626
|
+
await scheduler.flush();
|
|
2627
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error));
|
|
2628
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
2629
|
+
const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
|
|
2630
|
+
const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
|
|
2631
|
+
await eventPublisher.runFailed(run.store, run.metadata, typed, state);
|
|
2632
|
+
run.update?.(workflowToolUpdate(persisted));
|
|
2633
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) deliverFailure(pi, run.metadata.name, error);
|
|
2634
|
+
});
|
|
1920
2635
|
void completion;
|
|
1921
2636
|
};
|
|
2637
|
+
const applyBudgetDecision = async (request: BudgetApprovalRequest, approved: boolean): Promise<BudgetDecisionResult> => {
|
|
2638
|
+
const run = runs.get(request.runId);
|
|
2639
|
+
if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${request.runId}`);
|
|
2640
|
+
if (!approved) return { state: "budget_exhausted", approved: false };
|
|
2641
|
+
const nextBudget = validateBudget(request.proposed);
|
|
2642
|
+
const nextVersion = request.budgetVersion + 1;
|
|
2643
|
+
const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, request.consumed, run.budget.events, { active: false });
|
|
2644
|
+
run.budget = runtime;
|
|
2645
|
+
await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
|
|
2646
|
+
await coldResumeRun(run, false, {}, true);
|
|
2647
|
+
return { state: "running", approved: true };
|
|
2648
|
+
};
|
|
2649
|
+
const resumeWorkflowRun = async (runId: string, rawPatch?: unknown): Promise<Record<string, JsonValue>> => {
|
|
2650
|
+
const run = runs.get(runId);
|
|
2651
|
+
if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", `Unknown workflow run: ${runId}`);
|
|
2652
|
+
const loaded = await run.store.load();
|
|
2653
|
+
if (loaded.run.state !== "budget_exhausted") throw new WorkflowError("RESUME_INCOMPATIBLE", "Only budget-exhausted runs can be resumed with workflow_resume");
|
|
2654
|
+
const currentBudget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
2655
|
+
const patch = rawPatch === undefined ? {} : validateBudgetPatch(rawPatch);
|
|
2656
|
+
const nextBudget = mergeBudget(currentBudget, patch);
|
|
2657
|
+
const usage = budgetUsage(loaded.run.usage);
|
|
2658
|
+
if (!resumeBudgetAllowed(nextBudget, usage)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Every exhausted hard budget must be raised above retained usage or removed");
|
|
2659
|
+
if (budgetRelaxed(currentBudget, nextBudget)) {
|
|
2660
|
+
const proposalId = randomUUID();
|
|
2661
|
+
const request: BudgetApprovalRequest = { kind: "budget", proposalId, runId, consumed: usage, previous: currentBudget ?? {}, proposed: nextBudget ?? {}, budgetVersion: loaded.run.budgetVersion ?? 1 };
|
|
2662
|
+
const decision = new Promise<BudgetDecisionResult>((resolve) => { run.budgetResolvers.set(proposalId, resolve); });
|
|
2663
|
+
try { await run.store.requestWorkflowDecision(request); await appendBudgetDecisionEvent(run, request, "adjustment_requested"); } catch (error) { run.budgetResolvers.delete(proposalId); throw error; }
|
|
2664
|
+
deliver(pi, budgetDecisionDelivery(run.metadata, request));
|
|
2665
|
+
const decisionResult = await decision;
|
|
2666
|
+
return { state: decisionResult.state };
|
|
2667
|
+
}
|
|
2668
|
+
const changed = JSON.stringify(currentBudget ?? {}) !== JSON.stringify(nextBudget ?? {});
|
|
2669
|
+
if (changed) {
|
|
2670
|
+
const nextVersion = (loaded.run.budgetVersion ?? 1) + 1;
|
|
2671
|
+
const runtime = new WorkflowBudgetRuntime(nextBudget, nextVersion, usage, loaded.run.budgetEvents, { active: false });
|
|
2672
|
+
run.budget = runtime;
|
|
2673
|
+
await persistRunState(run.store, run.metadata, (current) => { const next = { ...current, ...runtime.snapshot(), budgetVersion: nextVersion }; if (nextBudget) next.budget = nextBudget; else delete next.budget; return next; });
|
|
2674
|
+
}
|
|
2675
|
+
await coldResumeRun(run, false, {}, true);
|
|
2676
|
+
return { state: "running" };
|
|
2677
|
+
};
|
|
2678
|
+
pi.registerTool({
|
|
2679
|
+
name: "workflow_resume",
|
|
2680
|
+
label: "Workflow Resume",
|
|
2681
|
+
description: "Resume an exhausted workflow with unchanged or patched aggregate budgets",
|
|
2682
|
+
parameters: Type.Object({ runId: Type.String(), budget: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
|
|
2683
|
+
async execute(_id, params) {
|
|
2684
|
+
try { const result = await resumeWorkflowRun(params.runId, params.budget); return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; }
|
|
2685
|
+
catch (error) { throw mainAgentError(error); }
|
|
2686
|
+
},
|
|
2687
|
+
});
|
|
1922
2688
|
pi.on("session_start", async (_event, ctx) => {
|
|
1923
2689
|
if (sessionStarted) return;
|
|
1924
2690
|
sessionStarted = true;
|
|
@@ -1931,20 +2697,27 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1931
2697
|
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
1932
2698
|
let loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> };
|
|
1933
2699
|
try { loaded = await store.load(); } catch { if (!await store.isComplete()) await store.delete(true).catch(() => undefined); continue; }
|
|
1934
|
-
if (
|
|
1935
|
-
if (loaded.run.state !== "interrupted") {
|
|
1936
|
-
|
|
2700
|
+
if (loaded.run.state === "completed" || loaded.run.state === "failed" || loaded.run.state === "stopped") { terminalRunStates.set(runId, loaded.run.state); continue; }
|
|
2701
|
+
if (loaded.run.state !== "interrupted" && loaded.run.state !== "budget_exhausted") {
|
|
2702
|
+
const previousState = loaded.run.state;
|
|
2703
|
+
await store.updateState((current) => ["completed", "failed", "stopped", "interrupted", "budget_exhausted"].includes(current.state) ? current : { ...current, state: "interrupted" });
|
|
2704
|
+
loaded = { ...loaded, run: (await store.load()).run };
|
|
2705
|
+
await eventPublisher.runState(store, loaded.snapshot.metadata, previousState, "interrupted", "session_shutdown");
|
|
1937
2706
|
loaded = { ...loaded, run: (await store.load()).run };
|
|
1938
2707
|
}
|
|
1939
2708
|
const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
|
|
1940
|
-
const
|
|
2709
|
+
const budget = validateBudget(loaded.run.budget ?? loaded.snapshot.budget);
|
|
2710
|
+
eventPublisher.seedBudget(runId, loaded.run.budgetEvents);
|
|
2711
|
+
const budgetRuntime = new WorkflowBudgetRuntime(budget, loaded.run.budgetVersion ?? 1, loaded.run.usage, loaded.run.budgetEvents, { active: loaded.run.state === "running" });
|
|
2712
|
+
const lifecycle = lifecycleFor(store, loaded.run.state, budgetRuntime, loaded.snapshot.metadata);
|
|
1941
2713
|
const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
1942
2714
|
const roleDefinitions = loaded.snapshot.roles ?? {};
|
|
1943
|
-
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() });
|
|
2715
|
+
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() });
|
|
1944
2716
|
for (const checkpoint of await store.awaitingCheckpoints()) deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
|
|
1945
|
-
|
|
2717
|
+
for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
|
|
2718
|
+
scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
1946
2719
|
}
|
|
1947
|
-
const resumeSelect = (ctx.ui
|
|
2720
|
+
const resumeSelect = uiHostCapabilities(ctx.ui)?.select;
|
|
1948
2721
|
if (ctx.hasUI && resumeSelect) {
|
|
1949
2722
|
const interrupted = [...runs.values()].filter((r) => r.lifecycle.state === "interrupted");
|
|
1950
2723
|
if (interrupted.length > 0) {
|
|
@@ -1954,7 +2727,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1954
2727
|
if (choice && choice !== "Skip") {
|
|
1955
2728
|
const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
|
|
1956
2729
|
for (const run of toResume) {
|
|
1957
|
-
try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx)); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
|
|
2730
|
+
try { await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx), ctx); ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info"); }
|
|
1958
2731
|
catch (err) { ctx.ui.notify(`Cannot resume ${run.metadata.name}: ${err instanceof Error ? err.message : String(err)}`, "warning"); }
|
|
1959
2732
|
}
|
|
1960
2733
|
}
|
|
@@ -1977,17 +2750,21 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
1977
2750
|
parameters: WORKFLOW_TOOL_PARAMETERS,
|
|
1978
2751
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1979
2752
|
try {
|
|
2753
|
+
const settingsPath = workflowSettingsPath();
|
|
2754
|
+
const defaults = loadSettings(settingsPath);
|
|
1980
2755
|
if (!ctx.model) throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
|
|
1981
|
-
const
|
|
1982
|
-
const
|
|
2756
|
+
const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, ...(defaults.modelAliases ? { modelAliases: defaults.modelAliases } : {}) });
|
|
2757
|
+
const budget = validateBudget(params.budget);
|
|
1983
2758
|
const rootModel: ModelSpec = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
|
|
1984
2759
|
const rootModelName = `${rootModel.provider}/${rootModel.model}`;
|
|
1985
|
-
const modelRegistry = (ctx
|
|
1986
|
-
const
|
|
1987
|
-
|
|
1988
|
-
const
|
|
2760
|
+
const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
|
|
2761
|
+
const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
|
|
2762
|
+
knownModels.add(rootModelName);
|
|
2763
|
+
const availableModels = knownModels;
|
|
2764
|
+
const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_stop" && name !== "workflow_catalog");
|
|
1989
2765
|
const trustedProject = projectTrusted(ctx);
|
|
1990
|
-
|
|
2766
|
+
if (typeof ctx.cwd === "string") resolveAgentResourcePolicy(ctx.cwd, trustedProject, settingsPath);
|
|
2767
|
+
const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases: defaults.modelAliases ?? {}, knownModels, settingsPath }, registry);
|
|
1991
2768
|
const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames } = validated;
|
|
1992
2769
|
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
1993
2770
|
const runId = randomUUID();
|
|
@@ -2000,25 +2777,41 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2000
2777
|
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
2001
2778
|
const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]])) as Record<string, AgentDefinition>;
|
|
2002
2779
|
const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
|
|
2003
|
-
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model)] : []; });
|
|
2780
|
+
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model, defaults.modelAliases, knownModels, settingsPath)] : []; });
|
|
2004
2781
|
const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
|
|
2005
|
-
const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
|
|
2006
|
-
|
|
2007
|
-
const
|
|
2782
|
+
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 });
|
|
2783
|
+
const budgetRuntime = new WorkflowBudgetRuntime(budget);
|
|
2784
|
+
const initialBudget = budgetRuntime.snapshot();
|
|
2785
|
+
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);
|
|
2786
|
+
const lifecycle = lifecycleFor(store, "running", budgetRuntime, checked.metadata);
|
|
2008
2787
|
const background = !params.foreground;
|
|
2009
2788
|
const providerPause = async () => { if (background) deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
2010
|
-
const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, agentDefinitions, runStore: store, providerPause }, createSession);
|
|
2011
|
-
runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, abortController: runController, checkpointResolvers: new Map(), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
|
|
2789
|
+
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);
|
|
2790
|
+
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 } : {}) });
|
|
2012
2791
|
if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
|
|
2013
|
-
scheduler.addRun(runId, settings.concurrency,
|
|
2792
|
+
scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch());
|
|
2014
2793
|
const execution = runWorkflow(script, args, withWorkflowFunctions({ agent: async (prompt, options, agentSignal, identity) => {
|
|
2015
2794
|
await lifecycle.enter();
|
|
2795
|
+
const conversationId = identity.conversation ? conversationIdentityPath(identity) : undefined;
|
|
2796
|
+
const conversationLock = conversationId ? `${runId}:${conversationId}` : "";
|
|
2016
2797
|
try {
|
|
2017
|
-
const path = agentIdentityPath(identity);
|
|
2798
|
+
const path = conversationId ? conversationTurnPath(identity) : agentIdentityPath(identity);
|
|
2018
2799
|
const replayed = await store.replay(path);
|
|
2019
|
-
if (replayed)
|
|
2800
|
+
if (replayed) {
|
|
2801
|
+
if (conversationId) {
|
|
2802
|
+
const conversation = await store.conversation(conversationId);
|
|
2803
|
+
if (!conversation || conversation.head.turn < (identity.conversation?.turn ?? 0)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Completed conversation turn has no persisted head");
|
|
2804
|
+
}
|
|
2805
|
+
return replayed.value;
|
|
2806
|
+
}
|
|
2807
|
+
if (conversationId) {
|
|
2808
|
+
if (conversationLocks.has(conversationLock)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turns cannot overlap");
|
|
2809
|
+
const conversation = await store.conversation(conversationId);
|
|
2810
|
+
if (conversation ? conversation.head.turn + 1 !== identity.conversation?.turn : identity.conversation?.turn !== 1) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation turn does not continue its persisted head");
|
|
2811
|
+
conversationLocks.add(conversationLock);
|
|
2812
|
+
}
|
|
2020
2813
|
const worktree = agentWorktree(identity);
|
|
2021
|
-
const cwd = worktree.worktreeOwner ? (await store.worktree
|
|
2814
|
+
const cwd = worktree.worktreeOwner ? (await persistWorktree(store, checked.metadata, worktree.worktreeOwner)).cwd : ctx.cwd;
|
|
2022
2815
|
const role = typeof options.role === "string" ? options.role : undefined;
|
|
2023
2816
|
const model = typeof options.model === "string" ? options.model : undefined;
|
|
2024
2817
|
const thinking = parseThinking(options.thinking);
|
|
@@ -2027,30 +2820,46 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2027
2820
|
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
2028
2821
|
const tools = resolved.tools;
|
|
2029
2822
|
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
2030
|
-
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"
|
|
2823
|
+
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 });
|
|
2031
2824
|
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
2032
2825
|
if (agentSignal.aborted) cancel(); else agentSignal.addEventListener("abort", cancel, { once: true });
|
|
2033
2826
|
const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
|
|
2034
2827
|
if (!outcome.ok) throw new WorkflowError(outcome.error.code as WorkflowErrorCode, outcome.error.message);
|
|
2035
2828
|
await store.complete(path, outcome.value);
|
|
2036
2829
|
return outcome.value;
|
|
2037
|
-
} finally { await lifecycle.leave(); }
|
|
2830
|
+
} finally { if (conversationLock) conversationLocks.delete(conversationLock); await lifecycle.leave(); }
|
|
2038
2831
|
}, checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined), phase: async (phase) => {
|
|
2039
2832
|
await lifecycle.enter();
|
|
2040
2833
|
try {
|
|
2041
|
-
|
|
2042
|
-
|
|
2834
|
+
let previousPhase: string | undefined;
|
|
2835
|
+
const persisted = await persistRunState(store, checked.metadata, (current) => { previousPhase = current.phase; return { ...current, phase }; });
|
|
2836
|
+
await eventPublisher.phase(store, checked.metadata, previousPhase, phase);
|
|
2837
|
+
runs.get(runId)?.update?.(workflowToolUpdate(persisted));
|
|
2043
2838
|
} finally { await lifecycle.leave(); }
|
|
2044
2839
|
}, log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
|
|
2045
2840
|
(runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
|
|
2046
|
-
|
|
2047
|
-
const finish = execution.result.then(async (value) => {
|
|
2841
|
+
await eventPublisher.runStarted(store, checked.metadata);
|
|
2842
|
+
const finish = execution.result.then(async (value) => {
|
|
2843
|
+
await scheduler.flush();
|
|
2844
|
+
if (budgetRuntime.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
|
|
2845
|
+
const resultPath = await store.saveResult(value);
|
|
2846
|
+
await lifecycle.terminal("completed", "completed");
|
|
2847
|
+
await eventPublisher.runCompleted(store, checked.metadata, resultPath);
|
|
2848
|
+
return { value, resultPath };
|
|
2849
|
+
}).catch(async (error: unknown) => {
|
|
2850
|
+
await scheduler.flush();
|
|
2851
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
|
|
2852
|
+
if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state)) await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
|
|
2853
|
+
await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
|
|
2854
|
+
const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
|
|
2855
|
+
await eventPublisher.runFailed(store, checked.metadata, typed, state);
|
|
2856
|
+
throw typed;
|
|
2857
|
+
});
|
|
2048
2858
|
(runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).completion = finish;
|
|
2049
2859
|
if (background) {
|
|
2050
2860
|
void finish.then(async ({ value, resultPath }) => {
|
|
2051
|
-
emitAsyncComplete(store, "complete");
|
|
2052
2861
|
deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
|
|
2053
|
-
}, (error: unknown) => {
|
|
2862
|
+
}, (error: unknown) => { deliverFailure(pi, checked.metadata.name, error); });
|
|
2054
2863
|
return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
|
|
2055
2864
|
}
|
|
2056
2865
|
const { value } = await finish;
|
|
@@ -2098,9 +2907,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2098
2907
|
return entries.filter((entry): entry is { store: RunStore; loaded: { run: PersistedRun; snapshot: Readonly<LaunchSnapshot> } } => entry !== undefined);
|
|
2099
2908
|
};
|
|
2100
2909
|
let stores = await loadStores();
|
|
2101
|
-
const usage = "Usage: /workflow [doctor], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint]"
|
|
2910
|
+
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."
|
|
2102
2911
|
const setWorkflowStatus = (text: string | undefined) => {
|
|
2103
|
-
const setStatus = (ctx.ui
|
|
2912
|
+
const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
|
|
2104
2913
|
setStatus?.call(ctx.ui, "workflow-stop", text);
|
|
2105
2914
|
};
|
|
2106
2915
|
const runAction = async (actionCommand: string, keepContext: boolean, status: (text: string | undefined) => void = setWorkflowStatus): Promise<"dashboard" | "picker" | "done"> => {
|
|
@@ -2114,22 +2923,44 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2114
2923
|
ctx.ui.notify(accepted ? `${action === "approve" ? "Approved" : "Rejected"} checkpoint ${rest.join(" ")}.` : "Checkpoint is not awaiting a response.", accepted ? "info" : "warning");
|
|
2115
2924
|
return keepContext ? "dashboard" : "done";
|
|
2116
2925
|
}
|
|
2926
|
+
if ((action === "budget-approve" || action === "budget-reject") && runId && rest[0]) {
|
|
2927
|
+
const result = await answerBudgetDecision(runId, rest[0], action === "budget-approve", true);
|
|
2928
|
+
ctx.ui.notify(result ? `Budget adjustment ${rest[0]} ${result.approved ? "approved" : "rejected"}.` : "Budget proposal is not pending.", result ? "info" : "warning");
|
|
2929
|
+
return keepContext ? "dashboard" : "done";
|
|
2930
|
+
}
|
|
2117
2931
|
if (action === "delete" && stored) {
|
|
2118
2932
|
if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) { ctx.ui.notify("Stop the workflow before deleting it.", "warning"); return keepContext ? "dashboard" : "done"; }
|
|
2119
2933
|
if (!await ctx.ui.confirm("Delete workflow?", `Delete ${stored.loaded.run.workflowName} (${stored.store.runId}) and all owned artifacts? This cannot be undone.`)) return keepContext ? "dashboard" : "done";
|
|
2120
|
-
await stored.store.delete(true); runs.delete(stored.store.runId); ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info"); return keepContext ? "picker" : "done";
|
|
2934
|
+
await stored.store.delete(true); runs.delete(stored.store.runId); terminalRunStates.delete(stored.store.runId); ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info"); return keepContext ? "picker" : "done";
|
|
2121
2935
|
}
|
|
2122
2936
|
if (action === "pause" && run) { await run.lifecycle.pause(); ctx.ui.notify(`Paused workflow ${run.store.runId}.`, "info"); return keepContext ? "dashboard" : "done"; }
|
|
2123
2937
|
if (action === "resume" && run) {
|
|
2124
|
-
if (run.lifecycle.state === "
|
|
2125
|
-
|
|
2126
|
-
|
|
2938
|
+
if (run.lifecycle.state === "budget_exhausted") {
|
|
2939
|
+
const patch: unknown = rest.length ? JSON.parse(rest.join(" ")) as unknown : undefined;
|
|
2940
|
+
const result = await resumeWorkflowRun(run.store.runId, patch);
|
|
2941
|
+
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");
|
|
2942
|
+
} else {
|
|
2943
|
+
if (run.lifecycle.state === "interrupted") await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx), ctx);
|
|
2944
|
+
else {
|
|
2945
|
+
if (run.lifecycle.state === "paused") await refreshPausedRunAliases(run, ctx);
|
|
2946
|
+
await run.lifecycle.resume();
|
|
2947
|
+
}
|
|
2948
|
+
ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
|
|
2949
|
+
}
|
|
2950
|
+
return keepContext ? "dashboard" : "done";
|
|
2951
|
+
}
|
|
2952
|
+
if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
|
|
2953
|
+
const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
|
|
2954
|
+
if (input === undefined) return keepContext ? "dashboard" : "done";
|
|
2955
|
+
const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
|
|
2956
|
+
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");
|
|
2957
|
+
return keepContext ? "dashboard" : "done";
|
|
2127
2958
|
}
|
|
2128
2959
|
if (action === "stop" && run) {
|
|
2129
2960
|
const workflowName = stored?.loaded.run.workflowName ?? run.metadata.name;
|
|
2130
2961
|
if (keepContext && !await ctx.ui.confirm("Stop workflow?", `Stop workflow ${workflowName} (${run.store.runId})? This cannot be undone.`)) return "dashboard";
|
|
2131
2962
|
if (keepContext) status(`Stopping workflow ${workflowName}...`);
|
|
2132
|
-
await
|
|
2963
|
+
await stopWorkflowRun(run.store.runId);
|
|
2133
2964
|
if (keepContext) status(`Workflow ${run.store.runId} stopped.`);
|
|
2134
2965
|
ctx.ui.notify(`Stopped workflow ${run.store.runId}.`, "info"); return keepContext ? "dashboard" : "done";
|
|
2135
2966
|
}
|
|
@@ -2144,10 +2975,76 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2144
2975
|
return "dashboard";
|
|
2145
2976
|
}
|
|
2146
2977
|
};
|
|
2978
|
+
const manageAliases = async (): Promise<void> => {
|
|
2979
|
+
const settingsPath = workflowSettingsPath();
|
|
2980
|
+
const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
|
|
2981
|
+
const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
|
|
2982
|
+
const selectTarget = async (): Promise<string | undefined> => {
|
|
2983
|
+
const models = available();
|
|
2984
|
+
const choice = await ctx.ui.select("Model alias target", [...models, "Manual model ID", "Back"]);
|
|
2985
|
+
if (!choice || choice === "Back") return undefined;
|
|
2986
|
+
if (choice !== "Manual model ID") return choice;
|
|
2987
|
+
return (await ctx.ui.input("Manual model ID", "provider/model[:thinking]"))?.trim() || undefined;
|
|
2988
|
+
};
|
|
2989
|
+
const save = (aliases: Readonly<Record<string, string>>): boolean => {
|
|
2990
|
+
try { saveModelAliases(settingsPath, aliases); ctx.ui.notify(`Saved model aliases to ${settingsPath}.`, "info"); return true; }
|
|
2991
|
+
catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; }
|
|
2992
|
+
};
|
|
2993
|
+
for (;;) {
|
|
2994
|
+
let aliases: Readonly<Record<string, string>>;
|
|
2995
|
+
try { aliases = loadSettings(settingsPath).modelAliases ?? {}; }
|
|
2996
|
+
catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return; }
|
|
2997
|
+
const names = Object.keys(aliases).sort();
|
|
2998
|
+
const listing = names.length ? names.map((name) => `${name} = ${aliases[name] ?? ""}`).join("\n") : "(none)";
|
|
2999
|
+
const options = ["Add alias", ...names.map((name) => `Edit ${name}`), ...names.map((name) => `Delete ${name}`), "Back"];
|
|
3000
|
+
const choice = await ctx.ui.select(`Model aliases\n${listing}`, options);
|
|
3001
|
+
if (!choice || choice === "Back") return;
|
|
3002
|
+
if (choice === "Add alias") {
|
|
3003
|
+
const name = (await ctx.ui.input("Alias name", "reviewer-model"))?.trim();
|
|
3004
|
+
if (!name) continue;
|
|
3005
|
+
if (Object.prototype.hasOwnProperty.call(aliases, name)) { ctx.ui.notify(`Alias ${name} already exists; choose Edit ${name}.`, "warning"); continue; }
|
|
3006
|
+
const target = await selectTarget();
|
|
3007
|
+
if (!target) continue;
|
|
3008
|
+
const next = { ...aliases, [name]: target };
|
|
3009
|
+
try { validateModelAliases(next, settingsPath); } catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
|
|
3010
|
+
const parsed = parseModelReference(target);
|
|
3011
|
+
if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
|
|
3012
|
+
ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
|
|
3013
|
+
if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
|
|
3014
|
+
}
|
|
3015
|
+
save(next);
|
|
3016
|
+
continue;
|
|
3017
|
+
}
|
|
3018
|
+
const edit = /^Edit (.+)$/.exec(choice);
|
|
3019
|
+
if (edit?.[1]) {
|
|
3020
|
+
const target = await selectTarget();
|
|
3021
|
+
if (!target) continue;
|
|
3022
|
+
const next = { ...aliases, [edit[1]]: target };
|
|
3023
|
+
try { validateModelAliases(next, settingsPath); } catch (error) { ctx.ui.notify(`${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, "error"); continue; }
|
|
3024
|
+
const parsed = parseModelReference(target);
|
|
3025
|
+
if (!available().includes(`${parsed.provider}/${parsed.model}`)) {
|
|
3026
|
+
ctx.ui.notify(`Warning: ${target} is not currently available in Pi.`, "warning");
|
|
3027
|
+
if (!await ctx.ui.confirm("Save unknown model?", "Save this target for cross-machine portability?")) continue;
|
|
3028
|
+
}
|
|
3029
|
+
save(next);
|
|
3030
|
+
continue;
|
|
3031
|
+
}
|
|
3032
|
+
const deletion = /^Delete (.+)$/.exec(choice);
|
|
3033
|
+
if (deletion?.[1] && await ctx.ui.confirm("Delete model alias?", `Delete ${deletion[1]}? Future workflow resumes using this alias may fail.`)) {
|
|
3034
|
+
const next = Object.fromEntries(Object.entries(aliases).filter(([name]) => name !== deletion[1]));
|
|
3035
|
+
save(next);
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
};
|
|
3039
|
+
if (command === "model-aliases") {
|
|
3040
|
+
if (!ctx.hasUI) { ctx.ui.notify("Model alias management requires UI.", "warning"); return; }
|
|
3041
|
+
await manageAliases();
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
2147
3044
|
if (!command) {
|
|
2148
3045
|
for (;;) {
|
|
2149
|
-
if (!stores.length) { ctx.ui.notify("No workflow runs in this session.", "info"); return; }
|
|
2150
3046
|
if (!ctx.hasUI) {
|
|
3047
|
+
if (!stores.length) { ctx.ui.notify("No workflow runs in this session.", "info"); return; }
|
|
2151
3048
|
const details = await Promise.all(stores.map(async ({ store, loaded }) => formatNavigatorRun(loaded, await store.awaitingCheckpoints(), await store.worktrees())));
|
|
2152
3049
|
ctx.ui.notify(details.join("\n\n"), "info"); return;
|
|
2153
3050
|
}
|
|
@@ -2155,13 +3052,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2155
3052
|
const labels = navigatorRunLabels(sorted);
|
|
2156
3053
|
const terminalStates = new Set(["completed", "failed", "stopped"]);
|
|
2157
3054
|
const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
|
|
2158
|
-
const pickerOptions = [...labels, ...(hasCompleted ? ["Delete all completed"] : [])
|
|
3055
|
+
const pickerOptions = [...labels, "Model aliases", "Close", ...(hasCompleted ? ["Delete all completed"] : [])];
|
|
2159
3056
|
const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
|
|
2160
3057
|
if (!runChoice || runChoice === "Close") return;
|
|
3058
|
+
if (runChoice === "Model aliases") { await manageAliases(); stores = await loadStores(); continue; }
|
|
2161
3059
|
if (runChoice === "Delete all completed") {
|
|
2162
3060
|
if (!await ctx.ui.confirm("Delete completed runs?", "Delete all completed workflow runs and their artifacts? This cannot be undone.")) continue;
|
|
2163
3061
|
for (const entry of sorted) {
|
|
2164
|
-
if (entry.loaded.run.state === "completed") { await entry.store.delete(true); runs.delete(entry.store.runId); }
|
|
3062
|
+
if (entry.loaded.run.state === "completed") { await entry.store.delete(true); runs.delete(entry.store.runId); terminalRunStates.delete(entry.store.runId); }
|
|
2165
3063
|
}
|
|
2166
3064
|
ctx.ui.notify("Deleted all completed workflow runs.", "info"); stores = await loadStores(); continue;
|
|
2167
3065
|
}
|
|
@@ -2178,6 +3076,38 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2178
3076
|
ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2179
3077
|
}
|
|
2180
3078
|
};
|
|
3079
|
+
const openTranscript = async (transcript: string): Promise<void> => {
|
|
3080
|
+
try {
|
|
3081
|
+
const entries = SessionManager.open(transcript).buildContextEntries();
|
|
3082
|
+
if (ctx.mode !== "tui") { ctx.ui.notify(`Transcript: ${transcript}`, "info"); return; }
|
|
3083
|
+
await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
3084
|
+
let offset = 0;
|
|
3085
|
+
let renderedLines: string[] = [];
|
|
3086
|
+
const viewport = () => Math.max(1, tuiRows(tui) - 3);
|
|
3087
|
+
const move = (delta: number) => { offset = Math.max(0, Math.min(Math.max(0, renderedLines.length - viewport()), offset + delta)); };
|
|
3088
|
+
return {
|
|
3089
|
+
render(width: number) {
|
|
3090
|
+
renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
|
|
3091
|
+
offset = Math.min(offset, Math.max(0, renderedLines.length - viewport()));
|
|
3092
|
+
return [theme.fg("accent", "Native Pi transcript"), ...renderedLines.slice(offset, offset + viewport()), "", theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close")];
|
|
3093
|
+
},
|
|
3094
|
+
invalidate() {},
|
|
3095
|
+
handleInput(data: string) {
|
|
3096
|
+
if (keybindings.matches(data, "tui.select.up")) move(-1);
|
|
3097
|
+
else if (keybindings.matches(data, "tui.select.down")) move(1);
|
|
3098
|
+
else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
|
|
3099
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
|
|
3100
|
+
else if (keybindings.matches(data, "tui.editor.cursorLineStart")) offset = 0;
|
|
3101
|
+
else if (keybindings.matches(data, "tui.editor.cursorLineEnd")) offset = Math.max(0, renderedLines.length - viewport());
|
|
3102
|
+
else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
|
|
3103
|
+
tui.requestRender();
|
|
3104
|
+
},
|
|
3105
|
+
};
|
|
3106
|
+
}, { overlay: true });
|
|
3107
|
+
} catch (error) {
|
|
3108
|
+
ctx.ui.notify(`Cannot open transcript: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
3109
|
+
}
|
|
3110
|
+
};
|
|
2181
3111
|
const loadDashboard = async () => {
|
|
2182
3112
|
const loaded = await store.load();
|
|
2183
3113
|
const checkpoints = await store.awaitingCheckpoints();
|
|
@@ -2189,6 +3119,12 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2189
3119
|
const addCopy = (label: string, value: string, artifact: string) => { actions.set(label, "copy"); copies.set(label, { value, artifact }); };
|
|
2190
3120
|
if (loaded.run.state === "running") add("Pause", "pause");
|
|
2191
3121
|
if (["paused", "interrupted"].includes(loaded.run.state)) add("Resume", "resume");
|
|
3122
|
+
if (loaded.run.state === "budget_exhausted") { actions.set("Resume unchanged", `resume ${store.runId}`); actions.set("Adjust budget", `adjust ${store.runId}`); }
|
|
3123
|
+
for (const decision of await store.pendingWorkflowDecisions()) {
|
|
3124
|
+
const id = decision.proposalId.slice(0, 8);
|
|
3125
|
+
actions.set(`Approve budget ${id}`, `budget-approve ${store.runId} ${decision.proposalId}`);
|
|
3126
|
+
actions.set(`Reject budget ${id}`, `budget-reject ${store.runId} ${decision.proposalId}`);
|
|
3127
|
+
}
|
|
2192
3128
|
if (!terminalStates.has(loaded.run.state)) add("Stop", "stop");
|
|
2193
3129
|
for (const cp of checkpoints) {
|
|
2194
3130
|
if (ctx.mode === "tui") {
|
|
@@ -2203,18 +3139,59 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2203
3139
|
if (ctx.mode !== "tui") actions.set("Refresh", "refresh");
|
|
2204
3140
|
else actions.set("View script", "view-script");
|
|
2205
3141
|
const transcripts = [...new Set([...loaded.run.agents.flatMap((agent) => (agent.attemptDetails ?? []).map((attempt) => attempt.sessionFile)), ...loaded.run.nativeSessions.map(({ sessionFile }) => sessionFile)])];
|
|
2206
|
-
if (
|
|
2207
|
-
if (transcripts.length) actions.set("
|
|
3142
|
+
if (loaded.run.agents.length) actions.set("Agents...", "agents");
|
|
3143
|
+
if (!loaded.run.agents.length && ctx.mode === "tui" && transcripts.length) actions.set("View transcript", "view-transcript");
|
|
3144
|
+
if (!loaded.run.agents.length && transcripts.length) actions.set("Transcript paths", "transcripts");
|
|
2208
3145
|
if (terminalStates.has(loaded.run.state)) add("Delete", "delete");
|
|
2209
3146
|
if (ctx.mode === "tui") {
|
|
2210
3147
|
addCopy("Copy run path", store.directory, "run path");
|
|
2211
3148
|
addCopy("Copy run ID", store.runId, "run ID");
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
3149
|
+
}
|
|
3150
|
+
return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script, agents: loaded.run.agents, worktrees };
|
|
3151
|
+
};
|
|
3152
|
+
const selectAgent = async (dashboard: Awaited<ReturnType<typeof loadDashboard>>): Promise<void> => {
|
|
3153
|
+
const byId = new Map(dashboard.agents.map((agent) => [agent.id, agent]));
|
|
3154
|
+
const title = (agent: AgentRecord): string => {
|
|
3155
|
+
const parents: string[] = [];
|
|
3156
|
+
for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
|
|
3157
|
+
const parent = byId.get(parentId);
|
|
3158
|
+
if (!parent) break;
|
|
3159
|
+
parents.unshift(parent.label ?? parent.name);
|
|
3160
|
+
}
|
|
3161
|
+
return [...((agent.structuralPath ?? []).length ? [(agent.structuralPath ?? []).join(" > ")] : []), ...(agent.parentBreadcrumb ? [agent.parentBreadcrumb] : []), ...parents, agent.label ?? agent.name].join(" > ");
|
|
3162
|
+
};
|
|
3163
|
+
const labels = dashboard.agents.map((agent, index) => `#${String(index + 1)} ${title(agent)} [${agent.state}]`);
|
|
3164
|
+
const selectedLabel = await ctx.ui.select("Agents", [...labels, "Back"]);
|
|
3165
|
+
const selectedIndex = selectedLabel ? labels.indexOf(selectedLabel) : -1;
|
|
3166
|
+
const selected = selectedIndex >= 0 ? dashboard.agents[selectedIndex] : undefined;
|
|
3167
|
+
if (!selected) return;
|
|
3168
|
+
const attempts = [...(selected.attemptDetails ?? [])].sort((left, right) => left.attempt - right.attempt);
|
|
3169
|
+
const worktree = selected.worktreeOwner ? dashboard.worktrees.find((candidate) => candidate.owner === selected.worktreeOwner) : undefined;
|
|
3170
|
+
const actions = [
|
|
3171
|
+
...(attempts.length ? ["View transcript", "Copy transcript path"] : []),
|
|
3172
|
+
...(worktree ? ["Copy branch", "Copy worktree path"] : []),
|
|
3173
|
+
"Copy agent ID",
|
|
3174
|
+
"Back",
|
|
3175
|
+
];
|
|
3176
|
+
const chooseAttempt = async (): Promise<AgentAttemptSummary | undefined> => {
|
|
3177
|
+
const choices = attempts.map((attempt) => `Attempt ${String(attempt.attempt)}`);
|
|
3178
|
+
const choice = choices.length === 1 ? choices[0] : await ctx.ui.select("Transcript attempts", [...choices, "Back"]);
|
|
3179
|
+
const index = choice ? choices.indexOf(choice) : -1;
|
|
3180
|
+
return index >= 0 ? attempts[index] : undefined;
|
|
3181
|
+
};
|
|
3182
|
+
for (;;) {
|
|
3183
|
+
const action = await ctx.ui.select(title(selected), actions);
|
|
3184
|
+
if (!action || action === "Back") return;
|
|
3185
|
+
if (action === "Copy agent ID") { await copyArtifact(selected.id, "agent ID"); continue; }
|
|
3186
|
+
if (action === "Copy branch" && worktree) { await copyArtifact(worktree.branch, "branch"); continue; }
|
|
3187
|
+
if (action === "Copy worktree path" && worktree) { await copyArtifact(worktree.path, "worktree path"); continue; }
|
|
3188
|
+
if (action === "View transcript" || action === "Copy transcript path") {
|
|
3189
|
+
const attempt = await chooseAttempt();
|
|
3190
|
+
if (!attempt) continue;
|
|
3191
|
+
if (action === "Copy transcript path") await copyArtifact(attempt.sessionFile, "transcript path");
|
|
3192
|
+
else await openTranscript(attempt.sessionFile);
|
|
2215
3193
|
}
|
|
2216
3194
|
}
|
|
2217
|
-
return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script };
|
|
2218
3195
|
};
|
|
2219
3196
|
for (;;) {
|
|
2220
3197
|
let view = await loadDashboard();
|
|
@@ -2227,11 +3204,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2227
3204
|
let disposed = false;
|
|
2228
3205
|
let stopRequested = false;
|
|
2229
3206
|
let stopStatus: string | undefined;
|
|
2230
|
-
const terminalRows = () => Math.max(1, (
|
|
3207
|
+
const terminalRows = () => Math.max(1, tuiRows(tui));
|
|
2231
3208
|
const keyLabels: Record<string, string> = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
|
|
2232
3209
|
const keyLabel = (binding: string, fallback: string) => {
|
|
2233
|
-
const
|
|
2234
|
-
const keys = getKeys?.call(keybindings, binding);
|
|
3210
|
+
const keys = keybindingKeys(keybindings, binding);
|
|
2235
3211
|
return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
|
|
2236
3212
|
};
|
|
2237
3213
|
const dashboardLayout = () => {
|
|
@@ -2318,13 +3294,14 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2318
3294
|
}, { overlay: true })
|
|
2319
3295
|
: await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
|
|
2320
3296
|
if (!actionChoice || actionChoice === "Close") return;
|
|
3297
|
+
if (actionChoice === "Agents...") { await selectAgent(view); continue; }
|
|
2321
3298
|
if (actionChoice === "Refresh") continue;
|
|
2322
3299
|
if (actionChoice === "View script") {
|
|
2323
3300
|
await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
2324
3301
|
const highlighted = highlightCode(view.script, "javascript");
|
|
2325
3302
|
let offset = 0;
|
|
2326
3303
|
let renderedLines: string[] = [];
|
|
2327
|
-
const viewport = () => Math.max(1, (
|
|
3304
|
+
const viewport = () => Math.max(1, tuiRows(tui) - 3);
|
|
2328
3305
|
const move = (delta: number) => {
|
|
2329
3306
|
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2330
3307
|
offset = Math.max(0, Math.min(maxOffset, offset + delta));
|
|
@@ -2356,47 +3333,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2356
3333
|
}
|
|
2357
3334
|
if (actionChoice === "View transcript") {
|
|
2358
3335
|
const transcript = await ctx.ui.select("Native Pi transcripts", [...view.transcripts, "Back"]);
|
|
2359
|
-
if (transcript && transcript !== "Back")
|
|
2360
|
-
try {
|
|
2361
|
-
const entries = SessionManager.open(transcript).buildContextEntries();
|
|
2362
|
-
await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
2363
|
-
let offset: number | undefined;
|
|
2364
|
-
let renderedLines: string[] = [];
|
|
2365
|
-
const terminalRows = () => Math.max(1, ((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24);
|
|
2366
|
-
const viewport = () => Math.max(1, terminalRows() - 3);
|
|
2367
|
-
const move = (delta: number) => {
|
|
2368
|
-
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2369
|
-
offset = Math.max(0, Math.min(maxOffset, (offset ?? maxOffset) + delta));
|
|
2370
|
-
};
|
|
2371
|
-
return {
|
|
2372
|
-
render(width: number) {
|
|
2373
|
-
renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
|
|
2374
|
-
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2375
|
-
offset = Math.min(offset ?? maxOffset, maxOffset);
|
|
2376
|
-
return [
|
|
2377
|
-
theme.fg("accent", "Native Pi transcript"),
|
|
2378
|
-
...renderedLines.slice(offset, offset + viewport()),
|
|
2379
|
-
"",
|
|
2380
|
-
theme.fg("dim", "↑↓/pgup/pgdn scroll · Home/End jump · esc close"),
|
|
2381
|
-
];
|
|
2382
|
-
},
|
|
2383
|
-
invalidate() {},
|
|
2384
|
-
handleInput(data: string) {
|
|
2385
|
-
if (keybindings.matches(data, "tui.select.up")) move(-1);
|
|
2386
|
-
else if (keybindings.matches(data, "tui.select.down")) move(1);
|
|
2387
|
-
else if (keybindings.matches(data, "tui.select.pageUp")) move(-viewport());
|
|
2388
|
-
else if (keybindings.matches(data, "tui.select.pageDown")) move(viewport());
|
|
2389
|
-
else if (keybindings.matches(data, "tui.editor.cursorLineStart")) offset = 0;
|
|
2390
|
-
else if (keybindings.matches(data, "tui.editor.cursorLineEnd")) offset = Math.max(0, renderedLines.length - viewport());
|
|
2391
|
-
else if (keybindings.matches(data, "tui.select.cancel")) done(undefined);
|
|
2392
|
-
tui.requestRender();
|
|
2393
|
-
},
|
|
2394
|
-
};
|
|
2395
|
-
}, { overlay: true });
|
|
2396
|
-
} catch (error) {
|
|
2397
|
-
ctx.ui.notify(`Cannot open transcript ${transcript}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2398
|
-
}
|
|
2399
|
-
}
|
|
3336
|
+
if (transcript && transcript !== "Back") await openTranscript(transcript);
|
|
2400
3337
|
continue;
|
|
2401
3338
|
}
|
|
2402
3339
|
if (actionChoice === "Transcript paths") {
|
|
@@ -2418,7 +3355,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2418
3355
|
let offset = 0;
|
|
2419
3356
|
let renderedLines: string[] = [];
|
|
2420
3357
|
const layout = () => {
|
|
2421
|
-
const rows = Math.max(1, (
|
|
3358
|
+
const rows = Math.max(1, tuiRows(tui));
|
|
2422
3359
|
const compactControls = rows < 4;
|
|
2423
3360
|
const titleRows = rows >= 5 ? 1 : 0;
|
|
2424
3361
|
const hintRows = rows >= 8 ? 1 : 0;
|
|
@@ -2481,9 +3418,9 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
|
|
|
2481
3418
|
pi.on("session_shutdown", async () => {
|
|
2482
3419
|
try {
|
|
2483
3420
|
await Promise.all([...runs.entries()].map(async ([runId, run]) => {
|
|
2484
|
-
if (["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.completion?.catch(() => undefined); return; }
|
|
2485
|
-
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
|
|
2486
|
-
try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) throw error; }
|
|
3421
|
+
if (["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) { await run.completion?.catch(() => undefined); return; }
|
|
3422
|
+
if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) {
|
|
3423
|
+
try { await run.lifecycle.terminal("interrupted"); } catch (error) { if (!["completed", "failed", "stopped", "budget_exhausted"].includes(run.lifecycle.state)) throw error; }
|
|
2487
3424
|
run.abortController.abort();
|
|
2488
3425
|
run.execution?.cancel();
|
|
2489
3426
|
await scheduler.cancelRun(runId);
|
|
@@ -2512,8 +3449,8 @@ function modelSpec(value: string, fallback: ModelSpec): ModelSpec {
|
|
|
2512
3449
|
}
|
|
2513
3450
|
|
|
2514
3451
|
export { acquireSessionLease, projectStorageKey, RunStore, runsDirectory, SessionLease, structuralPath } from "./persistence.js";
|
|
2515
|
-
export type { AwaitingCheckpoint, CompletedOperation, NativeSessionReference, PersistedOwnershipNode, PersistedRun, WorktreeReference } from "./persistence.js";
|
|
3452
|
+
export type { AwaitingCheckpoint, CompletedOperation, ConversationHead, NativeSessionReference, PendingWorkflowDecision, PersistedConversation, PersistedOwnershipNode, PersistedRun, WorktreeReference } from "./persistence.js";
|
|
2516
3453
|
export { FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
|
|
2517
|
-
export type { AgentAccounting, AgentAttempt, AgentDefinition, AgentExecutionOptions, AgentExecutionResult, AgentExecutionRoot, AgentProgress, AgentToolCallProgress } from "./agent-execution.js";
|
|
3454
|
+
export type { AgentAccounting, AgentAttempt, AgentBudgetHooks, AgentDefinition, AgentExecutionOptions, AgentExecutionResult, AgentExecutionRoot, AgentProgress, AgentSetup, AgentSetupContext, AgentSetupHook, AgentToolCallProgress, RegisteredAgentSetupHook, SessionInput } from "./agent-execution.js";
|
|
2518
3455
|
export { doctor, doctorExitCode, formatDoctorReport } from "./doctor.js";
|
|
2519
3456
|
export type { DoctorDiagnostic, DoctorOptions, DoctorPiState, DoctorReport, DoctorRole, DoctorSeverity, DoctorTrust, DoctorWorkflow } from "./doctor.js";
|