@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.
@@ -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
+ }
@@ -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");
@@ -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
@@ -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.values()) {
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
@@ -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
- if (gate?.refuse) {
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: ${gate.refuse}`, { node: currentId });
335
+ logger.warn(` harness refused the node: ${refusal}`, { node: currentId });
319
336
  result = {
320
337
  status: "failed",
321
- data: { error: gate.refuse, refused: true },
338
+ data: { error: refusal, refused: true },
322
339
  toolCalls: [],
323
- degraded: gate.degraded,
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
- result = await claude.run({
330
- instruction: currentInstruction,
331
- context,
332
- tools: trackedTools,
333
- outputSchema: node.output,
334
- maxTurns: node.max_turns,
335
- disallowedTools: node.disallowed_tools,
336
- ...(denyClasses.length > 0 ? { deny: denyClasses } : {}),
337
- model: nodeModel,
338
- signal,
339
- timeoutMs,
340
- agentAccess,
341
- ...(nodePolicy ? { policy: nodePolicy } : {}),
342
- ...(readOnlyNode ? {} : { mcpServers: skillMcpServers }),
343
- ...(readOnlyNode ? { readOnly: true } : {}),
344
- onProgress: (message) => {
345
- safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
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
- logger.warn(` node failed; halting workflow (on_fail: halt)`, { node: currentId });
521
- safeObserve(observer, { type: "route", from: currentId, to: "(end)", reason: "node failed (on_fail: halt)" }, logger);
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.