pi-extensible-workflows 0.3.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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,222 @@ 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");
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 };
30
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
+ });
43
105
  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;
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 ?? ""] } : {}) });
111
+ await resourceLoader.reload();
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 jsonValue(value, seen = new Set()) {
124
+ if (value === null || typeof value === "boolean" || typeof value === "string")
125
+ return true;
126
+ if (typeof value === "number")
127
+ return Number.isFinite(value);
128
+ if (typeof value !== "object" || seen.has(value))
129
+ return false;
130
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
131
+ return false;
132
+ const keys = Reflect.ownKeys(value);
133
+ if (keys.some((key) => typeof key !== "string"))
134
+ return false;
135
+ seen.add(value);
136
+ const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => value[key])).every((item) => jsonValue(item, seen));
137
+ seen.delete(value);
138
+ return valid;
139
+ }
140
+ function jsonObject(value) { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
141
+ function isChildAgentToolParams(value) {
142
+ if (!jsonObject(value) || typeof value.prompt !== "string" || typeof value.label !== "string")
143
+ return false;
144
+ if (value.tools !== undefined && (!Array.isArray(value.tools) || value.tools.some((tool) => typeof tool !== "string")))
145
+ return false;
146
+ if (value.model !== undefined && typeof value.model !== "string")
147
+ return false;
148
+ if (value.thinking !== undefined && !validThinking(value.thinking))
149
+ return false;
150
+ if (value.role !== undefined && typeof value.role !== "string")
151
+ return false;
152
+ if (value.outputSchema !== undefined && !jsonObject(value.outputSchema))
153
+ return false;
154
+ if (value.retries !== undefined && (typeof value.retries !== "number" || !Number.isInteger(value.retries) || value.retries < 0))
155
+ return false;
156
+ if (value.timeoutMs !== undefined && (value.timeoutMs !== null && (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs < 1)))
157
+ return false;
158
+ return true;
159
+ }
160
+ function fallbackSetupContext(root, options, signal) {
161
+ const identity = options.agentIdentity ?? { structuralPath: [], callSite: options.label, occurrence: 1 };
162
+ const run = root.runContext ?? Object.freeze({ cwd: root.cwd, sessionId: "", runId: "", workflow: Object.freeze({ name: options.workflowName }), args: null, signal });
163
+ return { run, identity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) };
164
+ }
165
+ function resourcePolicySummary(policy) {
166
+ return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
167
+ }
168
+ function canonicalJson(value) {
169
+ if (Array.isArray(value))
170
+ return value.map((item) => canonicalJson(item));
171
+ if (value && typeof value === "object")
172
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
173
+ return value;
174
+ }
175
+ function fingerprint(value) { return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex"); }
176
+ function promptFingerprint(value) { return createHash("sha256").update(value).digest("hex"); }
177
+ function fixedConversationOptions(options) {
178
+ const fixedOptions = structuredClone(options);
179
+ delete fixedOptions.timeoutMs;
180
+ delete fixedOptions.retries;
181
+ return fixedOptions;
182
+ }
183
+ function conversationExecutionPolicy(options, setup) {
184
+ return structuredClone({
185
+ model: setup.sessionInput.model,
186
+ tools: [...setup.sessionInput.tools],
187
+ cwd: setup.sessionInput.cwd,
188
+ role: options.role ?? null,
189
+ worktreeOwner: options.worktreeOwner ?? null,
190
+ parent: options.parent ?? null,
191
+ systemPromptAppend: setup.sessionInput.systemPromptAppend ?? "",
192
+ options: fixedConversationOptions(setup.options),
193
+ resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
194
+ });
195
+ }
196
+ function conversationFailure(message) { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
197
+ async function prepareAgentSetup(root, createSession, task, options, resolved, cwd, attempt, signal, customTools, resultTool, continuation) {
198
+ const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
199
+ const baselineOptions = structuredClone(options.agentOptions ?? {});
200
+ const baseResourcePolicy = await root.agentResourcePolicy?.();
201
+ const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
202
+ const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
203
+ 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) };
204
+ const setup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, createSession };
205
+ const base = fallbackSetupContext(root, options, setupSignal);
206
+ const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
207
+ const hookNames = [];
208
+ for (const hook of [...(root.agentSetupHooks ?? [])].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))) {
209
+ if (setupSignal.aborted)
210
+ throw new WorkflowError("CANCELLED", "Agent cancelled");
211
+ try {
212
+ await hook.setup(setup, context);
213
+ }
214
+ catch (error) {
215
+ if (setupSignal.reason !== undefined)
216
+ throw new WorkflowError("CANCELLED", "Agent cancelled");
217
+ throw error;
218
+ }
219
+ hookNames.push(hook.name);
220
+ if (setupSignal.reason !== undefined)
221
+ throw new WorkflowError("CANCELLED", "Agent cancelled");
222
+ }
223
+ setup.sessionInput.options = setup.options;
224
+ if (changedOption(setup.options, baselineOptions, "model") && typeof setup.options.model === "string")
225
+ 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);
226
+ if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking))
227
+ setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
228
+ if (changedOption(setup.options, baselineOptions, "tools") && Array.isArray(setup.options.tools) && setup.options.tools.every((tool) => typeof tool === "string"))
229
+ setup.sessionInput.tools = [...setup.options.tools];
230
+ if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string")
231
+ setup.sessionInput.cwd = setup.options.cwd;
232
+ if (continuation)
233
+ setup.sessionInput.continuation = { sessionId: continuation.sessionId, sessionFile: continuation.sessionFile, leafId: continuation.leafId };
234
+ const model = setup.sessionInput.model;
235
+ 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) } : {}) };
236
+ return { setup, summary };
46
237
  }
