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.
- package/README.md +9 -0
- package/dist/src/agent-execution.d.ts +81 -8
- package/dist/src/agent-execution.js +465 -75
- package/dist/src/ambient-workflow-evals.js +14 -33
- package/dist/src/doctor.d.ts +4 -1
- package/dist/src/doctor.js +57 -14
- package/dist/src/index.d.ts +222 -24
- package/dist/src/index.js +1548 -410
- package/dist/src/persistence.d.ts +28 -1
- package/dist/src/persistence.js +126 -9
- package/dist/src/session-inspector.d.ts +13 -1
- package/dist/src/session-inspector.js +22 -4
- package/dist/src/workflow-evals.d.ts +1 -0
- package/dist/src/workflow-evals.js +23 -25
- package/package.json +5 -2
- package/skills/pi-extensible-workflows/SKILL.md +14 -6
- package/src/agent-execution.ts +391 -86
- package/src/ambient-workflow-evals.ts +18 -28
- package/src/doctor.ts +60 -16
- package/src/index.ts +1332 -395
- package/src/persistence.ts +98 -10
- package/src/session-inspector.ts +25 -7
- package/src/workflow-evals.ts +13 -16
|
@@ -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,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
|
|
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");
|
|
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
|
-
|
|
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
|
+
});
|
|
43
105
|
await resourceLoader.reload();
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
66
|
-
const
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
145
|
-
await
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
210
|
-
|
|
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.
|
|
225
|
-
|
|
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,
|
|
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
|
|
260
|
-
throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency
|
|
261
|
-
this.#runs.set(runId, { limit,
|
|
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,
|
|
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
|
|
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);
|
|
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,
|
|
382
|
-
this.addRun(runId, limit,
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|