@librechat/agents 3.2.38 → 3.2.41

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.
Files changed (105) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +25 -8
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +7 -4
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +4 -3
  6. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -1
  7. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +20 -4
  8. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/index.cjs +7 -1
  10. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  11. package/dist/cjs/llm/bedrock/toolCache.cjs +5 -4
  12. package/dist/cjs/llm/bedrock/toolCache.cjs.map +1 -1
  13. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +34 -17
  14. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  15. package/dist/cjs/llm/openrouter/index.cjs +1 -0
  16. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  17. package/dist/cjs/llm/openrouter/toolCache.cjs +18 -5
  18. package/dist/cjs/llm/openrouter/toolCache.cjs.map +1 -1
  19. package/dist/cjs/main.cjs +4 -0
  20. package/dist/cjs/messages/anthropicToolCache.cjs +75 -13
  21. package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -1
  22. package/dist/cjs/messages/cache.cjs +91 -35
  23. package/dist/cjs/messages/cache.cjs.map +1 -1
  24. package/dist/cjs/summarization/node.cjs +3 -2
  25. package/dist/cjs/summarization/node.cjs.map +1 -1
  26. package/dist/cjs/tools/ReadFile.cjs +2 -2
  27. package/dist/cjs/tools/ReadFile.cjs.map +1 -1
  28. package/dist/cjs/tools/cloudflare/CloudflareProgrammaticToolCalling.cjs +11 -11
  29. package/dist/cjs/tools/cloudflare/CloudflareProgrammaticToolCalling.cjs.map +1 -1
  30. package/dist/cjs/tools/local/LocalCodingTools.cjs +11 -11
  31. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
  32. package/dist/esm/agents/AgentContext.mjs +26 -9
  33. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  34. package/dist/esm/graphs/Graph.mjs +8 -5
  35. package/dist/esm/graphs/Graph.mjs.map +1 -1
  36. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +4 -3
  37. package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -1
  38. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +20 -4
  39. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  40. package/dist/esm/llm/bedrock/index.mjs +7 -1
  41. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  42. package/dist/esm/llm/bedrock/toolCache.mjs +5 -4
  43. package/dist/esm/llm/bedrock/toolCache.mjs.map +1 -1
  44. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +34 -17
  45. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  46. package/dist/esm/llm/openrouter/index.mjs +1 -0
  47. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  48. package/dist/esm/llm/openrouter/toolCache.mjs +18 -5
  49. package/dist/esm/llm/openrouter/toolCache.mjs.map +1 -1
  50. package/dist/esm/main.mjs +2 -2
  51. package/dist/esm/messages/anthropicToolCache.mjs +75 -13
  52. package/dist/esm/messages/anthropicToolCache.mjs.map +1 -1
  53. package/dist/esm/messages/cache.mjs +88 -36
  54. package/dist/esm/messages/cache.mjs.map +1 -1
  55. package/dist/esm/summarization/node.mjs +4 -3
  56. package/dist/esm/summarization/node.mjs.map +1 -1
  57. package/dist/esm/tools/ReadFile.mjs +2 -2
  58. package/dist/esm/tools/ReadFile.mjs.map +1 -1
  59. package/dist/esm/tools/cloudflare/CloudflareProgrammaticToolCalling.mjs +11 -11
  60. package/dist/esm/tools/cloudflare/CloudflareProgrammaticToolCalling.mjs.map +1 -1
  61. package/dist/esm/tools/local/LocalCodingTools.mjs +11 -11
  62. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
  63. package/dist/types/agents/AgentContext.d.ts +11 -0
  64. package/dist/types/agents/__tests__/promptCacheLiveHelpers.d.ts +2 -0
  65. package/dist/types/llm/bedrock/index.d.ts +13 -0
  66. package/dist/types/llm/bedrock/toolCache.d.ts +2 -1
  67. package/dist/types/llm/openrouter/index.d.ts +8 -0
  68. package/dist/types/llm/openrouter/toolCache.d.ts +2 -1
  69. package/dist/types/messages/anthropicToolCache.d.ts +2 -1
  70. package/dist/types/messages/cache.d.ts +49 -5
  71. package/dist/types/tools/ReadFile.d.ts +4 -4
  72. package/dist/types/types/llm.d.ts +14 -0
  73. package/package.json +1 -1
  74. package/src/agents/AgentContext.ts +64 -17
  75. package/src/agents/__tests__/AgentContext.anthropic.live.test.ts +6 -2
  76. package/src/agents/__tests__/AgentContext.bedrock.live.test.ts +7 -5
  77. package/src/agents/__tests__/AgentContext.openrouter.live.test.ts +1 -1
  78. package/src/agents/__tests__/AgentContext.test.ts +31 -19
  79. package/src/agents/__tests__/promptCacheLiveHelpers.ts +6 -2
  80. package/src/graphs/Graph.ts +40 -4
  81. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +12 -12
  82. package/src/hooks/createWorkspacePolicyHook.ts +7 -6
  83. package/src/llm/anthropic/utils/message_inputs.ts +33 -6
  84. package/src/llm/bedrock/index.ts +21 -1
  85. package/src/llm/bedrock/llm.spec.ts +61 -0
  86. package/src/llm/bedrock/toolCache.test.ts +24 -0
  87. package/src/llm/bedrock/toolCache.ts +12 -7
  88. package/src/llm/bedrock/utils/message_inputs.ts +57 -40
  89. package/src/llm/openrouter/index.ts +9 -0
  90. package/src/llm/openrouter/toolCache.test.ts +52 -1
  91. package/src/llm/openrouter/toolCache.ts +40 -6
  92. package/src/messages/__tests__/anthropicToolCache.test.ts +168 -0
  93. package/src/messages/anthropicToolCache.ts +118 -15
  94. package/src/messages/cache.test.ts +175 -0
  95. package/src/messages/cache.ts +133 -48
  96. package/src/summarization/node.ts +21 -2
  97. package/src/tools/ReadFile.ts +2 -2
  98. package/src/tools/__tests__/LocalExecutionTools.test.ts +25 -25
  99. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +5 -5
  100. package/src/tools/__tests__/ReadFile.test.ts +3 -3
  101. package/src/tools/__tests__/ToolNode.session.test.ts +2 -2
  102. package/src/tools/__tests__/workspaceSeam.test.ts +2 -2
  103. package/src/tools/cloudflare/CloudflareProgrammaticToolCalling.ts +11 -11
  104. package/src/tools/local/LocalCodingTools.ts +14 -14
  105. package/src/types/llm.ts +14 -0
