pi-extensible-workflows 0.3.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,22 @@
1
- import { join } from "node:path";
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
2
4
  import { Type } from "@earendil-works/pi-ai";
3
5
  import { Value } from "typebox/value";
4
- import { createAgentSession, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, type AgentSessionEvent, type ToolDefinition } from "@earendil-works/pi-coding-agent";
6
+ import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type AgentSessionEvent, type InlineExtension, type SessionStats, type ToolDefinition } from "@earendil-works/pi-coding-agent";
5
7
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
6
- type AgentMessage = { role: string; content?: unknown; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
7
- import type { JsonSchema, JsonValue, ModelSpec } from "./index.js";
8
- import { parseModelReference, WorkflowError } from "./index.js";
9
- import type { RunStore } from "./persistence.js";
10
-
11
- export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[] }
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, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./index.js";
10
+ import { mergeAgentResourceExclusions, resolveModelReference, WorkflowError } from "./index.js";
11
+ import type { ConversationHead, PersistedConversation, RunStore } from "./persistence.js";
12
+
13
+ export interface AgentBudgetHooks {
14
+ beforeAttempt(): void;
15
+ beforeTurn(): void;
16
+ afterTurn(accounting: AgentAccounting, final: boolean): void;
17
+ instruction(): string | undefined;
18
+ }
19
+ export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[]; disabledAgentResources?: AgentResourceExclusions }
12
20
  export interface AgentExecutionOptions {
13
21
  label: string;
14
22
  workflowName: string;
@@ -17,7 +25,7 @@ export interface AgentExecutionOptions {
17
25
  model?: string;
18
26
  thinking?: ThinkingLevel;
19
27
  onProgress?: (progress: AgentProgress) => void | Promise<void>;
20
- onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile">) => void | Promise<void>;
28
+ onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => void | Promise<void>;
21
29
  tools?: readonly string[];
22
30
  effectiveTools?: readonly string[];
23
31
  role?: string;
@@ -27,6 +35,10 @@ export interface AgentExecutionOptions {
27
35
  retryState?: string;
28
36
  worktreeOwner?: string;
29
37
  cwd?: string;
38
+ budget?: AgentBudgetHooks;
39
+ agentOptions?: Readonly<Record<string, JsonValue>>;
40
+ agentIdentity?: AgentIdentity;
41
+ conversation?: { id: string; turn: number };
30
42
  }
31
43
  export interface AgentExecutionRoot {
32
44
  cwd: string;
@@ -35,34 +47,50 @@ export interface AgentExecutionRoot {
35
47
  agentDefinitions?: Readonly<Record<string, AgentDefinition>>;
36
48
  agentDir?: string;
37
49
  availableModels?: ReadonlySet<string>;
50
+ knownModels?: ReadonlySet<string>;
51
+ modelAliases?: Readonly<Record<string, string>>;
52
+ blockedAliases?: ReadonlySet<string>;
53
+ blockedAliasTargets?: Readonly<Record<string, string>>;
54
+ settingsPath?: string;
38
55
  runStore?: RunStore;
39
56
  providerPause?: () => Promise<void>;
57
+ agentSetupHooks?: readonly RegisteredAgentSetupHook[];
58
+ agentResourcePolicy?: () => AgentResourcePolicy | Promise<AgentResourcePolicy>;
59
+ runContext?: Readonly<WorkflowRunContext>;
40
60
  }
41
61
  export interface AgentAccounting { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }
42
62
  export interface AgentToolCallProgress { id: string; name: string; state: "running" | "completed" | "failed" }
43
63
  export interface AgentActivity { kind: "reasoning" | "tool" | "text"; text: string }
44
64
  export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; activity?: AgentActivity; persist: boolean }
45
- export interface AgentAttempt { attempt: number; sessionId: string; sessionFile: string; result?: JsonValue; error?: { code: string; message: string }; accounting: AgentAccounting }
65
+ export interface AgentAttempt { attempt: number; sessionId: string; sessionFile: string; result?: JsonValue; error?: { code: string; message: string }; accounting: AgentAccounting; setup?: AgentSetupSummary }
46
66
  export interface AgentExecutionResult { value: JsonValue; attempts: readonly AgentAttempt[]; cwd: string }
47
-
67
+ export interface AgentSetup { prompt: string; options: Record<string, JsonValue>; sessionInput: SessionInput; createSession: SessionFactory }
68
+ export interface AgentSetupContext { readonly run: Readonly<WorkflowRunContext>; readonly identity: Readonly<AgentIdentity>; readonly attempt: number; readonly signal: AbortSignal }
69
+ export interface AgentSetupHook { priority?: number; setup: (agent: AgentSetup, context: Readonly<AgentSetupContext>) => void | Promise<void> }
70
+ export interface RegisteredAgentSetupHook { name: string; priority: number; setup: AgentSetupHook["setup"] }
71
+ type NativeSessionStats = Pick<SessionStats, "tokens" | "cost">;
48
72
  export interface NativeSession {
49
73
  readonly sessionId: string;
50
74
  readonly sessionFile: string | undefined;
51
75
  readonly messages: readonly AgentMessage[];
76
+ getSessionStats(): NativeSessionStats;
52
77
  readonly systemPrompt?: string;
78
+ readonly model?: { provider: string; model?: string; id?: string };
53
79
  readonly agent?: { state: { tools: readonly { name: string }[] } };
80
+ getLeafId?: () => string | null;
81
+ getToolDefinitions?: () => unknown;
54
82
  subscribe?(listener: (event: AgentSessionEvent) => void): () => void;
55
83
  prompt(text: string): Promise<void>;
56
84
  steer?(text: string): Promise<void>;
57
85
  abort?(): Promise<void>;
58
86
  dispose(): void;
59
87
  }
60
- export interface SessionInput { cwd: string; model: ModelSpec; tools: readonly string[]; sessionLabel: string; agentDir?: string; customTools?: readonly ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string }
88
+ export interface SessionInput { cwd: string; model: ModelSpec; tools: string[]; sessionLabel: string; agentDir?: string; customTools?: ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string; extensionFactories?: InlineExtension[]; resourcePolicy?: AgentResourcePolicy; options?: Record<string, JsonValue>; continuation?: { sessionId: string; sessionFile: string; leafId: string } }
61
89
  export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
62
90
 
63
- function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel): ModelSpec {
91
+ function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
64
92
  if (!value) return { ...fallback, ...(thinking ? { thinking } : {}) };
65
- const parsed = parseModelReference(value);
93
+ const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
66
94
  return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
67
95
  }
68
96
  function modelCapability(model: ModelSpec): string { return `${model.provider}/${model.model}`; }
@@ -73,39 +101,201 @@ function text(messages: readonly AgentMessage[]): string {
73
101
  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("");
74
102
  }
75
103
 
76
- function accounting(messages: readonly AgentMessage[]): AgentAccounting {
77
- const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
78
- for (const message of messages) if (message.role === "assistant" && message.usage) {
79
- total.input += message.usage.input;
80
- total.output += message.usage.output;
81
- total.cacheRead += message.usage.cacheRead;
82
- total.cacheWrite += message.usage.cacheWrite;
83
- total.cost += message.usage.cost.total;
84
- }
85
- return total;
104
+ function hasToolCall(message: unknown): boolean {
105
+ 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");
106
+ }
107
+
108
+ function latestAssistantHasToolCall(messages: readonly AgentMessage[]): boolean {
109
+ const message = [...messages].reverse().find((item) => item.role === "assistant");
110
+ return hasToolCall(message);
86
111
  }
87
112
 
113
+ function throwIfTerminalAssistantError(session: NativeSession): void {
114
+ const message = [...session.messages].reverse().find((item) => item.role === "assistant");
115
+ if (message?.stopReason === "error") throw new WorkflowError("AGENT_FAILED", message.errorMessage ?? "Native Pi assistant ended with a terminal provider error");
116
+ }
117
+
118
+ function accounting(stats: NativeSessionStats): AgentAccounting {
119
+ return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
120
+ }
121
+ function canonicalSourcePath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
122
+
88
123
  export async function createNativeAgentSession(input: SessionInput): Promise<NativeSession> {
89
124
  const agentDir = input.agentDir ?? getAgentDir();
90
- const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
91
- manager.appendSessionInfo(input.sessionLabel);
125
+ let manager: SessionManager;
126
+ if (input.continuation) {
127
+ try {
128
+ manager = SessionManager.open(input.continuation.sessionFile, input.agentDir ? join(agentDir, "sessions") : undefined, input.cwd);
129
+ const header = manager.getHeader();
130
+ if (!header || canonicalSourcePath(header.cwd) !== canonicalSourcePath(input.cwd) || manager.getSessionId() !== input.continuation.sessionId || !manager.getEntry(input.continuation.leafId)) throw new Error("Persisted transcript identity does not match the conversation head");
131
+ manager.branch(input.continuation.leafId);
132
+ const context = manager.buildSessionContext();
133
+ if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) throw new Error("Persisted transcript model does not match the conversation execution policy");
134
+ if (input.model.thinking && context.thinkingLevel !== input.model.thinking) throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
135
+ } catch (error) {
136
+ if (error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE") throw error;
137
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot reopen conversation transcript: ${error instanceof Error ? error.message : String(error)}`);
138
+ }
139
+ } else {
140
+ manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
141
+ manager.appendSessionInfo(input.sessionLabel);
142
+ }
92
143
  const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
93
144
  const model = modelRuntime.getModel(input.model.provider, input.model.model);
94
145
  if (!model) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${input.model.provider}/${input.model.model}`);
95
146
  const customTools = [...(input.customTools ?? []), ...(input.resultTool ? [input.resultTool] : [])];
96
147
  const tools = [...new Set([...input.tools, ...customTools.map(({ name }) => name)])];
97
- const resourceLoader = input.systemPromptAppend ? new DefaultResourceLoader({ cwd: input.cwd, agentDir, appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] }) : undefined;
98
- if (resourceLoader) await resourceLoader.reload();
99
- const { session } = await createAgentSession({ cwd: input.cwd, agentDir, modelRuntime, model, ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(resourceLoader ? { resourceLoader } : {}), sessionManager: manager });
100
- return session;
148
+ let settingsManager: SettingsManager | undefined;
149
+ let resourceLoader: DefaultResourceLoader | undefined;
150
+ const policy = input.resourcePolicy;
151
+ if (policy) {
152
+ settingsManager = SettingsManager.create(input.cwd, agentDir, { projectTrusted: false });
153
+ settingsManager.setProjectTrusted(policy.projectTrusted);
154
+ const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager });
155
+ const resolved = await packageManager.resolve();
156
+ const disabledExtensions = new Set(policy.effective.extensions);
157
+ const extensionPaths = [...new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)).filter((path) => !disabledExtensions.has(canonicalSourcePath(path))))];
158
+ const skillPaths = [...new Set(resolved.skills.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => path))];
159
+ const updateSkillMatches = (skills: readonly { name: string }[]) => {
160
+ const names = new Set(skills.map(({ name }) => name));
161
+ Object.assign(policy, { unmatchedSkills: policy.effective.skills.filter((name) => !names.has(name)) });
162
+ };
163
+ const disabledSkills = new Set(policy.effective.skills);
164
+ resourceLoader = new DefaultResourceLoader({
165
+ cwd: input.cwd,
166
+ agentDir,
167
+ settingsManager,
168
+ noExtensions: true,
169
+ additionalExtensionPaths: extensionPaths,
170
+ noSkills: true,
171
+ additionalSkillPaths: skillPaths,
172
+ ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}),
173
+ skillsOverride: (base) => {
174
+ updateSkillMatches(base.skills);
175
+ return { ...base, skills: base.skills.filter(({ name }) => !disabledSkills.has(name)) };
176
+ },
177
+ ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}),
178
+ });
179
+ await resourceLoader.reload();
180
+ const discoveredExtensions = new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)));
181
+ Object.assign(policy, { unmatchedExtensions: policy.effective.extensions.filter((path) => !discoveredExtensions.has(canonicalSourcePath(path))) });
182
+ } else if (input.systemPromptAppend || input.extensionFactories?.length) {
183
+ resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
184
+ await resourceLoader.reload();
185
+ }
186
+ const { session, modelFallbackMessage } = await createAgentSession({ ...(input.options ?? {}), cwd: input.cwd, agentDir, modelRuntime, model, ...(settingsManager ? { settingsManager } : {}), ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(resourceLoader ? { resourceLoader } : {}), sessionManager: manager });
187
+ if (input.continuation && modelFallbackMessage) throw new WorkflowError("RESUME_INCOMPATIBLE", modelFallbackMessage);
188
+ return Object.assign(session, {
189
+ getLeafId: () => manager.getLeafId(),
190
+ getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
191
+ }) as unknown as NativeSession;
192
+ }
193
+ function changedOption(options: Readonly<Record<string, JsonValue>>, baseline: Readonly<Record<string, JsonValue>>, key: string): boolean { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
194
+ function validThinking(value: unknown): value is ThinkingLevel { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
195
+ function jsonValue(value: unknown, seen = new Set<object>()): value is JsonValue {
196
+ if (value === null || typeof value === "boolean" || typeof value === "string") return true;
197
+ if (typeof value === "number") return Number.isFinite(value);
198
+ if (typeof value !== "object" || seen.has(value)) return false;
199
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
200
+ const keys = Reflect.ownKeys(value);
201
+ if (keys.some((key) => typeof key !== "string")) return false;
202
+ seen.add(value);
203
+ const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => (value as Record<string, unknown>)[key as string])).every((item) => jsonValue(item, seen));
204
+ seen.delete(value);
205
+ return valid;
206
+ }
207
+ function jsonObject(value: unknown): value is Record<string, JsonValue> { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
208
+ interface ChildAgentToolParams {
209
+ prompt: string;
210
+ label: string;
211
+ tools?: string[];
212
+ model?: string;
213
+ thinking?: ThinkingLevel;
214
+ role?: string;
215
+ outputSchema?: JsonSchema;
216
+ retries?: number;
217
+ timeoutMs?: number | null;
218
+ [key: string]: unknown;
219
+ }
220
+ function isChildAgentToolParams(value: unknown): value is ChildAgentToolParams & Record<string, JsonValue> {
221
+ if (!jsonObject(value) || typeof value.prompt !== "string" || typeof value.label !== "string") return false;
222
+ if (value.tools !== undefined && (!Array.isArray(value.tools) || value.tools.some((tool) => typeof tool !== "string"))) return false;
223
+ if (value.model !== undefined && typeof value.model !== "string") return false;
224
+ if (value.thinking !== undefined && !validThinking(value.thinking)) return false;
225
+ if (value.role !== undefined && typeof value.role !== "string") return false;
226
+ if (value.outputSchema !== undefined && !jsonObject(value.outputSchema)) return false;
227
+ if (value.retries !== undefined && (typeof value.retries !== "number" || !Number.isInteger(value.retries) || value.retries < 0)) return false;
228
+ if (value.timeoutMs !== undefined && (value.timeoutMs !== null && (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs < 1))) return false;
229
+ return true;
230
+ }
231
+ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionOptions, signal: AbortSignal): { run: Readonly<WorkflowRunContext>; identity: Readonly<AgentIdentity> } {
232
+ const identity = options.agentIdentity ?? { structuralPath: [], callSite: options.label, occurrence: 1 };
233
+ const run = root.runContext ?? Object.freeze({ cwd: root.cwd, sessionId: "", runId: "", workflow: Object.freeze({ name: options.workflowName }), args: null, signal });
234
+ return { run, identity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) };
235
+ }
236
+ function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
237
+ return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
238
+ }
239
+ function canonicalJson(value: unknown): unknown {
240
+ if (Array.isArray(value)) return value.map((item) => canonicalJson(item));
241
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
242
+ return value;
243
+ }
244
+ function fingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex"); }
245
+ function promptFingerprint(value: string): string { return createHash("sha256").update(value).digest("hex"); }
246
+ function fixedConversationOptions(options: Readonly<Record<string, JsonValue>>): JsonValue {
247
+ const fixedOptions = structuredClone(options) as Record<string, JsonValue>;
248
+ delete fixedOptions.timeoutMs;
249
+ delete fixedOptions.retries;
250
+ return fixedOptions;
251
+ }
252
+ function conversationExecutionPolicy(options: AgentExecutionOptions, setup: AgentSetup): JsonValue {
253
+ return structuredClone({
254
+ model: setup.sessionInput.model,
255
+ tools: [...setup.sessionInput.tools],
256
+ cwd: setup.sessionInput.cwd,
257
+ role: options.role ?? null,
258
+ worktreeOwner: options.worktreeOwner ?? null,
259
+ parent: options.parent ?? null,
260
+ systemPromptAppend: setup.sessionInput.systemPromptAppend ?? "",
261
+ options: fixedConversationOptions(setup.options),
262
+ resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
263
+ }) as unknown as JsonValue;
264
+ }
265
+ function conversationFailure(message: string): WorkflowError { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
266
+ 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, continuation?: ConversationHead): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
267
+ const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
268
+ const baselineOptions = structuredClone(options.agentOptions ?? {});
269
+ const baseResourcePolicy = await root.agentResourcePolicy?.();
270
+ const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
271
+ const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
272
+ 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) };
273
+ const setup: AgentSetup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, createSession };
274
+ const base = fallbackSetupContext(root, options, setupSignal);
275
+ const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
276
+ const hookNames: string[] = [];
277
+ for (const hook of [...(root.agentSetupHooks ?? [])].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))) {
278
+ if (setupSignal.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
279
+ try { await hook.setup(setup, context); } catch (error) { if (setupSignal.reason !== undefined) throw new WorkflowError("CANCELLED", "Agent cancelled"); throw error; }
280
+ hookNames.push(hook.name);
281
+ if (setupSignal.reason !== undefined) throw new WorkflowError("CANCELLED", "Agent cancelled");
282
+ }
283
+ setup.sessionInput.options = setup.options;
284
+ 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);
285
+ if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking)) setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
286
+ 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];
287
+ if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
288
+ if (continuation) setup.sessionInput.continuation = { sessionId: continuation.sessionId, sessionFile: continuation.sessionFile, leafId: continuation.leafId };
289
+ const model = setup.sessionInput.model;
290
+ 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) } : {}) };
291
+ return { setup, summary };
101
292
  }
