pi-extensible-workflows 0.3.0 → 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,12 +1,14 @@
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 } from "@earendil-works/pi-coding-agent";
5
- import { parseModelReference, WorkflowError } from "./index.js";
6
- function parseModel(value, fallback, thinking) {
6
+ import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
7
+ import { mergeAgentResourceExclusions, resolveModelReference, WorkflowError } from "./index.js";
8
+ function parseModel(value, fallback, thinking, aliases = {}, knownModels, settingsPath) {
7
9
  if (!value)
8
10
  return { ...fallback, ...(thinking ? { thinking } : {}) };
9
- const parsed = parseModelReference(value);
11
+ const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
10
12
  return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
11
13
  }
12
14
  function modelCapability(model) { return `${model.provider}/${model.model}`; }
@@ -16,33 +18,185 @@ function text(messages) {
16
18
  return "";
17
19
  return message.content.filter((part) => typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string").map((part) => part.text).join("");
18
20
  }
19
- function accounting(messages) {
20
- const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
21
- for (const message of messages)
22
- if (message.role === "assistant" && message.usage) {
23
- total.input += message.usage.input;
24
- total.output += message.usage.output;
25
- total.cacheRead += message.usage.cacheRead;
26
- total.cacheWrite += message.usage.cacheWrite;
27
- total.cost += message.usage.cost.total;
28
- }
29
- return total;
21
+ function hasToolCall(message) {
22
+ return typeof message === "object" && message !== null && Array.isArray(message.content) && message.content.some((part) => typeof part === "object" && part !== null && part.type === "toolCall");
23
+ }
24
+ function latestAssistantHasToolCall(messages) {
25
+ const message = [...messages].reverse().find((item) => item.role === "assistant");
26
+ return hasToolCall(message);
27
+ }
28
+ function throwIfTerminalAssistantError(session) {
29
+ const message = [...session.messages].reverse().find((item) => item.role === "assistant");
30
+ if (message?.stopReason === "error")
31
+ throw new WorkflowError("AGENT_FAILED", message.errorMessage ?? "Native Pi assistant ended with a terminal provider error");
30
32
  }
33
+ function accounting(stats) {
34
+ return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
35
+ }
36
+ function canonicalSourcePath(path) { try {
37
+ return realpathSync(path);
38
+ }
39
+ catch {
40
+ return resolve(path);
41
+ } }
31
42
  export async function createNativeAgentSession(input) {
32
43
  const agentDir = input.agentDir ?? getAgentDir();
33
- const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
34
- manager.appendSessionInfo(input.sessionLabel);
44
+ let manager;
45
+ if (input.continuation) {
46
+ try {
47
+ manager = SessionManager.open(input.continuation.sessionFile, input.agentDir ? join(agentDir, "sessions") : undefined, input.cwd);
48
+ const header = manager.getHeader();
49
+ if (!header || canonicalSourcePath(header.cwd) !== canonicalSourcePath(input.cwd) || manager.getSessionId() !== input.continuation.sessionId || !manager.getEntry(input.continuation.leafId))
50
+ throw new Error("Persisted transcript identity does not match the conversation head");
51
+ manager.branch(input.continuation.leafId);
52
+ const context = manager.buildSessionContext();
53
+ if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model))
54
+ throw new Error("Persisted transcript model does not match the conversation execution policy");
55
+ if (input.model.thinking && context.thinkingLevel !== input.model.thinking)
56
+ throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
57
+ }
58
+ catch (error) {
59
+ if (error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE")
60
+ throw error;
61
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot reopen conversation transcript: ${error instanceof Error ? error.message : String(error)}`);
62
+ }
63
+ }
64
+ else {
65
+ manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
66
+ manager.appendSessionInfo(input.sessionLabel);
67
+ }
35
68
  const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
36
69
  const model = modelRuntime.getModel(input.model.provider, input.model.model);
37
70
  if (!model)
38
71
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${input.model.provider}/${input.model.model}`);
39
72
  const customTools = [...(input.customTools ?? []), ...(input.resultTool ? [input.resultTool] : [])];
40
73
  const tools = [...new Set([...input.tools, ...customTools.map(({ name }) => name)])];
41
- const resourceLoader = input.systemPromptAppend ? new DefaultResourceLoader({ cwd: input.cwd, agentDir, appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] }) : undefined;
42
- if (resourceLoader)
74
+ let settingsManager;
75
+ let resourceLoader;
76
+ const policy = input.resourcePolicy;
77
+ if (policy) {
78
+ settingsManager = SettingsManager.create(input.cwd, agentDir, { projectTrusted: false });
79
+ settingsManager.setProjectTrusted(policy.projectTrusted);
80
+ const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager });
81
+ const resolved = await packageManager.resolve();
82
+ const disabledExtensions = new Set(policy.effective.extensions);
83
+ 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))))];
84
+ const skillPaths = [...new Set(resolved.skills.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => path))];
85
+ const updateSkillMatches = (skills) => {
86
+ const names = new Set(skills.map(({ name }) => name));
87
+ Object.assign(policy, { unmatchedSkills: policy.effective.skills.filter((name) => !names.has(name)) });
88
+ };
89
+ const disabledSkills = new Set(policy.effective.skills);
90
+ resourceLoader = new DefaultResourceLoader({
91
+ cwd: input.cwd,
92
+ agentDir,
93
+ settingsManager,
94
+ noExtensions: true,
95
+ additionalExtensionPaths: extensionPaths,
96
+ noSkills: true,
97
+ additionalSkillPaths: skillPaths,
98
+ ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}),
99
+ skillsOverride: (base) => {
100
+ updateSkillMatches(base.skills);
101
+ return { ...base, skills: base.skills.filter(({ name }) => !disabledSkills.has(name)) };
102
+ },
103
+ ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}),
104
+ });
105
+ await resourceLoader.reload();
106
+ const discoveredExtensions = new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)));
107
+ Object.assign(policy, { unmatchedExtensions: policy.effective.extensions.filter((path) => !discoveredExtensions.has(canonicalSourcePath(path))) });
108
+ }
109
+ else if (input.systemPromptAppend || input.extensionFactories?.length) {
110
+ resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
43
111
  await resourceLoader.reload();
44
- 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 });
45
- return session;
112
+ }
113
+ 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 });
114
+ if (input.continuation && modelFallbackMessage)
115
+ throw new WorkflowError("RESUME_INCOMPATIBLE", modelFallbackMessage);
116
+ return Object.assign(session, {
117
+ getLeafId: () => manager.getLeafId(),
118
+ getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
119
+ });
120
+ }
121
+ function changedOption(options, baseline, key) { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
122
+ function validThinking(value) { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
123
+ function fallbackSetupContext(root, options, signal) {
124
+ const identity = options.agentIdentity ?? { structuralPath: [], callSite: options.label, occurrence: 1 };
125
+ const run = root.runContext ?? Object.freeze({ cwd: root.cwd, sessionId: "", runId: "", workflow: Object.freeze({ name: options.workflowName }), args: null, signal });
126
+ return { run, identity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) };
127
+ }
128
+ function resourcePolicySummary(policy) {
129
+ return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
130
+ }
131
+ function canonicalJson(value) {
132
+ if (Array.isArray(value))
133
+ return value.map((item) => canonicalJson(item));
134
+ if (value && typeof value === "object")
135
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
136
+ return value;
137
+ }
138
+ function fingerprint(value) { return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex"); }
139
+ function promptFingerprint(value) { return createHash("sha256").update(value).digest("hex"); }
140
+ function fixedConversationOptions(options) {
141
+ const fixedOptions = structuredClone(options);
142
+ delete fixedOptions.timeoutMs;
143
+ delete fixedOptions.retries;
144
+ return fixedOptions;
145
+ }
146
+ function conversationExecutionPolicy(options, setup) {
147
+ return structuredClone({
148
+ model: setup.sessionInput.model,
149
+ tools: [...setup.sessionInput.tools],
150
+ cwd: setup.sessionInput.cwd,
151
+ role: options.role ?? null,
152
+ worktreeOwner: options.worktreeOwner ?? null,
153
+ parent: options.parent ?? null,
154
+ systemPromptAppend: setup.sessionInput.systemPromptAppend ?? "",
155
+ options: fixedConversationOptions(setup.options),
156
+ resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
157
+ });
158
+ }
159
+ function conversationFailure(message) { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
160
+ async function prepareAgentSetup(root, createSession, task, options, resolved, cwd, attempt, signal, customTools, resultTool, continuation) {
161
+ const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
162
+ const baselineOptions = structuredClone(options.agentOptions ?? {});
163
+ const baseResourcePolicy = await root.agentResourcePolicy?.();
164
+ const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
165
+ const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
166
+ const 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) };
167
+ const setup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, createSession };
168
+ const base = fallbackSetupContext(root, options, setupSignal);
169
+ const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
170
+ const hookNames = [];
171
+ for (const hook of [...(root.agentSetupHooks ?? [])].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))) {
172
+ if (setupSignal.aborted)
173
+ throw new WorkflowError("CANCELLED", "Agent cancelled");
174
+ try {
175
+ await hook.setup(setup, context);
176
+ }
177
+ catch (error) {
178
+ if (setupSignal.reason !== undefined)
179
+ throw new WorkflowError("CANCELLED", "Agent cancelled");
180
+ throw error;
181
+ }
182
+ hookNames.push(hook.name);
183
+ if (setupSignal.reason !== undefined)
184
+ throw new WorkflowError("CANCELLED", "Agent cancelled");
185
+ }
186
+ setup.sessionInput.options = setup.options;
187
+ if (changedOption(setup.options, baselineOptions, "model") && typeof setup.options.model === "string")
188
+ 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);
189
+ if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking))
190
+ setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
191
+ if (changedOption(setup.options, baselineOptions, "tools") && Array.isArray(setup.options.tools) && setup.options.tools.every((tool) => typeof tool === "string"))
192
+ setup.sessionInput.tools = [...setup.options.tools];
193
+ if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string")
194
+ setup.sessionInput.cwd = setup.options.cwd;
195
+ if (continuation)
196
+ setup.sessionInput.continuation = { sessionId: continuation.sessionId, sessionFile: continuation.sessionFile, leafId: continuation.leafId };
197
+ const model = setup.sessionInput.model;
198
+ const summary = { 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) } : {}) };
199
+ return { setup, summary };
46
200
  }
