@sweny-ai/core 0.1.157 → 0.1.158
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/dist/budget.d.ts +115 -0
- package/dist/budget.js +191 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +5 -1
- package/dist/cli/run-output.d.ts +10 -0
- package/dist/cli/run-output.js +20 -1
- package/dist/cli/workflow-input.d.ts +9 -0
- package/dist/cli/workflow-input.js +21 -0
- package/dist/executor.d.ts +12 -0
- package/dist/executor.js +93 -28
- package/dist/harness/acp.js +4 -1
- package/dist/harness/capabilities.d.ts +10 -4
- package/dist/harness/capabilities.js +12 -6
- package/dist/harness/claude-code.js +25 -1
- package/dist/harness/index.d.ts +1 -1
- package/dist/harness/index.js +1 -1
- package/dist/harness/policy.d.ts +13 -0
- package/dist/harness/policy.js +37 -0
- package/dist/harness/types.d.ts +7 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/schema.d.ts +135 -1
- package/dist/schema.js +54 -0
- package/dist/types.d.ts +25 -0
- package/package.json +1 -1
package/dist/budget.d.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend budgets (#449): token and cost ceilings per node and per run.
|
|
3
|
+
*
|
|
4
|
+
* Pure and browser-safe. `budget: { tokens, cost_usd }` on a node caps one visit
|
|
5
|
+
* to that node (every retry attempt included); on the workflow it caps the whole
|
|
6
|
+
* run, and is the ceiling for every node. The CLI's `--max-tokens` and
|
|
7
|
+
* `--max-cost` tighten the run ceiling further (the lowest wins).
|
|
8
|
+
*
|
|
9
|
+
* Tokens are input + output tokens as the harness reports them (cache reads and
|
|
10
|
+
* writes are not counted, matching the run receipt). Cost is the harness's own
|
|
11
|
+
* reported USD, never estimated. Only agent node runs are counted: judge,
|
|
12
|
+
* routing and retry-reflection completions report no usage and are not metered.
|
|
13
|
+
*
|
|
14
|
+
* Enforcement, per unit the harness can report:
|
|
15
|
+
* - live: the harness calls `onUsage` during the node; crossing a limit aborts
|
|
16
|
+
* the attempt's signal, which stops the agent (harness cancel).
|
|
17
|
+
* - between nodes: usage on the finished result is checked; a crossing fails the
|
|
18
|
+
* node, and the run halts before the next node starts.
|
|
19
|
+
*/
|
|
20
|
+
import type { NodeUsage } from "./types.js";
|
|
21
|
+
/** The `budget` field of a node or workflow. */
|
|
22
|
+
export interface Budget {
|
|
23
|
+
/** Max input + output tokens. */
|
|
24
|
+
tokens?: number;
|
|
25
|
+
/** Max reported cost in USD. */
|
|
26
|
+
cost_usd?: number;
|
|
27
|
+
}
|
|
28
|
+
/** Spend limits in the units the executor works in. Absent = unlimited. */
|
|
29
|
+
export interface SpendLimits {
|
|
30
|
+
tokens?: number;
|
|
31
|
+
costUsd?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface Spend {
|
|
34
|
+
tokens: number;
|
|
35
|
+
costUsd: number;
|
|
36
|
+
}
|
|
37
|
+
export type BudgetUnit = "tokens" | "cost_usd";
|
|
38
|
+
/** Recorded on the node whose spend crossed a budget (`NodeResult.budget`). */
|
|
39
|
+
export interface BudgetOverrun {
|
|
40
|
+
/** Which ceiling was crossed: the node's own, or the run's. */
|
|
41
|
+
scope: "node" | "run";
|
|
42
|
+
unit: BudgetUnit;
|
|
43
|
+
limit: number;
|
|
44
|
+
/** Spend at the moment of the stop (live) or at the end of the node. */
|
|
45
|
+
spent: number;
|
|
46
|
+
}
|
|
47
|
+
export declare function toLimits(b: Budget | undefined): SpendLimits;
|
|
48
|
+
/** The tighter of two limit sets, per unit. */
|
|
49
|
+
export declare function minLimits(a: SpendLimits, b: SpendLimits): SpendLimits;
|
|
50
|
+
export declare function hasLimits(l: SpendLimits): boolean;
|
|
51
|
+
/** Tokens and cost a usage report carries. A field the harness did not report stays undefined. */
|
|
52
|
+
export declare function spendOf(u: NodeUsage | undefined): {
|
|
53
|
+
tokens?: number;
|
|
54
|
+
costUsd?: number;
|
|
55
|
+
};
|
|
56
|
+
/** One line for the log, the node's `data.error` and the receipt. */
|
|
57
|
+
export declare function describeOverrun(o: BudgetOverrun, nodeId?: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* Tracks spend for one run. `node()` opens a visit to a node; `attempt()` opens
|
|
60
|
+
* one agent run inside it. Spend is committed when an attempt finishes.
|
|
61
|
+
*/
|
|
62
|
+
export declare class BudgetGuard {
|
|
63
|
+
readonly runLimits: SpendLimits;
|
|
64
|
+
private spent;
|
|
65
|
+
constructor(runLimits: SpendLimits);
|
|
66
|
+
get totals(): Spend;
|
|
67
|
+
/** True when any limit exists at all, so the executor can skip the machinery. */
|
|
68
|
+
active(nodeLimits: SpendLimits): boolean;
|
|
69
|
+
/** The run ceiling is already reached: no further attempt may start. */
|
|
70
|
+
runExhausted(): BudgetOverrun | undefined;
|
|
71
|
+
node(nodeLimits: SpendLimits): NodeBudget;
|
|
72
|
+
/** @internal */
|
|
73
|
+
commit(s: Spend): void;
|
|
74
|
+
}
|
|
75
|
+
export declare class NodeBudget {
|
|
76
|
+
private readonly guard;
|
|
77
|
+
readonly limits: SpendLimits;
|
|
78
|
+
private spent;
|
|
79
|
+
constructor(guard: BudgetGuard, limits: SpendLimits);
|
|
80
|
+
/** A limit is already reached before the next attempt (a retry after a spend-heavy attempt, or a spent run). */
|
|
81
|
+
exhausted(): BudgetOverrun | undefined;
|
|
82
|
+
attempt(outer?: AbortSignal): BudgetAttempt;
|
|
83
|
+
/** @internal */
|
|
84
|
+
get committed(): Spend;
|
|
85
|
+
/** @internal */
|
|
86
|
+
commit(s: Spend): void;
|
|
87
|
+
}
|
|
88
|
+
/** One agent run. Its `signal` is the caller's signal plus a budget stop. */
|
|
89
|
+
export declare class BudgetAttempt {
|
|
90
|
+
private readonly guard;
|
|
91
|
+
private readonly node;
|
|
92
|
+
private readonly outer?;
|
|
93
|
+
private readonly controller;
|
|
94
|
+
private live;
|
|
95
|
+
private liveUsage;
|
|
96
|
+
private stopped;
|
|
97
|
+
private readonly onOuterAbort;
|
|
98
|
+
constructor(guard: BudgetGuard, node: NodeBudget, outer?: AbortSignal | undefined);
|
|
99
|
+
get signal(): AbortSignal;
|
|
100
|
+
/** The breach that stopped this attempt live, if any. */
|
|
101
|
+
get breach(): BudgetOverrun | undefined;
|
|
102
|
+
/** Latest usage the harness reported during the run (cumulative for the attempt). */
|
|
103
|
+
get lastUsage(): NodeUsage | undefined;
|
|
104
|
+
/** Handed to the harness as `onUsage`. Reports are cumulative for this attempt. */
|
|
105
|
+
readonly onUsage: (u: NodeUsage) => void;
|
|
106
|
+
private check;
|
|
107
|
+
/**
|
|
108
|
+
* The attempt is over. Commit its spend (the larger of what the result
|
|
109
|
+
* reports and what was seen live) and return the breach, if any: the live
|
|
110
|
+
* stop, else a crossing visible only in the final usage.
|
|
111
|
+
*/
|
|
112
|
+
finish(finalUsage: NodeUsage | undefined): BudgetOverrun | undefined;
|
|
113
|
+
/** Release the outer-signal listener when the run threw before `finish`. */
|
|
114
|
+
dispose(): void;
|
|
115
|
+
}
|
package/dist/budget.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend budgets (#449): token and cost ceilings per node and per run.
|
|
3
|
+
*
|
|
4
|
+
* Pure and browser-safe. `budget: { tokens, cost_usd }` on a node caps one visit
|
|
5
|
+
* to that node (every retry attempt included); on the workflow it caps the whole
|
|
6
|
+
* run, and is the ceiling for every node. The CLI's `--max-tokens` and
|
|
7
|
+
* `--max-cost` tighten the run ceiling further (the lowest wins).
|
|
8
|
+
*
|
|
9
|
+
* Tokens are input + output tokens as the harness reports them (cache reads and
|
|
10
|
+
* writes are not counted, matching the run receipt). Cost is the harness's own
|
|
11
|
+
* reported USD, never estimated. Only agent node runs are counted: judge,
|
|
12
|
+
* routing and retry-reflection completions report no usage and are not metered.
|
|
13
|
+
*
|
|
14
|
+
* Enforcement, per unit the harness can report:
|
|
15
|
+
* - live: the harness calls `onUsage` during the node; crossing a limit aborts
|
|
16
|
+
* the attempt's signal, which stops the agent (harness cancel).
|
|
17
|
+
* - between nodes: usage on the finished result is checked; a crossing fails the
|
|
18
|
+
* node, and the run halts before the next node starts.
|
|
19
|
+
*/
|
|
20
|
+
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
21
|
+
export function toLimits(b) {
|
|
22
|
+
return {
|
|
23
|
+
...(b?.tokens !== undefined ? { tokens: b.tokens } : {}),
|
|
24
|
+
...(b?.cost_usd !== undefined ? { costUsd: b.cost_usd } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** The tighter of two limit sets, per unit. */
|
|
28
|
+
export function minLimits(a, b) {
|
|
29
|
+
const pick = (x, y) => (x === undefined ? y : y === undefined ? x : Math.min(x, y));
|
|
30
|
+
const tokens = pick(a.tokens, b.tokens);
|
|
31
|
+
const costUsd = pick(a.costUsd, b.costUsd);
|
|
32
|
+
return { ...(tokens !== undefined ? { tokens } : {}), ...(costUsd !== undefined ? { costUsd } : {}) };
|
|
33
|
+
}
|
|
34
|
+
export function hasLimits(l) {
|
|
35
|
+
return l.tokens !== undefined || l.costUsd !== undefined;
|
|
36
|
+
}
|
|
37
|
+
/** Tokens and cost a usage report carries. A field the harness did not report stays undefined. */
|
|
38
|
+
export function spendOf(u) {
|
|
39
|
+
if (!u)
|
|
40
|
+
return {};
|
|
41
|
+
const tokens = isNum(u.inputTokens) || isNum(u.outputTokens) ? (u.inputTokens ?? 0) + (u.outputTokens ?? 0) : undefined;
|
|
42
|
+
return { ...(tokens !== undefined ? { tokens } : {}), ...(isNum(u.costUsd) ? { costUsd: u.costUsd } : {}) };
|
|
43
|
+
}
|
|
44
|
+
function over(spent, limit, strictlyOver) {
|
|
45
|
+
if (limit === undefined)
|
|
46
|
+
return false;
|
|
47
|
+
return strictlyOver ? spent > limit : spent >= limit;
|
|
48
|
+
}
|
|
49
|
+
function breachOf(scope, limits, spent, strictlyOver) {
|
|
50
|
+
if (over(spent.tokens, limits.tokens, strictlyOver)) {
|
|
51
|
+
return { scope, unit: "tokens", limit: limits.tokens, spent: spent.tokens };
|
|
52
|
+
}
|
|
53
|
+
if (over(spent.costUsd, limits.costUsd, strictlyOver)) {
|
|
54
|
+
return { scope, unit: "cost_usd", limit: limits.costUsd, spent: spent.costUsd };
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
/** One line for the log, the node's `data.error` and the receipt. */
|
|
59
|
+
export function describeOverrun(o, nodeId) {
|
|
60
|
+
const fmt = (n) => o.unit === "cost_usd" ? `$${n < 0.01 ? n.toFixed(4) : n.toFixed(2)}` : String(Math.round(n));
|
|
61
|
+
const what = o.scope === "run" ? "run budget" : `node budget${nodeId ? ` for '${nodeId}'` : ""}`;
|
|
62
|
+
return `${what} exceeded: ${o.unit} ${fmt(o.spent)} of ${fmt(o.limit)}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Tracks spend for one run. `node()` opens a visit to a node; `attempt()` opens
|
|
66
|
+
* one agent run inside it. Spend is committed when an attempt finishes.
|
|
67
|
+
*/
|
|
68
|
+
export class BudgetGuard {
|
|
69
|
+
runLimits;
|
|
70
|
+
spent = { tokens: 0, costUsd: 0 };
|
|
71
|
+
constructor(runLimits) {
|
|
72
|
+
this.runLimits = runLimits;
|
|
73
|
+
}
|
|
74
|
+
get totals() {
|
|
75
|
+
return { ...this.spent };
|
|
76
|
+
}
|
|
77
|
+
/** True when any limit exists at all, so the executor can skip the machinery. */
|
|
78
|
+
active(nodeLimits) {
|
|
79
|
+
return hasLimits(this.runLimits) || hasLimits(nodeLimits);
|
|
80
|
+
}
|
|
81
|
+
/** The run ceiling is already reached: no further attempt may start. */
|
|
82
|
+
runExhausted() {
|
|
83
|
+
return breachOf("run", this.runLimits, this.spent, false);
|
|
84
|
+
}
|
|
85
|
+
node(nodeLimits) {
|
|
86
|
+
return new NodeBudget(this, nodeLimits);
|
|
87
|
+
}
|
|
88
|
+
/** @internal */
|
|
89
|
+
commit(s) {
|
|
90
|
+
this.spent = { tokens: this.spent.tokens + s.tokens, costUsd: this.spent.costUsd + s.costUsd };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export class NodeBudget {
|
|
94
|
+
guard;
|
|
95
|
+
limits;
|
|
96
|
+
spent = { tokens: 0, costUsd: 0 };
|
|
97
|
+
constructor(guard, limits) {
|
|
98
|
+
this.guard = guard;
|
|
99
|
+
this.limits = limits;
|
|
100
|
+
}
|
|
101
|
+
/** A limit is already reached before the next attempt (a retry after a spend-heavy attempt, or a spent run). */
|
|
102
|
+
exhausted() {
|
|
103
|
+
return breachOf("node", this.limits, this.spent, false) ?? this.guard.runExhausted();
|
|
104
|
+
}
|
|
105
|
+
attempt(outer) {
|
|
106
|
+
return new BudgetAttempt(this.guard, this, outer);
|
|
107
|
+
}
|
|
108
|
+
/** @internal */
|
|
109
|
+
get committed() {
|
|
110
|
+
return this.spent;
|
|
111
|
+
}
|
|
112
|
+
/** @internal */
|
|
113
|
+
commit(s) {
|
|
114
|
+
this.spent = { tokens: this.spent.tokens + s.tokens, costUsd: this.spent.costUsd + s.costUsd };
|
|
115
|
+
this.guard.commit(s);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** One agent run. Its `signal` is the caller's signal plus a budget stop. */
|
|
119
|
+
export class BudgetAttempt {
|
|
120
|
+
guard;
|
|
121
|
+
node;
|
|
122
|
+
outer;
|
|
123
|
+
controller = new AbortController();
|
|
124
|
+
live = { tokens: 0, costUsd: 0 };
|
|
125
|
+
liveUsage;
|
|
126
|
+
stopped;
|
|
127
|
+
onOuterAbort = () => this.controller.abort();
|
|
128
|
+
constructor(guard, node, outer) {
|
|
129
|
+
this.guard = guard;
|
|
130
|
+
this.node = node;
|
|
131
|
+
this.outer = outer;
|
|
132
|
+
if (outer?.aborted)
|
|
133
|
+
this.controller.abort();
|
|
134
|
+
else
|
|
135
|
+
outer?.addEventListener("abort", this.onOuterAbort, { once: true });
|
|
136
|
+
}
|
|
137
|
+
get signal() {
|
|
138
|
+
return this.controller.signal;
|
|
139
|
+
}
|
|
140
|
+
/** The breach that stopped this attempt live, if any. */
|
|
141
|
+
get breach() {
|
|
142
|
+
return this.stopped;
|
|
143
|
+
}
|
|
144
|
+
/** Latest usage the harness reported during the run (cumulative for the attempt). */
|
|
145
|
+
get lastUsage() {
|
|
146
|
+
return this.liveUsage;
|
|
147
|
+
}
|
|
148
|
+
/** Handed to the harness as `onUsage`. Reports are cumulative for this attempt. */
|
|
149
|
+
onUsage = (u) => {
|
|
150
|
+
if (this.stopped)
|
|
151
|
+
return;
|
|
152
|
+
this.liveUsage = { ...this.liveUsage, ...u };
|
|
153
|
+
const s = spendOf(u);
|
|
154
|
+
// Reports are cumulative; never let a partial report move spend backwards.
|
|
155
|
+
this.live = {
|
|
156
|
+
tokens: Math.max(this.live.tokens, s.tokens ?? 0),
|
|
157
|
+
costUsd: Math.max(this.live.costUsd, s.costUsd ?? 0),
|
|
158
|
+
};
|
|
159
|
+
const b = this.check(this.live);
|
|
160
|
+
if (b) {
|
|
161
|
+
this.stopped = b;
|
|
162
|
+
this.controller.abort();
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
check(attempt) {
|
|
166
|
+
const n = this.node.committed;
|
|
167
|
+
const r = this.guard.totals;
|
|
168
|
+
return (breachOf("node", this.node.limits, { tokens: n.tokens + attempt.tokens, costUsd: n.costUsd + attempt.costUsd }, true) ??
|
|
169
|
+
breachOf("run", this.guard.runLimits, { tokens: r.tokens + attempt.tokens, costUsd: r.costUsd + attempt.costUsd }, true));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The attempt is over. Commit its spend (the larger of what the result
|
|
173
|
+
* reports and what was seen live) and return the breach, if any: the live
|
|
174
|
+
* stop, else a crossing visible only in the final usage.
|
|
175
|
+
*/
|
|
176
|
+
finish(finalUsage) {
|
|
177
|
+
this.outer?.removeEventListener("abort", this.onOuterAbort);
|
|
178
|
+
const f = spendOf(finalUsage);
|
|
179
|
+
const spent = {
|
|
180
|
+
tokens: Math.max(this.live.tokens, f.tokens ?? 0),
|
|
181
|
+
costUsd: Math.max(this.live.costUsd, f.costUsd ?? 0),
|
|
182
|
+
};
|
|
183
|
+
const b = this.stopped ?? this.check(spent);
|
|
184
|
+
this.node.commit(spent);
|
|
185
|
+
return b;
|
|
186
|
+
}
|
|
187
|
+
/** Release the outer-signal listener when the run threw before `finish`. */
|
|
188
|
+
dispose() {
|
|
189
|
+
this.outer?.removeEventListener("abort", this.onOuterAbort);
|
|
190
|
+
}
|
|
191
|
+
}
|
package/dist/cli/main.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare function workflowRunAction(file: string | undefined, options: Rec
|
|
|
9
9
|
verbose?: boolean;
|
|
10
10
|
timeout?: string;
|
|
11
11
|
maxSteps?: string;
|
|
12
|
+
maxTokens?: string;
|
|
13
|
+
maxCost?: string;
|
|
12
14
|
yes?: boolean;
|
|
13
15
|
}): Promise<void>;
|
|
14
16
|
export declare function workflowExportAction(name: string): void;
|
package/dist/cli/main.js
CHANGED
|
@@ -20,7 +20,7 @@ import { buildAutoMcpServers, buildSkillMcpServers, buildProviderContext } from
|
|
|
20
20
|
import { loadAdditionalContext } from "../templates.js";
|
|
21
21
|
import { loadAndValidateWorkflow } from "../loader.js";
|
|
22
22
|
import { validateRuntimeInput } from "../inputs.js";
|
|
23
|
-
import { mergeDryRunIntoInput, parseInputFlag, parseRunBudgetFlags } from "./workflow-input.js";
|
|
23
|
+
import { mergeDryRunIntoInput, parseInputFlag, parseRunBudgetFlags, parseSpendFlags } from "./workflow-input.js";
|
|
24
24
|
import { stringify as stringifyYaml } from "yaml";
|
|
25
25
|
import { buildWorkflow, refineWorkflow } from "../workflow-builder.js";
|
|
26
26
|
import { toMermaidBlock } from "../mermaid.js";
|
|
@@ -730,8 +730,10 @@ export async function workflowRunAction(file, options) {
|
|
|
730
730
|
// Reject junk --timeout/--max-steps up front (both paths) instead of
|
|
731
731
|
// silently falling back to a default.
|
|
732
732
|
let budget;
|
|
733
|
+
let spendBudget;
|
|
733
734
|
try {
|
|
734
735
|
budget = parseRunBudgetFlags(options.timeout, options.maxSteps, DEFAULT_WORKFLOW_TIMEOUT_MS);
|
|
736
|
+
spendBudget = parseSpendFlags(options.maxTokens, options.maxCost);
|
|
735
737
|
}
|
|
736
738
|
catch (err) {
|
|
737
739
|
console.error(chalk.red(`\n ${err instanceof Error ? err.message : String(err)}\n`));
|
|
@@ -1000,6 +1002,8 @@ export async function workflowRunAction(file, options) {
|
|
|
1000
1002
|
signal,
|
|
1001
1003
|
max_steps: wfMaxSteps,
|
|
1002
1004
|
stageOutputs: options.stage === true,
|
|
1005
|
+
...(spendBudget ? { budget: spendBudget } : {}),
|
|
1006
|
+
harnessPolicy: resolveHarnessPolicy(process.env, typeof options.harnessPolicy === "string" ? options.harnessPolicy : undefined, runLogger),
|
|
1003
1007
|
}), wfTimeoutMs, `Workflow ${workflow.name}`);
|
|
1004
1008
|
const wfDurationMs = Date.now() - runStart;
|
|
1005
1009
|
const wfHasFailed = [...results.values()].some((r) => r.status === "failed");
|
package/dist/cli/run-output.d.ts
CHANGED
|
@@ -25,6 +25,14 @@ export interface RunSummary {
|
|
|
25
25
|
* (`max_turns`, `egress allowlist`, `deny [write]`). Absent when none.
|
|
26
26
|
*/
|
|
27
27
|
degraded?: string[];
|
|
28
|
+
/** The spend budget a node crossed (#449). Absent when none was. */
|
|
29
|
+
budget?: {
|
|
30
|
+
node: string;
|
|
31
|
+
scope: "node" | "run";
|
|
32
|
+
unit: "tokens" | "cost_usd";
|
|
33
|
+
limit: number;
|
|
34
|
+
spent: number;
|
|
35
|
+
};
|
|
28
36
|
}
|
|
29
37
|
/** The short key of a `degraded` entry: the text before its first colon. */
|
|
30
38
|
export declare function degradedKey(entry: string): string;
|
|
@@ -33,6 +41,8 @@ export declare function summarizeRun(results: Map<string, NodeResult>, durationM
|
|
|
33
41
|
export declare function formatReceiptDuration(ms: number): string;
|
|
34
42
|
export declare function formatTokenCount(n: number): string;
|
|
35
43
|
export declare function formatCost(usd: number): string;
|
|
44
|
+
/** `budget exceeded: tokens 52k of 50k (run, at node gather)`. */
|
|
45
|
+
export declare function formatBudgetOverrun(b: NonNullable<RunSummary["budget"]>): string;
|
|
36
46
|
/**
|
|
37
47
|
* One plain line: `✓ 3/3 nodes · 41 tool calls · 2m10s · 12k tokens · $0.18`.
|
|
38
48
|
* Token and cost segments are omitted when the SDK reported none. A harness
|
package/dist/cli/run-output.js
CHANGED
|
@@ -24,8 +24,11 @@ export function summarizeRun(results, durationMs, crashed = false) {
|
|
|
24
24
|
let costUsd;
|
|
25
25
|
let harness;
|
|
26
26
|
const degraded = new Set();
|
|
27
|
+
let budget;
|
|
27
28
|
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
28
|
-
for (const r of results
|
|
29
|
+
for (const [nodeId, r] of results) {
|
|
30
|
+
if (r.budget && !budget)
|
|
31
|
+
budget = { node: nodeId, ...r.budget };
|
|
29
32
|
harness ??= r.harness?.id;
|
|
30
33
|
for (const d of r.degraded ?? [])
|
|
31
34
|
degraded.add(degradedKey(d));
|
|
@@ -57,6 +60,7 @@ export function summarizeRun(results, durationMs, crashed = false) {
|
|
|
57
60
|
...(costUsd !== undefined ? { costUsd } : {}),
|
|
58
61
|
...(harness !== undefined ? { harness } : {}),
|
|
59
62
|
...(degraded.size > 0 ? { degraded: [...degraded] } : {}),
|
|
63
|
+
...(budget ? { budget } : {}),
|
|
60
64
|
};
|
|
61
65
|
}
|
|
62
66
|
export function formatReceiptDuration(ms) {
|
|
@@ -81,6 +85,12 @@ export function formatCost(usd) {
|
|
|
81
85
|
return "<$0.01";
|
|
82
86
|
return `$${usd.toFixed(2)}`;
|
|
83
87
|
}
|
|
88
|
+
/** `budget exceeded: tokens 52k of 50k (run, at node gather)`. */
|
|
89
|
+
export function formatBudgetOverrun(b) {
|
|
90
|
+
const amount = (n) => b.unit === "cost_usd" ? (n < 0.01 ? `$${n.toFixed(4)}` : formatCost(n)) : formatTokenCount(n);
|
|
91
|
+
const unit = b.unit === "cost_usd" ? "cost" : "tokens";
|
|
92
|
+
return `budget exceeded: ${unit} ${amount(b.spent)} of ${amount(b.limit)} (${b.scope}, at node ${b.node})`;
|
|
93
|
+
}
|
|
84
94
|
/**
|
|
85
95
|
* One plain line: `✓ 3/3 nodes · 41 tool calls · 2m10s · 12k tokens · $0.18`.
|
|
86
96
|
* Token and cost segments are omitted when the SDK reported none. A harness
|
|
@@ -98,6 +108,7 @@ export function formatReceipt(s) {
|
|
|
98
108
|
...(s.costUsd !== undefined ? [formatCost(s.costUsd)] : []),
|
|
99
109
|
...(s.harness !== undefined && s.harness !== "claude-code" ? [s.harness] : []),
|
|
100
110
|
...(s.degraded && s.degraded.length > 0 ? [`degraded: ${s.degraded.join(", ")}`] : []),
|
|
111
|
+
...(s.budget ? [formatBudgetOverrun(s.budget)] : []),
|
|
101
112
|
];
|
|
102
113
|
return parts.join(" · ");
|
|
103
114
|
}
|
|
@@ -193,6 +204,14 @@ export const WORKFLOW_RUN_OPTIONS = [
|
|
|
193
204
|
"--max-steps <n>",
|
|
194
205
|
"Hard cap on total node executions for a single workflow file, including eval-failure retries (default: 200)",
|
|
195
206
|
],
|
|
207
|
+
[
|
|
208
|
+
"--max-tokens <n>",
|
|
209
|
+
"Run-wide token budget (input plus output) for a single workflow file. The lowest of this and the workflow's budget.tokens wins. A crossing stops the agent, fails the node and halts the run",
|
|
210
|
+
],
|
|
211
|
+
[
|
|
212
|
+
"--max-cost <usd>",
|
|
213
|
+
"Run-wide cost budget in US dollars for a single workflow file, from harness-reported cost (never estimated). The lowest of this and the workflow's budget.cost_usd wins",
|
|
214
|
+
],
|
|
196
215
|
["-y, --yes", "Skip the batch confirmation prompt (for CI)"],
|
|
197
216
|
[
|
|
198
217
|
"--dry-run",
|
|
@@ -39,6 +39,15 @@ export declare function parseRunBudgetFlags(timeout: string | undefined, maxStep
|
|
|
39
39
|
timeoutMs: number;
|
|
40
40
|
maxSteps: number | undefined;
|
|
41
41
|
};
|
|
42
|
+
/**
|
|
43
|
+
* Parse `--max-tokens <n>` and `--max-cost <usd>` (#449) into a run budget.
|
|
44
|
+
* Both are optional; junk is rejected up front instead of silently running
|
|
45
|
+
* with no ceiling. Returns `undefined` when neither flag is set.
|
|
46
|
+
*/
|
|
47
|
+
export declare function parseSpendFlags(maxTokens: string | undefined, maxCost: string | undefined): {
|
|
48
|
+
tokens?: number;
|
|
49
|
+
cost_usd?: number;
|
|
50
|
+
} | undefined;
|
|
42
51
|
/**
|
|
43
52
|
* Parse the `--input` flag (#339). The old catch printed only "--input must be
|
|
44
53
|
* valid JSON" and dropped the parser's reason, so a stray quote from shell
|
|
@@ -54,6 +54,27 @@ export function parseRunBudgetFlags(timeout, maxSteps, defaultTimeoutMs) {
|
|
|
54
54
|
maxSteps: maxSteps === undefined ? undefined : parse(maxSteps, "--max-steps", 1),
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Parse `--max-tokens <n>` and `--max-cost <usd>` (#449) into a run budget.
|
|
59
|
+
* Both are optional; junk is rejected up front instead of silently running
|
|
60
|
+
* with no ceiling. Returns `undefined` when neither flag is set.
|
|
61
|
+
*/
|
|
62
|
+
export function parseSpendFlags(maxTokens, maxCost) {
|
|
63
|
+
const parse = (raw, flag, integer) => {
|
|
64
|
+
const t = raw.trim();
|
|
65
|
+
const n = t === "" ? Number.NaN : Number(t);
|
|
66
|
+
if (!Number.isFinite(n) || n <= 0 || (integer && !Number.isInteger(n))) {
|
|
67
|
+
throw new Error(`${flag} must be a positive ${integer ? "integer" : "number"}, got "${raw}"` +
|
|
68
|
+
(integer ? " (input plus output tokens)" : " (US dollars, for example 2.50)"));
|
|
69
|
+
}
|
|
70
|
+
return n;
|
|
71
|
+
};
|
|
72
|
+
const tokens = maxTokens === undefined ? undefined : parse(maxTokens, "--max-tokens", true);
|
|
73
|
+
const cost = maxCost === undefined ? undefined : parse(maxCost, "--max-cost", false);
|
|
74
|
+
if (tokens === undefined && cost === undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
return { ...(tokens !== undefined ? { tokens } : {}), ...(cost !== undefined ? { cost_usd: cost } : {}) };
|
|
77
|
+
}
|
|
57
78
|
/**
|
|
58
79
|
* Parse the `--input` flag (#339). The old catch printed only "--input must be
|
|
59
80
|
* valid JSON" and dropped the parser's reason, so a stray quote from shell
|
package/dist/executor.d.ts
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* This replaces ~8k lines of engine + recipe step code.
|
|
14
14
|
*/
|
|
15
15
|
import type { Workflow, Skill, Tool, Claude, Observer, Logger, ExecutionResult } from "./types.js";
|
|
16
|
+
import type { HarnessPolicyMode } from "./harness/policy.js";
|
|
16
17
|
import type { AgentHarness } from "./harness/types.js";
|
|
18
|
+
import type { Budget } from "./budget.js";
|
|
17
19
|
import { type ActorInfo } from "./safe-outputs.js";
|
|
18
20
|
export interface ExecuteOptions {
|
|
19
21
|
/** Registered skills (id → Skill) */
|
|
@@ -85,6 +87,16 @@ export interface ExecuteOptions {
|
|
|
85
87
|
* association in the `GITHUB_EVENT_PATH` payload.
|
|
86
88
|
*/
|
|
87
89
|
actor?: ActorInfo;
|
|
90
|
+
/**
|
|
91
|
+
* Run-wide spend ceiling (#449), tightening `workflow.budget` (the lowest of
|
|
92
|
+
* the two wins per unit). CLI: `--max-tokens`, `--max-cost`. Default: none.
|
|
93
|
+
*/
|
|
94
|
+
budget?: Budget;
|
|
95
|
+
/**
|
|
96
|
+
* `strict` refuses a node whose spend budget the harness cannot enforce at
|
|
97
|
+
* all (cost on Codex). Default: {@link resolveHarnessPolicy} from the run env.
|
|
98
|
+
*/
|
|
99
|
+
harnessPolicy?: HarnessPolicyMode;
|
|
88
100
|
}
|
|
89
101
|
/**
|
|
90
102
|
* Default workflow-level step budget. Counts total node executions across the
|
package/dist/executor.js
CHANGED
|
@@ -23,7 +23,8 @@ import { validateWorkflow } from "./schema.js";
|
|
|
23
23
|
import { resolveAgentAccess } from "./agent-env.js";
|
|
24
24
|
import { fenceUntrusted } from "./untrusted.js";
|
|
25
25
|
import { asClaude } from "./harness/compat.js";
|
|
26
|
-
import { isToolClass, policyGate } from "./harness/policy.js";
|
|
26
|
+
import { budgetGate, isToolClass, policyGate, resolveHarnessPolicy } from "./harness/policy.js";
|
|
27
|
+
import { BudgetGuard, describeOverrun, minLimits, toLimits } from "./budget.js";
|
|
27
28
|
import { buildNodePolicy, resolveNodePermissions } from "./node-policy.js";
|
|
28
29
|
import { applySafeOutputs, createEmitOutputTool, createWriteStageState, resolveActor, safeOutputsInstruction, unresolvedOutputs, SAFE_OUTPUT_SCREEN_INSTRUCTION, } from "./safe-outputs.js";
|
|
29
30
|
/**
|
|
@@ -82,6 +83,10 @@ export async function execute(workflow, input, options) {
|
|
|
82
83
|
const actor = usesOutputs ? resolveActor(runEnv, options.actor) : {};
|
|
83
84
|
// The run input, for `number: { input }` issue pins on outputs.
|
|
84
85
|
const runInput = input && typeof input === "object" ? input : undefined;
|
|
86
|
+
// Spend budgets (#449): the run ceiling is the lower of the workflow's
|
|
87
|
+
// `budget` and the caller's (`--max-tokens`, `--max-cost`).
|
|
88
|
+
const budgetGuard = new BudgetGuard(minLimits(toLimits(workflow.budget), toLimits(options.budget)));
|
|
89
|
+
const harnessPolicy = options.harnessPolicy ?? resolveHarnessPolicy(runEnv);
|
|
85
90
|
// Build an eval-time alias table from the loaded skills. Each skill owns
|
|
86
91
|
// its own mapping between skill-tool names and equivalent MCP names. Core
|
|
87
92
|
// stays vendor-neutral; this call just unions what the skills declare.
|
|
@@ -312,39 +317,98 @@ export async function execute(workflow, input, options) {
|
|
|
312
317
|
const gate = nodePolicy && options.harness
|
|
313
318
|
? policyGate(options.harness.capabilities, { ...nodePolicy, egress: [] })
|
|
314
319
|
: undefined;
|
|
320
|
+
// Spend budgets (#449): this node's own limits plus what is left of the
|
|
321
|
+
// run's. The gate says whether the harness can keep them; a strict policy
|
|
322
|
+
// refuses the node when it cannot report the budgeted unit at all.
|
|
323
|
+
const nodeBudget = budgetGuard.node(toLimits(node.budget));
|
|
324
|
+
const budgetOn = budgetGuard.active(nodeBudget.limits);
|
|
325
|
+
const budgetCheck = budgetOn && options.harness
|
|
326
|
+
? budgetGate(options.harness.capabilities, minLimits(nodeBudget.limits, budgetGuard.runLimits), permissions.strict || harnessPolicy === "strict")
|
|
327
|
+
: undefined;
|
|
328
|
+
// A budget stop: fail_soft and on_fail: continue never apply, the run halts.
|
|
329
|
+
let budgetStop;
|
|
330
|
+
let previous;
|
|
315
331
|
while (true) {
|
|
316
|
-
|
|
332
|
+
const refusal = gate?.refuse ?? budgetCheck?.refuse;
|
|
333
|
+
if (refusal) {
|
|
317
334
|
// Not an agent failure: fail_soft never softens a policy refusal.
|
|
318
|
-
logger.warn(` harness refused the node: ${
|
|
335
|
+
logger.warn(` harness refused the node: ${refusal}`, { node: currentId });
|
|
319
336
|
result = {
|
|
320
337
|
status: "failed",
|
|
321
|
-
data: { error:
|
|
338
|
+
data: { error: refusal, refused: true },
|
|
322
339
|
toolCalls: [],
|
|
323
|
-
degraded: gate
|
|
340
|
+
degraded: [...(gate?.degraded ?? []), ...(budgetCheck?.degraded ?? [])],
|
|
341
|
+
};
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
// No attempt may start once a ceiling is reached (the run's budget spent
|
|
345
|
+
// by earlier nodes, or this node's by an earlier attempt).
|
|
346
|
+
const spent = budgetOn ? nodeBudget.exhausted() : undefined;
|
|
347
|
+
if (spent) {
|
|
348
|
+
const error = describeOverrun(spent, currentId);
|
|
349
|
+
logger.warn(` ${error}; not starting the node`, { node: currentId });
|
|
350
|
+
result = {
|
|
351
|
+
...(previous ?? { toolCalls: [] }),
|
|
352
|
+
status: "failed",
|
|
353
|
+
data: { ...(previous?.data ?? {}), error, budget_exceeded: true },
|
|
354
|
+
budget: spent,
|
|
324
355
|
};
|
|
356
|
+
agentRunFailed = true;
|
|
357
|
+
budgetStop = spent;
|
|
325
358
|
break;
|
|
326
359
|
}
|
|
327
360
|
// Only the final attempt's intents may be applied.
|
|
328
361
|
intents.length = 0;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
362
|
+
const attemptBudget = budgetOn ? nodeBudget.attempt(signal) : undefined;
|
|
363
|
+
try {
|
|
364
|
+
result = await claude.run({
|
|
365
|
+
instruction: currentInstruction,
|
|
366
|
+
context,
|
|
367
|
+
tools: trackedTools,
|
|
368
|
+
outputSchema: node.output,
|
|
369
|
+
maxTurns: node.max_turns,
|
|
370
|
+
disallowedTools: node.disallowed_tools,
|
|
371
|
+
...(denyClasses.length > 0 ? { deny: denyClasses } : {}),
|
|
372
|
+
model: nodeModel,
|
|
373
|
+
signal: attemptBudget?.signal ?? signal,
|
|
374
|
+
timeoutMs,
|
|
375
|
+
agentAccess,
|
|
376
|
+
...(nodePolicy ? { policy: nodePolicy } : {}),
|
|
377
|
+
...(readOnlyNode ? {} : { mcpServers: skillMcpServers }),
|
|
378
|
+
...(readOnlyNode ? { readOnly: true } : {}),
|
|
379
|
+
...(attemptBudget ? { onUsage: attemptBudget.onUsage } : {}),
|
|
380
|
+
onProgress: (message) => {
|
|
381
|
+
safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
attemptBudget?.dispose();
|
|
387
|
+
}
|
|
388
|
+
previous = result;
|
|
389
|
+
if (budgetCheck && budgetCheck.degraded.length > 0) {
|
|
390
|
+
result = { ...result, degraded: [...new Set([...(result.degraded ?? []), ...budgetCheck.degraded])] };
|
|
391
|
+
}
|
|
392
|
+
if (attemptBudget) {
|
|
393
|
+
// Commit this attempt's spend; a live stop, or a crossing visible only
|
|
394
|
+
// in the final usage, fails the node whatever else it did.
|
|
395
|
+
const breach = attemptBudget.finish(result.usage);
|
|
396
|
+
if (breach) {
|
|
397
|
+
const error = describeOverrun(breach, currentId);
|
|
398
|
+
logger.warn(` ${error}`, { node: currentId });
|
|
399
|
+
const usage = result.usage ?? attemptBudget.lastUsage;
|
|
400
|
+
result = {
|
|
401
|
+
...result,
|
|
402
|
+
status: "failed",
|
|
403
|
+
data: { ...result.data, error, budget_exceeded: true },
|
|
404
|
+
...(usage ? { usage } : {}),
|
|
405
|
+
budget: breach,
|
|
406
|
+
};
|
|
407
|
+
agentRunFailed = true;
|
|
408
|
+
budgetStop = breach;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
348
412
|
// Opinions the harness could not honor natively: said once per node, never silently dropped.
|
|
349
413
|
if (!degradedLogged && result.degraded && result.degraded.length > 0) {
|
|
350
414
|
degradedLogged = true;
|
|
@@ -450,7 +514,7 @@ export async function execute(workflow, input, options) {
|
|
|
450
514
|
// observers. Eval failures never take this path (agentRunFailed guards it).
|
|
451
515
|
// A strict-policy refusal (#331) is not an agent failure: fail_soft never softens it.
|
|
452
516
|
const refused = result.data?.refused === true;
|
|
453
|
-
if (agentRunFailed && result.status === "failed" && node.fail_soft === true && !refused) {
|
|
517
|
+
if (agentRunFailed && result.status === "failed" && node.fail_soft === true && !refused && !budgetStop) {
|
|
454
518
|
const failError = result.data?.error;
|
|
455
519
|
logger.warn(` fail_soft: node failed (${String(failError ?? "unknown error")}); continuing with partial output`, {
|
|
456
520
|
node: currentId,
|
|
@@ -516,9 +580,10 @@ export async function execute(workflow, input, options) {
|
|
|
516
580
|
// the back of a failure. The failed result stays in `results`, so the run
|
|
517
581
|
// surfaces as failed to callers (CLI exit code, cloud status). Authors who
|
|
518
582
|
// want the legacy fall-through opt in per-node with `on_fail: "continue"`.
|
|
519
|
-
if (result.status === "failed" && (node.on_fail ?? "halt") === "halt") {
|
|
520
|
-
|
|
521
|
-
|
|
583
|
+
if (result.status === "failed" && ((node.on_fail ?? "halt") === "halt" || budgetStop)) {
|
|
584
|
+
const why = budgetStop ? "budget exceeded" : "on_fail: halt";
|
|
585
|
+
logger.warn(` node failed; halting workflow (${why})`, { node: currentId });
|
|
586
|
+
safeObserve(observer, { type: "route", from: currentId, to: "(end)", reason: `node failed (${why})` }, logger);
|
|
522
587
|
break;
|
|
523
588
|
}
|
|
524
589
|
// Dry run gate + routing — shared with requires path via advanceFromNode helper.
|