102
-
103
-
104
293
 
105
294
  export class WorkflowAgentExecutor {
106
295
  constructor(private readonly root: AgentExecutionRoot, private readonly createSession: SessionFactory = createNativeAgentSession) {}
296
+ setRunContext(runContext: Readonly<WorkflowRunContext>): void { this.root.runContext = runContext; }
107
297
 
108
- resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string } {
298
+ resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPromptAppend: string } {
109
299
  const role = options.role;
110
300
  const definition = role ? this.root.agentDefinitions?.[role] : undefined;
111
301
  if (role && !definition) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown agent role: ${role}`);
@@ -113,13 +303,18 @@ export class WorkflowAgentExecutor {
113
303
  const requested = options.tools !== undefined ? options.tools : definition?.tools !== undefined ? definition.tools : options.effectiveTools !== undefined ? options.effectiveTools : inheritedTools !== undefined ? inheritedTools : [...this.root.tools];
114
304
  const forbidden = requested.find((tool) => !this.root.tools.has(tool));
115
305
  if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${forbidden}`);
116
- const model = parseModel(options.model ?? definition?.model, this.root.model, options.thinking ?? definition?.thinking);
117
- const availableModels = this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
118
- if (!availableModels.has(modelCapability(model))) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${modelCapability(model)}`);
119
- return { model, tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
306
+ const requestedModel = options.model ?? definition?.model;
307
+ const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
308
+ if (requestedModel !== undefined && this.root.blockedAliases?.has(requestedModel) && !hasAlias) { const target = this.root.blockedAliasTargets?.[requestedModel]; throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`); }
309
+ const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
310
+ const model = parseModel(requestedModel, this.root.model, options.thinking ?? (aliasThinking === undefined ? definition?.thinking : undefined), this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
311
+ const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
312
+ 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})` : ""}`);
313
+ return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
120
314
  }
