pi-extensible-workflows 1.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +9 -2
  2. package/dist/src/agent-execution.d.ts +13 -14
  3. package/dist/src/agent-execution.js +110 -197
  4. package/dist/src/budget.d.ts +38 -0
  5. package/dist/src/budget.js +160 -0
  6. package/dist/src/cli.d.ts +9 -0
  7. package/dist/src/cli.js +536 -6
  8. package/dist/src/doctor.d.ts +4 -4
  9. package/dist/src/doctor.js +9 -29
  10. package/dist/src/execution.d.ts +17 -0
  11. package/dist/src/execution.js +630 -0
  12. package/dist/src/herdr.d.ts +12 -0
  13. package/dist/src/herdr.js +74 -0
  14. package/dist/src/host.d.ts +62 -0
  15. package/dist/src/host.js +2696 -0
  16. package/dist/src/index.d.ts +11 -557
  17. package/dist/src/index.js +8 -3974
  18. package/dist/src/persistence.d.ts +32 -19
  19. package/dist/src/persistence.js +310 -70
  20. package/dist/src/registry.d.ts +31 -0
  21. package/dist/src/registry.js +169 -0
  22. package/dist/src/session-inspector.d.ts +1 -0
  23. package/dist/src/session-inspector.js +4 -1
  24. package/dist/src/types.d.ts +565 -0
  25. package/dist/src/types.js +27 -0
  26. package/dist/src/utils.d.ts +26 -0
  27. package/dist/src/utils.js +128 -0
  28. package/dist/src/validation.d.ts +24 -0
  29. package/dist/src/validation.js +740 -0
  30. package/dist/src/workflow-evals.js +2 -1
  31. package/package.json +4 -3
  32. package/skills/pi-extensible-workflows/SKILL.md +52 -67
  33. package/src/agent-execution.ts +84 -147
  34. package/src/budget.ts +75 -0
  35. package/src/cli.ts +427 -6
  36. package/src/doctor.ts +13 -32
  37. package/src/execution.ts +540 -0
  38. package/src/herdr.ts +73 -0
  39. package/src/host.ts +2265 -0
  40. package/src/index.ts +11 -3453
  41. package/src/persistence.ts +255 -47
  42. package/src/registry.ts +157 -0
  43. package/src/session-inspector.ts +5 -1
  44. package/src/types.ts +113 -0
  45. package/src/utils.ts +108 -0
  46. package/src/validation.ts +616 -0
  47. package/src/workflow-evals.ts +2 -1
