pi-extensible-workflows 3.3.0 → 3.4.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.
@@ -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,8 +90,7 @@ 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
  }
@@ -86,20 +99,15 @@ function hasToolCall(message: unknown): boolean {
86
99
  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
100
  }
88
101
 
89
- function latestAssistantHasToolCall(messages: readonly AgentMessage[]): boolean {
90
- const message = [...messages].reverse().find((item) => item.role === "assistant");
91
- return hasToolCall(message);
92
- }
102
+ function latestAssistantHasToolCall(message: WorkflowAgentMessage | undefined): boolean { return hasToolCall(message); }
93
103
 
94
104
  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");
105
+ function throwIfTerminalAssistantError(session: WorkflowAgentSession, message: WorkflowAgentMessage | undefined): void {
97
106
  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";
107
+ const state = session.getState();
108
+ const error = message.errorMessage ?? "Workflow agent session ended with a terminal provider error";
101
109
  const failure = new WorkflowError("AGENT_FAILED", error);
102
- Object.defineProperty(failure, "terminalProviderError", { value: { provider, model, error }, configurable: true });
110
+ Object.defineProperty(failure, "terminalProviderError", { value: { provider: state.model.provider, model: state.model.model, error }, configurable: true });
103
111
  throw failure;
104
112
  }
105
113
  function terminalProviderError(error: WorkflowError): TerminalProviderError | undefined {
@@ -110,10 +118,10 @@ function terminalProviderError(error: WorkflowError): TerminalProviderError | un
110
118
  }
111
119
  type ProviderRecoveryMarker = { providerRecoveryHandled?: boolean; providerRecovery?: AgentProviderRecovery; providerRecoveryFailed?: boolean };
112
120
  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> {
121
+ async function recoverTerminalProviderError(session: WorkflowAgentSession, label: string, recovery: AgentExecutionOptions["providerErrorRecovery"], continuePrompt: () => Promise<void>, getAssistant: () => WorkflowAgentMessage | undefined): Promise<boolean> {
114
122
  let continued = false;
115
123
  for (;;) {
116
- try { throwIfTerminalAssistantError(session, fallbackModel); return continued; }
124
+ try { throwIfTerminalAssistantError(session, getAssistant()); return continued; }
117
125
  catch (error) {
118
126
  const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
119
127
  const terminal = terminalProviderError(typed);
@@ -127,7 +135,7 @@ async function recoverTerminalProviderError(session: NativeSession, fallbackMode
127
135
  }
128
136
  }
129
137
 
130
- function accounting(stats: ReturnType<NativeSession["getSessionStats"]>): AgentAccounting {
138
+ function accounting(stats: WorkflowAgentSessionStats): AgentAccounting {
131
139
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
132
140
  }
133
141
  function canonicalSourcePath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
@@ -139,7 +147,7 @@ function workflowSystemPromptPath(cwd: string, agentDir: string, projectTrusted:
139
147
  return globalPaths.find((path) => existsSync(path));
140
148
  }
141
149
 
142
- export async function createNativeAgentSession(input: SessionInput): Promise<NativeSession> {
150
+ export async function createLocalPiSession(input: SessionInput): Promise<PiSession> {
143
151
  const agentDir = input.agentDir ?? getAgentDir();
144
152
  const systemPromptSource = workflowSystemPromptPath(input.cwd, agentDir, input.resourcePolicy?.projectTrusted ?? true);
145
153
  const systemPromptOptions = input.systemPrompt !== undefined ? { systemPromptOverride: () => input.systemPrompt } : systemPromptSource !== undefined ? { systemPrompt: systemPromptSource } : {};
@@ -194,8 +202,59 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
194
202
  return Object.assign(session, {
195
203
  getLeafId: () => manager.getLeafId(),
196
204
  getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
197
- }) as unknown as NativeSession;
205
+ }) as unknown as PiSession;
206
+ }
207
+ 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; }
208
+ 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 }; }
209
+ function workflowAgentState(native: PiSession, prepared: Readonly<PreparedAgentSession>): WorkflowAgentSessionState {
210
+ const tools = native.agent?.state.tools.map(({ name }) => name) ?? prepared.tools;
211
+ 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 };
212
+ return { model, ...(model.thinking ? { thinking: model.thinking } : {}), tools: [...tools], ...(native.systemPrompt === undefined ? {} : { systemPrompt: native.systemPrompt }) };
198
213
  }