121
315
 
122
316
  async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
317
+ const executionSignal = signal ?? this.root.runContext?.signal;
123
318
  if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0) throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
124
319
  if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw new WorkflowError("INVALID_METADATA", "timeoutMs must be null or a positive integer");
125
320
  const resolved = this.resolve(options);
@@ -142,12 +337,31 @@ export class WorkflowAgentExecutor {
142
337
  if (options.cwd) throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
143
338
  cwd = this.root.cwd;
144
339
  }
340
+ let conversationRecord: PersistedConversation | undefined;
341
+ if (options.conversation) {
342
+ const store = this.root.runStore;
343
+ if (!store) throw conversationFailure("Conversation persistence is unavailable");
344
+ try { conversationRecord = await store.conversation(options.conversation.id); } catch (error) { throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
345
+ if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1) throw conversationFailure("Conversation turn must be a positive integer");
346
+ if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1) throw conversationFailure(`Conversation turn ${String(options.conversation.turn)} does not continue its persisted head`);
347
+ }
145
348
  const attempts: AgentAttempt[] = [];
349
+ let conversationBaseline: { executionPolicy: JsonValue; toolDefinitionsSha256: string; systemPrompt?: string; systemPromptSha256?: string } | undefined;
146
350
  const maxAttempts = (options.retries ?? 0) + 1;
