pi-extensible-workflows 3.2.0 → 3.3.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.
@@ -1,4 +1,5 @@
1
- import { realpathSync } from "node:fs";
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { join, resolve } from "node:path";
3
4
  import { Type } from "@earendil-works/pi-ai";
4
5
  import { Value } from "typebox/value";
@@ -16,7 +17,7 @@ export interface AgentBudgetHooks {
16
17
  afterTurn(accounting: AgentAccounting, final: boolean): void;
17
18
  instruction(): string | undefined;
18
19
  }
19
- export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[]; disabledAgentResources?: AgentResourceExclusions }
20
+ export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[]; overrideSystemPrompt?: boolean; disabledAgentResources?: AgentResourceExclusions }
20
21
  export interface AgentProviderFailure { label: string; provider: string; model: string; error: string }
21
22
  export type AgentProviderRecovery = "retry" | "abort" | { model: string };
22
23
  export interface AgentExecutionOptions {
@@ -49,6 +50,7 @@ export interface AgentExecutionRoot {
49
50
  tools: ReadonlySet<string>;
50
51
  agentDefinitions?: Readonly<Record<string, AgentDefinition>>;
51
52
  agentDir?: string;
53
+ additionalSkillPaths?: readonly string[];
52
54
  availableModels?: ReadonlySet<string>;
53
55
  knownModels?: ReadonlySet<string>;
54
56
  modelAliases?: Readonly<Record<string, string>>;
@@ -64,7 +66,7 @@ export interface AgentExecutionRoot {
64
66
  export interface AgentAccounting { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }
65
67
  export interface AgentToolCallProgress { id: string; name: string; state: "running" | "completed" | "failed" }
66
68
  export interface AgentActivity { kind: "reasoning" | "tool" | "text"; text: string }
67
- export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; activity?: AgentActivity; persist: boolean }
69
+ export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; activity?: AgentActivity; lastEventAt?: number; persist: boolean }
68
70
  export interface AgentAttempt { attempt: number; sessionId: string; sessionFile: string; result?: JsonValue; error?: { code: string; message: string }; accounting: AgentAccounting; setup?: AgentSetupSummary }
69
71
  export interface AgentExecutionResult { value: JsonValue; attempts: readonly AgentAttempt[]; cwd: string }
70
72
 
@@ -106,14 +108,41 @@ function terminalProviderError(error: WorkflowError): TerminalProviderError | un
106
108
  const candidate = value as Partial<TerminalProviderError>;
107
109
  return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
108
110
  }
111
+ type ProviderRecoveryMarker = { providerRecoveryHandled?: boolean; providerRecovery?: AgentProviderRecovery; providerRecoveryFailed?: boolean };
112
+ const providerContinuationPrompt = "The provider error was transient. Continue the task from your current state.";
113
+ async function recoverTerminalProviderError(session: NativeSession, fallbackModel: ModelSpec, label: string, recovery: AgentExecutionOptions["providerErrorRecovery"], continuePrompt: () => Promise<void>): Promise<boolean> {
114
+ let continued = false;
115
+ for (;;) {
116
+ try { throwIfTerminalAssistantError(session, fallbackModel); return continued; }
117
+ catch (error) {
118
+ const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
119
+ const terminal = terminalProviderError(typed);
120
+ if (!terminal || !recovery) throw error;
121
+ let action: AgentProviderRecovery;
122
+ try { action = await recovery({ label, ...terminal }); } catch { Object.assign(typed, { providerRecoveryHandled: true, providerRecoveryFailed: true }); throw typed; }
123
+ if (action === "retry") { continued = true; await continuePrompt(); continue; }
124
+ Object.assign(typed, { providerRecoveryHandled: true, providerRecovery: action });
125
+ throw typed;
126
+ }
127
+ }
128
+ }
109
129
 
110
130
  function accounting(stats: ReturnType<NativeSession["getSessionStats"]>): AgentAccounting {
111
131
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
112
132
  }
113
133
  function canonicalSourcePath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
