@librechat/agents 3.2.35 → 3.2.37
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 +75 -2
- 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 +10 -26
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +16 -5
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/langfuseToolOutputTracing.cjs +7 -0
- package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +118 -7
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +44 -4
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/main.cjs +7 -0
- package/dist/cjs/messages/budget.cjs +23 -0
- package/dist/cjs/messages/budget.cjs.map +1 -0
- package/dist/cjs/messages/cache.cjs +184 -0
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/index.cjs +1 -0
- package/dist/cjs/summarization/node.cjs +1 -1
- package/dist/cjs/summarization/node.cjs.map +1 -1
- 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/cjs/tools/toolOutputReferences.cjs +28 -14
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +76 -3
- 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 +9 -25
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +16 -5
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/langfuseToolOutputTracing.mjs +7 -0
- package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +118 -7
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +44 -4
- 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 +182 -1
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/index.mjs +1 -0
- package/dist/esm/summarization/node.mjs +2 -2
- package/dist/esm/summarization/node.mjs.map +1 -1
- 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/esm/tools/toolOutputReferences.mjs +28 -14
- package/dist/esm/tools/toolOutputReferences.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 +47 -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/dist/types/types/graph.d.ts +2 -0
- package/package.json +2 -1
- package/src/agents/AgentContext.ts +105 -4
- package/src/agents/__tests__/AgentContext.test.ts +232 -9
- package/src/agents/__tests__/projection.test.ts +73 -0
- package/src/agents/projection.ts +46 -0
- package/src/graphs/Graph.ts +66 -65
- package/src/index.ts +3 -0
- package/src/langfuse.ts +38 -4
- package/src/langfuseToolOutputTracing.ts +18 -0
- package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +317 -0
- package/src/llm/anthropic/utils/message_inputs.ts +209 -19
- package/src/llm/anthropic/utils/stripPrefillCache.test.ts +111 -0
- package/src/llm/bedrock/utils/cross-provider-reasoning.test.ts +131 -0
- package/src/llm/bedrock/utils/message_inputs.test.ts +129 -0
- package/src/llm/bedrock/utils/message_inputs.ts +81 -4
- package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +103 -0
- package/src/messages/budget.ts +32 -0
- package/src/messages/cache.tail.test.ts +340 -0
- package/src/messages/cache.ts +267 -1
- package/src/messages/index.ts +1 -0
- package/src/messages/tailCacheConversion.test.ts +161 -0
- package/src/scripts/bench-prompt-cache.ts +479 -0
- package/src/specs/langfuse-config.test.ts +69 -2
- package/src/specs/langfuse-metadata.test.ts +44 -0
- package/src/specs/langfuse-tool-output-tracing.test.ts +6 -0
- package/src/summarization/node.ts +2 -2
- package/src/tools/__tests__/annotateMessagesForLLM.test.ts +50 -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
- package/src/tools/toolOutputReferences.ts +34 -20
- package/src/types/graph.ts +2 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { AnthropicMessageCreateParams } from '../types';
|
|
2
|
+
import { stripUnsupportedAssistantPrefill } from './message_inputs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* When a model disallows assistant prefill (Claude 4.6+), the trailing
|
|
6
|
+
* assistant message is stripped right before the API call. If the single tail
|
|
7
|
+
* prompt-cache breakpoint rode that assistant prefill, the survivors would lose
|
|
8
|
+
* their only message-level `cache_control` — so the strip must re-anchor the
|
|
9
|
+
* breakpoint onto the new tail.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type Msgs = AnthropicMessageCreateParams['messages'];
|
|
13
|
+
|
|
14
|
+
function cacheControlBlocks(messages: Msgs): number {
|
|
15
|
+
let n = 0;
|
|
16
|
+
for (const m of messages) {
|
|
17
|
+
if (!Array.isArray(m.content)) continue;
|
|
18
|
+
for (const b of m.content) {
|
|
19
|
+
if ('cache_control' in b) n++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return n;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('stripUnsupportedAssistantPrefill — cache re-anchoring', () => {
|
|
26
|
+
test('re-anchors the breakpoint onto the new tail when the prefill carried it', () => {
|
|
27
|
+
const request = {
|
|
28
|
+
model: 'claude-opus-4-6',
|
|
29
|
+
max_tokens: 100,
|
|
30
|
+
messages: [
|
|
31
|
+
{
|
|
32
|
+
role: 'user' as const,
|
|
33
|
+
content: [{ type: 'text' as const, text: 'q' }],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
role: 'assistant' as const,
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: 'text' as const,
|
|
40
|
+
text: 'prefill',
|
|
41
|
+
cache_control: { type: 'ephemeral' as const },
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const out = stripUnsupportedAssistantPrefill(request);
|
|
49
|
+
|
|
50
|
+
// Prefill removed, and exactly one breakpoint survives — on the new tail.
|
|
51
|
+
expect(out.messages).toHaveLength(1);
|
|
52
|
+
expect(out.messages[0].role).toBe('user');
|
|
53
|
+
expect(cacheControlBlocks(out.messages)).toBe(1);
|
|
54
|
+
const tail = out.messages[0].content as Array<{ cache_control?: unknown }>;
|
|
55
|
+
expect(tail[tail.length - 1].cache_control).toEqual({ type: 'ephemeral' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('does not add a breakpoint when caching was off (no marker present)', () => {
|
|
59
|
+
const request = {
|
|
60
|
+
model: 'claude-opus-4-6',
|
|
61
|
+
max_tokens: 100,
|
|
62
|
+
messages: [
|
|
63
|
+
{ role: 'user' as const, content: 'q' },
|
|
64
|
+
{ role: 'assistant' as const, content: 'prefill' },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const out = stripUnsupportedAssistantPrefill(request);
|
|
69
|
+
|
|
70
|
+
expect(out.messages).toHaveLength(1);
|
|
71
|
+
expect(cacheControlBlocks(out.messages)).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('leaves a surviving breakpoint untouched (no double-anchor)', () => {
|
|
75
|
+
const request = {
|
|
76
|
+
model: 'claude-opus-4-6',
|
|
77
|
+
max_tokens: 100,
|
|
78
|
+
messages: [
|
|
79
|
+
{
|
|
80
|
+
role: 'user' as const,
|
|
81
|
+
content: [
|
|
82
|
+
{
|
|
83
|
+
type: 'text' as const,
|
|
84
|
+
text: 'q',
|
|
85
|
+
cache_control: { type: 'ephemeral' as const },
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{ role: 'assistant' as const, content: 'prefill' },
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const out = stripUnsupportedAssistantPrefill(request);
|
|
94
|
+
|
|
95
|
+
expect(out.messages).toHaveLength(1);
|
|
96
|
+
expect(cacheControlBlocks(out.messages)).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('older models keep the assistant prefill (no strip, no re-anchor)', () => {
|
|
100
|
+
const request = {
|
|
101
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
102
|
+
max_tokens: 100,
|
|
103
|
+
messages: [
|
|
104
|
+
{ role: 'user' as const, content: 'q' },
|
|
105
|
+
{ role: 'assistant' as const, content: '{' },
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
expect(stripUnsupportedAssistantPrefill(request)).toBe(request);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
* Native-Bedrock reasoning serialization. A `reasoning_content` block whose
|
|
7
|
+
* `reasoningText.text` is null/empty (e.g. a signature-only block that never
|
|
8
|
+
* merged with its text) is invalid for Bedrock Converse — it rejects with
|
|
9
|
+
* `...reasoningContent.reasoningText.text ... Member must not be null`. Such a
|
|
10
|
+
* block must be dropped on replay rather than sent; a block carrying real text
|
|
11
|
+
* is still converted.
|
|
12
|
+
*/
|
|
13
|
+
type ConverseResult = ReturnType<typeof convertToConverseMessages>;
|
|
14
|
+
|
|
15
|
+
/** Minimal view of a converted Bedrock Converse content block the assertions read. */
|
|
16
|
+
interface ConverseBlock {
|
|
17
|
+
text?: string;
|
|
18
|
+
reasoningContent?: { reasoningText?: { text?: string; signature?: string } };
|
|
19
|
+
toolUse?: {
|
|
20
|
+
toolUseId?: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
input?: Record<string, string>;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const assistantContent = (result: ConverseResult): ConverseBlock[] => {
|
|
27
|
+
const msg = result.converseMessages.find((m) => m.role === 'assistant');
|
|
28
|
+
return (msg?.content ?? []) as ConverseBlock[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('convertToConverseMessages — native Bedrock reasoning serialization', () => {
|
|
32
|
+
it('drops a signature-only reasoning block, keeping text and tool calls', () => {
|
|
33
|
+
const messages: BaseMessage[] = [
|
|
34
|
+
new HumanMessage('what data do you have?'),
|
|
35
|
+
new AIMessage({
|
|
36
|
+
content: [
|
|
37
|
+
{
|
|
38
|
+
type: 'reasoning_content',
|
|
39
|
+
reasoningText: { signature: 'sig-abc' },
|
|
40
|
+
},
|
|
41
|
+
{ type: 'text', text: 'Let me check your databases.' },
|
|
42
|
+
],
|
|
43
|
+
tool_calls: [
|
|
44
|
+
{
|
|
45
|
+
id: 'tooluse_list',
|
|
46
|
+
name: 'list_databases',
|
|
47
|
+
args: {},
|
|
48
|
+
type: 'tool_call',
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
}),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
expect(() => convertToConverseMessages(messages)).not.toThrow();
|
|
55
|
+
const content = assistantContent(convertToConverseMessages(messages));
|
|
56
|
+
|
|
57
|
+
expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
|
|
58
|
+
expect(JSON.stringify(content)).not.toContain('sig-abc');
|
|
59
|
+
expect(content.some((b) => b.text === 'Let me check your databases.')).toBe(
|
|
60
|
+
true
|
|
61
|
+
);
|
|
62
|
+
const toolUse = content.find((b) => b.toolUse != null);
|
|
63
|
+
expect(toolUse?.toolUse).toMatchObject({
|
|
64
|
+
toolUseId: 'tooluse_list',
|
|
65
|
+
name: 'list_databases',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('drops a reasoning block whose text is empty', () => {
|
|
70
|
+
const messages: BaseMessage[] = [
|
|
71
|
+
new HumanMessage('hi'),
|
|
72
|
+
new AIMessage({
|
|
73
|
+
content: [
|
|
74
|
+
{
|
|
75
|
+
type: 'reasoning_content',
|
|
76
|
+
reasoningText: { text: '', signature: 'sig' },
|
|
77
|
+
},
|
|
78
|
+
{ type: 'text', text: 'answer' },
|
|
79
|
+
],
|
|
80
|
+
}),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const content = assistantContent(convertToConverseMessages(messages));
|
|
84
|
+
expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
|
|
85
|
+
expect(content.some((b) => b.text === 'answer')).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('emits a placeholder (not empty content) when the only block is a signature-only reasoning block', () => {
|
|
89
|
+
const messages: BaseMessage[] = [
|
|
90
|
+
new HumanMessage('hi'),
|
|
91
|
+
new AIMessage({
|
|
92
|
+
content: [
|
|
93
|
+
{ type: 'reasoning_content', reasoningText: { signature: 'sig' } },
|
|
94
|
+
],
|
|
95
|
+
}),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
expect(() => convertToConverseMessages(messages)).not.toThrow();
|
|
99
|
+
const content = assistantContent(convertToConverseMessages(messages));
|
|
100
|
+
expect(content.length).toBeGreaterThan(0);
|
|
101
|
+
expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
|
|
102
|
+
expect(content.every((b) => typeof b.text === 'string')).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('still converts a reasoning block that carries text (not dropped)', () => {
|
|
106
|
+
const messages: BaseMessage[] = [
|
|
107
|
+
new HumanMessage('hi'),
|
|
108
|
+
new AIMessage({
|
|
109
|
+
content: [
|
|
110
|
+
{
|
|
111
|
+
type: 'reasoning_content',
|
|
112
|
+
reasoningText: {
|
|
113
|
+
text: 'native bedrock reasoning',
|
|
114
|
+
signature: 'sig',
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{ type: 'text', text: 'answer' },
|
|
118
|
+
],
|
|
119
|
+
}),
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const content = assistantContent(convertToConverseMessages(messages));
|
|
123
|
+
const reasoning = content.find((b) => b.reasoningContent != null);
|
|
124
|
+
expect(reasoning).toBeDefined();
|
|
125
|
+
expect(reasoning?.reasoningContent?.reasoningText?.text).toBe(
|
|
126
|
+
'native bedrock reasoning'
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -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
|
*/
|
|
@@ -52,6 +72,22 @@ export function langchainReasoningBlockToBedrockReasoningBlock(
|
|
|
52
72
|
throw new Error('Invalid reasoning content');
|
|
53
73
|
}
|
|
54
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Whether a reasoning block can be serialized to a valid Bedrock
|
|
77
|
+
* `reasoningContent`. Bedrock Converse rejects `reasoningText` with a null/empty
|
|
78
|
+
* `text` (e.g. a signature-only block that never merged with its text), so such
|
|
79
|
+
* blocks must be dropped rather than sent.
|
|
80
|
+
*/
|
|
81
|
+
function isSerializableBedrockReasoningBlock(
|
|
82
|
+
content: MessageContentReasoningBlock
|
|
83
|
+
): boolean {
|
|
84
|
+
if (content.reasoningText != null) {
|
|
85
|
+
const text = content.reasoningText.text;
|
|
86
|
+
return text != null && text !== '';
|
|
87
|
+
}
|
|
88
|
+
return content.redactedContent != null && content.redactedContent !== '';
|
|
89
|
+
}
|
|
90
|
+
|
|
55
91
|
/**
|
|
56
92
|
* Concatenate consecutive reasoning blocks in content array.
|
|
57
93
|
*/
|
|
@@ -633,10 +669,17 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
|
|
|
633
669
|
contentBlocks.push({ text });
|
|
634
670
|
}
|
|
635
671
|
} else if (block.type === 'reasoning_content') {
|
|
672
|
+
const reasoningBlock = block as MessageContentReasoningBlock;
|
|
673
|
+
// Bedrock Converse rejects reasoningContent whose reasoningText.text is
|
|
674
|
+
// null/empty (a signature-only block that never merged with its text).
|
|
675
|
+
// Drop it rather than emit an invalid request; the empty-turn
|
|
676
|
+
// placeholder below covers a turn left with no content.
|
|
677
|
+
if (!isSerializableBedrockReasoningBlock(reasoningBlock)) {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
636
680
|
contentBlocks.push({
|
|
637
|
-
reasoningContent:
|
|
638
|
-
|
|
639
|
-
),
|
|
681
|
+
reasoningContent:
|
|
682
|
+
langchainReasoningBlockToBedrockReasoningBlock(reasoningBlock),
|
|
640
683
|
} as BedrockContentBlock);
|
|
641
684
|
} else if (isDefaultCachePoint(block)) {
|
|
642
685
|
contentBlocks.push({
|
|
@@ -644,6 +687,15 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
|
|
|
644
687
|
type: 'default',
|
|
645
688
|
},
|
|
646
689
|
} as BedrockContentBlock);
|
|
690
|
+
} else if (FOREIGN_REASONING_TYPES.some((t) => t === block.type)) {
|
|
691
|
+
// Reasoning from another provider (Anthropic `thinking`/
|
|
692
|
+
// `redacted_thinking`, Google `reasoning`, LibreChat `think`). Bedrock's
|
|
693
|
+
// native reasoning is `reasoning_content` (handled above); a foreign
|
|
694
|
+
// block carries a signature Bedrock cannot validate, so drop it on a
|
|
695
|
+
// cross-provider handoff (e.g. Anthropic → Bedrock) rather than crash.
|
|
696
|
+
// The Bedrock model produces its own reasoning. Anything else unknown
|
|
697
|
+
// still throws below — real content must be surfaced, not dropped.
|
|
698
|
+
return;
|
|
647
699
|
} else {
|
|
648
700
|
const blockValues = Object.fromEntries(
|
|
649
701
|
Object.entries(block).filter(([key]) => key !== 'type')
|
|
@@ -672,6 +724,12 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
|
|
|
672
724
|
] as BedrockContentBlock[];
|
|
673
725
|
}
|
|
674
726
|
|
|
727
|
+
// Bedrock rejects an assistant message with no content blocks; if filtering
|
|
728
|
+
// (e.g. dropping foreign reasoning) left it empty, emit a placeholder.
|
|
729
|
+
if (assistantMsg.content == null || assistantMsg.content.length === 0) {
|
|
730
|
+
assistantMsg.content = [{ text: BEDROCK_EMPTY_TEXT_PLACEHOLDER }];
|
|
731
|
+
}
|
|
732
|
+
|
|
675
733
|
return assistantMsg;
|
|
676
734
|
}
|
|
677
735
|
|
|
@@ -797,15 +855,34 @@ function convertToolMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
|
|
|
797
855
|
content = [{ text: String(msg.content) }];
|
|
798
856
|
}
|
|
799
857
|
|
|
858
|
+
// A `cachePoint` is a message-level ContentBlock — it is NOT a valid
|
|
859
|
+
// ToolResultContentBlock. A tail prompt-cache breakpoint that anchors on a
|
|
860
|
+
// tool result therefore ends up nested inside `toolResult.content`, which
|
|
861
|
+
// Bedrock silently ignores (no cache write, no cache read). Hoist any
|
|
862
|
+
// cachePoint(s) out of the tool result body so they sit as siblings after
|
|
863
|
+
// it, which is the only position Bedrock honors.
|
|
864
|
+
const toolResultContent: BedrockContentBlock[] = [];
|
|
865
|
+
const trailingCachePoints: BedrockContentBlock[] = [];
|
|
866
|
+
for (const block of content) {
|
|
867
|
+
if (isDefaultCachePoint(block)) {
|
|
868
|
+
trailingCachePoints.push({
|
|
869
|
+
cachePoint: { type: 'default' },
|
|
870
|
+
} as BedrockContentBlock);
|
|
871
|
+
} else {
|
|
872
|
+
toolResultContent.push(block);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
800
876
|
return {
|
|
801
877
|
role: 'user',
|
|
802
878
|
content: [
|
|
803
879
|
{
|
|
804
880
|
toolResult: {
|
|
805
881
|
toolUseId: toolCallId,
|
|
806
|
-
content:
|
|
882
|
+
content: toolResultContent as { text: string }[],
|
|
807
883
|
},
|
|
808
884
|
},
|
|
885
|
+
...trailingCachePoints,
|
|
809
886
|
],
|
|
810
887
|
};
|
|
811
888
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { HumanMessage, AIMessage, ToolMessage } from '@langchain/core/messages';
|
|
2
|
+
import type {
|
|
3
|
+
BaseMessage,
|
|
4
|
+
MessageContentComplex,
|
|
5
|
+
} from '@langchain/core/messages';
|
|
6
|
+
import { addBedrockTailCacheControl } from '@/messages/cache';
|
|
7
|
+
import { convertToConverseMessages } from './message_inputs';
|
|
8
|
+
import { toLangChainContent } from '@/messages/langchain';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A Bedrock `cachePoint` is a message-level ContentBlock and is NOT a valid
|
|
12
|
+
* `ToolResultContentBlock`. When the single tail prompt-cache breakpoint
|
|
13
|
+
* anchors on a tool result (the common agent-loop shape), the cachePoint must
|
|
14
|
+
* be hoisted out of `toolResult.content` to a message-level sibling — otherwise
|
|
15
|
+
* Bedrock silently drops the breakpoint (no cache write, no cache read),
|
|
16
|
+
* verified live against Bedrock Converse.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface ConverseBlock {
|
|
20
|
+
text?: string;
|
|
21
|
+
cachePoint?: { type?: string };
|
|
22
|
+
toolResult?: {
|
|
23
|
+
toolUseId?: string;
|
|
24
|
+
content?: Array<{ text?: string; cachePoint?: { type?: string } }>;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toolUserMessage(
|
|
29
|
+
result: ReturnType<typeof convertToConverseMessages>
|
|
30
|
+
): ConverseBlock[] {
|
|
31
|
+
const msg = result.converseMessages.find(
|
|
32
|
+
(m) =>
|
|
33
|
+
m.role === 'user' && m.content?.some((c) => 'toolResult' in c) === true
|
|
34
|
+
);
|
|
35
|
+
return (msg?.content ?? []) as ConverseBlock[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('convertToConverseMessages — tool-result cachePoint hoisting', () => {
|
|
39
|
+
it('hoists a cachePoint out of toolResult.content to a message-level sibling', () => {
|
|
40
|
+
const toolMsg = new ToolMessage({
|
|
41
|
+
tool_call_id: 't1',
|
|
42
|
+
content: toLangChainContent([
|
|
43
|
+
{ type: 'text', text: 'result body' },
|
|
44
|
+
{ cachePoint: { type: 'default' } },
|
|
45
|
+
] as MessageContentComplex[]),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const { converseMessages } = convertToConverseMessages([
|
|
49
|
+
new HumanMessage('go'),
|
|
50
|
+
toolMsg,
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const content = toolUserMessage({ converseMessages, converseSystem: [] });
|
|
54
|
+
|
|
55
|
+
// toolResult body must NOT contain the cachePoint
|
|
56
|
+
const toolResult = content.find((c) => 'toolResult' in c)?.toolResult;
|
|
57
|
+
expect(toolResult?.content?.some((b) => 'cachePoint' in b)).toBe(false);
|
|
58
|
+
expect(toolResult?.content).toEqual([{ text: 'result body' }]);
|
|
59
|
+
|
|
60
|
+
// cachePoint must be a sibling AFTER the toolResult block
|
|
61
|
+
expect(content[content.length - 1]).toEqual({
|
|
62
|
+
cachePoint: { type: 'default' },
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('leaves tool results without a cachePoint untouched', () => {
|
|
67
|
+
const { converseMessages } = convertToConverseMessages([
|
|
68
|
+
new HumanMessage('go'),
|
|
69
|
+
new ToolMessage({ tool_call_id: 't1', content: 'plain result' }),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const content = toolUserMessage({ converseMessages, converseSystem: [] });
|
|
73
|
+
expect(content).toEqual([
|
|
74
|
+
{ toolResult: { toolUseId: 't1', content: [{ text: 'plain result' }] } },
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('end-to-end: tail breakpoint on a string tool result renders as a valid sibling cachePoint', () => {
|
|
79
|
+
const messages: BaseMessage[] = [
|
|
80
|
+
new HumanMessage('What is 15 * 23? Use the calculator.'),
|
|
81
|
+
new AIMessage({
|
|
82
|
+
content: 'Calculating.',
|
|
83
|
+
tool_calls: [
|
|
84
|
+
{ id: 't1', name: 'calculator', args: { expression: '15 * 23' } },
|
|
85
|
+
],
|
|
86
|
+
}),
|
|
87
|
+
new ToolMessage({ tool_call_id: 't1', content: '345' }),
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const cached = addBedrockTailCacheControl(messages);
|
|
91
|
+
const { converseMessages } = convertToConverseMessages(cached);
|
|
92
|
+
|
|
93
|
+
const content = toolUserMessage({ converseMessages, converseSystem: [] });
|
|
94
|
+
const toolResult = content.find((c) => 'toolResult' in c)?.toolResult;
|
|
95
|
+
|
|
96
|
+
// Exactly one cachePoint, at the message level, never nested in the body.
|
|
97
|
+
expect(toolResult?.content?.some((b) => 'cachePoint' in b)).toBe(false);
|
|
98
|
+
expect(content.filter((c) => 'cachePoint' in c)).toHaveLength(1);
|
|
99
|
+
expect(content[content.length - 1]).toEqual({
|
|
100
|
+
cachePoint: { type: 'default' },
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -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
|
+
}
|