@rulvar/core 1.101.0 → 1.103.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.
package/dist/index.d.ts CHANGED
@@ -828,14 +828,18 @@ interface EntryBillingFold {
828
828
  /**
829
829
  * The billing fold over one terminal entry (RV504), shared by the
830
830
  * CostReport and invoice folds so the total, every breakdown, and the
831
- * per-row prices can never disagree. When the entry's per-dispatch
832
- * `providerCalls` exactly cover its usage, each call is priced
831
+ * per-row prices can never disagree. Coverage is decided per MODEL with
832
+ * the symmetric key (RV604): for every model whose per-dispatch
833
+ * `providerCalls` sum to exactly its usage, each call is priced
833
834
  * individually, so a nonlinear long-context tier fires per REQUEST,
834
835
  * which is the pricing contract's stated semantics; an aggregate that
835
836
  * crossed a threshold no single request crossed no longer re-prices
836
- * the whole entry (the ninth-experiment 52% overreport). An entry with
837
- * no records, or records that do not cover its usage, folds exactly as
838
- * before: the per-model aggregate slices of {@link priceEntryUsage}.
837
+ * that model (the ninth-experiment 52% overreport, and the round-52
838
+ * multi-role default). A model with no records, or records that do not
839
+ * cover its usage, folds exactly as before: the per-model aggregate
840
+ * slices of {@link priceEntryUsage}. `fullyAttributed` is true only
841
+ * when every slice model is covered and no record names a model absent
842
+ * from the slices.
839
843
  */
840
844
  declare function priceEntryBilling(entry: JournalEntry, priceUsd: (servedBy: ModelRef, usage: Usage, seq?: number) => number | undefined): EntryBillingFold;
841
845
  /**
@@ -4719,26 +4723,47 @@ interface RunAgentOptions<S extends SchemaSpec = JsonSchema> {
4719
4723
  * already promised the raised cap), never re-admitted or re-announced,
4720
4724
  * and a restored window entry keeps the summary's
4721
4725
  * finalizationWindowEntered truthful even when a later grant moved the
4722
- * counts back out of the window. The hooks are fire-and-forget from
4723
- * the loop's view; pressure notices stay events and are never
4724
- * journaled. Absent, the loop is byte-identical to before.
4726
+ * counts back out of the window.
4727
+ *
4728
+ * Both hooks are AWAITED before the thing they authorize becomes
4729
+ * observable (RV601): a grant lifts no expiry and queues no notice
4730
+ * until its decision is durable, and the window regime binds no call
4731
+ * until its entry is. A rejected append therefore leaves the grant
4732
+ * unissued and the entry unrecorded, and the rejection propagates
4733
+ * exactly like a failed boundary checkpoint rather than being
4734
+ * swallowed. Pressure notices stay events and are never journaled.
4735
+ * Absent, the loop is byte-identical to before.
4725
4736
  */
4726
4737
  toolBudgetDurability?: {
4727
4738
  restored?: {
4728
4739
  extensionsGranted: number;
4729
4740
  finalizationWindowEntered: boolean;
4741
+ /**
4742
+ * The effective cap the journaled grant announced (RV602). It
4743
+ * anchors the resumed ceiling, because the live `maxToolCalls`
4744
+ * and `increment` are not part of the dispatch identity and may
4745
+ * legitimately drift between segments: without the anchor the two
4746
+ * recovery paths (pure replay, which reads the journal, and live
4747
+ * resume, which recomputed) disagreed, and a promise already made
4748
+ * to the model could be silently revoked. Validated as a
4749
+ * persistent inlet: a non-integer, or one below the base cap, is
4750
+ * ignored with a warning, leaving the count derivation as the
4751
+ * floor. Grants taken AFTER the restore point still measure the
4752
+ * current increment from this anchor.
4753
+ */
4754
+ cap?: number;
4730
4755
  };
4731
4756
  onExtensionGrant?: (grant: {
4732
4757
  grant: number;
4733
4758
  maxExtensions: number;
4734
4759
  toolCallsUsed: number;
4735
4760
  cap: number;
4736
- }) => void;
4761
+ }) => Promise<void>;
4737
4762
  onWindowEntry?: (entry: {
4738
4763
  remaining: number;
4739
4764
  reserveCalls: number;
4740
4765
  budget: FinalizationWindowBudget;
4741
- }) => void;
4766
+ }) => Promise<void>;
4742
4767
  };
4743
4768
  /** Emits agent:stream deltas when true (telemetry only). */
4744
4769
  stream?: boolean;
@@ -9542,6 +9567,16 @@ interface InvoiceExport {
9542
9567
  }>;