package/README.md CHANGED
@@ -20,7 +20,9 @@ For source installs and local development, see the [installation guide](https://
20
20
 
21
21
  ## Capabilities
22
22
 
23
- The main Pi agent acts as the orchestrator: it writes workflow scripts on the fly for each task. Pi extensions can add reusable functions and variables to those scripts, or register complete workflows that can be invoked by name.
23
+ The main Pi agent acts as the orchestrator: it writes workflow scripts on the fly for each task. Pi extensions can add reusable functions and variables to those scripts; every registered function is also directly runnable as a top-level workflow.
24
+
25
+ Inline workflow launches require a non-empty `name`; registered function launches reject `name` and use their registered function name as the run name. Workflow worktree scopes always use the explicit `withWorktree(name, callback)` form.
24
26
 
25
27
  A workflow can fan out across specialized agents, combine their results, and resume without rerunning completed work.
26
28
 
@@ -61,9 +63,14 @@ Global workflow settings live at `~/.pi/agent/pi-extensible-workflows/settings.j
61
63
  ```sh
62
64
  npx pi-extensible-workflows doctor
63
65
  npx pi-extensible-workflows inspect [session-id]
66
+ npx pi-extensible-workflows transcript <session-file>
67
+ npx pi-extensible-workflows run <workflow-name> [workflow arguments]
68
+ npx pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]
64
69
  ```
65
70
 
66
- `doctor` validates the installation and active Pi resources. `inspect` opens a read-only terminal view of persisted workflow runs.
71
+ `doctor` validates the installation and active Pi resources. `inspect` opens a read-only terminal view of persisted workflow runs. `transcript` renders a session transcript to stdout. `run` derives flat CLI arguments and help from a registered function's input schema. Use `--input '<json>'` for nested or otherwise complex inputs. It executes in the current working directory, writes the final JSON result to stdout, and writes progress and errors to stderr. `export` creates an executable POSIX launcher in `~/.local/bin` by default.
72
+ `run` and `export` accept the trust overrides `--approve` and `--no-approve`; the generated launcher forwards its arguments to `run`. `--` ends launcher option parsing, and later tokens are passed to workflow input instead of being interpreted as launcher options.
73
+ Launch snapshots use identity version 5. Cold resume rejects older snapshots, including v4 snapshots created with the previous worktree or registered-function naming contracts, with `RESUME_INCOMPATIBLE`; relaunch the workflow instead.
67
74
 
68
75
  ## Development
69
76
 
@@ -15,7 +15,7 @@ type AgentMessage = {
15
15
  };
16
16
  };
17
17
  };
18
- import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./index.js";
18
+ import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./types.js";
19
19
  import type { RunStore } from "./persistence.js";
20
20
  export interface AgentBudgetHooks {
21
21
  beforeAttempt(): void;
@@ -31,6 +31,15 @@ export interface AgentDefinition {
31
31
  tools?: readonly string[];
32
32
  disabledAgentResources?: AgentResourceExclusions;
33
33
  }
34
+ export interface AgentProviderFailure {
35
+ label: string;
36
+ provider: string;
37
+ model: string;
38
+ error: string;
39
+ }
40
+ export type AgentProviderRecovery = "retry" | "abort" | {
41
+ model: string;
42
+ };
34
43
  export interface AgentExecutionOptions {
35
44
  label: string;
36
45
  workflowName: string;
@@ -40,6 +49,8 @@ export interface AgentExecutionOptions {
40
49
  thinking?: ThinkingLevel;
41
50
  onProgress?: (progress: AgentProgress) => void | Promise<void>;
42
51
  onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => void | Promise<void>;
52
+ providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>;
53
+ modelOverride?: ModelSpec;
43
54
  tools?: readonly string[];
44
55
  effectiveTools?: readonly string[];
45
56
  role?: string;
@@ -52,10 +63,6 @@ export interface AgentExecutionOptions {
52
63
  budget?: AgentBudgetHooks;
53
64
  agentOptions?: Readonly<Record<string, JsonValue>>;
54
65
  agentIdentity?: AgentIdentity;
55
- conversation?: {
56
- id: string;
57
- turn: number;
58
- };
59
66
  }
60
67
  export interface AgentExecutionRoot {
61
68
  cwd: string;
@@ -174,11 +181,6 @@ export interface SessionInput {
174
181
  extensionFactories?: InlineExtension[];
175
182
  resourcePolicy?: AgentResourcePolicy;
176
183
  options?: Record<string, JsonValue>;
177
- continuation?: {
178
- sessionId: string;
179
- sessionFile: string;
180
- leafId: string;
181
- };
182
184
  }
183
185
  export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
184
186
  export declare function createNativeAgentSession(input: SessionInput): Promise<NativeSession>;
@@ -210,10 +212,6 @@ export interface ScheduledAgentOptions {
210
212
  timeoutMs?: number | null;
211
213
  agentOptions?: Readonly<Record<string, JsonValue>>;
212
214
  agentIdentity?: AgentIdentity;
213
- conversation?: {
214
- id: string;
215
- turn: number;
216
- };
217
215
  }
218
216
  export type ScheduledAgentResult = {
219
217
  id: string;
@@ -278,6 +276,7 @@ export declare class FairAgentScheduler {
278
276
  retry(id: string): void;
279
277
  attemptStarted(id: string): void;
280
278
  cancelRun(runId: string): Promise<void>;
279
+ removeRun(runId: string): void;
281
280
  toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[];
282
281
  snapshot(): readonly OwnershipRecord[];
283
282
  restoreRun(runId: string, limit: number, ownership: readonly OwnershipRecord[], beforeLaunch?: () => void): void;
@@ -1,17 +1,16 @@
1
- import { createHash } from "node:crypto";
2
1
  import { realpathSync } from "node:fs";
3
2
  import { join, resolve } from "node:path";
4
3
  import { Type } from "@earendil-works/pi-ai";
5
4
  import { Value } from "typebox/value";
6
5
  import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
7
- import { mergeAgentResourceExclusions, resolveModelReference, WorkflowError } from "./index.js";
6
+ import { jsonObject, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference } from "./utils.js";
7
+ import { WorkflowError } from "./types.js";
8
8
  function parseModel(value, fallback, thinking, aliases = {}, knownModels, settingsPath) {
9
9
  if (!value)
10
10
  return { ...fallback, ...(thinking ? { thinking } : {}) };
11
11
  const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
12
12
  return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
13
13
  }
14
- function modelCapability(model) { return `${model.provider}/${model.model}`; }
15
14
  function text(messages) {
16
15
  const message = [...messages].reverse().find((item) => item.role === "assistant");
17
16
  if (!message || !Array.isArray(message.content))
@@ -25,10 +24,23 @@ function latestAssistantHasToolCall(messages) {
25
24
  const message = [...messages].reverse().find((item) => item.role === "assistant");
26
25
  return hasToolCall(message);
27
26
  }
28
- function throwIfTerminalAssistantError(session) {
27
+ function throwIfTerminalAssistantError(session, fallbackModel) {
29
28
  const message = [...session.messages].reverse().find((item) => item.role === "assistant");
30
- if (message?.stopReason === "error")
31
- throw new WorkflowError("AGENT_FAILED", message.errorMessage ?? "Native Pi assistant ended with a terminal provider error");
29
+ if (message?.stopReason !== "error")
30
+ return;
31
+ const provider = session.model?.provider ?? fallbackModel.provider;
32
+ const model = session.model?.model ?? session.model?.id ?? fallbackModel.model;
33
+ const error = message.errorMessage ?? "Native Pi assistant ended with a terminal provider error";
34
+ const failure = new WorkflowError("AGENT_FAILED", error);
35
+ Object.defineProperty(failure, "terminalProviderError", { value: { provider, model, error }, configurable: true });
36
+ throw failure;
37
+ }
38
+ function terminalProviderError(error) {
39
+ const value = error.terminalProviderError;
40
+ if (!value || typeof value !== "object")
41
+ return undefined;
42
+ const candidate = value;
43
+ return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
32
44
  }
33
45
  function accounting(stats) {
34
46
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
@@ -41,30 +53,8 @@ catch {
41
53
  } }
42
54
  export async function createNativeAgentSession(input) {
43
55
  const agentDir = input.agentDir ?? getAgentDir();
44
- let manager;
45
- if (input.continuation) {
46
- try {
47
- manager = SessionManager.open(input.continuation.sessionFile, input.agentDir ? join(agentDir, "sessions") : undefined, input.cwd);
48
- const header = manager.getHeader();
49
- if (!header || canonicalSourcePath(header.cwd) !== canonicalSourcePath(input.cwd) || manager.getSessionId() !== input.continuation.sessionId || !manager.getEntry(input.continuation.leafId))
50
- throw new Error("Persisted transcript identity does not match the conversation head");
51
- manager.branch(input.continuation.leafId);
52
- const context = manager.buildSessionContext();
53
- if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model))
54
- throw new Error("Persisted transcript model does not match the conversation execution policy");
55
- if (input.model.thinking && context.thinkingLevel !== input.model.thinking)
56
- throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
57
- }
58
- catch (error) {
59
- if (error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE")
60
- throw error;
61
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot reopen conversation transcript: ${error instanceof Error ? error.message : String(error)}`);
62
- }
63
- }
64
- else {
65
- manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
66
- manager.appendSessionInfo(input.sessionLabel);
67
- }
56
+ const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
57
+ manager.appendSessionInfo(input.sessionLabel);
68
58
  const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
