@librechat/agents 3.3.4 → 3.3.5

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 (159) hide show
  1. package/dist/cjs/common/constants.cjs +21 -0
  2. package/dist/cjs/common/constants.cjs.map +1 -1
  3. package/dist/cjs/events.cjs +10 -1
  4. package/dist/cjs/events.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +456 -7
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/hooks/HookRegistry.cjs +25 -0
  8. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  9. package/dist/cjs/hooks/index.cjs +12 -0
  10. package/dist/cjs/hooks/index.cjs.map +1 -1
  11. package/dist/cjs/hooks/types.cjs +1 -0
  12. package/dist/cjs/hooks/types.cjs.map +1 -1
  13. package/dist/cjs/langfuse.cjs +8 -0
  14. package/dist/cjs/langfuse.cjs.map +1 -1
  15. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +1 -3
  16. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  17. package/dist/cjs/llm/invoke.cjs +268 -3
  18. package/dist/cjs/llm/invoke.cjs.map +1 -1
  19. package/dist/cjs/llm/preempt.cjs +132 -0
  20. package/dist/cjs/llm/preempt.cjs.map +1 -0
  21. package/dist/cjs/main.cjs +17 -3
  22. package/dist/cjs/messages/alternation.cjs +92 -0
  23. package/dist/cjs/messages/alternation.cjs.map +1 -0
  24. package/dist/cjs/messages/format.cjs +72 -0
  25. package/dist/cjs/messages/format.cjs.map +1 -1
  26. package/dist/cjs/messages/handoffCue.cjs +63 -0
  27. package/dist/cjs/messages/handoffCue.cjs.map +1 -0
  28. package/dist/cjs/messages/index.cjs +3 -0
  29. package/dist/cjs/messages/injected.cjs +60 -0
  30. package/dist/cjs/messages/injected.cjs.map +1 -0
  31. package/dist/cjs/run.cjs +80 -7
  32. package/dist/cjs/run.cjs.map +1 -1
  33. package/dist/cjs/session/handlers.cjs +18 -9
  34. package/dist/cjs/session/handlers.cjs.map +1 -1
  35. package/dist/cjs/stream.cjs +28 -7
  36. package/dist/cjs/stream.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +3 -23
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
  40. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
  41. package/dist/cjs/tools/search/tool.cjs +9 -1
  42. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  43. package/dist/esm/common/constants.mjs +19 -1
  44. package/dist/esm/common/constants.mjs.map +1 -1
  45. package/dist/esm/events.mjs +10 -1
  46. package/dist/esm/events.mjs.map +1 -1
  47. package/dist/esm/graphs/Graph.mjs +455 -6
  48. package/dist/esm/graphs/Graph.mjs.map +1 -1
  49. package/dist/esm/hooks/HookRegistry.mjs +25 -0
  50. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  51. package/dist/esm/hooks/index.mjs +12 -1
  52. package/dist/esm/hooks/index.mjs.map +1 -1
  53. package/dist/esm/hooks/types.mjs +1 -0
  54. package/dist/esm/hooks/types.mjs.map +1 -1
  55. package/dist/esm/langfuse.mjs +9 -1
  56. package/dist/esm/langfuse.mjs.map +1 -1
  57. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +1 -3
  58. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  59. package/dist/esm/llm/invoke.mjs +270 -6
  60. package/dist/esm/llm/invoke.mjs.map +1 -1
  61. package/dist/esm/llm/preempt.mjs +131 -0
  62. package/dist/esm/llm/preempt.mjs.map +1 -0
  63. package/dist/esm/main.mjs +8 -5
  64. package/dist/esm/messages/alternation.mjs +91 -0
  65. package/dist/esm/messages/alternation.mjs.map +1 -0
  66. package/dist/esm/messages/format.mjs +72 -0
  67. package/dist/esm/messages/format.mjs.map +1 -1
  68. package/dist/esm/messages/handoffCue.mjs +61 -0
  69. package/dist/esm/messages/handoffCue.mjs.map +1 -0
  70. package/dist/esm/messages/index.mjs +3 -0
  71. package/dist/esm/messages/injected.mjs +60 -0
  72. package/dist/esm/messages/injected.mjs.map +1 -0
  73. package/dist/esm/run.mjs +80 -7
  74. package/dist/esm/run.mjs.map +1 -1
  75. package/dist/esm/session/handlers.mjs +19 -10
  76. package/dist/esm/session/handlers.mjs.map +1 -1
  77. package/dist/esm/stream.mjs +27 -8
  78. package/dist/esm/stream.mjs.map +1 -1
  79. package/dist/esm/tools/ToolNode.mjs +3 -23
  80. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  81. package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
  82. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
  83. package/dist/esm/tools/search/tool.mjs +9 -1
  84. package/dist/esm/tools/search/tool.mjs.map +1 -1
  85. package/dist/types/common/constants.d.ts +18 -0
  86. package/dist/types/graphs/Graph.d.ts +138 -1
  87. package/dist/types/hooks/HookRegistry.d.ts +15 -0
  88. package/dist/types/hooks/index.d.ts +12 -1
  89. package/dist/types/hooks/types.d.ts +45 -6
  90. package/dist/types/index.d.ts +1 -1
  91. package/dist/types/llm/invoke.d.ts +7 -0
  92. package/dist/types/llm/preempt.d.ts +40 -0
  93. package/dist/types/messages/alternation.d.ts +23 -0
  94. package/dist/types/messages/handoffCue.d.ts +40 -0
  95. package/dist/types/messages/index.d.ts +3 -0
  96. package/dist/types/messages/injected.d.ts +3 -0
  97. package/dist/types/run.d.ts +7 -0
  98. package/dist/types/stream.d.ts +14 -0
  99. package/dist/types/tools/ToolNode.d.ts +0 -7
  100. package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
  101. package/dist/types/tools/search/types.d.ts +31 -2
  102. package/dist/types/types/graph.d.ts +8 -1
  103. package/dist/types/types/run.d.ts +65 -0
  104. package/dist/types/types/stream.d.ts +0 -25
  105. package/package.json +1 -1
  106. package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
  107. package/src/common/constants.ts +21 -0
  108. package/src/events.ts +15 -1
  109. package/src/graphs/Graph.ts +568 -3
  110. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +18 -7
  111. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
  112. package/src/hooks/HookRegistry.ts +40 -0
  113. package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
  114. package/src/hooks/index.ts +16 -2
  115. package/src/hooks/types.ts +47 -3
  116. package/src/index.ts +1 -1
  117. package/src/langfuse.ts +26 -1
  118. package/src/llm/bedrock/utils/message_inputs.test.ts +82 -0
  119. package/src/llm/bedrock/utils/message_inputs.ts +14 -11
  120. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
  121. package/src/llm/invoke.alternation.test.ts +87 -0
  122. package/src/llm/invoke.handoffCue.test.ts +112 -0
  123. package/src/llm/invoke.ts +417 -8
  124. package/src/llm/preempt.test.ts +323 -0
  125. package/src/llm/preempt.ts +178 -0
  126. package/src/messages/alternation.test.ts +212 -0
  127. package/src/messages/alternation.ts +112 -0
  128. package/src/messages/format.ts +91 -0
  129. package/src/messages/formatAgentMessages.steer.test.ts +267 -0
  130. package/src/messages/handoffCue.test.ts +96 -0
  131. package/src/messages/handoffCue.ts +78 -0
  132. package/src/messages/index.ts +3 -0
  133. package/src/messages/injected.test.ts +90 -0
  134. package/src/messages/injected.ts +74 -0
  135. package/src/run.ts +91 -6
  136. package/src/scripts/preempt-probe.ts +330 -0
  137. package/src/scripts/preempt-scenarios.ts +388 -0
  138. package/src/session/handlers.ts +32 -12
  139. package/src/specs/handoffCue.test.ts +165 -0
  140. package/src/specs/langfuse-callbacks.test.ts +352 -2
  141. package/src/specs/preemptSeal.test.ts +309 -0
  142. package/src/stream.dispatch.test.ts +63 -0
  143. package/src/stream.ts +36 -8
  144. package/src/tools/ToolNode.ts +3 -31
  145. package/src/tools/search/keenable-scraper.test.ts +153 -0
  146. package/src/tools/search/keenable-scraper.ts +137 -0
  147. package/src/tools/search/tool.ts +13 -2
  148. package/src/tools/search/types.ts +50 -3
  149. package/src/types/graph.ts +12 -1
  150. package/src/types/run.ts +67 -0
  151. package/src/types/stream.ts +0 -41
  152. package/dist/cjs/splitStream.cjs +0 -151
  153. package/dist/cjs/splitStream.cjs.map +0 -1
  154. package/dist/esm/splitStream.mjs +0 -150
  155. package/dist/esm/splitStream.mjs.map +0 -1
  156. package/dist/types/mockStream.d.ts +0 -32
  157. package/dist/types/splitStream.d.ts +0 -37
  158. package/src/mockStream.ts +0 -99
  159. package/src/splitStream.ts +0 -234
