pi-extensible-workflows 3.2.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.
Files changed (47) hide show
  1. package/README.md +2 -31
  2. package/dist/src/agent-execution.d.ts +67 -9
  3. package/dist/src/agent-execution.js +385 -141
  4. package/dist/src/bundles.d.ts +53 -0
  5. package/dist/src/bundles.js +457 -0
  6. package/dist/src/cli.d.ts +3 -1
  7. package/dist/src/cli.js +156 -22
  8. package/dist/src/doctor-cleanup.js +68 -31
  9. package/dist/src/eval-capture-extension.js +16 -1
  10. package/dist/src/execution.d.ts +1 -1
  11. package/dist/src/execution.js +29 -13
  12. package/dist/src/host.d.ts +12 -9
  13. package/dist/src/host.js +525 -195
  14. package/dist/src/index.d.ts +5 -4
  15. package/dist/src/index.js +3 -2
  16. package/dist/src/persistence.d.ts +43 -8
  17. package/dist/src/persistence.js +54 -0
  18. package/dist/src/registry.d.ts +3 -2
  19. package/dist/src/registry.js +16 -4
  20. package/dist/src/session-inspector.d.ts +12 -2
  21. package/dist/src/session-inspector.js +50 -24
  22. package/dist/src/types.d.ts +141 -60
  23. package/dist/src/types.js +1 -0
  24. package/dist/src/validation.js +28 -7
  25. package/dist/src/workflow-artifacts.d.ts +1 -0
  26. package/dist/src/workflow-artifacts.js +1 -0
  27. package/dist/src/workflow-evals.d.ts +7 -1
  28. package/dist/src/workflow-evals.js +23 -3
  29. package/evals/cases/recovery-completed-worktree.yaml +17 -0
  30. package/evals/cases/recovery-failed-run.yaml +14 -0
  31. package/package.json +1 -1
  32. package/skills/pi-extensible-workflows/SKILL.md +13 -5
  33. package/src/agent-execution.ts +302 -107
  34. package/src/bundles.ts +471 -0
  35. package/src/cli.ts +130 -22
  36. package/src/doctor-cleanup.ts +38 -4
  37. package/src/eval-capture-extension.ts +15 -1
  38. package/src/execution.ts +27 -15
  39. package/src/host.ts +454 -175
  40. package/src/index.ts +5 -4
  41. package/src/persistence.ts +58 -4
  42. package/src/registry.ts +14 -5
  43. package/src/session-inspector.ts +49 -26
  44. package/src/types.ts +55 -30
  45. package/src/validation.ts +19 -6
  46. package/src/workflow-artifacts.ts +1 -0
  47. package/src/workflow-evals.ts +24 -3
@@ -1,22 +1,37 @@
1
- import { realpathSync } from "node:fs";
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { join, resolve } from "node:path";
3
4
  import { Type } from "@earendil-works/pi-ai";
4
5
  import { Value } from "typebox/value";
5
6
  import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
6
7
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
7
8
  type AgentMessage = { role: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
8
- import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetup, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, NativeSession, RegisteredAgentSetupHook, SessionFactory, SessionInput, WorkflowRunContext } from "./types.js";
9
- 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";
10
25
  import { WorkflowError } from "./types.js";
11
26
  import type { RunStore } from "./persistence.js";
12
- 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";
13
28
  export interface AgentBudgetHooks {
14
29
  beforeAttempt(): void;
15
30
  beforeTurn(): void;
16
31
  afterTurn(accounting: AgentAccounting, final: boolean): void;
17
32
  instruction(): string | undefined;
18
33
  }
19
- export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[]; disabledAgentResources?: AgentResourceExclusions }
34
+ export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[]; overrideSystemPrompt?: boolean; disabledAgentResources?: AgentResourceExclusions }
20
35
  export interface AgentProviderFailure { label: string; provider: string; model: string; error: string }
21
36
  export type AgentProviderRecovery = "retry" | "abort" | { model: string };