134
+ const WORKFLOW_DIRECTORY = "pi-extensible-workflows";
135
+ function workflowSystemPromptPath(cwd: string, agentDir: string, projectTrusted: boolean): string | undefined {
136
+ const projectPath = join(cwd, ".pi", WORKFLOW_DIRECTORY, "SYSTEM.md");
137
+ if (projectTrusted && existsSync(projectPath)) return projectPath;
138
+ const globalPaths = [join(homedir(), ".pi", WORKFLOW_DIRECTORY, "SYSTEM.md"), join(agentDir, WORKFLOW_DIRECTORY, "SYSTEM.md")];
139
+ return globalPaths.find((path) => existsSync(path));
140
+ }
114
141
 
115
142
  export async function createNativeAgentSession(input: SessionInput): Promise<NativeSession> {
116
143
  const agentDir = input.agentDir ?? getAgentDir();
144
+ const systemPromptSource = workflowSystemPromptPath(input.cwd, agentDir, input.resourcePolicy?.projectTrusted ?? true);
145
+ const systemPromptOptions = input.systemPrompt !== undefined ? { systemPromptOverride: () => input.systemPrompt } : systemPromptSource !== undefined ? { systemPrompt: systemPromptSource } : {};
117
146
  const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
118
147
  manager.appendSessionInfo(input.sessionLabel);
119
148
  const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
@@ -147,17 +176,18 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
147
176
  noExtensions: true,
148
177
  additionalExtensionPaths: extensionPaths,
149
178
  noSkills: true,
150
- additionalSkillPaths: skillPaths,
179
+ additionalSkillPaths: [...new Set([...skillPaths, ...(input.additionalSkillPaths ?? [])])],
151
180
  ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}),
152
181
  skillsOverride: (base) => {
153
182
  const disabledSkills = updateSkillMatches(base.skills);
154
183
  return { ...base, skills: base.skills.filter(({ name }) => !disabledSkills.has(name)) };
155
184
  },
156
185
  ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}),
186
+ ...systemPromptOptions,
157
187
  });
158
188
  await resourceLoader.reload();
159
- } else if (input.systemPromptAppend || input.extensionFactories?.length) {
160
- resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
189
+ } else if (input.systemPrompt !== undefined || systemPromptSource !== undefined || input.systemPromptAppend || input.extensionFactories?.length || input.additionalSkillPaths?.length) {
190
+ resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.additionalSkillPaths?.length ? { additionalSkillPaths: [...input.additionalSkillPaths] } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...systemPromptOptions, ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
161
191
  await resourceLoader.reload();
162
192
  }
163
193
  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 });
@@ -199,13 +229,13 @@ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionO
199
229
  function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
200
230
  return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], excludedSkills: [...(policy.excludedSkills ?? [])], excludedExtensions: [...(policy.excludedExtensions ?? [])], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
201
231
  }
202
- 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 }> {
232
+ async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
203
233
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
204
234
  const baselineOptions = structuredClone(options.agentOptions ?? {});
205
235
  const baseResourcePolicy = await root.agentResourcePolicy?.();
206
236
  const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
207
237
  const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
208
- const sessionInput: SessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), systemPromptAppend: resolved.systemPromptAppend, ...(resourcePolicy ? { resourcePolicy } : {}), options: structuredClone(baselineOptions) };
238
+ const sessionInput: SessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(root.additionalSkillPaths?.length ? { additionalSkillPaths: [...root.additionalSkillPaths] } : {}), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), ...(resolved.systemPrompt !== undefined ? { systemPrompt: resolved.systemPrompt } : {}), systemPromptAppend: resolved.systemPromptAppend, ...(resourcePolicy ? { resourcePolicy } : {}), options: structuredClone(baselineOptions) };
209
239
  const setup: AgentSetup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, createSession };
210
240
  const base = fallbackSetupContext(root, options, setupSignal);
211
241
  const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
