pi-extensible-workflows 0.3.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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,165 @@ 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);
111
+ }
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 };
86
120
  }
121
+ function canonicalSourcePath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
87
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 fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionOptions, signal: AbortSignal): { run: Readonly<WorkflowRunContext>; identity: Readonly<AgentIdentity> } {
196
+ const identity = options.agentIdentity ?? { structuralPath: [], callSite: options.label, occurrence: 1 };
197
+ const run = root.runContext ?? Object.freeze({ cwd: root.cwd, sessionId: "", runId: "", workflow: Object.freeze({ name: options.workflowName }), args: null, signal });
198
+ return { run, identity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) };
199
+ }
200
+ function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
201
+ return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
202
+ }
203
+ function canonicalJson(value: unknown): unknown {
204
+ if (Array.isArray(value)) return value.map((item) => canonicalJson(item));
205
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
206
+ return value;
207
+ }
208
+ function fingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex"); }
209
+ function promptFingerprint(value: string): string { return createHash("sha256").update(value).digest("hex"); }
210
+ function fixedConversationOptions(options: Readonly<Record<string, JsonValue>>): JsonValue {
211
+ const fixedOptions = structuredClone(options) as Record<string, JsonValue>;
212
+ delete fixedOptions.timeoutMs;
213
+ delete fixedOptions.retries;
214
+ return fixedOptions;
215
+ }
216
+ function conversationExecutionPolicy(options: AgentExecutionOptions, setup: AgentSetup): JsonValue {
217
+ return structuredClone({
218
+ model: setup.sessionInput.model,
219
+ tools: [...setup.sessionInput.tools],
220
+ cwd: setup.sessionInput.cwd,
221
+ role: options.role ?? null,
222
+ worktreeOwner: options.worktreeOwner ?? null,
223
+ parent: options.parent ?? null,
224
+ systemPromptAppend: setup.sessionInput.systemPromptAppend ?? "",
225
+ options: fixedConversationOptions(setup.options),
226
+ resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
227
+ }) as unknown as JsonValue;
228
+ }
229
+ function conversationFailure(message: string): WorkflowError { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
230
+ 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 }> {
231
+ const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
232
+ const baselineOptions = structuredClone(options.agentOptions ?? {});
233
+ const baseResourcePolicy = await root.agentResourcePolicy?.();
234
+ const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
235
+ const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
236
+ 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) };
237
+ const setup: AgentSetup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, createSession };
238
+ const base = fallbackSetupContext(root, options, setupSignal);
239
+ const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
240
+ const hookNames: string[] = [];
241
+ for (const hook of [...(root.agentSetupHooks ?? [])].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))) {
242
+ if (setupSignal.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
243
+ try { await hook.setup(setup, context); } catch (error) { if (setupSignal.reason !== undefined) throw new WorkflowError("CANCELLED", "Agent cancelled"); throw error; }
244
+ hookNames.push(hook.name);
245
+ if (setupSignal.reason !== undefined) throw new WorkflowError("CANCELLED", "Agent cancelled");
246
+ }
247
+ setup.sessionInput.options = setup.options;
248
+ 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);
249
+ if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking)) setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
250
+ 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];
251
+ if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
252
+ if (continuation) setup.sessionInput.continuation = { sessionId: continuation.sessionId, sessionFile: continuation.sessionFile, leafId: continuation.leafId };
253
+ const model = setup.sessionInput.model;
254
+ 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) } : {}) };
255
+ return { setup, summary };
101
256
  }
102
-
103
-
104
257
 
