@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
@@ -113,6 +113,54 @@ describe('ToolNode tool output references', () => {
113
113
 
114
114
  expect(capturedArgs).toEqual(['raw {{tool0turn0}}']);
115
115
  });
116
+
117
+ it('bounds structured ToolMessage output before its first model call', async () => {
118
+ const structuredTool = tool(
119
+ async () =>
120
+ new ToolMessage({
121
+ status: 'success',
122
+ content: [
123
+ {
124
+ type: 'json',
125
+ rows: Array.from({ length: 20 }, (_, index) => ({
126
+ id: index,
127
+ value: `${'x'.repeat(100)}-${index}`,
128
+ })),
129
+ },
130
+ ],
131
+ artifact: { source: 'clickhouse' },
132
+ metadata: { requestId: 'request-1' },
133
+ name: 'run_select_query',
134
+ tool_call_id: 'c1',
135
+ }),
136
+ {
137
+ name: 'run_select_query',
138
+ description: 'returns structured rows',
139
+ schema: z.object({ command: z.string() }),
140
+ }
141
+ ) as unknown as StructuredToolInterface;
142
+ const node = new ToolNode({
143
+ tools: [structuredTool],
144
+ maxToolResultChars: 200,
145
+ });
146
+
147
+ const [msg] = await invokeBatch(node, [
148
+ {
149
+ id: 'c1',
150
+ name: 'run_select_query',
151
+ command: 'SELECT * FROM events',
152
+ },
153
+ ]);
154
+
155
+ expect(typeof msg.content).toBe('string');
156
+ expect(msg.content).toContain('truncated');
157
+ expect((msg.content as string).length).toBeLessThanOrEqual(200);
158
+ expect(msg.tool_call_id).toBe('c1');
159
+ expect(msg.name).toBe('run_select_query');
160
+ expect(msg.status).toBe('success');
161
+ expect(msg.artifact).toEqual({ source: 'clickhouse' });
162
+ expect(msg.metadata).toEqual({ requestId: 'request-1' });
163
+ });
116
164
  });
117
165
 