22
37
  export interface AgentExecutionOptions {
@@ -27,7 +42,7 @@ export interface AgentExecutionOptions {
27
42
  model?: string;
28
43
  thinking?: ThinkingLevel;
29
44
  onProgress?: (progress: AgentProgress) => void | Promise<void>;
30
- onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => void | Promise<void>;
45
+ onAttempt?: (attempt: AgentAttempt) => void | Promise<void>;
31
46
  providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>;
32
47
  modelOverride?: ModelSpec;
33
48
  tools?: readonly string[];
@@ -49,6 +64,7 @@ export interface AgentExecutionRoot {
49
64
  tools: ReadonlySet<string>;
50
65
  agentDefinitions?: Readonly<Record<string, AgentDefinition>>;
51
66
  agentDir?: string;
67
+ additionalSkillPaths?: readonly string[];
52
68
  availableModels?: ReadonlySet<string>;
53
69
  knownModels?: ReadonlySet<string>;
54
70
  modelAliases?: Readonly<Record<string, string>>;
@@ -64,8 +80,8 @@ export interface AgentExecutionRoot {
64
80
  export interface AgentAccounting { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }
65
81
  export interface AgentToolCallProgress { id: string; name: string; state: "running" | "completed" | "failed" }
66
82
  export interface AgentActivity { kind: "reasoning" | "tool" | "text"; text: string }
67
- export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; activity?: AgentActivity; persist: boolean }
68
- 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 }
69
85
  export interface AgentExecutionResult { value: JsonValue; attempts: readonly AgentAttempt[]; cwd: string }
70
86
 
71
87
  function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
@@ -74,8 +90,7 @@ function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: T
74
90
  return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
75
91
  }
76
92
 
77
- function text(messages: readonly AgentMessage[]): string {
78
- const message = [...messages].reverse().find((item) => item.role === "assistant");
93
+ function text(message: WorkflowAgentMessage | undefined): string {
79
94
  if (!message || !Array.isArray(message.content)) return "";
80
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("");
81
96
  }
@@ -84,20 +99,15 @@ function hasToolCall(message: unknown): boolean {
84
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");
85
100
  }
86
101
 
87
- function latestAssistantHasToolCall(messages: readonly AgentMessage[]): boolean {
88
- const message = [...messages].reverse().find((item) => item.role === "assistant");
89
- return hasToolCall(message);
90
- }
102
+ function latestAssistantHasToolCall(message: WorkflowAgentMessage | undefined): boolean { return hasToolCall(message); }
91
103
 
92
104
  type TerminalProviderError = { provider: string; model: string; error: string };
93
- function throwIfTerminalAssistantError(session: NativeSession, fallbackModel: ModelSpec): void {
94
- const message = [...session.messages].reverse().find((item) => item.role === "assistant");
105
+ function throwIfTerminalAssistantError(session: WorkflowAgentSession, message: WorkflowAgentMessage | undefined): void {
95
106
  if (message?.stopReason !== "error") return;
96
- const provider = session.model?.provider ?? fallbackModel.provider;
97
- const model = session.model?.model ?? session.model?.id ?? fallbackModel.model;
98
- 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";
99
109
  const failure = new WorkflowError("AGENT_FAILED", error);
100
- 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 });
101
111
  throw failure;
102
112
  }
103
113
  function terminalProviderError(error: WorkflowError): TerminalProviderError | undefined {
@@ -106,14 +116,41 @@ function terminalProviderError(error: WorkflowError): TerminalProviderError | un
106
116
  const candidate = value as Partial<TerminalProviderError>;
107
117
  return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
108
118
  }
119
+ type ProviderRecoveryMarker = { providerRecoveryHandled?: boolean; providerRecovery?: AgentProviderRecovery; providerRecoveryFailed?: boolean };
120
+ const providerContinuationPrompt = "The provider error was transient. Continue the task from your current state.";
121
+ async function recoverTerminalProviderError(session: WorkflowAgentSession, label: string, recovery: AgentExecutionOptions["providerErrorRecovery"], continuePrompt: () => Promise<void>, getAssistant: () => WorkflowAgentMessage | undefined): Promise<boolean> {
122
+ let continued = false;
123
+ for (;;) {
124
+ try { throwIfTerminalAssistantError(session, getAssistant()); return continued; }
125
+ catch (error) {
126
+ const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
127
+ const terminal = terminalProviderError(typed);
128
+ if (!terminal || !recovery) throw error;
129
+ let action: AgentProviderRecovery;
130
+ try { action = await recovery({ label, ...terminal }); } catch { Object.assign(typed, { providerRecoveryHandled: true, providerRecoveryFailed: true }); throw typed; }
131
+ if (action === "retry") { continued = true; await continuePrompt(); continue; }
132
+ Object.assign(typed, { providerRecoveryHandled: true, providerRecovery: action });
133
+ throw typed;
134
+ }
135
+ }
136
+ }
109
137
 
110
- function accounting(stats: ReturnType<NativeSession["getSessionStats"]>): AgentAccounting {
138
+ function accounting(stats: WorkflowAgentSessionStats): AgentAccounting {
111
139
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
112
140
  }
113
141
  function canonicalSourcePath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
142
+ const WORKFLOW_DIRECTORY = "pi-extensible-workflows";
143
+ function workflowSystemPromptPath(cwd: string, agentDir: string, projectTrusted: boolean): string | undefined {
144
+ const projectPath = join(cwd, ".pi", WORKFLOW_DIRECTORY, "SYSTEM.md");
145
+ if (projectTrusted && existsSync(projectPath)) return projectPath;
146
+ const globalPaths = [join(homedir(), ".pi", WORKFLOW_DIRECTORY, "SYSTEM.md"), join(agentDir, WORKFLOW_DIRECTORY, "SYSTEM.md")];
147
+ return globalPaths.find((path) => existsSync(path));
148
+ }
114
149
 
115
- export async function createNativeAgentSession(input: SessionInput): Promise<NativeSession> {
150
+ export async function createLocalPiSession(input: SessionInput): Promise<PiSession> {
116
151
  const agentDir = input.agentDir ?? getAgentDir();
152
+ const systemPromptSource = workflowSystemPromptPath(input.cwd, agentDir, input.resourcePolicy?.projectTrusted ?? true);
153
+ const systemPromptOptions = input.systemPrompt !== undefined ? { systemPromptOverride: () => input.systemPrompt } : systemPromptSource !== undefined ? { systemPrompt: systemPromptSource } : {};
117
154
  const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
118
155
  manager.appendSessionInfo(input.sessionLabel);
119
156
  const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
@@ -147,25 +184,77 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
147
184
  noExtensions: true,
148
185
  additionalExtensionPaths: extensionPaths,
149
186
  noSkills: true,
150
- additionalSkillPaths: skillPaths,
187
+ additionalSkillPaths: [...new Set([...skillPaths, ...(input.additionalSkillPaths ?? [])])],
151
188
  ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}),
152
189
  skillsOverride: (base) => {
153
190
  const disabledSkills = updateSkillMatches(base.skills);
154
191
  return { ...base, skills: base.skills.filter(({ name }) => !disabledSkills.has(name)) };
155
192
  },
156
193
  ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}),
