pi-extensible-workflows 0.3.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/src/agent-execution.d.ts +81 -8
- package/dist/src/agent-execution.js +421 -71
- package/dist/src/doctor.d.ts +4 -1
- package/dist/src/doctor.js +56 -11
- package/dist/src/index.d.ts +217 -22
- package/dist/src/index.js +1416 -337
- package/dist/src/persistence.d.ts +26 -1
- package/dist/src/persistence.js +100 -6
- package/dist/src/session-inspector.d.ts +13 -1
- package/dist/src/session-inspector.js +22 -4
- package/dist/src/workflow-evals.js +1 -1
- package/package.json +1 -1
- package/skills/pi-extensible-workflows/SKILL.md +9 -5
- package/src/agent-execution.ts +350 -83
- package/src/doctor.ts +59 -12
- package/src/index.ts +1231 -360
- package/src/persistence.ts +76 -7
- package/src/session-inspector.ts +25 -7
- package/src/workflow-evals.ts +1 -1
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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 =
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
66
|
-
const
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
145
|
-
await
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
210
|
-
|
|
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.
|
|
225
|
-
|
|
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,
|
|
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
|
|
260
|
-
throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency
|
|
261
|
-
this.#runs.set(runId, { limit,
|
|
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
|
|
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);
|
|
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,
|
|
382
|
-
this.addRun(runId, limit,
|
|
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
|
-
|
|
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
|
|
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)
|