pi-extensible-workflows 3.3.0 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,11 +6,25 @@ import { Value } from "typebox/value";
6
6
  import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
7
7
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
8
8
  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, AgentSetup, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, NativeSession, RegisteredAgentSetupHook, SessionFactory, SessionInput, WorkflowRunContext } from "./types.js";
10
- import { jsonObject, disabledResources, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference, unmatchedResourcePatterns } from "./utils.js";
9
+ type PiSession = {
10
+ readonly sessionId: string;
11
+ readonly sessionFile: string | undefined;
12
+ readonly messages: readonly AgentMessage[];
13
+ getSessionStats(): { tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; cost: number };
14
+ readonly systemPrompt?: string;
15
+ readonly model?: { provider: string; model?: string; id?: string };
16
+ readonly agent?: { state: { tools: readonly { name: string }[] } };
17
+ subscribe?(listener: (event: unknown) => void): () => void;
18
+ prompt(text: string): Promise<void>;
19
+ steer?(text: string): Promise<void>;
20
+ abort?(): Promise<void>;
21
+ dispose(): void;
22
+ };
23
+ import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetup, AgentSetupSummary, AgentTransport, AgentTransportContext, JsonSchema, JsonValue, ModelSpec, PreparedAgentSession, RegisteredAgentSetupHook, SessionInput, WorkflowAgentMessage, WorkflowAgentSession, WorkflowAgentSessionEvent, WorkflowAgentSessionReference, WorkflowAgentSessionState, WorkflowAgentSessionStats, WorkflowAgentTurnResult, WorkflowRunContext } from "./types.js";
24
+ import { deepFreeze, jsonObject, disabledResources, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference, unmatchedResourcePatterns } from "./utils.js";
11
25
  import { WorkflowError } from "./types.js";
12
26
  import type { RunStore } from "./persistence.js";
13
- export type { AgentSetup, AgentSetupContext, AgentSetupHook, NativeSession, RegisteredAgentSetupHook, SessionFactory, SessionInput } from "./types.js";
27
+ export type { AgentSetup, AgentSetupContext, AgentSetupHook, AgentTransport, AgentTransportContext, PreparedAgentSession, RegisteredAgentSetupHook, SessionInput, WorkflowAgentMessage, WorkflowAgentSession, WorkflowAgentSessionEvent, WorkflowAgentSessionReference, WorkflowAgentSessionState, WorkflowAgentSessionStats, WorkflowAgentTurnResult } from "./types.js";
14
28
  export interface AgentBudgetHooks {
15
29
  beforeAttempt(): void;
16
30
  beforeTurn(): void;
@@ -28,7 +42,7 @@ export interface AgentExecutionOptions {
28
42
  model?: string;
29
43
  thinking?: ThinkingLevel;
30
44
  onProgress?: (progress: AgentProgress) => void | Promise<void>;
31
- onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => void | Promise<void>;
45
+ onAttempt?: (attempt: AgentAttempt) => void | Promise<void>;
32
46
  providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>;
33
47
  modelOverride?: ModelSpec;
34
48
  tools?: readonly string[];
@@ -66,8 +80,8 @@ export interface AgentExecutionRoot {
66
80
  export interface AgentAccounting { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }
67
81
  export interface AgentToolCallProgress { id: string; name: string; state: "running" | "completed" | "failed" }
68
82
  export interface AgentActivity { kind: "reasoning" | "tool" | "text"; text: string }
69
- export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; activity?: AgentActivity; lastEventAt?: number; persist: boolean }
70
- export interface AgentAttempt { attempt: number; sessionId: string; sessionFile: string; result?: JsonValue; error?: { code: string; message: string }; accounting: AgentAccounting; setup?: AgentSetupSummary }
83
+ export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; state?: WorkflowAgentSessionState; activity?: AgentActivity; lastEventAt?: number; persist: boolean }
84
+ export interface AgentAttempt { attempt: number; transport: string; session?: WorkflowAgentSessionReference; liveSession?: WorkflowAgentSession; result?: JsonValue; error?: { code: string; message: string }; accounting: AgentAccounting; setup: AgentSetupSummary }
71
85
  export interface AgentExecutionResult { value: JsonValue; attempts: readonly AgentAttempt[]; cwd: string }
72
86
 
73
87
  function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
@@ -76,30 +90,25 @@ function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: T
76
90
  return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
77
91
  }
78
92
 