47
238
  export class WorkflowAgentExecutor {
48
239
  root;
@@ -51,6 +242,7 @@ export class WorkflowAgentExecutor {
51
242
  this.root = root;
52
243
  this.createSession = createSession;
53
244
  }
245
+ setRunContext(runContext) { this.root.runContext = runContext; }
54
246
  resolve(options, inheritedTools) {
55
247
  const role = options.role;
56
248
  const definition = role ? this.root.agentDefinitions?.[role] : undefined;
@@ -62,13 +254,21 @@ export class WorkflowAgentExecutor {
62
254
  const forbidden = requested.find((tool) => !this.root.tools.has(tool));
63
255
  if (forbidden)
64
256
  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)]);
257
+ const requestedModel = options.model ?? definition?.model;
258
+ const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
259
+ if (requestedModel !== undefined && this.root.blockedAliases?.has(requestedModel) && !hasAlias) {
260
+ const target = this.root.blockedAliasTargets?.[requestedModel];
261
+ throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
262
+ }
263
+ const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
264
+ 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);
265
+ const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
67
266
  if (!availableModels.has(modelCapability(model)))
68
- throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${modelCapability(model)}`);
69
- return { model, tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
267
+ throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
268
+ return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
70
269
  }
71
270
  async execute(task, options, signal, customTools = [], setSteer, beforeRetry) {
271
+ const executionSignal = signal ?? this.root.runContext?.signal;
72
272
  if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0)
73
273
  throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
74
274
  if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0))
@@ -102,12 +302,39 @@ export class WorkflowAgentExecutor {
102
302
  throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
103
303
  cwd = this.root.cwd;
104
304
  }
305
+ let conversationRecord;
306
+ if (options.conversation) {
307
+ const store = this.root.runStore;
308
+ if (!store)
309
+ throw conversationFailure("Conversation persistence is unavailable");
310
+ try {
311
+ conversationRecord = await store.conversation(options.conversation.id);
312
+ }
313
+ catch (error) {
314
+ throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`);
315
+ }
316
+ if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1)
317
+ throw conversationFailure("Conversation turn must be a positive integer");
318
+ if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1)
319
+ throw conversationFailure(`Conversation turn ${String(options.conversation.turn)} does not continue its persisted head`);
320
+ }
105
321
  const attempts = [];
322
+ let conversationBaseline;
106
323
  const maxAttempts = (options.retries ?? 0) + 1;
107
324
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
108
- const started = Date.now();
325
+ options.budget?.beforeAttempt();
109
326
  let accepted = false;
110
327
  let schemaResult;
328
+ let session;
329
+ let setup;
330
+ let setupSummary;
331
+ let setupFailed = false;
332
+ let budgetError;
333
+ let turnStarted = false;
334
+ let conversationSystemPrompt = "";
335
+ let conversationToolDefinitionsSha256 = "";
336
+ let conversationMismatch;
337
+ const conversationMismatchError = () => conversationMismatch ? new WorkflowError("RESUME_INCOMPATIBLE", conversationMismatch.message) : undefined;
111
338
  const hasSchemaResult = () => schemaResult !== undefined;