147
351
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
148
- const started = Date.now();
352
+ options.budget?.beforeAttempt();
149
353
  let accepted = false;
150
354
  let schemaResult: JsonValue | undefined;
355
+ let session: NativeSession | undefined;
356
+ let setup: AgentSetup | undefined;
357
+ let setupSummary: AgentSetupSummary | undefined;
358
+ let setupFailed = false;
359
+ let budgetError: WorkflowError | undefined;
360
+ let turnStarted = false;
361
+ let conversationSystemPrompt = "";
362
+ let conversationToolDefinitionsSha256 = "";
363
+ let conversationMismatch: WorkflowError | undefined;
364
+ const conversationMismatchError = () => conversationMismatch ? new WorkflowError("RESUME_INCOMPATIBLE", conversationMismatch.message) : undefined;
151
365
  const hasSchemaResult = () => schemaResult !== undefined;
152
366
  const resultTool = options.schema ? {
153
367
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
@@ -159,7 +373,6 @@ export class WorkflowAgentExecutor {
159
373
  return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
160
374
  },
161
375
  } as ToolDefinition : undefined;
162
- let session: NativeSession | undefined;
163
376
  const toolCalls = new Map<string, AgentToolCallProgress>();
164
377
  let activity: AgentActivity | undefined;
165
378
  let progress = Promise.resolve();
