CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
runBudget.ts306 linesDownload Raw Back to utils
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 
basant307/AI_Governance_Project · CoolFace