pi-extensible-workflows 1.0.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/src/agent-execution.d.ts +13 -14
- package/dist/src/agent-execution.js +110 -197
- package/dist/src/budget.d.ts +38 -0
- package/dist/src/budget.js +160 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +536 -6
- package/dist/src/doctor.d.ts +4 -4
- package/dist/src/doctor.js +9 -29
- package/dist/src/execution.d.ts +17 -0
- package/dist/src/execution.js +630 -0
- package/dist/src/herdr.d.ts +12 -0
- package/dist/src/herdr.js +74 -0
- package/dist/src/host.d.ts +62 -0
- package/dist/src/host.js +2696 -0
- package/dist/src/index.d.ts +11 -557
- package/dist/src/index.js +8 -3974
- package/dist/src/persistence.d.ts +32 -19
- package/dist/src/persistence.js +310 -70
- package/dist/src/registry.d.ts +31 -0
- package/dist/src/registry.js +169 -0
- package/dist/src/session-inspector.d.ts +1 -0
- package/dist/src/session-inspector.js +4 -1
- package/dist/src/types.d.ts +565 -0
- package/dist/src/types.js +27 -0
- package/dist/src/utils.d.ts +26 -0
- package/dist/src/utils.js +128 -0
- package/dist/src/validation.d.ts +24 -0
- package/dist/src/validation.js +740 -0
- package/dist/src/workflow-evals.js +2 -1
- package/package.json +4 -3
- package/skills/pi-extensible-workflows/SKILL.md +52 -67
- package/src/agent-execution.ts +84 -147
- package/src/budget.ts +75 -0
- package/src/cli.ts +427 -6
- package/src/doctor.ts +13 -32
- package/src/execution.ts +540 -0
- package/src/herdr.ts +73 -0
- package/src/host.ts +2265 -0
- package/src/index.ts +11 -3453
- package/src/persistence.ts +255 -47
- package/src/registry.ts +157 -0
- package/src/session-inspector.ts +5 -1
- package/src/types.ts +113 -0
- package/src/utils.ts +108 -0
- package/src/validation.ts +616 -0
- package/src/workflow-evals.ts +2 -1
package/src/agent-execution.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { realpathSync } from "node:fs";
|
|
3
2
|
import { join, resolve } from "node:path";
|
|
4
3
|
import { Type } from "@earendil-works/pi-ai";
|
|
@@ -6,10 +5,10 @@ import { Value } from "typebox/value";
|
|
|
6
5
|
import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type AgentSessionEvent, type InlineExtension, type SessionStats, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
7
6
|
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
8
7
|
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 "./
|
|
10
|
-
import { mergeAgentResourceExclusions,
|
|
11
|
-
import
|
|
12
|
-
|
|
8
|
+
import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./types.js";
|
|
9
|
+
import { jsonObject, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference } from "./utils.js";
|
|
10
|
+
import { WorkflowError } from "./types.js";
|
|
11
|
+
import type { RunStore } from "./persistence.js";
|
|
13
12
|
export interface AgentBudgetHooks {
|
|
14
13
|
beforeAttempt(): void;
|
|
15
14
|
beforeTurn(): void;
|
|
@@ -17,6 +16,8 @@ export interface AgentBudgetHooks {
|
|
|
17
16
|
instruction(): string | undefined;
|
|
18
17
|
}
|
|
19
18
|
export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[]; disabledAgentResources?: AgentResourceExclusions }
|
|
19
|
+
export interface AgentProviderFailure { label: string; provider: string; model: string; error: string }
|
|
20
|
+
export type AgentProviderRecovery = "retry" | "abort" | { model: string };
|
|
20
21
|
export interface AgentExecutionOptions {
|
|
21
22
|
label: string;
|
|
22
23
|
workflowName: string;
|
|
@@ -26,6 +27,8 @@ export interface AgentExecutionOptions {
|
|
|
26
27
|
thinking?: ThinkingLevel;
|
|
27
28
|
onProgress?: (progress: AgentProgress) => void | Promise<void>;
|
|
28
29
|
onAttempt?: (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => void | Promise<void>;
|
|
30
|
+
providerErrorRecovery?: (failure: AgentProviderFailure) => Promise<AgentProviderRecovery>;
|
|
31
|
+
modelOverride?: ModelSpec;
|
|
29
32
|
tools?: readonly string[];
|
|
30
33
|
effectiveTools?: readonly string[];
|
|
31
34
|
role?: string;
|
|
@@ -38,7 +41,6 @@ export interface AgentExecutionOptions {
|
|
|
38
41
|
budget?: AgentBudgetHooks;
|
|
39
42
|
agentOptions?: Readonly<Record<string, JsonValue>>;
|
|
40
43
|
agentIdentity?: AgentIdentity;
|
|
41
|
-
conversation?: { id: string; turn: number };
|
|
42
44
|
}
|
|
43
45
|
export interface AgentExecutionRoot {
|
|
44
46
|
cwd: string;
|
|
@@ -85,7 +87,7 @@ export interface NativeSession {
|
|
|
85
87
|
abort?(): Promise<void>;
|
|
86
88
|
dispose(): void;
|
|
87
89
|
}
|
|
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
|
|
90
|
+
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> }
|
|
89
91
|
export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
|
|
90
92
|
|
|
91
93
|
function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
|
|
@@ -93,7 +95,6 @@ function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: T
|
|
|
93
95
|
const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
|
|
94
96
|
return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
|
|
95
97
|
}
|
|
96
|
-
function modelCapability(model: ModelSpec): string { return `${model.provider}/${model.model}`; }
|
|
97
98
|
|
|
98
99
|
function text(messages: readonly AgentMessage[]): string {
|
|
99
100
|
const message = [...messages].reverse().find((item) => item.role === "assistant");
|
|
@@ -110,9 +111,22 @@ function latestAssistantHasToolCall(messages: readonly AgentMessage[]): boolean
|
|
|
110
111
|
return hasToolCall(message);
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
|
|
114
|
+
type TerminalProviderError = { provider: string; model: string; error: string };
|
|
115
|
+
function throwIfTerminalAssistantError(session: NativeSession, fallbackModel: ModelSpec): void {
|
|
114
116
|
const message = [...session.messages].reverse().find((item) => item.role === "assistant");
|
|
115
|
-
if (message?.stopReason
|
|
117
|
+
if (message?.stopReason !== "error") return;
|
|
118
|
+
const provider = session.model?.provider ?? fallbackModel.provider;
|
|
119
|
+
const model = session.model?.model ?? session.model?.id ?? fallbackModel.model;
|
|
120
|
+
const error = message.errorMessage ?? "Native Pi assistant ended with a terminal provider error";
|
|
121
|
+
const failure = new WorkflowError("AGENT_FAILED", error);
|
|
122
|
+
Object.defineProperty(failure, "terminalProviderError", { value: { provider, model, error }, configurable: true });
|
|
123
|
+
throw failure;
|
|
124
|
+
}
|
|
125
|
+
function terminalProviderError(error: WorkflowError): TerminalProviderError | undefined {
|
|
126
|
+
const value = (error as WorkflowError & { terminalProviderError?: unknown }).terminalProviderError;
|
|
127
|
+
if (!value || typeof value !== "object") return undefined;
|
|
128
|
+
const candidate = value as Partial<TerminalProviderError>;
|
|
129
|
+
return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
|
|
116
130
|
}
|
|
117
131
|
|
|
118
132
|
function accounting(stats: NativeSessionStats): AgentAccounting {
|
|
@@ -122,24 +136,8 @@ function canonicalSourcePath(path: string): string { try { return realpathSync(p
|
|
|
122
136
|
|
|
123
137
|
export async function createNativeAgentSession(input: SessionInput): Promise<NativeSession> {
|
|
124
138
|
const agentDir = input.agentDir ?? getAgentDir();
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
}
|
|
139
|
+
const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
|
|
140
|
+
manager.appendSessionInfo(input.sessionLabel);
|
|
143
141
|
const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
|
|
144
142
|
const model = modelRuntime.getModel(input.model.provider, input.model.model);
|
|
145
143
|
if (!model) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${input.model.provider}/${input.model.model}`);
|
|
@@ -183,8 +181,7 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
|
|
|
183
181
|
resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
|
|
184
182
|
await resourceLoader.reload();
|
|
185
183
|
}
|
|
186
|
-
const { session
|
|
187
|
-
if (input.continuation && modelFallbackMessage) throw new WorkflowError("RESUME_INCOMPATIBLE", modelFallbackMessage);
|
|
184
|
+
const { session } = 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 });
|
|
188
185
|
return Object.assign(session, {
|
|
189
186
|
getLeafId: () => manager.getLeafId(),
|
|
190
187
|
getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
|
|
@@ -192,19 +189,6 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
|
|
|
192
189
|
}
|
|
193
190
|
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
191
|
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
192
|
interface ChildAgentToolParams {
|
|
209
193
|
prompt: string;
|
|
210
194
|
label: string;
|
|
@@ -236,34 +220,7 @@ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionO
|
|
|
236
220
|
function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
|
|
237
221
|
return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
|
|
238
222
|
}
|
|
239
|
-
function
|
|
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 }> {
|
|
223
|
+
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): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
|
|
267
224
|
const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
|
|
268
225
|
const baselineOptions = structuredClone(options.agentOptions ?? {});
|
|
269
226
|
const baseResourcePolicy = await root.agentResourcePolicy?.();
|
|
@@ -285,7 +242,6 @@ async function prepareAgentSetup(root: AgentExecutionRoot, createSession: Sessio
|
|
|
285
242
|
if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking)) setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
|
|
286
243
|
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
244
|
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
245
|
const model = setup.sessionInput.model;
|
|
290
246
|
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
247
|
return { setup, summary };
|
|
@@ -304,20 +260,22 @@ export class WorkflowAgentExecutor {
|
|
|
304
260
|
const forbidden = requested.find((tool) => !this.root.tools.has(tool));
|
|
305
261
|
if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${forbidden}`);
|
|
306
262
|
const requestedModel = options.model ?? definition?.model;
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
263
|
+
const alias = requestedModel === undefined ? undefined : modelAliasName(requestedModel, this.root.modelAliases ?? {});
|
|
264
|
+
const blockedAlias = requestedModel?.split(":", 1)[0];
|
|
265
|
+
if (requestedModel !== undefined && blockedAlias && this.root.blockedAliases?.has(blockedAlias) && !alias) { const target = this.root.blockedAliasTargets?.[blockedAlias]; throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`); }
|
|
266
|
+
const aliasThinking = requestedModel !== undefined && alias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
|
|
267
|
+
const model = options.modelOverride ?? 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
268
|
const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
|
|
312
269
|
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, ...(
|
|
270
|
+
return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
|
|
314
271
|
}
|
|
315
272
|
|
|
316
273
|
async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
|
|
317
274
|
const executionSignal = signal ?? this.root.runContext?.signal;
|
|
318
275
|
if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0) throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
|
|
319
276
|
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");
|
|
320
|
-
|
|
277
|
+
let resolved = this.resolve(options);
|
|
278
|
+
let recoveryModel: ModelSpec | undefined;
|
|
321
279
|
let cwd: string;
|
|
322
280
|
if (options.parent) {
|
|
323
281
|
if (!options.cwd) throw new WorkflowError("INVALID_METADATA", "Child agents require their parent cwd");
|
|
@@ -337,20 +295,11 @@ export class WorkflowAgentExecutor {
|
|
|
337
295
|
if (options.cwd) throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
|
|
338
296
|
cwd = this.root.cwd;
|
|
339
297
|
}
|
|
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
|
-
}
|
|
348
298
|
const attempts: AgentAttempt[] = [];
|
|
349
|
-
let
|
|
350
|
-
const maxAttempts = (options.retries ?? 0) + 1;
|
|
299
|
+
let maxAttempts = (options.retries ?? 0) + 1;
|
|
351
300
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
301
|
+
if (recoveryModel) resolved = this.resolve({ ...options, modelOverride: recoveryModel });
|
|
352
302
|
options.budget?.beforeAttempt();
|
|
353
|
-
let accepted = false;
|
|
354
303
|
let schemaResult: JsonValue | undefined;
|
|
355
304
|
let session: NativeSession | undefined;
|
|
356
305
|
let setup: AgentSetup | undefined;
|
|
@@ -358,16 +307,12 @@ export class WorkflowAgentExecutor {
|
|
|
358
307
|
let setupFailed = false;
|
|
359
308
|
let budgetError: WorkflowError | undefined;
|
|
360
309
|
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;
|
|
365
310
|
const hasSchemaResult = () => schemaResult !== undefined;
|
|
366
311
|
const resultTool = options.schema ? {
|
|
367
312
|
name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
|
|
368
313
|
async execute(_id: string, value: unknown) {
|
|
369
|
-
if (!accepted) return { content: [{ type: "text" as const, text: "Result acceptance is not enabled yet." }], details: {}, isError: true };
|
|
370
314
|
if (!Value.Check(options.schema as object, value)) return { content: [{ type: "text" as const, text: "Result does not match the required schema." }], details: {}, isError: true };
|
|
315
|
+
if (schemaResult !== undefined) return { content: [{ type: "text" as const, text: "Result has already been accepted." }], details: {}, isError: true };
|
|
371
316
|
schemaResult = structuredClone(value) as JsonValue;
|
|
372
317
|
void session?.abort?.();
|
|
373
318
|
return { content: [{ type: "text" as const, text: "Result accepted." }], details: {} };
|
|
@@ -391,7 +336,7 @@ export class WorkflowAgentExecutor {
|
|
|
391
336
|
};
|
|
392
337
|
try {
|
|
393
338
|
setupFailed = true;
|
|
394
|
-
const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool
|
|
339
|
+
const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool);
|
|
395
340
|
setup = prepared.setup;
|
|
396
341
|
setupSummary = prepared.summary;
|
|
397
342
|
setupFailed = false;
|
|
@@ -399,42 +344,11 @@ export class WorkflowAgentExecutor {
|
|
|
399
344
|
const started = Date.now();
|
|
400
345
|
session = await setup.createSession(setup.sessionInput);
|
|
401
346
|
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
347
|
const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
|
|
427
348
|
await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
|
|
428
349
|
const activeSession = session;
|
|
429
350
|
unsubscribe = activeSession.subscribe?.((event) => {
|
|
430
351
|
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
352
|
if (this.root.runStore) {
|
|
439
353
|
systemPromptTurn += 1;
|
|
440
354
|
const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
|
|
@@ -449,14 +363,14 @@ export class WorkflowAgentExecutor {
|
|
|
449
363
|
activity = undefined;
|
|
450
364
|
if (event.message.role === "assistant") {
|
|
451
365
|
const needsMoreWork = hasToolCall(event.message);
|
|
452
|
-
const final = !needsMoreWork || (options.schema !== undefined &&
|
|
366
|
+
const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
|
|
453
367
|
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
368
|
turnStarted = false;
|
|
455
369
|
report(true);
|
|
456
370
|
}
|
|
457
371
|
}
|
|
458
372
|
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); }
|
|
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); }
|
|
373
|
+
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); toolCalls.delete(event.toolCallId); }
|
|
460
374
|
});
|
|
461
375
|
report(false);
|
|
462
376
|
if (setSteer) {
|
|
@@ -468,23 +382,21 @@ export class WorkflowAgentExecutor {
|
|
|
468
382
|
const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
|
|
469
383
|
options.budget?.beforeTurn();
|
|
470
384
|
turnStarted = true;
|
|
471
|
-
await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
{ const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? false : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
|
|
385
|
+
try { await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { if (!hasSchemaResult()) throw error; }
|
|
386
|
+
throwIfTerminalAssistantError(session, setup.sessionInput.model);
|
|
387
|
+
{ const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
|
|
475
388
|
if (budgetError) throw budgetError;
|
|
476
389
|
if (options.schema) {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
390
|
+
if (!hasSchemaResult()) {
|
|
391
|
+
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; }
|
|
392
|
+
}
|
|
393
|
+
throwIfTerminalAssistantError(session, setup.sessionInput.model);
|
|
480
394
|
if (!hasSchemaResult()) {
|
|
481
395
|
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);
|
|
396
|
+
throwIfTerminalAssistantError(session, setup.sessionInput.model);
|
|
483
397
|
}
|
|
484
398
|
if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
|
|
485
399
|
}
|
|
486
|
-
const mismatch = conversationMismatchError();
|
|
487
|
-
if (mismatch) throw mismatch;
|
|
488
400
|
const value = options.schema ? schemaResult as JsonValue : text(session.messages);
|
|
489
401
|
if (options.worktreeOwner) await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
|
|
490
402
|
report(true);
|
|
@@ -492,19 +404,12 @@ export class WorkflowAgentExecutor {
|
|
|
492
404
|
await flushSystemPrompts();
|
|
493
405
|
unsubscribe?.();
|
|
494
406
|
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
407
|
const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
|
|
503
408
|
attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
|
|
504
409
|
session.dispose();
|
|
505
410
|
return { value, attempts, cwd: setupSummary.cwd };
|
|
506
411
|
} catch (error) {
|
|
507
|
-
const typed = budgetError ??
|
|
412
|
+
const typed = budgetError ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
|
|
508
413
|
if (session) {
|
|
509
414
|
report(true);
|
|
510
415
|
await progress;
|
|
@@ -517,6 +422,22 @@ export class WorkflowAgentExecutor {
|
|
|
517
422
|
session.dispose();
|
|
518
423
|
}
|
|
519
424
|
if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED") await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
|
|
425
|
+
const terminal = terminalProviderError(typed);
|
|
426
|
+
if (terminal && options.providerErrorRecovery) {
|
|
427
|
+
let recovery: AgentProviderRecovery;
|
|
428
|
+
try { recovery = await options.providerErrorRecovery({ label: options.label, ...terminal }); } catch { throw Object.assign(typed, { attempts }); }
|
|
429
|
+
if (recovery === "retry" || typeof recovery === "object" && typeof recovery.model === "string") {
|
|
430
|
+
if (typeof recovery === "object") {
|
|
431
|
+
try {
|
|
432
|
+
const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
|
|
433
|
+
recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
|
|
434
|
+
} catch { throw Object.assign(typed, { attempts }); }
|
|
435
|
+
}
|
|
436
|
+
maxAttempts += 1;
|
|
437
|
+
beforeRetry?.();
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
520
441
|
if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE") throw Object.assign(typed, { attempts });
|
|
521
442
|
beforeRetry?.();
|
|
522
443
|
}
|
|
@@ -540,7 +461,6 @@ export interface ScheduledAgentOptions {
|
|
|
540
461
|
timeoutMs?: number | null;
|
|
541
462
|
agentOptions?: Readonly<Record<string, JsonValue>>;
|
|
542
463
|
agentIdentity?: AgentIdentity;
|
|
543
|
-
conversation?: { id: string; turn: number };
|
|
544
464
|
}
|
|
545
465
|
|
|
546
466
|
export type ScheduledAgentResult =
|
|
@@ -676,6 +596,22 @@ export class FairAgentScheduler {
|
|
|
676
596
|
if (nodes.every(({ restored }) => restored)) run.logical = 0;
|
|
677
597
|
}
|
|
678
598
|
|
|
599
|
+
removeRun(runId: string): void {
|
|
600
|
+
const run = this.#runs.get(runId);
|
|
601
|
+
if (!run) return;
|
|
602
|
+
const nodes = [...this.#nodes.values()].filter((node) => node.runId === runId);
|
|
603
|
+
if (run.active > 0 || nodes.some(({ state }) => !["completed", "failed", "cancelled"].includes(state))) throw new WorkflowError("INTERNAL_ERROR", `Cannot remove active scheduler run: ${runId}`);
|
|
604
|
+
for (const { id } of nodes) this.#nodes.delete(id);
|
|
605
|
+
this.#runs.delete(runId);
|
|
606
|
+
const index = this.#runOrder.indexOf(runId);
|
|
607
|
+
if (index >= 0) {
|
|
608
|
+
this.#runOrder.splice(index, 1);
|
|
609
|
+
if (index < this.#cursor) this.#cursor -= 1;
|
|
610
|
+
if (this.#cursor >= this.#runOrder.length) this.#cursor = 0;
|
|
611
|
+
}
|
|
612
|
+
this.#dispatch();
|
|
613
|
+
}
|
|
614
|
+
|
|
679
615
|
toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[] {
|
|
680
616
|
const parent = this.#node(parentId);
|
|
681
617
|
if (!parent.options.tools.includes("agent")) return [];
|
|
@@ -772,6 +708,7 @@ export class FairAgentScheduler {
|
|
|
772
708
|
if (["completed", "failed", "cancelled"].includes(node.state)) return;
|
|
773
709
|
const heldPermit = node.state === "running" || node.state === "retrying";
|
|
774
710
|
node.state = result.ok ? "completed" : result.error.code === "CANCELLED" ? "cancelled" : "failed";
|
|
711
|
+
Reflect.deleteProperty(node, "steer");
|
|
775
712
|
this.#persist(node.runId);
|
|
776
713
|
if (heldPermit) this.#release(node.runId);
|
|
777
714
|
for (const childId of node.children) { const child = this.#nodes.get(childId); if (child && !child.collected) this.#cancelTree(child); }
|
package/src/budget.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { WorkflowError, type BudgetDimension, type BudgetEvent, type BudgetLimits, type WorkflowBudget, type WorkflowBudgetPatch, type WorkflowBudgetUsage, type AgentAccounting, type RunState } from "./types.js";
|
|
2
|
+
import { fail, object } from "./utils.js";
|
|
3
|
+
|
|
4
|
+
function nonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
|
|
5
|
+
function nonNegativeFinite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
|
|
6
|
+
export function validateBudget(value: unknown): WorkflowBudget | undefined {
|
|
7
|
+
if (value === undefined) return undefined;
|
|
8
|
+
if (!object(value)) fail("INVALID_METADATA", "budget must be an object");
|
|
9
|
+
const result: WorkflowBudget = {};
|
|
10
|
+
for (const [dimension, raw] of Object.entries(value)) {
|
|
11
|
+
if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
|
|
12
|
+
if (!object(raw)) fail("INVALID_METADATA", `${dimension} budget must be an object`);
|
|
13
|
+
if (Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget has an unknown limit`);
|
|
14
|
+
const isCost = dimension === "costUsd";
|
|
15
|
+
for (const key of ["soft", "hard"] as const) if (raw[key] !== undefined && !(isCost ? nonNegativeFinite(raw[key]) : nonNegativeInteger(raw[key]))) fail("INVALID_METADATA", `${dimension}.${key} must be a non-negative ${isCost ? "finite number" : "integer"}`);
|
|
16
|
+
if (raw.soft !== undefined && raw.soft !== null && raw.hard !== undefined && raw.hard !== null && raw.soft >= raw.hard) fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
|
|
17
|
+
const limits: BudgetLimits = {};
|
|
18
|
+
if (raw.soft !== undefined) limits.soft = raw.soft as number;
|
|
19
|
+
if (raw.hard !== undefined) limits.hard = raw.hard as number;
|
|
20
|
+
if (Object.keys(limits).length) (result as Record<string, BudgetLimits>)[dimension] = limits;
|
|
21
|
+
}
|
|
22
|
+
return Object.freeze(result);
|
|
23
|
+
}
|
|
24
|
+
export function validateBudgetPatch(value: unknown): WorkflowBudgetPatch {
|
|
25
|
+
if (!object(value)) fail("INVALID_METADATA", "budget patch must be an object");
|
|
26
|
+
const result: WorkflowBudgetPatch = {};
|
|
27
|
+
for (const [dimension, raw] of Object.entries(value)) {
|
|
28
|
+
if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension)) fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
|
|
29
|
+
if (raw === null) { (result as Record<string, null>)[dimension] = null; continue; }
|
|
30
|
+
if (!object(raw) || Object.keys(raw).some((key) => key !== "soft" && key !== "hard")) fail("INVALID_METADATA", `${dimension} budget patch must contain only soft and hard`);
|
|
31
|
+
const limits: { soft?: number | null; hard?: number | null } = {};
|
|
32
|
+
for (const key of ["soft", "hard"] as const) if (Object.prototype.hasOwnProperty.call(raw, key)) {
|
|
33
|
+
if (raw[key] === null) limits[key] = null;
|
|
34
|
+
else { const checked = validateBudget({ [dimension]: { [key]: raw[key] } })?.[dimension as BudgetDimension]; if (checked?.[key] !== undefined) limits[key] = checked[key]; }
|
|
35
|
+
}
|
|
36
|
+
if (limits.soft !== null && limits.hard !== null && limits.soft !== undefined && limits.hard !== undefined && limits.soft >= limits.hard) fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
|
|
37
|
+
(result as Record<string, { soft?: number | null; hard?: number | null }>)[dimension] = limits;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
export function budgetUsage(value?: Partial<WorkflowBudgetUsage>): WorkflowBudgetUsage { return { tokens: value?.tokens ?? 0, costUsd: value?.costUsd ?? 0, durationMs: value?.durationMs ?? 0, agentLaunches: value?.agentLaunches ?? 0 }; }
|
|
42
|
+
export class WorkflowBudgetRuntime {
|
|
43
|
+
readonly #now: () => number;
|
|
44
|
+
readonly #onChange: (() => void) | undefined;
|
|
45
|
+
readonly #injected = new Set<string>();
|
|
46
|
+
readonly #seen = new Set<string>();
|
|
47
|
+
#active: boolean;
|
|
48
|
+
#activeSince: number | undefined;
|
|
49
|
+
#usage: WorkflowBudgetUsage;
|
|
50
|
+
#events: BudgetEvent[];
|
|
51
|
+
#turnAccounting?: { input: number; output: number; cost: number };
|
|
52
|
+
constructor(readonly budget: WorkflowBudget | undefined, readonly version = 1, usage?: Partial<WorkflowBudgetUsage>, events: readonly BudgetEvent[] = [], options: { now?: () => number; onChange?: () => void; active?: boolean } = {}) { this.#now = options.now ?? (() => Date.now()); this.#onChange = options.onChange; this.#active = options.active ?? true; this.#activeSince = this.#active ? this.#now() : undefined; this.#usage = budgetUsage(usage); this.#events = [...events]; for (const event of events) if (event.budgetVersion === version) this.#seen.add(event.type); }
|
|
53
|
+
get usage(): WorkflowBudgetUsage { this.#syncDuration(); return { ...this.#usage }; }
|
|
54
|
+
get events(): readonly BudgetEvent[] { return this.#events; }
|
|
55
|
+
get hardExhausted(): boolean { return this.#events.some((event) => event.type === "hard_exhausted" && event.budgetVersion === this.version); }
|
|
56
|
+
checkAgentLaunch(): void { this.#checkHard(["agentLaunches"]); }
|
|
57
|
+
beforeAttempt(): void { this.#checkHard(["agentLaunches"]); this.#usage.agentLaunches += 1; this.#evaluate(); }
|
|
58
|
+
beforeTurn(): void { this.#syncDuration(); this.#evaluate(); this.#checkHard(["tokens", "costUsd", "durationMs"]); }
|
|
59
|
+
afterTurn(accounting: AgentAccounting, final: boolean): void { this.#syncDuration(); this.#applyTurn(accounting, final, this.#turnAccounting); this.#turnAccounting = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }
|
|
60
|
+
#applyTurn(accounting: AgentAccounting, final: boolean, previous = { input: 0, output: 0, cost: 0 }): void { this.#usage.tokens += Math.max(0, accounting.input - previous.input) + Math.max(0, accounting.output - previous.output); this.#usage.costUsd += Math.max(0, accounting.cost - previous.cost); this.#evaluate(); if (!final) this.#checkHard(["tokens", "costUsd", "durationMs"]); }
|
|
61
|
+
instruction(agentId = "agent"): string | undefined { if (!this.#hasSoftCrossed() || this.#injected.has(agentId)) return undefined; this.#injected.add(agentId); return `The workflow budget soft limit has been reached. Finish the requested output now, preserving any required output schema. Current usage: ${JSON.stringify(this.usage)}. Do not start additional model work unless it is required to produce the final requested result.`; }
|
|
62
|
+
forAgent(agentId: string) { let attempt = 0; let previous: { input: number; output: number; cost: number } | undefined; return { beforeAttempt: () => { attempt += 1; previous = undefined; this.beforeAttempt(); }, beforeTurn: () => { this.beforeTurn(); }, afterTurn: (accounting: AgentAccounting, final: boolean) => { this.#applyTurn(accounting, final, previous); previous = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }, instruction: () => this.instruction(`${agentId}:${String(attempt + 1)}`) }; }
|
|
63
|
+
transition(state: RunState): void { const active = state === "running"; if (active === this.#active) return; if (active) { this.#active = true; this.#activeSince = this.#now(); } else { this.#syncDuration(); this.#evaluate(); this.#active = false; this.#activeSince = undefined; } this.#onChange?.(); }
|
|
64
|
+
#syncDuration(): void { if (this.#active && this.#activeSince !== undefined) { const now = this.#now(); this.#usage.durationMs += Math.max(0, now - this.#activeSince); this.#activeSince = now; } }
|
|
65
|
+
#hasSoftCrossed(): boolean { return !!this.budget && (Object.entries(this.budget) as [BudgetDimension, BudgetLimits][]).some(([dimension, limits]) => limits.soft !== undefined && this.#usage[dimension] >= limits.soft); }
|
|
66
|
+
#checkHard(dimensions: readonly BudgetDimension[]): void { const exhausted = dimensions.filter((dimension) => { const hard = this.budget?.[dimension]?.hard; return hard !== undefined && this.#usage[dimension] >= hard; }); if (!exhausted.length) return; this.#record("hard_exhausted", exhausted); const detail = exhausted.map((dimension) => `${dimension} usage=${String(this.#usage[dimension])} hard=${String(this.budget?.[dimension]?.hard)}`).join(", "); throw new WorkflowError("BUDGET_EXHAUSTED", `Budget version ${String(this.version)} exhausted: ${detail}`); }
|
|
67
|
+
#evaluate(): void { const budget = this.budget; if (!budget) return; const soft = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.soft !== undefined && this.#usage[dimension] >= limits.soft; }); if (soft.length) this.#record("soft_crossed", soft); const overrun = (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && this.#usage[dimension] > limits.hard; }); if (overrun.length) this.#record("hard_overrun", overrun); }
|
|
68
|
+
#record(type: BudgetEvent["type"], dimensions: readonly BudgetDimension[]): void { if (this.#seen.has(type)) return; this.#seen.add(type); this.#events.push({ type, budgetVersion: this.version, dimensions: [...dimensions], usage: this.usage, limits: structuredClone(this.budget ?? {}), at: this.#now() }); this.#onChange?.(); }
|
|
69
|
+
recordEvent(event: BudgetEvent): void { this.#events.push(structuredClone(event)); }
|
|
70
|
+
snapshot(): { usage: WorkflowBudgetUsage; budgetEvents: readonly BudgetEvent[] } { return { usage: this.usage, budgetEvents: [...this.#events] }; }
|
|
71
|
+
}
|
|
72
|
+
export function mergeBudget(budget: WorkflowBudget | undefined, patch: WorkflowBudgetPatch): WorkflowBudget | undefined { const merged: WorkflowBudget = structuredClone(budget ?? {}); for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) if (Object.prototype.hasOwnProperty.call(patch, dimension)) { const value = patch[dimension]; if (value === null) { Reflect.deleteProperty(merged, dimension); continue; } const next: BudgetLimits = { ...(merged[dimension] ?? {}) }; for (const key of ["soft", "hard"] as const) if (value && Object.prototype.hasOwnProperty.call(value, key)) { const limit = value[key]; if (limit === null) Reflect.deleteProperty(next, key); else if (limit !== undefined) next[key] = limit; } if (Object.keys(next).length) (merged as Record<string, BudgetLimits>)[dimension] = next; else Reflect.deleteProperty(merged, dimension); } return validateBudget(merged); }
|
|
73
|
+
export function budgetRelaxed(previous: WorkflowBudget | undefined, next: WorkflowBudget | undefined): boolean { for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"] as const) { const oldLimit = previous?.[dimension]; const newLimit = next?.[dimension]; for (const key of ["soft", "hard"] as const) if ((oldLimit?.[key] !== undefined && newLimit?.[key] === undefined) || (oldLimit?.[key] !== undefined && newLimit?.[key] !== undefined && newLimit[key] > oldLimit[key])) return true; } return false; }
|
|
74
|
+
export function exhaustedBudgetDimensions(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): BudgetDimension[] { if (!budget) return []; return (Object.keys(budget) as BudgetDimension[]).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && usage[dimension] >= limits.hard; }); }
|
|
75
|
+
export function resumeBudgetAllowed(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): boolean { return exhaustedBudgetDimensions(budget, usage).length === 0; }
|