112
339
  const resultTool = options.schema ? {
113
340
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
@@ -121,7 +348,6 @@ export class WorkflowAgentExecutor {
121
348
  return { content: [{ type: "text", text: "Result accepted." }], details: {} };
122
349
  },
123
350
  } : undefined;
124
- let session;
125
351
  const toolCalls = new Map();
126
352
  let activity;
127
353
  let progress = Promise.resolve();
@@ -137,26 +363,115 @@ export class WorkflowAgentExecutor {
137
363
  const report = (persist) => {
138
364
  if (!session || !options.onProgress)
139
365
  return;
140
- const update = { accounting: accounting(session.messages), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
366
+ const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
141
367
  progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
142
368
  };
143
369
  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; });
370
+ setupFailed = true;
371
+ const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool, conversationRecord?.head);
372
+ setup = prepared.setup;
373
+ setupSummary = prepared.summary;
374
+ setupFailed = false;
375
+ if (executionSignal?.aborted)
376
+ throw new WorkflowError("CANCELLED", "Agent cancelled");
377
+ const started = Date.now();
378
+ session = await setup.createSession(setup.sessionInput);
379
+ if (setup.sessionInput.resourcePolicy)
380
+ setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
381
+ if (options.conversation) {
382
+ conversationSystemPrompt = session.systemPrompt ?? "";
383
+ conversationToolDefinitionsSha256 = fingerprint(session.getToolDefinitions?.() ?? session.agent?.state.tools ?? []);
384
+ const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
385
+ if (conversationRecord) {
386
+ if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile)
387
+ throw conversationFailure("Conversation transcript identity changed");
388
+ if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId)
389
+ throw conversationFailure("Conversation transcript leaf identity changed");
390
+ if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationRecord.policy))
391
+ throw conversationFailure("Conversation execution policy changed");
392
+ if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt))
393
+ throw conversationFailure("Conversation system prompt changed");
394
+ if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256)
395
+ throw conversationFailure("Conversation tool definitions changed");
396
+ }
397
+ else if (conversationBaseline) {
398
+ if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationBaseline.executionPolicy))
399
+ throw conversationFailure("Conversation execution policy changed");
400
+ if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256)
401
+ throw conversationFailure("Conversation tool definitions changed");
402
+ }
403
+ else {
404
+ conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
405
+ }
406
+ if (!session.subscribe) {
407
+ const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
408
+ const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
409
+ if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt))
410
+ throw conversationFailure("Conversation system prompt changed");
411
+ if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined)
412
+ conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
413
+ }
414
+ if (conversationRecord && (!session.model || session.model.provider !== setup.sessionInput.model.provider || (session.model.model ?? session.model.id) !== setup.sessionInput.model.model))
415
+ throw conversationFailure("Conversation model changed");
416
+ }
417
+ const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
418
+ await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
419
+ const activeSession = session;
420
+ unsubscribe = activeSession.subscribe?.((event) => {
421
+ if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
422
+ if (options.conversation) {
423
+ conversationSystemPrompt = session.systemPrompt;
424
+ const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
425
+ const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
426
+ if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) {
427
+ conversationMismatch = conversationFailure("Conversation system prompt changed");
428
+ void session.abort?.();
429
+ }
430
+ if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined)
431
+ conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
432
+ }
433
+ if (this.root.runStore) {
434
+ systemPromptTurn += 1;
435
+ const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
436
+ systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error) => { systemPromptWriteError ??= error; });
437
+ }
151
438
  }
152
439
  if (event.type === "message_start" && event.message.role === "assistant") {
440
+ if (!turnStarted) {
441
+ try {
442
+ options.budget?.beforeTurn();
443
+ turnStarted = true;
444
+ }
445
+ catch (error) {
446
+ budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error));
447
+ void session?.abort?.();
448
+ }
449
+ }
153
450
  activity = { kind: "text", text: "responding" };
154
451
  report(false);
155
452
  }
156
453
  if (event.type === "message_end") {
157
454
  activity = undefined;
158
- if (event.message.role === "assistant")
455
+ if (event.message.role === "assistant") {
456
+ const needsMoreWork = hasToolCall(event.message);
457
+ const final = !needsMoreWork || (options.schema !== undefined && accepted);
458
+ if (!budgetError) {
459
+ try {
460
+ options.budget?.afterTurn(accounting(activeSession.getSessionStats()), final);
461
+ if (!final) {
462
+ const instruction = options.budget?.instruction();
463
+ if (instruction)
464
+ void session?.steer?.(instruction);
465
+ }
466
+ }
467
+ catch (error) {
468
+ budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error));
469
+ void session?.abort?.();
470
+ }
471
+ }
472
+ turnStarted = false;
159
473
  report(true);
