From ee2383c567638a7614fcd2f9ab38cb8ba37b1879 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 30 Sep 2026 07:33:09 -0700 Subject: [PATCH 1/3] fix(sdk): an authored budget refusal names the limit it crossed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kernel refuses admission of an authored flow's next step with only `budget_exceeded`, and the authored runner reported "Flow budget exceeded before the next step." — no limit, no numbers. Cloud run f28314ed stopped on it after 26 successful steps, and the operator had to reconstruct from step durations that the flow header's `wallclock: "2h"` (summed step time, 132.9 min) had tripped, not the dollars cap or the Cloud run budget. The accumulator already carries the exact totals it sends as prior_spend, so the refusal now names each declared limit the carried spend crossed, with used vs declared, using the kernel's strict comparisons, and names the refused step when the child spec has exactly one: Flow budget exceeded before step "run-27": wallclock 132.9m used of 2h declared in the flow's budget header. Dollars say when some charges were unmetered, and a day-window budget says so. When the carried totals cannot explain the refusal, the original wording stays, so the SDK never invents a reason. Error code and completion reason are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/sdk/src/authored-budget.ts | 52 ++++++++++++++++++- packages/sdk/tests/budget-attribution.test.ts | 40 +++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/authored-budget.ts b/packages/sdk/src/authored-budget.ts index f934798ec..b60afe900 100644 --- a/packages/sdk/src/authored-budget.ts +++ b/packages/sdk/src/authored-budget.ts @@ -21,6 +21,54 @@ interface Charge { unmetered: boolean; } +type Total = Omit; + +/** `ms` as the budget header writes durations: `45s`, `2h`, `132.9m`. */ +function duration(ms: bigint): string { + const n = Number(ms); + if (n < 60_000) return `${Number((n / 1000).toFixed(1))}s`; + if (n % 3_600_000 === 0) return `${n / 3_600_000}h`; + return `${Number((n / 60_000).toFixed(1))}m`; +} + +const usd = (micro: bigint): string => `$${micro / 1_000_000n}.${String(micro % 1_000_000n).padStart(6, '0').replace(/0{1,4}$/, '')}`; + +/** A header dollar amount in microdollars, or undefined if it is not an exact decimal. */ +function microOf(value: string): bigint | undefined { + const match = /^(\d+)(?:\.(\d{1,6}))?$/.exec(value); + return match === null ? undefined : BigInt(match[1]!) * 1_000_000n + BigInt((match[2] ?? '').padEnd(6, '0')); +} + +/** + * The kernel refuses admission with only `budget_exceeded`; name which + * declared limit the carried spend crossed and by how much, using the same + * strict comparisons as `machine/budget.rs`. The kernel stays the authority: a + * refusal this accumulator cannot explain keeps the generic wording. + */ +export function budgetExceededMessage(limit: KernelBudgetSpec, total: Total, spec?: KernelRunSpec): string { + const crossed: string[] = []; + if (limit.max_wallclock_ms !== undefined && total.ms > BigInt(limit.max_wallclock_ms)) { + crossed.push(`wallclock ${duration(total.ms)} used of ${duration(BigInt(limit.max_wallclock_ms))} declared`); + } + const dollarLimit = limit.max_dollars === undefined ? undefined : microOf(limit.max_dollars); + if (dollarLimit !== undefined && total.micro > dollarLimit) { + crossed.push(`dollars ${usd(total.micro)}${total.unmetered ? ' metered (some steps unmetered)' : ''} used of ${usd(dollarLimit)} declared`); + } + if (limit.max_tokens !== undefined && total.input + total.output > BigInt(limit.max_tokens)) { + crossed.push(`tokens ${total.input + total.output} used of ${limit.max_tokens} declared`); + } + if (limit.max_tokens_in !== undefined && total.input > BigInt(limit.max_tokens_in)) { + crossed.push(`input tokens ${total.input} used of ${limit.max_tokens_in} declared`); + } + if (limit.max_tokens_out !== undefined && total.output > BigInt(limit.max_tokens_out)) { + crossed.push(`output tokens ${total.output} used of ${limit.max_tokens_out} declared`); + } + const next = spec?.steps.length === 1 && typeof spec.steps[0]?.id === 'string' ? `step "${spec.steps[0].id}"` : 'the next step'; + if (crossed.length === 0) return `Flow budget exceeded before ${next}.`; + const scope = limit.window === 'day' ? "in today's window of the flow's budget header" : "in the flow's budget header"; + return `Flow budget exceeded before ${next}: ${crossed.join('; ')} ${scope}.`; +} + /** Serialized admission for the internal authored runner's separate step runs. */ export class AuthoredBudget { private readonly limit: KernelBudgetSpec | undefined; @@ -66,7 +114,9 @@ export class AuthoredBudget { }; const outcome = await journal.runStart({ ...spec, budget: { ...this.limit, prior_spend: priorSpend } }, undefined, admissionKey); try { - if (outcome.completion_reason === 'budget_exceeded') throw new AuthoredFlowExecutionError('step_failed', 'Flow budget exceeded before the next step.', 'budget_exceeded', outcome.run_id); + if (outcome.completion_reason === 'budget_exceeded') { + throw new AuthoredFlowExecutionError('step_failed', budgetExceededMessage(this.limit!, total, spec), 'budget_exceeded', outcome.run_id); + } return await consume(outcome); } finally { let seq = 1; diff --git a/packages/sdk/tests/budget-attribution.test.ts b/packages/sdk/tests/budget-attribution.test.ts index cbfd3bc02..287d4c7ec 100644 --- a/packages/sdk/tests/budget-attribution.test.ts +++ b/packages/sdk/tests/budget-attribution.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'; import { describe, expect, it, vi } from 'vitest'; import { decodeProviderResult, decodeWrapperResult, requirePricedUsage } from '../src/worker-usage.js'; import { pricedUsage, MODEL_PRICING } from '../src/model-pricing.js'; -import { AuthoredBudget } from '../src/authored-budget.js'; +import { AuthoredBudget, budgetExceededMessage } from '../src/authored-budget.js'; import type { JournalClient } from '../src/journal-client.js'; import type { StepDispatchEvent } from '../src/protocol.js'; import { LlmWorker } from '../src/llm-worker.js'; @@ -68,3 +68,41 @@ describe('budget attribution', () => { expect(calls[1]?.[0].budget.prior_spend).toMatchObject({tokens_in:500, tokens_out:500, dollars:'0.000000', wallclock_ms:5, dollars_unmetered:true}); }); }); + +describe('authored budget refusal names the limit it crossed', () => { + const charge = (payload: Record) => ({seq: 1, entry_type: 'step.completed', at_ms: 0, payload}); + // The shape of Cloud run f28314ed: 26 steps summed to 132.9 min of step + // wallclock under `{ wallclock: "2h", dollars: 25 }`, and the refusal said + // only "Flow budget exceeded before the next step." — not which limit. + it('reports wallclock used against the declared header on refusal', async () => { + const budget = new AuthoredBudget({wallclock: '2h', dollars: 25}); + let started = 0; + const client = { + runStart: vi.fn(async () => ({run_id: `r${++started}`, status: started === 1 ? 'completed' : 'failed', + completion_reason: started === 1 ? 'success' : 'budget_exceeded', completed_steps: started === 1 ? 1 : 0})), + journalRead: vi.fn(async (run: string, seq: number) => ({entries: run === 'r1' && seq === 1 + ? [charge({budget: {tokens_in: 878, tokens_out: 257687, dollars: '6.446600'}, spend: {wallclock_ms: 7_974_000}})] : []})), + }; + const step = (id: string) => ({version: '0.1.0', steps: [{id}]}) as never; + await budget.execute(client as unknown as JournalClient, step('agent-26'), async () => 'ok'); + const refused = budget.execute(client as unknown as JournalClient, step('run-27'), async () => 'unreachable'); + await expect(refused).rejects.toMatchObject({ + message: 'step_failed: Flow budget exceeded before step "run-27": wallclock 132.9m used of 2h declared in the flow\'s budget header.', + code: 'step_failed', completionReason: 'budget_exceeded', + }); + }); + it('names every crossed dimension, marks unmetered dollars, and scopes a day window', () => { + const total = {input: 900n, output: 300n, micro: 25_500_000n, ms: 30_000n, unmetered: true}; + expect(budgetExceededMessage({max_dollars: '25', max_tokens: 1000, max_wallclock_ms: 60_000, window: 'day'}, total)) + .toBe("Flow budget exceeded before the next step: dollars $25.50 metered (some steps unmetered) used of $25.00 declared; tokens 1200 used of 1000 declared in today's window of the flow's budget header."); + expect(budgetExceededMessage({max_tokens_in: 800, max_tokens_out: 300}, total)) + .toBe("Flow budget exceeded before the next step: input tokens 900 used of 800 declared in the flow's budget header."); + }); + it('keeps the generic wording when the carried totals do not explain the kernel refusal', () => { + // Equal is not over: the kernel compares strictly, so this total alone + // did not trip it and the accumulator must not invent a reason. + expect(budgetExceededMessage({max_wallclock_ms: 60_000, max_dollars: '1'}, {input: 0n, output: 0n, micro: 1_000_000n, ms: 60_000n, unmetered: false})) + .toBe('Flow budget exceeded before the next step.'); + }); +}); + From dca79253a918e9b18ba7a4eca6c5e52955863ef2 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 30 Sep 2026 07:55:54 -0700 Subject: [PATCH 2/3] fix(sdk): a budget refusal never prints an overrun as equal to its limit Review of #594: rounding both durations to one decimal minute/second could render a strict overrun as "2m used of 2m declared" (120001 vs 120000 ms) or a sub-second one as "0s used of 0s"; both now fall back to exact milliseconds when the rounded forms coincide. A legacy maxDollars with more than six decimals was dropped from the message although the kernel compares it as written; the carried microdollars are now compared at the limit's own precision and the limit is printed as declared. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/sdk/src/authored-budget.ts | 33 ++++++++++++++----- packages/sdk/tests/budget-attribution.test.ts | 18 ++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/authored-budget.ts b/packages/sdk/src/authored-budget.ts index b60afe900..9d4d1431d 100644 --- a/packages/sdk/src/authored-budget.ts +++ b/packages/sdk/src/authored-budget.ts @@ -33,10 +33,24 @@ function duration(ms: bigint): string { const usd = (micro: bigint): string => `$${micro / 1_000_000n}.${String(micro % 1_000_000n).padStart(6, '0').replace(/0{1,4}$/, '')}`; -/** A header dollar amount in microdollars, or undefined if it is not an exact decimal. */ -function microOf(value: string): bigint | undefined { - const match = /^(\d+)(?:\.(\d{1,6}))?$/.exec(value); - return match === null ? undefined : BigInt(match[1]!) * 1_000_000n + BigInt((match[2] ?? '').padEnd(6, '0')); +/** + * Whether `micro` microdollars exceeds the decimal `limit`, compared exactly at + * the limit's own precision (legacy `maxDollars` may carry more than six + * decimals, which the kernel compares as written). Undefined if the limit is + * not a plain decimal. + */ +function dollarsOver(micro: bigint, limit: string): boolean | undefined { + const match = /^(\d+)(?:\.(\d+))?$/.exec(limit); + if (match === null) return undefined; + const scale = Math.max(6, match[2]?.length ?? 0); + const limitScaled = BigInt(match[1]! + (match[2] ?? '').padEnd(scale, '0')); + return micro * 10n ** BigInt(scale - 6) > limitScaled; +} + +/** Both durations, exact to the millisecond when rounding would print them equal. */ +function durations(used: bigint, limit: bigint): [string, string] { + const [u, l] = [duration(used), duration(limit)]; + return u === l ? [`${used}ms`, `${limit}ms`] : [u, l]; } /** @@ -48,11 +62,14 @@ function microOf(value: string): bigint | undefined { export function budgetExceededMessage(limit: KernelBudgetSpec, total: Total, spec?: KernelRunSpec): string { const crossed: string[] = []; if (limit.max_wallclock_ms !== undefined && total.ms > BigInt(limit.max_wallclock_ms)) { - crossed.push(`wallclock ${duration(total.ms)} used of ${duration(BigInt(limit.max_wallclock_ms))} declared`); + const [used, declared] = durations(total.ms, BigInt(limit.max_wallclock_ms)); + crossed.push(`wallclock ${used} used of ${declared} declared`); } - const dollarLimit = limit.max_dollars === undefined ? undefined : microOf(limit.max_dollars); - if (dollarLimit !== undefined && total.micro > dollarLimit) { - crossed.push(`dollars ${usd(total.micro)}${total.unmetered ? ' metered (some steps unmetered)' : ''} used of ${usd(dollarLimit)} declared`); + if (limit.max_dollars !== undefined && dollarsOver(total.micro, limit.max_dollars) === true) { + const exact = /^(\d+)(?:\.(\d{1,6}))?$/.exec(limit.max_dollars); + const declared = exact === null ? `$${limit.max_dollars}` + : usd(BigInt(exact[1]!) * 1_000_000n + BigInt((exact[2] ?? '').padEnd(6, '0'))); + crossed.push(`dollars ${usd(total.micro)}${total.unmetered ? ' metered (some steps unmetered)' : ''} used of ${declared} declared`); } if (limit.max_tokens !== undefined && total.input + total.output > BigInt(limit.max_tokens)) { crossed.push(`tokens ${total.input + total.output} used of ${limit.max_tokens} declared`); diff --git a/packages/sdk/tests/budget-attribution.test.ts b/packages/sdk/tests/budget-attribution.test.ts index 287d4c7ec..f91f351b8 100644 --- a/packages/sdk/tests/budget-attribution.test.ts +++ b/packages/sdk/tests/budget-attribution.test.ts @@ -98,6 +98,24 @@ describe('authored budget refusal names the limit it crossed', () => { expect(budgetExceededMessage({max_tokens_in: 800, max_tokens_out: 300}, total)) .toBe("Flow budget exceeded before the next step: input tokens 900 used of 800 declared in the flow's budget header."); }); + it('prints exact milliseconds when rounding would show a strict overrun as equal (PR #594 review)', () => { + const zero = {input: 0n, output: 0n, micro: 0n, unmetered: false}; + expect(budgetExceededMessage({max_wallclock_ms: 120_000}, {...zero, ms: 120_001n})) + .toBe("Flow budget exceeded before the next step: wallclock 120001ms used of 120000ms declared in the flow's budget header."); + expect(budgetExceededMessage({max_wallclock_ms: 60_000}, {...zero, ms: 60_001n})) + .toBe("Flow budget exceeded before the next step: wallclock 60001ms used of 60000ms declared in the flow's budget header."); + expect(budgetExceededMessage({max_wallclock_ms: 10}, {...zero, ms: 20n})) + .toBe("Flow budget exceeded before the next step: wallclock 20ms used of 10ms declared in the flow's budget header."); + }); + it('names a legacy sub-microdollar dollar limit, compared at its own precision (PR #594 review)', () => { + const spent = {input: 0n, output: 0n, micro: 1n, ms: 0n, unmetered: false}; + expect(budgetExceededMessage({max_dollars: '0.0000005'}, spent)) + .toBe("Flow budget exceeded before the next step: dollars $0.000001 used of $0.0000005 declared in the flow's budget header."); + // Redundant trailing zeros are still the same limit, and equal is not over. + expect(budgetExceededMessage({max_dollars: '0.0000010000'}, spent)).toBe('Flow budget exceeded before the next step.'); + expect(budgetExceededMessage({max_dollars: '0.0000009999'}, spent)) + .toBe("Flow budget exceeded before the next step: dollars $0.000001 used of $0.0000009999 declared in the flow's budget header."); + }); it('keeps the generic wording when the carried totals do not explain the kernel refusal', () => { // Equal is not over: the kernel compares strictly, so this total alone // did not trip it and the accumulator must not invent a reason. From 2847595bb5332283831bb43182413f0202a23dfb Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 30 Sep 2026 08:21:45 -0700 Subject: [PATCH 3/3] fix(sdk): compare budget durations as quantities, not rendered text Review of #594 (Codex): an exact-hour limit renders in hours and the spend in rounded minutes, so 7200001 ms against 2h printed "120m used of 2h" and the string-equality fallback missed it. The fallback now compares the quantities the texts denote and drops to exact milliseconds unless the displayed spend itself reads as over the displayed limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/sdk/src/authored-budget.ts | 19 ++++++++++++------- packages/sdk/tests/budget-attribution.test.ts | 5 +++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/authored-budget.ts b/packages/sdk/src/authored-budget.ts index 9d4d1431d..55a7c1bf4 100644 --- a/packages/sdk/src/authored-budget.ts +++ b/packages/sdk/src/authored-budget.ts @@ -23,12 +23,13 @@ interface Charge { type Total = Omit; -/** `ms` as the budget header writes durations: `45s`, `2h`, `132.9m`. */ -function duration(ms: bigint): string { +/** `ms` as the budget header writes durations (`45s`, `2h`, `132.9m`), with the value that text denotes. */ +function duration(ms: bigint): {text: string; shown: number} { const n = Number(ms); - if (n < 60_000) return `${Number((n / 1000).toFixed(1))}s`; - if (n % 3_600_000 === 0) return `${n / 3_600_000}h`; - return `${Number((n / 60_000).toFixed(1))}m`; + if (n < 60_000) { const s = Number((n / 1000).toFixed(1)); return {text: `${s}s`, shown: s * 1000}; } + if (n % 3_600_000 === 0) return {text: `${n / 3_600_000}h`, shown: n}; + const m = Number((n / 60_000).toFixed(1)); + return {text: `${m}m`, shown: m * 60_000}; } const usd = (micro: bigint): string => `$${micro / 1_000_000n}.${String(micro % 1_000_000n).padStart(6, '0').replace(/0{1,4}$/, '')}`; @@ -47,10 +48,14 @@ function dollarsOver(micro: bigint, limit: string): boolean | undefined { return micro * 10n ** BigInt(scale - 6) > limitScaled; } -/** Both durations, exact to the millisecond when rounding would print them equal. */ +/** + * Both durations, exact to the millisecond whenever rounding would make the + * overrun invisible: the displayed spend must itself read as over the + * displayed limit, compared as quantities so `120m` against `2h` counts too. + */ function durations(used: bigint, limit: bigint): [string, string] { const [u, l] = [duration(used), duration(limit)]; - return u === l ? [`${used}ms`, `${limit}ms`] : [u, l]; + return u.shown > l.shown ? [u.text, l.text] : [`${used}ms`, `${limit}ms`]; } /** diff --git a/packages/sdk/tests/budget-attribution.test.ts b/packages/sdk/tests/budget-attribution.test.ts index f91f351b8..7bf18aca6 100644 --- a/packages/sdk/tests/budget-attribution.test.ts +++ b/packages/sdk/tests/budget-attribution.test.ts @@ -104,6 +104,11 @@ describe('authored budget refusal names the limit it crossed', () => { .toBe("Flow budget exceeded before the next step: wallclock 120001ms used of 120000ms declared in the flow's budget header."); expect(budgetExceededMessage({max_wallclock_ms: 60_000}, {...zero, ms: 60_001n})) .toBe("Flow budget exceeded before the next step: wallclock 60001ms used of 60000ms declared in the flow's budget header."); + // Mixed units: the limit renders in hours, the spend in rounded minutes. + expect(budgetExceededMessage({max_wallclock_ms: 7_200_000}, {...zero, ms: 7_200_001n})) + .toBe("Flow budget exceeded before the next step: wallclock 7200001ms used of 7200000ms declared in the flow's budget header."); + expect(budgetExceededMessage({max_wallclock_ms: 7_200_000}, {...zero, ms: 7_206_000n})) + .toBe("Flow budget exceeded before the next step: wallclock 120.1m used of 2h declared in the flow's budget header."); expect(budgetExceededMessage({max_wallclock_ms: 10}, {...zero, ms: 20n})) .toBe("Flow budget exceeded before the next step: wallclock 20ms used of 10ms declared in the flow's budget header."); });