@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,212 @@
1
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
2
+ import { Providers } from '@/common';
3
+ import {
4
+ coalesceAdjacentUserTurns,
5
+ strictAlternationProviders,
6
+ } from './alternation';
7
+
8
+ describe('strictAlternationProviders', () => {
9
+ it('covers the providers that reject consecutive user turns', () => {
10
+ expect(strictAlternationProviders.has(Providers.BEDROCK)).toBe(true);
11
+ expect(strictAlternationProviders.has(Providers.MISTRAL)).toBe(true);
12
+ expect(strictAlternationProviders.has(Providers.MISTRALAI)).toBe(true);
13
+ });
14
+
15
+ it('leaves providers that accept them alone', () => {
16
+ expect(strictAlternationProviders.has(Providers.ANTHROPIC)).toBe(false);
17
+ expect(strictAlternationProviders.has(Providers.OPENAI)).toBe(false);
18
+ expect(strictAlternationProviders.has(Providers.GOOGLE)).toBe(false);
19
+ });
20
+ });
21
+
22
+ describe('coalesceAdjacentUserTurns', () => {
23
+ it('leaves an already-alternating run untouched, returning the SAME array', () => {
24
+ const messages = [
25
+ new HumanMessage({ content: 'question' }),
26
+ new AIMessage({ content: 'answer' }),
27
+ new HumanMessage({ content: 'follow-up' }),
28
+ ];
29
+ const result = coalesceAdjacentUserTurns(messages);
30
+ /**
31
+ * Identity, not just equality: the pass runs twice for a primary
32
+ * Bedrock/Mistral call (createCallModel, then the attemptInvoke funnel),
33
+ * so the normalized second pass must not reallocate a context-sized
34
+ * array — and origin tracking early-exits on `before === after`.
35
+ */
36
+ expect(result).toBe(messages);
37
+ });
38
+
39
+ it('is idempotent by identity: re-coalescing merged output is a no-op', () => {
40
+ const once = coalesceAdjacentUserTurns([
41
+ new HumanMessage({ content: 'steer 1' }),
42
+ new HumanMessage({ content: 'steer 2' }),
43
+ ]);
44
+ expect(once).toHaveLength(1);
45
+ expect(coalesceAdjacentUserTurns(once)).toBe(once);
46
+ });
47
+
48
+ /** A boundary that drains two steers, which is the ordinary queue case. */
49
+ it('merges a run of consecutive human turns', () => {
50
+ const result = coalesceAdjacentUserTurns([
51
+ new HumanMessage({ content: 'question' }),
52
+ new AIMessage({ content: 'partial' }),
53
+ new HumanMessage({ content: 'steer 1' }),
54
+ new HumanMessage({ content: 'steer 2' }),
55
+ ]);
56
+ expect(result.map((m) => m.getType())).toEqual(['human', 'ai', 'human']);
57
+ expect(result[2].content).toBe('steer 1\n\nsteer 2');
58
+ });
59
+
60
+ it('merges context plus injected messages into one turn', () => {
61
+ const result = coalesceAdjacentUserTurns([
62
+ new AIMessage({ content: 'partial' }),
63
+ new HumanMessage({
64
+ content: 'hook context',
65
+ additional_kwargs: { role: 'system', source: 'hook' },
66
+ }),
67
+ new HumanMessage({
68
+ content: 'steer',
69
+ additional_kwargs: { role: 'user', source: 'steer' },
70
+ }),
71
+ ]);
72
+ expect(result).toHaveLength(2);
73
+ expect(result[1].content).toBe('hook context\n\nsteer');
74
+ });
75
+
76
+ it('concatenates block content rather than stringifying it', () => {
77
+ const result = coalesceAdjacentUserTurns([
78
+ new HumanMessage({ content: [{ type: 'text', text: 'look' }] }),
79
+ new HumanMessage({
80
+ content: [{ type: 'image_url', image_url: { url: 'data:,' } }],
81
+ }),
82
+ ]);
83
+ expect(result).toHaveLength(1);
84
+ expect(result[0].content).toEqual([
85
+ { type: 'text', text: 'look' },
86
+ { type: 'image_url', image_url: { url: 'data:,' } },
87
+ ]);
88
+ });
89
+
90
+ it('normalizes a mixed string and block pair', () => {
91
+ const result = coalesceAdjacentUserTurns([
92
+ new HumanMessage({ content: 'plain' }),
93
+ new HumanMessage({ content: [{ type: 'text', text: 'blocks' }] }),
94
+ ]);
95
+ expect(result).toHaveLength(1);
96
+ expect(result[0].content).toEqual([
97
+ { type: 'text', text: 'plain' },
98
+ { type: 'text', text: 'blocks' },
99
+ ]);
100
+ });
101
+
102
+ /**
103
+ * Both vendored converters merge adjacent tool-result runs themselves, and
104
+ * folding one into a text turn would orphan the pairing.
105
+ */
106
+ it('never merges tool-result turns', () => {
107
+ const result = coalesceAdjacentUserTurns([
108
+ new HumanMessage({
109
+ content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }],
110
+ }),
111
+ new HumanMessage({
112
+ content: [{ type: 'tool_result', tool_use_id: 't2', content: 'ok' }],
113
+ }),
114
+ ]);
115
+ expect(result).toHaveLength(2);
116
+ });
117
+
118
+ it('never merges a tool-result turn into a text turn', () => {
119
+ const result = coalesceAdjacentUserTurns([
120
+ new HumanMessage({
121
+ content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }],
122
+ }),
123
+ new HumanMessage({ content: 'steer' }),
124
+ ]);
125
+ expect(result).toHaveLength(2);
126
+ });
127
+
128
+ /**
129
+ * The prompt-cache tail anchor reasons positionally — it inserts the
130
+ * breakpoint after the merged message's LAST text block — so the last
131
+ * part's provenance flags must survive the merge. A skill body absorbed
132
+ * into a real user turn stays anchorable; a real turn absorbed into a
133
+ * trailing skill body must not let the anchor pin the volatile body.
134
+ */
135
+ it('keeps the last turn\'s additional_kwargs and the first turn\'s id', () => {
136
+ const result = coalesceAdjacentUserTurns([
137
+ new HumanMessage({
138
+ content: 'skill body',
139
+ id: 'first-id',
140
+ additional_kwargs: { isMeta: true, source: 'skill' },
141
+ }),
142
+ new HumanMessage({
143
+ content: 'real user turn',
144
+ id: 'second-id',
145
+ additional_kwargs: { role: 'user' },
146
+ }),
147
+ ]);
148
+ expect(result).toHaveLength(1);
149
+ expect(result[0].additional_kwargs).toEqual({ role: 'user' });
150
+ expect(result[0].id).toBe('first-id');
151
+ });
152
+
153
+ it('marks the merge meta when the trailing part is the volatile one', () => {
154
+ const result = coalesceAdjacentUserTurns([
155
+ new HumanMessage({
156
+ content: 'real steer',
157
+ additional_kwargs: { source: 'steer' },
158
+ }),
159
+ new HumanMessage({
160
+ content: 'skill body',
161
+ additional_kwargs: { isMeta: true, source: 'skill', skillName: 'x' },
162
+ }),
163
+ ]);
164
+ expect(result).toHaveLength(1);
165
+ expect(result[0].additional_kwargs.source).toBe('skill');
166
+ expect(result[0].additional_kwargs.isMeta).toBe(true);
167
+ });
168
+
169
+ /**
170
+ * Only ALL-tool-result turns are excluded. A mixed turn is an ordinary
171
+ * user turn that happens to carry a result block — merging preserves block
172
+ * order, so the pairing survives, and excluding it would leave adjacent
173
+ * user turns on the wire for exactly the shape Bedrock rejects.
174
+ */
175
+ it('merges a mixed text plus tool-result turn', () => {
176
+ const result = coalesceAdjacentUserTurns([
177
+ new HumanMessage({
178
+ content: [
179
+ { type: 'tool_result', tool_use_id: 't1', content: 'ok' },
180
+ { type: 'text', text: 'and my comment' },
181
+ ],
182
+ }),
183
+ new HumanMessage({ content: 'next turn' }),
184
+ ]);
185
+ expect(result).toHaveLength(1);
186
+ expect(result[0].content).toEqual([
187
+ { type: 'tool_result', tool_use_id: 't1', content: 'ok' },
188
+ { type: 'text', text: 'and my comment' },
189
+ { type: 'text', text: 'next turn' },
190
+ ]);
191
+ });
192
+
193
+ it('excludes the camelCase toolResult variant too', () => {
194
+ const result = coalesceAdjacentUserTurns([
195
+ new HumanMessage({
196
+ content: [{ type: 'toolResult', toolResult: { content: 'ok' } }],
197
+ }),
198
+ new HumanMessage({ content: 'steer' }),
199
+ ]);
200
+ expect(result).toHaveLength(2);
201
+ });
202
+
203
+ it('does not mutate the input array or its messages', () => {
204
+ const first = new HumanMessage({ content: 'a' });
205
+ const second = new HumanMessage({ content: 'b' });
206
+ const messages = [first, second];
207
+ coalesceAdjacentUserTurns(messages);
208
+ expect(messages).toHaveLength(2);
209
+ expect(first.content).toBe('a');
210
+ expect(second.content).toBe('b');
211
+ });
212
+ });
@@ -0,0 +1,112 @@
1
+ // src/messages/alternation.ts
2
+ import { HumanMessage } from '@langchain/core/messages';
3
+ import type { BaseMessage, MessageContent } from '@langchain/core/messages';
4
+ import { Providers } from '@/common';
5
+
6
+ /**
7
+ * Providers whose APIs specify strict user/assistant alternation. Mistral
8
+ * rejects consecutive user turns outright. Bedrock's Converse API documents
9
+ * the alternation requirement across many model families; enforcement varies
10
+ * by family — Claude on Converse currently tolerates adjacent user turns
11
+ * (verified live, 2026-07-28) — so the payload is normalized for all of them
12
+ * rather than betting on per-family leniency. Anthropic's own Messages API,
13
+ * OpenAI and Gemini all accept consecutive user turns, so they are
14
+ * deliberately absent.
15
+ */
16
+ export const strictAlternationProviders: ReadonlySet<Providers> = new Set([
17
+ Providers.BEDROCK,
18
+ Providers.MISTRAL,
19
+ Providers.MISTRALAI,
20
+ ]);
21
+
22
+ const TOOL_RESULT_TYPES = new Set(['tool_result', 'toolResult']);
23
+
24
+ /**
25
+ * True when every block is a tool result. Both vendored converters already
26
+ * merge adjacent runs of these, and folding one into a text turn would break
27
+ * the tool pairing they depend on — so they are left alone here.
28
+ */
29
+ function isToolResultMessage(message: BaseMessage): boolean {
30
+ const { content } = message;
31
+ if (typeof content === 'string' || content.length === 0) {
32
+ return false;
33
+ }
34
+ return content.every(
35
+ (block) =>
36
+ typeof block.type === 'string' && TOOL_RESULT_TYPES.has(block.type)
37
+ );
38
+ }
39
+
40
+ function toBlocks(content: MessageContent): Exclude<MessageContent, string> {
41
+ if (typeof content === 'string') {
42
+ return content === '' ? [] : [{ type: 'text', text: content }];
43
+ }
44
+ return content;
45
+ }
46
+
47
+ function joinContent(
48
+ left: MessageContent,
49
+ right: MessageContent
50
+ ): MessageContent {
51
+ if (typeof left === 'string' && typeof right === 'string') {
52
+ return left === '' ? right : `${left}\n\n${right}`;
53
+ }
54
+ return [...toBlocks(left), ...toBlocks(right)];
55
+ }
56
+
57
+ /**
58
+ * Merges runs of consecutive human turns into one, for providers that reject
59
+ * them. Purely a wire-shaping pass: it returns a new array of new messages,
60
+ * so graph state and the host's persisted messages keep the per-message
61
+ * identity that steer rendering and the trailing-steer anchor rely on.
62
+ *
63
+ * Tool-result turns are excluded — the converters merge those themselves, and
64
+ * combining one with a text turn would orphan the pairing.
65
+ */
66
+ export function coalesceAdjacentUserTurns(
67
+ messages: BaseMessage[]
68
+ ): BaseMessage[] {
69
+ const result: BaseMessage[] = [];
70
+ let mergedAny = false;
71
+ for (const message of messages) {
72
+ const previous = result[result.length - 1];
73
+ const mergeable =
74
+ result.length > 0 &&
75
+ previous.getType() === 'human' &&
76
+ message.getType() === 'human' &&
77
+ !isToolResultMessage(previous) &&
78
+ !isToolResultMessage(message);
79
+
80
+ if (!mergeable) {
81
+ result.push(message);
82
+ continue;
83
+ }
84
+ mergedAny = true;
85
+
86
+ result[result.length - 1] = new HumanMessage({
87
+ content: joinContent(previous.content, message.content),
88
+ /**
89
+ * The LATER turn's kwargs, deliberately. The one provider-path consumer
90
+ * of these flags is the prompt-cache tail anchor, and it reasons
91
+ * positionally: `isSyntheticMetaMessage` decides whether a breakpoint
92
+ * may be inserted after the message's LAST text block. Merging keeps
93
+ * the last block's provenance only if the last part's kwargs survive —
94
+ * a skill body absorbed into a real user turn must stay anchorable
95
+ * (the real turn ends it), while a real steer absorbed into a trailing
96
+ * skill body must not pin the cache to the volatile body. The first
97
+ * turn's id is kept so origin tracking can re-attach by key.
98
+ */
99
+ additional_kwargs: message.additional_kwargs,
100
+ ...(previous.id != null && { id: previous.id }),
101
+ });
102
+ }
103
+ /**
104
+ * Identity on the no-merge path. The pass runs twice for a primary
105
+ * Bedrock/Mistral call — once in `createCallModel` (must precede the cache
106
+ * breakpoint) and once in the `attemptInvoke` funnel (must cover fallback
107
+ * and summarization sends) — so the already-normalized second pass returns
108
+ * the SAME array rather than reallocating a context-sized copy, and
109
+ * callers can cheaply detect "nothing changed" by identity.
110
+ */
111
+ return mergedAny ? result : messages;
112
+ }
@@ -1,3 +1,4 @@
1
+ import { convertMessagesToCompletionsMessageParams } from '@langchain/openai';
1
2
  import {
2
3
  AIMessage,
3
4
  HumanMessage,
@@ -10,7 +11,15 @@ import type {
10
11
  } from '@langchain/core/messages';
11
12
  import type Anthropic from '@anthropic-ai/sdk';
12
13
  import type { AnthropicMessages } from '@/types/messages';
14
+ import {
15
+ projectOpenAIChatToolMessageContent,
16
+ projectOpenAIToolMessageContent,
17
+ projectOpenAIResponsesToolMessageContent,
18
+ projectOpenRouterToolMessageContent,
19
+ projectComputerCallOutputsToText,
20
+ } from './core';
13
21
  import { addTailCacheControl, addBedrockTailCacheControl } from './cache';
22
+ import { convertToConverseMessages } from '@/llm/bedrock/utils';
14
23
  import { toLangChainContent } from './langchain';
15
24
 
16
25
  type CacheControlBlock = MessageContentComplex & {
@@ -86,6 +95,154 @@ describe('addTailCacheControl (single tail breakpoint)', () => {
86
95
  expect(blocksOf(result[2])[0].cache_control).toEqual({ type: 'ephemeral' });
87
96
  });
88
97
 
98
+ test('skips native computer outputs and removes stale screenshot markers', () => {
99
+ const computerCall = new AIMessage({
100
+ content: '',
101
+ response_metadata: {
102
+ output: [
103
+ {
104
+ type: 'computer_call',
105
+ call_id: 'call_computer_cache',
106
+ action: { type: 'screenshot' },
107
+ },
108
+ ],
109
+ },
110
+ });
111
+ const computerOutput = new ToolMessage({
112
+ content: 'data:image/png;base64,AA==',
113
+ tool_call_id: 'call_computer_cache',
114
+ additional_kwargs: { type: 'computer_call_output' },
115
+ });
116
+ const canonical = projectOpenAIToolMessageContent([
117
+ new HumanMessage('Take a screenshot'),
118
+ computerCall,
119
+ computerOutput,
120
+ ]);
121
+
122
+ const cached = addTailCacheControl(canonical);
123
+
124
+ expect(countCacheMarkers(cached)).toBe(1);
125
+ expect(blocksOf(cached[0])[0].cache_control).toEqual({
126
+ type: 'ephemeral',
127
+ });
128
+ expect(blocksOf(cached[2])[0]).not.toHaveProperty('cache_control');
129
+ expect(() =>
130
+ projectOpenAIResponsesToolMessageContent(cached)
131
+ ).not.toThrow();
132
+
133
+ const canonicalOutput = canonical[2] as ToolMessage;
134
+ const staleOutput = new ToolMessage({
135
+ content: toLangChainContent([
136
+ {
137
+ ...(canonicalOutput.content[0] as MessageContentComplex),
138
+ cache_control: { type: 'ephemeral' },
139
+ },
140
+ ]),
141
+ tool_call_id: canonicalOutput.tool_call_id,
142
+ additional_kwargs: canonicalOutput.additional_kwargs,
143
+ });
144
+ const cleaned = addTailCacheControl([
145
+ canonical[0],
146
+ canonical[1],
147
+ staleOutput,
148
+ ]);
149
+
150
+ expect(countCacheMarkers(cleaned)).toBe(1);
151
+ expect(blocksOf(cleaned[2])[0]).not.toHaveProperty('cache_control');
152
+ expect(blocksOf(staleOutput)[0]).toHaveProperty('cache_control');
153
+ expect(() =>
154
+ projectOpenAIResponsesToolMessageContent(cleaned)
155
+ ).not.toThrow();
156
+ });
157
+
158
+ test('keeps a cached tool result structured through Chat projection', () => {
159
+ const toolCall = new AIMessage({
160
+ content: '',
161
+ tool_calls: [{ id: 'call_cached_tool', name: 'search', args: {} }],
162
+ });
163
+ const toolOutput = new ToolMessage({
164
+ content: 'result body',
165
+ tool_call_id: 'call_cached_tool',
166
+ });
167
+ const graphProjected = projectOpenAIToolMessageContent([
168
+ new HumanMessage('Search'),
169
+ toolCall,
170
+ toolOutput,
171
+ ]);
172
+ const cached = addTailCacheControl(graphProjected);
173
+ const attemptProjected = projectOpenRouterToolMessageContent(cached);
174
+ const payload = convertMessagesToCompletionsMessageParams({
175
+ messages: attemptProjected,
176
+ model: 'gpt-4o',
177
+ });
178
+
179
+ expect(blocksOf(cached[2])[0]).toMatchObject({
180
+ type: 'text',
181
+ text: 'result body',
182
+ cache_control: { type: 'ephemeral' },
183
+ });
184
+ expect((attemptProjected[2] as ToolMessage).content).toEqual(
185
+ (cached[2] as ToolMessage).content
186
+ );
187
+ expect(payload[2]).toMatchObject({
188
+ role: 'tool',
189
+ tool_call_id: 'call_cached_tool',
190
+ content: [
191
+ {
192
+ type: 'text',
193
+ text: 'result body',
194
+ cache_control: { type: 'ephemeral' },
195
+ },
196
+ ],
197
+ });
198
+
199
+ const fallbackProjected = projectOpenAIChatToolMessageContent(cached);
200
+ const fallbackPayload = convertMessagesToCompletionsMessageParams({
201
+ messages: fallbackProjected,
202
+ model: 'gpt-4o',
203
+ });
204
+
205
+ expect((fallbackProjected[2] as ToolMessage).content).toBe('result body');
206
+ expect(fallbackPayload[2]).toMatchObject({
207
+ role: 'tool',
208
+ tool_call_id: 'call_cached_tool',
209
+ content: 'result body',
210
+ });
211
+ expect(JSON.stringify(fallbackPayload[2])).not.toContain('cache_control');
212
+
213
+ expect(
214
+ typeof (projectOpenAIToolMessageContent(cached)[2] as ToolMessage).content
215
+ ).toBe('string');
216
+ });
217
+
218
+ test('does not stringify noncanonical cache metadata into tool output', () => {
219
+ const messages = addTailCacheControl<BaseMessage>([
220
+ new HumanMessage('Search'),
221
+ new AIMessage({
222
+ content: '',
223
+ tool_calls: [{ id: 'call_nested_cache', name: 'search', args: {} }],
224
+ }),
225
+ new ToolMessage({
226
+ content: toLangChainContent([
227
+ {
228
+ type: 'tool_result',
229
+ tool_use_id: 'call_nested_cache',
230
+ content: 'result body',
231
+ },
232
+ ] as MessageContentComplex[]),
233
+ tool_call_id: 'call_nested_cache',
234
+ }),
235
+ ]);
236
+
237
+ const projected = projectOpenRouterToolMessageContent(messages);
238
+ const content = (projected[2] as ToolMessage).content;
239
+
240
+ expect(typeof content).toBe('string');
241
+ expect(content).toContain('result body');
242
+ expect(content).not.toContain('cache_control');
243
+ expect(JSON.stringify(messages[2].content)).toContain('cache_control');
244
+ });
245
+
89
246
  test('strips ALL stale markers and re-anchors a single one at the tail', () => {
90
247
  const messages: BaseMessage[] = [
91
248
  new HumanMessage({
@@ -337,4 +494,40 @@ describe('addBedrockTailCacheControl (single tail cachePoint)', () => {
337
494
  { cachePoint: { type: 'default' } },
338
495
  ]);
339
496
  });
497
+
498
+ test('keeps the cachePoint before an omitted computer screenshot', () => {
499
+ const computerOutput = new ToolMessage({
500
+ content: 'data:image/png;base64,AA==',
501
+ tool_call_id: 'call_bedrock_computer',
502
+ additional_kwargs: { type: 'computer_call_output' },
503
+ });
504
+ const cached = addBedrockTailCacheControl([
505
+ new HumanMessage('Take a screenshot'),
506
+ new AIMessage({
507
+ content: '',
508
+ tool_calls: [
509
+ {
510
+ id: 'call_bedrock_computer',
511
+ name: 'computer',
512
+ args: { action: 'screenshot' },
513
+ },
514
+ ],
515
+ }),
516
+ computerOutput,
517
+ ]);
518
+
519
+ expect(countCachePoints(cached)).toBe(1);
520
+ expect(blocksOf(cached[0])[1]).toEqual({
521
+ cachePoint: { type: 'default' },
522
+ });
523
+ expect(cached[2].content).toBe(computerOutput.content);
524
+
525
+ const projected = projectComputerCallOutputsToText(cached);
526
+ const converse = convertToConverseMessages(projected);
527
+ expect((projected[2] as ToolMessage).content).toBe(
528
+ '[Computer screenshot omitted for this provider]'
529
+ );
530
+ expect(JSON.stringify(converse).match(/"cachePoint"/g)).toHaveLength(1);
531
+ expect(computerOutput.content).toBe('data:image/png;base64,AA==');
532
+ });
340
533
  });
@@ -1029,6 +1029,87 @@ describe('stripAnthropicCacheControl', () => {
1029
1029
  expect(result[0].content).toBe('Hello');
1030
1030
  });
1031
1031
 
1032
+ it('ignores primitive and proxy blocks without invoking proxy traps', () => {
1033
+ const unsafe = new Proxy(
1034
+ {},
1035
+ {
1036
+ ownKeys() {
1037
+ throw new Error('proxy keys must not be inspected');
1038
+ },
1039
+ }
1040
+ );
1041
+ const content = [
1042
+ 'primitive',
1043
+ unsafe,
1044
+ {
1045
+ type: ContentTypes.TEXT,
1046
+ text: 'cached',
1047
+ cache_control: { type: 'ephemeral' },
1048
+ },
1049
+ ] as unknown as MessageContentComplex[];
1050
+
1051
+ const result = stripAnthropicCacheControl([{ role: 'user', content }]);
1052
+ const stripped = result[0].content as unknown[];
1053
+
1054
+ expect(stripped[0]).toBe('primitive');
1055
+ expect(stripped[1]).toBe(unsafe);
1056
+ expect(stripped[2]).toEqual({
1057
+ type: ContentTypes.TEXT,
1058
+ text: 'cached',
1059
+ });
1060
+ });
1061
+
1062
+ it('removes a non-configurable cache_control from the clone only', () => {
1063
+ const block = { type: ContentTypes.TEXT, text: 'cached' };
1064
+ Object.defineProperty(block, 'cache_control', {
1065
+ configurable: false,
1066
+ enumerable: true,
1067
+ value: { type: 'ephemeral' },
1068
+ writable: false,
1069
+ });
1070
+ const messages = [
1071
+ {
1072
+ role: 'user',
1073
+ content: [block as MessageContentComplex],
1074
+ },
1075
+ ];
1076
+
1077
+ const result = stripAnthropicCacheControl(messages);
1078
+ const stripped = (result[0].content as MessageContentComplex[])[0];
1079
+
1080
+ expect('cache_control' in stripped).toBe(false);
1081
+ expect(
1082
+ (block as typeof block & { cache_control: unknown }).cache_control
1083
+ ).toEqual({ type: 'ephemeral' });
1084
+ });
1085
+
1086
+ it('removes an accessor cache_control without invoking it', () => {
1087
+ let getterCalls = 0;
1088
+ const block = { type: ContentTypes.TEXT, text: 'cached' };
1089
+ Object.defineProperty(block, 'cache_control', {
1090
+ configurable: true,
1091
+ enumerable: true,
1092
+ get() {
1093
+ getterCalls++;
1094
+ throw new Error('cache getter must not run');
1095
+ },
1096
+ });
1097
+
1098
+ const result = stripAnthropicCacheControl([
1099
+ {
1100
+ role: 'user',
1101
+ content: [block as MessageContentComplex],
1102
+ },
1103
+ ]);
1104
+ const stripped = (result[0].content as MessageContentComplex[])[0];
1105
+
1106
+ expect(getterCalls).toBe(0);
1107
+ expect('cache_control' in stripped).toBe(false);
1108
+ expect(
1109
+ Object.getOwnPropertyDescriptor(block, 'cache_control')
1110
+ ).toBeDefined();
1111
+ });
1112
+
1032
1113
  it('returns non-array input unchanged', () => {
1033
1114
  const notArray = 'not an array';
1034
1115
  /** @ts-expect-error - Testing invalid input */
@@ -1074,6 +1155,38 @@ describe('stripBedrockCacheControl', () => {
1074
1155
  });
1075
1156
  });
1076
1157
 
1158
+ it('ignores primitive and proxy blocks while removing cache points', () => {
1159
+ let getterCalls = 0;
1160
+ const unsafe = new Proxy(
1161
+ {},
1162
+ {
1163
+ getOwnPropertyDescriptor() {
1164
+ throw new Error('proxy descriptors must not be inspected');
1165
+ },
1166
+ }
1167
+ );
1168
+ const accessorCachePoint = {};
1169
+ Object.defineProperty(accessorCachePoint, 'cachePoint', {
1170
+ configurable: true,
1171
+ enumerable: true,
1172
+ get() {
1173
+ getterCalls++;
1174
+ throw new Error('cache point getter must not run');
1175
+ },
1176
+ });
1177
+ const content = [
1178
+ 42,
1179
+ unsafe,
1180
+ accessorCachePoint,
1181
+ { cachePoint: { type: 'default' } },
1182
+ ] as unknown as MessageContentComplex[];
1183
+
1184
+ const result = stripBedrockCacheControl([{ role: 'user', content }]);
1185
+
1186
+ expect(result[0].content).toEqual([42, unsafe]);
1187
+ expect(getterCalls).toBe(0);
1188
+ });
1189
+
1077
1190
  it('handles messages without cachePoint blocks gracefully', () => {
1078
1191
  const messages: TestMsg[] = [
1079
1192
  {