@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
@@ -1,5 +1,6 @@
1
1
  import { HumanMessage, AIMessage, ToolMessage } from '@langchain/core/messages';
2
2
  import type { TPayload } from '@/types';
3
+ import { HARD_MAX_TOOL_RESULT_CHARS } from '@/utils/truncation';
3
4
  import { formatAgentMessages } from './format';
4
5
  import { ContentTypes } from '@/common';
5
6
 
@@ -38,6 +39,108 @@ describe('formatAgentMessages with tools parameter', () => {
38
39
  expect((result.messages[2] as ToolMessage).tool_call_id).toBe('123');
39
40
  });
40
41
 
42
+ it('restores persisted structured tool output for harness accounting', () => {
43
+ const output = [
44
+ {
45
+ type: ContentTypes.TEXT,
46
+ text: JSON.stringify([
47
+ { id: 1, value: 'first' },
48
+ { id: 2, value: 'second' },
49
+ ]),
50
+ },
51
+ ];
52
+ const payload: TPayload = [
53
+ { role: 'user', content: 'Query the table' },
54
+ {
55
+ role: 'assistant',
56
+ content: [
57
+ {
58
+ type: ContentTypes.TOOL_CALL,
59
+ tool_call: {
60
+ id: 'query-1',
61
+ name: 'run_select_query',
62
+ args: '{}',
63
+ output,
64
+ },
65
+ },
66
+ ],
67
+ },
68
+ ];
69
+
70
+ const result = formatAgentMessages(payload, { 0: 5, 1: 0 });
71
+
72
+ const toolMessage = result.messages[2] as ToolMessage;
73
+ expect(toolMessage).toBeInstanceOf(ToolMessage);
74
+ expect(toolMessage.content).toEqual(output);
75
+ expect(toolMessage.tool_call_id).toBe('query-1');
76
+ });
77
+
78
+ it('normalizes opaque persisted tool output before provider dispatch', () => {
79
+ const output = [{ type: 'json', rows: [{ id: 1, value: 'first' }] }];
80
+ const payload: TPayload = [
81
+ { role: 'user', content: 'Query the table' },
82
+ {
83
+ role: 'assistant',
84
+ content: [
85
+ {
86
+ type: ContentTypes.TOOL_CALL,
87
+ tool_call: {
88
+ id: 'query-opaque',
89
+ name: 'run_select_query',
90
+ args: '{}',
91
+ output,
92
+ },
93
+ },
94
+ ],
95
+ },
96
+ ];
97
+
98
+ const result = formatAgentMessages(payload);
99
+ const toolMessage = result.messages[2] as ToolMessage;
100
+
101
+ expect(typeof toolMessage.content).toBe('string');
102
+ expect(toolMessage.content).toBe(JSON.stringify(output));
103
+ });
104
+
105
+ it('hard-caps persisted structured output without invoking toJSON', () => {
106
+ let toJSONCalls = 0;
107
+ const output = {
108
+ rows: [{ value: 'x'.repeat(HARD_MAX_TOOL_RESULT_CHARS + 1_000) }],
109
+ toJSON() {
110
+ toJSONCalls++;
111
+ return 'y'.repeat(HARD_MAX_TOOL_RESULT_CHARS * 2);
112
+ },
113
+ };
114
+ const payload: TPayload = [
115
+ { role: 'user', content: 'Query the table' },
116
+ {
117
+ role: 'assistant',
118
+ content: [
119
+ {
120
+ type: ContentTypes.TOOL_CALL,
121
+ tool_call: {
122
+ id: 'query-bounded',
123
+ name: 'run_select_query',
124
+ args: '{}',
125
+ output,
126
+ },
127
+ },
128
+ ],
129
+ },
130
+ ];
131
+
132
+ const result = formatAgentMessages(payload);
133
+ const content = (result.messages[2] as ToolMessage).content;
134
+
135
+ expect(toJSONCalls).toBe(0);
136
+ expect(typeof content).toBe('string');
137
+ expect((content as string).length).toBeLessThanOrEqual(
138
+ HARD_MAX_TOOL_RESULT_CHARS
139
+ );
140
+ expect(content).toContain('truncated');
141
+ expect(content).not.toContain('y'.repeat(1_000));
142
+ });
143
+
41
144
  it('should filter out all tool calls when tools set is empty', () => {
42
145
  const payload: TPayload = [
43
146
  { role: 'user', content: 'What\'s the weather?' },
@@ -0,0 +1,96 @@
1
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
2
+ import type { BaseMessage } from '@langchain/core/messages';
3
+ import {
4
+ appendPredecessorHandoffCue,
5
+ removePredecessorHandoffCue,
6
+ PREDECESSOR_HANDOFF_CUE,
7
+ } from './handoffCue';
8
+
9
+ const runAi = new AIMessage({ content: 'predecessor output', id: 'run-ai-1' });
10
+ const producedIds = new Set(['run-ai-1']);
11
+ const isRunProduced = (message: BaseMessage): boolean =>
12
+ message.id != null && producedIds.has(message.id);
13
+
14
+ describe('appendPredecessorHandoffCue', () => {
15
+ it('appends the cue when the payload ends with a run-produced assistant turn', () => {
16
+ const payload = [new HumanMessage('ask'), runAi];
17
+ const result = appendPredecessorHandoffCue(payload, isRunProduced);
18
+ expect(result).toHaveLength(3);
19
+ expect(result[2].content).toBe(PREDECESSOR_HANDOFF_CUE);
20
+ expect(result[2].additional_kwargs).toEqual({
21
+ role: 'user',
22
+ isMeta: true,
23
+ source: 'handoff',
24
+ });
25
+ /** Non-mutating: the input array is untouched. */
26
+ expect(payload).toHaveLength(2);
27
+ });
28
+
29
+ it('matches a transformed clone of the run tail by id', () => {
30
+ const clone = new AIMessage({ content: 'projected copy', id: 'run-ai-1' });
31
+ expect(appendPredecessorHandoffCue([clone], isRunProduced)).toHaveLength(2);
32
+ });
33
+
34
+ /**
35
+ * Host-supplied trailing assistant turns are deliberate prefill (continue
36
+ * generation flows). The run has not produced them, so provenance never
37
+ * matches and the cue must stay off.
38
+ */
39
+ it('leaves host-history prefill payloads alone', () => {
40
+ const hostAi = new AIMessage({ content: 'host prefill', id: 'host-ai-9' });
41
+ expect(appendPredecessorHandoffCue([hostAi], isRunProduced)).toHaveLength(
42
+ 1
43
+ );
44
+ expect(appendPredecessorHandoffCue([hostAi], undefined)).toHaveLength(1);
45
+ });
46
+
47
+ it('is off when the payload does not end on an assistant turn', () => {
48
+ const tool = new ToolMessage({ content: 'r', tool_call_id: 't1' });
49
+ expect(
50
+ appendPredecessorHandoffCue([runAi, tool], isRunProduced)
51
+ ).toHaveLength(2);
52
+ expect(
53
+ appendPredecessorHandoffCue([new HumanMessage('hi')], isRunProduced)
54
+ ).toHaveLength(1);
55
+ expect(appendPredecessorHandoffCue([], isRunProduced)).toHaveLength(0);
56
+ });
57
+
58
+ it('fails safe when the tail carries no id', () => {
59
+ const noId = new AIMessage({ content: 'no id' });
60
+ expect(appendPredecessorHandoffCue([noId], isRunProduced)).toHaveLength(1);
61
+ });
62
+ });
63
+
64
+ describe('removePredecessorHandoffCue', () => {
65
+ const cue = new HumanMessage({
66
+ content: PREDECESSOR_HANDOFF_CUE,
67
+ additional_kwargs: { role: 'user', isMeta: true, source: 'handoff' },
68
+ });
69
+
70
+ it('strips a trailing cue and only a trailing cue', () => {
71
+ const stripped = removePredecessorHandoffCue([runAi, cue]);
72
+ expect(stripped).toHaveLength(1);
73
+ expect(stripped[0]).toBe(runAi);
74
+ });
75
+
76
+ it('is identity when no cue trails', () => {
77
+ const messages = [runAi];
78
+ expect(removePredecessorHandoffCue(messages)).toBe(messages);
79
+ const userTail = [runAi, new HumanMessage('real user turn')];
80
+ expect(removePredecessorHandoffCue(userTail)).toBe(userTail);
81
+ const empty: BaseMessage[] = [];
82
+ expect(removePredecessorHandoffCue(empty)).toBe(empty);
83
+ });
84
+
85
+ it('does not strip a user turn that merely repeats the cue text', () => {
86
+ const impostor = new HumanMessage({ content: PREDECESSOR_HANDOFF_CUE });
87
+ const messages = [runAi, impostor];
88
+ expect(removePredecessorHandoffCue(messages)).toBe(messages);
89
+ });
90
+
91
+ it('round-trips with append', () => {
92
+ const payload = [new HumanMessage('ask'), runAi];
93
+ const appended = appendPredecessorHandoffCue(payload, isRunProduced);
94
+ expect(removePredecessorHandoffCue(appended)).toEqual(payload);
95
+ });
96
+ });
@@ -0,0 +1,78 @@
1
+ // src/messages/handoffCue.ts
2
+ import { HumanMessage } from '@langchain/core/messages';
3
+ import type { BaseMessage } from '@langchain/core/messages';
4
+
5
+ /**
6
+ * Bracketed-meta convention, like the handoff path's
7
+ * `[Processed tool result and transferring to …]` bridge. The wording makes
8
+ * two things unambiguous to the model: the assistant turn above is FINISHED,
9
+ * and it belongs to a previous stage — so the successor answers as itself
10
+ * instead of continuing someone else's sentence.
11
+ */
12
+ export const PREDECESSOR_HANDOFF_CUE =
13
+ '[The assistant message above is the completed output of a previous ' +
14
+ 'agent. Respond now according to your own role and instructions.]';
15
+
16
+ /**
17
+ * Appends a user-turn handoff cue when a payload ends with an assistant turn
18
+ * that THIS RUN produced — which only happens when a different agent in a
19
+ * multi-agent workflow produced it (an agent's own self-loops always re-enter
20
+ * on a tool result or an injected user turn).
21
+ *
22
+ * Why: providers with prefill semantics (Anthropic, Bedrock-Claude) treat a
23
+ * trailing assistant message as a prefill and CONTINUE it. A bare direct-edge
24
+ * successor therefore speaks in its predecessor's voice — or, when the
25
+ * trailing turn reads complete (a preemption steer's short resume, say),
26
+ * returns empty content (danny-avila/agents#345, reproduced live 3/3).
27
+ * Handoff edges with instructions and prompt-instruction edges already break
28
+ * the prefill with a user turn; this closes the same gap for bare edges.
29
+ *
30
+ * Fail-safe OFF by provenance: the trailing payload message must be one the
31
+ * run itself produced (`isRunProduced`, backed by the graph's run-produced id
32
+ * set — immune to summarization compaction, which rewrites the live array
33
+ * and stales index-based boundaries). Host-supplied trailing assistant
34
+ * turns (deliberate prefill flows) never match — the run has not produced
35
+ * them — so single-agent prefill behavior is untouched. Wire-only: the cue is
36
+ * appended to the provider projection, never to graph state or host history.
37
+ */
38
+ export function appendPredecessorHandoffCue(
39
+ messages: BaseMessage[],
40
+ isRunProduced: ((message: BaseMessage) => boolean) | undefined
41
+ ): BaseMessage[] {
42
+ const last = messages.at(-1);
43
+ if (last == null || last.getType() !== 'ai') {
44
+ return messages;
45
+ }
46
+ if (isRunProduced == null || !isRunProduced(last)) {
47
+ return messages;
48
+ }
49
+ return [
50
+ ...messages,
51
+ new HumanMessage({
52
+ content: PREDECESSOR_HANDOFF_CUE,
53
+ additional_kwargs: { role: 'user', isMeta: true, source: 'handoff' },
54
+ }),
55
+ ];
56
+ }
57
+
58
+ /**
59
+ * Strips a trailing handoff cue. The counterpart for the serving-provider
60
+ * funnel: an Anthropic-like PRIMARY bakes the cue into its measured payload,
61
+ * and a tolerant fallback (OpenAI, Mistral, Bedrock-Nova) re-sending that
62
+ * payload must not ship the Claude-only synthetic turn. Identity on the
63
+ * no-op path.
64
+ */
65
+ export function removePredecessorHandoffCue(
66
+ messages: BaseMessage[]
67
+ ): BaseMessage[] {
68
+ const last = messages.at(-1);
69
+ if (
70
+ last == null ||
71
+ last.getType() !== 'human' ||
72
+ last.additional_kwargs.source !== 'handoff' ||
73
+ last.content !== PREDECESSOR_HANDOFF_CUE
74
+ ) {
75
+ return messages;
76
+ }
77
+ return messages.slice(0, -1);
78
+ }
@@ -7,6 +7,9 @@ export * from './cache';
7
7
  export * from './anthropicToolCache';
8
8
  export * from './content';
9
9
  export * from './tools';
10
+ export * from './injected';
11
+ export * from './alternation';
12
+ export * from './handoffCue';
10
13
  export * from './contextPruning';
11
14
  export * from './contextPruningSettings';
12
15
  export * from './reducer';
@@ -0,0 +1,90 @@
1
+ import { HumanMessage } from '@langchain/core/messages';
2
+ import type { InjectedMessage } from '@/types/tools';
3
+ import { convertInjectedMessages } from './injected';
4
+
5
+ describe('convertInjectedMessages', () => {
6
+ it('returns an empty array for no entries', () => {
7
+ expect(convertInjectedMessages([])).toEqual([]);
8
+ });
9
+
10
+ it('emits one HumanMessage per entry, in order', () => {
11
+ const messages: InjectedMessage[] = [
12
+ { role: 'user', content: 'first' },
13
+ { role: 'user', content: 'second' },
14
+ ];
15
+ const converted = convertInjectedMessages(messages);
16
+ expect(converted).toHaveLength(2);
17
+ expect(converted[0]).toBeInstanceOf(HumanMessage);
18
+ expect(converted[0].content).toBe('first');
19
+ expect(converted[1].content).toBe('second');
20
+ });
21
+
22
+ /**
23
+ * Both 'user' and 'system' become `HumanMessage` — Anthropic and Google
24
+ * reject a non-leading SystemMessage — with the original role preserved for
25
+ * downstream consumers.
26
+ */
27
+ it('converts a system role to a HumanMessage carrying the original role', () => {
28
+ const [converted] = convertInjectedMessages([
29
+ { role: 'system', content: 'convention reminder' },
30
+ ]);
31
+ expect(converted).toBeInstanceOf(HumanMessage);
32
+ expect(converted.additional_kwargs.role).toBe('system');
33
+ });
34
+
35
+ it('carries isMeta, source and skillName only when set', () => {
36
+ const [bare] = convertInjectedMessages([{ role: 'user', content: 'x' }]);
37
+ expect(bare.additional_kwargs).toEqual({ role: 'user' });
38
+
39
+ const [full] = convertInjectedMessages([
40
+ {
41
+ role: 'user',
42
+ content: 'x',
43
+ isMeta: true,
44
+ source: 'steer',
45
+ skillName: 'writing',
46
+ },
47
+ ]);
48
+ expect(full.additional_kwargs).toEqual({
49
+ role: 'user',
50
+ isMeta: true,
51
+ source: 'steer',
52
+ skillName: 'writing',
53
+ });
54
+ });
55
+
56
+ it('passes multimodal content through as a content array', () => {
57
+ const [converted] = convertInjectedMessages([
58
+ {
59
+ role: 'user',
60
+ content: [
61
+ { type: 'text', text: 'look at this' },
62
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,AA' } },
63
+ ],
64
+ },
65
+ ]);
66
+ expect(Array.isArray(converted.content)).toBe(true);
67
+ expect(converted.content).toHaveLength(2);
68
+ });
69
+
70
+ /**
71
+ * The provider-safety argument for sealing a stream mid-generation rests on
72
+ * the preempt boundary emitting exactly what the already-shipped tool
73
+ * boundary emits. Both call this function, so shape parity is structural —
74
+ * this pins it against a future divergence.
75
+ */
76
+ it('produces identical shapes for the same entry across calls', () => {
77
+ const entry: InjectedMessage = {
78
+ role: 'user',
79
+ content: 'Skip phase two.',
80
+ source: 'steer',
81
+ };
82
+ const [atToolBoundary] = convertInjectedMessages([entry]);
83
+ const [atPreemptBoundary] = convertInjectedMessages([entry]);
84
+ expect(atPreemptBoundary.content).toEqual(atToolBoundary.content);
85
+ expect(atPreemptBoundary.additional_kwargs).toEqual(
86
+ atToolBoundary.additional_kwargs
87
+ );
88
+ expect(atPreemptBoundary.getType()).toBe(atToolBoundary.getType());
89
+ });
90
+ });
@@ -0,0 +1,74 @@
1
+ // src/messages/injected.ts
2
+ import { HumanMessage } from '@langchain/core/messages';
3
+ import type { BaseMessage } from '@langchain/core/messages';
4
+ import type { InjectedMessage } from '@/types/tools';
5
+ import { ContentTypes } from '@/common';
6
+ import { toLangChainContent } from './langchain';
7
+
8
+ /**
9
+ * Converts `InjectedMessage` instances to LangChain `HumanMessage` objects.
10
+ * Both 'user' and 'system' roles become `HumanMessage` to avoid provider
11
+ * rejections (Anthropic/Google reject non-leading SystemMessages). The
12
+ * original role is preserved in `additional_kwargs` for downstream consumers.
13
+ *
14
+ * Shared by both injection boundaries — `ToolNode`'s tool-batch dispatch and
15
+ * `StandardGraph`'s preempt-boundary dispatch. Keeping one implementation is
16
+ * load-bearing rather than tidy: the provider-safety argument for sealing a
17
+ * stream mid-generation rests on the injected turn having byte-identical
18
+ * shape to the one the already-shipped tool boundary emits, so the two sites
19
+ * must not be able to drift.
20
+ */
21
+ /**
22
+ * True when an entry carries nothing a provider will accept as a turn.
23
+ *
24
+ * The public `InjectedMessage` type admits `content: ''` and `content: []`,
25
+ * and a host hook is free to return one. Converting it anyway produces a
26
+ * `HumanMessage` that passes the caller's `length > 0` test, so the graph
27
+ * resumes and sends a trailing EMPTY user turn — which Anthropic and other
28
+ * strict providers reject outright, turning a cooperative seal into a failed
29
+ * run. Permissive providers merely burn a model call.
30
+ *
31
+ * Whitespace counts as empty, matching the standard `canSealPreempt` applies
32
+ * to the assistant side of the same pair. Non-text blocks count as content: a
33
+ * media-only steer is a real turn and must survive.
34
+ */
35
+ function isEmptyInjectedContent(content: InjectedMessage['content']): boolean {
36
+ if (typeof content === 'string') {
37
+ return content.trim() === '';
38
+ }
39
+ if (content.length === 0) {
40
+ return true;
41
+ }
42
+ return content.every((block) => {
43
+ if (block.type !== ContentTypes.TEXT) {
44
+ return false;
45
+ }
46
+ const text = block[ContentTypes.TEXT];
47
+ return typeof text !== 'string' || text.trim() === '';
48
+ });
49
+ }
50
+
51
+ export function convertInjectedMessages(
52
+ messages: InjectedMessage[]
53
+ ): BaseMessage[] {
54
+ const converted: BaseMessage[] = [];
55
+ for (const msg of messages) {
56
+ if (isEmptyInjectedContent(msg.content)) {
57
+ continue;
58
+ }
59
+ const additional_kwargs: Record<string, unknown> = {
60
+ role: msg.role,
61
+ };
62
+ if (msg.isMeta != null) additional_kwargs.isMeta = msg.isMeta;
63
+ if (msg.source != null) additional_kwargs.source = msg.source;
64
+ if (msg.skillName != null) additional_kwargs.skillName = msg.skillName;
65
+
66
+ converted.push(
67
+ new HumanMessage({
68
+ content: toLangChainContent(msg.content),
69
+ additional_kwargs,
70
+ })
71
+ );
72
+ }
73
+ return converted;
74
+ }