pi-extensible-workflows 2.0.0 → 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 +3 -1
- package/dist/src/agent-execution.d.ts +1 -15
- package/dist/src/agent-execution.js +15 -199
- package/dist/src/budget.d.ts +38 -0
- package/dist/src/budget.js +160 -0
- package/dist/src/cli.js +13 -7
- package/dist/src/execution.d.ts +17 -0
- package/dist/src/execution.js +630 -0
- package/dist/src/host.d.ts +62 -0
- package/dist/src/host.js +2696 -0
- package/dist/src/index.d.ts +10 -633
- package/dist/src/index.js +8 -4431
- package/dist/src/persistence.d.ts +6 -19
- package/dist/src/persistence.js +126 -60
- package/dist/src/registry.d.ts +31 -0
- package/dist/src/registry.js +169 -0
- package/dist/src/session-inspector.js +1 -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/package.json +1 -1
- package/skills/pi-extensible-workflows/SKILL.md +28 -27
- package/src/agent-execution.ts +17 -155
- package/src/budget.ts +75 -0
- package/src/cli.ts +15 -6
- package/src/execution.ts +540 -0
- package/src/host.ts +2265 -0
- package/src/index.ts +11 -3855
- package/src/persistence.ts +87 -36
- package/src/registry.ts +157 -0
- package/src/session-inspector.ts +1 -1
- package/src/types.ts +113 -0
- package/src/utils.ts +108 -0
- package/src/validation.ts +616 -0
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ For source installs and local development, see the [installation guide](https://
|
|
|
22
22
|
|
|
23
23
|
The main Pi agent acts as the orchestrator: it writes workflow scripts on the fly for each task. Pi extensions can add reusable functions and variables to those scripts; every registered function is also directly runnable as a top-level workflow.
|
|
24
24
|
|
|
25
|
+
Inline workflow launches require a non-empty `name`; registered function launches reject `name` and use their registered function name as the run name. Workflow worktree scopes always use the explicit `withWorktree(name, callback)` form.
|
|
26
|
+
|
|
25
27
|
A workflow can fan out across specialized agents, combine their results, and resume without rerunning completed work.
|
|
26
28
|
|
|
27
29
|
```js
|
|
@@ -68,7 +70,7 @@ npx pi-extensible-workflows export <workflow-name> [--name <command>] [--output
|
|
|
68
70
|
|
|
69
71
|
`doctor` validates the installation and active Pi resources. `inspect` opens a read-only terminal view of persisted workflow runs. `transcript` renders a session transcript to stdout. `run` derives flat CLI arguments and help from a registered function's input schema. Use `--input '<json>'` for nested or otherwise complex inputs. It executes in the current working directory, writes the final JSON result to stdout, and writes progress and errors to stderr. `export` creates an executable POSIX launcher in `~/.local/bin` by default.
|
|
70
72
|
`run` and `export` accept the trust overrides `--approve` and `--no-approve`; the generated launcher forwards its arguments to `run`. `--` ends launcher option parsing, and later tokens are passed to workflow input instead of being interpreted as launcher options.
|
|
71
|
-
Launch snapshots use identity version
|
|
73
|
+
Launch snapshots use identity version 5. Cold resume rejects older snapshots, including v4 snapshots created with the previous worktree or registered-function naming contracts, with `RESUME_INCOMPATIBLE`; relaunch the workflow instead.
|
|
72
74
|
|
|
73
75
|
## Development
|
|
74
76
|
|
|
@@ -15,7 +15,7 @@ type AgentMessage = {
|
|
|
15
15
|
};
|
|
16
16
|
};
|
|
17
17
|
};
|
|
18
|
-
import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./
|
|
18
|
+
import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./types.js";
|
|
19
19
|
import type { RunStore } from "./persistence.js";
|
|
20
20
|
export interface AgentBudgetHooks {
|
|
21
21
|
beforeAttempt(): void;
|
|
@@ -63,10 +63,6 @@ export interface AgentExecutionOptions {
|
|
|
63
63
|
budget?: AgentBudgetHooks;
|
|
64
64
|
agentOptions?: Readonly<Record<string, JsonValue>>;
|
|
65
65
|
agentIdentity?: AgentIdentity;
|
|
66
|
-
conversation?: {
|
|
67
|
-
id: string;
|
|
68
|
-
turn: number;
|
|
69
|
-
};
|
|
70
66
|
}
|
|
71
67
|
export interface AgentExecutionRoot {
|
|
72
68
|
cwd: string;
|
|
@@ -185,12 +181,6 @@ export interface SessionInput {
|
|
|
185
181
|
extensionFactories?: InlineExtension[];
|
|
186
182
|
resourcePolicy?: AgentResourcePolicy;
|
|
187
183
|
options?: Record<string, JsonValue>;
|
|
188
|
-
continuation?: {
|
|
189
|
-
sessionId: string;
|
|
190
|
-
sessionFile: string;
|
|
191
|
-
leafId: string;
|
|
192
|
-
};
|
|
193
|
-
allowModelChange?: boolean;
|
|
194
184
|
}
|
|
195
185
|
export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
|
|
196
186
|
export declare function createNativeAgentSession(input: SessionInput): Promise<NativeSession>;
|
|
@@ -222,10 +212,6 @@ export interface ScheduledAgentOptions {
|
|
|
222
212
|
timeoutMs?: number | null;
|
|
223
213
|
agentOptions?: Readonly<Record<string, JsonValue>>;
|
|
224
214
|
agentIdentity?: AgentIdentity;
|
|
225
|
-
conversation?: {
|
|
226
|
-
id: string;
|
|
227
|
-
turn: number;
|
|
228
|
-
};
|
|
229
215
|
}
|
|
230
216
|
export type ScheduledAgentResult = {
|
|
231
217
|
id: string;
|
|
@@ -1,17 +1,16 @@
|
|
|
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";
|
|
5
4
|
import { Value } from "typebox/value";
|
|
6
5
|
import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { mergeAgentResourceExclusions,
|
|
6
|
+
import { jsonObject, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference } from "./utils.js";
|
|
7
|
+
import { WorkflowError } from "./types.js";
|
|
8
8
|
function parseModel(value, fallback, thinking, aliases = {}, knownModels, settingsPath) {
|
|
9
9
|
if (!value)
|
|
10
10
|
return { ...fallback, ...(thinking ? { thinking } : {}) };
|
|
11
11
|
const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
|
|
12
12
|
return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
|
|
13
13
|
}
|
|
14
|
-
function modelCapability(model) { return `${model.provider}/${model.model}`; }
|
|
15
14
|
function text(messages) {
|
|
16
15
|
const message = [...messages].reverse().find((item) => item.role === "assistant");
|
|
17
16
|
if (!message || !Array.isArray(message.content))
|
|
@@ -54,33 +53,8 @@ catch {
|
|
|
54
53
|
} }
|
|
55
54
|
export async function createNativeAgentSession(input) {
|
|
56
55
|
const agentDir = input.agentDir ?? getAgentDir();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
manager = SessionManager.open(input.continuation.sessionFile, input.agentDir ? join(agentDir, "sessions") : undefined, input.cwd);
|
|
61
|
-
const header = manager.getHeader();
|
|
62
|
-
if (!header || canonicalSourcePath(header.cwd) !== canonicalSourcePath(input.cwd) || manager.getSessionId() !== input.continuation.sessionId || !manager.getEntry(input.continuation.leafId))
|
|
63
|
-
throw new Error("Persisted transcript identity does not match the conversation head");
|
|
64
|
-
manager.branch(input.continuation.leafId);
|
|
65
|
-
const context = manager.buildSessionContext();
|
|
66
|
-
if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) {
|
|
67
|
-
if (!input.allowModelChange)
|
|
68
|
-
throw new Error("Persisted transcript model does not match the conversation execution policy");
|
|
69
|
-
manager.appendModelChange(input.model.provider, input.model.model);
|
|
70
|
-
}
|
|
71
|
-
if (input.model.thinking && context.thinkingLevel !== input.model.thinking)
|
|
72
|
-
throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
|
|
73
|
-
}
|
|
74
|
-
catch (error) {
|
|
75
|
-
if (error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE")
|
|
76
|
-
throw error;
|
|
77
|
-
throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot reopen conversation transcript: ${error instanceof Error ? error.message : String(error)}`);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
|
|
82
|
-
manager.appendSessionInfo(input.sessionLabel);
|
|
83
|
-
}
|
|
56
|
+
const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
|
|
57
|
+
manager.appendSessionInfo(input.sessionLabel);
|
|
84
58
|
const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
|
|
85
59
|
const model = modelRuntime.getModel(input.model.provider, input.model.model);
|
|
86
60
|
if (!model)
|
|
@@ -126,9 +100,7 @@ export async function createNativeAgentSession(input) {
|
|
|
126
100
|
resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
|
|
127
101
|
await resourceLoader.reload();
|
|
128
102
|
}
|
|
129
|
-
const { session
|
|
130
|
-
if (input.continuation && modelFallbackMessage)
|
|
131
|
-
throw new WorkflowError("RESUME_INCOMPATIBLE", modelFallbackMessage);
|
|
103
|
+
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 });
|
|
132
104
|
return Object.assign(session, {
|
|
133
105
|
getLeafId: () => manager.getLeafId(),
|
|
134
106
|
getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
|
|
@@ -136,24 +108,6 @@ export async function createNativeAgentSession(input) {
|
|
|
136
108
|
}
|
|
137
109
|
function changedOption(options, baseline, key) { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
|
|
138
110
|
function validThinking(value) { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
|
|
139
|
-
function jsonValue(value, seen = new Set()) {
|
|
140
|
-
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
141
|
-
return true;
|
|
142
|
-
if (typeof value === "number")
|
|
143
|
-
return Number.isFinite(value);
|
|
144
|
-
if (typeof value !== "object" || seen.has(value))
|
|
145
|
-
return false;
|
|
146
|
-
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
147
|
-
return false;
|
|
148
|
-
const keys = Reflect.ownKeys(value);
|
|
149
|
-
if (keys.some((key) => typeof key !== "string"))
|
|
150
|
-
return false;
|
|
151
|
-
seen.add(value);
|
|
152
|
-
const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => value[key])).every((item) => jsonValue(item, seen));
|
|
153
|
-
seen.delete(value);
|
|
154
|
-
return valid;
|
|
155
|
-
}
|
|
156
|
-
function jsonObject(value) { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
157
111
|
function isChildAgentToolParams(value) {
|
|
158
112
|
if (!jsonObject(value) || typeof value.prompt !== "string" || typeof value.label !== "string")
|
|
159
113
|
return false;
|
|
@@ -181,56 +135,7 @@ function fallbackSetupContext(root, options, signal) {
|
|
|
181
135
|
function resourcePolicySummary(policy) {
|
|
182
136
|
return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
|
|
183
137
|
}
|
|
184
|
-
function
|
|
185
|
-
if (Array.isArray(value))
|
|
186
|
-
return value.map((item) => canonicalJson(item));
|
|
187
|
-
if (value && typeof value === "object")
|
|
188
|
-
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
|
|
189
|
-
return value;
|
|
190
|
-
}
|
|
191
|
-
function fingerprint(value) { return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex"); }
|
|
192
|
-
function promptFingerprint(value) { return createHash("sha256").update(value).digest("hex"); }
|
|
193
|
-
function fixedConversationOptions(options) {
|
|
194
|
-
const fixedOptions = structuredClone(options);
|
|
195
|
-
delete fixedOptions.timeoutMs;
|
|
196
|
-
delete fixedOptions.retries;
|
|
197
|
-
return fixedOptions;
|
|
198
|
-
}
|
|
199
|
-
function conversationExecutionPolicy(options, setup) {
|
|
200
|
-
return structuredClone({
|
|
201
|
-
model: setup.sessionInput.model,
|
|
202
|
-
tools: [...setup.sessionInput.tools],
|
|
203
|
-
cwd: setup.sessionInput.cwd,
|
|
204
|
-
role: options.role ?? null,
|
|
205
|
-
worktreeOwner: options.worktreeOwner ?? null,
|
|
206
|
-
parent: options.parent ?? null,
|
|
207
|
-
systemPromptAppend: setup.sessionInput.systemPromptAppend ?? "",
|
|
208
|
-
options: fixedConversationOptions(setup.options),
|
|
209
|
-
resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
function conversationPolicyMatches(expected, current, allowModelChange) {
|
|
213
|
-
if (fingerprint(expected) === fingerprint(current))
|
|
214
|
-
return true;
|
|
215
|
-
if (!allowModelChange || !expected || typeof expected !== "object" || Array.isArray(expected) || !current || typeof current !== "object" || Array.isArray(current))
|
|
216
|
-
return false;
|
|
217
|
-
const expectedModel = conversationPolicyModel(expected);
|
|
218
|
-
const currentModel = conversationPolicyModel(current);
|
|
219
|
-
if (!expectedModel || !currentModel)
|
|
220
|
-
return false;
|
|
221
|
-
return fingerprint(expected) === fingerprint({ ...current, model: { ...currentModel, provider: expectedModel.provider, model: expectedModel.model } });
|
|
222
|
-
}
|
|
223
|
-
function conversationPolicyModel(policy) {
|
|
224
|
-
if (!policy || typeof policy !== "object" || Array.isArray(policy))
|
|
225
|
-
return undefined;
|
|
226
|
-
const model = policy.model;
|
|
227
|
-
if (!model || typeof model !== "object" || Array.isArray(model) || typeof model.provider !== "string" || typeof model.model !== "string")
|
|
228
|
-
return undefined;
|
|
229
|
-
const thinking = typeof model.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(model.thinking) ? model.thinking : undefined;
|
|
230
|
-
return thinking === undefined ? { provider: model.provider, model: model.model } : { provider: model.provider, model: model.model, thinking };
|
|
231
|
-
}
|
|
232
|
-
function conversationFailure(message) { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
|
|
233
|
-
async function prepareAgentSetup(root, createSession, task, options, resolved, cwd, attempt, signal, customTools, resultTool, continuation) {
|
|
138
|
+
async function prepareAgentSetup(root, createSession, task, options, resolved, cwd, attempt, signal, customTools, resultTool) {
|
|
234
139
|
const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
|
|
235
140
|
const baselineOptions = structuredClone(options.agentOptions ?? {});
|
|
236
141
|
const baseResourcePolicy = await root.agentResourcePolicy?.();
|
|
@@ -265,8 +170,6 @@ async function prepareAgentSetup(root, createSession, task, options, resolved, c
|
|
|
265
170
|
setup.sessionInput.tools = [...setup.options.tools];
|
|
266
171
|
if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string")
|
|
267
172
|
setup.sessionInput.cwd = setup.options.cwd;
|
|
268
|
-
if (continuation)
|
|
269
|
-
setup.sessionInput.continuation = { sessionId: continuation.sessionId, sessionFile: continuation.sessionFile, leafId: continuation.leafId };
|
|
270
173
|
const model = setup.sessionInput.model;
|
|
271
174
|
const summary = { hookNames: [...hookNames], model: { provider: model.provider, model: model.model, ...(model.thinking ? { thinking: model.thinking } : {}) }, tools: [...setup.sessionInput.tools], cwd: setup.sessionInput.cwd, ...(setup.sessionInput.resourcePolicy ? { disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) } : {}) };
|
|
272
175
|
return { setup, summary };
|
|
@@ -291,17 +194,18 @@ export class WorkflowAgentExecutor {
|
|
|
291
194
|
if (forbidden)
|
|
292
195
|
throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${forbidden}`);
|
|
293
196
|
const requestedModel = options.model ?? definition?.model;
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
197
|
+
const alias = requestedModel === undefined ? undefined : modelAliasName(requestedModel, this.root.modelAliases ?? {});
|
|
198
|
+
const blockedAlias = requestedModel?.split(":", 1)[0];
|
|
199
|
+
if (requestedModel !== undefined && blockedAlias && this.root.blockedAliases?.has(blockedAlias) && !alias) {
|
|
200
|
+
const target = this.root.blockedAliasTargets?.[blockedAlias];
|
|
297
201
|
throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
|
|
298
202
|
}
|
|
299
|
-
const aliasThinking = requestedModel !== undefined &&
|
|
203
|
+
const aliasThinking = requestedModel !== undefined && alias ? resolveModelReference(requestedModel, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath).thinking : undefined;
|
|
300
204
|
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);
|
|
301
205
|
const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
|
|
302
206
|
if (!availableModels.has(modelCapability(model)))
|
|
303
207
|
throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
|
|
304
|
-
return { model, ...(
|
|
208
|
+
return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
|
|
305
209
|
}
|
|
306
210
|
async execute(task, options, signal, customTools = [], setSteer, beforeRetry) {
|
|
307
211
|
const executionSignal = signal ?? this.root.runContext?.signal;
|
|
@@ -339,29 +243,7 @@ export class WorkflowAgentExecutor {
|
|
|
339
243
|
throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
|
|
340
244
|
cwd = this.root.cwd;
|
|
341
245
|
}
|
|
342
|
-
let conversationRecord;
|
|
343
|
-
if (options.conversation) {
|
|
344
|
-
const store = this.root.runStore;
|
|
345
|
-
if (!store)
|
|
346
|
-
throw conversationFailure("Conversation persistence is unavailable");
|
|
347
|
-
try {
|
|
348
|
-
conversationRecord = await store.conversation(options.conversation.id);
|
|
349
|
-
}
|
|
350
|
-
catch (error) {
|
|
351
|
-
throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`);
|
|
352
|
-
}
|
|
353
|
-
if (conversationRecord) {
|
|
354
|
-
const model = conversationPolicyModel(conversationRecord.policy);
|
|
355
|
-
if (model)
|
|
356
|
-
resolved = this.resolve({ ...options, modelOverride: model });
|
|
357
|
-
}
|
|
358
|
-
if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1)
|
|
359
|
-
throw conversationFailure("Conversation turn must be a positive integer");
|
|
360
|
-
if (conversationRecord ? conversationRecord.head.turn + 1 !== options.conversation.turn : options.conversation.turn !== 1)
|
|
361
|
-
throw conversationFailure(`Conversation turn ${String(options.conversation.turn)} does not continue its persisted head`);
|
|
362
|
-
}
|
|
363
246
|
const attempts = [];
|
|
364
|
-
let conversationBaseline;
|
|
365
247
|
let maxAttempts = (options.retries ?? 0) + 1;
|
|
366
248
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
367
249
|
if (recoveryModel)
|
|
@@ -374,10 +256,6 @@ export class WorkflowAgentExecutor {
|
|
|
374
256
|
let setupFailed = false;
|
|
375
257
|
let budgetError;
|
|
376
258
|
let turnStarted = false;
|
|
377
|
-
let conversationSystemPrompt = "";
|
|
378
|
-
let conversationToolDefinitionsSha256 = "";
|
|
379
|
-
let conversationMismatch;
|
|
380
|
-
const conversationMismatchError = () => conversationMismatch ? new WorkflowError("RESUME_INCOMPATIBLE", conversationMismatch.message) : undefined;
|
|
381
259
|
const hasSchemaResult = () => schemaResult !== undefined;
|
|
382
260
|
const resultTool = options.schema ? {
|
|
383
261
|
name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
|
|
@@ -411,70 +289,21 @@ export class WorkflowAgentExecutor {
|
|
|
411
289
|
};
|
|
412
290
|
try {
|
|
413
291
|
setupFailed = true;
|
|
414
|
-
const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool
|
|
292
|
+
const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool);
|
|
415
293
|
setup = prepared.setup;
|
|
416
294
|
setupSummary = prepared.summary;
|
|
417
295
|
setupFailed = false;
|
|
418
296
|
if (executionSignal?.aborted)
|
|
419
297
|
throw new WorkflowError("CANCELLED", "Agent cancelled");
|
|
420
|
-
if (recoveryModel && conversationRecord)
|
|
421
|
-
setup.sessionInput.allowModelChange = true;
|
|
422
298
|
const started = Date.now();
|
|
423
299
|
session = await setup.createSession(setup.sessionInput);
|
|
424
300
|
if (setup.sessionInput.resourcePolicy)
|
|
425
301
|
setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
|
|
426
|
-
if (options.conversation) {
|
|
427
|
-
conversationSystemPrompt = session.systemPrompt ?? "";
|
|
428
|
-
conversationToolDefinitionsSha256 = fingerprint(session.getToolDefinitions?.() ?? session.agent?.state.tools ?? []);
|
|
429
|
-
const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
|
|
430
|
-
if (conversationRecord) {
|
|
431
|
-
if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile)
|
|
432
|
-
throw conversationFailure("Conversation transcript identity changed");
|
|
433
|
-
if (!session.getLeafId || (!recoveryModel && session.getLeafId() !== conversationRecord.head.leafId))
|
|
434
|
-
throw conversationFailure("Conversation transcript leaf identity changed");
|
|
435
|
-
if (!conversationPolicyMatches(conversationRecord.policy, currentExecutionPolicy, Boolean(recoveryModel)))
|
|
436
|
-
throw conversationFailure("Conversation execution policy changed");
|
|
437
|
-
if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt))
|
|
438
|
-
throw conversationFailure("Conversation system prompt changed");
|
|
439
|
-
if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256)
|
|
440
|
-
throw conversationFailure("Conversation tool definitions changed");
|
|
441
|
-
}
|
|
442
|
-
else if (conversationBaseline) {
|
|
443
|
-
if (!conversationPolicyMatches(conversationBaseline.executionPolicy, currentExecutionPolicy, Boolean(recoveryModel)))
|
|
444
|
-
throw conversationFailure("Conversation execution policy changed");
|
|
445
|
-
if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256)
|
|
446
|
-
throw conversationFailure("Conversation tool definitions changed");
|
|
447
|
-
}
|
|
448
|
-
else {
|
|
449
|
-
conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
|
|
450
|
-
}
|
|
451
|
-
if (!session.subscribe) {
|
|
452
|
-
const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
|
|
453
|
-
const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
|
|
454
|
-
if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt))
|
|
455
|
-
throw conversationFailure("Conversation system prompt changed");
|
|
456
|
-
if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined)
|
|
457
|
-
conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
|
|
458
|
-
}
|
|
459
|
-
if (conversationRecord && (!session.model || session.model.provider !== setup.sessionInput.model.provider || (session.model.model ?? session.model.id) !== setup.sessionInput.model.model))
|
|
460
|
-
throw conversationFailure("Conversation model changed");
|
|
461
|
-
}
|
|
462
302
|
const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
|
|
463
303
|
await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
|
|
464
304
|
const activeSession = session;
|
|
465
305
|
unsubscribe = activeSession.subscribe?.((event) => {
|
|
466
306
|
if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
|
|
467
|
-
if (options.conversation) {
|
|
468
|
-
conversationSystemPrompt = session.systemPrompt;
|
|
469
|
-
const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
|
|
470
|
-
const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
|
|
471
|
-
if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) {
|
|
472
|
-
conversationMismatch = conversationFailure("Conversation system prompt changed");
|
|
473
|
-
void session.abort?.();
|
|
474
|
-
}
|
|
475
|
-
if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined)
|
|
476
|
-
conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
|
|
477
|
-
}
|
|
478
307
|
if (this.root.runStore) {
|
|
479
308
|
systemPromptTurn += 1;
|
|
480
309
|
const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
|
|
@@ -528,6 +357,7 @@ export class WorkflowAgentExecutor {
|
|
|
528
357
|
if (activity?.kind === "tool" && activity.text === event.toolName)
|
|
529
358
|
activity = undefined;
|
|
530
359
|
report(false);
|
|
360
|
+
toolCalls.delete(event.toolCallId);
|
|
531
361
|
}
|
|
532
362
|
});
|
|
533
363
|
report(false);
|
|
@@ -548,8 +378,6 @@ export class WorkflowAgentExecutor {
|
|
|
548
378
|
if (!hasSchemaResult())
|
|
549
379
|
throw error;
|
|
550
380
|
}
|
|
551
|
-
if (conversationMismatch)
|
|
552
|
-
throw conversationMismatch;
|
|
553
381
|
throwIfTerminalAssistantError(session, setup.sessionInput.model);
|
|
554
382
|
{
|
|
555
383
|
const completedAccounting = accounting(session.getSessionStats());
|
|
@@ -596,9 +424,6 @@ export class WorkflowAgentExecutor {
|
|
|
596
424
|
if (schemaResult === undefined)
|
|
597
425
|
throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
|
|
598
426
|
}
|
|
599
|
-
const mismatch = conversationMismatchError();
|
|
600
|
-
if (mismatch)
|
|
601
|
-
throw mismatch;
|
|
602
427
|
const value = options.schema ? schemaResult : text(session.messages);
|
|
603
428
|
if (options.worktreeOwner)
|
|
604
429
|
await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
|
|
@@ -607,22 +432,13 @@ export class WorkflowAgentExecutor {
|
|
|
607
432
|
await flushSystemPrompts();
|
|
608
433
|
unsubscribe?.();
|
|
609
434
|
const attemptAccounting = accounting(session.getSessionStats());
|
|
610
|
-
const leafId = session.getLeafId?.() ?? undefined;
|
|
611
|
-
if (options.conversation) {
|
|
612
|
-
if (!leafId)
|
|
613
|
-
throw conversationFailure("Conversation transcript has no persisted leaf");
|
|
614
|
-
const store = this.root.runStore;
|
|
615
|
-
if (!store)
|
|
616
|
-
throw conversationFailure("Conversation persistence is unavailable");
|
|
617
|
-
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 } });
|
|
618
|
-
}
|
|
619
435
|
const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
|
|
620
436
|
attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
|
|
621
437
|
session.dispose();
|
|
622
438
|
return { value, attempts, cwd: setupSummary.cwd };
|
|
623
439
|
}
|
|
624
440
|
catch (error) {
|
|
625
|
-
const typed = budgetError ??
|
|
441
|
+
const typed = budgetError ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
|
|
626
442
|
if (session) {
|
|
627
443
|
report(true);
|
|
628
444
|
await progress;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type BudgetDimension, type BudgetEvent, type WorkflowBudget, type WorkflowBudgetPatch, type WorkflowBudgetUsage, type AgentAccounting, type RunState } from "./types.js";
|
|
2
|
+
export declare function validateBudget(value: unknown): WorkflowBudget | undefined;
|
|
3
|
+
export declare function validateBudgetPatch(value: unknown): WorkflowBudgetPatch;
|
|
4
|
+
export declare function budgetUsage(value?: Partial<WorkflowBudgetUsage>): WorkflowBudgetUsage;
|
|
5
|
+
export declare class WorkflowBudgetRuntime {
|
|
6
|
+
#private;
|
|
7
|
+
readonly budget: WorkflowBudget | undefined;
|
|
8
|
+
readonly version: number;
|
|
9
|
+
constructor(budget: WorkflowBudget | undefined, version?: number, usage?: Partial<WorkflowBudgetUsage>, events?: readonly BudgetEvent[], options?: {
|
|
10
|
+
now?: () => number;
|
|
11
|
+
onChange?: () => void;
|
|
12
|
+
active?: boolean;
|
|
13
|
+
});
|
|
14
|
+
get usage(): WorkflowBudgetUsage;
|
|
15
|
+
get events(): readonly BudgetEvent[];
|
|
16
|
+
get hardExhausted(): boolean;
|
|
17
|
+
checkAgentLaunch(): void;
|
|
18
|
+
beforeAttempt(): void;
|
|
19
|
+
beforeTurn(): void;
|
|
20
|
+
afterTurn(accounting: AgentAccounting, final: boolean): void;
|
|
21
|
+
instruction(agentId?: string): string | undefined;
|
|
22
|
+
forAgent(agentId: string): {
|
|
23
|
+
beforeAttempt: () => void;
|
|
24
|
+
beforeTurn: () => void;
|
|
25
|
+
afterTurn: (accounting: AgentAccounting, final: boolean) => void;
|
|
26
|
+
instruction: () => string | undefined;
|
|
27
|
+
};
|
|
28
|
+
transition(state: RunState): void;
|
|
29
|
+
recordEvent(event: BudgetEvent): void;
|
|
30
|
+
snapshot(): {
|
|
31
|
+
usage: WorkflowBudgetUsage;
|
|
32
|
+
budgetEvents: readonly BudgetEvent[];
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export declare function mergeBudget(budget: WorkflowBudget | undefined, patch: WorkflowBudgetPatch): WorkflowBudget | undefined;
|
|
36
|
+
export declare function budgetRelaxed(previous: WorkflowBudget | undefined, next: WorkflowBudget | undefined): boolean;
|
|
37
|
+
export declare function exhaustedBudgetDimensions(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): BudgetDimension[];
|
|
38
|
+
export declare function resumeBudgetAllowed(budget: WorkflowBudget | undefined, usage: WorkflowBudgetUsage): boolean;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { WorkflowError } from "./types.js";
|
|
2
|
+
import { fail, object } from "./utils.js";
|
|
3
|
+
function nonNegativeInteger(value) { return Number.isInteger(value) && value >= 0; }
|
|
4
|
+
function nonNegativeFinite(value) { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
|
|
5
|
+
export function validateBudget(value) {
|
|
6
|
+
if (value === undefined)
|
|
7
|
+
return undefined;
|
|
8
|
+
if (!object(value))
|
|
9
|
+
fail("INVALID_METADATA", "budget must be an object");
|
|
10
|
+
const result = {};
|
|
11
|
+
for (const [dimension, raw] of Object.entries(value)) {
|
|
12
|
+
if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension))
|
|
13
|
+
fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
|
|
14
|
+
if (!object(raw))
|
|
15
|
+
fail("INVALID_METADATA", `${dimension} budget must be an object`);
|
|
16
|
+
if (Object.keys(raw).some((key) => key !== "soft" && key !== "hard"))
|
|
17
|
+
fail("INVALID_METADATA", `${dimension} budget has an unknown limit`);
|
|
18
|
+
const isCost = dimension === "costUsd";
|
|
19
|
+
for (const key of ["soft", "hard"])
|
|
20
|
+
if (raw[key] !== undefined && !(isCost ? nonNegativeFinite(raw[key]) : nonNegativeInteger(raw[key])))
|
|
21
|
+
fail("INVALID_METADATA", `${dimension}.${key} must be a non-negative ${isCost ? "finite number" : "integer"}`);
|
|
22
|
+
if (raw.soft !== undefined && raw.soft !== null && raw.hard !== undefined && raw.hard !== null && raw.soft >= raw.hard)
|
|
23
|
+
fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
|
|
24
|
+
const limits = {};
|
|
25
|
+
if (raw.soft !== undefined)
|
|
26
|
+
limits.soft = raw.soft;
|
|
27
|
+
if (raw.hard !== undefined)
|
|
28
|
+
limits.hard = raw.hard;
|
|
29
|
+
if (Object.keys(limits).length)
|
|
30
|
+
result[dimension] = limits;
|
|
31
|
+
}
|
|
32
|
+
return Object.freeze(result);
|
|
33
|
+
}
|
|
34
|
+
export function validateBudgetPatch(value) {
|
|
35
|
+
if (!object(value))
|
|
36
|
+
fail("INVALID_METADATA", "budget patch must be an object");
|
|
37
|
+
const result = {};
|
|
38
|
+
for (const [dimension, raw] of Object.entries(value)) {
|
|
39
|
+
if (!["tokens", "costUsd", "durationMs", "agentLaunches"].includes(dimension))
|
|
40
|
+
fail("INVALID_METADATA", `Unknown budget dimension: ${dimension}`);
|
|
41
|
+
if (raw === null) {
|
|
42
|
+
result[dimension] = null;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!object(raw) || Object.keys(raw).some((key) => key !== "soft" && key !== "hard"))
|
|
46
|
+
fail("INVALID_METADATA", `${dimension} budget patch must contain only soft and hard`);
|
|
47
|
+
const limits = {};
|
|
48
|
+
for (const key of ["soft", "hard"])
|
|
49
|
+
if (Object.prototype.hasOwnProperty.call(raw, key)) {
|
|
50
|
+
if (raw[key] === null)
|
|
51
|
+
limits[key] = null;
|
|
52
|
+
else {
|
|
53
|
+
const checked = validateBudget({ [dimension]: { [key]: raw[key] } })?.[dimension];
|
|
54
|
+
if (checked?.[key] !== undefined)
|
|
55
|
+
limits[key] = checked[key];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (limits.soft !== null && limits.hard !== null && limits.soft !== undefined && limits.hard !== undefined && limits.soft >= limits.hard)
|
|
59
|
+
fail("INVALID_METADATA", `${dimension}.soft must be less than hard`);
|
|
60
|
+
result[dimension] = limits;
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
export function budgetUsage(value) { return { tokens: value?.tokens ?? 0, costUsd: value?.costUsd ?? 0, durationMs: value?.durationMs ?? 0, agentLaunches: value?.agentLaunches ?? 0 }; }
|
|
65
|
+
export class WorkflowBudgetRuntime {
|
|
66
|
+
budget;
|
|
67
|
+
version;
|
|
68
|
+
#now;
|
|
69
|
+
#onChange;
|
|
70
|
+
#injected = new Set();
|
|
71
|
+
#seen = new Set();
|
|
72
|
+
#active;
|
|
73
|
+
#activeSince;
|
|
74
|
+
#usage;
|
|
75
|
+
#events;
|
|
76
|
+
#turnAccounting;
|
|
77
|
+
constructor(budget, version = 1, usage, events = [], options = {}) {
|
|
78
|
+
this.budget = budget;
|
|
79
|
+
this.version = version;
|
|
80
|
+
this.#now = options.now ?? (() => Date.now());
|
|
81
|
+
this.#onChange = options.onChange;
|
|
82
|
+
this.#active = options.active ?? true;
|
|
83
|
+
this.#activeSince = this.#active ? this.#now() : undefined;
|
|
84
|
+
this.#usage = budgetUsage(usage);
|
|
85
|
+
this.#events = [...events];
|
|
86
|
+
for (const event of events)
|
|
87
|
+
if (event.budgetVersion === version)
|
|
88
|
+
this.#seen.add(event.type);
|
|
89
|
+
}
|
|
90
|
+
get usage() { this.#syncDuration(); return { ...this.#usage }; }
|
|
91
|
+
get events() { return this.#events; }
|
|
92
|
+
get hardExhausted() { return this.#events.some((event) => event.type === "hard_exhausted" && event.budgetVersion === this.version); }
|
|
93
|
+
checkAgentLaunch() { this.#checkHard(["agentLaunches"]); }
|
|
94
|
+
beforeAttempt() { this.#checkHard(["agentLaunches"]); this.#usage.agentLaunches += 1; this.#evaluate(); }
|
|
95
|
+
beforeTurn() { this.#syncDuration(); this.#evaluate(); this.#checkHard(["tokens", "costUsd", "durationMs"]); }
|
|
96
|
+
afterTurn(accounting, final) { this.#syncDuration(); this.#applyTurn(accounting, final, this.#turnAccounting); this.#turnAccounting = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }
|
|
97
|
+
#applyTurn(accounting, final, previous = { input: 0, output: 0, cost: 0 }) { 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)
|
|
98
|
+
this.#checkHard(["tokens", "costUsd", "durationMs"]); }
|
|
99
|
+
instruction(agentId = "agent") { if (!this.#hasSoftCrossed() || this.#injected.has(agentId))
|
|
100
|
+
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.`; }
|
|
101
|
+
forAgent(agentId) { let attempt = 0; let previous; return { beforeAttempt: () => { attempt += 1; previous = undefined; this.beforeAttempt(); }, beforeTurn: () => { this.beforeTurn(); }, afterTurn: (accounting, final) => { this.#applyTurn(accounting, final, previous); previous = { input: accounting.input, output: accounting.output, cost: accounting.cost }; }, instruction: () => this.instruction(`${agentId}:${String(attempt + 1)}`) }; }
|
|
102
|
+
transition(state) { const active = state === "running"; if (active === this.#active)
|
|
103
|
+
return; if (active) {
|
|
104
|
+
this.#active = true;
|
|
105
|
+
this.#activeSince = this.#now();
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
this.#syncDuration();
|
|
109
|
+
this.#evaluate();
|
|
110
|
+
this.#active = false;
|
|
111
|
+
this.#activeSince = undefined;
|
|
112
|
+
} this.#onChange?.(); }
|
|
113
|
+
#syncDuration() { if (this.#active && this.#activeSince !== undefined) {
|
|
114
|
+
const now = this.#now();
|
|
115
|
+
this.#usage.durationMs += Math.max(0, now - this.#activeSince);
|
|
116
|
+
this.#activeSince = now;
|
|
117
|
+
} }
|
|
118
|
+
#hasSoftCrossed() { return !!this.budget && Object.entries(this.budget).some(([dimension, limits]) => limits.soft !== undefined && this.#usage[dimension] >= limits.soft); }
|
|
119
|
+
#checkHard(dimensions) { const exhausted = dimensions.filter((dimension) => { const hard = this.budget?.[dimension]?.hard; return hard !== undefined && this.#usage[dimension] >= hard; }); if (!exhausted.length)
|
|
120
|
+
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}`); }
|
|
121
|
+
#evaluate() { const budget = this.budget; if (!budget)
|
|
122
|
+
return; const soft = Object.keys(budget).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.soft !== undefined && this.#usage[dimension] >= limits.soft; }); if (soft.length)
|
|
123
|
+
this.#record("soft_crossed", soft); const overrun = Object.keys(budget).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && this.#usage[dimension] > limits.hard; }); if (overrun.length)
|
|
124
|
+
this.#record("hard_overrun", overrun); }
|
|
125
|
+
#record(type, dimensions) { if (this.#seen.has(type))
|
|
126
|
+
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?.(); }
|
|
127
|
+
recordEvent(event) { this.#events.push(structuredClone(event)); }
|
|
128
|
+
snapshot() { return { usage: this.usage, budgetEvents: [...this.#events] }; }
|
|
129
|
+
}
|
|
130
|
+
export function mergeBudget(budget, patch) { const merged = structuredClone(budget ?? {}); for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"])
|
|
131
|
+
if (Object.prototype.hasOwnProperty.call(patch, dimension)) {
|
|
132
|
+
const value = patch[dimension];
|
|
133
|
+
if (value === null) {
|
|
134
|
+
Reflect.deleteProperty(merged, dimension);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const next = { ...(merged[dimension] ?? {}) };
|
|
138
|
+
for (const key of ["soft", "hard"])
|
|
139
|
+
if (value && Object.prototype.hasOwnProperty.call(value, key)) {
|
|
140
|
+
const limit = value[key];
|
|
141
|
+
if (limit === null)
|
|
142
|
+
Reflect.deleteProperty(next, key);
|
|
143
|
+
else if (limit !== undefined)
|
|
144
|
+
next[key] = limit;
|
|
145
|
+
}
|
|
146
|
+
if (Object.keys(next).length)
|
|
147
|
+
merged[dimension] = next;
|
|
148
|
+
else
|
|
149
|
+
Reflect.deleteProperty(merged, dimension);
|
|
150
|
+
} return validateBudget(merged); }
|
|
151
|
+
export function budgetRelaxed(previous, next) { for (const dimension of ["tokens", "costUsd", "durationMs", "agentLaunches"]) {
|
|
152
|
+
const oldLimit = previous?.[dimension];
|
|
153
|
+
const newLimit = next?.[dimension];
|
|
154
|
+
for (const key of ["soft", "hard"])
|
|
155
|
+
if ((oldLimit?.[key] !== undefined && newLimit?.[key] === undefined) || (oldLimit?.[key] !== undefined && newLimit?.[key] !== undefined && newLimit[key] > oldLimit[key]))
|
|
156
|
+
return true;
|
|
157
|
+
} return false; }
|
|
158
|
+
export function exhaustedBudgetDimensions(budget, usage) { if (!budget)
|
|
159
|
+
return []; return Object.keys(budget).filter((dimension) => { const limits = budget[dimension]; return limits !== undefined && limits.hard !== undefined && usage[dimension] >= limits.hard; }); }
|
|
160
|
+
export function resumeBudgetAllowed(budget, usage) { return exhaustedBudgetDimensions(budget, usage).length === 0; }
|