@librechat/agents 3.3.3 → 3.3.5
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 +2 -1
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/common/constants.cjs +21 -0
- package/dist/cjs/common/constants.cjs.map +1 -1
- package/dist/cjs/events.cjs +10 -1
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +793 -111
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +3 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +25 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs +12 -0
- package/dist/cjs/hooks/index.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +8 -0
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +79 -6
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +38 -13
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/google/utils/common.cjs +19 -7
- package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +331 -5
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +41 -5
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/utils/index.cjs +25 -4
- package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs +4 -1
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/llm/preempt.cjs +132 -0
- package/dist/cjs/llm/preempt.cjs.map +1 -0
- package/dist/cjs/main.cjs +36 -5
- package/dist/cjs/messages/alternation.cjs +92 -0
- package/dist/cjs/messages/alternation.cjs.map +1 -0
- package/dist/cjs/messages/cache.cjs +54 -33
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/contextPruning.cjs +17 -43
- package/dist/cjs/messages/contextPruning.cjs.map +1 -1
- package/dist/cjs/messages/core.cjs +315 -23
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +290 -60
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/handoffCue.cjs +63 -0
- package/dist/cjs/messages/handoffCue.cjs.map +1 -0
- package/dist/cjs/messages/index.cjs +4 -1
- package/dist/cjs/messages/injected.cjs +60 -0
- package/dist/cjs/messages/injected.cjs.map +1 -0
- package/dist/cjs/messages/prune.cjs +503 -127
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +80 -7
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/handlers.cjs +18 -9
- package/dist/cjs/session/handlers.cjs.map +1 -1
- package/dist/cjs/stream.cjs +35 -10
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +28 -14
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +56 -63
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
- package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
- package/dist/cjs/tools/search/tool.cjs +9 -1
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/toolOutputReferences.cjs +6 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/cjs/utils/index.cjs +1 -1
- package/dist/cjs/utils/llm.cjs +1 -1
- package/dist/cjs/utils/llm.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +307 -20
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/cjs/utils/toolContent.cjs +1514 -0
- package/dist/cjs/utils/toolContent.cjs.map +1 -0
- package/dist/esm/agents/AgentContext.mjs +2 -1
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/common/constants.mjs +19 -1
- package/dist/esm/common/constants.mjs.map +1 -1
- package/dist/esm/events.mjs +10 -1
- package/dist/esm/events.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +798 -116
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +3 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +25 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs +12 -1
- package/dist/esm/hooks/index.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +9 -1
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +79 -6
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +38 -13
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/google/utils/common.mjs +19 -7
- package/dist/esm/llm/google/utils/common.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +332 -8
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +41 -5
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openai/utils/index.mjs +25 -4
- package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs +4 -1
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/llm/preempt.mjs +131 -0
- package/dist/esm/llm/preempt.mjs.map +1 -0
- package/dist/esm/main.mjs +14 -11
- package/dist/esm/messages/alternation.mjs +91 -0
- package/dist/esm/messages/alternation.mjs.map +1 -0
- package/dist/esm/messages/cache.mjs +55 -34
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/contextPruning.mjs +17 -43
- package/dist/esm/messages/contextPruning.mjs.map +1 -1
- package/dist/esm/messages/core.mjs +305 -24
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +290 -61
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/handoffCue.mjs +61 -0
- package/dist/esm/messages/handoffCue.mjs.map +1 -0
- package/dist/esm/messages/index.mjs +4 -1
- package/dist/esm/messages/injected.mjs +60 -0
- package/dist/esm/messages/injected.mjs.map +1 -0
- package/dist/esm/messages/prune.mjs +500 -128
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +80 -7
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/handlers.mjs +19 -10
- package/dist/esm/session/handlers.mjs.map +1 -1
- package/dist/esm/stream.mjs +34 -11
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +28 -14
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +56 -63
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
- package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
- package/dist/esm/tools/search/tool.mjs +9 -1
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/toolOutputReferences.mjs +6 -0
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
- package/dist/esm/utils/index.mjs +1 -1
- package/dist/esm/utils/llm.mjs +1 -1
- package/dist/esm/utils/llm.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +307 -21
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/esm/utils/toolContent.mjs +1503 -0
- package/dist/esm/utils/toolContent.mjs.map +1 -0
- package/dist/types/common/constants.d.ts +18 -0
- package/dist/types/graphs/Graph.d.ts +139 -2
- package/dist/types/hooks/HookRegistry.d.ts +15 -0
- package/dist/types/hooks/index.d.ts +12 -1
- package/dist/types/hooks/types.d.ts +45 -6
- package/dist/types/index.d.ts +1 -1
- package/dist/types/llm/invoke.d.ts +34 -1
- package/dist/types/llm/openai/index.d.ts +4 -0
- package/dist/types/llm/openai/utils/index.d.ts +2 -0
- package/dist/types/llm/openrouter/index.d.ts +5 -0
- package/dist/types/llm/preempt.d.ts +40 -0
- package/dist/types/messages/alternation.d.ts +23 -0
- package/dist/types/messages/contextPruning.d.ts +2 -1
- package/dist/types/messages/core.d.ts +36 -0
- package/dist/types/messages/format.d.ts +6 -0
- package/dist/types/messages/handoffCue.d.ts +40 -0
- package/dist/types/messages/index.d.ts +3 -0
- package/dist/types/messages/injected.d.ts +3 -0
- package/dist/types/messages/prune.d.ts +16 -12
- package/dist/types/run.d.ts +7 -0
- package/dist/types/stream.d.ts +14 -0
- package/dist/types/tools/ToolNode.d.ts +0 -7
- package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
- package/dist/types/tools/search/types.d.ts +31 -2
- package/dist/types/types/graph.d.ts +8 -1
- package/dist/types/types/run.d.ts +65 -0
- package/dist/types/types/stream.d.ts +1 -26
- package/dist/types/utils/tokens.d.ts +7 -0
- package/dist/types/utils/toolContent.d.ts +107 -0
- package/package.json +1 -1
- package/src/__tests__/stream.eagerEventExecution.test.ts +74 -0
- package/src/agents/AgentContext.ts +1 -0
- package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
- package/src/common/constants.ts +21 -0
- package/src/events.ts +15 -1
- package/src/graphs/Graph.ts +1261 -198
- package/src/graphs/MultiAgentGraph.ts +6 -1
- package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1139 -4
- package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
- package/src/hooks/HookRegistry.ts +40 -0
- package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
- package/src/hooks/index.ts +16 -2
- package/src/hooks/types.ts +47 -3
- package/src/index.ts +1 -1
- package/src/langfuse.ts +26 -1
- package/src/llm/anthropic/utils/message_inputs.ts +158 -12
- package/src/llm/anthropic/utils/tool-id-normalization.test.ts +109 -0
- package/src/llm/bedrock/utils/message_inputs.test.ts +131 -1
- package/src/llm/bedrock/utils/message_inputs.ts +100 -24
- package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
- package/src/llm/google/utils/common.test.ts +75 -9
- package/src/llm/google/utils/common.ts +34 -3
- package/src/llm/invoke.alternation.test.ts +87 -0
- package/src/llm/invoke.handoffCue.test.ts +112 -0
- package/src/llm/invoke.test.ts +488 -0
- package/src/llm/invoke.ts +611 -8
- package/src/llm/openai/index.ts +106 -6
- package/src/llm/openai/utils/index.ts +109 -53
- package/src/llm/openai/utils/messages.test.ts +330 -1
- package/src/llm/openrouter/index.ts +12 -2
- package/src/llm/openrouter/reasoning.test.ts +314 -0
- package/src/llm/preempt.test.ts +323 -0
- package/src/llm/preempt.ts +178 -0
- package/src/messages/__tests__/observationMasking.test.ts +93 -2
- package/src/messages/alternation.test.ts +212 -0
- package/src/messages/alternation.ts +112 -0
- package/src/messages/cache.tail.test.ts +193 -0
- package/src/messages/cache.test.ts +113 -0
- package/src/messages/cache.ts +92 -60
- package/src/messages/contextPruning.test.ts +184 -0
- package/src/messages/contextPruning.ts +49 -42
- package/src/messages/core.ts +653 -40
- package/src/messages/ensureThinkingBlock.test.ts +49 -1
- package/src/messages/foldToollessToolBlocks.test.ts +163 -5
- package/src/messages/format.ts +454 -91
- package/src/messages/formatAgentMessages.steer.test.ts +267 -0
- package/src/messages/formatAgentMessages.test.ts +604 -14
- package/src/messages/formatAgentMessages.tools.test.ts +103 -0
- package/src/messages/handoffCue.test.ts +96 -0
- package/src/messages/handoffCue.ts +78 -0
- package/src/messages/index.ts +3 -0
- package/src/messages/injected.test.ts +90 -0
- package/src/messages/injected.ts +74 -0
- package/src/messages/prune.ts +996 -183
- package/src/run.ts +91 -6
- package/src/scripts/preempt-probe.ts +330 -0
- package/src/scripts/preempt-scenarios.ts +388 -0
- package/src/session/handlers.ts +32 -12
- package/src/specs/handoffCue.test.ts +165 -0
- package/src/specs/langfuse-callbacks.test.ts +352 -2
- package/src/specs/preemptSeal.test.ts +309 -0
- package/src/specs/prune.test.ts +1083 -6
- package/src/specs/summarization-unit.test.ts +105 -0
- package/src/specs/tokens.test.ts +609 -32
- package/src/stream.dispatch.test.ts +63 -0
- package/src/stream.ts +59 -23
- package/src/summarization/__tests__/node.test.ts +77 -0
- package/src/summarization/node.ts +46 -18
- package/src/tools/ToolNode.ts +150 -99
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +148 -1
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +112 -3
- package/src/tools/__tests__/annotateMessagesForLLM.test.ts +32 -0
- package/src/tools/__tests__/directToolHooks.test.ts +255 -7
- package/src/tools/search/keenable-scraper.test.ts +153 -0
- package/src/tools/search/keenable-scraper.ts +137 -0
- package/src/tools/search/tool.ts +13 -2
- package/src/tools/search/types.ts +50 -3
- package/src/tools/toolOutputReferences.ts +6 -0
- package/src/types/graph.ts +12 -1
- package/src/types/run.ts +67 -0
- package/src/types/stream.ts +1 -42
- package/src/utils/llm.test.ts +18 -0
- package/src/utils/llm.ts +4 -1
- package/src/utils/tokens.ts +586 -31
- package/src/utils/toolContent.test.ts +1432 -0
- package/src/utils/toolContent.ts +2707 -0
- package/dist/cjs/splitStream.cjs +0 -151
- package/dist/cjs/splitStream.cjs.map +0 -1
- package/dist/esm/splitStream.mjs +0 -150
- package/dist/esm/splitStream.mjs.map +0 -1
- package/dist/types/mockStream.d.ts +0 -32
- package/dist/types/splitStream.d.ts +0 -37
- package/src/mockStream.ts +0 -99
- package/src/splitStream.ts +0 -234
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import { Providers } from '@/common';
|
|
3
|
+
import {
|
|
4
|
+
coalesceAdjacentUserTurns,
|
|
5
|
+
strictAlternationProviders,
|
|
6
|
+
} from './alternation';
|
|
7
|
+
|
|
8
|
+
describe('strictAlternationProviders', () => {
|
|
9
|
+
it('covers the providers that reject consecutive user turns', () => {
|
|
10
|
+
expect(strictAlternationProviders.has(Providers.BEDROCK)).toBe(true);
|
|
11
|
+
expect(strictAlternationProviders.has(Providers.MISTRAL)).toBe(true);
|
|
12
|
+
expect(strictAlternationProviders.has(Providers.MISTRALAI)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('leaves providers that accept them alone', () => {
|
|
16
|
+
expect(strictAlternationProviders.has(Providers.ANTHROPIC)).toBe(false);
|
|
17
|
+
expect(strictAlternationProviders.has(Providers.OPENAI)).toBe(false);
|
|
18
|
+
expect(strictAlternationProviders.has(Providers.GOOGLE)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('coalesceAdjacentUserTurns', () => {
|
|
23
|
+
it('leaves an already-alternating run untouched, returning the SAME array', () => {
|
|
24
|
+
const messages = [
|
|
25
|
+
new HumanMessage({ content: 'question' }),
|
|
26
|
+
new AIMessage({ content: 'answer' }),
|
|
27
|
+
new HumanMessage({ content: 'follow-up' }),
|
|
28
|
+
];
|
|
29
|
+
const result = coalesceAdjacentUserTurns(messages);
|
|
30
|
+
/**
|
|
31
|
+
* Identity, not just equality: the pass runs twice for a primary
|
|
32
|
+
* Bedrock/Mistral call (createCallModel, then the attemptInvoke funnel),
|
|
33
|
+
* so the normalized second pass must not reallocate a context-sized
|
|
34
|
+
* array — and origin tracking early-exits on `before === after`.
|
|
35
|
+
*/
|
|
36
|
+
expect(result).toBe(messages);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is idempotent by identity: re-coalescing merged output is a no-op', () => {
|
|
40
|
+
const once = coalesceAdjacentUserTurns([
|
|
41
|
+
new HumanMessage({ content: 'steer 1' }),
|
|
42
|
+
new HumanMessage({ content: 'steer 2' }),
|
|
43
|
+
]);
|
|
44
|
+
expect(once).toHaveLength(1);
|
|
45
|
+
expect(coalesceAdjacentUserTurns(once)).toBe(once);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** A boundary that drains two steers, which is the ordinary queue case. */
|
|
49
|
+
it('merges a run of consecutive human turns', () => {
|
|
50
|
+
const result = coalesceAdjacentUserTurns([
|
|
51
|
+
new HumanMessage({ content: 'question' }),
|
|
52
|
+
new AIMessage({ content: 'partial' }),
|
|
53
|
+
new HumanMessage({ content: 'steer 1' }),
|
|
54
|
+
new HumanMessage({ content: 'steer 2' }),
|
|
55
|
+
]);
|
|
56
|
+
expect(result.map((m) => m.getType())).toEqual(['human', 'ai', 'human']);
|
|
57
|
+
expect(result[2].content).toBe('steer 1\n\nsteer 2');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('merges context plus injected messages into one turn', () => {
|
|
61
|
+
const result = coalesceAdjacentUserTurns([
|
|
62
|
+
new AIMessage({ content: 'partial' }),
|
|
63
|
+
new HumanMessage({
|
|
64
|
+
content: 'hook context',
|
|
65
|
+
additional_kwargs: { role: 'system', source: 'hook' },
|
|
66
|
+
}),
|
|
67
|
+
new HumanMessage({
|
|
68
|
+
content: 'steer',
|
|
69
|
+
additional_kwargs: { role: 'user', source: 'steer' },
|
|
70
|
+
}),
|
|
71
|
+
]);
|
|
72
|
+
expect(result).toHaveLength(2);
|
|
73
|
+
expect(result[1].content).toBe('hook context\n\nsteer');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('concatenates block content rather than stringifying it', () => {
|
|
77
|
+
const result = coalesceAdjacentUserTurns([
|
|
78
|
+
new HumanMessage({ content: [{ type: 'text', text: 'look' }] }),
|
|
79
|
+
new HumanMessage({
|
|
80
|
+
content: [{ type: 'image_url', image_url: { url: 'data:,' } }],
|
|
81
|
+
}),
|
|
82
|
+
]);
|
|
83
|
+
expect(result).toHaveLength(1);
|
|
84
|
+
expect(result[0].content).toEqual([
|
|
85
|
+
{ type: 'text', text: 'look' },
|
|
86
|
+
{ type: 'image_url', image_url: { url: 'data:,' } },
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('normalizes a mixed string and block pair', () => {
|
|
91
|
+
const result = coalesceAdjacentUserTurns([
|
|
92
|
+
new HumanMessage({ content: 'plain' }),
|
|
93
|
+
new HumanMessage({ content: [{ type: 'text', text: 'blocks' }] }),
|
|
94
|
+
]);
|
|
95
|
+
expect(result).toHaveLength(1);
|
|
96
|
+
expect(result[0].content).toEqual([
|
|
97
|
+
{ type: 'text', text: 'plain' },
|
|
98
|
+
{ type: 'text', text: 'blocks' },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Both vendored converters merge adjacent tool-result runs themselves, and
|
|
104
|
+
* folding one into a text turn would orphan the pairing.
|
|
105
|
+
*/
|
|
106
|
+
it('never merges tool-result turns', () => {
|
|
107
|
+
const result = coalesceAdjacentUserTurns([
|
|
108
|
+
new HumanMessage({
|
|
109
|
+
content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }],
|
|
110
|
+
}),
|
|
111
|
+
new HumanMessage({
|
|
112
|
+
content: [{ type: 'tool_result', tool_use_id: 't2', content: 'ok' }],
|
|
113
|
+
}),
|
|
114
|
+
]);
|
|
115
|
+
expect(result).toHaveLength(2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('never merges a tool-result turn into a text turn', () => {
|
|
119
|
+
const result = coalesceAdjacentUserTurns([
|
|
120
|
+
new HumanMessage({
|
|
121
|
+
content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }],
|
|
122
|
+
}),
|
|
123
|
+
new HumanMessage({ content: 'steer' }),
|
|
124
|
+
]);
|
|
125
|
+
expect(result).toHaveLength(2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The prompt-cache tail anchor reasons positionally — it inserts the
|
|
130
|
+
* breakpoint after the merged message's LAST text block — so the last
|
|
131
|
+
* part's provenance flags must survive the merge. A skill body absorbed
|
|
132
|
+
* into a real user turn stays anchorable; a real turn absorbed into a
|
|
133
|
+
* trailing skill body must not let the anchor pin the volatile body.
|
|
134
|
+
*/
|
|
135
|
+
it('keeps the last turn\'s additional_kwargs and the first turn\'s id', () => {
|
|
136
|
+
const result = coalesceAdjacentUserTurns([
|
|
137
|
+
new HumanMessage({
|
|
138
|
+
content: 'skill body',
|
|
139
|
+
id: 'first-id',
|
|
140
|
+
additional_kwargs: { isMeta: true, source: 'skill' },
|
|
141
|
+
}),
|
|
142
|
+
new HumanMessage({
|
|
143
|
+
content: 'real user turn',
|
|
144
|
+
id: 'second-id',
|
|
145
|
+
additional_kwargs: { role: 'user' },
|
|
146
|
+
}),
|
|
147
|
+
]);
|
|
148
|
+
expect(result).toHaveLength(1);
|
|
149
|
+
expect(result[0].additional_kwargs).toEqual({ role: 'user' });
|
|
150
|
+
expect(result[0].id).toBe('first-id');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('marks the merge meta when the trailing part is the volatile one', () => {
|
|
154
|
+
const result = coalesceAdjacentUserTurns([
|
|
155
|
+
new HumanMessage({
|
|
156
|
+
content: 'real steer',
|
|
157
|
+
additional_kwargs: { source: 'steer' },
|
|
158
|
+
}),
|
|
159
|
+
new HumanMessage({
|
|
160
|
+
content: 'skill body',
|
|
161
|
+
additional_kwargs: { isMeta: true, source: 'skill', skillName: 'x' },
|
|
162
|
+
}),
|
|
163
|
+
]);
|
|
164
|
+
expect(result).toHaveLength(1);
|
|
165
|
+
expect(result[0].additional_kwargs.source).toBe('skill');
|
|
166
|
+
expect(result[0].additional_kwargs.isMeta).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Only ALL-tool-result turns are excluded. A mixed turn is an ordinary
|
|
171
|
+
* user turn that happens to carry a result block — merging preserves block
|
|
172
|
+
* order, so the pairing survives, and excluding it would leave adjacent
|
|
173
|
+
* user turns on the wire for exactly the shape Bedrock rejects.
|
|
174
|
+
*/
|
|
175
|
+
it('merges a mixed text plus tool-result turn', () => {
|
|
176
|
+
const result = coalesceAdjacentUserTurns([
|
|
177
|
+
new HumanMessage({
|
|
178
|
+
content: [
|
|
179
|
+
{ type: 'tool_result', tool_use_id: 't1', content: 'ok' },
|
|
180
|
+
{ type: 'text', text: 'and my comment' },
|
|
181
|
+
],
|
|
182
|
+
}),
|
|
183
|
+
new HumanMessage({ content: 'next turn' }),
|
|
184
|
+
]);
|
|
185
|
+
expect(result).toHaveLength(1);
|
|
186
|
+
expect(result[0].content).toEqual([
|
|
187
|
+
{ type: 'tool_result', tool_use_id: 't1', content: 'ok' },
|
|
188
|
+
{ type: 'text', text: 'and my comment' },
|
|
189
|
+
{ type: 'text', text: 'next turn' },
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('excludes the camelCase toolResult variant too', () => {
|
|
194
|
+
const result = coalesceAdjacentUserTurns([
|
|
195
|
+
new HumanMessage({
|
|
196
|
+
content: [{ type: 'toolResult', toolResult: { content: 'ok' } }],
|
|
197
|
+
}),
|
|
198
|
+
new HumanMessage({ content: 'steer' }),
|
|
199
|
+
]);
|
|
200
|
+
expect(result).toHaveLength(2);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('does not mutate the input array or its messages', () => {
|
|
204
|
+
const first = new HumanMessage({ content: 'a' });
|
|
205
|
+
const second = new HumanMessage({ content: 'b' });
|
|
206
|
+
const messages = [first, second];
|
|
207
|
+
coalesceAdjacentUserTurns(messages);
|
|
208
|
+
expect(messages).toHaveLength(2);
|
|
209
|
+
expect(first.content).toBe('a');
|
|
210
|
+
expect(second.content).toBe('b');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/messages/alternation.ts
|
|
2
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
3
|
+
import type { BaseMessage, MessageContent } from '@langchain/core/messages';
|
|
4
|
+
import { Providers } from '@/common';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Providers whose APIs specify strict user/assistant alternation. Mistral
|
|
8
|
+
* rejects consecutive user turns outright. Bedrock's Converse API documents
|
|
9
|
+
* the alternation requirement across many model families; enforcement varies
|
|
10
|
+
* by family — Claude on Converse currently tolerates adjacent user turns
|
|
11
|
+
* (verified live, 2026-07-28) — so the payload is normalized for all of them
|
|
12
|
+
* rather than betting on per-family leniency. Anthropic's own Messages API,
|
|
13
|
+
* OpenAI and Gemini all accept consecutive user turns, so they are
|
|
14
|
+
* deliberately absent.
|
|
15
|
+
*/
|
|
16
|
+
export const strictAlternationProviders: ReadonlySet<Providers> = new Set([
|
|
17
|
+
Providers.BEDROCK,
|
|
18
|
+
Providers.MISTRAL,
|
|
19
|
+
Providers.MISTRALAI,
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const TOOL_RESULT_TYPES = new Set(['tool_result', 'toolResult']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* True when every block is a tool result. Both vendored converters already
|
|
26
|
+
* merge adjacent runs of these, and folding one into a text turn would break
|
|
27
|
+
* the tool pairing they depend on — so they are left alone here.
|
|
28
|
+
*/
|
|
29
|
+
function isToolResultMessage(message: BaseMessage): boolean {
|
|
30
|
+
const { content } = message;
|
|
31
|
+
if (typeof content === 'string' || content.length === 0) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return content.every(
|
|
35
|
+
(block) =>
|
|
36
|
+
typeof block.type === 'string' && TOOL_RESULT_TYPES.has(block.type)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toBlocks(content: MessageContent): Exclude<MessageContent, string> {
|
|
41
|
+
if (typeof content === 'string') {
|
|
42
|
+
return content === '' ? [] : [{ type: 'text', text: content }];
|
|
43
|
+
}
|
|
44
|
+
return content;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function joinContent(
|
|
48
|
+
left: MessageContent,
|
|
49
|
+
right: MessageContent
|
|
50
|
+
): MessageContent {
|
|
51
|
+
if (typeof left === 'string' && typeof right === 'string') {
|
|
52
|
+
return left === '' ? right : `${left}\n\n${right}`;
|
|
53
|
+
}
|
|
54
|
+
return [...toBlocks(left), ...toBlocks(right)];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Merges runs of consecutive human turns into one, for providers that reject
|
|
59
|
+
* them. Purely a wire-shaping pass: it returns a new array of new messages,
|
|
60
|
+
* so graph state and the host's persisted messages keep the per-message
|
|
61
|
+
* identity that steer rendering and the trailing-steer anchor rely on.
|
|
62
|
+
*
|
|
63
|
+
* Tool-result turns are excluded — the converters merge those themselves, and
|
|
64
|
+
* combining one with a text turn would orphan the pairing.
|
|
65
|
+
*/
|
|
66
|
+
export function coalesceAdjacentUserTurns(
|
|
67
|
+
messages: BaseMessage[]
|
|
68
|
+
): BaseMessage[] {
|
|
69
|
+
const result: BaseMessage[] = [];
|
|
70
|
+
let mergedAny = false;
|
|
71
|
+
for (const message of messages) {
|
|
72
|
+
const previous = result[result.length - 1];
|
|
73
|
+
const mergeable =
|
|
74
|
+
result.length > 0 &&
|
|
75
|
+
previous.getType() === 'human' &&
|
|
76
|
+
message.getType() === 'human' &&
|
|
77
|
+
!isToolResultMessage(previous) &&
|
|
78
|
+
!isToolResultMessage(message);
|
|
79
|
+
|
|
80
|
+
if (!mergeable) {
|
|
81
|
+
result.push(message);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
mergedAny = true;
|
|
85
|
+
|
|
86
|
+
result[result.length - 1] = new HumanMessage({
|
|
87
|
+
content: joinContent(previous.content, message.content),
|
|
88
|
+
/**
|
|
89
|
+
* The LATER turn's kwargs, deliberately. The one provider-path consumer
|
|
90
|
+
* of these flags is the prompt-cache tail anchor, and it reasons
|
|
91
|
+
* positionally: `isSyntheticMetaMessage` decides whether a breakpoint
|
|
92
|
+
* may be inserted after the message's LAST text block. Merging keeps
|
|
93
|
+
* the last block's provenance only if the last part's kwargs survive —
|
|
94
|
+
* a skill body absorbed into a real user turn must stay anchorable
|
|
95
|
+
* (the real turn ends it), while a real steer absorbed into a trailing
|
|
96
|
+
* skill body must not pin the cache to the volatile body. The first
|
|
97
|
+
* turn's id is kept so origin tracking can re-attach by key.
|
|
98
|
+
*/
|
|
99
|
+
additional_kwargs: message.additional_kwargs,
|
|
100
|
+
...(previous.id != null && { id: previous.id }),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Identity on the no-merge path. The pass runs twice for a primary
|
|
105
|
+
* Bedrock/Mistral call — once in `createCallModel` (must precede the cache
|
|
106
|
+
* breakpoint) and once in the `attemptInvoke` funnel (must cover fallback
|
|
107
|
+
* and summarization sends) — so the already-normalized second pass returns
|
|
108
|
+
* the SAME array rather than reallocating a context-sized copy, and
|
|
109
|
+
* callers can cheaply detect "nothing changed" by identity.
|
|
110
|
+
*/
|
|
111
|
+
return mergedAny ? result : messages;
|
|
112
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { convertMessagesToCompletionsMessageParams } from '@langchain/openai';
|
|
1
2
|
import {
|
|
2
3
|
AIMessage,
|
|
3
4
|
HumanMessage,
|
|
@@ -10,7 +11,15 @@ import type {
|
|
|
10
11
|
} from '@langchain/core/messages';
|
|
11
12
|
import type Anthropic from '@anthropic-ai/sdk';
|
|
12
13
|
import type { AnthropicMessages } from '@/types/messages';
|
|
14
|
+
import {
|
|
15
|
+
projectOpenAIChatToolMessageContent,
|
|
16
|
+
projectOpenAIToolMessageContent,
|
|
17
|
+
projectOpenAIResponsesToolMessageContent,
|
|
18
|
+
projectOpenRouterToolMessageContent,
|
|
19
|
+
projectComputerCallOutputsToText,
|
|
20
|
+
} from './core';
|
|
13
21
|
import { addTailCacheControl, addBedrockTailCacheControl } from './cache';
|
|
22
|
+
import { convertToConverseMessages } from '@/llm/bedrock/utils';
|
|
14
23
|
import { toLangChainContent } from './langchain';
|
|
15
24
|
|
|
16
25
|
type CacheControlBlock = MessageContentComplex & {
|
|
@@ -86,6 +95,154 @@ describe('addTailCacheControl (single tail breakpoint)', () => {
|
|
|
86
95
|
expect(blocksOf(result[2])[0].cache_control).toEqual({ type: 'ephemeral' });
|
|
87
96
|
});
|
|
88
97
|
|
|
98
|
+
test('skips native computer outputs and removes stale screenshot markers', () => {
|
|
99
|
+
const computerCall = new AIMessage({
|
|
100
|
+
content: '',
|
|
101
|
+
response_metadata: {
|
|
102
|
+
output: [
|
|
103
|
+
{
|
|
104
|
+
type: 'computer_call',
|
|
105
|
+
call_id: 'call_computer_cache',
|
|
106
|
+
action: { type: 'screenshot' },
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const computerOutput = new ToolMessage({
|
|
112
|
+
content: 'data:image/png;base64,AA==',
|
|
113
|
+
tool_call_id: 'call_computer_cache',
|
|
114
|
+
additional_kwargs: { type: 'computer_call_output' },
|
|
115
|
+
});
|
|
116
|
+
const canonical = projectOpenAIToolMessageContent([
|
|
117
|
+
new HumanMessage('Take a screenshot'),
|
|
118
|
+
computerCall,
|
|
119
|
+
computerOutput,
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
const cached = addTailCacheControl(canonical);
|
|
123
|
+
|
|
124
|
+
expect(countCacheMarkers(cached)).toBe(1);
|
|
125
|
+
expect(blocksOf(cached[0])[0].cache_control).toEqual({
|
|
126
|
+
type: 'ephemeral',
|
|
127
|
+
});
|
|
128
|
+
expect(blocksOf(cached[2])[0]).not.toHaveProperty('cache_control');
|
|
129
|
+
expect(() =>
|
|
130
|
+
projectOpenAIResponsesToolMessageContent(cached)
|
|
131
|
+
).not.toThrow();
|
|
132
|
+
|
|
133
|
+
const canonicalOutput = canonical[2] as ToolMessage;
|
|
134
|
+
const staleOutput = new ToolMessage({
|
|
135
|
+
content: toLangChainContent([
|
|
136
|
+
{
|
|
137
|
+
...(canonicalOutput.content[0] as MessageContentComplex),
|
|
138
|
+
cache_control: { type: 'ephemeral' },
|
|
139
|
+
},
|
|
140
|
+
]),
|
|
141
|
+
tool_call_id: canonicalOutput.tool_call_id,
|
|
142
|
+
additional_kwargs: canonicalOutput.additional_kwargs,
|
|
143
|
+
});
|
|
144
|
+
const cleaned = addTailCacheControl([
|
|
145
|
+
canonical[0],
|
|
146
|
+
canonical[1],
|
|
147
|
+
staleOutput,
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
expect(countCacheMarkers(cleaned)).toBe(1);
|
|
151
|
+
expect(blocksOf(cleaned[2])[0]).not.toHaveProperty('cache_control');
|
|
152
|
+
expect(blocksOf(staleOutput)[0]).toHaveProperty('cache_control');
|
|
153
|
+
expect(() =>
|
|
154
|
+
projectOpenAIResponsesToolMessageContent(cleaned)
|
|
155
|
+
).not.toThrow();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('keeps a cached tool result structured through Chat projection', () => {
|
|
159
|
+
const toolCall = new AIMessage({
|
|
160
|
+
content: '',
|
|
161
|
+
tool_calls: [{ id: 'call_cached_tool', name: 'search', args: {} }],
|
|
162
|
+
});
|
|
163
|
+
const toolOutput = new ToolMessage({
|
|
164
|
+
content: 'result body',
|
|
165
|
+
tool_call_id: 'call_cached_tool',
|
|
166
|
+
});
|
|
167
|
+
const graphProjected = projectOpenAIToolMessageContent([
|
|
168
|
+
new HumanMessage('Search'),
|
|
169
|
+
toolCall,
|
|
170
|
+
toolOutput,
|
|
171
|
+
]);
|
|
172
|
+
const cached = addTailCacheControl(graphProjected);
|
|
173
|
+
const attemptProjected = projectOpenRouterToolMessageContent(cached);
|
|
174
|
+
const payload = convertMessagesToCompletionsMessageParams({
|
|
175
|
+
messages: attemptProjected,
|
|
176
|
+
model: 'gpt-4o',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(blocksOf(cached[2])[0]).toMatchObject({
|
|
180
|
+
type: 'text',
|
|
181
|
+
text: 'result body',
|
|
182
|
+
cache_control: { type: 'ephemeral' },
|
|
183
|
+
});
|
|
184
|
+
expect((attemptProjected[2] as ToolMessage).content).toEqual(
|
|
185
|
+
(cached[2] as ToolMessage).content
|
|
186
|
+
);
|
|
187
|
+
expect(payload[2]).toMatchObject({
|
|
188
|
+
role: 'tool',
|
|
189
|
+
tool_call_id: 'call_cached_tool',
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: 'text',
|
|
193
|
+
text: 'result body',
|
|
194
|
+
cache_control: { type: 'ephemeral' },
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const fallbackProjected = projectOpenAIChatToolMessageContent(cached);
|
|
200
|
+
const fallbackPayload = convertMessagesToCompletionsMessageParams({
|
|
201
|
+
messages: fallbackProjected,
|
|
202
|
+
model: 'gpt-4o',
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect((fallbackProjected[2] as ToolMessage).content).toBe('result body');
|
|
206
|
+
expect(fallbackPayload[2]).toMatchObject({
|
|
207
|
+
role: 'tool',
|
|
208
|
+
tool_call_id: 'call_cached_tool',
|
|
209
|
+
content: 'result body',
|
|
210
|
+
});
|
|
211
|
+
expect(JSON.stringify(fallbackPayload[2])).not.toContain('cache_control');
|
|
212
|
+
|
|
213
|
+
expect(
|
|
214
|
+
typeof (projectOpenAIToolMessageContent(cached)[2] as ToolMessage).content
|
|
215
|
+
).toBe('string');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('does not stringify noncanonical cache metadata into tool output', () => {
|
|
219
|
+
const messages = addTailCacheControl<BaseMessage>([
|
|
220
|
+
new HumanMessage('Search'),
|
|
221
|
+
new AIMessage({
|
|
222
|
+
content: '',
|
|
223
|
+
tool_calls: [{ id: 'call_nested_cache', name: 'search', args: {} }],
|
|
224
|
+
}),
|
|
225
|
+
new ToolMessage({
|
|
226
|
+
content: toLangChainContent([
|
|
227
|
+
{
|
|
228
|
+
type: 'tool_result',
|
|
229
|
+
tool_use_id: 'call_nested_cache',
|
|
230
|
+
content: 'result body',
|
|
231
|
+
},
|
|
232
|
+
] as MessageContentComplex[]),
|
|
233
|
+
tool_call_id: 'call_nested_cache',
|
|
234
|
+
}),
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
const projected = projectOpenRouterToolMessageContent(messages);
|
|
238
|
+
const content = (projected[2] as ToolMessage).content;
|
|
239
|
+
|
|
240
|
+
expect(typeof content).toBe('string');
|
|
241
|
+
expect(content).toContain('result body');
|
|
242
|
+
expect(content).not.toContain('cache_control');
|
|
243
|
+
expect(JSON.stringify(messages[2].content)).toContain('cache_control');
|
|
244
|
+
});
|
|
245
|
+
|
|
89
246
|
test('strips ALL stale markers and re-anchors a single one at the tail', () => {
|
|
90
247
|
const messages: BaseMessage[] = [
|
|
91
248
|
new HumanMessage({
|
|
@@ -337,4 +494,40 @@ describe('addBedrockTailCacheControl (single tail cachePoint)', () => {
|
|
|
337
494
|
{ cachePoint: { type: 'default' } },
|
|
338
495
|
]);
|
|
339
496
|
});
|
|
497
|
+
|
|
498
|
+
test('keeps the cachePoint before an omitted computer screenshot', () => {
|
|
499
|
+
const computerOutput = new ToolMessage({
|
|
500
|
+
content: 'data:image/png;base64,AA==',
|
|
501
|
+
tool_call_id: 'call_bedrock_computer',
|
|
502
|
+
additional_kwargs: { type: 'computer_call_output' },
|
|
503
|
+
});
|
|
504
|
+
const cached = addBedrockTailCacheControl([
|
|
505
|
+
new HumanMessage('Take a screenshot'),
|
|
506
|
+
new AIMessage({
|
|
507
|
+
content: '',
|
|
508
|
+
tool_calls: [
|
|
509
|
+
{
|
|
510
|
+
id: 'call_bedrock_computer',
|
|
511
|
+
name: 'computer',
|
|
512
|
+
args: { action: 'screenshot' },
|
|
513
|
+
},
|
|
514
|
+
],
|
|
515
|
+
}),
|
|
516
|
+
computerOutput,
|
|
517
|
+
]);
|
|
518
|
+
|
|
519
|
+
expect(countCachePoints(cached)).toBe(1);
|
|
520
|
+
expect(blocksOf(cached[0])[1]).toEqual({
|
|
521
|
+
cachePoint: { type: 'default' },
|
|
522
|
+
});
|
|
523
|
+
expect(cached[2].content).toBe(computerOutput.content);
|
|
524
|
+
|
|
525
|
+
const projected = projectComputerCallOutputsToText(cached);
|
|
526
|
+
const converse = convertToConverseMessages(projected);
|
|
527
|
+
expect((projected[2] as ToolMessage).content).toBe(
|
|
528
|
+
'[Computer screenshot omitted for this provider]'
|
|
529
|
+
);
|
|
530
|
+
expect(JSON.stringify(converse).match(/"cachePoint"/g)).toHaveLength(1);
|
|
531
|
+
expect(computerOutput.content).toBe('data:image/png;base64,AA==');
|
|
532
|
+
});
|
|
340
533
|
});
|
|
@@ -1029,6 +1029,87 @@ describe('stripAnthropicCacheControl', () => {
|
|
|
1029
1029
|
expect(result[0].content).toBe('Hello');
|
|
1030
1030
|
});
|
|
1031
1031
|
|
|
1032
|
+
it('ignores primitive and proxy blocks without invoking proxy traps', () => {
|
|
1033
|
+
const unsafe = new Proxy(
|
|
1034
|
+
{},
|
|
1035
|
+
{
|
|
1036
|
+
ownKeys() {
|
|
1037
|
+
throw new Error('proxy keys must not be inspected');
|
|
1038
|
+
},
|
|
1039
|
+
}
|
|
1040
|
+
);
|
|
1041
|
+
const content = [
|
|
1042
|
+
'primitive',
|
|
1043
|
+
unsafe,
|
|
1044
|
+
{
|
|
1045
|
+
type: ContentTypes.TEXT,
|
|
1046
|
+
text: 'cached',
|
|
1047
|
+
cache_control: { type: 'ephemeral' },
|
|
1048
|
+
},
|
|
1049
|
+
] as unknown as MessageContentComplex[];
|
|
1050
|
+
|
|
1051
|
+
const result = stripAnthropicCacheControl([{ role: 'user', content }]);
|
|
1052
|
+
const stripped = result[0].content as unknown[];
|
|
1053
|
+
|
|
1054
|
+
expect(stripped[0]).toBe('primitive');
|
|
1055
|
+
expect(stripped[1]).toBe(unsafe);
|
|
1056
|
+
expect(stripped[2]).toEqual({
|
|
1057
|
+
type: ContentTypes.TEXT,
|
|
1058
|
+
text: 'cached',
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it('removes a non-configurable cache_control from the clone only', () => {
|
|
1063
|
+
const block = { type: ContentTypes.TEXT, text: 'cached' };
|
|
1064
|
+
Object.defineProperty(block, 'cache_control', {
|
|
1065
|
+
configurable: false,
|
|
1066
|
+
enumerable: true,
|
|
1067
|
+
value: { type: 'ephemeral' },
|
|
1068
|
+
writable: false,
|
|
1069
|
+
});
|
|
1070
|
+
const messages = [
|
|
1071
|
+
{
|
|
1072
|
+
role: 'user',
|
|
1073
|
+
content: [block as MessageContentComplex],
|
|
1074
|
+
},
|
|
1075
|
+
];
|
|
1076
|
+
|
|
1077
|
+
const result = stripAnthropicCacheControl(messages);
|
|
1078
|
+
const stripped = (result[0].content as MessageContentComplex[])[0];
|
|
1079
|
+
|
|
1080
|
+
expect('cache_control' in stripped).toBe(false);
|
|
1081
|
+
expect(
|
|
1082
|
+
(block as typeof block & { cache_control: unknown }).cache_control
|
|
1083
|
+
).toEqual({ type: 'ephemeral' });
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it('removes an accessor cache_control without invoking it', () => {
|
|
1087
|
+
let getterCalls = 0;
|
|
1088
|
+
const block = { type: ContentTypes.TEXT, text: 'cached' };
|
|
1089
|
+
Object.defineProperty(block, 'cache_control', {
|
|
1090
|
+
configurable: true,
|
|
1091
|
+
enumerable: true,
|
|
1092
|
+
get() {
|
|
1093
|
+
getterCalls++;
|
|
1094
|
+
throw new Error('cache getter must not run');
|
|
1095
|
+
},
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
const result = stripAnthropicCacheControl([
|
|
1099
|
+
{
|
|
1100
|
+
role: 'user',
|
|
1101
|
+
content: [block as MessageContentComplex],
|
|
1102
|
+
},
|
|
1103
|
+
]);
|
|
1104
|
+
const stripped = (result[0].content as MessageContentComplex[])[0];
|
|
1105
|
+
|
|
1106
|
+
expect(getterCalls).toBe(0);
|
|
1107
|
+
expect('cache_control' in stripped).toBe(false);
|
|
1108
|
+
expect(
|
|
1109
|
+
Object.getOwnPropertyDescriptor(block, 'cache_control')
|
|
1110
|
+
).toBeDefined();
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1032
1113
|
it('returns non-array input unchanged', () => {
|
|
1033
1114
|
const notArray = 'not an array';
|
|
1034
1115
|
/** @ts-expect-error - Testing invalid input */
|
|
@@ -1074,6 +1155,38 @@ describe('stripBedrockCacheControl', () => {
|
|
|
1074
1155
|
});
|
|
1075
1156
|
});
|
|
1076
1157
|
|
|
1158
|
+
it('ignores primitive and proxy blocks while removing cache points', () => {
|
|
1159
|
+
let getterCalls = 0;
|
|
1160
|
+
const unsafe = new Proxy(
|
|
1161
|
+
{},
|
|
1162
|
+
{
|
|
1163
|
+
getOwnPropertyDescriptor() {
|
|
1164
|
+
throw new Error('proxy descriptors must not be inspected');
|
|
1165
|
+
},
|
|
1166
|
+
}
|
|
1167
|
+
);
|
|
1168
|
+
const accessorCachePoint = {};
|
|
1169
|
+
Object.defineProperty(accessorCachePoint, 'cachePoint', {
|
|
1170
|
+
configurable: true,
|
|
1171
|
+
enumerable: true,
|
|
1172
|
+
get() {
|
|
1173
|
+
getterCalls++;
|
|
1174
|
+
throw new Error('cache point getter must not run');
|
|
1175
|
+
},
|
|
1176
|
+
});
|
|
1177
|
+
const content = [
|
|
1178
|
+
42,
|
|
1179
|
+
unsafe,
|
|
1180
|
+
accessorCachePoint,
|
|
1181
|
+
{ cachePoint: { type: 'default' } },
|
|
1182
|
+
] as unknown as MessageContentComplex[];
|
|
1183
|
+
|
|
1184
|
+
const result = stripBedrockCacheControl([{ role: 'user', content }]);
|
|
1185
|
+
|
|
1186
|
+
expect(result[0].content).toEqual([42, unsafe]);
|
|
1187
|
+
expect(getterCalls).toBe(0);
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1077
1190
|
it('handles messages without cachePoint blocks gracefully', () => {
|
|
1078
1191
|
const messages: TestMsg[] = [
|
|
1079
1192
|
{
|