@@ -173,20 +386,75 @@ export class WorkflowAgentExecutor {
173
386
  };
174
387
  const report = (persist: boolean) => {
175
388
  if (!session || !options.onProgress) return;
176
- const update = { accounting: accounting(session.messages), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
389
+ const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
177
390
  progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
178
391
  };
179
392
  try {
180
- session = await this.createSession({ cwd, model: resolved.model, tools: resolved.tools, sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(this.root.agentDir ? { agentDir: this.root.agentDir } : {}), ...(customTools.length ? { customTools } : {}), ...(resultTool ? { resultTool } : {}), ...(resolved.systemPromptAppend ? { systemPromptAppend: resolved.systemPromptAppend } : {}) });
181
- await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile) });
182
- unsubscribe = session.subscribe?.((event) => {
183
- if (event.type === "agent_start" && session?.systemPrompt !== undefined && this.root.runStore) {
184
- systemPromptTurn += 1;
185
- const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
186
- systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error: unknown) => { systemPromptWriteError ??= error; });
393
+ setupFailed = true;
394
+ const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool, conversationRecord?.head);
395
+ setup = prepared.setup;
396
+ setupSummary = prepared.summary;
397
+ setupFailed = false;
398
+ if (executionSignal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
399
+ const started = Date.now();
400
+ session = await setup.createSession(setup.sessionInput);
401
+ if (setup.sessionInput.resourcePolicy) setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
402
+ if (options.conversation) {
403
+ conversationSystemPrompt = session.systemPrompt ?? "";
404
+ conversationToolDefinitionsSha256 = fingerprint(session.getToolDefinitions?.() ?? session.agent?.state.tools ?? []);
405
+ const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
406
+ if (conversationRecord) {
407
+ if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile) throw conversationFailure("Conversation transcript identity changed");
408
+ if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId) throw conversationFailure("Conversation transcript leaf identity changed");
409
+ if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationRecord.policy)) throw conversationFailure("Conversation execution policy changed");
410
+ if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt)) throw conversationFailure("Conversation system prompt changed");
411
+ if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
412
+ } else if (conversationBaseline) {
413
+ if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationBaseline.executionPolicy)) throw conversationFailure("Conversation execution policy changed");
414
+ if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
415
+ } else {
416
+ conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
417
+ }
418
+ if (!session.subscribe) {
419
+ const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
420
+ const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
421
+ if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) throw conversationFailure("Conversation system prompt changed");
422
+ if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
423
+ }
424
+ if (conversationRecord && (!session.model || session.model.provider !== setup.sessionInput.model.provider || (session.model.model ?? session.model.id) !== setup.sessionInput.model.model)) throw conversationFailure("Conversation model changed");
425
+ }
426
+ const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
427
+ await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
428
+ const activeSession = session;
429
+ unsubscribe = activeSession.subscribe?.((event) => {
430
+ if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
431
+ if (options.conversation) {
432
+ conversationSystemPrompt = session.systemPrompt;
433
+ const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
434
+ const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
435
+ if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) { conversationMismatch = conversationFailure("Conversation system prompt changed"); void session.abort?.(); }
436
+ if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
437
+ }
438
+ if (this.root.runStore) {
439
+ systemPromptTurn += 1;
440
+ const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
441
+ systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error: unknown) => { systemPromptWriteError ??= error; });
442
+ }
443
+ }
444
+ if (event.type === "message_start" && event.message.role === "assistant") {
445
+ 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?.(); } }
446
+ activity = { kind: "text", text: "responding" }; report(false);
447
+ }
448
+ if (event.type === "message_end") {
449
+ activity = undefined;
450
+ if (event.message.role === "assistant") {
451
+ const needsMoreWork = hasToolCall(event.message);
452
+ const final = !needsMoreWork || (options.schema !== undefined && accepted);
453
+ 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?.(); } }
454
+ turnStarted = false;
455
+ report(true);
456
+ }
187
457
  }
188
- if (event.type === "message_start" && event.message.role === "assistant") { activity = { kind: "text", text: "responding" }; report(false); }
189
- if (event.type === "message_end") { activity = undefined; if (event.message.role === "assistant") report(true); }
190
458
  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); }
191
459
  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); }
192
460
  });
@@ -196,40 +464,60 @@ export class WorkflowAgentExecutor {
196
464
  setSteer((message) => session?.steer?.(message));
197
465
  }
198
466
  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");
