@rulvar/core 1.86.0 → 1.87.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
@@ -3579,6 +3579,11 @@ interface ToolBudgetSummary {
3579
3579
  noticesFired?: number[];
3580
3580
  /** Present and true when the finalization reserve summary turn ran. */
3581
3581
  finalizationReserveUsed?: boolean;
3582
+ /**
3583
+ * Present and true when the finalization window activated at least
3584
+ * once this invocation (RV302).
3585
+ */
3586
+ finalizationWindowEntered?: boolean;
3582
3587
  /** The tool budget limiter that ended the loop, on that 'limit' only. */
3583
3588
  limiter?: "maxToolCalls" | "toolUnits";
3584
3589
  }
@@ -3710,11 +3715,12 @@ type ToolEvents = {
3710
3715
  rule?: Json;
3711
3716
  advisory?: Json;
3712
3717
  /**
3713
- * Present when an exploration guard (RV-210), not the permission
3714
- * chain, denied the call: the outcome is 'denied' and the call was
3715
- * never dispatched.
3718
+ * Present when an engine guard, not the permission chain, denied
3719
+ * the call: the exploration guards (RV-210) or the finalization
3720
+ * window (RV302). The outcome is 'denied' and the call was never
3721
+ * dispatched.
3716
3722
  */
3717
- guard?: "repeated-signature";
3723
+ guard?: "repeated-signature" | "per-tool-cap" | "finalization-window";
3718
3724
  };
3719
3725
  /**
3720
3726
  * Bare-nondeterminism detection (RV-209). Emitted LIVE by the segment
@@ -4106,6 +4112,27 @@ interface UsageLimits {
4106
4112
  minHeadroomUsd?: number; /** Default true: a grant needs new evidence since the last one. */
4107
4113
  requireNewEvidence?: boolean;
4108
4114
  };
4115
+ /**
4116
+ * The finalization window (RV302, the seventh comparison experiment):
4117
+ * once the remaining tool budget (executed calls against the effective
4118
+ * maxToolCalls, or remaining weighted units against toolUnits.max,
4119
+ * whichever is closer) drops to `reserveCalls`, only finalization
4120
+ * tools may execute. A call outside the window's allowlist receives a
4121
+ * typed error tool result naming the window (visible to the model,
4122
+ * never terminal, consuming no budget), and the model is told ONCE,
4123
+ * via a plain user message, to record its evidence and finish. The
4124
+ * allowlist defaults to the tools priced at toolUnits cost 0 (the
4125
+ * free bookkeeping tools); the engine terminal tool is always
4126
+ * admitted regardless. With toolBudgetExtension configured, remaining
4127
+ * money converts into a grant BEFORE any window refusal, so the
4128
+ * window binds only when the extension is exhausted or denied. Off by
4129
+ * default: the refusals and the notice enter the conversation, so
4130
+ * enabling it changes recorded model requests.
4131
+ */
4132
+ finalizationWindow?: {
4133
+ /** How many trailing executed calls (or units) the window reserves. */reserveCalls: number; /** Tool names allowed inside the window; default: zero-cost tools. */
4134
+ allow?: string[];
4135
+ };
4109
4136
  }
4110
4137
  declare const DEFAULT_MAX_TURNS = 32;
4111
4138
  declare const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 12e4;
@@ -4135,6 +4162,10 @@ interface EffectiveUsageLimits {
4135
4162
  minHeadroomUsd?: number;
4136
4163
  requireNewEvidence?: boolean;
4137
4164
  };
4165
+ finalizationWindow?: {
4166
+ reserveCalls: number;
4167
+ allow?: string[];
4168
+ };
4138
4169
  }
4139
4170
  /**
4140
4171
  * Limits merge per spawn: AgentOpts.limits over profile limits over engine
@@ -9234,6 +9265,19 @@ interface PreflightOrchestratorSpec {
9234
9265
  */
9235
9266
  extension?: boolean;
