@librechat/agents 3.1.41 → 3.1.43
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.
- package/dist/cjs/agents/AgentContext.cjs +33 -4
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +32 -9
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/messages/cache.cjs +31 -2
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +80 -10
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +33 -4
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +32 -9
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/messages/cache.mjs +31 -2
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +81 -11
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +1 -1
- package/package.json +1 -1
- package/src/agents/AgentContext.ts +37 -4
- package/src/graphs/MultiAgentGraph.ts +39 -9
- package/src/messages/cache.test.ts +82 -1
- package/src/messages/cache.ts +42 -3
- package/src/messages/ensureThinkingBlock.test.ts +207 -0
- package/src/messages/format.ts +106 -10
- package/src/scripts/test-handoff-steering.ts +427 -0
- package/src/scripts/test-tool-before-handoff-role-order.ts +276 -0
- package/src/specs/agent-handoffs.test.ts +106 -0
|
@@ -89,6 +89,213 @@ describe('ensureThinkingBlockInMessages', () => {
|
|
|
89
89
|
);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
test('should not modify AI message when reasoning_content is not the first block (Bedrock whitespace artifact)', () => {
|
|
93
|
+
// Bedrock emits a "\n\n" text chunk before the thinking block,
|
|
94
|
+
// pushing reasoning_content to content[1] instead of content[0].
|
|
95
|
+
const messages = [
|
|
96
|
+
new HumanMessage({ content: 'Do something' }),
|
|
97
|
+
new AIMessage({
|
|
98
|
+
content: [
|
|
99
|
+
{ type: 'text', text: '\n\n' },
|
|
100
|
+
{
|
|
101
|
+
type: ContentTypes.REASONING_CONTENT,
|
|
102
|
+
reasoningText: { text: 'Let me think about this' },
|
|
103
|
+
},
|
|
104
|
+
{ type: 'text', text: 'Let me help!' },
|
|
105
|
+
],
|
|
106
|
+
tool_calls: [
|
|
107
|
+
{
|
|
108
|
+
id: 'call_bedrock',
|
|
109
|
+
name: 'some_tool',
|
|
110
|
+
args: { x: 1 },
|
|
111
|
+
type: 'tool_call' as const,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
}),
|
|
115
|
+
new ToolMessage({
|
|
116
|
+
content: 'tool result',
|
|
117
|
+
tool_call_id: 'call_bedrock',
|
|
118
|
+
}),
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const result = ensureThinkingBlockInMessages(messages, Providers.BEDROCK);
|
|
122
|
+
|
|
123
|
+
expect(result).toHaveLength(3);
|
|
124
|
+
expect(result[0]).toBeInstanceOf(HumanMessage);
|
|
125
|
+
expect(result[1]).toBeInstanceOf(AIMessage);
|
|
126
|
+
expect(result[2]).toBeInstanceOf(ToolMessage);
|
|
127
|
+
// The AI message should be preserved, not converted to a HumanMessage
|
|
128
|
+
expect(result[1].content).toEqual(messages[1].content);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('should not convert follow-up tool calls in a thinking-enabled chain (Bedrock multi-step)', () => {
|
|
132
|
+
// Bedrock reasoning models produce reasoning on the first AI response,
|
|
133
|
+
// then subsequent tool calls in the same chain have content: "" with no
|
|
134
|
+
// reasoning block. These should NOT be converted because the chain
|
|
135
|
+
// already has a thinking block upstream.
|
|
136
|
+
const messages = [
|
|
137
|
+
new HumanMessage({ content: 'show me something cool' }),
|
|
138
|
+
new AIMessage({
|
|
139
|
+
content: [
|
|
140
|
+
{ type: 'text', text: '\n\n' },
|
|
141
|
+
{
|
|
142
|
+
type: ContentTypes.REASONING_CONTENT,
|
|
143
|
+
reasoningText: { text: 'Let me navigate to a page' },
|
|
144
|
+
},
|
|
145
|
+
{ type: 'text', text: 'Let me whip up something fun!' },
|
|
146
|
+
],
|
|
147
|
+
tool_calls: [
|
|
148
|
+
{
|
|
149
|
+
id: 'call_nav',
|
|
150
|
+
name: 'navigate_page',
|
|
151
|
+
args: { url: 'about:blank' },
|
|
152
|
+
type: 'tool_call' as const,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
}),
|
|
156
|
+
new ToolMessage({
|
|
157
|
+
content: 'Navigated to about:blank',
|
|
158
|
+
tool_call_id: 'call_nav',
|
|
159
|
+
}),
|
|
160
|
+
// Follow-up: content: "", tool calls, NO reasoning block
|
|
161
|
+
new AIMessage({
|
|
162
|
+
content: '',
|
|
163
|
+
tool_calls: [
|
|
164
|
+
{
|
|
165
|
+
id: 'call_eval',
|
|
166
|
+
name: 'evaluate_script',
|
|
167
|
+
args: { script: 'document.title = "test"' },
|
|
168
|
+
type: 'tool_call' as const,
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
}),
|
|
172
|
+
new ToolMessage({
|
|
173
|
+
content: 'Script executed',
|
|
174
|
+
tool_call_id: 'call_eval',
|
|
175
|
+
}),
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const result = ensureThinkingBlockInMessages(messages, Providers.BEDROCK);
|
|
179
|
+
|
|
180
|
+
// All 5 messages preserved — the follow-up AI message at index 3 is NOT converted
|
|
181
|
+
expect(result).toHaveLength(5);
|
|
182
|
+
expect(result[0]).toBeInstanceOf(HumanMessage);
|
|
183
|
+
expect(result[1]).toBeInstanceOf(AIMessage);
|
|
184
|
+
expect(result[2]).toBeInstanceOf(ToolMessage);
|
|
185
|
+
expect(result[3]).toBeInstanceOf(AIMessage);
|
|
186
|
+
expect(result[3].content).toBe('');
|
|
187
|
+
expect((result[3] as AIMessage).tool_calls).toHaveLength(1);
|
|
188
|
+
expect(result[4]).toBeInstanceOf(ToolMessage);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('should not convert multiple follow-up tool calls in a long chain', () => {
|
|
192
|
+
// Three AI→Tool rounds: only the first has reasoning
|
|
193
|
+
const messages = [
|
|
194
|
+
new HumanMessage({ content: 'do stuff' }),
|
|
195
|
+
new AIMessage({
|
|
196
|
+
content: [
|
|
197
|
+
{
|
|
198
|
+
type: ContentTypes.REASONING_CONTENT,
|
|
199
|
+
reasoningText: { text: 'Planning...' },
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
tool_calls: [
|
|
203
|
+
{ id: 'c1', name: 'step1', args: {}, type: 'tool_call' as const },
|
|
204
|
+
],
|
|
205
|
+
}),
|
|
206
|
+
new ToolMessage({ content: 'r1', tool_call_id: 'c1' }),
|
|
207
|
+
new AIMessage({
|
|
208
|
+
content: '',
|
|
209
|
+
tool_calls: [
|
|
210
|
+
{ id: 'c2', name: 'step2', args: {}, type: 'tool_call' as const },
|
|
211
|
+
],
|
|
212
|
+
}),
|
|
213
|
+
new ToolMessage({ content: 'r2', tool_call_id: 'c2' }),
|
|
214
|
+
new AIMessage({
|
|
215
|
+
content: '',
|
|
216
|
+
tool_calls: [
|
|
217
|
+
{ id: 'c3', name: 'step3', args: {}, type: 'tool_call' as const },
|
|
218
|
+
],
|
|
219
|
+
}),
|
|
220
|
+
new ToolMessage({ content: 'r3', tool_call_id: 'c3' }),
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
const result = ensureThinkingBlockInMessages(messages, Providers.BEDROCK);
|
|
224
|
+
|
|
225
|
+
expect(result).toHaveLength(7);
|
|
226
|
+
expect(result[1]).toBeInstanceOf(AIMessage);
|
|
227
|
+
expect(result[3]).toBeInstanceOf(AIMessage);
|
|
228
|
+
expect(result[5]).toBeInstanceOf(AIMessage);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('should still convert non-thinking agent tool calls after a human message boundary', () => {
|
|
232
|
+
// A chain with thinking, then a new human message, then a chain WITHOUT thinking
|
|
233
|
+
const messages = [
|
|
234
|
+
new HumanMessage({ content: 'first request' }),
|
|
235
|
+
new AIMessage({
|
|
236
|
+
content: [
|
|
237
|
+
{
|
|
238
|
+
type: ContentTypes.REASONING_CONTENT,
|
|
239
|
+
reasoningText: { text: 'Thinking...' },
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
tool_calls: [
|
|
243
|
+
{ id: 'c1', name: 'tool1', args: {}, type: 'tool_call' as const },
|
|
244
|
+
],
|
|
245
|
+
}),
|
|
246
|
+
new ToolMessage({ content: 'r1', tool_call_id: 'c1' }),
|
|
247
|
+
new HumanMessage({ content: 'second request' }),
|
|
248
|
+
// This chain has NO thinking blocks — should be converted
|
|
249
|
+
new AIMessage({
|
|
250
|
+
content: 'Using a tool',
|
|
251
|
+
tool_calls: [
|
|
252
|
+
{ id: 'c2', name: 'tool2', args: {}, type: 'tool_call' as const },
|
|
253
|
+
],
|
|
254
|
+
}),
|
|
255
|
+
new ToolMessage({ content: 'r2', tool_call_id: 'c2' }),
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
const result = ensureThinkingBlockInMessages(messages, Providers.BEDROCK);
|
|
259
|
+
|
|
260
|
+
// First chain preserved (3 msgs), human preserved, second chain converted (1 HumanMessage)
|
|
261
|
+
expect(result).toHaveLength(5);
|
|
262
|
+
expect(result[0]).toBeInstanceOf(HumanMessage);
|
|
263
|
+
expect(result[1]).toBeInstanceOf(AIMessage); // reasoning chain — kept
|
|
264
|
+
expect(result[2]).toBeInstanceOf(ToolMessage);
|
|
265
|
+
expect(result[3]).toBeInstanceOf(HumanMessage); // user message
|
|
266
|
+
expect(result[4]).toBeInstanceOf(HumanMessage); // converted — no thinking in this chain
|
|
267
|
+
expect(result[4].content).toContain('[Previous agent context]');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('should detect thinking via additional_kwargs.reasoning_content in chain', () => {
|
|
271
|
+
const messages = [
|
|
272
|
+
new HumanMessage({ content: 'hello' }),
|
|
273
|
+
new AIMessage({
|
|
274
|
+
content: '',
|
|
275
|
+
additional_kwargs: {
|
|
276
|
+
reasoning_content: 'Some reasoning...',
|
|
277
|
+
},
|
|
278
|
+
tool_calls: [
|
|
279
|
+
{ id: 'c1', name: 'tool1', args: {}, type: 'tool_call' as const },
|
|
280
|
+
],
|
|
281
|
+
}),
|
|
282
|
+
new ToolMessage({ content: 'r1', tool_call_id: 'c1' }),
|
|
283
|
+
new AIMessage({
|
|
284
|
+
content: '',
|
|
285
|
+
tool_calls: [
|
|
286
|
+
{ id: 'c2', name: 'tool2', args: {}, type: 'tool_call' as const },
|
|
287
|
+
],
|
|
288
|
+
}),
|
|
289
|
+
new ToolMessage({ content: 'r2', tool_call_id: 'c2' }),
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
const result = ensureThinkingBlockInMessages(messages, Providers.BEDROCK);
|
|
293
|
+
|
|
294
|
+
// Index 3 should NOT be converted — index 1 has reasoning in additional_kwargs
|
|
295
|
+
expect(result).toHaveLength(5);
|
|
296
|
+
expect(result[3]).toBeInstanceOf(AIMessage);
|
|
297
|
+
});
|
|
298
|
+
|
|
92
299
|
test('should not modify AI message with reasoning block and tool calls', () => {
|
|
93
300
|
const messages = [
|
|
94
301
|
new HumanMessage({ content: 'Calculate something' }),
|
package/src/messages/format.ts
CHANGED
|
@@ -1011,7 +1011,12 @@ export function ensureThinkingBlockInMessages(
|
|
|
1011
1011
|
|
|
1012
1012
|
while (i < messages.length) {
|
|
1013
1013
|
const msg = messages[i];
|
|
1014
|
-
|
|
1014
|
+
/** Detect AI messages by instanceof OR by role, in case cache-control cloning
|
|
1015
|
+
produced a plain object that lost the LangChain prototype. */
|
|
1016
|
+
const isAI =
|
|
1017
|
+
msg instanceof AIMessage ||
|
|
1018
|
+
msg instanceof AIMessageChunk ||
|
|
1019
|
+
('role' in msg && (msg as any).role === 'assistant');
|
|
1015
1020
|
|
|
1016
1021
|
if (!isAI) {
|
|
1017
1022
|
result.push(msg);
|
|
@@ -1025,30 +1030,58 @@ export function ensureThinkingBlockInMessages(
|
|
|
1025
1030
|
|
|
1026
1031
|
// Check if the message has tool calls or tool_use content
|
|
1027
1032
|
let hasToolUse = hasToolCalls ?? false;
|
|
1028
|
-
let
|
|
1033
|
+
let hasThinkingBlock = false;
|
|
1029
1034
|
|
|
1030
1035
|
if (contentIsArray && aiMsg.content.length > 0) {
|
|
1031
1036
|
const content = aiMsg.content as ExtendedMessageContent[];
|
|
1032
|
-
firstContentType = content[0]?.type;
|
|
1033
1037
|
hasToolUse =
|
|
1034
1038
|
hasToolUse ||
|
|
1035
1039
|
content.some((c) => typeof c === 'object' && c.type === 'tool_use');
|
|
1040
|
+
// Check ALL content blocks for thinking/reasoning, not just [0].
|
|
1041
|
+
// Bedrock may emit a whitespace text chunk before the thinking block,
|
|
1042
|
+
// pushing the reasoning_content to index 1+.
|
|
1043
|
+
hasThinkingBlock = content.some(
|
|
1044
|
+
(c) =>
|
|
1045
|
+
typeof c === 'object' &&
|
|
1046
|
+
(c.type === ContentTypes.THINKING ||
|
|
1047
|
+
c.type === ContentTypes.REASONING_CONTENT ||
|
|
1048
|
+
c.type === ContentTypes.REASONING ||
|
|
1049
|
+
c.type === 'redacted_thinking')
|
|
1050
|
+
);
|
|
1036
1051
|
}
|
|
1037
1052
|
|
|
1038
|
-
//
|
|
1053
|
+
// Bedrock also stores reasoning in additional_kwargs (may not be in content array)
|
|
1039
1054
|
if (
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
firstContentType !== ContentTypes.REASONING_CONTENT &&
|
|
1043
|
-
firstContentType !== ContentTypes.REASONING &&
|
|
1044
|
-
firstContentType !== 'redacted_thinking'
|
|
1055
|
+
!hasThinkingBlock &&
|
|
1056
|
+
aiMsg.additional_kwargs.reasoning_content != null
|
|
1045
1057
|
) {
|
|
1058
|
+
hasThinkingBlock = true;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// If message has tool use but no thinking block, check whether this is a
|
|
1062
|
+
// continuation of a thinking-enabled agent's chain before converting.
|
|
1063
|
+
// Bedrock reasoning models can produce multiple AI→Tool rounds after an
|
|
1064
|
+
// initial reasoning response: the first AI message has reasoning_content,
|
|
1065
|
+
// but follow-ups have content: "" with only tool_calls. These are the
|
|
1066
|
+
// same agent's turn and must NOT be converted to HumanMessages.
|
|
1067
|
+
if (hasToolUse && !hasThinkingBlock) {
|
|
1068
|
+
// Walk backwards — if an earlier AI message in the same chain (before
|
|
1069
|
+
// the nearest HumanMessage) has a thinking/reasoning block, this is a
|
|
1070
|
+
// continuation of a thinking-enabled turn, not a non-thinking handoff.
|
|
1071
|
+
if (chainHasThinkingBlock(messages, i)) {
|
|
1072
|
+
result.push(msg);
|
|
1073
|
+
i++;
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1046
1077
|
// Collect the AI message and any following tool messages
|
|
1047
1078
|
const toolSequence: BaseMessage[] = [msg];
|
|
1048
1079
|
let j = i + 1;
|
|
1049
1080
|
|
|
1050
1081
|
// Look ahead for tool messages that belong to this AI message
|
|
1051
|
-
|
|
1082
|
+
const isToolMsg = (m: BaseMessage): boolean =>
|
|
1083
|
+
m instanceof ToolMessage || ('role' in m && (m as any).role === 'tool');
|
|
1084
|
+
while (j < messages.length && isToolMsg(messages[j])) {
|
|
1052
1085
|
toolSequence.push(messages[j]);
|
|
1053
1086
|
j++;
|
|
1054
1087
|
}
|
|
@@ -1073,3 +1106,66 @@ export function ensureThinkingBlockInMessages(
|
|
|
1073
1106
|
|
|
1074
1107
|
return result;
|
|
1075
1108
|
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Walks backwards from `currentIndex` through the message array to check
|
|
1112
|
+
* whether an earlier AI message in the same "chain" (no HumanMessage boundary)
|
|
1113
|
+
* contains a thinking/reasoning block.
|
|
1114
|
+
*
|
|
1115
|
+
* A "chain" is a contiguous sequence of AI + Tool messages with no intervening
|
|
1116
|
+
* HumanMessage. Bedrock reasoning models produce reasoning on the first AI
|
|
1117
|
+
* response, then issue follow-up tool calls with `content: ""` and no
|
|
1118
|
+
* reasoning block. These follow-ups are part of the same thinking-enabled
|
|
1119
|
+
* turn and should not be converted.
|
|
1120
|
+
*/
|
|
1121
|
+
function chainHasThinkingBlock(
|
|
1122
|
+
messages: BaseMessage[],
|
|
1123
|
+
currentIndex: number
|
|
1124
|
+
): boolean {
|
|
1125
|
+
for (let k = currentIndex - 1; k >= 0; k--) {
|
|
1126
|
+
const prev = messages[k];
|
|
1127
|
+
|
|
1128
|
+
// HumanMessage = turn boundary — stop searching
|
|
1129
|
+
if (
|
|
1130
|
+
prev instanceof HumanMessage ||
|
|
1131
|
+
('role' in prev && (prev as any).role === 'user')
|
|
1132
|
+
) {
|
|
1133
|
+
return false;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Check AI messages for thinking/reasoning blocks
|
|
1137
|
+
const isPrevAI =
|
|
1138
|
+
prev instanceof AIMessage ||
|
|
1139
|
+
prev instanceof AIMessageChunk ||
|
|
1140
|
+
('role' in prev && (prev as any).role === 'assistant');
|
|
1141
|
+
|
|
1142
|
+
if (isPrevAI) {
|
|
1143
|
+
const prevAiMsg = prev as AIMessage | AIMessageChunk;
|
|
1144
|
+
|
|
1145
|
+
if (Array.isArray(prevAiMsg.content) && prevAiMsg.content.length > 0) {
|
|
1146
|
+
const content = prevAiMsg.content as ExtendedMessageContent[];
|
|
1147
|
+
if (
|
|
1148
|
+
content.some(
|
|
1149
|
+
(c) =>
|
|
1150
|
+
typeof c === 'object' &&
|
|
1151
|
+
(c.type === ContentTypes.THINKING ||
|
|
1152
|
+
c.type === ContentTypes.REASONING_CONTENT ||
|
|
1153
|
+
c.type === ContentTypes.REASONING ||
|
|
1154
|
+
c.type === 'redacted_thinking')
|
|
1155
|
+
)
|
|
1156
|
+
) {
|
|
1157
|
+
return true;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Bedrock also stores reasoning in additional_kwargs
|
|
1162
|
+
if (prevAiMsg.additional_kwargs.reasoning_content != null) {
|
|
1163
|
+
return true;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ToolMessages are part of the chain — keep walking back
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|