@librechat/agents 3.3.4 → 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 (159) 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/index.cjs +12 -0
  10. package/dist/cjs/hooks/index.cjs.map +1 -1
  11. package/dist/cjs/hooks/types.cjs +1 -0
  12. package/dist/cjs/hooks/types.cjs.map +1 -1
  13. package/dist/cjs/langfuse.cjs +8 -0
  14. package/dist/cjs/langfuse.cjs.map +1 -1
  15. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +1 -3
  16. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  17. package/dist/cjs/llm/invoke.cjs +268 -3
  18. package/dist/cjs/llm/invoke.cjs.map +1 -1
  19. package/dist/cjs/llm/preempt.cjs +132 -0
  20. package/dist/cjs/llm/preempt.cjs.map +1 -0
  21. package/dist/cjs/main.cjs +17 -3
  22. package/dist/cjs/messages/alternation.cjs +92 -0
  23. package/dist/cjs/messages/alternation.cjs.map +1 -0
  24. package/dist/cjs/messages/format.cjs +72 -0
  25. package/dist/cjs/messages/format.cjs.map +1 -1
  26. package/dist/cjs/messages/handoffCue.cjs +63 -0
  27. package/dist/cjs/messages/handoffCue.cjs.map +1 -0
  28. package/dist/cjs/messages/index.cjs +3 -0
  29. package/dist/cjs/messages/injected.cjs +60 -0
  30. package/dist/cjs/messages/injected.cjs.map +1 -0
  31. package/dist/cjs/run.cjs +80 -7
  32. package/dist/cjs/run.cjs.map +1 -1
  33. package/dist/cjs/session/handlers.cjs +18 -9
  34. package/dist/cjs/session/handlers.cjs.map +1 -1
  35. package/dist/cjs/stream.cjs +28 -7
  36. package/dist/cjs/stream.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +3 -23
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
  40. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
  41. package/dist/cjs/tools/search/tool.cjs +9 -1
  42. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  43. package/dist/esm/common/constants.mjs +19 -1
  44. package/dist/esm/common/constants.mjs.map +1 -1
  45. package/dist/esm/events.mjs +10 -1
  46. package/dist/esm/events.mjs.map +1 -1
  47. package/dist/esm/graphs/Graph.mjs +455 -6
  48. package/dist/esm/graphs/Graph.mjs.map +1 -1
  49. package/dist/esm/hooks/HookRegistry.mjs +25 -0
  50. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  51. package/dist/esm/hooks/index.mjs +12 -1
  52. package/dist/esm/hooks/index.mjs.map +1 -1
  53. package/dist/esm/hooks/types.mjs +1 -0
  54. package/dist/esm/hooks/types.mjs.map +1 -1
  55. package/dist/esm/langfuse.mjs +9 -1
  56. package/dist/esm/langfuse.mjs.map +1 -1
  57. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +1 -3
  58. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  59. package/dist/esm/llm/invoke.mjs +270 -6
  60. package/dist/esm/llm/invoke.mjs.map +1 -1
  61. package/dist/esm/llm/preempt.mjs +131 -0
  62. package/dist/esm/llm/preempt.mjs.map +1 -0
  63. package/dist/esm/main.mjs +8 -5
  64. package/dist/esm/messages/alternation.mjs +91 -0
  65. package/dist/esm/messages/alternation.mjs.map +1 -0
  66. package/dist/esm/messages/format.mjs +72 -0
  67. package/dist/esm/messages/format.mjs.map +1 -1
  68. package/dist/esm/messages/handoffCue.mjs +61 -0
  69. package/dist/esm/messages/handoffCue.mjs.map +1 -0
  70. package/dist/esm/messages/index.mjs +3 -0
  71. package/dist/esm/messages/injected.mjs +60 -0
  72. package/dist/esm/messages/injected.mjs.map +1 -0
  73. package/dist/esm/run.mjs +80 -7
  74. package/dist/esm/run.mjs.map +1 -1
  75. package/dist/esm/session/handlers.mjs +19 -10
  76. package/dist/esm/session/handlers.mjs.map +1 -1
  77. package/dist/esm/stream.mjs +27 -8
  78. package/dist/esm/stream.mjs.map +1 -1
  79. package/dist/esm/tools/ToolNode.mjs +3 -23
  80. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  81. package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
  82. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
  83. package/dist/esm/tools/search/tool.mjs +9 -1
  84. package/dist/esm/tools/search/tool.mjs.map +1 -1
  85. package/dist/types/common/constants.d.ts +18 -0
  86. package/dist/types/graphs/Graph.d.ts +138 -1
  87. package/dist/types/hooks/HookRegistry.d.ts +15 -0
  88. package/dist/types/hooks/index.d.ts +12 -1
  89. package/dist/types/hooks/types.d.ts +45 -6
  90. package/dist/types/index.d.ts +1 -1
  91. package/dist/types/llm/invoke.d.ts +7 -0
  92. package/dist/types/llm/preempt.d.ts +40 -0
  93. package/dist/types/messages/alternation.d.ts +23 -0
  94. package/dist/types/messages/handoffCue.d.ts +40 -0
  95. package/dist/types/messages/index.d.ts +3 -0
  96. package/dist/types/messages/injected.d.ts +3 -0
  97. package/dist/types/run.d.ts +7 -0
  98. package/dist/types/stream.d.ts +14 -0
  99. package/dist/types/tools/ToolNode.d.ts +0 -7
  100. package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
  101. package/dist/types/tools/search/types.d.ts +31 -2
  102. package/dist/types/types/graph.d.ts +8 -1
  103. package/dist/types/types/run.d.ts +65 -0
  104. package/dist/types/types/stream.d.ts +0 -25
  105. package/package.json +1 -1
  106. package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
  107. package/src/common/constants.ts +21 -0
  108. package/src/events.ts +15 -1
  109. package/src/graphs/Graph.ts +568 -3
  110. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +18 -7
  111. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
  112. package/src/hooks/HookRegistry.ts +40 -0
  113. package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
  114. package/src/hooks/index.ts +16 -2
  115. package/src/hooks/types.ts +47 -3
  116. package/src/index.ts +1 -1
  117. package/src/langfuse.ts +26 -1
  118. package/src/llm/bedrock/utils/message_inputs.test.ts +82 -0
  119. package/src/llm/bedrock/utils/message_inputs.ts +14 -11
  120. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
  121. package/src/llm/invoke.alternation.test.ts +87 -0
  122. package/src/llm/invoke.handoffCue.test.ts +112 -0
  123. package/src/llm/invoke.ts +417 -8
  124. package/src/llm/preempt.test.ts +323 -0
  125. package/src/llm/preempt.ts +178 -0
  126. package/src/messages/alternation.test.ts +212 -0
  127. package/src/messages/alternation.ts +112 -0
  128. package/src/messages/format.ts +91 -0
  129. package/src/messages/formatAgentMessages.steer.test.ts +267 -0
  130. package/src/messages/handoffCue.test.ts +96 -0
  131. package/src/messages/handoffCue.ts +78 -0
  132. package/src/messages/index.ts +3 -0
  133. package/src/messages/injected.test.ts +90 -0
  134. package/src/messages/injected.ts +74 -0
  135. package/src/run.ts +91 -6
  136. package/src/scripts/preempt-probe.ts +330 -0
  137. package/src/scripts/preempt-scenarios.ts +388 -0
  138. package/src/session/handlers.ts +32 -12
  139. package/src/specs/handoffCue.test.ts +165 -0
  140. package/src/specs/langfuse-callbacks.test.ts +352 -2
  141. package/src/specs/preemptSeal.test.ts +309 -0
  142. package/src/stream.dispatch.test.ts +63 -0
  143. package/src/stream.ts +36 -8
  144. package/src/tools/ToolNode.ts +3 -31
  145. package/src/tools/search/keenable-scraper.test.ts +153 -0
  146. package/src/tools/search/keenable-scraper.ts +137 -0
  147. package/src/tools/search/tool.ts +13 -2
  148. package/src/tools/search/types.ts +50 -3
  149. package/src/types/graph.ts +12 -1
  150. package/src/types/run.ts +67 -0
  151. package/src/types/stream.ts +0 -41
  152. package/dist/cjs/splitStream.cjs +0 -151
  153. package/dist/cjs/splitStream.cjs.map +0 -1
  154. package/dist/esm/splitStream.mjs +0 -150
  155. package/dist/esm/splitStream.mjs.map +0 -1
  156. package/dist/types/mockStream.d.ts +0 -32
  157. package/dist/types/splitStream.d.ts +0 -37
  158. package/src/mockStream.ts +0 -99
  159. 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
