pi-extensible-workflows 2.0.0 → 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.
package/README.md CHANGED
@@ -22,6 +22,8 @@ For source installs and local development, see the [installation guide](https://
22
22
 
23
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
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.
26
+
25
27
  A workflow can fan out across specialized agents, combine their results, and resume without rerunning completed work.
26
28
 
27
29
  ```js
@@ -68,7 +70,7 @@ npx pi-extensible-workflows export <workflow-name> [--name <command>] [--output
68
70
 
69
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.
70
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.
71
- Launch snapshots use identity version 4. A cold resume intentionally rejects persisted v3 runs with `RESUME_INCOMPATIBLE`; relaunch the workflow instead.
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.
72
74
 
73
75
  ## Development
74
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;
@@ -63,10 +63,6 @@ export interface AgentExecutionOptions {
63
63
  budget?: AgentBudgetHooks;
64
64
  agentOptions?: Readonly<Record<string, JsonValue>>;
65
65
  agentIdentity?: AgentIdentity;
66
- conversation?: {
67
- id: string;
68
- turn: number;
69
- };
70
66
  }
71
67
  export interface AgentExecutionRoot {
72
68
  cwd: string;
@@ -185,12 +181,6 @@ export interface SessionInput {
185
181
  extensionFactories?: InlineExtension[];
186
182
  resourcePolicy?: AgentResourcePolicy;
187
183
  options?: Record<string, JsonValue>;
188
- continuation?: {
189
- sessionId: string;
190
- sessionFile: string;
191
- leafId: string;
192
- };
193
- allowModelChange?: boolean;
194
184
  }
195
185
  export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
196
186
  export declare function createNativeAgentSession(input: SessionInput): Promise<NativeSession>;
@@ -222,10 +212,6 @@ export interface ScheduledAgentOptions {
222
212
  timeoutMs?: number | null;
223
213
  agentOptions?: Readonly<Record<string, JsonValue>>;
224
214
  agentIdentity?: AgentIdentity;
225
- conversation?: {
226
- id: string;
227
- turn: number;
228
- };
229
215
  }
230
216
  export type ScheduledAgentResult = {
231
217
  id: string;
@@ -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))
@@ -54,33 +53,8 @@ catch {
54
53
  } }
55
54
  export async function createNativeAgentSession(input) {
56
55
  const agentDir = input.agentDir ?? getAgentDir();
57
- let manager;
58
- if (input.continuation) {
59
- try {
60
- manager = SessionManager.open(input.continuation.sessionFile, input.agentDir ? join(agentDir, "sessions") : undefined, input.cwd);
61
- const header = manager.getHeader();
62
- if (!header || canonicalSourcePath(header.cwd) !== canonicalSourcePath(input.cwd) || manager.getSessionId() !== input.continuation.sessionId || !manager.getEntry(input.continuation.leafId))
63
- throw new Error("Persisted transcript identity does not match the conversation head");
64
- manager.branch(input.continuation.leafId);
65
- const context = manager.buildSessionContext();
66
- if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) {
67
- if (!input.allowModelChange)
68
- throw new Error("Persisted transcript model does not match the conversation execution policy");
69
- manager.appendModelChange(input.model.provider, input.model.model);
70
- }
71
- if (input.model.thinking && context.thinkingLevel !== input.model.thinking)
72
- throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
73
- }
74
- catch (error) {
75
- if (error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE")
76
- throw error;
77
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot reopen conversation transcript: ${error instanceof Error ? error.message : String(error)}`);
78
- }
79
- }
80
- else {
81
- manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
82
- manager.appendSessionInfo(input.sessionLabel);
83
- }
56
+ const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
57
+ manager.appendSessionInfo(input.sessionLabel);
84
58
  const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
85
59
  const model = modelRuntime.getModel(input.model.provider, input.model.model);
86
60
  if (!model)
@@ -126,9 +100,7 @@ export async function createNativeAgentSession(input) {
126
100
  resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
127
101
  await resourceLoader.reload();
128
102
  }
