@librechat/agents 3.2.31 → 3.2.32
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/llm/anthropic/utils/message_inputs.cjs +38 -3
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_outputs.cjs +5 -1
- package/dist/cjs/llm/anthropic/utils/message_outputs.cjs.map +1 -1
- package/dist/cjs/messages/cache.cjs +21 -1
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +5 -0
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +38 -3
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_outputs.mjs +5 -1
- package/dist/esm/llm/anthropic/utils/message_outputs.mjs.map +1 -1
- package/dist/esm/messages/cache.mjs +21 -1
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +5 -0
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/types/messages/format.d.ts +5 -0
- package/package.json +8 -8
- package/src/llm/anthropic/llm.spec.ts +36 -0
- package/src/llm/anthropic/utils/message_inputs.ts +45 -3
- package/src/llm/anthropic/utils/message_outputs.ts +6 -2
- package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -0
- package/src/messages/cache.test.ts +122 -0
- package/src/messages/cache.ts +25 -1
- package/src/messages/format.ts +9 -0
- package/src/messages/formatAgentMessages.skills.test.ts +100 -0
|
@@ -3199,3 +3199,39 @@ describe('Opus 4.6', () => {
|
|
|
3199
3199
|
});
|
|
3200
3200
|
});
|
|
3201
3201
|
});
|
|
3202
|
+
|
|
3203
|
+
describe('Tool input survives message conversion', () => {
|
|
3204
|
+
// Adapted from @langchain/anthropic's "converting messages doesn't drop tool input".
|
|
3205
|
+
// Guards the core >= 1.1.46 streaming-aggregation regression where the tool_use
|
|
3206
|
+
// content block's input was emptied (and re-serialization dropped it).
|
|
3207
|
+
test('converting messages does not drop tool input (live)', async () => {
|
|
3208
|
+
const jokeTool = {
|
|
3209
|
+
name: 'generate_random_joke',
|
|
3210
|
+
description: 'Generate a random joke.',
|
|
3211
|
+
schema: z.object({
|
|
3212
|
+
prompt: z.string().describe('The prompt to generate the joke for.'),
|
|
3213
|
+
}),
|
|
3214
|
+
};
|
|
3215
|
+
const model = new ChatAnthropic({
|
|
3216
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
3217
|
+
temperature: 0,
|
|
3218
|
+
}).bindTools([jokeTool]);
|
|
3219
|
+
|
|
3220
|
+
const result = await model.invoke([
|
|
3221
|
+
new HumanMessage(
|
|
3222
|
+
'Generate three (3) random jokes. Use the generate_random_joke tool and call it three times before responding. This is very important.'
|
|
3223
|
+
),
|
|
3224
|
+
]);
|
|
3225
|
+
expect(result.tool_calls?.length ?? 0).toBeGreaterThan(0);
|
|
3226
|
+
|
|
3227
|
+
const converted = _convertMessagesToAnthropicPayload([result]);
|
|
3228
|
+
const toolUseBlocks = (
|
|
3229
|
+
converted.messages[0].content as unknown as Array<Record<string, unknown>>
|
|
3230
|
+
).filter((block) => block.type === 'tool_use');
|
|
3231
|
+
expect(toolUseBlocks.length).toBeGreaterThan(0);
|
|
3232
|
+
for (const block of toolUseBlocks) {
|
|
3233
|
+
expect(block.input).toBeDefined();
|
|
3234
|
+
expect((block.input as Record<string, unknown>).prompt).toBeDefined();
|
|
3235
|
+
}
|
|
3236
|
+
});
|
|
3237
|
+
});
|
|
@@ -524,6 +524,17 @@ function _formatContent(message: BaseMessage) {
|
|
|
524
524
|
return null;
|
|
525
525
|
}
|
|
526
526
|
|
|
527
|
+
// Core's v1 streaming aggregation can leave a partial tool-input delta as a
|
|
528
|
+
// standalone block typed `text` carrying `input` but no `text`. The assembled
|
|
529
|
+
// input is restored on the tool_use block from `message.tool_calls`, so drop it.
|
|
530
|
+
if (
|
|
531
|
+
contentPart.type === 'text' &&
|
|
532
|
+
'input' in contentPart &&
|
|
533
|
+
!('text' in contentPart)
|
|
534
|
+
) {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
|
|
527
538
|
if (isDataContentBlock(contentPart)) {
|
|
528
539
|
return convertToProviderContentBlock(
|
|
529
540
|
contentPart,
|
|
@@ -617,9 +628,9 @@ function _formatContent(message: BaseMessage) {
|
|
|
617
628
|
}
|
|
618
629
|
|
|
619
630
|
if (contentPartCopy.type === 'input_json_delta') {
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
|
|
631
|
+
// Orphaned partial tool-input delta with no id of its own. The assembled
|
|
632
|
+
// input is restored on the tool_use block from `message.tool_calls`; drop it.
|
|
633
|
+
return null;
|
|
623
634
|
}
|
|
624
635
|
|
|
625
636
|
if (
|
|
@@ -631,6 +642,37 @@ function _formatContent(message: BaseMessage) {
|
|
|
631
642
|
contentPartCopy.type = 'server_tool_use';
|
|
632
643
|
}
|
|
633
644
|
|
|
645
|
+
// Core's streaming aggregation can leave the inline tool_use input empty
|
|
646
|
+
// (the assembled arguments live in `message.tool_calls` or, for persisted
|
|
647
|
+
// messages, in sibling input_json_delta blocks). Restore it when missing.
|
|
648
|
+
if (
|
|
649
|
+
contentPartCopy.type === 'tool_use' &&
|
|
650
|
+
typeof contentPartCopy.id === 'string' &&
|
|
651
|
+
(contentPartCopy.input === '' || contentPartCopy.input == null)
|
|
652
|
+
) {
|
|
653
|
+
const matchingToolCall = isAIMessage(message)
|
|
654
|
+
? message.tool_calls?.find((toolCall) => toolCall.id === contentPartCopy.id)
|
|
655
|
+
: undefined;
|
|
656
|
+
if (matchingToolCall) {
|
|
657
|
+
contentPartCopy.input = matchingToolCall.args;
|
|
658
|
+
} else {
|
|
659
|
+
const blockIndex = (contentPart as Record<string, unknown>).index;
|
|
660
|
+
const merged = contentParts
|
|
661
|
+
.filter((part) => {
|
|
662
|
+
const p = part as Record<string, unknown>;
|
|
663
|
+
return (
|
|
664
|
+
p.type === 'input_json_delta' &&
|
|
665
|
+
p.index === blockIndex &&
|
|
666
|
+
typeof p.input === 'string'
|
|
667
|
+
);
|
|
668
|
+
})
|
|
669
|
+
.reduce((acc, part) => acc + (part as Record<string, unknown>).input, '');
|
|
670
|
+
if (merged !== '') {
|
|
671
|
+
contentPartCopy.input = merged;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
634
676
|
if ('input' in contentPartCopy) {
|
|
635
677
|
// Anthropic tool use inputs should be valid objects, when applicable.
|
|
636
678
|
if (typeof contentPartCopy.input === 'string') {
|
|
@@ -203,10 +203,14 @@ export function _makeMessageChunkFromAnthropicEvent(
|
|
|
203
203
|
) {
|
|
204
204
|
const content = [
|
|
205
205
|
{
|
|
206
|
+
// No `type`: core's streaming aggregation merges this partial input into the
|
|
207
|
+
// sibling tool_use/server_tool_use block at the same index, keeping its type.
|
|
208
|
+
// A typed delta block won't merge under core >= 1.1.46 ("keep different block
|
|
209
|
+
// types separate"), which would orphan the input and empty the tool_use input.
|
|
206
210
|
index: data.index,
|
|
207
211
|
input: data.delta.partial_json,
|
|
208
|
-
|
|
209
|
-
},
|
|
212
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
213
|
+
} as any,
|
|
210
214
|
];
|
|
211
215
|
return {
|
|
212
216
|
chunk: new AIMessageChunk({
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
3
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
4
|
+
import { _convertMessagesToAnthropicPayload } from './message_inputs';
|
|
5
|
+
import { _makeMessageChunkFromAnthropicEvent } from './message_outputs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Regression for @langchain/core >= 1.1.46 streaming aggregation: a tool call's
|
|
9
|
+
* input_json_delta is kept as a separate content block and v1-cast to a `text`
|
|
10
|
+
* block carrying `input` but no `text`, leaving the sibling tool_use block with an
|
|
11
|
+
* empty inline input. The assembled arguments live on `message.tool_calls`.
|
|
12
|
+
* Re-serializing such a message previously threw "Unsupported message content format".
|
|
13
|
+
*/
|
|
14
|
+
describe('_convertMessagesToAnthropicPayload — aggregated streaming tool input', () => {
|
|
15
|
+
const buildHistory = (): BaseMessage[] => [
|
|
16
|
+
new HumanMessage('what is 12345 * 6789?'),
|
|
17
|
+
new AIMessage({
|
|
18
|
+
content: [
|
|
19
|
+
{ type: 'text', text: 'Let me calculate that.' },
|
|
20
|
+
// tool_use block left with empty inline input by aggregation
|
|
21
|
+
{
|
|
22
|
+
type: 'tool_use',
|
|
23
|
+
id: 'toolu_calc',
|
|
24
|
+
name: 'calculator',
|
|
25
|
+
input: '',
|
|
26
|
+
index: 0,
|
|
27
|
+
} as any,
|
|
28
|
+
// orphaned input delta, v1-cast to `text` with `input` and no `text`
|
|
29
|
+
{ type: 'text', index: 0, input: '{"input": "12345 * 6789"}' } as any,
|
|
30
|
+
],
|
|
31
|
+
tool_calls: [
|
|
32
|
+
{
|
|
33
|
+
id: 'toolu_calc',
|
|
34
|
+
name: 'calculator',
|
|
35
|
+
args: { input: '12345 * 6789' },
|
|
36
|
+
type: 'tool_call',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
it('does not throw on the orphaned text-with-input block', () => {
|
|
43
|
+
expect(() => _convertMessagesToAnthropicPayload(buildHistory())).not.toThrow();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('restores tool_use input from message.tool_calls and drops the orphan block', () => {
|
|
47
|
+
const payload = _convertMessagesToAnthropicPayload(buildHistory());
|
|
48
|
+
const assistant = payload.messages.find((m: any) => m.role === 'assistant');
|
|
49
|
+
expect(assistant).toBeDefined();
|
|
50
|
+
const blocks = assistant!.content as any[];
|
|
51
|
+
|
|
52
|
+
const toolUse = blocks.find((b) => b.type === 'tool_use');
|
|
53
|
+
expect(toolUse).toMatchObject({
|
|
54
|
+
type: 'tool_use',
|
|
55
|
+
id: 'toolu_calc',
|
|
56
|
+
name: 'calculator',
|
|
57
|
+
input: { input: '12345 * 6789' },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// No leftover delta: no text block carrying `input`, no input_json_delta.
|
|
61
|
+
expect(
|
|
62
|
+
blocks.find(
|
|
63
|
+
(b) => (b.type === 'text' && 'input' in b) || b.type === 'input_json_delta'
|
|
64
|
+
)
|
|
65
|
+
).toBeUndefined();
|
|
66
|
+
|
|
67
|
+
// The real assistant text is preserved.
|
|
68
|
+
expect(
|
|
69
|
+
blocks.some((b) => b.type === 'text' && b.text === 'Let me calculate that.')
|
|
70
|
+
).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not overwrite a tool_use block that already has inline input', () => {
|
|
74
|
+
const history: BaseMessage[] = [
|
|
75
|
+
new HumanMessage('hi'),
|
|
76
|
+
new AIMessage({
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
type: 'tool_use',
|
|
80
|
+
id: 'toolu_x',
|
|
81
|
+
name: 'calculator',
|
|
82
|
+
input: { input: '2 + 2' },
|
|
83
|
+
} as any,
|
|
84
|
+
],
|
|
85
|
+
tool_calls: [
|
|
86
|
+
{
|
|
87
|
+
id: 'toolu_x',
|
|
88
|
+
name: 'calculator',
|
|
89
|
+
args: { input: '999' },
|
|
90
|
+
type: 'tool_call',
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
}),
|
|
94
|
+
];
|
|
95
|
+
const payload = _convertMessagesToAnthropicPayload(history);
|
|
96
|
+
const assistant = payload.messages.find((m: any) => m.role === 'assistant');
|
|
97
|
+
const toolUse = (assistant!.content as any[]).find((b) => b.type === 'tool_use');
|
|
98
|
+
expect(toolUse.input).toEqual({ input: '2 + 2' });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Adapted from @langchain/anthropic's
|
|
102
|
+
// "partial tool input is correctly merged before calling Anthropic API".
|
|
103
|
+
it('merges sibling input_json_delta blocks into tool_use input (persisted, no tool_calls)', () => {
|
|
104
|
+
const messages: BaseMessage[] = [
|
|
105
|
+
new HumanMessage('What\'s the weather in Seattle tomorrow?'),
|
|
106
|
+
new AIMessage({
|
|
107
|
+
content: [
|
|
108
|
+
{ type: 'text', index: 1, text: 'I need to call the get_weather tool' },
|
|
109
|
+
{ type: 'tool_use', index: 2, name: 'get_weather', id: 'tool_call_id', input: '' },
|
|
110
|
+
{ type: 'input_json_delta', index: 2, input: '{"city": "' },
|
|
111
|
+
{ type: 'input_json_delta', index: 2, input: 'Seattle", "da' },
|
|
112
|
+
{ type: 'input_json_delta', index: 2, input: 'te": "to' },
|
|
113
|
+
{ type: 'input_json_delta', index: 2, input: 'morrow"}' },
|
|
114
|
+
] as any,
|
|
115
|
+
}),
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const payload = _convertMessagesToAnthropicPayload(messages);
|
|
119
|
+
const assistant = payload.messages.find((m: any) => m.role === 'assistant');
|
|
120
|
+
const blocks = assistant!.content as any[];
|
|
121
|
+
expect(blocks.filter((b) => b.type === 'input_json_delta')).toHaveLength(0);
|
|
122
|
+
const toolUse = blocks.find((b) => b.type === 'tool_use');
|
|
123
|
+
expect(toolUse).toMatchObject({
|
|
124
|
+
type: 'tool_use',
|
|
125
|
+
name: 'get_weather',
|
|
126
|
+
id: 'tool_call_id',
|
|
127
|
+
input: { city: 'Seattle', date: 'tomorrow' },
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('_makeMessageChunkFromAnthropicEvent — streamed tool input merges into content', () => {
|
|
133
|
+
const fields = { streamUsage: true, coerceContentToString: false };
|
|
134
|
+
|
|
135
|
+
it('emits input deltas without a type so aggregation merges them into the tool_use block', () => {
|
|
136
|
+
const events: any[] = [
|
|
137
|
+
{
|
|
138
|
+
type: 'content_block_start',
|
|
139
|
+
index: 0,
|
|
140
|
+
content_block: { type: 'tool_use', id: 'toolu_1', name: 'calculator', input: {} },
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
type: 'content_block_delta',
|
|
144
|
+
index: 0,
|
|
145
|
+
delta: { type: 'input_json_delta', partial_json: '{"input"' },
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
type: 'content_block_delta',
|
|
149
|
+
index: 0,
|
|
150
|
+
delta: { type: 'input_json_delta', partial_json: ': "2 + 2"}' },
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
const chunks = events
|
|
154
|
+
.map((e) => _makeMessageChunkFromAnthropicEvent(e, fields)?.chunk)
|
|
155
|
+
.filter((c): c is NonNullable<typeof c> => c != null);
|
|
156
|
+
|
|
157
|
+
// input-delta chunks must not carry a `type` (so core merges them by index
|
|
158
|
+
// into the sibling tool_use/server_tool_use block rather than orphaning them)
|
|
159
|
+
const deltaBlocks = chunks
|
|
160
|
+
.slice(1)
|
|
161
|
+
.flatMap((c) => (Array.isArray(c.content) ? (c.content as any[]) : []))
|
|
162
|
+
.filter((b) => 'input' in b);
|
|
163
|
+
expect(deltaBlocks.length).toBeGreaterThan(0);
|
|
164
|
+
deltaBlocks.forEach((b) => expect('type' in b).toBe(false));
|
|
165
|
+
|
|
166
|
+
// aggregate the chunks the way core does during streaming
|
|
167
|
+
const merged = chunks.reduce((acc, c) => acc.concat(c));
|
|
168
|
+
const blocks = merged.content as any[];
|
|
169
|
+
|
|
170
|
+
const toolUse = blocks.find((b) => b.type === 'tool_use');
|
|
171
|
+
expect(toolUse).toMatchObject({ type: 'tool_use', id: 'toolu_1', name: 'calculator' });
|
|
172
|
+
const parsed =
|
|
173
|
+
typeof toolUse.input === 'string' ? JSON.parse(toolUse.input) : toolUse.input;
|
|
174
|
+
expect(parsed).toEqual({ input: '2 + 2' });
|
|
175
|
+
|
|
176
|
+
// no orphaned delta block survives aggregation
|
|
177
|
+
expect(blocks.filter((b) => b.type !== 'tool_use' && 'input' in b)).toHaveLength(0);
|
|
178
|
+
|
|
179
|
+
// tool_calls remain correctly aggregated
|
|
180
|
+
expect(merged.tool_calls?.[0]).toMatchObject({
|
|
181
|
+
id: 'toolu_1',
|
|
182
|
+
name: 'calculator',
|
|
183
|
+
args: { input: '2 + 2' },
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
stripBedrockCacheControl,
|
|
14
14
|
addBedrockCacheControl,
|
|
15
15
|
addCacheControl,
|
|
16
|
+
addCacheControlToStablePrefixMessages,
|
|
16
17
|
} from './cache';
|
|
17
18
|
import { _convertMessagesToOpenAIParams } from '@/llm/openai/utils';
|
|
18
19
|
import { toLangChainContent } from './langchain';
|
|
@@ -767,6 +768,127 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
|
|
|
767
768
|
});
|
|
768
769
|
});
|
|
769
770
|
|
|
771
|
+
describe('synthetic skill/meta messages are not cache-anchored', () => {
|
|
772
|
+
const hasAnthropicMarker = (m: BaseMessage): boolean =>
|
|
773
|
+
Array.isArray(m.content) &&
|
|
774
|
+
m.content.some((block) => 'cache_control' in block);
|
|
775
|
+
|
|
776
|
+
const hasBedrockCachePoint = (m: BaseMessage): boolean =>
|
|
777
|
+
Array.isArray(m.content) &&
|
|
778
|
+
m.content.some((block) => 'cachePoint' in block);
|
|
779
|
+
|
|
780
|
+
const skillBody = (skillName: string, content = 'SKILL BODY'): HumanMessage =>
|
|
781
|
+
new HumanMessage({
|
|
782
|
+
content,
|
|
783
|
+
additional_kwargs: { isMeta: true, source: 'skill', skillName },
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('Anthropic: skips a trailing synthetic skill message; markers land on the real user messages', () => {
|
|
787
|
+
const messages: BaseMessage[] = [
|
|
788
|
+
new HumanMessage('First real question'),
|
|
789
|
+
new AIMessage('Answer'),
|
|
790
|
+
new HumanMessage('Second real question'),
|
|
791
|
+
skillBody('pdf-analyzer'),
|
|
792
|
+
];
|
|
793
|
+
|
|
794
|
+
const result = addCacheControl<BaseMessage>(messages);
|
|
795
|
+
|
|
796
|
+
expect(hasAnthropicMarker(result[3])).toBe(false);
|
|
797
|
+
expect(hasAnthropicMarker(result[2])).toBe(true);
|
|
798
|
+
expect(hasAnthropicMarker(result[0])).toBe(true);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('Anthropic: strips a stale marker from a synthetic skill message without re-adding one', () => {
|
|
802
|
+
const stale = new HumanMessage({
|
|
803
|
+
content: toLangChainContent([
|
|
804
|
+
{
|
|
805
|
+
type: 'text',
|
|
806
|
+
text: 'SKILL BODY',
|
|
807
|
+
cache_control: { type: 'ephemeral' },
|
|
808
|
+
} as MessageContentComplex,
|
|
809
|
+
]),
|
|
810
|
+
additional_kwargs: {
|
|
811
|
+
isMeta: true,
|
|
812
|
+
source: 'skill',
|
|
813
|
+
skillName: 'pdf-analyzer',
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
const messages: BaseMessage[] = [
|
|
817
|
+
new HumanMessage('Real question'),
|
|
818
|
+
new AIMessage('Answer'),
|
|
819
|
+
stale,
|
|
820
|
+
];
|
|
821
|
+
|
|
822
|
+
const result = addCacheControl<BaseMessage>(messages);
|
|
823
|
+
|
|
824
|
+
expect(hasAnthropicMarker(result[2])).toBe(false);
|
|
825
|
+
expect(hasAnthropicMarker(result[0])).toBe(true);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('Anthropic: detects skill messages by additional_kwargs.source even without isMeta', () => {
|
|
829
|
+
const messages: BaseMessage[] = [
|
|
830
|
+
new HumanMessage('Real question'),
|
|
831
|
+
new AIMessage('Answer'),
|
|
832
|
+
new HumanMessage({
|
|
833
|
+
content: 'SKILL BODY',
|
|
834
|
+
additional_kwargs: { source: 'skill', skillName: 'pdf-analyzer' },
|
|
835
|
+
}),
|
|
836
|
+
];
|
|
837
|
+
|
|
838
|
+
const result = addCacheControl<BaseMessage>(messages);
|
|
839
|
+
|
|
840
|
+
expect(hasAnthropicMarker(result[2])).toBe(false);
|
|
841
|
+
expect(hasAnthropicMarker(result[0])).toBe(true);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('Bedrock: skips a trailing synthetic skill message; cachePoints land on the real user messages', () => {
|
|
845
|
+
const messages: BaseMessage[] = [
|
|
846
|
+
new HumanMessage('First real question'),
|
|
847
|
+
new AIMessage('Answer'),
|
|
848
|
+
new HumanMessage('Second real question'),
|
|
849
|
+
skillBody('pdf-analyzer'),
|
|
850
|
+
];
|
|
851
|
+
|
|
852
|
+
const result = addBedrockCacheControl<BaseMessage>(messages);
|
|
853
|
+
|
|
854
|
+
expect(hasBedrockCachePoint(result[3])).toBe(false);
|
|
855
|
+
expect(hasBedrockCachePoint(result[2])).toBe(true);
|
|
856
|
+
expect(hasBedrockCachePoint(result[0])).toBe(true);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('stable-prefix fallback: anchors the real user message, not a synthetic skill message', () => {
|
|
860
|
+
// Mirrors AgentContext's dynamic-tail path: the only assistant message is a
|
|
861
|
+
// skill-only tool call (no text), so the assistant-only pass adds no marker
|
|
862
|
+
// and the cacheable fallback runs. It must skip the reconstructed skill
|
|
863
|
+
// HumanMessage and anchor the real user message instead.
|
|
864
|
+
const messages: BaseMessage[] = [
|
|
865
|
+
new HumanMessage('Real stable question'),
|
|
866
|
+
new AIMessage({
|
|
867
|
+
content: toLangChainContent([
|
|
868
|
+
{
|
|
869
|
+
type: 'tool_use',
|
|
870
|
+
id: 'call_1',
|
|
871
|
+
name: 'skill',
|
|
872
|
+
input: { skillName: 'pdf-analyzer' },
|
|
873
|
+
} as MessageContentComplex,
|
|
874
|
+
]),
|
|
875
|
+
tool_calls: [
|
|
876
|
+
{ id: 'call_1', name: 'skill', args: { skillName: 'pdf-analyzer' } },
|
|
877
|
+
],
|
|
878
|
+
}),
|
|
879
|
+
skillBody('pdf-analyzer'),
|
|
880
|
+
];
|
|
881
|
+
|
|
882
|
+
const result = addCacheControlToStablePrefixMessages<BaseMessage>(
|
|
883
|
+
messages,
|
|
884
|
+
2
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
expect(hasAnthropicMarker(result[2])).toBe(false);
|
|
888
|
+
expect(hasAnthropicMarker(result[0])).toBe(true);
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
|
|
770
892
|
describe('stripAnthropicCacheControl', () => {
|
|
771
893
|
it('removes cache_control fields from content blocks', () => {
|
|
772
894
|
const messages: TestMsg[] = [
|
package/src/messages/cache.ts
CHANGED
|
@@ -182,6 +182,7 @@ export function addCacheControl<T extends AnthropicMessage | BaseMessage>(
|
|
|
182
182
|
const needsCacheAdd =
|
|
183
183
|
userMessagesModified < 2 &&
|
|
184
184
|
isUserMessage &&
|
|
185
|
+
!isSyntheticMetaMessage(originalMessage) &&
|
|
185
186
|
(typeof content === 'string' || hasArrayContent);
|
|
186
187
|
|
|
187
188
|
// Skip messages that don't need any work
|
|
@@ -263,6 +264,26 @@ function getMessageRole(message: MessageWithContent): string | undefined {
|
|
|
263
264
|
return undefined;
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
const SKILL_MESSAGE_SOURCE = 'skill';
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Synthetic skill/meta messages (reconstructed skill bodies, primed SKILL.md
|
|
271
|
+
* instructions) are re-injected every turn and are not stable conversation
|
|
272
|
+
* turns. They must not anchor a fresh prompt-cache marker — doing so pins the
|
|
273
|
+
* cache to a volatile/duplicated prefix. Stale markers are still stripped from
|
|
274
|
+
* them; only the *adding* of new markers is suppressed. Detected via
|
|
275
|
+
* `additional_kwargs.isMeta === true` or `additional_kwargs.source === 'skill'`.
|
|
276
|
+
*/
|
|
277
|
+
function isSyntheticMetaMessage(message: MessageWithContent): boolean {
|
|
278
|
+
const { additional_kwargs: kwargs } = message as {
|
|
279
|
+
additional_kwargs?: { isMeta?: unknown; source?: unknown };
|
|
280
|
+
};
|
|
281
|
+
if (kwargs == null) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
return kwargs.isMeta === true || kwargs.source === SKILL_MESSAGE_SOURCE;
|
|
285
|
+
}
|
|
286
|
+
|
|
266
287
|
function isCacheableConversationMessage(message: MessageWithContent): boolean {
|
|
267
288
|
const role = getMessageRole(message);
|
|
268
289
|
return (
|
|
@@ -305,7 +326,9 @@ function addCacheControlToRecentMessages<
|
|
|
305
326
|
const content = originalMessage.content;
|
|
306
327
|
const hasArrayContent = Array.isArray(content);
|
|
307
328
|
const canAddCache =
|
|
308
|
-
cachePointsAdded < maxCachePoints &&
|
|
329
|
+
cachePointsAdded < maxCachePoints &&
|
|
330
|
+
canUseMessage(originalMessage) &&
|
|
331
|
+
!isSyntheticMetaMessage(originalMessage);
|
|
309
332
|
|
|
310
333
|
if (!canAddCache && !hasArrayContent) {
|
|
311
334
|
continue;
|
|
@@ -536,6 +559,7 @@ export function addBedrockCacheControl<
|
|
|
536
559
|
isUserMessage &&
|
|
537
560
|
!isToolMessage &&
|
|
538
561
|
!isEmptyString &&
|
|
562
|
+
!isSyntheticMetaMessage(originalMessage) &&
|
|
539
563
|
(typeof content === 'string' || hasArrayContent);
|
|
540
564
|
|
|
541
565
|
if (!needsCacheAdd && !hasArrayContent && !hasSerializationProps) {
|
package/src/messages/format.ts
CHANGED
|
@@ -334,6 +334,10 @@ interface FormatAssistantMessageOptions {
|
|
|
334
334
|
|
|
335
335
|
interface FormatAgentMessagesOptions {
|
|
336
336
|
provider?: Providers;
|
|
337
|
+
/** Skill names already primed fresh this turn (manual/always-apply). Their
|
|
338
|
+
* historical `skill` tool_calls are not reconstructed into a HumanMessage,
|
|
339
|
+
* so the same SKILL.md body is not injected twice in one request. */
|
|
340
|
+
skipSkillBodyNames?: Set<string>;
|
|
337
341
|
}
|
|
338
342
|
|
|
339
343
|
function extractReasoningContent(
|
|
@@ -1159,6 +1163,7 @@ function extractSkillName(args: unknown): string | undefined {
|
|
|
1159
1163
|
* @param indexTokenCountMap - Optional map of message indices to token counts.
|
|
1160
1164
|
* @param tools - Optional set of tool names that are allowed in the request.
|
|
1161
1165
|
* @param skills - Optional map of skill name to body for reconstructing skill HumanMessages.
|
|
1166
|
+
* @param options - Optional formatting options (provider, skipSkillBodyNames).
|
|
1162
1167
|
* @returns - Object containing formatted messages and updated indexTokenCountMap if provided.
|
|
1163
1168
|
*/
|
|
1164
1169
|
export const formatAgentMessages = (
|
|
@@ -1445,7 +1450,11 @@ export const formatAgentMessages = (
|
|
|
1445
1450
|
const endMessageIndex = messages.length;
|
|
1446
1451
|
|
|
1447
1452
|
if (pendingSkillNames?.size != null && pendingSkillNames.size > 0) {
|
|
1453
|
+
const skipSkillBodyNames = options?.skipSkillBodyNames;
|
|
1448
1454
|
for (const skillName of pendingSkillNames) {
|
|
1455
|
+
if (skipSkillBodyNames != null && skipSkillBodyNames.has(skillName)) {
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1449
1458
|
const body = skills?.get(skillName) ?? '';
|
|
1450
1459
|
if (body) {
|
|
1451
1460
|
messages.push(
|
|
@@ -410,4 +410,104 @@ describe('formatAgentMessages skill body reconstruction', () => {
|
|
|
410
410
|
expect(assistantTotal).toBe(500);
|
|
411
411
|
});
|
|
412
412
|
});
|
|
413
|
+
|
|
414
|
+
describe('skipSkillBodyNames (fresh-prime dedupe)', () => {
|
|
415
|
+
const tools = new Set([Constants.SKILL_TOOL]);
|
|
416
|
+
|
|
417
|
+
const injectedSkillBodies = (
|
|
418
|
+
messages: ReturnType<typeof formatAgentMessages>['messages']
|
|
419
|
+
) =>
|
|
420
|
+
messages.filter(
|
|
421
|
+
(m) =>
|
|
422
|
+
m instanceof HumanMessage &&
|
|
423
|
+
(m as HumanMessage).additional_kwargs.source === 'skill'
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
it('does NOT reconstruct body when skill name is in skipSkillBodyNames', () => {
|
|
427
|
+
const payload: TPayload = [
|
|
428
|
+
{ role: 'user', content: 'Review my code' },
|
|
429
|
+
{
|
|
430
|
+
role: 'assistant',
|
|
431
|
+
content: [skillToolCall('call_1', 'code-review')],
|
|
432
|
+
},
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
const { messages } = formatAgentMessages(
|
|
436
|
+
payload,
|
|
437
|
+
undefined,
|
|
438
|
+
tools,
|
|
439
|
+
skillBodies,
|
|
440
|
+
{ skipSkillBodyNames: new Set(['code-review']) }
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
expect(injectedSkillBodies(messages)).toHaveLength(0);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('reconstructs only names NOT in skipSkillBodyNames', () => {
|
|
447
|
+
const payload: TPayload = [
|
|
448
|
+
{ role: 'user', content: 'Go' },
|
|
449
|
+
{
|
|
450
|
+
role: 'assistant',
|
|
451
|
+
content: [
|
|
452
|
+
skillToolCall('call_1', 'pdf-analyzer'),
|
|
453
|
+
skillToolCall('call_2', 'code-review'),
|
|
454
|
+
],
|
|
455
|
+
},
|
|
456
|
+
];
|
|
457
|
+
|
|
458
|
+
const { messages } = formatAgentMessages(
|
|
459
|
+
payload,
|
|
460
|
+
undefined,
|
|
461
|
+
tools,
|
|
462
|
+
skillBodies,
|
|
463
|
+
{ skipSkillBodyNames: new Set(['code-review']) }
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const injected = injectedSkillBodies(messages);
|
|
467
|
+
expect(injected).toHaveLength(1);
|
|
468
|
+
expect((injected[0] as HumanMessage).additional_kwargs.skillName).toBe(
|
|
469
|
+
'pdf-analyzer'
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('reconstructs normally when skipSkillBodyNames is empty', () => {
|
|
474
|
+
const payload: TPayload = [
|
|
475
|
+
{ role: 'user', content: 'Review my code' },
|
|
476
|
+
{
|
|
477
|
+
role: 'assistant',
|
|
478
|
+
content: [skillToolCall('call_1', 'code-review')],
|
|
479
|
+
},
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
const { messages } = formatAgentMessages(
|
|
483
|
+
payload,
|
|
484
|
+
undefined,
|
|
485
|
+
tools,
|
|
486
|
+
skillBodies,
|
|
487
|
+
{ skipSkillBodyNames: new Set() }
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
expect(injectedSkillBodies(messages)).toHaveLength(1);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('skips in the non-tools-filtering path too', () => {
|
|
494
|
+
const payload: TPayload = [
|
|
495
|
+
{ role: 'user', content: 'Review my code' },
|
|
496
|
+
{
|
|
497
|
+
role: 'assistant',
|
|
498
|
+
content: [skillToolCall('call_1', 'code-review')],
|
|
499
|
+
},
|
|
500
|
+
];
|
|
501
|
+
|
|
502
|
+
const { messages } = formatAgentMessages(
|
|
503
|
+
payload,
|
|
504
|
+
undefined,
|
|
505
|
+
undefined,
|
|
506
|
+
skillBodies,
|
|
507
|
+
{ skipSkillBodyNames: new Set(['code-review']) }
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
expect(injectedSkillBodies(messages)).toHaveLength(0);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
413
513
|
});
|