9236
9267
  /**
9268
+ * The OrchestrateAcceptance slice the estimator judges (RV305):
9269
+ * declaring it lets preflight relate capped children to the salvage
9270
+ * arms. Absent, the salvage findings stay silent, exactly like every
9271
+ * other undeclared input.
9272
+ */
9273
+ acceptance?: {
9274
+ childPolicy?: "all-ok" | {
9275
+ minSuccessful: number;
9276
+ };
9277
+ acceptPartialChildren?: boolean;
9278
+ acceptValidatedTerminalOutputOnLimit?: boolean;
9279
+ };
9280
+ /**
9237
9281
  * The separate synthesis invocation (RV-211), when the orchestration
9238
9282
  * configures one (the v1.71 experiment review: the run ceiling used
9239
9283
  * to stop at the coordination loop, undercounting the synthesis
package/dist/index.js CHANGED
@@ -9023,6 +9023,8 @@ function mergeUsageLimits(call, profile, engine) {
9023
9023
  if (finalizationReserve !== void 0) merged.finalizationReserve = finalizationReserve;
9024
9024
  const toolBudgetExtension = pick("toolBudgetExtension");
9025
9025
  if (toolBudgetExtension !== void 0) merged.toolBudgetExtension = toolBudgetExtension;
9026
+ const finalizationWindow = pick("finalizationWindow");
9027
+ if (finalizationWindow !== void 0) merged.finalizationWindow = finalizationWindow;
9026
9028
  return merged;
9027
9029
  }
9028
9030
  /**
@@ -9077,6 +9079,16 @@ function validateUsageLimits(limits, site) {
9077
9079
  if (minHeadroomUsd !== void 0 && (typeof minHeadroomUsd !== "number" || !Number.isFinite(minHeadroomUsd) || minHeadroomUsd < 0)) throw new ConfigError(`${site}.toolBudgetExtension.minHeadroomUsd must be a finite nonnegative USD amount, got ${typeof minHeadroomUsd === "number" ? String(minHeadroomUsd) : typeof minHeadroomUsd}`);
9078
9080
  if (requireNewEvidence !== void 0 && typeof requireNewEvidence !== "boolean") throw new ConfigError(`${site}.toolBudgetExtension.requireNewEvidence must be a boolean; got ${typeof requireNewEvidence}`);
9079
9081
  }
9082
+ if (limits.finalizationWindow !== void 0) {
9083
+ const window = limits.finalizationWindow;
9084
+ if (typeof window !== "object" || window === null || Array.isArray(window)) throw new ConfigError(`${site}.finalizationWindow must be { reserveCalls, allow? }`);
9085
+ const { reserveCalls, allow } = window;
9086
+ requirePositiveInteger(reserveCalls, `${site}.finalizationWindow.reserveCalls`);
9087
+ if (allow !== void 0) {
9088
+ if (!Array.isArray(allow)) throw new ConfigError(`${site}.finalizationWindow.allow must be an array of tool names`);
9089
+ for (const [index, name] of allow.entries()) if (typeof name !== "string" || name.length === 0) throw new ConfigError(`${site}.finalizationWindow.allow[${String(index)}] must be a nonempty tool name`);
9090
+ }
9091
+ }
9080
9092
  }
9081
9093
  //#endregion
9082
9094
  //#region src/model/failover.ts
@@ -9657,6 +9669,8 @@ const DEFAULT_MODEL_RETRY_ATTEMPTS = 2;
9657
9669
  */
9658
9670
  /** The docs anchor cited by guard denials and the guard abort. */
9659
9671
  const GUARD_DOCS_URL = "https://docs.rulvar.com/guide/agents#exploration-guards";
