@rulvar/core 1.202.0 → 1.204.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
@@ -381,6 +381,14 @@ type AgentError = {
381
381
  retryable: boolean;
382
382
  retryAfterMs?: number;
383
383
  issues?: Issue$1[];
384
+ /**
385
+ * The typed refusal marker (RV2002): 'exposure-drained' names a
386
+ * spawned child refused pre-wire by the in-flight exposure cap with
387
+ * no live holder left to wait out. Zero provider attempts by
388
+ * construction, so the seat is cheap to re-spawn; an orchestrator
389
+ * treats it as a starved seat, never a crashed child.
390
+ */
391
+ reason?: "exposure-drained";
384
392
  };
385
393
  /**
386
394
  * Projects an AgentError to its WireError form: code 'agent', with kind,
@@ -2438,7 +2446,8 @@ type AgentEvents = {
2438
2446
  } | {
2439
2447
  type: "budget:exposure-wait";
2440
2448
  agentType: string;
2441
- label?: string; /** The refused model ref. */
2449
+ label?: string; /** The waiting party: the orchestrate root or a spawned child. */
2450
+ scope?: "root" | "child"; /** The refused model ref. */
2442
2451
  model?: string; /** The refusal arithmetic, verbatim from the typed refusal. */
2443
2452
  capUsd?: number;
2444
2453
  spentUsd?: number;