214
+ function localSessionEvent(event: unknown): WorkflowAgentSessionEvent { return event as WorkflowAgentSessionEvent; }
215
+ export async function createLocalWorkflowAgentSession(prepared: Readonly<PreparedAgentSession>, context: Readonly<AgentTransportContext>): Promise<WorkflowAgentSession> {
216
+ void context;
217
+ const input: SessionInput = {
218
+ cwd: prepared.cwd, model: { ...prepared.model }, tools: [...prepared.tools] as SessionInput["tools"], sessionLabel: prepared.sessionLabel,
219
+ ...(prepared.agentDir ? { agentDir: prepared.agentDir } : {}), ...(prepared.customTools?.length ? { customTools: [...prepared.customTools] as NonNullable<SessionInput["customTools"]> } : {}),
220
+ ...(prepared.resultTool ? { resultTool: prepared.resultTool } : {}), ...(prepared.systemPrompt === undefined ? {} : { systemPrompt: prepared.systemPrompt }),
221
+ ...(prepared.systemPromptAppend ? { systemPromptAppend: prepared.systemPromptAppend } : {}), ...(prepared.extensionFactories?.length ? { extensionFactories: [...prepared.extensionFactories] } : {}),
222
+ ...(prepared.additionalSkillPaths?.length ? { additionalSkillPaths: [...prepared.additionalSkillPaths] } : {}), ...(prepared.resourcePolicy ? { resourcePolicy: structuredClone(prepared.resourcePolicy) } : {}), ...(prepared.options ? { options: { ...prepared.options } } : {}),
223
+ };
224
+ const native = await createLocalPiSession(input);
225
+ let disposal: Promise<void> | undefined;
226
+ let aborting: Promise<void> | undefined;
227
+ let prompting: Promise<WorkflowAgentTurnResult> | undefined;
228
+ let disposed = false;
229
+ const startAbort = () => aborting ??= Promise.resolve().then(() => native.abort?.()).then(() => undefined).finally(() => { aborting = undefined; });
230
+ const reference: WorkflowAgentSessionReference = { transport: "local", sessionId: native.sessionId, ...(native.sessionFile ? { locator: { sessionFile: native.sessionFile } } : {}) };
231
+ const session = {
232
+ reference,
233
+ getState: () => Object.freeze(workflowAgentState(native, prepared)),
234
+ getSessionStats: () => workflowAgentStats(native.getSessionStats()),
235
+ subscribe(listener: (event: WorkflowAgentSessionEvent) => void) { listener({ type: "state_changed", state: workflowAgentState(native, prepared) }); return native.subscribe?.((event) => { listener(localSessionEvent(event)); }) ?? (() => undefined); },
236
+ getLastAssistant: () => workflowAgentMessage([...native.messages].reverse().find((message) => message.role === "assistant")),
237
+ async prompt(text: string) {
238
+ if (disposed) throw new WorkflowError("INTERNAL_ERROR", "Local workflow session is disposed");
239
+ const prompt = Promise.resolve().then(async () => { await native.prompt(text); const assistant = workflowAgentMessage([...native.messages].reverse().find((message) => message.role === "assistant")); return assistant ? { assistant } : {}; });
240
+ prompting = prompt;
241
+ try { return await prompt; } finally { if (prompting === prompt) prompting = undefined; }
242
+ },
243
+ async steer(text: string) { if (!native.steer) throw new WorkflowError("INTERNAL_ERROR", "Local workflow session does not support steering"); await native.steer(text); },
244
+ async abort() { if (disposed) return; await startAbort(); },
245
+ async dispose() {
246
+ disposal ??= (async () => {
247
+ disposed = true;
248
+ try { await startAbort(); } catch { /* Abort failure must not prevent the prompt from settling. */ }
249
+ try { await prompting; } catch { /* Prompt rejection is expected after abort during teardown. */ }
250
+ native.dispose();
251
+ })();
252
+ await disposal;
253
+ },
254
+ };
255
+ return session;
256
+ }
257
+ export const localAgentTransport: AgentTransport = Object.freeze({ id: "local", createSession: createLocalWorkflowAgentSession });
199
258
  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