199
- await promptWithProviderPause(session, `${context}\n\nTask:\n${task}`, remaining(options.timeoutMs, started), signal, this.root.providerPause);
467
+ const instruction = options.budget?.instruction();
468
+ const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
469
+ options.budget?.beforeTurn();
470
+ turnStarted = true;
471
+ await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
472
+ if (conversationMismatch) throw conversationMismatch;
473
+ throwIfTerminalAssistantError(session);
474
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? false : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
475
+ if (budgetError) throw budgetError;
200
476
  if (options.schema) {
201
477
  accepted = true;
202
- try { await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started), signal, this.root.providerPause); }
203
- catch (error) { if (!hasSchemaResult()) throw error; }
478
+ 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; }
479
+ throwIfTerminalAssistantError(session);
204
480
  if (!hasSchemaResult()) {
205
- try { 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), signal, this.root.providerPause); }
206
- catch (error) { if (!hasSchemaResult()) throw error; }
481
+ 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; }
482
+ throwIfTerminalAssistantError(session);
207
483
  }
208
484
  if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
209
485
  }
486
+ const mismatch = conversationMismatchError();
487
+ if (mismatch) throw mismatch;
210
488
  const value = options.schema ? schemaResult as JsonValue : text(session.messages);
211
489
  if (options.worktreeOwner) await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
212
490
  report(true);
213
491
  await progress;
214
492
  await flushSystemPrompts();
215
493
  unsubscribe?.();
216
- const attemptAccounting = accounting(session.messages);
217
- attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting });
494
+ const attemptAccounting = accounting(session.getSessionStats());
495
+ const leafId = session.getLeafId?.() ?? undefined;
496
+ if (options.conversation) {
497
+ if (!leafId) throw conversationFailure("Conversation transcript has no persisted leaf");
498
+ const store = this.root.runStore;
499
+ if (!store) throw conversationFailure("Conversation persistence is unavailable");
500
+ await store.saveConversation({ id: options.conversation.id, policy: conversationExecutionPolicy(options, setup), head: { turn: options.conversation.turn, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), leafId, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt), toolDefinitionsSha256: conversationToolDefinitionsSha256 } });
501
+ }
502
+ const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
503
+ attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
218
504
  session.dispose();
219
- return { value, attempts, cwd };
505
+ return { value, attempts, cwd: setupSummary.cwd };
220
506
  } catch (error) {
221
- const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
507
+ const typed = budgetError ?? conversationMismatch ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
222
508
  if (session) {
223
509
  report(true);
224
510
  await progress;
225
511
  try { await flushSystemPrompts(); } catch { /* Preserve the agent failure that prompted this cleanup. */ }
226
512
  unsubscribe?.();
227
- const attemptAccounting = accounting(session.messages);
228
- attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), error: { code: typed.code, message: typed.message }, accounting: attemptAccounting });
513
+ const attemptAccounting = accounting(session.getSessionStats());
514
+ 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)); } }
515
+ const includeFailedSetup = Boolean(this.root.agentSetupHooks?.length || setup?.sessionInput.resourcePolicy);
516
+ attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), error: { code: typed.code, message: typed.message }, accounting: attemptAccounting, ...(includeFailedSetup && setupSummary ? { setup: setupSummary } : {}) });
229
517
  session.dispose();
230
518
  }
231
519
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED") await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
232
- if (attempt === maxAttempts || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED") throw Object.assign(typed, { attempts });
520
+ if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE") throw Object.assign(typed, { attempts });
233
521
  beforeRetry?.();
234
522
  }
235
523
  }
@@ -250,6 +538,9 @@ export interface ScheduledAgentOptions {
250
538
  schema?: JsonSchema;
251
539
  retries?: number;
252
540
  timeoutMs?: number | null;
541
+ agentOptions?: Readonly<Record<string, JsonValue>>;
542
+ agentIdentity?: AgentIdentity;
543
+ conversation?: { id: string; turn: number };
253
544
  }
254
545
 
255
546
  export type ScheduledAgentResult =
