@librechat/agents 3.3.3 → 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 (274) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +2 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/constants.cjs +21 -0
  4. package/dist/cjs/common/constants.cjs.map +1 -1
  5. package/dist/cjs/events.cjs +10 -1
  6. package/dist/cjs/events.cjs.map +1 -1
  7. package/dist/cjs/graphs/Graph.cjs +793 -111
  8. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  9. package/dist/cjs/graphs/MultiAgentGraph.cjs +3 -1
  10. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  11. package/dist/cjs/hooks/HookRegistry.cjs +25 -0
  12. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  13. package/dist/cjs/hooks/index.cjs +12 -0
  14. package/dist/cjs/hooks/index.cjs.map +1 -1
  15. package/dist/cjs/hooks/types.cjs +1 -0
  16. package/dist/cjs/hooks/types.cjs.map +1 -1
  17. package/dist/cjs/langfuse.cjs +8 -0
  18. package/dist/cjs/langfuse.cjs.map +1 -1
  19. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +79 -6
  20. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  21. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +38 -13
  22. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  23. package/dist/cjs/llm/google/utils/common.cjs +19 -7
  24. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  25. package/dist/cjs/llm/invoke.cjs +331 -5
  26. package/dist/cjs/llm/invoke.cjs.map +1 -1
  27. package/dist/cjs/llm/openai/index.cjs +41 -5
  28. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  29. package/dist/cjs/llm/openai/utils/index.cjs +25 -4
  30. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  31. package/dist/cjs/llm/openrouter/index.cjs +4 -1
  32. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  33. package/dist/cjs/llm/preempt.cjs +132 -0
  34. package/dist/cjs/llm/preempt.cjs.map +1 -0
  35. package/dist/cjs/main.cjs +36 -5
  36. package/dist/cjs/messages/alternation.cjs +92 -0
  37. package/dist/cjs/messages/alternation.cjs.map +1 -0
  38. package/dist/cjs/messages/cache.cjs +54 -33
  39. package/dist/cjs/messages/cache.cjs.map +1 -1
  40. package/dist/cjs/messages/contextPruning.cjs +17 -43
  41. package/dist/cjs/messages/contextPruning.cjs.map +1 -1
  42. package/dist/cjs/messages/core.cjs +315 -23
  43. package/dist/cjs/messages/core.cjs.map +1 -1
  44. package/dist/cjs/messages/format.cjs +290 -60
  45. package/dist/cjs/messages/format.cjs.map +1 -1
  46. package/dist/cjs/messages/handoffCue.cjs +63 -0
  47. package/dist/cjs/messages/handoffCue.cjs.map +1 -0
  48. package/dist/cjs/messages/index.cjs +4 -1
  49. package/dist/cjs/messages/injected.cjs +60 -0
  50. package/dist/cjs/messages/injected.cjs.map +1 -0
  51. package/dist/cjs/messages/prune.cjs +503 -127
  52. package/dist/cjs/messages/prune.cjs.map +1 -1
  53. package/dist/cjs/run.cjs +80 -7
  54. package/dist/cjs/run.cjs.map +1 -1
  55. package/dist/cjs/session/handlers.cjs +18 -9
  56. package/dist/cjs/session/handlers.cjs.map +1 -1
  57. package/dist/cjs/stream.cjs +35 -10
  58. package/dist/cjs/stream.cjs.map +1 -1
  59. package/dist/cjs/summarization/node.cjs +28 -14
  60. package/dist/cjs/summarization/node.cjs.map +1 -1
  61. package/dist/cjs/tools/ToolNode.cjs +56 -63
  62. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  63. package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
  64. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
  65. package/dist/cjs/tools/search/tool.cjs +9 -1
  66. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  67. package/dist/cjs/tools/toolOutputReferences.cjs +6 -0
  68. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  69. package/dist/cjs/utils/index.cjs +1 -1
  70. package/dist/cjs/utils/llm.cjs +1 -1
  71. package/dist/cjs/utils/llm.cjs.map +1 -1
  72. package/dist/cjs/utils/tokens.cjs +307 -20
  73. package/dist/cjs/utils/tokens.cjs.map +1 -1
  74. package/dist/cjs/utils/toolContent.cjs +1514 -0
  75. package/dist/cjs/utils/toolContent.cjs.map +1 -0
  76. package/dist/esm/agents/AgentContext.mjs +2 -1
  77. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  78. package/dist/esm/common/constants.mjs +19 -1
  79. package/dist/esm/common/constants.mjs.map +1 -1
  80. package/dist/esm/events.mjs +10 -1
  81. package/dist/esm/events.mjs.map +1 -1
  82. package/dist/esm/graphs/Graph.mjs +798 -116
  83. package/dist/esm/graphs/Graph.mjs.map +1 -1
  84. package/dist/esm/graphs/MultiAgentGraph.mjs +3 -1
  85. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  86. package/dist/esm/hooks/HookRegistry.mjs +25 -0
  87. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  88. package/dist/esm/hooks/index.mjs +12 -1
  89. package/dist/esm/hooks/index.mjs.map +1 -1
  90. package/dist/esm/hooks/types.mjs +1 -0
  91. package/dist/esm/hooks/types.mjs.map +1 -1
  92. package/dist/esm/langfuse.mjs +9 -1
  93. package/dist/esm/langfuse.mjs.map +1 -1
  94. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +79 -6
  95. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  96. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +38 -13
  97. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  98. package/dist/esm/llm/google/utils/common.mjs +19 -7
  99. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  100. package/dist/esm/llm/invoke.mjs +332 -8
  101. package/dist/esm/llm/invoke.mjs.map +1 -1
  102. package/dist/esm/llm/openai/index.mjs +41 -5
  103. package/dist/esm/llm/openai/index.mjs.map +1 -1
  104. package/dist/esm/llm/openai/utils/index.mjs +25 -4
  105. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  106. package/dist/esm/llm/openrouter/index.mjs +4 -1
  107. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  108. package/dist/esm/llm/preempt.mjs +131 -0
  109. package/dist/esm/llm/preempt.mjs.map +1 -0
  110. package/dist/esm/main.mjs +14 -11
  111. package/dist/esm/messages/alternation.mjs +91 -0
  112. package/dist/esm/messages/alternation.mjs.map +1 -0
  113. package/dist/esm/messages/cache.mjs +55 -34
  114. package/dist/esm/messages/cache.mjs.map +1 -1
  115. package/dist/esm/messages/contextPruning.mjs +17 -43
  116. package/dist/esm/messages/contextPruning.mjs.map +1 -1
  117. package/dist/esm/messages/core.mjs +305 -24
  118. package/dist/esm/messages/core.mjs.map +1 -1
  119. package/dist/esm/messages/format.mjs +290 -61
  120. package/dist/esm/messages/format.mjs.map +1 -1
  121. package/dist/esm/messages/handoffCue.mjs +61 -0
  122. package/dist/esm/messages/handoffCue.mjs.map +1 -0
  123. package/dist/esm/messages/index.mjs +4 -1
  124. package/dist/esm/messages/injected.mjs +60 -0
  125. package/dist/esm/messages/injected.mjs.map +1 -0
  126. package/dist/esm/messages/prune.mjs +500 -128
  127. package/dist/esm/messages/prune.mjs.map +1 -1
  128. package/dist/esm/run.mjs +80 -7
  129. package/dist/esm/run.mjs.map +1 -1
  130. package/dist/esm/session/handlers.mjs +19 -10
  131. package/dist/esm/session/handlers.mjs.map +1 -1
  132. package/dist/esm/stream.mjs +34 -11
  133. package/dist/esm/stream.mjs.map +1 -1
  134. package/dist/esm/summarization/node.mjs +28 -14
  135. package/dist/esm/summarization/node.mjs.map +1 -1
  136. package/dist/esm/tools/ToolNode.mjs +56 -63
  137. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  138. package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
  139. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
  140. package/dist/esm/tools/search/tool.mjs +9 -1
  141. package/dist/esm/tools/search/tool.mjs.map +1 -1
  142. package/dist/esm/tools/toolOutputReferences.mjs +6 -0
  143. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  144. package/dist/esm/utils/index.mjs +1 -1
  145. package/dist/esm/utils/llm.mjs +1 -1
  146. package/dist/esm/utils/llm.mjs.map +1 -1
  147. package/dist/esm/utils/tokens.mjs +307 -21
  148. package/dist/esm/utils/tokens.mjs.map +1 -1
  149. package/dist/esm/utils/toolContent.mjs +1503 -0
  150. package/dist/esm/utils/toolContent.mjs.map +1 -0
  151. package/dist/types/common/constants.d.ts +18 -0
  152. package/dist/types/graphs/Graph.d.ts +139 -2
  153. package/dist/types/hooks/HookRegistry.d.ts +15 -0
  154. package/dist/types/hooks/index.d.ts +12 -1
  155. package/dist/types/hooks/types.d.ts +45 -6
  156. package/dist/types/index.d.ts +1 -1
  157. package/dist/types/llm/invoke.d.ts +34 -1
  158. package/dist/types/llm/openai/index.d.ts +4 -0
  159. package/dist/types/llm/openai/utils/index.d.ts +2 -0
  160. package/dist/types/llm/openrouter/index.d.ts +5 -0
  161. package/dist/types/llm/preempt.d.ts +40 -0
  162. package/dist/types/messages/alternation.d.ts +23 -0
  163. package/dist/types/messages/contextPruning.d.ts +2 -1
  164. package/dist/types/messages/core.d.ts +36 -0
  165. package/dist/types/messages/format.d.ts +6 -0
  166. package/dist/types/messages/handoffCue.d.ts +40 -0
  167. package/dist/types/messages/index.d.ts +3 -0
  168. package/dist/types/messages/injected.d.ts +3 -0
  169. package/dist/types/messages/prune.d.ts +16 -12
  170. package/dist/types/run.d.ts +7 -0
  171. package/dist/types/stream.d.ts +14 -0
  172. package/dist/types/tools/ToolNode.d.ts +0 -7
  173. package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
  174. package/dist/types/tools/search/types.d.ts +31 -2
  175. package/dist/types/types/graph.d.ts +8 -1
  176. package/dist/types/types/run.d.ts +65 -0
  177. package/dist/types/types/stream.d.ts +1 -26
  178. package/dist/types/utils/tokens.d.ts +7 -0
  179. package/dist/types/utils/toolContent.d.ts +107 -0
  180. package/package.json +1 -1
  181. package/src/__tests__/stream.eagerEventExecution.test.ts +74 -0
  182. package/src/agents/AgentContext.ts +1 -0
  183. package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
  184. package/src/common/constants.ts +21 -0
  185. package/src/events.ts +15 -1
  186. package/src/graphs/Graph.ts +1261 -198
  187. package/src/graphs/MultiAgentGraph.ts +6 -1
  188. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1139 -4
  189. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
  190. package/src/hooks/HookRegistry.ts +40 -0
  191. package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
  192. package/src/hooks/index.ts +16 -2
  193. package/src/hooks/types.ts +47 -3
  194. package/src/index.ts +1 -1
  195. package/src/langfuse.ts +26 -1
  196. package/src/llm/anthropic/utils/message_inputs.ts +158 -12
  197. package/src/llm/anthropic/utils/tool-id-normalization.test.ts +109 -0
  198. package/src/llm/bedrock/utils/message_inputs.test.ts +131 -1
  199. package/src/llm/bedrock/utils/message_inputs.ts +100 -24
  200. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
  201. package/src/llm/google/utils/common.test.ts +75 -9
  202. package/src/llm/google/utils/common.ts +34 -3
  203. package/src/llm/invoke.alternation.test.ts +87 -0
  204. package/src/llm/invoke.handoffCue.test.ts +112 -0
  205. package/src/llm/invoke.test.ts +488 -0
  206. package/src/llm/invoke.ts +611 -8
  207. package/src/llm/openai/index.ts +106 -6
  208. package/src/llm/openai/utils/index.ts +109 -53
  209. package/src/llm/openai/utils/messages.test.ts +330 -1
  210. package/src/llm/openrouter/index.ts +12 -2
  211. package/src/llm/openrouter/reasoning.test.ts +314 -0
  212. package/src/llm/preempt.test.ts +323 -0
  213. package/src/llm/preempt.ts +178 -0
  214. package/src/messages/__tests__/observationMasking.test.ts +93 -2
  215. package/src/messages/alternation.test.ts +212 -0
  216. package/src/messages/alternation.ts +112 -0
  217. package/src/messages/cache.tail.test.ts +193 -0
  218. package/src/messages/cache.test.ts +113 -0
  219. package/src/messages/cache.ts +92 -60
  220. package/src/messages/contextPruning.test.ts +184 -0
  221. package/src/messages/contextPruning.ts +49 -42
  222. package/src/messages/core.ts +653 -40
  223. package/src/messages/ensureThinkingBlock.test.ts +49 -1
  224. package/src/messages/foldToollessToolBlocks.test.ts +163 -5
  225. package/src/messages/format.ts +454 -91
  226. package/src/messages/formatAgentMessages.steer.test.ts +267 -0
  227. package/src/messages/formatAgentMessages.test.ts +604 -14
  228. package/src/messages/formatAgentMessages.tools.test.ts +103 -0
  229. package/src/messages/handoffCue.test.ts +96 -0
  230. package/src/messages/handoffCue.ts +78 -0
  231. package/src/messages/index.ts +3 -0
  232. package/src/messages/injected.test.ts +90 -0
  233. package/src/messages/injected.ts +74 -0
  234. package/src/messages/prune.ts +996 -183
  235. package/src/run.ts +91 -6
  236. package/src/scripts/preempt-probe.ts +330 -0
  237. package/src/scripts/preempt-scenarios.ts +388 -0
  238. package/src/session/handlers.ts +32 -12
  239. package/src/specs/handoffCue.test.ts +165 -0
  240. package/src/specs/langfuse-callbacks.test.ts +352 -2
  241. package/src/specs/preemptSeal.test.ts +309 -0
  242. package/src/specs/prune.test.ts +1083 -6
  243. package/src/specs/summarization-unit.test.ts +105 -0
  244. package/src/specs/tokens.test.ts +609 -32
  245. package/src/stream.dispatch.test.ts +63 -0
  246. package/src/stream.ts +59 -23
  247. package/src/summarization/__tests__/node.test.ts +77 -0
  248. package/src/summarization/node.ts +46 -18
  249. package/src/tools/ToolNode.ts +150 -99
  250. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +148 -1
  251. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +112 -3
  252. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +32 -0
  253. package/src/tools/__tests__/directToolHooks.test.ts +255 -7
  254. package/src/tools/search/keenable-scraper.test.ts +153 -0
  255. package/src/tools/search/keenable-scraper.ts +137 -0
  256. package/src/tools/search/tool.ts +13 -2
  257. package/src/tools/search/types.ts +50 -3
  258. package/src/tools/toolOutputReferences.ts +6 -0
  259. package/src/types/graph.ts +12 -1
  260. package/src/types/run.ts +67 -0
  261. package/src/types/stream.ts +1 -42
  262. package/src/utils/llm.test.ts +18 -0
  263. package/src/utils/llm.ts +4 -1
  264. package/src/utils/tokens.ts +586 -31
  265. package/src/utils/toolContent.test.ts +1432 -0
  266. package/src/utils/toolContent.ts +2707 -0
  267. package/dist/cjs/splitStream.cjs +0 -151
  268. package/dist/cjs/splitStream.cjs.map +0 -1
  269. package/dist/esm/splitStream.mjs +0 -150
  270. package/dist/esm/splitStream.mjs.map +0 -1
  271. package/dist/types/mockStream.d.ts +0 -32
  272. package/dist/types/splitStream.d.ts +0 -37
  273. package/src/mockStream.ts +0 -99
  274. package/src/splitStream.ts +0 -234