@@ -140,6 +140,174 @@ describe('partitionAndMarkAnthropicToolCache', () => {
140
140
  // is returned unchanged (same reference) so we don't churn the array.
141
141
  expect(partitionAndMarkAnthropicToolCache(input, () => false)).toBe(input);
142
142
  });
143
+
144
+ it('stamps the resolved 1h ttl on the last static tool', () => {
145
+ const out = partitionAndMarkAnthropicToolCache(
146
+ [fakeTool('a-static'), fakeTool('b-static')] as never,
147
+ () => false,
148
+ '1h'
149
+ ) as Array<{
150
+ extras?: { cache_control?: { type: string; ttl?: string } };
151
+ }>;
152
+ expect(out[1].extras?.cache_control).toEqual({
153
+ type: 'ephemeral',
154
+ ttl: '1h',
155
+ });
156
+ expect(out[0].extras?.cache_control).toBeUndefined();
157
+ });
158
+
159
+ it('re-stamps a pre-marked 5m tool to 1h so it does not precede a 1h breakpoint', () => {
160
+ const a = fakeTool('a-static') as {
161
+ extras?: { cache_control?: { type: string; ttl?: string } };
162
+ };
163
+ a.extras = { cache_control: { type: 'ephemeral' } };
164
+ const out = partitionAndMarkAnthropicToolCache(
165
+ [a] as never,
166
+ () => false,
167
+ '1h'
168
+ ) as Array<{
169
+ extras?: { cache_control?: { type: string; ttl?: string } };
170
+ }>;
171
+ expect(out[0].extras?.cache_control).toEqual({
172
+ type: 'ephemeral',
173
+ ttl: '1h',
174
+ });
175
+ });
176
+
177
+ it('strips a pre-marked earlier static tool so only the tail carries the 1h marker', () => {
178
+ const a = fakeTool('a-static') as {
179
+ extras?: { cache_control?: { type: string; ttl?: string } };
180
+ };
181
+ a.extras = { cache_control: { type: 'ephemeral' } };
182
+ const b = fakeTool('b-static');
183
+ const out = partitionAndMarkAnthropicToolCache(
184
+ [a, b] as never,
185
+ () => false,
186
+ '1h'
187
+ ) as Array<{
188
+ extras?: { cache_control?: { type: string; ttl?: string } };
189
+ }>;
190
+ // Earlier tool's stray 5m marker is removed so it can't precede the tail.
191
+ expect(out[0].extras?.cache_control).toBeUndefined();
192
+ expect(out[1].extras?.cache_control).toEqual({
193
+ type: 'ephemeral',
194
+ ttl: '1h',
195
+ });
196
+ });
197
+
198
+ it('strips stale markers off deferred tools so they do not precede the system/message breakpoint', () => {
199
+ const staticTool = fakeTool('a-static');
200
+ const deferred = fakeTool('b-deferred') as {
201
+ extras?: { cache_control?: { type: string } };
202
+ };
203
+ deferred.extras = { cache_control: { type: 'ephemeral' } };
204
+ const out = partitionAndMarkAnthropicToolCache(
205
+ [staticTool, deferred] as never,
206
+ (name) => name === 'b-deferred',
207
+ '1h'
208
+ ) as Array<{
209
+ extras?: { cache_control?: { type: string; ttl?: string } };
210
+ }>;
211
+ expect(out[0].extras?.cache_control).toEqual({
212
+ type: 'ephemeral',
213
+ ttl: '1h',
214
+ });
215
+ expect(out[1].extras?.cache_control).toBeUndefined();
216
+ });
217
+
218
+ it('strips stale markers in the all-deferred case', () => {
219
+ const deferred = fakeTool('only-deferred') as {
220
+ extras?: { cache_control?: { type: string } };
221
+ };
222
+ deferred.extras = { cache_control: { type: 'ephemeral' } };
223
+ const out = partitionAndMarkAnthropicToolCache(
224
+ [deferred] as never,
225
+ () => true,
226
+ '1h'
227
+ ) as Array<{ extras?: { cache_control?: unknown } }>;
228
+ expect(out[0].extras?.cache_control).toBeUndefined();
229
+ });
230
+
231
+ it('strips a direct cache_control on an earlier native (non-built-in) tool', () => {
232
+ const nativeWithMarker = {
233
+ name: 'native_a',
234
+ input_schema: { type: 'object', properties: {} },
235
+ cache_control: { type: 'ephemeral' },
236
+ };
237
+ const out = partitionAndMarkAnthropicToolCache(
238
+ [nativeWithMarker, fakeTool('native_b')] as never,
239
+ () => false,
240
+ '1h'
241
+ ) as Array<{
242
+ cache_control?: unknown;
243
+ extras?: { cache_control?: { type: string; ttl?: string } };
244
+ }>;
245
+ expect(out[0].cache_control).toBeUndefined();
246
+ expect(out[1].extras?.cache_control).toEqual({
247
+ type: 'ephemeral',
248
+ ttl: '1h',
249
+ });
250
+ });
251
+
252
+ it('upgrades a raw Anthropic tail tool to a direct 1h marker (not extras)', () => {
253
+ const nativeTail = {
254
+ name: 'native_tail',
255
+ input_schema: { type: 'object', properties: {} },
256
+ cache_control: { type: 'ephemeral' },
257
+ };
258
+ const out = partitionAndMarkAnthropicToolCache(
259
+ [nativeTail] as never,
260
+ () => false,
261
+ '1h'
262
+ ) as Array<{
263
+ cache_control?: { type: string; ttl?: string };
264
+ extras?: { cache_control?: unknown };
265
+ }>;
266
+ // Raw Anthropic tools carry cache_control directly (extras is not promoted
267
+ // for them), so the stale marker is upgraded in place to 1h — not moved.
268
+ expect(out[0].cache_control).toEqual({ type: 'ephemeral', ttl: '1h' });
269
+ expect(out[0].extras?.cache_control).toBeUndefined();
270
+ });
271
+
272
+ it('reorders deferred tools after a correctly pre-marked static tool', () => {
273
+ const deferred = fakeTool('z-deferred');
274
+ const staticTool = fakeTool('a-static') as {
275
+ extras?: { cache_control?: { type: string; ttl?: string } };
276
+ };
277
+ // Already carries the resolved 1h marker, so nothing is mutated...
278
+ staticTool.extras = { cache_control: { type: 'ephemeral', ttl: '1h' } };
279
+ const out = partitionAndMarkAnthropicToolCache(
280
+ [deferred, staticTool] as never, // deferred precedes static in the input
281
+ (name) => name === 'z-deferred',
282
+ '1h'
283
+ ) as Array<{ name?: string }>;
284
+ // ...but the static (cached) tool must still be hoisted ahead of the
285
+ // deferred tool so the breakpoint precedes discovered tools.
286
+ expect(out.map((t) => t.name)).toEqual(['a-static', 'z-deferred']);
287
+ });
288
+
289
+ it('re-stamps a LangChain tool whose marker sits only on the direct block', () => {
290
+ // A StructuredTool (custom) pre-marked directly — not under extras — does
291
+ // not reach the payload, so the breakpoint must be re-stamped under extras.
292
+ const t = fakeTool('a-static') as {
293
+ cache_control?: unknown;
294
+ extras?: { cache_control?: { type: string; ttl?: string } };
295
+ };
296
+ t.cache_control = { type: 'ephemeral', ttl: '1h' };
297
+ const out = partitionAndMarkAnthropicToolCache(
298
+ [t] as never,
299
+ () => false,
300
+ '1h'
301
+ ) as Array<{
302
+ cache_control?: unknown;
303
+ extras?: { cache_control?: { type: string; ttl?: string } };
304
+ }>;
305
+ expect(out[0].extras?.cache_control).toEqual({
306
+ type: 'ephemeral',
307
+ ttl: '1h',
308
+ });
309
+ expect(out[0].cache_control).toBeUndefined();
310
+ });
143
311
  });