@@ -275,7 +566,7 @@ type ScheduledNode = {
275
566
  options: Readonly<ScheduledAgentOptions>;
276
567
  children: Set<string>;
277
568
  collected: boolean;
278
- state: "queued" | "running" | "waiting_for_child" | "completed" | "failed" | "cancelled";
569
+ state: "queued" | "running" | "waiting_for_child" | "paused" | "retrying" | "completed" | "failed" | "cancelled";
279
570
  controller: AbortController;
280
571
  promise: Promise<ScheduledAgentResult>;
281
572
  resolve: (result: ScheduledAgentResult) => void;
@@ -284,7 +575,7 @@ type ScheduledNode = {
284
575
  steer?: (message: string) => void | Promise<void>;
285
576
  };
286
577
 
287
- type ScheduledRun = { limit: number; maxAgentLaunches: number; logical: number; active: number; queue: Array<() => void> };
578
+ type ScheduledRun = { limit: number; beforeLaunch?: () => void; logical: number; active: number; queue: Array<{ node?: ScheduledNode; start: () => void }> };
288
579
  export type OwnershipRecord = { id: string; parentId?: string; label: string; state: ScheduledNode["state"]; options: Readonly<ScheduledAgentOptions> };
289
580
  type OwnershipWriter = (runId: string, ownership: readonly OwnershipRecord[]) => void | Promise<void>;
290
581
 
@@ -301,10 +592,10 @@ export class FairAgentScheduler {
301
592
  if (!Number.isInteger(sessionLimit) || sessionLimit < 1 || sessionLimit > 16) throw new WorkflowError("INVALID_SETTINGS", "Session concurrency must be an integer from 1 to 16");
302
593
  }
303
594
 
304
- addRun(runId: string, limit = 8, maxAgentLaunches = 1000): void {
595
+ addRun(runId: string, limit = 8, beforeLaunch?: () => void): void {
305
596
  if (this.#runs.has(runId)) throw new WorkflowError("DUPLICATE_NAME", `Scheduler run already exists: ${runId}`);
306
- if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit || !Number.isInteger(maxAgentLaunches) || maxAgentLaunches < 1) throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency or maxAgentLaunches");
307
- this.#runs.set(runId, { limit, maxAgentLaunches, logical: 0, active: 0, queue: [] });
597
+ if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit) throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency");
598
+ this.#runs.set(runId, { limit, ...(beforeLaunch ? { beforeLaunch } : {}), logical: 0, active: 0, queue: [] });
308
599
  this.#runOrder.push(runId);
309
600
  }
310
601
 
@@ -314,7 +605,6 @@ export class FairAgentScheduler {
314
605
  const parent = parentId ? this.#nodes.get(parentId) : undefined;
315
606
  if (parentId && (!parent || parent.runId !== runId)) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Parent agent is not owned by this run");
316
607
  const effective = this.#inherit(parent, options);
317
- if (++run.logical > run.maxAgentLaunches) { run.logical -= 1; throw new WorkflowError("RUN_LIMIT_EXCEEDED", `Run ${runId} exceeded maxAgentLaunches`); }
318
608
  const id = `${runId}:${String(++this.#nextId)}`;
319
609
  let resolveResult: (result: ScheduledAgentResult) => void = () => undefined;
320
610
  const promise = new Promise<ScheduledAgentResult>((resolve) => { resolveResult = resolve; });
@@ -334,7 +624,7 @@ export class FairAgentScheduler {
334
624
  this.#nodes.set(id, node);
335
625
  parent?.children.add(id);
336
626
  this.#persist(runId);
337
- this.#enqueue(runId, () => { void node.task(); });
627
+ this.#enqueue(runId, node, () => { void node.task(); });
338
628
  return { id, result: promise };
339
629
  }
340
630
 
@@ -347,7 +637,7 @@ export class FairAgentScheduler {
347
637
  this.#persist(parent.runId);
348
638
  this.#release(parent.runId);
349
639
  const outcome = await child.promise;
350
- await new Promise<void>((resolve) => { this.#enqueue(parent.runId, () => { resolve(); }); });
640
+ await new Promise<void>((resolve) => { this.#enqueue(parent.runId, undefined, () => { resolve(); }); });
351
641
  parent.state = "running";
352
642
  if (parent.controller.signal.aborted) throw new WorkflowError("CANCELLED", "Parent agent cancelled");
353
643
  this.#persist(parent.runId);
@@ -367,6 +657,15 @@ export class FairAgentScheduler {
367
657
  cancelChildren(id: string): void {
368
658
  for (const childId of this.#node(id).children) { const child = this.#nodes.get(childId); if (child) this.#cancelTree(child); }
369
659
  }
660
+ retry(id: string): void {
661
+ const node = this.#node(id);
662
+ if (node.state === "running") { node.state = "retrying"; this.#persist(node.runId); }
663
+ }
664
+
665
+ attemptStarted(id: string): void {
666
+ const node = this.#node(id);
667
+ if (node.state === "retrying") { node.state = "running"; this.#persist(node.runId); }
668
+ }
370
669
 
371
670
  async cancelRun(runId: string): Promise<void> {
372
671
  const run = this.#runs.get(runId);
@@ -377,17 +676,20 @@ export class FairAgentScheduler {
377
676
  if (nodes.every(({ restored }) => restored)) run.logical = 0;
378
677
  }
379
678
 
380
- /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
381
679
  toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[] {
382
680
  const parent = this.#node(parentId);
383
681
  if (!parent.options.tools.includes("agent")) return [];
384
682
  const agentTool = {
385
683
  name: "agent", label: "Child Agent", description: "Start a direct child agent",
386
- parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), role: Type.Optional(Type.String()), outputSchema: Type.Optional(Type.Unsafe<JsonSchema>({})), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }),
387
- execute: async (_id: string, params: { prompt: string; label: string; tools?: string[]; model?: string; thinking?: ThinkingLevel; role?: string; outputSchema?: JsonSchema; retries?: number; timeoutMs?: number | null }) => {
684
+ parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), role: Type.Optional(Type.String()), outputSchema: Type.Optional(Type.Unsafe<JsonSchema>({})), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
685
+ execute: async (_id: string, rawParams: unknown) => {
686
+ if (!isChildAgentToolParams(rawParams)) throw new WorkflowError("INVALID_METADATA", "Invalid child agent parameters");
687
+ const params = rawParams;
388
688
  if (params.role !== undefined && (params.model !== undefined || params.thinking !== undefined || params.tools !== undefined)) throw new WorkflowError("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
389
689
  const tools = (params.tools !== undefined || params.role !== undefined ? resolveTools?.(params.role, params.tools, params.model, parent.options.tools, params.thinking) : undefined) ?? params.tools ?? parent.options.tools;
390
- const options = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, ...(params.model ? { model: params.model } : {}), ...(params.thinking ? { thinking: params.thinking } : {}), ...(params.role ? { role: params.role } : {}), ...(params.outputSchema ? { schema: params.outputSchema } : {}), ...(params.retries === undefined ? {} : { retries: params.retries }), ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }) };
690
+ const agentOptions = { ...params };
691
+ Reflect.deleteProperty(agentOptions, "prompt");
692
+ const options: ScheduledAgentOptions = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, agentOptions, ...(params.model ? { model: params.model } : {}), ...(params.thinking ? { thinking: params.thinking } : {}), ...(params.role ? { role: params.role } : {}), ...(params.outputSchema ? { schema: params.outputSchema } : {}), ...(params.retries === undefined ? {} : { retries: params.retries }), ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }) };
391
693
  const child = this.spawn(parent.runId, params.prompt, options, parentId);
392
694
  return { content: [{ type: "text" as const, text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
393
695
  },
@@ -395,7 +697,7 @@ export class FairAgentScheduler {
395
697
  const resultTool = {
396
698
  name: "get_subagent_result", label: "Child Result", description: "Wait for a direct child and return its result",
397
699
  parameters: Type.Object({ id: Type.String() }),
398
- execute: async (_id: string, params: { id: string }) => { const value = await this.result(parentId, params.id); return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value }; },
700
+ execute: async (_id: string, params: { id: string }) => { const value = await this.result(parentId, params.id); if (!value.ok && value.error.code === "BUDGET_EXHAUSTED") throw new WorkflowError("BUDGET_EXHAUSTED", value.error.message); return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value }; },
399
701
  } as ToolDefinition;
400
702
  const steerTool = {
401
703
  name: "steer_subagent", label: "Steer Child", description: "Steer a running direct child",
@@ -409,8 +711,8 @@ export class FairAgentScheduler {
409
711
  return [...this.#nodes.values()].map(({ id, parentId, options, state }) => ({ id, ...(parentId ? { parentId } : {}), label: options.label, state, options }));
410
712
  }
411
713
 
412
- restoreRun(runId: string, limit: number, maxAgentLaunches: number, ownership: readonly OwnershipRecord[]): void {
413
- this.addRun(runId, limit, maxAgentLaunches);
714
+ restoreRun(runId: string, limit: number, ownership: readonly OwnershipRecord[], beforeLaunch?: () => void): void {
715
+ this.addRun(runId, limit, beforeLaunch);
414
716
  const run = this.#runs.get(runId) as ScheduledRun;
415
717
  for (const record of ownership) {
416
718
  if (record.id.split(":").slice(0, -1).join(":") !== runId) throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted agent belongs to another run: ${record.id}`);
@@ -429,18 +731,17 @@ export class FairAgentScheduler {
429
731
  async flush(): Promise<void> { await this.#persistence; }
430
732
 
431
733
  #inherit(parent: ScheduledNode | undefined, options: ScheduledAgentOptions): Readonly<ScheduledAgentOptions> {
432
- const unknown = Object.keys(options).find((key) => !["label", "requestedLabel", "parentBreadcrumb", "cwd", "tools", "worktreeOwner", "model", "thinking", "role", "schema", "retries", "timeoutMs"].includes(key));
433
- if (unknown) throw new WorkflowError("INVALID_METADATA", `Unsupported child agent option: ${unknown}`);
434
734
  if (!options.label.trim() || !options.cwd || !Array.isArray(options.tools)) throw new WorkflowError("INVALID_METADATA", "Agents require label, cwd, and tools");
435
- if (!parent) return Object.freeze({ ...options, tools: Object.freeze([...options.tools]) });
735
+ const inheritedTools: readonly string[] = options.tools;
736
+ if (!parent) return Object.freeze({ ...options, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
436
737
  if (options.cwd !== parent.options.cwd) throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
437
- const forbidden = options.tools.find((tool) => !parent.options.tools.includes(tool));
738
+ const forbidden = inheritedTools.find((tool: string) => !parent.options.tools.includes(tool));
438
739
  if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
439
- return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...options.tools]), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
740
+ const identity = options.agentIdentity ?? parent.options.agentIdentity;
741
+ return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
440
742
  }
441
- /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
442
743
 
443
- #enqueue(runId: string, start: () => void): void { this.#runs.get(runId)?.queue.push(start); this.#dispatch(); }
744
+ #enqueue(runId: string, node: ScheduledNode | undefined, start: () => void): void { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
444
745
 
445
746
  #dispatch(): void {
446
747
  while (this.#active < this.sessionLimit && this.#runOrder.length) {
@@ -453,8 +754,12 @@ export class FairAgentScheduler {
453
754
  }
454
755
  if (!selected) return;
455
756
  const run = this.#runs.get(selected) as ScheduledRun;
456
- const start = run.queue.shift() as () => void;
457
- run.active += 1; this.#active += 1; start();
757
+ const item = run.queue.shift() as { node?: ScheduledNode; start: () => void };
758
+ if (item.node) {
759
+ try { run.beforeLaunch?.(); }
760
+ catch (error) { const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error)); this.#settle(item.node, { id: item.node.id, ok: false, error: { code: typed.code, message: typed.message } }); continue; }
761
+ }
762
+ run.active += 1; this.#active += 1; item.start();
458
763
  }
459
764
  }
460
765
 
@@ -465,7 +770,7 @@ export class FairAgentScheduler {
465
770
 
466
771
  #settle(node: ScheduledNode, result: ScheduledAgentResult): void {
467
772
  if (["completed", "failed", "cancelled"].includes(node.state)) return;
468
- const heldPermit = node.state === "running";
773
+ const heldPermit = node.state === "running" || node.state === "retrying";
469
774
  node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
470
775
  this.#persist(node.runId);
471
776
  if (heldPermit) this.#release(node.runId);