@librechat/agents 3.2.35 → 3.2.36
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 +74 -1
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/agents/projection.cjs +25 -0
- package/dist/cjs/agents/projection.cjs.map +1 -0
- package/dist/cjs/graphs/Graph.cjs +3 -18
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +26 -4
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +20 -0
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/main.cjs +5 -0
- package/dist/cjs/messages/budget.cjs +23 -0
- package/dist/cjs/messages/budget.cjs.map +1 -0
- package/dist/cjs/messages/cache.cjs +1 -0
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/index.cjs +1 -0
- package/dist/cjs/tools/search/format.cjs +91 -2
- package/dist/cjs/tools/search/format.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +4 -3
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +75 -2
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/agents/projection.mjs +25 -0
- package/dist/esm/agents/projection.mjs.map +1 -0
- package/dist/esm/graphs/Graph.mjs +1 -16
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +26 -4
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +20 -0
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/main.mjs +4 -2
- package/dist/esm/messages/budget.mjs +23 -0
- package/dist/esm/messages/budget.mjs.map +1 -0
- package/dist/esm/messages/cache.mjs +1 -1
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/index.mjs +1 -0
- package/dist/esm/tools/search/format.mjs +91 -2
- package/dist/esm/tools/search/format.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +4 -3
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +30 -1
- package/dist/types/agents/projection.d.ts +26 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/messages/budget.d.ts +11 -0
- package/dist/types/messages/cache.d.ts +7 -0
- package/dist/types/messages/index.d.ts +1 -0
- package/dist/types/tools/search/format.d.ts +4 -1
- package/dist/types/tools/search/types.d.ts +7 -0
- package/package.json +1 -1
- package/src/agents/AgentContext.ts +103 -2
- package/src/agents/__tests__/AgentContext.test.ts +229 -0
- package/src/agents/__tests__/projection.test.ts +73 -0
- package/src/agents/projection.ts +46 -0
- package/src/graphs/Graph.ts +1 -29
- package/src/index.ts +3 -0
- package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +317 -0
- package/src/llm/anthropic/utils/message_inputs.ts +78 -16
- package/src/llm/bedrock/utils/cross-provider-reasoning.test.ts +131 -0
- package/src/llm/bedrock/utils/message_inputs.ts +35 -0
- package/src/messages/budget.ts +32 -0
- package/src/messages/cache.ts +1 -1
- package/src/messages/index.ts +1 -0
- package/src/tools/search/format.test.ts +242 -0
- package/src/tools/search/format.ts +122 -5
- package/src/tools/search/tool.ts +5 -1
- package/src/tools/search/types.ts +7 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
3
|
+
import { _convertMessagesToAnthropicPayload } from './message_inputs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression for cross-provider agent handoffs (e.g. Bedrock → Anthropic): a
|
|
7
|
+
* Bedrock turn that used extended thinking leaves a `reasoning_content` content
|
|
8
|
+
* block ({ reasoningText: { text, signature } }) in the history. The official
|
|
9
|
+
* Anthropic converter has no branch for it and previously threw
|
|
10
|
+
* "Unsupported message content format", crashing the handoff. Only known
|
|
11
|
+
* foreign reasoning (Bedrock `reasoning_content`, Google `reasoning`, LibreChat
|
|
12
|
+
* `think`) is dropped; any other unknown block still throws rather than being
|
|
13
|
+
* silently omitted (real content — user media, Google code-execution — must be
|
|
14
|
+
* surfaced); and a tool call carried only on `tool_calls` survives dropping its
|
|
15
|
+
* reasoning sibling without being duplicated.
|
|
16
|
+
*/
|
|
17
|
+
type AnthropicPayload = ReturnType<typeof _convertMessagesToAnthropicPayload>;
|
|
18
|
+
|
|
19
|
+
/** Minimal view of a converted Anthropic content block the assertions read. */
|
|
20
|
+
interface TestBlock {
|
|
21
|
+
type?: string;
|
|
22
|
+
text?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const findAssistant = (payload: AnthropicPayload) =>
|
|
26
|
+
payload.messages.find((m) => m.role === 'assistant');
|
|
27
|
+
|
|
28
|
+
const assistantBlocks = (payload: AnthropicPayload): TestBlock[] => {
|
|
29
|
+
const content = findAssistant(payload)?.content;
|
|
30
|
+
return Array.isArray(content) ? (content as TestBlock[]) : [];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('_convertMessagesToAnthropicPayload — cross-provider reasoning blocks', () => {
|
|
34
|
+
const bedrockHandoffHistory = (): BaseMessage[] => [
|
|
35
|
+
new HumanMessage('research Assort Health'),
|
|
36
|
+
new AIMessage({
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: 'reasoning_content',
|
|
40
|
+
index: 0,
|
|
41
|
+
reasoningText: {
|
|
42
|
+
text: 'Let me search Notion then hand off to the data agent.',
|
|
43
|
+
signature: 'bedrock-signature-not-valid-for-anthropic',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{ type: 'text', text: 'Kicking off the searches now.' },
|
|
47
|
+
{
|
|
48
|
+
type: 'tool_use',
|
|
49
|
+
id: 'tooluse_abc',
|
|
50
|
+
name: 'notion-search',
|
|
51
|
+
input: { query: 'Assort Health' },
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
tool_calls: [
|
|
55
|
+
{
|
|
56
|
+
id: 'tooluse_abc',
|
|
57
|
+
name: 'notion-search',
|
|
58
|
+
args: { query: 'Assort Health' },
|
|
59
|
+
type: 'tool_call',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
}),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
it('does not throw on a Bedrock reasoning_content block', () => {
|
|
66
|
+
expect(() =>
|
|
67
|
+
_convertMessagesToAnthropicPayload(bedrockHandoffHistory())
|
|
68
|
+
).not.toThrow();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('drops reasoning_content (incl. its foreign signature) but keeps text and tool_use', () => {
|
|
72
|
+
const payload = _convertMessagesToAnthropicPayload(bedrockHandoffHistory());
|
|
73
|
+
expect(findAssistant(payload)).toBeDefined();
|
|
74
|
+
const blocks = assistantBlocks(payload);
|
|
75
|
+
|
|
76
|
+
expect(blocks.find((b) => b.type === 'reasoning_content')).toBeUndefined();
|
|
77
|
+
expect(
|
|
78
|
+
blocks.find(
|
|
79
|
+
(b) => b.type === 'thinking' || b.type === 'redacted_thinking'
|
|
80
|
+
)
|
|
81
|
+
).toBeUndefined();
|
|
82
|
+
expect(JSON.stringify(blocks)).not.toContain(
|
|
83
|
+
'bedrock-signature-not-valid-for-anthropic'
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(
|
|
87
|
+
blocks.some(
|
|
88
|
+
(b) => b.type === 'text' && b.text === 'Kicking off the searches now.'
|
|
89
|
+
)
|
|
90
|
+
).toBe(true);
|
|
91
|
+
expect(blocks.find((b) => b.type === 'tool_use')).toMatchObject({
|
|
92
|
+
type: 'tool_use',
|
|
93
|
+
id: 'tooluse_abc',
|
|
94
|
+
name: 'notion-search',
|
|
95
|
+
input: { query: 'Assort Health' },
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('drops a Google `reasoning` block without throwing', () => {
|
|
100
|
+
const history: BaseMessage[] = [
|
|
101
|
+
new HumanMessage('hi'),
|
|
102
|
+
new AIMessage({
|
|
103
|
+
content: [
|
|
104
|
+
{ type: 'reasoning', reasoning: 'internal google chain of thought' },
|
|
105
|
+
{ type: 'text', text: 'Hello!' },
|
|
106
|
+
],
|
|
107
|
+
}),
|
|
108
|
+
];
|
|
109
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
|
|
110
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
111
|
+
expect(blocks.find((b) => b.type === 'reasoning')).toBeUndefined();
|
|
112
|
+
expect(blocks.some((b) => b.type === 'text' && b.text === 'Hello!')).toBe(
|
|
113
|
+
true
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('drops a LibreChat `think` block without throwing', () => {
|
|
118
|
+
const history: BaseMessage[] = [
|
|
119
|
+
new HumanMessage('hi'),
|
|
120
|
+
new AIMessage({
|
|
121
|
+
content: [
|
|
122
|
+
{ type: 'think', think: 'librechat serialized reasoning' },
|
|
123
|
+
{ type: 'text', text: 'Done.' },
|
|
124
|
+
],
|
|
125
|
+
}),
|
|
126
|
+
];
|
|
127
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
|
|
128
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
129
|
+
expect(blocks.find((b) => b.type === 'think')).toBeUndefined();
|
|
130
|
+
expect(blocks.some((b) => b.type === 'text' && b.text === 'Done.')).toBe(
|
|
131
|
+
true
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('drops an unsigned `thinking` block (Google thinking-enabled output) on an assistant turn', () => {
|
|
136
|
+
const history: BaseMessage[] = [
|
|
137
|
+
new HumanMessage('hi'),
|
|
138
|
+
new AIMessage({
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: 'thinking',
|
|
142
|
+
thinking: 'google chain of thought, no signature',
|
|
143
|
+
},
|
|
144
|
+
{ type: 'text', text: 'Answer.' },
|
|
145
|
+
],
|
|
146
|
+
}),
|
|
147
|
+
];
|
|
148
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
|
|
149
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
150
|
+
expect(blocks.find((b) => b.type === 'thinking')).toBeUndefined();
|
|
151
|
+
expect(blocks.some((b) => b.type === 'text' && b.text === 'Answer.')).toBe(
|
|
152
|
+
true
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('forwards a signed `thinking` block (Anthropic-native) unchanged', () => {
|
|
157
|
+
const history: BaseMessage[] = [
|
|
158
|
+
new HumanMessage('hi'),
|
|
159
|
+
new AIMessage({
|
|
160
|
+
content: [
|
|
161
|
+
{
|
|
162
|
+
type: 'thinking',
|
|
163
|
+
thinking: 'native reasoning',
|
|
164
|
+
signature: 'valid-sig',
|
|
165
|
+
},
|
|
166
|
+
{ type: 'text', text: 'Answer.' },
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
];
|
|
170
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
171
|
+
expect(blocks.find((b) => b.type === 'thinking')).toMatchObject({
|
|
172
|
+
type: 'thinking',
|
|
173
|
+
thinking: 'native reasoning',
|
|
174
|
+
signature: 'valid-sig',
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('throws (not silently drops) on an unknown assistant block such as Google code execution', () => {
|
|
179
|
+
// executableCode/codeExecutionResult carry real visible content; silently
|
|
180
|
+
// dropping them on a Google → Anthropic handoff would lose evidence.
|
|
181
|
+
const history: BaseMessage[] = [
|
|
182
|
+
new HumanMessage('run some code'),
|
|
183
|
+
new AIMessage({
|
|
184
|
+
content: [
|
|
185
|
+
{
|
|
186
|
+
type: 'executableCode',
|
|
187
|
+
executableCode: { language: 'PYTHON', code: 'print(2+2)' },
|
|
188
|
+
},
|
|
189
|
+
{ type: 'text', text: 'Here is the result.' },
|
|
190
|
+
],
|
|
191
|
+
}),
|
|
192
|
+
];
|
|
193
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).toThrow(
|
|
194
|
+
'Unsupported message content format'
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('throws (not silently drops) on an unsupported user block such as media', () => {
|
|
199
|
+
const history: BaseMessage[] = [
|
|
200
|
+
new HumanMessage({
|
|
201
|
+
content: [
|
|
202
|
+
{
|
|
203
|
+
type: 'video_url',
|
|
204
|
+
video_url: { url: 'https://example.com/v.mp4' },
|
|
205
|
+
},
|
|
206
|
+
{ type: 'text', text: 'what is in this video?' },
|
|
207
|
+
],
|
|
208
|
+
}),
|
|
209
|
+
];
|
|
210
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).toThrow(
|
|
211
|
+
'Unsupported message content format'
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('does not drop a reasoning-typed block on a user turn (only assistant reasoning is dropped)', () => {
|
|
216
|
+
const history: BaseMessage[] = [
|
|
217
|
+
new HumanMessage({
|
|
218
|
+
content: [
|
|
219
|
+
{ type: 'reasoning_content', reasoningText: { text: 'user text' } },
|
|
220
|
+
{ type: 'text', text: 'hello' },
|
|
221
|
+
],
|
|
222
|
+
}),
|
|
223
|
+
];
|
|
224
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).toThrow(
|
|
225
|
+
'Unsupported message content format'
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('preserves a tool call carried only on tool_calls when its reasoning sibling is dropped', () => {
|
|
230
|
+
// Mirrors a Bedrock extended-thinking turn: the tool lives only on
|
|
231
|
+
// `tool_calls`; `content` holds just the reasoning block (no tool_use).
|
|
232
|
+
const history: BaseMessage[] = [
|
|
233
|
+
new HumanMessage('research Assort Health'),
|
|
234
|
+
new AIMessage({
|
|
235
|
+
content: [
|
|
236
|
+
{
|
|
237
|
+
type: 'reasoning_content',
|
|
238
|
+
reasoningText: { text: 'I should hand off now.', signature: 'sig' },
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
tool_calls: [
|
|
242
|
+
{
|
|
243
|
+
id: 'tooluse_transfer',
|
|
244
|
+
name: 'lc_transfer_to_data_agent',
|
|
245
|
+
args: { reason: 'need consumption data' },
|
|
246
|
+
type: 'tool_call',
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
}),
|
|
250
|
+
];
|
|
251
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
|
|
252
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
253
|
+
expect(blocks.find((b) => b.type === 'reasoning_content')).toBeUndefined();
|
|
254
|
+
expect(blocks.find((b) => b.type === 'tool_use')).toMatchObject({
|
|
255
|
+
type: 'tool_use',
|
|
256
|
+
id: 'tooluse_transfer',
|
|
257
|
+
name: 'lc_transfer_to_data_agent',
|
|
258
|
+
input: { reason: 'need consumption data' },
|
|
259
|
+
});
|
|
260
|
+
// The `_` placeholder must not linger once a real tool_use block is present.
|
|
261
|
+
expect(blocks.some((b) => b.type === 'text' && b.text === '_')).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('does not duplicate a Google functionCall tool call already materialized by _formatContent', () => {
|
|
265
|
+
// _formatContent converts the `functionCall` part into a tool_use; the
|
|
266
|
+
// materialization must recognize it as represented and not append a second.
|
|
267
|
+
const history: BaseMessage[] = [
|
|
268
|
+
new HumanMessage('weather in SF?'),
|
|
269
|
+
new AIMessage({
|
|
270
|
+
content: [
|
|
271
|
+
{
|
|
272
|
+
type: 'functionCall',
|
|
273
|
+
functionCall: { name: 'get_weather', args: { city: 'SF' } },
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
tool_calls: [
|
|
277
|
+
{
|
|
278
|
+
id: 'call_weather_1',
|
|
279
|
+
name: 'get_weather',
|
|
280
|
+
args: { city: 'SF' },
|
|
281
|
+
type: 'tool_call',
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
}),
|
|
285
|
+
];
|
|
286
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
287
|
+
const toolUses = blocks.filter((b) => b.type === 'tool_use');
|
|
288
|
+
expect(toolUses).toHaveLength(1);
|
|
289
|
+
expect(toolUses[0]).toMatchObject({
|
|
290
|
+
type: 'tool_use',
|
|
291
|
+
id: 'call_weather_1',
|
|
292
|
+
name: 'get_weather',
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('falls back to placeholder text when reasoning was the only content', () => {
|
|
297
|
+
const history: BaseMessage[] = [
|
|
298
|
+
new HumanMessage('hi'),
|
|
299
|
+
new AIMessage({
|
|
300
|
+
content: [
|
|
301
|
+
{
|
|
302
|
+
type: 'reasoning_content',
|
|
303
|
+
reasoningText: {
|
|
304
|
+
text: 'only thinking, no visible text',
|
|
305
|
+
signature: 'sig',
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
}),
|
|
310
|
+
];
|
|
311
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
|
|
312
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
313
|
+
expect(blocks.find((b) => b.type === 'reasoning_content')).toBeUndefined();
|
|
314
|
+
expect(blocks.length).toBeGreaterThan(0);
|
|
315
|
+
expect(blocks.every((b) => b.type === 'text')).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -429,6 +429,14 @@ function _formatContent(message: BaseMessage) {
|
|
|
429
429
|
'web_search_result',
|
|
430
430
|
];
|
|
431
431
|
const textTypes = ['text', 'text_delta'];
|
|
432
|
+
/**
|
|
433
|
+
* Reasoning blocks emitted by other providers — Bedrock's `reasoning_content`,
|
|
434
|
+
* Google's `reasoning`, and LibreChat's `think`. Their signatures are
|
|
435
|
+
* provider-specific and cannot be validated by Anthropic, so on a
|
|
436
|
+
* cross-provider handoff (e.g. Bedrock → Anthropic) we drop them rather than
|
|
437
|
+
* forwarding an unusable block. The receiving model produces its own thinking.
|
|
438
|
+
*/
|
|
439
|
+
const foreignReasoningTypes = ['reasoning_content', 'reasoning', 'think'];
|
|
432
440
|
const { content } = message;
|
|
433
441
|
|
|
434
442
|
if (typeof content === 'string') {
|
|
@@ -568,6 +576,15 @@ function _formatContent(message: BaseMessage) {
|
|
|
568
576
|
};
|
|
569
577
|
} else if (contentPart.type === 'thinking') {
|
|
570
578
|
const thinkingPart = contentPart as AnthropicThinkingBlockParam;
|
|
579
|
+
// Google thinking-enabled output reuses `type: 'thinking'` but carries
|
|
580
|
+
// no Anthropic signature. Anthropic rejects an unsigned thinking block,
|
|
581
|
+
// so on an assistant turn treat it as foreign reasoning and drop it
|
|
582
|
+
// rather than forward an unusable block. Signed (Anthropic-native)
|
|
583
|
+
// thinking is forwarded as before.
|
|
584
|
+
const signature = (thinkingPart as { signature?: string }).signature;
|
|
585
|
+
if (isAIMessage(message) && (signature == null || signature === '')) {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
571
588
|
const block: AnthropicThinkingBlockParam = {
|
|
572
589
|
type: 'thinking' as const, // Explicitly setting the type as "thinking"
|
|
573
590
|
thinking: thinkingPart.thinking,
|
|
@@ -651,7 +668,9 @@ function _formatContent(message: BaseMessage) {
|
|
|
651
668
|
(contentPartCopy.input === '' || contentPartCopy.input == null)
|
|
652
669
|
) {
|
|
653
670
|
const matchingToolCall = isAIMessage(message)
|
|
654
|
-
? message.tool_calls?.find(
|
|
671
|
+
? message.tool_calls?.find(
|
|
672
|
+
(toolCall) => toolCall.id === contentPartCopy.id
|
|
673
|
+
)
|
|
655
674
|
: undefined;
|
|
656
675
|
if (matchingToolCall) {
|
|
657
676
|
contentPartCopy.input = matchingToolCall.args;
|
|
@@ -666,7 +685,10 @@ function _formatContent(message: BaseMessage) {
|
|
|
666
685
|
typeof p.input === 'string'
|
|
667
686
|
);
|
|
668
687
|
})
|
|
669
|
-
.reduce(
|
|
688
|
+
.reduce(
|
|
689
|
+
(acc, part) => acc + (part as Record<string, unknown>).input,
|
|
690
|
+
''
|
|
691
|
+
);
|
|
670
692
|
if (merged !== '') {
|
|
671
693
|
contentPartCopy.input = merged;
|
|
672
694
|
}
|
|
@@ -720,6 +742,18 @@ function _formatContent(message: BaseMessage) {
|
|
|
720
742
|
name: correspondingToolCall.name,
|
|
721
743
|
input: functionCallPart.functionCall.args,
|
|
722
744
|
};
|
|
745
|
+
} else if (
|
|
746
|
+
isAIMessage(message) &&
|
|
747
|
+
foreignReasoningTypes.some((t) => t === contentPart.type)
|
|
748
|
+
) {
|
|
749
|
+
// Foreign reasoning on an ASSISTANT turn (Bedrock `reasoning_content`,
|
|
750
|
+
// Google `reasoning`, LibreChat `think`) carries provider-specific
|
|
751
|
+
// signatures Anthropic cannot validate; drop it so a cross-provider
|
|
752
|
+
// handoff doesn't crash. The same types on a user/tool turn are real
|
|
753
|
+
// input and fall through to the throw below rather than being silently
|
|
754
|
+
// dropped — as does any other unknown block (user media, Google
|
|
755
|
+
// code-execution), which must be surfaced, not discarded.
|
|
756
|
+
return null;
|
|
723
757
|
} else {
|
|
724
758
|
console.error(
|
|
725
759
|
'Unsupported content part:',
|
|
@@ -808,25 +842,53 @@ export function _convertMessagesToAnthropicPayload(
|
|
|
808
842
|
};
|
|
809
843
|
}
|
|
810
844
|
} else {
|
|
811
|
-
const
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
845
|
+
const formattedContent = _formatContent(message);
|
|
846
|
+
const formattedBlocks = Array.isArray(formattedContent)
|
|
847
|
+
? formattedContent
|
|
848
|
+
: [];
|
|
849
|
+
// Tool calls already materialized as content blocks by `_formatContent`.
|
|
850
|
+
// Derived from the FORMATTED output (not the raw content by type) so
|
|
851
|
+
// that Google `functionCall` parts — which `_formatContent` converts
|
|
852
|
+
// into `tool_use` — count as represented and are not appended twice.
|
|
853
|
+
const representedToolIds = new Set(
|
|
854
|
+
formattedBlocks
|
|
855
|
+
.filter(
|
|
856
|
+
(block) =>
|
|
857
|
+
block != null &&
|
|
858
|
+
(block.type === 'tool_use' || block.type === 'server_tool_use')
|
|
820
859
|
)
|
|
860
|
+
.map((block) => (block as { id?: string }).id)
|
|
821
861
|
);
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
862
|
+
// Client tool calls present in `tool_calls` but absent from the
|
|
863
|
+
// formatted content — e.g. a Bedrock extended-thinking turn records the
|
|
864
|
+
// tool only on `tool_calls` and leaves `content` as just the reasoning
|
|
865
|
+
// block. Without materializing them, dropping that reasoning block
|
|
866
|
+
// silently loses the (handoff) tool call instead of forwarding it.
|
|
867
|
+
const unrepresentedToolCalls = toolCalls.filter(
|
|
868
|
+
(toolCall) =>
|
|
869
|
+
!(
|
|
870
|
+
toolCall.id?.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX) ??
|
|
871
|
+
false
|
|
872
|
+
) && !representedToolIds.has(toolCall.id)
|
|
873
|
+
);
|
|
874
|
+
if (unrepresentedToolCalls.length === 0) {
|
|
875
|
+
return { role, content: formattedContent };
|
|
826
876
|
}
|
|
877
|
+
const existingBlocks = formattedBlocks.filter(
|
|
878
|
+
(block) =>
|
|
879
|
+
!(
|
|
880
|
+
block != null &&
|
|
881
|
+
block.type === 'text' &&
|
|
882
|
+
'text' in block &&
|
|
883
|
+
block.text === ANTHROPIC_EMPTY_TEXT_PLACEHOLDER
|
|
884
|
+
)
|
|
885
|
+
);
|
|
827
886
|
return {
|
|
828
887
|
role,
|
|
829
|
-
content:
|
|
888
|
+
content: [
|
|
889
|
+
...existingBlocks,
|
|
890
|
+
...unrepresentedToolCalls.map(_convertLangChainToolCallToAnthropic),
|
|
891
|
+
],
|
|
830
892
|
};
|
|
831
893
|
}
|
|
832
894
|
} else {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
3
|
+
import { convertToConverseMessages } from './message_inputs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mirror of the Anthropic-side cross-provider reasoning fix, for the reverse
|
|
7
|
+
* handoff (Anthropic → Bedrock). An Anthropic extended-thinking turn leaves
|
|
8
|
+
* `thinking`/`redacted_thinking` blocks in history; the Bedrock Converse
|
|
9
|
+
* converter has no branch for them and previously threw
|
|
10
|
+
* "Unsupported content block type: thinking", crashing the handoff. Bedrock's
|
|
11
|
+
* native reasoning is `reasoning_content` (still converted); foreign reasoning
|
|
12
|
+
* (`thinking`/`redacted_thinking`/`reasoning`/`think`) is dropped on assistant
|
|
13
|
+
* turns, while any other unknown block still throws rather than being silently
|
|
14
|
+
* omitted.
|
|
15
|
+
*/
|
|
16
|
+
type ConverseResult = ReturnType<typeof convertToConverseMessages>;
|
|
17
|
+
|
|
18
|
+
/** Minimal view of a converted Bedrock Converse content block the assertions read. */
|
|
19
|
+
interface ConverseBlock {
|
|
20
|
+
text?: string;
|
|
21
|
+
reasoningContent?: { reasoningText?: { text?: string; signature?: string } };
|
|
22
|
+
toolUse?: {
|
|
23
|
+
toolUseId?: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
input?: Record<string, string>;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const assistantContent = (result: ConverseResult): ConverseBlock[] => {
|
|
30
|
+
const msg = result.converseMessages.find((m) => m.role === 'assistant');
|
|
31
|
+
return (msg?.content ?? []) as ConverseBlock[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('convertToConverseMessages — cross-provider reasoning (Anthropic → Bedrock)', () => {
|
|
35
|
+
it('drops Anthropic thinking/redacted_thinking on an assistant turn, keeping text and tool calls', () => {
|
|
36
|
+
const messages: BaseMessage[] = [
|
|
37
|
+
new HumanMessage('research Assort Health'),
|
|
38
|
+
new AIMessage({
|
|
39
|
+
content: [
|
|
40
|
+
{
|
|
41
|
+
type: 'thinking',
|
|
42
|
+
thinking: 'Let me hand off to the data agent.',
|
|
43
|
+
signature: 'anthropic-signature-not-valid-for-bedrock',
|
|
44
|
+
},
|
|
45
|
+
{ type: 'redacted_thinking', data: 'redacted-blob' },
|
|
46
|
+
{ type: 'text', text: 'Handing off now.' },
|
|
47
|
+
],
|
|
48
|
+
tool_calls: [
|
|
49
|
+
{
|
|
50
|
+
id: 'tooluse_transfer',
|
|
51
|
+
name: 'lc_transfer_to_data_agent',
|
|
52
|
+
args: { reason: 'need consumption data' },
|
|
53
|
+
type: 'tool_call',
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
expect(() => convertToConverseMessages(messages)).not.toThrow();
|
|
60
|
+
const content = assistantContent(convertToConverseMessages(messages));
|
|
61
|
+
|
|
62
|
+
expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
|
|
63
|
+
expect(JSON.stringify(content)).not.toContain(
|
|
64
|
+
'anthropic-signature-not-valid-for-bedrock'
|
|
65
|
+
);
|
|
66
|
+
expect(JSON.stringify(content)).not.toContain('redacted-blob');
|
|
67
|
+
|
|
68
|
+
expect(content.some((b) => b.text === 'Handing off now.')).toBe(true);
|
|
69
|
+
const toolUse = content.find((b) => b.toolUse != null);
|
|
70
|
+
expect(toolUse?.toolUse).toMatchObject({
|
|
71
|
+
toolUseId: 'tooluse_transfer',
|
|
72
|
+
name: 'lc_transfer_to_data_agent',
|
|
73
|
+
input: { reason: 'need consumption data' },
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('emits a placeholder (not empty content) when a reasoning-only turn is fully dropped', () => {
|
|
78
|
+
const messages: BaseMessage[] = [
|
|
79
|
+
new HumanMessage('hi'),
|
|
80
|
+
new AIMessage({
|
|
81
|
+
content: [
|
|
82
|
+
{ type: 'thinking', thinking: 'only thinking, no other content' },
|
|
83
|
+
],
|
|
84
|
+
}),
|
|
85
|
+
];
|
|
86
|
+
expect(() => convertToConverseMessages(messages)).not.toThrow();
|
|
87
|
+
const content = assistantContent(convertToConverseMessages(messages));
|
|
88
|
+
expect(content.length).toBeGreaterThan(0);
|
|
89
|
+
expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
|
|
90
|
+
expect(content.every((b) => typeof b.text === 'string')).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('still throws on a genuinely unknown assistant block', () => {
|
|
94
|
+
const messages: BaseMessage[] = [
|
|
95
|
+
new HumanMessage('run code'),
|
|
96
|
+
new AIMessage({
|
|
97
|
+
content: [
|
|
98
|
+
{ type: 'some_future_block_type', foo: 'bar' },
|
|
99
|
+
{ type: 'text', text: 'done' },
|
|
100
|
+
],
|
|
101
|
+
}),
|
|
102
|
+
];
|
|
103
|
+
expect(() => convertToConverseMessages(messages)).toThrow(
|
|
104
|
+
'Unsupported content block type'
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('still converts Bedrock-native reasoning_content (not dropped)', () => {
|
|
109
|
+
const messages: BaseMessage[] = [
|
|
110
|
+
new HumanMessage('hi'),
|
|
111
|
+
new AIMessage({
|
|
112
|
+
content: [
|
|
113
|
+
{
|
|
114
|
+
type: 'reasoning_content',
|
|
115
|
+
reasoningText: {
|
|
116
|
+
text: 'native bedrock reasoning',
|
|
117
|
+
signature: 'sig',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{ type: 'text', text: 'answer' },
|
|
121
|
+
],
|
|
122
|
+
}),
|
|
123
|
+
];
|
|
124
|
+
const content = assistantContent(convertToConverseMessages(messages));
|
|
125
|
+
const reasoning = content.find((b) => b.reasoningContent != null);
|
|
126
|
+
expect(reasoning).toBeDefined();
|
|
127
|
+
expect(reasoning?.reasoningContent?.reasoningText?.text).toBe(
|
|
128
|
+
'native bedrock reasoning'
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -28,6 +28,26 @@ import type {
|
|
|
28
28
|
MessageContentReasoningBlock,
|
|
29
29
|
} from '../types';
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Reasoning blocks from other providers, relative to Bedrock. Bedrock's native
|
|
33
|
+
* reasoning format is `reasoning_content`; these carry provider-specific
|
|
34
|
+
* signatures Bedrock cannot validate, so they are dropped on a cross-provider
|
|
35
|
+
* handoff (e.g. Anthropic → Bedrock) rather than crashing the conversion.
|
|
36
|
+
*/
|
|
37
|
+
const FOREIGN_REASONING_TYPES = [
|
|
38
|
+
'thinking',
|
|
39
|
+
'redacted_thinking',
|
|
40
|
+
'reasoning',
|
|
41
|
+
'think',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Bedrock Converse rejects assistant messages with no content blocks. When
|
|
46
|
+
* filtering (e.g. dropping foreign reasoning) empties an assistant turn that
|
|
47
|
+
* also has no tool calls, fall back to this placeholder text.
|
|
48
|
+
*/
|
|
49
|
+
const BEDROCK_EMPTY_TEXT_PLACEHOLDER = '_';
|
|
50
|
+
|
|
31
51
|
/**
|
|
32
52
|
* Convert a LangChain reasoning block to a Bedrock reasoning block.
|
|
33
53
|
*/
|
|
@@ -644,6 +664,15 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
|
|
|
644
664
|
type: 'default',
|
|
645
665
|
},
|
|
646
666
|
} as BedrockContentBlock);
|
|
667
|
+
} else if (FOREIGN_REASONING_TYPES.some((t) => t === block.type)) {
|
|
668
|
+
// Reasoning from another provider (Anthropic `thinking`/
|
|
669
|
+
// `redacted_thinking`, Google `reasoning`, LibreChat `think`). Bedrock's
|
|
670
|
+
// native reasoning is `reasoning_content` (handled above); a foreign
|
|
671
|
+
// block carries a signature Bedrock cannot validate, so drop it on a
|
|
672
|
+
// cross-provider handoff (e.g. Anthropic → Bedrock) rather than crash.
|
|
673
|
+
// The Bedrock model produces its own reasoning. Anything else unknown
|
|
674
|
+
// still throws below — real content must be surfaced, not dropped.
|
|
675
|
+
return;
|
|
647
676
|
} else {
|
|
648
677
|
const blockValues = Object.fromEntries(
|
|
649
678
|
Object.entries(block).filter(([key]) => key !== 'type')
|
|
@@ -672,6 +701,12 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
|
|
|
672
701
|
] as BedrockContentBlock[];
|
|
673
702
|
}
|
|
674
703
|
|
|
704
|
+
// Bedrock rejects an assistant message with no content blocks; if filtering
|
|
705
|
+
// (e.g. dropping foreign reasoning) left it empty, emit a placeholder.
|
|
706
|
+
if (assistantMsg.content == null || assistantMsg.content.length === 0) {
|
|
707
|
+
assistantMsg.content = [{ text: BEDROCK_EMPTY_TEXT_PLACEHOLDER }];
|
|
708
|
+
}
|
|
709
|
+
|
|
675
710
|
return assistantMsg;
|
|
676
711
|
}
|
|
677
712
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type * as t from '@/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reconciles a context-usage breakdown's instruction/available/message fields
|
|
5
|
+
* from the pruner's budget metrics. `messageTokens` and `availableForMessages`
|
|
6
|
+
* are DERIVED from `contextBudget` / `effectiveInstructionTokens` /
|
|
7
|
+
* `remainingContextTokens` rather than summed from the index map — that map is
|
|
8
|
+
* keyed by pre-prune indices, so summing it over the kept context would missum.
|
|
9
|
+
* Shared by the live snapshot path (`Graph.createCallModel`) and the pre-send
|
|
10
|
+
* projection (`AgentContext.projectContextUsage`) so both yield identical numbers.
|
|
11
|
+
*/
|
|
12
|
+
export function syncBudgetDerivedFields(usage: t.ContextUsageEvent): void {
|
|
13
|
+
const { breakdown, contextBudget, effectiveInstructionTokens } = usage;
|
|
14
|
+
if (effectiveInstructionTokens == null) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
breakdown.instructionTokens = effectiveInstructionTokens;
|
|
18
|
+
if (contextBudget == null) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
breakdown.availableForMessages = Math.max(
|
|
22
|
+
0,
|
|
23
|
+
contextBudget - effectiveInstructionTokens
|
|
24
|
+
);
|
|
25
|
+
if (usage.remainingContextTokens == null) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
breakdown.messageTokens = Math.max(
|
|
29
|
+
0,
|
|
30
|
+
contextBudget - effectiveInstructionTokens - usage.remainingContextTokens
|
|
31
|
+
);
|
|
32
|
+
}
|
package/src/messages/cache.ts
CHANGED
|
@@ -41,7 +41,7 @@ function deepCloneContent<T extends string | MessageContentComplex[]>(
|
|
|
41
41
|
* in downstream code (e.g., ensureThinkingBlockInMessages).
|
|
42
42
|
* For plain objects (AnthropicMessage), uses object spread.
|
|
43
43
|
*/
|
|
44
|
-
function cloneMessage<T extends MessageWithContent>(
|
|
44
|
+
export function cloneMessage<T extends MessageWithContent>(
|
|
45
45
|
message: T,
|
|
46
46
|
content: string | MessageContentComplex[]
|
|
47
47
|
): T {
|