Skip to content

Commit 16d9273

Browse files
fix: cache tool loops with dynamic instructions (LibreChat-AI#550)
Co-authored-by: Lia <lia@librechat.ai>
1 parent 8d7edf4 commit 16d9273

3 files changed

Lines changed: 410 additions & 32 deletions

File tree

‎src/agents/AgentContext.ts‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from '@/tools/CallerCapabilities';
2727
import {
2828
addTailCacheControl,
29+
stripAnthropicCacheControl,
2930
addCacheControlToStablePrefixMessages,
3031
buildAnthropicCacheControl,
3132
buildBedrockCachePoint,
@@ -1063,12 +1064,16 @@ export class AgentContext {
10631064
: this.getPromptCacheDynamicTailIndex(messages, promptCacheProvider);
10641065
const stablePrefix = messages.slice(0, tailIndex);
10651066
const trailingMessages = messages.slice(tailIndex);
1066-
const cacheablePrefix = this.addStablePromptCacheMarkers(
1067-
stablePrefix,
1068-
this.getPromptCacheTtl(promptCacheProvider)
1067+
const ttl = this.getPromptCacheTtl(promptCacheProvider);
1068+
const cacheablePrefix = this.addStablePromptCacheMarkers(stablePrefix, ttl);
1069+
// Mark only the conversation suffix: marking the assembled body would strip
1070+
// the stable-prefix breakpoint and could anchor an instruction-only carrier.
1071+
const cacheableTrailingMessages = addTailCacheControl(
1072+
trailingMessages,
1073+
ttl
10691074
);
10701075

1071-
return [...cacheablePrefix, ...tail, ...trailingMessages];
1076+
return [...cacheablePrefix, ...tail, ...cacheableTrailingMessages];
10721077
}
10731078

10741079
private getPromptCacheDynamicTailIndex(
@@ -1101,13 +1106,12 @@ export class AgentContext {
11011106
messages: BaseMessage[],
11021107
ttl?: PromptCacheTtl
11031108
): BaseMessage[] {
1104-
if (messages.length <= 1) {
1105-
return messages;
1106-
}
1107-
1109+
// Reserve the other three breakpoints for tools, system, and conversation tail.
1110+
// The opening message is not an anchor, including when it has inherited markers.
1111+
const openingMessage = stripAnthropicCacheControl(messages.slice(0, 1));
11081112
return [
1109-
messages[0],
1110-
...addCacheControlToStablePrefixMessages(messages.slice(1), 2, ttl),
1113+
...openingMessage,
1114+
...addCacheControlToStablePrefixMessages(messages.slice(1), 1, ttl),
11111115
];
11121116
}
11131117

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
2+
import type {
3+
BaseMessage,
4+
MessageContentComplex,
5+
} from '@langchain/core/messages';
6+
import type { PromptCacheTtl } from '@/messages/cache';
7+
import { _convertMessagesToAnthropicPayload } from '@/llm/anthropic/utils/message_inputs';
8+
import { partitionAndMarkAnthropicToolCache } from '@/messages/anthropicToolCache';
9+
import { _convertMessagesToOpenAIParams } from '@/llm/openai/utils';
10+
import { AgentContext } from '../AgentContext';
11+
import { Providers } from '@/common';
12+
13+
const providers = [Providers.ANTHROPIC, Providers.OPENROUTER] as const;
14+
const ttls = ['5m', '1h'] as const;
15+
16+
function createContext(
17+
provider: (typeof providers)[number],
18+
ttl: PromptCacheTtl = '1h',
19+
dynamicInstructions = 'Current timestamp: Monday'
20+
): AgentContext {
21+
return AgentContext.fromConfig({
22+
agentId: 'cache-test',
23+
provider,
24+
clientOptions: {
25+
model: 'claude-sonnet-4-5',
26+
promptCache: true,
27+
promptCacheTtl: ttl,
28+
},
29+
instructions: 'Stable instructions',
30+
additional_instructions: dynamicInstructions,
31+
});
32+
}
33+
34+
function toolExchange(id: string): BaseMessage[] {
35+
return [
36+
new AIMessage({
37+
content: '',
38+
tool_calls: [{ id, name: 'search', args: {} }],
39+
}),
40+
new ToolMessage({ content: `Result ${id}`, tool_call_id: id }),
41+
];
42+
}
43+
44+
function markedBlocks(message: BaseMessage): MessageContentComplex[] {
45+
return Array.isArray(message.content)
46+
? message.content.filter((block) => 'cache_control' in block)
47+
: [];
48+
}
49+
50+
function markedMessages(messages: BaseMessage[]): BaseMessage[] {
51+
return messages.filter((message) => markedBlocks(message).length > 0);
52+
}
53+
54+
describe.each(providers)('%s dynamic prompt caching', (provider) => {
55+
it.each(ttls)(
56+
'advances the subagent loop tail with %s TTL without mutating history',
57+
async (ttl) => {
58+
const ctx = createContext(provider, ttl);
59+
const history: BaseMessage[] = [new HumanMessage('Research the task')];
60+
const cacheControl =
61+
ttl === '1h' ? { type: 'ephemeral', ttl } : { type: 'ephemeral' };
62+
63+
for (const id of ['call_1', 'call_2', 'call_3']) {
64+
history.push(...toolExchange(id));
65+
const before = JSON.stringify(history);
66+
const result = await ctx.systemRunnable!.invoke(history);
67+
68+
expect(markedMessages(result)).toEqual([result[0], result.at(-1)]);
69+
expect(markedBlocks(result.at(-1)!)).toEqual([
70+
{ type: 'text', text: `Result ${id}`, cache_control: cacheControl },
71+
]);
72+
expect(result.at(-1)).toBeInstanceOf(ToolMessage);
73+
expect((result.at(-1) as ToolMessage).tool_call_id).toBe(id);
74+
expect(JSON.stringify(history)).toBe(before);
75+
expect(result.map((message) => message.text)).toContain(
76+
'Current timestamp: Monday'
77+
);
78+
}
79+
}
80+
);
81+
82+
it('keeps one stable-history marker and one current-turn marker', async () => {
83+
const history = [
84+
new HumanMessage('First'),
85+
new AIMessage('First answer'),
86+
new HumanMessage('Second'),
87+
new AIMessage('Second answer'),
88+
new HumanMessage('Research the task'),
89+
...toolExchange('call_1'),
90+
];
91+
const result =
92+
await createContext(provider).systemRunnable!.invoke(history);
93+
94+
expect(markedMessages(result).map((message) => message.text)).toEqual([
95+
'Stable instructions',
96+
'Second answer',
97+
'Result call_1',
98+
]);
99+
const changed = await createContext(
100+
provider,
101+
'1h',
102+
'Current timestamp: Tuesday'
103+
).systemRunnable!.invoke(history);
104+
expect(changed.slice(0, 5)).toEqual(result.slice(0, 5));
105+
expect(changed[5].content).toBe('Current timestamp: Tuesday');
106+
expect(markedMessages(changed).map((message) => message.text)).toEqual([
107+
'Stable instructions',
108+
'Second answer',
109+
'Result call_1',
110+
]);
111+
});
112+
113+
it('normalizes inherited markers including the opening message', async () => {
114+
const history = [
115+
new HumanMessage({
116+
content: [
117+
{ type: 'text', text: 'First', cache_control: { type: 'ephemeral' } },
118+
{ type: 'text', text: 'More', cache_control: { type: 'ephemeral' } },
119+
],
120+
}),
121+
new AIMessage({
122+
content: [
123+
{
124+
type: 'text',
125+
text: 'Answer',
126+
cache_control: { type: 'ephemeral' },
127+
},
128+
],
129+
}),
130+
new HumanMessage('Latest'),
131+
...toolExchange('call_1'),
132+
];
133+
const before = JSON.stringify(history);
134+
const result =
135+
await createContext(provider).systemRunnable!.invoke(history);
136+
137+
expect(result.flatMap(markedBlocks)).toHaveLength(3);
138+
expect(markedBlocks(result[1])).toHaveLength(0);
139+
for (const block of result.flatMap(markedBlocks)) {
140+
expect(block).toHaveProperty('cache_control', {
141+
type: 'ephemeral',
142+
ttl: '1h',
143+
});
144+
}
145+
expect(JSON.stringify(history)).toBe(before);
146+
});
147+
148+
it.each([false, true])(
149+
'marks the conversation after a checkpoint (precedesMessages=%s)',
150+
async (precedesMessages) => {
151+
const ctx = createContext(provider);
152+
ctx.setSummary('Compacted history', 10, { precedesMessages });
153+
const history = precedesMessages
154+
? toolExchange('call_1')
155+
: [new HumanMessage('Task'), ...toolExchange('call_1')];
156+
const result = await ctx.systemRunnable!.invoke(history);
157+
158+
expect(markedMessages(result)).toEqual([result[0], result.at(-1)]);
159+
expect(result.at(-1)!.text).toBe('Result call_1');
160+
expect(result.map((message) => message.text).join('\n')).toContain(
161+
'Compacted history'
162+
);
163+
}
164+
);
165+
166+
it('replaces stale markers within the current tool loop', async () => {
167+
const history = [new HumanMessage('Task'), ...toolExchange('call_1')];
168+
const first = await createContext(provider, '5m').systemRunnable!.invoke(
169+
history
170+
);
171+
const markedResult = first.at(-1)!;
172+
const resumed = [
173+
...history.slice(0, -1),
174+
markedResult,
175+
...toolExchange('call_2'),
176+
];
177+
const before = JSON.stringify(resumed);
178+
const result =
179+
await createContext(provider).systemRunnable!.invoke(resumed);
180+
181+
expect(markedMessages(result).map((message) => message.text)).toEqual([
182+
'Stable instructions',
183+
'Result call_2',
184+
]);
185+
expect(markedBlocks(result.at(-1)!)[0]).toHaveProperty('cache_control', {
186+
type: 'ephemeral',
187+
ttl: '1h',
188+
});
189+
expect(JSON.stringify(resumed)).toBe(before);
190+
});
191+
192+
it('keeps the no-dynamic-instructions tail strategy', async () => {
193+
const ctx = createContext(provider, '1h', '');
194+
const result = await ctx.systemRunnable!.invoke([
195+
new HumanMessage('Task'),
196+
...toolExchange('call_1'),
197+
]);
198+
expect(markedMessages(result)).toEqual([result[0], result.at(-1)]);
199+
expect(result).toHaveLength(4);
200+
});
201+
202+
it('does not add markers when prompt caching is disabled', async () => {
203+
const ctx = AgentContext.fromConfig({
204+
agentId: 'uncached',
205+
provider,
206+
clientOptions: { promptCache: false },
207+
instructions: 'Stable instructions',
208+
additional_instructions: 'Dynamic instructions',
209+
});
210+
const result = await ctx.systemRunnable!.invoke([
211+
new HumanMessage('Task'),
212+
...toolExchange('call_1'),
213+
]);
214+
expect(markedMessages(result)).toHaveLength(0);
215+
expect(result[0].text).toBe('Stable instructions\n\nDynamic instructions');
216+
});
217+
218+
it('preserves single-message instruction ordering without anchoring dynamic text', async () => {
219+
const ctx = createContext(provider);
220+
const result = await ctx.systemRunnable!.invoke([new HumanMessage('Task')]);
221+
const bodyText = result.slice(1).map((message) => message.text);
222+
expect(bodyText).toEqual(
223+
provider === Providers.ANTHROPIC
224+
? ['Current timestamp: Monday', 'Task']
225+
: ['Task', 'Current timestamp: Monday']
226+
);
227+
const expectedAnchors =
228+
provider === Providers.ANTHROPIC
229+
? ['Stable instructions', 'Task']
230+
: ['Stable instructions'];
231+
expect(markedMessages(result).map((message) => message.text)).toEqual(
232+
expectedAnchors
233+
);
234+
});
235+
236+
it('does not anchor instruction-only or summary-only bodies', async () => {
237+
const ctx = createContext(provider);
238+
ctx.setSummary('Compacted history', 10, { precedesMessages: true });
239+
const result = await ctx.systemRunnable!.invoke([]);
240+
expect(markedMessages(result)).toEqual([result[0]]);
241+
});
242+
243+
it('skips synthetic and reasoning-only tail messages without losing the conversation marker', async () => {
244+
const result = await createContext(provider).systemRunnable!.invoke([
245+
new HumanMessage('Task'),
246+
...toolExchange('call_1'),
247+
new AIMessage({
248+
content: [
249+
{ type: 'thinking', thinking: 'Reasoning', signature: 'signature' },
250+
],
251+
}),
252+
new AIMessage({
253+
content: 'Volatile skill',
254+
additional_kwargs: { isMeta: true },
255+
}),
256+
]);
257+
expect(markedMessages(result).map((message) => message.text)).toContain(
258+
'Result call_1'
259+
);
260+
expect(markedBlocks(result.at(-1)!)).toHaveLength(0);
261+
});
262+
});
263+
264+
describe('dynamic prompt cache payload', () => {
265+
it('preserves the tool tail through OpenRouter chat conversion', async () => {
266+
const result = await createContext(
267+
Providers.OPENROUTER
268+
).systemRunnable!.invoke([
269+
new HumanMessage('Task'),
270+
...toolExchange('call_1'),
271+
]);
272+
const payload = _convertMessagesToOpenAIParams(
273+
result,
274+
'anthropic/claude-sonnet-4-5',
275+
{
276+
preserveToolCacheControl: true,
277+
}
278+
);
279+
expect(payload.at(-1)).toMatchObject({
280+
role: 'tool',
281+
tool_call_id: 'call_1',
282+
content: [
283+
{
284+
type: 'text',
285+
text: 'Result call_1',
286+
cache_control: { type: 'ephemeral', ttl: '1h' },
287+
},
288+
],
289+
});
290+
expect(JSON.stringify(payload).match(/"cache_control":/g)).toHaveLength(2);
291+
});
292+
293+
it('ships a tool-result tail and stays within four total breakpoints', async () => {
294+
const ctx = createContext(Providers.ANTHROPIC);
295+
const messages = await ctx.systemRunnable!.invoke([
296+
new HumanMessage('First'),
297+
new AIMessage('First answer'),
298+
new HumanMessage('Second'),
299+
new AIMessage('Second answer'),
300+
new HumanMessage('Task'),
301+
...toolExchange('call_1'),
302+
]);
303+
const payload = _convertMessagesToAnthropicPayload(messages);
304+
const tools = partitionAndMarkAnthropicToolCache(
305+
[
306+
{
307+
name: 'search',
308+
description: 'Search',
309+
input_schema: { type: 'object', properties: {} },
310+
},
311+
],
312+
() => false,
313+
'1h'
314+
);
315+
const blocks = payload.messages.flatMap((message) =>
316+
typeof message.content === 'string' ? [] : message.content
317+
);
318+
const toolResult = blocks.find((block) => block.type === 'tool_result');
319+
320+
expect(toolResult).toMatchObject({
321+
type: 'tool_result',
322+
tool_use_id: 'call_1',
323+
cache_control: { type: 'ephemeral', ttl: '1h' },
324+
});
325+
expect(blocks.filter((block) => 'cache_control' in block)).toHaveLength(2);
326+
const request = { ...payload, tools };
327+
expect(JSON.stringify(request).match(/"cache_control":/g)).toHaveLength(4);
328+
expect(JSON.stringify(toolResult?.content)).not.toContain('cache_control');
329+
});
330+
});

0 commit comments

Comments
 (0)