194
+ ...systemPromptOptions,
157
195
  });
158
196
  await resourceLoader.reload();
159
- } else if (input.systemPromptAppend || input.extensionFactories?.length) {
160
- resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
197
+ } else if (input.systemPrompt !== undefined || systemPromptSource !== undefined || input.systemPromptAppend || input.extensionFactories?.length || input.additionalSkillPaths?.length) {
198
+ resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.additionalSkillPaths?.length ? { additionalSkillPaths: [...input.additionalSkillPaths] } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...systemPromptOptions, ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
161
199
  await resourceLoader.reload();
162
200
  }
163
201
  const { session } = await createAgentSession({ ...(input.options ?? {}), cwd: input.cwd, agentDir, modelRuntime, model, ...(settingsManager ? { settingsManager } : {}), ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(resourceLoader ? { resourceLoader } : {}), sessionManager: manager });
164
202
  return Object.assign(session, {
165
203
  getLeafId: () => manager.getLeafId(),
166
204
  getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
167
- }) 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 }) };
168
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 });
169
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]); }
170
259
  function validThinking(value: unknown): value is ThinkingLevel { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
171
260
  interface ChildAgentToolParams {
@@ -199,38 +288,80 @@ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionO
199
288
  function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
200
289
  return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], excludedSkills: [...(policy.excludedSkills ?? [])], excludedExtensions: [...(policy.excludedExtensions ?? [])], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
201
290
  }
