basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7/**8 * Run-level budget enforcement for headless / non-interactive Qwen Code9 * sessions. See issue QwenLM/qwen-code#4103.10 *11 * Two budgets are enforced today:12 * - `--max-wall-time` / `model.maxWallTimeSeconds` — clock-time guardrail13 * for long-running unattended runs.14 * - `--max-tool-calls` / `model.maxToolCalls` — bounds the cumulative15 * number of tool executions (success or failure).16 *17 * `tickToolCall()` is invoked **before** each `executeToolCall` so that a18 * budget of N caps the run at exactly N executions — the (N+1)th tick19 * aborts before the work is performed. The wall-clock timer is started via20 * `start()` and torn down by `stop()`. When any limit is exceeded the21 * enforcer aborts the run via the shared `AbortController` and records the22 * reason so the caller can emit a structured error envelope.23 */24 25export type BudgetKind = 'wall-time' | 'tool-calls';26 27export interface BudgetExceeded {28 kind: BudgetKind;29 limit: number;30 /** Observed value at the moment the budget was exceeded. */31 observed: number;32 /** Human-readable message suitable for stderr / structured error output. */33 message: string;34}35 36export interface RunBudgetOptions {37 /**38 * Wall-clock budget in seconds. Non-positive (`-1`, `0`, undefined)39 * disables the budget; the CLI parser rejects `0` at the input layer so40 * this enforcer never sees a legitimate "zero seconds" value.41 */42 maxWallTimeSeconds?: number;43 /**44 * Max cumulative tool calls. `-1` / `undefined` disables; `0` is a valid45 * budget meaning "no tool calls allowed" (the first tick aborts).46 */47 maxToolCalls?: number;48}49 50const SECOND = 1000;51/**52 * Node clamps `setTimeout` delays >= 2^31 to 1 ms, which would fire the53 * timer almost immediately. Reject upstream so a user typing `--max-wall-time54 * 100d` gets a clear error instead of a confusing instant abort.55 */56const MAX_TIMEOUT_MS = 2_147_483_647;57const MAX_WALL_TIME_SECONDS = Math.floor(MAX_TIMEOUT_MS / SECOND);58/**59 * Wall-clock budgets below 1s are almost always a typo (someone meant `1m`60 * or `1h`); accepting them silently produces a run that aborts on the next61 * event-loop tick before any model request returns. Round-trip latency to62 * any reasonable LLM is multiple seconds, so a sub-second budget is also63 * not a meaningful guardrail. Reject loudly.64 */65const MIN_WALL_TIME_SECONDS = 1;66 67/**68 * Parses a duration string used by `--max-wall-time`.69 *70 * Accepted forms (all must resolve to a duration in71 * `[MIN_WALL_TIME_SECONDS, MAX_WALL_TIME_SECONDS]`):72 * - plain number (interpreted as seconds): `"90"` → 9073 * - suffixed: `"30s"`, `"5m"`, `"1h"`, `"1.5h"`, `"3600s"`74 * - `ms` suffix is syntactically accepted but rejected at the floor75 * unless the value resolves to `>= 1s` (e.g. `"1000ms"` is legal,76 * `"500ms"` is not)77 * - case-insensitive suffix; whitespace tolerated78 *79 * Returns the duration in **seconds** for parity with `maxWallTimeSeconds`80 * in settings.json.81 *82 * Throws on garbage input, on negative values (regex-rejected — no sign83 * allowed), on zero, on sub-second values below `MIN_WALL_TIME_SECONDS`,84 * and on values above `MAX_WALL_TIME_SECONDS`. A typo in a CI budget flag85 * should fail loud at startup, not silently disable (or instant-fire) the86 * guardrail.87 */88export function parseDurationSeconds(input: string): number {89 const trimmed = input.trim().toLowerCase();90 if (trimmed.length === 0) {91 throw new Error('Invalid duration: empty string');92 }93 // The regex disallows a leading sign, so negatives short-circuit on94 // structural mismatch — no explicit `< 0` check needed.95 const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/.exec(trimmed);96 if (!match) {97 throw new Error(98 `Invalid duration "${input}". Use a positive number of seconds (e.g. 90) or a duration with unit (e.g. 30s, 5m, 1h, 500ms).`,99 );100 }101 const value = Number.parseFloat(match[1]);102 const unit = match[2] ?? 's';103 let seconds: number;104 switch (unit) {105 case 'ms':106 seconds = value / 1000;107 break;108 case 's':109 seconds = value;110 break;111 case 'm':112 seconds = value * 60;113 break;114 case 'h':115 seconds = value * 3600;116 break;117 default:118 // Unreachable given the regex, but keeps the type-checker honest.119 throw new Error(`Invalid duration unit "${unit}"`);120 }121 if (seconds <= 0) {122 throw new Error(123 `Invalid duration "${input}": must be greater than zero. Omit the flag entirely if you don't want a wall-clock budget.`,124 );125 }126 if (seconds < MIN_WALL_TIME_SECONDS) {127 // Only suggest a "did you mean" rewrite when the user actually128 // used the `ms` suffix — for bare sub-second inputs like `0.5` or129 // `0.5s`, the rewrite would be a no-op ("did you mean 0.5s?") and130 // just confuses the error.131 const hint = /ms\b/i.test(trimmed)132 ? ` (probably a typo — did you mean ${input.replace(/ms\b/i, 's')}?)`133 : '';134 throw new Error(135 `Invalid duration "${input}": below the ${MIN_WALL_TIME_SECONDS}s minimum${hint}. Sub-second wall-clock budgets fire before any model round-trip can complete.`,136 );137 }138 if (seconds > MAX_WALL_TIME_SECONDS) {139 throw new Error(140 `Invalid duration "${input}": exceeds the maximum supported wall-clock budget (${MAX_WALL_TIME_SECONDS}s ≈ 24 days). Use a smaller value.`,141 );142 }143 return seconds;144}145 146/**147 * Validates a `maxWallTimeSeconds` value sourced from settings.json148 * (as opposed to the CLI flag, which goes through `parseDurationSeconds`).149 *150 * The settings entry is a plain number, so the CLI's parser doesn't run.151 * Mirror the same rejection rules here so `maxWallTimeSeconds: 0` in152 * settings.json doesn't silently disable the budget (the enforcer treats153 * `<= 0` as "no timer") while the equivalent `--max-wall-time 0` flag is154 * fatal. Asymmetry would be a foot-gun.155 *156 * Returns the validated value, or `-1` for the "unlimited" sentinel.157 */158export function validateMaxWallTimeSetting(value: number): number {159 if (value === -1) return -1;160 if (!Number.isFinite(value)) {161 throw new Error(162 `model.maxWallTimeSeconds must be a finite number; got ${value}.`,163 );164 }165 if (value <= 0) {166 throw new Error(167 `model.maxWallTimeSeconds must be > 0 (or -1 for unlimited); got ${value}. ` +168 `Use -1 to disable, not 0.`,169 );170 }171 if (value < MIN_WALL_TIME_SECONDS) {172 throw new Error(173 `model.maxWallTimeSeconds ${value} is below the ${MIN_WALL_TIME_SECONDS}s minimum. Sub-second budgets fire before any model round-trip can complete.`,174 );175 }176 if (value > MAX_WALL_TIME_SECONDS) {177 throw new Error(178 `model.maxWallTimeSeconds ${value} exceeds the maximum supported wall-clock budget (${MAX_WALL_TIME_SECONDS}s ≈ 24 days).`,179 );180 }181 return value;182}183 184/**185 * Upper bound for `maxToolCalls`. Above this, a value is almost certainly186 * a typo (`1e10` meant `1e1`, or a misplaced zero): no realistic run187 * executes a billion tool calls, and `tickToolCall`'s `>` gate would188 * functionally never trip. Same fail-loud philosophy as `MAX_WALL_TIME_SECONDS`.189 */190const MAX_TOOL_CALLS = 1_000_000;191 192/**193 * Validates a `maxToolCalls` value sourced from either the `--max-tool-calls`194 * CLI flag or `model.maxToolCalls` in settings.json. Mirrors195 * `validateMaxWallTimeSetting`: the enforcer treats anything `< 0` as "no196 * limit", so any non-`-1` negative would silently disable the budget. Reject197 * up front to keep the fail-loud philosophy symmetric across all budgets.198 *199 * `0` IS legal here — it means "no tool calls allowed; first tick aborts"200 * (asymmetric with wall-time where 0 is fatal). Documented in the schema.201 */202export function validateMaxToolCalls(value: number): number {203 if (value === -1) return -1;204 if (!Number.isFinite(value)) {205 throw new Error(`maxToolCalls must be a finite number; got ${value}.`);206 }207 if (!Number.isInteger(value)) {208 throw new Error(209 `maxToolCalls must be an integer (or -1 for unlimited); got ${value}.`,210 );211 }212 if (value < 0) {213 throw new Error(214 `maxToolCalls must be >= 0 (or -1 for unlimited); got ${value}. Use -1 to disable, not a negative number.`,215 );216 }217 if (value > MAX_TOOL_CALLS) {218 throw new Error(219 `maxToolCalls ${value} exceeds the supported ceiling (${MAX_TOOL_CALLS}). Likely a typo — use a smaller value or -1 for unlimited.`,220 );221 }222 return value;223}224 225export class RunBudgetEnforcer {226 private readonly maxWallTimeSeconds: number;227 private readonly maxToolCalls: number;228 private readonly abortController: AbortController;229 private wallTimer: ReturnType<typeof setTimeout> | null = null;230 private toolCallCount = 0;231 private exceeded: BudgetExceeded | null = null;232 233 constructor(opts: RunBudgetOptions, abortController: AbortController) {234 this.maxWallTimeSeconds = opts.maxWallTimeSeconds ?? -1;235 this.maxToolCalls = opts.maxToolCalls ?? -1;236 this.abortController = abortController;237 }238 239 /**240 * Starts the wall-clock timer (if configured). Idempotent so callers241 * don't need to thread "did I already start?" state.242 */243 start(): void {244 if (this.wallTimer !== null) return;245 if (this.maxWallTimeSeconds <= 0) return;246 this.wallTimer = setTimeout(() => {247 this.markExceeded({248 kind: 'wall-time',249 limit: this.maxWallTimeSeconds,250 observed: this.maxWallTimeSeconds,251 message: `Run aborted: wall-clock budget of ${this.maxWallTimeSeconds}s exceeded (--max-wall-time).`,252 });253 }, this.maxWallTimeSeconds * SECOND);254 // Don't keep the event loop alive solely for the timeout — once the255 // main loop exits naturally we want the process to exit too.256 (this.wallTimer as NodeJS.Timeout).unref?.();257 }258 259 /** Records one tool execution and enforces `maxToolCalls`. */260 tickToolCall(): void {261 this.toolCallCount += 1;262 if (this.maxToolCalls >= 0 && this.toolCallCount > this.maxToolCalls) {263 this.markExceeded({264 kind: 'tool-calls',265 limit: this.maxToolCalls,266 observed: this.toolCallCount,267 message: `Run aborted: tool-call budget of ${this.maxToolCalls} exceeded (--max-tool-calls); observed ${this.toolCallCount}.`,268 });269 }270 }271 272 /**273 * Returns the budget-exceeded record if one fired, else null. The274 * non-interactive loop checks this after `abortController.signal`275 * fires to distinguish "budget abort" from "user SIGINT" so it can276 * emit a structured-error envelope with the right reason.277 */278 getExceeded(): BudgetExceeded | null {279 return this.exceeded;280 }281 282 /** Cancels the wall-clock timer. Safe to call multiple times. */283 stop(): void {284 if (this.wallTimer !== null) {285 clearTimeout(this.wallTimer);286 this.wallTimer = null;287 }288 }289 290 private markExceeded(record: BudgetExceeded): void {291 // First fence wins — once one budget has been recorded, subsequent292 // overruns (e.g. an in-flight tool finishing after wall-time fired)293 // don't clobber the original reason.294 if (this.exceeded !== null) return;295 // If the abort already happened from a different source (SIGINT, an296 // external `options.abortController` shared with a parent), don't297 // claim it as a budget event — otherwise the caller would emit exit298 // code 55 ("budget exceeded") when the real cause was user299 // cancellation (130).300 if (this.abortController.signal.aborted) return;301 this.exceeded = record;302 this.stop();303 this.abortController.abort();304 }305}306 