144
312
 
145
313
  describe('makeIsDeferred', () => {
@@ -26,6 +26,10 @@
26
26
  */
27
27
 
28
28
  import type { GraphTools } from '@/types';
29
+ import {
30
+ buildAnthropicCacheControl,
31
+ type PromptCacheTtl,
32
+ } from '@/messages/cache';
29
33
 
30
34
  const ANTHROPIC_BUILT_IN_TOOL_PREFIXES = [
31
35
  'text_editor_',
@@ -41,8 +45,6 @@ const ANTHROPIC_BUILT_IN_TOOL_PREFIXES = [
41
45
  'mcp_toolset',
42
46
  ] as const;
43
47
 
44
- const CACHE_CONTROL = { type: 'ephemeral' as const };
45
-
46
48
  type AnthropicToolCacheCandidate = {
47
49
  name?: unknown;
48
50
  type?: unknown;
@@ -60,29 +62,91 @@ function isAnthropicBuiltInTool(
60
62
  );
61
63
  }
62
64
 
65
+ /**
66
+ * Whether a tool already carries a cache breakpoint. Built-ins use a direct
67
+ * `cache_control`; custom tools normally carry it under `extras`, but a
68
+ * caller-supplied Anthropic-native tool object can also put it directly on the
69
+ * block — check both so stale markers are never missed.
70
+ */
63
71
  function hasCacheControl(tool: AnthropicToolCacheCandidate): boolean {
64
72
  if (isAnthropicBuiltInTool(tool)) {
65
73
  return tool.cache_control != null;
66
74
  }
67
- return tool.extras?.cache_control != null;
75
+ return tool.cache_control != null || tool.extras?.cache_control != null;
68
76
  }
69
77
 
70
- function markCacheControl(
78
+ /**
79
+ * Return the `cache_control` from the location that actually reaches the
80
+ * Anthropic payload for this tool's shape: directly on the block for
81
+ * provider-shaped tools (built-ins and raw Anthropic tools), or under `extras`
82
+ * for LangChain custom tools (the adapter promotes only that). Returns
83
+ * `undefined` when no marker sits in the effective location — a marker in the
84
+ * wrong place (e.g. a direct marker on a custom tool) does not count.
85
+ */
86
+ function getEffectiveCacheControl(
87
+ tool: AnthropicToolCacheCandidate
88
+ ): { ttl?: unknown } | undefined {
89
+ return (
90
+ isProviderShapedTool(tool) ? tool.cache_control : tool.extras?.cache_control
91
+ ) as { ttl?: unknown } | undefined;
92
+ }
93
+
94
+ /**
95
+ * Return a clone of `tool` with any `cache_control` removed — both the direct
96
+ * block marker and the `extras` marker — preserving the prototype chain. Used
97
+ * to clear stray markers off tools that must not anchor a competing breakpoint.
98
+ */
99
+ function stripCacheControl(
71
100
  tool: AnthropicToolCacheCandidate
72
101
  ): AnthropicToolCacheCandidate {
73
102
  const prototype = Object.getPrototypeOf(tool) ?? Object.prototype;
74
- if (isAnthropicBuiltInTool(tool)) {
103
+ const wrapped = { ...tool };
104
+ delete wrapped.cache_control;
105
+ if (wrapped.extras != null) {
106
+ wrapped.extras = { ...wrapped.extras };
107
+ delete wrapped.extras.cache_control;
108
+ }
109
+ return Object.assign(Object.create(prototype), wrapped);
110
+ }
111
+
112
+ /**
113
+ * Whether `tool` is already in the Anthropic provider payload shape — an
114
+ * Anthropic built-in or a raw Anthropic tool object (has `input_schema`). These
115
+ * carry `cache_control` directly on the block; the LangChain adapter does NOT
116
+ * promote `extras.cache_control` for them. LangChain StructuredTools, by
117
+ * contrast, expose the marker via `extras`.
118
+ */
119
+ function isProviderShapedTool(tool: AnthropicToolCacheCandidate): boolean {
120
+ return (
121
+ isAnthropicBuiltInTool(tool) ||
122
+ 'input_schema' in (tool as Record<string, unknown>)
123
+ );
124
+ }
125
+
126
+ function markCacheControl(
127
+ tool: AnthropicToolCacheCandidate,
128
+ ttl?: PromptCacheTtl
129
+ ): AnthropicToolCacheCandidate {
130
+ const cacheControl = buildAnthropicCacheControl(ttl);
131
+ const prototype = Object.getPrototypeOf(tool) ?? Object.prototype;
132
+ if (isProviderShapedTool(tool)) {
133
+ // Built-ins and raw Anthropic tool objects carry cache_control directly on
134
+ // the block; `extras` is not promoted onto the payload for these shapes.
75
135
  const wrapped = { ...tool };
76
136
  delete wrapped.extras;
77
137
  return Object.assign(Object.create(prototype), wrapped, {
78
- cache_control: CACHE_CONTROL,
138
+ cache_control: cacheControl,
79
139
  });
80
140
  }
81
141
 
82
- return Object.assign(Object.create(prototype), tool, {
142
+ // LangChain custom tools: drop any direct marker and expose the breakpoint via
143
+ // `extras`, which the Anthropic adapter promotes onto the payload.
144
+ const wrapped = { ...tool };
145
+ delete wrapped.cache_control;
146
+ return Object.assign(Object.create(prototype), wrapped, {
83
147
  extras: {
84
148
  ...(tool.extras ?? {}),
85
- cache_control: CACHE_CONTROL,
149
+ cache_control: cacheControl,
86
150
  },
87
151
  });
88
152
  }
@@ -124,7 +188,8 @@ export function makeIsDeferred(
124
188
  */
125
189
  export function partitionAndMarkAnthropicToolCache(
126
190
  tools: GraphTools | undefined,
127
- isDeferred: (toolName: string) => boolean
191
+ isDeferred: (toolName: string) => boolean,
192
+ ttl?: PromptCacheTtl
128
193
  ): GraphTools | undefined {
129
194
  if (tools == null || tools.length === 0) return tools;
130
195
 
@@ -143,19 +208,57 @@ export function partitionAndMarkAnthropicToolCache(
143
208
  }
144
209
  }
145
210
 
211
+ // Anthropic serializes ALL tools before system/messages, so a stray
212
+ // cache_control on any tool — static or deferred — that survives the resolved
213
+ // breakpoint would violate the longer-TTL-first ordering. Strip stale markers
214
+ // off the deferred tools first (they sit after the breakpoint but still before
215
+ // system/messages, and the all-deferred case has no breakpoint of its own).
216
+ let mutated = false;
217
+ for (let i = 0; i < deferredTools.length; i++) {
218
+ const candidate = deferredTools[i] as AnthropicToolCacheCandidate;
219
+ if (hasCacheControl(candidate)) {
220
+ deferredTools[i] = stripCacheControl(candidate);
221
+ mutated = true;
222
+ }
223
+ }
224
+
146
225
  if (staticTools.length === 0) {
147
- return tools;
226
+ return mutated ? ([...deferredTools] as GraphTools) : tools;
227
+ }
228
+
229
+ // Strip any stray cache_control off the earlier static tools so a leftover
230
+ // 5-minute marker never sits ahead of the resolved breakpoint, then stamp (or
231
+ // re-stamp) only the last static tool with the resolved TTL.
232
+ for (let i = 0; i < staticTools.length - 1; i++) {
233
+ const candidate = staticTools[i] as AnthropicToolCacheCandidate;
234
+ if (hasCacheControl(candidate)) {
235
+ staticTools[i] = stripCacheControl(candidate);
236
+ mutated = true;
237
+ }
148
238
  }
149
239
 
150
240
  const last = staticTools[
151
241
  staticTools.length - 1
152
242
  ] as AnthropicToolCacheCandidate;
153
- // Already marked? Don't double-clone.
154
- if (hasCacheControl(last)) {
155
- if (deferredTools.length === 0) return tools;
156
- return [...staticTools, ...deferredTools] as GraphTools;
243
+ const desiredTtl: '1h' | undefined = ttl === '1h' ? '1h' : undefined;
244
+ // "Already correct" requires a marker IN the effective location (one that
245
+ // reaches the payload for this tool's shape) carrying the resolved TTL. A
246
+ // marker in the wrong place — e.g. a direct `cache_control` on a LangChain
247
+ // custom tool — is ineffective and must be re-stamped.
248
+ const effective = getEffectiveCacheControl(last);
249
+ const lastAlreadyCorrect =
250
+ effective != null &&
251
+ (effective.ttl === '1h' ? '1h' : undefined) === desiredTtl;
252
+ if (!lastAlreadyCorrect) {
253
+ staticTools[staticTools.length - 1] = markCacheControl(last, ttl);
254
+ mutated = true;
157
255
  }
158
256
 
159
- staticTools[staticTools.length - 1] = markCacheControl(last);
257
+ // Return the original reference only when nothing changed AND partitioning
258
+ // moved nothing. When deferred tools exist they must end up after the cache
259
+ // breakpoint, so the partitioned array is returned even if no marker changed.
260
+ if (!mutated && deferredTools.length === 0) {
261
+ return tools;
262
+ }
160
263
  return [...staticTools, ...deferredTools] as GraphTools;
161
264
  }
@@ -12,8 +12,14 @@ import {
12
12
  stripAnthropicCacheControl,
13
13
  stripBedrockCacheControl,
14
14
  addBedrockCacheControl,
15
+ addBedrockTailCacheControl,
15
16
  addCacheControl,
17
+ addTailCacheControl,
16
18
  addCacheControlToStablePrefixMessages,
19
+ buildAnthropicCacheControl,
20
+ buildBedrockCachePoint,
21
+ resolvePromptCacheTtl,
22
+ DEFAULT_PROMPT_CACHE_TTL,
17
23
  } from './cache';
18
24
  import { _convertMessagesToOpenAIParams } from '@/llm/openai/utils';
19
25
  import { toLangChainContent } from './langchain';
@@ -1813,3 +1819,172 @@ describe('OpenRouter prompt caching (reuses addCacheControl)', () => {
1813
1819
  expect('cache_control' in lastContent[0]).toBe(true);
1814
1820
  });
1815
1821
  });
1822
+
1823
+ describe('prompt-cache TTL (1h default, 5m legacy)', () => {
1824
+ describe('resolvePromptCacheTtl', () => {
1825
+ it('defaults to the 1h extended cache when unset', () => {
1826
+ expect(resolvePromptCacheTtl(undefined)).toBe('1h');
1827
+ expect(DEFAULT_PROMPT_CACHE_TTL).toBe('1h');
1828
+ });
1829
+
1830
+ it('passes an explicit value through unchanged', () => {
1831
+ expect(resolvePromptCacheTtl('5m')).toBe('5m');
1832
+ expect(resolvePromptCacheTtl('1h')).toBe('1h');
1833
+ });
1834
+ });
1835
+
1836
+ describe('marker builders', () => {
1837
+ it('buildAnthropicCacheControl adds ttl only for 1h', () => {
1838
+ expect(buildAnthropicCacheControl('1h')).toEqual({
1839
+ type: 'ephemeral',
1840
+ ttl: '1h',
1841
+ });
1842
+ // 5m / undefined stay byte-identical to the legacy marker (no ttl)
1843
+ expect(buildAnthropicCacheControl('5m')).toEqual({ type: 'ephemeral' });
1844
+ expect(buildAnthropicCacheControl()).toEqual({ type: 'ephemeral' });
1845
+ });
1846
+
1847
+ it('buildBedrockCachePoint adds ttl only for 1h', () => {
1848
+ expect(buildBedrockCachePoint('1h')).toEqual({
1849
+ type: 'default',
1850
+ ttl: '1h',
1851
+ });
1852
+ expect(buildBedrockCachePoint('5m')).toEqual({ type: 'default' });
1853
+ expect(buildBedrockCachePoint()).toEqual({ type: 'default' });
1854
+ });
1855
+ });
1856
+
1857
+ describe('addTailCacheControl threads ttl', () => {
1858
+ const baseMessages = (): AnthropicMessages => [
1859
+ { role: 'user', content: 'Hello' },
1860
+ { role: 'assistant', content: 'Hi there' },
1861
+ ];
1862
+
1863
+ it('stamps a 1h cache_control on the tail block', () => {
1864
+ const result = addTailCacheControl(baseMessages(), '1h');
1865
+ const tail = result[result.length - 1].content as MessageContentComplex[];
1866
+ expect(tail[tail.length - 1]).toEqual({
1867
+ type: 'text',
1868
+ text: 'Hi there',
1869
+ cache_control: { type: 'ephemeral', ttl: '1h' },
1870
+ });
1871
+ });
1872
+
1873
+ it('omits ttl for 5m and for the unspecified (legacy) default', () => {
1874
+ for (const ttl of ['5m', undefined] as const) {
1875
+ const result = addTailCacheControl(baseMessages(), ttl);
1876
+ const tail = result[result.length - 1]
1877
+ .content as MessageContentComplex[];
1878
+ expect(
1879
+ (tail[tail.length - 1] as Anthropic.TextBlockParam).cache_control
1880
+ ).toEqual({ type: 'ephemeral' });
1881
+ }
1882
+ });
1883
+ });
1884
+
1885
+ describe('addCacheControl threads ttl', () => {
1886
+ it('stamps a 1h cache_control on the latest user messages', () => {
1887
+ const messages: AnthropicMessages = [
1888
+ { role: 'user', content: 'first' },
1889
+ { role: 'assistant', content: 'reply' },
1890
+ { role: 'user', content: 'second' },
1891
+ ];
1892
+ const result = addCacheControl(messages, '1h');
1893
+ const lastUser = result[2].content as MessageContentComplex[];
1894
+ expect((lastUser[0] as Anthropic.TextBlockParam).cache_control).toEqual({
1895
+ type: 'ephemeral',
1896
+ ttl: '1h',
1897
+ });
1898
+ });
1899
+ });
1900
+
1901
+ describe('addCacheControlToStablePrefixMessages threads ttl', () => {
1902
+ it('stamps 1h on every stable-prefix marker', () => {
1903
+ const messages: AnthropicMessages = [
1904
+ { role: 'user', content: 'turn 1' },
1905
+ { role: 'assistant', content: 'reply 1' },
1906
+ { role: 'user', content: 'turn 2' },
1907
+ { role: 'assistant', content: 'reply 2' },
1908
+ ];
1909
+ const result = addCacheControlToStablePrefixMessages(messages, 2, '1h');
1910
+ const marked = result
1911
+ .flatMap((m) =>
1912
+ Array.isArray(m.content) ? (m.content as MessageContentComplex[]) : []
1913
+ )
1914
+ .filter((block) => 'cache_control' in block);
1915
+ expect(marked.length).toBeGreaterThan(0);
1916
+ for (const block of marked) {
1917
+ expect((block as Anthropic.TextBlockParam).cache_control).toEqual({
1918
+ type: 'ephemeral',
1919
+ ttl: '1h',
1920
+ });
1921
+ }
1922
+ });
1923
+ });
1924
+
1925
+ describe('addBedrockTailCacheControl threads ttl', () => {
1926
+ const messages = (): TestMsg[] => [
1927
+ { role: 'user', content: 'Hello' },
1928
+ { role: 'assistant', content: 'Hi' },
1929
+ ];
1930
+
1931
+ it('stamps a 1h cachePoint on the tail message', () => {
1932
+ const result = addBedrockTailCacheControl(messages(), '1h');
1933
+ const last = result[result.length - 1].content as MessageContentComplex[];
1934
+ expect(last[last.length - 1]).toEqual({
1935
+ cachePoint: { type: 'default', ttl: '1h' },
1936
+ });
1937
+ });
1938
+
1939
+ it('omits ttl for 5m and the legacy default', () => {
1940
+ for (const ttl of ['5m', undefined] as const) {
1941
+ const result = addBedrockTailCacheControl(messages(), ttl);
1942
+ const last = result[result.length - 1]
1943
+ .content as MessageContentComplex[];
1944
+ expect(last[last.length - 1]).toEqual({
1945
+ cachePoint: { type: 'default' },
1946
+ });
1947
+ }
1948
+ });
1949
+
1950
+ it('normalizes a stale 5m system cachePoint to the resolved tail ttl', () => {
1951
+ const msgs: TestMsg[] = [
1952
+ {
1953
+ role: 'system',
1954
+ content: [
1955
+ { type: ContentTypes.TEXT, text: 'System' },
1956
+ { cachePoint: { type: 'default' } } as MessageContentComplex,
1957
+ ],
1958
+ },
1959
+ { role: 'user', content: 'Hello' },
1960
+ { role: 'assistant', content: 'Hi' },
1961
+ ];
1962
+ const result = addBedrockTailCacheControl(msgs, '1h');
1963
+ // Stale 5m system checkpoint is upgraded to 1h so it never precedes the
1964
+ // 1h message tail (Bedrock requires longer-TTL checkpoints first).
1965
+ const system = result[0].content as MessageContentComplex[];
1966
+ expect(system[system.length - 1]).toEqual({
1967
+ cachePoint: { type: 'default', ttl: '1h' },
1968
+ });
1969
+ const tail = result[result.length - 1].content as MessageContentComplex[];
1970
+ expect(tail[tail.length - 1]).toEqual({
1971
+ cachePoint: { type: 'default', ttl: '1h' },
1972
+ });
1973
+ });
1974
+ });
1975
+
1976
+ describe('addBedrockCacheControl threads ttl', () => {
1977
+ it('stamps a 1h cachePoint when configured', () => {
1978
+ const messages: TestMsg[] = [
1979
+ { role: 'user', content: 'Hello' },
1980
+ { role: 'assistant', content: 'Hi' },
1981
+ ];
1982
+ const result = addBedrockCacheControl(messages, '1h');
1983
+ // Only one user message present, so the cachePoint anchors on it.
1984
+ const user = result[0].content as MessageContentComplex[];
1985
+ expect(user[user.length - 1]).toEqual({
1986
+ cachePoint: { type: 'default', ttl: '1h' },
1987
+ });
1988
+ });
1989
+ });
1990
+ });