129
- 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 });
130
- if (input.continuation && modelFallbackMessage)
131
- 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 });
132
104
  return Object.assign(session, {
133
105
  getLeafId: () => manager.getLeafId(),
134
106
  getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
@@ -136,24 +108,6 @@ export async function createNativeAgentSession(input) {
136
108
  }
137
109
  function changedOption(options, baseline, key) { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
138
110
  function validThinking(value) { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
139
- function jsonValue(value, seen = new Set()) {
140
- if (value === null || typeof value === "boolean" || typeof value === "string")
141
- return true;
142
- if (typeof value === "number")
143
- return Number.isFinite(value);
144
- if (typeof value !== "object" || seen.has(value))
145
- return false;
146
- if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
147
- return false;
148
- const keys = Reflect.ownKeys(value);
149
- if (keys.some((key) => typeof key !== "string"))
150
- return false;
151
- seen.add(value);
152
- const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => value[key])).every((item) => jsonValue(item, seen));
153
- seen.delete(value);
154
- return valid;
155
- }
156
- function jsonObject(value) { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
157
111
  function isChildAgentToolParams(value) {
158
112
  if (!jsonObject(value) || typeof value.prompt !== "string" || typeof value.label !== "string")
159
113
  return false;
@@ -181,56 +135,7 @@ function fallbackSetupContext(root, options, signal) {
181
135
  function resourcePolicySummary(policy) {
182
136
  return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
183
137
  }
184
- function canonicalJson(value) {
185
- if (Array.isArray(value))
186
- return value.map((item) => canonicalJson(item));
187
- if (value && typeof value === "object")
188
- return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
189
- return value;
190
- }
191
- function fingerprint(value) { return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex"); }
192
- function promptFingerprint(value) { return createHash("sha256").update(value).digest("hex"); }
193
- function fixedConversationOptions(options) {
194
- const fixedOptions = structuredClone(options);
195
- delete fixedOptions.timeoutMs;
196
- delete fixedOptions.retries;
197
- return fixedOptions;
198
- }
199
- function conversationExecutionPolicy(options, setup) {
200
- return structuredClone({
201
- model: setup.sessionInput.model,
202
- tools: [...setup.sessionInput.tools],
203
- cwd: setup.sessionInput.cwd,
204
- role: options.role ?? null,
205
- worktreeOwner: options.worktreeOwner ?? null,
206
- parent: options.parent ?? null,
207
- systemPromptAppend: setup.sessionInput.systemPromptAppend ?? "",
208
- options: fixedConversationOptions(setup.options),
209
- resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
210
- });
211
- }
212
- function conversationPolicyMatches(expected, current, allowModelChange) {
213
- if (fingerprint(expected) === fingerprint(current))
214
- return true;
215
- if (!allowModelChange || !expected || typeof expected !== "object" || Array.isArray(expected) || !current || typeof current !== "object" || Array.isArray(current))
216
- return false;
217
- const expectedModel = conversationPolicyModel(expected);
218
- const currentModel = conversationPolicyModel(current);
219
- if (!expectedModel || !currentModel)
220
- return false;
221
- return fingerprint(expected) === fingerprint({ ...current, model: { ...currentModel, provider: expectedModel.provider, model: expectedModel.model } });
222
- }
223
- function conversationPolicyModel(policy) {
224
- if (!policy || typeof policy !== "object" || Array.isArray(policy))
225
- return undefined;
226
- const model = policy.model;
227
- if (!model || typeof model !== "object" || Array.isArray(model) || typeof model.provider !== "string" || typeof model.model !== "string")
228
- return undefined;
229
- const thinking = typeof model.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(model.thinking) ? model.thinking : undefined;
230
- return thinking === undefined ? { provider: model.provider, model: model.model } : { provider: model.provider, model: model.model, thinking };
231
- }
232
- function conversationFailure(message) { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
233
- 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) {
234
139
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
235
140
  const baselineOptions = structuredClone(options.agentOptions ?? {});
236
141
  const baseResourcePolicy = await root.agentResourcePolicy?.();
@@ -265,8 +170,6 @@ async function prepareAgentSetup(root, createSession, task, options, resolved, c
265
170
  setup.sessionInput.tools = [...setup.options.tools];
266
171
  if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string")
267
172
  setup.sessionInput.cwd = setup.options.cwd;
268
- if (continuation)
269
- setup.sessionInput.continuation = { sessionId: continuation.sessionId, sessionFile: continuation.sessionFile, leafId: continuation.leafId };
270
173
  const model = setup.sessionInput.model;
271
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) } : {}) };
272
175
  return { setup, summary };
@@ -291,17 +194,18 @@ export class WorkflowAgentExecutor {
291
194
  if (forbidden)
292
195
  throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${forbidden}`);
293
196
  const requestedModel = options.model ?? definition?.model;
294
- const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
295
- if (requestedModel !== undefined && this.root.blockedAliases?.has(requestedModel) && !hasAlias) {
296
- 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];
297
201
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
298
202
  }
299
- const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
203
+ const aliasThinking = requestedModel !== undefined && alias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
300
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);
301
205
  const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
302
206
  if (!availableModels.has(modelCapability(model)))
303
207
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
304
- return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
208
+ return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
305
209
  }
306
210
  async execute(task, options, signal, customTools = [], setSteer, beforeRetry) {
307
211
  const executionSignal = signal ?? this.root.runContext?.signal;
@@ -339,29 +243,7 @@ export class WorkflowAgentExecutor {
339
243
  throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
340
244
  cwd = this.root.cwd;
341
245
  }
342
- let conversationRecord;
343
- if (options.conversation) {
344
- const store = this.root.runStore;
345
- if (!store)
346
- throw conversationFailure("Conversation persistence is unavailable");
347
- try {
348
- conversationRecord = await store.conversation(options.conversation.id);
349
- }
350
- catch (error) {
351
- throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`);
352
- }
353
- if (conversationRecord) {
354
- const model = conversationPolicyModel(conversationRecord.policy);
355
- if (model)
356
- resolved = this.resolve({ ...options, modelOverride: model });
357
- }
358
- if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1)
359
- throw conversationFailure("Conversation turn must be a positive integer");
360
- if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1)
361
- throw conversationFailure(`Conversation turn ${String(options.conversation.turn)} does not continue its persisted head`);
362
- }
363
246
  const attempts = [];