+ }
@@ -475,6 +475,25 @@ function formatToolCallOutput(
475
475
  return compactToolContent(output, HARD_MAX_TOOL_RESULT_CHARS).content;
476
476
  }
477
477
 
478
+ /**
479
+ * Content for the synthetic assistant turn that separates a trailing steer
480
+ * from the next user turn. Non-empty by necessity — see the push site.
481
+ */
482
+ const STEER_ANCHOR_PLACEHOLDER = '_';
483
+
484
+ /**
485
+ * True when an assistant message replayed as a steer and nothing followed it,
486
+ * so the emitted run ends on the steer's `HumanMessage`.
487
+ */
488
+ function endsWithSteerMessage(
489
+ formatted: Array<RoleBearingMessage<BaseMessage>>
490
+ ): boolean {
491
+ if (formatted.length === 0) {
492
+ return false;
493
+ }
494
+ return formatted[formatted.length - 1].additional_kwargs.source === 'steer';
495
+ }
496
+
478
497
  /**
479
498
  * Helper function to format an assistant message
480
499
  * @param message The message to format
@@ -1336,6 +1355,36 @@ export const formatAgentMessages = (
1336
1355
  | RoleBearingMessage<SystemMessage>
1337
1356
  | RoleBearingMessage<ToolMessage>
1338
1357
  > = [];
1358
+ /**
1359
+ * A steer ended the previous payload entry, so the next message emitted —
1360
+ * whichever entry finally produces one — must be separated from it by an
1361
+ * assistant turn. Held rather than emitted so an entry that produces
1362
+ * nothing cannot leave the anchor stranded as the final turn.
1363
+ */
1364
+ let pendingSteerAnchor = false;
1365
+ /**
1366
+ * Emits the deferred anchor ahead of `next` — the message about to be
1367
+ * pushed. When that message is itself an assistant turn, it already IS the
1368
+ * separation the anchor exists to synthesize, so the intent is simply
1369
+ * discharged: emitting the placeholder anyway would put two assistant turns
1370
+ * back to back, which strict-alternation providers can reject and nothing downstream
1371
+ * repairs (`coalesceAdjacentUserTurns` merges user turns only).
1372
+ */
1373
+ const flushSteerAnchor = (next: { role?: LangChainMessageRole }): void => {
1374
+ if (!pendingSteerAnchor) {
1375
+ return;
1376
+ }
1377
+ pendingSteerAnchor = false;
1378
+ if (next.role === 'assistant') {
1379
+ return;
1380
+ }
1381
+ messages.push(
1382
+ withMessageRole(
1383
+ new AIMessage({ content: STEER_ANCHOR_PLACEHOLDER }),
1384
+ 'assistant'
1385
+ )
1386
+ );
1387
+ };
1339
1388
  // If indexTokenCountMap is provided, create a new map to track the updated indices
