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/package.json
CHANGED
|
@@ -6,10 +6,11 @@ description: Use when the task is complex enough to require multiple subagents o
|
|
|
6
6
|
# pi-extensible-workflows
|
|
7
7
|
Use `workflow` only for genuinely multi-agent orchestration; one agent uses ordinary tools or `Agent` directly. Give phases distinct responsibilities and keep result flow explicit.
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Example
|
|
10
10
|
```js
|
|
11
11
|
const reportSchema = { type: "object", properties: { summary: { type: "string" }, findings: { type: "array", items: { type: "string" } } }, required: ["summary", "findings"], additionalProperties: false };
|
|
12
12
|
|
|
13
|
+
|
|
13
14
|
const reports = await parallel("research", {
|
|
14
15
|
first: () => agent("Research the first target.", { role: "scout", outputSchema: reportSchema }),
|
|
15
16
|
second: () => agent("Research the second target.", { role: "scout", outputSchema: reportSchema }),
|
|
@@ -21,33 +22,37 @@ return agent(
|
|
|
21
22
|
);
|
|
22
23
|
```
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
Inline launches require a non-empty `name`. Registered function launches must omit `name`; they use `workflow` as the run name:
|
|
25
26
|
```json
|
|
26
27
|
{ "workflow": "workflowName", "args": { "issue": 42 } }
|
|
27
28
|
```
|
|
28
|
-
Inside the workflow, read `args.issue` (`args` is `null` when omitted). `workflow_stop` requires the exact run ID; foreground results retain their value and completed `runId`, while background launches return `runId` immediately. A terminal `parentRunId` reuses matching named `withWorktree` scopes;
|
|
29
|
-
|
|
29
|
+
Inside the workflow, read `args.issue` (`args` is `null` when omitted). `workflow_stop` requires the exact run ID; foreground results retain their value and completed `runId`, while background launches return `runId` immediately. A terminal `parentRunId` reuses matching named `withWorktree` scopes but does not replay journal results. For an explicitly failed run, use the exact `runId` from failure diagnostics with `workflow_retry({ runId })`: diagnostics list replayable and incomplete paths, artifacts, and valid named worktrees; the tool creates a linked child, replays completed agent, shell, function, and checkpoint operations, and executes incomplete work. Retry versus per-agent `retries` and `workflow_resume` is always explicit; external side effects before failure are not guaranteed exactly once.
|
|
30
|
+
Inspect tool `workflow_catalog` result at least once before creating the first workflow for a task.
|
|
30
31
|
|
|
31
32
|
Workflow JavaScript has no imports, filesystem, network, process, or timers. Delegate that work to agents. `shell(command, options)` is the trusted host RPC for deterministic gates: it inherits the workflow or active-worktree cwd, merges string `env` overrides, and returns `{ exitCode, stdout, stderr }`; nonzero exits are results, but launch failures and timeouts fail with `SHELL_FAILED`.
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
Example use of `shell`:
|
|
35
|
+
|
|
34
36
|
```js
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return { path, branch, tests: await shell("yarn test", { env: { CI: "1" } }) };
|
|
42
|
-
});
|
|
37
|
+
// ... other code
|
|
38
|
+
const testRes = await shell("yarn test", { env: { CI: "1" } });
|
|
39
|
+
if (testRes.exitCode === 0) {
|
|
40
|
+
// success path
|
|
41
|
+
return {...}
|
|
42
|
+
}
|
|
43
43
|
```
|
|
44
|
-
|
|
44
|
+
|
|
45
|
+
Most of the times using `shell()` to perform mutations is an antipattern. Use it mainly for verifications or idempotent actions.
|
|
45
46
|
|
|
46
47
|
## `agent()` options
|
|
47
48
|
```typescript
|
|
48
|
-
interface AgentOptions {
|
|
49
|
+
export interface AgentOptions {
|
|
49
50
|
label?: string; model?: string; role?: string; tools?: string[];
|
|
50
|
-
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
51
|
+
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
52
|
+
outputSchema?: JsonSchema;
|
|
53
|
+
retries?: number;
|
|
54
|
+
timeoutMs?: number | null;
|
|
55
|
+
[key: string]: JsonValue;
|
|
51
56
|
}
|
|
52
57
|
```
|
|
53
58
|
|
|
@@ -55,20 +60,16 @@ Extensions may add JSON-compatible agent options such as `advisor: true`; core k
|
|
|
55
60
|
|
|
56
61
|
Agent calls are unnamed. Direct calls receive hidden source call-site identity; aliases are unsupported, and calls from one source site must not race outside `parallel` or `pipeline`, whose structural keys make replay deterministic.
|
|
57
62
|
|
|
58
|
-
##
|
|
59
|
-
Use `
|
|
60
|
-
|
|
63
|
+
## Passing agent results
|
|
64
|
+
Use independent `agent(prompt, options)` calls and pass each completed result explicitly to the next prompt. This keeps workflow execution deterministic and makes replay state local to each call:
|
|
61
65
|
```js
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const fix = await handle.run("Now propose the smallest fix.");
|
|
66
|
+
const findings = await agent("Inspect the implementation.");
|
|
67
|
+
const fix = await agent(prompt("Propose the smallest fix from these findings:\n\n{findings}", { findings }));
|
|
65
68
|
return { findings, fix };
|
|
66
69
|
```
|
|
67
70
|
|
|
68
|
-
Await each `handle.run(prompt, turnOptions)` call before starting the next one; conversation turns must be sequential and cannot overlap. Conversation creation accepts the same execution-policy options as `agent()`. `timeoutMs` and `retries` passed to `run()` are turn-local, so a failed turn does not advance the persisted conversation head.
|
|
69
|
-
|
|
70
71
|
## Worktrees
|
|
71
|
-
Use `withWorktree(
|
|
72
|
+
Use `withWorktree(name, callback)` for top-level agents that collaborate in one explicitly named worktree scope:
|
|
72
73
|
```js
|
|
73
74
|
const result = await withWorktree("issue", async ({ path, branch }) => {
|
|
74
75
|
const report = await agent("Implement the issue");
|
|
@@ -94,8 +95,8 @@ Registered extension functions receive `withWorktree` in context and can compose
|
|
|
94
95
|
- Preserve item metadata in workflow code between pipeline stages instead of making agents echo it through `outputSchema`.
|
|
95
96
|
- Use a JavaScript loop for repeated work; each direct `agent(...)` call gets deterministic call-site and occurrence identity.
|
|
96
97
|
- Runs default to background; set tool-call `foreground: true` when asked to wait.
|
|
97
|
-
- Add `budget` only for aggregate limits. Valid dimensions are exactly `tokens`, `costUsd`, `durationMs`, and `agentLaunches`; each is `{ soft?: number, hard?: number }` with `soft < hard`.
|
|
98
|
-
- `budget_exhausted` runs resume through `workflow_resume`: omitted patch values stay unchanged, `null` removes a limit, and tightening resumes directly. Relaxation stores the exact proposal and returns `{ state: "awaiting_approval", proposalId }`; `workflow_respond` must answer that ID. Rejection leaves the run exhausted; approval applies the budget and cold-resumes it.
|
|
98
|
+
- Add `budget` only for aggregate limits. Do not invent limits, omit if user do not ask explicitly. Valid dimensions are exactly `tokens`, `costUsd`, `durationMs`, and `agentLaunches`; each is `{ soft?: number, hard?: number }` with `soft < hard`.
|
|
99
|
+
- `budget_exhausted` runs resume through `workflow_resume`: omitted patch values stay unchanged, `null` removes a limit, and tightening resumes directly. Relaxation stores the exact proposal and returns `{ state: "awaiting_approval", proposalId }`; `workflow_respond` must answer that ID. Rejection leaves the run exhausted; approval applies the budget and cold-resumes it. `workflow_retry` is only for persisted `failed` runs and inherits cumulative usage; replay itself consumes no budget.
|
|
99
100
|
- `parallel()` and `pipeline()` return keyed bare values; await them before use. Interpolate results with `prompt("...{value}", { value })`; placeholders in plain strings remain literal.
|
|
100
101
|
- Use `outputSchema` only when another phase compares, aggregates, or validates a result, never for final prose. Keep only consumer-needed fields and avoid repeated evidence. Agents with it must call `workflow_result`; one repair prompt is built in. Omit `retries` unless an extra retry is justified and work is idempotent.
|
|
101
102
|
- Do not add persona specifications to agent prompts; define the task directly.
|
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;
|
|
@@ -42,7 +41,6 @@ export interface AgentExecutionOptions {
|
|
|
42
41
|
budget?: AgentBudgetHooks;
|
|
43
42
|
agentOptions?: Readonly<Record<string, JsonValue>>;
|
|
44
43
|
agentIdentity?: AgentIdentity;
|
|
45
|
-
conversation?: { id: string; turn: number };
|
|
46
44
|
}
|
|
47
45
|
export interface AgentExecutionRoot {
|
|
48
46
|
cwd: string;
|
|
@@ -89,7 +87,7 @@ export interface NativeSession {
|
|
|
89
87
|
abort?(): Promise<void>;
|
|
90
88
|
dispose(): void;
|
|
91
89
|
}
|
|
92
|
-
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> }
|
|
93
91
|
export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
|
|
94
92
|
|
|
95
93
|
function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
|
|
@@ -97,7 +95,6 @@ function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: T
|
|
|
97
95
|
const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
|
|
98
96
|
return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
|
|
99
97
|
}
|
|
100
|
-
function modelCapability(model: ModelSpec): string { return `${model.provider}/${model.model}`; }
|
|
101
98
|
|
|
102
99
|
function text(messages: readonly AgentMessage[]): string {
|
|
103
100
|
const message = [...messages].reverse().find((item) => item.role === "assistant");
|
|
@@ -139,27 +136,8 @@ function canonicalSourcePath(path: string): string { try { return realpathSync(p
|
|
|
139
136
|
|
|
140
137
|
export async function createNativeAgentSession(input: SessionInput): Promise<NativeSession> {
|
|
141
138
|
const agentDir = input.agentDir ?? getAgentDir();
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
manager = SessionManager.open(input.continuation.sessionFile, input.agentDir ? join(agentDir, "sessions") : undefined, input.cwd);
|
|
146
|
-
const header = manager.getHeader();
|
|
147
|
-
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");
|
|
148
|
-
manager.branch(input.continuation.leafId);
|
|
149
|
-
const context = manager.buildSessionContext();
|
|
150
|
-
if (context.model && (context.model.provider !== input.model.provider || context.model.modelId !== input.model.model)) {
|
|
151
|
-
if (!input.allowModelChange) throw new Error("Persisted transcript model does not match the conversation execution policy");
|
|
152
|
-
manager.appendModelChange(input.model.provider, input.model.model);
|
|
153
|
-
}
|
|
154
|
-
if (input.model.thinking && context.thinkingLevel !== input.model.thinking) throw new Error("Persisted transcript thinking level does not match the conversation execution policy");
|
|
155
|
-
} catch (error) {
|
|
156
|
-
if (error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE") throw error;
|
|
157
|
-
throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot reopen conversation transcript: ${error instanceof Error ? error.message : String(error)}`);
|
|
158
|
-
}
|
|
159
|
-
} else {
|
|
160
|
-
manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
|
|
161
|
-
manager.appendSessionInfo(input.sessionLabel);
|
|
162
|
-
}
|
|
139
|
+
const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
|
|
140
|
+
manager.appendSessionInfo(input.sessionLabel);
|
|
163
141
|
const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
|
|
164
142
|
const model = modelRuntime.getModel(input.model.provider, input.model.model);
|
|
165
143
|
if (!model) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${input.model.provider}/${input.model.model}`);
|
|
@@ -203,8 +181,7 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
|
|
|
203
181
|
resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
|
|
204
182
|
await resourceLoader.reload();
|
|
205
183
|
}
|
|
206
|
-
const { session
|
|
207
|
-
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 });
|
|
208
185
|
return Object.assign(session, {
|
|
209
186
|
getLeafId: () => manager.getLeafId(),
|
|
210
187
|
getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
|
|
@@ -212,19 +189,6 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
|
|
|
212
189
|
}
|
|
213
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]); }
|
|
214
191
|
function validThinking(value: unknown): value is ThinkingLevel { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
|
|
215
|
-
function jsonValue(value: unknown, seen = new Set<object>()): value is JsonValue {
|
|
216
|
-
if (value === null || typeof value === "boolean" || typeof value === "string") return true;
|
|
217
|
-
if (typeof value === "number") return Number.isFinite(value);
|
|
218
|
-
if (typeof value !== "object" || seen.has(value)) return false;
|
|
219
|
-
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
|
|
220
|
-
const keys = Reflect.ownKeys(value);
|
|
221
|
-
if (keys.some((key) => typeof key !== "string")) return false;
|
|
222
|
-
seen.add(value);
|
|
223
|
-
const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => (value as Record<string, unknown>)[key as string])).every((item) => jsonValue(item, seen));
|
|
224
|
-
seen.delete(value);
|
|
225
|
-
return valid;
|
|
226
|
-
}
|
|
227
|
-
function jsonObject(value: unknown): value is Record<string, JsonValue> { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
228
192
|
interface ChildAgentToolParams {
|
|
229
193
|
prompt: string;
|
|
230
194
|
label: string;
|
|
@@ -256,49 +220,7 @@ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionO
|
|
|
256
220
|
function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
|
|
257
221
|
return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
|
|
258
222
|
}
|
|
259
|
-
function
|
|
260
|
-
if (Array.isArray(value)) return value.map((item) => canonicalJson(item));
|
|
261
|
-
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
|
|
262
|
-
return value;
|
|
263
|
-
}
|
|
264
|
-
function fingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex"); }
|
|
265
|
-
function promptFingerprint(value: string): string { return createHash("sha256").update(value).digest("hex"); }
|
|
266
|
-
function fixedConversationOptions(options: Readonly<Record<string, JsonValue>>): JsonValue {
|
|
267
|
-
const fixedOptions = structuredClone(options) as Record<string, JsonValue>;
|
|
268
|
-
delete fixedOptions.timeoutMs;
|
|
269
|
-
delete fixedOptions.retries;
|
|
270
|
-
return fixedOptions;
|
|
271
|
-
}
|
|
272
|
-
function conversationExecutionPolicy(options: AgentExecutionOptions, setup: AgentSetup): JsonValue {
|
|
273
|
-
return structuredClone({
|
|
274
|
-
model: setup.sessionInput.model,
|
|
275
|
-
tools: [...setup.sessionInput.tools],
|
|
276
|
-
cwd: setup.sessionInput.cwd,
|
|
277
|
-
role: options.role ?? null,
|
|
278
|
-
worktreeOwner: options.worktreeOwner ?? null,
|
|
279
|
-
parent: options.parent ?? null,
|
|
280
|
-
systemPromptAppend: setup.sessionInput.systemPromptAppend ?? "",
|
|
281
|
-
options: fixedConversationOptions(setup.options),
|
|
282
|
-
resourcePolicy: setup.sessionInput.resourcePolicy ? resourcePolicySummary(setup.sessionInput.resourcePolicy) : null,
|
|
283
|
-
}) as unknown as JsonValue;
|
|
284
|
-
}
|
|
285
|
-
function conversationPolicyMatches(expected: JsonValue, current: JsonValue, allowModelChange: boolean): boolean {
|
|
286
|
-
if (fingerprint(expected) === fingerprint(current)) return true;
|
|
287
|
-
if (!allowModelChange || !expected || typeof expected !== "object" || Array.isArray(expected) || !current || typeof current !== "object" || Array.isArray(current)) return false;
|
|
288
|
-
const expectedModel = conversationPolicyModel(expected);
|
|
289
|
-
const currentModel = conversationPolicyModel(current);
|
|
290
|
-
if (!expectedModel || !currentModel) return false;
|
|
291
|
-
return fingerprint(expected) === fingerprint({ ...current, model: { ...currentModel, provider: expectedModel.provider, model: expectedModel.model } });
|
|
292
|
-
}
|
|
293
|
-
function conversationPolicyModel(policy: JsonValue): ModelSpec | undefined {
|
|
294
|
-
if (!policy || typeof policy !== "object" || Array.isArray(policy)) return undefined;
|
|
295
|
-
const model = policy.model;
|
|
296
|
-
if (!model || typeof model !== "object" || Array.isArray(model) || typeof model.provider !== "string" || typeof model.model !== "string") return undefined;
|
|
297
|
-
const thinking = typeof model.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(model.thinking) ? model.thinking as ModelSpec["thinking"] : undefined;
|
|
298
|
-
return thinking === undefined ? { provider: model.provider, model: model.model } : { provider: model.provider, model: model.model, thinking };
|
|
299
|
-
}
|
|
300
|
-
function conversationFailure(message: string): WorkflowError { return new WorkflowError("RESUME_INCOMPATIBLE", message); }
|
|
301
|
-
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 }> {
|
|
302
224
|
const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
|
|
303
225
|
const baselineOptions = structuredClone(options.agentOptions ?? {});
|
|
304
226
|
const baseResourcePolicy = await root.agentResourcePolicy?.();
|
|
@@ -320,7 +242,6 @@ async function prepareAgentSetup(root: AgentExecutionRoot, createSession: Sessio
|
|
|
320
242
|
if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking)) setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
|
|
321
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];
|
|
322
244
|
if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
|
|
323
|
-
if (continuation) setup.sessionInput.continuation = { sessionId: continuation.sessionId, sessionFile: continuation.sessionFile, leafId: continuation.leafId };
|
|
324
245
|
const model = setup.sessionInput.model;
|
|
325
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) } : {}) };
|
|
326
247
|
return { setup, summary };
|
|
@@ -339,13 +260,14 @@ export class WorkflowAgentExecutor {
|
|
|
339
260
|
const forbidden = requested.find((tool) => !this.root.tools.has(tool));
|
|
340
261
|
if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${forbidden}`);
|
|
341
262
|
const requestedModel = options.model ?? definition?.model;
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
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;
|
|
345
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);
|
|
346
268
|
const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
|
|
347
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})` : ""}`);
|
|
348
|
-
return { model, ...(
|
|
270
|
+
return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
|
|
349
271
|
}
|
|
350
272
|
|
|
351
273
|
async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
|
|
@@ -373,20 +295,7 @@ export class WorkflowAgentExecutor {
|
|
|
373
295
|
if (options.cwd) throw new WorkflowError("INVALID_METADATA", "Only child agents or worktree scopes may provide a cwd");
|
|
374
296
|
cwd = this.root.cwd;
|
|
375
297
|
}
|
|
376
|
-
let conversationRecord: PersistedConversation | undefined;
|
|
377
|
-
if (options.conversation) {
|
|
378
|
-
const store = this.root.runStore;
|
|
379
|
-
if (!store) throw conversationFailure("Conversation persistence is unavailable");
|
|
380
|
-
try { conversationRecord = await store.conversation(options.conversation.id); } catch (error) { throw conversationFailure(`Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
|
|
381
|
-
if (conversationRecord) {
|
|
382
|
-
const model = conversationPolicyModel(conversationRecord.policy);
|
|
383
|
-
if (model) resolved = this.resolve({ ...options, modelOverride: model });
|
|
384
|
-
}
|
|
385
|
-
if (!Number.isInteger(options.conversation.turn) || options.conversation.turn < 1) throw conversationFailure("Conversation turn must be a positive integer");
|
|
386
|
-
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`);
|
|
387
|
-
}
|
|
388
298
|
const attempts: AgentAttempt[] = [];
|
|
389
|
-
let conversationBaseline: { executionPolicy: JsonValue; toolDefinitionsSha256: string; systemPrompt?: string; systemPromptSha256?: string } | undefined;
|
|
390
299
|
let maxAttempts = (options.retries ?? 0) + 1;
|
|
391
300
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
392
301
|
if (recoveryModel) resolved = this.resolve({ ...options, modelOverride: recoveryModel });
|
|
@@ -398,10 +307,6 @@ export class WorkflowAgentExecutor {
|
|
|
398
307
|
let setupFailed = false;
|
|
399
308
|
let budgetError: WorkflowError | undefined;
|
|
400
309
|
let turnStarted = false;
|
|
401
|
-
let conversationSystemPrompt = "";
|
|
402
|
-
let conversationToolDefinitionsSha256 = "";
|
|
403
|
-
let conversationMismatch: WorkflowError | undefined;
|
|
404
|
-
const conversationMismatchError = () => conversationMismatch ? new WorkflowError("RESUME_INCOMPATIBLE", conversationMismatch.message) : undefined;
|
|
405
310
|
const hasSchemaResult = () => schemaResult !== undefined;
|
|
406
311
|
const resultTool = options.schema ? {
|
|
407
312
|
name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
|
|
@@ -431,51 +336,19 @@ export class WorkflowAgentExecutor {
|
|
|
431
336
|
};
|
|
432
337
|
try {
|
|
433
338
|
setupFailed = true;
|
|
434
|
-
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);
|
|
435
340
|
setup = prepared.setup;
|
|
436
341
|
setupSummary = prepared.summary;
|
|
437
342
|
setupFailed = false;
|
|
438
343
|
if (executionSignal?.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
|
|
439
|
-
if (recoveryModel && conversationRecord) setup.sessionInput.allowModelChange = true;
|
|
440
344
|
const started = Date.now();
|
|
441
345
|
session = await setup.createSession(setup.sessionInput);
|
|
442
346
|
if (setup.sessionInput.resourcePolicy) setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
|
|
443
|
-
if (options.conversation) {
|
|
444
|
-
conversationSystemPrompt = session.systemPrompt ?? "";
|
|
445
|
-
conversationToolDefinitionsSha256 = fingerprint(session.getToolDefinitions?.() ?? session.agent?.state.tools ?? []);
|
|
446
|
-
const currentExecutionPolicy = conversationExecutionPolicy(options, setup);
|
|
447
|
-
if (conversationRecord) {
|
|
448
|
-
if (session.sessionId !== conversationRecord.head.sessionId || requiredFile(session.sessionFile) !== conversationRecord.head.sessionFile) throw conversationFailure("Conversation transcript identity changed");
|
|
449
|
-
if (!session.getLeafId || (!recoveryModel && session.getLeafId() !== conversationRecord.head.leafId)) throw conversationFailure("Conversation transcript leaf identity changed");
|
|
450
|
-
if (!conversationPolicyMatches(conversationRecord.policy, currentExecutionPolicy, Boolean(recoveryModel))) throw conversationFailure("Conversation execution policy changed");
|
|
451
|
-
if (!session.subscribe && (promptFingerprint(conversationSystemPrompt) !== conversationRecord.head.systemPromptSha256 || conversationSystemPrompt !== conversationRecord.head.systemPrompt)) throw conversationFailure("Conversation system prompt changed");
|
|
452
|
-
if (conversationToolDefinitionsSha256 !== conversationRecord.head.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
|
|
453
|
-
} else if (conversationBaseline) {
|
|
454
|
-
if (!conversationPolicyMatches(conversationBaseline.executionPolicy, currentExecutionPolicy, Boolean(recoveryModel))) throw conversationFailure("Conversation execution policy changed");
|
|
455
|
-
if (conversationToolDefinitionsSha256 !== conversationBaseline.toolDefinitionsSha256) throw conversationFailure("Conversation tool definitions changed");
|
|
456
|
-
} else {
|
|
457
|
-
conversationBaseline = { executionPolicy: currentExecutionPolicy, toolDefinitionsSha256: conversationToolDefinitionsSha256 };
|
|
458
|
-
}
|
|
459
|
-
if (!session.subscribe) {
|
|
460
|
-
const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
|
|
461
|
-
const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
|
|
462
|
-
if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) throw conversationFailure("Conversation system prompt changed");
|
|
463
|
-
if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
|
|
464
|
-
}
|
|
465
|
-
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");
|
|
466
|
-
}
|
|
467
347
|
const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
|
|
468
348
|
await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
|
|
469
349
|
const activeSession = session;
|
|
470
350
|
unsubscribe = activeSession.subscribe?.((event) => {
|
|
471
351
|
if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
|
|
472
|
-
if (options.conversation) {
|
|
473
|
-
conversationSystemPrompt = session.systemPrompt;
|
|
474
|
-
const expectedPrompt = conversationRecord?.head.systemPrompt ?? conversationBaseline?.systemPrompt;
|
|
475
|
-
const expectedDigest = conversationRecord?.head.systemPromptSha256 ?? conversationBaseline?.systemPromptSha256;
|
|
476
|
-
if (expectedPrompt !== undefined && expectedDigest !== undefined && (promptFingerprint(conversationSystemPrompt) !== expectedDigest || expectedPrompt !== conversationSystemPrompt)) { conversationMismatch = conversationFailure("Conversation system prompt changed"); void session.abort?.(); }
|
|
477
|
-
if (!conversationRecord && conversationBaseline && conversationBaseline.systemPrompt === undefined) conversationBaseline = { ...conversationBaseline, systemPrompt: conversationSystemPrompt, systemPromptSha256: promptFingerprint(conversationSystemPrompt) };
|
|
478
|
-
}
|
|
479
352
|
if (this.root.runStore) {
|
|
480
353
|
systemPromptTurn += 1;
|
|
481
354
|
const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
|
|
@@ -497,7 +370,7 @@ export class WorkflowAgentExecutor {
|
|
|
497
370
|
}
|
|
498
371
|
}
|
|
499
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); }
|
|
500
|
-
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); }
|
|
501
374
|
});
|
|
502
375
|
report(false);
|
|
503
376
|
if (setSteer) {
|
|
@@ -510,7 +383,6 @@ export class WorkflowAgentExecutor {
|
|
|
510
383
|
options.budget?.beforeTurn();
|
|
511
384
|
turnStarted = true;
|
|
512
385
|
try { await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { if (!hasSchemaResult()) throw error; }
|
|
513
|
-
if (conversationMismatch) throw conversationMismatch;
|
|
514
386
|
throwIfTerminalAssistantError(session, setup.sessionInput.model);
|
|
515
387
|
{ const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
|
|
516
388
|
if (budgetError) throw budgetError;
|
|
@@ -525,8 +397,6 @@ export class WorkflowAgentExecutor {
|
|
|
525
397
|
}
|
|
526
398
|
if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
|
|
527
399
|
}
|
|
528
|
-
const mismatch = conversationMismatchError();
|
|
529
|
-
if (mismatch) throw mismatch;
|
|
530
400
|
const value = options.schema ? schemaResult as JsonValue : text(session.messages);
|
|
531
401
|
if (options.worktreeOwner) await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
|
|
532
402
|
report(true);
|
|
@@ -534,19 +404,12 @@ export class WorkflowAgentExecutor {
|
|
|
534
404
|
await flushSystemPrompts();
|
|
535
405
|
unsubscribe?.();
|
|
536
406
|
const attemptAccounting = accounting(session.getSessionStats());
|
|
537
|
-
const leafId = session.getLeafId?.() ?? undefined;
|
|
538
|
-
if (options.conversation) {
|
|
539
|
-
if (!leafId) throw conversationFailure("Conversation transcript has no persisted leaf");
|
|
540
|
-
const store = this.root.runStore;
|
|
541
|
-
if (!store) throw conversationFailure("Conversation persistence is unavailable");
|
|
542
|
-
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 } });
|
|
543
|
-
}
|
|
544
407
|
const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
|
|
545
408
|
attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
|
|
546
409
|
session.dispose();
|
|
547
410
|
return { value, attempts, cwd: setupSummary.cwd };
|
|
548
411
|
} catch (error) {
|
|
549
|
-
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)));
|
|
550
413
|
if (session) {
|
|
551
414
|
report(true);
|
|
552
415
|
await progress;
|
|
@@ -598,7 +461,6 @@ export interface ScheduledAgentOptions {
|
|
|
598
461
|
timeoutMs?: number | null;
|
|
599
462
|
agentOptions?: Readonly<Record<string, JsonValue>>;
|
|
600
463
|
agentIdentity?: AgentIdentity;
|
|
601
|
-
conversation?: { id: string; turn: number };
|
|
602
464
|
}
|
|
603
465
|
|
|
604
466
|
export type ScheduledAgentResult =
|
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; }
|
package/src/cli.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
7
7
|
import { ProjectTrustStore, SessionManager, SettingsManager, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, hasTrustRequiringProjectResources, type LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Value } from "typebox/value";
|
|
9
9
|
import { doctor, doctorExitCode, formatDoctorReport, type DoctorOptions } from "./doctor.js";
|
|
10
|
-
import workflowExtension, { formatWorkflowProgress, workflowCatalog, type JsonSchema, type JsonValue } from "./index.js";
|
|
10
|
+
import workflowExtension, { formatWorkflowProgress, truncateWorkflowProgress, workflowCatalog, type JsonSchema, type JsonValue, type WorkflowProgressStyles } from "./index.js";
|
|
11
11
|
import { runSessionInspector, transcriptFileLines } from "./session-inspector.js";
|
|
12
12
|
import type { PersistedRun } from "./persistence.js";
|
|
13
13
|
import type { WorkflowCatalogFunction } from "./index.js";
|
|
@@ -274,16 +274,25 @@ function writeLauncher(destination: string, workflowName: string, force: boolean
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
|
|
277
|
+
function terminalProgressStyles(enabled: boolean): WorkflowProgressStyles {
|
|
278
|
+
const style = (code: number) => enabled ? (text: string) => `\x1b[${String(code)}m${text}\x1b[0m` : (text: string) => text;
|
|
279
|
+
return { accent: style(36), success: style(32), error: style(31), warning: style(33), muted: style(90), dim: style(2), bold: style(1) };
|
|
280
|
+
}
|
|
277
281
|
class CliProgress {
|
|
278
282
|
#lastStable = "";
|
|
279
283
|
#lines = 0;
|
|
280
284
|
#frame = 0;
|
|
281
285
|
#run: PersistedRun | undefined;
|
|
282
286
|
#timer: ReturnType<typeof setInterval> | undefined;
|
|
283
|
-
|
|
287
|
+
#interactive: boolean;
|
|
288
|
+
#styles: WorkflowProgressStyles;
|
|
289
|
+
constructor(private readonly stderr: (text: string) => void, tty: boolean) {
|
|
290
|
+
this.#interactive = tty && process.env.NO_COLOR === undefined && process.env.TERM !== "dumb";
|
|
291
|
+
this.#styles = terminalProgressStyles(this.#interactive);
|
|
292
|
+
}
|
|
284
293
|
update(run: PersistedRun): void {
|
|
285
|
-
const stable = formatWorkflowProgress(run, "◇");
|
|
286
|
-
if (!this
|
|
294
|
+
const stable = formatWorkflowProgress(run, "◇", this.#styles);
|
|
295
|
+
if (!this.#interactive) { if (stable !== this.#lastStable) { this.#lastStable = stable; this.stderr(`${stable}\n`); } return; }
|
|
287
296
|
this.#run = run;
|
|
288
297
|
this.#timer ??= setInterval(() => { this.render(); }, 80);
|
|
289
298
|
this.#timer.unref();
|
|
@@ -293,13 +302,13 @@ class CliProgress {
|
|
|
293
302
|
if (!this.#run) return;
|
|
294
303
|
const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][this.#frame++ % 10] ?? "◇";
|
|
295
304
|
const width = process.stderr.columns || 80;
|
|
296
|
-
const text = formatWorkflowProgress(this.#run, spinner
|
|
305
|
+
const text = truncateWorkflowProgress(formatWorkflowProgress(this.#run, spinner, this.#styles), width).join("\n");
|
|
297
306
|
this.stderr(`${this.#lines ? `\x1b[${String(this.#lines)}A` : ""}${this.#lines ? "" : "\x1b[?25l"}\x1b[0J${text}\n`);
|
|
298
307
|
this.#lines = text.split("\n").length;
|
|
299
308
|
}
|
|
300
309
|
finish(): void {
|
|
301
310
|
if (this.#timer) { clearInterval(this.#timer); this.#timer = undefined; }
|
|
302
|
-
if (this
|
|
311
|
+
if (this.#interactive && this.#lines) { this.stderr(`\x1b[${String(this.#lines)}A\x1b[0J\x1b[?25h`); this.#lines = 0; }
|
|
303
312
|
this.#run = undefined;
|
|
304
313
|
}
|
|
305
314
|
}
|