@librechat/agents 3.3.4 → 3.3.6

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 (263) hide show
  1. package/dist/cjs/common/constants.cjs +21 -0
  2. package/dist/cjs/common/constants.cjs.map +1 -1
  3. package/dist/cjs/events.cjs +10 -1
  4. package/dist/cjs/events.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +456 -7
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/hooks/HookRegistry.cjs +25 -0
  8. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  9. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  10. package/dist/cjs/hooks/index.cjs +12 -0
  11. package/dist/cjs/hooks/index.cjs.map +1 -1
  12. package/dist/cjs/hooks/types.cjs +1 -0
  13. package/dist/cjs/hooks/types.cjs.map +1 -1
  14. package/dist/cjs/langfuse.cjs +8 -0
  15. package/dist/cjs/langfuse.cjs.map +1 -1
  16. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +1 -3
  17. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  18. package/dist/cjs/llm/invoke.cjs +268 -3
  19. package/dist/cjs/llm/invoke.cjs.map +1 -1
  20. package/dist/cjs/llm/openai/index.cjs +51 -6
  21. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  22. package/dist/cjs/llm/preempt.cjs +132 -0
  23. package/dist/cjs/llm/preempt.cjs.map +1 -0
  24. package/dist/cjs/main.cjs +31 -3
  25. package/dist/cjs/messages/alternation.cjs +92 -0
  26. package/dist/cjs/messages/alternation.cjs.map +1 -0
  27. package/dist/cjs/messages/format.cjs +72 -0
  28. package/dist/cjs/messages/format.cjs.map +1 -1
  29. package/dist/cjs/messages/handoffCue.cjs +63 -0
  30. package/dist/cjs/messages/handoffCue.cjs.map +1 -0
  31. package/dist/cjs/messages/index.cjs +3 -0
  32. package/dist/cjs/messages/injected.cjs +60 -0
  33. package/dist/cjs/messages/injected.cjs.map +1 -0
  34. package/dist/cjs/run.cjs +80 -7
  35. package/dist/cjs/run.cjs.map +1 -1
  36. package/dist/cjs/session/JsonlSessionStore.cjs +1 -1
  37. package/dist/cjs/session/handlers.cjs +18 -9
  38. package/dist/cjs/session/handlers.cjs.map +1 -1
  39. package/dist/cjs/stream.cjs +34 -8
  40. package/dist/cjs/stream.cjs.map +1 -1
  41. package/dist/cjs/tools/BashExecutor.cjs +3 -1
  42. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  43. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -0
  44. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  45. package/dist/cjs/tools/CodeExecutor.cjs +19 -16
  46. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  47. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +2 -0
  48. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  49. package/dist/cjs/tools/ReadFile.cjs +8 -4
  50. package/dist/cjs/tools/ReadFile.cjs.map +1 -1
  51. package/dist/cjs/tools/SkillTool.cjs +2 -0
  52. package/dist/cjs/tools/SkillTool.cjs.map +1 -1
  53. package/dist/cjs/tools/SubagentTool.cjs +3 -0
  54. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  55. package/dist/cjs/tools/ToolNode.cjs +91 -50
  56. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  57. package/dist/cjs/tools/ToolSearch.cjs +5 -1
  58. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  59. package/dist/cjs/tools/intentArg.cjs +230 -0
  60. package/dist/cjs/tools/intentArg.cjs.map +1 -0
  61. package/dist/cjs/tools/local/CompileCheckTool.cjs +3 -2
  62. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
  63. package/dist/cjs/tools/local/LocalCodingTools.cjs +13 -12
  64. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
  65. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +2 -2
  66. package/dist/cjs/tools/ptcTimeout.cjs.map +1 -1
  67. package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
  68. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
  69. package/dist/cjs/tools/search/schema.cjs +2 -0
  70. package/dist/cjs/tools/search/schema.cjs.map +1 -1
  71. package/dist/cjs/tools/search/tool.cjs +40 -2
  72. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  73. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  74. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  75. package/dist/cjs/tools/toolOutputReferences.cjs +41 -7
  76. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  77. package/dist/esm/common/constants.mjs +19 -1
  78. package/dist/esm/common/constants.mjs.map +1 -1
  79. package/dist/esm/events.mjs +10 -1
  80. package/dist/esm/events.mjs.map +1 -1
  81. package/dist/esm/graphs/Graph.mjs +455 -6
  82. package/dist/esm/graphs/Graph.mjs.map +1 -1
  83. package/dist/esm/hooks/HookRegistry.mjs +25 -0
  84. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  85. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  86. package/dist/esm/hooks/index.mjs +12 -1
  87. package/dist/esm/hooks/index.mjs.map +1 -1
  88. package/dist/esm/hooks/types.mjs +1 -0
  89. package/dist/esm/hooks/types.mjs.map +1 -1
  90. package/dist/esm/langfuse.mjs +9 -1
  91. package/dist/esm/langfuse.mjs.map +1 -1
  92. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +1 -3
  93. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  94. package/dist/esm/llm/invoke.mjs +270 -6
  95. package/dist/esm/llm/invoke.mjs.map +1 -1
  96. package/dist/esm/llm/openai/index.mjs +51 -6
  97. package/dist/esm/llm/openai/index.mjs.map +1 -1
  98. package/dist/esm/llm/preempt.mjs +131 -0
  99. package/dist/esm/llm/preempt.mjs.map +1 -0
  100. package/dist/esm/main.mjs +11 -7
  101. package/dist/esm/messages/alternation.mjs +91 -0
  102. package/dist/esm/messages/alternation.mjs.map +1 -0
  103. package/dist/esm/messages/format.mjs +72 -0
  104. package/dist/esm/messages/format.mjs.map +1 -1
  105. package/dist/esm/messages/handoffCue.mjs +61 -0
  106. package/dist/esm/messages/handoffCue.mjs.map +1 -0
  107. package/dist/esm/messages/index.mjs +3 -0
  108. package/dist/esm/messages/injected.mjs +60 -0
  109. package/dist/esm/messages/injected.mjs.map +1 -0
  110. package/dist/esm/run.mjs +80 -7
  111. package/dist/esm/run.mjs.map +1 -1
  112. package/dist/esm/session/JsonlSessionStore.mjs +1 -1
  113. package/dist/esm/session/handlers.mjs +19 -10
  114. package/dist/esm/session/handlers.mjs.map +1 -1
  115. package/dist/esm/stream.mjs +33 -9
  116. package/dist/esm/stream.mjs.map +1 -1
  117. package/dist/esm/tools/BashExecutor.mjs +3 -1
  118. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  119. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +2 -0
  120. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  121. package/dist/esm/tools/CodeExecutor.mjs +19 -16
  122. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  123. package/dist/esm/tools/ProgrammaticToolCalling.mjs +2 -0
  124. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  125. package/dist/esm/tools/ReadFile.mjs +8 -4
  126. package/dist/esm/tools/ReadFile.mjs.map +1 -1
  127. package/dist/esm/tools/SkillTool.mjs +2 -0
  128. package/dist/esm/tools/SkillTool.mjs.map +1 -1
  129. package/dist/esm/tools/SubagentTool.mjs +3 -0
  130. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  131. package/dist/esm/tools/ToolNode.mjs +91 -51
  132. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  133. package/dist/esm/tools/ToolSearch.mjs +5 -1
  134. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  135. package/dist/esm/tools/intentArg.mjs +220 -0
  136. package/dist/esm/tools/intentArg.mjs.map +1 -0
  137. package/dist/esm/tools/local/CompileCheckTool.mjs +3 -2
  138. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
  139. package/dist/esm/tools/local/LocalCodingTools.mjs +13 -12
  140. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
  141. package/dist/esm/tools/local/LocalExecutionEngine.mjs +2 -2
  142. package/dist/esm/tools/ptcTimeout.mjs.map +1 -1
  143. package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
  144. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
  145. package/dist/esm/tools/search/schema.mjs +2 -0
  146. package/dist/esm/tools/search/schema.mjs.map +1 -1
  147. package/dist/esm/tools/search/tool.mjs +40 -3
  148. package/dist/esm/tools/search/tool.mjs.map +1 -1
  149. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  150. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  151. package/dist/esm/tools/toolOutputReferences.mjs +41 -7
  152. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  153. package/dist/types/common/constants.d.ts +18 -0
  154. package/dist/types/graphs/Graph.d.ts +138 -1
  155. package/dist/types/hooks/HookRegistry.d.ts +15 -0
  156. package/dist/types/hooks/index.d.ts +12 -1
  157. package/dist/types/hooks/types.d.ts +45 -6
  158. package/dist/types/index.d.ts +2 -1
  159. package/dist/types/llm/invoke.d.ts +7 -0
  160. package/dist/types/llm/openai/index.d.ts +0 -11
  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/handoffCue.d.ts +40 -0
  164. package/dist/types/messages/index.d.ts +3 -0
  165. package/dist/types/messages/injected.d.ts +3 -0
  166. package/dist/types/run.d.ts +7 -0
  167. package/dist/types/stream.d.ts +14 -0
  168. package/dist/types/tools/BashExecutor.d.ts +18 -0
  169. package/dist/types/tools/CodeExecutor.d.ts +18 -0
  170. package/dist/types/tools/ReadFile.d.ts +18 -0
  171. package/dist/types/tools/SkillTool.d.ts +18 -0
  172. package/dist/types/tools/SubagentTool.d.ts +9 -0
  173. package/dist/types/tools/ToolNode.d.ts +37 -12
  174. package/dist/types/tools/ToolSearch.d.ts +18 -0
  175. package/dist/types/tools/intentArg.d.ts +119 -0
  176. package/dist/types/tools/ptcTimeout.d.ts +2 -0
  177. package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
  178. package/dist/types/tools/search/schema.d.ts +18 -0
  179. package/dist/types/tools/search/tool.d.ts +18 -0
  180. package/dist/types/tools/search/types.d.ts +31 -2
  181. package/dist/types/tools/toolOutputReferences.d.ts +12 -2
  182. package/dist/types/types/graph.d.ts +8 -1
  183. package/dist/types/types/run.d.ts +65 -0
  184. package/dist/types/types/stream.d.ts +12 -25
  185. package/dist/types/types/tools.d.ts +27 -2
  186. package/package.json +1 -1
  187. package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
  188. package/src/common/constants.ts +21 -0
  189. package/src/events.ts +15 -1
  190. package/src/graphs/Graph.ts +568 -3
  191. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +18 -7
  192. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
  193. package/src/hooks/HookRegistry.ts +40 -0
  194. package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
  195. package/src/hooks/index.ts +16 -2
  196. package/src/hooks/types.ts +47 -3
  197. package/src/index.ts +2 -1
  198. package/src/langfuse.ts +26 -1
  199. package/src/llm/bedrock/utils/message_inputs.test.ts +82 -0
  200. package/src/llm/bedrock/utils/message_inputs.ts +14 -11
  201. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
  202. package/src/llm/invoke.alternation.test.ts +87 -0
  203. package/src/llm/invoke.handoffCue.test.ts +112 -0
  204. package/src/llm/invoke.ts +417 -8
  205. package/src/llm/openai/index.ts +74 -10
  206. package/src/llm/openai/llm.spec.ts +105 -0
  207. package/src/llm/preempt.test.ts +323 -0
  208. package/src/llm/preempt.ts +178 -0
  209. package/src/messages/alternation.test.ts +212 -0
  210. package/src/messages/alternation.ts +112 -0
  211. package/src/messages/format.ts +91 -0
  212. package/src/messages/formatAgentMessages.steer.test.ts +267 -0
  213. package/src/messages/handoffCue.test.ts +96 -0
  214. package/src/messages/handoffCue.ts +78 -0
  215. package/src/messages/index.ts +3 -0
  216. package/src/messages/injected.test.ts +90 -0
  217. package/src/messages/injected.ts +74 -0
  218. package/src/run.ts +91 -6
  219. package/src/scripts/preempt-probe.ts +330 -0
  220. package/src/scripts/preempt-scenarios.ts +388 -0
  221. package/src/session/handlers.ts +32 -12
  222. package/src/specs/handoffCue.test.ts +165 -0
  223. package/src/specs/langfuse-callbacks.test.ts +352 -2
  224. package/src/specs/preemptSeal.test.ts +309 -0
  225. package/src/stream.dispatch.test.ts +63 -0
  226. package/src/stream.ts +48 -9
  227. package/src/tools/BashExecutor.ts +6 -0
  228. package/src/tools/BashProgrammaticToolCalling.ts +2 -0
  229. package/src/tools/CodeExecutor.ts +6 -0
  230. package/src/tools/ProgrammaticToolCalling.ts +2 -0
  231. package/src/tools/ReadFile.ts +2 -0
  232. package/src/tools/SkillTool.ts +2 -0
  233. package/src/tools/SubagentTool.ts +3 -0
  234. package/src/tools/ToolNode.ts +193 -74
  235. package/src/tools/ToolSearch.ts +3 -0
  236. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +174 -0
  237. package/src/tools/__tests__/intentArg.test.ts +281 -0
  238. package/src/tools/__tests__/intentCoverage.test.ts +139 -0
  239. package/src/tools/__tests__/toolOutputReferences.test.ts +83 -0
  240. package/src/tools/intentArg.ts +323 -0
  241. package/src/tools/local/CompileCheckTool.ts +4 -3
  242. package/src/tools/local/LocalCodingTools.ts +14 -13
  243. package/src/tools/ptcTimeout.ts +2 -0
  244. package/src/tools/search/keenable-scraper.test.ts +153 -0
  245. package/src/tools/search/keenable-scraper.ts +137 -0
  246. package/src/tools/search/outcome.test.ts +90 -0
  247. package/src/tools/search/schema.ts +3 -0
  248. package/src/tools/search/tool.ts +60 -3
  249. package/src/tools/search/types.ts +50 -3
  250. package/src/tools/subagent/SubagentExecutor.ts +2 -1
  251. package/src/tools/toolOutputReferences.ts +77 -9
  252. package/src/types/graph.ts +12 -1
  253. package/src/types/run.ts +67 -0
  254. package/src/types/stream.ts +12 -41
  255. package/src/types/tools.ts +24 -2
  256. package/dist/cjs/splitStream.cjs +0 -151
  257. package/dist/cjs/splitStream.cjs.map +0 -1
  258. package/dist/esm/splitStream.mjs +0 -150
  259. package/dist/esm/splitStream.mjs.map +0 -1
  260. package/dist/types/mockStream.d.ts +0 -32
  261. package/dist/types/splitStream.d.ts +0 -37
  262. package/src/mockStream.ts +0 -99
  263. package/src/splitStream.ts +0 -234