9672
+ /** The docs anchor cited by finalization window refusals (RV302). */
9673
+ const WINDOW_DOCS_URL = "https://docs.rulvar.com/guide/agents#the-finalization-window";
9660
9674
  /**
9661
9675
  * True when any exploration guard field asks for tracking. The tool
9662
9676
  * budget extension (RV301) counts too: its requireNewEvidence admission
@@ -9791,6 +9805,15 @@ var ExplorationGuard = class {
9791
9805
  return this.config.toolUnits !== void 0 && this.unitsUsed >= this.config.toolUnits.max;
9792
9806
  }
9793
9807
  /**
9808
+ * Weighted units still spendable, floored at zero; undefined without
9809
+ * toolUnits configured. The finalization window (RV302) reads this on
9810
+ * every call evaluation, so it stays an O(1) counter, unlike the
9811
+ * allocating summary().
9812
+ */
9813
+ unitsRemaining() {
9814
+ return this.config.toolUnits === void 0 ? void 0 : Math.max(0, this.config.toolUnits.max - this.unitsUsed);
9815
+ }
9816
+ /**
9794
9817
  * Distinct successful result digests seen this invocation: the
9795
9818
  * extension's requireNewEvidence admission compares snapshots of this
9796
9819
  * count (RV301). A result JCS cannot digest never counts, so a grant
@@ -9847,6 +9870,21 @@ function toolBudgetExtensionNoticeText(grant, maxExtensions, used, cap) {
9847
9870
  const remaining = Math.max(0, cap - used);
9848
9871
  return `Tool budget extended: grant ${String(grant)} of ${String(maxExtensions)}; ${String(used)} of ${String(cap)} tool calls used, ${String(remaining)} remaining. The budget headroom permits continued work; prioritize the highest value calls.`;
9849
9872
  }
9873
+ /**
9874
+ * The one-time model-visible window notice (RV302). Deterministic for
9875
+ * given counts, like the notices above.
9876
+ */
9877
+ function finalizationWindowNoticeText(remaining, reserve, budget) {
9878
+ return `Finalization window: ${String(Math.max(0, remaining))} of the reserved final ${String(reserve)} ${budget} remain. Only finalization tools (and the terminal tool) may execute now; record your evidence and finish with what you have.`;
9879
+ }
9880
+ /**
9881
+ * The typed window refusal a non-allowlisted call receives (RV302):
9882
+ * the same posture as the guard denials above, visible to the model,
9883
+ * never terminal, consuming no budget.
9884
+ */
9885
+ function finalizationWindowRefusalText(name, reserve, budget) {
9886
+ return `finalization window: the last ${String(reserve)} ${budget} are reserved for finalization tools, and '${name}' is not in the window allowlist. Record your evidence with the allowed tools and call the terminal tool (${WINDOW_DOCS_URL}).`;
9887
+ }
9850
9888
  //#endregion
9851
9889
  //#region src/runtime/no-progress.ts
9852
9890
  /**
@@ -10730,6 +10768,77 @@ async function runAgent(options) {
10730
10768
  }]
10731
10769
  });
10732
10770
  };
10771
+ /**
10772
+ * The finalization window (RV302): once the remaining tool budget
10773
+ * drops to reserveCalls, only the allowlisted finalization tools (and
10774
+ * the always-admitted terminal tool) execute; everything else gets a
10775
+ * typed refusal that consumes nothing. The run that motivated it
10776
+ * recorded 10 of 14 evidence entries before the cap: one summary turn
10777
+ * cannot dump a backlog, a reserved tail of bookkeeping calls can.
10778
+ */
10779
+ const finalizationWindow = limits.finalizationWindow;
10780
+ let windowEntered = false;
10781
+ let windowNoticeFired = false;
10782
+ const pendingWindowNotices = [];
10783
+ /** The tightest remaining budget and which dimension provides it. */
10784
+ const windowRemaining = () => {
10785
+ let best;
10786
+ const cap = effectiveMaxToolCalls();
10787
+ if (cap !== void 0) best = {
10788
+ remaining: Math.max(0, cap - toolCallsUsed),
10789
+ budget: "tool calls"
10790
+ };
10791
+ const units = guard?.unitsRemaining();
10792
+ if (units !== void 0 && (best === void 0 || units < best.remaining)) best = {
10793
+ remaining: units,
10794
+ budget: "tool units"
10795
+ };
10796
+ return best;
10797
+ };
10798
+ const windowActive = () => {
10799
+ if (finalizationWindow === void 0) return;
10800
+ const state = windowRemaining();
10801
+ return state !== void 0 && state.remaining <= finalizationWindow.reserveCalls ? state : void 0;
10802
+ };
10803
+ /**
10804
+ * Marks the entry and queues the one-time notice. Queued, not pushed:
10805
+ * a user message may not interleave a tool batch, so the queue
10806
+ * flushes with the other notices after the batch's results join the
10807
+ * history. On resume the flags re-derive from the restored counts and
10808
+ * the notice (already in the restored messages) never re-fires.
10809
+ */
10810
+ const maybeMarkWindowEntry = () => {
10811
+ if (finalizationWindow === void 0 || windowNoticeFired) return;
10812
+ const state = windowActive();
10813
+ if (state === void 0) return;
10814
+ windowEntered = true;
10815
+ windowNoticeFired = true;
10816
+ pendingWindowNotices.push(finalizationWindowNoticeText(state.remaining, finalizationWindow.reserveCalls, state.budget));
10817
+ events?.emit({
10818
+ type: "log",
10819
+ level: "info",
10820
+ msg: `finalization window entered: ${String(state.remaining)} of the reserved final ${String(finalizationWindow.reserveCalls)} ${state.budget} remain`
10821
+ });
10822
+ };
10823
+ const flushWindowNotices = () => {
10824
+ for (const text of pendingWindowNotices.splice(0)) messages.push({
10825
+ role: "user",
10826
+ parts: [{
10827
+ type: "text",
10828
+ text
10829
+ }]
10830
+ });
10831
+ };
10832
+ /**
10833
+ * The window allowlist. The terminal and escalate tools never reach
10834
+ * this check: the dispatch walk intercepts both before the window
10835
+ * block, so the exits are structurally exempt rather than listed.
10836
+ */
10837
+ const windowAllows = (name) => {
10838
+ const allow = finalizationWindow?.allow;
10839
+ if (allow !== void 0) return allow.includes(name);
10840
+ return limits.toolUnits?.costs?.[name] === 0;
10841
+ };
10733
10842
  const modelRetryCounts = /* @__PURE__ */ new Map();