364
- let conversationBaseline;
365
247
  let maxAttempts = (options.retries ?? 0) + 1;
366
248
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
367
249
  if (recoveryModel)
@@ -374,10 +256,6 @@ export class WorkflowAgentExecutor {
374
256
  let setupFailed = false;
375
257
  let budgetError;
376
258
  let turnStarted = false;
377
- let conversationSystemPrompt = "";
378
- let conversationToolDefinitionsSha256 = "";
379
- let conversationMismatch;
380
- const conversationMismatchError = () => conversationMismatch ? new WorkflowError("RESUME_INCOMPATIBLE", conversationMismatch.message) : undefined;
381
259
  const hasSchemaResult = () => schemaResult !== undefined;
382
260
  const resultTool = options.schema ? {
383
261
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
@@ -411,70 +289,21 @@ export class WorkflowAgentExecutor {
411
289
  };
412
290
  try {
413
291
  setupFailed = true;
414
- 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);
415
293
  setup = prepared.setup;
416
294
  setupSummary = prepared.summary;
417
295
  setupFailed = false;
418
296
  if (executionSignal?.aborted)
419
297
  throw new WorkflowError("CANCELLED", "Agent cancelled");
420
- if (recoveryModel && conversationRecord)
421
- setup.sessionInput.allowModelChange = true;
422
298
  const started = Date.now();
423
299
  session = await setup.createSession(setup.sessionInput);
424
300
  if (setup.sessionInput.resourcePolicy)
425
301
  setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
426
- if (options.conversation) {
427
- conversationSystemPrompt = session.systemPrompt ?? "";
428
- conversationToolDefinitionsSha256 = fingerprint(session.getToolDefinitions?.() ?? session.agent?.state.tools ?? []);
429
- const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
430
- if (conversationRecord) {
431
- if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile)
432
- throw conversationFailure("Conversation transcript identity changed");
433
- if (!session.getLeafId || (!recoveryModel && session.getLeafId() !== conversationRecord.head.leafId))
434
- throw conversationFailure("Conversation transcript leaf identity changed");
435
- if (!conversationPolicyMatches(conversationRecord.policy, currentExecutionPolicy, Boolean(recoveryModel)))
436
- throw conversationFailure("Conversation execution policy changed");
437
- if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt))
438
- throw conversationFailure("Conversation system prompt changed");
439
- if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256)
440
- throw conversationFailure("Conversation tool definitions changed");
441
- }
442
- else if (conversationBaseline) {
443
- if (!conversationPolicyMatches(conversationBaseline.executionPolicy, currentExecutionPolicy, Boolean(recoveryModel)))
444
- throw conversationFailure("Conversation execution policy changed");
445
- if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256)
446
- throw conversationFailure("Conversation tool definitions changed");
447
- }
448
- else {
449
- conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
450
- }
451
- if (!session.subscribe) {
452
- const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
453
- const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
454
- if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt))
455
- throw conversationFailure("Conversation system prompt changed");
456
- if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined)
457
- conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
458
- }
459
- if (conversationRecord && (!session.model || session.model.provider !== setup.sessionInput.model.provider || (session.model.model ?? session.model.id) !== setup.sessionInput.model.model))
460
- throw conversationFailure("Conversation model changed");
461
- }
462
302
  const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