@@ -1,5 +1,6 @@
1
1
  import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
2
2
  import type { TPayload } from '@/types';
3
+ import { _convertMessagesToAnthropicPayload } from '@/llm/anthropic/utils/message_inputs';
3
4
  import { ContentTypes, Constants, Providers } from '@/common';
4
5
  import { formatAgentMessages } from './format';
5
6
 
@@ -324,3 +325,269 @@ describe('formatAgentMessages steer replay', () => {
324
325
  ]);
325
326
  });
326
327
  });
328
+
329
+ describe('formatAgentMessages trailing-steer anchor', () => {
330
+ const steerPart = (text: string): Record<string, unknown> =>
331
+ ({
332
+ type: ContentTypes.STEER,
333
+ [ContentTypes.STEER]: text,
334
+ }) as unknown as Record<string, unknown>;
335
+
336
+ /**
337
+ * A steer that ends an assistant message leaves the replay on a
338
+ * HumanMessage. Followed by the next turn's user message, that reaches the
339
+ * provider as two adjacent user turns — a hard error on Bedrock and
340
+ * Mistral. Reachable today through abort, not only through preemption.
341
+ */
342
+ it('anchors a trailing steer when another turn follows', () => {
343
+ const payload: TPayload = [
344
+ { role: 'user', content: 'Original request' },
345
+ {
346
+ role: 'assistant',
347
+ content: [
348
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Working on it.' },
349
+ steerPart('Actually, stop and do the other thing.'),
350
+ ],
351
+ },
352
+ { role: 'user', content: 'Next turn' },
353
+ ];
354
+
355
+ const { messages } = formatAgentMessages(payload);
356
+
357
+ const steerIndex = messages.findIndex(
358
+ (message) =>
359
+ message instanceof HumanMessage &&
360
+ message.additional_kwargs.source === 'steer'
361
+ );
362
+ expect(steerIndex).toBeGreaterThan(0);
363
+
364
+ const anchor = messages[steerIndex + 1];
365
+ expect(anchor).toBeInstanceOf(AIMessage);
366
+ expect(anchor.content).not.toBe('');
367
+
368
+ const following = messages[steerIndex + 2];
369
+ expect(following).toBeInstanceOf(HumanMessage);
370
+ expect(following.content).toEqual([{ type: 'text', text: 'Next turn' }]);
371
+ });
372
+
373
+ /**
374
+ * `messagesStateReducer` treats a repeated id as replace-in-place, so an
375
+ * anchor stamped with the shared `sourceMessageId` would overwrite the very
376
+ * steer it exists to protect. It must reach the reducer unstamped.
377
+ */
378
+ it('does not stamp the anchor with the shared source message id', () => {
379
+ const payload: TPayload = [
380
+ { role: 'user', content: 'Original request' },
381
+ {
382
+ messageId: 'assistant_1',
383
+ role: 'assistant',
384
+ content: [
385
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Partial answer.' },
386
+ steerPart('Change of plan.'),
387
+ ],
388
+ } as unknown as TPayload[number],
389
+ { role: 'user', content: 'Next turn' },
390
+ ];
391
+
392
+ const { messages } = formatAgentMessages(payload);
393
+
394
+ const steerIndex = messages.findIndex(
395
+ (message) => message.additional_kwargs.source === 'steer'
396
+ );
397
+ expect(steerIndex).toBeGreaterThan(0);
398
+ expect(messages[steerIndex].id).toBe('assistant_1');
399
+ expect(messages[steerIndex - 1].id).toBe('assistant_1');
400
+
401
+ const anchor = messages[steerIndex + 1];
402
+ expect(anchor).toBeInstanceOf(AIMessage);
403
+ expect(anchor.content).not.toBe('');
404
+ expect(anchor.id).not.toBe('assistant_1');
405
+ });
406
+
407
+ /**
408
+ * The anchor exists to keep the sequence provider-valid, so the assertion
409
+ * that matters is what a provider actually receives. An empty-string
410
+ * assistant turn with no tool calls survives the Anthropic converter
411
+ * verbatim — the empty-text repair covers only array content and tool-call
412
+ * turns — so an empty anchor would trade adjacent user turns for an
413
+ * empty-content 400.
414
+ */
415
+ it('survives the Anthropic converter with non-empty assistant content', () => {
416
+ const payload: TPayload = [
417
+ { role: 'user', content: 'Original request' },
418
+ {
419
+ role: 'assistant',
420
+ content: [
421
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Partial answer.' },
422
+ steerPart('Change of plan.'),
423
+ ],
424
+ },
425
+ { role: 'user', content: 'Next turn' },
426
+ ];
427
+
428
+ const { messages } = formatAgentMessages(payload);
429
+ const { messages: anthropicMessages } =
430
+ _convertMessagesToAnthropicPayload(messages);
431
+
432
+ for (const message of anthropicMessages) {
433
+ if (message.role !== 'assistant') {
434
+ continue;
435
+ }
436
+ if (typeof message.content === 'string') {
437
+ expect(message.content.trim()).not.toBe('');
438
+ continue;
439
+ }
440
+ expect(message.content.length).toBeGreaterThan(0);
441
+ }
442
+
443
+ const roles = anthropicMessages.map((message) => message.role);
444
+ for (let i = 1; i < roles.length; i++) {
445
+ expect(roles[i] === 'user' && roles[i - 1] === 'user').toBe(false);
446
+ }
447
+ });
448
+
449
+ /**
450
+ * The old lookahead only proved a later ENTRY existed, not that it emitted.
451
+ * An entry with empty content is skipped silently, so a trailing steer
452
+ * followed only by such entries would leave the anchor as the final turn —
453
+ * an assistant prefill with no request after it.
454
+ */
455
+ it('omits the anchor when no later payload entry emits a message', () => {
456
+ const payload: TPayload = [
457
+ { role: 'user', content: 'Original request' },
458
+ {
459
+ role: 'assistant',
460
+ content: [
461
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Partial answer.' },
462
+ steerPart('Change of plan.'),
463
+ ],
464
+ },
465
+ { role: 'user', content: [] } as unknown as TPayload[number],
466
+ ];
467
+
468
+ const { messages } = formatAgentMessages(payload);
469
+ const last = messages[messages.length - 1];
470
+ expect(last.additional_kwargs.source).toBe('steer');
471
+ expect(
472
+ messages.some(
473
+ (m) => m instanceof AIMessage && m.content === '_'
474
+ )
475
+ ).toBe(false);
476
+ });
477
+
478
+ it('leaves the user turn last when the steer ends the payload', () => {
479
+ const payload: TPayload = [
480
+ { role: 'user', content: 'Original request' },
481
+ {
482
+ role: 'assistant',
483
+ content: [
484
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Working on it.' },
485
+ steerPart('Actually, stop and do the other thing.'),
486
+ ],
487
+ },
488
+ ];
489
+
490
+ const { messages } = formatAgentMessages(payload);
491
+
492
+ const last = messages[messages.length - 1];
493
+ expect(last).toBeInstanceOf(HumanMessage);
494
+ expect(last.additional_kwargs.source).toBe('steer');
495
+ });
496
+
497
+ it('does not anchor when assistant content follows the steer', () => {
498
+ const payload: TPayload = [
499
+ { role: 'user', content: 'Original request' },
500
+ {
501
+ role: 'assistant',
502
+ content: [
503
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Working on it.' },
504
+ steerPart('Focus on item two.'),
505
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Focusing.' },
506
+ ],
507
+ },
508
+ { role: 'user', content: 'Next turn' },
509
+ ];
510
+
511
+ const { messages } = formatAgentMessages(payload);
512
+
513
+ const emptyAssistants = messages.filter(
514
+ (message) => message instanceof AIMessage && message.content === ''
515
+ );
516
+ expect(emptyAssistants).toHaveLength(0);
517
+ });
518
+
519
+ /**
520
+ * A trailing steer followed by another assistant ENTRY needs no anchor —
521
+ * that entry's own assistant turn IS the separation. Emitting the
522
+ * placeholder anyway would put two assistant turns back to back, which
523
+ * strict-alternation providers can reject and nothing downstream merges
524
+ * (`coalesceAdjacentUserTurns` merges user turns only).
525
+ */
526
+ it('suppresses the anchor when the next payload entry is an assistant turn', () => {
527
+ const payload: TPayload = [
528
+ { role: 'user', content: 'Original request' },
529
+ {
530
+ role: 'assistant',
531
+ content: [
532
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Working on it.' },
533
+ steerPart('Actually, do the other thing.'),
534
+ ],
535
+ },
536
+ {
537
+ role: 'assistant',
538
+ content: [
539
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Doing the other thing.' },
540
+ ],
541
+ },
542
+ ];
543
+
544
+ const { messages } = formatAgentMessages(payload);
545
+
546
+ expect(messages.map((message) => message.constructor.name)).toEqual([
547
+ 'HumanMessage',
548
+ 'AIMessage',
549
+ 'HumanMessage',
550
+ 'AIMessage',
551
+ ]);
552
+ expect(messages[2].additional_kwargs.source).toBe('steer');
553
+ expect(messages[3].content).toEqual([
554
+ { type: 'text', text: 'Doing the other thing.' },
555
+ ]);
556
+ expect(
557
+ messages.some(
558
+ (message) => message instanceof AIMessage && message.content === '_'
559
+ )
560
+ ).toBe(false);
561
+ });
562
+
563
+ it('never emits two adjacent user turns around a trailing steer', () => {
564
+ const payload: TPayload = [
565
+ { role: 'user', content: 'Original request' },
566
+ {
567
+ role: 'assistant',
568
+ content: [
569
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Partial answer.' },
570
+ steerPart('Change of plan.'),
571
+ ],
572
+ },
573
+ { role: 'user', content: 'Next turn' },
574
+ {
575
+ role: 'assistant',
576
+ content: [
577
+ { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Second answer.' },
578
+ steerPart('Change again.'),
579
+ ],
580
+ },
581
+ { role: 'user', content: 'Third turn' },
582
+ ];
583
+
584
+ const { messages } = formatAgentMessages(payload);
585
+
586
+ for (let i = 1; i < messages.length; i++) {
587
+ const adjacentHumans =
588
+ messages[i] instanceof HumanMessage &&
589
+ messages[i - 1] instanceof HumanMessage;
590
+ expect(adjacentHumans).toBe(false);
591
+ }
592
+ });
593
+ });
@@ -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
+ }