@@ -5489,12 +5498,19 @@ interface RunAgentOptions<S extends SchemaSpec = JsonSchema> {
5489
5498
  /**
5490
5499
  * The exposure-wait posture (RV1902): an in-flight exposure refusal
5491
5500
  * on this invocation parks until a live hold releases and retries
5492
- * pre-wire, instead of settling a budget error. Set only by the
5493
- * orchestrate-owned root dispatches (the coordination loop, the
5494
- * synthesis invocation, the forced-finish wake), whose settle would
5495
- * tear down the run its own admitted children are still funding.
5496
- */
5497
- exposureWait?: boolean;
5501
+ * pre-wire, instead of settling a budget error. `true` is set only
5502
+ * by the orchestrate-owned root dispatches (the coordination loop,
5503
+ * the synthesis invocation, the forced-finish wake), whose settle
5504
+ * would tear down the run its own admitted children are still
5505
+ * funding. `'child'` (RV2002) rides on orchestrator-spawned
5506
+ * children: the same park-and-retry, but the drained arm (no live
5507
+ * holder left to wait out) dies as the typed cheap
5508
+ * 'exposure-drained' refusal instead of the raw budget error, so
5509
+ * the orchestrator can tell a starved seat apart from a crashed
5510
+ * child and re-spawn it; the third parity rerun terminally killed
5511
+ * three mid-research workers on exactly this path.
5512
+ */
5513
+ exposureWait?: boolean | "child";
5498
5514
  events?: RuntimeEventSink;
5499
5515
  transcript?: {
5500
5516
  mintRef(): string;
@@ -6199,6 +6215,25 @@ declare class RunBudget {
6199
6215
  /** Live dispatch estimates held by reserveTurnExposure (RV711). */
6200
6216
  private inFlightExposureUsd;
6201
6217
  /**
6218
+ * The same live estimates attributed to their holders (RV2001): one
6219
+ * entry per holder scope with a nonzero balance, kept in lockstep
6220
+ * with the scalar above by every acquire and release. The holder is
6221
+ * the agent invocation the dispatch belongs to, so a terminal can
6222
+ * return whatever its agent still holds; entries at zero are removed,
6223
+ * making the map size the live holder count.
6224
+ */
6225
+ private readonly exposureHolds;
6226
+ /**
6227
+ * Live holds taken without a holder attribution (a direct caller of
6228
+ * reserveTurnExposure): counted so the zero-holders snap below knows
6229
+ * when NOTHING is held. Subtraction leaves float residue (three 0.18
6230
+ * releases leave 5.5e-17), and a residue above zero would park the
6231
+ * exposure wait on money nobody holds, the epsilon-scale rebirth of
6232
+ * the very deadlock RV2001 closes; when the last hold of any kind
6233
+ * releases, the scalar snaps to exactly zero.
6234
+ */
6235
+ private unattributedHoldCount;
6236
+ /**
6202
6237
  * Waiters parked on the next exposure release (RV1902): the
6203
6238
  * orchestrate root's dispatch waits out a transient refusal here
6204
6239
  * instead of settling a budget error. Notified (and self-removed)
@@ -6418,7 +6453,37 @@ declare class RunBudget {
6418
6453
  * (committedReserveUsd) stay out of the formula, because a child's
6419
6454
  * lifetime reserve and its own turn exposure would double-count.
6420
6455
  */
6421
- reserveTurnExposure(servedBy: ModelRef, estimatedInputTokens: number, plannedOutputTokens: number): (() => void) | undefined;
6456
+ reserveTurnExposure(servedBy: ModelRef, estimatedInputTokens: number, plannedOutputTokens: number, holderScope?: string): (() => void) | undefined;
6457
+ /**
6458
+ * The one release chokepoint of the exposure scalar (RV2001):
6459
+ * subtracts, snaps to exactly zero when no hold of any kind remains
6460
+ * (float subtraction leaves residue, and a residue would park the
6461
+ * exposure wait on money nobody holds), and wakes the parked
6462
+ * waiters. Spend never shrinks, so releases stay the only wake
6463
+ * source that can turn a refusal into a fit.
6464
+ */
6465
+ private settleExposureRelease;
6466
+ /**
6467
+ * The terminal backstop of the exposure surface (RV2001, the third
6468
+ * parity rerun's quiescence deadlock): EVERY terminal of an agent
6469
+ * invocation (ok, error, exhausted, cancelled) returns whatever live
6470
+ * dispatch estimates that holder still has to the exposure budget.
6471
+ * The attempt settle owns the per-hold closure in a finally, so this
6472
+ * usually finds nothing; the parity crash proved a dispatch path can
6473
+ * die without its closure (three killed children left 0.478 USD of
6474
+ * live estimates parked against the cap forever, and the root's
6475
+ * exposure wait starved on money no live dispatch was holding). A
6476
+ * real release wakes the parked waiters exactly like the closure
6477
+ * does; a holder with nothing held is a free no-op. Returns the USD
6478
+ * actually returned.
6479
+ */
6480
+ releaseExposureHolder(holderScope: string): number;
6481
+ /**
6482
+ * Live exposure holders: agents with a nonzero held balance (RV2001).
6483
+ * Zero with live waiters means nothing can ever release, the drained
6484
+ * signal the quiescence machinery keys on.
6485
+ */
6486
+ get liveExposureHolderCount(): number;
6422
6487
  /** Live in-flight exposure currently held by open dispatches (RV1902). */
6423
6488
  get liveExposureUsd(): number;
6424
6489
  /**
package/dist/index.js CHANGED
@@ -413,6 +413,7 @@ var DeterminismError = class extends RulvarError {
413
413
  function agentErrorToWire(error, message) {
414
414
  const data = { kind: error.kind };
415
415
  if (error.retryAfterMs !== void 0) data.retryAfterMs = error.retryAfterMs;
416
+ if (error.reason !== void 0) data.reason = error.reason;
416
417
  if (error.issues !== void 0) data.issues = error.issues.map((issue) => {
417
418
  const out = { message: issue.message };
418
419
  if (issue.path !== void 0) out.path = issue.path.map((segment) => {
@@ -12368,20 +12369,30 @@ async function runAgent(options) {
12368
12369
  } catch (thrown) {
12369
12370
  const refusalData = thrown instanceof BudgetExhaustedError ? thrown.data : void 0;
12370
12371
  const awaitRelease = options.budget?.awaitExposureRelease;
12371
- if (options.exposureWait !== true || refusalData?.reason !== "in-flight-exposure" || awaitRelease === void 0) throw thrown;
12372
+ if (options.exposureWait !== true && options.exposureWait !== "child" || refusalData?.reason !== "in-flight-exposure" || awaitRelease === void 0) throw thrown;
12373
+ const waitScope = options.exposureWait === "child" ? "child" : "root";
12372
12374
  const willWait = (options.budget?.liveExposureUsd?.() ?? 0) > 0;
12373
12375
  events?.emit({
12374
12376
  type: "budget:exposure-wait",
12375
12377
  agentType,
12376
12378
  label: options.label,
12377
12379
  model: target.resolved.ref,
12380
+ scope: waitScope,
12378
12381
  ...typeof refusalData.capUsd === "number" ? { capUsd: refusalData.capUsd } : {},
12379
12382
  ...typeof refusalData.spentUsd === "number" ? { spentUsd: refusalData.spentUsd } : {},
12380
12383
  ...typeof refusalData.inFlightUsd === "number" ? { inFlightUsd: refusalData.inFlightUsd } : {},
12381
12384
  ...typeof refusalData.estimateUsd === "number" ? { estimateUsd: refusalData.estimateUsd } : {},
12382
12385
  willWait
12383
12386
  });
12384
- if (!willWait) throw thrown;
12387
+ if (!willWait) {
12388
+ if (waitScope === "child") throw new BudgetExhaustedError(`exposure pool drained for the spawned child: ${thrown instanceof Error ? thrown.message : String(thrown)}`, { data: {
12389
+ reason: "exposure-drained",
12390
+ ...typeof refusalData.capUsd === "number" ? { capUsd: refusalData.capUsd } : {},
12391
+ ...typeof refusalData.spentUsd === "number" ? { spentUsd: refusalData.spentUsd } : {},
12392
+ ...typeof refusalData.estimateUsd === "number" ? { estimateUsd: refusalData.estimateUsd } : {}
12393
+ } });
12394
+ throw thrown;
12395
+ }
12385
12396
  const waitSignals = [options.signal, options.budget?.signal].filter((candidate) => candidate !== void 0);
12386
12397
  await awaitRelease(waitSignals.length === 0 ? void 0 : AbortSignal.any(waitSignals));
12387
12398
  continue;
@@ -12604,7 +12615,8 @@ async function runAgent(options) {
12604
12615
  status = "error";
12605
12616
  agentError = {
12606
12617
  kind: "budget",
12607
- retryable: false
12618
+ retryable: false,
12619
+ ...thrown.data?.reason === "exposure-drained" ? { reason: "exposure-drained" } : {}
12608
12620
  };
12609
12621
  errorMessage = thrown.message;
12610
12622
  break;
@@ -13523,6 +13535,25 @@ var RunBudget = class {
13523
13535
  /** Live dispatch estimates held by reserveTurnExposure (RV711). */
13524
13536
  inFlightExposureUsd = 0;
13525
13537
  /**
13538
+ * The same live estimates attributed to their holders (RV2001): one
13539
+ * entry per holder scope with a nonzero balance, kept in lockstep
13540
+ * with the scalar above by every acquire and release. The holder is
13541
+ * the agent invocation the dispatch belongs to, so a terminal can
13542
+ * return whatever its agent still holds; entries at zero are removed,
13543
+ * making the map size the live holder count.
13544
+ */
13545
+ exposureHolds = /* @__PURE__ */ new Map();
13546
+ /**
13547
+ * Live holds taken without a holder attribution (a direct caller of
13548
+ * reserveTurnExposure): counted so the zero-holders snap below knows
13549
+ * when NOTHING is held. Subtraction leaves float residue (three 0.18
13550
+ * releases leave 5.5e-17), and a residue above zero would park the
13551
+ * exposure wait on money nobody holds, the epsilon-scale rebirth of
13552
+ * the very deadlock RV2001 closes; when the last hold of any kind
13553
+ * releases, the scalar snaps to exactly zero.
13554
+ */
13555
+ unattributedHoldCount = 0;
13556
+ /**
13526
13557
  * Waiters parked on the next exposure release (RV1902): the
13527
13558
  * orchestrate root's dispatch waits out a transient refusal here
13528
13559
  * instead of settling a budget error. Notified (and self-removed)
@@ -13920,7 +13951,7 @@ var RunBudget = class {
13920
13951
  * (committedReserveUsd) stay out of the formula, because a child's
13921
13952
  * lifetime reserve and its own turn exposure would double-count.
13922
13953
  */
13923
- reserveTurnExposure(servedBy, estimatedInputTokens, plannedOutputTokens) {
13954
+ reserveTurnExposure(servedBy, estimatedInputTokens, plannedOutputTokens, holderScope) {
13924
13955
  const cap = this.maxInFlightExposureUsd;
13925
13956
  if (cap === void 0) return;
13926
13957
  const pricing = this.pricingOf?.(servedBy);
@@ -13941,14 +13972,69 @@ var RunBudget = class {
13941
13972
  estimateUsd
13942
13973
  } });
13943
13974
  this.inFlightExposureUsd += estimateUsd;
13975
+ if (estimateUsd > 0) if (holderScope === void 0) this.unattributedHoldCount += 1;
13976
+ else this.exposureHolds.set(holderScope, (this.exposureHolds.get(holderScope) ?? 0) + estimateUsd);
13944
13977
  let released = false;
13945
13978
  return () => {
13946
13979
  if (released) return;
13947
13980
  released = true;
13948
- this.inFlightExposureUsd = Math.max(0, this.inFlightExposureUsd - estimateUsd);
13949
- for (const waiter of [...this.exposureWaiters]) waiter();
13981
+ let amount = estimateUsd;
13982
+ if (estimateUsd > 0) if (holderScope === void 0) this.unattributedHoldCount = Math.max(0, this.unattributedHoldCount - 1);
13983
+ else {
13984
+ const held = this.exposureHolds.get(holderScope) ?? 0;
13985
+ amount = Math.min(amount, held);
13986
+ const next = held - amount;
13987
+ if (next <= 0) this.exposureHolds.delete(holderScope);
13988
+ else this.exposureHolds.set(holderScope, next);
13989
+ }
13990
+ this.settleExposureRelease(amount);
13950
13991
  };
13951
13992
  }
13993
+ /**
13994
+ * The one release chokepoint of the exposure scalar (RV2001):
13995
+ * subtracts, snaps to exactly zero when no hold of any kind remains
13996
+ * (float subtraction leaves residue, and a residue would park the
13997
+ * exposure wait on money nobody holds), and wakes the parked
13998
+ * waiters. Spend never shrinks, so releases stay the only wake
13999
+ * source that can turn a refusal into a fit.
14000
+ */
14001
+ settleExposureRelease(amountUsd) {
14002
+ this.inFlightExposureUsd = Math.max(0, this.inFlightExposureUsd - amountUsd);
14003
+ if (this.exposureHolds.size === 0 && this.unattributedHoldCount === 0) this.inFlightExposureUsd = 0;
14004
+ for (const waiter of [...this.exposureWaiters]) waiter();
14005
+ }
14006
+ /**
14007
+ * The terminal backstop of the exposure surface (RV2001, the third
14008
+ * parity rerun's quiescence deadlock): EVERY terminal of an agent
14009
+ * invocation (ok, error, exhausted, cancelled) returns whatever live
14010
+ * dispatch estimates that holder still has to the exposure budget.
14011
+ * The attempt settle owns the per-hold closure in a finally, so this
14012
+ * usually finds nothing; the parity crash proved a dispatch path can
14013
+ * die without its closure (three killed children left 0.478 USD of
14014
+ * live estimates parked against the cap forever, and the root's
14015
+ * exposure wait starved on money no live dispatch was holding). A
14016
+ * real release wakes the parked waiters exactly like the closure
14017
+ * does; a holder with nothing held is a free no-op. Returns the USD
14018
+ * actually returned.
14019
+ */
14020
+ releaseExposureHolder(holderScope) {
14021
+ const held = this.exposureHolds.get(holderScope) ?? 0;
14022
+ if (held <= 0) {
14023
+ this.exposureHolds.delete(holderScope);
14024
+ return 0;
14025
+ }
14026
+ this.exposureHolds.delete(holderScope);
14027
+ this.settleExposureRelease(held);
14028
+ return held;
14029
+ }
14030
+ /**
14031
+ * Live exposure holders: agents with a nonzero held balance (RV2001).
14032
+ * Zero with live waiters means nothing can ever release, the drained
14033
+ * signal the quiescence machinery keys on.
14034
+ */
14035
+ get liveExposureHolderCount() {
14036
+ return this.exposureHolds.size;
14037
+ }
13952
14038
  /** Live in-flight exposure currently held by open dispatches (RV1902). */
13953
14039
  get liveExposureUsd() {
13954
14040
  return this.inFlightExposureUsd;
@@ -16399,9 +16485,16 @@ const kFinalizeReserve = Symbol("rulvar.finalizeReserve");
16399
16485
  * benchmark's recovery arm died exactly there: the refusal is transient
16400
16486
  * by contract (budgets guide), but the refused agent was the workflow's
16401
16487
  * coordinating root, so its settle tore down the whole run while four
16402
- * admitted children were still finalizing. Never part of the public
16403
- * AgentOpts surface; plain agents keep the documented settle-as-budget-
16404
- * error behavior, because their caller can catch and decide.
16488
+ * admitted children were still finalizing. The 'child' flavor (RV2002)
16489
+ * rides on orchestrator-spawned children (spawn_agent and
16490
+ * parallel_agents): the same park-and-retry, but a DRAINED refusal
16491
+ * (no live holder left to wait out) dies as the typed cheap
16492
+ * 'exposure-drained' child refusal the orchestrator can tell apart
16493
+ * from a crash and re-spawn, instead of the root's forced-finish
16494
+ * partial; the third parity rerun killed three mid-research workers
16495
+ * on exactly this path. Never part of the public AgentOpts surface;
16496
+ * plain agents keep the documented settle-as-budget-error behavior,
16497
+ * because their caller can catch and decide.
16405
16498
  */
16406
16499
  const kExposureWait = Symbol("rulvar.exposureWait");
16407
16500
  /** Typed accessor used by the in-package consumers. */
@@ -17761,7 +17854,7 @@ function createCtx(internals, rootWorkflow) {
17761
17854
  remainingUsd: () => internals.budget.remainingUsd(budgetAccount),
17762
17855
  ...internals.budget.strictPricing === void 0 ? {} : { assertPricedDispatch: (servedBy) => internals.budget.assertPricedDispatch(servedBy) },
17763
17856
  ...internals.budget.maxInFlightExposureUsd === void 0 ? {} : {
17764
- admitTurnExposure: (servedBy, estimatedInputTokens, plannedOutputTokens) => internals.budget.reserveTurnExposure(servedBy, estimatedInputTokens, plannedOutputTokens),
17857
+ admitTurnExposure: (servedBy, estimatedInputTokens, plannedOutputTokens) => internals.budget.reserveTurnExposure(servedBy, estimatedInputTokens, plannedOutputTokens, `agent:${running.seq}`),
17765
17858
  awaitExposureRelease: (signal) => internals.budget.awaitExposureRelease(signal),
17766
17859
  liveExposureUsd: () => internals.budget.liveExposureUsd
17767
17860
  },
@@ -17778,7 +17871,10 @@ function createCtx(internals, rootWorkflow) {
17778
17871
  if (escalation !== void 0) runAgentOptions.escalation = { minSpendUsd: escalation.minSpendUsd ?? 0 };
17779
17872
  const terminalTool = opts[kTerminalTool];
17780
17873
  if (terminalTool !== void 0) runAgentOptions.terminalTool = terminalTool;
17781
- if (opts[kExposureWait] === true) runAgentOptions.exposureWait = true;
17874
+ {
17875
+ const exposureWait = opts[kExposureWait];
17876
+ if (exposureWait === true || exposureWait === "child") runAgentOptions.exposureWait = exposureWait;
17877
+ }
17782
17878
  runAgentOptions.checkpoint = checkpointPlumbing;
17783
17879
  if (opts.schema !== void 0) runAgentOptions.schema = opts.schema;
17784
17880
  if (canonicalSchema !== void 0) runAgentOptions.canonicalSchema = canonicalSchema;
@@ -17865,6 +17961,7 @@ function createCtx(internals, rootWorkflow) {
17865
17961
  }, spanId), branchOrRunSignal);
17866
17962
  } finally {
17867
17963
  exitActivity?.();
17964
+ internals.budget.releaseExposureHolder(`agent:${running.seq}`);
17868
17965
  }
17869
17966
  internals.budget.releaseReserve(reserve, budgetAccount);
17870
17967
  const declaredRules = internals.quota?.declaredRules;
@@ -21395,7 +21492,8 @@ function makeOrchestratorWorkflow(goal, opts) {
21395
21492
  agentType: spec.agentType,
21396
21493
  result: "full",
21397
21494
  ...resolveDispatchOpts(spec, internals.defaults),
21398
- [kOnRunning]: (seq) => resolveHandle(seq)
21495
+ [kOnRunning]: (seq) => resolveHandle(seq),
21496
+ [kExposureWait]: "child"
21399
21497
  };
21400
21498
  const result = runtime.runInScope(childState, () => ctx.agent(spec.prompt, agentOpts));
21401
21499
  const PRE_ROOT_FAILED = -1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/core",
3
- "version": "1.202.0",
3
+ "version": "1.204.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",