@@ -3,11 +3,13 @@ import { resolveLangfuseRuntimeScope, withLangfuseRuntimeScope } from "../langfu
3
3
  import { createLangfuseHandler, createLangfuseTraceMetadata, disposeLangfuseHandler, isLangfuseCallbackHandler } from "../langfuse.mjs";
4
4
  import { shouldTraceToolNodeForLangfuse } from "../langfuseToolOutputTracing.mjs";
5
5
  import { appendCallbacks, findCallback } from "../utils/callbacks.mjs";
6
+ import { PREEMPT_BOUNDARY_HOOK_TIMEOUT_MS } from "../common/constants.mjs";
6
7
  import { GraphNodeKeys } from "../common/enum.mjs";
7
8
  import "../common/index.mjs";
8
9
  import { calculateMaxToolResultChars } from "../utils/truncation.mjs";
9
10
  import { compactToolContent, getToolContentCharLength, serializeToolContentBounded } from "../utils/toolContent.mjs";
10
11
  import { apportionTokenCounts } from "../utils/tokens.mjs";
12
+ import { resolveMaxSeals } from "../llm/preempt.mjs";
11
13
  import { initializeLangfuseTracing } from "../instrumentation.mjs";
12
14
  import { addBedrockTailCacheControl, addTailCacheControl, resolveBedrockPromptCacheTtl, resolvePromptCacheTtl, supportsBedrockToolCache } from "../messages/cache.mjs";
13
15
  import { convertMessagesToContent, projectAnthropicArtifactContent, projectArtifactPayload, projectToolStreamContentForProvider } from "../messages/core.mjs";
