pi-extensible-workflows 0.3.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- 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
package/src/agent-execution.ts
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
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, type AgentSessionEvent, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type AgentSessionEvent, type InlineExtension, type SessionStats, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
5
7
|
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
6
|
-
type AgentMessage = { role: string; content?: unknown; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
|
|
7
|
-
import type { JsonSchema, JsonValue, ModelSpec } from "./index.js";
|
|
8
|
-
import {
|
|
9
|
-
import type { RunStore } from "./persistence.js";
|
|
10
|
-
|
|
11
|
-
export interface
|
|
8
|
+
type AgentMessage = { role: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
|
|
9
|
+
import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./index.js";
|
|
10
|
+
import { mergeAgentResourceExclusions, resolveModelReference, WorkflowError } from "./index.js";
|
|
11
|
+
import type { ConversationHead, PersistedConversation, RunStore } from "./persistence.js";
|
|
12
|
+
|
|
13
|
+
export interface AgentBudgetHooks {
|
|
14
|
+
beforeAttempt(): void;
|
|
15
|
+
beforeTurn(): void;
|
|
16
|
+
afterTurn(accounting: AgentAccounting, final: boolean): void;
|
|
17
|
+
instruction(): string | undefined;
|
|
18
|
+
}
|
|
19
|
+
export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[]; disabledAgentResources?: AgentResourceExclusions }
|
|
12
20
|
export interface AgentExecutionOptions {
|
|
13
21
|
label: string;
|
|
14
22
|
workflowName: string;
|
|
@@ -17,7 +25,7 @@ export interface AgentExecutionOptions {
|
|
|
17
25
|
model?: string;
|
|
18
26
|
thinking?: ThinkingLevel;
|
|
19
27
|
onProgress?: (progress: AgentProgress) => void | Promise<void>;
|
|
20
|
-
onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile">) => void | Promise<void>;
|
|
28
|
+
onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => void | Promise<void>;
|
|
21
29
|
tools?: readonly string[];
|
|
22
30
|
effectiveTools?: readonly string[];
|
|
23
31
|
role?: string;
|
|
@@ -27,6 +35,10 @@ export interface AgentExecutionOptions {
|
|
|
27
35
|
retryState?: string;
|
|
28
36
|
worktreeOwner?: string;
|
|
29
37
|
cwd?: string;
|
|
38
|
+
budget?: AgentBudgetHooks;
|
|
39
|
+
agentOptions?: Readonly<Record<string, JsonValue>>;
|
|
40
|
+
agentIdentity?: AgentIdentity;
|
|
41
|
+
conversation?: { id: string; turn: number };
|
|
30
42
|
}
|
|
31
43
|
export interface AgentExecutionRoot {
|
|
32
44
|
cwd: string;
|
|
@@ -35,34 +47,50 @@ export interface AgentExecutionRoot {
|
|
|
35
47
|
agentDefinitions?: Readonly<Record<string, AgentDefinition>>;
|
|
36
48
|
agentDir?: string;
|
|
37
49
|
availableModels?: ReadonlySet<string>;
|
|
50
|
+
knownModels?: ReadonlySet<string>;
|
|
51
|
+
modelAliases?: Readonly<Record<string, string>>;
|
|
52
|
+
blockedAliases?: ReadonlySet<string>;
|
|
53
|
+
blockedAliasTargets?: Readonly<Record<string, string>>;
|
|
54
|
+
settingsPath?: string;
|
|
38
55
|
runStore?: RunStore;
|
|
39
56
|
providerPause?: () => Promise<void>;
|
|
57
|
+
agentSetupHooks?: readonly RegisteredAgentSetupHook[];
|
|
58
|
+
agentResourcePolicy?: () => AgentResourcePolicy | Promise<AgentResourcePolicy>;
|
|
59
|
+
runContext?: Readonly<WorkflowRunContext>;
|
|
40
60
|
}
|
|
41
61
|
export interface AgentAccounting { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }
|
|
42
62
|
export interface AgentToolCallProgress { id: string; name: string; state: "running" | "completed" | "failed" }
|
|
43
63
|
export interface AgentActivity { kind: "reasoning" | "tool" | "text"; text: string }
|
|
44
64
|
export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; activity?: AgentActivity; persist: boolean }
|
|
45
|
-
export interface AgentAttempt { attempt: number; sessionId: string; sessionFile: string; result?: JsonValue; error?: { code: string; message: string }; accounting: AgentAccounting }
|
|
65
|
+
export interface AgentAttempt { attempt: number; sessionId: string; sessionFile: string; result?: JsonValue; error?: { code: string; message: string }; accounting: AgentAccounting; setup?: AgentSetupSummary }
|
|
46
66
|
export interface AgentExecutionResult { value: JsonValue; attempts: readonly AgentAttempt[]; cwd: string }
|
|
47
|
-
|
|
67
|
+
export interface AgentSetup { prompt: string; options: Record<string, JsonValue>; sessionInput: SessionInput; createSession: SessionFactory }
|
|
68
|
+
export interface AgentSetupContext { readonly run: Readonly<WorkflowRunContext>; readonly identity: Readonly<AgentIdentity>; readonly attempt: number; readonly signal: AbortSignal }
|
|
69
|
+
export interface AgentSetupHook { priority?: number; setup: (agent: AgentSetup, context: Readonly<AgentSetupContext>) => void | Promise<void> }
|
|
70
|
+
export interface RegisteredAgentSetupHook { name: string; priority: number; setup: AgentSetupHook["setup"] }
|
|
71
|
+
type NativeSessionStats = Pick<SessionStats, "tokens" | "cost">;
|
|
48
72
|
export interface NativeSession {
|
|
49
73
|
readonly sessionId: string;
|
|
50
74
|
readonly sessionFile: string | undefined;
|
|
51
75
|
readonly messages: readonly AgentMessage[];
|
|
76
|
+
getSessionStats(): NativeSessionStats;
|
|
52
77
|
readonly systemPrompt?: string;
|
|
78
|
+
readonly model?: { provider: string; model?: string; id?: string };
|
|
53
79
|
readonly agent?: { state: { tools: readonly { name: string }[] } };
|
|
80
|
+
getLeafId?: () => string | null;
|
|
81
|
+
getToolDefinitions?: () => unknown;
|
|
54
82
|
subscribe?(listener: (event: AgentSessionEvent) => void): () => void;
|
|
55
83
|
prompt(text: string): Promise<void>;
|
|
56
84
|
steer?(text: string): Promise<void>;
|
|
57
85
|
abort?(): Promise<void>;
|
|
58
86
|
dispose(): void;
|
|
59
87
|
}
|
|
60
|
-
export interface SessionInput { cwd: string; model: ModelSpec; tools:
|
|
88
|
+
export interface SessionInput { cwd: string; model: ModelSpec; tools: string[]; sessionLabel: string; agentDir?: string; customTools?: ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string; extensionFactories?: InlineExtension[]; resourcePolicy?: AgentResourcePolicy; options?: Record<string, JsonValue>; continuation?: { sessionId: string; sessionFile: string; leafId: string } }
|
|
61
89
|
export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
|
|
62
90
|
|
|
63
|
-
function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel): ModelSpec {
|
|
91
|
+
function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
|
|
64
92
|
if (!value) return { ...fallback, ...(thinking ? { thinking } : {}) };
|
|
65
|
-
const parsed =
|
|
93
|
+
const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
|
|
66
94
|
return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
|
|
67
95
|
}
|
|
68
96
|
function modelCapability(model: ModelSpec): string { return `${model.provider}/${model.model}`; }
|
|
@@ -73,39 +101,165 @@ function text(messages: readonly AgentMessage[]): string {
|
|
|
73
101
|
return message.content.filter((part: unknown): part is { type: "text"; text: string } => typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string").map((part) => part.text).join("");
|
|
74
102
|
}
|
|
75
103
|
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
function hasToolCall(message: unknown): boolean {
|
|
105
|
+
return typeof message === "object" && message !== null && Array.isArray((message as { content?: unknown }).content) && (message as { content: unknown[] }).content.some((part) => typeof part === "object" && part !== null && (part as { type?: unknown }).type === "toolCall");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function latestAssistantHasToolCall(messages: readonly AgentMessage[]): boolean {
|
|
109
|
+
const message = [...messages].reverse().find((item) => item.role === "assistant");
|
|
110
|
+
return hasToolCall(message);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function throwIfTerminalAssistantError(session: NativeSession): void {
|
|
114
|
+
const message = [...session.messages].reverse().find((item) => item.role === "assistant");
|
|
115
|
+
if (message?.stopReason === "error") throw new WorkflowError("AGENT_FAILED", message.errorMessage ?? "Native Pi assistant ended with a terminal provider error");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function accounting(stats: NativeSessionStats): AgentAccounting {
|
|
119
|
+
return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
|
|
86
120
|
}
|
|
121
|
+
function canonicalSourcePath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
|
|
87
122
|
|
|
88
123
|
export async function createNativeAgentSession(input: SessionInput): Promise<NativeSession> {
|
|
89
124
|
const agentDir = input.agentDir ?? getAgentDir();
|
|
90
|
-
|
|
91
|
-
|
|
125
|
+
let manager: SessionManager;
|
|
126
|
+
if (input.continuation) {
|
|
127
|
+
try {
|
|
128
|
+
manager = SessionManager.open(input.continuation.sessionFile, input.agentDir ? join(agentDir, "sessions") : undefined, input.cwd);
|
|
129
|
+
const header = manager.getHeader();
|
|
130
|
+
if (!header || canonicalSourcePath(header.cwd) !== canonicalSourcePath(input.cwd) || manager.getSessionId() !== input.continuation.sessionId || !manager.getEntry(input.continuation.leafId)) throw new Error("Persisted transcript identity does not match the conversation head");
|
|
131
|
+
manager.branch(input.continuation.leafId);
|
|
132
|
+
const context = manager.buildSessionContext();
|
|
133
|
+
if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) throw new Error("Persisted transcript model does not match the conversation execution policy");
|
|
134
|
+
if (input.model.thinking && context.thinkingLevel !== input.model.thinking) throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE") throw error;
|
|
137
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot reopen conversation transcript: ${error instanceof Error ? error.message : String(error)}`);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
|
|
141
|
+
manager.appendSessionInfo(input.sessionLabel);
|
|
142
|
+
}
|
|
92
143
|
const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
|
|
93
144
|
const model = modelRuntime.getModel(input.model.provider, input.model.model);
|
|
94
145
|
if (!model) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${input.model.provider}/${input.model.model}`);
|
|
95
146
|
const customTools = [...(input.customTools ?? []), ...(input.resultTool ? [input.resultTool] : [])];
|
|
96
147
|
const tools = [...new Set([...input.tools, ...customTools.map(({ name }) => name)])];
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
148
|
+
let settingsManager: SettingsManager | undefined;
|
|
149
|
+
let resourceLoader: DefaultResourceLoader | undefined;
|
|
150
|
+
const policy = input.resourcePolicy;
|
|
151
|
+
if (policy) {
|
|
152
|
+
settingsManager = SettingsManager.create(input.cwd, agentDir, { projectTrusted: false });
|
|
153
|
+
settingsManager.setProjectTrusted(policy.projectTrusted);
|
|
154
|
+
const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager });
|
|
155
|
+
const resolved = await packageManager.resolve();
|
|
156
|
+
const disabledExtensions = new Set(policy.effective.extensions);
|
|
157
|
+
const extensionPaths = [...new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)).filter((path) => !disabledExtensions.has(canonicalSourcePath(path))))];
|
|
158
|
+
const skillPaths = [...new Set(resolved.skills.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => path))];
|
|
159
|
+
const updateSkillMatches = (skills: readonly { name: string }[]) => {
|
|
160
|
+
const names = new Set(skills.map(({ name }) => name));
|
|
161
|
+
Object.assign(policy, { unmatchedSkills: policy.effective.skills.filter((name) => !names.has(name)) });
|
|
162
|
+
};
|
|
163
|
+
const disabledSkills = new Set(policy.effective.skills);
|
|
164
|
+
resourceLoader = new DefaultResourceLoader({
|
|
165
|
+
cwd: input.cwd,
|
|
166
|
+
agentDir,
|
|
167
|
+
settingsManager,
|
|
168
|
+
noExtensions: true,
|
|
169
|
+
additionalExtensionPaths: extensionPaths,
|
|
170
|
+
noSkills: true,
|
|
171
|
+
additionalSkillPaths: skillPaths,
|
|
172
|
+
...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}),
|
|
173
|
+
skillsOverride: (base) => {
|
|
174
|
+
updateSkillMatches(base.skills);
|
|
175
|
+
return { ...base, skills: base.skills.filter(({ name }) => !disabledSkills.has(name)) };
|
|
176
|
+
},
|
|
177
|
+
...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}),
|
|
178
|
+
});
|
|
179
|
+
await resourceLoader.reload();
|
|
180
|
+
const discoveredExtensions = new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)));
|
|
181
|
+
Object.assign(policy, { unmatchedExtensions: policy.effective.extensions.filter((path) => !discoveredExtensions.has(canonicalSourcePath(path))) });
|
|
182
|
+
} else if (input.systemPromptAppend || input.extensionFactories?.length) {
|
|
183
|
+
resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
|
|
184
|
+
await resourceLoader.reload();
|
|
185
|
+
}
|
|
186
|
+
const { session, modelFallbackMessage } = await createAgentSession({ ...(input.options ?? {}), cwd: input.cwd, agentDir, modelRuntime, model, ...(settingsManager ? { settingsManager } : {}), ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(resourceLoader ? { resourceLoader } : {}), sessionManager: manager });
|
|
187
|
+
if (input.continuation && modelFallbackMessage) throw new WorkflowError("RESUME_INCOMPATIBLE", modelFallbackMessage);
|
|
188
|
+
return Object.assign(session, {
|
|
189
|
+
getLeafId: () => manager.getLeafId(),
|
|
190
|
+
getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
|
|
191
|
+
}) as unknown as NativeSession;
|
|
192
|
+
}
|
|
193
|
+
function changedOption(options: Readonly<Record<string, JsonValue>>, baseline: Readonly<Record<string, JsonValue>>, key: string): boolean { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
|
|
194
|
+
function validThinking(value: unknown): value is ThinkingLevel { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
|
|
195
|
+
function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionOptions, signal: AbortSignal): { run: Readonly<WorkflowRunContext>; identity: Readonly<AgentIdentity> } {
|
|
196
|
+
const identity = options.agentIdentity ?? { structuralPath: [], callSite: options.label, occurrence: 1 };
|
|
197
|
+
const run = root.runContext ?? Object.freeze({ cwd: root.cwd, sessionId: "", runId: "", workflow: Object.freeze({ name: options.workflowName }), args: null, signal });
|
|
198
|
+
return { run, identity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) };
|
|
199
|
+
}
|
|
200
|
+
function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
|
|
201
|
+
return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
|
|
202
|
+
}
|
|
203
|
+
function canonicalJson(value: unknown): unknown {
|
|
204
|
+
if (Array.isArray(value)) return value.map((item) => canonicalJson(item));
|
|
205
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
function fingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex"); }
|
|
209
|
+
function promptFingerprint(value: string): string { return createHash("sha256").update(value).digest("hex"); }
|
|
210
|
+
function fixedConversationOptions(options: Readonly<Record<string, JsonValue>>): JsonValue {
|
|
211
|
+
const fixedOptions = structuredClone(options) as Record<string, JsonValue>;
|
|
212
|
+
delete fixedOptions.timeoutMs;
|
|
213
|
+
delete fixedOptions.retries;
|
|
214
|
+
return fixedOptions;
|
|
215
|
+
}
|
|
216
|
+
function conversationExecutionPolicy(options: AgentExecutionOptions, setup: AgentSetup): JsonValue {
|
|
217
|
+
return structuredClone({
|
|
218
|
+
model: setup.sessionInput.model,
|
|
219
|
+
tools: [...setup.sessionInput.tools],
|
|
220
|
+
cwd: setup.sessionInput.cwd,
|
|
221
|
+
role: options.role ?? null,
|
|
222
|
+
worktreeOwner: options.worktreeOwner ?? null,
|
|
223
|
+
parent: options.parent ?? null,
|
|
224
|
+
systemPromptAppend: setup.sessionInput.systemPromptAppend ?? "",
|
|
225
|
+
options: fixedConversationOptions(setup.options),
|
|
226
|
+
resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
|
|
227
|
+
}) as unknown as JsonValue;
|
|
228
|
+
}
|
|
229
|
+
function conversationFailure(message: string): WorkflowError { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
|
|
230
|
+
async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined, continuation?: ConversationHead): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
|
|
231
|
+
const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
|
|
232
|
+
const baselineOptions = structuredClone(options.agentOptions ?? {});
|
|
233
|
+
const baseResourcePolicy = await root.agentResourcePolicy?.();
|
|
234
|
+
const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
|
|
235
|
+
const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
|
|
236
|
+
const sessionInput: SessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), systemPromptAppend: resolved.systemPromptAppend, ...(resourcePolicy ? { resourcePolicy } : {}), options: structuredClone(baselineOptions) };
|
|
237
|
+
const setup: AgentSetup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, createSession };
|
|
238
|
+
const base = fallbackSetupContext(root, options, setupSignal);
|
|
239
|
+
const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
|
|
240
|
+
const hookNames: string[] = [];
|
|
241
|
+
for (const hook of [...(root.agentSetupHooks ?? [])].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))) {
|
|
242
|
+
if (setupSignal.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
|
|
243
|
+
try { await hook.setup(setup, context); } catch (error) { if (setupSignal.reason !== undefined) throw new WorkflowError("CANCELLED", "Agent cancelled"); throw error; }
|
|
244
|
+
hookNames.push(hook.name);
|
|
245
|
+
if (setupSignal.reason !== undefined) throw new WorkflowError("CANCELLED", "Agent cancelled");
|
|
246
|
+
}
|
|
247
|
+
setup.sessionInput.options = setup.options;
|
|
248
|
+
if (changedOption(setup.options, baselineOptions, "model") && typeof setup.options.model === "string") setup.sessionInput.model = parseModel(setup.options.model, setup.sessionInput.model, changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking) ? setup.options.thinking : undefined, root.modelAliases, root.knownModels ?? root.availableModels, root.settingsPath);
|
|
249
|
+
if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking)) setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
|
|
250
|
+
if (changedOption(setup.options, baselineOptions, "tools") && Array.isArray(setup.options.tools) && setup.options.tools.every((tool) => typeof tool === "string")) setup.sessionInput.tools = [...setup.options.tools];
|
|
251
|
+
if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
|
|
252
|
+
if (continuation) setup.sessionInput.continuation = { sessionId: continuation.sessionId, sessionFile: continuation.sessionFile, leafId: continuation.leafId };
|
|
253
|
+
const model = setup.sessionInput.model;
|
|
254
|
+
const summary: AgentSetupSummary = { hookNames: [...hookNames], model: { provider: model.provider, model: model.model, ...(model.thinking ? { thinking: model.thinking } : {}) }, tools: [...setup.sessionInput.tools], cwd: setup.sessionInput.cwd, ...(setup.sessionInput.resourcePolicy ? { disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) } : {}) };
|
|
255
|
+
return { setup, summary };
|
|
101
256
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
257
|
|
|
105
258
|
export class WorkflowAgentExecutor {
|
|
106
259
|
constructor(private readonly root: AgentExecutionRoot, private readonly createSession: SessionFactory = createNativeAgentSession) {}
|
|
260
|
+
setRunContext(runContext: Readonly<WorkflowRunContext>): void { this.root.runContext = runContext; }
|
|
107
261
|
|
|
108
|
-
resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string } {
|
|
262
|
+
resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPromptAppend: string } {
|
|
109
263
|
const role = options.role;
|
|
110
264
|
const definition = role ? this.root.agentDefinitions?.[role] : undefined;
|
|
111
265
|
if (role && !definition) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown agent role: ${role}`);
|
|
@@ -113,13 +267,18 @@ export class WorkflowAgentExecutor {
|
|
|
113
267
|
const requested = options.tools !== undefined ? options.tools : definition?.tools !== undefined ? definition.tools : options.effectiveTools !== undefined ? options.effectiveTools : inheritedTools !== undefined ? inheritedTools : [...this.root.tools];
|
|
114
268
|
const forbidden = requested.find((tool) => !this.root.tools.has(tool));
|
|
115
269
|
if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${forbidden}`);
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
|
|
270
|
+
const requestedModel = options.model ?? definition?.model;
|
|
271
|
+
const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
|
|
272
|
+
if (requestedModel !== undefined && this.root.blockedAliases?.has(requestedModel) && !hasAlias) { const target = this.root.blockedAliasTargets?.[requestedModel]; throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`); }
|
|
273
|
+
const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
|
|
274
|
+
const model = parseModel(requestedModel, this.root.model, options.thinking ?? (aliasThinking === undefined ? definition?.thinking : undefined), this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
|
|
275
|
+
const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
|
|
276
|
+
if (!availableModels.has(modelCapability(model))) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
|
|
277
|
+
return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
|
|
120
278
|
}
|
|
121
279
|
|
|
122
280
|
async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
|
|
281
|
+
const executionSignal = signal ?? this.root.runContext?.signal;
|
|
123
282
|
if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0) throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
|
|
124
283
|
if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw new WorkflowError("INVALID_METADATA", "timeoutMs must be null or a positive integer");
|
|
125
284
|
const resolved = this.resolve(options);
|
|
@@ -142,12 +301,31 @@ export class WorkflowAgentExecutor {
|
|
|
142
301
|
if (options.cwd) throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
|
|
143
302
|
cwd = this.root.cwd;
|
|
144
303
|
}
|
|
304
|
+
let conversationRecord: PersistedConversation | undefined;
|
|
305
|
+
if (options.conversation) {
|
|
306
|
+
const store = this.root.runStore;
|
|
307
|
+
if (!store) throw conversationFailure("Conversation persistence is unavailable");
|
|
308
|
+
try { conversationRecord = await store.conversation(options.conversation.id); } catch (error) { throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
|
|
309
|
+
if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1) throw conversationFailure("Conversation turn must be a positive integer");
|
|
310
|
+
if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1) throw conversationFailure(`Conversation turn ${String(options.conversation.turn)} does not continue its persisted head`);
|
|
311
|
+
}
|
|
145
312
|
const attempts: AgentAttempt[] = [];
|
|
313
|
+
let conversationBaseline: { executionPolicy: JsonValue; toolDefinitionsSha256: string; systemPrompt?: string; systemPromptSha256?: string } | undefined;
|
|
146
314
|
const maxAttempts = (options.retries ?? 0) + 1;
|
|
147
315
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
148
|
-
|
|
316
|
+
options.budget?.beforeAttempt();
|
|
149
317
|
let accepted = false;
|
|
150
318
|
let schemaResult: JsonValue | undefined;
|
|
319
|
+
let session: NativeSession | undefined;
|
|
320
|
+
let setup: AgentSetup | undefined;
|
|
321
|
+
let setupSummary: AgentSetupSummary | undefined;
|
|
322
|
+
let setupFailed = false;
|
|
323
|
+
let budgetError: WorkflowError | undefined;
|
|
324
|
+
let turnStarted = false;
|
|
325
|
+
let conversationSystemPrompt = "";
|
|
326
|
+
let conversationToolDefinitionsSha256 = "";
|
|
327
|
+
let conversationMismatch: WorkflowError | undefined;
|
|
328
|
+
const conversationMismatchError = () => conversationMismatch ? new WorkflowError("RESUME_INCOMPATIBLE", conversationMismatch.message) : undefined;
|
|
151
329
|
const hasSchemaResult = () => schemaResult !== undefined;
|
|
152
330
|
const resultTool = options.schema ? {
|
|
153
331
|
name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
|
|
@@ -159,7 +337,6 @@ export class WorkflowAgentExecutor {
|
|
|
159
337
|
return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
|
|
160
338
|
},
|
|
161
339
|
} as ToolDefinition : undefined;
|
|
162
|
-
let session: NativeSession | undefined;
|
|
163
340
|
const toolCalls = new Map<string, AgentToolCallProgress>();
|
|
164
341
|
let activity: AgentActivity | undefined;
|
|
165
342
|
let progress = Promise.resolve();
|
|
@@ -173,20 +350,75 @@ export class WorkflowAgentExecutor {
|
|
|
173
350
|
};
|
|
174
351
|
const report = (persist: boolean) => {
|
|
175
352
|
if (!session || !options.onProgress) return;
|
|
176
|
-
const update = { accounting: accounting(session.
|
|
353
|
+
const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
|
|
177
354
|
progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
|
|
178
355
|
};
|
|
179
356
|
try {
|
|
180
|
-
|
|
181
|
-
await
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
357
|
+
setupFailed = true;
|
|
358
|
+
const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool, conversationRecord?.head);
|
|
359
|
+
setup = prepared.setup;
|
|
360
|
+
setupSummary = prepared.summary;
|
|
361
|
+
setupFailed = false;
|
|
362
|
+
if (executionSignal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
|
|
363
|
+
const started = Date.now();
|
|
364
|
+
session = await setup.createSession(setup.sessionInput);
|
|
365
|
+
if (setup.sessionInput.resourcePolicy) setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
|
|
366
|
+
if (options.conversation) {
|
|
367
|
+
conversationSystemPrompt = session.systemPrompt ?? "";
|
|
368
|
+
conversationToolDefinitionsSha256 = fingerprint(session.getToolDefinitions?.() ?? session.agent?.state.tools ?? []);
|
|
369
|
+
const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
|
|
370
|
+
if (conversationRecord) {
|
|
371
|
+
if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile) throw conversationFailure("Conversation transcript identity changed");
|
|
372
|
+
if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId) throw conversationFailure("Conversation transcript leaf identity changed");
|
|
373
|
+
if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationRecord.policy)) throw conversationFailure("Conversation execution policy changed");
|
|
374
|
+
if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt)) throw conversationFailure("Conversation system prompt changed");
|
|
375
|
+
if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
|
|
376
|
+
} else if (conversationBaseline) {
|
|
377
|
+
if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationBaseline.executionPolicy)) throw conversationFailure("Conversation execution policy changed");
|
|
378
|
+
if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
|
|
379
|
+
} else {
|
|
380
|
+
conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
|
|
381
|
+
}
|
|
382
|
+
if (!session.subscribe) {
|
|
383
|
+
const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
|
|
384
|
+
const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
|
|
385
|
+
if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) throw conversationFailure("Conversation system prompt changed");
|
|
386
|
+
if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
|
|
387
|
+
}
|
|
388
|
+
if (conversationRecord && (!session.model || session.model.provider !== setup.sessionInput.model.provider || (session.model.model ?? session.model.id) !== setup.sessionInput.model.model)) throw conversationFailure("Conversation model changed");
|
|
389
|
+
}
|
|
390
|
+
const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
|
|
391
|
+
await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
|
|
392
|
+
const activeSession = session;
|
|
393
|
+
unsubscribe = activeSession.subscribe?.((event) => {
|
|
394
|
+
if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
|
|
395
|
+
if (options.conversation) {
|
|
396
|
+
conversationSystemPrompt = session.systemPrompt;
|
|
397
|
+
const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
|
|
398
|
+
const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
|
|
399
|
+
if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) { conversationMismatch = conversationFailure("Conversation system prompt changed"); void session.abort?.(); }
|
|
400
|
+
if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
|
|
401
|
+
}
|
|
402
|
+
if (this.root.runStore) {
|
|
403
|
+
systemPromptTurn += 1;
|
|
404
|
+
const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
|
|
405
|
+
systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error: unknown) => { systemPromptWriteError ??= error; });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (event.type === "message_start" && event.message.role === "assistant") {
|
|
409
|
+
if (!turnStarted) { try { options.budget?.beforeTurn(); turnStarted = true; } catch (error) { budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error)); void session?.abort?.(); } }
|
|
410
|
+
activity = { kind: "text", text: "responding" }; report(false);
|
|
411
|
+
}
|
|
412
|
+
if (event.type === "message_end") {
|
|
413
|
+
activity = undefined;
|
|
414
|
+
if (event.message.role === "assistant") {
|
|
415
|
+
const needsMoreWork = hasToolCall(event.message);
|
|
416
|
+
const final = !needsMoreWork || (options.schema !== undefined && accepted);
|
|
417
|
+
if (!budgetError) { try { options.budget?.afterTurn(accounting(activeSession.getSessionStats()), final); if (!final) { const instruction = options.budget?.instruction(); if (instruction) void session?.steer?.(instruction); } } catch (error) { budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error)); void session?.abort?.(); } }
|
|
418
|
+
turnStarted = false;
|
|
419
|
+
report(true);
|
|
420
|
+
}
|
|
187
421
|
}
|
|
188
|
-
if (event.type === "message_start" && event.message.role === "assistant") { activity = { kind: "text", text: "responding" }; report(false); }
|
|
189
|
-
if (event.type === "message_end") { activity = undefined; if (event.message.role === "assistant") report(true); }
|
|
190
422
|
if (event.type === "tool_execution_start") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" }); activity = { kind: "tool", text: event.toolName }; report(false); }
|
|
191
423
|
if (event.type === "tool_execution_end") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: event.isError ? "failed" : "completed" }); if (activity?.kind === "tool" && activity.text === event.toolName) activity = undefined; report(false); }
|
|
192
424
|
});
|
|
@@ -196,40 +428,60 @@ export class WorkflowAgentExecutor {
|
|
|
196
428
|
setSteer((message) => session?.steer?.(message));
|
|
197
429
|
}
|
|
198
430
|
const context = [`Workflow: ${options.workflowName}`, `Agent: ${options.label}`, options.phase ? `Phase: ${options.phase}` : "", options.parent ? `Parent: ${options.parent}` : "", "You own this task and any direct child agents you create. Return child results to your parent; do not leave descendants running.", attempt > 1 ? `Retry attempt ${String(attempt)}. Previous state: ${options.retryState ?? attempts.at(-1)?.error?.message ?? "failed attempt"}` : ""].filter(Boolean).join("\n");
|
|
199
|
-
|
|
431
|
+
const instruction = options.budget?.instruction();
|
|
432
|
+
const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
|
|
433
|
+
options.budget?.beforeTurn();
|
|
434
|
+
turnStarted = true;
|
|
435
|
+
await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
|
|
436
|
+
if (conversationMismatch) throw conversationMismatch;
|
|
437
|
+
throwIfTerminalAssistantError(session);
|
|
438
|
+
{ const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? false : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
|
|
439
|
+
if (budgetError) throw budgetError;
|
|
200
440
|
if (options.schema) {
|
|
201
441
|
accepted = true;
|
|
202
|
-
try { await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started),
|
|
203
|
-
|
|
442
|
+
try { options.budget?.beforeTurn(); turnStarted = true; await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
|
|
443
|
+
throwIfTerminalAssistantError(session);
|
|
204
444
|
if (!hasSchemaResult()) {
|
|
205
|
-
try { await promptWithProviderPause(session, "Your result was missing or invalid. Repair it by calling workflow_result exactly once with a schema-valid value.", remaining(options.timeoutMs, started),
|
|
206
|
-
|
|
445
|
+
try { options.budget?.beforeTurn(); turnStarted = true; await promptWithProviderPause(session, "Your result was missing or invalid. Repair it by calling workflow_result exactly once with a schema-valid value.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
|
|
446
|
+
throwIfTerminalAssistantError(session);
|
|
207
447
|
}
|
|
208
448
|
if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
|
|
209
449
|
}
|
|
450
|
+
const mismatch = conversationMismatchError();
|
|
451
|
+
if (mismatch) throw mismatch;
|
|
210
452
|
const value = options.schema ? schemaResult as JsonValue : text(session.messages);
|
|
211
453
|
if (options.worktreeOwner) await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
|
|
212
454
|
report(true);
|
|
213
455
|
await progress;
|
|
214
456
|
await flushSystemPrompts();
|
|
215
457
|
unsubscribe?.();
|
|
216
|
-
const attemptAccounting = accounting(session.
|
|
217
|
-
|
|
458
|
+
const attemptAccounting = accounting(session.getSessionStats());
|
|
459
|
+
const leafId = session.getLeafId?.() ?? undefined;
|
|
460
|
+
if (options.conversation) {
|
|
461
|
+
if (!leafId) throw conversationFailure("Conversation transcript has no persisted leaf");
|
|
462
|
+
const store = this.root.runStore;
|
|
463
|
+
if (!store) throw conversationFailure("Conversation persistence is unavailable");
|
|
464
|
+
await store.saveConversation({ id: options.conversation.id, policy: conversationExecutionPolicy(options, setup), head: { turn: options.conversation.turn, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), leafId, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt), toolDefinitionsSha256: conversationToolDefinitionsSha256 } });
|
|
465
|
+
}
|
|
466
|
+
const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
|
|
467
|
+
attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
|
|
218
468
|
session.dispose();
|
|
219
|
-
return { value, attempts, cwd };
|
|
469
|
+
return { value, attempts, cwd: setupSummary.cwd };
|
|
220
470
|
} catch (error) {
|
|
221
|
-
const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
|
|
471
|
+
const typed = budgetError ?? conversationMismatch ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
|
|
222
472
|
if (session) {
|
|
223
473
|
report(true);
|
|
224
474
|
await progress;
|
|
225
475
|
try { await flushSystemPrompts(); } catch { /* Preserve the agent failure that prompted this cleanup. */ }
|
|
226
476
|
unsubscribe?.();
|
|
227
|
-
const attemptAccounting = accounting(session.
|
|
228
|
-
|
|
477
|
+
const attemptAccounting = accounting(session.getSessionStats());
|
|
478
|
+
if (!budgetError && typed.code !== "BUDGET_EXHAUSTED") { try { options.budget?.afterTurn(attemptAccounting, true); } catch (budgetFailure) { budgetError ??= budgetFailure instanceof WorkflowError ? budgetFailure : new WorkflowError("BUDGET_EXHAUSTED", budgetFailure instanceof Error ? budgetFailure.message : String(budgetFailure)); } }
|
|
479
|
+
const includeFailedSetup = Boolean(this.root.agentSetupHooks?.length || setup?.sessionInput.resourcePolicy);
|
|
480
|
+
attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), error: { code: typed.code, message: typed.message }, accounting: attemptAccounting, ...(includeFailedSetup && setupSummary ? { setup: setupSummary } : {}) });
|
|
229
481
|
session.dispose();
|
|
230
482
|
}
|
|
231
483
|
if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED") await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
|
|
232
|
-
if (attempt === maxAttempts || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED") throw Object.assign(typed, { attempts });
|
|
484
|
+
if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE") throw Object.assign(typed, { attempts });
|
|
233
485
|
beforeRetry?.();
|
|
234
486
|
}
|
|
235
487
|
}
|
|
@@ -250,6 +502,9 @@ export interface ScheduledAgentOptions {
|
|
|
250
502
|
schema?: JsonSchema;
|
|
251
503
|
retries?: number;
|
|
252
504
|
timeoutMs?: number | null;
|
|
505
|
+
agentOptions?: Readonly<Record<string, JsonValue>>;
|
|
506
|
+
agentIdentity?: AgentIdentity;
|
|
507
|
+
conversation?: { id: string; turn: number };
|
|
253
508
|
}
|
|
254
509
|
|
|
255
510
|
export type ScheduledAgentResult =
|
|
@@ -275,7 +530,7 @@ type ScheduledNode = {
|
|
|
275
530
|
options: Readonly<ScheduledAgentOptions>;
|
|
276
531
|
children: Set<string>;
|
|
277
532
|
collected: boolean;
|
|
278
|
-
state: "queued" | "running" | "waiting_for_child" | "completed" | "failed" | "cancelled";
|
|
533
|
+
state: "queued" | "running" | "waiting_for_child" | "paused" | "retrying" | "completed" | "failed" | "cancelled";
|
|
279
534
|
controller: AbortController;
|
|
280
535
|
promise: Promise<ScheduledAgentResult>;
|
|
281
536
|
resolve: (result: ScheduledAgentResult) => void;
|
|
@@ -284,7 +539,7 @@ type ScheduledNode = {
|
|
|
284
539
|
steer?: (message: string) => void | Promise<void>;
|
|
285
540
|
};
|
|
286
541
|
|
|
287
|
-
type ScheduledRun = { limit: number;
|
|
542
|
+
type ScheduledRun = { limit: number; beforeLaunch?: () => void; logical: number; active: number; queue: Array<{ node?: ScheduledNode; start: () => void }> };
|
|
288
543
|
export type OwnershipRecord = { id: string; parentId?: string; label: string; state: ScheduledNode["state"]; options: Readonly<ScheduledAgentOptions> };
|
|
289
544
|
type OwnershipWriter = (runId: string, ownership: readonly OwnershipRecord[]) => void | Promise<void>;
|
|
290
545
|
|
|
@@ -301,10 +556,10 @@ export class FairAgentScheduler {
|
|
|
301
556
|
if (!Number.isInteger(sessionLimit) || sessionLimit < 1 || sessionLimit > 16) throw new WorkflowError("INVALID_SETTINGS", "Session concurrency must be an integer from 1 to 16");
|
|
302
557
|
}
|
|
303
558
|
|
|
304
|
-
addRun(runId: string, limit = 8,
|
|
559
|
+
addRun(runId: string, limit = 8, beforeLaunch?: () => void): void {
|
|
305
560
|
if (this.#runs.has(runId)) throw new WorkflowError("DUPLICATE_NAME", `Scheduler run already exists: ${runId}`);
|
|
306
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit
|
|
307
|
-
this.#runs.set(runId, { limit,
|
|
561
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit) throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency");
|
|
562
|
+
this.#runs.set(runId, { limit, ...(beforeLaunch ? { beforeLaunch } : {}), logical: 0, active: 0, queue: [] });
|
|
308
563
|
this.#runOrder.push(runId);
|
|
309
564
|
}
|
|
310
565
|
|
|
@@ -314,7 +569,6 @@ export class FairAgentScheduler {
|
|
|
314
569
|
const parent = parentId ? this.#nodes.get(parentId) : undefined;
|
|
315
570
|
if (parentId && (!parent || parent.runId !== runId)) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Parent agent is not owned by this run");
|
|
316
571
|
const effective = this.#inherit(parent, options);
|
|
317
|
-
if (++run.logical > run.maxAgentLaunches) { run.logical -= 1; throw new WorkflowError("RUN_LIMIT_EXCEEDED", `Run ${runId} exceeded maxAgentLaunches`); }
|
|
318
572
|
const id = `${runId}:${String(++this.#nextId)}`;
|
|
319
573
|
let resolveResult: (result: ScheduledAgentResult) => void = () => undefined;
|
|
320
574
|
const promise = new Promise<ScheduledAgentResult>((resolve) => { resolveResult = resolve; });
|
|
@@ -334,7 +588,7 @@ export class FairAgentScheduler {
|
|
|
334
588
|
this.#nodes.set(id, node);
|
|
335
589
|
parent?.children.add(id);
|
|
336
590
|
this.#persist(runId);
|
|
337
|
-
this.#enqueue(runId, () => { void node.task(); });
|
|
591
|
+
this.#enqueue(runId, node, () => { void node.task(); });
|
|
338
592
|
return { id, result: promise };
|
|
339
593
|
}
|
|
340
594
|
|
|
@@ -347,7 +601,7 @@ export class FairAgentScheduler {
|
|
|
347
601
|
this.#persist(parent.runId);
|
|
348
602
|
this.#release(parent.runId);
|
|
349
603
|
const outcome = await child.promise;
|
|
350
|
-
await new Promise<void>((resolve) => { this.#enqueue(parent.runId, () => { resolve(); }); });
|
|
604
|
+
await new Promise<void>((resolve) => { this.#enqueue(parent.runId, undefined, () => { resolve(); }); });
|
|
351
605
|
parent.state = "running";
|
|
352
606
|
if (parent.controller.signal.aborted) throw new WorkflowError("CANCELLED", "Parent agent cancelled");
|
|
353
607
|
this.#persist(parent.runId);
|
|
@@ -367,6 +621,15 @@ export class FairAgentScheduler {
|
|
|
367
621
|
cancelChildren(id: string): void {
|
|
368
622
|
for (const childId of this.#node(id).children) { const child = this.#nodes.get(childId); if (child) this.#cancelTree(child); }
|
|
369
623
|
}
|
|
624
|
+
retry(id: string): void {
|
|
625
|
+
const node = this.#node(id);
|
|
626
|
+
if (node.state === "running") { node.state = "retrying"; this.#persist(node.runId); }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
attemptStarted(id: string): void {
|
|
630
|
+
const node = this.#node(id);
|
|
631
|
+
if (node.state === "retrying") { node.state = "running"; this.#persist(node.runId); }
|
|
632
|
+
}
|
|
370
633
|
|
|
371
634
|
async cancelRun(runId: string): Promise<void> {
|
|
372
635
|
const run = this.#runs.get(runId);
|
|
@@ -383,11 +646,12 @@ export class FairAgentScheduler {
|
|
|
383
646
|
if (!parent.options.tools.includes("agent")) return [];
|
|
384
647
|
const agentTool = {
|
|
385
648
|
name: "agent", label: "Child Agent", description: "Start a direct child agent",
|
|
386
|
-
parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), role: Type.Optional(Type.String()), outputSchema: Type.Optional(Type.Unsafe<JsonSchema>({})), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }),
|
|
387
|
-
execute: async (_id: string, params: { prompt: string; label: string; tools?: string[]; model?: string; thinking?: ThinkingLevel; role?: string; outputSchema?: JsonSchema; retries?: number; timeoutMs?: number | null }) => {
|
|
649
|
+
parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), role: Type.Optional(Type.String()), outputSchema: Type.Optional(Type.Unsafe<JsonSchema>({})), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
|
|
650
|
+
execute: async (_id: string, params: { prompt: string; label: string; tools?: string[]; model?: string; thinking?: ThinkingLevel; role?: string; outputSchema?: JsonSchema; retries?: number; timeoutMs?: number | null; [key: string]: unknown }) => {
|
|
388
651
|
if (params.role !== undefined && (params.model !== undefined || params.thinking !== undefined || params.tools !== undefined)) throw new WorkflowError("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
389
652
|
const tools = (params.tools !== undefined || params.role !== undefined ? resolveTools?.(params.role, params.tools, params.model, parent.options.tools, params.thinking) : undefined) ?? params.tools ?? parent.options.tools;
|
|
390
|
-
const
|
|
653
|
+
const agentOptions = Object.fromEntries(Object.entries(params).filter(([key]) => key !== "prompt")) as Record<string, JsonValue>;
|
|
654
|
+
const options: ScheduledAgentOptions = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, agentOptions, ...(params.model ? { model: params.model } : {}), ...(params.thinking ? { thinking: params.thinking } : {}), ...(params.role ? { role: params.role } : {}), ...(params.outputSchema ? { schema: params.outputSchema } : {}), ...(params.retries === undefined ? {} : { retries: params.retries }), ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }) };
|
|
391
655
|
const child = this.spawn(parent.runId, params.prompt, options, parentId);
|
|
392
656
|
return { content: [{ type: "text" as const, text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
|
|
393
657
|
},
|
|
@@ -395,7 +659,7 @@ export class FairAgentScheduler {
|
|
|
395
659
|
const resultTool = {
|
|
396
660
|
name: "get_subagent_result", label: "Child Result", description: "Wait for a direct child and return its result",
|
|
397
661
|
parameters: Type.Object({ id: Type.String() }),
|
|
398
|
-
execute: async (_id: string, params: { id: string }) => { const value = await this.result(parentId, params.id); return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value }; },
|
|
662
|
+
execute: async (_id: string, params: { id: string }) => { const value = await this.result(parentId, params.id); if (!value.ok && value.error.code === "BUDGET_EXHAUSTED") throw new WorkflowError("BUDGET_EXHAUSTED", value.error.message); return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value }; },
|
|
399
663
|
} as ToolDefinition;
|
|
400
664
|
const steerTool = {
|
|
401
665
|
name: "steer_subagent", label: "Steer Child", description: "Steer a running direct child",
|
|
@@ -409,8 +673,8 @@ export class FairAgentScheduler {
|
|
|
409
673
|
return [...this.#nodes.values()].map(({ id, parentId, options, state }) => ({ id, ...(parentId ? { parentId } : {}), label: options.label, state, options }));
|
|
410
674
|
}
|
|
411
675
|
|
|
412
|
-
restoreRun(runId: string, limit: number,
|
|
413
|
-
this.addRun(runId, limit,
|
|
676
|
+
restoreRun(runId: string, limit: number, ownership: readonly OwnershipRecord[], beforeLaunch?: () => void): void {
|
|
677
|
+
this.addRun(runId, limit, beforeLaunch);
|
|
414
678
|
const run = this.#runs.get(runId) as ScheduledRun;
|
|
415
679
|
for (const record of ownership) {
|
|
416
680
|
if (record.id.split(":").slice(0, -1).join(":") !== runId) throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted agent belongs to another run: ${record.id}`);
|
|
@@ -429,18 +693,17 @@ export class FairAgentScheduler {
|
|
|
429
693
|
async flush(): Promise<void> { await this.#persistence; }
|
|
430
694
|
|
|
431
695
|
#inherit(parent: ScheduledNode | undefined, options: ScheduledAgentOptions): Readonly<ScheduledAgentOptions> {
|
|
432
|
-
const unknown = Object.keys(options).find((key) => !["label", "requestedLabel", "parentBreadcrumb", "cwd", "tools", "worktreeOwner", "model", "thinking", "role", "schema", "retries", "timeoutMs"].includes(key));
|
|
433
|
-
if (unknown) throw new WorkflowError("INVALID_METADATA", `Unsupported child agent option: ${unknown}`);
|
|
434
696
|
if (!options.label.trim() || !options.cwd || !Array.isArray(options.tools)) throw new WorkflowError("INVALID_METADATA", "Agents require label, cwd, and tools");
|
|
435
|
-
if (!parent) return Object.freeze({ ...options, tools: Object.freeze([...options.tools]) });
|
|
697
|
+
if (!parent) return Object.freeze({ ...options, tools: Object.freeze([...options.tools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
|
|
436
698
|
if (options.cwd !== parent.options.cwd) throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
|
|
437
699
|
const forbidden = options.tools.find((tool) => !parent.options.tools.includes(tool));
|
|
438
700
|
if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
|
|
439
|
-
|
|
701
|
+
const identity = options.agentIdentity ?? parent.options.agentIdentity;
|
|
702
|
+
return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...options.tools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
|
|
440
703
|
}
|
|
441
704
|
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
|
|
442
705
|
|
|
443
|
-
#enqueue(runId: string, start: () => void): void { this.#runs.get(runId)?.queue.push(start); this.#dispatch(); }
|
|
706
|
+
#enqueue(runId: string, node: ScheduledNode | undefined, start: () => void): void { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
|
|
444
707
|
|
|
445
708
|
#dispatch(): void {
|
|
446
709
|
while (this.#active < this.sessionLimit && this.#runOrder.length) {
|
|
@@ -453,8 +716,12 @@ export class FairAgentScheduler {
|
|
|
453
716
|
}
|
|
454
717
|
if (!selected) return;
|
|
455
718
|
const run = this.#runs.get(selected) as ScheduledRun;
|
|
456
|
-
const
|
|
457
|
-
|
|
719
|
+
const item = run.queue.shift() as { node?: ScheduledNode; start: () => void };
|
|
720
|
+
if (item.node) {
|
|
721
|
+
try { run.beforeLaunch?.(); }
|
|
722
|
+
catch (error) { const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error)); this.#settle(item.node, { id: item.node.id, ok: false, error: { code: typed.code, message: typed.message } }); continue; }
|
|
723
|
+
}
|
|
724
|
+
run.active += 1; this.#active += 1; item.start();
|
|
458
725
|
}
|
|
459
726
|
}
|
|
460
727
|
|
|
@@ -465,7 +732,7 @@ export class FairAgentScheduler {
|
|
|
465
732
|
|
|
466
733
|
#settle(node: ScheduledNode, result: ScheduledAgentResult): void {
|
|
467
734
|
if (["completed", "failed", "cancelled"].includes(node.state)) return;
|
|
468
|
-
const heldPermit = node.state === "running";
|
|
735
|
+
const heldPermit = node.state === "running" || node.state === "retrying";
|
|
469
736
|
node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
|
|
470
737
|
this.#persist(node.runId);
|
|
471
738
|
if (heldPermit) this.#release(node.runId);
|