202
- async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
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> {
203
313
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
204
314
  const baselineOptions = structuredClone(options.agentOptions ?? {});
205
315
  const baseResourcePolicy = await root.agentResourcePolicy?.();
206
316
  const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
207
317
  const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
208
- const sessionInput: SessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), systemPromptAppend: resolved.systemPromptAppend, ...(resourcePolicy ? { resourcePolicy } : {}), options: structuredClone(baselineOptions) };
209
- const setup: AgentSetup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, createSession };
318
+ const resourcePolicyCeiling = resourcePolicy ? structuredClone(resourcePolicy) : undefined;
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) };
320
+ const setup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, prepared: preparedAgentSession(sessionInput), transport };
210
321
  const base = fallbackSetupContext(root, options, setupSignal);
211
322
  const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
212
323
  const hookNames: string[] = [];
213
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))) {
214
- if (setupSignal.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
215
- 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);
216
331
  hookNames.push(hook.name);
217
- 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") } };
218
333
  }
219
- setup.sessionInput.options = setup.options;
220
- 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);
221
- if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking)) setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
222
- 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];
223
- if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
224
- const model = setup.sessionInput.model;
225
- 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) } : {}) };
226
- 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 });
227
358
  }
228
-
229
359
  export class WorkflowAgentExecutor {
230
- 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; }
231
362
  setRunContext(runContext: Readonly<WorkflowRunContext>): void { this.root.runContext = runContext; }
232
363
 
233
- resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPromptAppend: string } {
364
+ resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string } {
234
365
  const role = options.role;
235
366
  const definition = role ? this.root.agentDefinitions?.[role] : undefined;
236
367
  if (role && !definition) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown agent role: ${role}`);
@@ -246,7 +377,8 @@ export class WorkflowAgentExecutor {
246
377
  const model = options.modelOverride ?? parseModel(requestedModel, this.root.model, options.thinking ?? (aliasThinking === undefined ? definition?.thinking : undefined), this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
247
378
  const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
248
379
  if (!availableModels.has(modelCapability(model))) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
249
- return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
380
+ const overrideSystemPrompt = definition?.overrideSystemPrompt === true;
381
+ return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], ...(overrideSystemPrompt ? { systemPrompt: definition.prompt ?? "" } : {}), systemPromptAppend: overrideSystemPrompt ? "" : definition?.prompt ?? "" };
250
382
  }
251
383
 
252
384
  async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
@@ -277,15 +409,15 @@ export class WorkflowAgentExecutor {
277
409
  const attempts: AgentAttempt[] = [];
278
410
  let maxAttempts = (options.retries ?? 0) + 1;
279
411
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
412
+ const attemptSignal = executionSignal ?? new AbortController().signal;
280
413
  if (recoveryModel) resolved = this.resolve({ ...options, modelOverride: recoveryModel });
281
414
  options.budget?.beforeAttempt();
282
415
  let schemaResult: JsonValue | undefined;
283
- let session: NativeSession | undefined;
416
+ let session: WorkflowAgentSession | undefined;
284
417
  let setup: AgentSetup | undefined;
285
- let setupSummary: AgentSetupSummary | undefined;
418
+ let setupSummary: AgentSetupSummary = { hookNames: [], model: { ...resolved.model }, tools: [...resolved.tools], cwd };
286
419
  let setupFailed = false;
287
420
  let budgetError: WorkflowError | undefined;
288
- let turnStarted = false;
289
421
  const hasSchemaResult = () => schemaResult !== undefined;
290
422
  const resultTool = options.schema ? {
291
423
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
@@ -293,130 +425,197 @@ export class WorkflowAgentExecutor {
293
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 };
294
426
  if (schemaResult !== undefined) return { content: [{ type: "text" as const, text: "Result has already been accepted." }], details: {}, isError: true };
295
427
  schemaResult = structuredClone(value) as JsonValue;
296
- void session?.abort?.();
428
+ const currentSession = session;
429
+ if (currentSession) void currentSession.abort();
297
430
  return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
298
431
  },
299
432
  } as ToolDefinition : undefined;
300
433
  const toolCalls = new Map<string, AgentToolCallProgress>();
301
434
  let activity: AgentActivity | undefined;
435
+ let lastEventAt: number | undefined;
436
+ let lastReportedEventAt: number | undefined;
302
437
  let progress = Promise.resolve();
303
438
  let unsubscribe: (() => void) | undefined;
304
439
  let systemPromptTurn = 0;
305
440
  let systemPromptWrite = Promise.resolve();
306
441
  let systemPromptWriteError: unknown;
442
+ let turnStarted = false;
443
+ let completedAttempt: AgentAttempt | undefined;
307
444
  const flushSystemPrompts = async () => {
308
445
  await systemPromptWrite;
309
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"}`);
310
447
  };
