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
@@ -1,4 +1,3 @@
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";
@@ -6,10 +5,10 @@ import { Value } from "typebox/value";
6
5
  import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type AgentSessionEvent, type InlineExtension, type SessionStats, type ToolDefinition } from "@earendil-works/pi-coding-agent";
7
6
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
8
7
  type AgentMessage = { role: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
9
- import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./index.js";
10
- import { mergeAgentResourceExclusions, resolveModelReference, WorkflowError } from "./index.js";
11
- import type { ConversationHead, PersistedConversation, RunStore } from "./persistence.js";
12
-
8
+ import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./types.js";
9
+ import { jsonObject, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference } from "./utils.js";
10
+ import { WorkflowError } from "./types.js";
11
+ import type { RunStore } from "./persistence.js";
13
12
  export interface AgentBudgetHooks {
14
13
  beforeAttempt(): void;
15
14
  beforeTurn(): void;
@@ -17,6 +16,8 @@ export interface AgentBudgetHooks {
17
16
  instruction(): string | undefined;
18
17
  }
19
18
  export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[]; disabledAgentResources?: AgentResourceExclusions }
19
+ export interface AgentProviderFailure { label: string; provider: string; model: string; error: string }
20
+ export type AgentProviderRecovery = "retry" | "abort" | { model: string };
20
21
  export interface AgentExecutionOptions {
21
22
  label: string;
22
23
  workflowName: string;
@@ -26,6 +27,8 @@ export interface AgentExecutionOptions {
26
27
  thinking?: ThinkingLevel;
27
28
  onProgress?: (progress: AgentProgress) => void | Promise<void>;
28
29
  onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => void | Promise<void>;
30
+ providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>;
31
+ modelOverride?: ModelSpec;
29
32
  tools?: readonly string[];
30
33
  effectiveTools?: readonly string[];
31
34
  role?: string;
@@ -38,7 +41,6 @@ export interface AgentExecutionOptions {
38
41
  budget?: AgentBudgetHooks;
39
42
  agentOptions?: Readonly<Record<string, JsonValue>>;
40
43
  agentIdentity?: AgentIdentity;
41
- conversation?: { id: string; turn: number };
42
44
  }
43
45
  export interface AgentExecutionRoot {
44
46
  cwd: string;
@@ -85,7 +87,7 @@ export interface NativeSession {
85
87
  abort?(): Promise<void>;
86
88
  dispose(): void;
87
89
  }
88
- export interface SessionInput { cwd: string; model: ModelSpec; tools: string[]; sessionLabel: string; agentDir?: string; customTools?: ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string; extensionFactories?: InlineExtension[]; resourcePolicy?: AgentResourcePolicy; options?: Record<string, JsonValue>; continuation?: { sessionId: string; sessionFile: string; leafId: string } }
90
+ export interface SessionInput { cwd: string; model: ModelSpec; tools: string[]; sessionLabel: string; agentDir?: string; customTools?: ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string; extensionFactories?: InlineExtension[]; resourcePolicy?: AgentResourcePolicy; options?: Record<string, JsonValue> }
89
91
  export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
90
92
 
91
93
  function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
@@ -93,7 +95,6 @@ function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: T
93
95
  const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
94
96
  return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
95
97
  }
96
- function modelCapability(model: ModelSpec): string { return `${model.provider}/${model.model}`; }
97
98
 
98
99
  function text(messages: readonly AgentMessage[]): string {
99
100
  const message = [...messages].reverse().find((item) => item.role === "assistant");
@@ -110,9 +111,22 @@ function latestAssistantHasToolCall(messages: readonly AgentMessage[]): boolean
110
111
  return hasToolCall(message);
111
112
  }
112
113
 
113
- function throwIfTerminalAssistantError(session: NativeSession): void {
114
+ type TerminalProviderError = { provider: string; model: string; error: string };
115
+ function throwIfTerminalAssistantError(session: NativeSession, fallbackModel: ModelSpec): void {
114
116
  const message = [...session.messages].reverse().find((item) => item.role === "assistant");
115
- if (message?.stopReason === "error") throw new WorkflowError("AGENT_FAILED", message.errorMessage ?? "Native Pi assistant ended with a terminal provider error");
117
+ if (message?.stopReason !== "error") return;
118
+ const provider = session.model?.provider ?? fallbackModel.provider;
119
+ const model = session.model?.model ?? session.model?.id ?? fallbackModel.model;
120
+ const error = message.errorMessage ?? "Native Pi assistant ended with a terminal provider error";
121
+ const failure = new WorkflowError("AGENT_FAILED", error);
122
+ Object.defineProperty(failure, "terminalProviderError", { value: { provider, model, error }, configurable: true });
123
+ throw failure;
124
+ }
125
+ function terminalProviderError(error: WorkflowError): TerminalProviderError | undefined {
126
+ const value = (error as WorkflowError & { terminalProviderError?: unknown }).terminalProviderError;
127
+ if (!value || typeof value !== "object") return undefined;
128
+ const candidate = value as Partial<TerminalProviderError>;
129
+ return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
116
130
  }
117
131
 
118
132
  function accounting(stats: NativeSessionStats): AgentAccounting {
@@ -122,24 +136,8 @@ function canonicalSourcePath(path: string): string { try { return realpathSync(p
122
136
 
123
137
  export async function createNativeAgentSession(input: SessionInput): Promise<NativeSession> {
124
138
  const agentDir = input.agentDir ?? getAgentDir();
125
- let manager: SessionManager;
126
- if (input.continuation) {
127
- try {
128
- manager = SessionManager.open(input.continuation.sessionFile, input.agentDir ? join(agentDir, "sessions") : undefined, input.cwd);
129
- const header = manager.getHeader();
130
- if (!header || canonicalSourcePath(header.cwd) !== canonicalSourcePath(input.cwd) || manager.getSessionId() !== input.continuation.sessionId || !manager.getEntry(input.continuation.leafId)) throw new Error("Persisted transcript identity does not match the conversation head");
131
- manager.branch(input.continuation.leafId);
132
- const context = manager.buildSessionContext();
133
- if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) throw new Error("Persisted transcript model does not match the conversation execution policy");
134
- if (input.model.thinking && context.thinkingLevel !== input.model.thinking) throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
135
- } catch (error) {
136
- if (error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE") throw error;
137
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot reopen conversation transcript: ${error instanceof Error ? error.message : String(error)}`);
138
- }
139
- } else {
140
- manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
141
- manager.appendSessionInfo(input.sessionLabel);
142
- }
139
+ const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
140
+ manager.appendSessionInfo(input.sessionLabel);
143
141
  const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
144
142
  const model = modelRuntime.getModel(input.model.provider, input.model.model);
145
143
  if (!model) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${input.model.provider}/${input.model.model}`);
@@ -183,8 +181,7 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
183
181
  resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
184
182
  await resourceLoader.reload();
185
183
  }
186
- 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 });
187
- if (input.continuation && modelFallbackMessage) throw new WorkflowError("RESUME_INCOMPATIBLE", modelFallbackMessage);
184
+ 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 });
188
185
  return Object.assign(session, {
189
186
  getLeafId: () => manager.getLeafId(),
190
187
  getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
@@ -192,19 +189,6 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
192
189
  }
193
190
  function changedOption(options: Readonly<Record<string, JsonValue>>, baseline: Readonly<Record<string, JsonValue>>, key: string): boolean { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
194
191
  function validThinking(value: unknown): value is ThinkingLevel { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
195
- function jsonValue(value: unknown, seen = new Set<object>()): value is JsonValue {
196
- if (value === null || typeof value === "boolean" || typeof value === "string") return true;
197
- if (typeof value === "number") return Number.isFinite(value);
198
- if (typeof value !== "object" || seen.has(value)) return false;
199
- if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
200
- const keys = Reflect.ownKeys(value);
201
- if (keys.some((key) => typeof key !== "string")) return false;
202
- seen.add(value);
203
- const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => (value as Record<string, unknown>)[key as string])).every((item) => jsonValue(item, seen));
204
- seen.delete(value);
205
- return valid;
206
- }
207
- function jsonObject(value: unknown): value is Record<string, JsonValue> { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
208
192
  interface ChildAgentToolParams {
209
193
  prompt: string;
210
194
  label: string;
@@ -236,34 +220,7 @@ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionO
236
220
  function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
237
221
  return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
238
222
  }
239
- function canonicalJson(value: unknown): unknown {
240
- if (Array.isArray(value)) return value.map((item) => canonicalJson(item));
241
- if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
242
- return value;
243
- }
244
- function fingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex"); }
245
- function promptFingerprint(value: string): string { return createHash("sha256").update(value).digest("hex"); }
246
- function fixedConversationOptions(options: Readonly<Record<string, JsonValue>>): JsonValue {
247
- const fixedOptions = structuredClone(options) as Record<string, JsonValue>;
248
- delete fixedOptions.timeoutMs;
249
- delete fixedOptions.retries;
250
- return fixedOptions;
251
- }
252
- function conversationExecutionPolicy(options: AgentExecutionOptions, setup: AgentSetup): JsonValue {
253
- return structuredClone({
254
- model: setup.sessionInput.model,
255
- tools: [...setup.sessionInput.tools],
256
- cwd: setup.sessionInput.cwd,
257
- role: options.role ?? null,
258
- worktreeOwner: options.worktreeOwner ?? null,
259
- parent: options.parent ?? null,
260
- systemPromptAppend: setup.sessionInput.systemPromptAppend ?? "",
261
- options: fixedConversationOptions(setup.options),
262
- resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
263
- }) as unknown as JsonValue;
264
- }
265
- function conversationFailure(message: string): WorkflowError { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
266
- async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined, continuation?: ConversationHead): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
223
+ async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
267
224
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
268
225
  const baselineOptions = structuredClone(options.agentOptions ?? {});
269
226
  const baseResourcePolicy = await root.agentResourcePolicy?.();
@@ -285,7 +242,6 @@ async function prepareAgentSetup(root: AgentExecutionRoot, createSession: Sessio
285
242
  if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking)) setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
286
243
  if (changedOption(setup.options, baselineOptions, "tools") && Array.isArray(setup.options.tools) && setup.options.tools.every((tool) => typeof tool === "string")) setup.sessionInput.tools = [...setup.options.tools];
287
244
  if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
288
- if (continuation) setup.sessionInput.continuation = { sessionId: continuation.sessionId, sessionFile: continuation.sessionFile, leafId: continuation.leafId };
289
245
  const model = setup.sessionInput.model;
290
246
  const summary: AgentSetupSummary = { 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) } : {}) };
291
247
  return { setup, summary };
@@ -304,20 +260,22 @@ export class WorkflowAgentExecutor {
304
260
  const forbidden = requested.find((tool) => !this.root.tools.has(tool));
305
261
  if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${forbidden}`);
306
262
  const requestedModel = options.model ?? definition?.model;
307
- const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
308
- if (requestedModel !== undefined && this.root.blockedAliases?.has(requestedModel) && !hasAlias) { const target = this.root.blockedAliasTargets?.[requestedModel]; throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`); }
309
- const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
310
- 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);
263
+ const alias = requestedModel === undefined ? undefined : modelAliasName(requestedModel, this.root.modelAliases ?? {});
264
+ const blockedAlias = requestedModel?.split(":", 1)[0];
265
+ if (requestedModel !== undefined && blockedAlias && this.root.blockedAliases?.has(blockedAlias) && !alias) { const target = this.root.blockedAliasTargets?.[blockedAlias]; throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`); }
266
+ const aliasThinking = requestedModel !== undefined && alias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
267
+ 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);
311
268
  const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
312
269
  if (!availableModels.has(modelCapability(model))) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
313
- return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
270
+ return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
314
271
  }
315
272
 
316
273
  async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
317
274
  const executionSignal = signal ?? this.root.runContext?.signal;
318
275
  if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0) throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
319
276
  if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw new WorkflowError("INVALID_METADATA", "timeoutMs must be null or a positive integer");
320
- const resolved = this.resolve(options);
277
+ let resolved = this.resolve(options);
278
+ let recoveryModel: ModelSpec | undefined;
321
279
  let cwd: string;
322
280
  if (options.parent) {
323
281
  if (!options.cwd) throw new WorkflowError("INVALID_METADATA", "Child agents require their parent cwd");
@@ -337,20 +295,11 @@ export class WorkflowAgentExecutor {
337
295
  if (options.cwd) throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
338
296
  cwd = this.root.cwd;
339
297
  }
340
- let conversationRecord: PersistedConversation | undefined;
341
- if (options.conversation) {
342
- const store = this.root.runStore;
343
- if (!store) throw conversationFailure("Conversation persistence is unavailable");
344
- try { conversationRecord = await store.conversation(options.conversation.id); } catch (error) { throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
345
- if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1) throw conversationFailure("Conversation turn must be a positive integer");
346
- if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1) throw conversationFailure(`Conversation turn ${String(options.conversation.turn)} does not continue its persisted head`);
347
- }
348
298
  const attempts: AgentAttempt[] = [];
349
- let conversationBaseline: { executionPolicy: JsonValue; toolDefinitionsSha256: string; systemPrompt?: string; systemPromptSha256?: string } | undefined;
350
- const maxAttempts = (options.retries ?? 0) + 1;
299
+ let maxAttempts = (options.retries ?? 0) + 1;
351
300
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
301
+ if (recoveryModel) resolved = this.resolve({ ...options, modelOverride: recoveryModel });
352
302
  options.budget?.beforeAttempt();
353
- let accepted = false;
354
303
  let schemaResult: JsonValue | undefined;
355
304
  let session: NativeSession | undefined;
356
305
  let setup: AgentSetup | undefined;
@@ -358,16 +307,12 @@ export class WorkflowAgentExecutor {
358
307
  let setupFailed = false;
359
308
  let budgetError: WorkflowError | undefined;
360
309
  let turnStarted = false;
361
- let conversationSystemPrompt = "";
362
- let conversationToolDefinitionsSha256 = "";
363
- let conversationMismatch: WorkflowError | undefined;
364
- const conversationMismatchError = () => conversationMismatch ? new WorkflowError("RESUME_INCOMPATIBLE", conversationMismatch.message) : undefined;
365
310
  const hasSchemaResult = () => schemaResult !== undefined;
366
311
  const resultTool = options.schema ? {
367
312
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
368
313
  async execute(_id: string, value: unknown) {
369
- if (!accepted) return { content: [{ type: "text" as const, text: "Result acceptance is not enabled yet." }], details: {}, isError: true };
370
314
  if (!Value.Check(options.schema as object, value)) return { content: [{ type: "text" as const, text: "Result does not match the required schema." }], details: {}, isError: true };
315
+ if (schemaResult !== undefined) return { content: [{ type: "text" as const, text: "Result has already been accepted." }], details: {}, isError: true };
371
316
  schemaResult = structuredClone(value) as JsonValue;
372
317
  void session?.abort?.();
373
318
  return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
@@ -391,7 +336,7 @@ export class WorkflowAgentExecutor {
391
336
  };
392
337
  try {
393
338
  setupFailed = true;
394
- const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool, conversationRecord?.head);
339
+ const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool);
395
340
  setup = prepared.setup;
396
341
  setupSummary = prepared.summary;
397
342
  setupFailed = false;
@@ -399,42 +344,11 @@ export class WorkflowAgentExecutor {
399
344
  const started = Date.now();
400
345
  session = await setup.createSession(setup.sessionInput);
401
346
  if (setup.sessionInput.resourcePolicy) setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
402
- if (options.conversation) {
403
- conversationSystemPrompt = session.systemPrompt ?? "";
404
- conversationToolDefinitionsSha256 = fingerprint(session.getToolDefinitions?.() ?? session.agent?.state.tools ?? []);
405
- const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
406
- if (conversationRecord) {
407
- if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile) throw conversationFailure("Conversation transcript identity changed");
408
- if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId) throw conversationFailure("Conversation transcript leaf identity changed");
409
- if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationRecord.policy)) throw conversationFailure("Conversation execution policy changed");
410
- if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt)) throw conversationFailure("Conversation system prompt changed");
411
- if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
412
- } else if (conversationBaseline) {
413
- if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationBaseline.executionPolicy)) throw conversationFailure("Conversation execution policy changed");
414
- if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
415
- } else {
416
- conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
417
- }
418
- if (!session.subscribe) {
419
- const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
420
- const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
421
- if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) throw conversationFailure("Conversation system prompt changed");
422
- if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
423
- }
424
- if (conversationRecord && (!session.model || session.model.provider !== setup.sessionInput.model.provider || (session.model.model ?? session.model.id) !== setup.sessionInput.model.model)) throw conversationFailure("Conversation model changed");
425
- }
426
347
  const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
427
348
  await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
428
349
  const activeSession = session;
429
350
  unsubscribe = activeSession.subscribe?.((event) => {
430
351
  if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
431
- if (options.conversation) {
432
- conversationSystemPrompt = session.systemPrompt;
433
- const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
434
- const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
435
- if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) { conversationMismatch = conversationFailure("Conversation system prompt changed"); void session.abort?.(); }
436
- if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
437
- }
438
352
  if (this.root.runStore) {
439
353
  systemPromptTurn += 1;
440
354
  const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
@@ -449,14 +363,14 @@ export class WorkflowAgentExecutor {
449
363
  activity = undefined;
450
364
  if (event.message.role === "assistant") {
451
365
  const needsMoreWork = hasToolCall(event.message);
452
- const final = !needsMoreWork || (options.schema !== undefined && accepted);
366
+ const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
453
367
  if (!budgetError) { try { options.budget?.afterTurn(accounting(activeSession.getSessionStats()), final); if (!final) { const instruction = options.budget?.instruction(); if (instruction) void session?.steer?.(instruction); } } catch (error) { budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error)); void session?.abort?.(); } }
454
368
  turnStarted = false;
455
369
  report(true);
456
370
  }
457
371
  }
458
372
  if (event.type === "tool_execution_start") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" }); activity = { kind: "tool", text: event.toolName }; report(false); }
459
- if (event.type === "tool_execution_end") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: event.isError ? "failed" : "completed" }); if (activity?.kind === "tool" && activity.text === event.toolName) activity = undefined; report(false); }
373
+ if (event.type === "tool_execution_end") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: event.isError ? "failed" : "completed" }); if (activity?.kind === "tool" && activity.text === event.toolName) activity = undefined; report(false); toolCalls.delete(event.toolCallId); }
460
374
  });
461
375
  report(false);
462
376
  if (setSteer) {
@@ -468,23 +382,21 @@ export class WorkflowAgentExecutor {
468
382
  const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
469
383
  options.budget?.beforeTurn();
470
384
  turnStarted = true;
471
- await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
472
- if (conversationMismatch) throw conversationMismatch;
473
- throwIfTerminalAssistantError(session);
474
- { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? false : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
385
+ try { await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { if (!hasSchemaResult()) throw error; }
386
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
387
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
475
388
  if (budgetError) throw budgetError;
476
389
  if (options.schema) {
477
- accepted = true;
478
- try { options.budget?.beforeTurn(); turnStarted = true; 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); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
479
- throwIfTerminalAssistantError(session);
390
+ if (!hasSchemaResult()) {
391
+ try { options.budget?.beforeTurn(); turnStarted = true; 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); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
392
+ }
393
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
480
394
  if (!hasSchemaResult()) {
481
395
  try { options.budget?.beforeTurn(); turnStarted = true; await promptWithProviderPause(session, "Your result was missing or invalid. Repair it by calling workflow_result exactly once with a schema-valid value.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
482
- throwIfTerminalAssistantError(session);
396
+ throwIfTerminalAssistantError(session, setup.sessionInput.model);
483
397
  }
484
398
  if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
485
399
  }
486
- const mismatch = conversationMismatchError();
487
- if (mismatch) throw mismatch;
488
400
  const value = options.schema ? schemaResult as JsonValue : text(session.messages);
489
401
  if (options.worktreeOwner) await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
490
402
  report(true);
@@ -492,19 +404,12 @@ export class WorkflowAgentExecutor {
492
404
  await flushSystemPrompts();
493
405
  unsubscribe?.();
494
406
  const attemptAccounting = accounting(session.getSessionStats());
495
- const leafId = session.getLeafId?.() ?? undefined;
496
- if (options.conversation) {
497
- if (!leafId) throw conversationFailure("Conversation transcript has no persisted leaf");
498
- const store = this.root.runStore;
499
- if (!store) throw conversationFailure("Conversation persistence is unavailable");
500
- 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 } });
501
- }
502
407
  const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
503
408
  attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
504
409
  session.dispose();
505
410
  return { value, attempts, cwd: setupSummary.cwd };
506
411
  } catch (error) {
507
- const typed = budgetError ?? conversationMismatch ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
412
+ const typed = budgetError ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
508
413
  if (session) {
509
414
  report(true);
510
415
  await progress;
@@ -517,6 +422,22 @@ export class WorkflowAgentExecutor {
517
422
  session.dispose();
518
423
  }
519
424
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED") await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
425
+ const terminal = terminalProviderError(typed);
426
+ if (terminal && options.providerErrorRecovery) {
427
+ let recovery: AgentProviderRecovery;
428
+ try { recovery = await options.providerErrorRecovery({ label: options.label, ...terminal }); } catch { throw Object.assign(typed, { attempts }); }
429
+ if (recovery === "retry" || typeof recovery === "object" && typeof recovery.model === "string") {
430
+ if (typeof recovery === "object") {
431
+ try {
432
+ const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
433
+ recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
434
+ } catch { throw Object.assign(typed, { attempts }); }
435
+ }
436
+ maxAttempts += 1;
437
+ beforeRetry?.();
438
+ continue;
439
+ }
440
+ }
520
441
  if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE") throw Object.assign(typed, { attempts });
521
442
  beforeRetry?.();
522
443
  }
@@ -540,7 +461,6 @@ export interface ScheduledAgentOptions {
540
461
  timeoutMs?: number | null;
541
462
  agentOptions?: Readonly<Record<string, JsonValue>>;
542
463
  agentIdentity?: AgentIdentity;
543
- conversation?: { id: string; turn: number };
544
464
  }
545
465
 
546
466
  export type ScheduledAgentResult =
@@ -676,6 +596,22 @@ export class FairAgentScheduler {
676
596
  if (nodes.every(({ restored }) => restored)) run.logical = 0;
677
597
  }
678
598
 
599
+ removeRun(runId: string): void {
600
+ const run = this.#runs.get(runId);
601
+ if (!run) return;
602
+ const nodes = [...this.#nodes.values()].filter((node) => node.runId === runId);
603
+ if (run.active > 0 || nodes.some(({ state }) => !["completed", "failed", "cancelled"].includes(state))) throw new WorkflowError("INTERNAL_ERROR", `Cannot remove active scheduler run: ${runId}`);
604
+ for (const { id } of nodes) this.#nodes.delete(id);
605
+ this.#runs.delete(runId);
606
+ const index = this.#runOrder.indexOf(runId);
607
+ if (index >= 0) {
608
+ this.#runOrder.splice(index, 1);
609
+ if (index < this.#cursor) this.#cursor -= 1;
610
+ if (this.#cursor >= this.#runOrder.length) this.#cursor = 0;
611
+ }
612
+ this.#dispatch();
613
+ }
614
+
679
615
  toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[] {
680
616
  const parent = this.#node(parentId);
681
617
  if (!parent.options.tools.includes("agent")) return [];
@@ -772,6 +708,7 @@ export class FairAgentScheduler {
772
708
  if (["completed", "failed", "cancelled"].includes(node.state)) return;
773
709
  const heldPermit = node.state === "running" || node.state === "retrying";
774
710
  node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
711
+ Reflect.deleteProperty(node, "steer");
775
712
  this.#persist(node.runId);
776
713
  if (heldPermit) this.#release(node.runId);
777
714
  for (const childId of node.children) { const child = this.#nodes.get(childId); if (child && !child.collected) this.#cancelTree(child); }
package/src/budget.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { WorkflowError, type BudgetDimension, type BudgetEvent, type BudgetLimits, type WorkflowBudget, type WorkflowBudgetPatch, type WorkflowBudgetUsage, type AgentAccounting, type RunState } from "./types.js";
2
+ import { fail, object } from "./utils.js";
3
+
4
+ function nonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
5
+ function nonNegativeFinite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
6
+ export function validateBudget(value: unknown): WorkflowBudget | undefined {
7
+ if (value === undefined) return undefined;
8
+ if (!object(value)) fail("INVALID_METADATA", "budget must be an object");
9
+ const result: WorkflowBudget = {};
10
+ for (const [dimension, raw] of Object.entries(value)) {
11
+ if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
12
+ if (!object(raw)) fail("INVALID_METADATA", `${dimension} budget must be an object`);
13
+ if (Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget has an unknown limit`);
14
+ const isCost = dimension === "costUsd";
15
+ 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"}`);
16
+ 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`);
17
+ const limits: BudgetLimits = {};
18
+ if (raw.soft !== undefined) limits.soft = raw.soft as number;
19
+ if (raw.hard !== undefined) limits.hard = raw.hard as number;
20
+ if (Object.keys(limits).length) (result as Record<string, BudgetLimits>)[dimension] = limits;
21
+ }
22
+ return Object.freeze(result);
23
+ }
24
+ export function validateBudgetPatch(value: unknown): WorkflowBudgetPatch {
25
+ if (!object(value)) fail("INVALID_METADATA", "budget patch must be an object");
26
+ const result: WorkflowBudgetPatch = {};
27
+ for (const [dimension, raw] of Object.entries(value)) {
28
+ if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
29
+ if (raw === null) { (result as Record<string, null>)[dimension] = null; continue; }
30
+ if (!object(raw) || Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget patch must contain only soft and hard`);
31
+ const limits: { soft?: number | null; hard?: number | null } = {};
32
+ for (const key of ["soft", "hard"] as const) if (Object.prototype.hasOwnProperty.call(raw, key)) {
33
+ if (raw[key] === null) limits[key] = null;
34
+ else { const checked = validateBudget({ [dimension]: { [key]: raw[key] } })?.[dimension as BudgetDimension]; if (checked?.[key] !== undefined) limits[key] = checked[key]; }
35
+ }
36
+ 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`);
37
+ (result as Record<string, { soft?: number | null; hard?: number | null }>)[dimension] = limits;
38
+ }
39
+ return result;
40
+ }
41
+ export function budgetUsage(value?: Partial<WorkflowBudgetUsage>): WorkflowBudgetUsage { return { tokens: value?.tokens ?? 0, costUsd: value?.costUsd ?? 0, durationMs: value?.durationMs ?? 0, agentLaunches: value?.agentLaunches ?? 0 }; }
42
+ export class WorkflowBudgetRuntime {
43
+ readonly #now: () => number;
44
+ readonly #onChange: (() => void) | undefined;
45
+ readonly #injected = new Set<string>();
46
+ readonly #seen = new Set<string>();
47
+ #active: boolean;
48
+ #activeSince: number | undefined;
49
+ #usage: WorkflowBudgetUsage;
50
+ #events: BudgetEvent[];
51
+ #turnAccounting?: { input: number; output: number; cost: number };
52
+ constructor(readonly budget: WorkflowBudget | undefined, readonly version = 1, usage?: Partial<WorkflowBudgetUsage>, events: readonly BudgetEvent[] = [], options: { now?: () => number; onChange?: () => void; active?: boolean } = {}) { this.#now = options.now ?? (() => Date.now()); this.#onChange = options.onChange; this.#active = options.active ?? true; this.#activeSince = this.#active ? this.#now() : undefined; this.#usage = budgetUsage(usage); this.#events = [...events]; for (const event of events) if (event.budgetVersion === version) this.#seen.add(event.type); }
53
+ get usage(): WorkflowBudgetUsage { this.#syncDuration(); return { ...this.#usage }; }
54
+ get events(): readonly BudgetEvent[] { return this.#events; }
55
+ get hardExhausted(): boolean { return this.#events.some((event) => event.type === "hard_exhausted" && event.budgetVersion === this.version); }
56
+ checkAgentLaunch(): void { this.#checkHard(["agentLaunches"]); }
57
+ beforeAttempt(): void { this.#checkHard(["agentLaunches"]); this.#usage.agentLaunches += 1; this.#evaluate(); }
58
+ beforeTurn(): void { this.#syncDuration(); this.#evaluate(); this.#checkHard(["tokens", "costUsd", "durationMs"]); }
59
+ 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 }; }
60
+ #applyTurn(accounting: AgentAccounting, final: boolean, previous = { input: 0, output: 0, cost: 0 }): void { this.#usage.tokens += Math.max(0, accounting.input - previous.input) + Math.max(0, accounting.output - previous.output); this.#usage.costUsd += Math.max(0, accounting.cost - previous.cost); this.#evaluate(); if (!final) this.#checkHard(["tokens", "costUsd", "durationMs"]); }
61
+ instruction(agentId = "agent"): string | undefined { if (!this.#hasSoftCrossed() || this.#injected.has(agentId)) return undefined; this.#injected.add(agentId); 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.`; }
62
+ forAgent(agentId: string) { let attempt = 0; let previous: { input: number; output: number; cost: number } | undefined; return { beforeAttempt: () => { attempt += 1; previous = undefined; this.beforeAttempt(); }, beforeTurn: () => { this.beforeTurn(); }, afterTurn: (accounting: AgentAccounting, final: boolean) => { this.#applyTurn(accounting, final, previous); previous = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }, instruction: () => this.instruction(`${agentId}:${String(attempt + 1)}`) }; }
63
+ transition(state: RunState): void { const active = state === "running"; if (active === this.#active) return; if (active) { this.#active = true; this.#activeSince = this.#now(); } else { this.#syncDuration(); this.#evaluate(); this.#active = false; this.#activeSince = undefined; } this.#onChange?.(); }
64
+ #syncDuration(): void { if (this.#active && this.#activeSince !== undefined) { const now = this.#now(); this.#usage.durationMs += Math.max(0, now - this.#activeSince); this.#activeSince = now; } }
65
+ #hasSoftCrossed(): boolean { return !!this.budget && (Object.entries(this.budget) as [BudgetDimension, BudgetLimits][]).some(([dimension, limits]) => limits.soft !== undefined && this.#usage[dimension] >= limits.soft); }
66
+ #checkHard(dimensions: readonly BudgetDimension[]): void { const exhausted = dimensions.filter((dimension) => { const hard = this.budget?.[dimension]?.hard; return hard !== undefined && this.#usage[dimension] >= hard; }); if (!exhausted.length) return; this.#record("hard_exhausted", exhausted); const detail = exhausted.map((dimension) => `${dimension} usage=${String(this.#usage[dimension])} hard=${String(this.budget?.[dimension]?.hard)}`).join(", "); throw new WorkflowError("BUDGET_EXHAUSTED", `Budget version ${String(this.version)} exhausted: ${detail}`); }
67
+ #evaluate(): void { const budget = this.budget; if (!budget) return; const soft = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.soft !== undefined && this.#usage[dimension] >= limits.soft; }); if (soft.length) this.#record("soft_crossed", soft); const overrun = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && this.#usage[dimension] > limits.hard; }); if (overrun.length) this.#record("hard_overrun", overrun); }
68
+ #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?.(); }
69
+ recordEvent(event: BudgetEvent): void { this.#events.push(structuredClone(event)); }
70
+ snapshot(): { usage: WorkflowBudgetUsage; budgetEvents: readonly BudgetEvent[] } { return { usage: this.usage, budgetEvents: [...this.#events] }; }
71
+ }
72
+ export function mergeBudget(budget: WorkflowBudget | undefined, patch: WorkflowBudgetPatch): WorkflowBudget | undefined { const merged: WorkflowBudget = structuredClone(budget ?? {}); for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) if (Object.prototype.hasOwnProperty.call(patch, dimension)) { const value = patch[dimension]; if (value === null) { Reflect.deleteProperty(merged, dimension); continue; } const next: BudgetLimits = { ...(merged[dimension] ?? {}) }; 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; } if (Object.keys(next).length) (merged as Record<string, BudgetLimits>)[dimension] = next; else Reflect.deleteProperty(merged, dimension); } return validateBudget(merged); }
73
+ 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; }
74
+ export function exhaustedBudgetDimensions(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): BudgetDimension[] { if (!budget) return []; return (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && usage[dimension] >= limits.hard; }); }
75
+ export function resumeBudgetAllowed(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): boolean { return exhaustedBudgetDimensions(budget, usage).length === 0; }