@librechat/agents 3.1.56 → 3.1.60
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 +326 -62
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/common/enum.cjs +13 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/events.cjs +7 -27
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +303 -222
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +4 -4
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +6 -2
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/init.cjs +60 -0
- package/dist/cjs/llm/init.cjs.map +1 -0
- package/dist/cjs/llm/invoke.cjs +90 -0
- package/dist/cjs/llm/invoke.cjs.map +1 -0
- package/dist/cjs/llm/openai/index.cjs +2 -0
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/request.cjs +41 -0
- package/dist/cjs/llm/request.cjs.map +1 -0
- package/dist/cjs/main.cjs +40 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/cache.cjs +76 -89
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/contextPruning.cjs +156 -0
- package/dist/cjs/messages/contextPruning.cjs.map +1 -0
- package/dist/cjs/messages/contextPruningSettings.cjs +53 -0
- package/dist/cjs/messages/contextPruningSettings.cjs.map +1 -0
- package/dist/cjs/messages/core.cjs +23 -37
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +156 -11
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +1161 -49
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/messages/reducer.cjs +87 -0
- package/dist/cjs/messages/reducer.cjs.map +1 -0
- package/dist/cjs/run.cjs +81 -42
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/stream.cjs +54 -7
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/index.cjs +75 -0
- package/dist/cjs/summarization/index.cjs.map +1 -0
- package/dist/cjs/summarization/node.cjs +663 -0
- package/dist/cjs/summarization/node.cjs.map +1 -0
- package/dist/cjs/tools/ToolNode.cjs +16 -8
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/handlers.cjs +2 -0
- package/dist/cjs/tools/handlers.cjs.map +1 -1
- package/dist/cjs/utils/errors.cjs +115 -0
- package/dist/cjs/utils/errors.cjs.map +1 -0
- package/dist/cjs/utils/events.cjs +17 -0
- package/dist/cjs/utils/events.cjs.map +1 -1
- package/dist/cjs/utils/handlers.cjs +16 -0
- package/dist/cjs/utils/handlers.cjs.map +1 -1
- package/dist/cjs/utils/llm.cjs +10 -0
- package/dist/cjs/utils/llm.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +247 -14
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/cjs/utils/truncation.cjs +107 -0
- package/dist/cjs/utils/truncation.cjs.map +1 -0
- package/dist/esm/agents/AgentContext.mjs +325 -61
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/common/enum.mjs +13 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/events.mjs +8 -28
- package/dist/esm/events.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +307 -226
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +4 -4
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +6 -2
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/init.mjs +58 -0
- package/dist/esm/llm/init.mjs.map +1 -0
- package/dist/esm/llm/invoke.mjs +87 -0
- package/dist/esm/llm/invoke.mjs.map +1 -0
- package/dist/esm/llm/openai/index.mjs +2 -0
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/request.mjs +38 -0
- package/dist/esm/llm/request.mjs.map +1 -0
- package/dist/esm/main.mjs +13 -3
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/cache.mjs +76 -89
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/contextPruning.mjs +154 -0
- package/dist/esm/messages/contextPruning.mjs.map +1 -0
- package/dist/esm/messages/contextPruningSettings.mjs +50 -0
- package/dist/esm/messages/contextPruningSettings.mjs.map +1 -0
- package/dist/esm/messages/core.mjs +23 -37
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +156 -11
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +1158 -52
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/messages/reducer.mjs +83 -0
- package/dist/esm/messages/reducer.mjs.map +1 -0
- package/dist/esm/run.mjs +82 -43
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/stream.mjs +54 -7
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/index.mjs +73 -0
- package/dist/esm/summarization/index.mjs.map +1 -0
- package/dist/esm/summarization/node.mjs +659 -0
- package/dist/esm/summarization/node.mjs.map +1 -0
- package/dist/esm/tools/ToolNode.mjs +16 -8
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/handlers.mjs +2 -0
- package/dist/esm/tools/handlers.mjs.map +1 -1
- package/dist/esm/utils/errors.mjs +111 -0
- package/dist/esm/utils/errors.mjs.map +1 -0
- package/dist/esm/utils/events.mjs +17 -1
- package/dist/esm/utils/events.mjs.map +1 -1
- package/dist/esm/utils/handlers.mjs +16 -0
- package/dist/esm/utils/handlers.mjs.map +1 -1
- package/dist/esm/utils/llm.mjs +10 -1
- package/dist/esm/utils/llm.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +245 -15
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/esm/utils/truncation.mjs +102 -0
- package/dist/esm/utils/truncation.mjs.map +1 -0
- package/dist/types/agents/AgentContext.d.ts +124 -6
- package/dist/types/common/enum.d.ts +14 -1
- package/dist/types/graphs/Graph.d.ts +22 -27
- package/dist/types/index.d.ts +5 -0
- package/dist/types/llm/init.d.ts +18 -0
- package/dist/types/llm/invoke.d.ts +48 -0
- package/dist/types/llm/request.d.ts +14 -0
- package/dist/types/messages/contextPruning.d.ts +42 -0
- package/dist/types/messages/contextPruningSettings.d.ts +44 -0
- package/dist/types/messages/core.d.ts +1 -1
- package/dist/types/messages/format.d.ts +17 -1
- package/dist/types/messages/index.d.ts +3 -0
- package/dist/types/messages/prune.d.ts +162 -1
- package/dist/types/messages/reducer.d.ts +18 -0
- package/dist/types/run.d.ts +12 -1
- package/dist/types/summarization/index.d.ts +20 -0
- package/dist/types/summarization/node.d.ts +29 -0
- package/dist/types/tools/ToolNode.d.ts +3 -1
- package/dist/types/types/graph.d.ts +44 -6
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/run.d.ts +30 -0
- package/dist/types/types/stream.d.ts +31 -4
- package/dist/types/types/summarize.d.ts +47 -0
- package/dist/types/types/tools.d.ts +7 -0
- package/dist/types/utils/errors.d.ts +28 -0
- package/dist/types/utils/events.d.ts +13 -0
- package/dist/types/utils/index.d.ts +2 -0
- package/dist/types/utils/llm.d.ts +4 -0
- package/dist/types/utils/tokens.d.ts +14 -1
- package/dist/types/utils/truncation.d.ts +49 -0
- package/package.json +2 -2
- package/src/agents/AgentContext.ts +388 -58
- package/src/agents/__tests__/AgentContext.test.ts +265 -5
- package/src/common/enum.ts +13 -0
- package/src/events.ts +9 -39
- package/src/graphs/Graph.ts +468 -331
- package/src/index.ts +7 -0
- package/src/llm/anthropic/llm.spec.ts +3 -3
- package/src/llm/anthropic/utils/message_inputs.ts +6 -4
- package/src/llm/bedrock/llm.spec.ts +1 -1
- package/src/llm/bedrock/utils/message_inputs.ts +6 -2
- package/src/llm/init.ts +63 -0
- package/src/llm/invoke.ts +144 -0
- package/src/llm/request.ts +55 -0
- package/src/messages/__tests__/observationMasking.test.ts +221 -0
- package/src/messages/cache.ts +77 -102
- package/src/messages/contextPruning.ts +191 -0
- package/src/messages/contextPruningSettings.ts +90 -0
- package/src/messages/core.ts +32 -53
- package/src/messages/ensureThinkingBlock.test.ts +39 -39
- package/src/messages/format.ts +227 -15
- package/src/messages/formatAgentMessages.test.ts +511 -1
- package/src/messages/index.ts +3 -0
- package/src/messages/prune.ts +1548 -62
- package/src/messages/reducer.ts +22 -0
- package/src/run.ts +104 -51
- package/src/scripts/bedrock-merge-test.ts +1 -1
- package/src/scripts/test-thinking-handoff-bedrock.ts +1 -1
- package/src/scripts/test-thinking-handoff.ts +1 -1
- package/src/scripts/thinking-bedrock.ts +1 -1
- package/src/scripts/thinking.ts +1 -1
- package/src/specs/anthropic.simple.test.ts +1 -1
- package/src/specs/multi-agent-summarization.test.ts +396 -0
- package/src/specs/prune.test.ts +1196 -23
- package/src/specs/summarization-unit.test.ts +868 -0
- package/src/specs/summarization.test.ts +3810 -0
- package/src/specs/summarize-prune.test.ts +376 -0
- package/src/specs/thinking-handoff.test.ts +10 -10
- package/src/specs/thinking-prune.test.ts +7 -4
- package/src/specs/token-accounting-e2e.test.ts +1034 -0
- package/src/specs/token-accounting-pipeline.test.ts +882 -0
- package/src/specs/token-distribution-edge-case.test.ts +25 -26
- package/src/splitStream.test.ts +42 -33
- package/src/stream.ts +64 -11
- package/src/summarization/__tests__/aggregator.test.ts +153 -0
- package/src/summarization/__tests__/node.test.ts +708 -0
- package/src/summarization/__tests__/trigger.test.ts +50 -0
- package/src/summarization/index.ts +102 -0
- package/src/summarization/node.ts +982 -0
- package/src/tools/ToolNode.ts +25 -3
- package/src/types/graph.ts +62 -7
- package/src/types/index.ts +1 -0
- package/src/types/run.ts +32 -0
- package/src/types/stream.ts +45 -5
- package/src/types/summarize.ts +58 -0
- package/src/types/tools.ts +7 -0
- package/src/utils/errors.ts +117 -0
- package/src/utils/events.ts +31 -0
- package/src/utils/handlers.ts +18 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/llm.ts +12 -0
- package/src/utils/tokens.ts +336 -18
- package/src/utils/truncation.ts +124 -0
- package/src/scripts/image.ts +0 -180
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
import { HumanMessage, AIMessage, ToolMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
3
|
+
import {
|
|
4
|
+
calculateMaxToolResultChars,
|
|
5
|
+
truncateToolResultContent,
|
|
6
|
+
truncateToolInput,
|
|
7
|
+
HARD_MAX_TOOL_RESULT_CHARS,
|
|
8
|
+
} from '@/utils/truncation';
|
|
9
|
+
import {
|
|
10
|
+
preFlightTruncateToolResults,
|
|
11
|
+
preFlightTruncateToolCallInputs,
|
|
12
|
+
createPruneMessages,
|
|
13
|
+
} from '@/messages/prune';
|
|
14
|
+
import { shouldTriggerSummarization } from '@/summarization/index';
|
|
15
|
+
import { Providers } from '@/common';
|
|
16
|
+
import { SummarizationTrigger } from '@/types';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Shared helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
function tokenCounter(msg: { content: unknown }): number {
|
|
23
|
+
const content =
|
|
24
|
+
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
|
25
|
+
return Math.ceil(content.length / 4);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// calculateMaxToolResultChars
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
describe('calculateMaxToolResultChars', () => {
|
|
33
|
+
it('returns 30% of context window in chars (×4 ratio)', () => {
|
|
34
|
+
// 1000 tokens × 0.3 = 300; 300 × 4 = 1200 chars
|
|
35
|
+
expect(calculateMaxToolResultChars(1000)).toBe(1200);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('caps at HARD_MAX_TOOL_RESULT_CHARS for large contexts', () => {
|
|
39
|
+
expect(calculateMaxToolResultChars(10_000_000)).toBe(
|
|
40
|
+
HARD_MAX_TOOL_RESULT_CHARS
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns hard max when contextWindowTokens is undefined', () => {
|
|
45
|
+
expect(calculateMaxToolResultChars(undefined)).toBe(
|
|
46
|
+
HARD_MAX_TOOL_RESULT_CHARS
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns hard max when contextWindowTokens is 0 or negative', () => {
|
|
51
|
+
expect(calculateMaxToolResultChars(0)).toBe(HARD_MAX_TOOL_RESULT_CHARS);
|
|
52
|
+
expect(calculateMaxToolResultChars(-100)).toBe(HARD_MAX_TOOL_RESULT_CHARS);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('handles small context windows', () => {
|
|
56
|
+
// 50 tokens × 0.3 = 15; 15 × 4 = 60
|
|
57
|
+
expect(calculateMaxToolResultChars(50)).toBe(60);
|
|
58
|
+
// 10 tokens × 0.3 = 3; 3 × 4 = 12
|
|
59
|
+
expect(calculateMaxToolResultChars(10)).toBe(12);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// truncateToolResultContent
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe('truncateToolResultContent', () => {
|
|
68
|
+
it('returns content unchanged when within budget', () => {
|
|
69
|
+
const content = 'Short result';
|
|
70
|
+
expect(truncateToolResultContent(content, 100)).toBe(content);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns content unchanged when exactly at budget', () => {
|
|
74
|
+
const content = 'x'.repeat(50);
|
|
75
|
+
expect(truncateToolResultContent(content, 50)).toBe(content);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('truncates with head+tail when budget is large enough', () => {
|
|
79
|
+
// Need available >= 200 for head+tail. With 1000-char content and budget=500,
|
|
80
|
+
// indicator ≈ 42 chars, available ≈ 458 — well above the 200 threshold.
|
|
81
|
+
const content = 'A'.repeat(400) + 'B'.repeat(200) + 'C'.repeat(400);
|
|
82
|
+
const result = truncateToolResultContent(content, 500);
|
|
83
|
+
|
|
84
|
+
expect(result.length).toBeLessThanOrEqual(510); // some slack for indicator
|
|
85
|
+
expect(result).toContain('truncated');
|
|
86
|
+
expect(result).toContain('1000'); // original length
|
|
87
|
+
expect(result).toContain('500'); // limit
|
|
88
|
+
// Head preserved (starts with As)
|
|
89
|
+
expect(result.startsWith('A')).toBe(true);
|
|
90
|
+
// Tail preserved (ends with Cs)
|
|
91
|
+
expect(result.endsWith('C')).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('falls back to head-only when budget is very small', () => {
|
|
95
|
+
// With 1000-char content and budget=235, indicator ≈ 37 chars,
|
|
96
|
+
// available ≈ 198 < 200 threshold → head-only path.
|
|
97
|
+
const content = 'A'.repeat(500) + 'B'.repeat(500);
|
|
98
|
+
const result = truncateToolResultContent(content, 235);
|
|
99
|
+
|
|
100
|
+
expect(result).toContain('truncated');
|
|
101
|
+
expect(result.startsWith('A')).toBe(true);
|
|
102
|
+
expect(result).not.toMatch(/B/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns head-only slice when budget is smaller than indicator', () => {
|
|
106
|
+
// When budget is so small the indicator doesn't fit, just returns head slice
|
|
107
|
+
const content = 'Error: ENOENT: no such file or directory';
|
|
108
|
+
const result = truncateToolResultContent(content, 30);
|
|
109
|
+
|
|
110
|
+
expect(result).toBe(content.slice(0, 30));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('preserves the truncation indicator format', () => {
|
|
114
|
+
const content = 'x'.repeat(500);
|
|
115
|
+
const result = truncateToolResultContent(content, 300);
|
|
116
|
+
// Format: [truncated: N chars exceeded M limit]
|
|
117
|
+
expect(result).toMatch(/\[truncated: 500 chars exceeded 300 limit\]/);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// truncateToolInput
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
describe('truncateToolInput', () => {
|
|
126
|
+
it('returns unchanged string when within budget', () => {
|
|
127
|
+
const result = truncateToolInput('short input', 100);
|
|
128
|
+
expect(result._truncated).toBe('short input');
|
|
129
|
+
expect(result._originalChars).toBe(11);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('serializes objects to JSON before truncating', () => {
|
|
133
|
+
const input = { key: 'value', nested: { a: 1 } };
|
|
134
|
+
const result = truncateToolInput(input, 10);
|
|
135
|
+
expect(result._originalChars).toBe(JSON.stringify(input).length);
|
|
136
|
+
expect(result._truncated).toContain('truncated');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('truncates long strings with indicator', () => {
|
|
140
|
+
const input = 'x'.repeat(500);
|
|
141
|
+
const result = truncateToolInput(input, 200);
|
|
142
|
+
expect(result._truncated).toContain('truncated');
|
|
143
|
+
expect(result._truncated).toContain('500');
|
|
144
|
+
expect(result._originalChars).toBe(500);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// preFlightTruncateToolResults
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
describe('preFlightTruncateToolResults', () => {
|
|
153
|
+
it('truncates oversized tool results and updates token counts', () => {
|
|
154
|
+
const toolMsg = new ToolMessage({
|
|
155
|
+
content: 'x'.repeat(500),
|
|
156
|
+
tool_call_id: 'tc1',
|
|
157
|
+
name: 'big_tool',
|
|
158
|
+
});
|
|
159
|
+
const messages: BaseMessage[] = [
|
|
160
|
+
new HumanMessage('run it'),
|
|
161
|
+
new AIMessage({
|
|
162
|
+
content: '',
|
|
163
|
+
tool_calls: [{ id: 'tc1', name: 'big_tool', args: {} }],
|
|
164
|
+
}),
|
|
165
|
+
toolMsg,
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const indexTokenCountMap: Record<string, number | undefined> = {
|
|
169
|
+
0: 5,
|
|
170
|
+
1: 10,
|
|
171
|
+
2: tokenCounter(toolMsg),
|
|
172
|
+
};
|
|
173
|
+
const originalTokenCount = indexTokenCountMap[2]!;
|
|
174
|
+
|
|
175
|
+
const count = preFlightTruncateToolResults({
|
|
176
|
+
messages,
|
|
177
|
+
maxContextTokens: 200, // calculateMaxToolResultChars(200) = 240 chars
|
|
178
|
+
indexTokenCountMap,
|
|
179
|
+
tokenCounter,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(count).toBe(1);
|
|
183
|
+
// Content was mutated in place
|
|
184
|
+
const truncatedContent = messages[2].content as string;
|
|
185
|
+
expect(truncatedContent.length).toBeLessThan(500);
|
|
186
|
+
expect(truncatedContent).toContain('truncated');
|
|
187
|
+
// Token count was updated
|
|
188
|
+
expect(indexTokenCountMap[2]).toBeLessThan(originalTokenCount);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('does not truncate results that fit within budget', () => {
|
|
192
|
+
const toolMsg = new ToolMessage({
|
|
193
|
+
content: 'OK',
|
|
194
|
+
tool_call_id: 'tc1',
|
|
195
|
+
name: 'small_tool',
|
|
196
|
+
});
|
|
197
|
+
const messages: BaseMessage[] = [toolMsg];
|
|
198
|
+
const indexTokenCountMap: Record<string, number | undefined> = {
|
|
199
|
+
0: 2,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const count = preFlightTruncateToolResults({
|
|
203
|
+
messages,
|
|
204
|
+
maxContextTokens: 1000,
|
|
205
|
+
indexTokenCountMap,
|
|
206
|
+
tokenCounter,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(count).toBe(0);
|
|
210
|
+
expect(messages[0].content).toBe('OK');
|
|
211
|
+
expect(indexTokenCountMap[0]).toBe(2);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('skips non-tool messages', () => {
|
|
215
|
+
const messages: BaseMessage[] = [
|
|
216
|
+
new HumanMessage('x'.repeat(500)),
|
|
217
|
+
new AIMessage('y'.repeat(500)),
|
|
218
|
+
];
|
|
219
|
+
const indexTokenCountMap: Record<string, number | undefined> = {
|
|
220
|
+
0: 125,
|
|
221
|
+
1: 125,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const count = preFlightTruncateToolResults({
|
|
225
|
+
messages,
|
|
226
|
+
maxContextTokens: 10, // very tight budget
|
|
227
|
+
indexTokenCountMap,
|
|
228
|
+
tokenCounter,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(count).toBe(0);
|
|
232
|
+
expect((messages[0].content as string).length).toBe(500);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('uses raw maxContextTokens (not effective budget) for threshold', () => {
|
|
236
|
+
// This verifies the bug fix: with maxContextTokens=50,
|
|
237
|
+
// calculateMaxToolResultChars(50) = 60 chars.
|
|
238
|
+
// A 60-char tool result should NOT be truncated.
|
|
239
|
+
const content =
|
|
240
|
+
'Error: ENOENT: no such file or directory, open /src/index.ts'; // 60 chars
|
|
241
|
+
const toolMsg = new ToolMessage({
|
|
242
|
+
content,
|
|
243
|
+
tool_call_id: 'tc1',
|
|
244
|
+
name: 'run_linter',
|
|
245
|
+
status: 'error',
|
|
246
|
+
});
|
|
247
|
+
const messages: BaseMessage[] = [toolMsg];
|
|
248
|
+
const indexTokenCountMap: Record<string, number | undefined> = {
|
|
249
|
+
0: tokenCounter(toolMsg),
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const count = preFlightTruncateToolResults({
|
|
253
|
+
messages,
|
|
254
|
+
maxContextTokens: 50,
|
|
255
|
+
indexTokenCountMap,
|
|
256
|
+
tokenCounter,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(count).toBe(0);
|
|
260
|
+
expect(messages[0].content).toBe(content);
|
|
261
|
+
expect(messages[0].content).toContain('ENOENT');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('handles multiple tool messages, truncating only oversized ones', () => {
|
|
265
|
+
const smallTool = new ToolMessage({
|
|
266
|
+
content: 'ok',
|
|
267
|
+
tool_call_id: 'tc1',
|
|
268
|
+
name: 'tool_a',
|
|
269
|
+
});
|
|
270
|
+
const bigTool = new ToolMessage({
|
|
271
|
+
content: 'x'.repeat(2000),
|
|
272
|
+
tool_call_id: 'tc2',
|
|
273
|
+
name: 'tool_b',
|
|
274
|
+
});
|
|
275
|
+
const messages: BaseMessage[] = [smallTool, bigTool];
|
|
276
|
+
const indexTokenCountMap: Record<string, number | undefined> = {
|
|
277
|
+
0: tokenCounter(smallTool),
|
|
278
|
+
1: tokenCounter(bigTool),
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const count = preFlightTruncateToolResults({
|
|
282
|
+
messages,
|
|
283
|
+
maxContextTokens: 500, // maxChars = 600
|
|
284
|
+
indexTokenCountMap,
|
|
285
|
+
tokenCounter,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(count).toBe(1);
|
|
289
|
+
expect(messages[0].content).toBe('ok');
|
|
290
|
+
expect((messages[1].content as string).length).toBeLessThan(2000);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// preFlightTruncateToolResults uses raw maxTokens in pruner
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
describe('pre-flight truncation in pruner uses raw maxContextTokens', () => {
|
|
299
|
+
it('preserves small tool results even with tight effective budget', () => {
|
|
300
|
+
// Simulate: maxContextTokens=50, high instruction overhead.
|
|
301
|
+
// Pre-flight should use raw 50 (maxChars=60), not effectiveMaxTokens.
|
|
302
|
+
const content =
|
|
303
|
+
'Error: ENOENT: no such file or directory, open /src/index.ts'; // 60 chars
|
|
304
|
+
const toolMsg = new ToolMessage({
|
|
305
|
+
content,
|
|
306
|
+
tool_call_id: 'tc1',
|
|
307
|
+
name: 'run_linter',
|
|
308
|
+
status: 'error',
|
|
309
|
+
});
|
|
310
|
+
const aiMsg = new AIMessage({
|
|
311
|
+
content: [
|
|
312
|
+
{ type: 'text' as const, text: 'Running linter.' },
|
|
313
|
+
{
|
|
314
|
+
type: 'tool_use' as const,
|
|
315
|
+
id: 'tc1',
|
|
316
|
+
name: 'run_linter',
|
|
317
|
+
input: '{"path":"/src"}',
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
tool_calls: [{ id: 'tc1', name: 'run_linter', args: { path: '/src' } }],
|
|
321
|
+
});
|
|
322
|
+
const messages: BaseMessage[] = [
|
|
323
|
+
new HumanMessage('Run the linter.'),
|
|
324
|
+
aiMsg,
|
|
325
|
+
toolMsg,
|
|
326
|
+
new AIMessage('The linter failed.'),
|
|
327
|
+
];
|
|
328
|
+
|
|
329
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
330
|
+
for (let i = 0; i < messages.length; i++) {
|
|
331
|
+
indexTokenCountMap[i] = tokenCounter(messages[i]);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const pruneMessages = createPruneMessages({
|
|
335
|
+
provider: Providers.OPENAI,
|
|
336
|
+
maxTokens: 50,
|
|
337
|
+
startIndex: messages.length,
|
|
338
|
+
tokenCounter,
|
|
339
|
+
indexTokenCountMap,
|
|
340
|
+
getInstructionTokens: () => 15,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
pruneMessages({ messages });
|
|
344
|
+
|
|
345
|
+
// The 60-char tool result must survive pre-flight truncation.
|
|
346
|
+
// With raw maxTokens=50: calculateMaxToolResultChars(50) = 60, so 60 <= 60 → not truncated.
|
|
347
|
+
// The old bug used effectiveMaxTokens (~32), which gave maxChars=40 and truncated ENOENT.
|
|
348
|
+
expect(toolMsg.content).toBe(content);
|
|
349
|
+
expect(toolMsg.content).toContain('ENOENT');
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// shouldTriggerSummarization
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
describe('shouldTriggerSummarization', () => {
|
|
358
|
+
describe('no trigger configured (default)', () => {
|
|
359
|
+
it('returns true when messagesToRefineCount > 0', () => {
|
|
360
|
+
expect(shouldTriggerSummarization({ messagesToRefineCount: 1 })).toBe(
|
|
361
|
+
true
|
|
362
|
+
);
|
|
363
|
+
expect(shouldTriggerSummarization({ messagesToRefineCount: 100 })).toBe(
|
|
364
|
+
true
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('returns false when messagesToRefineCount is 0', () => {
|
|
369
|
+
expect(shouldTriggerSummarization({ messagesToRefineCount: 0 })).toBe(
|
|
370
|
+
false
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe('token_ratio trigger', () => {
|
|
376
|
+
it('fires when used ratio exceeds threshold', () => {
|
|
377
|
+
expect(
|
|
378
|
+
shouldTriggerSummarization({
|
|
379
|
+
trigger: { type: 'token_ratio', value: 0.8 },
|
|
380
|
+
maxContextTokens: 1000,
|
|
381
|
+
prePruneContextTokens: 900, // 90% used
|
|
382
|
+
messagesToRefineCount: 5,
|
|
383
|
+
})
|
|
384
|
+
).toBe(true);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('does not fire when used ratio is below threshold', () => {
|
|
388
|
+
expect(
|
|
389
|
+
shouldTriggerSummarization({
|
|
390
|
+
trigger: { type: 'token_ratio', value: 0.8 },
|
|
391
|
+
maxContextTokens: 1000,
|
|
392
|
+
prePruneContextTokens: 500, // 50% used
|
|
393
|
+
messagesToRefineCount: 5,
|
|
394
|
+
})
|
|
395
|
+
).toBe(false);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('fires at exact boundary', () => {
|
|
399
|
+
expect(
|
|
400
|
+
shouldTriggerSummarization({
|
|
401
|
+
trigger: { type: 'token_ratio', value: 0.8 },
|
|
402
|
+
maxContextTokens: 1000,
|
|
403
|
+
prePruneContextTokens: 800, // exactly 80%
|
|
404
|
+
messagesToRefineCount: 5,
|
|
405
|
+
})
|
|
406
|
+
).toBe(true);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('does not fire when maxContextTokens is missing', () => {
|
|
410
|
+
expect(
|
|
411
|
+
shouldTriggerSummarization({
|
|
412
|
+
trigger: { type: 'token_ratio', value: 0.8 },
|
|
413
|
+
prePruneContextTokens: 900,
|
|
414
|
+
messagesToRefineCount: 5,
|
|
415
|
+
})
|
|
416
|
+
).toBe(false);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('falls back to remainingContextTokens when prePruneContextTokens is missing', () => {
|
|
420
|
+
expect(
|
|
421
|
+
shouldTriggerSummarization({
|
|
422
|
+
trigger: { type: 'token_ratio', value: 0.8 },
|
|
423
|
+
maxContextTokens: 1000,
|
|
424
|
+
remainingContextTokens: 100, // 90% used
|
|
425
|
+
messagesToRefineCount: 5,
|
|
426
|
+
})
|
|
427
|
+
).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe('remaining_tokens trigger', () => {
|
|
432
|
+
it('fires when remaining tokens are at or below threshold', () => {
|
|
433
|
+
expect(
|
|
434
|
+
shouldTriggerSummarization({
|
|
435
|
+
trigger: { type: 'remaining_tokens', value: 200 },
|
|
436
|
+
maxContextTokens: 1000,
|
|
437
|
+
prePruneContextTokens: 850, // remaining = 150
|
|
438
|
+
messagesToRefineCount: 3,
|
|
439
|
+
})
|
|
440
|
+
).toBe(true);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('does not fire when remaining tokens exceed threshold', () => {
|
|
444
|
+
expect(
|
|
445
|
+
shouldTriggerSummarization({
|
|
446
|
+
trigger: { type: 'remaining_tokens', value: 200 },
|
|
447
|
+
maxContextTokens: 1000,
|
|
448
|
+
prePruneContextTokens: 500, // remaining = 500
|
|
449
|
+
messagesToRefineCount: 3,
|
|
450
|
+
})
|
|
451
|
+
).toBe(false);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('does not fire when remaining tokens data is missing', () => {
|
|
455
|
+
expect(
|
|
456
|
+
shouldTriggerSummarization({
|
|
457
|
+
trigger: { type: 'remaining_tokens', value: 200 },
|
|
458
|
+
messagesToRefineCount: 3,
|
|
459
|
+
})
|
|
460
|
+
).toBe(false);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe('messages_to_refine trigger', () => {
|
|
465
|
+
it('fires when messagesToRefineCount meets threshold', () => {
|
|
466
|
+
expect(
|
|
467
|
+
shouldTriggerSummarization({
|
|
468
|
+
trigger: { type: 'messages_to_refine', value: 5 },
|
|
469
|
+
messagesToRefineCount: 5,
|
|
470
|
+
})
|
|
471
|
+
).toBe(true);
|
|
472
|
+
expect(
|
|
473
|
+
shouldTriggerSummarization({
|
|
474
|
+
trigger: { type: 'messages_to_refine', value: 5 },
|
|
475
|
+
messagesToRefineCount: 10,
|
|
476
|
+
})
|
|
477
|
+
).toBe(true);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('does not fire when messagesToRefineCount is below threshold', () => {
|
|
481
|
+
expect(
|
|
482
|
+
shouldTriggerSummarization({
|
|
483
|
+
trigger: { type: 'messages_to_refine', value: 5 },
|
|
484
|
+
messagesToRefineCount: 3,
|
|
485
|
+
})
|
|
486
|
+
).toBe(false);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
describe('edge cases', () => {
|
|
491
|
+
it('returns false for unrecognized trigger type', () => {
|
|
492
|
+
expect(
|
|
493
|
+
shouldTriggerSummarization({
|
|
494
|
+
trigger: {
|
|
495
|
+
type: 'unknown_type' as SummarizationTrigger['type'],
|
|
496
|
+
value: 1,
|
|
497
|
+
},
|
|
498
|
+
messagesToRefineCount: 10,
|
|
499
|
+
})
|
|
500
|
+
).toBe(false);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('returns false when trigger value is invalid', () => {
|
|
504
|
+
expect(
|
|
505
|
+
shouldTriggerSummarization({
|
|
506
|
+
trigger: { type: 'token_ratio', value: NaN },
|
|
507
|
+
maxContextTokens: 1000,
|
|
508
|
+
prePruneContextTokens: 900,
|
|
509
|
+
messagesToRefineCount: 5,
|
|
510
|
+
})
|
|
511
|
+
).toBe(false);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('returns false when messagesToRefineCount is 0 regardless of trigger', () => {
|
|
515
|
+
expect(
|
|
516
|
+
shouldTriggerSummarization({
|
|
517
|
+
trigger: { type: 'messages_to_refine', value: 0 },
|
|
518
|
+
messagesToRefineCount: 0,
|
|
519
|
+
})
|
|
520
|
+
).toBe(false);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// preFlightTruncateToolCallInputs
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
describe('preFlightTruncateToolCallInputs', () => {
|
|
530
|
+
it('truncates oversized tool_use input fields', () => {
|
|
531
|
+
const bigInput = JSON.stringify({ code: 'x'.repeat(5000) });
|
|
532
|
+
const aiMsg = new AIMessage({
|
|
533
|
+
content: [
|
|
534
|
+
{ type: 'text' as const, text: 'Running code.' },
|
|
535
|
+
{
|
|
536
|
+
type: 'tool_use' as const,
|
|
537
|
+
id: 'tc1',
|
|
538
|
+
name: 'execute',
|
|
539
|
+
input: bigInput,
|
|
540
|
+
},
|
|
541
|
+
],
|
|
542
|
+
tool_calls: [
|
|
543
|
+
{ id: 'tc1', name: 'execute', args: { code: 'x'.repeat(5000) } },
|
|
544
|
+
],
|
|
545
|
+
});
|
|
546
|
+
const messages: BaseMessage[] = [aiMsg];
|
|
547
|
+
const indexTokenCountMap: Record<string, number | undefined> = {
|
|
548
|
+
0: tokenCounter(aiMsg),
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const count = preFlightTruncateToolCallInputs({
|
|
552
|
+
messages,
|
|
553
|
+
maxContextTokens: 200, // maxInputChars = floor(200*0.15)*4 = 120
|
|
554
|
+
indexTokenCountMap,
|
|
555
|
+
tokenCounter,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
expect(count).toBe(1);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('does not truncate small inputs', () => {
|
|
562
|
+
const aiMsg = new AIMessage({
|
|
563
|
+
content: [
|
|
564
|
+
{
|
|
565
|
+
type: 'tool_use' as const,
|
|
566
|
+
id: 'tc1',
|
|
567
|
+
name: 'calc',
|
|
568
|
+
input: '{"a":1}',
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
tool_calls: [{ id: 'tc1', name: 'calc', args: { a: 1 } }],
|
|
572
|
+
});
|
|
573
|
+
const messages: BaseMessage[] = [aiMsg];
|
|
574
|
+
const indexTokenCountMap: Record<string, number | undefined> = {
|
|
575
|
+
0: tokenCounter(aiMsg),
|
|
576
|
+
};
|
|
577
|
+
const originalCount = indexTokenCountMap[0];
|
|
578
|
+
|
|
579
|
+
const count = preFlightTruncateToolCallInputs({
|
|
580
|
+
messages,
|
|
581
|
+
maxContextTokens: 10000,
|
|
582
|
+
indexTokenCountMap,
|
|
583
|
+
tokenCounter,
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
expect(count).toBe(0);
|
|
587
|
+
expect(indexTokenCountMap[0]).toBe(originalCount);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('skips non-AI messages', () => {
|
|
591
|
+
const messages: BaseMessage[] = [
|
|
592
|
+
new HumanMessage('hello'),
|
|
593
|
+
new ToolMessage({
|
|
594
|
+
content: 'x'.repeat(5000),
|
|
595
|
+
tool_call_id: 'tc1',
|
|
596
|
+
name: 'big',
|
|
597
|
+
}),
|
|
598
|
+
];
|
|
599
|
+
const indexTokenCountMap: Record<string, number | undefined> = {
|
|
600
|
+
0: 3,
|
|
601
|
+
1: 1250,
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const count = preFlightTruncateToolCallInputs({
|
|
605
|
+
messages,
|
|
606
|
+
maxContextTokens: 10,
|
|
607
|
+
indexTokenCountMap,
|
|
608
|
+
tokenCounter,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
expect(count).toBe(0);
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// Pruner → summarization routing: messagesToRefine populated correctly
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
describe('pruner messagesToRefine for summarization', () => {
|
|
620
|
+
it('populates messagesToRefine with pruned messages when over budget', () => {
|
|
621
|
+
const messages: BaseMessage[] = [];
|
|
622
|
+
for (let i = 0; i < 20; i++) {
|
|
623
|
+
messages.push(new HumanMessage(`Question ${i}: ${'detail '.repeat(20)}`));
|
|
624
|
+
messages.push(new AIMessage(`Answer ${i}: ${'explanation '.repeat(20)}`));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
628
|
+
for (let i = 0; i < messages.length; i++) {
|
|
629
|
+
indexTokenCountMap[i] = tokenCounter(messages[i]);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const pruneMessages = createPruneMessages({
|
|
633
|
+
provider: Providers.OPENAI,
|
|
634
|
+
maxTokens: 200,
|
|
635
|
+
startIndex: messages.length,
|
|
636
|
+
tokenCounter,
|
|
637
|
+
indexTokenCountMap,
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
const result = pruneMessages({ messages });
|
|
641
|
+
|
|
642
|
+
expect(result.messagesToRefine!.length).toBeGreaterThan(0);
|
|
643
|
+
expect(result.context.length).toBeGreaterThan(0);
|
|
644
|
+
expect(result.context.length + result.messagesToRefine!.length).toBe(
|
|
645
|
+
messages.length
|
|
646
|
+
);
|
|
647
|
+
expect(typeof result.remainingContextTokens).toBe('number');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('returns empty messagesToRefine when everything fits', () => {
|
|
651
|
+
const messages: BaseMessage[] = [
|
|
652
|
+
new HumanMessage('Hi'),
|
|
653
|
+
new AIMessage('Hello'),
|
|
654
|
+
];
|
|
655
|
+
|
|
656
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
657
|
+
for (let i = 0; i < messages.length; i++) {
|
|
658
|
+
indexTokenCountMap[i] = tokenCounter(messages[i]);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const pruneMessages = createPruneMessages({
|
|
662
|
+
provider: Providers.OPENAI,
|
|
663
|
+
maxTokens: 10000,
|
|
664
|
+
startIndex: messages.length,
|
|
665
|
+
tokenCounter,
|
|
666
|
+
indexTokenCountMap,
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const result = pruneMessages({ messages });
|
|
670
|
+
|
|
671
|
+
expect(result.messagesToRefine!).toHaveLength(0);
|
|
672
|
+
expect(result.context).toEqual(messages);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('messagesToRefine contains the oldest messages (chronological order)', () => {
|
|
676
|
+
const messages: BaseMessage[] = [
|
|
677
|
+
new HumanMessage('First question - oldest'),
|
|
678
|
+
new AIMessage('First answer - oldest'),
|
|
679
|
+
new HumanMessage('Second question'),
|
|
680
|
+
new AIMessage('Second answer'),
|
|
681
|
+
new HumanMessage(
|
|
682
|
+
'Third question with much more detail to push token count up significantly'
|
|
683
|
+
),
|
|
684
|
+
new AIMessage(
|
|
685
|
+
'Third answer with extensive explanation that uses many tokens in the response'
|
|
686
|
+
),
|
|
687
|
+
];
|
|
688
|
+
|
|
689
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
690
|
+
for (let i = 0; i < messages.length; i++) {
|
|
691
|
+
indexTokenCountMap[i] = tokenCounter(messages[i]);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const totalTokens = Object.values(indexTokenCountMap).reduce(
|
|
695
|
+
(a = 0, b = 0) => a! + b!,
|
|
696
|
+
0
|
|
697
|
+
) as number;
|
|
698
|
+
|
|
699
|
+
const pruneMessages = createPruneMessages({
|
|
700
|
+
provider: Providers.OPENAI,
|
|
701
|
+
maxTokens: Math.floor(totalTokens * 0.5),
|
|
702
|
+
startIndex: messages.length,
|
|
703
|
+
tokenCounter,
|
|
704
|
+
indexTokenCountMap,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const result = pruneMessages({ messages });
|
|
708
|
+
|
|
709
|
+
expect(result.messagesToRefine!.length).toBeGreaterThan(0);
|
|
710
|
+
// The oldest messages should be in messagesToRefine
|
|
711
|
+
const refinedContent = result.messagesToRefine!.map((m) => m.content);
|
|
712
|
+
expect(refinedContent[0]).toContain('First question');
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
// Emergency truncation in pruner
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
|
|
720
|
+
describe('emergency truncation when pruning produces empty context', () => {
|
|
721
|
+
it('recovers from empty context by truncating tool results', () => {
|
|
722
|
+
// Single large tool result that exceeds the entire budget
|
|
723
|
+
const bigToolMsg = new ToolMessage({
|
|
724
|
+
content: 'x'.repeat(10_000),
|
|
725
|
+
tool_call_id: 'tc1',
|
|
726
|
+
name: 'big_result',
|
|
727
|
+
});
|
|
728
|
+
const aiMsg = new AIMessage({
|
|
729
|
+
content: [
|
|
730
|
+
{ type: 'text' as const, text: 'Calling tool.' },
|
|
731
|
+
{
|
|
732
|
+
type: 'tool_use' as const,
|
|
733
|
+
id: 'tc1',
|
|
734
|
+
name: 'big_result',
|
|
735
|
+
input: '{}',
|
|
736
|
+
},
|
|
737
|
+
],
|
|
738
|
+
tool_calls: [{ id: 'tc1', name: 'big_result', args: {} }],
|
|
739
|
+
});
|
|
740
|
+
const messages: BaseMessage[] = [
|
|
741
|
+
new HumanMessage('Run it'),
|
|
742
|
+
aiMsg,
|
|
743
|
+
bigToolMsg,
|
|
744
|
+
new AIMessage('Done.'),
|
|
745
|
+
new HumanMessage('What happened?'),
|
|
746
|
+
];
|
|
747
|
+
|
|
748
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
749
|
+
for (let i = 0; i < messages.length; i++) {
|
|
750
|
+
indexTokenCountMap[i] = tokenCounter(messages[i]);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const pruneMessages = createPruneMessages({
|
|
754
|
+
provider: Providers.OPENAI,
|
|
755
|
+
maxTokens: 100, // Very tight — forces emergency path
|
|
756
|
+
startIndex: messages.length,
|
|
757
|
+
tokenCounter,
|
|
758
|
+
indexTokenCountMap,
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
const result = pruneMessages({ messages });
|
|
762
|
+
|
|
763
|
+
// Emergency truncation should produce a non-empty context
|
|
764
|
+
// (or at minimum, non-empty messagesToRefine)
|
|
765
|
+
const totalReturned =
|
|
766
|
+
result.context.length + (result.messagesToRefine?.length ?? 0);
|
|
767
|
+
expect(totalReturned).toBeGreaterThan(0);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('recovers via emergency truncation after fallback fading when summarizationEnabled=true', () => {
|
|
771
|
+
const bigToolMsg = new ToolMessage({
|
|
772
|
+
content: 'y'.repeat(20_000),
|
|
773
|
+
tool_call_id: 'tc1',
|
|
774
|
+
name: 'huge_result',
|
|
775
|
+
});
|
|
776
|
+
const aiMsg = new AIMessage({
|
|
777
|
+
content: [
|
|
778
|
+
{ type: 'text' as const, text: 'Running.' },
|
|
779
|
+
{
|
|
780
|
+
type: 'tool_use' as const,
|
|
781
|
+
id: 'tc1',
|
|
782
|
+
name: 'huge_result',
|
|
783
|
+
input: '{}',
|
|
784
|
+
},
|
|
785
|
+
],
|
|
786
|
+
tool_calls: [{ id: 'tc1', name: 'huge_result', args: {} }],
|
|
787
|
+
});
|
|
788
|
+
const messages: BaseMessage[] = [
|
|
789
|
+
new HumanMessage('Do it'),
|
|
790
|
+
aiMsg,
|
|
791
|
+
bigToolMsg,
|
|
792
|
+
new AIMessage('Complete.'),
|
|
793
|
+
new HumanMessage('Status?'),
|
|
794
|
+
];
|
|
795
|
+
|
|
796
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
797
|
+
for (let i = 0; i < messages.length; i++) {
|
|
798
|
+
indexTokenCountMap[i] = tokenCounter(messages[i]);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const pruneMessages = createPruneMessages({
|
|
802
|
+
provider: Providers.OPENAI,
|
|
803
|
+
maxTokens: 100,
|
|
804
|
+
startIndex: messages.length,
|
|
805
|
+
tokenCounter,
|
|
806
|
+
indexTokenCountMap,
|
|
807
|
+
summarizationEnabled: true,
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const result = pruneMessages({ messages });
|
|
811
|
+
|
|
812
|
+
const totalReturned =
|
|
813
|
+
result.context.length + (result.messagesToRefine?.length ?? 0);
|
|
814
|
+
expect(totalReturned).toBeGreaterThan(0);
|
|
815
|
+
|
|
816
|
+
if (result.context.length > 0) {
|
|
817
|
+
const toolMsgs = result.context.filter((m) => m.getType() === 'tool');
|
|
818
|
+
for (const tm of toolMsgs) {
|
|
819
|
+
const content = typeof tm.content === 'string' ? tm.content : '';
|
|
820
|
+
expect(content.length).toBeLessThan(20_000);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
// Interaction: pre-flight truncation does not destroy enrichment data
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
describe('pre-flight + enrichment interaction', () => {
|
|
831
|
+
it('tool error content survives pre-flight with raw maxContextTokens', () => {
|
|
832
|
+
// Simulate the exact scenario from the bug:
|
|
833
|
+
// maxContextTokens=50, tool message with 60-char error content.
|
|
834
|
+
// Pre-flight should NOT truncate since calculateMaxToolResultChars(50) = 60.
|
|
835
|
+
const errorContent =
|
|
836
|
+
'Error: ENOENT: no such file or directory, open /src/index.ts';
|
|
837
|
+
expect(errorContent.length).toBe(60);
|
|
838
|
+
|
|
839
|
+
const toolMsg = new ToolMessage({
|
|
840
|
+
content: errorContent,
|
|
841
|
+
tool_call_id: 'tc1',
|
|
842
|
+
name: 'run_linter',
|
|
843
|
+
status: 'error',
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
const messages: BaseMessage[] = [toolMsg];
|
|
847
|
+
const indexTokenCountMap: Record<string, number | undefined> = {
|
|
848
|
+
0: tokenCounter(toolMsg),
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
// Pre-flight with maxContextTokens=50 → maxChars = 60
|
|
852
|
+
const count = preFlightTruncateToolResults({
|
|
853
|
+
messages,
|
|
854
|
+
maxContextTokens: 50,
|
|
855
|
+
indexTokenCountMap,
|
|
856
|
+
tokenCounter,
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
expect(count).toBe(0);
|
|
860
|
+
expect(messages[0].content).toContain('ENOENT');
|
|
861
|
+
|
|
862
|
+
// Verify: if we had used effectiveMaxTokens (e.g., 37),
|
|
863
|
+
// it WOULD have truncated (maxChars=44 < 60)
|
|
864
|
+
const wouldTruncateMaxChars = calculateMaxToolResultChars(37);
|
|
865
|
+
expect(wouldTruncateMaxChars).toBe(44);
|
|
866
|
+
expect(errorContent.length).toBeGreaterThan(wouldTruncateMaxChars);
|
|
867
|
+
});
|
|
868
|
+
});
|