@rulvar/core 1.101.0 → 1.102.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
@@ -4719,26 +4719,47 @@ interface RunAgentOptions<S extends SchemaSpec = JsonSchema> {
4719
4719
  * already promised the raised cap), never re-admitted or re-announced,
4720
4720
  * and a restored window entry keeps the summary's
4721
4721
  * 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.
4722
+ * counts back out of the window.
4723
+ *
4724
+ * Both hooks are AWAITED before the thing they authorize becomes
4725
+ * observable (RV601): a grant lifts no expiry and queues no notice
4726
+ * until its decision is durable, and the window regime binds no call
4727
+ * until its entry is. A rejected append therefore leaves the grant
4728
+ * unissued and the entry unrecorded, and the rejection propagates
4729
+ * exactly like a failed boundary checkpoint rather than being
4730
+ * swallowed. Pressure notices stay events and are never journaled.
4731
+ * Absent, the loop is byte-identical to before.
4725
4732
  */
4726
4733
  toolBudgetDurability?: {
4727
4734
  restored?: {
4728
4735
  extensionsGranted: number;
4729
4736
  finalizationWindowEntered: boolean;
4737
+ /**
4738
+ * The effective cap the journaled grant announced (RV602). It
4739
+ * anchors the resumed ceiling, because the live `maxToolCalls`
4740
+ * and `increment` are not part of the dispatch identity and may
4741
+ * legitimately drift between segments: without the anchor the two
4742
+ * recovery paths (pure replay, which reads the journal, and live
4743
+ * resume, which recomputed) disagreed, and a promise already made
4744
+ * to the model could be silently revoked. Validated as a
4745
+ * persistent inlet: a non-integer, or one below the base cap, is
4746
+ * ignored with a warning, leaving the count derivation as the
4747
+ * floor. Grants taken AFTER the restore point still measure the
4748
+ * current increment from this anchor.
4749
+ */
4750
+ cap?: number;
4730
4751
  };
4731
4752
  onExtensionGrant?: (grant: {
4732
4753
  grant: number;
4733
4754
  maxExtensions: number;
4734
4755
  toolCallsUsed: number;
4735
4756
  cap: number;
4736
- }) => void;
4757
+ }) => Promise<void>;
4737
4758
  onWindowEntry?: (entry: {
4738
4759
  remaining: number;
4739
4760
  reserveCalls: number;
4740
4761
  budget: FinalizationWindowBudget;
4741
- }) => void;
4762
+ }) => Promise<void>;
4742
4763
  };
4743
4764
  /** Emits agent:stream deltas when true (telemetry only). */
4744
4765
  stream?: boolean;