474
+ }
160
475
  }
161
476
  if (event.type === "tool_execution_start") {
162
477
  toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" });
@@ -177,28 +492,61 @@ export class WorkflowAgentExecutor {
177
492
  setSteer((message) => session?.steer?.(message));
178
493
  }
179
494
  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);
495
+ const instruction = options.budget?.instruction();
496
+ const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
497
+ options.budget?.beforeTurn();
498
+ turnStarted = true;
499
+ await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
500
+ if (conversationMismatch)
501
+ throw conversationMismatch;
502
+ throwIfTerminalAssistantError(session);
503
+ {
504
+ const completedAccounting = accounting(session.getSessionStats());
505
+ options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? false : !latestAssistantHasToolCall(session.messages));
506
+ turnStarted = false;
507
+ }
508
+ if (budgetError)
509
+ throw budgetError;
181
510
  if (options.schema) {
182
511
  accepted = true;
183
512
  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);
513
+ options.budget?.beforeTurn();
514
+ turnStarted = true;
515
+ 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);
516
+ {
517
+ const completedAccounting = accounting(session.getSessionStats());
518
+ options.budget?.afterTurn(completedAccounting, true);
519
+ turnStarted = false;
520
+ }
185
521
  }
186
522
  catch (error) {
187
523
  if (!hasSchemaResult())
188
524
  throw error;
189
525
  }
526
+ throwIfTerminalAssistantError(session);
190
527
  if (!hasSchemaResult()) {
191
528
  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);
529
+ options.budget?.beforeTurn();
530
+ turnStarted = true;
531
+ 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);
532
+ {
533
+ const completedAccounting = accounting(session.getSessionStats());
534
+ options.budget?.afterTurn(completedAccounting, true);
535
+ turnStarted = false;
536
+ }
193
537
  }
194
538
  catch (error) {
195
539
  if (!hasSchemaResult())
196
540
  throw error;
197
541
  }
542
+ throwIfTerminalAssistantError(session);
198
543
  }
199
544
  if (schemaResult === undefined)
200
545
  throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
201
546
  }
547
+ const mismatch = conversationMismatchError();
548
+ if (mismatch)
549
+ throw mismatch;
202
550
  const value = options.schema ? schemaResult : text(session.messages);
203
551
  if (options.worktreeOwner)
204
552
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
@@ -206,13 +554,23 @@ export class WorkflowAgentExecutor {
206
554
  await progress;
207
555
  await flushSystemPrompts();
208
556
  unsubscribe?.();
209
- const attemptAccounting = accounting(session.messages);
210
- attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting });
557
+ const attemptAccounting = accounting(session.getSessionStats());
558
+ const leafId = session.getLeafId?.() ?? undefined;
559
+ if (options.conversation) {
560
+ if (!leafId)
561
+ throw conversationFailure("Conversation transcript has no persisted leaf");
562
+ const store = this.root.runStore;
563
+ if (!store)
564
+ throw conversationFailure("Conversation persistence is unavailable");
565
+ 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 } });
566
+ }
567
+ const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
568
+ attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
211
569
  session.dispose();
212
- return { value, attempts, cwd };
570
+ return { value, attempts, cwd: setupSummary.cwd };
213
571
  }
214
572
  catch (error) {
215
- const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
573
+ const typed = budgetError ?? conversationMismatch ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
216
574
  if (session) {
217
575
  report(true);
218
576
  await progress;
@@ -221,13 +579,22 @@ export class WorkflowAgentExecutor {
221
579
  }
222
580
  catch { /* Preserve the agent failure that prompted this cleanup. */ }
223
581
  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 });