105
258
  export class WorkflowAgentExecutor {
106
259
  constructor(private readonly root: AgentExecutionRoot, private readonly createSession: SessionFactory = createNativeAgentSession) {}
260
+ setRunContext(runContext: Readonly<WorkflowRunContext>): void { this.root.runContext = runContext; }
107
261
 
108
- resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string } {
262
+ resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPromptAppend: string } {
109
263
  const role = options.role;
110
264
  const definition = role ? this.root.agentDefinitions?.[role] : undefined;
111
265
  if (role && !definition) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown agent role: ${role}`);
@@ -113,13 +267,18 @@ export class WorkflowAgentExecutor {
113
267
  const requested = options.tools !== undefined ? options.tools : definition?.tools !== undefined ? definition.tools : options.effectiveTools !== undefined ? options.effectiveTools : inheritedTools !== undefined ? inheritedTools : [...this.root.tools];
114
268
  const forbidden = requested.find((tool) => !this.root.tools.has(tool));
115
269
  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 ?? "" };
270
+ const requestedModel = options.model ?? definition?.model;
271
+ const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
272
+ 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})` : ""}`); }
273
+ const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
274
+ 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);
275
+ const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
276
+ 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})` : ""}`);
277
+ return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
120
278
  }
121
279
 
122
280
  async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
281
+ const executionSignal = signal ?? this.root.runContext?.signal;
123
282
  if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0) throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
124
283
  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
284
  const resolved = this.resolve(options);
@@ -142,12 +301,31 @@ export class WorkflowAgentExecutor {
142
301
  if (options.cwd) throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
143
302
  cwd = this.root.cwd;
144
303
  }
304
+ let conversationRecord: PersistedConversation | undefined;
305
+ if (options.conversation) {
306
+ const store = this.root.runStore;
307
+ if (!store) throw conversationFailure("Conversation persistence is unavailable");
308
+ try { conversationRecord = await store.conversation(options.conversation.id); } catch (error) { throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
309
+ if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1) throw conversationFailure("Conversation turn must be a positive integer");
310
+ 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`);
311
+ }
145
312
  const attempts: AgentAttempt[] = [];
313
+ let conversationBaseline: { executionPolicy: JsonValue; toolDefinitionsSha256: string; systemPrompt?: string; systemPromptSha256?: string } | undefined;
146
314
  const maxAttempts = (options.retries ?? 0) + 1;
147
315
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
148
- const started = Date.now();
316
+ options.budget?.beforeAttempt();
149
317
  let accepted = false;
150
318
  let schemaResult: JsonValue | undefined;
319
+ let session: NativeSession | undefined;
320
+ let setup: AgentSetup | undefined;
321
+ let setupSummary: AgentSetupSummary | undefined;
322
+ let setupFailed = false;
323
+ let budgetError: WorkflowError | undefined;
324
+ let turnStarted = false;
325
+ let conversationSystemPrompt = "";
326
+ let conversationToolDefinitionsSha256 = "";
327
+ let conversationMismatch: WorkflowError | undefined;
328
+ const conversationMismatchError = () => conversationMismatch ? new WorkflowError("RESUME_INCOMPATIBLE", conversationMismatch.message) : undefined;
151
329
  const hasSchemaResult = () => schemaResult !== undefined;
152
330
  const resultTool = options.schema ? {
153
331
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
@@ -159,7 +337,6 @@ export class WorkflowAgentExecutor {
159
337
  return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
160
338
  },
161
339
  } as ToolDefinition : undefined;
162
- let session: NativeSession | undefined;
163
340
  const toolCalls = new Map<string, AgentToolCallProgress>();
164
341
  let activity: AgentActivity | undefined;
165
342
  let progress = Promise.resolve();
@@ -173,20 +350,75 @@ export class WorkflowAgentExecutor {
173
350
  };
174
351
  const report = (persist: boolean) => {
175
352
  if (!session || !options.onProgress) return;
176
- const update = { accounting: accounting(session.messages), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
353
+ const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
177
354
  progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
178
355
  };
179
356
  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; });
357
+ setupFailed = true;
358
+ const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool, conversationRecord?.head);
359
+ setup = prepared.setup;
360
+ setupSummary = prepared.summary;
361
+ setupFailed = false;
362
+ if (executionSignal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
363
+ const started = Date.now();
364
+ session = await setup.createSession(setup.sessionInput);
365
+ if (setup.sessionInput.resourcePolicy) setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
366
+ if (options.conversation) {
367
+ conversationSystemPrompt = session.systemPrompt ?? "";
368
+ conversationToolDefinitionsSha256 = fingerprint(session.getToolDefinitions?.() ?? session.agent?.state.tools ?? []);
369
+ const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
370
+ if (conversationRecord) {
371
+ if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile) throw conversationFailure("Conversation transcript identity changed");
372
+ if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId) throw conversationFailure("Conversation transcript leaf identity changed");
373
+ if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationRecord.policy)) throw conversationFailure("Conversation execution policy changed");
374
+ if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt)) throw conversationFailure("Conversation system prompt changed");
375
+ if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
376
+ } else if (conversationBaseline) {
377
+ if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationBaseline.executionPolicy)) throw conversationFailure("Conversation execution policy changed");
378
+ if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
379
+ } else {
380
+ conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
381
+ }
382
+ if (!session.subscribe) {
383
+ const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
384
+ const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
385
+ if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) throw conversationFailure("Conversation system prompt changed");
386
+ if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
387
+ }
388
+ 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");
389
+ }
390
+ const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
391
+ await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
392
+ const activeSession = session;
393
+ unsubscribe = activeSession.subscribe?.((event) => {
394
+ if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
395
+ if (options.conversation) {
396
+ conversationSystemPrompt = session.systemPrompt;
397
+ const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
398
+ const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
399
+ if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) { conversationMismatch = conversationFailure("Conversation system prompt changed"); void session.abort?.(); }
400
+ if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
401
+ }
402
+ if (this.root.runStore) {
403
+ systemPromptTurn += 1;
404
+ const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
405
+ systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error: unknown) => { systemPromptWriteError ??= error; });
406
+ }
407
+ }
408
+ if (event.type === "message_start" && event.message.role === "assistant") {
409
+ 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?.(); } }
410
+ activity = { kind: "text", text: "responding" }; report(false);
411
+ }
412
+ if (event.type === "message_end") {
413
+ activity = undefined;
414
+ if (event.message.role === "assistant") {
415
+ const needsMoreWork = hasToolCall(event.message);
416
+ const final = !needsMoreWork || (options.schema !== undefined && accepted);
417
+ 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?.(); } }
418
+ turnStarted = false;
419
+ report(true);
420
+ }
187
421
  }
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
422
  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
