@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
@@ -47,6 +47,10 @@ describe('convertToConverseMessages — tool-result cachePoint hoisting', () =>
47
47
 
48
48
  const { converseMessages } = convertToConverseMessages([
49
49
  new HumanMessage('go'),
50
+ new AIMessage({
51
+ content: '',
52
+ tool_calls: [{ id: 't1', name: 'calc', args: {}, type: 'tool_call' }],
53
+ }),
50
54
  toolMsg,
51
55
  ]);
52
56
 
@@ -66,6 +70,17 @@ describe('convertToConverseMessages — tool-result cachePoint hoisting', () =>
66
70
  it('leaves tool results without a cachePoint untouched', () => {
67
71
  const { converseMessages } = convertToConverseMessages([
68
72
  new HumanMessage('go'),
73
+ /**
74
+ * The calling assistant turn matters: without it the human turn and
75
+ * the tool result are adjacent user-role messages, which the converter
76
+ * now merges (Converse rejects consecutive user messages), and this
77
+ * test would assert against the merged artifact instead of the
78
+ * tool-result body it is about.
79
+ */
80
+ new AIMessage({
81
+ content: '',
82
+ tool_calls: [{ id: 't1', name: 'calc', args: {}, type: 'tool_call' }],
83
+ }),
69
84
  new ToolMessage({ tool_call_id: 't1', content: 'plain result' }),
70
85
  ]);
71
86
 
@@ -1,6 +1,9 @@
1
1
  import { expect, test, describe } from '@jest/globals';
2
- import { AIMessageChunk } from '@langchain/core/messages';
3
- import type { Content, EnhancedGenerateContentResponse } from '@google/generative-ai';
2
+ import { AIMessage, AIMessageChunk } from '@langchain/core/messages';
3
+ import type {
4
+ Content,
5
+ EnhancedGenerateContentResponse,
6
+ } from '@google/generative-ai';
4
7
  import {
5
8
  STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
6
9
  STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
@@ -8,9 +11,53 @@ import {
8
11
  } from '@/tools/streamedToolCallSeals';
9
12
  import {
10
13
  convertResponseContentToChatGenerationChunk,
14
+ convertMessageContentToParts,
11
15
  dropUnsupportedModelTurnPrefill,
12
16
  rejectsModelTurnPrefill,
13
17
  } from './common';
18
+ import { toLangChainContent } from '@/messages/langchain';
19
+
20
+ describe('convertMessageContentToParts cross-provider tool calls', () => {
21
+ test('deduplicates Anthropic tool_use content mirrored in parsed tool_calls', () => {
22
+ const message = new AIMessage({
23
+ content: toLangChainContent([
24
+ { type: 'text', text: 'Checking.' },
25
+ {
26
+ type: 'tool_use',
27
+ id: 'call_weather',
28
+ name: 'weather',
29
+ input: '',
30
+ },
31
+ { type: 'text', index: 0, input: '{"city":"NYC"}' },
32
+ ]),
33
+ tool_calls: [
34
+ {
35
+ id: 'call_weather',
36
+ name: 'weather',
37
+ args: { city: 'NYC' },
38
+ },
39
+ ],
40
+ });
41
+
42
+ const parts = convertMessageContentToParts(
43
+ message,
44
+ false,
45
+ [],
46
+ 'gemini-3.5-flash'
47
+ );
48
+ const functionCalls = parts.filter((part) => 'functionCall' in part);
49
+
50
+ expect(functionCalls).toHaveLength(1);
51
+ expect(parts).not.toContainEqual({});
52
+ expect(functionCalls[0]).toMatchObject({
53
+ functionCall: {
54
+ id: 'call_weather',
55
+ name: 'weather',
56
+ args: { city: 'NYC' },
57
+ },
58
+ });
59
+ });
60
+ });
14
61
 
15
62
  function buildResponse(
16
63
  parts: Array<Record<string, unknown>>
@@ -72,7 +119,9 @@ describe('rejectsModelTurnPrefill', () => {
72
119
  expect(rejectsModelTurnPrefill('gemini-3.6-flash')).toBe(true);
73
120
  expect(rejectsModelTurnPrefill('gemini-3.5-flash-lite')).toBe(true);
74
121
  expect(rejectsModelTurnPrefill('models/gemini-3.6-flash')).toBe(true);
75
- expect(rejectsModelTurnPrefill('google/gemini-3.5-flash-lite-latest')).toBe(true);
122
+ expect(rejectsModelTurnPrefill('google/gemini-3.5-flash-lite-latest')).toBe(
123
+ true
124
+ );
76
125
  });
77
126
 
78
127
  test('is false for models that still accept prefill and for empty input', () => {
@@ -86,34 +135,51 @@ describe('rejectsModelTurnPrefill', () => {
86
135
 
87
136
  describe('dropUnsupportedModelTurnPrefill', () => {
88
137
  const userTurn: Content = { role: 'user', parts: [{ text: 'Hi' }] };
89
- const modelTurn: Content = { role: 'model', parts: [{ text: 'Hello, I am' }] };
138
+ const modelTurn: Content = {
139
+ role: 'model',
140
+ parts: [{ text: 'Hello, I am' }],
141
+ };
90
142
 
91
143
  test('drops a trailing model turn for no-prefill models', () => {
92
144
  const contents: Content[] = [userTurn, modelTurn];
93
- const result = dropUnsupportedModelTurnPrefill(contents, 'gemini-3.6-flash');
145
+ const result = dropUnsupportedModelTurnPrefill(
146
+ contents,
147
+ 'gemini-3.6-flash'
148
+ );
94
149
  expect(result).toEqual([userTurn]);
95
150
  });
96
151
 
97
152
  test('drops multiple consecutive trailing model turns but keeps one turn', () => {
98
153
  const contents: Content[] = [userTurn, modelTurn, modelTurn];
99
- const result = dropUnsupportedModelTurnPrefill(contents, 'gemini-3.5-flash-lite');
154
+ const result = dropUnsupportedModelTurnPrefill(
155
+ contents,
156
+ 'gemini-3.5-flash-lite'
157
+ );
100
158
  expect(result).toEqual([userTurn]);
101
159
  });
102
160
 
103
161
  test('leaves a trailing model turn for models that accept prefill', () => {
104
162
  const contents: Content[] = [userTurn, modelTurn];
105
- const result = dropUnsupportedModelTurnPrefill(contents, 'gemini-3.5-flash');
163
+ const result = dropUnsupportedModelTurnPrefill(
164
+ contents,
165
+ 'gemini-3.5-flash'
166
+ );
106
167
  expect(result).toBe(contents);
107
168
  });
108
169
 
109
170
  test('is a no-op when the request already ends with a user turn', () => {
110
171
  const contents: Content[] = [modelTurn, userTurn];
111
- const result = dropUnsupportedModelTurnPrefill(contents, 'gemini-3.6-flash');
172
+ const result = dropUnsupportedModelTurnPrefill(
173
+ contents,
174
+ 'gemini-3.6-flash'
175
+ );
112
176
  expect(result).toBe(contents);
113
177
  });
114
178
 
115
179
  test('is a no-op for empty or undefined contents', () => {
116
180
  expect(dropUnsupportedModelTurnPrefill([], 'gemini-3.6-flash')).toEqual([]);
117
- expect(dropUnsupportedModelTurnPrefill(undefined, 'gemini-3.6-flash')).toBeUndefined();
181
+ expect(
182
+ dropUnsupportedModelTurnPrefill(undefined, 'gemini-3.6-flash')
183
+ ).toBeUndefined();
118
184
  });
119
185
  });
@@ -390,7 +390,9 @@ function _convertLangChainContentToPart(
390
390
  }
391
391
 
392
392
  if (content.type === 'text') {
393
- return { text: content.text };
393
+ return typeof content.text === 'string' && content.text !== ''
394
+ ? { text: content.text }
395
+ : undefined;
394
396
  } else if (content.type === 'executableCode') {
395
397
  return { executableCode: content.executableCode };
396
398
  } else if (content.type === 'codeExecutionResult') {
@@ -429,10 +431,14 @@ function _convertLangChainContentToPart(
429
431
  } else if (content.type === 'media') {
430
432
  return messageContentMedia(content);
431
433
  } else if (content.type === 'tool_use') {
434
+ const functionId = getGoogleFunctionId(
435
+ typeof content.id === 'string' ? content.id : undefined
436
+ );
432
437
  return {
433
438
  functionCall: {
434
439
  name: content.name,
435
440
  args: content.input,
441
+ ...(functionId != null ? { id: functionId } : {}),
436
442
  },
437
443
  };
438
444
  } else if (
@@ -553,7 +559,28 @@ export function convertMessageContentToParts(
553
559
  });
554
560
  }
555
561
 
556
- return [...messageParts, ...functionCalls];
562
+ const parsedFunctionCallIds = new Set(
563
+ functionCalls.flatMap((part) => {
564
+ const functionCall = part.functionCall as GoogleFunctionCallWithId;
565
+ return functionCall.id != null ? [functionCall.id] : [];
566
+ })
567
+ );
568
+ const parsedFunctionCallNames = new Set(
569
+ functionCalls.map((part) => part.functionCall.name)
570
+ );
571
+ const contentWithoutParsedMirrors = messageParts.filter((part) => {
572
+ if (!('functionCall' in part) || part.functionCall == null) {
573
+ return true;
574
+ }
575
+ const functionCall = part.functionCall as GoogleFunctionCallWithId;
576
+ return !(
577
+ (functionCall.id != null && parsedFunctionCallIds.has(functionCall.id)) ||
578
+ (functionCall.id == null &&
579
+ parsedFunctionCallNames.has(functionCall.name))
580
+ );
581
+ });
582
+
583
+ return [...contentWithoutParsedMirrors, ...functionCalls];
557
584
  }
558
585
 
559
586
  export function convertBaseMessagesToContent(
@@ -665,7 +692,11 @@ export function dropUnsupportedModelTurnPrefill(
665
692
  contents: Content[] | undefined,
666
693
  model?: string
667
694
  ): Content[] | undefined {
668
- if (contents == null || contents.length === 0 || !rejectsModelTurnPrefill(model)) {
695
+ if (
696
+ contents == null ||
697
+ contents.length === 0 ||
698
+ !rejectsModelTurnPrefill(model)
699
+ ) {
669
700
  return contents;
670
701
  }
671
702
  let end = contents.length;
@@ -0,0 +1,87 @@
1
+ // src/llm/invoke.alternation.test.ts
2
+ /**
3
+ * `attemptInvoke` is the single funnel for primary, fallback and
4
+ * summarization model calls, and it keys the alternation pass on the provider
5
+ * ACTUALLY serving the call. This is the seam that protects a fallback: an
6
+ * OpenAI primary that failed after a boundary injected two human turns hands
7
+ * the same array to a Bedrock fallback, which rejects consecutive user turns.
8
+ */
9
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
10
+ import type { BaseMessage } from '@langchain/core/messages';
11
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
12
+ import type { ChatGenerationChunk } from '@langchain/core/outputs';
13
+ import { Providers } from '@/common';
14
+ import { FakeChatModel } from '@/llm/fake';
15
+ import { attemptInvoke } from './invoke';
16
+ import type * as t from '@/types';
17
+
18
+ class CapturingChatModel extends FakeChatModel {
19
+ readonly invocations: BaseMessage[][] = [];
20
+
21
+ constructor() {
22
+ super({ responses: ['ok'] });
23
+ }
24
+
25
+ override async *_streamResponseChunks(
26
+ messages: BaseMessage[],
27
+ options: this['ParsedCallOptions'],
28
+ runManager?: CallbackManagerForLLMRun
29
+ ): AsyncGenerator<ChatGenerationChunk> {
30
+ this.invocations.push(messages);
31
+ yield* super._streamResponseChunks(messages, options, runManager);
32
+ }
33
+ }
34
+
35
+ const adjacentUserPayload = (): BaseMessage[] => [
36
+ new HumanMessage({ content: 'question' }),
37
+ new AIMessage({ content: 'partial answer' }),
38
+ new HumanMessage({ content: 'hook context' }),
39
+ new HumanMessage({ content: 'steer' }),
40
+ ];
41
+
42
+ async function invokeAs(provider: Providers): Promise<BaseMessage[]> {
43
+ const model = new CapturingChatModel();
44
+ await attemptInvoke({
45
+ model: model as unknown as t.ChatModel,
46
+ messages: adjacentUserPayload(),
47
+ provider,
48
+ onChunk: async () => undefined,
49
+ });
50
+ expect(model.invocations).toHaveLength(1);
51
+ return model.invocations[0];
52
+ }
53
+
54
+ describe('attemptInvoke alternation funnel', () => {
55
+ it('coalesces adjacent user turns when the serving provider is strict', async () => {
56
+ const sent = await invokeAs(Providers.BEDROCK);
57
+ expect(sent.map((m) => m.getType())).toEqual(['human', 'ai', 'human']);
58
+ expect(sent[2].content).toBe('hook context\n\nsteer');
59
+ });
60
+
61
+ it('leaves the payload alone for a tolerant provider', async () => {
62
+ const sent = await invokeAs(Providers.OPENAI);
63
+ expect(sent.map((m) => m.getType())).toEqual([
64
+ 'human',
65
+ 'ai',
66
+ 'human',
67
+ 'human',
68
+ ]);
69
+ });
70
+
71
+ it('is idempotent when the graph already coalesced for the primary', async () => {
72
+ const model = new CapturingChatModel();
73
+ const once = await invokeAs(Providers.BEDROCK);
74
+ await attemptInvoke({
75
+ model: model as unknown as t.ChatModel,
76
+ messages: once,
77
+ provider: Providers.BEDROCK,
78
+ onChunk: async () => undefined,
79
+ });
80
+ expect(model.invocations[0].map((m) => m.getType())).toEqual([
81
+ 'human',
82
+ 'ai',
83
+ 'human',
84
+ ]);
85
+ expect(model.invocations[0][2].content).toBe('hook context\n\nsteer');
86
+ });
87
+ });
@@ -0,0 +1,112 @@
1
+ // src/llm/invoke.handoffCue.test.ts
2
+ /**
3
+ * The handoff cue is re-keyed on the provider ACTUALLY serving the call at
4
+ * the `attemptInvoke` funnel — the seam every fallback send passes through.
5
+ * A tolerant primary falling back to a Claude surface must gain the cue; an
6
+ * Anthropic primary falling back to a tolerant provider must have the baked
7
+ * cue stripped; and the serving model id must be read through the wrapper
8
+ * stack, or a wrapped Bedrock-Nova model would default to Claude.
9
+ */
10
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
11
+ import type { BaseMessage } from '@langchain/core/messages';
12
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
13
+ import type { ChatGenerationChunk } from '@langchain/core/outputs';
14
+ import { RunnableBinding } from '@langchain/core/runnables';
15
+ import { Providers } from '@/common';
16
+ import { PREDECESSOR_HANDOFF_CUE } from '@/messages/handoffCue';
17
+ import { FakeChatModel } from '@/llm/fake';
18
+ import { attemptInvoke, type InvokeContext } from './invoke';
19
+
20
+ class CapturingChatModel extends FakeChatModel {
21
+ readonly invocations: BaseMessage[][] = [];
22
+ /** Serving model id, as a real provider client would expose it. */
23
+ model?: string;
24
+
25
+ constructor(modelId?: string) {
26
+ super({ responses: ['ok'] });
27
+ this.model = modelId;
28
+ }
29
+
30
+ override async *_streamResponseChunks(
31
+ messages: BaseMessage[],
32
+ options: this['ParsedCallOptions'],
33
+ runManager?: CallbackManagerForLLMRun
34
+ ): AsyncGenerator<ChatGenerationChunk> {
35
+ this.invocations.push(messages);
36
+ yield* super._streamResponseChunks(messages, options, runManager);
37
+ }
38
+ }
39
+
40
+ const runTail = new AIMessage({ content: 'predecessor output', id: 'run-1' });
41
+ const context = {
42
+ isRunProducedMessage: (message: BaseMessage): boolean =>
43
+ message.id === 'run-1',
44
+ getOrCreateToolOutputRegistry: (): undefined => undefined,
45
+ } as unknown as InvokeContext;
46
+
47
+ const bakedCue = (): HumanMessage =>
48
+ new HumanMessage({
49
+ content: PREDECESSOR_HANDOFF_CUE,
50
+ additional_kwargs: { role: 'user', isMeta: true, source: 'handoff' },
51
+ });
52
+
53
+ async function sentBy(
54
+ provider: Providers,
55
+ options?: { modelId?: string; wrap?: boolean; messages?: BaseMessage[] }
56
+ ): Promise<BaseMessage[]> {
57
+ const model = new CapturingChatModel(options?.modelId);
58
+ const served =
59
+ options?.wrap === true
60
+ ? new RunnableBinding({ bound: model, kwargs: {}, config: {} })
61
+ : model;
62
+ await attemptInvoke({
63
+ model: served as never,
64
+ messages: options?.messages ?? [new HumanMessage('go'), runTail],
65
+ provider,
66
+ context,
67
+ onChunk: async () => undefined,
68
+ });
69
+ return model.invocations[0];
70
+ }
71
+
72
+ describe('attemptInvoke handoff-cue funnel', () => {
73
+ it('applies the cue when the serving provider is Anthropic', async () => {
74
+ const sent = await sentBy(Providers.ANTHROPIC);
75
+ expect(sent.at(-1)?.content).toBe(PREDECESSOR_HANDOFF_CUE);
76
+ });
77
+
78
+ it('applies the cue for Bedrock serving a WRAPPED Claude model', async () => {
79
+ const sent = await sentBy(Providers.BEDROCK, {
80
+ modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
81
+ wrap: true,
82
+ });
83
+ expect(sent.at(-1)?.content).toBe(PREDECESSOR_HANDOFF_CUE);
84
+ });
85
+
86
+ it('does not apply the cue for Bedrock serving a WRAPPED Nova model', async () => {
87
+ const sent = await sentBy(Providers.BEDROCK, {
88
+ modelId: 'us.amazon.nova-pro-v1:0',
89
+ wrap: true,
90
+ });
91
+ expect(sent.at(-1)?.getType()).toBe('ai');
92
+ });
93
+
94
+ it('strips a baked cue for a tolerant serving provider', async () => {
95
+ const sent = await sentBy(Providers.OPENAI, {
96
+ messages: [new HumanMessage('go'), runTail, bakedCue()],
97
+ });
98
+ expect(sent.at(-1)?.getType()).toBe('ai');
99
+ expect(sent.some((m) => m.content === PREDECESSOR_HANDOFF_CUE)).toBe(
100
+ false
101
+ );
102
+ });
103
+
104
+ it('is idempotent when the primary already baked the cue', async () => {
105
+ const sent = await sentBy(Providers.ANTHROPIC, {
106
+ messages: [new HumanMessage('go'), runTail, bakedCue()],
107
+ });
108
+ expect(
109
+ sent.filter((m) => m.content === PREDECESSOR_HANDOFF_CUE)
110
+ ).toHaveLength(1);
111
+ });
112
+ });