311
448
  const report = (persist: boolean) => {
312
449
  if (!session || !options.onProgress) return;
313
- const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
450
+ const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], state: session.getState(), ...(activity ? { activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }), persist };
451
+ if (lastEventAt !== undefined) lastReportedEventAt = lastEventAt;
314
452
  progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
315
453
  };
454
+ const reportTimestamp = () => {
455
+ if (lastEventAt !== undefined && lastReportedEventAt !== undefined && lastEventAt - lastReportedEventAt < 1000) return;
456
+ report(false);
457
+ };
458
+ const activityChanged = (previous: AgentActivity | undefined) => previous?.kind !== activity?.kind || previous?.text !== activity?.text;
316
459
  try {
317
460
  setupFailed = true;
318
- 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);
319
462
  setup = prepared.setup;
320
463
  setupSummary = prepared.summary;
464
+ if (prepared.failure) throw prepared.failure.error;
321
465
  setupFailed = false;
322
- if (executionSignal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
466
+ if (attemptSignal.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
323
467
  const started = Date.now();
324
- 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`);
325
480
  if (setup.sessionInput.resourcePolicy) setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
326
- const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
327
- await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
328
481
  const activeSession = session;
329
- unsubscribe = activeSession.subscribe?.((event) => {
330
- if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
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);
484
+ const promptAndRecover = async (prompt: string): Promise<void> => {
485
+ let promptFailed = false;
486
+ let promptError: unknown;
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; }
488
+ const recovered = await recoverTerminal();
489
+ if (promptFailed && !hasSchemaResult() && !recovered) throw promptError;
490
+ };
491
+ unsubscribe = activeSession.subscribe((event) => {
492
+ lastEventAt = Date.now();
493
+ let persist = false;
494
+ let shouldReport = false;
495
+ let removeToolCallId: string | undefined;
496
+ if (event.type === "agent_start" && session?.getState().systemPrompt !== undefined) {
331
497
  if (this.root.runStore) {
332
498
  systemPromptTurn += 1;
333
- 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 ?? "" };
334
500
  systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error: unknown) => { systemPromptWriteError ??= error; });
335
501
  }
336
502
  }
337
- if (event.type === "message_start" && event.message.role === "assistant") {
338
- if (!turnStarted) { try { options.budget?.beforeTurn(); turnStarted = true; } catch (error) { budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error)); void session?.abort?.(); } }
339
- activity = { kind: "text", text: "responding" }; report(false);
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(); } }
507
+ activity = { kind: "text", text: "responding" };
508
+ shouldReport = true;
509
+ }
510
+ if (event.type === "message_update") {
511
+ const previousActivity = activity;
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" };
515
+ shouldReport = activityChanged(previousActivity);
340
516
  }
341
517
  if (event.type === "message_end") {
518
+ const previousActivity = activity;
342
519
  activity = undefined;
343
- if (event.message.role === "assistant") {
520
+ shouldReport = activityChanged(previousActivity);
521
+ if (event.message?.role === "assistant") {
522
+ lastAssistant = event.message;
344
523
  const needsMoreWork = hasToolCall(event.message);
345
524
  const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
346
- 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(); } }
347
526
  turnStarted = false;
348
- report(true);
527
+ persist = true;
349
528
  }
350
529
  }
351
- if (event.type === "tool_execution_start") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" }); activity = { kind: "tool", text: event.toolName }; report(false); }
352
- if (event.type === "tool_execution_end") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: event.isError ? "failed" : "completed" }); if (activity?.kind === "tool" && activity.text === event.toolName) activity = undefined; report(false); toolCalls.delete(event.toolCallId); }
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; }
533
+ if (shouldReport || persist) report(persist); else reportTimestamp();
534
+ if (removeToolCallId) toolCalls.delete(removeToolCallId);
353
535
  });
354
536
  report(false);
355
537
  if (setSteer) {
356
- if (!session.steer) throw new WorkflowError("INTERNAL_ERROR", "Native Pi session does not support steering");
357
- setSteer((message) => session?.steer?.(message));
538
+ setSteer((message) => activeSession.steer(message));
358
539
  }
359
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");
360
541
  const instruction = options.budget?.instruction();
361
542
  const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
362
543
  options.budget?.beforeTurn();
363
544
  turnStarted = true;
364
- try { await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { if (!hasSchemaResult()) throw error; }
365
- throwIfTerminalAssistantError(session, setup.sessionInput.model);
366
- { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
545
+ await promptAndRecover(promptText);
546
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(lastAssistant)); turnStarted = false; }
367
547
  if (budgetError) throw budgetError;
368
548
  if (options.schema) {
369
549
  if (!hasSchemaResult()) {
370
- try { options.budget?.beforeTurn(); turnStarted = true; await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
550
+ options.budget?.beforeTurn();
551
+ turnStarted = true;
552
+ await promptAndRecover("Submit the final result now by calling workflow_result exactly once. Do not return prose.");
553
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; }
371
554
  }
372
- throwIfTerminalAssistantError(session, setup.sessionInput.model);
373
555
  if (!hasSchemaResult()) {
374
- try { options.budget?.beforeTurn(); turnStarted = true; await promptWithProviderPause(session, "Your result was missing or invalid. Repair it by calling workflow_result exactly once with a schema-valid value.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
375
- throwIfTerminalAssistantError(session, setup.sessionInput.model);
556
+ options.budget?.beforeTurn();
557
+ turnStarted = true;
558
+ await promptAndRecover("Your result was missing or invalid. Repair it by calling workflow_result exactly once with a schema-valid value.");
559
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; }
376
560
  }
377
561
  if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
378
562
  }
379
- const value = options.schema ? schemaResult as JsonValue : text(session.messages);
563
+ const value = options.schema ? schemaResult as JsonValue : text(lastAssistant);
380
564
  if (options.worktreeOwner) await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
381
565
  report(true);
382
566
  await progress;
383
567
  await flushSystemPrompts();
384
- unsubscribe?.();
568
+ unsubscribe();
385
569
  const attemptAccounting = accounting(session.getSessionStats());
386
- const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
387
- attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
388
- 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(); }
389
573
  return { value, attempts, cwd: setupSummary.cwd };
390
574
  } catch (error) {
391
- 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
+ }
392
582
  if (session) {
393
583
  report(true);
394
- await progress;
584
+ await progress.catch(() => undefined);
395
585
  try { await flushSystemPrompts(); } catch { /* Preserve the agent failure that prompted this cleanup. */ }
396
586
  unsubscribe?.();
397
587
  const attemptAccounting = accounting(session.getSessionStats());
398
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)); } }
399
- const includeFailedSetup = Boolean(this.root.agentSetupHooks?.length || setup?.sessionInput.resourcePolicy);
400
- attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), error: { code: typed.code, message: typed.message }, accounting: attemptAccounting, ...(includeFailedSetup && setupSummary ? { setup: setupSummary } : {}) });
401
- 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
+ }
402
596
  }
403
597
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED") await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
404
598
  const terminal = terminalProviderError(typed);
405
- if (terminal && options.providerErrorRecovery) {
406
- let recovery: AgentProviderRecovery;
599
+ const recoveryState = typed as WorkflowError & ProviderRecoveryMarker;
600
+ let recovery = recoveryState.providerRecovery;
601
+ if (terminal && options.providerErrorRecovery && !recoveryState.providerRecoveryHandled) {
407
602
  try { recovery = await options.providerErrorRecovery({ label: options.label, ...terminal }); } catch { throw Object.assign(typed, { attempts }); }
408
- if (recovery === "retry" || typeof recovery === "object" && typeof recovery.model === "string") {
409
- if (typeof recovery === "object") {
410
- try {
411
- const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
412
- recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
413
- } catch { throw Object.assign(typed, { attempts }); }
414
- }
603
+ if (recovery === "retry") {
415
604
  maxAttempts += 1;
416
605
  beforeRetry?.();
417
606
  continue;
418
607
  }
419
608
  }
609
+ if (recoveryState.providerRecoveryFailed || recovery === "abort") throw Object.assign(typed, { attempts });
610
+ if (typeof recovery === "object" && typeof recovery.model === "string") {
611
+ try {
612
+ const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
613
+ recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
614
+ } catch { throw Object.assign(typed, { attempts }); }
615
+ maxAttempts += 1;
616
+ beforeRetry?.();
617
+ continue;
618
+ }
420
619
  if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE") throw Object.assign(typed, { attempts });
421
620
  beforeRetry?.();
422
621
  }
@@ -462,6 +661,7 @@ type ScheduledNode = {
462
661
  id: string;
463
662
  runId: string;
464
663
  parentId?: string;
664
+ prompt?: string;
465
665
  options: Readonly<ScheduledAgentOptions>;
466
666
  children: Set<string>;
467
667
  collected: boolean;
@@ -475,7 +675,7 @@ type ScheduledNode = {
475
675
  };
476
676
 
477
677
  type ScheduledRun = { limit: number; beforeLaunch?: () => void; logical: number; active: number; queue: Array<{ node?: ScheduledNode; start: () => void }> };
478
- 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> };
479
679
  type OwnershipWriter = (runId: string, ownership: readonly OwnershipRecord[]) => void | Promise<void>;
480
680
 
481
681
  export class FairAgentScheduler {
@@ -514,7 +714,7 @@ export class FairAgentScheduler {
514
714
  const id = `${runId}:${String(++this.#nextId)}`;
515
715
  let resolveResult: (result: ScheduledAgentResult) => void = () => undefined;
516
716
  const promise = new Promise<ScheduledAgentResult>((resolve) => { resolveResult = resolve; });
517
- 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 };
518
718
  node.task = async () => {
519
719
  if (node.controller.signal.aborted) { this.#release(node.runId); return; }
520
720
  node.state = "running";
@@ -630,7 +830,7 @@ export class FairAgentScheduler {
630
830
  }
631
831
 
632
832
  snapshot(): readonly OwnershipRecord[] {
633
- 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 }));
634
834
  }
635
835
 
636
836
  restoreRun(runId: string, limit: number, ownership: readonly OwnershipRecord[], beforeLaunch?: () => void): void {
@@ -640,7 +840,7 @@ export class FairAgentScheduler {
640
840
  if (record.id.split(":").slice(0, -1).join(":") !== runId) throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted agent belongs to another run: ${record.id}`);
641
841
  let resolveResult: (result: ScheduledAgentResult) => void = () => undefined;
642
842
  const promise = new Promise<ScheduledAgentResult>((resolve) => { resolveResult = resolve; });
643
- 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 };
644
844
  this.#nodes.set(node.id, node);
645
845
  run.logical += 1;
646
846
  this.#nextId = Math.max(this.#nextId, Number(node.id.slice(node.id.lastIndexOf(":") + 1)) || 0);
@@ -723,11 +923,6 @@ export class FairAgentScheduler {
723
923
 
724
924
  function resolvePath(path: string): string { return path.replace(/[\\/]+$/, ""); }
725
925
 
726
- function requiredFile(file: string | undefined): string {
727
- if (!file) throw new WorkflowError("INTERNAL_ERROR", "Workflow agents require persisted native Pi sessions");
728
- return file;
729
- }
730
-
731
926
  function remaining(timeoutMs: number | null | undefined, started: number): number | null | undefined {
732
927
  return timeoutMs === null || timeoutMs === undefined ? timeoutMs : Math.max(1, timeoutMs - (Date.now() - started));
733
928
  }
@@ -738,20 +933,20 @@ function providerLimited(error: unknown): boolean {
738
933
  return candidate.status === 429 || candidate.code === 429 || candidate.code === "rate_limit_exceeded" || candidate.code === "RATE_LIMITED";
739
934
  }
740
935
 
741
- 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> {
742
937
  for (;;) {
743
- try { await withTimeout(session.prompt(text), timeoutMs, signal, session); return; }
938
+ try { return await withTimeout(session.prompt(text), timeoutMs, signal, session); }
744
939
  catch (error) { if (!pause || !providerLimited(error)) throw error; await pause(); }
745
940
  }
746
941
  }
747
942
 
748
- 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> {
749
944
  if (signal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
750
945
  let timer: NodeJS.Timeout | undefined;
751
946
  let abort: (() => void) | undefined;
752
947
  const state = { interrupted: false };
753
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>(() => {});
754
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>(() => {});
755
- try { await Promise.race([work, timeout, cancelled]); }
756
- 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(); }
757
952
  }