423
  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
424
  });
@@ -196,40 +428,60 @@ export class WorkflowAgentExecutor {
196
428
  setSteer((message) => session?.steer?.(message));
197
429
  }
198
430
  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);
431
+ const instruction = options.budget?.instruction();
432
+ const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
433
+ options.budget?.beforeTurn();
434
+ turnStarted = true;
435
+ await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
436
+ if (conversationMismatch) throw conversationMismatch;
437
+ throwIfTerminalAssistantError(session);
438
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? false : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
439
+ if (budgetError) throw budgetError;
200
440
  if (options.schema) {
201
441
  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; }
442
+ 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; }
443
+ throwIfTerminalAssistantError(session);
204
444
  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; }
445
+ 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; }
446
+ throwIfTerminalAssistantError(session);
207
447
  }
208
448
  if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
209
449
  }
450
+ const mismatch = conversationMismatchError();
451
+ if (mismatch) throw mismatch;
210
452
  const value = options.schema ? schemaResult as JsonValue : text(session.messages);
211
453
  if (options.worktreeOwner) await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
212
454
  report(true);
213
455
  await progress;
214
456
  await flushSystemPrompts();
215
457
  unsubscribe?.();
216
- const attemptAccounting = accounting(session.messages);
217
- attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting });
458
+ const attemptAccounting = accounting(session.getSessionStats());
459
+ const leafId = session.getLeafId?.() ?? undefined;
460
+ if (options.conversation) {
461
+ if (!leafId) throw conversationFailure("Conversation transcript has no persisted leaf");
462
+ const store = this.root.runStore;
463
+ if (!store) throw conversationFailure("Conversation persistence is unavailable");
464
+ 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 } });
465
+ }
466
+ const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
467
+ attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
218
468
  session.dispose();
219
- return { value, attempts, cwd };
469
+ return { value, attempts, cwd: setupSummary.cwd };
220
470
  } catch (error) {
221
- const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
471
+ const typed = budgetError ?? conversationMismatch ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
222
472
  if (session) {
223
473
  report(true);
224
474
  await progress;
225
475
  try { await flushSystemPrompts(); } catch { /* Preserve the agent failure that prompted this cleanup. */ }
226
476
  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 });
477
+ const attemptAccounting = accounting(session.getSessionStats());
478
+ 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)); } }
479
+ const includeFailedSetup = Boolean(this.root.agentSetupHooks?.length || setup?.sessionInput.resourcePolicy);
480
+ attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), error: { code: typed.code, message: typed.message }, accounting: attemptAccounting, ...(includeFailedSetup && setupSummary ? { setup: setupSummary } : {}) });
229
481
  session.dispose();
230
482
  }
231
483
  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 });
484
+ if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE") throw Object.assign(typed, { attempts });
233
485
  beforeRetry?.();
234
486
  }
235
487
  }
@@ -250,6 +502,9 @@ export interface ScheduledAgentOptions {
250
502
  schema?: JsonSchema;
251
503
  retries?: number;
252
504
  timeoutMs?: number | null;
505
+ agentOptions?: Readonly<Record<string, JsonValue>>;
506
+ agentIdentity?: AgentIdentity;
507
+ conversation?: { id: string; turn: number };
253
508
  }