10734
10843
  let lastTurnUsage = {
10735
10844
  inputTokens: 0,
@@ -10766,6 +10875,10 @@ async function runAgent(options) {
10766
10875
  extensionGrants = Math.min(extension.maxExtensions, Math.ceil((toolCallsUsed - limits.maxToolCalls) / extension.increment));
10767
10876
  extensionEvidenceAtLastGrant = guard?.evidenceCount() ?? 0;
10768
10877
  }
10878
+ if (windowActive() !== void 0) {
10879
+ windowEntered = true;
10880
+ windowNoticeFired = true;
10881
+ }
10769
10882
  }
10770
10883
  const usageSlices = () => [...usageByPhaseModel.values()].map(({ role, servedBy: sliceServedBy, usage }) => ({
10771
10884
  servedBy: sliceServedBy,
@@ -11044,6 +11157,27 @@ async function runAgent(options) {
11044
11157
  finished: finishArgs.result ?? null
11045
11158
  };
11046
11159
  }
11160
+ if (finalizationWindow !== void 0) {
11161
+ maybeMarkWindowEntry();
11162
+ let windowState = windowActive();
11163
+ if (windowState !== void 0 && !windowAllows(gatedCall.name)) {
11164
+ if (windowState.budget === "tool calls" && extension !== void 0 && windowState.remaining + extension.increment > finalizationWindow.reserveCalls && tryToolBudgetGrant()) windowState = windowActive();
11165
+ if (windowState !== void 0 && !windowAllows(gatedCall.name)) {
11166
+ events?.emit({
11167
+ type: "tool:end",
11168
+ toolName: gatedCall.name,
11169
+ outcome: "denied",
11170
+ durationMs: now() - gateStartedAt,
11171
+ guard: "finalization-window"
11172
+ });
11173
+ parts.push(errorPart(call, {
11174
+ error: finalizationWindowRefusalText(gatedCall.name, finalizationWindow.reserveCalls, windowState.budget),
11175
+ guard: "finalization-window"
11176
+ }));
11177
+ continue;
11178
+ }
11179
+ }
11180
+ }
11047
11181
  if (guard !== void 0) {
11048
11182
  const guardVerdict = guard.beforeExecute(gatedCall.name, gatedCall.args);
11049
11183
  if (guardVerdict.deny) {
@@ -11082,6 +11216,7 @@ async function runAgent(options) {
11082
11216
  };
11083
11217
  }
11084
11218
  }
11219
+ maybeMarkWindowEntry();
11085
11220
  return {
11086
11221
  parts,
11087
11222
  limitHit: false
@@ -11133,6 +11268,7 @@ async function runAgent(options) {
11133
11268
  }
11134
11269
  } else {
11135
11270
  flushExtensionNotices();
11271
+ flushWindowNotices();
11136
11272
  maybePushBudgetNotice();
11137
11273
  await saveBoundary();
11138
11274
  }
@@ -11598,6 +11734,7 @@ async function runAgent(options) {
11598
11734
  break;
11599
11735
  }
11600
11736
  flushExtensionNotices();
11737
+ flushWindowNotices();
11601
11738
  maybePushBudgetNotice();
11602
11739
  if (options.summarize !== void 0 && !compactionDisabled && shouldCompact({
11603
11740
  lastTurnUsage,
@@ -12159,6 +12296,7 @@ async function runAgent(options) {
12159
12296
  if (extension !== void 0) toolBudget.extensionsGranted = extensionGrants;
12160
12297
  if (firedNotices.size > 0) toolBudget.noticesFired = [...firedNotices].sort((a, b) => a - b);
12161
12298
  if (reserveSummaryRan) toolBudget.finalizationReserveUsed = true;
12299
+ if (windowEntered) toolBudget.finalizationWindowEntered = true;
12162
12300
  if (limitLimiter !== void 0) toolBudget.limiter = limitLimiter;
12163
12301
  result.toolBudget = toolBudget;
12164
12302
  }
@@ -17810,6 +17948,18 @@ function makeOrchestratorWorkflow(goal, opts) {
17810
17948
  * run falls back to the draft under a journaled decision and a warn
17811
17949
  * log, never silently.
17812
17950
  */
17951
+ /**
17952
+ * The reserve lifecycle snapshot (RV304 second half, the seventh
17953
+ * comparison experiment review, P1.7): configured is the declared
17954
+ * hold, held what actually registered on the cap account (zero when
17955
+ * no cap resolved and the config was silently inert), released
17956
+ * what the synthesis dispatch freed, remainingBeforeSynthesisUsd
17957
+ * the chain headroom the invocation saw right after the release,
17958
+ * and consumedUsd its own priced spend. Frozen into a journaled
17959
+ * decision at first completion, so a resume reports the identical
17960
+ * facts; absent everywhere when no reserve is configured.
17961
+ */
17962
+ let synthesisReserveLifecycle;
17813
17963
  const runSynthesis = async (draft) => {
17814
17964
  const spec = opts?.synthesis;
17815
17965
  if (spec === void 0) return draft;
@@ -17872,11 +18022,14 @@ function makeOrchestratorWorkflow(goal, opts) {
17872
18022
  ...repeatedClaims === void 0 ? {} : { repeatedClaims: repeatedClaims.length }
17873
18023
  }
17874
18024
  }, callingState.spanId);
18025
+ const configuredReserveUsd = opts?.budget?.synthesisReserveUsd ?? 0;
18026
+ const heldReserveUsd = orchestratorAccount === void 0 ? 0 : internals.budget.accountView(orchestratorAccount)?.synthesisReserveUsd ?? 0;
17875
18027
  const synthesisState = { ...callingState };
17876
18028
  if (orchestratorAccount !== void 0) {
17877
18029
  synthesisState.budgetScope = orchestratorAccount;
17878
18030
  internals.budget.releaseSynthesisReserve(orchestratorAccount);
17879
18031
  }
18032
+ const remainingBeforeSynthesisUsd = configuredReserveUsd > 0 ? internals.budget.remainingUsd(orchestratorAccount ?? callingState.budgetScope ?? void 0) : void 0;
17880
18033
  const synthesisBreak = validationSpec === void 0 ? void 0 : validationAbort.signal;
17881
18034
  if (synthesisBreak !== void 0) synthesisState.signal = callingState.signal === void 0 ? synthesisBreak : AbortSignal.any([callingState.signal, synthesisBreak]);
17882
18035
  const synthesisOpts = {
@@ -17899,6 +18052,46 @@ function makeOrchestratorWorkflow(goal, opts) {
17899
18052
  synthesisSchemaRejectedExchanges = synthesized.schemaRejectedTerminalExchanges ?? 0;
17900
18053
  synthesisSchemaRecoveredExchanges = synthesized.schemaRecoveredTerminalExchanges ?? 0;
17901
18054
  if (validationTermination !== void 0) throw validationTermination;
18055
+ if (configuredReserveUsd > 0) {
18056
+ const reserveKey = "synthesis-reserve-lifecycle";
18057
+ const prior = internals.replayer.snapshot().find((entry) => entry.kind === "decision" && entry.scope === callingState.scope && entry.key === reserveKey);
18058
+ if (prior !== void 0) {
18059
+ const frozen = prior.value;
18060
+ synthesisReserveLifecycle = {
18061
+ configuredUsd: frozen.configuredUsd,
18062
+ heldUsd: frozen.heldUsd,
18063
+ releasedUsd: frozen.releasedUsd,
18064
+ ...frozen.remainingBeforeSynthesisUsd === void 0 ? {} : { remainingBeforeSynthesisUsd: frozen.remainingBeforeSynthesisUsd },
18065
+ ...frozen.consumedUsd === void 0 ? {} : { consumedUsd: frozen.consumedUsd }
18066
+ };
18067
+ } else {
18068
+ synthesisReserveLifecycle = {
18069
+ configuredUsd: configuredReserveUsd,
18070
+ heldUsd: heldReserveUsd,
18071
+ releasedUsd: heldReserveUsd,
18072
+ ...remainingBeforeSynthesisUsd === void 0 ? {} : { remainingBeforeSynthesisUsd },
18073
+ consumedUsd: synthesized.costUsd
18074
+ };
18075
+ await internals.replayer.appendSinglePhase({
18076
+ scope: callingState.scope,
18077
+ key: reserveKey,
18078
+ kind: "decision",
18079
+ status: "ok",
18080
+ spanId: internals.spans.mint(callingState.spanId),
18081
+ site: "orchestrator-synthesis-reserve",
18082
+ value: {
18083
+ decisionType: "orchestrator_synthesis_reserve",
18084
+ ...synthesisReserveLifecycle
18085
+ }
18086
+ });
18087
+ }
18088
+ internals.events.emit({
18089
+ type: "log",
18090
+ level: "info",
18091
+ msg: "orchestrator synthesis reserve lifecycle",
18092
+ data: { ...synthesisReserveLifecycle }
18093
+ }, callingState.spanId);
18094
+ }
17902
18095
  if (synthesized.status === "ok") return synthesized.output;
17903
18096
  if (validationSpec !== void 0) throw new FailRunError(`the synthesis invocation terminated with status '${synthesized.status}'` + (synthesized.errorMessage === void 0 ? "" : `: ${synthesized.errorMessage}`) + "; finish validators are configured, so the unvalidated draft cannot stand", { data: {
17904
18097
  source: "orchestrator_synthesis",
@@ -18109,7 +18302,8 @@ function makeOrchestratorWorkflow(goal, opts) {
18109
18302
  degradedReasons: decision.degradedReasons,
18110
18303
  ...decision.salvagedPartialChildren === void 0 ? {} : { salvagedPartialChildren: decision.salvagedPartialChildren },
18111
18304
  ...decision.salvagedTerminalOutputChildren === void 0 ? {} : { salvagedTerminalOutputChildren: decision.salvagedTerminalOutputChildren },
18112
- ...envelopeSchemaRecovered === 0 ? {} : { schemaRecoveredFinishExchanges: envelopeSchemaRecovered }
18305
+ ...envelopeSchemaRecovered === 0 ? {} : { schemaRecoveredFinishExchanges: envelopeSchemaRecovered },
18306
+ ...synthesisReserveLifecycle === void 0 ? {} : { synthesisReserve: synthesisReserveLifecycle }
18113
18307
  };
18114
18308
  });
18115
18309
  }
@@ -18337,6 +18531,8 @@ function preflightEstimate(input) {
18337
18531
  const waveGateInputs = [];
18338
18532
  const units = [];
18339
18533
  const shapes = [];
18534
+ /** Whether any declared spawn caps its tool budget (RV305). */
18535
+ let anyCappedSpawn = false;
18340
18536
  for (const spec of spawnSpecs) {
18341
18537
  const role = spec.role ?? "loop";
18342
18538
  const label = spec.label ?? role;
@@ -18460,6 +18656,37 @@ function preflightEstimate(input) {
18460
18656
  message: `spawn '${label}' sets toolBudgetNotices without maxToolCalls: the notices never fire`,
18461
18657
  spawn: label
18462
18658
  });
18659
+ if (limits.finalizationWindow !== void 0) if (limits.maxToolCalls === void 0 && limits.toolUnits === void 0) say({
18660
+ severity: "warning",
18661
+ code: "inert-finalization-window",
18662
+ message: `spawn '${label}' sets finalizationWindow without maxToolCalls or toolUnits: no tool budget exists for the window to reserve a tail of`,
18663
+ spawn: label
18664
+ });
18665
+ else {
18666
+ const reserve = limits.finalizationWindow.reserveCalls;
18667
+ const windowCallCap = extendedMaxToolCalls(limits);
18668
+ const unitsMax = limits.toolUnits?.max;
18669
+ if (windowCallCap !== void 0 && reserve >= windowCallCap || unitsMax !== void 0 && reserve >= unitsMax) say({
18670
+ severity: "warning",
18671
+ code: "finalization-window-covers-cap",
18672
+ message: `spawn '${label}': finalizationWindow.reserveCalls ${String(reserve)} is not below the tool budget, so the window governs from the very first call and nothing but the allowlisted finalization tools ever executes`,
18673
+ spawn: label
18674
+ });
18675
+ if (limits.finalizationWindow.allow !== void 0 && limits.finalizationWindow.allow.length === 0) say({
18676
+ severity: "warning",
18677
+ code: "finalization-window-empty-allowlist",
18678
+ message: `spawn '${label}': finalizationWindow.allow is empty, so inside the window only the engine terminal tool (when one exists) remains callable and every other call is refused`,
18679
+ spawn: label
18680
+ });
18681
+ }
18682
+ const positiveCallCap = limits.maxToolCalls !== void 0 && limits.maxToolCalls > 0;
18683
+ if ((positiveCallCap || limits.toolUnits !== void 0) && limits.toolBudgetNotices !== true && limits.finalizationReserve === void 0 && limits.toolBudgetExtension === void 0 && limits.finalizationWindow === void 0) say({
18684
+ severity: "warning",
18685
+ code: "bare-tool-cap",
18686
+ message: `spawn '${label}' caps its tool budget (${positiveCallCap ? `maxToolCalls ${String(limits.maxToolCalls)}` : `toolUnits.max ${String(limits.toolUnits?.max ?? 0)}`}) with no softener: no toolBudgetNotices, no toolBudgetExtension, no finalizationReserve, no finalizationWindow. Expiry is a silent hard 'limit' the model never saw coming; enable a notice or a reserve, or drop the cap and rely on the USD ceiling`,
18687
+ spawn: label
18688
+ });
18689
+ if (positiveCallCap || limits.toolUnits !== void 0) anyCappedSpawn = true;
18463
18690
  if (unpriced && ceilingUsd !== void 0) say({
18464
18691
  severity: "warning",
18465
18692
  code: "unpriced-under-ceiling",
@@ -18519,6 +18746,12 @@ function preflightEstimate(input) {
18519
18746
  }
18520
18747
  let orchestratorReserveUsd;
18521
18748
  if (input.orchestrator !== void 0) {
18749
+ const acceptance = input.orchestrator.acceptance;
18750
+ if (acceptance !== void 0 && anyCappedSpawn && acceptance.acceptPartialChildren !== true && acceptance.acceptValidatedTerminalOutputOnLimit !== true) say({
18751
+ severity: "info",
18752
+ code: "capped-children-without-salvage",
18753
+ message: "children in the declared wave cap their tool budgets and the declared acceptance policy enables no salvage arm (acceptPartialChildren, acceptValidatedTerminalOutputOnLimit): a child that expires settles limit and counts against the policy with nothing to salvage"
18754
+ });
18522
18755
  const servedBy = resolveServing(defaults.routing?.orchestrate);
18523
18756
  if (servedBy === void 0) say({
18524
18757
  severity: "error",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/core",
3
- "version": "1.86.0",
3
+ "version": "1.87.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",