1340
1389
  const updatedIndexTokenCountMap: Record<number, number> = {};
1341
1390
  let boundaryTokenAdjustment:
@@ -1397,6 +1446,7 @@ export const formatAgentMessages = (
1397
1446
  if (sourceMessageId != null && sourceMessageId !== '') {
1398
1447
  formattedMessage.id = sourceMessageId;
1399
1448
  }
1449
+ flushSteerAnchor(formattedMessage);
1400
1450
  messages.push(formattedMessage);
1401
1451
 
1402
1452
  // Update the index mapping for this message
@@ -1580,7 +1630,48 @@ export const formatAgentMessages = (
1580
1630
  formattedMessage.id = sourceMessageId;
1581
1631
  }
1582
1632
  }
1633
+ /**
1634
+ * A steer that ends an assistant message leaves the replay on a
1635
+ * `HumanMessage`. The next payload message is itself a user turn, so the
1636
+ * sequence would reach the provider as two adjacent user turns — rejected
1637
+ * by strict-alternation providers. Anchor it with a placeholder assistant
1638
+ * turn.
1639
+ *
1640
+ * The placeholder must be NON-EMPTY. A string-content assistant message
1641
+ * with no tool calls passes through `_convertMessagesToAnthropicPayload`
1642
+ * verbatim — the empty-text repair there only covers array content and
1643
+ * tool-call turns — so an empty anchor would reach Anthropic as
1644
+ * `{role: 'assistant', content: ''}` and trade one invalid sequence for
1645
+ * another. Same single-underscore convention the Anthropic converter
1646
+ * already uses when it has to synthesize a non-empty block.
1647
+ *
1648
+ * Deferred rather than decided by lookahead. `i < payload.length - 1` only
1649
+ * proves a later ENTRY exists, not that it EMITS: entries with empty
1650
+ * content, and entries dropped by `applySummaryBoundary`, are skipped
1651
+ * silently. A trailing steer followed only by those would get the anchor
1652
+ * as the FINAL turn — an assistant prefill with no request after it, which
1653
+ * the model may simply never answer. So the intent is recorded and flushed
1654
+ * only when a message actually follows.
1655
+ *
1656
+ * Pushed AFTER the id stamping above, deliberately. `messagesStateReducer`
1657
+ * treats a repeated id as replace-in-place, so an anchor carrying the
1658
+ * shared `sourceMessageId` would overwrite the steer it exists to protect.
1659
+ * Left unstamped, it reaches the reducer with a null id and is assigned a
1660
+ * fresh one. `endsWithSteerMessage` reads only `additional_kwargs.source`,
1661
+ * so the deferral cannot change which messages get anchored.
1662
+ */
1663
+ /**
1664
+ * Guarded on emission: an assistant entry whose blocks all filtered away
1665
+ * emits nothing, and flushing for it would strand the anchor as the final
1666
+ * turn — the pending flag stays set for whichever entry emits next.
1667
+ */
1668
+ if (formattedMessages.length > 0) {
1669
+ flushSteerAnchor(formattedMessages[0]);
1670
+ }
1583
1671
  messages.push(...formattedMessages);
1672
+ if (endsWithSteerMessage(formattedMessages)) {
1673
+ pendingSteerAnchor = true;
1674
+ }
1584
1675
 
1585
1676
  // Capture index range BEFORE skill body injection so injected
1586
1677
  // HumanMessages are excluded from the assistant's token distribution.