47
201
  export class WorkflowAgentExecutor {
48
202
  root;
@@ -51,6 +205,7 @@ export class WorkflowAgentExecutor {
51
205
  this.root = root;
52
206
  this.createSession = createSession;
53
207
  }
208
+ setRunContext(runContext) { this.root.runContext = runContext; }
54
209
  resolve(options, inheritedTools) {
55
210
  const role = options.role;
56
211
  const definition = role ? this.root.agentDefinitions?.[role] : undefined;
@@ -62,13 +217,21 @@ export class WorkflowAgentExecutor {
62
217
  const forbidden = requested.find((tool) => !this.root.tools.has(tool));
63
218
  if (forbidden)
64
219
  throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${forbidden}`);
65
- const model = parseModel(options.model ?? definition?.model, this.root.model, options.thinking ?? definition?.thinking);
66
- const availableModels = this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
220
+ const requestedModel = options.model ?? definition?.model;
221
+ const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
222
+ if (requestedModel !== undefined && this.root.blockedAliases?.has(requestedModel) && !hasAlias) {
223
+ const target = this.root.blockedAliasTargets?.[requestedModel];
224
+ throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
225
+ }
226
+ const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
227
+ 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);
228
+ const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
67
229
  if (!availableModels.has(modelCapability(model)))
68
- throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${modelCapability(model)}`);
69
- return { model, tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
230
+ throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
231
+ return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
70
232
  }
71
233
  async execute(task, options, signal, customTools = [], setSteer, beforeRetry) {
234
+ const executionSignal = signal ?? this.root.runContext?.signal;
72
235
  if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0)
73
236
  throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
74
237
  if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0))
