@rulvar/core 1.50.0 → 1.52.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 +1253 -1179
  2. package/dist/index.js +278 -11
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8002,6 +8002,12 @@ function mergeUsageLimits(call, profile, engine) {
8002
8002
  if (timeoutMs !== void 0) merged.timeoutMs = timeoutMs;
8003
8003
  const noProgressTurns = pick("noProgressTurns");
8004
8004
  if (noProgressTurns !== void 0) merged.noProgressTurns = noProgressTurns;
8005
+ const toolBudgetNotices = pick("toolBudgetNotices");
8006
+ if (toolBudgetNotices !== void 0) merged.toolBudgetNotices = toolBudgetNotices;
8007
+ const maxRepeatedToolSignature = pick("maxRepeatedToolSignature");
8008
+ if (maxRepeatedToolSignature !== void 0) merged.maxRepeatedToolSignature = maxRepeatedToolSignature;
8009
+ const maxNoNewEvidenceCalls = pick("maxNoNewEvidenceCalls");
8010
+ if (maxNoNewEvidenceCalls !== void 0) merged.maxNoNewEvidenceCalls = maxNoNewEvidenceCalls;
8005
8011
  return merged;
8006
8012
  }
8007
8013
  /**
@@ -8023,6 +8029,9 @@ function validateUsageLimits(limits, site) {
8023
8029
  if (limits.timeoutMs !== void 0) requirePositiveInteger(limits.timeoutMs, `${site}.timeoutMs`);
8024
8030
  if (limits.streamIdleTimeoutMs !== void 0) requireTimerDelayMs(limits.streamIdleTimeoutMs, `${site}.streamIdleTimeoutMs`);
8025
8031
  if (limits.noProgressTurns !== void 0) requirePositiveInteger(limits.noProgressTurns, `${site}.noProgressTurns`);
8032
+ if (limits.toolBudgetNotices !== void 0 && typeof limits.toolBudgetNotices !== "boolean") throw new ConfigError(`${site}.toolBudgetNotices must be a boolean; got ${typeof limits.toolBudgetNotices}`);
8033
+ if (limits.maxRepeatedToolSignature !== void 0) requirePositiveInteger(limits.maxRepeatedToolSignature, `${site}.maxRepeatedToolSignature`);
8034
+ if (limits.maxNoNewEvidenceCalls !== void 0) requirePositiveInteger(limits.maxNoNewEvidenceCalls, `${site}.maxNoNewEvidenceCalls`);
8026
8035
  }
8027
8036
  //#endregion
8028
8037
  //#region src/runtime/model-retry.ts
@@ -8518,6 +8527,182 @@ function formatRePrompt(issues, attempt, maxAttempts) {
8518
8527
  };
8519
8528
  }
8520
8529
  //#endregion
8530
+ //#region src/runtime/exploration.ts
8531
+ /**
8532
+ * Exploration guards (RV-210, first slice): the engine-side counters that
8533
+ * make an oscillating tool loop visible and boundable. The published gap:
8534
+ * an agent that repeats the byte-identical tool call, or keeps receiving
8535
+ * pages it has already seen, burns its whole tool budget with zero signal
8536
+ * and dies as a bare 'limit' terminal; the no-progress detector never
8537
+ * trips because tool calls reset it.
8538
+ *
8539
+ * Three opt-in UsageLimits fields drive this module:
8540
+ *
8541
+ * - `maxRepeatedToolSignature`: how many times the SAME signature (tool
8542
+ * name + RFC 8785 canonical args) may execute per invocation. The call
8543
+ * that would exceed it is not dispatched; the model receives a typed
8544
+ * error tool result instead (visible, bounded, never terminal), and the
8545
+ * denial does not consume the tool budget.
8546
+ * - `maxNoNewEvidenceCalls`: how many consecutive successful executions
8547
+ * may return only already-seen result digests before the loop aborts as
8548
+ * status 'limit' with abortClass 'exploration' (paid partial work; the
8549
+ * executed results stand and the terminal memoizes like every
8550
+ * engine-decided abort).
8551
+ * - `toolBudgetNotices`: soft 50%/80% thresholds over `maxToolCalls`,
8552
+ * surfaced to the model as a plain user message with the exact
8553
+ * remaining count, so pacing is possible before the hard cap.
8554
+ *
8555
+ * Determinism: signatures and digests derive from the canonical JCS
8556
+ * serialization; values JCS cannot serialize never match anything (a
8557
+ * unique signature; a fresh-evidence result), so the guards fail open,
8558
+ * never spuriously. On resume the guard state is rebuilt from the
8559
+ * restored checkpoint messages (successful executions only, and only the
8560
+ * window a compaction kept), which is the same source the model itself
8561
+ * sees; enforcement is engine-side and live-only, while a replayed
8562
+ * guard abort is re-stamped from the journaled terminal like every other
8563
+ * abort class.
8564
+ */
8565
+ /** The docs anchor cited by guard denials and the guard abort. */
8566
+ const GUARD_DOCS_URL = "https://docs.rulvar.com/guide/agents#exploration-guards";
8567
+ /** True when any exploration guard field asks for tracking. */
8568
+ function explorationTrackingEnabled(limits) {
8569
+ return limits.maxRepeatedToolSignature !== void 0 || limits.maxNoNewEvidenceCalls !== void 0 || limits.toolBudgetNotices === true;
8570
+ }
8571
+ function digestOf$1(value) {
8572
+ try {
8573
+ return createHash("sha256").update(jcsSerialize(value), "utf8").digest("hex");
8574
+ } catch {
8575
+ return;
8576
+ }
8577
+ }
8578
+ var ExplorationGuard = class {
8579
+ config;
8580
+ signatureExecutions = /* @__PURE__ */ new Map();
8581
+ seenDigests = /* @__PURE__ */ new Set();
8582
+ byTool = /* @__PURE__ */ new Map();
8583
+ noNewEvidenceStreak = 0;
8584
+ executed = 0;
8585
+ repeated = 0;
8586
+ duplicateResults = 0;
8587
+ denied = 0;
8588
+ unserializableSeq = 0;
8589
+ constructor(config) {
8590
+ this.config = config;
8591
+ }
8592
+ /**
8593
+ * The canonical signature: tool name + JCS args. Args JCS cannot
8594
+ * serialize get a unique per-occurrence signature, so they never
8595
+ * repeat and the guard fails open.
8596
+ */
8597
+ signatureOf(name, args) {
8598
+ try {
8599
+ return `${name}\u0000${jcsSerialize(args ?? null)}`;
8600
+ } catch {
8601
+ this.unserializableSeq += 1;
8602
+ return `${name}\u0000<unserializable:${String(this.unserializableSeq)}>`;
8603
+ }
8604
+ }
8605
+ /**
8606
+ * Rebuilds guard state from restored checkpoint messages: assistant
8607
+ * tool-call parts paired with their successful tool results by id.
8608
+ * Error results (denials, tool failures) are skipped, so a resume
8609
+ * never over-counts; a compaction naturally narrows the window to
8610
+ * what the model itself still sees.
8611
+ */
8612
+ restore(messages) {
8613
+ const callsById = /* @__PURE__ */ new Map();
8614
+ for (const msg of messages) for (const part of msg.parts) if (part.type === "tool-call") callsById.set(part.id, {
8615
+ name: part.name,
8616
+ args: part.args
8617
+ });
8618
+ else if (part.type === "tool-result" && part.isError !== true) {
8619
+ const call = callsById.get(part.id);
8620
+ if (call === void 0) continue;
8621
+ this.recordExecution(call.name, call.args, part.result, true);
8622
+ }
8623
+ }
8624
+ /**
8625
+ * The pre-dispatch verdict: denies the call that would exceed
8626
+ * maxRepeatedToolSignature executions of the same signature.
8627
+ */
8628
+ beforeExecute(name, args) {
8629
+ const max = this.config.maxRepeatedToolSignature;
8630
+ if (max === void 0) return { deny: false };
8631
+ const executions = this.signatureExecutions.get(this.signatureOf(name, args)) ?? 0;
8632
+ if (executions < max) return { deny: false };
8633
+ this.denied += 1;
8634
+ return {
8635
+ deny: true,
8636
+ executions,
8637
+ 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}).`
8638
+ };
8639
+ }
8640
+ /**
8641
+ * Records one dispatched execution and answers whether the
8642
+ * no-new-evidence guard trips. Only successful results feed the
8643
+ * evidence chain: an error result neither resets nor lengthens it
8644
+ * (repeated failing calls are the signature guard's job), and a
8645
+ * result JCS cannot digest counts as fresh evidence.
8646
+ */
8647
+ afterExecute(name, args, result, isError) {
8648
+ return this.recordExecution(name, args, result, !isError);
8649
+ }
8650
+ recordExecution(name, args, result, successful) {
8651
+ this.executed += 1;
8652
+ this.byTool.set(name, (this.byTool.get(name) ?? 0) + 1);
8653
+ const signature = this.signatureOf(name, args);
8654
+ const prior = this.signatureExecutions.get(signature) ?? 0;
8655
+ if (prior > 0) this.repeated += 1;
8656
+ this.signatureExecutions.set(signature, prior + 1);
8657
+ if (!successful) return false;
8658
+ const digest = digestOf$1(result);
8659
+ if (digest === void 0 || !this.seenDigests.has(digest)) {
8660
+ if (digest !== void 0) this.seenDigests.add(digest);
8661
+ this.noNewEvidenceStreak = 0;
8662
+ return false;
8663
+ }
8664
+ this.duplicateResults += 1;
8665
+ this.noNewEvidenceStreak += 1;
8666
+ const max = this.config.maxNoNewEvidenceCalls;
8667
+ return max !== void 0 && this.noNewEvidenceStreak >= max;
8668
+ }
8669
+ /** The abort message for a tripped no-new-evidence guard. */
8670
+ describeTrip() {
8671
+ 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}).`;
8672
+ }
8673
+ /** The structured summary; `toolCallsUsed` is the loop's own counter. */
8674
+ summary(toolCallsUsed) {
8675
+ const byTool = {};
8676
+ for (const [name, count] of [...this.byTool.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) byTool[name] = count;
8677
+ return {
8678
+ toolCallsUsed,
8679
+ distinctSignatures: this.signatureExecutions.size,
8680
+ repeatedCalls: this.repeated,
8681
+ duplicateResultCalls: this.duplicateResults,
8682
+ deniedRepeats: this.denied,
8683
+ byTool
8684
+ };
8685
+ }
8686
+ };
8687
+ /** The soft notice thresholds over maxToolCalls, in ascending order. */
8688
+ const TOOL_BUDGET_NOTICE_THRESHOLDS = [.5, .8];
8689
+ /**
8690
+ * Which notice thresholds `used` calls out of `max` have crossed
8691
+ * (ceil-based, so a threshold fires no earlier than its exact fraction).
8692
+ */
8693
+ function crossedNoticeThresholds(used, max) {
8694
+ return TOOL_BUDGET_NOTICE_THRESHOLDS.filter((threshold) => used >= Math.ceil(threshold * max)).map((threshold) => threshold);
8695
+ }
8696
+ /**
8697
+ * The model-visible budget notice. Deterministic for a given usage
8698
+ * count, so a recorded conversation rebuilds byte-identically on
8699
+ * resume and replay.
8700
+ */
8701
+ function toolBudgetNoticeText(used, max) {
8702
+ const remaining = Math.max(0, max - used);
8703
+ 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.`;
8704
+ }
8705
+ //#endregion
8521
8706
  //#region src/runtime/agent-loop.ts
8522
8707
  /**
8523
8708
  * Agent runtime v1 (M1-T06): the single subagent loop shared by every
@@ -8978,6 +9163,34 @@ async function runAgent(options) {
8978
9163
  let escalationRequest;
8979
9164
  let abortClass;
8980
9165
  const noProgress = new NoProgressDetector(limits.noProgressTurns);
9166
+ const guard = explorationTrackingEnabled(limits) ? new ExplorationGuard(limits) : void 0;
9167
+ if (limits.toolBudgetNotices === true && limits.maxToolCalls === void 0) events?.emit({
9168
+ type: "log",
9169
+ level: "warn",
9170
+ msg: "toolBudgetNotices is enabled but maxToolCalls is not set; the notices are inert"
9171
+ });
9172
+ const firedNotices = /* @__PURE__ */ new Set();
9173
+ /**
9174
+ * Pushes the soft tool-budget notice when an unfired threshold has
9175
+ * been crossed (one message per boundary, carrying the exact counts,
9176
+ * so the model can pace itself before the hard cap). The notice is an
9177
+ * ordinary user message: it rides checkpoints and transcripts, so a
9178
+ * resume never re-fires a threshold the restored count already
9179
+ * crossed.
9180
+ */
9181
+ const maybePushBudgetNotice = () => {
9182
+ if (limits.toolBudgetNotices !== true || limits.maxToolCalls === void 0) return;
9183
+ const crossed = crossedNoticeThresholds(toolCallsUsed, limits.maxToolCalls).filter((threshold) => !firedNotices.has(threshold));
9184
+ if (crossed.length === 0) return;
9185
+ for (const threshold of crossed) firedNotices.add(threshold);
9186
+ messages.push({
9187
+ role: "user",
9188
+ parts: [{
9189
+ type: "text",
9190
+ text: toolBudgetNoticeText(toolCallsUsed, limits.maxToolCalls)
9191
+ }]
9192
+ });
9193
+ };
8981
9194
  const modelRetryCounts = /* @__PURE__ */ new Map();
8982
9195
  let lastTurnUsage = {
8983
9196
  inputTokens: 0,
@@ -9004,6 +9217,8 @@ async function runAgent(options) {
9004
9217
  addPhaseUsage(slice.role ?? primaryRole, slice.servedBy, sliceUsage);
9005
9218
  options.budget?.onUsage(sliceUsage, slice.servedBy);
9006
9219
  }
9220
+ guard?.restore(messages);
9221
+ if (limits.toolBudgetNotices === true && limits.maxToolCalls !== void 0) for (const threshold of crossedNoticeThresholds(toolCallsUsed, limits.maxToolCalls)) firedNotices.add(threshold);
9007
9222
  }
9008
9223
  const usageSlices = () => [...usageByPhaseModel.values()].map(({ role, servedBy: sliceServedBy, usage }) => ({
9009
9224
  servedBy: sliceServedBy,
@@ -9222,8 +9437,25 @@ async function runAgent(options) {
9222
9437
  finished: finishArgs.result ?? null
9223
9438
  };
9224
9439
  }
9440
+ if (guard !== void 0) {
9441
+ const guardVerdict = guard.beforeExecute(gatedCall.name, gatedCall.args);
9442
+ if (guardVerdict.deny) {
9443
+ events?.emit({
9444
+ type: "tool:end",
9445
+ toolName: gatedCall.name,
9446
+ outcome: "denied",
9447
+ durationMs: now() - gateStartedAt,
9448
+ guard: "repeated-signature"
9449
+ });
9450
+ parts.push(errorPart(call, {
9451
+ error: guardVerdict.reason,
9452
+ guard: "repeated-signature"
9453
+ }));
9454
+ continue;
9455
+ }
9456
+ }
9225
9457
  toolCallsUsed += 1;
9226
- parts.push(await executeToolCall({
9458
+ const executedPart = await executeToolCall({
9227
9459
  call: gatedCall,
9228
9460
  runtime,
9229
9461
  retryCounts: modelRetryCounts,
@@ -9231,7 +9463,16 @@ async function runAgent(options) {
9231
9463
  ...events === void 0 ? {} : { events },
9232
9464
  ...gateAudit === void 0 ? {} : { audit: gateAudit },
9233
9465
  now
9234
- }));
9466
+ });
9467
+ parts.push(executedPart);
9468
+ if (guard !== void 0) {
9469
+ const executedRecord = executedPart;
9470
+ if (guard.afterExecute(gatedCall.name, gatedCall.args, executedRecord.result, executedRecord.isError === true)) return {
9471
+ parts,
9472
+ limitHit: true,
9473
+ guardTrip: true
9474
+ };
9475
+ }
9235
9476
  }
9236
9477
  return {
9237
9478
  parts,
@@ -9249,7 +9490,7 @@ async function runAgent(options) {
9249
9490
  if (record.isError === true) part.isError = true;
9250
9491
  return part;
9251
9492
  });
9252
- const { parts, limitHit, escalated, finished } = await runToolCalls([restored.pending.awaiting, ...restored.pending.remaining], priorParts);
9493
+ const { parts, limitHit, escalated, finished, guardTrip } = await runToolCalls([restored.pending.awaiting, ...restored.pending.remaining], priorParts);
9253
9494
  if (parts.length > 0) messages.push({
9254
9495
  role: "tool",
9255
9496
  parts
@@ -9261,8 +9502,20 @@ async function runAgent(options) {
9261
9502
  output = finished;
9262
9503
  finishedViaTool = true;
9263
9504
  await saveBoundary();
9264
- } else if (limitHit) status = "limit";
9265
- else await saveBoundary();
9505
+ } else if (limitHit) {
9506
+ status = "limit";
9507
+ if (guardTrip === true && guard !== void 0) {
9508
+ abortClass = "exploration";
9509
+ agentError = {
9510
+ kind: "terminal",
9511
+ retryable: false
9512
+ };
9513
+ errorMessage = guard.describeTrip();
9514
+ }
9515
+ } else {
9516
+ maybePushBudgetNotice();
9517
+ await saveBoundary();
9518
+ }
9266
9519
  }
9267
9520
  const separateExtract = options.extract !== void 0 && options.schema !== void 0;
9268
9521
  events?.emit({
@@ -9597,7 +9850,7 @@ async function runAgent(options) {
9597
9850
  }
9598
9851
  if (options.tools !== void 0 && outcome.turn.toolCalls.length > 0) {
9599
9852
  noProgress.recordTurn({ toolCalls: outcome.turn.toolCalls.length });
9600
- const { parts, limitHit, escalated, finished } = await runToolCalls(outcome.turn.toolCalls, []);
9853
+ const { parts, limitHit, escalated, finished, guardTrip } = await runToolCalls(outcome.turn.toolCalls, []);
9601
9854
  if (parts.length > 0) messages.push({
9602
9855
  role: "tool",
9603
9856
  parts
@@ -9615,8 +9868,17 @@ async function runAgent(options) {
9615
9868
  }
9616
9869
  if (limitHit) {
9617
9870
  status = "limit";
9871
+ if (guardTrip === true && guard !== void 0) {
9872
+ abortClass = "exploration";
9873
+ agentError = {
9874
+ kind: "terminal",
9875
+ retryable: false
9876
+ };
9877
+ errorMessage = guard.describeTrip();
9878
+ }
9618
9879
  break;
9619
9880
  }
9881
+ maybePushBudgetNotice();
9620
9882
  if (options.summarize !== void 0 && !compactionDisabled && shouldCompact({
9621
9883
  lastTurnUsage,
9622
9884
  contextWindow: options.adapter.caps(options.resolved.model).contextWindow,
@@ -10057,6 +10319,7 @@ async function runAgent(options) {
10057
10319
  if (escalationRequest !== void 0) result.escalationRequest = escalationRequest;
10058
10320
  if (abortClass !== void 0) result.abortClass = abortClass;
10059
10321
  if (errorMessage !== void 0) result.errorMessage = errorMessage;
10322
+ if (guard !== void 0) result.exploration = guard.summary(toolCallsUsed);
10060
10323
  if (usageApprox) result.usageApprox = true;
10061
10324
  if (transportRetries > 0) result.transportRetries = transportRetries;
10062
10325
  return result;
@@ -11891,8 +12154,9 @@ function createCtx(internals, rootWorkflow) {
11891
12154
  if (terminal?.artifacts !== void 0) result.artifacts = terminal.artifacts;
11892
12155
  if (terminal?.status === "escalated" && terminal.escalation !== void 0) result.escalation = terminal.escalation;
11893
12156
  {
11894
- const stamped = (terminal?.error?.data)?.abortClass;
11895
- if (stamped !== void 0) result.abortClass = stamped;
12157
+ const stampedData = terminal?.error?.data;
12158
+ if (stampedData?.abortClass !== void 0) result.abortClass = stampedData.abortClass;
12159
+ if (stampedData?.exploration !== void 0) result.exploration = stampedData.exploration;
11896
12160
  }
11897
12161
  let replayedToolResults = [];
11898
12162
  if (matched.kind === "replay" && terminal?.checkpointRef !== void 0) {
@@ -11956,7 +12220,8 @@ function createCtx(internals, rootWorkflow) {
11956
12220
  usage,
11957
12221
  costUsd,
11958
12222
  entryRef: terminal?.seq ?? matched.running.seq,
11959
- ...terminal?.usageApprox === true ? { usageApprox: true } : {}
12223
+ ...terminal?.usageApprox === true ? { usageApprox: true } : {},
12224
+ ...result.exploration === void 0 ? {} : { exploration: result.exploration }
11960
12225
  }, spanId, true);
11961
12226
  for (const slice of replayPriced?.priced ?? []) bump(internals.cost.byModel, slice.servedBy, slice.usd);
11962
12227
  for (const slice of replayPriced?.unpriced ?? []) internals.cost.unpriced.push({
@@ -12423,7 +12688,8 @@ function createCtx(internals, rootWorkflow) {
12423
12688
  ...terminalPatch.error,
12424
12689
  data: {
12425
12690
  ...dataRecord,
12426
- abortClass: result.abortClass
12691
+ abortClass: result.abortClass,
12692
+ ...result.abortClass === "exploration" && result.exploration !== void 0 ? { exploration: result.exploration } : {}
12427
12693
  }
12428
12694
  };
12429
12695
  }
@@ -12439,7 +12705,8 @@ function createCtx(internals, rootWorkflow) {
12439
12705
  costUsd: result.costUsd,
12440
12706
  entryRef: terminal.seq,
12441
12707
  ...resultUsageApprox ? { usageApprox: true } : {},
12442
- ...result.transportRetries !== void 0 && result.transportRetries > 0 ? { retryCount: result.transportRetries } : {}
12708
+ ...result.transportRetries !== void 0 && result.transportRetries > 0 ? { retryCount: result.transportRetries } : {},
12709
+ ...result.exploration === void 0 ? {} : { exploration: result.exploration }
12443
12710
  }, spanId);
12444
12711
  if (result.status === "escalated" && result.escalation !== void 0) {
12445
12712
  let decision = flavorBDecision;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/core",
3
- "version": "1.50.0",
3
+ "version": "1.52.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",