@rulvar/core 1.59.4 → 1.60.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
@@ -3876,6 +3876,24 @@ interface UsageLimits {
3876
3876
  max: number;
3877
3877
  costs?: Record<string, number>;
3878
3878
  };
3879
+ /**
3880
+ * The guaranteed finalization turn (the experiment-review P1.1): when
3881
+ * a TOOL budget limiter (maxToolCalls or toolUnits) expires, the
3882
+ * runtime closes the current batch's remaining calls with explicit
3883
+ * skipped-call error results instead of dropping them silently, then
3884
+ * grants the model exactly ONE summary turn with tools withheld
3885
+ * before the invocation settles as status 'limit' with the exact
3886
+ * limiter named in the terminal error. The summary text becomes the
3887
+ * limit result's output for schema-less calls; a ridden schema
3888
+ * validates into typed output when the summary parses (one attempt,
3889
+ * no re-prompt). `maxOutputTokens` bounds the summary turn only;
3890
+ * absent, the ordinary per-turn output policy applies. Off by
3891
+ * default: the skip results and the summary instruction enter the
3892
+ * conversation, so enabling it changes recorded model requests.
3893
+ */
3894
+ finalizationReserve?: {
3895
+ maxOutputTokens?: number;
3896
+ };
3879
3897
  }
3880
3898
  declare const DEFAULT_MAX_TURNS = 32;
3881
3899
  declare const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 12e4;
@@ -3896,6 +3914,9 @@ interface EffectiveUsageLimits {
3896
3914
  max: number;
3897
3915
  costs?: Record<string, number>;
3898
3916
  };
3917
+ finalizationReserve?: {
3918
+ maxOutputTokens?: number;
3919
+ };
3899
3920
  }
3900
3921
  /**
3901
3922
  * Limits merge per spawn: AgentOpts.limits over profile limits over engine
package/dist/index.js CHANGED
@@ -9328,6 +9328,8 @@ function mergeUsageLimits(call, profile, engine) {
9328
9328
  if (maxCallsPerTool !== void 0) merged.maxCallsPerTool = maxCallsPerTool;
9329
9329
  const toolUnits = pick("toolUnits");
9330
9330
  if (toolUnits !== void 0) merged.toolUnits = toolUnits;
9331
+ const finalizationReserve = pick("finalizationReserve");
9332
+ if (finalizationReserve !== void 0) merged.finalizationReserve = finalizationReserve;
9331
9333
  return merged;
9332
9334
  }
9333
9335
  /**
@@ -9367,6 +9369,12 @@ function validateUsageLimits(limits, site) {
9367
9369
  for (const [name, cost] of Object.entries(costs)) requireNonNegativeInteger(cost, `${site}.toolUnits.costs['${name}']`);
9368
9370
  }
9369
9371
  }
9372
+ if (limits.finalizationReserve !== void 0) {
9373
+ const reserve = limits.finalizationReserve;
9374
+ if (typeof reserve !== "object" || reserve === null || Array.isArray(reserve)) throw new ConfigError(`${site}.finalizationReserve must be { maxOutputTokens? }`);
9375
+ const { maxOutputTokens } = reserve;
9376
+ if (maxOutputTokens !== void 0) requirePositiveInteger(maxOutputTokens, `${site}.finalizationReserve.maxOutputTokens`);
9377
+ }
9370
9378
  }
9371
9379
  //#endregion
9372
9380
  //#region src/runtime/model-retry.ts
@@ -10530,8 +10538,27 @@ async function runAgent(options) {
10530
10538
  let toolCallsUsed = 0;
10531
10539
  let escalationRequest;
10532
10540
  let abortClass;
10541
+ /**
10542
+ * Set at a tool-budget expiry when limits.finalizationReserve is
10543
+ * configured (P1.1); the reserve turn itself runs at ONE site after
10544
+ * the loop ends (the pending-turn path trips before the dispatch
10545
+ * machinery below is even defined), inside the still-open loop phase.
10546
+ */
10547
+ let reserveRequest;
10533
10548
  const noProgress = new NoProgressDetector(limits.noProgressTurns);
10534
10549
  const guard = explorationTrackingEnabled(limits) ? new ExplorationGuard(limits) : void 0;