254
509
 
255
510
  export type ScheduledAgentResult =
@@ -275,7 +530,7 @@ type ScheduledNode = {
275
530
  options: Readonly<ScheduledAgentOptions>;
276
531
  children: Set<string>;
277
532
  collected: boolean;
278
- state: "queued" | "running" | "waiting_for_child" | "completed" | "failed" | "cancelled";
533
+ state: "queued" | "running" | "waiting_for_child" | "paused" | "retrying" | "completed" | "failed" | "cancelled";
279
534
  controller: AbortController;
280
535
  promise: Promise<ScheduledAgentResult>;
281
536
  resolve: (result: ScheduledAgentResult) => void;
@@ -284,7 +539,7 @@ type ScheduledNode = {
284
539
  steer?: (message: string) => void | Promise<void>;
285
540
  };
286
541
 
287
- type ScheduledRun = { limit: number; maxAgentLaunches: number; logical: number; active: number; queue: Array<() => void> };
542
+ type ScheduledRun = { limit: number; beforeLaunch?: () => void; logical: number; active: number; queue: Array<{ node?: ScheduledNode; start: () => void }> };
288
543
  export type OwnershipRecord = { id: string; parentId?: string; label: string; state: ScheduledNode["state"]; options: Readonly<ScheduledAgentOptions> };
289
544
  type OwnershipWriter = (runId: string, ownership: readonly OwnershipRecord[]) => void | Promise<void>;
290
545
 
@@ -301,10 +556,10 @@ export class FairAgentScheduler {
301
556
  if (!Number.isInteger(sessionLimit) || sessionLimit < 1 || sessionLimit > 16) throw new WorkflowError("INVALID_SETTINGS", "Session concurrency must be an integer from 1 to 16");
302
557
  }
303
558
 
304
- addRun(runId: string, limit = 8, maxAgentLaunches = 1000): void {
559
+ addRun(runId: string, limit = 8, beforeLaunch?: () => void): void {
305
560
  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: [] });
561
+ if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit) throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency");
562
+ this.#runs.set(runId, { limit, ...(beforeLaunch ? { beforeLaunch } : {}), logical: 0, active: 0, queue: [] });
308
563
  this.#runOrder.push(runId);
309
564
  }
310
565
 