@@ -2,9 +2,14 @@
2
2
  import { nanoid } from 'nanoid';
3
3
  import { tool } from '@langchain/core/tools';
4
4
  import { ToolNode } from '@langchain/langgraph/prebuilt';
5
+ import { ContextOverflowError } from '@langchain/core/errors';
5
6
  import { Runnable, RunnableConfig } from '@langchain/core/runnables';
6
- import { ToolMessage, AIMessageChunk } from '@langchain/core/messages';
7
7
  import { START, END, StateGraph, Annotation } from '@langchain/langgraph';
8
+ import {
9
+ ToolMessage,
10
+ HumanMessage,
11
+ AIMessageChunk,
12
+ } from '@langchain/core/messages';
8
13
  import type {
9
14
  UsageMetadata,
10
15
  BaseMessage,
@@ -16,55 +21,74 @@ import type { FallbackErrorContext } from '@/llm/invoke';
16
21
  import type { HookRegistry } from '@/hooks';
17
22
  import type * as t from '@/types';
18
23
  import {
19
- formatAnthropicArtifactContent,
24
+ projectAnthropicArtifactContent,
20
25
  ensureThinkingBlockInMessages,
21
26
  foldToolBlocksForToollessAgent,
22
27
  convertMessagesToContent,
23
28
  sanitizeOrphanToolBlocks,
24
29
  extractToolDiscoveries,
25
30
  addBedrockTailCacheControl,
26
- formatArtifactPayload,
31
+ projectArtifactPayload,
27
32
  formatContentStrings,
28
- isLegacyConvertible,
29
33
  CALIBRATION_RATIO_MAX,
34
+ REPLY_PRIMER_TOKENS,
30
35
  createPruneMessages,
36
+ projectToolCallInputs,
37
+ calculateMaxToolCallInputChars,
38
+ projectToolStreamContentForProvider,
31
39
  syncBudgetDerivedFields,
32
40
  addTailCacheControl,
33
41
  resolvePromptCacheTtl,
34
42
  resolveBedrockPromptCacheTtl,
35
43
  supportsBedrockToolCache,
44
+ isSyntheticProviderContextMessage,
36
45
  getMessageId,
37
46
  makeIsDeferred,
38
47
  partitionAndMarkAnthropicToolCache,
39
48
  DEFAULT_RETAIN_RECENT_TURNS,
40
49
  splitAtRecencyBoundary,
50
+ convertInjectedMessages,
51
+ coalesceAdjacentUserTurns,
52
+ strictAlternationProviders,
53
+ appendPredecessorHandoffCue,
54
+ removePredecessorHandoffCue,
41
55
  } from '@/messages';
42
- import {
43
- createLangfuseHandler,
44
- createLangfuseTraceMetadata,
45
- disposeLangfuseHandler,
46
- isLangfuseCallbackHandler,
47
- } from '@/langfuse';
48
56
  import {
49
57
  resetIfNotEmpty,
50
58
  isAnthropicLike,
51
59
  isOpenAILike,
52
60
  isGoogleLike,
53
61
  apportionTokenCounts,
62
+ calculateMaxToolResultChars,
54
63
  joinKeys,
55
64
  sleep,
56
65
  } from '@/utils';
57
- import {
58
- getBlindRecoveryBudget,
59
- planContextOverflowRecovery,
60
- translateRecoveryBudget,
61
- } from '@/llm/contextOverflowRecovery';
62
66
  import {
63
67
  attemptInvoke,
64
68
  tryFallbackProviders,
65
69
  getFallbackErrorContext,
66
70
  getFallbackOverflowCandidates,
71
+ projectMessagesForProvider,
72
+ resolveServingModelId,
67
73
  } from '@/llm/invoke';
74
+ import { v4 } from 'uuid';
75
+ import {
76
+ createLangfuseHandler,
77
+ createLangfuseTraceMetadata,
78
+ disposeLangfuseHandler,
79
+ isLangfuseCallbackHandler,
80
+ } from '@/langfuse';
81
+ import {
82
+ getBlindRecoveryBudget,
83
+ planContextOverflowRecovery,
84
+ translateRecoveryBudget,
85
+ } from '@/llm/contextOverflowRecovery';
86
+ import {
87
+ compactToolContent,
88
+ getToolContentCharLength,
89
+ serializeToolContentBounded,
90
+ } from '@/utils/toolContent';
91
+ import { resolveMaxSeals } from '@/llm/preempt';
68
92
  import {
69
93
  Constants,
70
94
  GraphNodeKeys,
@@ -72,7 +96,12 @@ import {
72
96
  GraphEvents,
73
97
  Providers,
74
98
  StepTypes,
99
+ PREEMPT_BOUNDARY_HOOK_TIMEOUT_MS,
75
100
  } from '@/common';
101
+ import {
102
+ annotateMessagesForLLM,
103
+ ToolOutputReferenceRegistry,
104
+ } from '@/tools/toolOutputReferences';
76
105
  import {
77
106
  resolveLangfuseRuntimeScope,
78
107
  withLangfuseRuntimeScope,
@@ -87,7 +116,6 @@ import { ToolNode as CustomToolNode, toolsCondition } from '@/tools/ToolNode';
87
116
  import { shouldTraceToolNodeForLangfuse } from '@/langfuseToolOutputTracing';
88
117
  import { createLocalCodingToolBundle } from '@/tools/local/LocalCodingTools';
89
118
  import { SubagentExecutor, resolveSubagentConfigs } from '@/tools/subagent';
90
- import { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
91
119
  import { partitionAndMarkBedrockToolCache } from '@/llm/bedrock/toolCache';
92
120
  import { safeDispatchCustomEvent, emitAgentLog } from '@/utils/events';
93
121
  import { createCloudflareCodingToolBundle } from '@/tools/cloudflare';
@@ -106,9 +134,41 @@ import { isThinkingEnabled } from '@/llm/request';
106
134
  import { initializeModel } from '@/llm/init';
107
135
  import { HandlerRegistry } from '@/events';
108
136
  import { ChatOpenAI } from '@/llm/openai';
137
+ import { executeHooks } from '@/hooks';
109
138
 
110
139
  const { AGENT, TOOLS, SUMMARIZE } = GraphNodeKeys;
111
140
 
141
+ /** What a `PreemptBoundary` drain resolved to. */
142
+ type PreemptBoundaryResult = {
143
+ messages: BaseMessage[];
144
+ /** A hook asked for no further model turn; the seal must not self-loop. */
145
+ preventContinuation: boolean;
146
+ };
147
+
148
+ const EMPTY_PREEMPT_BOUNDARY: PreemptBoundaryResult = {
149
+ messages: [],
150
+ preventContinuation: false,
151
+ };
152
+
153
+ /**
154
+ * One signal that fires when either input fires. `AbortSignal.any` is skipped
155
+ * when the inputs collapse to a single signal — the composite is a fresh
156
+ * object per call, and the common cases (one channel, or the host reusing the
157
+ * same controller for both) don't need one.
158
+ */
159
+ function composeAbortSignals(
160
+ a: AbortSignal | undefined,
161
+ b: AbortSignal | undefined
162
+ ): AbortSignal | undefined {
163
+ if (a == null || a === b) {
164
+ return b;
165
+ }
166
+ if (b == null) {
167
+ return a;
168
+ }
169
+ return AbortSignal.any([a, b]);
170
+ }
171
+
112
172
  /** Minimum relative variance before calibrated toolSchemaTokens overrides current value. */
113
173
  const CALIBRATION_VARIANCE_THRESHOLD = 0.15;
114
174
 
@@ -124,26 +184,6 @@ function createToolHandlerRegistry(
124
184
  return registry;
125
185
  }
126
186
 
127
- /**
128
- * Start index of the span post-prune formatters can mutate in place: the
129
- * trailing tool batch plus its owning AI message (artifact formatting touches
130
- * every tool result after the last AI tool call; Bedrock rewrites the AI
131
- * message before a trailing tool result). Capped so the usage-snapshot
132
- * recount stays constant-cost.
133
- */
134
- function trailingMutationStart(messages: BaseMessage[]): number {
135
- const MAX_SPAN = 16;
136
- let index = messages.length - 1;
137
- while (
138
- index >= 0 &&
139
- messages[index]?.getType() === 'tool' &&
140
- messages.length - index < MAX_SPAN
141
- ) {
142
- index--;
143
- }
144
- return Math.max(0, Math.min(index, messages.length - 2));
145
- }
146
-
147
187
  type ReasoningKey = 'reasoning_content' | 'reasoning';
148
188
  type ReasoningSummary = { summary?: Array<{ text?: string }> };
149
189
  type ReasoningDetail = { type?: string; text?: string };
@@ -656,6 +696,20 @@ export abstract class Graph<
656
696
  reasoningStepHasDeltas: Set<string> = new Set();
657
697
  protected handlerDispatchedEventCounts: Map<string, number> = new Map();
658
698
  signal?: AbortSignal;
699
+ /**
700
+ * The abort signal the CALLER handed to the current `processStream` call,
701
+ * assigned unconditionally — including back to `undefined` — on every call.
702
+ *
703
+ * Kept separate from {@link signal} on purpose. That field is construction
704
+ * state with its own consumers (model-call config, subagent parentSignal),
705
+ * so adopting a per-call signal into it would leak one call's controller
706
+ * into the next — `clearHeavyState()` is skipped on HITL interrupts, so a
707
+ * host that aborts a finished request's controller would poison the resumed
708
+ * run's model calls and boundary drains with an already-aborted signal.
709
+ * Boundary dispatch composes the two instead; see
710
+ * `StandardGraph.dispatchPreemptBoundary`.
711
+ */
712
+ callerSignal?: AbortSignal;
659
713
  /** Set of invoked tool call IDs from non-message run steps completed mid-run, if any */
660
714
  invokedToolIds?: Set<string>;
661
715
  handlerRegistry: HandlerRegistry | undefined;
@@ -735,6 +789,7 @@ export abstract class Graph<
735
789
  clearHeavyState(): void {
736
790
  this.config = undefined;
737
791
  this.signal = undefined;
792
+ this.callerSignal = undefined;
738
793
  this.contentData = [];
739
794
  this.contentIndexMap = new Map();
740
795
  this.stepKeyIds = new Map();
@@ -937,6 +992,8 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
937
992
  messages: BaseMessage[] = [];
938
993
  /** Cached run messages preserved before clearHeavyState() so getRunMessages() works after cleanup. */
939
994
  private cachedRunMessages?: BaseMessage[];
995
+ /** Ids of AI turns the agent node returned THIS run; see isRunProducedMessage. */
996
+ protected runProducedAiMessageIds = new Set<string>();
940
997
  /** Checkpoint scope whose messages match index-keyed tool snapshots. */
941
998
  private originalToolContentCheckpointScope?: string;
942
999
  runId: string | undefined;
@@ -962,6 +1019,53 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
962
1019
  subagentUsageSink?: t.SubagentUsageSink;
963
1020
  /** See {@link t.StandardGraphInput.subagentScope}. */
964
1021
  subagentScope: boolean;
1022
+ /** See {@link t.StandardGraphInput.preemption}. */
1023
+ preemption?: t.StreamPreemption;
1024
+ /**
1025
+ * Seals charged against `preemption.maxSeals`. Per-turn: cleared by both
1026
+ * reset paths so a fresh turn gets a fresh budget, while a HITL resume —
1027
+ * which skips `resetValues` — keeps what it had left.
1028
+ */
1029
+ private preemptSealBudgetUsed = 0;
1030
+ /**
1031
+ * Seals honored over the graph's lifetime. Reported by
1032
+ * {@link getPreemptStats}, so it deliberately SURVIVES `clearHeavyState()`
1033
+ * — a host reads it after `processStream` returns, which is strictly after
1034
+ * cleanup runs.
1035
+ */
1036
+ preemptSealCount = 0;
1037
+ /** Boundaries that produced nothing to inject, so the turn stopped early. */
1038
+ preemptEmptyBoundaries = 0;
1039
+ /**
1040
+ * Set between claiming a seal and resolving its boundary. `MultiAgentGraph`
1041
+ * fans parallel agents through this one instance against a single host
1042
+ * request, so without a one-at-a-time gate several streams would each seal
1043
+ * for the same queued message and every loser would take the
1044
+ * nothing-to-inject path and cut its answer short.
1045
+ */
1046
+ private preemptSealInFlight = false;
1047
+ /**
1048
+ * True when a seal ended the turn without a resume. The assistant turn is
1049
+ * real and kept, but it is not the answer the model intended to finish —
1050
+ * hosts persist it as unfinished rather than complete.
1051
+ */
1052
+ preemptIncomplete = false;
1053
+ /**
1054
+ * `stopReason` from a `PreemptBoundary` hook that halted the turn.
1055
+ *
1056
+ * Clearing the registry halt is what keeps the sealed turn alive, but the
1057
+ * registry held the only copy of the reason — so it is captured here first.
1058
+ * Without it `getHaltReason()` returns undefined and a host records a
1059
+ * hook-halted turn as an ordinary completion.
1060
+ */
1061
+ preemptHaltReason: string | undefined;
1062
+ /**
1063
+ * Agent IDs whose next superstep must return to the agent node. Keyed by
1064
+ * agent because `MultiAgentGraph` routes every parallel agent through this
1065
+ * same instance, and a single field would let one agent's boundary resume
1066
+ * another's turn.
1067
+ */
1068
+ pendingPreemptReturn = new Set<string>();
965
1069
 
966
1070
  constructor({
967
1071
  runId,
@@ -973,6 +1077,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
973
1077
  calibrationRatio,
974
1078
  subagentUsageSink,
975
1079
  subagentScope,
1080
+ preemption,
976
1081
  }: t.StandardGraphInput) {
977
1082
  super();
978
1083
  this.runId = runId;
@@ -980,6 +1085,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
980
1085
  this.langfuse = langfuse;
981
1086
  this.subagentUsageSink = subagentUsageSink;
982
1087
  this.subagentScope = subagentScope === true;
1088
+ this.preemption = preemption;
983
1089
 
984
1090
  if (agents.length === 0) {
985
1091
  throw new Error('At least one agent configuration is required');
@@ -1019,6 +1125,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1019
1125
  * a stale reference on 2nd+ processStream calls.
1020
1126
  */
1021
1127
  this.toolCallStepIds.clear();
1128
+ this.runProducedAiMessageIds.clear();
1022
1129
  this.eagerEventToolExecutions.clear();
1023
1130
  this.clearEagerEventToolUsageCounts();
1024
1131
  this.eagerEventToolCallChunks.clear();
@@ -1051,6 +1158,8 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1051
1158
  new Map()
1052
1159
  );
1053
1160
  this.invokedToolIds = resetIfNotEmpty(this.invokedToolIds, undefined);
1161
+ this.resetPreemptTurnState();
1162
+ this.resetPreemptTotals();
1054
1163
  const hasScopedCheckpoint =
1055
1164
  this.hasCompiledCheckpointer &&
1056
1165
  checkpointScope != null &&
@@ -1071,6 +1180,12 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1071
1180
  super.clearHeavyState();
1072
1181
  this.messages = [];
1073
1182
  this.overrideModel = undefined;
1183
+ /**
1184
+ * Turn state only. The reported totals must outlive cleanup — this runs
1185
+ * in `processStream`'s `finally`, and the host reads `getPreemptStats()`
1186
+ * after that returns.
1187
+ */
1188
+ this.resetPreemptTurnState();
1074
1189
  const preserveOriginalToolContent =
1075
1190
  this.hasCompiledCheckpointer &&
1076
1191
  this.originalToolContentCheckpointScope != null;
@@ -1079,6 +1194,120 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1079
1194
  }
1080
1195
  }
1081
1196
 
1197
+ /**
1198
+ * Per-turn seal budget and routing markers. Cleared by both reset paths so
1199
+ * a new turn starts with a full budget and no stale resume marker.
1200
+ *
1201
+ * The REPORTED counters are deliberately not touched here — see
1202
+ * {@link resetPreemptTotals}.
1203
+ */
1204
+ private resetPreemptTurnState(): void {
1205
+ this.preemptSealBudgetUsed = 0;
1206
+ this.preemptSealInFlight = false;
1207
+ this.pendingPreemptReturn.clear();
1208
+ }
1209
+
1210
+ /**
1211
+ * Lifetime seal totals, cleared only when a genuinely new run starts.
1212
+ * `clearHeavyState()` must NOT call this: it runs in `processStream`'s
1213
+ * `finally`, so zeroing here would make {@link getPreemptStats} and
1214
+ * `preemptIncomplete` unreadable for every caller of the method that just
1215
+ * produced them.
1216
+ */
1217
+ private resetPreemptTotals(): void {
1218
+ this.preemptSealCount = 0;
1219
+ this.preemptEmptyBoundaries = 0;
1220
+ this.preemptIncomplete = false;
1221
+ this.preemptHaltReason = undefined;
1222
+ }
1223
+
1224
+ /**
1225
+ * True when the host has requested a cooperative seal AND this graph may
1226
+ * honor it. Read once per streamed chunk, so it stays property reads plus
1227
+ * one host callback — no I/O, no allocation.
1228
+ *
1229
+ * Non-mutating: a true result only means a seal is worth evaluating. The
1230
+ * budget is taken by {@link claimPreemptSeal} once the accumulated chunk is
1231
+ * known to be safe, so a chunk that cannot seal never spends budget.
1232
+ *
1233
+ * Subagent scopes never seal: a steer targets the top-level conversation,
1234
+ * and a child run must finish so its parent sees a complete result.
1235
+ */
1236
+ /** Internal seal preconditions only — no host callback, no side effects. */
1237
+ private canClaimPreemptSeal(): boolean {
1238
+ /**
1239
+ * Resolved and required here with the same rule `dispatchPreemptBoundary`
1240
+ * uses. Without it a direct `StandardGraph` consumer that supplies no
1241
+ * `runId` could claim a seal on the strength of a global matcher, then hit
1242
+ * the boundary's own null-runId guard and get nothing back — truncating
1243
+ * the answer for a drain that provably could not run.
1244
+ */
1245
+ const runId =
1246
+ (this.config?.configurable?.run_id as string | undefined) ?? this.runId;
1247
+ return (
1248
+ !this.subagentScope &&
1249
+ this.preemption != null &&
1250
+ !this.preemptSealInFlight &&
1251
+ this.preemptSealBudgetUsed < resolveMaxSeals(this.preemption.maxSeals) &&
1252
+ runId != null &&
1253
+ /**
1254
+ * A seal only buys room for an injection. With no `PreemptBoundary`
1255
+ * matcher live — never registered, or a `once` matcher already
1256
+ * consumed — the boundary provably returns nothing and the answer is
1257
+ * cut short for no gain, so refuse the seal instead. Failing closed
1258
+ * lands on the documented no-preemption behavior: the model finishes
1259
+ * and the message waits for the next tool boundary.
1260
+ *
1261
+ * Same session resolution as `dispatchPreemptBoundary`, or a
1262
+ * session-scoped matcher would be visible at one site and not the other.
1263
+ */
1264
+ this.hookRegistry?.hasDispatchableHookFor('PreemptBoundary', runId) ===
1265
+ true
1266
+ );
1267
+ }
1268
+
1269
+ shouldPreemptStream(): boolean {
1270
+ return (
1271
+ this.canClaimPreemptSeal() && this.preemption?.shouldPreempt() === true
1272
+ );
1273
+ }
1274
+
1275
+ /**
1276
+ * Takes the seal slot, or returns false if another stream already holds it.
1277
+ *
1278
+ * Assumes the caller already polled `shouldPreemptStream()` for THIS chunk,
1279
+ * and deliberately does not poll the host again — `StreamPreemption`
1280
+ * documents `shouldPreempt` as once per chunk, and a host that consumes a
1281
+ * pending flag on read would lose the request to a second call.
1282
+ *
1283
+ * The guard and both mutations remain one synchronous body, which is what
1284
+ * makes this safe under a parallel `MultiAgentGraph`: several agents share
1285
+ * one graph and can each see the poll as true, but no `await` can split the
1286
+ * claim, so only one takes the slot. The loser keeps streaming normally
1287
+ * rather than sealing for a message it would never receive.
1288
+ */
1289
+ claimPreemptSeal(): boolean {
1290
+ if (!this.canClaimPreemptSeal()) {
1291
+ return false;
1292
+ }
1293
+ this.preemptSealInFlight = true;
1294
+ this.preemptSealBudgetUsed += 1;
1295
+ this.preemptSealCount += 1;
1296
+ return true;
1297
+ }
1298
+
1299
+ /** Releases the seal slot once its boundary has resolved, win or lose. */
1300
+ releasePreemptSeal(): void {
1301
+ this.preemptSealInFlight = false;
1302
+ }
1303
+
1304
+ getPreemptStats(): t.PreemptStats {
1305
+ return {
1306
+ seals: this.preemptSealCount,
1307
+ emptyBoundaries: this.preemptEmptyBoundaries,
1308
+ };
1309
+ }
1310
+
1082
1311
  /* Run Step Processing */
1083
1312
 
1084
1313
  getRunStep(stepId: string): t.RunStep | undefined {
@@ -1242,6 +1471,25 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1242
1471
  return this.messages.slice(this.startIndex);
1243
1472
  }
1244
1473
 
1474
+ /**
1475
+ * True when THIS RUN produced `message` — the provenance the handoff cue
1476
+ * gate needs. Tracked as an id set rather than inferred from `startIndex`
1477
+ * arithmetic: summarization's remove-all compaction rewrites the live
1478
+ * array and leaves `startIndex` stale, so index-based run/host
1479
+ * discrimination silently breaks right after a mid-run summarize. Ids
1480
+ * survive compaction (retained messages keep theirs), host-supplied
1481
+ * prefill messages are never in the set, and membership is O(1) per
1482
+ * model call.
1483
+ */
1484
+ isRunProducedMessage(message: BaseMessage): boolean {
1485
+ const id = message.id;
1486
+ return (
1487
+ typeof id === 'string' &&
1488
+ id !== '' &&
1489
+ this.runProducedAiMessageIds.has(id)
1490
+ );
1491
+ }
1492
+
1245
1493
  getContentParts(): t.MessageContentComplex[] | undefined {
1246
1494
  // `messages` can be null/undefined on a graph that has been disposed
1247
1495
  // (clearHeavyState) but is still reachable via a cache (e.g. RedisJobStore's
@@ -1622,6 +1870,23 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1622
1870
  throw new Error('No config provided');
1623
1871
  }
1624
1872
 
1873
+ /**
1874
+ * A `PreemptBoundary` hook halted this run and the sealed commit is
1875
+ * already in state. Enforced at every model node's ENTRY because that
1876
+ * is the only site that covers all of `MultiAgentGraph`'s onward
1877
+ * routing at once — static direct edges, Command fan-out, fan-in
1878
+ * wrappers, and parallel siblings' subsequent inner-loop turns — none
1879
+ * of which consult the halt (the registry signal was deliberately
1880
+ * cleared to keep the stream-cancel from destroying the sealed turn).
1881
+ * Declining the model call turns every routed-to successor into a
1882
+ * no-op, so the outer workflow drains to END without new turns or tool
1883
+ * side effects. Reset per turn in `resetPreemptTotals`, so the next
1884
+ * `processStream` call starts clean.
1885
+ */
1886
+ if (this.preemptHaltReason != null) {
1887
+ return { messages: [] };
1888
+ }
1889
+
1625
1890
  const { messages } = state;
1626
1891
 
1627
1892
  const discoveredNames = extractToolDiscoveries(messages);
@@ -1747,6 +2012,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1747
2012
  provider: agentContext.provider,
1748
2013
  tokenCounter: agentContext.tokenCounter,
1749
2014
  maxTokens: agentContext.maxContextTokens,
2015
+ maxToolResultChars: agentContext.maxToolResultChars,
1750
2016
  thinkingEnabled: isThinkingEnabled(
1751
2017
  agentContext.provider,
1752
2018
  agentContext.clientOptions
@@ -1923,37 +2189,136 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1923
2189
  }
1924
2190
 
1925
2191
  let finalMessages = messagesToUse;
1926
- /** Tail snapshot for the dispatch-time usage delta: in-place
1927
- * formatters (artifact appends, Bedrock content rewrites, legacy
1928
- * string conversion) mutate without changing length or identity
1929
- * capture before they run. Legacy string conversion can also touch
1930
- * messages before the tail, so those convertible indices are
1931
- * tracked separately (none exist in the common case). */
1932
- const tailStart = trailingMutationStart(messagesToUse);
1933
- let preFormatTailTokens: number | null = null;
1934
- let legacyIndices: number[] | null = null;
1935
- let preFormatLegacyTokens = 0;
2192
+ /**
2193
+ * Keep the pruner's provider-grounded aggregate as the authoritative
2194
+ * baseline, then attribute it across retained messages. Provider
2195
+ * transforms can shrink one message while expanding or adding another;
2196
+ * per-origin accounting prevents that unrelated shrink from canceling
2197
+ * the expansion. Raw counts are frozen before in-place formatters run.
2198
+ */
2199
+ let providerMessageBaseline:
2200
+ | Array<{ rawTokens: number; accountingWeight: number }>
2201
+ | undefined;
2202
+ const providerMessageOrigins = new WeakMap<BaseMessage, number>();
1936
2203
  if (contextUsage != null && agentContext.tokenCounter != null) {
1937
- preFormatTailTokens = 0;
1938
- for (const message of messagesToUse.slice(tailStart)) {
1939
- preFormatTailTokens += agentContext.tokenCounter(message);
2204
+ const sourceIndices = new WeakMap<BaseMessage, number>();
2205
+ for (let i = 0; i < messages.length; i++) {
2206
+ sourceIndices.set(messages[i], i);
1940
2207
  }
1941
- if (agentContext.useLegacyContent) {
1942
- legacyIndices = [];
1943
- for (let i = 0; i < tailStart; i++) {
1944
- if (isLegacyConvertible(messagesToUse[i])) {
1945
- legacyIndices.push(i);
1946
- preFormatLegacyTokens += agentContext.tokenCounter(
1947
- messagesToUse[i]
1948
- );
2208
+ providerMessageBaseline = messagesToUse.map((message, index) => {
2209
+ const rawTokens = agentContext.tokenCounter!(message);
2210
+ const sourceIndex = sourceIndices.get(message);
2211
+ const indexedTokens =
2212
+ sourceIndex != null
2213
+ ? agentContext.indexTokenCountMap[sourceIndex]
2214
+ : undefined;
2215
+ const accountingWeight =
2216
+ indexedTokens != null &&
2217
+ Number.isFinite(indexedTokens) &&
2218
+ indexedTokens >= 0
2219
+ ? indexedTokens
2220
+ : rawTokens;
2221
+ if (!providerMessageOrigins.has(message)) {
2222
+ providerMessageOrigins.set(message, index);
2223
+ }
2224
+ return { rawTokens, accountingWeight };
2225
+ });
2226
+ }
2227
+
2228
+ const getProviderMessageOriginKey = (
2229
+ message: BaseMessage
2230
+ ): string | undefined => {
2231
+ const type = message.getType();
2232
+ if (
2233
+ message instanceof ToolMessage &&
2234
+ typeof message.tool_call_id === 'string' &&
2235
+ message.tool_call_id.length > 0
2236
+ ) {
2237
+ return `tool:call:${message.tool_call_id}`;
2238
+ }
2239
+ if (typeof message.id === 'string' && message.id.length > 0) {
2240
+ return `${type}:id:${message.id}`;
2241
+ }
2242
+ return undefined;
2243
+ };
2244
+
2245
+ /**
2246
+ * Provider projections clone messages. Preserve their baseline origin
2247
+ * without writing tracking metadata onto the wire. Synthetic fold
2248
+ * messages intentionally remain unattributed and are charged in full.
2249
+ */
2250
+ const trackProviderMessageOrigins = (
2251
+ before: BaseMessage[],
2252
+ after: BaseMessage[]
2253
+ ): BaseMessage[] => {
2254
+ if (providerMessageBaseline == null || before === after) {
2255
+ return after;
2256
+ }
2257
+ if (before.length === after.length) {
2258
+ for (let i = 0; i < after.length; i++) {
2259
+ const origin = providerMessageOrigins.get(before[i]);
2260
+ if (
2261
+ origin != null &&
2262
+ !providerMessageOrigins.has(after[i]) &&
2263
+ before[i].getType() === after[i].getType() &&
2264
+ !isSyntheticProviderContextMessage(after[i])
2265
+ ) {
2266
+ providerMessageOrigins.set(after[i], origin);
1949
2267
  }
1950
2268
  }
2269
+ return after;
1951
2270
  }
1952
- }
2271
+
2272
+ const keyedOrigins = new Map<string, number | null>();
2273
+ for (const message of before) {
2274
+ const origin = providerMessageOrigins.get(message);
2275
+ const key = getProviderMessageOriginKey(message);
2276
+ if (origin == null || key == null) {
2277
+ continue;
2278
+ }
2279
+ keyedOrigins.set(key, keyedOrigins.has(key) ? null : origin);
2280
+ }
2281
+ for (const message of after) {
2282
+ if (
2283
+ providerMessageOrigins.has(message) ||
2284
+ isSyntheticProviderContextMessage(message)
2285
+ ) {
2286
+ continue;
2287
+ }
2288
+ const key = getProviderMessageOriginKey(message);
2289
+ const origin = key != null ? keyedOrigins.get(key) : undefined;
2290
+ if (origin != null) {
2291
+ providerMessageOrigins.set(message, origin);
2292
+ }
2293
+ }
2294
+ return after;
2295
+ };
2296
+
1953
2297
  if (agentContext.useLegacyContent) {
1954
- finalMessages = formatContentStrings(finalMessages);
2298
+ const before = finalMessages;
2299
+ finalMessages = trackProviderMessageOrigins(
2300
+ before,
2301
+ formatContentStrings(before)
2302
+ );
1955
2303
  }
1956
2304
 
2305
+ const maxProviderToolResultChars =
2306
+ agentContext.maxToolResultChars ??
2307
+ calculateMaxToolResultChars(agentContext.maxContextTokens);
2308
+ const beforeToolStreamProjection = finalMessages;
2309
+ finalMessages = trackProviderMessageOrigins(
2310
+ beforeToolStreamProjection,
2311
+ projectToolStreamContentForProvider(beforeToolStreamProjection)
2312
+ );
2313
+ const beforeToolInputProjection = finalMessages;
2314
+ finalMessages = trackProviderMessageOrigins(
2315
+ beforeToolInputProjection,
2316
+ projectToolCallInputs(
2317
+ beforeToolInputProjection,
2318
+ calculateMaxToolCallInputChars(agentContext.maxContextTokens)
2319
+ )
2320
+ );
2321
+
1957
2322
  const lastMessageX =
1958
2323
  finalMessages.length >= 2
1959
2324
  ? finalMessages[finalMessages.length - 2]
@@ -1979,56 +2344,433 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1979
2344
  trimmed.length > 0 ? [{ type: 'text' as const, text: trimmed }] : '';
1980
2345
  }
1981
2346
 
2347
+ const localProviderOverflowMeasurements = new WeakMap<
2348
+ object,
2349
+ {
2350
+ contextBudget: number;
2351
+ estimatedPromptTokens: number;
2352
+ }
2353
+ >();
2354
+ const measureProviderPayload = (
2355
+ candidate: BaseMessage[],
2356
+ contextBudgetOverride?: number,
2357
+ forceRawRecount = false
2358
+ ): {
2359
+ fits: boolean;
2360
+ projectedMessageTokens?: number;
2361
+ availableMessageTokens?: number;
2362
+ contextBudget?: number;
2363
+ effectiveInstructionTokens?: number;
2364
+ } => {
2365
+ const contextBudget =
2366
+ contextBudgetOverride ?? contextUsage?.contextBudget;
2367
+ const effectiveInstructionTokens =
2368
+ contextUsage?.effectiveInstructionTokens ??
2369
+ (forceRawRecount ? agentContext.instructionTokens : undefined);
2370
+ if (
2371
+ agentContext.tokenCounter == null ||
2372
+ contextBudget == null ||
2373
+ effectiveInstructionTokens == null
2374
+ ) {
2375
+ return { fits: true };
2376
+ }
2377
+ const availableMessageTokens = Math.max(
2378
+ 0,
2379
+ contextBudget - effectiveInstructionTokens
2380
+ );
2381
+ let usageRatio =
2382
+ agentContext.calibrationRatio > 0 ? agentContext.calibrationRatio : 1;
2383
+ if (
2384
+ contextUsage?.calibrationRatio != null &&
2385
+ contextUsage.calibrationRatio > 0
2386
+ ) {
2387
+ usageRatio = contextUsage.calibrationRatio;
2388
+ }
2389
+ if (forceRawRecount) {
2390
+ usageRatio = Math.max(1, usageRatio);
2391
+ }
2392
+ const baselineRemaining = contextUsage?.remainingContextTokens;
2393
+ const accountedMessageTokens =
2394
+ !forceRawRecount &&
2395
+ providerMessageBaseline != null &&
2396
+ baselineRemaining != null &&
2397
+ Number.isFinite(baselineRemaining)
2398
+ ? availableMessageTokens -
2399
+ Math.min(availableMessageTokens, Math.max(0, baselineRemaining))
2400
+ : undefined;
2401
+
2402
+ let projectedMessageTokens: number;
2403
+ if (accountedMessageTokens != null && providerMessageBaseline != null) {
2404
+ const replyPrimerTokens = Math.round(
2405
+ REPLY_PRIMER_TOKENS * usageRatio
2406
+ );
2407
+ const rawWeights: Record<string, number> = {};
2408
+ let totalWeight = 0;
2409
+ for (let i = 0; i < providerMessageBaseline.length; i++) {
2410
+ const weight = providerMessageBaseline[i].accountingWeight;
2411
+ rawWeights[i] = weight;
2412
+ totalWeight += weight;
2413
+ }
2414
+ const attributableTokens =
2415
+ totalWeight > 0
2416
+ ? Math.min(
2417
+ Math.max(0, accountedMessageTokens - replyPrimerTokens),
2418
+ Math.round(totalWeight * usageRatio)
2419
+ )
2420
+ : 0;
2421
+ const apportionedTokens =
2422
+ totalWeight > 0
2423
+ ? apportionTokenCounts(
2424
+ rawWeights,
2425
+ attributableTokens / totalWeight,
2426
+ attributableTokens
2427
+ )
2428
+ : {};
2429
+ const attributedByOrigin = providerMessageBaseline.map(
2430
+ (_, origin) => apportionedTokens[origin] || 0
2431
+ );
2432
+ projectedMessageTokens = Math.max(
2433
+ replyPrimerTokens,
2434
+ accountedMessageTokens - attributableTokens
2435
+ );
2436
+ let newRawTokens = 0;
2437
+ const usedOrigins = new Set<number>();
2438
+ for (const message of candidate) {
2439
+ const rawTokens = agentContext.tokenCounter(message);
2440
+ const origin = providerMessageOrigins.get(message);
2441
+ if (origin == null || usedOrigins.has(origin)) {
2442
+ newRawTokens += rawTokens;
2443
+ continue;
2444
+ }
2445
+ usedOrigins.add(origin);
2446
+ projectedMessageTokens += Math.max(
2447
+ 0,
2448
+ attributedByOrigin[origin] +
2449
+ Math.round(
2450
+ (rawTokens - providerMessageBaseline[origin].rawTokens) *
2451
+ usageRatio
2452
+ )
2453
+ );
2454
+ }
2455
+ projectedMessageTokens += Math.round(newRawTokens * usageRatio);
2456
+ } else {
2457
+ let rawTokens = REPLY_PRIMER_TOKENS;
2458
+ for (const message of candidate) {
2459
+ rawTokens += agentContext.tokenCounter(message);
2460
+ }
2461
+ projectedMessageTokens = Math.round(rawTokens * usageRatio);
2462
+ }
2463
+ return {
2464
+ fits: projectedMessageTokens <= availableMessageTokens,
2465
+ projectedMessageTokens,
2466
+ availableMessageTokens,
2467
+ contextBudget,
2468
+ effectiveInstructionTokens,
2469
+ };
2470
+ };
2471
+
2472
+ const createProviderPayloadOverflowError = ({
2473
+ projection,
2474
+ provider,
2475
+ info,
2476
+ }: {
2477
+ projection: ReturnType<typeof measureProviderPayload>;
2478
+ provider?: Providers;
2479
+ info: string;
2480
+ }): ContextOverflowError => {
2481
+ const error = new ContextOverflowError(
2482
+ JSON.stringify({
2483
+ type: 'final_context_overflow',
2484
+ info,
2485
+ provider,
2486
+ projectedMessageTokens: projection.projectedMessageTokens,
2487
+ availableMessageTokens: projection.availableMessageTokens,
2488
+ })
2489
+ );
2490
+ if (
2491
+ projection.projectedMessageTokens != null &&
2492
+ projection.contextBudget != null &&
2493
+ projection.effectiveInstructionTokens != null
2494
+ ) {
2495
+ localProviderOverflowMeasurements.set(error, {
2496
+ contextBudget: projection.contextBudget,
2497
+ estimatedPromptTokens:
2498
+ projection.projectedMessageTokens +
2499
+ projection.effectiveInstructionTokens,
2500
+ });
2501
+ }
2502
+ return error;
2503
+ };
2504
+
2505
+ const applyProviderMessageTransforms = (
2506
+ candidate: BaseMessage[]
2507
+ ): BaseMessage[] => {
2508
+ let transformed = candidate;
2509
+ if (
2510
+ isThinkingEnabled(agentContext.provider, agentContext.clientOptions)
2511
+ ) {
2512
+ /**
2513
+ * Current-run AI messages may validly omit a thinking block. The
2514
+ * boundary prevents them from being mistaken for foreign history.
2515
+ */
2516
+ const before = transformed;
2517
+ transformed = trackProviderMessageOrigins(
2518
+ before,
2519
+ ensureThinkingBlockInMessages(
2520
+ before,
2521
+ agentContext.provider,
2522
+ config,
2523
+ this.startIndex
2524
+ )
2525
+ );
2526
+ }
2527
+
2528
+ /**
2529
+ * Tool-less destinations cannot send inherited tool blocks without a
2530
+ * tool schema, so fold those interactions into provider-valid content.
2531
+ */
2532
+ if (toolsForBinding == null || toolsForBinding.length === 0) {
2533
+ const before = transformed;
2534
+ transformed = trackProviderMessageOrigins(
2535
+ before,
2536
+ foldToolBlocksForToollessAgent(before, config)
2537
+ );
2538
+ if (agentContext.useLegacyContent) {
2539
+ const beforeLegacyFormat = transformed;
2540
+ transformed = trackProviderMessageOrigins(
2541
+ beforeLegacyFormat,
2542
+ formatContentStrings(beforeLegacyFormat)
2543
+ );
2544
+ }
2545
+ }
2546
+ /**
2547
+ * Applied HERE for the primary so the cue is part of the MEASURED
2548
+ * payload — the pre-invoke projection and overflow guard run on this
2549
+ * stage's output, and a post-measure append could push a just-fits
2550
+ * prompt over budget unreported (#346 round 2). The attemptInvoke
2551
+ * funnel re-keys per SERVING provider: it strips this cue for a
2552
+ * tolerant fallback and adds it for a Claude fallback behind a
2553
+ * tolerant primary.
2554
+ */
2555
+ if (
2556
+ isAnthropicLike(
2557
+ agentContext.provider,
2558
+ agentContext.clientOptions as { model?: string }
2559
+ )
2560
+ ) {
2561
+ const before = transformed;
2562
+ transformed = trackProviderMessageOrigins(
2563
+ before,
2564
+ appendPredecessorHandoffCue(before, (message) =>
2565
+ this.isRunProducedMessage(message)
2566
+ )
2567
+ );
2568
+ }
2569
+ return transformed;
2570
+ };
2571
+
2572
+ const toolOutputRegistry = this.getOrCreateToolOutputRegistry();
2573
+ const providerRunId = config.configurable?.run_id as string | undefined;
2574
+ const projectProviderReferences = (
2575
+ candidate: BaseMessage[]
2576
+ ): BaseMessage[] =>
2577
+ trackProviderMessageOrigins(
2578
+ candidate,
2579
+ annotateMessagesForLLM(candidate, toolOutputRegistry, providerRunId)
2580
+ );
2581
+
2582
+ const compactSyntheticProviderContext = (
2583
+ candidate: BaseMessage[]
2584
+ ): BaseMessage[] => {
2585
+ const synthetic: Array<{
2586
+ index: number;
2587
+ message: HumanMessage;
2588
+ chars: number;
2589
+ }> = [];
2590
+ for (let i = 0; i < candidate.length; i++) {
2591
+ const message = candidate[i];
2592
+ if (
2593
+ !(message instanceof HumanMessage) ||
2594
+ !isSyntheticProviderContextMessage(message)
2595
+ ) {
2596
+ continue;
2597
+ }
2598
+ const content = message.content;
2599
+ synthetic.push({
2600
+ index: i,
2601
+ message,
2602
+ chars: getToolContentCharLength(content),
2603
+ });
2604
+ }
2605
+ if (synthetic.length === 0) {
2606
+ return candidate;
2607
+ }
2608
+
2609
+ const buildCandidate = (scale: number): BaseMessage[] => {
2610
+ const compacted = [...candidate];
2611
+ for (const { index, message, chars } of synthetic) {
2612
+ const content = compactToolContent(
2613
+ message.content,
2614
+ Math.floor(chars * scale)
2615
+ ).content;
2616
+ compacted[index] = new HumanMessage({
2617
+ content,
2618
+ id: message.id,
2619
+ name: message.name,
2620
+ additional_kwargs: message.additional_kwargs,
2621
+ response_metadata: message.response_metadata,
2622
+ });
2623
+ }
2624
+ return compacted;
2625
+ };
2626
+
2627
+ let best = buildCandidate(0);
2628
+ if (!measureProviderPayload(best).fits) {
2629
+ return candidate;
2630
+ }
2631
+ let low = 0;
2632
+ let high = 1;
2633
+ for (let i = 0; i < 12; i++) {
2634
+ const scale = (low + high) / 2;
2635
+ const attempt = buildCandidate(scale);
2636
+ if (measureProviderPayload(attempt).fits) {
2637
+ best = attempt;
2638
+ low = scale;
2639
+ } else {
2640
+ high = scale;
2641
+ }
2642
+ }
2643
+ return best;
2644
+ };
2645
+
2646
+ let artifactBaseMessages: BaseMessage[] | undefined;
1982
2647
  if (lastMessageY instanceof ToolMessage) {
2648
+ let artifactCandidate = finalMessages;
1983
2649
  if (anthropicLike) {
1984
- formatAnthropicArtifactContent(finalMessages);
2650
+ artifactCandidate = trackProviderMessageOrigins(
2651
+ finalMessages,
2652
+ projectAnthropicArtifactContent(
2653
+ finalMessages,
2654
+ maxProviderToolResultChars
2655
+ )
2656
+ );
1985
2657
  } else if (
1986
2658
  (isOpenAILike(agentContext.provider) &&
1987
2659
  agentContext.provider !== Providers.DEEPSEEK) ||
1988
2660
  isGoogleLike(agentContext.provider)
1989
2661
  ) {
1990
- formatArtifactPayload(finalMessages);
2662
+ artifactCandidate = trackProviderMessageOrigins(
2663
+ finalMessages,
2664
+ projectArtifactPayload(finalMessages, maxProviderToolResultChars)
2665
+ );
2666
+ }
2667
+
2668
+ if (artifactCandidate !== finalMessages) {
2669
+ const projection = measureProviderPayload(artifactCandidate);
2670
+ if (projection.fits) {
2671
+ artifactBaseMessages = finalMessages;
2672
+ finalMessages = artifactCandidate;
2673
+ } else {
2674
+ emitAgentLog(
2675
+ config,
2676
+ 'warn',
2677
+ 'graph',
2678
+ 'Artifact payload omitted because it exceeds the remaining context budget',
2679
+ {
2680
+ projectedMessageTokens: projection.projectedMessageTokens,
2681
+ availableMessageTokens: projection.availableMessageTokens,
2682
+ },
2683
+ { runId: this.runId, agentId }
2684
+ );
2685
+ }
1991
2686
  }
1992
2687
  }
1993
2688
 
1994
- if (
1995
- isThinkingEnabled(agentContext.provider, agentContext.clientOptions)
1996
- ) {
1997
- /**
1998
- * Pass `this.startIndex` so the function can distinguish CURRENT-run
1999
- * AI messages (the agent's own iterations — possibly without a
2000
- * leading thinking block, which Claude is allowed to skip) from
2001
- * historical context that genuinely needs the
2002
- * `[Previous agent context]` placeholder. Without this signal the
2003
- * function would convert the agent's own in-run tool_use messages,
2004
- * polluting the next iteration's prompt with a placeholder the
2005
- * model treats as suspicious injected content.
2006
- */
2007
- finalMessages = ensureThinkingBlockInMessages(
2008
- finalMessages,
2009
- agentContext.provider,
2010
- config,
2011
- this.startIndex
2012
- );
2689
+ finalMessages = projectProviderReferences(
2690
+ applyProviderMessageTransforms(finalMessages)
2691
+ );
2692
+ let finalProjection = measureProviderPayload(finalMessages);
2693
+ if (artifactBaseMessages != null) {
2694
+ if (!finalProjection.fits) {
2695
+ finalMessages = projectProviderReferences(
2696
+ applyProviderMessageTransforms(artifactBaseMessages)
2697
+ );
2698
+ finalProjection = measureProviderPayload(finalMessages);
2699
+ emitAgentLog(
2700
+ config,
2701
+ 'warn',
2702
+ 'graph',
2703
+ 'Artifact payload omitted after final provider formatting exceeded the remaining context budget',
2704
+ {
2705
+ projectedMessageTokens: finalProjection.projectedMessageTokens,
2706
+ availableMessageTokens: finalProjection.availableMessageTokens,
2707
+ },
2708
+ { runId: this.runId, agentId }
2709
+ );
2710
+ }
2711
+ }
2712
+ if (!finalProjection.fits) {
2713
+ const compacted = compactSyntheticProviderContext(finalMessages);
2714
+ if (compacted !== finalMessages) {
2715
+ finalMessages = compacted;
2716
+ finalProjection = measureProviderPayload(finalMessages);
2717
+ emitAgentLog(
2718
+ config,
2719
+ finalProjection.fits ? 'warn' : 'error',
2720
+ 'graph',
2721
+ finalProjection.fits
2722
+ ? 'Synthetic provider context compacted to fit the final payload budget'
2723
+ : 'Final provider payload still exceeds budget after synthetic context compaction',
2724
+ {
2725
+ projectedMessageTokens: finalProjection.projectedMessageTokens,
2726
+ availableMessageTokens: finalProjection.availableMessageTokens,
2727
+ },
2728
+ { runId: this.runId, agentId }
2729
+ );
2730
+ }
2013
2731
  }
2014
2732
 
2015
2733
  /**
2016
- * A destination that binds no tools is invoked without a tool schema, but
2017
- * in a multi-agent graph it can still inherit a prior agent's toolUse/
2018
- * toolResult history. Bedrock's Converse API (and other tool-schema-strict
2019
- * providers) reject such a request when no top-level toolConfig is sent.
2020
- * Fold that historical tool content into plain text so the tool-less agent
2021
- * receives valid, context-preserving messages. Handoff tools count as
2022
- * bound tools, so a tool-less router mid-handoff is not affected.
2734
+ * Mistral rejects consecutive user turns outright; Bedrock's Converse
2735
+ * API documents strict user/assistant alternation across its model
2736
+ * families, with enforcement varying by family (Claude on Converse
2737
+ * currently tolerates the shape verified live but the payload is
2738
+ * normalized for all of them rather than betting on leniency). Four
2739
+ * sites can emit them the `PostToolBatch` and `PreemptBoundary` hook
2740
+ * boundaries (a consolidated context message followed by one
2741
+ * `HumanMessage` per injected entry), a queue drain carrying more than
2742
+ * one steer, and `run.ts`'s pre-stream context push onto a payload that
2743
+ * already ends on a user turn.
2744
+ *
2745
+ * Normalized here, at the last provider-facing hop, rather than at any
2746
+ * one boundary: the boundaries must keep per-message identity, because
2747
+ * `additional_kwargs.source`/`skillName` drive steer rendering and the
2748
+ * trailing-steer anchor downstream. Graph state and the host's
2749
+ * persisted messages are untouched — this shapes only what goes on the
2750
+ * wire, for the providers that actually care.
2751
+ *
2752
+ * Runs AFTER synthetic-context compaction: that pass can rewrite or
2753
+ * drop messages, so coalescing has to see its output, and it is the
2754
+ * last shaping step before the cache breakpoint is chosen.
2023
2755
  */
2024
- if (toolsForBinding == null || toolsForBinding.length === 0) {
2025
- finalMessages = foldToolBlocksForToollessAgent(finalMessages, config);
2026
- // The fold emits structured (array) content; re-flatten for agents that
2027
- // opted into string-only messages (`useLegacyContent`, run earlier at
2028
- // the top of this block) so the folded turn isn't the lone exception.
2029
- if (agentContext.useLegacyContent) {
2030
- finalMessages = formatContentStrings(finalMessages);
2031
- }
2756
+ if (strictAlternationProviders.has(agentContext.provider)) {
2757
+ /**
2758
+ * Wrapped like every other provider transform: the merged message is
2759
+ * a NEW object, and without re-attachment the final pre-invoke
2760
+ * measurement would drop both source turns' calibrated shares and
2761
+ * recharge the merge at full raw estimate — enough to flip a
2762
+ * just-fits payload (the synthetic-context compaction above binary
2763
+ * searches to exactly that) into a spurious pre-invoke overflow. The
2764
+ * merge keeps the first source's id, so the keyed branch re-attaches
2765
+ * that origin; the absorbed turn's tokens are charged as new raw
2766
+ * growth, which only ever under-estimates by less than the old
2767
+ * behavior over-estimated.
2768
+ */
2769
+ const beforeCoalesce = finalMessages;
2770
+ finalMessages = trackProviderMessageOrigins(
2771
+ beforeCoalesce,
2772
+ coalesceAdjacentUserTurns(beforeCoalesce)
2773
+ );
2032
2774
  }
2033
2775
 
2034
2776
  // Determine the prompt-cache strategy up front. Two distinct facts:
@@ -2083,7 +2825,16 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2083
2825
  providerPromptCacheEnabled);
2084
2826
  if (needsOrphanSanitize) {
2085
2827
  const beforeSanitize = finalMessages.length;
2086
- finalMessages = sanitizeOrphanToolBlocks(finalMessages);
2828
+ const beforeSanitizeMessages = finalMessages;
2829
+ finalMessages = trackProviderMessageOrigins(
2830
+ beforeSanitizeMessages,
2831
+ sanitizeOrphanToolBlocks(beforeSanitizeMessages, (source, clone) => {
2832
+ const origin = providerMessageOrigins.get(source);
2833
+ if (origin != null) {
2834
+ providerMessageOrigins.set(clone, origin);
2835
+ }
2836
+ })
2837
+ );
2087
2838
  if (finalMessages.length !== beforeSanitize) {
2088
2839
  emitAgentLog(
2089
2840
  config,
@@ -2113,20 +2864,24 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2113
2864
  (anthropicPromptCacheEnabled || openRouterPromptCacheEnabled) &&
2114
2865
  !agentContext.systemRunnable
2115
2866
  ) {
2116
- finalMessages = addTailCacheControl<BaseMessage>(
2117
- finalMessages,
2118
- resolvePromptCacheTtl(
2119
- anthropicPromptCacheEnabled
2120
- ? (
2121
- agentContext.clientOptions as
2122
- | t.AnthropicClientOptions
2123
- | undefined
2124
- )?.promptCacheTtl
2125
- : (
2126
- agentContext.clientOptions as
2127
- | t.ProviderOptionsMap[Providers.OPENROUTER]
2128
- | undefined
2129
- )?.promptCacheTtl
2867
+ const beforeCacheControl = finalMessages;
2868
+ finalMessages = trackProviderMessageOrigins(
2869
+ beforeCacheControl,
2870
+ addTailCacheControl<BaseMessage>(
2871
+ beforeCacheControl,
2872
+ resolvePromptCacheTtl(
2873
+ anthropicPromptCacheEnabled
2874
+ ? (
2875
+ agentContext.clientOptions as
2876
+ | t.AnthropicClientOptions
2877
+ | undefined
2878
+ )?.promptCacheTtl
2879
+ : (
2880
+ agentContext.clientOptions as
2881
+ | t.ProviderOptionsMap[Providers.OPENROUTER]
2882
+ | undefined
2883
+ )?.promptCacheTtl
2884
+ )
2130
2885
  )
2131
2886
  );
2132
2887
  } else if (bedrockPromptCacheEnabled) {
@@ -2135,15 +2890,46 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2135
2890
  | undefined;
2136
2891
  // Non-Claude models (Nova) reject the extended 1h TTL, so resolve it
2137
2892
  // against the model — message/system caching stays on, clamped to 5m.
2138
- finalMessages = addBedrockTailCacheControl<BaseMessage>(
2139
- finalMessages,
2140
- resolveBedrockPromptCacheTtl(
2141
- bedrockOptions?.promptCacheTtl,
2142
- (bedrockOptions as { model?: string } | undefined)?.model
2893
+ const beforeCacheControl = finalMessages;
2894
+ finalMessages = trackProviderMessageOrigins(
2895
+ beforeCacheControl,
2896
+ addBedrockTailCacheControl<BaseMessage>(
2897
+ beforeCacheControl,
2898
+ resolveBedrockPromptCacheTtl(
2899
+ bedrockOptions?.promptCacheTtl,
2900
+ (bedrockOptions as { model?: string } | undefined)?.model
2901
+ )
2143
2902
  )
2144
2903
  );
2145
2904
  }
2146
2905
 
2906
+ const fallbackBaseMessages = finalMessages;
2907
+ const beforeFinalProviderProjection = fallbackBaseMessages;
2908
+ finalMessages = trackProviderMessageOrigins(
2909
+ beforeFinalProviderProjection,
2910
+ projectMessagesForProvider({
2911
+ model: (this.overrideModel ?? model) as t.ChatModel,
2912
+ messages: beforeFinalProviderProjection,
2913
+ provider: agentContext.provider,
2914
+ maxToolResultChars: maxProviderToolResultChars,
2915
+ callOptions: config,
2916
+ })
2917
+ );
2918
+
2919
+ /**
2920
+ * Prompt-cache placement and orphan sanitization are provider-wire
2921
+ * transforms too. Re-measure after both so no content added after the
2922
+ * earlier artifact/synthetic compaction decision can bypass the guard.
2923
+ */
2924
+ finalProjection = measureProviderPayload(finalMessages);
2925
+ const preInvokeContextOverflowError = !finalProjection.fits
2926
+ ? createProviderPayloadOverflowError({
2927
+ projection: finalProjection,
2928
+ provider: agentContext.provider,
2929
+ info: 'Provider message formatting exceeded the context budget and no safe synthetic-context compaction could make it fit.',
2930
+ })
2931
+ : undefined;
2932
+
2147
2933
  if (
2148
2934
  agentContext.lastStreamCall != null &&
2149
2935
  agentContext.streamBuffer != null
@@ -2219,66 +3005,16 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2219
3005
 
2220
3006
  /** Past the empty-prompt guard — a model call is now guaranteed */
2221
3007
  if (contextUsage != null) {
2222
- const usageRatio =
2223
- contextUsage.calibrationRatio != null &&
2224
- contextUsage.calibrationRatio > 0
2225
- ? contextUsage.calibrationRatio
2226
- : 1;
2227
3008
  if (
2228
- agentContext.tokenCounter != null &&
2229
- finalMessages.length !== messagesToUse.length
3009
+ finalProjection.projectedMessageTokens != null &&
3010
+ finalProjection.availableMessageTokens != null
2230
3011
  ) {
2231
- /** Post-prune formatting restructured the payload (e.g. thinking
2232
- * placeholder collapse, orphan drops) — recount so the gauge
2233
- * reflects what is actually sent */
2234
- let rawTokens = 0;
2235
- for (const message of finalMessages) {
2236
- rawTokens += agentContext.tokenCounter(message);
2237
- }
2238
3012
  contextUsage.breakdown.messageCount = finalMessages.length;
2239
- if (
2240
- contextUsage.contextBudget != null &&
2241
- contextUsage.effectiveInstructionTokens != null
2242
- ) {
2243
- contextUsage.remainingContextTokens = Math.max(
2244
- 0,
2245
- contextUsage.contextBudget -
2246
- contextUsage.effectiveInstructionTokens -
2247
- Math.round(rawTokens * usageRatio)
2248
- );
2249
- }
2250
- } else if (
2251
- preFormatTailTokens != null &&
2252
- agentContext.tokenCounter != null &&
2253
- contextUsage.remainingContextTokens != null
2254
- ) {
2255
- /** Same-length formatting can still mutate in place — the trailing
2256
- * tool batch (artifacts, Bedrock rewrites) and any legacy-converted
2257
- * messages before it — adjust remaining by the calibrated delta */
2258
- let postFormatTailTokens = 0;
2259
- for (const message of finalMessages.slice(tailStart)) {
2260
- postFormatTailTokens += agentContext.tokenCounter(message);
2261
- }
2262
- let formatDelta = postFormatTailTokens - preFormatTailTokens;
2263
- if (legacyIndices != null && legacyIndices.length > 0) {
2264
- let postFormatLegacyTokens = 0;
2265
- for (const index of legacyIndices) {
2266
- postFormatLegacyTokens += agentContext.tokenCounter(
2267
- finalMessages[index]
2268
- );
2269
- }
2270
- formatDelta += postFormatLegacyTokens - preFormatLegacyTokens;
2271
- }
2272
- if (formatDelta !== 0) {
2273
- contextUsage.remainingContextTokens = Math.max(
2274
- 0,
2275
- Math.min(
2276
- contextUsage.contextBudget ?? Number.MAX_SAFE_INTEGER,
2277
- contextUsage.remainingContextTokens -
2278
- Math.round(formatDelta * usageRatio)
2279
- )
2280
- );
2281
- }
3013
+ contextUsage.remainingContextTokens = Math.max(
3014
+ 0,
3015
+ finalProjection.availableMessageTokens -
3016
+ finalProjection.projectedMessageTokens
3017
+ );
2282
3018
  }
2283
3019
  syncBudgetDerivedFields(contextUsage);
2284
3020
  /** Awaited so async host handlers receive the pre-invoke snapshot
@@ -2346,6 +3082,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2346
3082
  const metadata = config.metadata as Record<string, unknown>;
2347
3083
 
2348
3084
  try {
3085
+ if (preInvokeContextOverflowError != null) {
3086
+ throw preInvokeContextOverflowError;
3087
+ }
2349
3088
  result = await withLangfuseRuntimeScope(
2350
3089
  resolveLangfuseRuntimeScope({
2351
3090
  runLangfuse: this.langfuse,
@@ -2383,15 +3122,6 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2383
3122
  */
2384
3123
  const estimatedPromptTokens = getEstimatedPromptTokens(contextUsage);
2385
3124
 
2386
- /**
2387
- * A previous correction that left the prompt no smaller proves this
2388
- * state has nothing left to compact — an emptied message list whose
2389
- * content rides along in an injected summary, for instance. Measuring
2390
- * that beats trying to predict every such configuration.
2391
- */
2392
- const recoveryStalled = agentContext.overflowRecoveryStalled(
2393
- estimatedPromptTokens
2394
- );
2395
3125
  const canSummarizeOverflow =
2396
3126
  agentContext.summarizationEnabled === true &&
2397
3127
  splitAtRecencyBoundary(messages, {
@@ -2402,13 +3132,36 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2402
3132
  tokenCounter: agentContext.tokenCounter,
2403
3133
  }).head.length > 0;
2404
3134
 
3135
+ const getLocalProviderOverflowMeasurement = (
3136
+ error: unknown
3137
+ ):
3138
+ | {
3139
+ contextBudget: number;
3140
+ estimatedPromptTokens: number;
3141
+ }
3142
+ | undefined =>
3143
+ typeof error === 'object' && error !== null
3144
+ ? localProviderOverflowMeasurements.get(error)
3145
+ : undefined;
3146
+
3147
+ const getRecoveryPromptEstimate = (
3148
+ error: unknown,
3149
+ fallbackContext?: FallbackErrorContext
3150
+ ): number | undefined => {
3151
+ const resolvedFallbackContext =
3152
+ fallbackContext ?? getFallbackErrorContext(error);
3153
+ return (
3154
+ getLocalProviderOverflowMeasurement(error)?.estimatedPromptTokens ??
3155
+ (resolvedFallbackContext == null
3156
+ ? estimatedPromptTokens
3157
+ : undefined)
3158
+ );
3159
+ };
3160
+
2405
3161
  const planRecovery = (
2406
3162
  error: unknown,
2407
3163
  attributedFallbackContext?: FallbackErrorContext
2408
3164
  ): OverflowRecoveryPlan | null => {
2409
- if (recoveryStalled) {
2410
- return null;
2411
- }
2412
3165
  /**
2413
3166
  * When the rejection came from a fallback, plan against *that*
2414
3167
  * client: its window and output allowance are why it was configured
@@ -2416,13 +3169,28 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2416
3169
  */
2417
3170
  const fallbackContext =
2418
3171
  attributedFallbackContext ?? getFallbackErrorContext(error);
3172
+ const localMeasurement = getLocalProviderOverflowMeasurement(error);
3173
+ const recoveryPromptEstimate = getRecoveryPromptEstimate(
3174
+ error,
3175
+ fallbackContext
3176
+ );
3177
+ /**
3178
+ * A previous correction that left the rejected prompt no smaller
3179
+ * proves this state has nothing left to compact. Use the fallback
3180
+ * projection when one exists so unlike provider formats are never
3181
+ * compared through the primary's cheaper pre-projection estimate.
3182
+ */
3183
+ if (agentContext.overflowRecoveryStalled(recoveryPromptEstimate)) {
3184
+ return null;
3185
+ }
2419
3186
  const recovery = planContextOverflowRecovery({
2420
3187
  error,
2421
3188
  provider: fallbackContext?.provider ?? agentContext.provider,
2422
3189
  maxContextTokens:
3190
+ localMeasurement?.contextBudget ??
2423
3191
  fallbackContext?.maxContextTokens ??
2424
3192
  agentContext.maxContextTokens,
2425
- estimatedPromptTokens,
3193
+ estimatedPromptTokens: recoveryPromptEstimate,
2426
3194
  calibrationRatio: agentContext.calibrationRatio,
2427
3195
  instructionTokens: agentContext.instructionTokens,
2428
3196
  canSummarize: agentContext.summarizationEnabled === true,
@@ -2440,12 +3208,14 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2440
3208
  ...recovery,
2441
3209
  budgetTokens: minDefined(
2442
3210
  getBlindRecoveryBudget(agentContext.maxContextTokens),
2443
- translateRecoveryBudget(
2444
- recovery.budgetTokens,
2445
- recovery.observedCalibrationRatio ??
2446
- CALIBRATION_RATIO_MAX,
2447
- agentContext.calibrationRatio
2448
- )
3211
+ localMeasurement != null
3212
+ ? recovery.budgetTokens
3213
+ : translateRecoveryBudget(
3214
+ recovery.budgetTokens,
3215
+ recovery.observedCalibrationRatio ??
3216
+ CALIBRATION_RATIO_MAX,
3217
+ agentContext.calibrationRatio
3218
+ )
2449
3219
  ),
2450
3220
  observedCalibrationRatio: undefined,
2451
3221
  }
@@ -2459,16 +3229,17 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2459
3229
 
2460
3230
  const recovery = planRecovery(primaryError);
2461
3231
  if (recovery != null) {
3232
+ const recoveryPromptEstimate =
3233
+ getRecoveryPromptEstimate(primaryError);
2462
3234
  return this.beginOverflowRecovery({
2463
3235
  recovery,
2464
3236
  agentContext,
2465
3237
  agentId,
2466
3238
  config,
2467
3239
  originalToolContent: prunedOriginalToolContent,
2468
- estimatedPromptTokens,
3240
+ estimatedPromptTokens: recoveryPromptEstimate,
2469
3241
  });
2470
3242
  }
2471
-
2472
3243
  /**
2473
3244
  * A fallback can reject the same prompt as too large even when the
2474
3245
  * primary failed for an unrelated reason — a fallback with a smaller
@@ -2485,7 +3256,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2485
3256
  tryFallbackProviders({
2486
3257
  fallbacks,
2487
3258
  tools: agentContext.tools,
2488
- messages: finalMessages,
3259
+ messages: fallbackBaseMessages,
2489
3260
  config: invokeConfig,
2490
3261
  primaryError,
2491
3262
  context: this,
@@ -2499,20 +3270,91 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2499
3270
  estimatedPromptTokens: getEstimatedPromptTokens(contextUsage),
2500
3271
  maxContextTokens: agentContext.maxContextTokens,
2501
3272
  },
3273
+ prepareProviderMessages: ({
3274
+ model: fallbackModel,
3275
+ messages: fallbackMessages,
3276
+ provider: fallbackProvider,
3277
+ maxContextTokens: fallbackMaxContextTokens,
3278
+ config: fallbackConfig,
3279
+ }) => {
3280
+ const fallbackToolResultChars =
3281
+ agentContext.maxToolResultChars ??
3282
+ calculateMaxToolResultChars(
3283
+ fallbackMaxContextTokens ?? agentContext.maxContextTokens
3284
+ );
3285
+ /**
3286
+ * Serving-provider cue shaping BEFORE the fallback payload
3287
+ * is measured: a Claude fallback behind a tolerant primary
3288
+ * gains the cue inside the guarded projection (a prompt
3289
+ * within the cue's cost of the fallback budget must take
3290
+ * the recovery path, not ship oversized), and a tolerant
3291
+ * fallback behind an Anthropic primary sheds the baked cue
3292
+ * before it is measured against the tighter budget. The
3293
+ * attemptInvoke funnel pass then finds nothing to change.
3294
+ */
3295
+ const cueShapedFallbackMessages = trackProviderMessageOrigins(
3296
+ fallbackMessages,
3297
+ isAnthropicLike(fallbackProvider, {
3298
+ model: resolveServingModelId(fallbackModel),
3299
+ })
3300
+ ? appendPredecessorHandoffCue(fallbackMessages, (m) =>
3301
+ this.isRunProducedMessage(m)
3302
+ )
3303
+ : removePredecessorHandoffCue(fallbackMessages)
3304
+ );
3305
+ const projectedFallbackMessages = trackProviderMessageOrigins(
3306
+ cueShapedFallbackMessages,
3307
+ projectMessagesForProvider({
3308
+ model: fallbackModel,
3309
+ messages: cueShapedFallbackMessages,
3310
+ provider: fallbackProvider,
3311
+ maxToolResultChars: fallbackToolResultChars,
3312
+ callOptions: fallbackConfig,
3313
+ })
3314
+ );
3315
+ const primaryContextBudget = contextUsage?.contextBudget;
3316
+ const fallbackContextBudget =
3317
+ fallbackMaxContextTokens == null
3318
+ ? primaryContextBudget
3319
+ : Math.min(
3320
+ primaryContextBudget ?? fallbackMaxContextTokens,
3321
+ fallbackMaxContextTokens
3322
+ );
3323
+ const projection = measureProviderPayload(
3324
+ projectedFallbackMessages,
3325
+ fallbackContextBudget,
3326
+ true
3327
+ );
3328
+ if (!projection.fits) {
3329
+ throw createProviderPayloadOverflowError({
3330
+ projection,
3331
+ provider: fallbackProvider,
3332
+ info: 'Fallback provider message formatting exceeded the context budget before invocation.',
3333
+ });
3334
+ }
3335
+ return projectedFallbackMessages;
3336
+ },
2502
3337
  })
2503
3338
  );
2504
3339
  } catch (fallbackError) {
2505
3340
  const overflowCandidates =
2506
3341
  getFallbackOverflowCandidates(fallbackError);
2507
3342
  let fallbackRecovery: OverflowRecoveryPlan | null = null;
3343
+ let fallbackRecoveryPromptEstimate: number | undefined;
2508
3344
  for (const candidate of overflowCandidates) {
2509
3345
  fallbackRecovery = planRecovery(candidate.error, candidate.context);
2510
3346
  if (fallbackRecovery != null) {
3347
+ fallbackRecoveryPromptEstimate = getRecoveryPromptEstimate(
3348
+ candidate.error,
3349
+ candidate.context
3350
+ );
2511
3351
  break;
2512
3352
  }
2513
3353
  }
2514
3354
  if (overflowCandidates.length === 0) {
2515
3355
  fallbackRecovery = planRecovery(fallbackError);
3356
+ fallbackRecoveryPromptEstimate =
3357
+ getRecoveryPromptEstimate(fallbackError);
2516
3358
  }
2517
3359
  if (fallbackRecovery == null) {
2518
3360
  throw fallbackError;
@@ -2523,7 +3365,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2523
3365
  agentId,
2524
3366
  config,
2525
3367
  originalToolContent: prunedOriginalToolContent,
2526
- estimatedPromptTokens,
3368
+ estimatedPromptTokens: fallbackRecoveryPromptEstimate,
2527
3369
  });
2528
3370
  }
2529
3371
  } finally {
@@ -2550,6 +3392,25 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2550
3392
  * handled everything — both paths become no-ops.
2551
3393
  */
2552
3394
  const responseMessage = result.messages?.[0];
3395
+ /**
3396
+ * Provenance for the handoff-cue gate: recorded at the node, where the
3397
+ * produced turn is unambiguous. The public ChatModel contract does not
3398
+ * require implementations to set message ids — the reducer would
3399
+ * assign one AFTER this node returns, which is too late for the set —
3400
+ * so an id is assigned here first, the same way the reducer does it
3401
+ * (`v4()`, mirrored into `lc_kwargs`), and the reducer's
3402
+ * keep-existing-id rule makes the state message match.
3403
+ */
3404
+ if (responseMessage?.getType() === 'ai') {
3405
+ if (
3406
+ typeof responseMessage.id !== 'string' ||
3407
+ responseMessage.id === ''
3408
+ ) {
3409
+ responseMessage.id = v4();
3410
+ responseMessage.lc_kwargs.id = responseMessage.id;
3411
+ }
3412
+ this.runProducedAiMessageIds.add(responseMessage.id);
3413
+ }
2553
3414
  const toolCalls = (responseMessage as AIMessageChunk | undefined)
2554
3415
  ?.tool_calls;
2555
3416
  const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
@@ -2637,8 +3498,20 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2637
3498
 
2638
3499
  const invokeElapsed = ((Date.now() - invokeStart) / 1000).toFixed(2);
2639
3500
  agentContext.currentUsage = this.getUsageMetadata(result.messages?.[0]);
3501
+ /**
3502
+ * Synthetic usage from a sealed turn is an estimate derived from the
3503
+ * host's own counter, so feeding it to calibration would teach a ratio
3504
+ * of exactly 1.0 — self-consistent by construction, and wrong for any
3505
+ * provider whose real ratio differs. It still flows to `currentUsage`
3506
+ * for host billing; it just does not get to move the EMA.
3507
+ */
3508
+ const estimatedUsage =
3509
+ (result.messages?.[0] as AIMessageChunk | undefined)?.response_metadata
3510
+ .estimated_usage === true;
2640
3511
  if (agentContext.currentUsage) {
2641
- agentContext.updateLastCallUsage(agentContext.currentUsage);
3512
+ if (!estimatedUsage) {
3513
+ agentContext.updateLastCallUsage(agentContext.currentUsage);
3514
+ }
2642
3515
  emitAgentLog(
2643
3516
  config,
2644
3517
  'debug',
@@ -2668,11 +3541,184 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2668
3541
  { force: true }
2669
3542
  );
2670
3543
  }
3544
+ if (
3545
+ (responseMessage as AIMessageChunk | undefined)?.response_metadata
3546
+ .preempted === true
3547
+ ) {
3548
+ const { messages: injected, preventContinuation } =
3549
+ await this.dispatchPreemptBoundary(agentId, config);
3550
+ /**
3551
+ * Release before branching: the slot is held only for the duration of
3552
+ * the drain, and an early return below must not strand it.
3553
+ */
3554
+ this.releasePreemptSeal();
3555
+ if (preventContinuation) {
3556
+ /**
3557
+ * A hook halted at the boundary. Commit the sealed turn and anything
3558
+ * it injected, but do NOT self-loop: `preventContinuation` promises
3559
+ * no further model turn, and the run-loop poll in `processStream`
3560
+ * only sees the halt AFTER the next call would already have started
3561
+ * — direct graph consumers never poll it at all. A trailing injected
3562
+ * HumanMessage carries no tool calls, so `toolsCondition` routes it
3563
+ * to END.
3564
+ */
3565
+ this.preemptIncomplete = true;
3566
+ /**
3567
+ * A halting boundary that ALSO injected nothing is still an empty
3568
+ * boundary by the `getPreemptStats().emptyBoundaries` contract —
3569
+ * hosts use the counter for truncated-seal telemetry, and both
3570
+ * paths end the turn with nothing to resume from.
3571
+ */
3572
+ if (injected.length === 0) {
3573
+ this.preemptEmptyBoundaries += 1;
3574
+ }
3575
+ this.cleanupSignalListener();
3576
+ return injected.length > 0
3577
+ ? { messages: [...(result.messages ?? []), ...injected] }
3578
+ : result;
3579
+ }
3580
+ if (injected.length > 0) {
3581
+ this.pendingPreemptReturn.add(agentId);
3582
+ this.cleanupSignalListener();
3583
+ return { messages: [...(result.messages ?? []), ...injected] };
3584
+ }
3585
+ /**
3586
+ * Nothing to inject — the host cancelled or already drained. Do NOT
3587
+ * self-loop: a trailing model turn with no new input is dropped by
3588
+ * some Gemini models and read as prefill by Anthropic. Do NOT pretend
3589
+ * the turn completed either; the answer really was cut short.
3590
+ */
3591
+ this.preemptEmptyBoundaries += 1;
3592
+ this.preemptIncomplete = true;
3593
+ }
3594
+
2671
3595
  this.cleanupSignalListener();
2672
3596
  return result;
2673
3597
  };
2674
3598
  }
2675
3599
 
3600
+ /**
3601
+ * Fires `PreemptBoundary` after a sealed turn and returns whatever the
3602
+ * hooks asked to inject, converted through the same `convertInjectedMessages`
3603
+ * the tool boundary uses so the two sites cannot emit different shapes.
3604
+ *
3605
+ * Never throws: a drain that fails or times out costs the injection, not the
3606
+ * run. The caller treats an empty result as "nothing to resume with".
3607
+ *
3608
+ * `preventContinuation` is surfaced alongside the messages rather than left
3609
+ * to the registry halt signal, which `processStream` only polls between
3610
+ * stream events — by then the self-loop it was meant to prevent has already
3611
+ * issued another model call, and a direct graph consumer never polls it.
3612
+ */
3613
+ private async dispatchPreemptBoundary(
3614
+ agentId: string,
3615
+ config: RunnableConfig | undefined
3616
+ ): Promise<PreemptBoundaryResult> {
3617
+ if (this.hookRegistry == null) {
3618
+ return EMPTY_PREEMPT_BOUNDARY;
3619
+ }
3620
+ const configurable = config?.configurable;
3621
+ const runId = (configurable?.run_id as string | undefined) ?? this.runId;
3622
+ if (runId == null) {
3623
+ return EMPTY_PREEMPT_BOUNDARY;
3624
+ }
3625
+ const result = await executeHooks({
3626
+ registry: this.hookRegistry,
3627
+ input: {
3628
+ hook_event_name: 'PreemptBoundary',
3629
+ runId,
3630
+ threadId: configurable?.thread_id as string | undefined,
3631
+ agentId: this.subagentScope ? agentId : undefined,
3632
+ executingAgentId: agentId,
3633
+ sealCount: this.preemptSealCount,
3634
+ },
3635
+ sessionId: runId,
3636
+ timeoutMs: PREEMPT_BOUNDARY_HOOK_TIMEOUT_MS,
3637
+ /**
3638
+ * The host's own abort signal(s), deliberately NOT `config.signal` —
3639
+ * inside a node the latter is LangGraph's composed signal, which also
3640
+ * fires when an unrelated sibling in the same superstep throws.
3641
+ * Cancellation already returns control in milliseconds without this;
3642
+ * what it buys is that a drain does not keep running after the run it
3643
+ * belongs to died.
3644
+ *
3645
+ * Composed because the host can cancel through either channel: the
3646
+ * construction signal, or the per-call `callerConfig.signal` — the only
3647
+ * one a multi-agent run has, since `MultiAgentGraphConfig` exposes no
3648
+ * construction signal. When both exist they may be different
3649
+ * controllers, and a drain must stop when EITHER fires.
3650
+ */
3651
+ signal: composeAbortSignals(this.signal, this.callerSignal),
3652
+ }).catch((): undefined => undefined);
3653
+ if (result == null) {
3654
+ return EMPTY_PREEMPT_BOUNDARY;
3655
+ }
3656
+ /**
3657
+ * `executeHooks` raises a registry halt whenever a hook returns
3658
+ * `preventContinuation`. That halt has exactly one consumer — the poll in
3659
+ * `Run.processStream` — and its `break` cancels the stream iterator, which
3660
+ * aborts Pregel. The abort lands BEFORE the outer reducer commits
3661
+ * `StandardGraph.messages`, so honoring the halt here would destroy the
3662
+ * sealed assistant turn: the run returns empty content and the host
3663
+ * persists nothing. Measured deterministically — the commit is several
3664
+ * stream events downstream of the point the halt becomes observable.
3665
+ *
3666
+ * The `preventContinuation` branch in `createCallModel` already enforces
3667
+ * the contract locally by declining to self-loop, and a sealed chunk
3668
+ * provably carries no tool calls, so the turn routes to END after exactly
3669
+ * one model call either way. Clearing the halt therefore costs nothing it
3670
+ * was buying and saves the content the seal exists to preserve.
3671
+ *
3672
+ * Scoped to a halt this event raised, so a halt from an earlier hook in
3673
+ * the same run — `haltRun` is first-write-wins — is left alone.
3674
+ */
3675
+ const halt = this.hookRegistry.getHaltSignal(runId);
3676
+ if (result.preventContinuation === true && halt?.source === 'PreemptBoundary') {
3677
+ this.preemptHaltReason = halt.reason;
3678
+ this.hookRegistry.clearHaltSignal(runId);
3679
+ }
3680
+ const injected: BaseMessage[] = [];
3681
+ /**
3682
+ * `PreemptBoundaryHookOutput` is `BaseHookOutput`, so `additionalContext`
3683
+ * is part of the contract here just as it is at the tool boundary. It has
3684
+ * to be materialized BEFORE the emptiness test, or a hook that returns
3685
+ * context alone would read as "nothing to resume with" and cut the answer
3686
+ * short. Same system-flavored `HumanMessage` convention `ToolNode` uses —
3687
+ * Anthropic and Google reject a mid-conversation `SystemMessage`.
3688
+ */
3689
+ /**
3690
+ * Whitespace-only entries are dropped for the same reason empty
3691
+ * `injectedMessages` are: `executeHooks` keeps them because their raw
3692
+ * length is nonzero, but a blank turn is not something to resume from —
3693
+ * it costs a model call and strict providers reject it outright.
3694
+ */
3695
+ const contexts = result.additionalContexts.filter(
3696
+ (context) => context.trim() !== ''
3697
+ );
3698
+ if (contexts.length > 0) {
3699
+ injected.push(
3700
+ new HumanMessage({
3701
+ content: contexts.join('\n\n'),
3702
+ additional_kwargs: { role: 'system', isMeta: true, source: 'hook' },
3703
+ })
3704
+ );
3705
+ }
3706
+ if (result.injectedMessages.length > 0) {
3707
+ try {
3708
+ injected.push(...convertInjectedMessages(result.injectedMessages));
3709
+ } catch (e) {
3710
+ console.warn(
3711
+ '[StandardGraph] Failed to convert PreemptBoundary injectedMessages:',
3712
+ e instanceof Error ? e.message : e
3713
+ );
3714
+ }
3715
+ }
3716
+ return {
3717
+ messages: injected,
3718
+ preventContinuation: result.preventContinuation === true,
3719
+ };
3720
+ }
3721
+
2676
3722
  createAgentNode(agentId: string): t.CompiledAgentWorfklow {
2677
3723
  const getConfig = (): RunnableConfig | undefined => this.config;
2678
3724
  const agentContext = this.agentContexts.get(agentId);
@@ -2836,6 +3882,14 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2836
3882
  config?: RunnableConfig
2837
3883
  ): string => {
2838
3884
  this.config = config;
3885
+ /**
3886
+ * A sealed turn that injected messages resumes in the SAME pregel run:
3887
+ * back to the agent node as a new superstep, so the model continues in
3888
+ * one assistant message instead of restarting the graph.
3889
+ */
3890
+ if (this.pendingPreemptReturn.delete(agentId)) {
3891
+ return agentNode;
3892
+ }
2839
3893
  if (state.summarizationRequest != null) {
2840
3894
  return summarizeNode;
2841
3895
  }
@@ -3165,6 +4219,15 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
3165
4219
  }
3166
4220
 
3167
4221
  const { name, input: args, error } = data;
4222
+ const eventValueLimit = calculateMaxToolResultChars();
4223
+ const errorOutputPrefix = 'Error processing tool';
4224
+ const errorDetail =
4225
+ error?.message != null
4226
+ ? `: ${serializeToolContentBounded(
4227
+ error.message,
4228
+ Math.max(0, eventValueLimit - errorOutputPrefix.length - 2)
4229
+ )}`
4230
+ : '';
3168
4231
 
3169
4232
  const runStep = graph.getRunStep(stepId);
3170
4233
  if (!runStep) {
@@ -3174,8 +4237,8 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
3174
4237
  const tool_call: t.ProcessedToolCall = {
3175
4238
  id: data.id,
3176
4239
  name: name || '',
3177
- args: typeof args === 'string' ? args : JSON.stringify(args),
3178
- output: `Error processing tool${error?.message != null ? `: ${error.message}` : ''}`,
4240
+ args: serializeToolContentBounded(args, eventValueLimit),
4241
+ output: `${errorOutputPrefix}${errorDetail}`,
3179
4242
  progress: 1,
3180
4243
  };
3181
4244