@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
@@ -0,0 +1,323 @@
1
+ import { AIMessageChunk } from '@langchain/core/messages';
2
+ import { canSealPreempt, resolveMaxSeals } from './preempt';
3
+
4
+ /**
5
+ * The budget is read by two consumers that interpret it differently — a
6
+ * numeric comparison in the seal gate, and an addition into the graph's
7
+ * recursion limit. Normalizing once keeps them in agreement.
8
+ */
9
+ describe('resolveMaxSeals', () => {
10
+ it('defaults when unset', () => {
11
+ expect(resolveMaxSeals(undefined)).toBe(8);
12
+ });
13
+
14
+ it('passes a sane whole number through', () => {
15
+ expect(resolveMaxSeals(3)).toBe(3);
16
+ });
17
+
18
+ it('floors a fractional budget so both readers agree', () => {
19
+ expect(resolveMaxSeals(1.5)).toBe(1);
20
+ });
21
+
22
+ it('honors zero as a deliberate never-seal', () => {
23
+ expect(resolveMaxSeals(0)).toBe(0);
24
+ });
25
+
26
+ it('clamps a negative budget to never-seal', () => {
27
+ expect(resolveMaxSeals(-4)).toBe(0);
28
+ });
29
+
30
+ it('falls back rather than poisoning the recursion limit', () => {
31
+ expect(resolveMaxSeals(NaN)).toBe(8);
32
+ expect(resolveMaxSeals(Infinity)).toBe(8);
33
+ expect(resolveMaxSeals(-Infinity)).toBe(8);
34
+ });
35
+ });
36
+
37
+ function chunk(fields: Partial<AIMessageChunk>): AIMessageChunk {
38
+ return new AIMessageChunk({
39
+ content: '',
40
+ ...fields,
41
+ } as ConstructorParameters<typeof AIMessageChunk>[0]);
42
+ }
43
+
44
+ describe('canSealPreempt', () => {
45
+ describe('refuses to seal', () => {
46
+ it('on a missing chunk', () => {
47
+ expect(canSealPreempt(undefined)).toBe(false);
48
+ });
49
+
50
+ it('on empty string content', () => {
51
+ expect(canSealPreempt(chunk({ content: '' }))).toBe(false);
52
+ });
53
+
54
+ it('on whitespace-only string content', () => {
55
+ expect(canSealPreempt(chunk({ content: ' \n\t ' }))).toBe(false);
56
+ });
57
+
58
+ it('on an empty content array', () => {
59
+ expect(canSealPreempt(chunk({ content: [] }))).toBe(false);
60
+ });
61
+
62
+ it('on whitespace-only text blocks', () => {
63
+ expect(
64
+ canSealPreempt(chunk({ content: [{ type: 'text', text: ' ' }] }))
65
+ ).toBe(false);
66
+ });
67
+
68
+ it('when the only content is thinking', () => {
69
+ expect(
70
+ canSealPreempt(
71
+ chunk({
72
+ content: [
73
+ { type: 'thinking', thinking: 'Let me work through this.' },
74
+ ],
75
+ })
76
+ )
77
+ ).toBe(false);
78
+ });
79
+
80
+ it('when the only content is reasoning', () => {
81
+ expect(
82
+ canSealPreempt(
83
+ chunk({
84
+ content: [{ type: 'reasoning', reasoning: 'Considering.' }],
85
+ })
86
+ )
87
+ ).toBe(false);
88
+ });
89
+
90
+ it('when the only content is redacted_thinking', () => {
91
+ expect(
92
+ canSealPreempt(
93
+ chunk({ content: [{ type: 'redacted_thinking', data: 'xxx' }] })
94
+ )
95
+ ).toBe(false);
96
+ });
97
+
98
+ it('with a resolved tool call present', () => {
99
+ expect(
100
+ canSealPreempt(
101
+ chunk({
102
+ content: 'Looking that up.',
103
+ tool_calls: [{ id: 'c1', name: 'search', args: {} }],
104
+ })
105
+ )
106
+ ).toBe(false);
107
+ });
108
+
109
+ it('with a partial tool call still streaming', () => {
110
+ expect(
111
+ canSealPreempt(
112
+ chunk({
113
+ content: 'Looking that up.',
114
+ tool_call_chunks: [
115
+ { id: 'c1', name: 'search', args: '{"q"', index: 0 },
116
+ ],
117
+ })
118
+ )
119
+ ).toBe(false);
120
+ });
121
+
122
+ /**
123
+ * Assigned after construction on purpose: `AIMessageChunk` derives
124
+ * `invalid_tool_calls` from `tool_call_chunks` and drops a directly
125
+ * supplied array, so this is the only way to exercise the guard on its
126
+ * own rather than through the `tool_call_chunks` check above it.
127
+ */
128
+ it('with an invalid tool call present', () => {
129
+ const c = chunk({ content: 'Looking that up.' });
130
+ c.invalid_tool_calls = [
131
+ { id: 'c1', name: 'search', args: '{oops', error: 'bad json' },
132
+ ];
133
+ expect(canSealPreempt(c)).toBe(false);
134
+ });
135
+
136
+ /**
137
+ * Anthropic emits a `tool_call_chunk` alongside every `server_tool_use`
138
+ * content block, and `concat` keeps it on the accumulated message for the
139
+ * rest of the turn. So a web-search turn is refused by the tool-call gate,
140
+ * both while the search is in flight and after its result has landed —
141
+ * this pins that invariant rather than a content-block check.
142
+ */
143
+ it('while an Anthropic server tool is in flight', () => {
144
+ expect(
145
+ canSealPreempt(
146
+ chunk({
147
+ content: [
148
+ { type: 'text', text: 'Searching the web for that.' },
149
+ {
150
+ type: 'server_tool_use',
151
+ id: 'srvtoolu_1',
152
+ name: 'web_search',
153
+ input: { query: 'x' },
154
+ },
155
+ ],
156
+ tool_call_chunks: [
157
+ { id: 'srvtoolu_1', name: 'web_search', args: '', index: 0 },
158
+ ],
159
+ })
160
+ )
161
+ ).toBe(false);
162
+ });
163
+
164
+ /**
165
+ * Gemini server-side tools land as `toolCall` / `toolResponse` content
166
+ * blocks and never populate `tool_calls` or `tool_call_chunks`, so the
167
+ * tool-call gates above cannot see them.
168
+ */
169
+ it('while a Google server tool call is unanswered', () => {
170
+ expect(
171
+ canSealPreempt(
172
+ chunk({
173
+ content: [
174
+ { type: 'text', text: 'Let me look that up.' },
175
+ { type: 'toolCall', id: 'gcall_1', name: 'google_search' },
176
+ ],
177
+ })
178
+ )
179
+ ).toBe(false);
180
+ });
181
+
182
+ it('with more Google server tool calls than responses', () => {
183
+ expect(
184
+ canSealPreempt(
185
+ chunk({
186
+ content: [
187
+ { type: 'text', text: 'Checking two things.' },
188
+ { type: 'toolCall', id: 'gcall_1', name: 'google_search' },
189
+ { type: 'toolResponse', id: 'gcall_1', response: {} },
190
+ { type: 'toolCall', id: 'gcall_2', name: 'google_search' },
191
+ ],
192
+ })
193
+ )
194
+ ).toBe(false);
195
+ });
196
+
197
+ it('while one of several server tool calls is still unanswered', () => {
198
+ expect(
199
+ canSealPreempt(
200
+ chunk({
201
+ content: [
202
+ { type: 'text', text: 'Checking two sources.' },
203
+ {
204
+ type: 'web_search_tool_result',
205
+ tool_use_id: 'srvtoolu_1',
206
+ content: [],
207
+ },
208
+ ],
209
+ tool_call_chunks: [
210
+ { id: 'srvtoolu_1', name: 'web_search', args: '', index: 0 },
211
+ { id: 'srvtoolu_2', name: 'web_search', args: '', index: 1 },
212
+ ],
213
+ })
214
+ )
215
+ ).toBe(false);
216
+ });
217
+ });
218
+
219
+ describe('seals', () => {
220
+ it('on non-empty string content', () => {
221
+ expect(canSealPreempt(chunk({ content: 'The migration has' }))).toBe(
222
+ true
223
+ );
224
+ });
225
+
226
+ it('on a non-empty text block', () => {
227
+ expect(
228
+ canSealPreempt(
229
+ chunk({ content: [{ type: 'text', text: 'The migration has' }] })
230
+ )
231
+ ).toBe(true);
232
+ });
233
+
234
+ it('on text that follows signed thinking', () => {
235
+ expect(
236
+ canSealPreempt(
237
+ chunk({
238
+ content: [
239
+ {
240
+ type: 'thinking',
241
+ thinking: 'Working it out.',
242
+ signature: 'sig',
243
+ },
244
+ { type: 'text', text: 'The migration has' },
245
+ ],
246
+ })
247
+ )
248
+ ).toBe(true);
249
+ });
250
+
251
+ it('with empty tool-call arrays present', () => {
252
+ expect(
253
+ canSealPreempt(
254
+ chunk({
255
+ content: 'The migration has',
256
+ tool_calls: [],
257
+ tool_call_chunks: [],
258
+ invalid_tool_calls: [],
259
+ })
260
+ )
261
+ ).toBe(true);
262
+ });
263
+
264
+ it('once every Google server tool call has been answered', () => {
265
+ expect(
266
+ canSealPreempt(
267
+ chunk({
268
+ content: [
269
+ { type: 'text', text: 'Here is what I found.' },
270
+ { type: 'toolCall', id: 'gcall_1', name: 'google_search' },
271
+ { type: 'toolResponse', id: 'gcall_1', response: {} },
272
+ ],
273
+ })
274
+ )
275
+ ).toBe(true);
276
+ });
277
+
278
+ /**
279
+ * Provider-side tools never reach `ToolNode`, so no `PostToolBatch`
280
+ * boundary exists to drain into. Refusing forever after a search would
281
+ * defer a queued message to the end of the turn rather than to the next
282
+ * tool step, so a call whose result has landed counts as settled.
283
+ */
284
+ it('once an Anthropic server tool result has landed', () => {
285
+ expect(
286
+ canSealPreempt(
287
+ chunk({
288
+ content: [
289
+ { type: 'text', text: 'Here is what I found.' },
290
+ {
291
+ type: 'server_tool_use',
292
+ id: 'srvtoolu_1',
293
+ name: 'web_search',
294
+ input: { query: 'x' },
295
+ },
296
+ {
297
+ type: 'web_search_tool_result',
298
+ tool_use_id: 'srvtoolu_1',
299
+ content: [],
300
+ },
301
+ ],
302
+ tool_call_chunks: [
303
+ { id: 'srvtoolu_1', name: 'web_search', args: '', index: 0 },
304
+ ],
305
+ })
306
+ )
307
+ ).toBe(true);
308
+ });
309
+
310
+ it('on a non-text block that carries no tool call', () => {
311
+ expect(
312
+ canSealPreempt(
313
+ chunk({
314
+ content: [
315
+ { type: 'text', text: 'Here is the diagram.' },
316
+ { type: 'image_url', image_url: { url: 'https://x/y.png' } },
317
+ ],
318
+ })
319
+ )
320
+ ).toBe(true);
321
+ });
322
+ });
323
+ });
@@ -0,0 +1,178 @@
1
+ // src/llm/preempt.ts
2
+ import type { AIMessageChunk } from '@langchain/core/messages';
3
+ import { ContentTypes, DEFAULT_MAX_SEALS } from '@/common';
4
+
5
+ /**
6
+ * Normalizes a host-supplied seal budget.
7
+ *
8
+ * Read in two places that interpret it differently — a numeric comparison in
9
+ * the seal gate and an addition into the recursion limit — so a value like
10
+ * `1.5` would permit two seals while reserving fractional headroom, `NaN`
11
+ * would poison the recursion limit outright, and `Infinity` would remove both
12
+ * bounds at once. Normalizing once keeps the two readings in agreement.
13
+ *
14
+ * `0` is honored as a deliberate "never seal"; anything not finite falls back
15
+ * to the default rather than silently disabling the feature.
16
+ */
17
+ export function resolveMaxSeals(maxSeals: number | undefined): number {
18
+ if (maxSeals == null || !Number.isFinite(maxSeals)) {
19
+ return DEFAULT_MAX_SEALS;
20
+ }
21
+ const whole = Math.floor(maxSeals);
22
+ return whole > 0 ? whole : 0;
23
+ }
24
+
25
+ /**
26
+ * True when the accumulated content carries at least one non-whitespace text
27
+ * block. `thinking` / `reasoning` blocks deliberately do not count: they are
28
+ * stripped or signed on several providers and cannot stand in for the visible
29
+ * assistant turn a sealed sequence needs.
30
+ */
31
+ function hasNonEmptyTextContent(content: AIMessageChunk['content']): boolean {
32
+ if (typeof content === 'string') {
33
+ return content.trim() !== '';
34
+ }
35
+ for (const block of content) {
36
+ if (block.type !== ContentTypes.TEXT) {
37
+ continue;
38
+ }
39
+ const text = block[ContentTypes.TEXT];
40
+ if (typeof text === 'string' && text.trim() !== '') {
41
+ return true;
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+
47
+ /**
48
+ * True while a Gemini server-side tool call is still awaiting its response.
49
+ *
50
+ * Google's server-side tools (Search, URL context) never populate
51
+ * `tool_calls` or `tool_call_chunks` — those are derived from `functionCall`
52
+ * parts only, while a server-side invocation arrives as a `toolCall` CONTENT
53
+ * block and its result as a later `toolResponse` block. The tool-call gates
54
+ * cannot see it, so sealing between the two would replay a model turn holding
55
+ * an unanswered server-side call.
56
+ *
57
+ * Counted rather than id-matched: Google answers calls in order, and counting
58
+ * stays correct when a part omits its id.
59
+ */
60
+ function hasOpenGoogleServerToolCall(
61
+ content: AIMessageChunk['content']
62
+ ): boolean {
63
+ if (typeof content === 'string') {
64
+ return false;
65
+ }
66
+ let calls = 0;
67
+ let responses = 0;
68
+ for (const block of content) {
69
+ if (block.type === 'toolCall') {
70
+ calls += 1;
71
+ } else if (block.type === 'toolResponse') {
72
+ responses += 1;
73
+ }
74
+ }
75
+ return calls > responses;
76
+ }
77
+
78
+ /**
79
+ * Ids of Anthropic server-side tool calls whose paired result block is already
80
+ * on the accumulated content. Those calls are answered and cannot be orphaned
81
+ * by a seal.
82
+ */
83
+ function settledServerToolCallIds(
84
+ content: AIMessageChunk['content']
85
+ ): Set<string> {
86
+ const settled = new Set<string>();
87
+ if (typeof content === 'string') {
88
+ return settled;
89
+ }
90
+ for (const block of content) {
91
+ if (block.type !== 'web_search_tool_result') {
92
+ continue;
93
+ }
94
+ const id = block.tool_use_id;
95
+ if (typeof id === 'string') {
96
+ settled.add(id);
97
+ }
98
+ }
99
+ return settled;
100
+ }
101
+
102
+ /** Tool calls still awaiting an answer, ignoring ones already settled. */
103
+ function countOpenToolCalls(
104
+ calls: ReadonlyArray<{ id?: string }> | undefined,
105
+ settled: Set<string>
106
+ ): number {
107
+ if (calls == null || calls.length === 0) {
108
+ return 0;
109
+ }
110
+ if (settled.size === 0) {
111
+ return calls.length;
112
+ }
113
+ let open = 0;
114
+ for (const call of calls) {
115
+ if (call.id == null || !settled.has(call.id)) {
116
+ open += 1;
117
+ }
118
+ }
119
+ return open;
120
+ }
121
+
122
+ /**
123
+ * Cooperative mid-generation seal gate. Returns true ONLY when sealing here
124
+ * yields a message sequence valid on EVERY supported provider:
125
+ * - non-whitespace TEXT content, so the FIRST injected user turn is preceded
126
+ * by a non-empty assistant turn — no empty-content 400s. Note this says
127
+ * nothing about adjacency AMONG several injected turns: a boundary that
128
+ * drains two steers emits two consecutive user messages, which strict
129
+ * providers reject. That is normalized at the provider-facing hop by
130
+ * `coalesceAdjacentUserTurns`, not here;
131
+ * - no tool call in flight, so no `tool_use` can be orphaned AND no eagerly
132
+ * prestarted execution can be stripped out from under the model.
133
+ *
134
+ * Anthropic's server-side tools need no check of their own. Every
135
+ * `server_tool_use` content block also emits a `tool_call_chunk`
136
+ * (`_makeMessageChunkFromAnthropicEvent`), and `concat` keeps that chunk on
137
+ * the accumulated message for the remainder of the turn, so the tool-call
138
+ * gates below already cover it. The practical consequence is worth stating
139
+ * plainly: once a turn starts a web search it is no longer preemptible, and
140
+ * a queued message waits for the ordinary tool boundary instead.
141
+ *
142
+ * Nothing is stripped and nothing is repaired: when the accumulated shape is
143
+ * not already safe the stream simply runs on, and whatever the host queued
144
+ * lands at the next tool boundary instead. Chunks accumulate monotonically
145
+ * through `concat`, so an unsafe shape can never be observed at a seal point.
146
+ */
147
+ export function canSealPreempt(chunk: AIMessageChunk | undefined): boolean {
148
+ if (chunk == null) {
149
+ return false;
150
+ }
151
+ /**
152
+ * Anthropic's server-side tools run inside the provider, so unlike a client
153
+ * tool they never reach `ToolNode` and never produce a `PostToolBatch`
154
+ * boundary. `concat` also keeps their `server_tool_use` chunk on the
155
+ * accumulated message for the rest of the turn. Together that means a naive
156
+ * tool-call gate makes a turn permanently unsealable the moment a web
157
+ * search starts, with no later boundary to drain into — the queued message
158
+ * waits for the whole turn to finish rather than for the next tool step.
159
+ *
160
+ * So calls whose paired result block has already landed are treated as
161
+ * settled: they cannot be orphaned, because the provider has already
162
+ * answered them.
163
+ */
164
+ const settled = settledServerToolCallIds(chunk.content);
165
+ if (countOpenToolCalls(chunk.tool_calls, settled) > 0) {
166
+ return false;
167
+ }
168
+ if (countOpenToolCalls(chunk.tool_call_chunks, settled) > 0) {
169
+ return false;
170
+ }
171
+ if ((chunk.invalid_tool_calls?.length ?? 0) > 0) {
172
+ return false;
173
+ }
174
+ if (hasOpenGoogleServerToolCall(chunk.content)) {
175
+ return false;
176
+ }
177
+ return hasNonEmptyTextContent(chunk.content);
178
+ }
@@ -1,12 +1,15 @@
1
1
  import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
2
2
  import type { BaseMessage } from '@langchain/core/messages';
3
3
  import type { TokenCounter } from '@/types/run';
4
- import { maskConsumedToolResults } from '@/messages/prune';
4
+ import {
5
+ maskConsumedToolResults,
6
+ ORIGINAL_CONTENT_MAX_CHARS,
7
+ } from '@/messages/prune';
5
8
 
6
9
  const charCounter: TokenCounter = (msg) => {
7
10
  const raw = msg.content;
8
11
  if (typeof raw === 'string') return raw.length;
9
- return 0;
12
+ return JSON.stringify(raw).length;
10
13
  };
11
14
 
12
15
  function toolMsg(
@@ -209,6 +212,94 @@ describe('maskConsumedToolResults', () => {
209
212
  expect(map[2]).toBe((messages[2].content as string).length);
210
213
  });
211
214
 
215
+ it('masks consumed structured tool results with a bounded text preview', () => {
216
+ const tcId = 'tc-structured';
217
+ const toolMessage = new ToolMessage({
218
+ content: [
219
+ {
220
+ type: 'json',
221
+ rows: Array.from({ length: 20 }, (_, index) => ({
222
+ id: index,
223
+ value: `${'x'.repeat(100)}-${index}`,
224
+ })),
225
+ },
226
+ ],
227
+ tool_call_id: tcId,
228
+ name: 'run_select_query',
229
+ status: 'success',
230
+ artifact: { source: 'clickhouse' },
231
+ });
232
+ const messages: BaseMessage[] = [
233
+ new HumanMessage('query the table'),
234
+ aiToolCall(tcId, 'run_select_query'),
235
+ toolMessage,
236
+ aiWithText('The query returned 20 rows.'),
237
+ ];
238
+ const map: Record<string, number | undefined> = {
239
+ 0: 5,
240
+ 1: 20,
241
+ 2: 0,
242
+ 3: 30,
243
+ };
244
+ const originalContentStore = new Map<number, string>();
245
+ const originalContent = JSON.stringify(toolMessage.content);
246
+
247
+ const count = maskConsumedToolResults({
248
+ messages,
249
+ indexTokenCountMap: map,
250
+ tokenCounter: charCounter,
251
+ originalContentStore,
252
+ });
253
+
254
+ expect(count).toBe(1);
255
+ expect(typeof messages[2].content).toBe('string');
256
+ expect(messages[2].content).toContain('truncated');
257
+ expect((messages[2].content as string).length).toBeLessThanOrEqual(300);
258
+ const masked = messages[2] as ToolMessage;
259
+ expect(masked.tool_call_id).toBe(tcId);
260
+ expect(masked.name).toBe('run_select_query');
261
+ expect(masked.status).toBe('success');
262
+ expect(masked.artifact).toEqual({ source: 'clickhouse' });
263
+ expect(map[2]).toBe(charCounter(masked));
264
+ expect(originalContentStore.get(2)).toBe(originalContent);
265
+ });
266
+
267
+ it('bounds stored structured originals without invoking custom toJSON', () => {
268
+ const tcId = 'tc-large-structured';
269
+ const toJSON = jest.fn(() => ({ expanded: 'should not run' }));
270
+ const messages: BaseMessage[] = [
271
+ new HumanMessage('query the table'),
272
+ aiToolCall(tcId, 'run_select_query'),
273
+ new ToolMessage({
274
+ content: [
275
+ {
276
+ type: 'json',
277
+ payload: 'x'.repeat(ORIGINAL_CONTENT_MAX_CHARS + 1_000),
278
+ toJSON,
279
+ },
280
+ ],
281
+ tool_call_id: tcId,
282
+ name: 'run_select_query',
283
+ }),
284
+ aiWithText('The query completed.'),
285
+ ];
286
+ const originalContentStore = new Map<number, string>();
287
+
288
+ const count = maskConsumedToolResults({
289
+ messages,
290
+ indexTokenCountMap: { 0: 1, 1: 1, 2: 1, 3: 1 },
291
+ tokenCounter: () => 1,
292
+ originalContentStore,
293
+ });
294
+
295
+ expect(count).toBe(1);
296
+ expect(toJSON).not.toHaveBeenCalled();
297
+ expect(originalContentStore.get(2)?.length).toBeLessThanOrEqual(
298
+ ORIGINAL_CONTENT_MAX_CHARS
299
+ );
300
+ expect(originalContentStore.get(2)).toContain('truncated');
301
+ });
302
+
212
303
  it('handles empty messages array', () => {
213
304
  const map: Record<string, number | undefined> = {};
214
305
  const count = maskConsumedToolResults({