package/dist/index.js CHANGED
@@ -10927,7 +10927,17 @@ async function runAgent(options) {
10927
10927
  let extensionGrants = 0;
10928
10928
  let extensionEvidenceAtLastGrant = 0;
10929
10929
  const pendingExtensionNotices = [];
10930
- const effectiveMaxToolCalls = () => limits.maxToolCalls === void 0 ? void 0 : extension === void 0 ? limits.maxToolCalls : limits.maxToolCalls + extensionGrants * extension.increment;
10930
+ /**
10931
+ * The ceiling the effective cap is measured from (RV602). The base cap
10932
+ * normally, so a fresh loop is byte-identical arithmetic; the journaled
10933
+ * cap of the restored grant when a resume carried one, so drifting live
10934
+ * limits cannot revoke a raise the journal already recorded. Grants
10935
+ * taken after the restore point count from here at the CURRENT
10936
+ * increment, which is the live policy doing what it should.
10937
+ */
10938
+ let capBase = limits.maxToolCalls;
10939
+ let grantsOverCapBase = 0;
10940
+ const effectiveMaxToolCalls = () => capBase === void 0 ? void 0 : extension === void 0 ? capBase : capBase + grantsOverCapBase * extension.increment;
10931
10941
  /** The limiter that ended the loop; rides the RV304 pressure snapshot. */
10932
10942
  let limitLimiter;
10933
10943
  /** True once the finalization reserve summary turn actually ran. */
@@ -10979,7 +10989,7 @@ async function runAgent(options) {
10979
10989
  * join the history.
10980
10990
  */
10981
10991
  const tryToolBudgetGrant = () => {
10982
- if (extension === void 0 || limits.maxToolCalls === void 0) return false;
10992
+ if (extension === void 0 || limits.maxToolCalls === void 0 || capBase === void 0) return false;
10983
10993
  if (extensionGrants >= extension.maxExtensions) return false;
10984
10994
  if (extension.requireNewEvidence !== false) {
10985
10995
  if ((guard?.evidenceCount() ?? 0) <= extensionEvidenceAtLastGrant) return false;
@@ -10989,22 +10999,28 @@ async function runAgent(options) {
10989
10999
  const floor = extension.minHeadroomUsd ?? 0;
10990
11000
  if (floor > 0 ? remaining < floor : remaining <= 0) return false;
10991
11001
  }
10992
- extensionGrants += 1;
10993
- extensionEvidenceAtLastGrant = guard?.evidenceCount() ?? 0;
10994
- const cap = limits.maxToolCalls + extensionGrants * extension.increment;
10995
- options.toolBudgetDurability?.onExtensionGrant?.({
10996
- grant: extensionGrants,
11002
+ const grant = extensionGrants + 1;
11003
+ const cap = capBase + (grantsOverCapBase + 1) * extension.increment;
11004
+ const commit = () => {
11005
+ extensionGrants = grant;
11006
+ grantsOverCapBase += 1;
11007
+ extensionEvidenceAtLastGrant = guard?.evidenceCount() ?? 0;
11008
+ pendingExtensionNotices.push(toolBudgetExtensionNoticeText(grant, extension.maxExtensions, toolCallsUsed, cap));
11009
+ events?.emit({
11010
+ type: "log",
11011
+ level: "info",
11012
+ msg: `tool budget extended (grant ${String(grant)}/${String(extension.maxExtensions)}): maxToolCalls now ${String(cap)}`
11013
+ });
11014
+ return true;
11015
+ };
11016
+ const durable = options.toolBudgetDurability?.onExtensionGrant;
11017
+ if (durable === void 0) return commit();
11018
+ return durable({
11019
+ grant,
10997
11020
  maxExtensions: extension.maxExtensions,
10998
11021
  toolCallsUsed,
10999
11022
  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;
11023
+ }).then(commit);
11008
11024
  };
11009
11025
  const flushExtensionNotices = () => {
11010
11026
  for (const text of pendingExtensionNotices.splice(0)) messages.push({
@@ -11058,19 +11074,26 @@ async function runAgent(options) {
11058
11074
  if (finalizationWindow === void 0 || windowNoticeFired) return;
11059
11075
  const state = windowActive();
11060
11076
  if (state === void 0) return;
11061
- windowEntered = true;
11062
- windowNoticeFired = true;
11063
- options.toolBudgetDurability?.onWindowEntry?.({
11077
+ const commit = () => {
11078
+ windowEntered = true;
11079
+ windowNoticeFired = true;
11080
+ pendingWindowNotices.push(finalizationWindowNoticeText(state.remaining, finalizationWindow.reserveCalls, state.budget));
11081
+ events?.emit({
11082
+ type: "log",
11083
+ level: "info",
11084
+ msg: `finalization window entered: ${String(state.remaining)} of the reserved final ${String(finalizationWindow.reserveCalls)} ${state.budget} remain`
11085
+ });
11086
+ };
11087
+ const durable = options.toolBudgetDurability?.onWindowEntry;
11088
+ if (durable === void 0) {
11089
+ commit();
11090
+ return;
11091
+ }
11092
+ return durable({
11064
11093
  remaining: state.remaining,
11065
11094
  reserveCalls: finalizationWindow.reserveCalls,
11066
11095
  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
- });
11096
+ }).then(commit);
11074
11097
  };
11075
11098
  const flushWindowNotices = () => {
11076
11099
  for (const text of pendingWindowNotices.splice(0)) messages.push({
@@ -11127,6 +11150,7 @@ async function runAgent(options) {
11127
11150
  extensionGrants = Math.min(extension.maxExtensions, Math.ceil((toolCallsUsed - limits.maxToolCalls) / extension.increment));
11128
11151
  extensionEvidenceAtLastGrant = guard?.evidenceCount() ?? 0;
11129
11152
  }
11153
+ const derivedCap = limits.maxToolCalls === void 0 || extension === void 0 ? void 0 : limits.maxToolCalls + extensionGrants * extension.increment;
11130
11154
  const durableRestored = options.toolBudgetDurability?.restored;
11131
11155
  if (extension !== void 0 && durableRestored !== void 0) {
11132
11156
  const journaled = Math.min(extension.maxExtensions, durableRestored.extensionsGranted);
@@ -11135,6 +11159,16 @@ async function runAgent(options) {
11135
11159
  extensionEvidenceAtLastGrant = guard?.evidenceCount() ?? 0;
11136
11160
  }
11137
11161
  }
11162
+ grantsOverCapBase = extensionGrants;
11163
+ const journaledCap = durableRestored?.cap;
11164
+ if (journaledCap !== void 0 && limits.maxToolCalls !== void 0 && derivedCap !== void 0) if (Number.isSafeInteger(journaledCap) && journaledCap >= limits.maxToolCalls) {
11165
+ capBase = Math.max(journaledCap, derivedCap);
11166
+ grantsOverCapBase = 0;
11167
+ } else events?.emit({
11168
+ type: "log",
11169
+ level: "warn",
11170
+ 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`
11171
+ });
11138
11172
  if (windowActive() !== void 0 || finalizationWindow !== void 0 && durableRestored?.finalizationWindowEntered === true) {
11139
11173
  windowEntered = true;
11140
11174
  windowNoticeFired = true;
@@ -11231,7 +11265,10 @@ async function runAgent(options) {
11231
11265
  return cap !== void 0 && toolCallsUsed >= cap ? "maxToolCalls" : guard !== void 0 && guard.unitsExhausted() ? "toolUnits" : void 0;
11232
11266
  };
11233
11267
  let expiredLimiter = expiryOf();
11234
- if (expiredLimiter === "maxToolCalls" && call.name !== options.terminalTool?.name && tryToolBudgetGrant()) expiredLimiter = expiryOf();
11268
+ if (expiredLimiter === "maxToolCalls" && call.name !== options.terminalTool?.name) {
11269
+ const attempt = tryToolBudgetGrant();
11270
+ if (typeof attempt === "boolean" ? attempt : await attempt) expiredLimiter = expiryOf();
11271
+ }
11235
11272
  if (expiredLimiter !== void 0) {
11236
11273
  const tail = calls.slice(index);
11237
11274
  const terminalName = options.terminalTool?.name;
@@ -11419,10 +11456,14 @@ async function runAgent(options) {
11419
11456
  };
11420
11457
  }
11421
11458
  if (finalizationWindow !== void 0) {
11422
- maybeMarkWindowEntry();
11459
+ const entry = maybeMarkWindowEntry();
11460
+ if (entry !== void 0) await entry;
11423
11461
  let windowState = windowActive();
11424
11462
  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();
11463
+ if (windowState.budget === "tool calls" && extension !== void 0 && windowState.remaining + extension.increment > finalizationWindow.reserveCalls) {
11464
+ const attempt = tryToolBudgetGrant();
11465
+ if (typeof attempt === "boolean" ? attempt : await attempt) windowState = windowActive();
11466
+ }
11426
11467
  if (windowState !== void 0 && !windowAllows(gatedCall.name)) {
11427
11468
  events?.emit({
11428
11469
  type: "tool:end",
@@ -11492,7 +11533,8 @@ async function runAgent(options) {
11492
11533
  });
11493
11534
  }
11494
11535
  }
11495
- maybeMarkWindowEntry();
11536
+ const lastEntry = maybeMarkWindowEntry();
11537
+ if (lastEntry !== void 0) await lastEntry;
11496
11538
  return {
11497
11539
  parts,
11498
11540
  limitHit: false
@@ -14847,10 +14889,11 @@ function createCtx(internals, rootWorkflow) {
14847
14889
  runAgentOptions.toolBudgetDurability = {
14848
14890
  ...durableRestored === void 0 ? {} : { restored: {
14849
14891
  extensionsGranted: durableRestored.extensionsGranted,
14850
- finalizationWindowEntered: durableRestored.finalizationWindowEntered
14892
+ finalizationWindowEntered: durableRestored.finalizationWindowEntered,
14893
+ ...durableRestored.cap === void 0 ? {} : { cap: durableRestored.cap }
14851
14894
  } },
14852
- onExtensionGrant: (grant) => {
14853
- internals.replayer.appendSinglePhase({
14895
+ onExtensionGrant: async (grant) => {
14896
+ await internals.replayer.appendSinglePhase({
14854
14897
  scope: state.scope,
14855
14898
  key: "",
14856
14899
  kind: "decision",
@@ -14863,8 +14906,8 @@ function createCtx(internals, rootWorkflow) {
14863
14906
  }
14864
14907
  });
14865
14908
  },
14866
- onWindowEntry: (entry) => {
14867
- internals.replayer.appendSinglePhase({
14909
+ onWindowEntry: async (entry) => {
14910
+ await internals.replayer.appendSinglePhase({
14868
14911
  scope: state.scope,
14869
14912
  key: "",
14870
14913
  kind: "decision",
@@ -18431,13 +18474,30 @@ function makeOrchestratorWorkflow(goal, opts) {
18431
18474
  }
18432
18475
  }, callingState.spanId);
18433
18476
  };
18434
- const prior = internals.replayer.snapshot().find((entry) => entry.kind === "decision" && entry.scope === callingState.scope && entry.key === skipKey);
18435
- if (prior !== void 0) {
18477
+ const draftValue = draft ?? null;
18478
+ const draftHash = createHash("sha256").update(jcsSerialize(draftValue), "utf8").digest("hex");
18479
+ const validatorNames = validationSpec.validators.map((validator) => validator.name);
18480
+ /**
18481
+ * A journaled skip is the authority only for the generation and
18482
+ * the draft it judged (RV603). The documented remedy for a
18483
+ * broken contract is to fix it and resume, and a verdict that
18484
+ * outlives its contract defeats exactly that: the run would
18485
+ * settle ok carrying output the CURRENT contract rejects.
18486
+ * Bound by three facts, in descending strength: the contract
18487
+ * identity (the same `contractGenerationCurrent` test the
18488
+ * finish-validation decisions already use), the draft the
18489
+ * verdict actually judged, and the validator names that
18490
+ * rendered it. Entries written before this field existed carry
18491
+ * no draftHash and stay reusable, so journals in flight roll
18492
+ * forward byte for byte.
18493
+ */
18494
+ const applies = (value) => contractGenerationCurrent(value) && (value.draftHash === void 0 || value.draftHash === draftHash) && (!Array.isArray(value.validators) || JSON.stringify(value.validators) === JSON.stringify(validatorNames));
18495
+ const prior = internals.replayer.snapshot().filter((entry) => entry.kind === "decision" && entry.scope === callingState.scope && entry.key === skipKey).at(-1);
18496
+ if (prior !== void 0 && applies(prior.value)) {
18436
18497
  synthesisSkippedByValidDraft = true;
18437
18498
  announceSkip(prior.seq);
18438
18499
  return draft;
18439
18500
  }
18440
- const draftValue = draft ?? null;
18441
18501
  const input = {
18442
18502
  result: draftValue,
18443
18503
  text: typeof draftValue === "string" ? draftValue : JSON.stringify(draftValue),
@@ -18467,7 +18527,9 @@ function makeOrchestratorWorkflow(goal, opts) {
18467
18527
  value: {
18468
18528
  decisionType: "orchestrator_synthesis_skip",
18469
18529
  reason: "synthesis_skipped_by_valid_draft",
18470
- validators: validationSpec.validators.map((validator) => validator.name)
18530
+ validators: validatorNames,
18531
+ ...validationSpec.contract === void 0 ? {} : { contractHash: validationSpec.contract.hash },
18532
+ draftHash
18471
18533
  }
18472
18534
  });
18473
18535
  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.102.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",