463
303
  await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
464
304
  const activeSession = session;
465
305
  unsubscribe = activeSession.subscribe?.((event) => {
466
306
  if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
467
- if (options.conversation) {
468
- conversationSystemPrompt = session.systemPrompt;
469
- const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
470
- const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
471
- if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) {
472
- conversationMismatch = conversationFailure("Conversation system prompt changed");
473
- void session.abort?.();
474
- }
475
- if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined)
476
- conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
477
- }
478
307
  if (this.root.runStore) {
479
308
  systemPromptTurn += 1;
480
309
  const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
@@ -528,6 +357,7 @@ export class WorkflowAgentExecutor {
528
357
  if (activity?.kind === "tool" && activity.text === event.toolName)
529
358
  activity = undefined;
530
359
  report(false);
360
+ toolCalls.delete(event.toolCallId);
531
361
  }
532
362
  });
533
363
  report(false);
@@ -548,8 +378,6 @@ export class WorkflowAgentExecutor {
548
378
  if (!hasSchemaResult())
549
379
  throw error;
550
380
  }
551
- if (conversationMismatch)
552
- throw conversationMismatch;
553
381
  throwIfTerminalAssistantError(session, setup.sessionInput.model);
554
382
  {
555
383
  const completedAccounting = accounting(session.getSessionStats());
@@ -596,9 +424,6 @@ export class WorkflowAgentExecutor {
596
424
  if (schemaResult === undefined)
597
425
  throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
598
426
  }
599
- const mismatch = conversationMismatchError();
600
- if (mismatch)
601
- throw mismatch;
602
427
  const value = options.schema ? schemaResult : text(session.messages);
603
428
  if (options.worktreeOwner)
604
429
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
@@ -607,22 +432,13 @@ export class WorkflowAgentExecutor {
607
432
  await flushSystemPrompts();
608
433
  unsubscribe?.();
609
434
  const attemptAccounting = accounting(session.getSessionStats());
610
- const leafId = session.getLeafId?.() ?? undefined;
611
- if (options.conversation) {
612
- if (!leafId)
613
- throw conversationFailure("Conversation transcript has no persisted leaf");
614
- const store = this.root.runStore;
615
- if (!store)
616
- throw conversationFailure("Conversation persistence is unavailable");
617
- 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 } });
618
- }
619
435
  const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
620
436
  attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
621
437
  session.dispose();
622
438
  return { value, attempts, cwd: setupSummary.cwd };
623
439
  }