@@ -314,7 +569,6 @@ export class FairAgentScheduler {
314
569
  const parent = parentId ? this.#nodes.get(parentId) : undefined;
315
570
  if (parentId && (!parent || parent.runId !== runId)) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Parent agent is not owned by this run");
316
571
  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
572
  const id = `${runId}:${String(++this.#nextId)}`;
319
573
  let resolveResult: (result: ScheduledAgentResult) => void = () => undefined;
320
574
  const promise = new Promise<ScheduledAgentResult>((resolve) => { resolveResult = resolve; });
@@ -334,7 +588,7 @@ export class FairAgentScheduler {
334
588
  this.#nodes.set(id, node);
335
589
  parent?.children.add(id);
336
590
  this.#persist(runId);
337
- this.#enqueue(runId, () => { void node.task(); });
591
+ this.#enqueue(runId, node, () => { void node.task(); });
338
592
  return { id, result: promise };
339
593
  }
340
594
 
@@ -347,7 +601,7 @@ export class FairAgentScheduler {
347
601
  this.#persist(parent.runId);
348
602
  this.#release(parent.runId);
349
603
  const outcome = await child.promise;
350
- await new Promise<void>((resolve) => { this.#enqueue(parent.runId, () => { resolve(); }); });
604
+ await new Promise<void>((resolve) => { this.#enqueue(parent.runId, undefined, () => { resolve(); }); });
351
605
  parent.state = "running";
352
606
  if (parent.controller.signal.aborted) throw new WorkflowError("CANCELLED", "Parent agent cancelled");
353
607
  this.#persist(parent.runId);
@@ -367,6 +621,15 @@ export class FairAgentScheduler {
367
621
  cancelChildren(id: string): void {
368
622
  for (const childId of this.#node(id).children) { const child = this.#nodes.get(childId); if (child) this.#cancelTree(child); }
369
623
  }
624
+ retry(id: string): void {
625
+ const node = this.#node(id);
626
+ if (node.state === "running") { node.state = "retrying"; this.#persist(node.runId); }
627
+ }
628
+
629
+ attemptStarted(id: string): void {
630
+ const node = this.#node(id);
631
+ if (node.state === "retrying") { node.state = "running"; this.#persist(node.runId); }
632
+ }
370
633
 
371
634
  async cancelRun(runId: string): Promise<void> {
372
635
  const run = this.#runs.get(runId);
@@ -383,11 +646,12 @@ export class FairAgentScheduler {
383
646
  if (!parent.options.tools.includes("agent")) return [];
384
647
  const agentTool = {
385
648
  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 }) => {
649
+ 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 }),
650
+ execute: async (_id: string, params: { prompt: string; label: string; tools?: string[]; model?: string; thinking?: ThinkingLevel; role?: string; outputSchema?: JsonSchema; retries?: number; timeoutMs?: number | null; [key: string]: unknown }) => {
388
651
  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
652
  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 }) };
653
+ const agentOptions = Object.fromEntries(Object.entries(params).filter(([key]) => key !== "prompt")) as Record<string, JsonValue>;
654
+ 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
655
  const child = this.spawn(parent.runId, params.prompt, options, parentId);
392
656
  return { content: [{ type: "text" as const, text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
393
657
  },
@@ -395,7 +659,7 @@ export class FairAgentScheduler {
395
659
  const resultTool = {
396
660
  name: "get_subagent_result", label: "Child Result", description: "Wait for a direct child and return its result",
397
661
  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 }; },
662
+ 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
663
  } as ToolDefinition;
400
664
  const steerTool = {
401
665
  name: "steer_subagent", label: "Steer Child", description: "Steer a running direct child",
@@ -409,8 +673,8 @@ export class FairAgentScheduler {
409
673
  return [...this.#nodes.values()].map(({ id, parentId, options, state }) => ({ id, ...(parentId ? { parentId } : {}), label: options.label, state, options }));
410
674
  }
411
675
 
412
- restoreRun(runId: string, limit: number, maxAgentLaunches: number, ownership: readonly OwnershipRecord[]): void {
413
- this.addRun(runId, limit, maxAgentLaunches);
676
+ restoreRun(runId: string, limit: number, ownership: readonly OwnershipRecord[], beforeLaunch?: () => void): void {
677
+ this.addRun(runId, limit, beforeLaunch);
414
678
  const run = this.#runs.get(runId) as ScheduledRun;
415
679
  for (const record of ownership) {
416
680
  if (record.id.split(":").slice(0, -1).join(":") !== runId) throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted agent belongs to another run: ${record.id}`);
@@ -429,18 +693,17 @@ export class FairAgentScheduler {
429
693
  async flush(): Promise<void> { await this.#persistence; }
430
694
 
431
695
  #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
696
  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]) });
697
+ if (!parent) return Object.freeze({ ...options, tools: Object.freeze([...options.tools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
436
698
  if (options.cwd !== parent.options.cwd) throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
437
699
  const forbidden = options.tools.find((tool) => !parent.options.tools.includes(tool));
438
700
  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 } : {}) });
701
+ const identity = options.agentIdentity ?? parent.options.agentIdentity;
702
+ return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...options.tools]), ...(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
703
  }
441
704
  /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
442
705
 
443
- #enqueue(runId: string, start: () => void): void { this.#runs.get(runId)?.queue.push(start); this.#dispatch(); }
706
+ #enqueue(runId: string, node: ScheduledNode | undefined, start: () => void): void { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
444
707
 
445
708
  #dispatch(): void {
446
709
  while (this.#active < this.sessionLimit && this.#runOrder.length) {
@@ -453,8 +716,12 @@ export class FairAgentScheduler {
453
716
  }
454
717
  if (!selected) return;
455
718
  const run = this.#runs.get(selected) as ScheduledRun;
456
- const start = run.queue.shift() as () => void;
457
- run.active += 1; this.#active += 1; start();
719
+ const item = run.queue.shift() as { node?: ScheduledNode; start: () => void };
720
+ if (item.node) {
721
+ try { run.beforeLaunch?.(); }
722
+ 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; }
723
+ }
724
+ run.active += 1; this.#active += 1; item.start();
458
725
  }
459
726
  }
460
727
 
@@ -465,7 +732,7 @@ export class FairAgentScheduler {
465
732
 
466
733
  #settle(node: ScheduledNode, result: ScheduledAgentResult): void {
467
734
  if (["completed", "failed", "cancelled"].includes(node.state)) return;
468
- const heldPermit = node.state === "running";
735
+ const heldPermit = node.state === "running" || node.state === "retrying";
469
736
  node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
470
737
  this.#persist(node.runId);
471
738
  if (heldPermit) this.#release(node.runId);