582
+ const attemptAccounting = accounting(session.getSessionStats());
583
+ if (!budgetError && typed.code !== "BUDGET_EXHAUSTED") {
584
+ try {
585
+ options.budget?.afterTurn(attemptAccounting, true);
586
+ }
587
+ catch (budgetFailure) {
588
+ budgetError ??= budgetFailure instanceof WorkflowError ? budgetFailure : new WorkflowError("BUDGET_EXHAUSTED", budgetFailure instanceof Error ? budgetFailure.message : String(budgetFailure));
589
+ }
590
+ }
591
+ const includeFailedSetup = Boolean(this.root.agentSetupHooks?.length || setup?.sessionInput.resourcePolicy);
592
+ attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), error: { code: typed.code, message: typed.message }, accounting: attemptAccounting, ...(includeFailedSetup && setupSummary ? { setup: setupSummary } : {}) });
226
593
  session.dispose();
227
594
  }
228
595
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED")
229
596
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
230
- if (attempt === maxAttempts || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED")
597
+ if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE")
231
598
  throw Object.assign(typed, { attempts });
232
599
  beforeRetry?.();
233
600
  }
@@ -253,12 +620,12 @@ export class FairAgentScheduler {
253
620
  if (!Number.isInteger(sessionLimit) || sessionLimit < 1 || sessionLimit > 16)
254
621
  throw new WorkflowError("INVALID_SETTINGS", "Session concurrency must be an integer from 1 to 16");
255
622
  }
256
- addRun(runId, limit = 8, maxAgentLaunches = 1000) {
623
+ addRun(runId, limit = 8, beforeLaunch) {
257
624
  if (this.#runs.has(runId))
258
625
  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: [] });
626
+ if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit)
627
+ throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency");
628
+ this.#runs.set(runId, { limit, ...(beforeLaunch ? { beforeLaunch } : {}), logical: 0, active: 0, queue: [] });
262
629
  this.#runOrder.push(runId);
263
630
  }