@@ -102,12 +265,39 @@ export class WorkflowAgentExecutor {
102
265
  throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
103
266
  cwd = this.root.cwd;
104
267
  }
268
+ let conversationRecord;
269
+ if (options.conversation) {
270
+ const store = this.root.runStore;
271
+ if (!store)
272
+ throw conversationFailure("Conversation persistence is unavailable");
273
+ try {
274
+ conversationRecord = await store.conversation(options.conversation.id);
275
+ }
276
+ catch (error) {
277
+ throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`);
278
+ }
279
+ if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1)
280
+ throw conversationFailure("Conversation turn must be a positive integer");
281
+ if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1)
282
+ throw conversationFailure(`Conversation turn ${String(options.conversation.turn)} does not continue its persisted head`);
283
+ }
105
284
  const attempts = [];
285
+ let conversationBaseline;
106
286
  const maxAttempts = (options.retries ?? 0) + 1;
107
287
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
108
- const started = Date.now();
288
+ options.budget?.beforeAttempt();
109
289
  let accepted = false;
110
290
  let schemaResult;
291
+ let session;
292
+ let setup;
293
+ let setupSummary;
294
+ let setupFailed = false;
295
+ let budgetError;
296
+ let turnStarted = false;
297
+ let conversationSystemPrompt = "";
298
+ let conversationToolDefinitionsSha256 = "";
299
+ let conversationMismatch;
300
+ const conversationMismatchError = () => conversationMismatch ? new WorkflowError("RESUME_INCOMPATIBLE", conversationMismatch.message) : undefined;
111
301
  const hasSchemaResult = () => schemaResult !== undefined;
112
302
  const resultTool = options.schema ? {
113
303
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
@@ -121,7 +311,6 @@ export class WorkflowAgentExecutor {
121
311
  return { content: [{ type: "text", text: "Result accepted." }], details: {} };
122
312
  },
123
313
  } : undefined;
124
- let session;
125
314
  const toolCalls = new Map();
126
315
  let activity;
127
316
  let progress = Promise.resolve();
@@ -137,26 +326,115 @@ export class WorkflowAgentExecutor {
137
326
  const report = (persist) => {
138
327
  if (!session || !options.onProgress)
139
328
  return;
140
- const update = { accounting: accounting(session.messages), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
329
+ const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
141
330
  progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
142
331
  };
143
332
  try {
144
- 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 } : {}) });
145
- await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile) });
146
- unsubscribe = session.subscribe?.((event) => {
147
- if (event.type === "agent_start" && session?.systemPrompt !== undefined && this.root.runStore) {
148
- systemPromptTurn += 1;
149
- const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
150
- systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error) => { systemPromptWriteError ??= error; });
333
+ setupFailed = true;
334
+ const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool, conversationRecord?.head);
335
+ setup = prepared.setup;
336
+ setupSummary = prepared.summary;
337
+ setupFailed = false;
338
+ if (executionSignal?.aborted)
339
+ throw new WorkflowError("CANCELLED", "Agent cancelled");
340
+ const started = Date.now();
341
+ session = await setup.createSession(setup.sessionInput);
342
+ if (setup.sessionInput.resourcePolicy)
343
+ setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
344
+ if (options.conversation) {
345
+ conversationSystemPrompt = session.systemPrompt ?? "";
346
+ conversationToolDefinitionsSha256 = fingerprint(session.getToolDefinitions?.() ?? session.agent?.state.tools ?? []);
347
+ const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
348
+ if (conversationRecord) {
349
+ if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile)
350
+ throw conversationFailure("Conversation transcript identity changed");
351
+ if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId)
352
+ throw conversationFailure("Conversation transcript leaf identity changed");
353
+ if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationRecord.policy))
354
+ throw conversationFailure("Conversation execution policy changed");
355
+ if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt))
356
+ throw conversationFailure("Conversation system prompt changed");
357
+ if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256)
358
+ throw conversationFailure("Conversation tool definitions changed");
359
+ }
360
+ else if (conversationBaseline) {
361
+ if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationBaseline.executionPolicy))
362
+ throw conversationFailure("Conversation execution policy changed");
363
+ if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256)
364
+ throw conversationFailure("Conversation tool definitions changed");
365
+ }
366
+ else {
367
+ conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
368
+ }
369
+ if (!session.subscribe) {
370
+ const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
371
+ const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
372
+ if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt))
373
+ throw conversationFailure("Conversation system prompt changed");
374
+ if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined)
375
+ conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
376
+ }
377
+ if (conversationRecord && (!session.model || session.model.provider !== setup.sessionInput.model.provider || (session.model.model ?? session.model.id) !== setup.sessionInput.model.model))
378
+ throw conversationFailure("Conversation model changed");
379
+ }
380
+ const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
381
+ await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
382
+ const activeSession = session;
383
+ unsubscribe = activeSession.subscribe?.((event) => {
384
+ if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
385
+ if (options.conversation) {
386
+ conversationSystemPrompt = session.systemPrompt;
387
+ const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
388
+ const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
389
+ if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) {
390
+ conversationMismatch = conversationFailure("Conversation system prompt changed");
391
+ void session.abort?.();
392
+ }
393
+ if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined)
394
+ conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
395
+ }
396
+ if (this.root.runStore) {
397
+ systemPromptTurn += 1;
398
+ const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
399
+ systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error) => { systemPromptWriteError ??= error; });
400
+ }
151
401
  }
152
402
  if (event.type === "message_start" && event.message.role === "assistant") {
403
+ if (!turnStarted) {
404
+ try {
405
+ options.budget?.beforeTurn();
406
+ turnStarted = true;
407
+ }
408
+ catch (error) {
409
+ budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error));
410
+ void session?.abort?.();
411
+ }
412
+ }
153
413
  activity = { kind: "text", text: "responding" };
154
414
  report(false);
155
415
  }
156
416
  if (event.type === "message_end") {
157
417
  activity = undefined;
158
- if (event.message.role === "assistant")
418
+ if (event.message.role === "assistant") {
419
+ const needsMoreWork = hasToolCall(event.message);
420
+ const final = !needsMoreWork || (options.schema !== undefined && accepted);
421
+ if (!budgetError) {
422
+ try {
423
+ options.budget?.afterTurn(accounting(activeSession.getSessionStats()), final);
424
+ if (!final) {
425
+ const instruction = options.budget?.instruction();
426
+ if (instruction)
427
+ void session?.steer?.(instruction);
428
+ }
429
+ }
430
+ catch (error) {
431
+ budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error));
432
+ void session?.abort?.();
433
+ }
434
+ }
435
+ turnStarted = false;
159
436
  report(true);
437
+ }
160
438
  }
161
439
  if (event.type === "tool_execution_start") {
162
440
  toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" });
@@ -177,28 +455,61 @@ export class WorkflowAgentExecutor {
177
455
  setSteer((message) => session?.steer?.(message));
178
456
  }
179
457
  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");
180
- await promptWithProviderPause(session, `${context}\n\nTask:\n${task}`, remaining(options.timeoutMs, started), signal, this.root.providerPause);
458
+ const instruction = options.budget?.instruction();
459
+ const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
460
+ options.budget?.beforeTurn();
461
+ turnStarted = true;
462
+ await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
463
+ if (conversationMismatch)
464
+ throw conversationMismatch;
465
+ throwIfTerminalAssistantError(session);
466
+ {
467
+ const completedAccounting = accounting(session.getSessionStats());
468
+ options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? false : !latestAssistantHasToolCall(session.messages));
469
+ turnStarted = false;
470
+ }
471
+ if (budgetError)
472
+ throw budgetError;
181
473
  if (options.schema) {
182
474
  accepted = true;
183
475
  try {
184
- 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);
476
+ options.budget?.beforeTurn();
477
+ turnStarted = true;
478
+ 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);
479
+ {
480
+ const completedAccounting = accounting(session.getSessionStats());
481
+ options.budget?.afterTurn(completedAccounting, true);
482
+ turnStarted = false;
483
+ }
185
484
  }
186
485
  catch (error) {
187
486
  if (!hasSchemaResult())
188
487
  throw error;
189
488
  }
489
+ throwIfTerminalAssistantError(session);
190
490
  if (!hasSchemaResult()) {
191
491
  try {
192
- 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);
492
+ options.budget?.beforeTurn();
493
+ turnStarted = true;
494
+ 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);
495
+ {
496
+ const completedAccounting = accounting(session.getSessionStats());
497
+ options.budget?.afterTurn(completedAccounting, true);
498
+ turnStarted = false;
499
+ }
193
500
  }
194
501
  catch (error) {
195
502
  if (!hasSchemaResult())
196
503
  throw error;
197
504
  }
505
+ throwIfTerminalAssistantError(session);
198
506
  }
199
507
  if (schemaResult === undefined)
200
508
  throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
201
509
  }
510
+ const mismatch = conversationMismatchError();
511
+ if (mismatch)
512
+ throw mismatch;
202
513
  const value = options.schema ? schemaResult : text(session.messages);
203
514
  if (options.worktreeOwner)
204
515
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
@@ -206,13 +517,23 @@ export class WorkflowAgentExecutor {
206
517
  await progress;
207
518
  await flushSystemPrompts();
208
519
  unsubscribe?.();
209
- const attemptAccounting = accounting(session.messages);
210
- attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting });
520
+ const attemptAccounting = accounting(session.getSessionStats());
521
+ const leafId = session.getLeafId?.() ?? undefined;
522
+ if (options.conversation) {
523
+ if (!leafId)
524
+ throw conversationFailure("Conversation transcript has no persisted leaf");
525
+ const store = this.root.runStore;
526
+ if (!store)
527
+ throw conversationFailure("Conversation persistence is unavailable");
528
+ 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 } });
529
+ }
530
+ const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
531
+ attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
211
532
  session.dispose();
212
- return { value, attempts, cwd };
533
+ return { value, attempts, cwd: setupSummary.cwd };
213
534
  }
214
535
  catch (error) {
215
- const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
536
+ const typed = budgetError ?? conversationMismatch ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
216
537
  if (session) {
217
538
  report(true);
218
539
  await progress;
@@ -221,13 +542,22 @@ export class WorkflowAgentExecutor {
221
542
  }
222
543
  catch { /* Preserve the agent failure that prompted this cleanup. */ }
223
544
  unsubscribe?.();
224
- const attemptAccounting = accounting(session.messages);
225
- attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), error: { code: typed.code, message: typed.message }, accounting: attemptAccounting });
545
+ const attemptAccounting = accounting(session.getSessionStats());
546
+ if (!budgetError && typed.code !== "BUDGET_EXHAUSTED") {
547
+ try {
548
+ options.budget?.afterTurn(attemptAccounting, true);
549
+ }
550
+ catch (budgetFailure) {
551
+ budgetError ??= budgetFailure instanceof WorkflowError ? budgetFailure : new WorkflowError("BUDGET_EXHAUSTED", budgetFailure instanceof Error ? budgetFailure.message : String(budgetFailure));
552
+ }
553
+ }
554
+ const includeFailedSetup = Boolean(this.root.agentSetupHooks?.length || setup?.sessionInput.resourcePolicy);
555
+ attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), error: { code: typed.code, message: typed.message }, accounting: attemptAccounting, ...(includeFailedSetup && setupSummary ? { setup: setupSummary } : {}) });
226
556
  session.dispose();
227
557
  }
228
558
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED")
229
559
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
230
- if (attempt === maxAttempts || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED")
560
+ if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE")
231
561
  throw Object.assign(typed, { attempts });
232
562
  beforeRetry?.();
233
563
  }
@@ -253,12 +583,12 @@ export class FairAgentScheduler {
253
583
  if (!Number.isInteger(sessionLimit) || sessionLimit < 1 || sessionLimit > 16)
254
584
  throw new WorkflowError("INVALID_SETTINGS", "Session concurrency must be an integer from 1 to 16");
255
585
  }
256
- addRun(runId, limit = 8, maxAgentLaunches = 1000) {
586
+ addRun(runId, limit = 8, beforeLaunch) {
257
587
  if (this.#runs.has(runId))
258
588
  throw new WorkflowError("DUPLICATE_NAME", `Scheduler run already exists: ${runId}`);
259
- if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit || !Number.isInteger(maxAgentLaunches) || maxAgentLaunches < 1)
260
- throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency or maxAgentLaunches");
261
- this.#runs.set(runId, { limit, maxAgentLaunches, logical: 0, active: 0, queue: [] });
589
+ if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit)
590
+ throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency");
591
+ this.#runs.set(runId, { limit, ...(beforeLaunch ? { beforeLaunch } : {}), logical: 0, active: 0, queue: [] });
262
592
  this.#runOrder.push(runId);
263
593
  }
264
594
  spawn(runId, prompt, options, parentId) {
@@ -269,10 +599,6 @@ export class FairAgentScheduler {
269
599
  if (parentId && (!parent || parent.runId !== runId))
270
600
  throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Parent agent is not owned by this run");
271
601
  const effective = this.#inherit(parent, options);
272
- if (++run.logical > run.maxAgentLaunches) {
273
- run.logical -= 1;
274
- throw new WorkflowError("RUN_LIMIT_EXCEEDED", `Run ${runId} exceeded maxAgentLaunches`);
275
- }
276
602
  const id = `${runId}:${String(++this.#nextId)}`;
277
603
  let resolveResult = () => undefined;
278
604
  const promise = new Promise((resolve) => { resolveResult = resolve; });
@@ -296,7 +622,7 @@ export class FairAgentScheduler {
296
622
  this.#nodes.set(id, node);
297
623
  parent?.children.add(id);
298
624
  this.#persist(runId);
299
- this.#enqueue(runId, () => { void node.task(); });
625
+ this.#enqueue(runId, node, () => { void node.task(); });
300
626
  return { id, result: promise };
301
627
  }
302
628
  async result(parentId, childId) {
@@ -309,7 +635,7 @@ export class FairAgentScheduler {
309
635
  this.#persist(parent.runId);
310
636
  this.#release(parent.runId);
311
637
  const outcome = await child.promise;
312
- await new Promise((resolve) => { this.#enqueue(parent.runId, () => { resolve(); }); });
638
+ await new Promise((resolve) => { this.#enqueue(parent.runId, undefined, () => { resolve(); }); });
313
639
  parent.state = "running";
314
640
  if (parent.controller.signal.aborted)
315
641
  throw new WorkflowError("CANCELLED", "Parent agent cancelled");
@@ -334,6 +660,20 @@ export class FairAgentScheduler {
334
660
  this.#cancelTree(child);
335
661
  }
336
662
  }
663
+ retry(id) {
664
+ const node = this.#node(id);
665
+ if (node.state === "running") {
666
+ node.state = "retrying";
667
+ this.#persist(node.runId);
668
+ }
669
+ }
670
+ attemptStarted(id) {
671
+ const node = this.#node(id);
672
+ if (node.state === "retrying") {
673
+ node.state = "running";
674
+ this.#persist(node.runId);
675
+ }
676
+ }
337
677
  async cancelRun(runId) {
338
678
  const run = this.#runs.get(runId);
339
679
  if (!run)
@@ -353,12 +693,13 @@ export class FairAgentScheduler {
353
693
  return [];
354
694
  const agentTool = {
355
695
  name: "agent", label: "Child Agent", description: "Start a direct child agent",
356
- 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({})), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }),
696
+ 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({})), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
357
697
  execute: async (_id, params) => {
358
698
  if (params.role !== undefined && (params.model !== undefined || params.thinking !== undefined || params.tools !== undefined))
359
699
  throw new WorkflowError("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
360
700
  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;
361
- 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 }) };
701
+ const agentOptions = Object.fromEntries(Object.entries(params).filter(([key]) => key !== "prompt"));
702
+ const options = { 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 }) };
362
703
  const child = this.spawn(parent.runId, params.prompt, options, parentId);
363
704
  return { content: [{ type: "text", text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
364
705
  },
@@ -366,7 +707,8 @@ export class FairAgentScheduler {
366
707
  const resultTool = {
367
708
  name: "get_subagent_result", label: "Child Result", description: "Wait for a direct child and return its result",
368
709
  parameters: Type.Object({ id: Type.String() }),
369
- execute: async (_id, params) => { const value = await this.result(parentId, params.id); return { content: [{ type: "text", text: JSON.stringify(value) }], details: value }; },
710
+ execute: async (_id, params) => { const value = await this.result(parentId, params.id); if (!value.ok && value.error.code === "BUDGET_EXHAUSTED")
711
+ throw new WorkflowError("BUDGET_EXHAUSTED", value.error.message); return { content: [{ type: "text", text: JSON.stringify(value) }], details: value }; },
370
712
  };
371
713
  const steerTool = {
372
714
  name: "steer_subagent", label: "Steer Child", description: "Steer a running direct child",
@@ -378,8 +720,8 @@ export class FairAgentScheduler {
378
720
  snapshot() {
379
721
  return [...this.#nodes.values()].map(({ id, parentId, options, state }) => ({ id, ...(parentId ? { parentId } : {}), label: options.label, state, options }));
380
722
  }
381
- restoreRun(runId, limit, maxAgentLaunches, ownership) {
382
- this.addRun(runId, limit, maxAgentLaunches);
723
+ restoreRun(runId, limit, ownership, beforeLaunch) {
724
+ this.addRun(runId, limit, beforeLaunch);
383
725
  const run = this.#runs.get(runId);
384
726
  for (const record of ownership) {
385
727
  if (record.id.split(":").slice(0, -1).join(":") !== runId)
@@ -401,22 +743,20 @@ export class FairAgentScheduler {
401
743
  }
402
744
  async flush() { await this.#persistence; }
403
745
  #inherit(parent, options) {
404
- const unknown = Object.keys(options).find((key) => !["label", "requestedLabel", "parentBreadcrumb", "cwd", "tools", "worktreeOwner", "model", "thinking", "role", "schema", "retries", "timeoutMs"].includes(key));
405
- if (unknown)
406
- throw new WorkflowError("INVALID_METADATA", `Unsupported child agent option: ${unknown}`);
407
746
  if (!options.label.trim() || !options.cwd || !Array.isArray(options.tools))
408
747
  throw new WorkflowError("INVALID_METADATA", "Agents require label, cwd, and tools");
409
748
  if (!parent)
410
- return Object.freeze({ ...options, tools: Object.freeze([...options.tools]) });
749
+ 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]) }) } : {}) });
411
750
  if (options.cwd !== parent.options.cwd)
412
751
  throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
413
752
  const forbidden = options.tools.find((tool) => !parent.options.tools.includes(tool));
414
753
  if (forbidden)
415
754
  throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
416
- return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...options.tools]), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
755
+ const identity = options.agentIdentity ?? parent.options.agentIdentity;
756
+ 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 } : {}) });
417
757
  }