79
- function text(messages: readonly AgentMessage[]): string {
80
- const message = [...messages].reverse().find((item) => item.role === "assistant");
93
+ function text(message: WorkflowAgentMessage | undefined): string {
81
94
  if (!message || !Array.isArray(message.content)) return "";
82
95
  return message.content.filter((part: unknown): part is { type: "text"; text: string } => typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string").map((part) => part.text).join("");
83
96
  }
97
+ function isEmptyAbortedAssistant(message: WorkflowAgentMessage | undefined): boolean { return message?.stopReason === "aborted" && Array.isArray(message.content) && message.content.length === 0; }
84
98
 
85
99
  function hasToolCall(message: unknown): boolean {
86
100
  return typeof message === "object" && message !== null && Array.isArray((message as { content?: unknown }).content) && (message as { content: unknown[] }).content.some((part) => typeof part === "object" && part !== null && (part as { type?: unknown }).type === "toolCall");
87
101
  }
88
102
 
89
- function latestAssistantHasToolCall(messages: readonly AgentMessage[]): boolean {
90
- const message = [...messages].reverse().find((item) => item.role === "assistant");
91
- return hasToolCall(message);
92
- }
103
+ function latestAssistantHasToolCall(message: WorkflowAgentMessage | undefined): boolean { return hasToolCall(message); }
93
104
 
94
105
  type TerminalProviderError = { provider: string; model: string; error: string };
95
- function throwIfTerminalAssistantError(session: NativeSession, fallbackModel: ModelSpec): void {
96
- const message = [...session.messages].reverse().find((item) => item.role === "assistant");
106
+ function throwIfTerminalAssistantError(session: WorkflowAgentSession, message: WorkflowAgentMessage | undefined): void {
97
107
  if (message?.stopReason !== "error") return;
98
- const provider = session.model?.provider ?? fallbackModel.provider;
99
- const model = session.model?.model ?? session.model?.id ?? fallbackModel.model;
100
- const error = message.errorMessage ?? "Native Pi assistant ended with a terminal provider error";
108
+ const state = session.getState();
109
+ const error = message.errorMessage ?? "Workflow agent session ended with a terminal provider error";
101
110
  const failure = new WorkflowError("AGENT_FAILED", error);
102
- Object.defineProperty(failure, "terminalProviderError", { value: { provider, model, error }, configurable: true });
111
+ Object.defineProperty(failure, "terminalProviderError", { value: { provider: state.model.provider, model: state.model.model, error }, configurable: true });
103
112
  throw failure;
104
113
  }
105
114
  function terminalProviderError(error: WorkflowError): TerminalProviderError | undefined {
@@ -110,10 +119,10 @@ function terminalProviderError(error: WorkflowError): TerminalProviderError | un
110
119
  }
111
120
  type ProviderRecoveryMarker = { providerRecoveryHandled?: boolean; providerRecovery?: AgentProviderRecovery; providerRecoveryFailed?: boolean };
112
121
  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> {
122
+ async function recoverTerminalProviderError(session: WorkflowAgentSession, label: string, recovery: AgentExecutionOptions["providerErrorRecovery"], continuePrompt: () => Promise<void>, getAssistant: () => WorkflowAgentMessage | undefined): Promise<boolean> {
114
123
  let continued = false;
115
124
  for (;;) {
116
- try { throwIfTerminalAssistantError(session, fallbackModel); return continued; }
125
+ try { throwIfTerminalAssistantError(session, getAssistant()); return continued; }
117
126
  catch (error) {
118
127
  const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
119
128
  const terminal = terminalProviderError(typed);
@@ -127,7 +136,7 @@ async function recoverTerminalProviderError(session: NativeSession, fallbackMode
127
136
  }
128
137
  }
129
138
 
130
- function accounting(stats: ReturnType<NativeSession["getSessionStats"]>): AgentAccounting {
139
+ function accounting(stats: WorkflowAgentSessionStats): AgentAccounting {
131
140
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
132
141
  }
133
142
  function canonicalSourcePath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
@@ -139,7 +148,7 @@ function workflowSystemPromptPath(cwd: string, agentDir: string, projectTrusted:
139
148
  return globalPaths.find((path) => existsSync(path));
140
149
  }
141
150
 
142
- export async function createNativeAgentSession(input: SessionInput): Promise<NativeSession> {
151
+ export async function createLocalPiSession(input: SessionInput): Promise<PiSession> {
143
152
  const agentDir = input.agentDir ?? getAgentDir();
144
153
  const systemPromptSource = workflowSystemPromptPath(input.cwd, agentDir, input.resourcePolicy?.projectTrusted ?? true);
145
154
  const systemPromptOptions = input.systemPrompt !== undefined ? { systemPromptOverride: () => input.systemPrompt } : systemPromptSource !== undefined ? { systemPrompt: systemPromptSource } : {};
@@ -194,8 +203,60 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
194
203
  return Object.assign(session, {
195
204
  getLeafId: () => manager.getLeafId(),
196
205
  getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
197
- }) as unknown as NativeSession;
206
+ }) as unknown as PiSession;
207
+ }
208
+ function workflowAgentMessage(message: AgentMessage | undefined): WorkflowAgentMessage | undefined { return message ? { role: message.role, ...(message.content === undefined ? {} : { content: message.content }), ...(message.stopReason === undefined ? {} : { stopReason: message.stopReason }), ...(message.errorMessage === undefined ? {} : { errorMessage: message.errorMessage }), ...(message.usage === undefined ? {} : { usage: message.usage }) } : undefined; }
209
+ function latestUsableAssistant(messages: readonly AgentMessage[]): WorkflowAgentMessage | undefined { for (let index = messages.length - 1; index >= 0; index -= 1) { const candidate = workflowAgentMessage(messages[index]); if (candidate?.role === "assistant" && !isEmptyAbortedAssistant(candidate)) return candidate; } return undefined; }
210
+ function workflowAgentStats(stats: ReturnType<PiSession["getSessionStats"]>): WorkflowAgentSessionStats { return { tokens: { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, total: stats.tokens.total }, cost: stats.cost }; }
211
+ function workflowAgentState(native: PiSession, prepared: Readonly<PreparedAgentSession>): WorkflowAgentSessionState {
212
+ const tools = native.agent?.state.tools.map(({ name }) => name) ?? prepared.tools;
213
+ const model = native.model?.provider && (native.model.model ?? native.model.id) ? { provider: native.model.provider, model: native.model.model ?? native.model.id ?? prepared.model.model, ...(prepared.model.thinking ? { thinking: prepared.model.thinking } : {}) } : { ...prepared.model };
214
+ return { model, ...(model.thinking ? { thinking: model.thinking } : {}), tools: [...tools], ...(native.systemPrompt === undefined ? {} : { systemPrompt: native.systemPrompt }) };
198
215
  }
216
+ function localSessionEvent(event: unknown): WorkflowAgentSessionEvent { return event as WorkflowAgentSessionEvent; }
217
+ export async function createLocalWorkflowAgentSession(prepared: Readonly<PreparedAgentSession>, context: Readonly<AgentTransportContext>): Promise<WorkflowAgentSession> {
218
+ void context;
219
+ const input: SessionInput = {
220
+ cwd: prepared.cwd, model: { ...prepared.model }, tools: [...prepared.tools] as SessionInput["tools"], sessionLabel: prepared.sessionLabel,
221
+ ...(prepared.agentDir ? { agentDir: prepared.agentDir } : {}), ...(prepared.customTools?.length ? { customTools: [...prepared.customTools] as NonNullable<SessionInput["customTools"]> } : {}),
222
+ ...(prepared.resultTool ? { resultTool: prepared.resultTool } : {}), ...(prepared.systemPrompt === undefined ? {} : { systemPrompt: prepared.systemPrompt }),
223
+ ...(prepared.systemPromptAppend ? { systemPromptAppend: prepared.systemPromptAppend } : {}), ...(prepared.extensionFactories?.length ? { extensionFactories: [...prepared.extensionFactories] } : {}),
224
+ ...(prepared.additionalSkillPaths?.length ? { additionalSkillPaths: [...prepared.additionalSkillPaths] } : {}), ...(prepared.resourcePolicy ? { resourcePolicy: structuredClone(prepared.resourcePolicy) } : {}), ...(prepared.options ? { options: { ...prepared.options } } : {}),
225
+ };
226
+ const native = await createLocalPiSession(input);
227
+ let disposal: Promise<void> | undefined;
228
+ let aborting: Promise<void> | undefined;
229
+ let prompting: Promise<WorkflowAgentTurnResult> | undefined;
230
+ let disposed = false;
231
+ const startAbort = () => aborting ??= Promise.resolve().then(() => native.abort?.()).then(() => undefined).finally(() => { aborting = undefined; });
232
+ const reference: WorkflowAgentSessionReference = { transport: "local", sessionId: native.sessionId, ...(native.sessionFile ? { locator: { sessionFile: native.sessionFile } } : {}) };
233
+ const session = {
234
+ reference,
235
+ getState: () => Object.freeze(workflowAgentState(native, prepared)),
236
+ getSessionStats: () => workflowAgentStats(native.getSessionStats()),
237
+ subscribe(listener: (event: WorkflowAgentSessionEvent) => void) { listener({ type: "state_changed", state: workflowAgentState(native, prepared) }); return native.subscribe?.((event) => { listener(localSessionEvent(event)); }) ?? (() => undefined); },
238
+ getLastAssistant: () => latestUsableAssistant(native.messages),
239
+ async prompt(text: string) {
240
+ if (disposed) throw new WorkflowError("INTERNAL_ERROR", "Local workflow session is disposed");
241
+ const prompt = Promise.resolve().then(async () => { await native.prompt(text); const assistant = latestUsableAssistant(native.messages); return assistant ? { assistant } : {}; });
242
+ prompting = prompt;
243
+ try { return await prompt; } finally { if (prompting === prompt) prompting = undefined; }
244
+ },
245
+ async steer(text: string) { if (!native.steer) throw new WorkflowError("INTERNAL_ERROR", "Local workflow session does not support steering"); await native.steer(text); },
246
+ async abort() { if (disposed) return; await startAbort(); },
247
+ async dispose() {
248
+ disposal ??= (async () => {
249
+ disposed = true;
250
+ try { await startAbort(); } catch { /* Abort failure must not prevent the prompt from settling. */ }
251
+ try { await prompting; } catch { /* Prompt rejection is expected after abort during teardown. */ }
252
+ native.dispose();
253
+ })();
254
+ await disposal;
255
+ },
256
+ };
257
+ return session;
258
+ }
259
+ export const localAgentTransport: AgentTransport = Object.freeze({ id: "local", createSession: createLocalWorkflowAgentSession });
199
260
  function changedOption(options: Readonly<Record<string, JsonValue>>, baseline: Readonly<Record<string, JsonValue>>, key: string): boolean { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
200
261
  function validThinking(value: unknown): value is ThinkingLevel { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
201
262
  interface ChildAgentToolParams {
@@ -229,35 +290,77 @@ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionO
229
290
  function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
230
291
  return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], excludedSkills: [...(policy.excludedSkills ?? [])], excludedExtensions: [...(policy.excludedExtensions ?? [])], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
231
292
  }
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 }> {
293
+ function resourcePolicyWidened(ceiling: AgentResourcePolicy | undefined, candidate: AgentResourcePolicy | undefined): boolean {
294
+ if (!ceiling) return false;
295
+ if (!candidate) return true;
296
+ if (!ceiling.projectTrusted && candidate.projectTrusted) return true;
297
+ return ceiling.effective.skills.some((pattern) => !candidate.effective.skills.includes(pattern)) || ceiling.effective.extensions.some((pattern) => !candidate.effective.extensions.includes(pattern));
298
+ }
299
+ function preparedAgentSession(input: SessionInput): Readonly<PreparedAgentSession> {
300
+ const prepared = {
301
+ cwd: input.cwd, model: Object.freeze({ ...input.model }), tools: Object.freeze([...input.tools]), sessionLabel: input.sessionLabel,
302
+ ...(input.agentDir ? { agentDir: input.agentDir } : {}), ...(input.customTools?.length ? { customTools: Object.freeze([...input.customTools]) } : {}), ...(input.resultTool ? { resultTool: input.resultTool } : {}), ...(input.options ? { options: Object.freeze(structuredClone(input.options)) } : {}),
303
+ ...(input.systemPrompt === undefined ? {} : { systemPrompt: input.systemPrompt }), ...(input.systemPromptAppend ? { systemPromptAppend: input.systemPromptAppend } : {}),
304
+ ...(input.extensionFactories?.length ? { extensionFactories: Object.freeze([...input.extensionFactories]) } : {}), ...(input.additionalSkillPaths?.length ? { additionalSkillPaths: Object.freeze([...input.additionalSkillPaths]) } : {}),
305
+ ...(input.resourcePolicy ? { resourcePolicy: Object.freeze(structuredClone(input.resourcePolicy)) } : {}),
306
+ };
307
+ return deepFreeze(prepared);
308
+ }
309
+ function agentSetupSummary(setup: AgentSetup, hookNames: readonly string[]): AgentSetupSummary {
310
+ const model = setup.sessionInput.model;
311
+ return { 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) } : {}) };
312
+ }
313
+ type PreparedAgentSetup = { setup: AgentSetup; summary: AgentSetupSummary; failure?: { error: unknown } };
314
+ async function prepareAgentSetup(root: AgentExecutionRoot, transport: AgentTransport, 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<PreparedAgentSetup> {
233
315
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
234
316
  const baselineOptions = structuredClone(options.agentOptions ?? {});
235
317
  const baseResourcePolicy = await root.agentResourcePolicy?.();
236
318
  const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
237
319
  const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
320
+ const resourcePolicyCeiling = resourcePolicy ? structuredClone(resourcePolicy) : undefined;
238
321
  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) };
239
- const setup: AgentSetup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, createSession };
322
+ const setup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, prepared: preparedAgentSession(sessionInput), transport };
240
323
  const base = fallbackSetupContext(root, options, setupSignal);
241
324
  const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
242
325
  const hookNames: string[] = [];
243
326
  for (const hook of [...(root.agentSetupHooks ?? [])].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))) {
244
- if (setupSignal.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
245
- try { await hook.setup(setup, context); } catch (error) { if (setupSignal.reason !== undefined) throw new WorkflowError("CANCELLED", "Agent cancelled"); throw error; }
327
+ if (setupSignal.aborted) return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error: new WorkflowError("CANCELLED", "Agent cancelled") } };
328
+ try { await hook.setup(setup, context); } catch (error) {
329
+ setup.prepared = preparedAgentSession(setup.sessionInput);
330
+ return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error: setupSignal.reason !== undefined ? new WorkflowError("CANCELLED", "Agent cancelled") : error } };
331
+ }
332
+ setup.prepared = preparedAgentSession(setup.sessionInput);
246
333
  hookNames.push(hook.name);
