@sweny-ai/core 0.1.157 → 0.1.159

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
+ }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { ExecutionTrace, NodeResult, Workflow } from "../types.js";
10
10
  import { type RunSummary } from "./run-output.js";
11
- export declare const RUN_COMMENT_FOOTER = "Run with [SWEny](https://sweny.ai): `npx @sweny-ai/core new`";
11
+ export declare const RUN_COMMENT_FOOTER = "Run with [SWEny](https://github.com/swenyai/sweny): `npx @sweny-ai/core new`";
12
12
  export interface RunCommentOptions {
13
13
  trace?: ExecutionTrace;
14
14
  /** Per-node wall-clock duration in ms, keyed by node id. Missing entries render as "-". */
@@ -10,7 +10,7 @@ import fs from "node:fs";
10
10
  import path from "node:path";
11
11
  import { toMermaidBlock } from "../mermaid.js";
12
12
  import { STEP_SUMMARY_CLASS_DEFS, formatReceipt, formatReceiptDuration, nodeStates, } from "./run-output.js";
13
- export const RUN_COMMENT_FOOTER = "Run with [SWEny](https://sweny.ai): `npx @sweny-ai/core new`";
13
+ export const RUN_COMMENT_FOOTER = "Run with [SWEny](https://github.com/swenyai/sweny): `npx @sweny-ai/core new`";
14
14
  /** Hidden marker the poster searches for to update the comment in place. */
15
15
  export function runCommentMarker(workflowId) {
16
16
  const safe = workflowId.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/-{2,}/g, "-") || "workflow";
@@ -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";
@@ -82,7 +82,7 @@ const program = new Command()
82
82
  // ── sweny new ─────────────────────────────────────────────────────────
83
83
  program
84
84
  .command("new [id]")
85
- .description("Create a new workflow. With no id, opens the interactive picker. With an id, uses that built-in template, or installs it from the marketplace (swenyai/workflows).")
85
+ .description("Create a new workflow. With no id, opens the interactive picker. With an id, uses that built-in template.")
86
86
  .option("--template <id>", "Use a built-in template without the picker")
87
87
  .option("-y, --yes", "Skip every prompt (never overwrites existing files)")
88
88
  .action(async (id, options) => {
@@ -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");
@@ -27,7 +27,7 @@ export async function fetchMarketplaceWorkflow(id) {
27
27
  throw err;
28
28
  }
29
29
  if (res.status === 404) {
30
- const err = new Error(`Workflow "${id}" not found in ${MARKETPLACE_REPO}. See https://marketplace.sweny.ai for available workflows.`);
30
+ const err = new Error(`Workflow "${id}" not found in ${MARKETPLACE_REPO}.`);
31
31
  err.kind = "not-found";
32
32
  throw err;
33
33
  }
package/dist/cli/new.js CHANGED
@@ -625,12 +625,9 @@ export async function runNew(options) {
625
625
  const entries = await fetchMarketplaceIndex({ timeoutMs: 3000 });
626
626
  if (Array.isArray(entries) && entries.length > 0)
627
627
  marketplaceEntries = entries;
628
- else
629
- p.log.info("Marketplace has no workflows yet. Showing built-in templates.");
630
628
  }
631
- catch (err) {
632
- const reason = err instanceof Error ? err.message : String(err);
633
- p.log.info(`Marketplace unavailable (${reason}). Showing built-in templates.`);
629
+ catch {
630
+ // No reachable index: built-in templates only, and nothing about a marketplace is shown.
634
631
  }
635
632
  templateChoice = await p.select({
636
633
  message: "What do you want to do?",
@@ -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