418
758
  /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
419
- #enqueue(runId, start) { this.#runs.get(runId)?.queue.push(start); this.#dispatch(); }
759
+ #enqueue(runId, node, start) { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
420
760
  #dispatch() {
421
761
  while (this.#active < this.sessionLimit && this.#runOrder.length) {
422
762
  let selected;
@@ -433,10 +773,20 @@ export class FairAgentScheduler {
433
773
  if (!selected)
434
774
  return;
435
775
  const run = this.#runs.get(selected);
436
- const start = run.queue.shift();
776
+ const item = run.queue.shift();
777
+ if (item.node) {
778
+ try {
779
+ run.beforeLaunch?.();
780
+ }
781
+ catch (error) {
782
+ const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
783
+ this.#settle(item.node, { id: item.node.id, ok: false, error: { code: typed.code, message: typed.message } });
784
+ continue;
785
+ }
786
+ }
437
787
  run.active += 1;
438
788
  this.#active += 1;
439
- start();
789
+ item.start();
440
790
  }
441
791
  }
442
792
  #release(runId) {
@@ -450,7 +800,7 @@ export class FairAgentScheduler {
450
800
  #settle(node, result) {
451
801
  if (["completed", "failed", "cancelled"].includes(node.state))
452
802
  return;
453
- const heldPermit = node.state === "running";
803
+ const heldPermit = node.state === "running" || node.state === "retrying";
454
804
  node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
455
805
  this.#persist(node.runId);
456
806
  if (heldPermit)