9543
9568
  /** Rows whose reconciliation is not 'provider-id-present'. */
9544
9569
  reconciliationFailures: number;
9570
+ /**
9571
+ * USD of allocation pools that had a target and no row to carry it
9572
+ * (RV605). The dust pass refuses to move such dollars onto another
9573
+ * model's rows just to make the column sum, so on the (pathological)
9574
+ * journals where this happens the flat `allocatedUsd` sum reproduces
9575
+ * `totalUsd` minus this amount. Absent when zero, which is every
9576
+ * well-formed journal: the per-slice remainder rows guarantee a row
9577
+ * wherever a slice has usage.
9578
+ */
9579
+ unallocatedUsd?: number;
9545
9580
  /** Rows carrying `usageUnknown`; present when at least one does. */
9546
9581
  usageUnknownRows?: number;
9547
9582
  /** Present and true when any contributing entry carried approximate usage. */
package/dist/index.js CHANGED
@@ -2401,12 +2401,11 @@ const BILLING_FIELDS = [
2401
2401
  "cacheReadTokens",
2402
2402
  "cacheWriteTokens"
2403
2403
  ];
2404
- /** Exact per-model coverage: records sum to every slice, counter for counter. */
2405
- function callsCoverSlices(slices, records) {
2406
- if (slices.length === 0 || records.length === 0) return false;
2404
+ /** Per-model billing-counter sums of either side of the coverage test. */
2405
+ function sumUsageByModel(items) {
2407
2406
  const sums = /* @__PURE__ */ new Map();
2408
- for (const record of records) {
2409
- let sum = sums.get(record.servedBy);
2407
+ for (const item of items) {
2408
+ let sum = sums.get(item.servedBy);
2410
2409
  if (sum === void 0) {
2411
2410
  sum = {
2412
2411
  fields: {
@@ -2417,54 +2416,61 @@ function callsCoverSlices(slices, records) {
2417
2416
  },
2418
2417
  reasoning: 0
2419
2418
  };
2420
- sums.set(record.servedBy, sum);
2419
+ sums.set(item.servedBy, sum);
2421
2420
  }
2422
- for (const field of BILLING_FIELDS) sum.fields[field] = (sum.fields[field] ?? 0) + record.usage[field];
2423
- sum.reasoning += record.usage.reasoningTokens ?? 0;
2421
+ for (const field of BILLING_FIELDS) sum.fields[field] = (sum.fields[field] ?? 0) + item.usage[field];
2422
+ sum.reasoning += item.usage.reasoningTokens ?? 0;
2424
2423
  }
2425
- for (const slice of slices) {
2426
- const sum = sums.get(slice.servedBy);
2427
- if (sum === void 0) return false;
2428
- for (const field of BILLING_FIELDS) if (sum.fields[field] !== slice.usage[field]) return false;
2429
- if (sum.reasoning !== (slice.usage.reasoningTokens ?? 0)) return false;
2424
+ return sums;
2425
+ }
2426
+ /**
2427
+ * The models whose records exactly cover their usage, counter for
2428
+ * counter. BOTH sides aggregate by `servedBy` (RV604): the usage slices
2429
+ * split one model's spend by role (a schema fires a same-model extract
2430
+ * by default, so several slices of one model are the ordinary shape),
2431
+ * and comparing a per-role slice against the per-model record sum
2432
+ * refused coverage on exactly that default, re-tiering aggregates no
2433
+ * single request crossed. The symmetric key compares model sum against
2434
+ * model sum, so coverage is per model: a covered model prices per call
2435
+ * while an uncovered one honestly keeps the aggregate basis.
2436
+ */
2437
+ function coveredModels(slices, records) {
2438
+ const covered = /* @__PURE__ */ new Set();
2439
+ if (slices.length === 0 || records.length === 0) return covered;
2440
+ const recordSums = sumUsageByModel(records);
2441
+ const sliceSums = sumUsageByModel(slices);
2442
+ for (const [model, sliceSum] of sliceSums) {
2443
+ const recordSum = recordSums.get(model);
2444
+ if (recordSum === void 0) continue;
2445
+ if (BILLING_FIELDS.every((field) => recordSum.fields[field] === sliceSum.fields[field]) && recordSum.reasoning === sliceSum.reasoning) covered.add(model);
2430
2446
  }
2431
- for (const model of sums.keys()) if (!slices.some((slice) => slice.servedBy === model)) return false;
2432
- return true;
2447
+ return covered;
2433
2448
  }
2434
2449
  /**
2435
2450
  * The billing fold over one terminal entry (RV504), shared by the
2436
2451
  * CostReport and invoice folds so the total, every breakdown, and the
2437
- * per-row prices can never disagree. When the entry's per-dispatch
2438
- * `providerCalls` exactly cover its usage, each call is priced
2452
+ * per-row prices can never disagree. Coverage is decided per MODEL with
2453
+ * the symmetric key (RV604): for every model whose per-dispatch
2454
+ * `providerCalls` sum to exactly its usage, each call is priced
2439
2455
  * individually, so a nonlinear long-context tier fires per REQUEST,
2440
2456
  * which is the pricing contract's stated semantics; an aggregate that
2441
2457
  * crossed a threshold no single request crossed no longer re-prices
2442
- * the whole entry (the ninth-experiment 52% overreport). An entry with
2443
- * no records, or records that do not cover its usage, folds exactly as
2444
- * before: the per-model aggregate slices of {@link priceEntryUsage}.
2458
+ * that model (the ninth-experiment 52% overreport, and the round-52
2459
+ * multi-role default). A model with no records, or records that do not
2460
+ * cover its usage, folds exactly as before: the per-model aggregate
2461
+ * slices of {@link priceEntryUsage}. `fullyAttributed` is true only
2462
+ * when every slice model is covered and no record names a model absent
2463
+ * from the slices.
2445
2464
  */
2446
2465
  function priceEntryBilling(entry, priceUsd) {
2447
2466
  const slices = entryUsageSlices(entry);
2448
2467
  const records = entry.providerCalls ?? [];
2449
- if (!callsCoverSlices(slices, records)) {
2450
- const aggregate = priceEntryUsage(entry, priceUsd);
2451
- return {
2452
- units: aggregate.priced.map((slice) => ({
2453
- source: "slice",
2454
- servedBy: slice.servedBy,
2455
- usage: slice.usage,
2456
- ...slice.role === void 0 ? {} : { role: slice.role },
2457
- usd: slice.usd
2458
- })),
2459
- usd: aggregate.usd,
2460
- unpriced: aggregate.unpriced,
2461
- fullyAttributed: false
2462
- };
2463
- }
2468
+ const covered = coveredModels(slices, records);
2464
2469
  const units = [];
2465
2470
  const unpricedByModel = /* @__PURE__ */ new Map();
2466
2471
  let usd = 0;
2467
2472
  for (const record of records) {
2473
+ if (!covered.has(record.servedBy)) continue;
2468
2474
  const price = priceUsd(record.servedBy, record.usage, entry.seq);
2469
2475
  if (price === void 0 || !Number.isFinite(price) || price < 0) {
2470
2476
  const sum = unpricedByModel.get(record.servedBy) ?? {
@@ -2489,6 +2495,32 @@ function priceEntryBilling(entry, priceUsd) {
2489
2495
  usd: price
2490
2496
  });
2491
2497
  }
2498
+ for (const slice of slices) {
2499
+ if (covered.has(slice.servedBy)) continue;
2500
+ const price = priceUsd(slice.servedBy, slice.usage, entry.seq);
2501
+ if (price === void 0 || !Number.isFinite(price) || price < 0) {
2502
+ const sum = unpricedByModel.get(slice.servedBy) ?? {
2503
+ inputTokens: 0,
2504
+ outputTokens: 0,
2505
+ cacheReadTokens: 0,
2506
+ cacheWriteTokens: 0
2507
+ };
2508
+ for (const field of BILLING_FIELDS) sum[field] += slice.usage[field];
2509
+ const reasoning = (sum.reasoningTokens ?? 0) + (slice.usage.reasoningTokens ?? 0);
2510
+ if (reasoning > 0) sum.reasoningTokens = reasoning;
2511
+ unpricedByModel.set(slice.servedBy, sum);
2512
+ continue;
2513
+ }
2514
+ usd += price;
2515
+ units.push({
2516
+ source: "slice",
2517
+ servedBy: slice.servedBy,
2518
+ usage: slice.usage,
2519
+ ...slice.role === void 0 ? {} : { role: slice.role },
2520
+ usd: price
2521
+ });
2522
+ }
2523
+ const fullyAttributed = slices.length > 0 && slices.every((slice) => covered.has(slice.servedBy)) && records.every((record) => slices.some((slice) => slice.servedBy === record.servedBy));
2492
2524
  return {
2493
2525
  units,
2494
2526
  usd,
@@ -2496,7 +2528,7 @@ function priceEntryBilling(entry, priceUsd) {
2496
2528
  servedBy,
2497
2529
  usage
2498
2530
  })),
2499
- fullyAttributed: true
2531
+ fullyAttributed
2500
2532
  };
2501
2533
  }
2502
2534
  /**
@@ -8404,16 +8436,29 @@ const USAGE_FIELDS = [
8404
8436
  "cacheReadTokens",
8405
8437
  "cacheWriteTokens"
8406
8438
  ];
8407
- /** entry.usage minus the records' sum, clamped at zero per field. */
8408
- function usageRemainder(total, records) {
8439
+ /**
8440
+ * One usage slice minus ITS OWN records' sum, clamped at zero per field
8441
+ * (RV605). A record belongs to a slice when it names the same serving
8442
+ * model, and the same role when the slice carries one; a slice written
8443
+ * without a role (the legacy whole-entry fallback) absorbs every record
8444
+ * of its model, which is exactly the pre-RV605 arithmetic for
8445
+ * single-model entries. Computing the remainder per slice instead of
8446
+ * per entry is what keeps an orphaned model's spend on a row of that
8447
+ * model: the whole-entry remainder was published under `entry.servedBy`,
8448
+ * so a slice with no records left its allocation pool rowless and the
8449
+ * dust pass moved its dollars onto another model's row.
8450
+ */
8451
+ function sliceRemainder(slice, records) {
8409
8452
  const remainder = {
8410
- inputTokens: total.inputTokens,
8411
- outputTokens: total.outputTokens,
8412
- cacheReadTokens: total.cacheReadTokens,
8413
- cacheWriteTokens: total.cacheWriteTokens
8453
+ inputTokens: slice.usage.inputTokens,
8454
+ outputTokens: slice.usage.outputTokens,
8455
+ cacheReadTokens: slice.usage.cacheReadTokens,
8456
+ cacheWriteTokens: slice.usage.cacheWriteTokens
8414
8457
  };
8415
- let reasoning = total.reasoningTokens ?? 0;
8458
+ let reasoning = slice.usage.reasoningTokens ?? 0;
8416
8459
  for (const record of records) {
8460
+ if (record.servedBy !== slice.servedBy) continue;
8461
+ if (slice.role !== void 0 && record.role !== slice.role) continue;
8417
8462
  for (const field of USAGE_FIELDS) remainder[field] = Math.max(0, remainder[field] - record.usage[field]);
8418
8463
  reasoning = Math.max(0, reasoning - (record.usage.reasoningTokens ?? 0));
8419
8464
  }
@@ -8437,9 +8482,16 @@ function totalTokens(usage) {
8437
8482
  * association so the flat sum over `rows` reproduces `totalUsd`
8438
8483
  * exactly. Rows on unpriced models keep zero: their spend is in
8439
8484
  * `unpriced`, not in `totalUsd`.
8485
+ *
8486
+ * A pool with a target and NO rows refuses the transfer (RV605): its
8487
+ * dollars are excluded from the dust reconciliation and returned as the
8488
+ * unallocated share, because dumping them on the globally largest row
8489
+ * moved one model's spend onto another model's line just to make the
8490
+ * column sum. The returned amount is zero on every well-formed journal
8491
+ * (the per-slice remainder rows above guarantee a row wherever a slice
8492
+ * has usage), so the flat sum still reproduces `totalUsd` exactly there.
8440
8493
  */
8441
8494
  function allocateRows(rows, entries, priceUsd, totalUsd) {
8442
- if (rows.length === 0) return;
8443
8495
  const targets = /* @__PURE__ */ new Map();
8444
8496
  for (const entry of entries) {
8445
8497
  if (entry.status === "running" || entry.usage === void 0) continue;
@@ -8455,6 +8507,9 @@ function allocateRows(rows, entries, priceUsd, totalUsd) {
8455
8507
  if (pool === void 0) pools.set(key, [row]);
8456
8508
  else pool.push(row);
8457
8509
  }
8510
+ let unallocated = 0;
8511
+ for (const [key, target] of targets) if (target !== 0 && !pools.has(key)) unallocated += target;
8512
+ if (rows.length === 0) return unallocated;
8458
8513
  for (const [key, members] of pools) {
8459
8514
  const target = targets.get(key) ?? 0;
8460
8515
  if (target === 0) continue;
@@ -8471,12 +8526,14 @@ function allocateRows(rows, entries, priceUsd, totalUsd) {
8471
8526
  }
8472
8527
  let absorber;
8473
8528
  for (const row of rows) if (absorber === void 0 || row.allocatedUsd > absorber.allocatedUsd) absorber = row;
8474
- if (absorber === void 0) return;
8529
+ if (absorber === void 0) return unallocated;
8530
+ const goal = totalUsd - unallocated;
8475
8531
  for (let pass = 0; pass < 8; pass += 1) {
8476
8532
  const flat = rows.reduce((acc, row) => acc + row.allocatedUsd, 0);
8477
- if (flat === totalUsd) break;
8478
- absorber.allocatedUsd += totalUsd - flat;
8533
+ if (flat === goal) break;
8534
+ absorber.allocatedUsd += goal - flat;
8479
8535
  }
8536
+ return unallocated;
8480
8537
  }
8481
8538
  /** A single row priced at its own model's rate; broken rates fold as unpriced. */
8482
8539
  function rowUsd(priceUsd, servedBy, usage, seq) {
@@ -8548,13 +8605,16 @@ function invoiceFromJournal(entries, priceUsd, options) {
8548
8605
  });
8549
8606
  continue;
8550
8607
  }
8551
- const remainder = usageRemainder(entry.usage, records);
8552
- if (remainder !== void 0 && entry.servedBy !== void 0) {
8553
- const usd = rowUsd(priceUsd, entry.servedBy, remainder, entry.seq);
8608
+ let remainderOrdinal = records.length + 1;
8609
+ for (const slice of entryUsageSlices(entry)) {
8610
+ const remainder = sliceRemainder(slice, records);
8611
+ if (remainder === void 0) continue;
8612
+ const usd = rowUsd(priceUsd, slice.servedBy, remainder, entry.seq);
8554
8613
  rows.push({
8555
8614
  ...base,
8556
- ordinal: records.length + 1,
8557
- servedBy: entry.servedBy,
8615
+ ordinal: remainderOrdinal,
8616
+ servedBy: slice.servedBy,
8617
+ ...slice.role === void 0 ? {} : { role: slice.role },
8558
8618
  outcome: "unattributed",
8559
8619
  usage: remainder,
8560
8620
  ...entry.usageApprox === true ? { usageApprox: true } : {},
@@ -8563,9 +8623,10 @@ function invoiceFromJournal(entries, priceUsd, options) {
8563
8623
  ...mark,
8564
8624
  reconciliation: "unattributed"
8565
8625
  });
8626
+ remainderOrdinal += 1;
8566
8627
  }
8567
8628
  }
8568
- allocateRows(rows, entries, priceUsd, report.grossUsd);
8629
+ const unallocatedUsd = allocateRows(rows, entries, priceUsd, report.grossUsd);
8569
8630
  const usageApprox = report.usageApprox === true || report.abandoned.usageApprox === true;
8570
8631
  return {
8571
8632
  rows,
@@ -8574,6 +8635,7 @@ function invoiceFromJournal(entries, priceUsd, options) {
8574
8635
  abandonedUsd: report.abandoned.usd,
8575
8636
  pricingBasis: "per-call",
8576
8637
  rowUsdNonAdditive: !everyEntryFullyAttributed,
8638
+ ...unallocatedUsd === 0 ? {} : { unallocatedUsd },
8577
8639
  unpriced: [...report.unpriced, ...report.abandoned.unpriced],
8578
8640
  reconciliationFailures: rows.filter((row) => row.reconciliation !== "provider-id-present").length,
8579
8641
  ...(() => {
@@ -10927,7 +10989,17 @@ async function runAgent(options) {
10927
10989
  let extensionGrants = 0;
10928
10990
  let extensionEvidenceAtLastGrant = 0;
10929
10991
  const pendingExtensionNotices = [];
10930
- const effectiveMaxToolCalls = () => limits.maxToolCalls === void 0 ? void 0 : extension === void 0 ? limits.maxToolCalls : limits.maxToolCalls + extensionGrants * extension.increment;
10992
+ /**
10993
+ * The ceiling the effective cap is measured from (RV602). The base cap
10994
+ * normally, so a fresh loop is byte-identical arithmetic; the journaled
10995
+ * cap of the restored grant when a resume carried one, so drifting live
10996
+ * limits cannot revoke a raise the journal already recorded. Grants
10997
+ * taken after the restore point count from here at the CURRENT
10998
+ * increment, which is the live policy doing what it should.
10999
+ */
11000
+ let capBase = limits.maxToolCalls;
11001
+ let grantsOverCapBase = 0;
11002
+ const effectiveMaxToolCalls = () => capBase === void 0 ? void 0 : extension === void 0 ? capBase : capBase + grantsOverCapBase * extension.increment;
10931
11003
  /** The limiter that ended the loop; rides the RV304 pressure snapshot. */
10932
11004
  let limitLimiter;
10933
11005
  /** True once the finalization reserve summary turn actually ran. */
@@ -10979,7 +11051,7 @@ async function runAgent(options) {
10979
11051
  * join the history.
10980
11052
  */
10981
11053
  const tryToolBudgetGrant = () => {
10982
- if (extension === void 0 || limits.maxToolCalls === void 0) return false;
11054
+ if (extension === void 0 || limits.maxToolCalls === void 0 || capBase === void 0) return false;
10983
11055
  if (extensionGrants >= extension.maxExtensions) return false;
10984
11056
  if (extension.requireNewEvidence !== false) {
10985
11057
  if ((guard?.evidenceCount() ?? 0) <= extensionEvidenceAtLastGrant) return false;
@@ -10989,22 +11061,28 @@ async function runAgent(options) {
10989
11061
  const floor = extension.minHeadroomUsd ?? 0;
10990
11062
  if (floor > 0 ? remaining < floor : remaining <= 0) return false;
10991
11063
  }
10992
- extensionGrants += 1;
10993
- extensionEvidenceAtLastGrant = guard?.evidenceCount() ?? 0;
10994
- const cap = limits.maxToolCalls + extensionGrants * extension.increment;
10995
- options.toolBudgetDurability?.onExtensionGrant?.({
10996
- grant: extensionGrants,
11064
+ const grant = extensionGrants + 1;
11065
+ const cap = capBase + (grantsOverCapBase + 1) * extension.increment;
11066
+ const commit = () => {
11067
+ extensionGrants = grant;
11068
+ grantsOverCapBase += 1;
11069
+ extensionEvidenceAtLastGrant = guard?.evidenceCount() ?? 0;
11070
+ pendingExtensionNotices.push(toolBudgetExtensionNoticeText(grant, extension.maxExtensions, toolCallsUsed, cap));
11071
+ events?.emit({
11072
+ type: "log",
11073
+ level: "info",
11074
+ msg: `tool budget extended (grant ${String(grant)}/${String(extension.maxExtensions)}): maxToolCalls now ${String(cap)}`
11075
+ });
11076
+ return true;
11077
+ };
11078
+ const durable = options.toolBudgetDurability?.onExtensionGrant;
11079
+ if (durable === void 0) return commit();
11080
+ return durable({
11081
+ grant,
10997
11082
  maxExtensions: extension.maxExtensions,
10998
11083
  toolCallsUsed,
10999
11084
  cap
11000
- });
11001
- pendingExtensionNotices.push(toolBudgetExtensionNoticeText(extensionGrants, extension.maxExtensions, toolCallsUsed, cap));
11002
- events?.emit({
11003
- type: "log",
11004
- level: "info",
11005
- msg: `tool budget extended (grant ${String(extensionGrants)}/${String(extension.maxExtensions)}): maxToolCalls now ${String(cap)}`
11006
- });
11007
- return true;
11085
+ }).then(commit);
11008
11086
  };
11009
11087
  const flushExtensionNotices = () => {
11010
11088
  for (const text of pendingExtensionNotices.splice(0)) messages.push({
@@ -11058,19 +11136,26 @@ async function runAgent(options) {
11058
11136
  if (finalizationWindow === void 0 || windowNoticeFired) return;
11059
11137
  const state = windowActive();
11060
11138
  if (state === void 0) return;
11061
- windowEntered = true;
11062
- windowNoticeFired = true;
11063
- options.toolBudgetDurability?.onWindowEntry?.({
11139
+ const commit = () => {
11140
+ windowEntered = true;
11141
+ windowNoticeFired = true;
11142
+ pendingWindowNotices.push(finalizationWindowNoticeText(state.remaining, finalizationWindow.reserveCalls, state.budget));
11143
+ events?.emit({
11144
+ type: "log",
11145
+ level: "info",
11146
+ msg: `finalization window entered: ${String(state.remaining)} of the reserved final ${String(finalizationWindow.reserveCalls)} ${state.budget} remain`
11147
+ });
11148
+ };
11149
+ const durable = options.toolBudgetDurability?.onWindowEntry;
11150
+ if (durable === void 0) {
11151
+ commit();
11152
+ return;
11153
+ }
11154
+ return durable({
11064
11155
  remaining: state.remaining,
11065
11156
  reserveCalls: finalizationWindow.reserveCalls,
11066
11157
  budget: state.budget
11067
- });
11068
- pendingWindowNotices.push(finalizationWindowNoticeText(state.remaining, finalizationWindow.reserveCalls, state.budget));
11069
- events?.emit({
11070
- type: "log",
11071
- level: "info",
11072
- msg: `finalization window entered: ${String(state.remaining)} of the reserved final ${String(finalizationWindow.reserveCalls)} ${state.budget} remain`
11073
- });
11158
+ }).then(commit);
11074
11159
  };
11075
11160
  const flushWindowNotices = () => {
11076
11161
  for (const text of pendingWindowNotices.splice(0)) messages.push({
@@ -11127,6 +11212,7 @@ async function runAgent(options) {
11127
11212
  extensionGrants = Math.min(extension.maxExtensions, Math.ceil((toolCallsUsed - limits.maxToolCalls) / extension.increment));
11128
11213
  extensionEvidenceAtLastGrant = guard?.evidenceCount() ?? 0;
11129
11214
  }
11215
+ const derivedCap = limits.maxToolCalls === void 0 || extension === void 0 ? void 0 : limits.maxToolCalls + extensionGrants * extension.increment;
11130
11216
  const durableRestored = options.toolBudgetDurability?.restored;
11131
11217
  if (extension !== void 0 && durableRestored !== void 0) {
11132
11218
  const journaled = Math.min(extension.maxExtensions, durableRestored.extensionsGranted);
@@ -11135,6 +11221,16 @@ async function runAgent(options) {
11135
11221
  extensionEvidenceAtLastGrant = guard?.evidenceCount() ?? 0;
11136
11222
  }
11137
11223
  }
11224
+ grantsOverCapBase = extensionGrants;
11225
+ const journaledCap = durableRestored?.cap;
11226
+ if (journaledCap !== void 0 && limits.maxToolCalls !== void 0 && derivedCap !== void 0) if (Number.isSafeInteger(journaledCap) && journaledCap >= limits.maxToolCalls) {
11227
+ capBase = Math.max(journaledCap, derivedCap);
11228
+ grantsOverCapBase = 0;
11229
+ } else events?.emit({
11230
+ type: "log",
11231
+ level: "warn",
11232
+ msg: `restored tool budget cap ${String(journaledCap)} is not an integer at or above the base cap ${String(limits.maxToolCalls)}; ignoring it and deriving from the counts`
11233
+ });
11138
11234
  if (windowActive() !== void 0 || finalizationWindow !== void 0 && durableRestored?.finalizationWindowEntered === true) {
11139
11235
  windowEntered = true;
11140
11236
  windowNoticeFired = true;
@@ -11231,7 +11327,10 @@ async function runAgent(options) {
11231
11327
  return cap !== void 0 && toolCallsUsed >= cap ? "maxToolCalls" : guard !== void 0 && guard.unitsExhausted() ? "toolUnits" : void 0;
11232
11328
  };
11233
11329
  let expiredLimiter = expiryOf();
11234
- if (expiredLimiter === "maxToolCalls" && call.name !== options.terminalTool?.name && tryToolBudgetGrant()) expiredLimiter = expiryOf();
11330
+ if (expiredLimiter === "maxToolCalls" && call.name !== options.terminalTool?.name) {
11331
+ const attempt = tryToolBudgetGrant();
11332
+ if (typeof attempt === "boolean" ? attempt : await attempt) expiredLimiter = expiryOf();
11333
+ }
11235
11334
  if (expiredLimiter !== void 0) {
11236
11335
  const tail = calls.slice(index);
11237
11336
  const terminalName = options.terminalTool?.name;
@@ -11419,10 +11518,14 @@ async function runAgent(options) {
11419
11518
  };
11420
11519
  }
11421
11520
  if (finalizationWindow !== void 0) {
11422
- maybeMarkWindowEntry();
11521
+ const entry = maybeMarkWindowEntry();
11522
+ if (entry !== void 0) await entry;
11423
11523
  let windowState = windowActive();
11424
11524
  if (windowState !== void 0 && !windowAllows(gatedCall.name)) {
11425
- if (windowState.budget === "tool calls" && extension !== void 0 && windowState.remaining + extension.increment > finalizationWindow.reserveCalls && tryToolBudgetGrant()) windowState = windowActive();
11525
+ if (windowState.budget === "tool calls" && extension !== void 0 && windowState.remaining + extension.increment > finalizationWindow.reserveCalls) {
11526
+ const attempt = tryToolBudgetGrant();
11527
+ if (typeof attempt === "boolean" ? attempt : await attempt) windowState = windowActive();
11528
+ }
11426
11529
  if (windowState !== void 0 && !windowAllows(gatedCall.name)) {
11427
11530
  events?.emit({
11428
11531
  type: "tool:end",
@@ -11492,7 +11595,8 @@ async function runAgent(options) {
11492
11595
  });
11493
11596
  }
11494
11597
  }
11495
- maybeMarkWindowEntry();
11598
+ const lastEntry = maybeMarkWindowEntry();
11599
+ if (lastEntry !== void 0) await lastEntry;
11496
11600
  return {
11497
11601
  parts,
11498
11602
  limitHit: false
@@ -14847,10 +14951,11 @@ function createCtx(internals, rootWorkflow) {
14847
14951
  runAgentOptions.toolBudgetDurability = {
14848
14952
  ...durableRestored === void 0 ? {} : { restored: {
14849
14953
  extensionsGranted: durableRestored.extensionsGranted,
14850
- finalizationWindowEntered: durableRestored.finalizationWindowEntered
14954
+ finalizationWindowEntered: durableRestored.finalizationWindowEntered,
14955
+ ...durableRestored.cap === void 0 ? {} : { cap: durableRestored.cap }
14851
14956
  } },
14852
- onExtensionGrant: (grant) => {
14853
- internals.replayer.appendSinglePhase({
14957
+ onExtensionGrant: async (grant) => {
14958
+ await internals.replayer.appendSinglePhase({
14854
14959
  scope: state.scope,
14855
14960
  key: "",
14856
14961
  kind: "decision",
@@ -14863,8 +14968,8 @@ function createCtx(internals, rootWorkflow) {
14863
14968
  }
14864
14969
  });
14865
14970
  },
14866
- onWindowEntry: (entry) => {
14867
- internals.replayer.appendSinglePhase({
14971
+ onWindowEntry: async (entry) => {
14972
+ await internals.replayer.appendSinglePhase({
14868
14973
  scope: state.scope,
14869
14974
  key: "",
14870
14975
  kind: "decision",
@@ -18431,13 +18536,30 @@ function makeOrchestratorWorkflow(goal, opts) {
18431
18536
  }
18432
18537
  }, callingState.spanId);
18433
18538
  };
18434
- const prior = internals.replayer.snapshot().find((entry) => entry.kind === "decision" && entry.scope === callingState.scope && entry.key === skipKey);
18435
- if (prior !== void 0) {
18539
+ const draftValue = draft ?? null;
18540
+ const draftHash = createHash("sha256").update(jcsSerialize(draftValue), "utf8").digest("hex");
18541
+ const validatorNames = validationSpec.validators.map((validator) => validator.name);
18542
+ /**
18543
+ * A journaled skip is the authority only for the generation and
18544
+ * the draft it judged (RV603). The documented remedy for a
18545
+ * broken contract is to fix it and resume, and a verdict that
18546
+ * outlives its contract defeats exactly that: the run would
18547
+ * settle ok carrying output the CURRENT contract rejects.
18548
+ * Bound by three facts, in descending strength: the contract
18549
+ * identity (the same `contractGenerationCurrent` test the
18550
+ * finish-validation decisions already use), the draft the
18551
+ * verdict actually judged, and the validator names that
18552
+ * rendered it. Entries written before this field existed carry
18553
+ * no draftHash and stay reusable, so journals in flight roll
18554
+ * forward byte for byte.
18555
+ */
18556
+ const applies = (value) => contractGenerationCurrent(value) && (value.draftHash === void 0 || value.draftHash === draftHash) && (!Array.isArray(value.validators) || JSON.stringify(value.validators) === JSON.stringify(validatorNames));
18557
+ const prior = internals.replayer.snapshot().filter((entry) => entry.kind === "decision" && entry.scope === callingState.scope && entry.key === skipKey).at(-1);
18558
+ if (prior !== void 0 && applies(prior.value)) {
18436
18559
  synthesisSkippedByValidDraft = true;
18437
18560
  announceSkip(prior.seq);
18438
18561
  return draft;
18439
18562
  }
18440
- const draftValue = draft ?? null;
18441
18563
  const input = {
18442
18564
  result: draftValue,
18443
18565
  text: typeof draftValue === "string" ? draftValue : JSON.stringify(draftValue),
@@ -18467,7 +18589,9 @@ function makeOrchestratorWorkflow(goal, opts) {
18467
18589
  value: {
18468
18590
  decisionType: "orchestrator_synthesis_skip",
18469
18591
  reason: "synthesis_skipped_by_valid_draft",
18470
- validators: validationSpec.validators.map((validator) => validator.name)
18592
+ validators: validatorNames,
18593
+ ...validationSpec.contract === void 0 ? {} : { contractHash: validationSpec.contract.hash },
18594
+ draftHash
18471
18595
  }
18472
18596
  });
18473
18597
  if (orchestratorAccount !== void 0 && (opts?.budget?.synthesisReserveUsd ?? 0) > 0) internals.budget.releaseSynthesisReserve(orchestratorAccount);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/core",
3
- "version": "1.101.0",
3
+ "version": "1.103.0",
4
4
  "description": "Rulvar core: L0 contracts, journal kernel, ctx primitives, agent runtime, model router, tool system, dynamic orchestrator, InMemory and JSONL stores, event stream.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",