259
  function validThinking(value: unknown): value is ThinkingLevel { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
201
260
  interface ChildAgentToolParams {
@@ -229,35 +288,77 @@ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionO
229
288
  function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
230
289
  return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], excludedSkills: [...(policy.excludedSkills ?? [])], excludedExtensions: [...(policy.excludedExtensions ?? [])], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
231
290
  }
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 }> {
291
+ function resourcePolicyWidened(ceiling: AgentResourcePolicy | undefined, candidate: AgentResourcePolicy | undefined): boolean {
292
+ if (!ceiling) return false;
293
+ if (!candidate) return true;
294
+ if (!ceiling.projectTrusted && candidate.projectTrusted) return true;
295
+ return ceiling.effective.skills.some((pattern) => !candidate.effective.skills.includes(pattern)) || ceiling.effective.extensions.some((pattern) => !candidate.effective.extensions.includes(pattern));
296
+ }
297
+ function preparedAgentSession(input: SessionInput): Readonly<PreparedAgentSession> {
298
+ const prepared = {
299
+ cwd: input.cwd, model: Object.freeze({ ...input.model }), tools: Object.freeze([...input.tools]), sessionLabel: input.sessionLabel,
300
+ ...(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)) } : {}),
301
+ ...(input.systemPrompt === undefined ? {} : { systemPrompt: input.systemPrompt }), ...(input.systemPromptAppend ? { systemPromptAppend: input.systemPromptAppend } : {}),
302
+ ...(input.extensionFactories?.length ? { extensionFactories: Object.freeze([...input.extensionFactories]) } : {}), ...(input.additionalSkillPaths?.length ? { additionalSkillPaths: Object.freeze([...input.additionalSkillPaths]) } : {}),
303
+ ...(input.resourcePolicy ? { resourcePolicy: Object.freeze(structuredClone(input.resourcePolicy)) } : {}),
304
+ };
305
+ return deepFreeze(prepared);
306
+ }
307
+ function agentSetupSummary(setup: AgentSetup, hookNames: readonly string[]): AgentSetupSummary {
308
+ const model = setup.sessionInput.model;
309
+ 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) } : {}) };
310
+ }
311
+ type PreparedAgentSetup = { setup: AgentSetup; summary: AgentSetupSummary; failure?: { error: unknown } };
312
+ 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
313
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
234
314
  const baselineOptions = structuredClone(options.agentOptions ?? {});
235
315
  const baseResourcePolicy = await root.agentResourcePolicy?.();
236
316
  const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
237
317
  const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
318
+ const resourcePolicyCeiling = resourcePolicy ? structuredClone(resourcePolicy) : undefined;
238
319
  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 };
320
+ const setup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, prepared: preparedAgentSession(sessionInput), transport };
240
321
  const base = fallbackSetupContext(root, options, setupSignal);
241
322
  const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
242
323
  const hookNames: string[] = [];
243
324
  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; }