118
166
  describe('enabled', () => {
@@ -364,6 +412,67 @@ describe('ToolNode tool output references', () => {
364
412
  ).toBe(raw);
365
413
  });
366
414
 
415
+ it('preserves structured output exactly up to the registry limit', async () => {
416
+ const structured = {
417
+ rows: Array.from({ length: 10 }, (_, index) => ({
418
+ id: index,
419
+ value: `row-${index}-${'x'.repeat(100)}`,
420
+ })),
421
+ };
422
+ const exact = JSON.stringify(structured);
423
+ const capturedArgs: string[] = [];
424
+ let callCount = 0;
425
+ const structuredTool = createEchoTool({
426
+ capturedArgs: [],
427
+ outputs: ['unused'],
428
+ name: 'structured',
429
+ });
430
+ (
431
+ structuredTool as unknown as {
432
+ invoke: (input: {
433
+ args: { command: string };
434
+ }) => Promise<typeof structured | string>;
435
+ }
436
+ ).invoke = async (input) => {
437
+ capturedArgs.push(input.args.command);
438
+ return callCount++ === 0 ? structured : 'done';
439
+ };
440
+ const node = new ToolNode({
441
+ tools: [structuredTool],
442
+ maxToolResultChars: 100,
443
+ toolOutputReferences: {
444
+ enabled: true,
445
+ maxOutputSize: exact.length,
446
+ },
447
+ });
448
+
449
+ const [first] = await invokeBatch(
450
+ node,
451
+ [{ id: 'c1', name: 'structured', command: 'first' }],
452
+ 'structured-raw-preservation'
453
+ );
454
+ await invokeBatch(
455
+ node,
456
+ [
457
+ {
458
+ id: 'c2',
459
+ name: 'structured',
460
+ command: '{{tool0turn0}}',
461
+ },
462
+ ],
463
+ 'structured-raw-preservation'
464
+ );
465
+
466
+ expect((first.content as string).length).toBeLessThanOrEqual(100);
467
+ expect(first.content).toContain('truncated');
468
+ expect(capturedArgs[1]).toBe(exact);
469
+ expect(
470
+ node
471
+ ._unsafeGetToolOutputRegistry()!
472
+ .get('structured-raw-preservation', 'tool0turn0')
473
+ ).toBe(exact);
474
+ });
475
+
367
476
  it('uses each batch\'s own turn when ToolNode is invoked concurrently within a run', async () => {
368
477
  const gates: Record<string, () => void> = {};
369
478
  const slowTool = tool(
@@ -823,7 +932,7 @@ describe('ToolNode tool output references', () => {
823
932
  expect(JSON.parse(stepCompletedArgs[1]).command).toBe('echo STORED');
824
933
  });
825
934
 
826
- it('records unresolved refs as metadata on non-string ToolMessage content (content untouched)', async () => {
935
+ it('records unresolved refs on multipart ToolMessage content within the cap', async () => {
827
936
  const complexTool = tool(
828
937
  async () =>
829
938
  new ToolMessage({
@@ -855,8 +964,8 @@ describe('ToolNode tool output references', () => {
855
964
 
856
965
  expect(Array.isArray(msg.content)).toBe(true);
857
966
  const blocks = msg.content as Array<{ type: string; text?: string }>;
858
- // Multi-part content is untouched at storage time — the lazy
859
- // transform handles the unresolved-refs warning at request time.
967
+ // In-budget multi-part content stays intact; the lazy transform handles
968
+ // the unresolved-refs warning at request time.
860
969
  expect(blocks).toHaveLength(2);
861
970
  expect(blocks[0].type).toBe('text');
862
971
  expect(blocks[0].text).toBe('data');
@@ -90,6 +90,38 @@ describe('annotateMessagesForLLM', () => {
90
90
  expect(out[0]).not.toBe(tm);
91
91
  });
92
92
 
93
+ it('does not annotate native computer screenshot strings', () => {
94
+ const registry = new ToolOutputReferenceRegistry();
95
+ registry.set('r1', 'tool0turn0', 'stored-raw');
96
+ const screenshot = `data:image/png;base64,${'A'.repeat(2_000)}`;
97
+ const tm = makeToolMessage({
98
+ content: screenshot,
99
+ additional_kwargs: {
100
+ type: 'computer_call_output',
101
+ _refKey: 'tool0turn0',
102
+ },
103
+ });
104
+
105
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
106
+
107
+ expect(out[0].content).toBe(screenshot);
108
+ expect((out[0] as ToolMessage).additional_kwargs.type).toBe(
109
+ 'computer_call_output'
110
+ );
111
+ expect((out[0] as ToolMessage).additional_kwargs._refKey).toBeUndefined();
112
+ });
113
+
114
+ it('leaves native computer screenshots reference-equal without ref metadata', () => {
115
+ const registry = new ToolOutputReferenceRegistry();
116
+ const tm = makeToolMessage({
117
+ content: 'data:image/png;base64,AAAA',
118
+ additional_kwargs: { type: 'computer_call_output' },
119
+ });
120
+ const messages = [tm];
121
+
122
+ expect(annotateMessagesForLLM(messages, registry, 'r1')).toBe(messages);
123
+ });
124
+
93
125
  it('leaves content untouched but strips framework metadata when _refKey is stale', () => {
94
126
  /**
95
127
  * Stale `_refKey` (not in registry) doesn't trigger annotation,
@@ -20,7 +20,7 @@ import { ToolNode } from '../ToolNode';
20
20
  */
21
21
  function createDirectTool(
22
22
  name: string,
23
- impl: (args: Record<string, unknown>) => string | Promise<string>
23
+ impl: (args: Record<string, unknown>) => unknown | Promise<unknown>
24
24
  ): StructuredToolInterface {
25
25
  return tool(async (args: Record<string, unknown>) => impl(args), {
26
26
  name,
@@ -147,6 +147,217 @@ describe('Direct-path lifecycle hooks (in-process tools)', () => {
147
147
  expect(String(message.content)).toBe('ran:ls');
148
148
  });
149
149
 
150
+ it('serializes bigint values in direct structured tool output', async () => {
151
+ const query = createDirectTool('query', () => 'unused');
152
+ (
153
+ query as unknown as {
154
+ invoke: () => Promise<Array<Record<string, unknown>>>;
155
+ }
156
+ ).invoke = async () => [{ rowsRead: BigInt(42), status: 'complete' }];
157
+ const node = new ToolNode({
158
+ tools: [query],
159
+ eventDrivenMode: true,
160
+ directToolNames: new Set(['query']),
161
+ });
162
+
163
+ const result = await node.invoke({
164
+ messages: [aiCall('call_bigint', 'query', {})],
165
+ });
166
+ const [message] = toolMessages(result);
167
+
168
+ expect(message.status).toBe('success');
169
+ expect(message.content).toBe('[{"rowsRead":"42","status":"complete"}]');
170
+ });
171
+
172
+ it('bounds a large structured result without invoking toJSON', async () => {
173
+ let toJSONCalls = 0;
174
+ const structuredOutput = {
175
+ rows: [{ value: 'x'.repeat(20_000) }],
176
+ toJSON() {
177
+ toJSONCalls++;
178
+ return 'y'.repeat(100_000);
179
+ },
180
+ };
181
+ const query = createDirectTool('large_query', () => 'unused');
182
+ (
183
+ query as unknown as {
184
+ invoke: () => Promise<typeof structuredOutput>;
185
+ }
186
+ ).invoke = async () => structuredOutput;
187
+ const node = new ToolNode({
188
+ tools: [query],
189
+ eventDrivenMode: true,
190
+ directToolNames: new Set(['large_query']),
191
+ maxToolResultChars: 200,
192
+ });
193
+
194
+ const result = await node.invoke({
195
+ messages: [aiCall('call_large', 'large_query', {})],
196
+ });
197
+ const [message] = toolMessages(result);
198
+
199
+ expect(toJSONCalls).toBe(0);
200
+ expect(typeof message.content).toBe('string');
201
+ expect((message.content as string).length).toBeLessThanOrEqual(200);
202
+ expect(message.content).toContain('truncated');
203
+ });
204
+
205
+ it('keeps native computer screenshots intact despite the generic result cap', async () => {
206
+ const screenshot = `data:image/png;base64,${'A'.repeat(2_000)}`;
207
+ const contents: ToolMessage['content'][] = [
208
+ screenshot,
209
+ [{ type: 'computer_screenshot', image_url: screenshot }],
210
+ ];
211
+
212
+ for (let i = 0; i < contents.length; i++) {
213
+ const name = `computer_output_${i}`;
214
+ const computerOutput = new ToolMessage({
215
+ content: contents[i],
216
+ tool_call_id: `computer-call-${i}`,
217
+ additional_kwargs: { type: 'computer_call_output' },
218
+ });
219
+ const computer = createDirectTool(name, () => 'unused');
220
+ (
221
+ computer as unknown as {
222
+ invoke: () => Promise<ToolMessage>;
223
+ }
224
+ ).invoke = async () => computerOutput;
225
+ const node = new ToolNode({
226
+ tools: [computer],
227
+ eventDrivenMode: true,
228
+ directToolNames: new Set([name]),
229
+ maxToolResultChars: 80,
230
+ });
231
+
232
+ const result = await node.invoke({
233
+ messages: [aiCall(`computer-call-${i}`, name, {})],
234
+ });
235
+ const [message] = toolMessages(result);
236
+
237
+ expect(message).toBe(computerOutput);
238
+ expect(message.content).toBe(contents[i]);
239
+ expect(message.additional_kwargs.type).toBe('computer_call_output');
240
+ }
241
+ });
242
+
243
+ it('preserves a valid PostToolUse computer screenshot replacement', async () => {
244
+ const original = `data:image/png;base64,${'A'.repeat(2_000)}`;
245
+ const replacement = `data:image/png;base64,${'B'.repeat(2_000)}`;
246
+ const computerOutput = new ToolMessage({
247
+ content: original,
248
+ tool_call_id: 'computer-hook-valid',
249
+ additional_kwargs: { type: 'computer_call_output' },
250
+ });
251
+ const computer = createDirectTool('computer_hook_valid', () => 'unused');
252
+ (
253
+ computer as unknown as {
254
+ invoke: () => Promise<ToolMessage>;
255
+ }
256
+ ).invoke = async () => computerOutput;
257
+ const registry = new HookRegistry();
258
+ registry.register('PostToolUse', {
259
+ hooks: [
260
+ async (): Promise<PostToolUseHookOutput> => ({
261
+ updatedOutput: replacement,
262
+ }),
263
+ ],
264
+ });
265
+ const node = new ToolNode({
266
+ tools: [computer],
267
+ eventDrivenMode: true,
268
+ hookRegistry: registry,
269
+ directToolNames: new Set(['computer_hook_valid']),
270
+ maxToolResultChars: 80,
271
+ });
272
+
273
+ const result = await node.invoke({
274
+ messages: [aiCall('computer-hook-valid', 'computer_hook_valid', {})],
275
+ });
276
+ const [message] = toolMessages(result);
277
+
278
+ expect(message.content).toBe(replacement);
279
+ expect(message.additional_kwargs.type).toBe('computer_call_output');
280
+ });
281
+
282
+ it('rejects an invalid PostToolUse computer output replacement', async () => {
283
+ const screenshot = `data:image/png;base64,${'A'.repeat(2_000)}`;
284
+ const computerOutput = new ToolMessage({
285
+ content: screenshot,
286
+ tool_call_id: 'computer-hook-invalid',
287
+ additional_kwargs: { type: 'computer_call_output' },
288
+ });
289
+ const computer = createDirectTool('computer_hook_invalid', () => 'unused');
290
+ (
291
+ computer as unknown as {
292
+ invoke: () => Promise<ToolMessage>;
293
+ }
294
+ ).invoke = async () => computerOutput;
295
+ const registry = new HookRegistry();
296
+ registry.register('PostToolUse', {
297
+ hooks: [
298
+ async (): Promise<PostToolUseHookOutput> => ({
299
+ updatedOutput: 'redacted screenshot',
300
+ }),
301
+ ],
302
+ });
303
+ const node = new ToolNode({
304
+ tools: [computer],
305
+ eventDrivenMode: true,
306
+ hookRegistry: registry,
307
+ directToolNames: new Set(['computer_hook_invalid']),
308
+ maxToolResultChars: 80,
309
+ });
310
+
311
+ await expect(
312
+ node.invoke({
313
+ messages: [
314
+ aiCall('computer-hook-invalid', 'computer_hook_invalid', {}),
315
+ ],
316
+ })
317
+ ).rejects.toThrow(
318
+ 'PostToolUse updatedOutput for a computer call must be a valid screenshot'
319
+ );
320
+ });
321
+
322
+ it('caps returned and thrown direct-tool errors before storing them', async () => {
323
+ const returnedError = createDirectTool(
324
+ 'returned_error',
325
+ () =>
326
+ new ToolMessage({
327
+ status: 'error',
328
+ content: `returned:${'x'.repeat(2_000)}`,
329
+ tool_call_id: 'returned',
330
+ })
331
+ );
332
+ const thrownError = createDirectTool('thrown_error', () => {
333
+ throw new Error(`thrown:${'y'.repeat(2_000)}`);
334
+ });
335
+ const node = new ToolNode({
336
+ tools: [returnedError, thrownError],
337
+ eventDrivenMode: true,
338
+ directToolNames: new Set(['returned_error', 'thrown_error']),
339
+ maxToolResultChars: 200,
340
+ });
341
+
342
+ const returned = toolMessages(
343
+ await node.invoke({
344
+ messages: [aiCall('returned', 'returned_error', {})],
345
+ })
346
+ )[0];
347
+ const thrown = toolMessages(
348
+ await node.invoke({
349
+ messages: [aiCall('thrown', 'thrown_error', {})],
350
+ })
351
+ )[0];
352
+
353
+ expect(returned.status).toBe('error');
354
+ expect(String(returned.content).length).toBeLessThanOrEqual(200);
355
+ expect(returned.content).toContain('[truncated:');
356
+ expect(thrown.status).toBe('error');
357
+ expect(String(thrown.content).length).toBeLessThanOrEqual(200);
358
+ expect(thrown.content).toContain('[truncated:');
359
+ });
360
+
150
361
  it('executingAgentId defaults to agentId for direct callers, and an explicit value wins', async () => {
151
362
  const echo = createDirectTool('echo', (args) => `ran:${args.command}`);
152
363
  const captured: Array<string | undefined> = [];
@@ -247,6 +458,37 @@ describe('Direct-path lifecycle hooks (in-process tools)', () => {
247
458
  expect(message.status).toBe('success');
248
459
  });
249
460
 
461
+ it('caps PostToolUse updatedOutput after the hook rewrite', async () => {
462
+ const echo = createDirectTool('echo', () => 'ORIGINAL');
463
+ const replacement = 'R'.repeat(500);
464
+
465
+ const registry = new HookRegistry();
466
+ registry.register('PostToolUse', {
467
+ hooks: [
468
+ async (): Promise<PostToolUseHookOutput> => ({
469
+ updatedOutput: replacement,
470
+ }),
471
+ ],
472
+ });
473
+
474
+ const node = new ToolNode({
475
+ tools: [echo],
476
+ eventDrivenMode: true,
477
+ hookRegistry: registry,
478
+ directToolNames: new Set(['echo']),
479
+ maxToolResultChars: 50,
480
+ });
481
+
482
+ const result = await node.invoke({
483
+ messages: [aiCall('call_5_capped', 'echo', { command: 'x' })],
484
+ });
485
+ const [message] = toolMessages(result);
486
+
487
+ expect(typeof message.content).toBe('string');
488
+ expect((message.content as string).length).toBeLessThanOrEqual(50);
489
+ expect(message.content).not.toBe(replacement);
490
+ });
491
+
250
492
  it('PostToolUseFailure observes errors thrown by the tool', async () => {
251
493
  const failing = createDirectTool('boom', () => {
252
494
  throw new Error('kaboom');
@@ -376,11 +618,16 @@ describe('Direct-path lifecycle hooks (in-process tools)', () => {
376
618
 
377
619
  // Patch the tool's `func` to record the turn the body sees via the
378
620
  // standard LangChain config.toolCall.turn channel.
379
- const originalFunc = (echo as unknown as { func: (input: unknown, config: unknown) => Promise<string> }).func;
380
- (echo as unknown as { func: (input: unknown, config: unknown) => Promise<string> }).func = async (
381
- input,
382
- config
383
- ): Promise<string> => {
621
+ const originalFunc = (
622
+ echo as unknown as {
623
+ func: (input: unknown, config: unknown) => Promise<string>;
624
+ }
625
+ ).func;
626
+ (
627
+ echo as unknown as {
628
+ func: (input: unknown, config: unknown) => Promise<string>;
629
+ }
630
+ ).func = async (input, config): Promise<string> => {
384
631
  const t = (config as { toolCall?: { turn?: number } } | undefined)
385
632
  ?.toolCall?.turn;
386
633
  if (typeof t === 'number') bodyTurns.push(t);
@@ -424,7 +671,8 @@ describe('Direct-path lifecycle hooks (in-process tools)', () => {
424
671
  hooks: [
425
672
  async (): Promise<PreToolUseHookOutput> => ({
426
673
  decision: 'allow',
427
- additionalContext: 'POLICY-NOTE: writes here require approval next time',
674
+ additionalContext:
675
+ 'POLICY-NOTE: writes here require approval next time',
428
676
  }),
429
677
  ],
430
678
  });
@@ -0,0 +1,153 @@
1
+ import axios from 'axios';
2
+ import { createKeenableScraper } from './keenable-scraper';
3
+
4
+ jest.mock('axios');
5
+ const mockedAxios = axios as jest.Mocked<typeof axios>;
6
+
7
+ const sampleResponse = {
8
+ data: {
9
+ url: 'https://example.com/',
10
+ title: 'Example Domain',
11
+ content:
12
+ '# Example Domain\n\nThis domain is for use in documentation examples.',
13
+ description: 'An example page.',
14
+ },
15
+ };
16
+
17
+ describe('Keenable scraper', () => {
18
+ beforeEach(() => {
19
+ jest.clearAllMocks();
20
+ delete process.env.KEENABLE_API_KEY;
21
+ delete process.env.KEENABLE_FETCH_URL;
22
+ });
23
+
24
+ it('returns an error for empty URLs without calling the API', async () => {
25
+ const scraper = createKeenableScraper();
26
+ const [url, response] = await scraper.scrapeUrl(' ');
27
+
28
+ expect(url).toBe(' ');
29
+ expect(response.success).toBe(false);
30
+ expect(mockedAxios.get).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it('hits the public endpoint and omits the API key header when keyless', async () => {
34
+ mockedAxios.get.mockResolvedValueOnce(sampleResponse);
35
+
36
+ const scraper = createKeenableScraper();
37
+ const [, response] = await scraper.scrapeUrl('https://example.com');
38
+
39
+ expect(mockedAxios.get).toHaveBeenCalledWith(
40
+ 'https://api.keenable.ai/v1/fetch/public',
41
+ expect.objectContaining({
42
+ params: { url: 'https://example.com' },
43
+ headers: expect.objectContaining({ 'X-Keenable-Title': 'LibreChat' }),
44
+ })
45
+ );
46
+ const headers = mockedAxios.get.mock.calls[0][1]?.headers as Record<
47
+ string,
48
+ string
49
+ >;
50
+ expect(headers['X-API-Key']).toBeUndefined();
51
+ expect(response.success).toBe(true);
52
+ expect(response.data?.content).toContain('# Example Domain');
53
+ });
54
+
55
+ it('hits the keyed endpoint and sends the API key header when a key is set', async () => {
56
+ mockedAxios.get.mockResolvedValueOnce(sampleResponse);
57
+
58
+ const scraper = createKeenableScraper({ apiKey: 'secret-key' });
59
+ await scraper.scrapeUrl('https://example.com');
60
+
61
+ expect(mockedAxios.get).toHaveBeenCalledWith(
62
+ 'https://api.keenable.ai/v1/fetch',
63
+ expect.objectContaining({
64
+ headers: expect.objectContaining({ 'X-API-Key': 'secret-key' }),
65
+ })
66
+ );
67
+ });
68
+
69
+ it('honors a custom attribution title and fetch URL override', async () => {
70
+ mockedAxios.get.mockResolvedValueOnce(sampleResponse);
71
+
72
+ const scraper = createKeenableScraper({
73
+ apiUrl: 'https://keenable.internal/v1/fetch/public',
74
+ attributionTitle: 'MyApp',
75
+ });
76
+ await scraper.scrapeUrl('https://example.com');
77
+
78
+ expect(mockedAxios.get).toHaveBeenCalledWith(
79
+ 'https://keenable.internal/v1/fetch/public',
80
+ expect.objectContaining({
81
+ headers: expect.objectContaining({ 'X-Keenable-Title': 'MyApp' }),
82
+ })
83
+ );
84
+ });
85
+
86
+ it('reports failure when the API returns no content', async () => {
87
+ mockedAxios.get.mockResolvedValueOnce({
88
+ data: { url: 'https://x.test', content: '' },
89
+ });
90
+
91
+ const scraper = createKeenableScraper();
92
+ const [, response] = await scraper.scrapeUrl('https://x.test');
93
+
94
+ expect(response.success).toBe(false);
95
+ expect(response.error).toMatch(/no content/i);
96
+ });
97
+
98
+ it('reports failure with a message when the request throws', async () => {
99
+ mockedAxios.get.mockRejectedValueOnce(new Error('network down'));
100
+
101
+ const scraper = createKeenableScraper();
102
+ const [, response] = await scraper.scrapeUrl('https://x.test');
103
+
104
+ expect(response.success).toBe(false);
105
+ expect(response.error).toContain('network down');
106
+ });
107
+
108
+ it('scrapes multiple URLs concurrently', async () => {
109
+ mockedAxios.get
110
+ .mockResolvedValueOnce({ data: { content: 'a', url: 'https://a.test' } })
111
+ .mockResolvedValueOnce({ data: { content: 'b', url: 'https://b.test' } });
112
+
113
+ const scraper = createKeenableScraper();
114
+ const results = await scraper.scrapeUrls([
115
+ 'https://a.test',
116
+ 'https://b.test',
117
+ ]);
118
+
119
+ expect(results).toHaveLength(2);
120
+ expect(results[0][1].data?.content).toBe('a');
121
+ expect(results[1][1].data?.content).toBe('b');
122
+ });
123
+
124
+ it('extractContent returns the markdown with no structured references', () => {
125
+ const scraper = createKeenableScraper();
126
+ const [content, references] = scraper.extractContent({
127
+ success: true,
128
+ data: { content: '# Title\n\nBody' },
129
+ });
130
+
131
+ expect(content).toBe('# Title\n\nBody');
132
+ expect(references).toBeUndefined();
133
+ });
134
+
135
+ it('extractMetadata surfaces title, description and url', () => {
136
+ const scraper = createKeenableScraper();
137
+ const metadata = scraper.extractMetadata({
138
+ success: true,
139
+ data: {
140
+ content: 'x',
141
+ title: 'Example Domain',
142
+ description: 'An example page.',
143
+ url: 'https://example.com/',
144
+ },
145
+ });
146
+
147
+ expect(metadata).toEqual({
148
+ title: 'Example Domain',
149
+ description: 'An example page.',
150
+ url: 'https://example.com/',
151
+ });
152
+ });
153
+ });