@rulvar/core 1.61.0 → 1.62.0

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.
Files changed (3) hide show
  1. package/dist/index.d.ts +179 -1
  2. package/dist/index.js +2502 -2037
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8320,293 +8320,259 @@ function invoiceFromJournal(entries, priceUsd) {
8320
8320
  };
8321
8321
  }
8322
8322
  //#endregion
8323
- //#region src/engine/run-profiles.ts
8323
+ //#region src/model/router.ts
8324
8324
  /**
8325
- * The shipped presets (fast / standard / deep / ultra "and similar").
8326
- * Data only; a review-time assertion checks the
8327
- * engine has zero behavioral branches keyed on these names.
8325
+ * Model router core (M1-T05): the per-engine adapter registry, ModelRef
8326
+ * parsing, the per-invocation resolution chain, canonicalization into
8327
+ * CanonicalModelSpec, and caps scrubbing with visible scrub notes.
8328
+ *
8329
+ * Public contract: https://docs.rulvar.com/guide/model-routing.
8328
8330
  */
8329
- const RUN_PROFILES = {
8330
- fast: {
8331
- effortByRole: {
8332
- orchestrate: "low",
8333
- plan: "low",
8334
- summarize: "low",
8335
- extract: "low"
8336
- },
8337
- perRunConcurrency: 16,
8338
- permissionPreset: "standard",
8339
- lifetimeSpawnCap: 64,
8340
- maxDepth: 1
8341
- },
8342
- standard: {
8343
- effortByRole: {
8344
- orchestrate: "high",
8345
- plan: "high",
8346
- summarize: "low",
8347
- extract: "low"
8348
- },
8349
- perRunConcurrency: 12,
8350
- permissionPreset: "standard",
8351
- lifetimeSpawnCap: 500,
8352
- maxDepth: 1
8353
- },
8354
- deep: {
8355
- effortByRole: {
8356
- orchestrate: "high",
8357
- plan: "high",
8358
- summarize: "medium",
8359
- extract: "medium"
8360
- },
8361
- perRunConcurrency: 8,
8362
- permissionPreset: "standard",
8363
- lifetimeSpawnCap: 500,
8364
- maxDepth: 2
8365
- },
8366
- ultra: {
8367
- effortByRole: {
8368
- orchestrate: "max",
8369
- plan: "max",
8370
- summarize: "high",
8371
- extract: "high"
8372
- },
8373
- perRunConcurrency: 8,
8374
- permissionPreset: "strict",
8375
- lifetimeSpawnCap: 500,
8376
- maxDepth: 3
8331
+ /**
8332
+ * Per-engine adapter registry: strictly per engine, no global mutable
8333
+ * registry exists. A duplicate adapterId is a typed ConfigError.
8334
+ */
8335
+ function buildAdapterRegistry(adapters) {
8336
+ const registry = /* @__PURE__ */ new Map();
8337
+ for (const adapter of adapters) {
8338
+ if (registry.has(adapter.id)) throw new ConfigError(`duplicate adapterId '${adapter.id}' at createEngine`);
8339
+ registry.set(adapter.id, adapter);
8377
8340
  }
8378
- };
8379
- /** Looks up a shipped RunProfile by name; undefined for unknown names. */
8380
- function runProfile(name) {
8381
- return RUN_PROFILES[name];
8341
+ return registry;
8382
8342
  }
8383
- //#endregion
8384
- //#region src/model/caps.ts
8385
- const TIER_ORDER = {
8386
- native: 2,
8387
- "forced-tool": 1,
8388
- prompt: 0
8389
- };
8390
8343
  /**
8391
- * Strict-schema compatibility as both first-class providers define it:
8392
- * every object node declares `additionalProperties: false` and lists every
8393
- * property in `required`. Boolean schemas and
8394
- * non-object shapes are trivially compatible.
8344
+ * ModelRef is strictly 'adapterId:model', no query parameters. The wire
8345
+ * model id may itself contain colons (for example ollama tags), so only
8346
+ * the FIRST colon splits.
8395
8347
  */
8396
- function isStrictCompatibleSchema(schema) {
8397
- if (typeof schema === "boolean") return true;
8398
- if (schema.type === "object" || schema.properties !== void 0 || schema.additionalProperties !== void 0) {
8399
- if (schema.additionalProperties !== false) return false;
8400
- const properties = typeof schema.properties === "object" && schema.properties !== null ? schema.properties : {};
8401
- const required = Array.isArray(schema.required) ? schema.required : [];
8402
- for (const name of Object.keys(properties)) if (!required.includes(name)) return false;
8403
- for (const value of Object.values(properties)) if (typeof value === "object" && value !== null || typeof value === "boolean") {
8404
- if (!isStrictCompatibleSchema(value)) return false;
8405
- }
8406
- }
8407
- for (const key of [
8408
- "items",
8409
- "additionalProperties",
8410
- "contains"
8411
- ]) {
8412
- const value = schema[key];
8413
- if (typeof value === "object" && value !== null || typeof value === "boolean") {
8414
- if (!isStrictCompatibleSchema(value)) return false;
8415
- }
8416
- }
8417
- for (const key of [
8418
- "allOf",
8419
- "anyOf",
8420
- "oneOf",
8421
- "prefixItems"
8422
- ]) {
8423
- const value = schema[key];
8424
- if (Array.isArray(value)) {
8425
- for (const element of value) if (typeof element === "object" && element !== null || typeof element === "boolean") {
8426
- if (!isStrictCompatibleSchema(element)) return false;
8427
- }
8428
- }
8429
- }
8430
- return true;
8348
+ function parseModelRef(ref) {
8349
+ const colon = ref.indexOf(":");
8350
+ if (colon <= 0 || colon === ref.length - 1) throw new ConfigError(`invalid ModelRef '${ref}': expected the strict 'adapterId:model' form`);
8351
+ return {
8352
+ adapterId: ref.slice(0, colon),
8353
+ model: ref.slice(colon + 1)
8354
+ };
8431
8355
  }
8432
8356
  /**
8433
- * Tier selection: the model's declared ceiling
8434
- * bounds the tier; the native tier additionally requires a
8435
- * strict-compatible canonical schema (relying on silent server-side
8436
- * fallback is forbidden), degrading to forced-tool.
8437
- * Prefill is not a tier.
8357
+ * Role effort defaults: orchestrate and plan default to high; summarize and extract
8358
+ * default to low. loop and finalize have NO role default: when the chain
8359
+ * resolves nothing, the wire omits effort and identity records the spec
8360
+ * with the effort member absent.
8438
8361
  */
8439
- function selectStructuredOutputTier(caps, canonicalSchema) {
8440
- const ceiling = caps.structuredOutput;
8441
- if (ceiling === "native" && !isStrictCompatibleSchema(canonicalSchema)) return "forced-tool";
8442
- return ceiling;
8362
+ const ROLE_EFFORT_DEFAULTS = {
8363
+ orchestrate: "high",
8364
+ plan: "high",
8365
+ summarize: "low",
8366
+ extract: "low"
8367
+ };
8368
+ function contribution(spec, _role) {
8369
+ if (spec === void 0) return {};
8370
+ if (typeof spec === "string") return { model: spec };
8371
+ if ("ladder" in spec) return { ladder: spec.ladder };
8372
+ const choice = spec;
8373
+ const fields = { model: choice.model };
8374
+ if (choice.effort !== void 0) fields.effort = choice.effort;
8375
+ if (choice.providerOptions !== void 0) fields.providerOptions = choice.providerOptions;
8376
+ if (choice.fallbacks !== void 0) fields.fallbacks = choice.fallbacks;
8377
+ return fields;
8443
8378
  }
8444
- /** True when `tier` is at or below the model's declared ceiling. */
8445
- function tierWithinCaps(tier, caps) {
8446
- return TIER_ORDER[tier] <= TIER_ORDER[caps.structuredOutput];
8379
+ function layerFields(layer, role) {
8380
+ if (layer === void 0) return {};
8381
+ const fromModel = contribution(layer.model, role);
8382
+ const fromRouting = contribution(layer.routing?.[role], role);
8383
+ const merged = {
8384
+ ...fromModel,
8385
+ ...pruneUndefined(fromRouting)
8386
+ };
8387
+ if (layer.effort !== void 0) merged.effort = layer.effort;
8388
+ return merged;
8447
8389
  }
8448
- //#endregion
8449
- //#region src/engine/scheduler.ts
8390
+ function pruneUndefined(value) {
8391
+ const out = {};
8392
+ for (const [key, member] of Object.entries(value)) if (member !== void 0) out[key] = member;
8393
+ return out;
8394
+ }
8395
+ function mergeProviderOptions(lower, higher) {
8396
+ if (lower === void 0) return higher;
8397
+ if (higher === void 0) return lower;
8398
+ const merged = { ...lower };
8399
+ for (const [namespace, options] of Object.entries(higher)) merged[namespace] = {
8400
+ ...merged[namespace],
8401
+ ...options
8402
+ };
8403
+ return merged;
8404
+ }
8405
+ /** Sampling parameters both first-class providers reject on reasoning models. */
8406
+ const SAMPLING_KEYS = [
8407
+ "temperature",
8408
+ "top_p",
8409
+ "top_k"
8410
+ ];
8450
8411
  /**
8451
- * Scheduler and concurrency (M1-T08): the per-run semaphore with a FIFO
8452
- * queue (default 12 concurrent model calls). The engine lifetime spawn cap
8453
- * is enforced by the budget layer at admission; parallel/pipeline
8454
- * composition semantics live with ctx.
8455
- * Per-provider concurrency keys land with M4.
8412
+ * Resolution runs on every model invocation, not once per agent: a layered
8413
+ * merge of { model, effort, providerOptions, fallbacks } in the order call
8414
+ * override > agent profile > workflow defaults > engine defaults, with the
8415
+ * invocation role attached as a tag.
8416
+ * After resolution the router reads ModelCaps and scrubs illegal
8417
+ * parameters visibly: unsupported effort is removed from the wire but
8418
+ * kept in identity; sampling params rejected by the model are removed
8419
+ * from the adapter's namespace, never silently sent.
8456
8420
  */
8457
- /** FIFO semaphore; default per-run width is 12. */
8458
- const DEFAULT_PER_RUN_CONCURRENCY = 12;
8459
- var Semaphore = class {
8460
- limit;
8461
- active = 0;
8462
- waiters = [];
8463
- /**
8464
- * `limit` must be a positive integer: anything else (NaN included) is
8465
- * a typed ConfigError. Before this gate a NaN limit made
8466
- * `active < limit` permanently false, so the first acquire queued
8467
- * forever and the run could not settle, not even through cancel()
8468
- * (v1.34.0 review P2-4). Unlimited is expressed by not constructing a
8469
- * semaphore, never by a sentinel limit.
8470
- */
8471
- constructor(limit) {
8472
- requirePositiveInteger(limit, "Semaphore limit");
8473
- this.limit = limit;
8474
- }
8475
- get pending() {
8476
- return this.waiters.length;
8421
+ function resolveModelInvocation(options) {
8422
+ const { role } = options;
8423
+ const layers = [
8424
+ options.engine,
8425
+ options.workflow,
8426
+ options.profile,
8427
+ options.call
8428
+ ];
8429
+ let merged = {};
8430
+ for (const layer of layers) {
8431
+ const fields = layerFields(layer, role);
8432
+ merged = {
8433
+ ...merged,
8434
+ ...pruneUndefined(fields),
8435
+ providerOptions: mergeProviderOptions(merged.providerOptions, fields.providerOptions)
8436
+ };
8437
+ if (fields.ladder !== void 0) delete merged.model;
8438
+ else if (fields.model !== void 0) delete merged.ladder;
8477
8439
  }
8478
- /**
8479
- * Acquires a slot, resolving in FIFO order. `onQueued` fires only when
8480
- * the caller actually has to wait (feeds the agent:queued event).
8481
- * An aborted `signal` releases the caller from the queue without a
8482
- * slot: the returned release is a no-op, the remaining waiters keep
8483
- * their FIFO positions, and the caller proceeds to observe its own
8484
- * aborted signal (the model layers refuse dispatch under an aborted
8485
- * signal, so no provider call follows). Cancellation can therefore
8486
- * always drain a queued run (v1.34.0 review P2-4).
8487
- */
8488
- async acquire(onQueued, signal) {
8489
- if (this.active < this.limit) {
8490
- this.active += 1;
8491
- return () => this.release();
8492
- }
8493
- if (signal?.aborted === true) return () => void 0;
8494
- onQueued?.();
8495
- const waiter = {
8496
- resolve: () => void 0,
8497
- aborted: false
8498
- };
8499
- const wait = new Promise((resolve) => {
8500
- waiter.resolve = resolve;
8440
+ if (merged.ladder !== void 0) throw new ConfigError(`a ladder ModelSpec wins wire resolution for role '${role}': ladder execution is owned by the PlanRunner ladder driver, which resolves each rung attempt to a concrete model override; dispatch laddered profiles through orchestratePlanned or pass a plain ModelRef or ModelChoice`);
8441
+ if (merged.model === void 0) throw new ConfigError(`no model resolves for role '${role}': set AgentOpts.model, a profile model, or engine defaults.routing`);
8442
+ checkFloors({
8443
+ ref: merged.model,
8444
+ role,
8445
+ ...options.floors === void 0 ? {} : { floors: options.floors },
8446
+ ...options.taskClass === void 0 ? {} : { taskClass: options.taskClass }
8447
+ });
8448
+ const requestedEffort = merged.effort ?? ROLE_EFFORT_DEFAULTS[role];
8449
+ const { adapterId, model } = parseModelRef(merged.model);
8450
+ let caps;
8451
+ try {
8452
+ caps = options.capsOf(merged.model);
8453
+ } catch (thrown) {
8454
+ if (thrown instanceof ConfigError) throw new ConfigError(`role '${role}': ${thrown.message}`);
8455
+ throw thrown;
8456
+ }
8457
+ const scrubs = [];
8458
+ let wireEffort = requestedEffort;
8459
+ if (wireEffort !== void 0 && !caps.reasoningEfforts.includes(wireEffort)) {
8460
+ scrubs.push({
8461
+ scrubbed: "effort",
8462
+ model: merged.model,
8463
+ detail: `effort '${wireEffort}' is not in caps.reasoningEfforts for ${merged.model}; the request proceeds without it (identity keeps the requested effort)`
8501
8464
  });
8502
- this.waiters.push(waiter);
8503
- let onAbort;
8504
- if (signal !== void 0) {
8505
- onAbort = () => {
8506
- const index = this.waiters.indexOf(waiter);
8507
- if (index === -1) return;
8508
- this.waiters.splice(index, 1);
8509
- waiter.aborted = true;
8510
- waiter.resolve();
8511
- };
8512
- signal.addEventListener("abort", onAbort, { once: true });
8513
- }
8514
- try {
8515
- await wait;
8516
- } finally {
8517
- if (signal !== void 0 && onAbort !== void 0) signal.removeEventListener("abort", onAbort);
8518
- }
8519
- if (waiter.aborted) return () => void 0;
8520
- this.active += 1;
8521
- return () => this.release();
8465
+ wireEffort = void 0;
8522
8466
  }
8523
- async withSlot(fn, onQueued, signal) {
8524
- const release = await this.acquire(onQueued, signal);
8525
- try {
8526
- return await fn();
8527
- } finally {
8528
- release();
8467
+ let providerOptions = merged.providerOptions;
8468
+ if (providerOptions?.[adapterId] !== void 0 && !caps.supportsTemperature) {
8469
+ const namespace = { ...providerOptions[adapterId] };
8470
+ const removed = SAMPLING_KEYS.filter((key) => key in namespace);
8471
+ if (removed.length > 0) {
8472
+ for (const key of removed) delete namespace[key];
8473
+ providerOptions = {
8474
+ ...providerOptions,
8475
+ [adapterId]: namespace
8476
+ };
8477
+ scrubs.push({
8478
+ scrubbed: "sampling",
8479
+ model: merged.model,
8480
+ detail: `sampling parameter(s) ${removed.join(", ")} removed for ${merged.model}: the model rejects them (caps.supportsTemperature is false); never silently sent`
8481
+ });
8529
8482
  }
8530
8483
  }
8531
- release() {
8532
- this.active -= 1;
8533
- const next = this.waiters.shift();
8534
- if (next !== void 0) next.resolve();
8535
- }
8536
- };
8537
- //#endregion
8538
- //#region src/model/concurrency.ts
8539
- /**
8540
- * Per-provider concurrency keys (M4-T07): a keyed limiter beside the
8541
- * router, ENGINE-scoped (keys constrain calls
8542
- * across a single engine per adapter). The Appendix A default is
8543
- * unlimited: an embeddable library must not surprise-throttle hosts, so
8544
- * the per-run semaphore stays the only default bound and provider 429s
8545
- * ride RetryPolicy; hosts with known tier limits opt in per adapter id
8546
- * via createEngine concurrency.perProvider.
8547
- *
8548
- * This keyed limiter bounds PARALLELISM inside one engine only. Two
8549
- * processes sharing one API key coordinate through the QuotaLimiter
8550
- * SPI instead (RV-215, createEngine `quota`): rate and volume live
8551
- * there, in shared storage; in-flight slots live here.
8552
- */
8553
- var KeyedLimiter = class {
8554
- semaphores = /* @__PURE__ */ new Map();
8555
- constructor(caps) {
8556
- for (const [key, limit] of Object.entries(caps ?? {})) this.semaphores.set(key, new Semaphore(limit));
8557
- }
8558
- /** Queue depth for one key (0 for unlimited keys); telemetry only. */
8559
- pending(key) {
8560
- return this.semaphores.get(key)?.pending ?? 0;
8484
+ const canonical = requestedEffort === void 0 ? {
8485
+ kind: "model",
8486
+ model: merged.model
8487
+ } : {
8488
+ kind: "model",
8489
+ model: merged.model,
8490
+ effort: requestedEffort
8491
+ };
8492
+ const resolved = {
8493
+ ref: merged.model,
8494
+ adapterId,
8495
+ model,
8496
+ canonical,
8497
+ scrubs
8498
+ };
8499
+ if (wireEffort !== void 0) resolved.wireEffort = wireEffort;
8500
+ if (requestedEffort !== void 0) resolved.requestedEffort = requestedEffort;
8501
+ if (providerOptions !== void 0) resolved.providerOptions = providerOptions;
8502
+ if (merged.fallbacks !== void 0) resolved.fallbacks = merged.fallbacks;
8503
+ return resolved;
8504
+ }
8505
+ /** The closed trigger vocabulary guard. */
8506
+ const TRIGGER_CLASSES = [
8507
+ "error",
8508
+ "limit",
8509
+ "schema-exhausted",
8510
+ "verify-failed",
8511
+ "no-progress"
8512
+ ];
8513
+ function validateGate(gate, rungCount, index) {
8514
+ if (gate.kind === "mechanical") {
8515
+ if (typeof gate.profile !== "string" || gate.profile === "") throw new ConfigError(`ladder acceptance gate ${String(index)}: a mechanical gate names a registered gate profile`);
8516
+ return;
8561
8517
  }
8562
- /**
8563
- * Runs `fn` under the key's semaphore; keys without a configured cap
8564
- * run unlimited (no queueing, no overhead). An aborted `signal` frees
8565
- * a queued caller without a slot (the Semaphore contract), so run
8566
- * cancellation drains provider queues too (v1.34.0 review P2-4).
8567
- */
8568
- async withSlot(key, fn, onQueued, signal) {
8569
- const semaphore = this.semaphores.get(key);
8570
- if (semaphore === void 0) return fn();
8571
- return semaphore.withSlot(fn, onQueued, signal);
8518
+ if (gate.kind === "judge") {
8519
+ if (typeof gate.rung === "number") {
8520
+ if (!Number.isInteger(gate.rung) || gate.rung < 0 || gate.rung >= rungCount) throw new ConfigError(`ladder acceptance gate ${String(index)}: judge rung ${String(gate.rung)} is not a declared rung of a ${String(rungCount)}-rung ladder (FR-119)`);
8521
+ return;
8522
+ }
8523
+ parseModelRef(gate.rung);
8524
+ return;
8572
8525
  }
8573
- };
8574
- //#endregion
8575
- //#region src/model/failover.ts
8576
- /** Normalizes the author-facing ModelChoice.fallbacks list. */
8577
- function normalizeFallbacks(refs) {
8578
- return (refs ?? []).map((model) => ({ model }));
8579
- }
8580
- /**
8581
- * Maps a retry class to its failover trigger once retries exhaust.
8582
- * Overloaded (529) is transport-class for failover purposes; a
8583
- * non-retryable error never fails over.
8584
- */
8585
- function failoverTriggerOf(retryClass) {
8586
- if (retryClass === void 0) return;
8587
- return retryClass === "rate-limit" ? "rate-limit" : "transport";
8526
+ if (!(gate.fraction > 0 && gate.fraction <= 1)) throw new ConfigError(`ladder acceptance gate ${String(index)}: a spot-check fraction lies in (0, 1], got ${String(gate.fraction)}`);
8588
8527
  }
8589
8528
  /**
8590
- * The next target index past `from` that serves `trigger`, or undefined
8591
- * when the chain is exhausted. Index 0 is the primary; the chain never
8592
- * moves backwards (sticky failover).
8529
+ * Canonicalizes a declared LadderSpec: validates the
8530
+ * shape once (FR-119 judge declaration included) and resolves every rung's
8531
+ * effort to an explicit value. `chainEffort` is the effort the resolution
8532
+ * chain would contribute at the declaring layer; a rung that resolves no
8533
+ * effort at all is a ConfigError (the canonical form has no absent-effort
8534
+ * member by declaration).
8593
8535
  */
8594
- function nextFailover(targets, trigger, from) {
8595
- for (let index = from + 1; index < targets.length; index += 1) {
8596
- const on = targets[index]?.on;
8597
- if (on === void 0 || on.includes(trigger)) return index;
8598
- }
8536
+ function canonicalizeLadder(spec, options) {
8537
+ if (!Array.isArray(spec.rungs) || spec.rungs.length === 0) throw new ConfigError("a ladder declares at least one rung");
8538
+ if (!Number.isInteger(spec.startTier) || spec.startTier < 0 || spec.startTier >= spec.rungs.length) throw new ConfigError(`ladder startTier ${String(spec.startTier)} is not a declared rung index of a ${String(spec.rungs.length)}-rung ladder`);
8539
+ for (const trigger of spec.escalateOn) if (!TRIGGER_CLASSES.includes(trigger)) throw new ConfigError(`unknown ladder trigger '${String(trigger)}': the vocabulary is closed to ${TRIGGER_CLASSES.join(", ")}`);
8540
+ const rungs = spec.rungs.map((rung, index) => {
8541
+ parseModelRef(rung.model);
8542
+ if (!Number.isInteger(rung.maxTurns) || rung.maxTurns <= 0) throw new ConfigError(`ladder rung ${String(index)}: maxTurns is a positive integer`);
8543
+ if (!Number.isInteger(rung.maxTokens) || rung.maxTokens <= 0) throw new ConfigError(`ladder rung ${String(index)}: maxTokens is a positive integer`);
8544
+ if (rung.maxCostUsd !== void 0 && !(rung.maxCostUsd > 0)) throw new ConfigError(`ladder rung ${String(index)}: maxCostUsd is positive when present`);
8545
+ const effort = rung.effort ?? options?.chainEffort;
8546
+ if (effort === void 0) throw new ConfigError(`ladder rung ${String(index)} resolves no effort: the canonical ladder embeds explicit efforts; declare rung.effort or a chain effort`);
8547
+ return {
8548
+ model: rung.model,
8549
+ effort,
8550
+ maxTurns: rung.maxTurns,
8551
+ maxTokens: rung.maxTokens,
8552
+ ...rung.maxCostUsd === void 0 ? {} : { maxCostUsd: rung.maxCostUsd },
8553
+ ...rung.memoizeOutcome === void 0 ? {} : { memoizeOutcome: rung.memoizeOutcome }
8554
+ };
8555
+ });
8556
+ for (const [index, gate] of (spec.acceptance ?? []).entries()) validateGate(gate, spec.rungs.length, index);
8557
+ return {
8558
+ rungs,
8559
+ startTier: spec.startTier,
8560
+ escalateOn: [...spec.escalateOn],
8561
+ ...spec.acceptance === void 0 ? {} : { acceptance: spec.acceptance.map((gate) => gate) }
8562
+ };
8599
8563
  }
8600
8564
  /**
8601
- * Classifies a terminal agent outcome for the degenerate fallback:
8602
- * schema-mismatch errors are
8603
- * 'schema-exhausted'; any other error is 'error'; limit terminals (the
8604
- * no-progress abort included) are 'limit'; cancelled, escalated, and
8605
- * skipped never trigger.
8565
+ * The concrete ModelChoice of one rung attempt: each attempt is an
8566
+ * ordinary agent scope whose CanonicalModelSpec is that rung's
8567
+ * `{ kind: 'model' }` form.
8606
8568
  */
8607
- function fallbackTriggerOf(outcome) {
8608
- if (outcome.status === "error") return outcome.error?.kind === "schema-mismatch" ? "schema-exhausted" : "error";
8609
- if (outcome.status === "limit") return "limit";
8569
+ function ladderRungChoice(ladder, index) {
8570
+ const rung = ladder.rungs[index];
8571
+ if (rung === void 0) throw new ConfigError(`rung ${String(index)} is not declared on a ${String(ladder.rungs.length)}-rung ladder`);
8572
+ return {
8573
+ model: rung.model,
8574
+ effort: rung.effort
8575
+ };
8610
8576
  }
8611
8577
  //#endregion
8612
8578
  //#region src/model/pricing.ts
@@ -8665,91 +8631,7 @@ function affordableOutputTokens(pricing, remainingUsd, estimatedInputTokens) {
8665
8631
  return Math.floor((remainingUsd - inputUsd) / outputRate * 1e6);
8666
8632
  }
8667
8633
  //#endregion
8668
- //#region src/model/profile-card.ts
8669
- function toolNamesOf(profile) {
8670
- return (profile.tools ?? []).map((entry) => {
8671
- if (typeof entry === "string") return `${entry} (registered toolset)`;
8672
- if ("kind" in entry && entry.kind === "tool") return entry.name;
8673
- return `${entry.id}:* (tool source)`;
8674
- });
8675
- }
8676
- /**
8677
- * Renders the registry into the shared agent vocabulary card. Sorted,
8678
- * deterministic, byte-stable; an empty registry renders explicitly so
8679
- * the planner never guesses at unregistered agentTypes. When the engine
8680
- * registers toolsets, their names render as a closing line (v1.17.0
8681
- * review P1-3): those are the ONLY values valid as string entries of a
8682
- * tools option, so the planner never invents a registry name.
8683
- */
8684
- function profileCard(profiles, toolsets) {
8685
- const toolsetNames = Object.keys(toolsets ?? {}).sort();
8686
- const toolsetsLine = toolsetNames.length === 0 ? void 0 : `Registered toolsets (valid string entries of a tools option): ${toolsetNames.join(", ")}.`;
8687
- const names = Object.keys(profiles ?? {}).sort();
8688
- if (profiles === void 0 || names.length === 0) {
8689
- const empty = "Agent profiles: none registered. Calls take no agentType.";
8690
- return toolsetsLine === void 0 ? empty : `${empty}\n${toolsetsLine}`;
8691
- }
8692
- const lines = ["Agent profiles (agentType values):"];
8693
- for (const name of names) {
8694
- const profile = profiles[name];
8695
- const description = profile.description ?? "no description";
8696
- lines.push(`- ${name}: ${description}`);
8697
- const toolNames = toolNamesOf(profile);
8698
- if (toolNames.length > 0) lines.push(` tools: ${toolNames.join(", ")}`);
8699
- if (profile.taskClass !== void 0) lines.push(` taskClass: ${profile.taskClass}`);
8700
- if (profile.estCost !== void 0) lines.push(` estCost: ${profile.estCost.toFixed(2)} USD`);
8701
- if (profile.escalation !== void 0) lines.push(` escalation: flavor ${profile.escalation.flavor ?? "A"} (opt-in)`);
8702
- }
8703
- if (toolsetsLine !== void 0) lines.push(toolsetsLine);
8704
- return lines.join("\n");
8705
- }
8706
- //#endregion
8707
- //#region src/model/projector.ts
8708
- /** The provider family of an adapter: `provider` when set, else `id`. */
8709
- function providerOf(adapter) {
8710
- return adapter.provider ?? adapter.id;
8711
- }
8712
- /**
8713
- * Projects the canonical history into the target provider's view:
8714
- * provider-raw parts of a DIFFERENT provider are omitted; everything
8715
- * else (text, images, tool calls, tool results, compaction content)
8716
- * passes through untouched. Messages whose parts all belong to another
8717
- * provider vanish entirely rather than ride as empty messages.
8718
- */
8719
- function projectHistory(messages, targetProvider) {
8720
- const projected = [];
8721
- for (const msg of messages) {
8722
- const parts = msg.parts.filter((part) => part.type !== "provider-raw" || part.provider === targetProvider);
8723
- if (parts.length === 0 && msg.parts.length > 0) continue;
8724
- projected.push(parts.length === msg.parts.length ? msg : {
8725
- ...msg,
8726
- parts
8727
- });
8728
- }
8729
- return projected;
8730
- }
8731
- /**
8732
- * Lifts the adapter-shipped retention payload of one finished turn into
8733
- * provider-raw parts (the retention transport). Reads
8734
- * providerMetadata[<adapter id>].retainedParts and tags each block with
8735
- * the adapter's provider family. Returns [] when the adapter shipped
8736
- * nothing.
8737
- */
8738
- function liftRetainedParts(providerMetadata, adapter) {
8739
- const namespace = providerMetadata?.[adapter.id];
8740
- if (typeof namespace !== "object" || namespace === null) return [];
8741
- const retained = namespace.retainedParts;
8742
- if (!Array.isArray(retained)) return [];
8743
- const blocks = retained;
8744
- const provider = providerOf(adapter);
8745
- return blocks.map((block) => ({
8746
- type: "provider-raw",
8747
- provider,
8748
- block
8749
- }));
8750
- }
8751
- //#endregion
8752
- //#region src/model/quota.ts
8634
+ //#region src/model/quota.ts
8753
8635
  /**
8754
8636
  * Quota rules and the in-process reference QuotaLimiter (RV-215).
8755
8637
  * The rule model is shared by every reference implementation
@@ -8966,6 +8848,177 @@ function validateEngineQuotaConfig(config, site = "createEngine quota") {
8966
8848
  if (candidate.onLimiterError !== void 0 && candidate.onLimiterError !== "deny" && candidate.onLimiterError !== "allow") throw new ConfigError(`${site}.onLimiterError must be 'deny' or 'allow' when given`);
8967
8849
  }
8968
8850
  //#endregion
8851
+ //#region src/runtime/usage-limits.ts
8852
+ /**
8853
+ * UsageLimits (M1-T06): normative limit vocabulary and the per-spawn merge.
8854
+ *
8855
+ * Full contract: https://docs.rulvar.com/guide/agents. Expiry of maxTurns, maxToolCalls,
8856
+ * or timeoutMs produces the terminal status 'limit' (paid partial work);
8857
+ * streamIdleTimeoutMs expiry is a retryable transport-class AgentError,
8858
+ * never 'limit'. The run-level deadline is RunOptions.deadlineAt, not a
8859
+ * UsageLimits field.
8860
+ */
8861
+ const DEFAULT_MAX_TURNS = 32;
8862
+ const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 12e4;
8863
+ /**
8864
+ * Limits merge per spawn: AgentOpts.limits over profile limits over engine
8865
+ * defaults.limits.
8866
+ */
8867
+ function mergeUsageLimits(call, profile, engine) {
8868
+ const pick = (key) => call?.[key] ?? profile?.[key] ?? engine?.[key];
8869
+ const merged = {
8870
+ maxTurns: pick("maxTurns") ?? 32,
8871
+ streamIdleTimeoutMs: pick("streamIdleTimeoutMs") ?? 12e4
8872
+ };
8873
+ const maxToolCalls = pick("maxToolCalls");
8874
+ if (maxToolCalls !== void 0) merged.maxToolCalls = maxToolCalls;
8875
+ const maxOutputTokensPerTurn = pick("maxOutputTokensPerTurn");
8876
+ if (maxOutputTokensPerTurn !== void 0) merged.maxOutputTokensPerTurn = maxOutputTokensPerTurn;
8877
+ const timeoutMs = pick("timeoutMs");
8878
+ if (timeoutMs !== void 0) merged.timeoutMs = timeoutMs;
8879
+ const noProgressTurns = pick("noProgressTurns");
8880
+ if (noProgressTurns !== void 0) merged.noProgressTurns = noProgressTurns;
8881
+ const toolBudgetNotices = pick("toolBudgetNotices");
8882
+ if (toolBudgetNotices !== void 0) merged.toolBudgetNotices = toolBudgetNotices;
8883
+ const maxRepeatedToolSignature = pick("maxRepeatedToolSignature");
8884
+ if (maxRepeatedToolSignature !== void 0) merged.maxRepeatedToolSignature = maxRepeatedToolSignature;
8885
+ const maxNoNewEvidenceCalls = pick("maxNoNewEvidenceCalls");
8886
+ if (maxNoNewEvidenceCalls !== void 0) merged.maxNoNewEvidenceCalls = maxNoNewEvidenceCalls;
8887
+ const maxCallsPerTool = pick("maxCallsPerTool");
8888
+ if (maxCallsPerTool !== void 0) merged.maxCallsPerTool = maxCallsPerTool;
8889
+ const toolUnits = pick("toolUnits");
8890
+ if (toolUnits !== void 0) merged.toolUnits = toolUnits;
8891
+ const finalizationReserve = pick("finalizationReserve");
8892
+ if (finalizationReserve !== void 0) merged.finalizationReserve = finalizationReserve;
8893
+ return merged;
8894
+ }
8895
+ /**
8896
+ * Validates one UsageLimits layer at its intake boundary (v1.34.0
8897
+ * review P2-3): a malformed field (NaN, Infinity, a negative, a
8898
+ * fraction) is a typed ConfigError before the merge, before any journal
8899
+ * entry, and before any provider dispatch. `site` names the layer in the
8900
+ * error text (e.g. `RunOptions.limits`). Counts are positive integers
8901
+ * (maxToolCalls may be 0: a spawn that must not call tools).
8902
+ * streamIdleTimeoutMs is handed to setTimeout as-is, so it is bounded by
8903
+ * the Node timer maximum like RetryPolicy delays; timeoutMs is a
8904
+ * wall-clock comparison, so it has no upper bound. Every present field
8905
+ * is checked; absent fields keep their defaults.
8906
+ */
8907
+ function validateUsageLimits(limits, site) {
8908
+ if (limits.maxTurns !== void 0) requirePositiveInteger(limits.maxTurns, `${site}.maxTurns`);
8909
+ if (limits.maxToolCalls !== void 0) requireNonNegativeInteger(limits.maxToolCalls, `${site}.maxToolCalls`);
8910
+ if (limits.maxOutputTokensPerTurn !== void 0) requirePositiveInteger(limits.maxOutputTokensPerTurn, `${site}.maxOutputTokensPerTurn`);
8911
+ if (limits.timeoutMs !== void 0) requirePositiveInteger(limits.timeoutMs, `${site}.timeoutMs`);
8912
+ if (limits.streamIdleTimeoutMs !== void 0) requireTimerDelayMs(limits.streamIdleTimeoutMs, `${site}.streamIdleTimeoutMs`);
8913
+ if (limits.noProgressTurns !== void 0) requirePositiveInteger(limits.noProgressTurns, `${site}.noProgressTurns`);
8914
+ if (limits.toolBudgetNotices !== void 0 && typeof limits.toolBudgetNotices !== "boolean") throw new ConfigError(`${site}.toolBudgetNotices must be a boolean; got ${typeof limits.toolBudgetNotices}`);
8915
+ if (limits.maxRepeatedToolSignature !== void 0) requirePositiveInteger(limits.maxRepeatedToolSignature, `${site}.maxRepeatedToolSignature`);
8916
+ if (limits.maxNoNewEvidenceCalls !== void 0) requirePositiveInteger(limits.maxNoNewEvidenceCalls, `${site}.maxNoNewEvidenceCalls`);
8917
+ if (limits.maxCallsPerTool !== void 0) {
8918
+ const caps = limits.maxCallsPerTool;
8919
+ if (typeof caps !== "object" || caps === null || Array.isArray(caps)) throw new ConfigError(`${site}.maxCallsPerTool must be a record of per-tool caps`);
8920
+ for (const [name, cap] of Object.entries(caps)) requireNonNegativeInteger(cap, `${site}.maxCallsPerTool['${name}']`);
8921
+ }
8922
+ if (limits.toolUnits !== void 0) {
8923
+ const units = limits.toolUnits;
8924
+ if (typeof units !== "object" || units === null || Array.isArray(units)) throw new ConfigError(`${site}.toolUnits must be { max, costs? }`);
8925
+ const { max, costs } = units;
8926
+ requirePositiveInteger(max, `${site}.toolUnits.max`);
8927
+ if (costs !== void 0) {
8928
+ if (typeof costs !== "object" || costs === null || Array.isArray(costs)) throw new ConfigError(`${site}.toolUnits.costs must be a record of per-tool costs`);
8929
+ for (const [name, cost] of Object.entries(costs)) requireNonNegativeInteger(cost, `${site}.toolUnits.costs['${name}']`);
8930
+ }
8931
+ }
8932
+ if (limits.finalizationReserve !== void 0) {
8933
+ const reserve = limits.finalizationReserve;
8934
+ if (typeof reserve !== "object" || reserve === null || Array.isArray(reserve)) throw new ConfigError(`${site}.finalizationReserve must be { maxOutputTokens? }`);
8935
+ const { maxOutputTokens } = reserve;
8936
+ if (maxOutputTokens !== void 0) requirePositiveInteger(maxOutputTokens, `${site}.finalizationReserve.maxOutputTokens`);
8937
+ }
8938
+ }
8939
+ //#endregion
8940
+ //#region src/model/failover.ts
8941
+ /** Normalizes the author-facing ModelChoice.fallbacks list. */
8942
+ function normalizeFallbacks(refs) {
8943
+ return (refs ?? []).map((model) => ({ model }));
8944
+ }
8945
+ /**
8946
+ * Maps a retry class to its failover trigger once retries exhaust.
8947
+ * Overloaded (529) is transport-class for failover purposes; a
8948
+ * non-retryable error never fails over.
8949
+ */
8950
+ function failoverTriggerOf(retryClass) {
8951
+ if (retryClass === void 0) return;
8952
+ return retryClass === "rate-limit" ? "rate-limit" : "transport";
8953
+ }
8954
+ /**
8955
+ * The next target index past `from` that serves `trigger`, or undefined
8956
+ * when the chain is exhausted. Index 0 is the primary; the chain never
8957
+ * moves backwards (sticky failover).
8958
+ */
8959
+ function nextFailover(targets, trigger, from) {
8960
+ for (let index = from + 1; index < targets.length; index += 1) {
8961
+ const on = targets[index]?.on;
8962
+ if (on === void 0 || on.includes(trigger)) return index;
8963
+ }
8964
+ }
8965
+ /**
8966
+ * Classifies a terminal agent outcome for the degenerate fallback:
8967
+ * schema-mismatch errors are
8968
+ * 'schema-exhausted'; any other error is 'error'; limit terminals (the
8969
+ * no-progress abort included) are 'limit'; cancelled, escalated, and
8970
+ * skipped never trigger.
8971
+ */
8972
+ function fallbackTriggerOf(outcome) {
8973
+ if (outcome.status === "error") return outcome.error?.kind === "schema-mismatch" ? "schema-exhausted" : "error";
8974
+ if (outcome.status === "limit") return "limit";
8975
+ }
8976
+ //#endregion
8977
+ //#region src/model/projector.ts
8978
+ /** The provider family of an adapter: `provider` when set, else `id`. */
8979
+ function providerOf(adapter) {
8980
+ return adapter.provider ?? adapter.id;
8981
+ }
8982
+ /**
8983
+ * Projects the canonical history into the target provider's view:
8984
+ * provider-raw parts of a DIFFERENT provider are omitted; everything
8985
+ * else (text, images, tool calls, tool results, compaction content)
8986
+ * passes through untouched. Messages whose parts all belong to another
8987
+ * provider vanish entirely rather than ride as empty messages.
8988
+ */
8989
+ function projectHistory(messages, targetProvider) {
8990
+ const projected = [];
8991
+ for (const msg of messages) {
8992
+ const parts = msg.parts.filter((part) => part.type !== "provider-raw" || part.provider === targetProvider);
8993
+ if (parts.length === 0 && msg.parts.length > 0) continue;
8994
+ projected.push(parts.length === msg.parts.length ? msg : {
8995
+ ...msg,
8996
+ parts
8997
+ });
8998
+ }
8999
+ return projected;
9000
+ }
9001
+ /**
9002
+ * Lifts the adapter-shipped retention payload of one finished turn into
9003
+ * provider-raw parts (the retention transport). Reads
9004
+ * providerMetadata[<adapter id>].retainedParts and tags each block with
9005
+ * the adapter's provider family. Returns [] when the adapter shipped
9006
+ * nothing.
9007
+ */
9008
+ function liftRetainedParts(providerMetadata, adapter) {
9009
+ const namespace = providerMetadata?.[adapter.id];
9010
+ if (typeof namespace !== "object" || namespace === null) return [];
9011
+ const retained = namespace.retainedParts;
9012
+ if (!Array.isArray(retained)) return [];
9013
+ const blocks = retained;
9014
+ const provider = providerOf(adapter);
9015
+ return blocks.map((block) => ({
9016
+ type: "provider-raw",
9017
+ provider,
9018
+ block
9019
+ }));
9020
+ }
9021
+ //#endregion
8969
9022
  //#region src/model/retry.ts
8970
9023
  /**
8971
9024
  * Transport RetryPolicy (M4-T05): retries live UNDER the journal. A
@@ -9100,21 +9153,234 @@ function retryDelayMs(policy, retryIndex, retryAfterMs, random = nativeRandom) {
9100
9153
  return timerSafe(base / 2 + random() * (base / 2));
9101
9154
  }
9102
9155
  //#endregion
9103
- //#region src/model/roles.ts
9156
+ //#region src/model/caps.ts
9157
+ const TIER_ORDER = {
9158
+ native: 2,
9159
+ "forced-tool": 1,
9160
+ prompt: 0
9161
+ };
9104
9162
  /**
9105
- * True when the given structured-output tier can ride the last loop turn.
9106
- * `native` and `prompt` coexist with tool availability; `forced-tool`
9107
- * pins toolChoice to the synthesized emit_result contract and therefore
9108
- * cannot ride while the agent's tools must remain available. For an
9109
- * agent with no tools every tier rides (the M1 behavior, unchanged).
9163
+ * Strict-schema compatibility as both first-class providers define it:
9164
+ * every object node declares `additionalProperties: false` and lists every
9165
+ * property in `required`. Boolean schemas and
9166
+ * non-object shapes are trivially compatible.
9110
9167
  */
9111
- function canRideLoopTurn(tier, toolsAvailable) {
9112
- return tier !== "forced-tool" || !toolsAvailable;
9113
- }
9114
- /**
9115
- * The completed extract-necessity rule: a separate final structured-output
9116
- * invocation fires only when a schema is set AND (routing directs extract
9117
- * to a different model OR the loop model's caps cannot serve the required
9168
+ function isStrictCompatibleSchema(schema) {
9169
+ if (typeof schema === "boolean") return true;
9170
+ if (schema.type === "object" || schema.properties !== void 0 || schema.additionalProperties !== void 0) {
9171
+ if (schema.additionalProperties !== false) return false;
9172
+ const properties = typeof schema.properties === "object" && schema.properties !== null ? schema.properties : {};
9173
+ const required = Array.isArray(schema.required) ? schema.required : [];
9174
+ for (const name of Object.keys(properties)) if (!required.includes(name)) return false;
9175
+ for (const value of Object.values(properties)) if (typeof value === "object" && value !== null || typeof value === "boolean") {
9176
+ if (!isStrictCompatibleSchema(value)) return false;
9177
+ }
9178
+ }
9179
+ for (const key of [
9180
+ "items",
9181
+ "additionalProperties",
9182
+ "contains"
9183
+ ]) {
9184
+ const value = schema[key];
9185
+ if (typeof value === "object" && value !== null || typeof value === "boolean") {
9186
+ if (!isStrictCompatibleSchema(value)) return false;
9187
+ }
9188
+ }
9189
+ for (const key of [
9190
+ "allOf",
9191
+ "anyOf",
9192
+ "oneOf",
9193
+ "prefixItems"
9194
+ ]) {
9195
+ const value = schema[key];
9196
+ if (Array.isArray(value)) {
9197
+ for (const element of value) if (typeof element === "object" && element !== null || typeof element === "boolean") {
9198
+ if (!isStrictCompatibleSchema(element)) return false;
9199
+ }
9200
+ }
9201
+ }
9202
+ return true;
9203
+ }
9204
+ /**
9205
+ * Tier selection: the model's declared ceiling
9206
+ * bounds the tier; the native tier additionally requires a
9207
+ * strict-compatible canonical schema (relying on silent server-side
9208
+ * fallback is forbidden), degrading to forced-tool.
9209
+ * Prefill is not a tier.
9210
+ */
9211
+ function selectStructuredOutputTier(caps, canonicalSchema) {
9212
+ const ceiling = caps.structuredOutput;
9213
+ if (ceiling === "native" && !isStrictCompatibleSchema(canonicalSchema)) return "forced-tool";
9214
+ return ceiling;
9215
+ }
9216
+ /** True when `tier` is at or below the model's declared ceiling. */
9217
+ function tierWithinCaps(tier, caps) {
9218
+ return TIER_ORDER[tier] <= TIER_ORDER[caps.structuredOutput];
9219
+ }
9220
+ //#endregion
9221
+ //#region src/runtime/escalation.ts
9222
+ const ESCALATE_TOOL_NAME = "escalate";
9223
+ /**
9224
+ * The escalate tool's exact request schema. costToDate and salvage
9225
+ * MUST NOT appear here: additionalProperties false rejects model-authored
9226
+ * values for them at argument validation.
9227
+ */
9228
+ const ESCALATION_REQUEST_SCHEMA = {
9229
+ type: "object",
9230
+ additionalProperties: false,
9231
+ required: [
9232
+ "kind",
9233
+ "scopeDelta",
9234
+ "revisedEstimate"
9235
+ ],
9236
+ properties: {
9237
+ kind: { enum: [
9238
+ "scope_bigger",
9239
+ "scope_different",
9240
+ "blocked_with_evidence"
9241
+ ] },
9242
+ scopeDelta: { type: "string" },
9243
+ revisedEstimate: {
9244
+ type: "object",
9245
+ additionalProperties: false,
9246
+ required: ["usd", "turns"],
9247
+ properties: {
9248
+ usd: {
9249
+ type: "number",
9250
+ minimum: 0
9251
+ },
9252
+ turns: {
9253
+ type: "integer",
9254
+ minimum: 0
9255
+ }
9256
+ }
9257
+ },
9258
+ blockers: {
9259
+ type: "array",
9260
+ items: { type: "string" }
9261
+ },
9262
+ proposedDecomposition: {
9263
+ type: "array",
9264
+ items: { type: "object" }
9265
+ }
9266
+ }
9267
+ };
9268
+ /** The full-report schema applied BEFORE append. */
9269
+ const ESCALATION_REPORT_SCHEMA = {
9270
+ type: "object",
9271
+ additionalProperties: false,
9272
+ required: [
9273
+ "kind",
9274
+ "scopeDelta",
9275
+ "revisedEstimate",
9276
+ "blockers",
9277
+ "proposedDecomposition",
9278
+ "costToDate",
9279
+ "salvage"
9280
+ ],
9281
+ properties: {
9282
+ kind: { enum: [
9283
+ "scope_bigger",
9284
+ "scope_different",
9285
+ "blocked_with_evidence"
9286
+ ] },
9287
+ scopeDelta: { type: "string" },
9288
+ revisedEstimate: {
9289
+ type: "object",
9290
+ additionalProperties: false,
9291
+ required: ["usd", "turns"],
9292
+ properties: {
9293
+ usd: {
9294
+ type: "number",
9295
+ minimum: 0
9296
+ },
9297
+ turns: {
9298
+ type: "integer",
9299
+ minimum: 0
9300
+ }
9301
+ }
9302
+ },
9303
+ blockers: {
9304
+ type: "array",
9305
+ items: { type: "string" }
9306
+ },
9307
+ proposedDecomposition: {
9308
+ type: "array",
9309
+ items: { type: "object" }
9310
+ },
9311
+ costToDate: {
9312
+ type: "object",
9313
+ additionalProperties: false,
9314
+ required: ["usd", "turns"],
9315
+ properties: {
9316
+ usd: { type: "number" },
9317
+ turns: {
9318
+ type: "integer",
9319
+ minimum: 0
9320
+ }
9321
+ }
9322
+ },
9323
+ salvage: {
9324
+ type: "object",
9325
+ additionalProperties: false,
9326
+ required: ["transcriptRef", "artifacts"],
9327
+ properties: {
9328
+ transcriptRef: { type: "string" },
9329
+ artifacts: {
9330
+ type: "array",
9331
+ items: { type: "string" }
9332
+ },
9333
+ worktreePatchRef: { type: "string" }
9334
+ }
9335
+ }
9336
+ }
9337
+ };
9338
+ /**
9339
+ * The engine opt-in tool: registered through the
9340
+ * same path as any tool under escalation opt-in of EITHER flavor (the
9341
+ * worker's only authoring channel for a report), never available without
9342
+ * opt-in, and dispatched through the same permission chain. The loop
9343
+ * intercepts accepted calls; execute is unreachable by construction.
9344
+ */
9345
+ function escalateTool() {
9346
+ return tool({
9347
+ name: ESCALATE_TOOL_NAME,
9348
+ description: "Escalate to the owner of this task: the scope is bigger than estimated, materially different, or blocked with evidence. Escalating ends your turn loop; include everything the owner needs to decide.",
9349
+ parameters: ESCALATION_REQUEST_SCHEMA,
9350
+ execute: () => {
9351
+ throw new Error("escalate is intercepted by the agent runtime, never executed");
9352
+ }
9353
+ });
9354
+ }
9355
+ /** Validates the runtime-completed report BEFORE append; returns issues. */
9356
+ async function validateEscalationReport(report) {
9357
+ const validation = await validateSchemaSpec(ESCALATION_REPORT_SCHEMA, report);
9358
+ return validation.valid ? [] : validation.issues;
9359
+ }
9360
+ /**
9361
+ * countsAgainstLimit derivation (XF-06): true iff
9362
+ * scope_bigger; scope_different and blocked_with_evidence are exempt and
9363
+ * never debit the escalation counter.
9364
+ */
9365
+ function countsAgainstLimit(kind) {
9366
+ return kind === "scope_bigger";
9367
+ }
9368
+ //#endregion
9369
+ //#region src/model/roles.ts
9370
+ /**
9371
+ * True when the given structured-output tier can ride the last loop turn.
9372
+ * `native` and `prompt` coexist with tool availability; `forced-tool`
9373
+ * pins toolChoice to the synthesized emit_result contract and therefore
9374
+ * cannot ride while the agent's tools must remain available. For an
9375
+ * agent with no tools every tier rides (the M1 behavior, unchanged).
9376
+ */
9377
+ function canRideLoopTurn(tier, toolsAvailable) {
9378
+ return tier !== "forced-tool" || !toolsAvailable;
9379
+ }
9380
+ /**
9381
+ * The completed extract-necessity rule: a separate final structured-output
9382
+ * invocation fires only when a schema is set AND (routing directs extract
9383
+ * to a different model OR the loop model's caps cannot serve the required
9118
9384
  * tier OR finalize is routed, in which case the schema never rides a loop
9119
9385
  * or synthesis turn). Otherwise the schema rides the last loop turn with
9120
9386
  * no extra call (as amended in M4-T01).
@@ -9200,1646 +9466,979 @@ function compactMessages(messages, summaryText) {
9200
9466
  return head === void 0 ? [summary] : [head, summary];
9201
9467
  }
9202
9468
  //#endregion
9203
- //#region src/model/router.ts
9204
- /**
9205
- * Model router core (M1-T05): the per-engine adapter registry, ModelRef
9206
- * parsing, the per-invocation resolution chain, canonicalization into
9207
- * CanonicalModelSpec, and caps scrubbing with visible scrub notes.
9208
- *
9209
- * Public contract: https://docs.rulvar.com/guide/model-routing.
9210
- */
9211
- /**
9212
- * Per-engine adapter registry: strictly per engine, no global mutable
9213
- * registry exists. A duplicate adapterId is a typed ConfigError.
9214
- */
9215
- function buildAdapterRegistry(adapters) {
9216
- const registry = /* @__PURE__ */ new Map();
9217
- for (const adapter of adapters) {
9218
- if (registry.has(adapter.id)) throw new ConfigError(`duplicate adapterId '${adapter.id}' at createEngine`);
9219
- registry.set(adapter.id, adapter);
9469
+ //#region src/runtime/model-retry.ts
9470
+ var ModelRetry = class extends Error {
9471
+ data;
9472
+ constructor(message, opts) {
9473
+ super(message);
9474
+ this.name = "ModelRetry";
9475
+ if (opts?.data !== void 0) this.data = opts.data;
9220
9476
  }
9221
- return registry;
9222
- }
9477
+ };
9478
+ /** Bounded semantic retries per tool call chain. */
9479
+ const DEFAULT_MODEL_RETRY_ATTEMPTS = 2;
9480
+ //#endregion
9481
+ //#region src/runtime/exploration.ts
9223
9482
  /**
9224
- * ModelRef is strictly 'adapterId:model', no query parameters. The wire
9225
- * model id may itself contain colons (for example ollama tags), so only
9226
- * the FIRST colon splits.
9227
- */
9228
- function parseModelRef(ref) {
9229
- const colon = ref.indexOf(":");
9230
- if (colon <= 0 || colon === ref.length - 1) throw new ConfigError(`invalid ModelRef '${ref}': expected the strict 'adapterId:model' form`);
9231
- return {
9232
- adapterId: ref.slice(0, colon),
9233
- model: ref.slice(colon + 1)
9234
- };
9235
- }
9236
- /**
9237
- * Role effort defaults: orchestrate and plan default to high; summarize and extract
9238
- * default to low. loop and finalize have NO role default: when the chain
9239
- * resolves nothing, the wire omits effort and identity records the spec
9240
- * with the effort member absent.
9483
+ * Exploration guards (RV-210, first slice): the engine-side counters that
9484
+ * make an oscillating tool loop visible and boundable. The published gap:
9485
+ * an agent that repeats the byte-identical tool call, or keeps receiving
9486
+ * pages it has already seen, burns its whole tool budget with zero signal
9487
+ * and dies as a bare 'limit' terminal; the no-progress detector never
9488
+ * trips because tool calls reset it.
9489
+ *
9490
+ * Three opt-in UsageLimits fields drive this module:
9491
+ *
9492
+ * - `maxRepeatedToolSignature`: how many times the SAME signature (tool
9493
+ * name + RFC 8785 canonical args) may execute per invocation. The call
9494
+ * that would exceed it is not dispatched; the model receives a typed
9495
+ * error tool result instead (visible, bounded, never terminal), and the
9496
+ * denial does not consume the tool budget.
9497
+ * - `maxNoNewEvidenceCalls`: how many consecutive successful executions
9498
+ * may return only already-seen result digests before the loop aborts as
9499
+ * status 'limit' with abortClass 'exploration' (paid partial work; the
9500
+ * executed results stand and the terminal memoizes like every
9501
+ * engine-decided abort).
9502
+ * - `toolBudgetNotices`: soft 50%/80% thresholds over `maxToolCalls`,
9503
+ * surfaced to the model as a plain user message with the exact
9504
+ * remaining count, so pacing is possible before the hard cap.
9505
+ *
9506
+ * Determinism: signatures and digests derive from the canonical JCS
9507
+ * serialization; values JCS cannot serialize never match anything (a
9508
+ * unique signature; a fresh-evidence result), so the guards fail open,
9509
+ * never spuriously. On resume the guard state is rebuilt from the
9510
+ * restored checkpoint messages (successful executions only, and only the
9511
+ * window a compaction kept), which is the same source the model itself
9512
+ * sees; enforcement is engine-side and live-only, while a replayed
9513
+ * guard abort is re-stamped from the journaled terminal like every other
9514
+ * abort class.
9241
9515
  */
9242
- const ROLE_EFFORT_DEFAULTS = {
9243
- orchestrate: "high",
9244
- plan: "high",
9245
- summarize: "low",
9246
- extract: "low"
9247
- };
9248
- function contribution(spec, _role) {
9249
- if (spec === void 0) return {};
9250
- if (typeof spec === "string") return { model: spec };
9251
- if ("ladder" in spec) return { ladder: spec.ladder };
9252
- const choice = spec;
9253
- const fields = { model: choice.model };
9254
- if (choice.effort !== void 0) fields.effort = choice.effort;
9255
- if (choice.providerOptions !== void 0) fields.providerOptions = choice.providerOptions;
9256
- if (choice.fallbacks !== void 0) fields.fallbacks = choice.fallbacks;
9257
- return fields;
9258
- }
9259
- function layerFields(layer, role) {
9260
- if (layer === void 0) return {};
9261
- const fromModel = contribution(layer.model, role);
9262
- const fromRouting = contribution(layer.routing?.[role], role);
9263
- const merged = {
9264
- ...fromModel,
9265
- ...pruneUndefined(fromRouting)
9266
- };
9267
- if (layer.effort !== void 0) merged.effort = layer.effort;
9268
- return merged;
9269
- }
9270
- function pruneUndefined(value) {
9271
- const out = {};
9272
- for (const [key, member] of Object.entries(value)) if (member !== void 0) out[key] = member;
9273
- return out;
9274
- }
9275
- function mergeProviderOptions(lower, higher) {
9276
- if (lower === void 0) return higher;
9277
- if (higher === void 0) return lower;
9278
- const merged = { ...lower };
9279
- for (const [namespace, options] of Object.entries(higher)) merged[namespace] = {
9280
- ...merged[namespace],
9281
- ...options
9282
- };
9283
- return merged;
9516
+ /** The docs anchor cited by guard denials and the guard abort. */
9517
+ const GUARD_DOCS_URL = "https://docs.rulvar.com/guide/agents#exploration-guards";
9518
+ /** True when any exploration guard field asks for tracking. */
9519
+ function explorationTrackingEnabled(limits) {
9520
+ return limits.maxRepeatedToolSignature !== void 0 || limits.maxNoNewEvidenceCalls !== void 0 || limits.toolBudgetNotices === true || limits.maxCallsPerTool !== void 0 || limits.toolUnits !== void 0;
9284
9521
  }
9285
- /** Sampling parameters both first-class providers reject on reasoning models. */
9286
- const SAMPLING_KEYS = [
9287
- "temperature",
9288
- "top_p",
9289
- "top_k"
9290
- ];
9291
- /**
9292
- * Resolution runs on every model invocation, not once per agent: a layered
9293
- * merge of { model, effort, providerOptions, fallbacks } in the order call
9294
- * override > agent profile > workflow defaults > engine defaults, with the
9295
- * invocation role attached as a tag.
9296
- * After resolution the router reads ModelCaps and scrubs illegal
9297
- * parameters visibly: unsupported effort is removed from the wire but
9298
- * kept in identity; sampling params rejected by the model are removed
9299
- * from the adapter's namespace, never silently sent.
9300
- */
9301
- function resolveModelInvocation(options) {
9302
- const { role } = options;
9303
- const layers = [
9304
- options.engine,
9305
- options.workflow,
9306
- options.profile,
9307
- options.call
9308
- ];
9309
- let merged = {};
9310
- for (const layer of layers) {
9311
- const fields = layerFields(layer, role);
9312
- merged = {
9313
- ...merged,
9314
- ...pruneUndefined(fields),
9315
- providerOptions: mergeProviderOptions(merged.providerOptions, fields.providerOptions)
9316
- };
9317
- if (fields.ladder !== void 0) delete merged.model;
9318
- else if (fields.model !== void 0) delete merged.ladder;
9319
- }
9320
- if (merged.ladder !== void 0) throw new ConfigError(`a ladder ModelSpec wins wire resolution for role '${role}': ladder execution is owned by the PlanRunner ladder driver, which resolves each rung attempt to a concrete model override; dispatch laddered profiles through orchestratePlanned or pass a plain ModelRef or ModelChoice`);
9321
- if (merged.model === void 0) throw new ConfigError(`no model resolves for role '${role}': set AgentOpts.model, a profile model, or engine defaults.routing`);
9322
- checkFloors({
9323
- ref: merged.model,
9324
- role,
9325
- ...options.floors === void 0 ? {} : { floors: options.floors },
9326
- ...options.taskClass === void 0 ? {} : { taskClass: options.taskClass }
9327
- });
9328
- const requestedEffort = merged.effort ?? ROLE_EFFORT_DEFAULTS[role];
9329
- const { adapterId, model } = parseModelRef(merged.model);
9330
- let caps;
9522
+ function digestOf$1(value) {
9331
9523
  try {
9332
- caps = options.capsOf(merged.model);
9333
- } catch (thrown) {
9334
- if (thrown instanceof ConfigError) throw new ConfigError(`role '${role}': ${thrown.message}`);
9335
- throw thrown;
9524
+ return createHash("sha256").update(jcsSerialize(value), "utf8").digest("hex");
9525
+ } catch {
9526
+ return;
9336
9527
  }
9337
- const scrubs = [];
9338
- let wireEffort = requestedEffort;
9339
- if (wireEffort !== void 0 && !caps.reasoningEfforts.includes(wireEffort)) {
9340
- scrubs.push({
9341
- scrubbed: "effort",
9342
- model: merged.model,
9343
- detail: `effort '${wireEffort}' is not in caps.reasoningEfforts for ${merged.model}; the request proceeds without it (identity keeps the requested effort)`
9344
- });
9345
- wireEffort = void 0;
9528
+ }
9529
+ var ExplorationGuard = class {
9530
+ config;
9531
+ signatureExecutions = /* @__PURE__ */ new Map();
9532
+ seenDigests = /* @__PURE__ */ new Set();
9533
+ byTool = /* @__PURE__ */ new Map();
9534
+ noNewEvidenceStreak = 0;
9535
+ executed = 0;
9536
+ repeated = 0;
9537
+ duplicateResults = 0;
9538
+ denied = 0;
9539
+ deniedToolCap = 0;
9540
+ unitsUsed = 0;
9541
+ unserializableSeq = 0;
9542
+ constructor(config) {
9543
+ this.config = config;
9346
9544
  }
9347
- let providerOptions = merged.providerOptions;
9348
- if (providerOptions?.[adapterId] !== void 0 && !caps.supportsTemperature) {
9349
- const namespace = { ...providerOptions[adapterId] };
9350
- const removed = SAMPLING_KEYS.filter((key) => key in namespace);
9351
- if (removed.length > 0) {
9352
- for (const key of removed) delete namespace[key];
9353
- providerOptions = {
9354
- ...providerOptions,
9355
- [adapterId]: namespace
9356
- };
9357
- scrubs.push({
9358
- scrubbed: "sampling",
9359
- model: merged.model,
9360
- detail: `sampling parameter(s) ${removed.join(", ")} removed for ${merged.model}: the model rejects them (caps.supportsTemperature is false); never silently sent`
9361
- });
9545
+ /**
9546
+ * The canonical signature: tool name + JCS args. Args JCS cannot
9547
+ * serialize get a unique per-occurrence signature, so they never
9548
+ * repeat and the guard fails open.
9549
+ */
9550
+ signatureOf(name, args) {
9551
+ try {
9552
+ return `${name}\u0000${jcsSerialize(args ?? null)}`;
9553
+ } catch {
9554
+ this.unserializableSeq += 1;
9555
+ return `${name}\u0000<unserializable:${String(this.unserializableSeq)}>`;
9362
9556
  }
9363
9557
  }
9364
- const canonical = requestedEffort === void 0 ? {
9365
- kind: "model",
9366
- model: merged.model
9367
- } : {
9368
- kind: "model",
9369
- model: merged.model,
9370
- effort: requestedEffort
9371
- };
9372
- const resolved = {
9373
- ref: merged.model,
9374
- adapterId,
9375
- model,
9376
- canonical,
9377
- scrubs
9378
- };
9379
- if (wireEffort !== void 0) resolved.wireEffort = wireEffort;
9380
- if (requestedEffort !== void 0) resolved.requestedEffort = requestedEffort;
9381
- if (providerOptions !== void 0) resolved.providerOptions = providerOptions;
9382
- if (merged.fallbacks !== void 0) resolved.fallbacks = merged.fallbacks;
9383
- return resolved;
9384
- }
9385
- /** The closed trigger vocabulary guard. */
9386
- const TRIGGER_CLASSES = [
9387
- "error",
9388
- "limit",
9389
- "schema-exhausted",
9390
- "verify-failed",
9391
- "no-progress"
9392
- ];
9393
- function validateGate(gate, rungCount, index) {
9394
- if (gate.kind === "mechanical") {
9395
- if (typeof gate.profile !== "string" || gate.profile === "") throw new ConfigError(`ladder acceptance gate ${String(index)}: a mechanical gate names a registered gate profile`);
9396
- return;
9397
- }
9398
- if (gate.kind === "judge") {
9399
- if (typeof gate.rung === "number") {
9400
- if (!Number.isInteger(gate.rung) || gate.rung < 0 || gate.rung >= rungCount) throw new ConfigError(`ladder acceptance gate ${String(index)}: judge rung ${String(gate.rung)} is not a declared rung of a ${String(rungCount)}-rung ladder (FR-119)`);
9401
- return;
9558
+ /**
9559
+ * Rebuilds guard state from restored checkpoint messages: assistant
9560
+ * tool-call parts paired with their successful tool results by id.
9561
+ * Error results (denials, tool failures) are skipped, so a resume
9562
+ * never over-counts; a compaction naturally narrows the window to
9563
+ * what the model itself still sees.
9564
+ */
9565
+ restore(messages) {
9566
+ const callsById = /* @__PURE__ */ new Map();
9567
+ for (const msg of messages) for (const part of msg.parts) if (part.type === "tool-call") callsById.set(part.id, {
9568
+ name: part.name,
9569
+ args: part.args
9570
+ });
9571
+ else if (part.type === "tool-result" && part.isError !== true) {
9572
+ const call = callsById.get(part.id);
9573
+ if (call === void 0) continue;
9574
+ this.recordExecution(call.name, call.args, part.result, true);
9402
9575
  }
9403
- parseModelRef(gate.rung);
9404
- return;
9405
9576
  }
9406
- if (!(gate.fraction > 0 && gate.fraction <= 1)) throw new ConfigError(`ladder acceptance gate ${String(index)}: a spot-check fraction lies in (0, 1], got ${String(gate.fraction)}`);
9407
- }
9408
- /**
9409
- * Canonicalizes a declared LadderSpec: validates the
9410
- * shape once (FR-119 judge declaration included) and resolves every rung's
9411
- * effort to an explicit value. `chainEffort` is the effort the resolution
9412
- * chain would contribute at the declaring layer; a rung that resolves no
9413
- * effort at all is a ConfigError (the canonical form has no absent-effort
9414
- * member by declaration).
9415
- */
9416
- function canonicalizeLadder(spec, options) {
9417
- if (!Array.isArray(spec.rungs) || spec.rungs.length === 0) throw new ConfigError("a ladder declares at least one rung");
9418
- if (!Number.isInteger(spec.startTier) || spec.startTier < 0 || spec.startTier >= spec.rungs.length) throw new ConfigError(`ladder startTier ${String(spec.startTier)} is not a declared rung index of a ${String(spec.rungs.length)}-rung ladder`);
9419
- for (const trigger of spec.escalateOn) if (!TRIGGER_CLASSES.includes(trigger)) throw new ConfigError(`unknown ladder trigger '${String(trigger)}': the vocabulary is closed to ${TRIGGER_CLASSES.join(", ")}`);
9420
- const rungs = spec.rungs.map((rung, index) => {
9421
- parseModelRef(rung.model);
9422
- if (!Number.isInteger(rung.maxTurns) || rung.maxTurns <= 0) throw new ConfigError(`ladder rung ${String(index)}: maxTurns is a positive integer`);
9423
- if (!Number.isInteger(rung.maxTokens) || rung.maxTokens <= 0) throw new ConfigError(`ladder rung ${String(index)}: maxTokens is a positive integer`);
9424
- if (rung.maxCostUsd !== void 0 && !(rung.maxCostUsd > 0)) throw new ConfigError(`ladder rung ${String(index)}: maxCostUsd is positive when present`);
9425
- const effort = rung.effort ?? options?.chainEffort;
9426
- if (effort === void 0) throw new ConfigError(`ladder rung ${String(index)} resolves no effort: the canonical ladder embeds explicit efforts; declare rung.effort or a chain effort`);
9577
+ /**
9578
+ * The pre-dispatch verdict: denies the call that would exceed its
9579
+ * tool's maxCallsPerTool cap, then the call that would exceed
9580
+ * maxRepeatedToolSignature executions of the same signature. A denial
9581
+ * never consumes maxToolCalls or tool units.
9582
+ */
9583
+ beforeExecute(name, args) {
9584
+ const cap = this.config.maxCallsPerTool?.[name];
9585
+ if (cap !== void 0) {
9586
+ const executions = this.byTool.get(name) ?? 0;
9587
+ if (executions >= cap) {
9588
+ this.deniedToolCap += 1;
9589
+ return {
9590
+ deny: true,
9591
+ guard: "per-tool-cap",
9592
+ executions,
9593
+ reason: `exploration guard: '${name}' already executed ${String(executions)} time(s) this invocation (maxCallsPerTool ${String(cap)}). Use what you have or a different tool (${GUARD_DOCS_URL}).`
9594
+ };
9595
+ }
9596
+ }
9597
+ const max = this.config.maxRepeatedToolSignature;
9598
+ if (max === void 0) return { deny: false };
9599
+ const executions = this.signatureExecutions.get(this.signatureOf(name, args)) ?? 0;
9600
+ if (executions < max) return { deny: false };
9601
+ this.denied += 1;
9427
9602
  return {
9428
- model: rung.model,
9429
- effort,
9430
- maxTurns: rung.maxTurns,
9431
- maxTokens: rung.maxTokens,
9432
- ...rung.maxCostUsd === void 0 ? {} : { maxCostUsd: rung.maxCostUsd },
9433
- ...rung.memoizeOutcome === void 0 ? {} : { memoizeOutcome: rung.memoizeOutcome }
9603
+ deny: true,
9604
+ guard: "repeated-signature",
9605
+ executions,
9606
+ reason: `exploration guard: this exact '${name}' call already executed ${String(executions)} time(s) this invocation (maxRepeatedToolSignature ${String(max)}). Reuse the earlier result or change the arguments (${GUARD_DOCS_URL}).`
9434
9607
  };
9435
- });
9436
- for (const [index, gate] of (spec.acceptance ?? []).entries()) validateGate(gate, spec.rungs.length, index);
9437
- return {
9438
- rungs,
9439
- startTier: spec.startTier,
9440
- escalateOn: [...spec.escalateOn],
9441
- ...spec.acceptance === void 0 ? {} : { acceptance: spec.acceptance.map((gate) => gate) }
9442
- };
9608
+ }
9609
+ /**
9610
+ * Records one dispatched execution and answers whether the
9611
+ * no-new-evidence guard trips. Only successful results feed the
9612
+ * evidence chain: an error result neither resets nor lengthens it
9613
+ * (repeated failing calls are the signature guard's job), and a
9614
+ * result JCS cannot digest counts as fresh evidence.
9615
+ */
9616
+ afterExecute(name, args, result, isError) {
9617
+ return this.recordExecution(name, args, result, !isError);
9618
+ }
9619
+ recordExecution(name, args, result, successful) {
9620
+ this.executed += 1;
9621
+ this.byTool.set(name, (this.byTool.get(name) ?? 0) + 1);
9622
+ if (this.config.toolUnits !== void 0) this.unitsUsed += this.config.toolUnits.costs?.[name] ?? 1;
9623
+ const signature = this.signatureOf(name, args);
9624
+ const prior = this.signatureExecutions.get(signature) ?? 0;
9625
+ if (prior > 0) this.repeated += 1;
9626
+ this.signatureExecutions.set(signature, prior + 1);
9627
+ if (!successful) return false;
9628
+ const digest = digestOf$1(result);
9629
+ if (digest === void 0 || !this.seenDigests.has(digest)) {
9630
+ if (digest !== void 0) this.seenDigests.add(digest);
9631
+ this.noNewEvidenceStreak = 0;
9632
+ return false;
9633
+ }
9634
+ this.duplicateResults += 1;
9635
+ this.noNewEvidenceStreak += 1;
9636
+ const max = this.config.maxNoNewEvidenceCalls;
9637
+ return max !== void 0 && this.noNewEvidenceStreak >= max;
9638
+ }
9639
+ /**
9640
+ * True once the spent tool units reached the weighted budget: the
9641
+ * loop's pre-dispatch check, mirroring maxToolCalls (terminal 'limit',
9642
+ * paid partial work). Never true without toolUnits configured.
9643
+ */
9644
+ unitsExhausted() {
9645
+ return this.config.toolUnits !== void 0 && this.unitsUsed >= this.config.toolUnits.max;
9646
+ }
9647
+ /** The abort message for a tripped no-new-evidence guard. */
9648
+ describeTrip() {
9649
+ return `exploration guard: ${String(this.noNewEvidenceStreak)} consecutive tool calls returned no new evidence (maxNoNewEvidenceCalls ${String(this.config.maxNoNewEvidenceCalls ?? this.noNewEvidenceStreak)}; every result was already seen this invocation). The executed work is kept; narrow the scope, vary the queries, or raise the limit (${GUARD_DOCS_URL}).`;
9650
+ }
9651
+ /** The structured summary; `toolCallsUsed` is the loop's own counter. */
9652
+ summary(toolCallsUsed) {
9653
+ const byTool = {};
9654
+ for (const [name, count] of [...this.byTool.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) byTool[name] = count;
9655
+ return {
9656
+ toolCallsUsed,
9657
+ distinctSignatures: this.signatureExecutions.size,
9658
+ repeatedCalls: this.repeated,
9659
+ duplicateResultCalls: this.duplicateResults,
9660
+ deniedRepeats: this.denied,
9661
+ byTool,
9662
+ ...this.config.maxCallsPerTool === void 0 ? {} : { deniedToolCap: this.deniedToolCap },
9663
+ ...this.config.toolUnits === void 0 ? {} : { toolUnitsUsed: this.unitsUsed }
9664
+ };
9665
+ }
9666
+ };
9667
+ /** The soft notice thresholds over maxToolCalls, in ascending order. */
9668
+ const TOOL_BUDGET_NOTICE_THRESHOLDS = [.5, .8];
9669
+ /**
9670
+ * Which notice thresholds `used` calls out of `max` have crossed
9671
+ * (ceil-based, so a threshold fires no earlier than its exact fraction).
9672
+ */
9673
+ function crossedNoticeThresholds(used, max) {
9674
+ return TOOL_BUDGET_NOTICE_THRESHOLDS.filter((threshold) => used >= Math.ceil(threshold * max)).map((threshold) => threshold);
9443
9675
  }
9444
9676
  /**
9445
- * The concrete ModelChoice of one rung attempt: each attempt is an
9446
- * ordinary agent scope whose CanonicalModelSpec is that rung's
9447
- * `{ kind: 'model' }` form.
9677
+ * The model-visible budget notice. Deterministic for a given usage
9678
+ * count, so a recorded conversation rebuilds byte-identically on
9679
+ * resume and replay.
9448
9680
  */
9449
- function ladderRungChoice(ladder, index) {
9450
- const rung = ladder.rungs[index];
9451
- if (rung === void 0) throw new ConfigError(`rung ${String(index)} is not declared on a ${String(ladder.rungs.length)}-rung ladder`);
9452
- return {
9453
- model: rung.model,
9454
- effort: rung.effort
9455
- };
9681
+ function toolBudgetNoticeText(used, max) {
9682
+ const remaining = Math.max(0, max - used);
9683
+ return `Tool budget notice: ${String(used)} of ${String(max)} tool calls used; ${String(remaining)} remaining. Prioritize the highest value calls and finish with what you have.`;
9456
9684
  }
9457
9685
  //#endregion
9458
- //#region src/runtime/usage-limits.ts
9686
+ //#region src/runtime/no-progress.ts
9459
9687
  /**
9460
- * UsageLimits (M1-T06): normative limit vocabulary and the per-spawn merge.
9688
+ * The no-progress abort class (M3-T08): an engine-defined detector
9689
+ * journaled as a first-class terminal abort distinct from user
9690
+ * cancellation (a cancelled entry always reruns; a no-progress abort
9691
+ * must replay, or every resume would re-pay the stuck turns). The
9692
+ * interim heuristic is committed: N consecutive
9693
+ * turns without tool calls or artifact deltas, N = 3; the broader
9694
+ * heuristic stays OQ-15, revisited on dogfood traces.
9461
9695
  *
9462
- * Full contract: https://docs.rulvar.com/guide/agents. Expiry of maxTurns, maxToolCalls,
9463
- * or timeoutMs produces the terminal status 'limit' (paid partial work);
9464
- * streamIdleTimeoutMs expiry is a retryable transport-class AgentError,
9465
- * never 'limit'. The run-level deadline is RunOptions.deadlineAt, not a
9466
- * UsageLimits field.
9467
- */
9468
- const DEFAULT_MAX_TURNS = 32;
9469
- const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 12e4;
9470
- /**
9471
- * Limits merge per spawn: AgentOpts.limits over profile limits over engine
9472
- * defaults.limits.
9696
+ * Encoding: the abort is the agent's
9697
+ * terminal entry with status 'limit', an error payload carrying
9698
+ * abortClass 'no-progress', and memoizeOutcome stamped by the ENGINE on
9699
+ * the terminal entry, so the frozen memoize-limit rule replays it on
9700
+ * every subsequent resume without a live rerun. In M3 the runtime has no
9701
+ * per-turn artifact channel, so the tool-call test subsumes artifact
9702
+ * deltas; per-turn artifact producers arrive with M4 compaction.
9473
9703
  */
9474
- function mergeUsageLimits(call, profile, engine) {
9475
- const pick = (key) => call?.[key] ?? profile?.[key] ?? engine?.[key];
9476
- const merged = {
9477
- maxTurns: pick("maxTurns") ?? 32,
9478
- streamIdleTimeoutMs: pick("streamIdleTimeoutMs") ?? 12e4
9479
- };
9480
- const maxToolCalls = pick("maxToolCalls");
9481
- if (maxToolCalls !== void 0) merged.maxToolCalls = maxToolCalls;
9482
- const maxOutputTokensPerTurn = pick("maxOutputTokensPerTurn");
9483
- if (maxOutputTokensPerTurn !== void 0) merged.maxOutputTokensPerTurn = maxOutputTokensPerTurn;
9484
- const timeoutMs = pick("timeoutMs");
9485
- if (timeoutMs !== void 0) merged.timeoutMs = timeoutMs;
9486
- const noProgressTurns = pick("noProgressTurns");
9487
- if (noProgressTurns !== void 0) merged.noProgressTurns = noProgressTurns;
9488
- const toolBudgetNotices = pick("toolBudgetNotices");
9489
- if (toolBudgetNotices !== void 0) merged.toolBudgetNotices = toolBudgetNotices;
9490
- const maxRepeatedToolSignature = pick("maxRepeatedToolSignature");
9491
- if (maxRepeatedToolSignature !== void 0) merged.maxRepeatedToolSignature = maxRepeatedToolSignature;
9492
- const maxNoNewEvidenceCalls = pick("maxNoNewEvidenceCalls");
9493
- if (maxNoNewEvidenceCalls !== void 0) merged.maxNoNewEvidenceCalls = maxNoNewEvidenceCalls;
9494
- const maxCallsPerTool = pick("maxCallsPerTool");
9495
- if (maxCallsPerTool !== void 0) merged.maxCallsPerTool = maxCallsPerTool;
9496
- const toolUnits = pick("toolUnits");
9497
- if (toolUnits !== void 0) merged.toolUnits = toolUnits;
9498
- const finalizationReserve = pick("finalizationReserve");
9499
- if (finalizationReserve !== void 0) merged.finalizationReserve = finalizationReserve;
9500
- return merged;
9501
- }
9704
+ /** The committed no-progress detector N. */
9705
+ const DEFAULT_NO_PROGRESS_TURNS = 3;
9502
9706
  /**
9503
- * Validates one UsageLimits layer at its intake boundary (v1.34.0
9504
- * review P2-3): a malformed field (NaN, Infinity, a negative, a
9505
- * fraction) is a typed ConfigError before the merge, before any journal
9506
- * entry, and before any provider dispatch. `site` names the layer in the
9507
- * error text (e.g. `RunOptions.limits`). Counts are positive integers
9508
- * (maxToolCalls may be 0: a spawn that must not call tools).
9509
- * streamIdleTimeoutMs is handed to setTimeout as-is, so it is bounded by
9510
- * the Node timer maximum like RetryPolicy delays; timeoutMs is a
9511
- * wall-clock comparison, so it has no upper bound. Every present field
9512
- * is checked; absent fields keep their defaults.
9707
+ * Counts consecutive progress-free turns. A turn with at least one tool
9708
+ * call (or, later, an artifact delta) resets the streak; a turn with
9709
+ * neither lengthens it; the detector trips when the streak reaches the
9710
+ * threshold AND the loop would otherwise continue.
9513
9711
  */
9514
- function validateUsageLimits(limits, site) {
9515
- if (limits.maxTurns !== void 0) requirePositiveInteger(limits.maxTurns, `${site}.maxTurns`);
9516
- if (limits.maxToolCalls !== void 0) requireNonNegativeInteger(limits.maxToolCalls, `${site}.maxToolCalls`);
9517
- if (limits.maxOutputTokensPerTurn !== void 0) requirePositiveInteger(limits.maxOutputTokensPerTurn, `${site}.maxOutputTokensPerTurn`);
9518
- if (limits.timeoutMs !== void 0) requirePositiveInteger(limits.timeoutMs, `${site}.timeoutMs`);
9519
- if (limits.streamIdleTimeoutMs !== void 0) requireTimerDelayMs(limits.streamIdleTimeoutMs, `${site}.streamIdleTimeoutMs`);
9520
- if (limits.noProgressTurns !== void 0) requirePositiveInteger(limits.noProgressTurns, `${site}.noProgressTurns`);
9521
- if (limits.toolBudgetNotices !== void 0 && typeof limits.toolBudgetNotices !== "boolean") throw new ConfigError(`${site}.toolBudgetNotices must be a boolean; got ${typeof limits.toolBudgetNotices}`);
9522
- if (limits.maxRepeatedToolSignature !== void 0) requirePositiveInteger(limits.maxRepeatedToolSignature, `${site}.maxRepeatedToolSignature`);
9523
- if (limits.maxNoNewEvidenceCalls !== void 0) requirePositiveInteger(limits.maxNoNewEvidenceCalls, `${site}.maxNoNewEvidenceCalls`);
9524
- if (limits.maxCallsPerTool !== void 0) {
9525
- const caps = limits.maxCallsPerTool;
9526
- if (typeof caps !== "object" || caps === null || Array.isArray(caps)) throw new ConfigError(`${site}.maxCallsPerTool must be a record of per-tool caps`);
9527
- for (const [name, cap] of Object.entries(caps)) requireNonNegativeInteger(cap, `${site}.maxCallsPerTool['${name}']`);
9712
+ var NoProgressDetector = class {
9713
+ streakInternal = 0;
9714
+ threshold;
9715
+ constructor(threshold) {
9716
+ this.threshold = threshold ?? 3;
9528
9717
  }
9529
- if (limits.toolUnits !== void 0) {
9530
- const units = limits.toolUnits;
9531
- if (typeof units !== "object" || units === null || Array.isArray(units)) throw new ConfigError(`${site}.toolUnits must be { max, costs? }`);
9532
- const { max, costs } = units;
9533
- requirePositiveInteger(max, `${site}.toolUnits.max`);
9534
- if (costs !== void 0) {
9535
- if (typeof costs !== "object" || costs === null || Array.isArray(costs)) throw new ConfigError(`${site}.toolUnits.costs must be a record of per-tool costs`);
9536
- for (const [name, cost] of Object.entries(costs)) requireNonNegativeInteger(cost, `${site}.toolUnits.costs['${name}']`);
9537
- }
9718
+ get streak() {
9719
+ return this.streakInternal;
9538
9720
  }
9539
- if (limits.finalizationReserve !== void 0) {
9540
- const reserve = limits.finalizationReserve;
9541
- if (typeof reserve !== "object" || reserve === null || Array.isArray(reserve)) throw new ConfigError(`${site}.finalizationReserve must be { maxOutputTokens? }`);
9542
- const { maxOutputTokens } = reserve;
9543
- if (maxOutputTokens !== void 0) requirePositiveInteger(maxOutputTokens, `${site}.finalizationReserve.maxOutputTokens`);
9721
+ /** Records one completed model turn. */
9722
+ recordTurn(progress) {
9723
+ if (progress.toolCalls > 0 || (progress.artifactDeltas ?? 0) > 0) this.streakInternal = 0;
9724
+ else this.streakInternal += 1;
9544
9725
  }
9545
- }
9546
- //#endregion
9547
- //#region src/runtime/model-retry.ts
9548
- var ModelRetry = class extends Error {
9549
- data;
9550
- constructor(message, opts) {
9551
- super(message);
9552
- this.name = "ModelRetry";
9553
- if (opts?.data !== void 0) this.data = opts.data;
9726
+ get tripped() {
9727
+ return this.streakInternal >= this.threshold;
9728
+ }
9729
+ describe() {
9730
+ return `no-progress abort after ${this.streakInternal} consecutive turns without tool calls or artifact deltas (threshold ${this.threshold}; https://docs.rulvar.com/guide/agents#the-agent-loop-and-turns)`;
9554
9731
  }
9555
9732
  };
9556
- /** Bounded semantic retries per tool call chain. */
9557
- const DEFAULT_MODEL_RETRY_ATTEMPTS = 2;
9558
9733
  //#endregion
9559
- //#region src/runtime/escalation.ts
9560
- const ESCALATE_TOOL_NAME = "escalate";
9734
+ //#region src/runtime/structured-output.ts
9735
+ /** The synthesized forced-tool contract name. */
9736
+ const EMIT_RESULT_TOOL = "emit_result";
9561
9737
  /**
9562
- * The escalate tool's exact request schema. costToDate and salvage
9563
- * MUST NOT appear here: additionalProperties false rejects model-authored
9564
- * values for them at argument validation.
9738
+ * Applies the selected tier to an outgoing request. Native rides
9739
+ * ChatRequest.schema; forced-tool synthesizes a single emit_result tool
9740
+ * with toolChoice pinned to it; prompt injects the schema into the last
9741
+ * user message.
9565
9742
  */
9566
- const ESCALATION_REQUEST_SCHEMA = {
9567
- type: "object",
9568
- additionalProperties: false,
9569
- required: [
9570
- "kind",
9571
- "scopeDelta",
9572
- "revisedEstimate"
9573
- ],
9574
- properties: {
9575
- kind: { enum: [
9576
- "scope_bigger",
9577
- "scope_different",
9578
- "blocked_with_evidence"
9579
- ] },
9580
- scopeDelta: { type: "string" },
9581
- revisedEstimate: {
9582
- type: "object",
9583
- additionalProperties: false,
9584
- required: ["usd", "turns"],
9585
- properties: {
9586
- usd: {
9587
- type: "number",
9588
- minimum: 0
9589
- },
9590
- turns: {
9591
- type: "integer",
9592
- minimum: 0
9593
- }
9594
- }
9595
- },
9596
- blockers: {
9597
- type: "array",
9598
- items: { type: "string" }
9599
- },
9600
- proposedDecomposition: {
9601
- type: "array",
9602
- items: { type: "object" }
9603
- }
9604
- }
9605
- };
9606
- /** The full-report schema applied BEFORE append. */
9607
- const ESCALATION_REPORT_SCHEMA = {
9608
- type: "object",
9609
- additionalProperties: false,
9610
- required: [
9611
- "kind",
9612
- "scopeDelta",
9613
- "revisedEstimate",
9614
- "blockers",
9615
- "proposedDecomposition",
9616
- "costToDate",
9617
- "salvage"
9618
- ],
9619
- properties: {
9620
- kind: { enum: [
9621
- "scope_bigger",
9622
- "scope_different",
9623
- "blocked_with_evidence"
9624
- ] },
9625
- scopeDelta: { type: "string" },
9626
- revisedEstimate: {
9627
- type: "object",
9628
- additionalProperties: false,
9629
- required: ["usd", "turns"],
9630
- properties: {
9631
- usd: {
9632
- type: "number",
9633
- minimum: 0
9634
- },
9635
- turns: {
9636
- type: "integer",
9637
- minimum: 0
9638
- }
9639
- }
9640
- },
9641
- blockers: {
9642
- type: "array",
9643
- items: { type: "string" }
9644
- },
9645
- proposedDecomposition: {
9646
- type: "array",
9647
- items: { type: "object" }
9648
- },
9649
- costToDate: {
9650
- type: "object",
9651
- additionalProperties: false,
9652
- required: ["usd", "turns"],
9653
- properties: {
9654
- usd: { type: "number" },
9655
- turns: {
9656
- type: "integer",
9657
- minimum: 0
9658
- }
9659
- }
9660
- },
9661
- salvage: {
9662
- type: "object",
9663
- additionalProperties: false,
9664
- required: ["transcriptRef", "artifacts"],
9665
- properties: {
9666
- transcriptRef: { type: "string" },
9667
- artifacts: {
9668
- type: "array",
9669
- items: { type: "string" }
9670
- },
9671
- worktreePatchRef: { type: "string" }
9672
- }
9673
- }
9743
+ function applyStructuredOutputTier(req, tier, schema) {
9744
+ if (tier === "native") return {
9745
+ ...req,
9746
+ schema
9747
+ };
9748
+ if (tier === "forced-tool") {
9749
+ const contract = {
9750
+ name: EMIT_RESULT_TOOL,
9751
+ description: "Emit the final structured result. Call exactly once with the complete answer.",
9752
+ parameters: schema
9753
+ };
9754
+ return {
9755
+ ...req,
9756
+ tools: [...req.tools ?? [], contract],
9757
+ toolChoice: { name: EMIT_RESULT_TOOL }
9758
+ };
9674
9759
  }
9675
- };
9676
- /**
9677
- * The engine opt-in tool: registered through the
9678
- * same path as any tool under escalation opt-in of EITHER flavor (the
9679
- * worker's only authoring channel for a report), never available without
9680
- * opt-in, and dispatched through the same permission chain. The loop
9681
- * intercepts accepted calls; execute is unreachable by construction.
9682
- */
9683
- function escalateTool() {
9684
- return tool({
9685
- name: ESCALATE_TOOL_NAME,
9686
- description: "Escalate to the owner of this task: the scope is bigger than estimated, materially different, or blocked with evidence. Escalating ends your turn loop; include everything the owner needs to decide.",
9687
- parameters: ESCALATION_REQUEST_SCHEMA,
9688
- execute: () => {
9689
- throw new Error("escalate is intercepted by the agent runtime, never executed");
9690
- }
9760
+ const instruction = "Respond with a single JSON value that validates against this JSON Schema, with no surrounding prose and no code fences:\n" + JSON.stringify(schema);
9761
+ const messages = [...req.messages];
9762
+ const last = messages[messages.length - 1];
9763
+ if (last !== void 0 && last.role === "user") messages[messages.length - 1] = {
9764
+ role: "user",
9765
+ parts: [...last.parts, {
9766
+ type: "text",
9767
+ text: instruction
9768
+ }]
9769
+ };
9770
+ else messages.push({
9771
+ role: "user",
9772
+ parts: [{
9773
+ type: "text",
9774
+ text: instruction
9775
+ }]
9691
9776
  });
9692
- }
9693
- /** Validates the runtime-completed report BEFORE append; returns issues. */
9694
- async function validateEscalationReport(report) {
9695
- const validation = await validateSchemaSpec(ESCALATION_REPORT_SCHEMA, report);
9696
- return validation.valid ? [] : validation.issues;
9777
+ return {
9778
+ ...req,
9779
+ messages
9780
+ };
9697
9781
  }
9698
9782
  /**
9699
- * countsAgainstLimit derivation (XF-06): true iff
9700
- * scope_bigger; scope_different and blocked_with_evidence are exempt and
9701
- * never debit the escalation counter.
9783
+ * Extracts the structured-output candidate from a collected turn per tier.
9784
+ * Returns `undefined` when the turn carries no candidate (for example the
9785
+ * model answered prose without the forced tool call).
9702
9786
  */
9703
- function countsAgainstLimit(kind) {
9704
- return kind === "scope_bigger";
9787
+ function extractCandidate(turn, tier) {
9788
+ if (tier === "forced-tool") {
9789
+ const call = turn.toolCalls.find((c) => c.name === EMIT_RESULT_TOOL);
9790
+ return call === void 0 ? void 0 : { raw: call.args };
9791
+ }
9792
+ const text = turn.text.trim();
9793
+ if (text === "") return;
9794
+ try {
9795
+ return { raw: JSON.parse(text) };
9796
+ } catch {
9797
+ const extracted = extractFirstJsonValue(text);
9798
+ return extracted === void 0 ? void 0 : { raw: extracted };
9799
+ }
9800
+ }
9801
+ /** Best-effort extraction of the first complete JSON object or array in prose. */
9802
+ function extractFirstJsonValue(text) {
9803
+ const start = text.search(/[[{]/);
9804
+ if (start === -1) return;
9805
+ const open = text[start];
9806
+ const close = open === "{" ? "}" : "]";
9807
+ let depth = 0;
9808
+ let inString = false;
9809
+ let escaped = false;
9810
+ for (let i = start; i < text.length; i += 1) {
9811
+ const ch = text[i];
9812
+ if (inString) {
9813
+ if (escaped) escaped = false;
9814
+ else if (ch === "\\") escaped = true;
9815
+ else if (ch === "\"") inString = false;
9816
+ continue;
9817
+ }
9818
+ if (ch === "\"") inString = true;
9819
+ else if (ch === open) depth += 1;
9820
+ else if (ch === close) {
9821
+ depth -= 1;
9822
+ if (depth === 0) try {
9823
+ return JSON.parse(text.slice(start, i + 1));
9824
+ } catch {
9825
+ return;
9826
+ }
9827
+ }
9828
+ }
9829
+ }
9830
+ /** The bounded re-prompt message sent back to the model on a validation miss. */
9831
+ function formatRePrompt(issues, attempt, maxAttempts) {
9832
+ return {
9833
+ role: "user",
9834
+ parts: [{
9835
+ type: "text",
9836
+ text: `Your previous answer did not validate against the required schema (attempt ${attempt} of ${maxAttempts}). Issues:\n${issues.slice(0, 16).map((issue) => {
9837
+ const path = issue.path === void 0 || issue.path.length === 0 ? "" : ` (at ${issue.path.map((seg) => String(typeof seg === "object" ? seg.key : seg)).join(".")})`;
9838
+ return `- ${issue.message}${path}`;
9839
+ }).join("\n")}\nRespond again with ONLY a corrected JSON value that validates.`
9840
+ }]
9841
+ };
9705
9842
  }
9706
9843
  //#endregion
9707
- //#region src/runtime/no-progress.ts
9844
+ //#region src/runtime/agent-loop.ts
9708
9845
  /**
9709
- * The no-progress abort class (M3-T08): an engine-defined detector
9710
- * journaled as a first-class terminal abort distinct from user
9711
- * cancellation (a cancelled entry always reruns; a no-progress abort
9712
- * must replay, or every resume would re-pay the stuck turns). The
9713
- * interim heuristic is committed: N consecutive
9714
- * turns without tool calls or artifact deltas, N = 3; the broader
9715
- * heuristic stays OQ-15, revisited on dogfood traces.
9846
+ * Agent runtime v1 (M1-T06): the single subagent loop shared by every
9847
+ * orchestration mode. A model turn; structured output in three tiers with
9848
+ * client validation and a bounded re-prompt; typed AgentResult; beyond the
9849
+ * configured policy the runtime never throws: failures become typed
9850
+ * AgentResult statuses. Tool dispatch, checkpoints, and compaction arrive
9851
+ * with M3/M4; the escalated status arrives in M3 as the flagged breaking
9852
+ * change.
9716
9853
  *
9717
- * Encoding: the abort is the agent's
9718
- * terminal entry with status 'limit', an error payload carrying
9719
- * abortClass 'no-progress', and memoizeOutcome stamped by the ENGINE on
9720
- * the terminal entry, so the frozen memoize-limit rule replays it on
9721
- * every subsequent resume without a live rerun. In M3 the runtime has no
9722
- * per-turn artifact channel, so the tool-call test subsumes artifact
9723
- * deltas; per-turn artifact producers arrive with M4 compaction.
9724
- */
9725
- /** The committed no-progress detector N. */
9726
- const DEFAULT_NO_PROGRESS_TURNS = 3;
9727
- /**
9728
- * Counts consecutive progress-free turns. A turn with at least one tool
9729
- * call (or, later, an artifact delta) resets the streak; a turn with
9730
- * neither lengthens it; the detector trips when the streak reaches the
9731
- * threshold AND the loop would otherwise continue.
9854
+ * Docs: https://docs.rulvar.com/guide/agents (agent runtime binding);
9855
+ * https://docs.rulvar.com/guide/model-routing (roles, tiers, refusal).
9732
9856
  */
9733
- var NoProgressDetector = class {
9734
- streakInternal = 0;
9735
- threshold;
9736
- constructor(threshold) {
9737
- this.threshold = threshold ?? 3;
9738
- }
9739
- get streak() {
9740
- return this.streakInternal;
9741
- }
9742
- /** Records one completed model turn. */
9743
- recordTurn(progress) {
9744
- if (progress.toolCalls > 0 || (progress.artifactDeltas ?? 0) > 0) this.streakInternal = 0;
9745
- else this.streakInternal += 1;
9746
- }
9747
- get tripped() {
9748
- return this.streakInternal >= this.threshold;
9749
- }
9750
- describe() {
9751
- return `no-progress abort after ${this.streakInternal} consecutive turns without tool calls or artifact deltas (threshold ${this.threshold}; https://docs.rulvar.com/guide/agents#the-agent-loop-and-turns)`;
9752
- }
9857
+ function isEscalated(r) {
9858
+ return r.status === "escalated";
9859
+ }
9860
+ /** Reason marker distinguishing a budget-ceiling abort from host cancellation. */
9861
+ const BUDGET_ABORT_REASON = "rulvar:budget-ceiling";
9862
+ const ZERO_USAGE$1 = {
9863
+ inputTokens: 0,
9864
+ outputTokens: 0,
9865
+ cacheReadTokens: 0,
9866
+ cacheWriteTokens: 0
9753
9867
  };
9754
- //#endregion
9755
- //#region src/runtime/permission-chain.ts
9756
- /**
9757
- * The layered permission chain (M3-T03): the single approval surface for
9758
- * every tool dispatch, regardless of tool origin. The order is fixed and
9759
- * normative: hooks -> deny rules -> ask rules -> canUseTool -> terminal
9760
- * default (allow unless needsApproval, then ask). Evaluation is
9761
- * short-circuit; unconfigured layers are skipped. Rules never yield
9762
- * allow: allow is only ever falling through to canUseTool or the
9763
- * terminal default.
9764
- *
9765
- * Full contract: https://docs.rulvar.com/guide/tools.
9766
- * Risk presets, the argv shell matcher, domain rules, and the
9767
- * audit/dry-run surface land in M5.
9768
- */
9769
- /**
9770
- * Merges the engine-wide config and the profile config into one chain.
9771
- * Layers concatenate engine-first; since rules only deny or ask, ordering
9772
- * within a layer cannot change the verdict. The
9773
- * profile's canUseTool wins over the engine's (a single slot by
9774
- * construction). A declared preset compiles INTO the same layers, after
9775
- * the host-authored rules, never as a fifth layer (M5-T05).
9776
- */
9777
- function compilePermissionChain(engine, profile) {
9778
- const preset = profile?.preset === void 0 ? {
9779
- deny: [],
9780
- ask: []
9781
- } : compilePermissionPreset(profile.preset);
9782
- const deny = [
9783
- ...engine?.deny ?? [],
9784
- ...profile?.deny ?? [],
9785
- ...preset.deny
9786
- ];
9787
- const ask = [
9788
- ...engine?.ask ?? [],
9789
- ...profile?.ask ?? [],
9790
- ...preset.ask
9791
- ];
9792
- const canUseTool = profile?.canUseTool ?? engine?.canUseTool;
9793
- return {
9794
- hooks: [...engine?.hooks ?? [], ...profile?.hooks ?? []],
9795
- deny,
9796
- ask,
9797
- ...canUseTool === void 0 ? {} : { canUseTool }
9868
+ const wallRandom = Math.random.bind(globalThis);
9869
+ function addUsage$1(total, turn) {
9870
+ const sum = {
9871
+ inputTokens: total.inputTokens + turn.inputTokens,
9872
+ outputTokens: total.outputTokens + turn.outputTokens,
9873
+ cacheReadTokens: total.cacheReadTokens + turn.cacheReadTokens,
9874
+ cacheWriteTokens: total.cacheWriteTokens + turn.cacheWriteTokens
9798
9875
  };
9799
- }
9800
- /** The command text an argv rule matches against. */
9801
- function commandOf(input) {
9802
- if (typeof input === "string") return input;
9803
- if (typeof input === "object" && input !== null) {
9804
- const command = input.command;
9805
- if (typeof command === "string") return command;
9806
- }
9807
- }
9808
- function ruleMatches(rule, toolName, risk, input) {
9809
- if ("risk" in rule) {
9810
- const risks = Array.isArray(rule.risk) ? rule.risk : [rule.risk];
9811
- if (risks.includes("undeclared") && risk === void 0) return true;
9812
- return risk !== void 0 && risks.includes(risk);
9813
- }
9814
- if ("domains" in rule) return false;
9815
- if (!(Array.isArray(rule.tool) ? rule.tool : [rule.tool]).includes(toolName)) return false;
9816
- if ("argv" in rule) {
9817
- const command = commandOf(input);
9818
- if (command === void 0) return false;
9819
- const patterns = Array.isArray(rule.argv) ? rule.argv : [rule.argv];
9820
- return lexShellCommand(command).some((segment) => !segment.unmatchable && patterns.some((pattern) => matchArgvPattern(pattern, segment.argv)));
9821
- }
9822
- return true;
9876
+ const reasoning = (total.reasoningTokens ?? 0) + (turn.reasoningTokens ?? 0);
9877
+ if (reasoning > 0) sum.reasoningTokens = reasoning;
9878
+ return sum;
9823
9879
  }
9824
9880
  /**
9825
- * Advisory domain-rule matches for the audit payload:
9826
- * reported, never enforced in the current release.
9881
+ * The Usage invariant is verified at the adapter boundary: inputTokens is
9882
+ * the FULL prompt including cache reads and writes.
9827
9883
  */
9828
- function advisoryMatches(chain, toolName) {
9829
- return [...chain.deny, ...chain.ask].filter((rule) => "domains" in rule && rule.tool === toolName);
9830
- }
9831
9884
  /**
9832
- * Unmatchable segments (command/process substitution, here-docs) yield
9833
- * ask, ALWAYS, for any tool that has argv rules.
9885
+ * The full canonical invariant at the adapter boundary (v1.20.0 review
9886
+ * P1-1): every count finite, integral, and nonnegative, and the cache
9887
+ * subsets inside the input. One violation message covers every adapter,
9888
+ * injected clients and mocks included; the financial invariant never
9889
+ * depends on the good faith of an external transport.
9834
9890
  */
9835
- function argvUnmatchableAsk(chain, toolName, input) {
9836
- if (![...chain.deny, ...chain.ask].some((rule) => "argv" in rule && (Array.isArray(rule.tool) ? rule.tool : [rule.tool]).includes(toolName))) return false;
9837
- const command = commandOf(input);
9838
- if (command === void 0) return true;
9839
- return lexShellCommand(command).some((segment) => segment.unmatchable);
9891
+ function usageInvariantViolation(usage, adapterId) {
9892
+ const violations = usageViolations(usage);
9893
+ if (violations.length === 0) return;
9894
+ return `adapter '${adapterId}' violated the Usage invariant: ${violations.join("; ")}`;
9840
9895
  }
9841
- /** A stub ToolContext for offline (dry-run) evaluations. */
9842
- function offlineContext(toolName) {
9843
- return {
9844
- runId: "dry-run",
9845
- spanId: `dry-run-${toolName}`,
9846
- agent: { agentType: "" },
9847
- cwd: process.cwd(),
9848
- isolation: "none",
9849
- signal: new AbortController().signal,
9850
- log: () => void 0
9896
+ async function streamTurn(adapter, req, options) {
9897
+ const idle = new AbortController();
9898
+ const all = [...options.signals, idle.signal];
9899
+ if (options.budgetSignal !== void 0) all.push(options.budgetSignal);
9900
+ const combined = AbortSignal.any(all);
9901
+ const turn = {
9902
+ text: "",
9903
+ toolCalls: []
9851
9904
  };
9852
- }
9853
- /**
9854
- * Evaluates the chain for one dispatch, or OFFLINE against a
9855
- * hypothetical call by tool name (the dry-run API: nothing executes;
9856
- * shells and tests read the verdict, the
9857
- * deciding layer, and the matched rule). Hooks run in deterministic
9858
- * registration order; { modifiedInput } substitutes the input and
9859
- * continues; the first decisive verdict wins. The returned input is what
9860
- * execute receives and what the approval identity hashes (post hook
9861
- * modification). Advisory domain-rule matches
9862
- * ride every verdict for the audit payload.
9863
- */
9864
- async function evaluatePermission(chain, tool, input, ctx) {
9865
- const def = typeof tool === "string" ? {
9866
- name: tool,
9867
- needsApproval: false
9868
- } : tool;
9869
- const risk = typeof tool === "string" ? void 0 : tool.risk;
9870
- const context = ctx ?? offlineContext(def.name);
9871
- const advisory = advisoryMatches(chain, def.name);
9872
- const withAdvisory = (verdict) => advisory.length === 0 ? verdict : {
9873
- ...verdict,
9874
- advisory
9905
+ const pendingArgs = /* @__PURE__ */ new Map();
9906
+ let usage = ZERO_USAGE$1;
9907
+ let reported = ZERO_USAGE$1;
9908
+ let usageViolation;
9909
+ let sawFinish = false;
9910
+ let finish;
9911
+ let providerMetadata;
9912
+ let wireError;
9913
+ let idleTimer;
9914
+ const armIdle = () => {
9915
+ if (idleTimer !== void 0) clearTimeout(idleTimer);
9916
+ idleTimer = setTimeout(() => idle.abort("rulvar:stream-idle"), options.idleTimeoutMs);
9875
9917
  };
9876
- let effective = input;
9877
- for (const hook of chain.hooks) {
9878
- const verdict = await hook(def.name, effective, context);
9879
- if (verdict === void 0) continue;
9880
- if (verdict === "allow" || verdict === "deny" || verdict === "ask") return withAdvisory({
9881
- verdict,
9882
- decidedBy: "hook",
9883
- input: effective
9884
- });
9885
- effective = verdict.modifiedInput;
9886
- }
9887
- for (const rule of chain.deny) if (ruleMatches(rule, def.name, risk, effective)) return withAdvisory({
9888
- verdict: "deny",
9889
- decidedBy: "deny-rule",
9890
- rule,
9891
- input: effective
9892
- });
9893
- for (const rule of chain.ask) if (ruleMatches(rule, def.name, risk, effective)) return withAdvisory({
9894
- verdict: "ask",
9895
- decidedBy: "ask-rule",
9896
- rule,
9897
- input: effective
9898
- });
9899
- if (argvUnmatchableAsk(chain, def.name, effective)) return withAdvisory({
9900
- verdict: "ask",
9901
- decidedBy: "ask-rule",
9902
- input: effective
9903
- });
9904
- if (chain.canUseTool !== void 0) {
9905
- const verdict = await chain.canUseTool(def.name, effective, context);
9906
- if (verdict === "allow") return withAdvisory({
9907
- verdict: "allow",
9908
- decidedBy: "canUseTool",
9909
- input: effective
9910
- });
9911
- if (verdict === "deny") return withAdvisory({
9912
- verdict: "deny",
9913
- decidedBy: "canUseTool",
9914
- input: effective
9915
- });
9916
- effective = verdict.modifiedInput;
9917
- }
9918
- if (def.needsApproval) return withAdvisory({
9919
- verdict: "ask",
9920
- decidedBy: "default",
9921
- input: effective
9922
- });
9923
- return withAdvisory({
9924
- verdict: "allow",
9925
- decidedBy: "default",
9926
- input: effective
9927
- });
9918
+ try {
9919
+ armIdle();
9920
+ for await (const event of adapter.stream(req, combined)) {
9921
+ armIdle();
9922
+ switch (event.type) {
9923
+ case "text-delta":
9924
+ turn.text += event.text;
9925
+ options.onDelta?.(event.text);
9926
+ break;
9927
+ case "reasoning-delta":
9928
+ options.onDelta?.(event.text);
9929
+ break;
9930
+ case "tool-call-start":
9931
+ pendingArgs.set(event.id, {
9932
+ name: event.name,
9933
+ argsText: ""
9934
+ });
9935
+ break;
9936
+ case "tool-call-delta": {
9937
+ const pending = pendingArgs.get(event.id);
9938
+ if (pending !== void 0) pending.argsText += event.argsTextDelta;
9939
+ break;
9940
+ }
9941
+ case "tool-call-end": {
9942
+ const pending = pendingArgs.get(event.id);
9943
+ turn.toolCalls.push({
9944
+ id: event.id,
9945
+ name: pending?.name ?? "",
9946
+ args: event.args
9947
+ });
9948
+ pendingArgs.delete(event.id);
9949
+ break;
9950
+ }
9951
+ case "usage": {
9952
+ const cleaned = {};
9953
+ for (const field of [
9954
+ "inputTokens",
9955
+ "outputTokens",
9956
+ "cacheReadTokens",
9957
+ "cacheWriteTokens",
9958
+ "reasoningTokens"
9959
+ ]) {
9960
+ const value = event.usage[field];
9961
+ if (value === void 0) continue;
9962
+ if (Number.isInteger(value) && value >= 0) cleaned[field] = value;
9963
+ else {
9964
+ usageViolation ??= `mid-stream usage event carried invalid ${field} (${String(value)})`;
9965
+ cleaned[field] = sanitizeTokenCount(value);
9966
+ }
9967
+ }
9968
+ usage = {
9969
+ ...usage,
9970
+ ...cleaned
9971
+ };
9972
+ const delta = {
9973
+ inputTokens: cleaned.inputTokens ?? 0,
9974
+ outputTokens: cleaned.outputTokens ?? 0,
9975
+ cacheReadTokens: cleaned.cacheReadTokens ?? 0,
9976
+ cacheWriteTokens: cleaned.cacheWriteTokens ?? 0
9977
+ };
9978
+ if (cleaned.reasoningTokens !== void 0) delta.reasoningTokens = cleaned.reasoningTokens;
9979
+ reported = addUsage$1(reported, delta);
9980
+ options.onUsage?.(delta);
9981
+ break;
9982
+ }
9983
+ case "finish":
9984
+ sawFinish = true;
9985
+ finish = event.finish;
9986
+ usage = event.usage;
9987
+ providerMetadata = event.providerMetadata;
9988
+ break;
9989
+ case "error":
9990
+ wireError = event.error;
9991
+ break;
9992
+ }
9993
+ if (sawFinish || wireError !== void 0) break;
9994
+ }
9995
+ } catch (thrown) {
9996
+ if (!combined.aborted) wireError = {
9997
+ code: "agent",
9998
+ message: thrown instanceof Error ? thrown.message : String(thrown),
9999
+ retryable: true,
10000
+ data: { kind: "transport" }
10001
+ };
10002
+ } finally {
10003
+ if (idleTimer !== void 0) clearTimeout(idleTimer);
10004
+ }
10005
+ if (combined.aborted && !sawFinish && wireError === void 0) {
10006
+ const aborted = options.budgetSignal?.aborted === true ? "budget" : idle.signal.aborted ? "idle" : "external";
10007
+ const outcome = {
10008
+ turn,
10009
+ usage,
10010
+ reported,
10011
+ usageApprox: true,
10012
+ aborted
10013
+ };
10014
+ if (finish !== void 0) outcome.finish = finish;
10015
+ if (usageViolation !== void 0) outcome.usageViolation = usageViolation;
10016
+ return outcome;
10017
+ }
10018
+ if (!sawFinish && wireError === void 0) wireError = {
10019
+ code: "agent",
10020
+ message: `adapter '${adapter.id}' stream ended without a terminal finish or error event; the adapter contract requires exactly one per stream, so the partial turn is discarded as a retryable transport fault`,
10021
+ retryable: true,
10022
+ data: { kind: "transport" }
10023
+ };
10024
+ const outcome = {
10025
+ turn,
10026
+ usage,
10027
+ reported,
10028
+ usageApprox: !sawFinish
10029
+ };
10030
+ if (finish !== void 0) outcome.finish = finish;
10031
+ if (usageViolation !== void 0) outcome.usageViolation = usageViolation;
10032
+ if (providerMetadata !== void 0) outcome.providerMetadata = providerMetadata;
10033
+ if (wireError !== void 0) outcome.wireError = wireError;
10034
+ return outcome;
10035
+ }
10036
+ function classifyWireError(wire) {
10037
+ const data = wire.data ?? {};
10038
+ const error = {
10039
+ kind: data.kind === "rate-limit" || wire.code === "rate-limit" ? "rate-limit" : data.kind ?? "transport",
10040
+ retryable: wire.retryable
10041
+ };
10042
+ if (typeof data.retryAfterMs === "number") error.retryAfterMs = data.retryAfterMs;
10043
+ return error;
10044
+ }
10045
+ function buildRequest(resolved, messages, limits, tools) {
10046
+ const req = {
10047
+ model: resolved.model,
10048
+ messages
10049
+ };
10050
+ if (resolved.wireEffort !== void 0) req.effort = resolved.wireEffort;
10051
+ if (resolved.providerOptions !== void 0) req.providerOptions = resolved.providerOptions;
10052
+ if (limits.maxOutputTokensPerTurn !== void 0) req.maxOutputTokens = limits.maxOutputTokensPerTurn;
10053
+ if (tools !== void 0 && tools.length > 0) req.tools = tools;
10054
+ return req;
9928
10055
  }
9929
- //#endregion
9930
- //#region src/runtime/structured-output.ts
9931
- /** The synthesized forced-tool contract name. */
9932
- const EMIT_RESULT_TOOL = "emit_result";
9933
10056
  /**
9934
- * Applies the selected tier to an outgoing request. Native rides
9935
- * ChatRequest.schema; forced-tool synthesizes a single emit_result tool
9936
- * with toolChoice pinned to it; prompt injects the schema into the last
9937
- * user message.
10057
+ * Cheap deterministic prompt-size estimate (about four serialized
10058
+ * characters per token) for the layer-2b output bound. Never used for
10059
+ * identity, accounting, or anything the journal records.
9938
10060
  */
9939
- function applyStructuredOutputTier(req, tier, schema) {
9940
- if (tier === "native") return {
9941
- ...req,
9942
- schema
9943
- };
9944
- if (tier === "forced-tool") {
9945
- const contract = {
9946
- name: EMIT_RESULT_TOOL,
9947
- description: "Emit the final structured result. Call exactly once with the complete answer.",
9948
- parameters: schema
9949
- };
10061
+ function estimateInputTokens(messages) {
10062
+ let chars = 0;
10063
+ for (const msg of messages) chars += JSON.stringify(msg.parts).length;
10064
+ return Math.ceil(chars / 4);
10065
+ }
10066
+ /**
10067
+ * Layer 2b at the wire boundary: clamps the outgoing request's
10068
+ * maxOutputTokens to what the remaining budget affords from the serving
10069
+ * model. The clamp uses the heuristic prompt estimate; the DENIAL does
10070
+ * not: a turn is refused (BudgetExhaustedError, never dispatched) only
10071
+ * when the remainder cannot buy even ONE output token at zero input,
10072
+ * which is exact. Denying on the estimate would kill turns the budget
10073
+ * still funds, including the DEF-7 forced finish paid from the released
10074
+ * finalize reserve; when the estimate says the prompt alone spends the
10075
+ * remainder, the turn dispatches with a one-token output floor and the
10076
+ * exact layers (2 and 3) settle the difference. A no-op without a hook
10077
+ * or when the hook reports no bound. The clamp touches only the wire
10078
+ * request, exactly like limits.maxOutputTokensPerTurn above it; identity
10079
+ * is computed at the ctx layer and never sees it.
10080
+ */
10081
+ function applyOutputBudget(req, target, budget) {
10082
+ const hook = budget?.maxAffordableOutputTokens;
10083
+ if (hook === void 0) return req;
10084
+ const affordable = hook(target.resolved.ref, estimateInputTokens(req.messages));
10085
+ if (affordable === void 0) return req;
10086
+ if (affordable < 1) {
10087
+ const zeroInputAffordable = hook(target.resolved.ref, 0);
10088
+ if (zeroInputAffordable !== void 0 && zeroInputAffordable < 1) throw new BudgetExhaustedError(`the remaining budget cannot afford one output token from ${target.resolved.ref}; the turn was not dispatched`);
9950
10089
  return {
9951
10090
  ...req,
9952
- tools: [...req.tools ?? [], contract],
9953
- toolChoice: { name: EMIT_RESULT_TOOL }
10091
+ maxOutputTokens: 1
9954
10092
  };
9955
10093
  }
9956
- const instruction = "Respond with a single JSON value that validates against this JSON Schema, with no surrounding prose and no code fences:\n" + JSON.stringify(schema);
9957
- const messages = [...req.messages];
9958
- const last = messages[messages.length - 1];
9959
- if (last !== void 0 && last.role === "user") messages[messages.length - 1] = {
9960
- role: "user",
9961
- parts: [...last.parts, {
9962
- type: "text",
9963
- text: instruction
9964
- }]
9965
- };
9966
- else messages.push({
9967
- role: "user",
9968
- parts: [{
9969
- type: "text",
9970
- text: instruction
9971
- }]
9972
- });
9973
- return {
10094
+ if (req.maxOutputTokens === void 0 || affordable < req.maxOutputTokens) return {
9974
10095
  ...req,
9975
- messages
10096
+ maxOutputTokens: affordable
9976
10097
  };
10098
+ return req;
9977
10099
  }
9978
10100
  /**
9979
- * Extracts the structured-output candidate from a collected turn per tier.
9980
- * Returns `undefined` when the turn carries no candidate (for example the
9981
- * model answered prose without the forced tool call).
10101
+ * The output-truncation abort message (v1.9.0 follow-up review). The
10102
+ * constraint is named neutrally as the turn's output token allowance:
10103
+ * the effective request cap can come from limits.maxOutputTokensPerTurn,
10104
+ * the budget clamp above, or the adapter's own default, and the provider
10105
+ * can also cut at its model maximum with no request cap at all.
9982
10106
  */
9983
- function extractCandidate(turn, tier) {
9984
- if (tier === "forced-tool") {
9985
- const call = turn.toolCalls.find((c) => c.name === EMIT_RESULT_TOOL);
9986
- return call === void 0 ? void 0 : { raw: call.args };
9987
- }
9988
- const text = turn.text.trim();
9989
- if (text === "") return;
9990
- try {
9991
- return { raw: JSON.parse(text) };
9992
- } catch {
9993
- const extracted = extractFirstJsonValue(text);
9994
- return extracted === void 0 ? void 0 : { raw: extracted };
9995
- }
10107
+ /**
10108
+ * The deterministic synthesis instruction appended (as a user message)
10109
+ * to the finalize REQUEST only, never to the durable transcript. A
10110
+ * transcript that simply ends at an assistant message reads to a real
10111
+ * model as a fresh conversation opening, so an uninstructed synthesis
10112
+ * call can replace the loop's correct answer with a greeting (v1.18.0
10113
+ * review P1-1); the extract arm has carried its own instruction since
10114
+ * M4, and this is its finalize twin. The wording is part of the wire
10115
+ * request: keep it stable.
10116
+ */
10117
+ const FINALIZE_SYNTHESIS_INSTRUCTION = "Write the final answer to the original request, synthesized only from the conversation and tool results above. Do not start a new conversation and do not add greetings; respond with the final answer only.";
10118
+ function outputTruncatedMessage(invocation) {
10119
+ return `the ${invocation} ended at its output token allowance (finish reason 'max-tokens') before producing visible output; raise limits.maxOutputTokensPerTurn, reduce the reasoning effort, or free budget for the turn (https://docs.rulvar.com/guide/agents#output-truncation)`;
9996
10120
  }
9997
- /** Best-effort extraction of the first complete JSON object or array in prose. */
9998
- function extractFirstJsonValue(text) {
9999
- const start = text.search(/[[{]/);
10000
- if (start === -1) return;
10001
- const open = text[start];
10002
- const close = open === "{" ? "}" : "]";
10003
- let depth = 0;
10004
- let inString = false;
10005
- let escaped = false;
10006
- for (let i = start; i < text.length; i += 1) {
10007
- const ch = text[i];
10008
- if (inString) {
10009
- if (escaped) escaped = false;
10010
- else if (ch === "\\") escaped = true;
10011
- else if (ch === "\"") inString = false;
10012
- continue;
10013
- }
10014
- if (ch === "\"") inString = true;
10015
- else if (ch === open) depth += 1;
10016
- else if (ch === close) {
10017
- depth -= 1;
10018
- if (depth === 0) try {
10019
- return JSON.parse(text.slice(start, i + 1));
10020
- } catch {
10021
- return;
10022
- }
10023
- }
10024
- }
10025
- }
10026
- /** The bounded re-prompt message sent back to the model on a validation miss. */
10027
- function formatRePrompt(issues, attempt, maxAttempts) {
10121
+ /**
10122
+ * Builds the turn's canonical assistant message. Retained provider-raw
10123
+ * parts go at the HEAD: on both first-class providers the retained
10124
+ * blocks (thinking blocks, reasoning items) precede the turn's text and
10125
+ * tool calls, and head placement reproduces that order on re-projection
10126
+ * (M4-T02).
10127
+ */
10128
+ function assistantMsg(turn, retained = []) {
10129
+ const parts = [...retained];
10130
+ if (turn.text !== "") parts.push({
10131
+ type: "text",
10132
+ text: turn.text
10133
+ });
10134
+ for (const call of turn.toolCalls) parts.push({
10135
+ type: "tool-call",
10136
+ id: call.id,
10137
+ name: call.name,
10138
+ args: call.args
10139
+ });
10028
10140
  return {
10029
- role: "user",
10030
- parts: [{
10031
- type: "text",
10032
- text: `Your previous answer did not validate against the required schema (attempt ${attempt} of ${maxAttempts}). Issues:\n${issues.slice(0, 16).map((issue) => {
10033
- const path = issue.path === void 0 || issue.path.length === 0 ? "" : ` (at ${issue.path.map((seg) => String(typeof seg === "object" ? seg.key : seg)).join(".")})`;
10034
- return `- ${issue.message}${path}`;
10035
- }).join("\n")}\nRespond again with ONLY a corrected JSON value that validates.`
10036
- }]
10141
+ role: "assistant",
10142
+ parts
10037
10143
  };
10038
10144
  }
10039
- //#endregion
10040
- //#region src/runtime/exploration.ts
10041
10145
  /**
10042
- * Exploration guards (RV-210, first slice): the engine-side counters that
10043
- * make an oscillating tool loop visible and boundable. The published gap:
10044
- * an agent that repeats the byte-identical tool call, or keeps receiving
10045
- * pages it has already seen, burns its whole tool budget with zero signal
10046
- * and dies as a bare 'limit' terminal; the no-progress detector never
10047
- * trips because tool calls reset it.
10048
- *
10049
- * Three opt-in UsageLimits fields drive this module:
10050
- *
10051
- * - `maxRepeatedToolSignature`: how many times the SAME signature (tool
10052
- * name + RFC 8785 canonical args) may execute per invocation. The call
10053
- * that would exceed it is not dispatched; the model receives a typed
10054
- * error tool result instead (visible, bounded, never terminal), and the
10055
- * denial does not consume the tool budget.
10056
- * - `maxNoNewEvidenceCalls`: how many consecutive successful executions
10057
- * may return only already-seen result digests before the loop aborts as
10058
- * status 'limit' with abortClass 'exploration' (paid partial work; the
10059
- * executed results stand and the terminal memoizes like every
10060
- * engine-decided abort).
10061
- * - `toolBudgetNotices`: soft 50%/80% thresholds over `maxToolCalls`,
10062
- * surfaced to the model as a plain user message with the exact
10063
- * remaining count, so pacing is possible before the hard cap.
10064
- *
10065
- * Determinism: signatures and digests derive from the canonical JCS
10066
- * serialization; values JCS cannot serialize never match anything (a
10067
- * unique signature; a fresh-evidence result), so the guards fail open,
10068
- * never spuriously. On resume the guard state is rebuilt from the
10069
- * restored checkpoint messages (successful executions only, and only the
10070
- * window a compaction kept), which is the same source the model itself
10071
- * sees; enforcement is engine-side and live-only, while a replayed
10072
- * guard abort is re-stamped from the journaled terminal like every other
10073
- * abort class.
10146
+ * Executes one model-issued tool call to a tool-result part. Failures are
10147
+ * surfaced to the model as error tool results and never thrown past
10148
+ * policy: unknown names, argument-validation issues, ModelRetry (bounded
10149
+ * per tool call chain), NonSerializableValueError, and arbitrary execute
10150
+ * throws all land as { isError: true } results.
10074
10151
  */
10075
- /** The docs anchor cited by guard denials and the guard abort. */
10076
- const GUARD_DOCS_URL = "https://docs.rulvar.com/guide/agents#exploration-guards";
10077
- /** True when any exploration guard field asks for tracking. */
10078
- function explorationTrackingEnabled(limits) {
10079
- return limits.maxRepeatedToolSignature !== void 0 || limits.maxNoNewEvidenceCalls !== void 0 || limits.toolBudgetNotices === true || limits.maxCallsPerTool !== void 0 || limits.toolUnits !== void 0;
10080
- }
10081
- function digestOf$1(value) {
10152
+ async function executeToolCall(options) {
10153
+ const { call, runtime } = options;
10154
+ const def = runtime.defs.find((candidate) => candidate.name === call.name);
10155
+ const startedAt = options.now();
10156
+ const finish = (result, outcome) => {
10157
+ options.events?.emit({
10158
+ type: "tool:end",
10159
+ toolName: call.name,
10160
+ outcome,
10161
+ durationMs: options.now() - startedAt,
10162
+ ...options.audit
10163
+ });
10164
+ const part = {
10165
+ type: "tool-result",
10166
+ id: call.id,
10167
+ name: call.name,
10168
+ result
10169
+ };
10170
+ if (outcome !== "ok") part.isError = true;
10171
+ return part;
10172
+ };
10173
+ if (def === void 0) return finish({ error: `unknown tool '${call.name}'` }, "error");
10174
+ const validation = await validateSchemaSpec(def.parameters, call.args);
10175
+ if (!validation.valid) return finish({
10176
+ error: `arguments for '${call.name}' failed validation`,
10177
+ issues: validation.issues.map((issue) => issue.message)
10178
+ }, "error");
10082
10179
  try {
10083
- return createHash("sha256").update(jcsSerialize(value), "utf8").digest("hex");
10084
- } catch {
10085
- return;
10086
- }
10087
- }
10088
- var ExplorationGuard = class {
10089
- config;
10090
- signatureExecutions = /* @__PURE__ */ new Map();
10091
- seenDigests = /* @__PURE__ */ new Set();
10092
- byTool = /* @__PURE__ */ new Map();
10093
- noNewEvidenceStreak = 0;
10094
- executed = 0;
10095
- repeated = 0;
10096
- duplicateResults = 0;
10097
- denied = 0;
10098
- deniedToolCap = 0;
10099
- unitsUsed = 0;
10100
- unserializableSeq = 0;
10101
- constructor(config) {
10102
- this.config = config;
10103
- }
10104
- /**
10105
- * The canonical signature: tool name + JCS args. Args JCS cannot
10106
- * serialize get a unique per-occurrence signature, so they never
10107
- * repeat and the guard fails open.
10108
- */
10109
- signatureOf(name, args) {
10110
- try {
10111
- return `${name}\u0000${jcsSerialize(args ?? null)}`;
10112
- } catch {
10113
- this.unserializableSeq += 1;
10114
- return `${name}\u0000<unserializable:${String(this.unserializableSeq)}>`;
10180
+ let value;
10181
+ if (def.executor === "inprocess") value = await def.execute(validation.value, runtime.contextFor(call.name));
10182
+ else if (runtime.executeExternal !== void 0) value = await runtime.executeExternal(def, validation.value, options.ordinal);
10183
+ else return finish({ error: `tool '${call.name}' declares executor '${def.executor}' but no executor is registered` }, "error");
10184
+ const serialized = toJournalValue(value === void 0 ? null : value, `tool '${call.name}'`);
10185
+ options.retryCounts.delete(call.name);
10186
+ return finish(serialized, "ok");
10187
+ } catch (thrown) {
10188
+ if (thrown instanceof ModelRetry) {
10189
+ const used = options.retryCounts.get(call.name) ?? 0;
10190
+ options.retryCounts.set(call.name, used + 1);
10191
+ const exhausted = used >= options.maxModelRetries;
10192
+ return finish({
10193
+ error: thrown.message,
10194
+ ...thrown.data === void 0 ? {} : { data: thrown.data },
10195
+ ...exhausted ? { retriesExhausted: true } : {}
10196
+ }, "error");
10115
10197
  }
10198
+ if (thrown instanceof NonSerializableValueError) return finish({ error: thrown.message }, "error");
10199
+ return finish({ error: thrown instanceof Error ? thrown.message : String(thrown) }, "error");
10116
10200
  }
10117
- /**
10118
- * Rebuilds guard state from restored checkpoint messages: assistant
10119
- * tool-call parts paired with their successful tool results by id.
10120
- * Error results (denials, tool failures) are skipped, so a resume
10121
- * never over-counts; a compaction naturally narrows the window to
10122
- * what the model itself still sees.
10123
- */
10124
- restore(messages) {
10125
- const callsById = /* @__PURE__ */ new Map();
10126
- for (const msg of messages) for (const part of msg.parts) if (part.type === "tool-call") callsById.set(part.id, {
10127
- name: part.name,
10128
- args: part.args
10201
+ }
10202
+ /**
10203
+ * Runs one agent to a typed AgentResult. Never throws past policy: every
10204
+ * failure mode becomes a typed status on the result.
10205
+ */
10206
+ async function runAgent(options) {
10207
+ const now = options.now ?? realNow;
10208
+ const startedAt = now();
10209
+ const limits = options.limits;
10210
+ const maxSchemaAttempts = (options.schemaRetryAttempts ?? 2) + 1;
10211
+ const events = options.events;
10212
+ const agentType = options.agentType ?? "";
10213
+ const messages = [{
10214
+ role: "user",
10215
+ parts: [{
10216
+ type: "text",
10217
+ text: options.prompt
10218
+ }]
10219
+ }];
10220
+ let totalUsage = ZERO_USAGE$1;
10221
+ const primaryRole = options.role ?? "loop";
10222
+ const usageByPhaseModel = /* @__PURE__ */ new Map();
10223
+ const addPhaseUsage = (role, ref, usage) => {
10224
+ const key = `${role}\u0000${ref}`;
10225
+ const prior = usageByPhaseModel.get(key);
10226
+ usageByPhaseModel.set(key, {
10227
+ role,
10228
+ servedBy: ref,
10229
+ usage: addUsage$1(prior?.usage ?? ZERO_USAGE$1, usage)
10129
10230
  });
10130
- else if (part.type === "tool-result" && part.isError !== true) {
10131
- const call = callsById.get(part.id);
10132
- if (call === void 0) continue;
10133
- this.recordExecution(call.name, call.args, part.result, true);
10134
- }
10135
- }
10136
- /**
10137
- * The pre-dispatch verdict: denies the call that would exceed its
10138
- * tool's maxCallsPerTool cap, then the call that would exceed
10139
- * maxRepeatedToolSignature executions of the same signature. A denial
10140
- * never consumes maxToolCalls or tool units.
10141
- */
10142
- beforeExecute(name, args) {
10143
- const cap = this.config.maxCallsPerTool?.[name];
10144
- if (cap !== void 0) {
10145
- const executions = this.byTool.get(name) ?? 0;
10146
- if (executions >= cap) {
10147
- this.deniedToolCap += 1;
10148
- return {
10149
- deny: true,
10150
- guard: "per-tool-cap",
10151
- executions,
10152
- reason: `exploration guard: '${name}' already executed ${String(executions)} time(s) this invocation (maxCallsPerTool ${String(cap)}). Use what you have or a different tool (${GUARD_DOCS_URL}).`
10153
- };
10154
- }
10155
- }
10156
- const max = this.config.maxRepeatedToolSignature;
10157
- if (max === void 0) return { deny: false };
10158
- const executions = this.signatureExecutions.get(this.signatureOf(name, args)) ?? 0;
10159
- if (executions < max) return { deny: false };
10160
- this.denied += 1;
10161
- return {
10162
- deny: true,
10163
- guard: "repeated-signature",
10164
- executions,
10165
- reason: `exploration guard: this exact '${name}' call already executed ${String(executions)} time(s) this invocation (maxRepeatedToolSignature ${String(max)}). Reuse the earlier result or change the arguments (${GUARD_DOCS_URL}).`
10231
+ };
10232
+ const providerCalls = [];
10233
+ let invocationCounter = 0;
10234
+ let transportRetries = 0;
10235
+ const roleUsageSnapshot = (role) => {
10236
+ const snapshot = /* @__PURE__ */ new Map();
10237
+ for (const [key, slice] of usageByPhaseModel) if (slice.role === role) snapshot.set(key, slice.usage);
10238
+ return snapshot;
10239
+ };
10240
+ const usageDelta = (after, before) => {
10241
+ const base = before ?? ZERO_USAGE$1;
10242
+ const delta = {
10243
+ inputTokens: Math.max(0, after.inputTokens - base.inputTokens),
10244
+ outputTokens: Math.max(0, after.outputTokens - base.outputTokens),
10245
+ cacheReadTokens: Math.max(0, after.cacheReadTokens - base.cacheReadTokens),
10246
+ cacheWriteTokens: Math.max(0, after.cacheWriteTokens - base.cacheWriteTokens)
10166
10247
  };
10167
- }
10248
+ const reasoning = (after.reasoningTokens ?? 0) - (base.reasoningTokens ?? 0);
10249
+ if (reasoning > 0) delta.reasoningTokens = reasoning;
10250
+ return delta;
10251
+ };
10252
+ const beginPhase = (role, model) => {
10253
+ invocationCounter += 1;
10254
+ events?.emit({
10255
+ type: "agent:phase:start",
10256
+ agentType,
10257
+ label: options.label,
10258
+ role,
10259
+ model,
10260
+ invocation: invocationCounter
10261
+ });
10262
+ return {
10263
+ invocation: invocationCounter,
10264
+ role,
10265
+ model,
10266
+ before: roleUsageSnapshot(role),
10267
+ startedAtMs: now(),
10268
+ retriesBefore: transportRetries
10269
+ };
10270
+ };
10271
+ const endPhase = (phase, outcome, servedModel) => {
10272
+ let phaseUsage = ZERO_USAGE$1;
10273
+ let phaseUsd = 0;
10274
+ for (const [key, slice] of usageByPhaseModel) {
10275
+ if (slice.role !== phase.role) continue;
10276
+ const delta = usageDelta(slice.usage, phase.before.get(key));
10277
+ phaseUsage = addUsage$1(phaseUsage, delta);
10278
+ const priced = options.priceUsd?.(slice.servedBy, delta) ?? 0;
10279
+ if (Number.isFinite(priced) && priced > 0) phaseUsd += priced;
10280
+ }
10281
+ const retries = transportRetries - phase.retriesBefore;
10282
+ events?.emit({
10283
+ type: "agent:phase:end",
10284
+ agentType,
10285
+ label: options.label,
10286
+ role: phase.role,
10287
+ model: servedModel ?? phase.model,
10288
+ invocation: phase.invocation,
10289
+ durationMs: Math.max(0, now() - phase.startedAtMs),
10290
+ usage: phaseUsage,
10291
+ costUsd: phaseUsd,
10292
+ outcome,
10293
+ ...retries > 0 ? { retries } : {}
10294
+ });
10295
+ };
10296
+ const phaseOutcome = () => status === "error" || status === "cancelled" ? "error" : "ok";
10297
+ let turns = 0;
10298
+ let schemaAttempts = 0;
10299
+ let output = null;
10300
+ let status = "ok";
10301
+ let finishedViaTool = false;
10302
+ let agentError;
10303
+ let errorMessage;
10304
+ let usageApprox = false;
10305
+ let toolCallsUsed = 0;
10306
+ let escalationRequest;
10307
+ let abortClass;
10168
10308
  /**
10169
- * Records one dispatched execution and answers whether the
10170
- * no-new-evidence guard trips. Only successful results feed the
10171
- * evidence chain: an error result neither resets nor lengthens it
10172
- * (repeated failing calls are the signature guard's job), and a
10173
- * result JCS cannot digest counts as fresh evidence.
10309
+ * Set at a tool-budget expiry when limits.finalizationReserve is
10310
+ * configured (P1.1); the reserve turn itself runs at ONE site after
10311
+ * the loop ends (the pending-turn path trips before the dispatch
10312
+ * machinery below is even defined), inside the still-open loop phase.
10174
10313
  */
10175
- afterExecute(name, args, result, isError) {
10176
- return this.recordExecution(name, args, result, !isError);
10177
- }
10178
- recordExecution(name, args, result, successful) {
10179
- this.executed += 1;
10180
- this.byTool.set(name, (this.byTool.get(name) ?? 0) + 1);
10181
- if (this.config.toolUnits !== void 0) this.unitsUsed += this.config.toolUnits.costs?.[name] ?? 1;
10182
- const signature = this.signatureOf(name, args);
10183
- const prior = this.signatureExecutions.get(signature) ?? 0;
10184
- if (prior > 0) this.repeated += 1;
10185
- this.signatureExecutions.set(signature, prior + 1);
10186
- if (!successful) return false;
10187
- const digest = digestOf$1(result);
10188
- if (digest === void 0 || !this.seenDigests.has(digest)) {
10189
- if (digest !== void 0) this.seenDigests.add(digest);
10190
- this.noNewEvidenceStreak = 0;
10191
- return false;
10314
+ let reserveRequest;
10315
+ const noProgress = new NoProgressDetector(limits.noProgressTurns);
10316
+ const guard = explorationTrackingEnabled(limits) ? new ExplorationGuard(limits) : void 0;
10317
+ /**
10318
+ * The exact limiter behind a tool-budget expiry, with its counts: the
10319
+ * wording rides the finalization-reserve instruction and the 'limit'
10320
+ * terminal's errorMessage (P1.1 criterion: the terminal names the
10321
+ * limiter, never a bare status).
10322
+ */
10323
+ const toolBudgetDetail = (limiter) => {
10324
+ if (limiter === "maxToolCalls") return `maxToolCalls (${String(toolCallsUsed)}/${String(limits.maxToolCalls ?? 0)})`;
10325
+ const max = limits.toolUnits?.max ?? 0;
10326
+ const used = guard === void 0 ? max : guard.summary(toolCallsUsed).toolUnitsUsed ?? max;
10327
+ return `toolUnits (${String(used)}/${String(max)})`;
10328
+ };
10329
+ if (limits.toolBudgetNotices === true && limits.maxToolCalls === void 0) events?.emit({
10330
+ type: "log",
10331
+ level: "warn",
10332
+ msg: "toolBudgetNotices is enabled but maxToolCalls is not set; the notices are inert"
10333
+ });
10334
+ const firedNotices = /* @__PURE__ */ new Set();
10335
+ /**
10336
+ * Pushes the soft tool-budget notice when an unfired threshold has
10337
+ * been crossed (one message per boundary, carrying the exact counts,
10338
+ * so the model can pace itself before the hard cap). The notice is an
10339
+ * ordinary user message: it rides checkpoints and transcripts, so a
10340
+ * resume never re-fires a threshold the restored count already
10341
+ * crossed.
10342
+ */
10343
+ const maybePushBudgetNotice = () => {
10344
+ if (limits.toolBudgetNotices !== true || limits.maxToolCalls === void 0) return;
10345
+ const crossed = crossedNoticeThresholds(toolCallsUsed, limits.maxToolCalls).filter((threshold) => !firedNotices.has(threshold));
10346
+ if (crossed.length === 0) return;
10347
+ for (const threshold of crossed) firedNotices.add(threshold);
10348
+ messages.push({
10349
+ role: "user",
10350
+ parts: [{
10351
+ type: "text",
10352
+ text: toolBudgetNoticeText(toolCallsUsed, limits.maxToolCalls)
10353
+ }]
10354
+ });
10355
+ };
10356
+ const modelRetryCounts = /* @__PURE__ */ new Map();
10357
+ let lastTurnUsage = {
10358
+ inputTokens: 0,
10359
+ outputTokens: 0
10360
+ };
10361
+ let compactionDisabled = false;
10362
+ const compactionPoints = [];
10363
+ let servedBy = options.resolved.ref;
10364
+ const restored = await options.checkpoint?.load();
10365
+ if (restored !== void 0) {
10366
+ messages.length = 0;
10367
+ messages.push(...restored.messages);
10368
+ turns = restored.turns;
10369
+ totalUsage = usageViolations(restored.usage).length === 0 ? restored.usage : sanitizeUsage(restored.usage);
10370
+ toolCallsUsed = restored.toolCallsUsed;
10371
+ schemaAttempts = restored.schemaAttempts;
10372
+ compactionPoints.push(...restored.compaction);
10373
+ const restoredSlices = restored.usageByModel ?? [{
10374
+ servedBy,
10375
+ usage: totalUsage
10376
+ }];
10377
+ for (const slice of restoredSlices) {
10378
+ const sliceUsage = usageViolations(slice.usage).length === 0 ? slice.usage : sanitizeUsage(slice.usage);
10379
+ addPhaseUsage(slice.role ?? primaryRole, slice.servedBy, sliceUsage);
10380
+ options.budget?.onUsage(sliceUsage, slice.servedBy);
10192
10381
  }
10193
- this.duplicateResults += 1;
10194
- this.noNewEvidenceStreak += 1;
10195
- const max = this.config.maxNoNewEvidenceCalls;
10196
- return max !== void 0 && this.noNewEvidenceStreak >= max;
10382
+ for (const record of restored.providerCalls ?? []) providerCalls.push(usageViolations(record.usage).length === 0 ? record : {
10383
+ ...record,
10384
+ usage: sanitizeUsage(record.usage)
10385
+ });
10386
+ guard?.restore(messages);
10387
+ if (limits.toolBudgetNotices === true && limits.maxToolCalls !== void 0) for (const threshold of crossedNoticeThresholds(toolCallsUsed, limits.maxToolCalls)) firedNotices.add(threshold);
10197
10388
  }
10389
+ const usageSlices = () => [...usageByPhaseModel.values()].map(({ role, servedBy: sliceServedBy, usage }) => ({
10390
+ servedBy: sliceServedBy,
10391
+ usage,
10392
+ role
10393
+ }));
10198
10394
  /**
10199
- * True once the spent tool units reached the weighted budget: the
10200
- * loop's pre-dispatch check, mirroring maxToolCalls (terminal 'limit',
10201
- * paid partial work). Never true without toolUnits configured.
10395
+ * Every slice priced at ITS OWN model's rate. An unpriced model
10396
+ * contributes zero here and surfaces through CostReport.unpriced, never
10397
+ * as a silent zero.
10202
10398
  */
10203
- unitsExhausted() {
10204
- return this.config.toolUnits !== void 0 && this.unitsUsed >= this.config.toolUnits.max;
10205
- }
10206
- /** The abort message for a tripped no-new-evidence guard. */
10207
- describeTrip() {
10208
- return `exploration guard: ${String(this.noNewEvidenceStreak)} consecutive tool calls returned no new evidence (maxNoNewEvidenceCalls ${String(this.config.maxNoNewEvidenceCalls ?? this.noNewEvidenceStreak)}; every result was already seen this invocation). The executed work is kept; narrow the scope, vary the queries, or raise the limit (${GUARD_DOCS_URL}).`;
10209
- }
10210
- /** The structured summary; `toolCallsUsed` is the loop's own counter. */
10211
- summary(toolCallsUsed) {
10212
- const byTool = {};
10213
- for (const [name, count] of [...this.byTool.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) byTool[name] = count;
10214
- return {
10399
+ const priceRecordedUsage = () => {
10400
+ const price = options.priceUsd;
10401
+ if (price === void 0) return 0;
10402
+ let usd = 0;
10403
+ for (const slice of usageByPhaseModel.values()) {
10404
+ const sliceUsd = price(slice.servedBy, slice.usage) ?? 0;
10405
+ if (Number.isFinite(sliceUsd) && sliceUsd > 0) usd += sliceUsd;
10406
+ }
10407
+ return usd;
10408
+ };
10409
+ const saveBoundary = async (pending) => {
10410
+ if (options.checkpoint === void 0) return;
10411
+ await options.checkpoint.save({
10412
+ v: 1,
10413
+ messages: [...messages],
10414
+ turns,
10415
+ usage: totalUsage,
10416
+ usageByModel: usageSlices(),
10215
10417
  toolCallsUsed,
10216
- distinctSignatures: this.signatureExecutions.size,
10217
- repeatedCalls: this.repeated,
10218
- duplicateResultCalls: this.duplicateResults,
10219
- deniedRepeats: this.denied,
10220
- byTool,
10221
- ...this.config.maxCallsPerTool === void 0 ? {} : { deniedToolCap: this.deniedToolCap },
10222
- ...this.config.toolUnits === void 0 ? {} : { toolUnitsUsed: this.unitsUsed }
10223
- };
10224
- }
10225
- };
10226
- /** The soft notice thresholds over maxToolCalls, in ascending order. */
10227
- const TOOL_BUDGET_NOTICE_THRESHOLDS = [.5, .8];
10228
- /**
10229
- * Which notice thresholds `used` calls out of `max` have crossed
10230
- * (ceil-based, so a threshold fires no earlier than its exact fraction).
10231
- */
10232
- function crossedNoticeThresholds(used, max) {
10233
- return TOOL_BUDGET_NOTICE_THRESHOLDS.filter((threshold) => used >= Math.ceil(threshold * max)).map((threshold) => threshold);
10234
- }
10235
- /**
10236
- * The model-visible budget notice. Deterministic for a given usage
10237
- * count, so a recorded conversation rebuilds byte-identically on
10238
- * resume and replay.
10239
- */
10240
- function toolBudgetNoticeText(used, max) {
10241
- const remaining = Math.max(0, max - used);
10242
- return `Tool budget notice: ${String(used)} of ${String(max)} tool calls used; ${String(remaining)} remaining. Prioritize the highest value calls and finish with what you have.`;
10243
- }
10244
- //#endregion
10245
- //#region src/runtime/agent-loop.ts
10246
- /**
10247
- * Agent runtime v1 (M1-T06): the single subagent loop shared by every
10248
- * orchestration mode. A model turn; structured output in three tiers with
10249
- * client validation and a bounded re-prompt; typed AgentResult; beyond the
10250
- * configured policy the runtime never throws: failures become typed
10251
- * AgentResult statuses. Tool dispatch, checkpoints, and compaction arrive
10252
- * with M3/M4; the escalated status arrives in M3 as the flagged breaking
10253
- * change.
10254
- *
10255
- * Docs: https://docs.rulvar.com/guide/agents (agent runtime binding);
10256
- * https://docs.rulvar.com/guide/model-routing (roles, tiers, refusal).
10257
- */
10258
- function isEscalated(r) {
10259
- return r.status === "escalated";
10260
- }
10261
- /** Reason marker distinguishing a budget-ceiling abort from host cancellation. */
10262
- const BUDGET_ABORT_REASON = "rulvar:budget-ceiling";
10263
- const ZERO_USAGE$1 = {
10264
- inputTokens: 0,
10265
- outputTokens: 0,
10266
- cacheReadTokens: 0,
10267
- cacheWriteTokens: 0
10268
- };
10269
- const wallRandom = Math.random.bind(globalThis);
10270
- function addUsage$1(total, turn) {
10271
- const sum = {
10272
- inputTokens: total.inputTokens + turn.inputTokens,
10273
- outputTokens: total.outputTokens + turn.outputTokens,
10274
- cacheReadTokens: total.cacheReadTokens + turn.cacheReadTokens,
10275
- cacheWriteTokens: total.cacheWriteTokens + turn.cacheWriteTokens
10276
- };
10277
- const reasoning = (total.reasoningTokens ?? 0) + (turn.reasoningTokens ?? 0);
10278
- if (reasoning > 0) sum.reasoningTokens = reasoning;
10279
- return sum;
10280
- }
10281
- /**
10282
- * The Usage invariant is verified at the adapter boundary: inputTokens is
10283
- * the FULL prompt including cache reads and writes.
10284
- */
10285
- /**
10286
- * The full canonical invariant at the adapter boundary (v1.20.0 review
10287
- * P1-1): every count finite, integral, and nonnegative, and the cache
10288
- * subsets inside the input. One violation message covers every adapter,
10289
- * injected clients and mocks included; the financial invariant never
10290
- * depends on the good faith of an external transport.
10291
- */
10292
- function usageInvariantViolation(usage, adapterId) {
10293
- const violations = usageViolations(usage);
10294
- if (violations.length === 0) return;
10295
- return `adapter '${adapterId}' violated the Usage invariant: ${violations.join("; ")}`;
10296
- }
10297
- async function streamTurn(adapter, req, options) {
10298
- const idle = new AbortController();
10299
- const all = [...options.signals, idle.signal];
10300
- if (options.budgetSignal !== void 0) all.push(options.budgetSignal);
10301
- const combined = AbortSignal.any(all);
10302
- const turn = {
10303
- text: "",
10304
- toolCalls: []
10305
- };
10306
- const pendingArgs = /* @__PURE__ */ new Map();
10307
- let usage = ZERO_USAGE$1;
10308
- let reported = ZERO_USAGE$1;
10309
- let usageViolation;
10310
- let sawFinish = false;
10311
- let finish;
10312
- let providerMetadata;
10313
- let wireError;
10314
- let idleTimer;
10315
- const armIdle = () => {
10316
- if (idleTimer !== void 0) clearTimeout(idleTimer);
10317
- idleTimer = setTimeout(() => idle.abort("rulvar:stream-idle"), options.idleTimeoutMs);
10318
- };
10319
- try {
10320
- armIdle();
10321
- for await (const event of adapter.stream(req, combined)) {
10322
- armIdle();
10323
- switch (event.type) {
10324
- case "text-delta":
10325
- turn.text += event.text;
10326
- options.onDelta?.(event.text);
10327
- break;
10328
- case "reasoning-delta":
10329
- options.onDelta?.(event.text);
10330
- break;
10331
- case "tool-call-start":
10332
- pendingArgs.set(event.id, {
10333
- name: event.name,
10334
- argsText: ""
10335
- });
10336
- break;
10337
- case "tool-call-delta": {
10338
- const pending = pendingArgs.get(event.id);
10339
- if (pending !== void 0) pending.argsText += event.argsTextDelta;
10340
- break;
10341
- }
10342
- case "tool-call-end": {
10343
- const pending = pendingArgs.get(event.id);
10344
- turn.toolCalls.push({
10345
- id: event.id,
10346
- name: pending?.name ?? "",
10347
- args: event.args
10348
- });
10349
- pendingArgs.delete(event.id);
10350
- break;
10351
- }
10352
- case "usage": {
10353
- const cleaned = {};
10354
- for (const field of [
10355
- "inputTokens",
10356
- "outputTokens",
10357
- "cacheReadTokens",
10358
- "cacheWriteTokens",
10359
- "reasoningTokens"
10360
- ]) {
10361
- const value = event.usage[field];
10362
- if (value === void 0) continue;
10363
- if (Number.isInteger(value) && value >= 0) cleaned[field] = value;
10364
- else {
10365
- usageViolation ??= `mid-stream usage event carried invalid ${field} (${String(value)})`;
10366
- cleaned[field] = sanitizeTokenCount(value);
10367
- }
10368
- }
10369
- usage = {
10370
- ...usage,
10371
- ...cleaned
10372
- };
10373
- const delta = {
10374
- inputTokens: cleaned.inputTokens ?? 0,
10375
- outputTokens: cleaned.outputTokens ?? 0,
10376
- cacheReadTokens: cleaned.cacheReadTokens ?? 0,
10377
- cacheWriteTokens: cleaned.cacheWriteTokens ?? 0
10378
- };
10379
- if (cleaned.reasoningTokens !== void 0) delta.reasoningTokens = cleaned.reasoningTokens;
10380
- reported = addUsage$1(reported, delta);
10381
- options.onUsage?.(delta);
10382
- break;
10383
- }
10384
- case "finish":
10385
- sawFinish = true;
10386
- finish = event.finish;
10387
- usage = event.usage;
10388
- providerMetadata = event.providerMetadata;
10389
- break;
10390
- case "error":
10391
- wireError = event.error;
10392
- break;
10393
- }
10394
- if (sawFinish || wireError !== void 0) break;
10395
- }
10396
- } catch (thrown) {
10397
- if (!combined.aborted) wireError = {
10398
- code: "agent",
10399
- message: thrown instanceof Error ? thrown.message : String(thrown),
10400
- retryable: true,
10401
- data: { kind: "transport" }
10402
- };
10403
- } finally {
10404
- if (idleTimer !== void 0) clearTimeout(idleTimer);
10405
- }
10406
- if (combined.aborted && !sawFinish && wireError === void 0) {
10407
- const aborted = options.budgetSignal?.aborted === true ? "budget" : idle.signal.aborted ? "idle" : "external";
10408
- const outcome = {
10409
- turn,
10410
- usage,
10411
- reported,
10412
- usageApprox: true,
10413
- aborted
10414
- };
10415
- if (finish !== void 0) outcome.finish = finish;
10416
- if (usageViolation !== void 0) outcome.usageViolation = usageViolation;
10417
- return outcome;
10418
- }
10419
- if (!sawFinish && wireError === void 0) wireError = {
10420
- code: "agent",
10421
- message: `adapter '${adapter.id}' stream ended without a terminal finish or error event; the adapter contract requires exactly one per stream, so the partial turn is discarded as a retryable transport fault`,
10422
- retryable: true,
10423
- data: { kind: "transport" }
10424
- };
10425
- const outcome = {
10426
- turn,
10427
- usage,
10428
- reported,
10429
- usageApprox: !sawFinish
10430
- };
10431
- if (finish !== void 0) outcome.finish = finish;
10432
- if (usageViolation !== void 0) outcome.usageViolation = usageViolation;
10433
- if (providerMetadata !== void 0) outcome.providerMetadata = providerMetadata;
10434
- if (wireError !== void 0) outcome.wireError = wireError;
10435
- return outcome;
10436
- }
10437
- function classifyWireError(wire) {
10438
- const data = wire.data ?? {};
10439
- const error = {
10440
- kind: data.kind === "rate-limit" || wire.code === "rate-limit" ? "rate-limit" : data.kind ?? "transport",
10441
- retryable: wire.retryable
10442
- };
10443
- if (typeof data.retryAfterMs === "number") error.retryAfterMs = data.retryAfterMs;
10444
- return error;
10445
- }
10446
- function buildRequest(resolved, messages, limits, tools) {
10447
- const req = {
10448
- model: resolved.model,
10449
- messages
10450
- };
10451
- if (resolved.wireEffort !== void 0) req.effort = resolved.wireEffort;
10452
- if (resolved.providerOptions !== void 0) req.providerOptions = resolved.providerOptions;
10453
- if (limits.maxOutputTokensPerTurn !== void 0) req.maxOutputTokens = limits.maxOutputTokensPerTurn;
10454
- if (tools !== void 0 && tools.length > 0) req.tools = tools;
10455
- return req;
10456
- }
10457
- /**
10458
- * Cheap deterministic prompt-size estimate (about four serialized
10459
- * characters per token) for the layer-2b output bound. Never used for
10460
- * identity, accounting, or anything the journal records.
10461
- */
10462
- function estimateInputTokens(messages) {
10463
- let chars = 0;
10464
- for (const msg of messages) chars += JSON.stringify(msg.parts).length;
10465
- return Math.ceil(chars / 4);
10466
- }
10467
- /**
10468
- * Layer 2b at the wire boundary: clamps the outgoing request's
10469
- * maxOutputTokens to what the remaining budget affords from the serving
10470
- * model. The clamp uses the heuristic prompt estimate; the DENIAL does
10471
- * not: a turn is refused (BudgetExhaustedError, never dispatched) only
10472
- * when the remainder cannot buy even ONE output token at zero input,
10473
- * which is exact. Denying on the estimate would kill turns the budget
10474
- * still funds, including the DEF-7 forced finish paid from the released
10475
- * finalize reserve; when the estimate says the prompt alone spends the
10476
- * remainder, the turn dispatches with a one-token output floor and the
10477
- * exact layers (2 and 3) settle the difference. A no-op without a hook
10478
- * or when the hook reports no bound. The clamp touches only the wire
10479
- * request, exactly like limits.maxOutputTokensPerTurn above it; identity
10480
- * is computed at the ctx layer and never sees it.
10481
- */
10482
- function applyOutputBudget(req, target, budget) {
10483
- const hook = budget?.maxAffordableOutputTokens;
10484
- if (hook === void 0) return req;
10485
- const affordable = hook(target.resolved.ref, estimateInputTokens(req.messages));
10486
- if (affordable === void 0) return req;
10487
- if (affordable < 1) {
10488
- const zeroInputAffordable = hook(target.resolved.ref, 0);
10489
- if (zeroInputAffordable !== void 0 && zeroInputAffordable < 1) throw new BudgetExhaustedError(`the remaining budget cannot afford one output token from ${target.resolved.ref}; the turn was not dispatched`);
10490
- return {
10491
- ...req,
10492
- maxOutputTokens: 1
10493
- };
10494
- }
10495
- if (req.maxOutputTokens === void 0 || affordable < req.maxOutputTokens) return {
10496
- ...req,
10497
- maxOutputTokens: affordable
10498
- };
10499
- return req;
10500
- }
10501
- /**
10502
- * The output-truncation abort message (v1.9.0 follow-up review). The
10503
- * constraint is named neutrally as the turn's output token allowance:
10504
- * the effective request cap can come from limits.maxOutputTokensPerTurn,
10505
- * the budget clamp above, or the adapter's own default, and the provider
10506
- * can also cut at its model maximum with no request cap at all.
10507
- */
10508
- /**
10509
- * The deterministic synthesis instruction appended (as a user message)
10510
- * to the finalize REQUEST only, never to the durable transcript. A
10511
- * transcript that simply ends at an assistant message reads to a real
10512
- * model as a fresh conversation opening, so an uninstructed synthesis
10513
- * call can replace the loop's correct answer with a greeting (v1.18.0
10514
- * review P1-1); the extract arm has carried its own instruction since
10515
- * M4, and this is its finalize twin. The wording is part of the wire
10516
- * request: keep it stable.
10517
- */
10518
- const FINALIZE_SYNTHESIS_INSTRUCTION = "Write the final answer to the original request, synthesized only from the conversation and tool results above. Do not start a new conversation and do not add greetings; respond with the final answer only.";
10519
- function outputTruncatedMessage(invocation) {
10520
- return `the ${invocation} ended at its output token allowance (finish reason 'max-tokens') before producing visible output; raise limits.maxOutputTokensPerTurn, reduce the reasoning effort, or free budget for the turn (https://docs.rulvar.com/guide/agents#output-truncation)`;
10521
- }
10522
- /**
10523
- * Builds the turn's canonical assistant message. Retained provider-raw
10524
- * parts go at the HEAD: on both first-class providers the retained
10525
- * blocks (thinking blocks, reasoning items) precede the turn's text and
10526
- * tool calls, and head placement reproduces that order on re-projection
10527
- * (M4-T02).
10528
- */
10529
- function assistantMsg(turn, retained = []) {
10530
- const parts = [...retained];
10531
- if (turn.text !== "") parts.push({
10532
- type: "text",
10533
- text: turn.text
10534
- });
10535
- for (const call of turn.toolCalls) parts.push({
10536
- type: "tool-call",
10537
- id: call.id,
10538
- name: call.name,
10539
- args: call.args
10540
- });
10541
- return {
10542
- role: "assistant",
10543
- parts
10544
- };
10545
- }
10546
- /**
10547
- * Executes one model-issued tool call to a tool-result part. Failures are
10548
- * surfaced to the model as error tool results and never thrown past
10549
- * policy: unknown names, argument-validation issues, ModelRetry (bounded
10550
- * per tool call chain), NonSerializableValueError, and arbitrary execute
10551
- * throws all land as { isError: true } results.
10552
- */
10553
- async function executeToolCall(options) {
10554
- const { call, runtime } = options;
10555
- const def = runtime.defs.find((candidate) => candidate.name === call.name);
10556
- const startedAt = options.now();
10557
- const finish = (result, outcome) => {
10558
- options.events?.emit({
10559
- type: "tool:end",
10560
- toolName: call.name,
10561
- outcome,
10562
- durationMs: options.now() - startedAt,
10563
- ...options.audit
10564
- });
10565
- const part = {
10566
- type: "tool-result",
10567
- id: call.id,
10568
- name: call.name,
10569
- result
10570
- };
10571
- if (outcome !== "ok") part.isError = true;
10572
- return part;
10573
- };
10574
- if (def === void 0) return finish({ error: `unknown tool '${call.name}'` }, "error");
10575
- const validation = await validateSchemaSpec(def.parameters, call.args);
10576
- if (!validation.valid) return finish({
10577
- error: `arguments for '${call.name}' failed validation`,
10578
- issues: validation.issues.map((issue) => issue.message)
10579
- }, "error");
10580
- try {
10581
- let value;
10582
- if (def.executor === "inprocess") value = await def.execute(validation.value, runtime.contextFor(call.name));
10583
- else if (runtime.executeExternal !== void 0) value = await runtime.executeExternal(def, validation.value, options.ordinal);
10584
- else return finish({ error: `tool '${call.name}' declares executor '${def.executor}' but no executor is registered` }, "error");
10585
- const serialized = toJournalValue(value === void 0 ? null : value, `tool '${call.name}'`);
10586
- options.retryCounts.delete(call.name);
10587
- return finish(serialized, "ok");
10588
- } catch (thrown) {
10589
- if (thrown instanceof ModelRetry) {
10590
- const used = options.retryCounts.get(call.name) ?? 0;
10591
- options.retryCounts.set(call.name, used + 1);
10592
- const exhausted = used >= options.maxModelRetries;
10593
- return finish({
10594
- error: thrown.message,
10595
- ...thrown.data === void 0 ? {} : { data: thrown.data },
10596
- ...exhausted ? { retriesExhausted: true } : {}
10597
- }, "error");
10598
- }
10599
- if (thrown instanceof NonSerializableValueError) return finish({ error: thrown.message }, "error");
10600
- return finish({ error: thrown instanceof Error ? thrown.message : String(thrown) }, "error");
10601
- }
10602
- }
10603
- /**
10604
- * Runs one agent to a typed AgentResult. Never throws past policy: every
10605
- * failure mode becomes a typed status on the result.
10606
- */
10607
- async function runAgent(options) {
10608
- const now = options.now ?? realNow;
10609
- const startedAt = now();
10610
- const limits = options.limits;
10611
- const maxSchemaAttempts = (options.schemaRetryAttempts ?? 2) + 1;
10612
- const events = options.events;
10613
- const agentType = options.agentType ?? "";
10614
- const messages = [{
10615
- role: "user",
10616
- parts: [{
10617
- type: "text",
10618
- text: options.prompt
10619
- }]
10620
- }];
10621
- let totalUsage = ZERO_USAGE$1;
10622
- const primaryRole = options.role ?? "loop";
10623
- const usageByPhaseModel = /* @__PURE__ */ new Map();
10624
- const addPhaseUsage = (role, ref, usage) => {
10625
- const key = `${role}\u0000${ref}`;
10626
- const prior = usageByPhaseModel.get(key);
10627
- usageByPhaseModel.set(key, {
10628
- role,
10629
- servedBy: ref,
10630
- usage: addUsage$1(prior?.usage ?? ZERO_USAGE$1, usage)
10631
- });
10632
- };
10633
- const providerCalls = [];
10634
- let invocationCounter = 0;
10635
- let transportRetries = 0;
10636
- const roleUsageSnapshot = (role) => {
10637
- const snapshot = /* @__PURE__ */ new Map();
10638
- for (const [key, slice] of usageByPhaseModel) if (slice.role === role) snapshot.set(key, slice.usage);
10639
- return snapshot;
10640
- };
10641
- const usageDelta = (after, before) => {
10642
- const base = before ?? ZERO_USAGE$1;
10643
- const delta = {
10644
- inputTokens: Math.max(0, after.inputTokens - base.inputTokens),
10645
- outputTokens: Math.max(0, after.outputTokens - base.outputTokens),
10646
- cacheReadTokens: Math.max(0, after.cacheReadTokens - base.cacheReadTokens),
10647
- cacheWriteTokens: Math.max(0, after.cacheWriteTokens - base.cacheWriteTokens)
10648
- };
10649
- const reasoning = (after.reasoningTokens ?? 0) - (base.reasoningTokens ?? 0);
10650
- if (reasoning > 0) delta.reasoningTokens = reasoning;
10651
- return delta;
10652
- };
10653
- const beginPhase = (role, model) => {
10654
- invocationCounter += 1;
10655
- events?.emit({
10656
- type: "agent:phase:start",
10657
- agentType,
10658
- label: options.label,
10659
- role,
10660
- model,
10661
- invocation: invocationCounter
10662
- });
10663
- return {
10664
- invocation: invocationCounter,
10665
- role,
10666
- model,
10667
- before: roleUsageSnapshot(role),
10668
- startedAtMs: now(),
10669
- retriesBefore: transportRetries
10670
- };
10671
- };
10672
- const endPhase = (phase, outcome, servedModel) => {
10673
- let phaseUsage = ZERO_USAGE$1;
10674
- let phaseUsd = 0;
10675
- for (const [key, slice] of usageByPhaseModel) {
10676
- if (slice.role !== phase.role) continue;
10677
- const delta = usageDelta(slice.usage, phase.before.get(key));
10678
- phaseUsage = addUsage$1(phaseUsage, delta);
10679
- const priced = options.priceUsd?.(slice.servedBy, delta) ?? 0;
10680
- if (Number.isFinite(priced) && priced > 0) phaseUsd += priced;
10681
- }
10682
- const retries = transportRetries - phase.retriesBefore;
10683
- events?.emit({
10684
- type: "agent:phase:end",
10685
- agentType,
10686
- label: options.label,
10687
- role: phase.role,
10688
- model: servedModel ?? phase.model,
10689
- invocation: phase.invocation,
10690
- durationMs: Math.max(0, now() - phase.startedAtMs),
10691
- usage: phaseUsage,
10692
- costUsd: phaseUsd,
10693
- outcome,
10694
- ...retries > 0 ? { retries } : {}
10695
- });
10696
- };
10697
- const phaseOutcome = () => status === "error" || status === "cancelled" ? "error" : "ok";
10698
- let turns = 0;
10699
- let schemaAttempts = 0;
10700
- let output = null;
10701
- let status = "ok";
10702
- let finishedViaTool = false;
10703
- let agentError;
10704
- let errorMessage;
10705
- let usageApprox = false;
10706
- let toolCallsUsed = 0;
10707
- let escalationRequest;
10708
- let abortClass;
10709
- /**
10710
- * Set at a tool-budget expiry when limits.finalizationReserve is
10711
- * configured (P1.1); the reserve turn itself runs at ONE site after
10712
- * the loop ends (the pending-turn path trips before the dispatch
10713
- * machinery below is even defined), inside the still-open loop phase.
10714
- */
10715
- let reserveRequest;
10716
- const noProgress = new NoProgressDetector(limits.noProgressTurns);
10717
- const guard = explorationTrackingEnabled(limits) ? new ExplorationGuard(limits) : void 0;
10718
- /**
10719
- * The exact limiter behind a tool-budget expiry, with its counts: the
10720
- * wording rides the finalization-reserve instruction and the 'limit'
10721
- * terminal's errorMessage (P1.1 criterion: the terminal names the
10722
- * limiter, never a bare status).
10723
- */
10724
- const toolBudgetDetail = (limiter) => {
10725
- if (limiter === "maxToolCalls") return `maxToolCalls (${String(toolCallsUsed)}/${String(limits.maxToolCalls ?? 0)})`;
10726
- const max = limits.toolUnits?.max ?? 0;
10727
- const used = guard === void 0 ? max : guard.summary(toolCallsUsed).toolUnitsUsed ?? max;
10728
- return `toolUnits (${String(used)}/${String(max)})`;
10729
- };
10730
- if (limits.toolBudgetNotices === true && limits.maxToolCalls === void 0) events?.emit({
10731
- type: "log",
10732
- level: "warn",
10733
- msg: "toolBudgetNotices is enabled but maxToolCalls is not set; the notices are inert"
10734
- });
10735
- const firedNotices = /* @__PURE__ */ new Set();
10736
- /**
10737
- * Pushes the soft tool-budget notice when an unfired threshold has
10738
- * been crossed (one message per boundary, carrying the exact counts,
10739
- * so the model can pace itself before the hard cap). The notice is an
10740
- * ordinary user message: it rides checkpoints and transcripts, so a
10741
- * resume never re-fires a threshold the restored count already
10742
- * crossed.
10743
- */
10744
- const maybePushBudgetNotice = () => {
10745
- if (limits.toolBudgetNotices !== true || limits.maxToolCalls === void 0) return;
10746
- const crossed = crossedNoticeThresholds(toolCallsUsed, limits.maxToolCalls).filter((threshold) => !firedNotices.has(threshold));
10747
- if (crossed.length === 0) return;
10748
- for (const threshold of crossed) firedNotices.add(threshold);
10749
- messages.push({
10750
- role: "user",
10751
- parts: [{
10752
- type: "text",
10753
- text: toolBudgetNoticeText(toolCallsUsed, limits.maxToolCalls)
10754
- }]
10755
- });
10756
- };
10757
- const modelRetryCounts = /* @__PURE__ */ new Map();
10758
- let lastTurnUsage = {
10759
- inputTokens: 0,
10760
- outputTokens: 0
10761
- };
10762
- let compactionDisabled = false;
10763
- const compactionPoints = [];
10764
- let servedBy = options.resolved.ref;
10765
- const restored = await options.checkpoint?.load();
10766
- if (restored !== void 0) {
10767
- messages.length = 0;
10768
- messages.push(...restored.messages);
10769
- turns = restored.turns;
10770
- totalUsage = usageViolations(restored.usage).length === 0 ? restored.usage : sanitizeUsage(restored.usage);
10771
- toolCallsUsed = restored.toolCallsUsed;
10772
- schemaAttempts = restored.schemaAttempts;
10773
- compactionPoints.push(...restored.compaction);
10774
- const restoredSlices = restored.usageByModel ?? [{
10775
- servedBy,
10776
- usage: totalUsage
10777
- }];
10778
- for (const slice of restoredSlices) {
10779
- const sliceUsage = usageViolations(slice.usage).length === 0 ? slice.usage : sanitizeUsage(slice.usage);
10780
- addPhaseUsage(slice.role ?? primaryRole, slice.servedBy, sliceUsage);
10781
- options.budget?.onUsage(sliceUsage, slice.servedBy);
10782
- }
10783
- for (const record of restored.providerCalls ?? []) providerCalls.push(usageViolations(record.usage).length === 0 ? record : {
10784
- ...record,
10785
- usage: sanitizeUsage(record.usage)
10786
- });
10787
- guard?.restore(messages);
10788
- if (limits.toolBudgetNotices === true && limits.maxToolCalls !== void 0) for (const threshold of crossedNoticeThresholds(toolCallsUsed, limits.maxToolCalls)) firedNotices.add(threshold);
10789
- }
10790
- const usageSlices = () => [...usageByPhaseModel.values()].map(({ role, servedBy: sliceServedBy, usage }) => ({
10791
- servedBy: sliceServedBy,
10792
- usage,
10793
- role
10794
- }));
10795
- /**
10796
- * Every slice priced at ITS OWN model's rate. An unpriced model
10797
- * contributes zero here and surfaces through CostReport.unpriced, never
10798
- * as a silent zero.
10799
- */
10800
- const priceRecordedUsage = () => {
10801
- const price = options.priceUsd;
10802
- if (price === void 0) return 0;
10803
- let usd = 0;
10804
- for (const slice of usageByPhaseModel.values()) {
10805
- const sliceUsd = price(slice.servedBy, slice.usage) ?? 0;
10806
- if (Number.isFinite(sliceUsd) && sliceUsd > 0) usd += sliceUsd;
10807
- }
10808
- return usd;
10809
- };
10810
- const saveBoundary = async (pending) => {
10811
- if (options.checkpoint === void 0) return;
10812
- await options.checkpoint.save({
10813
- v: 1,
10814
- messages: [...messages],
10815
- turns,
10816
- usage: totalUsage,
10817
- usageByModel: usageSlices(),
10818
- toolCallsUsed,
10819
- schemaAttempts,
10820
- compaction: [...compactionPoints],
10821
- ...providerCalls.length === 0 ? {} : { providerCalls: [...providerCalls] },
10822
- ...pending === void 0 ? {} : { pending }
10823
- });
10824
- };
10825
- const toPendingRecords = (parts) => parts.filter((part) => part.type === "tool-result").map((part) => ({
10826
- id: part.id,
10827
- name: part.name,
10828
- result: part.result,
10829
- ...part.isError === true ? { isError: true } : {}
10830
- }));
10831
- /**
10832
- * Gates and executes one turn's tool calls in source order. priorParts
10833
- * carries results already executed before a mid-turn suspension; the
10834
- * pending state checkpointed at an ask verdict stores RAW model args so
10835
- * a resume re-runs the chain (hooks apply exactly once) and re-matches
10836
- * the same approval identity.
10837
- */
10838
- const runToolCalls = async (calls, priorParts) => {
10839
- const runtime = options.tools;
10840
- if (runtime === void 0) return {
10841
- parts: priorParts,
10842
- limitHit: false
10418
+ schemaAttempts,
10419
+ compaction: [...compactionPoints],
10420
+ ...providerCalls.length === 0 ? {} : { providerCalls: [...providerCalls] },
10421
+ ...pending === void 0 ? {} : { pending }
10422
+ });
10423
+ };
10424
+ const toPendingRecords = (parts) => parts.filter((part) => part.type === "tool-result").map((part) => ({
10425
+ id: part.id,
10426
+ name: part.name,
10427
+ result: part.result,
10428
+ ...part.isError === true ? { isError: true } : {}
10429
+ }));
10430
+ /**
10431
+ * Gates and executes one turn's tool calls in source order. priorParts
10432
+ * carries results already executed before a mid-turn suspension; the
10433
+ * pending state checkpointed at an ask verdict stores RAW model args so
10434
+ * a resume re-runs the chain (hooks apply exactly once) and re-matches
10435
+ * the same approval identity.
10436
+ */
10437
+ const runToolCalls = async (calls, priorParts) => {
10438
+ const runtime = options.tools;
10439
+ if (runtime === void 0) return {
10440
+ parts: priorParts,
10441
+ limitHit: false
10843
10442
  };
10844
10443
  const parts = [...priorParts];
10845
10444
  const errorPart = (call, payload) => {
@@ -12791,140 +12390,1006 @@ var AdmissionController = class {
12791
12390
  statsBefore
12792
12391
  };
12793
12392
  }
12794
- if (depth > this.maxDepth) return {
12795
- verdict: {
12796
- kind: "reject",
12797
- reason: { code: "depth" }
12798
- },
12799
- statsBefore
12800
- };
12801
- if (childrenBefore >= this.maxChildrenPerNode) return {
12802
- verdict: {
12803
- kind: "reject",
12804
- reason: { code: "quota" }
12805
- },
12806
- statsBefore
12807
- };
12808
- if (this.maxTotalSpawns !== void 0 && this.admittedTotal >= this.maxTotalSpawns) return {
12809
- verdict: {
12810
- kind: "reject",
12811
- reason: { code: "lifetime" }
12812
- },
12813
- statsBefore
12814
- };
12815
- let childCeilingUsd;
12816
- const parentRemainder = this.budget.remainderOf(spec.parentAccountScope);
12817
- if (parentRemainder !== void 0) {
12818
- const fractionCap = this.childBudgetFraction * parentRemainder;
12819
- childCeilingUsd = spec.budgetUsd === void 0 ? fractionCap : Math.min(spec.budgetUsd, fractionCap);
12820
- } else if (spec.budgetUsd !== void 0) childCeilingUsd = spec.budgetUsd;
12821
- let reserveUsd = spec.estCostUsd ?? this.flatReserveUsd;
12822
- if (childCeilingUsd !== void 0) reserveUsd = Math.min(reserveUsd, childCeilingUsd);
12823
- const reserve = { reserveUsd };
12824
- if (childCeilingUsd !== void 0) reserve.childCeilingUsd = childCeilingUsd;
12825
- if (this.budget.spawnHeadroom <= 0) return {
12826
- verdict: {
12827
- kind: "reject",
12828
- reason: { code: "lifetime" }
12829
- },
12830
- statsBefore
12393
+ if (depth > this.maxDepth) return {
12394
+ verdict: {
12395
+ kind: "reject",
12396
+ reason: { code: "depth" }
12397
+ },
12398
+ statsBefore
12399
+ };
12400
+ if (childrenBefore >= this.maxChildrenPerNode) return {
12401
+ verdict: {
12402
+ kind: "reject",
12403
+ reason: { code: "quota" }
12404
+ },
12405
+ statsBefore
12406
+ };
12407
+ if (this.maxTotalSpawns !== void 0 && this.admittedTotal >= this.maxTotalSpawns) return {
12408
+ verdict: {
12409
+ kind: "reject",
12410
+ reason: { code: "lifetime" }
12411
+ },
12412
+ statsBefore
12413
+ };
12414
+ let childCeilingUsd;
12415
+ const parentRemainder = this.budget.remainderOf(spec.parentAccountScope);
12416
+ if (parentRemainder !== void 0) {
12417
+ const fractionCap = this.childBudgetFraction * parentRemainder;
12418
+ childCeilingUsd = spec.budgetUsd === void 0 ? fractionCap : Math.min(spec.budgetUsd, fractionCap);
12419
+ } else if (spec.budgetUsd !== void 0) childCeilingUsd = spec.budgetUsd;
12420
+ let reserveUsd = spec.estCostUsd ?? this.flatReserveUsd;
12421
+ if (childCeilingUsd !== void 0) reserveUsd = Math.min(reserveUsd, childCeilingUsd);
12422
+ const reserve = { reserveUsd };
12423
+ if (childCeilingUsd !== void 0) reserve.childCeilingUsd = childCeilingUsd;
12424
+ if (this.budget.spawnHeadroom <= 0) return {
12425
+ verdict: {
12426
+ kind: "reject",
12427
+ reason: { code: "lifetime" }
12428
+ },
12429
+ statsBefore
12430
+ };
12431
+ if (commitReserve) try {
12432
+ this.budget.admitSpawn(reserveUsd, spec.parentAccountScope);
12433
+ } catch {
12434
+ return {
12435
+ verdict: {
12436
+ kind: "reject",
12437
+ reason: { code: "budget" }
12438
+ },
12439
+ statsBefore
12440
+ };
12441
+ }
12442
+ else {
12443
+ const remainder = this.budget.remainderOf(spec.parentAccountScope);
12444
+ const projection = this.projectedDispatchReserveUsd(spec);
12445
+ if (remainder !== void 0 && (remainder <= 0 || remainder < projection + (spec.pendingReserveUsd ?? 0))) return {
12446
+ verdict: {
12447
+ kind: "reject",
12448
+ reason: { code: "budget" }
12449
+ },
12450
+ statsBefore
12451
+ };
12452
+ }
12453
+ this.childrenOf.set(nodeKey, childrenBefore + 1);
12454
+ this.admittedTotal += 1;
12455
+ const lineage = evaluated.decision.lineage;
12456
+ this.registerLineageAdmit(lineage.logicalTaskId);
12457
+ let spawnUnitsAfter = this.budget.spawnHeadroom;
12458
+ if (this.terminationAccount !== void 0) {
12459
+ const debited = this.terminationAccount.debitSpawn({
12460
+ logicalTaskId: lineage.logicalTaskId,
12461
+ isNew: spec.lineage === void 0,
12462
+ ladderLength: spec.ladderLength ?? 1
12463
+ });
12464
+ if (!debited.ok) return {
12465
+ verdict: {
12466
+ kind: "reject",
12467
+ reason: { code: "termination_exhausted" }
12468
+ },
12469
+ statsBefore
12470
+ };
12471
+ spawnUnitsAfter = debited.spawnUnitsAfter;
12472
+ }
12473
+ return {
12474
+ verdict: {
12475
+ kind: "admit",
12476
+ reserve,
12477
+ spawnUnitsAfter,
12478
+ lineage: {
12479
+ logicalTaskId: lineage.logicalTaskId,
12480
+ isNew: spec.lineage === void 0,
12481
+ depth
12482
+ }
12483
+ },
12484
+ statsBefore,
12485
+ nodeId: this.mintId(),
12486
+ lineage,
12487
+ ...this.terminationAccount === void 0 ? {} : { ladderLength: spec.ladderLength ?? 1 }
12488
+ };
12489
+ }
12490
+ /**
12491
+ * Resume roll-forward for an orchestrator child (M6-T07): restores the
12492
+ * children-quota counter only. The budget seed already counts settled
12493
+ * agent dispatches, and an in-flight child re-commits its reserve
12494
+ * through the ctx.agent dispatch path.
12495
+ */
12496
+ recoverChild(nodeKey) {
12497
+ this.childrenOf.set(nodeKey, (this.childrenOf.get(nodeKey) ?? 0) + 1);
12498
+ this.admittedTotal += 1;
12499
+ }
12500
+ /**
12501
+ * Resume roll-forward for a child that already SETTLED before the
12502
+ * resume: re-registers the counters (maxChildrenPerNode, the lifetime
12503
+ * cap, statsBefore fidelity) without committing any reserve; the spend
12504
+ * itself sits in the root ledger seed.
12505
+ */
12506
+ recoverSettled(parentAccountScope) {
12507
+ this.budget.admitRecovered(0, parentAccountScope);
12508
+ this.childrenOf.set(parentAccountScope, (this.childrenOf.get(parentAccountScope) ?? 0) + 1);
12509
+ this.admittedTotal += 1;
12510
+ }
12511
+ /**
12512
+ * Resume roll-forward for an admission whose decision entry exists but
12513
+ * whose child has NOT settled: re-applies the recorded reserve and
12514
+ * counters without re-evaluating any limit (replay never
12515
+ * re-evaluates admission; reserves are recovered, never
12516
+ * re-estimated).
12517
+ */
12518
+ recoverInFlight(parentAccountScope, verdict) {
12519
+ if (verdict.kind === "reject") return;
12520
+ const reserveUsd = verdict.kind === "reuse_full" ? 0 : verdict.reserve.reserveUsd;
12521
+ this.budget.admitRecovered(reserveUsd, parentAccountScope);
12522
+ this.childrenOf.set(parentAccountScope, (this.childrenOf.get(parentAccountScope) ?? 0) + 1);
12523
+ this.admittedTotal += 1;
12524
+ }
12525
+ };
12526
+ //#endregion
12527
+ //#region src/engine/scheduler.ts
12528
+ /**
12529
+ * Scheduler and concurrency (M1-T08): the per-run semaphore with a FIFO
12530
+ * queue (default 12 concurrent model calls). The engine lifetime spawn cap
12531
+ * is enforced by the budget layer at admission; parallel/pipeline
12532
+ * composition semantics live with ctx.
12533
+ * Per-provider concurrency keys land with M4.
12534
+ */
12535
+ /** FIFO semaphore; default per-run width is 12. */
12536
+ const DEFAULT_PER_RUN_CONCURRENCY = 12;
12537
+ var Semaphore = class {
12538
+ limit;
12539
+ active = 0;
12540
+ waiters = [];
12541
+ /**
12542
+ * `limit` must be a positive integer: anything else (NaN included) is
12543
+ * a typed ConfigError. Before this gate a NaN limit made
12544
+ * `active < limit` permanently false, so the first acquire queued
12545
+ * forever and the run could not settle, not even through cancel()
12546
+ * (v1.34.0 review P2-4). Unlimited is expressed by not constructing a
12547
+ * semaphore, never by a sentinel limit.
12548
+ */
12549
+ constructor(limit) {
12550
+ requirePositiveInteger(limit, "Semaphore limit");
12551
+ this.limit = limit;
12552
+ }
12553
+ get pending() {
12554
+ return this.waiters.length;
12555
+ }
12556
+ /**
12557
+ * Acquires a slot, resolving in FIFO order. `onQueued` fires only when
12558
+ * the caller actually has to wait (feeds the agent:queued event).
12559
+ * An aborted `signal` releases the caller from the queue without a
12560
+ * slot: the returned release is a no-op, the remaining waiters keep
12561
+ * their FIFO positions, and the caller proceeds to observe its own
12562
+ * aborted signal (the model layers refuse dispatch under an aborted
12563
+ * signal, so no provider call follows). Cancellation can therefore
12564
+ * always drain a queued run (v1.34.0 review P2-4).
12565
+ */
12566
+ async acquire(onQueued, signal) {
12567
+ if (this.active < this.limit) {
12568
+ this.active += 1;
12569
+ return () => this.release();
12570
+ }
12571
+ if (signal?.aborted === true) return () => void 0;
12572
+ onQueued?.();
12573
+ const waiter = {
12574
+ resolve: () => void 0,
12575
+ aborted: false
12576
+ };
12577
+ const wait = new Promise((resolve) => {
12578
+ waiter.resolve = resolve;
12579
+ });
12580
+ this.waiters.push(waiter);
12581
+ let onAbort;
12582
+ if (signal !== void 0) {
12583
+ onAbort = () => {
12584
+ const index = this.waiters.indexOf(waiter);
12585
+ if (index === -1) return;
12586
+ this.waiters.splice(index, 1);
12587
+ waiter.aborted = true;
12588
+ waiter.resolve();
12589
+ };
12590
+ signal.addEventListener("abort", onAbort, { once: true });
12591
+ }
12592
+ try {
12593
+ await wait;
12594
+ } finally {
12595
+ if (signal !== void 0 && onAbort !== void 0) signal.removeEventListener("abort", onAbort);
12596
+ }
12597
+ if (waiter.aborted) return () => void 0;
12598
+ this.active += 1;
12599
+ return () => this.release();
12600
+ }
12601
+ async withSlot(fn, onQueued, signal) {
12602
+ const release = await this.acquire(onQueued, signal);
12603
+ try {
12604
+ return await fn();
12605
+ } finally {
12606
+ release();
12607
+ }
12608
+ }
12609
+ release() {
12610
+ this.active -= 1;
12611
+ const next = this.waiters.shift();
12612
+ if (next !== void 0) next.resolve();
12613
+ }
12614
+ };
12615
+ //#endregion
12616
+ //#region src/engine/preflight.ts
12617
+ const ANY_TOOL = "(any)";
12618
+ function resolveServing(spec) {
12619
+ if (spec === void 0) return;
12620
+ if (typeof spec === "string") return spec;
12621
+ if ("model" in spec) return spec.model;
12622
+ return spec.ladder.rungs[spec.ladder.startTier]?.model;
12623
+ }
12624
+ /**
12625
+ * Per-tool executed-call ceilings from the merged limits: for every
12626
+ * tool a per-tool cap or a unit cost names (plus the '(any)' tool that
12627
+ * nothing names, unit cost 1), the smallest of maxCallsPerTool[T],
12628
+ * floor(toolUnits.max / cost(T)) for a positive cost (a zero cost is
12629
+ * free), and maxToolCalls.
12630
+ */
12631
+ function toolCeilingsOf(limits) {
12632
+ const names = /* @__PURE__ */ new Set();
12633
+ for (const name of Object.keys(limits.maxCallsPerTool ?? {})) names.add(name);
12634
+ for (const name of Object.keys(limits.toolUnits?.costs ?? {})) names.add(name);
12635
+ const rows = [];
12636
+ for (const tool of [...[...names].sort(), ANY_TOOL]) {
12637
+ const terms = [];
12638
+ const cap = tool === ANY_TOOL ? void 0 : limits.maxCallsPerTool?.[tool];
12639
+ if (cap !== void 0) terms.push({
12640
+ boundBy: "maxCallsPerTool",
12641
+ ceiling: cap
12642
+ });
12643
+ if (limits.toolUnits !== void 0) {
12644
+ const cost = tool === ANY_TOOL ? 1 : limits.toolUnits.costs?.[tool] ?? 1;
12645
+ if (cost > 0) terms.push({
12646
+ boundBy: "toolUnits",
12647
+ ceiling: Math.floor(limits.toolUnits.max / cost)
12648
+ });
12649
+ }
12650
+ if (limits.maxToolCalls !== void 0) terms.push({
12651
+ boundBy: "maxToolCalls",
12652
+ ceiling: limits.maxToolCalls
12653
+ });
12654
+ if (terms.length === 0) {
12655
+ rows.push({
12656
+ tool,
12657
+ ceiling: null
12658
+ });
12659
+ continue;
12660
+ }
12661
+ const min = terms.reduce((best, term) => term.ceiling < best.ceiling ? term : best);
12662
+ rows.push({
12663
+ tool,
12664
+ ceiling: min.ceiling,
12665
+ boundBy: min.boundBy
12666
+ });
12667
+ }
12668
+ return rows;
12669
+ }
12670
+ function validateSpawnSpec(spec, index) {
12671
+ const site = `preflight.spawns[${index}]`;
12672
+ if (spec.limits !== void 0) validateUsageLimits(spec.limits, `${site}.limits`);
12673
+ if (spec.estCost !== void 0) requireNonNegativeNumber(spec.estCost, `${site}.estCost`);
12674
+ if (spec.estInputTokens !== void 0) requireNonNegativeInteger(spec.estInputTokens, `${site}.estInputTokens`);
12675
+ if (spec.count !== void 0) requirePositiveInteger(spec.count, `${site}.count`);
12676
+ }
12677
+ /**
12678
+ * Computes the preflight report: the effective merged limits per
12679
+ * declared spawn, the layer-1 admission projection over the declared
12680
+ * wave, the per-tool and weighted-unit bottleneck ordering, the
12681
+ * concurrency and quota exposure at the declared estimates, and the
12682
+ * linter findings. Pure: no engine is constructed, no store is opened,
12683
+ * no adapter stream is dispatched, and no journal entry is written.
12684
+ */
12685
+ function preflightEstimate(input) {
12686
+ const engine = input.engine ?? {};
12687
+ const defaults = engine.defaults ?? {};
12688
+ if (defaults.limits !== void 0) validateUsageLimits(defaults.limits, "preflight.engine.defaults.limits");
12689
+ if (input.run?.limits !== void 0) validateUsageLimits(input.run.limits, "preflight.run.limits");
12690
+ if (input.orchestrator?.limits !== void 0) validateUsageLimits(input.orchestrator.limits, "preflight.orchestrator.limits");
12691
+ const findings = [];
12692
+ const say = (finding) => {
12693
+ findings.push(finding);
12694
+ };
12695
+ const adapters = new Map((engine.adapters ?? []).map((adapter) => [adapter.id, adapter]));
12696
+ const capsOf = (ref) => {
12697
+ const { adapterId, model } = parseModelRef(ref);
12698
+ return adapters.get(adapterId)?.caps(model);
12699
+ };
12700
+ const pricingOf = (ref) => resolvePricing(ref, engine.pricing, capsOf(ref)?.pricing);
12701
+ const ceilingUsd = input.run?.budgetUsd;
12702
+ const flatReserveUsd = engine.budgetDefaults?.flatReserveUsd ?? .5;
12703
+ const lifetimeSpawnCap = engine.budgetDefaults?.lifetimeSpawnCap ?? 500;
12704
+ const childBudgetFraction = engine.budgetDefaults?.childBudgetFraction ?? .3;
12705
+ const maxDepth = engine.budgetDefaults?.maxDepth ?? 1;
12706
+ const perRun = engine.concurrency?.perRun ?? 12;
12707
+ const runLimits = mergeUsageLimits(void 0, input.run?.limits, defaults.limits);
12708
+ let orchestratorEcho;
12709
+ let reservedForFinalizationUsd = 0;
12710
+ let effectiveCapUsd;
12711
+ if (input.orchestrator !== void 0) {
12712
+ const spec = input.orchestrator.budget;
12713
+ const fraction = spec?.capFraction ?? .2;
12714
+ const fromFraction = ceilingUsd === void 0 ? void 0 : fraction * ceilingUsd;
12715
+ const bounds = [spec?.capUsd, fromFraction].filter((bound) => bound !== void 0);
12716
+ effectiveCapUsd = bounds.length === 0 ? void 0 : Math.min(...bounds);
12717
+ const finalizeTurns = spec?.finalizeTurns ?? 2;
12718
+ const finalizeReserveUsd = spec?.finalizeReserveUsd ?? finalizeTurns * flatReserveUsd;
12719
+ const reserveCommitted = input.orchestrator.extension === true;
12720
+ if (reserveCommitted) reservedForFinalizationUsd = finalizeReserveUsd;
12721
+ orchestratorEcho = {
12722
+ ...effectiveCapUsd === void 0 ? {} : { effectiveCapUsd },
12723
+ finalizeReserveUsd,
12724
+ finalizeTurns,
12725
+ reserveCommitted
12831
12726
  };
12832
- if (commitReserve) try {
12833
- this.budget.admitSpawn(reserveUsd, spec.parentAccountScope);
12834
- } catch {
12835
- return {
12836
- verdict: {
12837
- kind: "reject",
12838
- reason: { code: "budget" }
12839
- },
12840
- statsBefore
12727
+ if (spec?.capUsd !== void 0 && spec.capFraction === void 0 && effectiveCapUsd !== void 0 && effectiveCapUsd < spec.capUsd) say({
12728
+ severity: "warning",
12729
+ code: "orchestrator-cap-fraction-bound",
12730
+ message: `orchestrator budget.capUsd ${spec.capUsd.toFixed(4)} USD is bounded to ${effectiveCapUsd.toFixed(4)} USD by the default capFraction 0.2 of the run ceiling; pass capFraction: 1.0 to make capUsd the sole bound`
12731
+ });
12732
+ if (input.orchestrator.extension === true && effectiveCapUsd !== void 0 && effectiveCapUsd < finalizeReserveUsd) say({
12733
+ severity: "error",
12734
+ code: "orchestrator-cap-below-finalize-reserve",
12735
+ message: `effectiveCap ${effectiveCapUsd.toFixed(4)} USD is below the finalize reserve ${finalizeReserveUsd.toFixed(4)} USD: the run would refuse to start`
12736
+ });
12737
+ }
12738
+ const spawnSpecs = input.spawns ?? [];
12739
+ spawnSpecs.forEach(validateSpawnSpec);
12740
+ const spawnReports = [];
12741
+ const units = [];
12742
+ for (const spec of spawnSpecs) {
12743
+ const role = spec.role ?? "loop";
12744
+ const label = spec.label ?? role;
12745
+ const count = spec.count ?? 1;
12746
+ const profile = spec.profile === void 0 ? void 0 : defaults.profiles?.[spec.profile];
12747
+ if (spec.profile !== void 0 && profile === void 0) say({
12748
+ severity: "error",
12749
+ code: "unknown-profile",
12750
+ message: `spawn '${label}' names profile '${spec.profile}', which defaults.profiles does not register`,
12751
+ spawn: label
12752
+ });
12753
+ const limits = mergeUsageLimits(spec.limits, profile?.limits, defaults.limits);
12754
+ const servedBy = resolveServing(spec.model ?? profile?.routing?.[role] ?? profile?.model ?? defaults.routing?.[role]);
12755
+ if (servedBy === void 0) say({
12756
+ severity: "error",
12757
+ code: "unrouted-role",
12758
+ message: `spawn '${label}' resolves no model for role '${role}': the run would fail with a ConfigError at spawn time; set a model, a profile model, or defaults.routing.${role}`,
12759
+ spawn: label
12760
+ });
12761
+ const caps = servedBy === void 0 ? void 0 : capsOf(servedBy);
12762
+ const pricing = servedBy === void 0 ? void 0 : pricingOf(servedBy);
12763
+ const unpriced = servedBy !== void 0 && pricing === void 0;
12764
+ let reserveSource;
12765
+ let reserveUsd;
12766
+ if (unpriced && spec.estCost === void 0 && profile?.estCost === void 0) {
12767
+ reserveSource = "unpriced-zero";
12768
+ reserveUsd = 0;
12769
+ } else {
12770
+ reserveSource = spec.estCost !== void 0 ? "estCost" : profile?.estCost !== void 0 ? "profile-estCost" : spec.estInputTokens !== void 0 && caps?.pricing !== void 0 ? "priced-estimate" : "flat-default";
12771
+ reserveUsd = admissionReserveUsd({
12772
+ ...spec.estCost === void 0 ? {} : { estCost: spec.estCost },
12773
+ ...profile?.estCost === void 0 ? {} : { profileEstCost: profile.estCost },
12774
+ ...spec.estInputTokens === void 0 ? {} : { inputTokens: spec.estInputTokens },
12775
+ ...caps === void 0 ? {} : { caps },
12776
+ ...limits.maxOutputTokensPerTurn === void 0 ? {} : { maxOutputTokensPerTurn: limits.maxOutputTokensPerTurn },
12777
+ flatReserveUsd
12778
+ });
12779
+ }
12780
+ const outputBound = caps === void 0 ? limits.maxOutputTokensPerTurn : limits.maxOutputTokensPerTurn === void 0 ? caps.maxOutputTokens : Math.min(caps.maxOutputTokens, limits.maxOutputTokensPerTurn);
12781
+ if (caps !== void 0 && limits.maxOutputTokensPerTurn !== void 0 && limits.maxOutputTokensPerTurn > caps.maxOutputTokens) say({
12782
+ severity: "warning",
12783
+ code: "output-cap-above-model",
12784
+ message: `spawn '${label}' sets maxOutputTokensPerTurn ${String(limits.maxOutputTokensPerTurn)} above the model's maxOutputTokens ${String(caps.maxOutputTokens)}: the model clamp wins`,
12785
+ spawn: label
12786
+ });
12787
+ const turnFloorUsd = pricing === void 0 || outputBound === void 0 ? void 0 : priceUsdOf(pricing, {
12788
+ inputTokens: spec.estInputTokens ?? 0,
12789
+ outputTokens: outputBound,
12790
+ cacheReadTokens: 0,
12791
+ cacheWriteTokens: 0
12792
+ });
12793
+ const toolCeilings = toolCeilingsOf(limits);
12794
+ const overall = toolCeilings.reduce((best, row) => row.ceiling === null ? best : best === null ? row.ceiling : Math.max(best, row.ceiling), null);
12795
+ const executedToolCallCeiling = limits.maxToolCalls !== void 0 && (overall === null || limits.maxToolCalls < overall) ? limits.maxToolCalls : overall;
12796
+ for (const row of toolCeilings) {
12797
+ if (row.tool === ANY_TOOL) continue;
12798
+ const cost = limits.toolUnits?.costs?.[row.tool];
12799
+ if (cost !== void 0 && cost > 0 && limits.toolUnits !== void 0 && cost > limits.toolUnits.max) {
12800
+ say({
12801
+ severity: "warning",
12802
+ code: "tool-unaffordable",
12803
+ message: `spawn '${label}' prices tool '${row.tool}' at ${String(cost)} units against toolUnits.max ${String(limits.toolUnits.max)}: the tool can never execute`,
12804
+ spawn: label
12805
+ });
12806
+ continue;
12807
+ }
12808
+ if (row.boundBy === "toolUnits" && row.ceiling !== null) {
12809
+ const nominal = limits.maxToolCalls;
12810
+ const cap = limits.maxCallsPerTool?.[row.tool];
12811
+ if (nominal !== void 0 && row.ceiling < nominal || cap !== void 0 && row.ceiling < cap) say({
12812
+ severity: "warning",
12813
+ code: "weighted-units-bind-first",
12814
+ message: `spawn '${label}': toolUnits is the first bottleneck for '${row.tool}': ${String(row.ceiling)} executed calls (cost ${String(limits.toolUnits?.costs?.[row.tool] ?? 1)} of max ${String(limits.toolUnits?.max ?? 0)})` + (nominal === void 0 ? "" : ` while maxToolCalls suggests ${String(nominal)}`),
12815
+ spawn: label
12816
+ });
12817
+ }
12818
+ const cap = limits.maxCallsPerTool?.[row.tool];
12819
+ if (cap !== void 0 && cap > 0 && row.ceiling !== null && row.boundBy !== "maxCallsPerTool") say({
12820
+ severity: "info",
12821
+ code: "per-tool-cap-unreachable",
12822
+ message: `spawn '${label}': maxCallsPerTool['${row.tool}'] ${String(cap)} can never bind: ${row.boundBy ?? "another limiter"} already stops at ${String(row.ceiling)}`,
12823
+ spawn: label
12824
+ });
12825
+ }
12826
+ if (limits.finalizationReserve !== void 0 && limits.maxToolCalls === void 0 && limits.toolUnits === void 0) say({
12827
+ severity: "warning",
12828
+ code: "inert-finalization-reserve",
12829
+ message: `spawn '${label}' sets finalizationReserve without maxToolCalls or toolUnits: no tool budget limiter exists for it to fire on`,
12830
+ spawn: label
12831
+ });
12832
+ if (limits.toolBudgetNotices === true && limits.maxToolCalls === void 0) say({
12833
+ severity: "warning",
12834
+ code: "inert-tool-budget-notices",
12835
+ message: `spawn '${label}' sets toolBudgetNotices without maxToolCalls: the notices never fire`,
12836
+ spawn: label
12837
+ });
12838
+ if (unpriced && ceilingUsd !== void 0) say({
12839
+ severity: "warning",
12840
+ code: "unpriced-under-ceiling",
12841
+ message: `spawn '${label}' is served by '${servedBy ?? ""}' with no price row: the ${ceilingUsd.toFixed(4)} USD run ceiling does NOT bound it and its admission reserve is zero`,
12842
+ spawn: label
12843
+ });
12844
+ spawnReports.push({
12845
+ label,
12846
+ role,
12847
+ count,
12848
+ ...servedBy === void 0 ? {} : { servedBy },
12849
+ ...unpriced ? { unpriced: true } : {},
12850
+ limits,
12851
+ admissionReserveUsd: reserveUsd,
12852
+ reserveSource,
12853
+ ...outputBound === void 0 ? {} : { maxOutputTokensPerTurn: outputBound },
12854
+ ...turnFloorUsd === void 0 ? {} : { turnFloorUsd },
12855
+ executedToolCallCeiling,
12856
+ toolCeilings
12857
+ });
12858
+ for (let i = 0; i < count; i += 1) {
12859
+ const unit = {
12860
+ label: count === 1 ? label : `${label}#${String(i + 1)}`,
12861
+ tokensFloor: (spec.estInputTokens ?? 0) + (outputBound ?? 0)
12841
12862
  };
12863
+ if (servedBy !== void 0) {
12864
+ const { adapterId, model } = parseModelRef(servedBy);
12865
+ unit.provider = adapterId;
12866
+ unit.model = model;
12867
+ }
12868
+ if (turnFloorUsd !== void 0) unit.turnFloorUsd = turnFloorUsd;
12869
+ units.push(unit);
12842
12870
  }
12871
+ }
12872
+ if (input.orchestrator !== void 0) {
12873
+ const servedBy = resolveServing(defaults.routing?.orchestrate);
12874
+ if (servedBy === void 0) say({
12875
+ severity: "error",
12876
+ code: "unrouted-role",
12877
+ message: "the orchestrator resolves no model for role 'orchestrate': set defaults.routing.orchestrate or an orchestrate model on the call",
12878
+ spawn: "orchestrator"
12879
+ });
12843
12880
  else {
12844
- const remainder = this.budget.remainderOf(spec.parentAccountScope);
12845
- const projection = this.projectedDispatchReserveUsd(spec);
12846
- if (remainder !== void 0 && (remainder <= 0 || remainder < projection + (spec.pendingReserveUsd ?? 0))) return {
12847
- verdict: {
12848
- kind: "reject",
12849
- reason: { code: "budget" }
12850
- },
12851
- statsBefore
12881
+ const caps = capsOf(servedBy);
12882
+ const pricing = pricingOf(servedBy);
12883
+ const orchLimits = mergeUsageLimits(input.orchestrator.limits, void 0, defaults.limits);
12884
+ const outputBound = caps === void 0 ? orchLimits.maxOutputTokensPerTurn : orchLimits.maxOutputTokensPerTurn === void 0 ? caps.maxOutputTokens : Math.min(caps.maxOutputTokens, orchLimits.maxOutputTokensPerTurn);
12885
+ const { adapterId, model } = parseModelRef(servedBy);
12886
+ const unit = {
12887
+ label: "orchestrator",
12888
+ provider: adapterId,
12889
+ model,
12890
+ tokensFloor: outputBound ?? 0
12852
12891
  };
12853
- }
12854
- this.childrenOf.set(nodeKey, childrenBefore + 1);
12855
- this.admittedTotal += 1;
12856
- const lineage = evaluated.decision.lineage;
12857
- this.registerLineageAdmit(lineage.logicalTaskId);
12858
- let spawnUnitsAfter = this.budget.spawnHeadroom;
12859
- if (this.terminationAccount !== void 0) {
12860
- const debited = this.terminationAccount.debitSpawn({
12861
- logicalTaskId: lineage.logicalTaskId,
12862
- isNew: spec.lineage === void 0,
12863
- ladderLength: spec.ladderLength ?? 1
12892
+ if (pricing !== void 0 && outputBound !== void 0) unit.turnFloorUsd = priceUsdOf(pricing, {
12893
+ inputTokens: 0,
12894
+ outputTokens: outputBound,
12895
+ cacheReadTokens: 0,
12896
+ cacheWriteTokens: 0
12864
12897
  });
12865
- if (!debited.ok) return {
12866
- verdict: {
12867
- kind: "reject",
12868
- reason: { code: "termination_exhausted" }
12869
- },
12870
- statsBefore
12871
- };
12872
- spawnUnitsAfter = debited.spawnUnitsAfter;
12898
+ units.push(unit);
12873
12899
  }
12874
- return {
12875
- verdict: {
12876
- kind: "admit",
12877
- reserve,
12878
- spawnUnitsAfter,
12879
- lineage: {
12880
- logicalTaskId: lineage.logicalTaskId,
12881
- isNew: spec.lineage === void 0,
12882
- depth
12883
- }
12884
- },
12885
- statsBefore,
12886
- nodeId: this.mintId(),
12887
- lineage,
12888
- ...this.terminationAccount === void 0 ? {} : { ladderLength: spec.ladderLength ?? 1 }
12900
+ }
12901
+ const wave = [];
12902
+ let committed = 0;
12903
+ let spawned = 0;
12904
+ let children = 0;
12905
+ const admitAgainstRoot = (reserveUsd) => {
12906
+ if (ceilingUsd === void 0) return true;
12907
+ const held = committed + reservedForFinalizationUsd;
12908
+ return !(held >= ceilingUsd || held + reserveUsd > ceilingUsd);
12909
+ };
12910
+ if (input.orchestrator !== void 0) {
12911
+ const reserveUsd = flatReserveUsd;
12912
+ let deniedBy;
12913
+ if (spawned >= lifetimeSpawnCap) deniedBy = "spawn-cap";
12914
+ else if (effectiveCapUsd !== void 0 && reserveUsd > effectiveCapUsd) deniedBy = "orchestrator-cap";
12915
+ else if (!admitAgainstRoot(reserveUsd)) deniedBy = "budget";
12916
+ wave.push({
12917
+ label: "orchestrator",
12918
+ reserveUsd,
12919
+ admitted: deniedBy === void 0,
12920
+ ...deniedBy === void 0 ? {} : { deniedBy }
12921
+ });
12922
+ if (deniedBy === void 0) {
12923
+ committed += reserveUsd;
12924
+ spawned += 1;
12925
+ } else if (deniedBy === "orchestrator-cap") say({
12926
+ severity: "error",
12927
+ code: "orchestrator-cap-below-reserve",
12928
+ message: `the orchestrator's own admission reserve ${reserveUsd.toFixed(4)} USD does not fit its effective cap ${(effectiveCapUsd ?? 0).toFixed(4)} USD: the run cannot start`
12929
+ });
12930
+ }
12931
+ const maxSpawns = input.orchestrator?.maxSpawns;
12932
+ for (const report of spawnReports) for (let i = 0; i < report.count; i += 1) {
12933
+ const label = report.count === 1 ? report.label : `${report.label}#${String(i + 1)}`;
12934
+ const reserveUsd = report.admissionReserveUsd;
12935
+ let deniedBy;
12936
+ if (spawned >= lifetimeSpawnCap) deniedBy = "spawn-cap";
12937
+ else if (maxSpawns !== void 0 && children >= maxSpawns) deniedBy = "orchestrator-max-spawns";
12938
+ else if (!admitAgainstRoot(reserveUsd)) deniedBy = "budget";
12939
+ wave.push({
12940
+ label,
12941
+ reserveUsd,
12942
+ admitted: deniedBy === void 0,
12943
+ ...deniedBy === void 0 ? {} : { deniedBy }
12944
+ });
12945
+ if (deniedBy === void 0) {
12946
+ committed += reserveUsd;
12947
+ spawned += 1;
12948
+ children += 1;
12949
+ }
12950
+ }
12951
+ const admitted = wave.filter((row) => row.admitted).length;
12952
+ const denied = wave.length - admitted;
12953
+ if (wave.length > 0 && denied > 0) {
12954
+ const deniedLabels = wave.filter((row) => !row.admitted).map((row) => row.label);
12955
+ if (admitted === 0) say({
12956
+ severity: "error",
12957
+ code: "nothing-admitted",
12958
+ message: `the declared wave admits NOTHING: every spawn is denied (${deniedLabels.join(", ")}); no paid work can start`
12959
+ });
12960
+ else say({
12961
+ severity: "warning",
12962
+ code: "partial-admission",
12963
+ message: `the declared wave admits ${String(admitted)} of ${String(wave.length)} spawns; denied before any work: ${deniedLabels.join(", ")}`
12964
+ });
12965
+ }
12966
+ if (ceilingUsd === void 0 && wave.length > 0) say({
12967
+ severity: "info",
12968
+ code: "no-usd-ceiling",
12969
+ message: "the run has no budgetUsd ceiling: only turn, tool, and time limits bound spend, and the whole declared wave admits"
12970
+ });
12971
+ const declaredUnits = units.length;
12972
+ const maxInFlight = declaredUnits === 0 ? perRun : Math.min(perRun, declaredUnits);
12973
+ const perProviderCaps = engine.concurrency?.perProvider;
12974
+ const perProvider = {};
12975
+ const byProvider = /* @__PURE__ */ new Map();
12976
+ for (const unit of units) {
12977
+ if (unit.provider === void 0) continue;
12978
+ const list = byProvider.get(unit.provider) ?? [];
12979
+ list.push(unit);
12980
+ byProvider.set(unit.provider, list);
12981
+ }
12982
+ for (const [provider, list] of [...byProvider.entries()].sort(([a], [b]) => a.localeCompare(b))) {
12983
+ const cap = perProviderCaps?.[provider];
12984
+ const inFlight = Math.min(list.length, maxInFlight, cap ?? Number.POSITIVE_INFINITY);
12985
+ perProvider[provider] = {
12986
+ inFlight,
12987
+ requestsPerWave: inFlight,
12988
+ tokensPerWaveFloor: [...list].sort((a, b) => b.tokensFloor - a.tokensFloor).slice(0, inFlight).reduce((sum, unit) => sum + unit.tokensFloor, 0)
12889
12989
  };
12890
12990
  }
12891
- /**
12892
- * Resume roll-forward for an orchestrator child (M6-T07): restores the
12893
- * children-quota counter only. The budget seed already counts settled
12894
- * agent dispatches, and an in-flight child re-commits its reserve
12895
- * through the ctx.agent dispatch path.
12896
- */
12897
- recoverChild(nodeKey) {
12898
- this.childrenOf.set(nodeKey, (this.childrenOf.get(nodeKey) ?? 0) + 1);
12899
- this.admittedTotal += 1;
12991
+ const pricedTurns = units.map((unit) => unit.turnFloorUsd).filter((usd) => usd !== void 0).sort((a, b) => b - a).slice(0, maxInFlight);
12992
+ const overshootOneTurnFloorUsd = pricedTurns.length === 0 ? void 0 : pricedTurns.reduce((sum, usd) => sum + usd, 0);
12993
+ if (ceilingUsd !== void 0 && overshootOneTurnFloorUsd !== void 0 && units.length > 0) say({
12994
+ severity: "info",
12995
+ code: "overshoot-exposure",
12996
+ message: `past a ceiling crossing, up to ${String(Math.min(maxInFlight, units.length))} in-flight turns may still complete: at least ${overshootOneTurnFloorUsd.toFixed(4)} USD past the ${ceilingUsd.toFixed(4)} USD ceiling at the declared estimates, growing with prompt size`
12997
+ });
12998
+ const quotaConfigured = engine.quota !== void 0;
12999
+ if (!quotaConfigured && maxInFlight > 1 && units.length > 0) say({
13000
+ severity: "info",
13001
+ code: "no-quota",
13002
+ message: `no shared quota limiter is configured while up to ${String(maxInFlight)} turns run concurrently: provider-side rate limits are unprotected (createEngine quota)`
13003
+ });
13004
+ if (input.quotaRules !== void 0) input.quotaRules.forEach((rule, index) => {
13005
+ let requests = 0;
13006
+ let tokens = 0;
13007
+ for (const unit of units) {
13008
+ if (unit.provider === void 0 || unit.model === void 0) continue;
13009
+ if (quotaRuleMatches(rule, {
13010
+ provider: unit.provider,
13011
+ model: unit.model,
13012
+ ...engine.quota?.tenant === void 0 ? {} : { tenant: engine.quota.tenant },
13013
+ estimate: {
13014
+ requests: 1,
13015
+ inputTokens: 0
13016
+ }
13017
+ })) {
13018
+ requests += 1;
13019
+ tokens += unit.tokensFloor;
13020
+ }
13021
+ }
13022
+ const dims = [
13023
+ rule.provider === void 0 ? void 0 : `provider=${rule.provider}`,
13024
+ rule.model === void 0 ? void 0 : `model=${rule.model}`,
13025
+ rule.tenant === void 0 ? void 0 : `tenant=${rule.tenant}`
13026
+ ].filter((dim) => dim !== void 0).join(" ");
13027
+ const name = dims === "" ? `rule[${String(index)}]` : `rule[${String(index)}] (${dims})`;
13028
+ if (rule.requestsPerMinute !== void 0 && requests > rule.requestsPerMinute) say({
13029
+ severity: "warning",
13030
+ code: "quota-requests-below-wave",
13031
+ message: `${name}: the declared wave holds ${String(requests)} matching dispatches against requestsPerMinute ${String(rule.requestsPerMinute)}: expect synthetic rate-limit denials and backoff inside one window`
13032
+ });
13033
+ if (rule.tokensPerMinute !== void 0 && tokens > rule.tokensPerMinute) say({
13034
+ severity: "warning",
13035
+ code: "quota-tokens-below-wave",
13036
+ message: `${name}: the declared wave demands at least ${String(tokens)} tokens against tokensPerMinute ${String(rule.tokensPerMinute)}: expect estimate-driven throttling inside one window`
13037
+ });
13038
+ });
13039
+ const severityRank = {
13040
+ error: 0,
13041
+ warning: 1,
13042
+ info: 2
13043
+ };
13044
+ findings.sort((a, b) => severityRank[a.severity] - severityRank[b.severity]);
13045
+ return {
13046
+ concurrency: {
13047
+ perRun,
13048
+ ...perProviderCaps === void 0 ? {} : { perProvider: { ...perProviderCaps } }
13049
+ },
13050
+ budget: {
13051
+ ...ceilingUsd === void 0 ? {} : { ceilingUsd },
13052
+ flatReserveUsd,
13053
+ lifetimeSpawnCap,
13054
+ childBudgetFraction,
13055
+ maxDepth,
13056
+ ...orchestratorEcho === void 0 ? {} : { orchestrator: orchestratorEcho }
13057
+ },
13058
+ quota: {
13059
+ configured: quotaConfigured,
13060
+ ...engine.quota?.tenant === void 0 ? {} : { tenant: engine.quota.tenant },
13061
+ ...input.quotaRules === void 0 ? {} : { rules: input.quotaRules.length }
13062
+ },
13063
+ runLimits,
13064
+ spawns: spawnReports,
13065
+ admission: {
13066
+ ...ceilingUsd === void 0 ? {} : { ceilingUsd },
13067
+ reservedForFinalizationUsd,
13068
+ wave,
13069
+ admitted,
13070
+ denied
13071
+ },
13072
+ exposure: {
13073
+ maxInFlight,
13074
+ ...overshootOneTurnFloorUsd === void 0 ? {} : { overshootOneTurnFloorUsd },
13075
+ perProvider
13076
+ },
13077
+ findings
13078
+ };
13079
+ }
13080
+ //#endregion
13081
+ //#region src/engine/run-profiles.ts
13082
+ /**
13083
+ * The shipped presets (fast / standard / deep / ultra "and similar").
13084
+ * Data only; a review-time assertion checks the
13085
+ * engine has zero behavioral branches keyed on these names.
13086
+ */
13087
+ const RUN_PROFILES = {
13088
+ fast: {
13089
+ effortByRole: {
13090
+ orchestrate: "low",
13091
+ plan: "low",
13092
+ summarize: "low",
13093
+ extract: "low"
13094
+ },
13095
+ perRunConcurrency: 16,
13096
+ permissionPreset: "standard",
13097
+ lifetimeSpawnCap: 64,
13098
+ maxDepth: 1
13099
+ },
13100
+ standard: {
13101
+ effortByRole: {
13102
+ orchestrate: "high",
13103
+ plan: "high",
13104
+ summarize: "low",
13105
+ extract: "low"
13106
+ },
13107
+ perRunConcurrency: 12,
13108
+ permissionPreset: "standard",
13109
+ lifetimeSpawnCap: 500,
13110
+ maxDepth: 1
13111
+ },
13112
+ deep: {
13113
+ effortByRole: {
13114
+ orchestrate: "high",
13115
+ plan: "high",
13116
+ summarize: "medium",
13117
+ extract: "medium"
13118
+ },
13119
+ perRunConcurrency: 8,
13120
+ permissionPreset: "standard",
13121
+ lifetimeSpawnCap: 500,
13122
+ maxDepth: 2
13123
+ },
13124
+ ultra: {
13125
+ effortByRole: {
13126
+ orchestrate: "max",
13127
+ plan: "max",
13128
+ summarize: "high",
13129
+ extract: "high"
13130
+ },
13131
+ perRunConcurrency: 8,
13132
+ permissionPreset: "strict",
13133
+ lifetimeSpawnCap: 500,
13134
+ maxDepth: 3
13135
+ }
13136
+ };
13137
+ /** Looks up a shipped RunProfile by name; undefined for unknown names. */
13138
+ function runProfile(name) {
13139
+ return RUN_PROFILES[name];
13140
+ }
13141
+ //#endregion
13142
+ //#region src/model/concurrency.ts
13143
+ /**
13144
+ * Per-provider concurrency keys (M4-T07): a keyed limiter beside the
13145
+ * router, ENGINE-scoped (keys constrain calls
13146
+ * across a single engine per adapter). The Appendix A default is
13147
+ * unlimited: an embeddable library must not surprise-throttle hosts, so
13148
+ * the per-run semaphore stays the only default bound and provider 429s
13149
+ * ride RetryPolicy; hosts with known tier limits opt in per adapter id
13150
+ * via createEngine concurrency.perProvider.
13151
+ *
13152
+ * This keyed limiter bounds PARALLELISM inside one engine only. Two
13153
+ * processes sharing one API key coordinate through the QuotaLimiter
13154
+ * SPI instead (RV-215, createEngine `quota`): rate and volume live
13155
+ * there, in shared storage; in-flight slots live here.
13156
+ */
13157
+ var KeyedLimiter = class {
13158
+ semaphores = /* @__PURE__ */ new Map();
13159
+ constructor(caps) {
13160
+ for (const [key, limit] of Object.entries(caps ?? {})) this.semaphores.set(key, new Semaphore(limit));
12900
13161
  }
12901
- /**
12902
- * Resume roll-forward for a child that already SETTLED before the
12903
- * resume: re-registers the counters (maxChildrenPerNode, the lifetime
12904
- * cap, statsBefore fidelity) without committing any reserve; the spend
12905
- * itself sits in the root ledger seed.
12906
- */
12907
- recoverSettled(parentAccountScope) {
12908
- this.budget.admitRecovered(0, parentAccountScope);
12909
- this.childrenOf.set(parentAccountScope, (this.childrenOf.get(parentAccountScope) ?? 0) + 1);
12910
- this.admittedTotal += 1;
13162
+ /** Queue depth for one key (0 for unlimited keys); telemetry only. */
13163
+ pending(key) {
13164
+ return this.semaphores.get(key)?.pending ?? 0;
12911
13165
  }
12912
13166
  /**
12913
- * Resume roll-forward for an admission whose decision entry exists but
12914
- * whose child has NOT settled: re-applies the recorded reserve and
12915
- * counters without re-evaluating any limit (replay never
12916
- * re-evaluates admission; reserves are recovered, never
12917
- * re-estimated).
13167
+ * Runs `fn` under the key's semaphore; keys without a configured cap
13168
+ * run unlimited (no queueing, no overhead). An aborted `signal` frees
13169
+ * a queued caller without a slot (the Semaphore contract), so run
13170
+ * cancellation drains provider queues too (v1.34.0 review P2-4).
12918
13171
  */
12919
- recoverInFlight(parentAccountScope, verdict) {
12920
- if (verdict.kind === "reject") return;
12921
- const reserveUsd = verdict.kind === "reuse_full" ? 0 : verdict.reserve.reserveUsd;
12922
- this.budget.admitRecovered(reserveUsd, parentAccountScope);
12923
- this.childrenOf.set(parentAccountScope, (this.childrenOf.get(parentAccountScope) ?? 0) + 1);
12924
- this.admittedTotal += 1;
13172
+ async withSlot(key, fn, onQueued, signal) {
13173
+ const semaphore = this.semaphores.get(key);
13174
+ if (semaphore === void 0) return fn();
13175
+ return semaphore.withSlot(fn, onQueued, signal);
12925
13176
  }
12926
13177
  };
12927
13178
  //#endregion
13179
+ //#region src/model/profile-card.ts
13180
+ function toolNamesOf(profile) {
13181
+ return (profile.tools ?? []).map((entry) => {
13182
+ if (typeof entry === "string") return `${entry} (registered toolset)`;
13183
+ if ("kind" in entry && entry.kind === "tool") return entry.name;
13184
+ return `${entry.id}:* (tool source)`;
13185
+ });
13186
+ }
13187
+ /**
13188
+ * Renders the registry into the shared agent vocabulary card. Sorted,
13189
+ * deterministic, byte-stable; an empty registry renders explicitly so
13190
+ * the planner never guesses at unregistered agentTypes. When the engine
13191
+ * registers toolsets, their names render as a closing line (v1.17.0
13192
+ * review P1-3): those are the ONLY values valid as string entries of a
13193
+ * tools option, so the planner never invents a registry name.
13194
+ */
13195
+ function profileCard(profiles, toolsets) {
13196
+ const toolsetNames = Object.keys(toolsets ?? {}).sort();
13197
+ const toolsetsLine = toolsetNames.length === 0 ? void 0 : `Registered toolsets (valid string entries of a tools option): ${toolsetNames.join(", ")}.`;
13198
+ const names = Object.keys(profiles ?? {}).sort();
13199
+ if (profiles === void 0 || names.length === 0) {
13200
+ const empty = "Agent profiles: none registered. Calls take no agentType.";
13201
+ return toolsetsLine === void 0 ? empty : `${empty}\n${toolsetsLine}`;
13202
+ }
13203
+ const lines = ["Agent profiles (agentType values):"];
13204
+ for (const name of names) {
13205
+ const profile = profiles[name];
13206
+ const description = profile.description ?? "no description";
13207
+ lines.push(`- ${name}: ${description}`);
13208
+ const toolNames = toolNamesOf(profile);
13209
+ if (toolNames.length > 0) lines.push(` tools: ${toolNames.join(", ")}`);
13210
+ if (profile.taskClass !== void 0) lines.push(` taskClass: ${profile.taskClass}`);
13211
+ if (profile.estCost !== void 0) lines.push(` estCost: ${profile.estCost.toFixed(2)} USD`);
13212
+ if (profile.escalation !== void 0) lines.push(` escalation: flavor ${profile.escalation.flavor ?? "A"} (opt-in)`);
13213
+ }
13214
+ if (toolsetsLine !== void 0) lines.push(toolsetsLine);
13215
+ return lines.join("\n");
13216
+ }
13217
+ //#endregion
13218
+ //#region src/runtime/permission-chain.ts
13219
+ /**
13220
+ * The layered permission chain (M3-T03): the single approval surface for
13221
+ * every tool dispatch, regardless of tool origin. The order is fixed and
13222
+ * normative: hooks -> deny rules -> ask rules -> canUseTool -> terminal
13223
+ * default (allow unless needsApproval, then ask). Evaluation is
13224
+ * short-circuit; unconfigured layers are skipped. Rules never yield
13225
+ * allow: allow is only ever falling through to canUseTool or the
13226
+ * terminal default.
13227
+ *
13228
+ * Full contract: https://docs.rulvar.com/guide/tools.
13229
+ * Risk presets, the argv shell matcher, domain rules, and the
13230
+ * audit/dry-run surface land in M5.
13231
+ */
13232
+ /**
13233
+ * Merges the engine-wide config and the profile config into one chain.
13234
+ * Layers concatenate engine-first; since rules only deny or ask, ordering
13235
+ * within a layer cannot change the verdict. The
13236
+ * profile's canUseTool wins over the engine's (a single slot by
13237
+ * construction). A declared preset compiles INTO the same layers, after
13238
+ * the host-authored rules, never as a fifth layer (M5-T05).
13239
+ */
13240
+ function compilePermissionChain(engine, profile) {
13241
+ const preset = profile?.preset === void 0 ? {
13242
+ deny: [],
13243
+ ask: []
13244
+ } : compilePermissionPreset(profile.preset);
13245
+ const deny = [
13246
+ ...engine?.deny ?? [],
13247
+ ...profile?.deny ?? [],
13248
+ ...preset.deny
13249
+ ];
13250
+ const ask = [
13251
+ ...engine?.ask ?? [],
13252
+ ...profile?.ask ?? [],
13253
+ ...preset.ask
13254
+ ];
13255
+ const canUseTool = profile?.canUseTool ?? engine?.canUseTool;
13256
+ return {
13257
+ hooks: [...engine?.hooks ?? [], ...profile?.hooks ?? []],
13258
+ deny,
13259
+ ask,
13260
+ ...canUseTool === void 0 ? {} : { canUseTool }
13261
+ };
13262
+ }
13263
+ /** The command text an argv rule matches against. */
13264
+ function commandOf(input) {
13265
+ if (typeof input === "string") return input;
13266
+ if (typeof input === "object" && input !== null) {
13267
+ const command = input.command;
13268
+ if (typeof command === "string") return command;
13269
+ }
13270
+ }
13271
+ function ruleMatches(rule, toolName, risk, input) {
13272
+ if ("risk" in rule) {
13273
+ const risks = Array.isArray(rule.risk) ? rule.risk : [rule.risk];
13274
+ if (risks.includes("undeclared") && risk === void 0) return true;
13275
+ return risk !== void 0 && risks.includes(risk);
13276
+ }
13277
+ if ("domains" in rule) return false;
13278
+ if (!(Array.isArray(rule.tool) ? rule.tool : [rule.tool]).includes(toolName)) return false;
13279
+ if ("argv" in rule) {
13280
+ const command = commandOf(input);
13281
+ if (command === void 0) return false;
13282
+ const patterns = Array.isArray(rule.argv) ? rule.argv : [rule.argv];
13283
+ return lexShellCommand(command).some((segment) => !segment.unmatchable && patterns.some((pattern) => matchArgvPattern(pattern, segment.argv)));
13284
+ }
13285
+ return true;
13286
+ }
13287
+ /**
13288
+ * Advisory domain-rule matches for the audit payload:
13289
+ * reported, never enforced in the current release.
13290
+ */
13291
+ function advisoryMatches(chain, toolName) {
13292
+ return [...chain.deny, ...chain.ask].filter((rule) => "domains" in rule && rule.tool === toolName);
13293
+ }
13294
+ /**
13295
+ * Unmatchable segments (command/process substitution, here-docs) yield
13296
+ * ask, ALWAYS, for any tool that has argv rules.
13297
+ */
13298
+ function argvUnmatchableAsk(chain, toolName, input) {
13299
+ if (![...chain.deny, ...chain.ask].some((rule) => "argv" in rule && (Array.isArray(rule.tool) ? rule.tool : [rule.tool]).includes(toolName))) return false;
13300
+ const command = commandOf(input);
13301
+ if (command === void 0) return true;
13302
+ return lexShellCommand(command).some((segment) => segment.unmatchable);
13303
+ }
13304
+ /** A stub ToolContext for offline (dry-run) evaluations. */
13305
+ function offlineContext(toolName) {
13306
+ return {
13307
+ runId: "dry-run",
13308
+ spanId: `dry-run-${toolName}`,
13309
+ agent: { agentType: "" },
13310
+ cwd: process.cwd(),
13311
+ isolation: "none",
13312
+ signal: new AbortController().signal,
13313
+ log: () => void 0
13314
+ };
13315
+ }
13316
+ /**
13317
+ * Evaluates the chain for one dispatch, or OFFLINE against a
13318
+ * hypothetical call by tool name (the dry-run API: nothing executes;
13319
+ * shells and tests read the verdict, the
13320
+ * deciding layer, and the matched rule). Hooks run in deterministic
13321
+ * registration order; { modifiedInput } substitutes the input and
13322
+ * continues; the first decisive verdict wins. The returned input is what
13323
+ * execute receives and what the approval identity hashes (post hook
13324
+ * modification). Advisory domain-rule matches
13325
+ * ride every verdict for the audit payload.
13326
+ */
13327
+ async function evaluatePermission(chain, tool, input, ctx) {
13328
+ const def = typeof tool === "string" ? {
13329
+ name: tool,
13330
+ needsApproval: false
13331
+ } : tool;
13332
+ const risk = typeof tool === "string" ? void 0 : tool.risk;
13333
+ const context = ctx ?? offlineContext(def.name);
13334
+ const advisory = advisoryMatches(chain, def.name);
13335
+ const withAdvisory = (verdict) => advisory.length === 0 ? verdict : {
13336
+ ...verdict,
13337
+ advisory
13338
+ };
13339
+ let effective = input;
13340
+ for (const hook of chain.hooks) {
13341
+ const verdict = await hook(def.name, effective, context);
13342
+ if (verdict === void 0) continue;
13343
+ if (verdict === "allow" || verdict === "deny" || verdict === "ask") return withAdvisory({
13344
+ verdict,
13345
+ decidedBy: "hook",
13346
+ input: effective
13347
+ });
13348
+ effective = verdict.modifiedInput;
13349
+ }
13350
+ for (const rule of chain.deny) if (ruleMatches(rule, def.name, risk, effective)) return withAdvisory({
13351
+ verdict: "deny",
13352
+ decidedBy: "deny-rule",
13353
+ rule,
13354
+ input: effective
13355
+ });
13356
+ for (const rule of chain.ask) if (ruleMatches(rule, def.name, risk, effective)) return withAdvisory({
13357
+ verdict: "ask",
13358
+ decidedBy: "ask-rule",
13359
+ rule,
13360
+ input: effective
13361
+ });
13362
+ if (argvUnmatchableAsk(chain, def.name, effective)) return withAdvisory({
13363
+ verdict: "ask",
13364
+ decidedBy: "ask-rule",
13365
+ input: effective
13366
+ });
13367
+ if (chain.canUseTool !== void 0) {
13368
+ const verdict = await chain.canUseTool(def.name, effective, context);
13369
+ if (verdict === "allow") return withAdvisory({
13370
+ verdict: "allow",
13371
+ decidedBy: "canUseTool",
13372
+ input: effective
13373
+ });
13374
+ if (verdict === "deny") return withAdvisory({
13375
+ verdict: "deny",
13376
+ decidedBy: "canUseTool",
13377
+ input: effective
13378
+ });
13379
+ effective = verdict.modifiedInput;
13380
+ }
13381
+ if (def.needsApproval) return withAdvisory({
13382
+ verdict: "ask",
13383
+ decidedBy: "default",
13384
+ input: effective
13385
+ });
13386
+ return withAdvisory({
13387
+ verdict: "allow",
13388
+ decidedBy: "default",
13389
+ input: effective
13390
+ });
13391
+ }
13392
+ //#endregion
12928
13393
  //#region src/orchestrator/finish-validators.ts
12929
13394
  /**
12930
13395
  * Deterministic host validation of the orchestrator finish result (the
@@ -18440,4 +18905,4 @@ function createSandboxBridge(ctx, options) {
18440
18905
  };
18441
18906
  }
18442
18907
  //#endregion
18443
- export { AWAIT_SCHEMA, AdmissionController, AdmissionRejectedError, AgentCallError, BUDGET_ABORT_REASON, BudgetExhaustedError, CANCEL_AGENT_SCHEMA, CHECKPOINT_FORMAT_V1, CLAIM_STATEMENT_MAX_CHARS, CLAIM_TTL_DAYS, COMPACTION_SUMMARY_PREFIX, CURRENT_HASH_VERSION, ConfigError, DEFAULT_CHILD_BUDGET_FRACTION, DEFAULT_CHILD_RESULT_PAGE_CHARS, DEFAULT_CITATION_PATTERN, DEFAULT_COMPACTION_THRESHOLD, DEFAULT_ESCALATION_LIMITS, DEFAULT_EVIDENCE_MIN_SHARE, DEFAULT_FINISH_MAX_REPAIRS, DEFAULT_FLAT_RESERVE_USD, DEFAULT_MAX_CHILDREN_PER_NODE, DEFAULT_MAX_DEPTH, DEFAULT_MAX_OSCILLATIONS_PER_KEY, DEFAULT_MAX_PINNED_WORKTREES, DEFAULT_MAX_REVISIONS_PER_RUN, DEFAULT_MAX_TOTAL_SPAWNS, DEFAULT_MAX_TURNS, DEFAULT_MODEL_RETRY_ATTEMPTS, DEFAULT_NO_PROGRESS_TURNS, DEFAULT_PER_RUN_CONCURRENCY, DEFAULT_RETRY_POLICY, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_SYNTHESIS_MAX_TURNS, DEFAULT_SYNTHESIS_NOTE_MAX_TURNS, DedupIndex, DeterminismError, EMIT_RESULT_TOOL, EMPTY_SCHEMA_HASH, EMPTY_TOOLSET_HASH, ESCALATE_TOOL_NAME, ESCALATION_REPORT_SCHEMA, ESCALATION_REQUEST_SCHEMA, EVENT_SEGMENT_STRIDE, EscalationDecisionAbortedError, EventBus, ExternalRegistry, FINALIZE_SYNTHESIS_INSTRUCTION, FINISH_SCHEMA, FINISH_TOOL_NAME, FailRunError, FileModelKnowledgeStore, FileTranscriptStore, GET_CHILD_RESULT_SCHEMA, GET_CHILD_RESULT_TOOL_NAME, GitWorktreeProvider, IMPLEMENTATION_PROFILE_LIMITS, INBOX_PROPOSAL_TTL_DAYS, InMemoryStore, InMemoryTranscriptStore, InProcessRunner, InvalidResolutionError, JOURNAL_ENVELOPE_MARKER, JournalCompatibilityError, JournalMatcher, JournalMissError, JournalOrderViolation, JsonlFileStore, KB_ACTIVE_CLAIMS_CAP, KB_CARD_RENDER_BUDGET_CHARS, KeyedLimiter, KnowledgeCasError, LARGE_VALUE_WARN_BYTES, LEGACY_LTID_PREFIX, LEGACY_SIGNATURE_INPUTS, LINEAGE_SIG_VERSION, LeaseHeldError, LineageIndex, MASKED_SECRET, MAX_CHILD_RESULT_PAGE_CHARS, MAX_DEPTH_CEILING, ModelRetry, NoProgressDetector, NonSerializableValueError, ORCHESTRATE_WORKFLOW_NAME, OrchestratorCapConfigError, PARALLEL_AGENTS_SCHEMA, PROGRESS_REPORT_TOOL_NAME, ParallelSiteCounter, PlanInvariantError, QUOTA_WINDOW_MS, READ_CHILD_ARTIFACT_SCHEMA, READ_CHILD_ARTIFACT_TOOL_NAME, RESEARCH_PROFILE_LIMITS, REVIEW_PROFILE_LIMITS, ROLE_EFFORT_DEFAULTS, ROOT_ACCOUNT, ROOT_SCOPE, RUN_PROFILES, RUN_SETTLE_DECISION_TYPE, ReplayPlanHashMismatch, Replayer, ResolutionArbiter, ResolutionFold, RulvarError, RunBudget, SANDBOX_AGENT_OPT_KEYS, SPAWN_AGENT_SCHEMA, SandboxError, ScriptRejected, Semaphore, SpanRegistry, TOOL_NAME_PATTERN, TerminationAccount, WAIT_FOR_EVENTS_SCHEMA, WAIT_FOR_EVENTS_TOOL_NAME, WAKE_SUMMARY_RENDER_BUDGET_CHARS, admissionReserveUsd, affordableOutputTokens, agentErrorFromWire, agentErrorToWire, agentResultWire, agentScope, applyClaimOps, applyStructuredOutputTier, approachSigCoarse, approachSigOf, archiveDeprecatedModelOps, assertFencedWrites, atCompactionThreshold, auditRun, auditRuns, buildAbandonFold, buildAdapterRegistry, buildCostReport, buildDeriverRegistry, buildOrchestratorTools, buildTerminationInitValue, buildToolContext, canRideLoopTurn, canonicalIsolationTag, canonicalizeLadder, canonicalizeSchema, capIssues, capsHashOf, checkFloors, checkpointRefFor, childCoveragePrefix, claimExpired, claimExpiry, claimIssues, claimOpIssues, classifyAgentError, classifyAttemptOutcome, collectDeclaredLadders, compactMessages, compilePermissionChain, compilePermissionPreset, compileSecretMasker, compileVerifiedLayer, constantTimeEqual, costReportFromJournal, countsAgainstLimit, createCanonicalIdMinter, createCtx, createEngine, createEnvelopeEncryption, createSandboxBridge, currentOnlyKeyRing, decodeCheckpoint, dedupeRepeatedClaims, defineWorkflow, deriveContentKey, deriverV1, deriverV2, digestOf, dispositionHook, emptyDigestBlocks, emptyToolset, encodeCheckpoint, entryUsageSlices, escalateTool, evaluatePermission, evaluateReuse, evidencePreservedValidator, executeWorkflow, exhaustionCodeOf, extractCandidate, failoverTriggerOf, fallbackTriggerOf, filterClaimsForRun, finalizeFires, foldTermination, formatRePrompt, formatScopePath, hasFencedWrites, hasMetaLookup, hashRunArgs, hashRunOutput, hashWorkflowBody, hashWorkflowSource, identityJcs, implementationAgentProfile, invoiceFromJournal, isEscalated, isSchemaPairSpec, isStandardSchemaSpec, isStrictCompatibleSchema, kMaxOf, knowledgeHash, ladderLengthOf, ladderRungChoice, lastRunSettle, latestProgressReport, lexShellCommand, liftRetainedParts, lineageWeightOf, localKeyProvider, makeOrchestratorWorkflow, maskSecrets, maskSecretsDeep, maskSecretsJson, matchArgvPattern, matchShellCommand, mcp, memoryQuotaLimiter, mergeQuotaDenial, mergeUsageLimits, metaMatchesFilter, minMatchesValidator, modelEpochOf, modelKnowledgeCard, modelSpecIdentity, needsSeparateExtract, nextFailover, nodeLinkKey, normalizeApproachTag, normalizeEntry, normalizeFallbacks, orchestrate, parallelScope, parseModelRef, parseScopePath, phiInitialOf, pipelineScope, planNodeScope, priceEntryUsage, priceUsdOf, profileCard, profileRegistrySnapshotHash, progressReportTool, projectHistory, projectIdentity, projectToJsonSchema, proposalStatement, providerOf, quotaActualTokens, quotaEstimateTokens, quotaRuleAdmission, quotaRuleMatches, readRunMeta, readTerminationInit, reconcileRunMeta, reduceAuditTrail, reduceCriticalPath, reduceInvocationTable, registryKeyRing, remeasureQueue, replayDisposition, repositoryResearchToolset, requiredFieldsValidator, requiredSectionsValidator, researchAgentProfile, resolveModelInvocation, resolvePricing, resolveToolset, retryClassOf, retryDelayMs, reviewAgentProfile, roleConfiguredInRouting, roundOneDisposition, runAgent, runProfile, sanitizeTerminalText, sanitizeTokenCount, sanitizeUsage, sanitizeUsageDelta, scanJournalCompatibility, schemaHash, schemaHashOfSpec, selectStructuredOutputTier, shouldCompact, snapshotUsage, spawnDepthOf, summarizeInstruction, summarizeOutput, terminationConfigDrift, tierWithinCaps, toApprovalDecision, toJournalValue, tool, toolContract, toolsetHash, ttlState, usageViolations, validateEditorialCommit, validateEngineQuotaConfig, validateEntryShape, validateEscalationLimits, validateEscalationReport, validateQuotaRules, validateRetryPolicy, validateSchemaSpec, validateTerminationLimits, validateUsageLimits, workflowScope, workflowSourceRef, wrapJournalStore, wrapTranscriptStore };
18908
+ export { AWAIT_SCHEMA, AdmissionController, AdmissionRejectedError, AgentCallError, BUDGET_ABORT_REASON, BudgetExhaustedError, CANCEL_AGENT_SCHEMA, CHECKPOINT_FORMAT_V1, CLAIM_STATEMENT_MAX_CHARS, CLAIM_TTL_DAYS, COMPACTION_SUMMARY_PREFIX, CURRENT_HASH_VERSION, ConfigError, DEFAULT_CHILD_BUDGET_FRACTION, DEFAULT_CHILD_RESULT_PAGE_CHARS, DEFAULT_CITATION_PATTERN, DEFAULT_COMPACTION_THRESHOLD, DEFAULT_ESCALATION_LIMITS, DEFAULT_EVIDENCE_MIN_SHARE, DEFAULT_FINISH_MAX_REPAIRS, DEFAULT_FLAT_RESERVE_USD, DEFAULT_MAX_CHILDREN_PER_NODE, DEFAULT_MAX_DEPTH, DEFAULT_MAX_OSCILLATIONS_PER_KEY, DEFAULT_MAX_PINNED_WORKTREES, DEFAULT_MAX_REVISIONS_PER_RUN, DEFAULT_MAX_TOTAL_SPAWNS, DEFAULT_MAX_TURNS, DEFAULT_MODEL_RETRY_ATTEMPTS, DEFAULT_NO_PROGRESS_TURNS, DEFAULT_PER_RUN_CONCURRENCY, DEFAULT_RETRY_POLICY, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_SYNTHESIS_MAX_TURNS, DEFAULT_SYNTHESIS_NOTE_MAX_TURNS, DedupIndex, DeterminismError, EMIT_RESULT_TOOL, EMPTY_SCHEMA_HASH, EMPTY_TOOLSET_HASH, ESCALATE_TOOL_NAME, ESCALATION_REPORT_SCHEMA, ESCALATION_REQUEST_SCHEMA, EVENT_SEGMENT_STRIDE, EscalationDecisionAbortedError, EventBus, ExternalRegistry, FINALIZE_SYNTHESIS_INSTRUCTION, FINISH_SCHEMA, FINISH_TOOL_NAME, FailRunError, FileModelKnowledgeStore, FileTranscriptStore, GET_CHILD_RESULT_SCHEMA, GET_CHILD_RESULT_TOOL_NAME, GitWorktreeProvider, IMPLEMENTATION_PROFILE_LIMITS, INBOX_PROPOSAL_TTL_DAYS, InMemoryStore, InMemoryTranscriptStore, InProcessRunner, InvalidResolutionError, JOURNAL_ENVELOPE_MARKER, JournalCompatibilityError, JournalMatcher, JournalMissError, JournalOrderViolation, JsonlFileStore, KB_ACTIVE_CLAIMS_CAP, KB_CARD_RENDER_BUDGET_CHARS, KeyedLimiter, KnowledgeCasError, LARGE_VALUE_WARN_BYTES, LEGACY_LTID_PREFIX, LEGACY_SIGNATURE_INPUTS, LINEAGE_SIG_VERSION, LeaseHeldError, LineageIndex, MASKED_SECRET, MAX_CHILD_RESULT_PAGE_CHARS, MAX_DEPTH_CEILING, ModelRetry, NoProgressDetector, NonSerializableValueError, ORCHESTRATE_WORKFLOW_NAME, OrchestratorCapConfigError, PARALLEL_AGENTS_SCHEMA, PROGRESS_REPORT_TOOL_NAME, ParallelSiteCounter, PlanInvariantError, QUOTA_WINDOW_MS, READ_CHILD_ARTIFACT_SCHEMA, READ_CHILD_ARTIFACT_TOOL_NAME, RESEARCH_PROFILE_LIMITS, REVIEW_PROFILE_LIMITS, ROLE_EFFORT_DEFAULTS, ROOT_ACCOUNT, ROOT_SCOPE, RUN_PROFILES, RUN_SETTLE_DECISION_TYPE, ReplayPlanHashMismatch, Replayer, ResolutionArbiter, ResolutionFold, RulvarError, RunBudget, SANDBOX_AGENT_OPT_KEYS, SPAWN_AGENT_SCHEMA, SandboxError, ScriptRejected, Semaphore, SpanRegistry, TOOL_NAME_PATTERN, TerminationAccount, WAIT_FOR_EVENTS_SCHEMA, WAIT_FOR_EVENTS_TOOL_NAME, WAKE_SUMMARY_RENDER_BUDGET_CHARS, admissionReserveUsd, affordableOutputTokens, agentErrorFromWire, agentErrorToWire, agentResultWire, agentScope, applyClaimOps, applyStructuredOutputTier, approachSigCoarse, approachSigOf, archiveDeprecatedModelOps, assertFencedWrites, atCompactionThreshold, auditRun, auditRuns, buildAbandonFold, buildAdapterRegistry, buildCostReport, buildDeriverRegistry, buildOrchestratorTools, buildTerminationInitValue, buildToolContext, canRideLoopTurn, canonicalIsolationTag, canonicalizeLadder, canonicalizeSchema, capIssues, capsHashOf, checkFloors, checkpointRefFor, childCoveragePrefix, claimExpired, claimExpiry, claimIssues, claimOpIssues, classifyAgentError, classifyAttemptOutcome, collectDeclaredLadders, compactMessages, compilePermissionChain, compilePermissionPreset, compileSecretMasker, compileVerifiedLayer, constantTimeEqual, costReportFromJournal, countsAgainstLimit, createCanonicalIdMinter, createCtx, createEngine, createEnvelopeEncryption, createSandboxBridge, currentOnlyKeyRing, decodeCheckpoint, dedupeRepeatedClaims, defineWorkflow, deriveContentKey, deriverV1, deriverV2, digestOf, dispositionHook, emptyDigestBlocks, emptyToolset, encodeCheckpoint, entryUsageSlices, escalateTool, evaluatePermission, evaluateReuse, evidencePreservedValidator, executeWorkflow, exhaustionCodeOf, extractCandidate, failoverTriggerOf, fallbackTriggerOf, filterClaimsForRun, finalizeFires, foldTermination, formatRePrompt, formatScopePath, hasFencedWrites, hasMetaLookup, hashRunArgs, hashRunOutput, hashWorkflowBody, hashWorkflowSource, identityJcs, implementationAgentProfile, invoiceFromJournal, isEscalated, isSchemaPairSpec, isStandardSchemaSpec, isStrictCompatibleSchema, kMaxOf, knowledgeHash, ladderLengthOf, ladderRungChoice, lastRunSettle, latestProgressReport, lexShellCommand, liftRetainedParts, lineageWeightOf, localKeyProvider, makeOrchestratorWorkflow, maskSecrets, maskSecretsDeep, maskSecretsJson, matchArgvPattern, matchShellCommand, mcp, memoryQuotaLimiter, mergeQuotaDenial, mergeUsageLimits, metaMatchesFilter, minMatchesValidator, modelEpochOf, modelKnowledgeCard, modelSpecIdentity, needsSeparateExtract, nextFailover, nodeLinkKey, normalizeApproachTag, normalizeEntry, normalizeFallbacks, orchestrate, parallelScope, parseModelRef, parseScopePath, phiInitialOf, pipelineScope, planNodeScope, preflightEstimate, priceEntryUsage, priceUsdOf, profileCard, profileRegistrySnapshotHash, progressReportTool, projectHistory, projectIdentity, projectToJsonSchema, proposalStatement, providerOf, quotaActualTokens, quotaEstimateTokens, quotaRuleAdmission, quotaRuleMatches, readRunMeta, readTerminationInit, reconcileRunMeta, reduceAuditTrail, reduceCriticalPath, reduceInvocationTable, registryKeyRing, remeasureQueue, replayDisposition, repositoryResearchToolset, requiredFieldsValidator, requiredSectionsValidator, researchAgentProfile, resolveModelInvocation, resolvePricing, resolveToolset, retryClassOf, retryDelayMs, reviewAgentProfile, roleConfiguredInRouting, roundOneDisposition, runAgent, runProfile, sanitizeTerminalText, sanitizeTokenCount, sanitizeUsage, sanitizeUsageDelta, scanJournalCompatibility, schemaHash, schemaHashOfSpec, selectStructuredOutputTier, shouldCompact, snapshotUsage, spawnDepthOf, summarizeInstruction, summarizeOutput, terminationConfigDrift, tierWithinCaps, toApprovalDecision, toJournalValue, tool, toolContract, toolsetHash, ttlState, usageViolations, validateEditorialCommit, validateEngineQuotaConfig, validateEntryShape, validateEscalationLimits, validateEscalationReport, validateQuotaRules, validateRetryPolicy, validateSchemaSpec, validateTerminationLimits, validateUsageLimits, workflowScope, workflowSourceRef, wrapJournalStore, wrapTranscriptStore };