247
- if (setupSignal.reason !== undefined) throw new WorkflowError("CANCELLED", "Agent cancelled");
334
+ if (setupSignal.reason !== undefined) return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error: new WorkflowError("CANCELLED", "Agent cancelled") } };
248
335
  }
249
- setup.sessionInput.options = setup.options;
250
- if (changedOption(setup.options, baselineOptions, "model") && typeof setup.options.model === "string") setup.sessionInput.model = parseModel(setup.options.model, setup.sessionInput.model, changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking) ? setup.options.thinking : undefined, root.modelAliases, root.knownModels ?? root.availableModels, root.settingsPath);
251
- if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking)) setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
252
- 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];
253
- if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
254
- const model = setup.sessionInput.model;
255
- 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) } : {}) };
256
- return { setup, summary };
336
+ try {
337
+ if (resourcePolicyWidened(resourcePolicyCeiling, setup.sessionInput.resourcePolicy)) throw new WorkflowError("INVALID_METADATA", "Agent setup widened the prepared resource policy");
338
+ setup.sessionInput.options = setup.options;
339
+ if (changedOption(setup.options, baselineOptions, "model") && typeof setup.options.model === "string") setup.sessionInput.model = parseModel(setup.options.model, setup.sessionInput.model, changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking) ? setup.options.thinking : undefined, root.modelAliases, root.knownModels ?? root.availableModels, root.settingsPath);
340
+ if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking)) setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
341
+ 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];
342
+ if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
343
+ const customToolNames = new Set([...(setup.sessionInput.customTools ?? []).map(({ name }) => name), ...(setup.sessionInput.resultTool ? [setup.sessionInput.resultTool.name] : [])]);
344
+ const widened = setup.sessionInput.tools.find((tool) => !resolved.tools.includes(tool) && !customToolNames.has(tool));
345
+ const outsideTool = widened ?? setup.sessionInput.tools.find((tool) => !root.tools.has(tool) && !customToolNames.has(tool));
346
+ if (outsideTool) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the prepared agent policy: ${outsideTool}`);
347
+ } catch (error) {
348
+ setup.prepared = preparedAgentSession(setup.sessionInput);
349
+ return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error } };
350
+ }
351
+ setup.prepared = preparedAgentSession(setup.sessionInput);
352
+ return { setup, summary: agentSetupSummary(setup, hookNames) };
353
+ }
354
+ function attemptRecord(transport: string, attempt: number, session: WorkflowAgentSession, setup: AgentSetupSummary, stats: AgentAccounting, result?: JsonValue, error?: { code: string; message: string }): AgentAttempt {
355
+ return { attempt, transport, session: session.reference, ...(result === undefined ? {} : { result }), ...(error ? { error } : {}), accounting: stats, setup };
356
+ }
357
+ function errorWithAttempts(error: unknown, attempts: readonly AgentAttempt[]): Error {
358
+ const typed = error instanceof Error ? error : new Error(typeof error === "string" ? error : String(error));
359
+ return Object.assign(typed, { attempts });
257
360
  }
258
-
259
361
  export class WorkflowAgentExecutor {
260
- constructor(private readonly root: AgentExecutionRoot, private readonly createSession: SessionFactory = createNativeAgentSession) {}
362
+ private readonly transport: AgentTransport;
363
+ constructor(private readonly root: AgentExecutionRoot, transport: AgentTransport = localAgentTransport) { this.transport = transport; }
261
364
  setRunContext(runContext: Readonly<WorkflowRunContext>): void { this.root.runContext = runContext; }
262
365
 
263
366
  resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string } {
@@ -308,15 +411,15 @@ export class WorkflowAgentExecutor {
308
411
  const attempts: AgentAttempt[] = [];
309
412
  let maxAttempts = (options.retries ?? 0) + 1;
310
413
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
414
+ const attemptSignal = executionSignal ?? new AbortController().signal;
311
415
  if (recoveryModel) resolved = this.resolve({ ...options, modelOverride: recoveryModel });
312
416
  options.budget?.beforeAttempt();
313
417
  let schemaResult: JsonValue | undefined;
314
- let session: NativeSession | undefined;
418
+ let session: WorkflowAgentSession | undefined;
315
419
  let setup: AgentSetup | undefined;
316
- let setupSummary: AgentSetupSummary | undefined;
420
+ let setupSummary: AgentSetupSummary = { hookNames: [], model: { ...resolved.model }, tools: [...resolved.tools], cwd };
317
421
  let setupFailed = false;
318
422
  let budgetError: WorkflowError | undefined;
319
- let turnStarted = false;
320
423
  const hasSchemaResult = () => schemaResult !== undefined;
321
424
  const resultTool = options.schema ? {
322
425
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
@@ -324,7 +427,8 @@ export class WorkflowAgentExecutor {
324
427
  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 };
325
428
  if (schemaResult !== undefined) return { content: [{ type: "text" as const, text: "Result has already been accepted." }], details: {}, isError: true };
326
429
  schemaResult = structuredClone(value) as JsonValue;
327
- void session?.abort?.();
430
+ const currentSession = session;
431
+ if (currentSession) void currentSession.abort();
328
432
  return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
329
433
  },
330
434
  } as ToolDefinition : undefined;
@@ -337,13 +441,15 @@ export class WorkflowAgentExecutor {
337
441
  let systemPromptTurn = 0;
338
442
  let systemPromptWrite = Promise.resolve();
339
443
  let systemPromptWriteError: unknown;
444
+ let turnStarted = false;
445
+ let completedAttempt: AgentAttempt | undefined;
340
446
  const flushSystemPrompts = async () => {
341
447
  await systemPromptWrite;
342
448
  if (systemPromptWriteError) throw new WorkflowError("INTERNAL_ERROR", `Failed to persist effective system prompt: ${systemPromptWriteError instanceof Error ? systemPromptWriteError.message : typeof systemPromptWriteError === "string" ? systemPromptWriteError : "unknown error"}`);
343
449
  };
344
450
  const report = (persist: boolean) => {
345
451
  if (!session || !options.onProgress) return;
346
- const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }), persist };
452
+ const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], state: session.getState(), ...(activity ? { activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }), persist };
347
453
  if (lastEventAt !== undefined) lastReportedEventAt = lastEventAt;
348
454
  progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
349
455
  };
@@ -354,71 +460,90 @@ export class WorkflowAgentExecutor {
354
460
  const activityChanged = (previous: AgentActivity | undefined) => previous?.kind !== activity?.kind || previous?.text !== activity?.text;
355
461
  try {
356
462
  setupFailed = true;
357
- const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool);
463
+ const prepared = await prepareAgentSetup(this.root, this.transport, task, options, resolved, cwd, attempt, attemptSignal, customTools, resultTool);
358
464
  setup = prepared.setup;
359
465
  setupSummary = prepared.summary;
466
+ if (prepared.failure) throw prepared.failure.error;
360
467
  setupFailed = false;
361
- if (executionSignal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
468
+ if (attemptSignal.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
362
469
  const started = Date.now();
363
- session = await setup.createSession(setup.sessionInput);
470
+ const transportSignal = attemptSignal;
471
+ await options.onAttempt?.({ attempt, transport: setup.transport.id, accounting: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, setup: setupSummary });
472
+ const transportBase = fallbackSetupContext(this.root, options, transportSignal);
473
+ const createdSession = await setup.transport.createSession(setup.prepared, Object.freeze({ run: transportBase.run, identity: transportBase.identity, attempt, signal: transportSignal }));
474
+ if (createdSession.reference.transport !== setup.transport.id) {
475
+ await createdSession.dispose();
476
+ throw new WorkflowError("INTERNAL_ERROR", `Agent transport ${setup.transport.id} created a session for ${createdSession.reference.transport}`);
477
+ }
478
+ session = createdSession;
479
+ await options.onAttempt?.({ attempt, transport: setup.transport.id, session: session.reference, liveSession: session, accounting: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, setup: setupSummary });
480
+ const preparedTools = new Set([...setup.prepared.tools, ...(setup.prepared.customTools ?? []).map(({ name }) => name), ...(setup.prepared.resultTool ? [setup.prepared.resultTool.name] : [])]);
481
+ if (session.getState().tools.some((tool) => !preparedTools.has(tool))) throw new WorkflowError("INTERNAL_ERROR", `Agent transport ${setup.transport.id} widened the prepared tool policy`);
364
482
  if (setup.sessionInput.resourcePolicy) setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
365
- const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
366
- await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
367
483
  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; } });
484
+ let lastAssistant: WorkflowAgentMessage | undefined;
485
+ const acceptAssistant = (candidate: WorkflowAgentMessage | undefined) => {
486
+ if (isEmptyAbortedAssistant(candidate)) {
487
+ const previous = (activeSession as WorkflowAgentSession & { getLastAssistant?: () => WorkflowAgentMessage | undefined }).getLastAssistant?.();
488
+ if (previous && !isEmptyAbortedAssistant(previous)) lastAssistant = previous;
489
+ } else if (candidate) lastAssistant = candidate;
490
+ };
491
+ const recoverTerminal = () => recoverTerminalProviderError(activeSession, options.label, options.providerErrorRecovery, async () => { try { acceptAssistant((await promptWithProviderPause(activeSession, providerContinuationPrompt, remaining(options.timeoutMs, started), attemptSignal, this.root.providerPause)).assistant); } catch (error) { acceptAssistant((activeSession as WorkflowAgentSession & { getLastAssistant?: () => WorkflowAgentMessage | undefined }).getLastAssistant?.() ?? lastAssistant); if (!hasSchemaResult()) throw error; } }, () => lastAssistant);
370
492
  const promptAndRecover = async (prompt: string): Promise<void> => {
371
493
  let promptFailed = false;
372
494
  let promptError: unknown;
373
- try { await promptWithProviderPause(activeSession, prompt, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { promptFailed = true; promptError = error; }
495
+ try { acceptAssistant((await promptWithProviderPause(activeSession, prompt, remaining(options.timeoutMs, started), attemptSignal, this.root.providerPause)).assistant); } catch (error) { acceptAssistant((activeSession as WorkflowAgentSession & { getLastAssistant?: () => WorkflowAgentMessage | undefined }).getLastAssistant?.() ?? lastAssistant); promptFailed = true; promptError = error; }
374
496
  const recovered = await recoverTerminal();
375
497
  if (promptFailed && !hasSchemaResult() && !recovered) throw promptError;
376
498
  };
377
- unsubscribe = activeSession.subscribe?.((event) => {
499
+ unsubscribe = activeSession.subscribe((event) => {
378
500
  lastEventAt = Date.now();
379
501
  let persist = false;
380
502
  let shouldReport = false;
381
503
  let removeToolCallId: string | undefined;
382
- if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
504
+ if (event.type === "agent_start" && session?.getState().systemPrompt !== undefined) {
383
505
  if (this.root.runStore) {
384
506
  systemPromptTurn += 1;
385
- const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
507
+ const entry = { sessionId: session.reference.sessionId, attempt, turn: systemPromptTurn, prompt: session.getState().systemPrompt ?? "" };
386
508
  systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error: unknown) => { systemPromptWriteError ??= error; });
387
509
  }
388
510
  }
389
- if (event.type === "message_start" && event.message.role === "assistant") {
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?.(); } }
511
+ if (event.type === "state_changed") { shouldReport = true; persist = true; }
512
+ if (event.type === "turnStarted" || event.type === "turn_started") { 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 activeSession.abort(); } } }
513
+ if (event.type === "message_start" && event.message?.role === "assistant") {
514
+ 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 activeSession.abort(); } }
391
515
  activity = { kind: "text", text: "responding" };
392
516
  shouldReport = true;
393
517
  }
394
518
  if (event.type === "message_update") {
395
519
  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" };
520
+ const updateType = event.assistantMessageEvent?.type;
521
+ if (updateType && ["thinking_start", "thinking_delta", "thinking_end"].includes(updateType)) activity = { kind: "reasoning", text: "reasoning" };
522
+ else if (updateType && ["text_start", "text_delta", "text_end", "toolcall_start", "toolcall_delta", "toolcall_end"].includes(updateType)) activity = { kind: "text", text: "responding" };
398
523
  shouldReport = activityChanged(previousActivity);
399
524
  }
400
525
  if (event.type === "message_end") {
401
526
  const previousActivity = activity;
402
527
  activity = undefined;
403
528
  shouldReport = activityChanged(previousActivity);
404
- if (event.message.role === "assistant") {
529
+ if (event.message?.role === "assistant") {
530
+ acceptAssistant(event.message);
405
531
  const needsMoreWork = hasToolCall(event.message);
406
532
  const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
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?.(); } }
533
+ if (!budgetError) { try { options.budget?.afterTurn(accounting(activeSession.getSessionStats()), final); if (!final) { const instruction = options.budget?.instruction(); if (instruction) void activeSession.steer(instruction); } } catch (error) { budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error)); void activeSession.abort(); } }
408
534
  turnStarted = false;
409
535
  persist = true;
410
536
  }
411
537
  }
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; }
538
+ if (event.type === "tool_execution_start" && event.toolCallId && event.toolName) { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" }); activity = { kind: "tool", text: event.toolName }; shouldReport = true; }
539
+ if (event.type === "tool_execution_update" && event.toolName) { const previousActivity = activity; activity = { kind: "tool", text: event.toolName }; shouldReport = activityChanged(previousActivity); }
540
+ if (event.type === "tool_execution_end" && event.toolCallId && event.toolName) { 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
541
  if (shouldReport || persist) report(persist); else reportTimestamp();
416
542
  if (removeToolCallId) toolCalls.delete(removeToolCallId);
417
543
  });
418
544
  report(false);
419
545
  if (setSteer) {
420
- if (!session.steer) throw new WorkflowError("INTERNAL_ERROR", "Native Pi session does not support steering");
421
- setSteer((message) => session?.steer?.(message));
546
+ setSteer((message) => activeSession.steer(message));
422
547
  }
423
548
  const context = [`Workflow: ${options.workflowName}`, `Agent: ${options.label}`, options.phase ? `Phase: ${options.phase}` : "", options.parent ? `Parent: ${options.parent}` : "", "You own this task and any direct child agents you create. Return child results to your parent; do not leave descendants running.", attempt > 1 ? `Retry attempt ${String(attempt)}. Previous state: ${options.retryState ?? attempts.at(-1)?.error?.message ?? "failed attempt"}` : ""].filter(Boolean).join("\n");
424
549
  const instruction = options.budget?.instruction();
@@ -426,7 +551,7 @@ export class WorkflowAgentExecutor {
426
551
  options.budget?.beforeTurn();
427
552
  turnStarted = true;
428
553
  await promptAndRecover(promptText);
429
- { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
554
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(lastAssistant)); turnStarted = false; }
430
555
  if (budgetError) throw budgetError;
431
556
  if (options.schema) {
432
557
  if (!hasSchemaResult()) {
@@ -443,29 +568,39 @@ export class WorkflowAgentExecutor {
443
568
  }
444
569
  if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
445
570
  }
446
- const value = options.schema ? schemaResult as JsonValue : text(session.messages);
571
+ const value = options.schema ? schemaResult as JsonValue : text(lastAssistant);
447
572
  if (options.worktreeOwner) await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
448
573
  report(true);
449
574
  await progress;
450
575
  await flushSystemPrompts();
451
- unsubscribe?.();
576
+ unsubscribe();
452
577
  const attemptAccounting = accounting(session.getSessionStats());
453
- const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
454
- attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
455
- session.dispose();
578
+ completedAttempt = attemptRecord(setup.transport.id, attempt, session, setupSummary, attemptAccounting, value);
579
+ attempts.push(completedAttempt);
580
+ try { await options.onAttempt?.(completedAttempt); } finally { await session.dispose(); }
456
581
  return { value, attempts, cwd: setupSummary.cwd };
457
582
  } catch (error) {
458
- const typed = budgetError ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
583
+ if (completedAttempt) throw errorWithAttempts(error, attempts);
584
+ const typed = budgetError ?? (error instanceof WorkflowError ? error : new WorkflowError(attemptSignal.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
585
+ if (!session) {
586
+ const failedAttempt: AgentAttempt = { attempt, transport: setup?.transport.id ?? this.transport.id, accounting: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, error: { code: typed.code, message: typed.message }, setup: setupSummary };
587
+ attempts.push(failedAttempt);
588
+ try { await options.onAttempt?.(failedAttempt); } catch (persistenceError) { throw errorWithAttempts(persistenceError, attempts); }
589
+ }
459
590
  if (session) {
460
591
  report(true);
461
- await progress;
592
+ await progress.catch(() => undefined);
462
593
  try { await flushSystemPrompts(); } catch { /* Preserve the agent failure that prompted this cleanup. */ }
463
594
  unsubscribe?.();
464
595
  const attemptAccounting = accounting(session.getSessionStats());
465
596
  if (!budgetError && typed.code !== "BUDGET_EXHAUSTED") { try { options.budget?.afterTurn(attemptAccounting, true); } catch (budgetFailure) { budgetError ??= budgetFailure instanceof WorkflowError ? budgetFailure : new WorkflowError("BUDGET_EXHAUSTED", budgetFailure instanceof Error ? budgetFailure.message : String(budgetFailure)); } }
466
- const includeFailedSetup = Boolean(this.root.agentSetupHooks?.length || setup?.sessionInput.resourcePolicy);
467
- attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), error: { code: typed.code, message: typed.message }, accounting: attemptAccounting, ...(includeFailedSetup && setupSummary ? { setup: setupSummary } : {}) });
468
- session.dispose();
597
+ const failedAttempt = attemptRecord(setup?.transport.id ?? this.transport.id, attempt, session, setupSummary, attemptAccounting, undefined, { code: typed.code, message: typed.message });
598
+ attempts.push(failedAttempt);
599
+ try {
600
+ try { await options.onAttempt?.(failedAttempt); } finally { await session.dispose(); }
601
+ } catch (persistenceError) {
602
+ throw errorWithAttempts(persistenceError, attempts);
603
+ }
469
604
  }
470
605
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED") await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
471
606
  const terminal = terminalProviderError(typed);
@@ -534,6 +669,7 @@ type ScheduledNode = {
534
669
  id: string;
535
670
  runId: string;
536
671
  parentId?: string;
672
+ prompt?: string;
537
673
  options: Readonly<ScheduledAgentOptions>;
538
674
  children: Set<string>;
539
675
  collected: boolean;
@@ -547,7 +683,7 @@ type ScheduledNode = {
547
683
  };
548
684
 
549
685
  type ScheduledRun = { limit: number; beforeLaunch?: () => void; logical: number; active: number; queue: Array<{ node?: ScheduledNode; start: () => void }> };
550
- export type OwnershipRecord = { id: string; parentId?: string; label: string; state: ScheduledNode["state"]; options: Readonly<ScheduledAgentOptions> };
686
+ export type OwnershipRecord = { id: string; parentId?: string; prompt?: string; label: string; state: ScheduledNode["state"]; options: Readonly<ScheduledAgentOptions> };
551
687
  type OwnershipWriter = (runId: string, ownership: readonly OwnershipRecord[]) => void | Promise<void>;
552
688
 
553
689
  export class FairAgentScheduler {
@@ -586,7 +722,7 @@ export class FairAgentScheduler {
586
722
  const id = `${runId}:${String(++this.#nextId)}`;
587
723
  let resolveResult: (result: ScheduledAgentResult) => void = () => undefined;
588
724
  const promise = new Promise<ScheduledAgentResult>((resolve) => { resolveResult = resolve; });
589
- const node: ScheduledNode = { id, runId, ...(parentId ? { parentId } : {}), options: effective, children: new Set<string>(), collected: false, state: "queued", controller: new AbortController(), promise, resolve: resolveResult, task: async () => undefined, restored: false };
725
+ const node: ScheduledNode = { id, runId, ...(parentId ? { parentId } : {}), prompt, options: effective, children: new Set<string>(), collected: false, state: "queued", controller: new AbortController(), promise, resolve: resolveResult, task: async () => undefined, restored: false };
590
726
  node.task = async () => {
591
727
  if (node.controller.signal.aborted) { this.#release(node.runId); return; }
592
728
  node.state = "running";
@@ -702,7 +838,7 @@ export class FairAgentScheduler {
702
838
  }
703
839
 
704
840
  snapshot(): readonly OwnershipRecord[] {
705
- return [...this.#nodes.values()].map(({ id, parentId, options, state }) => ({ id, ...(parentId ? { parentId } : {}), label: options.label, state, options }));
841
+ return [...this.#nodes.values()].map(({ id, parentId, prompt, options, state }) => ({ id, ...(parentId ? { parentId } : {}), ...(prompt === undefined ? {} : { prompt }), label: options.label, state, options }));
706
842
  }
707
843
 
708
844
  restoreRun(runId: string, limit: number, ownership: readonly OwnershipRecord[], beforeLaunch?: () => void): void {
@@ -712,7 +848,7 @@ export class FairAgentScheduler {
712
848
  if (record.id.split(":").slice(0, -1).join(":") !== runId) throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted agent belongs to another run: ${record.id}`);
713
849
  let resolveResult: (result: ScheduledAgentResult) => void = () => undefined;
714
850
  const promise = new Promise<ScheduledAgentResult>((resolve) => { resolveResult = resolve; });
715
- const node: ScheduledNode = { id: record.id, runId, ...(record.parentId ? { parentId: record.parentId } : {}), options: this.#inherit(undefined, record.options), children: new Set(), collected: false, state: record.state, controller: new AbortController(), promise, resolve: resolveResult, task: async () => undefined, restored: true };
851
+ const node: ScheduledNode = { id: record.id, runId, ...(record.parentId ? { parentId: record.parentId } : {}), ...(record.prompt === undefined ? {} : { prompt: record.prompt }), options: this.#inherit(undefined, record.options), children: new Set(), collected: false, state: record.state, controller: new AbortController(), promise, resolve: resolveResult, task: async () => undefined, restored: true };
716
852
  this.#nodes.set(node.id, node);
717
853
  run.logical += 1;
718
854
  this.#nextId = Math.max(this.#nextId, Number(node.id.slice(node.id.lastIndexOf(":") + 1)) || 0);
@@ -795,11 +931,6 @@ export class FairAgentScheduler {
795
931
 
796
932
  function resolvePath(path: string): string { return path.replace(/[\\/]+$/, ""); }
797
933
 
798
- function requiredFile(file: string | undefined): string {
799
- if (!file) throw new WorkflowError("INTERNAL_ERROR", "Workflow agents require persisted native Pi sessions");
800
- return file;
801
- }
802
-
803
934
  function remaining(timeoutMs: number | null | undefined, started: number): number | null | undefined {
804
935
  return timeoutMs === null || timeoutMs === undefined ? timeoutMs : Math.max(1, timeoutMs - (Date.now() - started));
805
936
  }
@@ -810,20 +941,20 @@ function providerLimited(error: unknown): boolean {
810
941
  return candidate.status === 429 || candidate.code === 429 || candidate.code === "rate_limit_exceeded" || candidate.code === "RATE_LIMITED";
811
942
  }
812
943
 
813
- async function promptWithProviderPause(session: NativeSession, text: string, timeoutMs: number | null | undefined, signal: AbortSignal | undefined, pause?: () => Promise<void>): Promise<void> {
944
+ async function promptWithProviderPause(session: WorkflowAgentSession, text: string, timeoutMs: number | null | undefined, signal: AbortSignal | undefined, pause?: () => Promise<void>): Promise<WorkflowAgentTurnResult> {
814
945
  for (;;) {
815
- try { await withTimeout(session.prompt(text), timeoutMs, signal, session); return; }
946
+ try { return await withTimeout(session.prompt(text), timeoutMs, signal, session); }
816
947
  catch (error) { if (!pause || !providerLimited(error)) throw error; await pause(); }
817
948
  }
818
949
  }
819
950
 
820
- async function withTimeout(work: Promise<void>, timeoutMs: number | null | undefined, signal: AbortSignal | undefined, session: NativeSession): Promise<void> {
951
+ async function withTimeout(work: Promise<WorkflowAgentTurnResult>, timeoutMs: number | null | undefined, signal: AbortSignal | undefined, session: WorkflowAgentSession): Promise<WorkflowAgentTurnResult> {
821
952
  if (signal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
822
953
  let timer: NodeJS.Timeout | undefined;
823
954
  let abort: (() => void) | undefined;
824
955
  const state = { interrupted: false };
825
956
  const timeout = timeoutMs ? new Promise<never>((_, reject) => { timer = setTimeout(() => { state.interrupted = true; reject(new WorkflowError("AGENT_TIMEOUT", "Agent attempt timed out")); }, timeoutMs); }) : new Promise<never>(() => {});
826
957
  const cancelled = signal ? new Promise<never>((_, reject) => { abort = () => { state.interrupted = true; reject(new WorkflowError("CANCELLED", "Agent cancelled")); }; signal.addEventListener("abort", abort, { once: true }); }) : new Promise<never>(() => {});
827
- try { await Promise.race([work, timeout, cancelled]); }
828
- finally { if (timer) clearTimeout(timer); if (abort) signal?.removeEventListener("abort", abort); if (state.interrupted) await session.abort?.(); }
958
+ try { return await Promise.race([work, timeout, cancelled]); }
959
+ finally { if (timer) clearTimeout(timer); if (abort) signal?.removeEventListener("abort", abort); if (state.interrupted) await session.abort(); }
829
960
  }