fix(cli): address audit findings for headless guardrails (#4103, #4502)

Round-1 audit (3 angles × line-by-line + removed-behavior + cross-file)
plus an open-ended design pass surfaced eight correctness issues. This
commit lands all of them; the larger ACP / serve-mode structural items
are documented for follow-up.

Correctness fixes

- headlessSafetyWarnings: `SANDBOX` env check reverted to plain truthy.
  The sandbox transport sets `SANDBOX` to `sandbox-exec` (macOS
  seatbelt) or the container name (`qwen-code-sandbox`), neither of
  which matches `isTruthyEnv`. The PR's strict-`1`/`true` check was
  emitting the "no sandbox" warning INSIDE real sandboxes. Match the
  rest of the codebase (sandboxConfig.ts, gemini.tsx, Footer.tsx,
  prompts.ts, …) which all treat any non-empty value as "sandboxed".
- nonInteractiveCli main-loop abort: add `finalizeAssistantMessage()`
  before `routeAbort()`. The drain-item loop already had it (PR #4502
  Critical bug #1); the main loop was asymmetric — stream-json
  consumers would see an unterminated `message_start` when a budget /
  SIGINT abort landed mid-stream.
- nonInteractiveCli drain-loop `routeAbort`: also flush
  `flushQueuedNotificationsToSdk(localQueue)` and
  `finalizeOneShotMonitors()` before exiting. The old `return`-and-
  fall-through path went through the outer holdback loop, which did
  this flushing; switching to `routeAbort()` skipped it, so
  `task_started` envelopes lost their paired `task_notification`.
- nonInteractiveCli catch handler: emit `adapter.emitResult({...})`
  BEFORE `handleBudgetExceededError`, with the budget message as
  `errorMessage` when budget tripped. Previously the budget handler
  `process.exit(55)`ed before the adapter could emit a terminal
  `result` envelope, so STREAM_JSON consumers never saw a stream
  terminator on budget exits and hung waiting for one.
- runBudget: new `validateMaxToolCalls` mirrors
  `validateMaxWallTimeSetting`. yargs coerces non-numeric flag values
  (`--max-tool-calls abc`) to `NaN`, and the enforcer's `>= 0` gate
  treats `NaN` and negatives as "no limit", silently disabling the
  budget. Reject `NaN`, `Infinity`, fractional, and negative-other-
  than-`-1` values at both flag and settings layers. `0` remains
  legal (`first tick aborts`), unlike wall-time where 0 is fatal.
- runBudget: new `MIN_WALL_TIME_SECONDS = 1` floor. Previously
  `--max-wall-time 500ms` parsed cleanly and aborted on the next
  event-loop tick before any model round-trip — almost certainly a
  typo (`5m`?) and not a useful guardrail at any rate.
- nonInteractiveCli `tickToolCall`: exempt `ToolNames.STRUCTURED_OUTPUT`.
  Under `--json-schema` this is the terminal "I'm done" contract tool,
  not real work. Without the exemption a budget-edge completion is
  aborted as a false positive (model used N tools then emitted
  structured_output as call N+1 → exit 55 instead of success).
- commands/serve.ts: emit the YOLO-no-sandbox warning at daemon
  startup when settings.json statically configures
  `tools.approvalMode: 'yolo'` with no `tools.sandbox` /
  `SANDBOX` env. The daemon can't use `getHeadlessYoloSafetyWarning`
  (no Config yet — sessions get their own) so we re-derive the
  predicate from settings. Per-session ACP override is documented as
  out of scope.

Documentation

- `docs/users/features/headless.md`: new "Scope" subsection under
  Run-level budgets explaining (a) `--max-tool-calls` counts top-level
  dispatches only — subagent / `agent` tool inner calls are not
  counted, (b) `structured_output` is exempt, (c) stream-json input
  mode resets budgets per user message, (d) `qwen serve` / ACP
  sessions do not currently consult budgets from settings.json.

Tests

- `runBudget.test.ts` grows from 32 → 41 tests: `validateMaxToolCalls`
  (NaN / Infinity / negatives / fractional), `parseDurationSeconds`
  sub-second rejection, `validateMaxWallTimeSetting` sub-second
  rejection.
- `headlessSafetyWarnings.test.ts`: replaced the "still warns when
  SANDBOX is 0/false/no" case (which encoded the strict-check bug) with
  positive coverage for the real sandbox-set values
  (`sandbox-exec`, `qwen-code-sandbox`).

All previously-green suites still green: cli/nonInteractiveCli (46),
cli/gemini.test (23), cli/config/config.test (220), core/utils/errors
(12), core/config/config.test (172). 337 tests across the touched suites.

Won't-fix (out of scope, documented or pre-existing)

- Unpaired `tool_use` in stream-json when a tool is aborted mid-execution
  — pre-existing structural gap (SIGINT mid-tool has the same outcome);
  PR amplifies it but doesn't introduce it.
- Narrow SIGINT-vs-budget-timer race — already mitigated by
  `markExceeded`'s `signal.aborted` check.
- `tickToolCall` increments past abort (cosmetic; only affects the
  `observed` value in the error envelope for a pathological caller).
This commit is contained in:
克竟 2026-05-25 16:47:58 +08:00
parent 3abc7d6b76
commit 40ae6dd0ff
8 changed files with 243 additions and 29 deletions

View file

@ -252,11 +252,18 @@ Headless / CI runs combined with `--yolo` (or `--approval-mode=yolo`) auto-appro
Qwen Code can abort an unattended run when it crosses one of the following thresholds. Each is `-1` (unlimited) by default; setting any one is enough to bound runaway behavior. They are enforced cooperatively against the same `AbortController` that already carries SIGINT, so a budget abort emits a structured `FatalBudgetExceededError` (exit code **55**) — distinct from the turn-cap exit code 53 and SIGINT's 130 so CI scripts can branch on the reason.
| Flag | Settings key | What it bounds |
| --------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `--max-wall-time` | `model.maxWallTimeSeconds` | Wall-clock duration of the whole run. Flag accepts `90` (s), `30s`, `5m`, `1h`, `500ms`. Settings is seconds. |
| `--max-tool-calls` | `model.maxToolCalls` | Cumulative tool calls executed (counts successes _and_ failures — the model still consumes tokens on errors). |
| `--max-session-turns` | `model.maxSessionTurns` | Number of user/model/tool turns; pre-existing. Exits with code 53 on overrun (distinct from budget exit 55). |
| Flag | Settings key | What it bounds |
| --------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--max-wall-time` | `model.maxWallTimeSeconds` | Wall-clock duration of the whole run. Flag accepts `90` (s), `30s`, `5m`, `1h`, `500ms`. Settings is seconds. |
| `--max-tool-calls` | `model.maxToolCalls` | Cumulative top-level tool calls dispatched by the main run loop (counts successes _and_ failures — the model still consumes tokens on errors). See "Scope" below for subagent / structured-output exemptions. |
| `--max-session-turns` | `model.maxSessionTurns` | Number of user/model/tool turns; pre-existing. Exits with code 53 on overrun (distinct from budget exit 55). |
#### Scope
- **`--max-tool-calls` counts top-level dispatches only.** When the model calls the `agent` tool, the dispatch counts as **1**; inner tool calls performed by the spawned subagent are **not** counted. A model that funnels work through subagents can do unbounded inner work under a small top-level budget. Combine with `--exclude-tools agent` if you need a tighter cap.
- **`structured_output` is exempt.** Under `--json-schema`, the model's terminal `structured_output` call is the "I'm done" contract, not real work — it doesn't count against `--max-tool-calls` so a budget-edge completion isn't aborted as a false positive.
- **Single-shot vs `--input-format stream-json`:** in stream-json input mode the daemon resets the budget counters at the start of every user message; the budget is per-message, not per-process.
- **`qwen serve` / ACP sessions:** the daemon ACP session path does NOT currently consult `--max-wall-time` / `--max-tool-calls` from settings.json. These budgets only apply to single-shot `qwen -p` runs and to `--input-format stream-json` sessions. (`qwen serve` does emit the YOLO-no-sandbox warning at boot if `tools.approvalMode: 'yolo'` is set in settings.)
### Recommended combinations

View file

@ -13,6 +13,8 @@ import type { Argv, CommandModule } from 'yargs';
import { writeStderrLine } from '../utils/stdioHelpers.js';
import { DEFAULT_RING_SIZE } from '../serve/eventBus.js';
import { MCP_BUDGET_WARN_FRACTION } from '@qwen-code/qwen-code-core';
import { loadSettings } from '../config/settings.js';
import { HEADLESS_YOLO_NO_SANDBOX_WARNING } from '../utils/headlessSafetyWarnings.js';
/**
* Pause the current async function indefinitely. Used after the daemon
@ -203,6 +205,32 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
);
}
// Emit the headless-YOLO safety warning at daemon startup if
// settings.json statically configures yolo + no sandbox. We can't
// use `getHeadlessYoloSafetyWarning(config)` here because the daemon
// hasn't constructed a `Config` yet — sessions get their own — so
// we re-derive the predicate from the same settings.json the
// sessions will load. Per-session override (the ACP client flipping
// approval mode mid-session) is out of scope here; this warns about
// a deployment that's wide-open at boot. Suppress with
// QWEN_CODE_SUPPRESS_YOLO_WARNING=1.
try {
const loaded = loadSettings(argv.workspace ?? process.cwd());
const merged = loaded.merged;
const approvalMode = merged.tools?.approvalMode;
const sandbox = merged.tools?.sandbox;
const sandboxEnv = process.env['SANDBOX'];
const suppress = process.env['QWEN_CODE_SUPPRESS_YOLO_WARNING'];
const suppressed = suppress === '1' || suppress === 'true';
if (approvalMode === 'yolo' && !sandbox && !sandboxEnv && !suppressed) {
writeStderrLine(HEADLESS_YOLO_NO_SANDBOX_WARNING);
}
} catch {
// Settings load can fail (corrupt JSON, etc.); don't block
// daemon startup just to emit a warning — the existing settings
// path will report the same error to the user via Session.
}
// Lazy-load the serve module so non-serve invocations don't pay for
// express + body-parser + qs in their startup path.
const { runQwenServe } = await import('../serve/index.js');

View file

@ -77,6 +77,7 @@ import { isWorkspaceTrusted } from './trustedFolders.js';
import { writeStderrLine } from '../utils/stdioHelpers.js';
import {
parseDurationSeconds,
validateMaxToolCalls,
validateMaxWallTimeSetting,
} from '../utils/runBudget.js';
@ -1162,6 +1163,35 @@ function resolveMaxWallTimeSeconds(argv: CliArgs, settings: Settings): number {
return -1;
}
/**
* Resolves the tool-call budget for a run. Returns the validated count
* (`-1` = unlimited). Order of precedence: `--max-tool-calls` flag, then
* `model.maxToolCalls` from settings, else unlimited.
*
* Symmetric with `resolveMaxWallTimeSeconds`: yargs accepts `NaN` from
* non-numeric flag values, and the enforcer's `>= 0` gate would silently
* disable the budget for `NaN` / negatives. Validate up front so a typo
* in a CI guardrail fails loudly.
*/
function resolveMaxToolCalls(argv: CliArgs, settings: Settings): number {
if (argv.maxToolCalls !== undefined && argv.maxToolCalls !== null) {
try {
return validateMaxToolCalls(argv.maxToolCalls);
} catch (err) {
throw new Error(`--max-tool-calls: ${(err as Error).message}`);
}
}
const fromSettings = settings.model?.maxToolCalls;
if (typeof fromSettings === 'number') {
try {
return validateMaxToolCalls(fromSettings);
} catch (err) {
throw new Error(`settings.json: ${(err as Error).message}`);
}
}
return -1;
}
export function isDebugMode(argv: CliArgs): boolean {
return (
argv.debug ||
@ -1778,7 +1808,7 @@ export async function loadCliConfig(
maxSessionTurns:
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
maxWallTimeSeconds: resolveMaxWallTimeSeconds(argv, settings),
maxToolCalls: argv.maxToolCalls ?? settings.model?.maxToolCalls ?? -1,
maxToolCalls: resolveMaxToolCalls(argv, settings),
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
cronEnabled: settings.experimental?.cron ?? false,
emitToolUseSummaries: settings.experimental?.emitToolUseSummaries ?? true,

View file

@ -656,7 +656,15 @@ export async function runNonInteractive(
// at exactly N executions: the (N+1)th tick aborts before the
// tool runs. Ticking after would let the (N+1)th tool execute
// and only then abort. See issue #4103.
budgetEnforcer.tickToolCall();
//
// Exempt `structured_output`: under --json-schema this is the
// terminal "I'm done" contract tool, not real work. Counting
// it would abort an otherwise-valid completion at the budget
// edge (e.g. budget=3, model used 3 tools then emits
// structured_output as call #4 → exit 55 instead of success).
if (requestInfo.name !== ToolNames.STRUCTURED_OUTPUT) {
budgetEnforcer.tickToolCall();
}
if (abortController.signal.aborted) await routeAbort();
const toolResponse = await executeToolCall(
config,
@ -794,6 +802,11 @@ export async function runNonInteractive(
for await (const event of responseStream) {
if (abortController.signal.aborted) {
// Pair the startAssistantMessage() above so stream-json mode
// doesn't leave an unterminated message_start when a budget /
// SIGINT abort lands mid-stream. Symmetric with the drain-item
// loop fix below.
adapter.finalizeAssistantMessage();
await routeAbort();
}
// Use adapter for all event processing
@ -914,7 +927,17 @@ export async function runNonInteractive(
// final drain item surfaces as exit code 55 instead of
// being silently swallowed by the outer success path
// (drain-loop fall-through; see issue #4103 review).
//
// Also flush queued task notifications and finalize
// one-shot monitors here. Previously this site used a
// bare `return` and let control fall through to the
// outer holdback loop, which did the flushing before
// exiting; routing through `routeAbort` skips that
// path, so we re-do it inline to preserve the
// task_started↔task_notification pairing invariant.
adapter.finalizeAssistantMessage();
flushQueuedNotificationsToSdk(localQueue);
finalizeOneShotMonitors();
await routeAbort();
}
adapter.processEvent(event);
@ -1181,16 +1204,20 @@ export async function runNonInteractive(
// If a run-level budget tripped during an awaited stream / tool
// call, the underlying fetch's AbortError lands here before our
// explicit `routeAbort` sites can fire. Re-route through the
// budget handler so the user sees the friendly "Run aborted: …"
// envelope (exit 55) instead of a raw "AbortError" line.
// explicit `routeAbort` sites can fire. Capture the reason so we
// can (a) include the friendly "Run aborted: …" message in the
// adapter's terminal result envelope (STREAM_JSON consumers
// depend on that envelope to close the stream cleanly) and (b)
// exit with the budget handler's exit code 55 instead of the
// generic `handleError` exit code 1 from a raw "AbortError".
const budgetExceeded = budgetEnforcer.getExceeded();
if (budgetExceeded) {
await handleBudgetExceededError(config, budgetExceeded);
}
// For JSON and STREAM_JSON modes, compute usage from metrics
const message = error instanceof Error ? error.message : String(error);
const message = budgetExceeded
? budgetExceeded.message
: error instanceof Error
? error.message
: String(error);
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
// Get stats for JSON format output
@ -1221,6 +1248,11 @@ export async function runNonInteractive(
stats,
});
}
if (budgetExceeded) {
// Always exit AFTER emitResult so STREAM_JSON / JSON consumers
// see a terminal result envelope before the process dies.
await handleBudgetExceededError(config, budgetExceeded);
}
await handleError(error, config);
} finally {
// Cancel the wall-clock timer so it doesn't fire after a successful

View file

@ -44,19 +44,29 @@ describe('getHeadlessYoloSafetyWarning', () => {
expect(getHeadlessYoloSafetyWarning(cfg, {})).toBeNull();
});
it('does not warn when SANDBOX env is strictly truthy', () => {
it('does not warn when SANDBOX env is set to the value the sandbox transport actually writes', () => {
const cfg = makeConfig(ApprovalMode.YOLO, undefined);
// macOS seatbelt
expect(
getHeadlessYoloSafetyWarning(cfg, { SANDBOX: 'sandbox-exec' }),
).toBeNull();
// Docker / Podman container name
expect(
getHeadlessYoloSafetyWarning(cfg, { SANDBOX: 'qwen-code-sandbox' }),
).toBeNull();
// Generic truthy values
expect(getHeadlessYoloSafetyWarning(cfg, { SANDBOX: '1' })).toBeNull();
expect(getHeadlessYoloSafetyWarning(cfg, { SANDBOX: 'true' })).toBeNull();
});
it('still warns when SANDBOX env is a non-truthy value like 0 / false / no', () => {
it('warns when SANDBOX env is unset or empty string', () => {
const cfg = makeConfig(ApprovalMode.YOLO, undefined);
for (const val of ['0', 'false', 'no']) {
expect(getHeadlessYoloSafetyWarning(cfg, { SANDBOX: val })).toBe(
HEADLESS_YOLO_NO_SANDBOX_WARNING,
);
}
expect(getHeadlessYoloSafetyWarning(cfg, {})).toBe(
HEADLESS_YOLO_NO_SANDBOX_WARNING,
);
expect(getHeadlessYoloSafetyWarning(cfg, { SANDBOX: '' })).toBe(
HEADLESS_YOLO_NO_SANDBOX_WARNING,
);
});
it('respects the explicit suppression env var when set to 1 or true', () => {

View file

@ -31,10 +31,14 @@ export function getHeadlessYoloSafetyWarning(
): string | null {
if (config.getApprovalMode() !== ApprovalMode.YOLO) return null;
if (config.getSandbox()) return null;
// SANDBOX is set by the sandbox transport itself (container / seatbelt
// wrapper). Use the same strict truthy check as the suppression env to
// avoid `SANDBOX=false` / `SANDBOX=0` accidentally silencing the warning.
if (isTruthyEnv(env['SANDBOX'])) return null;
// `SANDBOX` is set by the sandbox transport itself: macOS seatbelt sets
// it to `sandbox-exec`, Docker/Podman to the container name (e.g.
// `qwen-code-sandbox`). Match the rest of the codebase
// (sandboxConfig.ts, gemini.tsx, Footer.tsx, prompts.ts, …) which all
// treat any non-empty value as "inside a sandbox". A strict 1/true
// check here misfires inside real sandboxes, where the helper would
// wrongly emit a "no sandbox" warning despite the run being contained.
if (env['SANDBOX']) return null;
if (isTruthyEnv(env['QWEN_CODE_SUPPRESS_YOLO_WARNING'])) return null;
return HEADLESS_YOLO_NO_SANDBOX_WARNING;
}

View file

@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
RunBudgetEnforcer,
parseDurationSeconds,
validateMaxToolCalls,
validateMaxWallTimeSetting,
} from './runBudget.js';
@ -19,8 +20,8 @@ describe('parseDurationSeconds', () => {
[' 45 ', 45],
['5m', 300],
['1h', 3600],
['500ms', 0.5],
['1.5h', 5400],
['1s', 1],
])('parses %s as %d seconds', (input, expected) => {
expect(parseDurationSeconds(input)).toBeCloseTo(expected);
});
@ -32,8 +33,18 @@ describe('parseDurationSeconds', () => {
},
);
it('rejects sub-second budgets — they fire before any model round-trip', () => {
// Previously a tiny budget like `500ms` parsed cleanly and immediately
// aborted the run on the next event-loop tick. That's a typo, not a
// useful guardrail.
expect(() => parseDurationSeconds('500ms')).toThrow(/minimum/i);
expect(() => parseDurationSeconds('1ms')).toThrow(/minimum/i);
expect(() => parseDurationSeconds('0.5')).toThrow(/minimum/i);
});
it('rejects values larger than Node.js can safely time out on', () => {
// 100 days in seconds is well above MAX_TIMEOUT_MS / 1000 (~24.8d).
// The regex doesn't accept `d`, so `100d` fails as a format error;
// `2400h` parses but exceeds MAX_WALL_TIME_SECONDS (~24.8d).
expect(() => parseDurationSeconds('100d')).toThrow();
expect(() => parseDurationSeconds('2400h')).toThrow();
});
@ -44,15 +55,20 @@ describe('validateMaxWallTimeSetting', () => {
expect(validateMaxWallTimeSetting(-1)).toBe(-1);
});
it('accepts positive numbers', () => {
it('accepts positive numbers at or above the 1s floor', () => {
expect(validateMaxWallTimeSetting(60)).toBe(60);
expect(validateMaxWallTimeSetting(0.5)).toBeCloseTo(0.5);
expect(validateMaxWallTimeSetting(1)).toBe(1);
});
it('rejects 0 (mirrors CLI flag behavior — 0 is a foot-gun)', () => {
expect(() => validateMaxWallTimeSetting(0)).toThrow();
});
it('rejects sub-second values', () => {
expect(() => validateMaxWallTimeSetting(0.5)).toThrow(/minimum/i);
expect(() => validateMaxWallTimeSetting(0.001)).toThrow(/minimum/i);
});
it('rejects negatives other than -1', () => {
expect(() => validateMaxWallTimeSetting(-2)).toThrow();
});
@ -69,6 +85,47 @@ describe('validateMaxWallTimeSetting', () => {
});
});
describe('validateMaxToolCalls', () => {
it('accepts -1 (unlimited sentinel)', () => {
expect(validateMaxToolCalls(-1)).toBe(-1);
});
it('accepts 0 (no-tool-calls-allowed sentinel)', () => {
// Asymmetric with wall-time where 0 is fatal — for tool-calls, 0 means
// "the first tick aborts", which is a legitimate "model must answer
// without invoking tools" mode.
expect(validateMaxToolCalls(0)).toBe(0);
});
it('accepts positive integers', () => {
expect(validateMaxToolCalls(5)).toBe(5);
expect(validateMaxToolCalls(1000)).toBe(1000);
});
it('rejects NaN — yargs coerces non-numeric flag values to NaN', () => {
// `qwen -p '...' --max-tool-calls abc` would otherwise silently
// disable the budget; the >= 0 gate in tickToolCall is false for NaN.
expect(() => validateMaxToolCalls(Number.NaN)).toThrow();
});
it('rejects Infinity', () => {
expect(() => validateMaxToolCalls(Number.POSITIVE_INFINITY)).toThrow();
});
it('rejects negatives other than -1', () => {
// `--max-tool-calls=-5` (typo for `5`) would otherwise silently
// disable the budget — the exact foot-gun the wall-time validator
// was built to prevent.
expect(() => validateMaxToolCalls(-5)).toThrow();
expect(() => validateMaxToolCalls(-2)).toThrow();
});
it('rejects fractional values', () => {
expect(() => validateMaxToolCalls(1.5)).toThrow();
expect(() => validateMaxToolCalls(0.5)).toThrow();
});
});
describe('RunBudgetEnforcer', () => {
beforeEach(() => {
vi.useFakeTimers();

View file

@ -55,6 +55,14 @@ const SECOND = 1000;
*/
const MAX_TIMEOUT_MS = 2_147_483_647;
const MAX_WALL_TIME_SECONDS = Math.floor(MAX_TIMEOUT_MS / SECOND);
/**
* Wall-clock budgets below 1s are almost always a typo (someone meant `1m`
* or `1h`); accepting them silently produces a run that aborts on the next
* event-loop tick before any model request returns. Round-trip latency to
* any reasonable LLM is multiple seconds, so a sub-second budget is also
* not a meaningful guardrail. Reject loudly.
*/
const MIN_WALL_TIME_SECONDS = 1;
/**
* Parses a duration string used by `--max-wall-time`.
@ -110,6 +118,11 @@ export function parseDurationSeconds(input: string): number {
`Invalid duration "${input}": must be greater than zero. Omit the flag entirely if you don't want a wall-clock budget.`,
);
}
if (seconds < MIN_WALL_TIME_SECONDS) {
throw new Error(
`Invalid duration "${input}": below the ${MIN_WALL_TIME_SECONDS}s minimum (probably a typo — did you mean ${input.replace(/ms\b/i, 's')}?). Sub-second wall-clock budgets fire before any model round-trip can complete.`,
);
}
if (seconds > MAX_WALL_TIME_SECONDS) {
throw new Error(
`Invalid duration "${input}": exceeds the maximum supported wall-clock budget (${MAX_WALL_TIME_SECONDS}s ≈ 24 days). Use a smaller value.`,
@ -143,6 +156,11 @@ export function validateMaxWallTimeSetting(value: number): number {
`Use -1 to disable, not 0.`,
);
}
if (value < MIN_WALL_TIME_SECONDS) {
throw new Error(
`model.maxWallTimeSeconds ${value} is below the ${MIN_WALL_TIME_SECONDS}s minimum. Sub-second budgets fire before any model round-trip can complete.`,
);
}
if (value > MAX_WALL_TIME_SECONDS) {
throw new Error(
`model.maxWallTimeSeconds ${value} exceeds the maximum supported wall-clock budget (${MAX_WALL_TIME_SECONDS}s ≈ 24 days).`,
@ -151,6 +169,34 @@ export function validateMaxWallTimeSetting(value: number): number {
return value;
}
/**
* Validates a `maxToolCalls` value sourced from either the `--max-tool-calls`
* CLI flag or `model.maxToolCalls` in settings.json. Mirrors
* `validateMaxWallTimeSetting`: the enforcer treats anything `< 0` as "no
* limit", so any non-`-1` negative would silently disable the budget. Reject
* up front to keep the fail-loud philosophy symmetric across all budgets.
*
* `0` IS legal here it means "no tool calls allowed; first tick aborts"
* (asymmetric with wall-time where 0 is fatal). Documented in the schema.
*/
export function validateMaxToolCalls(value: number): number {
if (value === -1) return -1;
if (!Number.isFinite(value)) {
throw new Error(`maxToolCalls must be a finite number; got ${value}.`);
}
if (!Number.isInteger(value)) {
throw new Error(
`maxToolCalls must be an integer (or -1 for unlimited); got ${value}.`,
);
}
if (value < 0) {
throw new Error(
`maxToolCalls must be >= 0 (or -1 for unlimited); got ${value}. Use -1 to disable, not a negative number.`,
);
}
return value;
}
export class RunBudgetEnforcer {
private readonly maxWallTimeSeconds: number;
private readonly maxToolCalls: number;