69
59
  const model = modelRuntime.getModel(input.model.provider, input.model.model);
70
60
  if (!model)
@@ -110,9 +100,7 @@ export async function createNativeAgentSession(input) {
110
100
  resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
111
101
  await resourceLoader.reload();
112
102
  }
113
- const { session, modelFallbackMessage } = await createAgentSession({ ...(input.options ?? {}), cwd: input.cwd, agentDir, modelRuntime, model, ...(settingsManager ? { settingsManager } : {}), ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(resourceLoader ? { resourceLoader } : {}), sessionManager: manager });
114
- if (input.continuation && modelFallbackMessage)
115
- throw new WorkflowError("RESUME_INCOMPATIBLE", modelFallbackMessage);
103
+ const { session } = await createAgentSession({ ...(input.options ?? {}), cwd: input.cwd, agentDir, modelRuntime, model, ...(settingsManager ? { settingsManager } : {}), ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(resourceLoader ? { resourceLoader } : {}), sessionManager: manager });
116
104
  return Object.assign(session, {
117
105
  getLeafId: () => manager.getLeafId(),
118
106
  getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
@@ -120,24 +108,6 @@ export async function createNativeAgentSession(input) {
120
108
  }
121
109
  function changedOption(options, baseline, key) { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
122
110
  function validThinking(value) { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
123
- function jsonValue(value, seen = new Set()) {
124
- if (value === null || typeof value === "boolean" || typeof value === "string")
125
- return true;
126
- if (typeof value === "number")
127
- return Number.isFinite(value);
128
- if (typeof value !== "object" || seen.has(value))
129
- return false;
130
- if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
131
- return false;
132
- const keys = Reflect.ownKeys(value);
133
- if (keys.some((key) => typeof key !== "string"))
134
- return false;
135
- seen.add(value);
136
- const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => value[key])).every((item) => jsonValue(item, seen));
137
- seen.delete(value);
138
- return valid;
139
- }
140
- function jsonObject(value) { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
141
111
  function isChildAgentToolParams(value) {
142
112
  if (!jsonObject(value) || typeof value.prompt !== "string" || typeof value.label !== "string")
143
113
  return false;
@@ -165,36 +135,7 @@ function fallbackSetupContext(root, options, signal) {
165
135
  function resourcePolicySummary(policy) {
166
136
  return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
167
137
  }
168
- function canonicalJson(value) {
169
- if (Array.isArray(value))
170
- return value.map((item) => canonicalJson(item));
171
- if (value && typeof value === "object")
172
- return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
173
- return value;
174
- }
175
- function fingerprint(value) { return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex"); }
176
- function promptFingerprint(value) { return createHash("sha256").update(value).digest("hex"); }
177
- function fixedConversationOptions(options) {
178
- const fixedOptions = structuredClone(options);
179
- delete fixedOptions.timeoutMs;
180
- delete fixedOptions.retries;
181
- return fixedOptions;
182
- }
183
- function conversationExecutionPolicy(options, setup) {
184
- return structuredClone({
185
- model: setup.sessionInput.model,
186
- tools: [...setup.sessionInput.tools],
187
- cwd: setup.sessionInput.cwd,
188
- role: options.role ?? null,
189
- worktreeOwner: options.worktreeOwner ?? null,
190
- parent: options.parent ?? null,
191
- systemPromptAppend: setup.sessionInput.systemPromptAppend ?? "",
192
- options: fixedConversationOptions(setup.options),
193
- resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
194
- });
195
- }
196
- function conversationFailure(message) { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
197
- async function prepareAgentSetup(root, createSession, task, options, resolved, cwd, attempt, signal, customTools, resultTool, continuation) {
138
+ async function prepareAgentSetup(root, createSession, task, options, resolved, cwd, attempt, signal, customTools, resultTool) {
198
139
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
199
140
  const baselineOptions = structuredClone(options.agentOptions ?? {});
200
141
  const baseResourcePolicy = await root.agentResourcePolicy?.();
@@ -229,8 +170,6 @@ async function prepareAgentSetup(root, createSession, task, options, resolved, c
229
170
  setup.sessionInput.tools = [...setup.options.tools];
230
171
  if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string")
231
172
  setup.sessionInput.cwd = setup.options.cwd;
232
- if (continuation)
233
- setup.sessionInput.continuation = { sessionId: continuation.sessionId, sessionFile: continuation.sessionFile, leafId: continuation.leafId };
234
173
  const model = setup.sessionInput.model;
235
174
  const summary = { hookNames: [...hookNames], model: { provider: model.provider, model: model.model, ...(model.thinking ? { thinking: model.thinking } : {}) }, tools: [...setup.sessionInput.tools], cwd: setup.sessionInput.cwd, ...(setup.sessionInput.resourcePolicy ? { disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) } : {}) };
236
175
  return { setup, summary };
@@ -255,17 +194,18 @@ export class WorkflowAgentExecutor {
255
194
  if (forbidden)
256
195
  throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${forbidden}`);
257
196
  const requestedModel = options.model ?? definition?.model;
258
- const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
259
- if (requestedModel !== undefined && this.root.blockedAliases?.has(requestedModel) && !hasAlias) {
260
- const target = this.root.blockedAliasTargets?.[requestedModel];
197
+ const alias = requestedModel === undefined ? undefined : modelAliasName(requestedModel, this.root.modelAliases ?? {});
198
+ const blockedAlias = requestedModel?.split(":", 1)[0];
199
+ if (requestedModel !== undefined && blockedAlias && this.root.blockedAliases?.has(blockedAlias) && !alias) {
200
+ const target = this.root.blockedAliasTargets?.[blockedAlias];
261
201
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
262
202
  }
263
- const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
264
- const model = parseModel(requestedModel, this.root.model, options.thinking ?? (aliasThinking === undefined ? definition?.thinking : undefined), this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
203
+ const aliasThinking = requestedModel !== undefined && alias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
204
+ const model = options.modelOverride ?? parseModel(requestedModel, this.root.model, options.thinking ?? (aliasThinking === undefined ? definition?.thinking : undefined), this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
265
205
  const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
266
206
  if (!availableModels.has(modelCapability(model)))
267
207
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
268
- return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
208
+ return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
269
209
  }
270
210
  async execute(task, options, signal, customTools = [], setSteer, beforeRetry) {
271
211
  const executionSignal = signal ?? this.root.runContext?.signal;
@@ -273,7 +213,8 @@ export class WorkflowAgentExecutor {
273
213
  throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
274
214
  if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0))
275
215
  throw new WorkflowError("INVALID_METADATA", "timeoutMs must be null or a positive integer");
276
- const resolved = this.resolve(options);
216
+ let resolved = this.resolve(options);
217
+ let recoveryModel;
277
218
  let cwd;
278
219
  if (options.parent) {
279
220
  if (!options.cwd)
@@ -302,28 +243,12 @@ export class WorkflowAgentExecutor {
302
243
  throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
303
244
  cwd = this.root.cwd;
304
245
  }
305
- let conversationRecord;
306
- if (options.conversation) {
307
- const store = this.root.runStore;
308
- if (!store)
309
- throw conversationFailure("Conversation persistence is unavailable");
310
- try {
311
- conversationRecord = await store.conversation(options.conversation.id);
312
- }
313
- catch (error) {
314
- throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`);
315
- }
316
- if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1)
317
- throw conversationFailure("Conversation turn must be a positive integer");
318
- if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1)
319
- throw conversationFailure(`Conversation turn ${String(options.conversation.turn)} does not continue its persisted head`);
320
- }
321
246
  const attempts = [];
322
- let conversationBaseline;
323
- const maxAttempts = (options.retries ?? 0) + 1;
247
+ let maxAttempts = (options.retries ?? 0) + 1;
324
248
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
249
+ if (recoveryModel)
250
+ resolved = this.resolve({ ...options, modelOverride: recoveryModel });
325
251
  options.budget?.beforeAttempt();
326
- let accepted = false;
327
252
  let schemaResult;
328
253
  let session;
329
254
  let setup;
@@ -331,18 +256,14 @@ export class WorkflowAgentExecutor {
331
256
  let setupFailed = false;
332
257
  let budgetError;
333
258
  let turnStarted = false;
334
- let conversationSystemPrompt = "";
335
- let conversationToolDefinitionsSha256 = "";
336
- let conversationMismatch;
337
- const conversationMismatchError = () => conversationMismatch ? new WorkflowError("RESUME_INCOMPATIBLE", conversationMismatch.message) : undefined;
338
259
  const hasSchemaResult = () => schemaResult !== undefined;
339
260
  const resultTool = options.schema ? {
340
261
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
341
262
  async execute(_id, value) {
342
- if (!accepted)
343
- return { content: [{ type: "text", text: "Result acceptance is not enabled yet." }], details: {}, isError: true };
344
263
  if (!Value.Check(options.schema, value))
345
264
  return { content: [{ type: "text", text: "Result does not match the required schema." }], details: {}, isError: true };
265
+ if (schemaResult !== undefined)
266
+ return { content: [{ type: "text", text: "Result has already been accepted." }], details: {}, isError: true };
346
267
  schemaResult = structuredClone(value);
347
268
  void session?.abort?.();
348
269
  return { content: [{ type: "text", text: "Result accepted." }], details: {} };
@@ -368,7 +289,7 @@ export class WorkflowAgentExecutor {
368
289
  };
369
290
  try {
370
291
  setupFailed = true;
371
- const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool, conversationRecord?.head);
292
+ const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool);
372
293
  setup = prepared.setup;
373
294
  setupSummary = prepared.summary;
374
295
  setupFailed = false;
@@ -378,58 +299,11 @@ export class WorkflowAgentExecutor {
378
299
  session = await setup.createSession(setup.sessionInput);
379
300
  if (setup.sessionInput.resourcePolicy)
380
301
  setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
381
- if (options.conversation) {
382
- conversationSystemPrompt = session.systemPrompt ?? "";
383
- conversationToolDefinitionsSha256 = fingerprint(session.getToolDefinitions?.() ?? session.agent?.state.tools ?? []);
384
- const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
385
- if (conversationRecord) {
386
- if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile)
387
- throw conversationFailure("Conversation transcript identity changed");
388
- if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId)
389
- throw conversationFailure("Conversation transcript leaf identity changed");
390
- if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationRecord.policy))
391
- throw conversationFailure("Conversation execution policy changed");
392
- if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt))
393
- throw conversationFailure("Conversation system prompt changed");
394
- if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256)
395
- throw conversationFailure("Conversation tool definitions changed");
396
- }
397
- else if (conversationBaseline) {
398
- if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationBaseline.executionPolicy))
399
- throw conversationFailure("Conversation execution policy changed");
400
- if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256)
401
- throw conversationFailure("Conversation tool definitions changed");
402
- }
403
- else {
404
- conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
405
- }
406
- if (!session.subscribe) {
407
- const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
408
- const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
409
- if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt))
410
- throw conversationFailure("Conversation system prompt changed");
411
- if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined)
412
- conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
413
- }
414
- if (conversationRecord && (!session.model || session.model.provider !== setup.sessionInput.model.provider || (session.model.model ?? session.model.id) !== setup.sessionInput.model.model))
415
- throw conversationFailure("Conversation model changed");
416
- }
417
302
  const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