@@ -230,7 +260,7 @@ export class WorkflowAgentExecutor {
230
260
  constructor(private readonly root: AgentExecutionRoot, private readonly createSession: SessionFactory = createNativeAgentSession) {}
231
261
  setRunContext(runContext: Readonly<WorkflowRunContext>): void { this.root.runContext = runContext; }
232
262
 
233
- resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPromptAppend: string } {
263
+ resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string } {
234
264
  const role = options.role;
235
265
  const definition = role ? this.root.agentDefinitions?.[role] : undefined;
236
266
  if (role && !definition) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown agent role: ${role}`);
@@ -246,7 +276,8 @@ export class WorkflowAgentExecutor {
246
276
  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);
247
277
  const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
248
278
  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})` : ""}`);
249
- return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
279
+ const overrideSystemPrompt = definition?.overrideSystemPrompt === true;
280
+ return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], ...(overrideSystemPrompt ? { systemPrompt: definition.prompt ?? "" } : {}), systemPromptAppend: overrideSystemPrompt ? "" : definition?.prompt ?? "" };
250
281
  }
251
282
 
252
283
  async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
@@ -299,6 +330,8 @@ export class WorkflowAgentExecutor {
299
330
  } as ToolDefinition : undefined;
300
331
  const toolCalls = new Map<string, AgentToolCallProgress>();
301
332
  let activity: AgentActivity | undefined;
333
+ let lastEventAt: number | undefined;
334
+ let lastReportedEventAt: number | undefined;
302
335
  let progress = Promise.resolve();
303
336
  let unsubscribe: (() => void) | undefined;
304
337
  let systemPromptTurn = 0;
@@ -310,9 +343,15 @@ export class WorkflowAgentExecutor {
310
343
  };
311
344
  const report = (persist: boolean) => {
312
345
  if (!session || !options.onProgress) return;
313
- const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
346
+ const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }), persist };
347
+ if (lastEventAt !== undefined) lastReportedEventAt = lastEventAt;
314
348
  progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
315
349
  };
350
+ const reportTimestamp = () => {
351
+ if (lastEventAt !== undefined && lastReportedEventAt !== undefined && lastEventAt - lastReportedEventAt < 1000) return;
352
+ report(false);
353
+ };
354
+ const activityChanged = (previous: AgentActivity | undefined) => previous?.kind !== activity?.kind || previous?.text !== activity?.text;
316
355
  try {
317
356
  setupFailed = true;
318
357
  const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool);
@@ -326,7 +365,20 @@ export class WorkflowAgentExecutor {
326
365
  const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
327
366
  await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
328
367
  const activeSession = session;
368
+ const sessionModel = setup.sessionInput.model;
369
+ const recoverTerminal = () => recoverTerminalProviderError(activeSession, sessionModel, options.label, options.providerErrorRecovery, async () => { try { await promptWithProviderPause(activeSession, providerContinuationPrompt, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { if (!hasSchemaResult()) throw error; } });
370
+ const promptAndRecover = async (prompt: string): Promise<void> => {
371
+ let promptFailed = false;
372
+ let promptError: unknown;
373
+ try { await promptWithProviderPause(activeSession, prompt, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { promptFailed = true; promptError = error; }
374
+ const recovered = await recoverTerminal();
375
+ if (promptFailed && !hasSchemaResult() && !recovered) throw promptError;
376
+ };
329
377
  unsubscribe = activeSession.subscribe?.((event) => {
378
+ lastEventAt = Date.now();
379
+ let persist = false;
380
+ let shouldReport = false;
381
+ let removeToolCallId: string | undefined;
330
382
  if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
331
383
  if (this.root.runStore) {
332
384
  systemPromptTurn += 1;
@@ -336,20 +388,32 @@ export class WorkflowAgentExecutor {
336
388
  }
337
389
  if (event.type === "message_start" && event.message.role === "assistant") {
338
390
  if (!turnStarted) { try { options.budget?.beforeTurn(); turnStarted = true; } catch (error) { budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error)); void session?.abort?.(); } }
339
- activity = { kind: "text", text: "responding" }; report(false);
391
+ activity = { kind: "text", text: "responding" };
392
+ shouldReport = true;
393
+ }
394
+ if (event.type === "message_update") {
395
+ const previousActivity = activity;
396
+ if (["thinking_start", "thinking_delta", "thinking_end"].includes(event.assistantMessageEvent.type)) activity = { kind: "reasoning", text: "reasoning" };
397
+ else if (["text_start", "text_delta", "text_end", "toolcall_start", "toolcall_delta", "toolcall_end"].includes(event.assistantMessageEvent.type)) activity = { kind: "text", text: "responding" };
398
+ shouldReport = activityChanged(previousActivity);
340
399
  }
341
400
  if (event.type === "message_end") {
401
+ const previousActivity = activity;
342
402
  activity = undefined;
403
+ shouldReport = activityChanged(previousActivity);
343
404
  if (event.message.role === "assistant") {
344
405
  const needsMoreWork = hasToolCall(event.message);
345
406
  const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
346
407
  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?.(); } }
347
408
  turnStarted = false;
348
- report(true);
409
+ persist = true;
349
410
  }
350
411
  }
351
- 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); }
352
- 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); }
412
+ if (event.type === "tool_execution_start") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" }); activity = { kind: "tool", text: event.toolName }; shouldReport = true; }
413
+ if (event.type === "tool_execution_update") { const previousActivity = activity; activity = { kind: "tool", text: event.toolName }; shouldReport = activityChanged(previousActivity); }
414
+ 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; shouldReport = true; removeToolCallId = event.toolCallId; }
415
+ if (shouldReport || persist) report(persist); else reportTimestamp();
416
+ if (removeToolCallId) toolCalls.delete(removeToolCallId);
353
417
  });
354
418
  report(false);
355
419
  if (setSteer) {
@@ -361,18 +425,21 @@ export class WorkflowAgentExecutor {
361
425
  const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
362
426
  options.budget?.beforeTurn();
363
427
  turnStarted = true;
364
- try { await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { if (!hasSchemaResult()) throw error; }
365
- throwIfTerminalAssistantError(session, setup.sessionInput.model);
428
+ await promptAndRecover(promptText);
366
429
  { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
367
430
  if (budgetError) throw budgetError;
368
431
  if (options.schema) {
369
432
  if (!hasSchemaResult()) {
370
- 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; }
433
+ options.budget?.beforeTurn();
434
+ turnStarted = true;
435
+ await promptAndRecover("Submit the final result now by calling workflow_result exactly once. Do not return prose.");
436
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; }
371
437
  }
372
- throwIfTerminalAssistantError(session, setup.sessionInput.model);
373
438
  if (!hasSchemaResult()) {
374
- 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; }
375
- throwIfTerminalAssistantError(session, setup.sessionInput.model);
439
+ options.budget?.beforeTurn();
440
+ turnStarted = true;
441
+ await promptAndRecover("Your result was missing or invalid. Repair it by calling workflow_result exactly once with a schema-valid value.");
442
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; }
376
443
  }
377
444
  if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
378
445
  }
@@ -402,21 +469,26 @@ export class WorkflowAgentExecutor {
402
469
  }
403
470
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED") await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
404
471
  const terminal = terminalProviderError(typed);
405
- if (terminal && options.providerErrorRecovery) {
406
- let recovery: AgentProviderRecovery;
472
+ const recoveryState = typed as WorkflowError & ProviderRecoveryMarker;
473
+ let recovery = recoveryState.providerRecovery;
474
+ if (terminal && options.providerErrorRecovery && !recoveryState.providerRecoveryHandled) {
407
475
  try { recovery = await options.providerErrorRecovery({ label: options.label, ...terminal }); } catch { throw Object.assign(typed, { attempts }); }
408
- if (recovery === "retry" || typeof recovery === "object" && typeof recovery.model === "string") {
409
- if (typeof recovery === "object") {
410
- try {
411
- const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
412
- recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
413
- } catch { throw Object.assign(typed, { attempts }); }
414
- }
476
+ if (recovery === "retry") {
415
477
  maxAttempts += 1;
416
478
  beforeRetry?.();
417
479
  continue;
418
480
  }
419
481
  }
482
+ if (recoveryState.providerRecoveryFailed || recovery === "abort") throw Object.assign(typed, { attempts });
483
+ if (typeof recovery === "object" && typeof recovery.model === "string") {
484
+ try {
485
+ const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
486
+ recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
487
+ } catch { throw Object.assign(typed, { attempts }); }
488
+ maxAttempts += 1;
489
+ beforeRetry?.();
490
+ continue;
491
+ }
420
492
  if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE") throw Object.assign(typed, { attempts });
421
493
  beforeRetry?.();
422
494
  }