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
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,201 @@ 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
|
-
total.cost += message.usage.cost.total;
|
|
84
|
-
}
|
|
85
|
-
return total;
|
|
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);
|
|
86
111
|
}
|
|
87
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 };
|
|
120
|
+
}
|
|
121
|
+
function canonicalSourcePath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
|
|
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 jsonValue(value: unknown, seen = new Set<object>()): value is JsonValue {
|
|
196
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") return true;
|
|
197
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
198
|
+
if (typeof value !== "object" || seen.has(value)) return false;
|
|
199
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
|
|
200
|
+
const keys = Reflect.ownKeys(value);
|
|
201
|
+
if (keys.some((key) => typeof key !== "string")) return false;
|
|
202
|
+
seen.add(value);
|
|
203
|
+
const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => (value as Record<string, unknown>)[key as string])).every((item) => jsonValue(item, seen));
|
|
204
|
+
seen.delete(value);
|
|
205
|
+
return valid;
|
|
206
|
+
}
|
|
207
|
+
function jsonObject(value: unknown): value is Record<string, JsonValue> { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
208
|
+
interface ChildAgentToolParams {
|
|
209
|
+
prompt: string;
|
|
210
|
+
label: string;
|
|
211
|
+
tools?: string[];
|
|
212
|
+
model?: string;
|
|
213
|
+
thinking?: ThinkingLevel;
|
|
214
|
+
role?: string;
|
|
215
|
+
outputSchema?: JsonSchema;
|
|
216
|
+
retries?: number;
|
|
217
|
+
timeoutMs?: number | null;
|
|
218
|
+
[key: string]: unknown;
|
|
219
|
+
}
|
|
220
|
+
function isChildAgentToolParams(value: unknown): value is ChildAgentToolParams & Record<string, JsonValue> {
|
|
221
|
+
if (!jsonObject(value) || typeof value.prompt !== "string" || typeof value.label !== "string") return false;
|
|
222
|
+
if (value.tools !== undefined && (!Array.isArray(value.tools) || value.tools.some((tool) => typeof tool !== "string"))) return false;
|
|
223
|
+
if (value.model !== undefined && typeof value.model !== "string") return false;
|
|
224
|
+
if (value.thinking !== undefined && !validThinking(value.thinking)) return false;
|
|
225
|
+
if (value.role !== undefined && typeof value.role !== "string") return false;
|
|
226
|
+
if (value.outputSchema !== undefined && !jsonObject(value.outputSchema)) return false;
|
|
227
|
+
if (value.retries !== undefined && (typeof value.retries !== "number" || !Number.isInteger(value.retries) || value.retries < 0)) return false;
|
|
228
|
+
if (value.timeoutMs !== undefined && (value.timeoutMs !== null && (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs < 1))) return false;
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionOptions, signal: AbortSignal): { run: Readonly<WorkflowRunContext>; identity: Readonly<AgentIdentity> } {
|
|
232
|
+
const identity = options.agentIdentity ?? { structuralPath: [], callSite: options.label, occurrence: 1 };
|
|
233
|
+
const run = root.runContext ?? Object.freeze({ cwd: root.cwd, sessionId: "", runId: "", workflow: Object.freeze({ name: options.workflowName }), args: null, signal });
|
|
234
|
+
return { run, identity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) };
|
|
235
|
+
}
|
|
236
|
+
function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
|
|
237
|
+
return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
|
|
238
|
+
}
|
|
239
|
+
function canonicalJson(value: unknown): unknown {
|
|
240
|
+
if (Array.isArray(value)) return value.map((item) => canonicalJson(item));
|
|
241
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
function fingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex"); }
|
|
245
|
+
function promptFingerprint(value: string): string { return createHash("sha256").update(value).digest("hex"); }
|
|
246
|
+
function fixedConversationOptions(options: Readonly<Record<string, JsonValue>>): JsonValue {
|
|
247
|
+
const fixedOptions = structuredClone(options) as Record<string, JsonValue>;
|
|
248
|
+
delete fixedOptions.timeoutMs;
|
|
249
|
+
delete fixedOptions.retries;
|
|
250
|
+
return fixedOptions;
|
|
251
|
+
}
|
|
252
|
+
function conversationExecutionPolicy(options: AgentExecutionOptions, setup: AgentSetup): JsonValue {
|
|
253
|
+
return structuredClone({
|
|
254
|
+
model: setup.sessionInput.model,
|
|
255
|
+
tools: [...setup.sessionInput.tools],
|
|
256
|
+
cwd: setup.sessionInput.cwd,
|
|
257
|
+
role: options.role ?? null,
|
|
258
|
+
worktreeOwner: options.worktreeOwner ?? null,
|
|
259
|
+
parent: options.parent ?? null,
|
|
260
|
+
systemPromptAppend: setup.sessionInput.systemPromptAppend ?? "",
|
|
261
|
+
options: fixedConversationOptions(setup.options),
|
|
262
|
+
resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
|
|
263
|
+
}) as unknown as JsonValue;
|
|
264
|
+
}
|
|
265
|
+
function conversationFailure(message: string): WorkflowError { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
|
|
266
|
+
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 }> {
|
|
267
|
+
const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
|
|
268
|
+
const baselineOptions = structuredClone(options.agentOptions ?? {});
|
|
269
|
+
const baseResourcePolicy = await root.agentResourcePolicy?.();
|
|
270
|
+
const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
|
|
271
|
+
const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
|
|
272
|
+
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) };
|
|
273
|
+
const setup: AgentSetup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, createSession };
|
|
274
|
+
const base = fallbackSetupContext(root, options, setupSignal);
|
|
275
|
+
const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
|
|
276
|
+
const hookNames: string[] = [];
|
|
277
|
+
for (const hook of [...(root.agentSetupHooks ?? [])].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))) {
|
|
278
|
+
if (setupSignal.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
|
|
279
|
+
try { await hook.setup(setup, context); } catch (error) { if (setupSignal.reason !== undefined) throw new WorkflowError("CANCELLED", "Agent cancelled"); throw error; }
|
|
280
|
+
hookNames.push(hook.name);
|
|
281
|
+
if (setupSignal.reason !== undefined) throw new WorkflowError("CANCELLED", "Agent cancelled");
|
|
282
|
+
}
|
|
283
|
+
setup.sessionInput.options = setup.options;
|
|
284
|
+
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);
|
|
285
|
+
if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking)) setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
|
|
286
|
+
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];
|
|
287
|
+
if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
|
|
288
|
+
if (continuation) setup.sessionInput.continuation = { sessionId: continuation.sessionId, sessionFile: continuation.sessionFile, leafId: continuation.leafId };
|
|
289
|
+
const model = setup.sessionInput.model;
|
|
290
|
+
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) } : {}) };
|
|
291
|
+
return { setup, summary };
|
|
101
292
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
293
|
|
|
105
294
|
export class WorkflowAgentExecutor {
|
|
106
295
|
constructor(private readonly root: AgentExecutionRoot, private readonly createSession: SessionFactory = createNativeAgentSession) {}
|
|
296
|
+
setRunContext(runContext: Readonly<WorkflowRunContext>): void { this.root.runContext = runContext; }
|
|
107
297
|
|
|
108
|
-
resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string } {
|
|
298
|
+
resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPromptAppend: string } {
|
|
109
299
|
const role = options.role;
|
|
110
300
|
const definition = role ? this.root.agentDefinitions?.[role] : undefined;
|
|
111
301
|
if (role && !definition) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown agent role: ${role}`);
|
|
@@ -113,13 +303,18 @@ export class WorkflowAgentExecutor {
|
|
|
113
303
|
const requested = options.tools !== undefined ? options.tools : definition?.tools !== undefined ? definition.tools : options.effectiveTools !== undefined ? options.effectiveTools : inheritedTools !== undefined ? inheritedTools : [...this.root.tools];
|
|
114
304
|
const forbidden = requested.find((tool) => !this.root.tools.has(tool));
|
|
115
305
|
if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${forbidden}`);
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
|
|
306
|
+
const requestedModel = options.model ?? definition?.model;
|
|
307
|
+
const hasAlias = requestedModel !== undefined && Object.prototype.hasOwnProperty.call(this.root.modelAliases ?? {}, requestedModel);
|
|
308
|
+
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})` : ""}`); }
|
|
309
|
+
const aliasThinking = requestedModel !== undefined && hasAlias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
|
|
310
|
+
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);
|
|
311
|
+
const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
|
|
312
|
+
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})` : ""}`);
|
|
313
|
+
return { model, ...(hasAlias ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
|
|
120
314
|
}
|
|
121
315
|
|
|
122
316
|
async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
|
|
317
|
+
const executionSignal = signal ?? this.root.runContext?.signal;
|
|
123
318
|
if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0) throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
|
|
124
319
|
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
320
|
const resolved = this.resolve(options);
|
|
@@ -142,12 +337,31 @@ export class WorkflowAgentExecutor {
|
|
|
142
337
|
if (options.cwd) throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
|
|
143
338
|
cwd = this.root.cwd;
|
|
144
339
|
}
|
|
340
|
+
let conversationRecord: PersistedConversation | undefined;
|
|
341
|
+
if (options.conversation) {
|
|
342
|
+
const store = this.root.runStore;
|
|
343
|
+
if (!store) throw conversationFailure("Conversation persistence is unavailable");
|
|
344
|
+
try { conversationRecord = await store.conversation(options.conversation.id); } catch (error) { throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
|
|
345
|
+
if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1) throw conversationFailure("Conversation turn must be a positive integer");
|
|
346
|
+
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`);
|
|
347
|
+
}
|
|
145
348
|
const attempts: AgentAttempt[] = [];
|
|
349
|
+
let conversationBaseline: { executionPolicy: JsonValue; toolDefinitionsSha256: string; systemPrompt?: string; systemPromptSha256?: string } | undefined;
|
|
146
350
|
const maxAttempts = (options.retries ?? 0) + 1;
|
|
147
351
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
148
|
-
|
|
352
|
+
options.budget?.beforeAttempt();
|
|
149
353
|
let accepted = false;
|
|
150
354
|
let schemaResult: JsonValue | undefined;
|
|
355
|
+
let session: NativeSession | undefined;
|
|
356
|
+
let setup: AgentSetup | undefined;
|
|
357
|
+
let setupSummary: AgentSetupSummary | undefined;
|
|
358
|
+
let setupFailed = false;
|
|
359
|
+
let budgetError: WorkflowError | undefined;
|
|
360
|
+
let turnStarted = false;
|
|
361
|
+
let conversationSystemPrompt = "";
|
|
362
|
+
let conversationToolDefinitionsSha256 = "";
|
|
363
|
+
let conversationMismatch: WorkflowError | undefined;
|
|
364
|
+
const conversationMismatchError = () => conversationMismatch ? new WorkflowError("RESUME_INCOMPATIBLE", conversationMismatch.message) : undefined;
|
|
151
365
|
const hasSchemaResult = () => schemaResult !== undefined;
|
|
152
366
|
const resultTool = options.schema ? {
|
|
153
367
|
name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
|
|
@@ -159,7 +373,6 @@ export class WorkflowAgentExecutor {
|
|
|
159
373
|
return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
|
|
160
374
|
},
|
|
161
375
|
} as ToolDefinition : undefined;
|
|
162
|
-
let session: NativeSession | undefined;
|
|
163
376
|
const toolCalls = new Map<string, AgentToolCallProgress>();
|
|
164
377
|
let activity: AgentActivity | undefined;
|
|
165
378
|
let progress = Promise.resolve();
|
|
@@ -173,20 +386,75 @@ export class WorkflowAgentExecutor {
|
|
|
173
386
|
};
|
|
174
387
|
const report = (persist: boolean) => {
|
|
175
388
|
if (!session || !options.onProgress) return;
|
|
176
|
-
const update = { accounting: accounting(session.
|
|
389
|
+
const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
|
|
177
390
|
progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
|
|
178
391
|
};
|
|
179
392
|
try {
|
|
180
|
-
|
|
181
|
-
await
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
393
|
+
setupFailed = true;
|
|
394
|
+
const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool, conversationRecord?.head);
|
|
395
|
+
setup = prepared.setup;
|
|
396
|
+
setupSummary = prepared.summary;
|
|
397
|
+
setupFailed = false;
|
|
398
|
+
if (executionSignal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
|
|
399
|
+
const started = Date.now();
|
|
400
|
+
session = await setup.createSession(setup.sessionInput);
|
|
401
|
+
if (setup.sessionInput.resourcePolicy) setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
|
|
402
|
+
if (options.conversation) {
|
|
403
|
+
conversationSystemPrompt = session.systemPrompt ?? "";
|
|
404
|
+
conversationToolDefinitionsSha256 = fingerprint(session.getToolDefinitions?.() ?? session.agent?.state.tools ?? []);
|
|
405
|
+
const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
|
|
406
|
+
if (conversationRecord) {
|
|
407
|
+
if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile) throw conversationFailure("Conversation transcript identity changed");
|
|
408
|
+
if (!session.getLeafId || session.getLeafId() !== conversationRecord.head.leafId) throw conversationFailure("Conversation transcript leaf identity changed");
|
|
409
|
+
if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationRecord.policy)) throw conversationFailure("Conversation execution policy changed");
|
|
410
|
+
if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt)) throw conversationFailure("Conversation system prompt changed");
|
|
411
|
+
if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
|
|
412
|
+
} else if (conversationBaseline) {
|
|
413
|
+
if (fingerprint(currentExecutionPolicy) !== fingerprint(conversationBaseline.executionPolicy)) throw conversationFailure("Conversation execution policy changed");
|
|
414
|
+
if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
|
|
415
|
+
} else {
|
|
416
|
+
conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
|
|
417
|
+
}
|
|
418
|
+
if (!session.subscribe) {
|
|
419
|
+
const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
|
|
420
|
+
const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
|
|
421
|
+
if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) throw conversationFailure("Conversation system prompt changed");
|
|
422
|
+
if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
|
|
423
|
+
}
|
|
424
|
+
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");
|
|
425
|
+
}
|
|
426
|
+
const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
|
|
427
|
+
await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
|
|
428
|
+
const activeSession = session;
|
|
429
|
+
unsubscribe = activeSession.subscribe?.((event) => {
|
|
430
|
+
if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
|
|
431
|
+
if (options.conversation) {
|
|
432
|
+
conversationSystemPrompt = session.systemPrompt;
|
|
433
|
+
const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
|
|
434
|
+
const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
|
|
435
|
+
if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) { conversationMismatch = conversationFailure("Conversation system prompt changed"); void session.abort?.(); }
|
|
436
|
+
if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
|
|
437
|
+
}
|
|
438
|
+
if (this.root.runStore) {
|
|
439
|
+
systemPromptTurn += 1;
|
|
440
|
+
const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
|
|
441
|
+
systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error: unknown) => { systemPromptWriteError ??= error; });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (event.type === "message_start" && event.message.role === "assistant") {
|
|
445
|
+
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?.(); } }
|
|
446
|
+
activity = { kind: "text", text: "responding" }; report(false);
|
|
447
|
+
}
|
|
448
|
+
if (event.type === "message_end") {
|
|
449
|
+
activity = undefined;
|
|
450
|
+
if (event.message.role === "assistant") {
|
|
451
|
+
const needsMoreWork = hasToolCall(event.message);
|
|
452
|
+
const final = !needsMoreWork || (options.schema !== undefined && accepted);
|
|
453
|
+
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?.(); } }
|
|
454
|
+
turnStarted = false;
|
|
455
|
+
report(true);
|
|
456
|
+
}
|
|
187
457
|
}
|
|
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
458
|
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
459
|
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
460
|
});
|
|
@@ -196,40 +464,60 @@ export class WorkflowAgentExecutor {
|
|
|
196
464
|
setSteer((message) => session?.steer?.(message));
|
|
197
465
|
}
|
|
198
466
|
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
|
-
|
|
467
|
+
const instruction = options.budget?.instruction();
|
|
468
|
+
const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
|
|
469
|
+
options.budget?.beforeTurn();
|
|
470
|
+
turnStarted = true;
|
|
471
|
+
await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
|
|
472
|
+
if (conversationMismatch) throw conversationMismatch;
|
|
473
|
+
throwIfTerminalAssistantError(session);
|
|
474
|
+
{ const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? false : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
|
|
475
|
+
if (budgetError) throw budgetError;
|
|
200
476
|
if (options.schema) {
|
|
201
477
|
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
|
-
|
|
478
|
+
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; }
|
|
479
|
+
throwIfTerminalAssistantError(session);
|
|
204
480
|
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
|
-
|
|
481
|
+
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; }
|
|
482
|
+
throwIfTerminalAssistantError(session);
|
|
207
483
|
}
|
|
208
484
|
if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
|
|
209
485
|
}
|
|
486
|
+
const mismatch = conversationMismatchError();
|
|
487
|
+
if (mismatch) throw mismatch;
|
|
210
488
|
const value = options.schema ? schemaResult as JsonValue : text(session.messages);
|
|
211
489
|
if (options.worktreeOwner) await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
|
|
212
490
|
report(true);
|
|
213
491
|
await progress;
|
|
214
492
|
await flushSystemPrompts();
|
|
215
493
|
unsubscribe?.();
|
|
216
|
-
const attemptAccounting = accounting(session.
|
|
217
|
-
|
|
494
|
+
const attemptAccounting = accounting(session.getSessionStats());
|
|
495
|
+
const leafId = session.getLeafId?.() ?? undefined;
|
|
496
|
+
if (options.conversation) {
|
|
497
|
+
if (!leafId) throw conversationFailure("Conversation transcript has no persisted leaf");
|
|
498
|
+
const store = this.root.runStore;
|
|
499
|
+
if (!store) throw conversationFailure("Conversation persistence is unavailable");
|
|
500
|
+
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 } });
|
|
501
|
+
}
|
|
502
|
+
const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
|
|
503
|
+
attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
|
|
218
504
|
session.dispose();
|
|
219
|
-
return { value, attempts, cwd };
|
|
505
|
+
return { value, attempts, cwd: setupSummary.cwd };
|
|
220
506
|
} catch (error) {
|
|
221
|
-
const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
|
|
507
|
+
const typed = budgetError ?? conversationMismatch ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
|
|
222
508
|
if (session) {
|
|
223
509
|
report(true);
|
|
224
510
|
await progress;
|
|
225
511
|
try { await flushSystemPrompts(); } catch { /* Preserve the agent failure that prompted this cleanup. */ }
|
|
226
512
|
unsubscribe?.();
|
|
227
|
-
const attemptAccounting = accounting(session.
|
|
228
|
-
|
|
513
|
+
const attemptAccounting = accounting(session.getSessionStats());
|
|
514
|
+
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)); } }
|
|
515
|
+
const includeFailedSetup = Boolean(this.root.agentSetupHooks?.length || setup?.sessionInput.resourcePolicy);
|
|
516
|
+
attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), error: { code: typed.code, message: typed.message }, accounting: attemptAccounting, ...(includeFailedSetup && setupSummary ? { setup: setupSummary } : {}) });
|
|
229
517
|
session.dispose();
|
|
230
518
|
}
|
|
231
519
|
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 });
|
|
520
|
+
if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE") throw Object.assign(typed, { attempts });
|
|
233
521
|
beforeRetry?.();
|
|
234
522
|
}
|
|
235
523
|
}
|
|
@@ -250,6 +538,9 @@ export interface ScheduledAgentOptions {
|
|
|
250
538
|
schema?: JsonSchema;
|
|
251
539
|
retries?: number;
|
|
252
540
|
timeoutMs?: number | null;
|
|
541
|
+
agentOptions?: Readonly<Record<string, JsonValue>>;
|
|
542
|
+
agentIdentity?: AgentIdentity;
|
|
543
|
+
conversation?: { id: string; turn: number };
|
|
253
544
|
}
|
|
254
545
|
|
|
255
546
|
export type ScheduledAgentResult =
|
|
@@ -275,7 +566,7 @@ type ScheduledNode = {
|
|
|
275
566
|
options: Readonly<ScheduledAgentOptions>;
|
|
276
567
|
children: Set<string>;
|
|
277
568
|
collected: boolean;
|
|
278
|
-
state: "queued" | "running" | "waiting_for_child" | "completed" | "failed" | "cancelled";
|
|
569
|
+
state: "queued" | "running" | "waiting_for_child" | "paused" | "retrying" | "completed" | "failed" | "cancelled";
|
|
279
570
|
controller: AbortController;
|
|
280
571
|
promise: Promise<ScheduledAgentResult>;
|
|
281
572
|
resolve: (result: ScheduledAgentResult) => void;
|
|
@@ -284,7 +575,7 @@ type ScheduledNode = {
|
|
|
284
575
|
steer?: (message: string) => void | Promise<void>;
|
|
285
576
|
};
|
|
286
577
|
|
|
287
|
-
type ScheduledRun = { limit: number;
|
|
578
|
+
type ScheduledRun = { limit: number; beforeLaunch?: () => void; logical: number; active: number; queue: Array<{ node?: ScheduledNode; start: () => void }> };
|
|
288
579
|
export type OwnershipRecord = { id: string; parentId?: string; label: string; state: ScheduledNode["state"]; options: Readonly<ScheduledAgentOptions> };
|
|
289
580
|
type OwnershipWriter = (runId: string, ownership: readonly OwnershipRecord[]) => void | Promise<void>;
|
|
290
581
|
|
|
@@ -301,10 +592,10 @@ export class FairAgentScheduler {
|
|
|
301
592
|
if (!Number.isInteger(sessionLimit) || sessionLimit < 1 || sessionLimit > 16) throw new WorkflowError("INVALID_SETTINGS", "Session concurrency must be an integer from 1 to 16");
|
|
302
593
|
}
|
|
303
594
|
|
|
304
|
-
addRun(runId: string, limit = 8,
|
|
595
|
+
addRun(runId: string, limit = 8, beforeLaunch?: () => void): void {
|
|
305
596
|
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,
|
|
597
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit) throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency");
|
|
598
|
+
this.#runs.set(runId, { limit, ...(beforeLaunch ? { beforeLaunch } : {}), logical: 0, active: 0, queue: [] });
|
|
308
599
|
this.#runOrder.push(runId);
|
|
309
600
|
}
|
|
310
601
|
|
|
@@ -314,7 +605,6 @@ export class FairAgentScheduler {
|
|
|
314
605
|
const parent = parentId ? this.#nodes.get(parentId) : undefined;
|
|
315
606
|
if (parentId && (!parent || parent.runId !== runId)) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Parent agent is not owned by this run");
|
|
316
607
|
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
608
|
const id = `${runId}:${String(++this.#nextId)}`;
|
|
319
609
|
let resolveResult: (result: ScheduledAgentResult) => void = () => undefined;
|
|
320
610
|
const promise = new Promise<ScheduledAgentResult>((resolve) => { resolveResult = resolve; });
|
|
@@ -334,7 +624,7 @@ export class FairAgentScheduler {
|
|
|
334
624
|
this.#nodes.set(id, node);
|
|
335
625
|
parent?.children.add(id);
|
|
336
626
|
this.#persist(runId);
|
|
337
|
-
this.#enqueue(runId, () => { void node.task(); });
|
|
627
|
+
this.#enqueue(runId, node, () => { void node.task(); });
|
|
338
628
|
return { id, result: promise };
|
|
339
629
|
}
|
|
340
630
|
|
|
@@ -347,7 +637,7 @@ export class FairAgentScheduler {
|
|
|
347
637
|
this.#persist(parent.runId);
|
|
348
638
|
this.#release(parent.runId);
|
|
349
639
|
const outcome = await child.promise;
|
|
350
|
-
await new Promise<void>((resolve) => { this.#enqueue(parent.runId, () => { resolve(); }); });
|
|
640
|
+
await new Promise<void>((resolve) => { this.#enqueue(parent.runId, undefined, () => { resolve(); }); });
|
|
351
641
|
parent.state = "running";
|
|
352
642
|
if (parent.controller.signal.aborted) throw new WorkflowError("CANCELLED", "Parent agent cancelled");
|
|
353
643
|
this.#persist(parent.runId);
|
|
@@ -367,6 +657,15 @@ export class FairAgentScheduler {
|
|
|
367
657
|
cancelChildren(id: string): void {
|
|
368
658
|
for (const childId of this.#node(id).children) { const child = this.#nodes.get(childId); if (child) this.#cancelTree(child); }
|
|
369
659
|
}
|
|
660
|
+
retry(id: string): void {
|
|
661
|
+
const node = this.#node(id);
|
|
662
|
+
if (node.state === "running") { node.state = "retrying"; this.#persist(node.runId); }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
attemptStarted(id: string): void {
|
|
666
|
+
const node = this.#node(id);
|
|
667
|
+
if (node.state === "retrying") { node.state = "running"; this.#persist(node.runId); }
|
|
668
|
+
}
|
|
370
669
|
|
|
371
670
|
async cancelRun(runId: string): Promise<void> {
|
|
372
671
|
const run = this.#runs.get(runId);
|
|
@@ -377,17 +676,20 @@ export class FairAgentScheduler {
|
|
|
377
676
|
if (nodes.every(({ restored }) => restored)) run.logical = 0;
|
|
378
677
|
}
|
|
379
678
|
|
|
380
|
-
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
|
|
381
679
|
toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[] {
|
|
382
680
|
const parent = this.#node(parentId);
|
|
383
681
|
if (!parent.options.tools.includes("agent")) return [];
|
|
384
682
|
const agentTool = {
|
|
385
683
|
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,
|
|
684
|
+
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 }),
|
|
685
|
+
execute: async (_id: string, rawParams: unknown) => {
|
|
686
|
+
if (!isChildAgentToolParams(rawParams)) throw new WorkflowError("INVALID_METADATA", "Invalid child agent parameters");
|
|
687
|
+
const params = rawParams;
|
|
388
688
|
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
689
|
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
|
|
690
|
+
const agentOptions = { ...params };
|
|
691
|
+
Reflect.deleteProperty(agentOptions, "prompt");
|
|
692
|
+
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
693
|
const child = this.spawn(parent.runId, params.prompt, options, parentId);
|
|
392
694
|
return { content: [{ type: "text" as const, text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
|
|
393
695
|
},
|
|
@@ -395,7 +697,7 @@ export class FairAgentScheduler {
|
|
|
395
697
|
const resultTool = {
|
|
396
698
|
name: "get_subagent_result", label: "Child Result", description: "Wait for a direct child and return its result",
|
|
397
699
|
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 }; },
|
|
700
|
+
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
701
|
} as ToolDefinition;
|
|
400
702
|
const steerTool = {
|
|
401
703
|
name: "steer_subagent", label: "Steer Child", description: "Steer a running direct child",
|
|
@@ -409,8 +711,8 @@ export class FairAgentScheduler {
|
|
|
409
711
|
return [...this.#nodes.values()].map(({ id, parentId, options, state }) => ({ id, ...(parentId ? { parentId } : {}), label: options.label, state, options }));
|
|
410
712
|
}
|
|
411
713
|
|
|
412
|
-
restoreRun(runId: string, limit: number,
|
|
413
|
-
this.addRun(runId, limit,
|
|
714
|
+
restoreRun(runId: string, limit: number, ownership: readonly OwnershipRecord[], beforeLaunch?: () => void): void {
|
|
715
|
+
this.addRun(runId, limit, beforeLaunch);
|
|
414
716
|
const run = this.#runs.get(runId) as ScheduledRun;
|
|
415
717
|
for (const record of ownership) {
|
|
416
718
|
if (record.id.split(":").slice(0, -1).join(":") !== runId) throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted agent belongs to another run: ${record.id}`);
|
|
@@ -429,18 +731,17 @@ export class FairAgentScheduler {
|
|
|
429
731
|
async flush(): Promise<void> { await this.#persistence; }
|
|
430
732
|
|
|
431
733
|
#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
734
|
if (!options.label.trim() || !options.cwd || !Array.isArray(options.tools)) throw new WorkflowError("INVALID_METADATA", "Agents require label, cwd, and tools");
|
|
435
|
-
|
|
735
|
+
const inheritedTools: readonly string[] = options.tools;
|
|
736
|
+
if (!parent) 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]) }) } : {}) });
|
|
436
737
|
if (options.cwd !== parent.options.cwd) throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
|
|
437
|
-
const forbidden =
|
|
738
|
+
const forbidden = inheritedTools.find((tool: string) => !parent.options.tools.includes(tool));
|
|
438
739
|
if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
|
|
439
|
-
|
|
740
|
+
const identity = options.agentIdentity ?? parent.options.agentIdentity;
|
|
741
|
+
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 } : {}) });
|
|
440
742
|
}
|
|
441
|
-
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
|
|
442
743
|
|
|
443
|
-
#enqueue(runId: string, start: () => void): void { this.#runs.get(runId)?.queue.push(start); this.#dispatch(); }
|
|
744
|
+
#enqueue(runId: string, node: ScheduledNode | undefined, start: () => void): void { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
|
|
444
745
|
|
|
445
746
|
#dispatch(): void {
|
|
446
747
|
while (this.#active < this.sessionLimit && this.#runOrder.length) {
|
|
@@ -453,8 +754,12 @@ export class FairAgentScheduler {
|
|
|
453
754
|
}
|
|
454
755
|
if (!selected) return;
|
|
455
756
|
const run = this.#runs.get(selected) as ScheduledRun;
|
|
456
|
-
const
|
|
457
|
-
|
|
757
|
+
const item = run.queue.shift() as { node?: ScheduledNode; start: () => void };
|
|
758
|
+
if (item.node) {
|
|
759
|
+
try { run.beforeLaunch?.(); }
|
|
760
|
+
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; }
|
|
761
|
+
}
|
|
762
|
+
run.active += 1; this.#active += 1; item.start();
|
|
458
763
|
}
|
|
459
764
|
}
|
|
460
765
|
|
|
@@ -465,7 +770,7 @@ export class FairAgentScheduler {
|
|
|
465
770
|
|
|
466
771
|
#settle(node: ScheduledNode, result: ScheduledAgentResult): void {
|
|
467
772
|
if (["completed", "failed", "cancelled"].includes(node.state)) return;
|
|
468
|
-
const heldPermit = node.state === "running";
|
|
773
|
+
const heldPermit = node.state === "running" || node.state === "retrying";
|
|
469
774
|
node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
|
|
470
775
|
this.#persist(node.runId);
|
|
471
776
|
if (heldPermit) this.#release(node.runId);
|