@@ -19,6 +21,9 @@ import { ensureThinkingBlockInMessages, foldToolBlocksForToollessAgent, isSynthe
19
21
  import { makeIsDeferred, partitionAndMarkAnthropicToolCache } from "../messages/anthropicToolCache.mjs";
20
22
  import { formatContentStrings } from "../messages/content.mjs";
21
23
  import { extractToolDiscoveries } from "../messages/tools.mjs";
24
+ import { convertInjectedMessages } from "../messages/injected.mjs";
25
+ import { coalesceAdjacentUserTurns, strictAlternationProviders } from "../messages/alternation.mjs";
26
+ import { appendPredecessorHandoffCue, removePredecessorHandoffCue } from "../messages/handoffCue.mjs";
22
27
  import { messagesStateReducer as messagesStateReducer$1 } from "../messages/reducer.mjs";
23
28
  import { splitAtRecencyBoundary } from "../messages/recency.mjs";
24
29
  import "../messages/index.mjs";
@@ -31,9 +36,11 @@ import { sleep } from "../utils/run.mjs";
31
36
  import "../utils/index.mjs";
32
37
  import { partitionAndMarkBedrockToolCache } from "../llm/bedrock/toolCache.mjs";
33
38
  import { initializeModel } from "../llm/init.mjs";
34
- import { attemptInvoke, getFallbackErrorContext, getFallbackOverflowCandidates, projectMessagesForProvider, tryFallbackProviders } from "../llm/invoke.mjs";
39
+ import { attemptInvoke, getFallbackErrorContext, getFallbackOverflowCandidates, projectMessagesForProvider, resolveServingModelId, tryFallbackProviders } from "../llm/invoke.mjs";
35
40
  import { getBlindRecoveryBudget, planContextOverflowRecovery, translateRecoveryBudget } from "../llm/contextOverflowRecovery.mjs";
36
41
  import { partitionAndMarkOpenRouterToolCache } from "../llm/openrouter/toolCache.mjs";
42
+ import { executeHooks } from "../hooks/executeHooks.mjs";
43
+ import "../hooks/index.mjs";
37
44
  import { createLocalCodingToolBundle } from "../tools/local/LocalCodingTools.mjs";
38
45
  import { createCloudflareCodingToolBundle } from "../tools/cloudflare/CloudflareSandboxTools.mjs";
39
46
  import "../tools/cloudflare/index.mjs";
@@ -54,8 +61,24 @@ import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
54
61
  import { tool } from "@langchain/core/tools";
55
62
  import { nanoid } from "nanoid";
56
63
  import { ContextOverflowError } from "@langchain/core/errors";
64
+ import { v4 } from "uuid";
57
65
  //#region src/graphs/Graph.ts
58
66
  const { AGENT, TOOLS, SUMMARIZE } = GraphNodeKeys;
67
+ const EMPTY_PREEMPT_BOUNDARY = {
68
+ messages: [],
69
+ preventContinuation: false
70
+ };
71
+ /**
72
+ * One signal that fires when either input fires. `AbortSignal.any` is skipped
73
+ * when the inputs collapse to a single signal — the composite is a fresh
74
+ * object per call, and the common cases (one channel, or the host reusing the
75
+ * same controller for both) don't need one.
76
+ */
77
+ function composeAbortSignals(a, b) {
78
+ if (a == null || a === b) return b;
79
+ if (b == null) return a;
80
+ return AbortSignal.any([a, b]);
81
+ }
59
82
  /** Minimum relative variance before calibrated toolSchemaTokens overrides current value. */
60
83
  const CALIBRATION_VARIANCE_THRESHOLD = .15;
61
84
  function createToolHandlerRegistry(source) {
@@ -266,6 +289,20 @@ var Graph = class {
266
289
  reasoningStepHasDeltas = /* @__PURE__ */ new Set();
267
290
  handlerDispatchedEventCounts = /* @__PURE__ */ new Map();
268
291
  signal;
292
+ /**
293
+ * The abort signal the CALLER handed to the current `processStream` call,
294
+ * assigned unconditionally — including back to `undefined` — on every call.
295
+ *
296
+ * Kept separate from {@link signal} on purpose. That field is construction
297
+ * state with its own consumers (model-call config, subagent parentSignal),
298
+ * so adopting a per-call signal into it would leak one call's controller
299
+ * into the next — `clearHeavyState()` is skipped on HITL interrupts, so a
300
+ * host that aborts a finished request's controller would poison the resumed
301
+ * run's model calls and boundary drains with an already-aborted signal.
302
+ * Boundary dispatch composes the two instead; see
303
+ * `StandardGraph.dispatchPreemptBoundary`.
304
+ */
305
+ callerSignal;
269
306
  /** Set of invoked tool call IDs from non-message run steps completed mid-run, if any */
270
307
  invokedToolIds;
271
308
  handlerRegistry;
@@ -342,6 +379,7 @@ var Graph = class {
342
379
  clearHeavyState() {
343
380
  this.config = void 0;
344
381
  this.signal = void 0;
382
+ this.callerSignal = void 0;
345
383
  this.contentData = [];
346
384
  this.contentIndexMap = /* @__PURE__ */ new Map();
347
385
  this.stepKeyIds = /* @__PURE__ */ new Map();
@@ -481,6 +519,8 @@ var StandardGraph = class StandardGraph extends Graph {
481
519
  messages = [];
482
520
  /** Cached run messages preserved before clearHeavyState() so getRunMessages() works after cleanup. */
483
521
  cachedRunMessages;
522
+ /** Ids of AI turns the agent node returned THIS run; see isRunProducedMessage. */
523
+ runProducedAiMessageIds = /* @__PURE__ */ new Set();
484
524
  /** Checkpoint scope whose messages match index-keyed tool snapshots. */
485
525
  originalToolContentCheckpointScope;
486
526
  runId;
@@ -506,13 +546,61 @@ var StandardGraph = class StandardGraph extends Graph {
506
546
  subagentUsageSink;
507
547
  /** See {@link t.StandardGraphInput.subagentScope}. */
508
548
  subagentScope;
509
- constructor({ runId, signal, agents, langfuse, tokenCounter, indexTokenCountMap, calibrationRatio, subagentUsageSink, subagentScope }) {
549
+ /** See {@link t.StandardGraphInput.preemption}. */
550
+ preemption;
551
+ /**
552
+ * Seals charged against `preemption.maxSeals`. Per-turn: cleared by both
553
+ * reset paths so a fresh turn gets a fresh budget, while a HITL resume —
554
+ * which skips `resetValues` — keeps what it had left.
555
+ */
556
+ preemptSealBudgetUsed = 0;
557
+ /**
558
+ * Seals honored over the graph's lifetime. Reported by
559
+ * {@link getPreemptStats}, so it deliberately SURVIVES `clearHeavyState()`
560
+ * — a host reads it after `processStream` returns, which is strictly after
561
+ * cleanup runs.
562
+ */
563
+ preemptSealCount = 0;
564
+ /** Boundaries that produced nothing to inject, so the turn stopped early. */
565
+ preemptEmptyBoundaries = 0;
566
+ /**
567
+ * Set between claiming a seal and resolving its boundary. `MultiAgentGraph`
568
+ * fans parallel agents through this one instance against a single host
569
+ * request, so without a one-at-a-time gate several streams would each seal
570
+ * for the same queued message and every loser would take the
571
+ * nothing-to-inject path and cut its answer short.
572
+ */
573
+ preemptSealInFlight = false;
574
+ /**
575
+ * True when a seal ended the turn without a resume. The assistant turn is
576
+ * real and kept, but it is not the answer the model intended to finish —
577
+ * hosts persist it as unfinished rather than complete.
578
+ */
579
+ preemptIncomplete = false;
580
+ /**
581
+ * `stopReason` from a `PreemptBoundary` hook that halted the turn.
582
+ *
583
+ * Clearing the registry halt is what keeps the sealed turn alive, but the
584
+ * registry held the only copy of the reason — so it is captured here first.
585
+ * Without it `getHaltReason()` returns undefined and a host records a
586
+ * hook-halted turn as an ordinary completion.
587
+ */
588
+ preemptHaltReason;
589
+ /**
590
+ * Agent IDs whose next superstep must return to the agent node. Keyed by
591
+ * agent because `MultiAgentGraph` routes every parallel agent through this
592
+ * same instance, and a single field would let one agent's boundary resume
593
+ * another's turn.
594
+ */
595
+ pendingPreemptReturn = /* @__PURE__ */ new Set();
596
+ constructor({ runId, signal, agents, langfuse, tokenCounter, indexTokenCountMap, calibrationRatio, subagentUsageSink, subagentScope, preemption }) {
510
597
  super();
511
598
  this.runId = runId;
512
599
  this.signal = signal;
513
600
  this.langfuse = langfuse;
514
601
  this.subagentUsageSink = subagentUsageSink;
515
602
  this.subagentScope = subagentScope === true;
603
+ this.preemption = preemption;
516
604
  if (agents.length === 0) throw new Error("At least one agent configuration is required");
517
605
  for (const agentConfig of agents) {
518
606
  const agentContext = AgentContext.fromConfig(agentConfig, tokenCounter, indexTokenCountMap);
@@ -537,6 +625,7 @@ var StandardGraph = class StandardGraph extends Graph {
537
625
  * a stale reference on 2nd+ processStream calls.
538
626
  */
539
627
  this.toolCallStepIds.clear();
628
+ this.runProducedAiMessageIds.clear();
540
629
  this.eagerEventToolExecutions.clear();
541
630
  this.clearEagerEventToolUsageCounts();
542
631
  this.eagerEventToolCallChunks.clear();
@@ -548,6 +637,8 @@ var StandardGraph = class StandardGraph extends Graph {
548
637
  this.reasoningStepHasDeltas = resetIfNotEmpty(this.reasoningStepHasDeltas, /* @__PURE__ */ new Set());
549
638
  this.prelimMessageIdsByStepKey = resetIfNotEmpty(this.prelimMessageIdsByStepKey, /* @__PURE__ */ new Map());
550
639
  this.invokedToolIds = resetIfNotEmpty(this.invokedToolIds, void 0);
640
+ this.resetPreemptTurnState();
641
+ this.resetPreemptTotals();
551
642
  const hasScopedCheckpoint = this.hasCompiledCheckpointer && checkpointScope != null && checkpointScope !== "";
552
643
  const preserveOriginalToolContent = hasScopedCheckpoint && this.originalToolContentCheckpointScope === checkpointScope;
553
644
  for (const context of this.agentContexts.values()) context.reset({ preserveOriginalToolContent });
@@ -558,9 +649,98 @@ var StandardGraph = class StandardGraph extends Graph {
558
649
  super.clearHeavyState();
559
650
  this.messages = [];
560
651
  this.overrideModel = void 0;
652
+ /**
653
+ * Turn state only. The reported totals must outlive cleanup — this runs
654
+ * in `processStream`'s `finally`, and the host reads `getPreemptStats()`
655
+ * after that returns.
656
+ */
657
+ this.resetPreemptTurnState();
561
658
  const preserveOriginalToolContent = this.hasCompiledCheckpointer && this.originalToolContentCheckpointScope != null;
562
659
  for (const context of this.agentContexts.values()) context.reset({ preserveOriginalToolContent });
563
660
  }
661
+ /**
662
+ * Per-turn seal budget and routing markers. Cleared by both reset paths so
663
+ * a new turn starts with a full budget and no stale resume marker.
664
+ *
665
+ * The REPORTED counters are deliberately not touched here — see
666
+ * {@link resetPreemptTotals}.
667
+ */
668
+ resetPreemptTurnState() {
669
+ this.preemptSealBudgetUsed = 0;
670
+ this.preemptSealInFlight = false;
671
+ this.pendingPreemptReturn.clear();
672
+ }
673
+ /**
674
+ * Lifetime seal totals, cleared only when a genuinely new run starts.
675
+ * `clearHeavyState()` must NOT call this: it runs in `processStream`'s
676
+ * `finally`, so zeroing here would make {@link getPreemptStats} and
677
+ * `preemptIncomplete` unreadable for every caller of the method that just
678
+ * produced them.
679
+ */
680
+ resetPreemptTotals() {
681
+ this.preemptSealCount = 0;
682
+ this.preemptEmptyBoundaries = 0;
683
+ this.preemptIncomplete = false;
684
+ this.preemptHaltReason = void 0;
685
+ }
686
+ /**
687
+ * True when the host has requested a cooperative seal AND this graph may
688
+ * honor it. Read once per streamed chunk, so it stays property reads plus
689
+ * one host callback — no I/O, no allocation.
690
+ *
691
+ * Non-mutating: a true result only means a seal is worth evaluating. The
692
+ * budget is taken by {@link claimPreemptSeal} once the accumulated chunk is
693
+ * known to be safe, so a chunk that cannot seal never spends budget.
694
+ *
695
+ * Subagent scopes never seal: a steer targets the top-level conversation,
696
+ * and a child run must finish so its parent sees a complete result.
697
+ */
698
+ /** Internal seal preconditions only — no host callback, no side effects. */
699
+ canClaimPreemptSeal() {
700
+ /**
701
+ * Resolved and required here with the same rule `dispatchPreemptBoundary`
702
+ * uses. Without it a direct `StandardGraph` consumer that supplies no
703
+ * `runId` could claim a seal on the strength of a global matcher, then hit
704
+ * the boundary's own null-runId guard and get nothing back — truncating
705
+ * the answer for a drain that provably could not run.
706
+ */
707
+ const runId = this.config?.configurable?.run_id ?? this.runId;
708
+ return !this.subagentScope && this.preemption != null && !this.preemptSealInFlight && this.preemptSealBudgetUsed < resolveMaxSeals(this.preemption.maxSeals) && runId != null && this.hookRegistry?.hasDispatchableHookFor("PreemptBoundary", runId) === true;
709
+ }
710
+ shouldPreemptStream() {
711
+ return this.canClaimPreemptSeal() && this.preemption?.shouldPreempt() === true;
712
+ }
713
+ /**
714
+ * Takes the seal slot, or returns false if another stream already holds it.
715
+ *
716
+ * Assumes the caller already polled `shouldPreemptStream()` for THIS chunk,
717
+ * and deliberately does not poll the host again — `StreamPreemption`
718
+ * documents `shouldPreempt` as once per chunk, and a host that consumes a
719
+ * pending flag on read would lose the request to a second call.
720
+ *
721
+ * The guard and both mutations remain one synchronous body, which is what
722
+ * makes this safe under a parallel `MultiAgentGraph`: several agents share
723
+ * one graph and can each see the poll as true, but no `await` can split the
724
+ * claim, so only one takes the slot. The loser keeps streaming normally
725
+ * rather than sealing for a message it would never receive.
726
+ */
727
+ claimPreemptSeal() {
728
+ if (!this.canClaimPreemptSeal()) return false;
729
+ this.preemptSealInFlight = true;
730
+ this.preemptSealBudgetUsed += 1;
731
+ this.preemptSealCount += 1;
732
+ return true;
733
+ }
734
+ /** Releases the seal slot once its boundary has resolved, win or lose. */
735
+ releasePreemptSeal() {
736
+ this.preemptSealInFlight = false;
737
+ }
738
+ getPreemptStats() {
739
+ return {
740
+ seals: this.preemptSealCount,
741
+ emptyBoundaries: this.preemptEmptyBoundaries
742
+ };
743
+ }
564
744
  getRunStep(stepId) {
565
745
  const index = this.contentIndexMap.get(stepId);
566
746
  if (index !== void 0) return this.contentData[index];
@@ -644,6 +824,20 @@ var StandardGraph = class StandardGraph extends Graph {
644
824
  if (this.messages.length === 0 && this.cachedRunMessages != null) return this.cachedRunMessages;
645
825
  return this.messages.slice(this.startIndex);
646
826
  }
827
+ /**
828
+ * True when THIS RUN produced `message` — the provenance the handoff cue
829
+ * gate needs. Tracked as an id set rather than inferred from `startIndex`
830
+ * arithmetic: summarization's remove-all compaction rewrites the live
831
+ * array and leaves `startIndex` stale, so index-based run/host
832
+ * discrimination silently breaks right after a mid-run summarize. Ids
833
+ * survive compaction (retained messages keep theirs), host-supplied
834
+ * prefill messages are never in the set, and membership is O(1) per
835
+ * model call.
836
+ */
837
+ isRunProducedMessage(message) {
838
+ const id = message.id;
839
+ return typeof id === "string" && id !== "" && this.runProducedAiMessageIds.has(id);
840
+ }
647
841
  getContentParts() {
648
842
  if (this.messages == null) return;
649
843
  return convertMessagesToContent(this.messages.slice(this.startIndex));
@@ -842,6 +1036,20 @@ var StandardGraph = class StandardGraph extends Graph {
842
1036
  const agentContext = this.agentContexts.get(agentId);
843
1037
  if (!agentContext) throw new Error(`Agent context not found for agentId: ${agentId}`);
844
1038
  if (!config) throw new Error("No config provided");
1039
+ /**
1040
+ * A `PreemptBoundary` hook halted this run and the sealed commit is
1041
+ * already in state. Enforced at every model node's ENTRY because that
1042
+ * is the only site that covers all of `MultiAgentGraph`'s onward
1043
+ * routing at once — static direct edges, Command fan-out, fan-in
1044
+ * wrappers, and parallel siblings' subsequent inner-loop turns — none
1045
+ * of which consult the halt (the registry signal was deliberately
1046
+ * cleared to keep the stream-cancel from destroying the sealed turn).
1047
+ * Declining the model call turns every routed-to successor into a
1048
+ * no-op, so the outer workflow drains to END without new turns or tool
1049
+ * side effects. Reset per turn in `resetPreemptTotals`, so the next
1050
+ * `processStream` call starts clean.
1051
+ */
1052
+ if (this.preemptHaltReason != null) return { messages: [] };
845
1053
  const { messages } = state;
846
1054
  const discoveredNames = extractToolDiscoveries(messages);
847
1055
  if (discoveredNames.length > 0) agentContext.markToolsAsDiscovered(discoveredNames);
@@ -1165,6 +1373,19 @@ var StandardGraph = class StandardGraph extends Graph {
1165
1373
  transformed = trackProviderMessageOrigins(beforeLegacyFormat, formatContentStrings(beforeLegacyFormat));
1166
1374
  }
1167
1375
  }
1376
+ /**
1377
+ * Applied HERE for the primary so the cue is part of the MEASURED
1378
+ * payload — the pre-invoke projection and overflow guard run on this
1379
+ * stage's output, and a post-measure append could push a just-fits
1380
+ * prompt over budget unreported (#346 round 2). The attemptInvoke
1381
+ * funnel re-keys per SERVING provider: it strips this cue for a
1382
+ * tolerant fallback and adds it for a Claude fallback behind a
1383
+ * tolerant primary.
1384
+ */
1385
+ if (isAnthropicLike(agentContext.provider, agentContext.clientOptions)) {
1386
+ const before = transformed;
1387
+ transformed = trackProviderMessageOrigins(before, appendPredecessorHandoffCue(before, (message) => this.isRunProducedMessage(message)));
1388
+ }
1168
1389
  return transformed;
1169
1390
  };
1170
1391
  const toolOutputRegistry = this.getOrCreateToolOutputRegistry();
@@ -1259,6 +1480,45 @@ var StandardGraph = class StandardGraph extends Graph {
1259
1480
  });
1260
1481
  }
1261
1482
  }
1483
+ /**
1484
+ * Mistral rejects consecutive user turns outright; Bedrock's Converse
1485
+ * API documents strict user/assistant alternation across its model
1486
+ * families, with enforcement varying by family (Claude on Converse
1487
+ * currently tolerates the shape — verified live — but the payload is
1488
+ * normalized for all of them rather than betting on leniency). Four
1489
+ * sites can emit them — the `PostToolBatch` and `PreemptBoundary` hook
1490
+ * boundaries (a consolidated context message followed by one
1491
+ * `HumanMessage` per injected entry), a queue drain carrying more than
1492
+ * one steer, and `run.ts`'s pre-stream context push onto a payload that
1493
+ * already ends on a user turn.
1494
+ *
1495
+ * Normalized here, at the last provider-facing hop, rather than at any
1496
+ * one boundary: the boundaries must keep per-message identity, because
1497
+ * `additional_kwargs.source`/`skillName` drive steer rendering and the
1498
+ * trailing-steer anchor downstream. Graph state and the host's
1499
+ * persisted messages are untouched — this shapes only what goes on the
1500
+ * wire, for the providers that actually care.
1501
+ *
1502
+ * Runs AFTER synthetic-context compaction: that pass can rewrite or
1503
+ * drop messages, so coalescing has to see its output, and it is the
1504
+ * last shaping step before the cache breakpoint is chosen.
1505
+ */
1506
+ if (strictAlternationProviders.has(agentContext.provider)) {
1507
+ /**
1508
+ * Wrapped like every other provider transform: the merged message is
1509
+ * a NEW object, and without re-attachment the final pre-invoke
1510
+ * measurement would drop both source turns' calibrated shares and
1511
+ * recharge the merge at full raw estimate — enough to flip a
1512
+ * just-fits payload (the synthetic-context compaction above binary
1513
+ * searches to exactly that) into a spurious pre-invoke overflow. The
1514
+ * merge keeps the first source's id, so the keyed branch re-attaches
1515
+ * that origin; the absorbed turn's tokens are charged as new raw
1516
+ * growth, which only ever under-estimates by less than the old
1517
+ * behavior over-estimated.
1518
+ */
1519
+ const beforeCoalesce = finalMessages;
1520
+ finalMessages = trackProviderMessageOrigins(beforeCoalesce, coalesceAdjacentUserTurns(beforeCoalesce));
1521
+ }
1262
1522
  const anthropicPromptCacheEnabled = agentContext.provider === "anthropic" && agentContext.clientOptions?.promptCache === true;
1263
1523
  const openRouterPromptCacheEnabled = agentContext.provider === "openrouter" && agentContext.clientOptions?.promptCache === true;
1264
1524
  const bedrockPromptCacheEnabled = agentContext.provider === "bedrock" && agentContext.clientOptions?.promptCache === true;
@@ -1505,11 +1765,23 @@ var StandardGraph = class StandardGraph extends Graph {
1505
1765
  maxContextTokens: agentContext.maxContextTokens
1506
1766
  },
1507
1767
  prepareProviderMessages: ({ model: fallbackModel, messages: fallbackMessages, provider: fallbackProvider, maxContextTokens: fallbackMaxContextTokens, config: fallbackConfig }) => {
1508
- const projectedFallbackMessages = trackProviderMessageOrigins(fallbackMessages, projectMessagesForProvider({
1768
+ const fallbackToolResultChars = agentContext.maxToolResultChars ?? calculateMaxToolResultChars(fallbackMaxContextTokens ?? agentContext.maxContextTokens);
1769
+ /**
1770
+ * Serving-provider cue shaping BEFORE the fallback payload
1771
+ * is measured: a Claude fallback behind a tolerant primary
1772
+ * gains the cue inside the guarded projection (a prompt
1773
+ * within the cue's cost of the fallback budget must take
1774
+ * the recovery path, not ship oversized), and a tolerant
1775
+ * fallback behind an Anthropic primary sheds the baked cue
1776
+ * before it is measured against the tighter budget. The
1777
+ * attemptInvoke funnel pass then finds nothing to change.
1778
+ */
1779
+ const cueShapedFallbackMessages = trackProviderMessageOrigins(fallbackMessages, isAnthropicLike(fallbackProvider, { model: resolveServingModelId(fallbackModel) }) ? appendPredecessorHandoffCue(fallbackMessages, (m) => this.isRunProducedMessage(m)) : removePredecessorHandoffCue(fallbackMessages));
1780
+ const projectedFallbackMessages = trackProviderMessageOrigins(cueShapedFallbackMessages, projectMessagesForProvider({
1509
1781
  model: fallbackModel,
1510
- messages: fallbackMessages,
1782
+ messages: cueShapedFallbackMessages,
1511
1783
  provider: fallbackProvider,
1512
- maxToolResultChars: agentContext.maxToolResultChars ?? calculateMaxToolResultChars(fallbackMaxContextTokens ?? agentContext.maxContextTokens),
1784
+ maxToolResultChars: fallbackToolResultChars,
1513
1785
  callOptions: fallbackConfig
1514
1786
  }));
1515
1787
  const primaryContextBudget = contextUsage?.contextBudget;
@@ -1567,6 +1839,22 @@ var StandardGraph = class StandardGraph extends Graph {
1567
1839
  * handled everything — both paths become no-ops.
1568
1840
  */
1569
1841
  const responseMessage = result.messages?.[0];
1842
+ /**
1843
+ * Provenance for the handoff-cue gate: recorded at the node, where the
1844
+ * produced turn is unambiguous. The public ChatModel contract does not
1845
+ * require implementations to set message ids — the reducer would
1846
+ * assign one AFTER this node returns, which is too late for the set —
1847
+ * so an id is assigned here first, the same way the reducer does it
1848
+ * (`v4()`, mirrored into `lc_kwargs`), and the reducer's
1849
+ * keep-existing-id rule makes the state message match.
1850
+ */
1851
+ if (responseMessage?.getType() === "ai") {
1852
+ if (typeof responseMessage.id !== "string" || responseMessage.id === "") {
1853
+ responseMessage.id = v4();
1854
+ responseMessage.lc_kwargs.id = responseMessage.id;
1855
+ }
1856
+ this.runProducedAiMessageIds.add(responseMessage.id);
1857
+ }
1570
1858
  const toolCalls = responseMessage?.tool_calls;
1571
1859
  const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
1572
1860
  const responseReasoningContent = getResponseReasoningContent({
@@ -1631,8 +1919,16 @@ var StandardGraph = class StandardGraph extends Graph {
1631
1919
  }
1632
1920
  const invokeElapsed = ((Date.now() - invokeStart) / 1e3).toFixed(2);
1633
1921
  agentContext.currentUsage = this.getUsageMetadata(result.messages?.[0]);
1922
+ /**
1923
+ * Synthetic usage from a sealed turn is an estimate derived from the
1924
+ * host's own counter, so feeding it to calibration would teach a ratio
1925
+ * of exactly 1.0 — self-consistent by construction, and wrong for any
1926
+ * provider whose real ratio differs. It still flows to `currentUsage`
1927
+ * for host billing; it just does not get to move the EMA.
1928
+ */
1929
+ const estimatedUsage = (result.messages?.[0])?.response_metadata.estimated_usage === true;
1634
1930
  if (agentContext.currentUsage) {
1635
- agentContext.updateLastCallUsage(agentContext.currentUsage);
1931
+ if (!estimatedUsage) agentContext.updateLastCallUsage(agentContext.currentUsage);
1636
1932
  emitAgentLog(config, "debug", "graph", `LLM call complete (${invokeElapsed}s)`, {
1637
1933
  ...agentContext.currentUsage,
1638
1934
  elapsedSeconds: Number(invokeElapsed),
@@ -1644,10 +1940,157 @@ var StandardGraph = class StandardGraph extends Graph {
1644
1940
  elapsedSeconds: Number(invokeElapsed),
1645
1941
  messageCount: finalMessages.length
1646
1942
  }, invokeMeta, { force: true });
1943
+ if (responseMessage?.response_metadata.preempted === true) {
1944
+ const { messages: injected, preventContinuation } = await this.dispatchPreemptBoundary(agentId, config);
1945
+ /**
1946
+ * Release before branching: the slot is held only for the duration of
1947
+ * the drain, and an early return below must not strand it.
1948
+ */
1949
+ this.releasePreemptSeal();
1950
+ if (preventContinuation) {
1951
+ /**
1952
+ * A hook halted at the boundary. Commit the sealed turn and anything
1953
+ * it injected, but do NOT self-loop: `preventContinuation` promises
1954
+ * no further model turn, and the run-loop poll in `processStream`
1955
+ * only sees the halt AFTER the next call would already have started
1956
+ * — direct graph consumers never poll it at all. A trailing injected
1957
+ * HumanMessage carries no tool calls, so `toolsCondition` routes it
1958
+ * to END.
1959
+ */
1960
+ this.preemptIncomplete = true;
1961
+ /**
1962
+ * A halting boundary that ALSO injected nothing is still an empty
1963
+ * boundary by the `getPreemptStats().emptyBoundaries` contract —
1964
+ * hosts use the counter for truncated-seal telemetry, and both
1965
+ * paths end the turn with nothing to resume from.
1966
+ */
1967
+ if (injected.length === 0) this.preemptEmptyBoundaries += 1;
1968
+ this.cleanupSignalListener();
1969
+ return injected.length > 0 ? { messages: [...result.messages ?? [], ...injected] } : result;
1970
+ }
1971
+ if (injected.length > 0) {
1972
+ this.pendingPreemptReturn.add(agentId);
1973
+ this.cleanupSignalListener();
1974
+ return { messages: [...result.messages ?? [], ...injected] };
1975
+ }
1976
+ /**
1977
+ * Nothing to inject — the host cancelled or already drained. Do NOT
1978
+ * self-loop: a trailing model turn with no new input is dropped by
1979
+ * some Gemini models and read as prefill by Anthropic. Do NOT pretend
1980
+ * the turn completed either; the answer really was cut short.
1981
+ */
1982
+ this.preemptEmptyBoundaries += 1;
1983
+ this.preemptIncomplete = true;
1984
+ }
1647
1985
  this.cleanupSignalListener();
1648
1986
  return result;
1649
1987
  };
1650
1988
  }
1989
+ /**
1990
+ * Fires `PreemptBoundary` after a sealed turn and returns whatever the
1991
+ * hooks asked to inject, converted through the same `convertInjectedMessages`
1992
+ * the tool boundary uses so the two sites cannot emit different shapes.
1993
+ *
1994
+ * Never throws: a drain that fails or times out costs the injection, not the
1995
+ * run. The caller treats an empty result as "nothing to resume with".
1996
+ *
1997
+ * `preventContinuation` is surfaced alongside the messages rather than left
1998
+ * to the registry halt signal, which `processStream` only polls between
1999
+ * stream events — by then the self-loop it was meant to prevent has already
2000
+ * issued another model call, and a direct graph consumer never polls it.
2001
+ */
2002
+ async dispatchPreemptBoundary(agentId, config) {
2003
+ if (this.hookRegistry == null) return EMPTY_PREEMPT_BOUNDARY;
2004
+ const configurable = config?.configurable;
2005
+ const runId = configurable?.run_id ?? this.runId;
2006
+ if (runId == null) return EMPTY_PREEMPT_BOUNDARY;
2007
+ const result = await executeHooks({
2008
+ registry: this.hookRegistry,
2009
+ input: {
2010
+ hook_event_name: "PreemptBoundary",
2011
+ runId,
2012
+ threadId: configurable?.thread_id,
2013
+ agentId: this.subagentScope ? agentId : void 0,
2014
+ executingAgentId: agentId,
2015
+ sealCount: this.preemptSealCount
2016
+ },
2017
+ sessionId: runId,
2018
+ timeoutMs: PREEMPT_BOUNDARY_HOOK_TIMEOUT_MS,
2019
+ /**
2020
+ * The host's own abort signal(s), deliberately NOT `config.signal` —
2021
+ * inside a node the latter is LangGraph's composed signal, which also
2022
+ * fires when an unrelated sibling in the same superstep throws.
2023
+ * Cancellation already returns control in milliseconds without this;
2024
+ * what it buys is that a drain does not keep running after the run it
2025
+ * belongs to died.
2026
+ *
2027
+ * Composed because the host can cancel through either channel: the
2028
+ * construction signal, or the per-call `callerConfig.signal` — the only
2029
+ * one a multi-agent run has, since `MultiAgentGraphConfig` exposes no
2030
+ * construction signal. When both exist they may be different
2031
+ * controllers, and a drain must stop when EITHER fires.
2032
+ */
2033
+ signal: composeAbortSignals(this.signal, this.callerSignal)
2034
+ }).catch(() => void 0);
2035
+ if (result == null) return EMPTY_PREEMPT_BOUNDARY;
2036
+ /**
2037
+ * `executeHooks` raises a registry halt whenever a hook returns
2038
+ * `preventContinuation`. That halt has exactly one consumer — the poll in
2039
+ * `Run.processStream` — and its `break` cancels the stream iterator, which
2040
+ * aborts Pregel. The abort lands BEFORE the outer reducer commits
2041
+ * `StandardGraph.messages`, so honoring the halt here would destroy the
2042
+ * sealed assistant turn: the run returns empty content and the host
2043
+ * persists nothing. Measured deterministically — the commit is several
2044
+ * stream events downstream of the point the halt becomes observable.
2045
+ *
2046
+ * The `preventContinuation` branch in `createCallModel` already enforces
2047
+ * the contract locally by declining to self-loop, and a sealed chunk
2048
+ * provably carries no tool calls, so the turn routes to END after exactly
2049
+ * one model call either way. Clearing the halt therefore costs nothing it
2050
+ * was buying and saves the content the seal exists to preserve.
2051
+ *
2052
+ * Scoped to a halt this event raised, so a halt from an earlier hook in
2053
+ * the same run — `haltRun` is first-write-wins — is left alone.
2054
+ */
2055
+ const halt = this.hookRegistry.getHaltSignal(runId);
2056
+ if (result.preventContinuation === true && halt?.source === "PreemptBoundary") {
2057
+ this.preemptHaltReason = halt.reason;
2058
+ this.hookRegistry.clearHaltSignal(runId);
2059
+ }
2060
+ const injected = [];
2061
+ /**
2062
+ * `PreemptBoundaryHookOutput` is `BaseHookOutput`, so `additionalContext`
2063
+ * is part of the contract here just as it is at the tool boundary. It has
2064
+ * to be materialized BEFORE the emptiness test, or a hook that returns
2065
+ * context alone would read as "nothing to resume with" and cut the answer
2066
+ * short. Same system-flavored `HumanMessage` convention `ToolNode` uses —
2067
+ * Anthropic and Google reject a mid-conversation `SystemMessage`.
2068
+ */
2069
+ /**
2070
+ * Whitespace-only entries are dropped for the same reason empty
2071
+ * `injectedMessages` are: `executeHooks` keeps them because their raw
2072
+ * length is nonzero, but a blank turn is not something to resume from —
2073
+ * it costs a model call and strict providers reject it outright.
2074
+ */
2075
+ const contexts = result.additionalContexts.filter((context) => context.trim() !== "");
2076
+ if (contexts.length > 0) injected.push(new HumanMessage({
2077
+ content: contexts.join("\n\n"),
2078
+ additional_kwargs: {
2079
+ role: "system",
2080
+ isMeta: true,
2081
+ source: "hook"
2082
+ }
2083
+ }));
2084
+ if (result.injectedMessages.length > 0) try {
2085
+ injected.push(...convertInjectedMessages(result.injectedMessages));
2086
+ } catch (e) {
2087
+ console.warn("[StandardGraph] Failed to convert PreemptBoundary injectedMessages:", e instanceof Error ? e.message : e);
2088
+ }
2089
+ return {
2090
+ messages: injected,
2091
+ preventContinuation: result.preventContinuation === true
2092
+ };
2093
+ }
1651
2094
  createAgentNode(agentId) {
1652
2095
  const getConfig = () => this.config;
1653
2096
  const agentContext = this.agentContexts.get(agentId);
@@ -1757,6 +2200,12 @@ var StandardGraph = class StandardGraph extends Graph {
1757
2200
  const summarizeNode = `${SUMMARIZE}${agentId}`;
1758
2201
  const routeMessage = (state, config) => {
1759
2202
  this.config = config;
2203
+ /**
2204
+ * A sealed turn that injected messages resumes in the SAME pregel run:
2205
+ * back to the agent node as a new superstep, so the model continues in
2206
+ * one assistant message instead of restarting the graph.
2207
+ */
2208
+ if (this.pendingPreemptReturn.delete(agentId)) return agentNode;
1760
2209
  if (state.summarizationRequest != null) return summarizeNode;
1761
2210
  return toolsCondition(state, toolNode, this.invokedToolIds);
1762
2211
  };