264
631
  spawn(runId, prompt, options, parentId) {
@@ -269,10 +636,6 @@ export class FairAgentScheduler {
269
636
  if (parentId && (!parent || parent.runId !== runId))
270
637
  throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Parent agent is not owned by this run");
271
638
  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
639
  const id = `${runId}:${String(++this.#nextId)}`;
277
640
  let resolveResult = () => undefined;
278
641
  const promise = new Promise((resolve) => { resolveResult = resolve; });
@@ -296,7 +659,7 @@ export class FairAgentScheduler {
296
659
  this.#nodes.set(id, node);
297
660
  parent?.children.add(id);
298
661
  this.#persist(runId);
299
- this.#enqueue(runId, () => { void node.task(); });
662
+ this.#enqueue(runId, node, () => { void node.task(); });
300
663
  return { id, result: promise };
301
664
  }
302
665
  async result(parentId, childId) {
@@ -309,7 +672,7 @@ export class FairAgentScheduler {
309
672
  this.#persist(parent.runId);
310
673
  this.#release(parent.runId);
311
674
  const outcome = await child.promise;
312
- await new Promise((resolve) => { this.#enqueue(parent.runId, () => { resolve(); }); });
675
+ await new Promise((resolve) => { this.#enqueue(parent.runId, undefined, () => { resolve(); }); });
313
676
  parent.state = "running";
314
677
  if (parent.controller.signal.aborted)
315
678
  throw new WorkflowError("CANCELLED", "Parent agent cancelled");
@@ -334,6 +697,20 @@ export class FairAgentScheduler {
334
697
  this.#cancelTree(child);
335
698
  }
336
699
  }
700
+ retry(id) {
701
+ const node = this.#node(id);
702
+ if (node.state === "running") {
703
+ node.state = "retrying";
704
+ this.#persist(node.runId);
705
+ }
706
+ }
707
+ attemptStarted(id) {
708
+ const node = this.#node(id);
709
+ if (node.state === "retrying") {
710
+ node.state = "running";
711
+ this.#persist(node.runId);
712
+ }
713
+ }
337
714
  async cancelRun(runId) {
338
715
  const run = this.#runs.get(runId);
339
716
  if (!run)
@@ -346,19 +723,23 @@ export class FairAgentScheduler {
346
723
  if (nodes.every(({ restored }) => restored))
347
724
  run.logical = 0;
348
725
  }
349
- /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
350
726
  toolsFor(parentId, resolveTools) {
351
727
  const parent = this.#node(parentId);
352
728
  if (!parent.options.tools.includes("agent"))
353
729
  return [];
354
730
  const agentTool = {
355
731
  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()])) }),
357
- execute: async (_id, params) => {
732
+ 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 }),
733
+ execute: async (_id, rawParams) => {
734
+ if (!isChildAgentToolParams(rawParams))
735
+ throw new WorkflowError("INVALID_METADATA", "Invalid child agent parameters");
736
+ const params = rawParams;
358
737
  if (params.role !== undefined && (params.model !== undefined || params.thinking !== undefined || params.tools !== undefined))
359
738
  throw new WorkflowError("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
360
739
  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 }) };
740
+ const agentOptions = { ...params };
741
+ Reflect.deleteProperty(agentOptions, "prompt");
742
+ 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
743
  const child = this.spawn(parent.runId, params.prompt, options, parentId);
363
744
  return { content: [{ type: "text", text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
364
745
  },
@@ -366,7 +747,8 @@ export class FairAgentScheduler {
366
747
  const resultTool = {
367
748
  name: "get_subagent_result", label: "Child Result", description: "Wait for a direct child and return its result",
368
749
  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 }; },
750
+ execute: async (_id, params) => { const value = await this.result(parentId, params.id); if (!value.ok && value.error.code === "BUDGET_EXHAUSTED")
751
+ throw new WorkflowError("BUDGET_EXHAUSTED", value.error.message); return { content: [{ type: "text", text: JSON.stringify(value) }], details: value }; },
370
752
  };
371
753
  const steerTool = {
372
754
  name: "steer_subagent", label: "Steer Child", description: "Steer a running direct child",
@@ -378,8 +760,8 @@ export class FairAgentScheduler {
378
760
  snapshot() {
379
761
  return [...this.#nodes.values()].map(({ id, parentId, options, state }) => ({ id, ...(parentId ? { parentId } : {}), label: options.label, state, options }));
380
762
  }
381
- restoreRun(runId, limit, maxAgentLaunches, ownership) {
382
- this.addRun(runId, limit, maxAgentLaunches);
763
+ restoreRun(runId, limit, ownership, beforeLaunch) {
764
+ this.addRun(runId, limit, beforeLaunch);
383
765
  const run = this.#runs.get(runId);
384
766
  for (const record of ownership) {
385
767
  if (record.id.split(":").slice(0, -1).join(":") !== runId)
@@ -401,22 +783,20 @@ export class FairAgentScheduler {
401
783
  }
402
784
  async flush() { await this.#persistence; }
403
785
  #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
786
  if (!options.label.trim() || !options.cwd || !Array.isArray(options.tools))
408
787
  throw new WorkflowError("INVALID_METADATA", "Agents require label, cwd, and tools");
788
+ const inheritedTools = options.tools;
409
789
  if (!parent)
410
- return Object.freeze({ ...options, tools: Object.freeze([...options.tools]) });
790
+ return Object.freeze({ ...options, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
411
791
  if (options.cwd !== parent.options.cwd)
412
792
  throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
413
- const forbidden = options.tools.find((tool) => !parent.options.tools.includes(tool));
793
+ const forbidden = inheritedTools.find((tool) => !parent.options.tools.includes(tool));
414
794
  if (forbidden)
415
795
  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 } : {}) });
796
+ const identity = options.agentIdentity ?? parent.options.agentIdentity;
797
+ return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
417
798
  }
418
- /* 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(); }
799
+ #enqueue(runId, node, start) { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
420
800
  #dispatch() {
421
801
  while (this.#active < this.sessionLimit && this.#runOrder.length) {
422
802
  let selected;
@@ -433,10 +813,20 @@ export class FairAgentScheduler {
433
813
  if (!selected)
434
814
  return;
435
815
  const run = this.#runs.get(selected);
436
- const start = run.queue.shift();
816
+ const item = run.queue.shift();
817
+ if (item.node) {
818
+ try {
819
+ run.beforeLaunch?.();
820
+ }
821
+ catch (error) {
822
+ const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
823
+ this.#settle(item.node, { id: item.node.id, ok: false, error: { code: typed.code, message: typed.message } });
824
+ continue;
825
+ }
826
+ }
437
827
  run.active += 1;
438
828
  this.#active += 1;
439
- start();
829
+ item.start();
440
830
  }
441
831
  }
442
832
  #release(runId) {
@@ -450,7 +840,7 @@ export class FairAgentScheduler {
450
840
  #settle(node, result) {
451
841
  if (["completed", "failed", "cancelled"].includes(node.state))
452
842
  return;
453
- const heldPermit = node.state === "running";
843
+ const heldPermit = node.state === "running" || node.state === "retrying";
454
844
  node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
455
845
  this.#persist(node.runId);
456
846
  if (heldPermit)