624
440
  catch (error) {
625
- 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)));
626
442
  if (session) {
627
443
  report(true);
628
444
  await progress;
@@ -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;
@@ -0,0 +1,160 @@
1
+ import { WorkflowError } from "./types.js";
2
+ import { fail, object } from "./utils.js";
3
+ function nonNegativeInteger(value) { return Number.isInteger(value) && value >= 0; }
4
+ function nonNegativeFinite(value) { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
5
+ export function validateBudget(value) {
6
+ if (value === undefined)
7
+ return undefined;
8
+ if (!object(value))
9
+ fail("INVALID_METADATA", "budget must be an object");
10
+ const result = {};
11
+ for (const [dimension, raw] of Object.entries(value)) {
12
+ if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension))
13
+ fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
14
+ if (!object(raw))
15
+ fail("INVALID_METADATA", `${dimension} budget must be an object`);
16
+ if (Object.keys(raw).some((key) => key !== "soft" && key !== "hard"))
17
+ fail("INVALID_METADATA", `${dimension} budget has an unknown limit`);
18
+ const isCost = dimension === "costUsd";
19
+ for (const key of ["soft", "hard"])
20
+ if (raw[key] !== undefined && !(isCost ? nonNegativeFinite(raw[key]) : nonNegativeInteger(raw[key])))
21
+ fail("INVALID_METADATA", `${dimension}.${key} must be a non-negative ${isCost ? "finite number" : "integer"}`);
22
+ if (raw.soft !== undefined && raw.soft !== null && raw.hard !== undefined && raw.hard !== null && raw.soft >= raw.hard)
23
+ fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
24
+ const limits = {};
25
+ if (raw.soft !== undefined)
26
+ limits.soft = raw.soft;
27
+ if (raw.hard !== undefined)
28
+ limits.hard = raw.hard;
29
+ if (Object.keys(limits).length)
30
+ result[dimension] = limits;
31
+ }
32
+ return Object.freeze(result);
33
+ }
34
+ export function validateBudgetPatch(value) {
35
+ if (!object(value))
36
+ fail("INVALID_METADATA", "budget patch must be an object");
37
+ const result = {};
38
+ for (const [dimension, raw] of Object.entries(value)) {
39
+ if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension))
40
+ fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
41
+ if (raw === null) {
42
+ result[dimension] = null;
43
+ continue;
44
+ }
45
+ if (!object(raw) || Object.keys(raw).some((key) => key !== "soft" && key !== "hard"))
46
+ fail("INVALID_METADATA", `${dimension} budget patch must contain only soft and hard`);
47
+ const limits = {};
48
+ for (const key of ["soft", "hard"])
49
+ if (Object.prototype.hasOwnProperty.call(raw, key)) {
50
+ if (raw[key] === null)
51
+ limits[key] = null;
52
+ else {
53
+ const checked = validateBudget({ [dimension]: { [key]: raw[key] } })?.[dimension];
54
+ if (checked?.[key] !== undefined)
55
+ limits[key] = checked[key];
56
+ }
57
+ }
58
+ if (limits.soft !== null && limits.hard !== null && limits.soft !== undefined && limits.hard !== undefined && limits.soft >= limits.hard)
59
+ fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
60
+ result[dimension] = limits;
61
+ }
62
+ return result;
63
+ }
64
+ export function budgetUsage(value) { return { tokens: value?.tokens ?? 0, costUsd: value?.costUsd ?? 0, durationMs: value?.durationMs ?? 0, agentLaunches: value?.agentLaunches ?? 0 }; }
65
+ export class WorkflowBudgetRuntime {
66
+ budget;
67
+ version;
68
+ #now;
69
+ #onChange;
70
+ #injected = new Set();
71
+ #seen = new Set();
72
+ #active;
73
+ #activeSince;
74
+ #usage;
75
+ #events;
76
+ #turnAccounting;
77
+ constructor(budget, version = 1, usage, events = [], options = {}) {
78
+ this.budget = budget;
79
+ this.version = version;
80
+ this.#now = options.now ?? (() => Date.now());
81
+ this.#onChange = options.onChange;
82
+ this.#active = options.active ?? true;
83
+ this.#activeSince = this.#active ? this.#now() : undefined;
84
+ this.#usage = budgetUsage(usage);
85
+ this.#events = [...events];
86
+ for (const event of events)
87
+ if (event.budgetVersion === version)
88
+ this.#seen.add(event.type);
89
+ }
90
+ get usage() { this.#syncDuration(); return { ...this.#usage }; }
91
+ get events() { return this.#events; }
92
+ get hardExhausted() { return this.#events.some((event) => event.type === "hard_exhausted" && event.budgetVersion === this.version); }
93
+ checkAgentLaunch() { this.#checkHard(["agentLaunches"]); }
94
+ beforeAttempt() { this.#checkHard(["agentLaunches"]); this.#usage.agentLaunches += 1; this.#evaluate(); }
95
+ beforeTurn() { this.#syncDuration(); this.#evaluate(); this.#checkHard(["tokens", "costUsd", "durationMs"]); }
96
+ afterTurn(accounting, final) { this.#syncDuration(); this.#applyTurn(accounting, final, this.#turnAccounting); this.#turnAccounting = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }
97
+ #applyTurn(accounting, final, previous = { input: 0, output: 0, cost: 0 }) { 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)
98
+ this.#checkHard(["tokens", "costUsd", "durationMs"]); }
99
+ instruction(agentId = "agent") { if (!this.#hasSoftCrossed() || this.#injected.has(agentId))
100
+ 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.`; }
101
+ forAgent(agentId) { let attempt = 0; let previous; return { beforeAttempt: () => { attempt += 1; previous = undefined; this.beforeAttempt(); }, beforeTurn: () => { this.beforeTurn(); }, afterTurn: (accounting, final) => { this.#applyTurn(accounting, final, previous); previous = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }, instruction: () => this.instruction(`${agentId}:${String(attempt + 1)}`) }; }
102
+ transition(state) { const active = state === "running"; if (active === this.#active)
103
+ return; if (active) {
104
+ this.#active = true;
105
+ this.#activeSince = this.#now();
106
+ }
107
+ else {
108
+ this.#syncDuration();
109
+ this.#evaluate();
110
+ this.#active = false;
111
+ this.#activeSince = undefined;
112
+ } this.#onChange?.(); }
113
+ #syncDuration() { if (this.#active && this.#activeSince !== undefined) {
114
+ const now = this.#now();
115
+ this.#usage.durationMs += Math.max(0, now - this.#activeSince);
116
+ this.#activeSince = now;
117
+ } }
118
+ #hasSoftCrossed() { return !!this.budget && Object.entries(this.budget).some(([dimension, limits]) => limits.soft !== undefined && this.#usage[dimension] >= limits.soft); }
119
+ #checkHard(dimensions) { const exhausted = dimensions.filter((dimension) => { const hard = this.budget?.[dimension]?.hard; return hard !== undefined && this.#usage[dimension] >= hard; }); if (!exhausted.length)
120
+ 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}`); }
121
+ #evaluate() { const budget = this.budget; if (!budget)
122
+ return; const soft = Object.keys(budget).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.soft !== undefined && this.#usage[dimension] >= limits.soft; }); if (soft.length)
123
+ this.#record("soft_crossed", soft); const overrun = Object.keys(budget).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && this.#usage[dimension] > limits.hard; }); if (overrun.length)
124
+ this.#record("hard_overrun", overrun); }
125
+ #record(type, dimensions) { if (this.#seen.has(type))
126
+ 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?.(); }
127
+ recordEvent(event) { this.#events.push(structuredClone(event)); }
128
+ snapshot() { return { usage: this.usage, budgetEvents: [...this.#events] }; }
129
+ }
130
+ export function mergeBudget(budget, patch) { const merged = structuredClone(budget ?? {}); for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"])
131
+ if (Object.prototype.hasOwnProperty.call(patch, dimension)) {
132
+ const value = patch[dimension];
133
+ if (value === null) {
134
+ Reflect.deleteProperty(merged, dimension);
135
+ continue;
136
+ }
137
+ const next = { ...(merged[dimension] ?? {}) };
138
+ for (const key of ["soft", "hard"])
139
+ if (value && Object.prototype.hasOwnProperty.call(value, key)) {
140
+ const limit = value[key];
141
+ if (limit === null)
142
+ Reflect.deleteProperty(next, key);
143
+ else if (limit !== undefined)
144
+ next[key] = limit;
145
+ }
146
+ if (Object.keys(next).length)
147
+ merged[dimension] = next;
148
+ else
149
+ Reflect.deleteProperty(merged, dimension);
150
+ } return validateBudget(merged); }
151
+ export function budgetRelaxed(previous, next) { for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"]) {
152
+ const oldLimit = previous?.[dimension];
153
+ const newLimit = next?.[dimension];
154
+ for (const key of ["soft", "hard"])
155
+ if ((oldLimit?.[key] !== undefined && newLimit?.[key] === undefined) || (oldLimit?.[key] !== undefined && newLimit?.[key] !== undefined && newLimit[key] > oldLimit[key]))
156
+ return true;
157
+ } return false; }
158
+ export function exhaustedBudgetDimensions(budget, usage) { if (!budget)
159
+ return []; return Object.keys(budget).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && usage[dimension] >= limits.hard; }); }
160
+ export function resumeBudgetAllowed(budget, usage) { return exhaustedBudgetDimensions(budget, usage).length === 0; }