418
303
  await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
419
304
  const activeSession = session;
420
305
  unsubscribe = activeSession.subscribe?.((event) => {
421
306
  if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
422
- if (options.conversation) {
423
- conversationSystemPrompt = session.systemPrompt;
424
- const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
425
- const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
426
- if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) {
427
- conversationMismatch = conversationFailure("Conversation system prompt changed");
428
- void session.abort?.();
429
- }
430
- if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined)
431
- conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
432
- }
433
307
  if (this.root.runStore) {
434
308
  systemPromptTurn += 1;
435
309
  const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
@@ -454,7 +328,7 @@ export class WorkflowAgentExecutor {
454
328
  activity = undefined;
455
329
  if (event.message.role === "assistant") {
456
330
  const needsMoreWork = hasToolCall(event.message);
457
- const final = !needsMoreWork || (options.schema !== undefined && accepted);
331
+ const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
458
332
  if (!budgetError) {
459
333
  try {
460
334
  options.budget?.afterTurn(accounting(activeSession.getSessionStats()), final);
@@ -483,6 +357,7 @@ export class WorkflowAgentExecutor {
483
357
  if (activity?.kind === "tool" && activity.text === event.toolName)
484
358
  activity = undefined;
485
359
  report(false);
360
+ toolCalls.delete(event.toolCallId);
486
361
  }
487
362
  });
488
363
  report(false);
@@ -496,34 +371,39 @@ export class WorkflowAgentExecutor {
496
371
  const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
497
372
  options.budget?.beforeTurn();
498
373
  turnStarted = true;
499
- await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
500
- if (conversationMismatch)
501
- throw conversationMismatch;
502
- throwIfTerminalAssistantError(session);
374
+ try {
375
+ await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
376
+ }
377
+ catch (error) {
378
+ if (!hasSchemaResult())
379
+ throw error;
380
+ }
381
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
503
382
  {
504
383
  const completedAccounting = accounting(session.getSessionStats());
505
- options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? false : !latestAssistantHasToolCall(session.messages));
384
+ options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages));
506
385
  turnStarted = false;
507
386
  }
508
387
  if (budgetError)
509
388
  throw budgetError;
510
389
  if (options.schema) {
511
- accepted = true;
512
- try {
513
- options.budget?.beforeTurn();
514
- turnStarted = true;
515
- await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
516
- {
517
- const completedAccounting = accounting(session.getSessionStats());
518
- options.budget?.afterTurn(completedAccounting, true);
519
- turnStarted = false;
390
+ if (!hasSchemaResult()) {
391
+ try {
392
+ options.budget?.beforeTurn();
393
+ turnStarted = true;
394
+ await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
395
+ {
396
+ const completedAccounting = accounting(session.getSessionStats());
397
+ options.budget?.afterTurn(completedAccounting, true);
398
+ turnStarted = false;
399
+ }
400
+ }
401
+ catch (error) {
402
+ if (!hasSchemaResult())
403
+ throw error;
520
404
  }
521
405
  }
522
- catch (error) {
523
- if (!hasSchemaResult())
524
- throw error;
525
- }
526
- throwIfTerminalAssistantError(session);
406
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
527
407
  if (!hasSchemaResult()) {
528
408
  try {
529
409
  options.budget?.beforeTurn();
@@ -539,14 +419,11 @@ export class WorkflowAgentExecutor {
539
419
  if (!hasSchemaResult())
540
420
  throw error;
541
421
  }
542
- throwIfTerminalAssistantError(session);
422
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
543
423
  }
544
424
  if (schemaResult === undefined)
545
425
  throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
546
426
  }
547
- const mismatch = conversationMismatchError();
548
- if (mismatch)
549
- throw mismatch;
550
427
  const value = options.schema ? schemaResult : text(session.messages);
551
428
  if (options.worktreeOwner)
552
429
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
@@ -555,22 +432,13 @@ export class WorkflowAgentExecutor {
555
432
  await flushSystemPrompts();
556
433
  unsubscribe?.();
557
434
  const attemptAccounting = accounting(session.getSessionStats());
558
- const leafId = session.getLeafId?.() ?? undefined;
559
- if (options.conversation) {
560
- if (!leafId)
561
- throw conversationFailure("Conversation transcript has no persisted leaf");
562
- const store = this.root.runStore;
563
- if (!store)
564
- throw conversationFailure("Conversation persistence is unavailable");
565
- await store.saveConversation({ id: options.conversation.id, policy: conversationExecutionPolicy(options, setup), head: { turn: options.conversation.turn, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), leafId, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt), toolDefinitionsSha256: conversationToolDefinitionsSha256 } });
566
- }
567
435
  const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
568
436
  attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
569
437
  session.dispose();
570
438
  return { value, attempts, cwd: setupSummary.cwd };
571
439
  }
572
440
  catch (error) {
573
- const typed = budgetError ?? conversationMismatch ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
441
+ const typed = budgetError ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
574
442
  if (session) {
575
443
  report(true);
576
444
  await progress;
@@ -594,6 +462,30 @@ export class WorkflowAgentExecutor {
594
462
  }
595
463
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED")
596
464
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
465
+ const terminal = terminalProviderError(typed);
466
+ if (terminal && options.providerErrorRecovery) {
467
+ let recovery;
468
+ try {
469
+ recovery = await options.providerErrorRecovery({ label: options.label, ...terminal });
470
+ }
471
+ catch {
472
+ throw Object.assign(typed, { attempts });
473
+ }
474
+ if (recovery === "retry" || typeof recovery === "object" && typeof recovery.model === "string") {
475
+ if (typeof recovery === "object") {
476
+ try {
477
+ const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
478
+ recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
479
+ }
480
+ catch {
481
+ throw Object.assign(typed, { attempts });
482
+ }
483
+ }
484
+ maxAttempts += 1;
485
+ beforeRetry?.();
486
+ continue;
487
+ }
488
+ }
597
489
  if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE")
598
490
  throw Object.assign(typed, { attempts });
599
491
  beforeRetry?.();
@@ -723,6 +615,26 @@ export class FairAgentScheduler {
723
615
  if (nodes.every(({ restored }) => restored))
724
616
  run.logical = 0;
725
617
  }
618
+ removeRun(runId) {
619
+ const run = this.#runs.get(runId);
620
+ if (!run)
621
+ return;
622
+ const nodes = [...this.#nodes.values()].filter((node) => node.runId === runId);
623
+ if (run.active > 0 || nodes.some(({ state }) => !["completed", "failed", "cancelled"].includes(state)))
624
+ throw new WorkflowError("INTERNAL_ERROR", `Cannot remove active scheduler run: ${runId}`);
625
+ for (const { id } of nodes)
626
+ this.#nodes.delete(id);
627
+ this.#runs.delete(runId);
628
+ const index = this.#runOrder.indexOf(runId);
629
+ if (index >= 0) {
630
+ this.#runOrder.splice(index, 1);
631
+ if (index < this.#cursor)
632
+ this.#cursor -= 1;
633
+ if (this.#cursor >= this.#runOrder.length)
634
+ this.#cursor = 0;
635
+ }
636
+ this.#dispatch();
637
+ }
726
638
  toolsFor(parentId, resolveTools) {
727
639
  const parent = this.#node(parentId);
728
640
  if (!parent.options.tools.includes("agent"))
@@ -842,6 +754,7 @@ export class FairAgentScheduler {
842
754
  return;
843
755
  const heldPermit = node.state === "running" || node.state === "retrying";
844
756
  node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
757
+ Reflect.deleteProperty(node, "steer");
845
758
  this.#persist(node.runId);
846
759
  if (heldPermit)
847
760
  this.#release(node.runId);
@@ -0,0 +1,38 @@
1
+ import { type BudgetDimension, type BudgetEvent, type WorkflowBudget, type WorkflowBudgetPatch, type WorkflowBudgetUsage, type AgentAccounting, type RunState } from "./types.js";
2
+ export declare function validateBudget(value: unknown): WorkflowBudget | undefined;
3
+ export declare function validateBudgetPatch(value: unknown): WorkflowBudgetPatch;
4
+ export declare function budgetUsage(value?: Partial<WorkflowBudgetUsage>): WorkflowBudgetUsage;
5
+ export declare class WorkflowBudgetRuntime {
6
+ #private;
7
+ readonly budget: WorkflowBudget | undefined;
8
+ readonly version: number;
9
+ constructor(budget: WorkflowBudget | undefined, version?: number, usage?: Partial<WorkflowBudgetUsage>, events?: readonly BudgetEvent[], options?: {
10
+ now?: () => number;
11
+ onChange?: () => void;
12
+ active?: boolean;
13
+ });
14
+ get usage(): WorkflowBudgetUsage;
15
+ get events(): readonly BudgetEvent[];
16
+ get hardExhausted(): boolean;
17
+ checkAgentLaunch(): void;
18
+ beforeAttempt(): void;
19
+ beforeTurn(): void;
20
+ afterTurn(accounting: AgentAccounting, final: boolean): void;
21
+ instruction(agentId?: string): string | undefined;
22
+ forAgent(agentId: string): {
23
+ beforeAttempt: () => void;
24
+ beforeTurn: () => void;
25
+ afterTurn: (accounting: AgentAccounting, final: boolean) => void;
26
+ instruction: () => string | undefined;
27
+ };
28
+ transition(state: RunState): void;
29
+ recordEvent(event: BudgetEvent): void;
30
+ snapshot(): {
31
+ usage: WorkflowBudgetUsage;
32
+ budgetEvents: readonly BudgetEvent[];
33
+ };
34
+ }
35
+ export declare function mergeBudget(budget: WorkflowBudget | undefined, patch: WorkflowBudgetPatch): WorkflowBudget | undefined;
36
+ export declare function budgetRelaxed(previous: WorkflowBudget | undefined, next: WorkflowBudget | undefined): boolean;
37
+ export declare function exhaustedBudgetDimensions(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): BudgetDimension[];
38
+ export declare function resumeBudgetAllowed(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): boolean;