10550
+ /**
10551
+ * The exact limiter behind a tool-budget expiry, with its counts: the
10552
+ * wording rides the finalization-reserve instruction and the 'limit'
10553
+ * terminal's errorMessage (P1.1 criterion: the terminal names the
10554
+ * limiter, never a bare status).
10555
+ */
10556
+ const toolBudgetDetail = (limiter) => {
10557
+ if (limiter === "maxToolCalls") return `maxToolCalls (${String(toolCallsUsed)}/${String(limits.maxToolCalls ?? 0)})`;
10558
+ const max = limits.toolUnits?.max ?? 0;
10559
+ const used = guard === void 0 ? max : guard.summary(toolCallsUsed).toolUnitsUsed ?? max;
10560
+ return `toolUnits (${String(used)}/${String(max)})`;
10561
+ };
10535
10562
  if (limits.toolBudgetNotices === true && limits.maxToolCalls === void 0) events?.emit({
10536
10563
  type: "log",
10537
10564
  level: "warn",
@@ -10653,15 +10680,42 @@ async function runAgent(options) {
10653
10680
  part.isError = true;
10654
10681
  return part;
10655
10682
  };
10683
+ /**
10684
+ * Closes the batch tail at a tool-budget expiry (P1.1): with the
10685
+ * finalization reserve configured every not-admitted call gets a
10686
+ * typed skipped-call error result naming the limiter, so the model
10687
+ * (and the transcript) sees exactly which calls never executed and
10688
+ * the summary turn's history stays well formed (providers reject
10689
+ * tool calls without matching results). Without the reserve the
10690
+ * tail stays unanswered, byte-identical to before.
10691
+ */
10692
+ const closeSkippedTail = (skippedCalls, limiter) => {
10693
+ if (limits.finalizationReserve === void 0) return;
10694
+ for (const call of skippedCalls) parts.push(errorPart(call, {
10695
+ error: "skipped: the tool budget is exhausted; the call was not executed",
10696
+ limiter,
10697
+ skipped: true
10698
+ }));
10699
+ };
10656
10700
  for (const [index, call] of calls.entries()) {
10657
- if (limits.maxToolCalls !== void 0 && toolCallsUsed >= limits.maxToolCalls) return {
10658
- parts,
10659
- limitHit: true
10660
- };
10661
- if (guard !== void 0 && guard.unitsExhausted()) return {
10662
- parts,
10663
- limitHit: true
10664
- };
10701
+ if (limits.maxToolCalls !== void 0 && toolCallsUsed >= limits.maxToolCalls) {
10702
+ closeSkippedTail(calls.slice(index), "maxToolCalls");
10703
+ return {
10704
+ parts,
10705
+ limitHit: true,
10706
+ limiter: "maxToolCalls",
10707
+ skipped: calls.length - index
10708
+ };
10709
+ }
10710
+ if (guard !== void 0 && guard.unitsExhausted()) {
10711
+ closeSkippedTail(calls.slice(index), "toolUnits");
10712
+ return {
10713
+ parts,
10714
+ limitHit: true,
10715
+ limiter: "toolUnits",
10716
+ skipped: calls.length - index
10717
+ };
10718
+ }
10665
10719
  const def = runtime.defs.find((candidate) => candidate.name === call.name);
10666
10720
  events?.emit({
10667
10721
  type: "tool:start",
@@ -10863,7 +10917,7 @@ async function runAgent(options) {
10863
10917
  if (record.isError === true) part.isError = true;
10864
10918
  return part;
10865
10919
  });
10866
- const { parts, limitHit, escalated, finished, guardTrip } = await runToolCalls([restored.pending.awaiting, ...restored.pending.remaining], priorParts);
10920
+ const { parts, limitHit, escalated, finished, guardTrip, limiter, skipped } = await runToolCalls([restored.pending.awaiting, ...restored.pending.remaining], priorParts);
10867
10921
  if (parts.length > 0) messages.push({
10868
10922
  role: "tool",
10869
10923
  parts
@@ -10884,6 +10938,16 @@ async function runAgent(options) {
10884
10938
  retryable: false
10885
10939
  };
10886
10940
  errorMessage = guard.describeTrip();
10941
+ } else if (limiter !== void 0 && limits.finalizationReserve !== void 0) {
10942
+ agentError = {
10943
+ kind: "terminal",
10944
+ retryable: false
10945
+ };
10946
+ errorMessage = `tool budget exhausted: ${toolBudgetDetail(limiter)}; skipped tool calls: ${String(skipped ?? 0)}`;
10947
+ reserveRequest = {
10948
+ limiter,
10949
+ skipped: skipped ?? 0
10950
+ };
10887
10951
  }
10888
10952
  } else {
10889
10953
  maybePushBudgetNotice();
@@ -11285,7 +11349,7 @@ async function runAgent(options) {
11285
11349
  }
11286
11350
  if (options.tools !== void 0 && outcome.turn.toolCalls.length > 0) {
11287
11351
  noProgress.recordTurn({ toolCalls: outcome.turn.toolCalls.length });
11288
- const { parts, limitHit, escalated, finished, guardTrip } = await runToolCalls(outcome.turn.toolCalls, []);
11352
+ const { parts, limitHit, escalated, finished, guardTrip, limiter, skipped } = await runToolCalls(outcome.turn.toolCalls, []);
11289
11353
  if (parts.length > 0) messages.push({
11290
11354
  role: "tool",
11291
11355
  parts
@@ -11310,6 +11374,16 @@ async function runAgent(options) {
11310
11374
  retryable: false
11311
11375
  };
11312
11376
  errorMessage = guard.describeTrip();
11377
+ } else if (limiter !== void 0 && limits.finalizationReserve !== void 0) {
11378
+ agentError = {
11379
+ kind: "terminal",
11380
+ retryable: false
11381
+ };
11382
+ errorMessage = `tool budget exhausted: ${toolBudgetDetail(limiter)}; skipped tool calls: ${String(skipped ?? 0)}`;
11383
+ reserveRequest = {
11384
+ limiter,
11385
+ skipped: skipped ?? 0
11386
+ };
11313
11387
  }
11314
11388
  break;
11315
11389
  }
@@ -11495,6 +11569,110 @@ async function runAgent(options) {
11495
11569
  await saveBoundary();
11496
11570
  continue loop;
11497
11571
  }
11572
+ if (status === "limit" && reserveRequest !== void 0) {
11573
+ const { limiter, skipped } = reserveRequest;
11574
+ let proceed = true;
11575
+ try {
11576
+ options.budget?.beforeTurn();
11577
+ } catch {
11578
+ events?.emit({
11579
+ type: "log",
11580
+ level: "warn",
11581
+ msg: "the finalization reserve turn was skipped: the budget blocks further turns"
11582
+ });
11583
+ proceed = false;
11584
+ }
11585
+ if (proceed) {
11586
+ turns += 1;
11587
+ const reserveMessages = [...messages, {
11588
+ role: "user",
11589
+ parts: [{
11590
+ type: "text",
11591
+ text: `The tool budget is exhausted (${toolBudgetDetail(limiter)}). Skipped tool calls: ${String(skipped)}; no further tool calls will execute. This is the final turn: produce your best final answer from the evidence already collected.`
11592
+ }]
11593
+ }];
11594
+ let reserveDispatch;
11595
+ try {
11596
+ reserveDispatch = await dispatchPhase({
11597
+ role: primaryRole,
11598
+ chain: loopChain,
11599
+ cursor: loopCursor,
11600
+ requestFor: (target) => {
11601
+ let req = buildRequest(target.resolved, projectHistory(reserveMessages, providerOf(target.adapter)), limits, options.tools?.contracts);
11602
+ if (options.schema !== void 0 && options.canonicalSchema !== void 0 && !separateExtract) req = applyStructuredOutputTier(req, rideTierFor(target), options.canonicalSchema);
11603
+ if (req.tools !== void 0) req = {
11604
+ ...req,
11605
+ toolChoice: "none"
11606
+ };
11607
+ const reserveMax = limits.finalizationReserve?.maxOutputTokens;
11608
+ if (reserveMax !== void 0) req = {
11609
+ ...req,
11610
+ maxOutputTokens: Math.min(req.maxOutputTokens ?? reserveMax, reserveMax)
11611
+ };
11612
+ return applyOutputBudget(req, target, options.budget);
11613
+ },
11614
+ streamOptionsFor: (target) => {
11615
+ const reserveStreamOptions = {
11616
+ idleTimeoutMs: limits.streamIdleTimeoutMs,
11617
+ signals: options.signal === void 0 ? [] : [options.signal],
11618
+ onUsage: (delta) => options.budget?.onUsage(delta, target.resolved.ref)
11619
+ };
11620
+ if (options.budget?.signal !== void 0) reserveStreamOptions.budgetSignal = options.budget.signal;
11621
+ if (options.stream === true) reserveStreamOptions.onDelta = (delta) => events?.emit({
11622
+ type: "agent:stream",
11623
+ delta
11624
+ });
11625
+ return reserveStreamOptions;
11626
+ }
11627
+ });
11628
+ } catch (thrown) {
11629
+ if (!(thrown instanceof BudgetExhaustedError)) throw thrown;
11630
+ events?.emit({
11631
+ type: "log",
11632
+ level: "warn",
11633
+ msg: `the finalization reserve turn was skipped: ${thrown.message}`
11634
+ });
11635
+ }
11636
+ if (reserveDispatch !== void 0) {
11637
+ const { outcome, target: reserveTarget } = reserveDispatch;
11638
+ servedBy = reserveTarget.resolved.ref;
11639
+ usageApprox = usageApprox || outcome.usageApprox;
11640
+ messages.push(assistantMsg(outcome.turn, liftRetainedParts(outcome.providerMetadata, reserveTarget.adapter)));
11641
+ if (invariantViolation !== void 0) {
11642
+ status = "error";
11643
+ agentError = {
11644
+ kind: "transport",
11645
+ retryable: false
11646
+ };
11647
+ errorMessage = invariantViolation;
11648
+ } else if (outcome.aborted === "external") status = "cancelled";
11649
+ else if (outcome.aborted === "budget") {
11650
+ status = "cancelled";
11651
+ agentError = {
11652
+ kind: "budget",
11653
+ retryable: false
11654
+ };
11655
+ } else {
11656
+ await saveBoundary();
11657
+ if (outcome.wireError !== void 0 || outcome.aborted === "idle") events?.emit({
11658
+ type: "log",
11659
+ level: "warn",
11660
+ msg: "the finalization reserve turn failed; the limit terminal stands" + (outcome.wireError === void 0 ? " (stream idle timeout)" : ` (${outcome.wireError.message})`)
11661
+ });
11662
+ else if (options.schema === void 0) {
11663
+ const summary = outcome.turn.text;
11664
+ if (summary.trim() !== "") output = summary;
11665
+ } else if (!separateExtract && options.canonicalSchema !== void 0) {
11666
+ const candidate = extractCandidate(outcome.turn, rideTierFor(reserveTarget));
11667
+ if (candidate !== void 0) {
11668
+ const validation = await validateSchemaSpec(options.schema, candidate.raw);
11669
+ if (validation.valid) output = validation.value;
11670
+ }
11671
+ }
11672
+ }
11673
+ }
11674
+ }
11675
+ }
11498
11676
  endPhase(loopPhase, phaseOutcome(), servedBy);
11499
11677
  if (status === "ok" && !finishedViaTool && options.finalize !== void 0) {
11500
11678
  const finalizeResolved = options.finalize.resolved;
@@ -14209,7 +14387,7 @@ function createCtx(internals, rootWorkflow) {
14209
14387
  transcriptRef: result.transcriptRef
14210
14388
  };
14211
14389
  if (result.status === "escalated" && result.escalation !== void 0) terminalPatch.escalation = result.escalation;
14212
- if (result.output !== null && result.status === "ok") terminalPatch.value = result.output;
14390
+ if (result.output !== null && (result.status === "ok" || result.status === "limit")) terminalPatch.value = result.output;
14213
14391
  if (result.error !== void 0) terminalPatch.error = agentErrorToWire(result.error, result.errorMessage ?? `agent terminated with status ${result.status}`);
14214
14392
  const resultUsageApprox = result.usageApprox === true;
14215
14393
  if (resultUsageApprox) terminalPatch.usageApprox = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/core",
3
- "version": "1.59.4",
3
+ "version": "1.60.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",