325
+ if (setupSignal.aborted) return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error: new WorkflowError("CANCELLED", "Agent cancelled") } };
326
+ try { await hook.setup(setup, context); } catch (error) {
327
+ setup.prepared = preparedAgentSession(setup.sessionInput);
328
+ return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error: setupSignal.reason !== undefined ? new WorkflowError("CANCELLED", "Agent cancelled") : error } };
329
+ }
330
+ setup.prepared = preparedAgentSession(setup.sessionInput);
246
331
  hookNames.push(hook.name);
247
- if (setupSignal.reason !== undefined) throw new WorkflowError("CANCELLED", "Agent cancelled");
332
+ if (setupSignal.reason !== undefined) return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error: new WorkflowError("CANCELLED", "Agent cancelled") } };
248
333
  }
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 };
334
+ try {
335
+ if (resourcePolicyWidened(resourcePolicyCeiling, setup.sessionInput.resourcePolicy)) throw new WorkflowError("INVALID_METADATA", "Agent setup widened the prepared resource policy");
336
+ setup.sessionInput.options = setup.options;
337
+ 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);
338
+ if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking)) setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
339
+ 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];
340
+ if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
341
+ const customToolNames = new Set([...(setup.sessionInput.customTools ?? []).map(({ name }) => name), ...(setup.sessionInput.resultTool ? [setup.sessionInput.resultTool.name] : [])]);
342
+ const widened = setup.sessionInput.tools.find((tool) => !resolved.tools.includes(tool) && !customToolNames.has(tool));
343
+ const outsideTool = widened ?? setup.sessionInput.tools.find((tool) => !root.tools.has(tool) && !customToolNames.has(tool));
344
+ if (outsideTool) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the prepared agent policy: ${outsideTool}`);
345
+ } catch (error) {
346
+ setup.prepared = preparedAgentSession(setup.sessionInput);
347
+ return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error } };
348
+ }
349
+ setup.prepared = preparedAgentSession(setup.sessionInput);
350
+ return { setup, summary: agentSetupSummary(setup, hookNames) };
351
+ }
352
+ function attemptRecord(transport: string, attempt: number, session: WorkflowAgentSession, setup: AgentSetupSummary, stats: AgentAccounting, result?: JsonValue, error?: { code: string; message: string }): AgentAttempt {
353
+ return { attempt, transport, session: session.reference, ...(result === undefined ? {} : { result }), ...(error ? { error } : {}), accounting: stats, setup };
354
+ }
355
+ function errorWithAttempts(error: unknown, attempts: readonly AgentAttempt[]): Error {
356
+ const typed = error instanceof Error ? error : new Error(typeof error === "string" ? error : String(error));
357
+ return Object.assign(typed, { attempts });
257
358
  }
258
-
259
359
  export class WorkflowAgentExecutor {
260
- constructor(private readonly root: AgentExecutionRoot, private readonly createSession: SessionFactory = createNativeAgentSession) {}
360
+ private readonly transport: AgentTransport;
361
+ constructor(private readonly root: AgentExecutionRoot, transport: AgentTransport = localAgentTransport) { this.transport = transport; }
261
362
  setRunContext(runContext: Readonly<WorkflowRunContext>): void { this.root.runContext = runContext; }
262
363
 
263
364
  resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string } {
@@ -308,15 +409,15 @@ export class WorkflowAgentExecutor {
308
409
  const attempts: AgentAttempt[] = [];
309
410
  let maxAttempts = (options.retries ?? 0) + 1;
310
411
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
412
+ const attemptSignal = executionSignal ?? new AbortController().signal;
311
413
  if (recoveryModel) resolved = this.resolve({ ...options, modelOverride: recoveryModel });
312
414
  options.budget?.beforeAttempt();
313
415
  let schemaResult: JsonValue | undefined;
314
- let session: NativeSession | undefined;
416
+ let session: WorkflowAgentSession | undefined;
315
417
  let setup: AgentSetup | undefined;
316
- let setupSummary: AgentSetupSummary | undefined;
418
+ let setupSummary: AgentSetupSummary = { hookNames: [], model: { ...resolved.model }, tools: [...resolved.tools], cwd };
317
419
  let setupFailed = false;
318
420
  let budgetError: WorkflowError | undefined;
319
- let turnStarted = false;
320
421
  const hasSchemaResult = () => schemaResult !== undefined;
321
422
  const resultTool = options.schema ? {
322
423
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
@@ -324,7 +425,8 @@ export class WorkflowAgentExecutor {
324
425
  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
426
  if (schemaResult !== undefined) return { content: [{ type: "text" as const, text: "Result has already been accepted." }], details: {}, isError: true };
326
427
  schemaResult = structuredClone(value) as JsonValue;
327
- void session?.abort?.();
428
+ const currentSession = session;
429
+ if (currentSession) void currentSession.abort();
328
430
  return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
329
431
  },
330
432
  } as ToolDefinition : undefined;
@@ -337,13 +439,15 @@ export class WorkflowAgentExecutor {
337
439
  let systemPromptTurn = 0;
338
440
  let systemPromptWrite = Promise.resolve();
339
441
  let systemPromptWriteError: unknown;
442
+ let turnStarted = false;
443
+ let completedAttempt: AgentAttempt | undefined;
340
444
  const flushSystemPrompts = async () => {
341
445
  await systemPromptWrite;
342
446
  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
447
  };
344
448
  const report = (persist: boolean) => {
345
449
  if (!session || !options.onProgress) return;
346
- const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }), persist };
450
+ const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], state: session.getState(), ...(activity ? { activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }), persist };
347
451
  if (lastEventAt !== undefined) lastReportedEventAt = lastEventAt;
348
452
  progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
349
453
  };
@@ -354,71 +458,84 @@ export class WorkflowAgentExecutor {
354
458
  const activityChanged = (previous: AgentActivity | undefined) => previous?.kind !== activity?.kind || previous?.text !== activity?.text;
355
459
  try {
356
460
  setupFailed = true;
357
- const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool);
461
+ const prepared = await prepareAgentSetup(this.root, this.transport, task, options, resolved, cwd, attempt, attemptSignal, customTools, resultTool);
358
462
  setup = prepared.setup;
359
463
  setupSummary = prepared.summary;
464
+ if (prepared.failure) throw prepared.failure.error;
360
465
  setupFailed = false;
361
- if (executionSignal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
466
+ if (attemptSignal.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
362
467
  const started = Date.now();
363
- session = await setup.createSession(setup.sessionInput);
468
+ const transportSignal = attemptSignal;
469
+ await options.onAttempt?.({ attempt, transport: setup.transport.id, accounting: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, setup: setupSummary });
470
+ const transportBase = fallbackSetupContext(this.root, options, transportSignal);
471
+ const createdSession = await setup.transport.createSession(setup.prepared, Object.freeze({ run: transportBase.run, identity: transportBase.identity, attempt, signal: transportSignal }));
472
+ if (createdSession.reference.transport !== setup.transport.id) {
473
+ await createdSession.dispose();
474
+ throw new WorkflowError("INTERNAL_ERROR", `Agent transport ${setup.transport.id} created a session for ${createdSession.reference.transport}`);
475
+ }
476
+ session = createdSession;
477
+ 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 });
478
+ const preparedTools = new Set([...setup.prepared.tools, ...(setup.prepared.customTools ?? []).map(({ name }) => name), ...(setup.prepared.resultTool ? [setup.prepared.resultTool.name] : [])]);
479
+ 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
480
  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
481
  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; } });
482
+ let lastAssistant: WorkflowAgentMessage | undefined;
483
+ const recoverTerminal = () => recoverTerminalProviderError(activeSession, options.label, options.providerErrorRecovery, async () => { try { lastAssistant = (await promptWithProviderPause(activeSession, providerContinuationPrompt, remaining(options.timeoutMs, started), attemptSignal, this.root.providerPause)).assistant; } catch (error) { lastAssistant = (activeSession as WorkflowAgentSession & { getLastAssistant?: () => WorkflowAgentMessage | undefined }).getLastAssistant?.() ?? lastAssistant; if (!hasSchemaResult()) throw error; } }, () => lastAssistant);
370
484
  const promptAndRecover = async (prompt: string): Promise<void> => {
371
485
  let promptFailed = false;
372
486
  let promptError: unknown;
373
- try { await promptWithProviderPause(activeSession, prompt, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { promptFailed = true; promptError = error; }
487
+ try { lastAssistant = (await promptWithProviderPause(activeSession, prompt, remaining(options.timeoutMs, started), attemptSignal, this.root.providerPause)).assistant; } catch (error) { lastAssistant = (activeSession as WorkflowAgentSession & { getLastAssistant?: () => WorkflowAgentMessage | undefined }).getLastAssistant?.() ?? lastAssistant; promptFailed = true; promptError = error; }
374
488
  const recovered = await recoverTerminal();
375
489
  if (promptFailed && !hasSchemaResult() && !recovered) throw promptError;
376
490
  };
377
- unsubscribe = activeSession.subscribe?.((event) => {
491
+ unsubscribe = activeSession.subscribe((event) => {
378
492
  lastEventAt = Date.now();
379
493
  let persist = false;
380
494
  let shouldReport = false;
381
495
  let removeToolCallId: string | undefined;
382
- if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
496
+ if (event.type === "agent_start" && session?.getState().systemPrompt !== undefined) {
383
497
  if (this.root.runStore) {
384
498
  systemPromptTurn += 1;
385
- const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
499
+ const entry = { sessionId: session.reference.sessionId, attempt, turn: systemPromptTurn, prompt: session.getState().systemPrompt ?? "" };
386
500
  systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error: unknown) => { systemPromptWriteError ??= error; });
387
501
  }
388
502
  }
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?.(); } }
503
+ if (event.type === "state_changed") { shouldReport = true; persist = true; }
504
+ 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(); } } }
505
+ if (event.type === "message_start" && event.message?.role === "assistant") {
506
+ 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
507
  activity = { kind: "text", text: "responding" };
392
508
  shouldReport = true;
393
509
  }
394
510
  if (event.type === "message_update") {
395
511
  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" };
512
+ const updateType = event.assistantMessageEvent?.type;
513
+ if (updateType && ["thinking_start", "thinking_delta", "thinking_end"].includes(updateType)) activity = { kind: "reasoning", text: "reasoning" };
514
+ else if (updateType && ["text_start", "text_delta", "text_end", "toolcall_start", "toolcall_delta", "toolcall_end"].includes(updateType)) activity = { kind: "text", text: "responding" };
398
515
  shouldReport = activityChanged(previousActivity);
399
516
  }
400
517
  if (event.type === "message_end") {
401
518
  const previousActivity = activity;
402
519
  activity = undefined;
403
520
  shouldReport = activityChanged(previousActivity);
404
- if (event.message.role === "assistant") {
521
+ if (event.message?.role === "assistant") {
522
+ lastAssistant = event.message;
405
523
  const needsMoreWork = hasToolCall(event.message);
406
524
  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?.(); } }
525
+ 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
526
  turnStarted = false;
409
527
  persist = true;
410
528
  }
411
529
  }
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; }
530
+ 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; }
531
+ if (event.type === "tool_execution_update" && event.toolName) { const previousActivity = activity; activity = { kind: "tool", text: event.toolName }; shouldReport = activityChanged(previousActivity); }
532
+ 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
533
  if (shouldReport || persist) report(persist); else reportTimestamp();
416
534
  if (removeToolCallId) toolCalls.delete(removeToolCallId);
417
535
  });
418
536
  report(false);
419
537
  if (setSteer) {
420
- if (!session.steer) throw new WorkflowError("INTERNAL_ERROR", "Native Pi session does not support steering");
421
- setSteer((message) => session?.steer?.(message));
538
+ setSteer((message) => activeSession.steer(message));
422
539
  }
423
540
  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
541
  const instruction = options.budget?.instruction();
@@ -426,7 +543,7 @@ export class WorkflowAgentExecutor {
426
543
  options.budget?.beforeTurn();
427
544
  turnStarted = true;
428
545
  await promptAndRecover(promptText);
429
- { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
546
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(lastAssistant)); turnStarted = false; }
430
547
  if (budgetError) throw budgetError;
431
548
  if (options.schema) {
432
549
  if (!hasSchemaResult()) {
@@ -443,29 +560,39 @@ export class WorkflowAgentExecutor {
443
560
  }
444
561
  if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
445
562
  }
446
- const value = options.schema ? schemaResult as JsonValue : text(session.messages);
563
+ const value = options.schema ? schemaResult as JsonValue : text(lastAssistant);
447
564
  if (options.worktreeOwner) await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
448
565
  report(true);
449
566
  await progress;
450
567
  await flushSystemPrompts();
451
- unsubscribe?.();
568
+ unsubscribe();
452
569
  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();
570
+ completedAttempt = attemptRecord(setup.transport.id, attempt, session, setupSummary, attemptAccounting, value);
571
+ attempts.push(completedAttempt);
572
+ try { await options.onAttempt?.(completedAttempt); } finally { await session.dispose(); }
456
573
  return { value, attempts, cwd: setupSummary.cwd };
457
574
  } 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)));
575
+ if (completedAttempt) throw errorWithAttempts(error, attempts);
576
+ const typed = budgetError ?? (error instanceof WorkflowError ? error : new WorkflowError(attemptSignal.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
577
+ if (!session) {
578
+ 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 };
579
+ attempts.push(failedAttempt);
580
+ try { await options.onAttempt?.(failedAttempt); } catch (persistenceError) { throw errorWithAttempts(persistenceError, attempts); }
581
+ }
459
582
  if (session) {
460
583
  report(true);
461
- await progress;
584
+ await progress.catch(() => undefined);
462
585
  try { await flushSystemPrompts(); } catch { /* Preserve the agent failure that prompted this cleanup. */ }
463
586
  unsubscribe?.();
464
587
  const attemptAccounting = accounting(session.getSessionStats());
465
588
  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();
589
+ const failedAttempt = attemptRecord(setup?.transport.id ?? this.transport.id, attempt, session, setupSummary, attemptAccounting, undefined, { code: typed.code, message: typed.message });
590
+ attempts.push(failedAttempt);
591
+ try {
592
+ try { await options.onAttempt?.(failedAttempt); } finally { await session.dispose(); }
593
+ } catch (persistenceError) {
594
+ throw errorWithAttempts(persistenceError, attempts);
595
+ }
469
596
  }
470
597
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED") await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
471
598
  const terminal = terminalProviderError(typed);
@@ -534,6 +661,7 @@ type ScheduledNode = {
534
661
  id: string;
535
662
  runId: string;
536
663
  parentId?: string;
664
+ prompt?: string;
537
665
  options: Readonly<ScheduledAgentOptions>;
538
666
  children: Set<string>;
539
667
  collected: boolean;
@@ -547,7 +675,7 @@ type ScheduledNode = {
547
675
  };
548
676
 
549
677
  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> };
678
+ export type OwnershipRecord = { id: string; parentId?: string; prompt?: string; label: string; state: ScheduledNode["state"]; options: Readonly<ScheduledAgentOptions> };
551
679
  type OwnershipWriter = (runId: string, ownership: readonly OwnershipRecord[]) => void | Promise<void>;
552
680
 
553
681
  export class FairAgentScheduler {
@@ -586,7 +714,7 @@ export class FairAgentScheduler {
586
714
  const id = `${runId}:${String(++this.#nextId)}`;
587
715
  let resolveResult: (result: ScheduledAgentResult) => void = () => undefined;
588
716
  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 };
717
+ 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
718
  node.task = async () => {
591
719
  if (node.controller.signal.aborted) { this.#release(node.runId); return; }
592
720
  node.state = "running";
@@ -702,7 +830,7 @@ export class FairAgentScheduler {
702
830
  }
703
831
 
704
832
  snapshot(): readonly OwnershipRecord[] {
705
- return [...this.#nodes.values()].map(({ id, parentId, options, state }) => ({ id, ...(parentId ? { parentId } : {}), label: options.label, state, options }));
833
+ return [...this.#nodes.values()].map(({ id, parentId, prompt, options, state }) => ({ id, ...(parentId ? { parentId } : {}), ...(prompt === undefined ? {} : { prompt }), label: options.label, state, options }));
706
834
  }
707
835
 
708
836
  restoreRun(runId: string, limit: number, ownership: readonly OwnershipRecord[], beforeLaunch?: () => void): void {
@@ -712,7 +840,7 @@ export class FairAgentScheduler {
712
840
  if (record.id.split(":").slice(0, -1).join(":") !== runId) throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted agent belongs to another run: ${record.id}`);
713
841
  let resolveResult: (result: ScheduledAgentResult) => void = () => undefined;
714
842
  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 };
843
+ 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
844
  this.#nodes.set(node.id, node);
717
845
  run.logical += 1;
718
846
  this.#nextId = Math.max(this.#nextId, Number(node.id.slice(node.id.lastIndexOf(":") + 1)) || 0);
@@ -795,11 +923,6 @@ export class FairAgentScheduler {
795
923
 
796
924
  function resolvePath(path: string): string { return path.replace(/[\\/]+$/, ""); }
797
925
 
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
926
  function remaining(timeoutMs: number | null | undefined, started: number): number | null | undefined {
804
927
  return timeoutMs === null || timeoutMs === undefined ? timeoutMs : Math.max(1, timeoutMs - (Date.now() - started));
805
928
  }
@@ -810,20 +933,20 @@ function providerLimited(error: unknown): boolean {
810
933
  return candidate.status === 429 || candidate.code === 429 || candidate.code === "rate_limit_exceeded" || candidate.code === "RATE_LIMITED";
811
934
  }
812
935
 
813
- async function promptWithProviderPause(session: NativeSession, text: string, timeoutMs: number | null | undefined, signal: AbortSignal | undefined, pause?: () => Promise<void>): Promise<void> {
936
+ async function promptWithProviderPause(session: WorkflowAgentSession, text: string, timeoutMs: number | null | undefined, signal: AbortSignal | undefined, pause?: () => Promise<void>): Promise<WorkflowAgentTurnResult> {
814
937
  for (;;) {
815
- try { await withTimeout(session.prompt(text), timeoutMs, signal, session); return; }
938
+ try { return await withTimeout(session.prompt(text), timeoutMs, signal, session); }
816
939
  catch (error) { if (!pause || !providerLimited(error)) throw error; await pause(); }
817
940
  }
818
941
  }
819
942
 
820
- async function withTimeout(work: Promise<void>, timeoutMs: number | null | undefined, signal: AbortSignal | undefined, session: NativeSession): Promise<void> {
943
+ async function withTimeout(work: Promise<WorkflowAgentTurnResult>, timeoutMs: number | null | undefined, signal: AbortSignal | undefined, session: WorkflowAgentSession): Promise<WorkflowAgentTurnResult> {
821
944
  if (signal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
822
945
  let timer: NodeJS.Timeout | undefined;
823
946
  let abort: (() => void) | undefined;
824
947
  const state = { interrupted: false };
825
948
  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
949
  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?.(); }
950
+ try { return await Promise.race([work, timeout, cancelled]); }
951
+ finally { if (timer) clearTimeout(timer); if (abort) signal?.removeEventListener("abort", abort); if (state.interrupted) await session.abort(); }
829
952
  }