@illuma-ai/agents 1.0.96 → 1.1.0
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 +6 -2
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/common/constants.cjs +78 -0
- package/dist/cjs/common/constants.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +191 -165
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/main.cjs +22 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/dedup.cjs +95 -0
- package/dist/cjs/messages/dedup.cjs.map +1 -0
- package/dist/cjs/tools/CodeExecutor.cjs +22 -3
- package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
- package/dist/cjs/types/graph.cjs.map +1 -1
- package/dist/cjs/utils/contextPressure.cjs +154 -0
- package/dist/cjs/utils/contextPressure.cjs.map +1 -0
- package/dist/cjs/utils/pruneCalibration.cjs +78 -0
- package/dist/cjs/utils/pruneCalibration.cjs.map +1 -0
- package/dist/cjs/utils/run.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/cjs/utils/toolDiscoveryCache.cjs +127 -0
- package/dist/cjs/utils/toolDiscoveryCache.cjs.map +1 -0
- package/dist/esm/agents/AgentContext.mjs +6 -2
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/common/constants.mjs +71 -1
- package/dist/esm/common/constants.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +192 -166
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/main.mjs +5 -1
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/dedup.mjs +93 -0
- package/dist/esm/messages/dedup.mjs.map +1 -0
- package/dist/esm/tools/CodeExecutor.mjs +22 -3
- package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
- package/dist/esm/types/graph.mjs.map +1 -1
- package/dist/esm/utils/contextPressure.mjs +148 -0
- package/dist/esm/utils/contextPressure.mjs.map +1 -0
- package/dist/esm/utils/pruneCalibration.mjs +74 -0
- package/dist/esm/utils/pruneCalibration.mjs.map +1 -0
- package/dist/esm/utils/run.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/esm/utils/toolDiscoveryCache.mjs +125 -0
- package/dist/esm/utils/toolDiscoveryCache.mjs.map +1 -0
- package/dist/types/agents/AgentContext.d.ts +4 -1
- package/dist/types/common/constants.d.ts +49 -0
- package/dist/types/graphs/Graph.d.ts +25 -0
- package/dist/types/messages/dedup.d.ts +25 -0
- package/dist/types/messages/index.d.ts +1 -0
- package/dist/types/types/graph.d.ts +63 -0
- package/dist/types/utils/contextPressure.d.ts +72 -0
- package/dist/types/utils/index.d.ts +3 -0
- package/dist/types/utils/pruneCalibration.d.ts +43 -0
- package/dist/types/utils/toolDiscoveryCache.d.ts +77 -0
- package/package.json +1 -1
- package/src/agents/AgentContext.ts +7 -0
- package/src/common/constants.ts +82 -0
- package/src/graphs/Graph.ts +254 -208
- package/src/graphs/contextManagement.e2e.test.ts +28 -20
- package/src/graphs/gapFeatures.test.ts +520 -0
- package/src/graphs/nonBlockingSummarization.test.ts +307 -0
- package/src/messages/__tests__/dedup.test.ts +166 -0
- package/src/messages/dedup.ts +104 -0
- package/src/messages/index.ts +1 -0
- package/src/specs/agent-handoffs-bedrock.integration.test.ts +7 -7
- package/src/specs/agent-handoffs.test.ts +36 -36
- package/src/specs/thinking-handoff.test.ts +10 -10
- package/src/tools/CodeExecutor.ts +22 -3
- package/src/types/graph.ts +73 -0
- package/src/utils/__tests__/pruneCalibration.test.ts +148 -0
- package/src/utils/__tests__/toolDiscoveryCache.test.ts +214 -0
- package/src/utils/contextPressure.test.ts +262 -0
- package/src/utils/contextPressure.ts +188 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/pruneCalibration.ts +92 -0
- package/src/utils/run.ts +108 -108
- package/src/utils/tokens.ts +118 -118
- package/src/utils/toolDiscoveryCache.ts +150 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// src/utils/__tests__/toolDiscoveryCache.test.ts
|
|
2
|
+
import {
|
|
3
|
+
ToolMessage,
|
|
4
|
+
AIMessageChunk,
|
|
5
|
+
HumanMessage,
|
|
6
|
+
SystemMessage,
|
|
7
|
+
} from '@langchain/core/messages';
|
|
8
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
9
|
+
import { ToolDiscoveryCache } from '../toolDiscoveryCache';
|
|
10
|
+
import { Constants } from '@/common';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a mock tool_search result message.
|
|
14
|
+
*/
|
|
15
|
+
function createToolSearchResult(
|
|
16
|
+
toolNames: string[],
|
|
17
|
+
toolCallId: string = 'tc_1'
|
|
18
|
+
): ToolMessage {
|
|
19
|
+
return new ToolMessage({
|
|
20
|
+
content: `Found ${toolNames.length} tools`,
|
|
21
|
+
tool_call_id: toolCallId,
|
|
22
|
+
name: Constants.TOOL_SEARCH,
|
|
23
|
+
artifact: {
|
|
24
|
+
tool_references: toolNames.map((name) => ({ tool_name: name })),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a mock AI message with tool calls.
|
|
31
|
+
*/
|
|
32
|
+
function createAIWithToolCalls(toolCallIds: string[]): AIMessageChunk {
|
|
33
|
+
return new AIMessageChunk({
|
|
34
|
+
content: 'I will search for tools',
|
|
35
|
+
tool_calls: toolCallIds.map((id) => ({
|
|
36
|
+
id,
|
|
37
|
+
name: Constants.TOOL_SEARCH,
|
|
38
|
+
args: { query: 'test' },
|
|
39
|
+
})),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('ToolDiscoveryCache', () => {
|
|
44
|
+
let cache: ToolDiscoveryCache;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
cache = new ToolDiscoveryCache();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('getNewDiscoveries', () => {
|
|
51
|
+
it('returns empty array for empty messages', () => {
|
|
52
|
+
expect(cache.getNewDiscoveries([])).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('discovers tools from tool_search results', () => {
|
|
56
|
+
const messages: BaseMessage[] = [
|
|
57
|
+
new SystemMessage('You are helpful'),
|
|
58
|
+
new HumanMessage('Find tools'),
|
|
59
|
+
createAIWithToolCalls(['tc_1']),
|
|
60
|
+
createToolSearchResult(['web_search', 'file_read'], 'tc_1'),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const result = cache.getNewDiscoveries(messages);
|
|
64
|
+
expect(result).toEqual(['web_search', 'file_read']);
|
|
65
|
+
expect(cache.size).toBe(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('only scans new messages on subsequent calls', () => {
|
|
69
|
+
const messages: BaseMessage[] = [
|
|
70
|
+
new HumanMessage('msg1'),
|
|
71
|
+
createAIWithToolCalls(['tc_1']),
|
|
72
|
+
createToolSearchResult(['tool_a'], 'tc_1'),
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// First scan
|
|
76
|
+
const first = cache.getNewDiscoveries(messages);
|
|
77
|
+
expect(first).toEqual(['tool_a']);
|
|
78
|
+
|
|
79
|
+
// Add more messages
|
|
80
|
+
messages.push(
|
|
81
|
+
new HumanMessage('msg2'),
|
|
82
|
+
createAIWithToolCalls(['tc_2']),
|
|
83
|
+
createToolSearchResult(['tool_b'], 'tc_2')
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Second scan: only finds tool_b (tool_a already cached)
|
|
87
|
+
const second = cache.getNewDiscoveries(messages);
|
|
88
|
+
expect(second).toEqual(['tool_b']);
|
|
89
|
+
expect(cache.size).toBe(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('deduplicates tool names across scans', () => {
|
|
93
|
+
const messages: BaseMessage[] = [
|
|
94
|
+
createAIWithToolCalls(['tc_1']),
|
|
95
|
+
createToolSearchResult(['tool_a', 'tool_b'], 'tc_1'),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
cache.getNewDiscoveries(messages);
|
|
99
|
+
|
|
100
|
+
// Add another search that returns tool_a again
|
|
101
|
+
messages.push(
|
|
102
|
+
createAIWithToolCalls(['tc_2']),
|
|
103
|
+
createToolSearchResult(['tool_a', 'tool_c'], 'tc_2')
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const second = cache.getNewDiscoveries(messages);
|
|
107
|
+
// tool_a is already cached, only tool_c is new
|
|
108
|
+
expect(second).toEqual(['tool_c']);
|
|
109
|
+
expect(cache.size).toBe(3);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('ignores non-tool-search tool messages', () => {
|
|
113
|
+
const messages: BaseMessage[] = [
|
|
114
|
+
createAIWithToolCalls(['tc_1']),
|
|
115
|
+
new ToolMessage({
|
|
116
|
+
content: 'result',
|
|
117
|
+
tool_call_id: 'tc_1',
|
|
118
|
+
name: 'some_other_tool',
|
|
119
|
+
}),
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const result = cache.getNewDiscoveries(messages);
|
|
123
|
+
expect(result).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns empty when no new messages since last scan', () => {
|
|
127
|
+
const messages: BaseMessage[] = [
|
|
128
|
+
createAIWithToolCalls(['tc_1']),
|
|
129
|
+
createToolSearchResult(['tool_a'], 'tc_1'),
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
cache.getNewDiscoveries(messages);
|
|
133
|
+
// No new messages added
|
|
134
|
+
const second = cache.getNewDiscoveries(messages);
|
|
135
|
+
expect(second).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('has', () => {
|
|
140
|
+
it('returns true for discovered tools', () => {
|
|
141
|
+
const messages: BaseMessage[] = [
|
|
142
|
+
createAIWithToolCalls(['tc_1']),
|
|
143
|
+
createToolSearchResult(['tool_a'], 'tc_1'),
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
cache.getNewDiscoveries(messages);
|
|
147
|
+
expect(cache.has('tool_a')).toBe(true);
|
|
148
|
+
expect(cache.has('tool_b')).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('getAllDiscoveredTools', () => {
|
|
153
|
+
it('returns all discovered tool names', () => {
|
|
154
|
+
const messages: BaseMessage[] = [
|
|
155
|
+
createAIWithToolCalls(['tc_1']),
|
|
156
|
+
createToolSearchResult(['tool_a', 'tool_b'], 'tc_1'),
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
cache.getNewDiscoveries(messages);
|
|
160
|
+
expect(cache.getAllDiscoveredTools()).toEqual(
|
|
161
|
+
expect.arrayContaining(['tool_a', 'tool_b'])
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('seed', () => {
|
|
167
|
+
it('pre-populates the cache with known tool names', () => {
|
|
168
|
+
cache.seed(['tool_x', 'tool_y']);
|
|
169
|
+
expect(cache.size).toBe(2);
|
|
170
|
+
expect(cache.has('tool_x')).toBe(true);
|
|
171
|
+
expect(cache.has('tool_y')).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('seeded tools are treated as already discovered', () => {
|
|
175
|
+
cache.seed(['tool_a']);
|
|
176
|
+
|
|
177
|
+
const messages: BaseMessage[] = [
|
|
178
|
+
createAIWithToolCalls(['tc_1']),
|
|
179
|
+
createToolSearchResult(['tool_a', 'tool_b'], 'tc_1'),
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
// tool_a is already seeded, only tool_b should be new
|
|
183
|
+
const result = cache.getNewDiscoveries(messages);
|
|
184
|
+
expect(result).toEqual(['tool_b']);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('reset', () => {
|
|
189
|
+
it('clears all state', () => {
|
|
190
|
+
cache.seed(['tool_a']);
|
|
191
|
+
expect(cache.size).toBe(1);
|
|
192
|
+
|
|
193
|
+
cache.reset();
|
|
194
|
+
expect(cache.size).toBe(0);
|
|
195
|
+
expect(cache.has('tool_a')).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('allows re-discovery after reset', () => {
|
|
199
|
+
const messages: BaseMessage[] = [
|
|
200
|
+
createAIWithToolCalls(['tc_1']),
|
|
201
|
+
createToolSearchResult(['tool_a'], 'tc_1'),
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
cache.getNewDiscoveries(messages);
|
|
205
|
+
expect(cache.size).toBe(1);
|
|
206
|
+
|
|
207
|
+
cache.reset();
|
|
208
|
+
|
|
209
|
+
// Same messages should produce discoveries again
|
|
210
|
+
const result = cache.getNewDiscoveries(messages);
|
|
211
|
+
expect(result).toEqual(['tool_a']);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HumanMessage,
|
|
3
|
+
AIMessage,
|
|
4
|
+
SystemMessage,
|
|
5
|
+
} from '@langchain/core/messages';
|
|
6
|
+
import { MULTI_DOCUMENT_THRESHOLD } from '@/common/constants';
|
|
7
|
+
import {
|
|
8
|
+
detectDocuments,
|
|
9
|
+
shouldInjectMultiDocHint,
|
|
10
|
+
buildMultiDocHintContent,
|
|
11
|
+
buildPostPruneNote,
|
|
12
|
+
hasTaskTool,
|
|
13
|
+
} from './contextPressure';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// detectDocuments
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
describe('detectDocuments', () => {
|
|
20
|
+
it('returns zero when no documents are present', () => {
|
|
21
|
+
const messages = [
|
|
22
|
+
new HumanMessage('Hello, what can you do?'),
|
|
23
|
+
new AIMessage('I can help with many things.'),
|
|
24
|
+
];
|
|
25
|
+
const result = detectDocuments(messages);
|
|
26
|
+
expect(result.count).toBe(0);
|
|
27
|
+
expect(result.names).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('detects documents from # "filename" pattern', () => {
|
|
31
|
+
const messages = [
|
|
32
|
+
new HumanMessage(
|
|
33
|
+
'Attached document(s):\n# "report.pdf"\nContent here\n# "analysis.docx"\nMore content'
|
|
34
|
+
),
|
|
35
|
+
];
|
|
36
|
+
const result = detectDocuments(messages);
|
|
37
|
+
expect(result.count).toBe(2);
|
|
38
|
+
expect(result.names).toEqual(['report.pdf', 'analysis.docx']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('detects documents from "user has attached" pattern', () => {
|
|
42
|
+
const messages = [
|
|
43
|
+
new HumanMessage(
|
|
44
|
+
'The user has attached: **budget.xlsx, summary.pdf, notes.txt**'
|
|
45
|
+
),
|
|
46
|
+
];
|
|
47
|
+
const result = detectDocuments(messages);
|
|
48
|
+
expect(result.count).toBe(3);
|
|
49
|
+
expect(result.names).toEqual(['budget.xlsx', 'summary.pdf', 'notes.txt']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('deduplicates documents across messages', () => {
|
|
53
|
+
const messages = [
|
|
54
|
+
new HumanMessage('# "report.pdf"\nContent'),
|
|
55
|
+
new HumanMessage('# "report.pdf"\nSame doc again'),
|
|
56
|
+
new HumanMessage('# "other.pdf"\nDifferent doc'),
|
|
57
|
+
];
|
|
58
|
+
const result = detectDocuments(messages);
|
|
59
|
+
expect(result.count).toBe(2);
|
|
60
|
+
expect(result.names).toEqual(['report.pdf', 'other.pdf']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('deduplicates across both patterns', () => {
|
|
64
|
+
const messages = [
|
|
65
|
+
new HumanMessage('# "report.pdf"\nContent'),
|
|
66
|
+
new HumanMessage('The user has attached: **report.pdf, budget.xlsx**'),
|
|
67
|
+
];
|
|
68
|
+
const result = detectDocuments(messages);
|
|
69
|
+
expect(result.count).toBe(2);
|
|
70
|
+
expect(result.names).toEqual(['report.pdf', 'budget.xlsx']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('handles array content (multi-part messages)', () => {
|
|
74
|
+
const messages = [
|
|
75
|
+
new HumanMessage({
|
|
76
|
+
content: [
|
|
77
|
+
{ type: 'text', text: '# "doc1.pdf"\nPart 1' },
|
|
78
|
+
{ type: 'text', text: '# "doc2.pdf"\nPart 2' },
|
|
79
|
+
],
|
|
80
|
+
}),
|
|
81
|
+
];
|
|
82
|
+
const result = detectDocuments(messages);
|
|
83
|
+
expect(result.count).toBe(2);
|
|
84
|
+
expect(result.names).toEqual(['doc1.pdf', 'doc2.pdf']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('ignores non-human messages with document patterns', () => {
|
|
88
|
+
// detectDocuments scans ALL messages — AI messages with doc patterns
|
|
89
|
+
// should still be detected (they may contain tool results with docs)
|
|
90
|
+
const messages = [new AIMessage('Found: # "results.csv"\nData here')];
|
|
91
|
+
const result = detectDocuments(messages);
|
|
92
|
+
expect(result.count).toBe(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('handles empty messages array', () => {
|
|
96
|
+
const result = detectDocuments([]);
|
|
97
|
+
expect(result.count).toBe(0);
|
|
98
|
+
expect(result.names).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('handles case-insensitive "user has attached" pattern', () => {
|
|
102
|
+
const messages = [
|
|
103
|
+
new HumanMessage('The User Has Attached: **file1.pdf, file2.pdf**'),
|
|
104
|
+
];
|
|
105
|
+
const result = detectDocuments(messages);
|
|
106
|
+
expect(result.count).toBe(2);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// shouldInjectMultiDocHint
|
|
112
|
+
// ============================================================================
|
|
113
|
+
|
|
114
|
+
describe('shouldInjectMultiDocHint', () => {
|
|
115
|
+
it('returns true when document count meets threshold and no AI response', () => {
|
|
116
|
+
expect(shouldInjectMultiDocHint(MULTI_DOCUMENT_THRESHOLD, false)).toBe(
|
|
117
|
+
true
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('returns true when document count exceeds threshold', () => {
|
|
122
|
+
expect(shouldInjectMultiDocHint(MULTI_DOCUMENT_THRESHOLD + 5, false)).toBe(
|
|
123
|
+
true
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('returns false when document count is below threshold', () => {
|
|
128
|
+
expect(shouldInjectMultiDocHint(MULTI_DOCUMENT_THRESHOLD - 1, false)).toBe(
|
|
129
|
+
false
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns false when AI has already responded', () => {
|
|
134
|
+
expect(shouldInjectMultiDocHint(MULTI_DOCUMENT_THRESHOLD, true)).toBe(
|
|
135
|
+
false
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns false with zero documents', () => {
|
|
140
|
+
expect(shouldInjectMultiDocHint(0, false)).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('uses MULTI_DOCUMENT_THRESHOLD constant (currently 3)', () => {
|
|
144
|
+
expect(MULTI_DOCUMENT_THRESHOLD).toBe(3);
|
|
145
|
+
expect(shouldInjectMultiDocHint(2, false)).toBe(false);
|
|
146
|
+
expect(shouldInjectMultiDocHint(3, false)).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ============================================================================
|
|
151
|
+
// buildMultiDocHintContent
|
|
152
|
+
// ============================================================================
|
|
153
|
+
|
|
154
|
+
describe('buildMultiDocHintContent', () => {
|
|
155
|
+
it('includes document count in header', () => {
|
|
156
|
+
const content = buildMultiDocHintContent(4, [
|
|
157
|
+
'a.pdf',
|
|
158
|
+
'b.pdf',
|
|
159
|
+
'c.pdf',
|
|
160
|
+
'd.pdf',
|
|
161
|
+
]);
|
|
162
|
+
expect(content).toContain('4 documents detected');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('lists all document names', () => {
|
|
166
|
+
const names = ['report.pdf', 'budget.xlsx', 'summary.docx'];
|
|
167
|
+
const content = buildMultiDocHintContent(3, names);
|
|
168
|
+
expect(content).toContain('report.pdf');
|
|
169
|
+
expect(content).toContain('budget.xlsx');
|
|
170
|
+
expect(content).toContain('summary.docx');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('mentions the task tool for delegation', () => {
|
|
174
|
+
const content = buildMultiDocHintContent(3, ['a', 'b', 'c']);
|
|
175
|
+
expect(content).toContain('"task" tool');
|
|
176
|
+
expect(content).toContain('sub-agent');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('does NOT contain any token numbers or budget percentages', () => {
|
|
180
|
+
const content = buildMultiDocHintContent(5, ['a', 'b', 'c', 'd', 'e']);
|
|
181
|
+
expect(content).not.toMatch(/\d+%/);
|
|
182
|
+
expect(content).not.toMatch(/\d+ of \d+ tokens/);
|
|
183
|
+
expect(content).not.toMatch(/tokens remaining/);
|
|
184
|
+
expect(content).not.toMatch(/BUDGET/i);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// buildPostPruneNote
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
describe('buildPostPruneNote', () => {
|
|
193
|
+
it('returns null when no messages were discarded', () => {
|
|
194
|
+
expect(buildPostPruneNote(0, true)).toBeNull();
|
|
195
|
+
expect(buildPostPruneNote(0, false)).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns null for negative discard count', () => {
|
|
199
|
+
expect(buildPostPruneNote(-5, true)).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('returns note with summary reference when summary exists', () => {
|
|
203
|
+
const note = buildPostPruneNote(10, true);
|
|
204
|
+
expect(note).not.toBeNull();
|
|
205
|
+
expect(note).toContain('summarized above');
|
|
206
|
+
expect(note).toContain('"task" tool');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('returns note without summary reference when no summary', () => {
|
|
210
|
+
const note = buildPostPruneNote(10, false);
|
|
211
|
+
expect(note).not.toBeNull();
|
|
212
|
+
expect(note).toContain('removed to maintain context');
|
|
213
|
+
expect(note).toContain('"task" tool');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('does NOT contain any token numbers or budget percentages', () => {
|
|
217
|
+
const noteWithSummary = buildPostPruneNote(20, true)!;
|
|
218
|
+
const noteWithout = buildPostPruneNote(20, false)!;
|
|
219
|
+
for (const note of [noteWithSummary, noteWithout]) {
|
|
220
|
+
expect(note).not.toMatch(/\d+%/);
|
|
221
|
+
expect(note).not.toMatch(/\d+ of \d+ tokens/);
|
|
222
|
+
expect(note).not.toMatch(/tokens remaining/);
|
|
223
|
+
expect(note).not.toMatch(/BUDGET/i);
|
|
224
|
+
expect(note).not.toMatch(/CRITICAL/i);
|
|
225
|
+
expect(note).not.toMatch(/WARNING/i);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// hasTaskTool
|
|
232
|
+
// ============================================================================
|
|
233
|
+
|
|
234
|
+
describe('hasTaskTool', () => {
|
|
235
|
+
it('returns true when task tool exists', () => {
|
|
236
|
+
const tools = [{ name: 'search' }, { name: 'task' }, { name: 'code' }];
|
|
237
|
+
expect(hasTaskTool(tools)).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('returns false when task tool does not exist', () => {
|
|
241
|
+
const tools = [{ name: 'search' }, { name: 'code' }];
|
|
242
|
+
expect(hasTaskTool(tools)).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('returns false for undefined tools', () => {
|
|
246
|
+
expect(hasTaskTool(undefined)).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('returns false for empty tools array', () => {
|
|
250
|
+
expect(hasTaskTool([])).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('handles tools without name property', () => {
|
|
254
|
+
const tools = [{}, { name: 'task' }, 'not-an-object'] as unknown[];
|
|
255
|
+
expect(hasTaskTool(tools)).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('returns false when tools have no name property', () => {
|
|
259
|
+
const tools = [{}, {}, 42] as unknown[];
|
|
260
|
+
expect(hasTaskTool(tools)).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Pressure Utilities
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for context overflow management. These handle:
|
|
5
|
+
* 1. Multi-document detection — counting attached documents in messages
|
|
6
|
+
* 2. Multi-document delegation hint — injected when 3+ documents detected
|
|
7
|
+
* 3. Post-prune context note — injected after pruning/summarization
|
|
8
|
+
*
|
|
9
|
+
* DESIGN PRINCIPLE: The LLM never sees raw token numbers. Context overflow
|
|
10
|
+
* is handled mechanically by pruning (Graph) + auto-continuation (client.js).
|
|
11
|
+
* Only task-driven hints (multi-document) are injected — never budget-based.
|
|
12
|
+
*
|
|
13
|
+
* @see docs/context-overflow-architecture.md
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
17
|
+
import { MULTI_DOCUMENT_THRESHOLD } from '@/common/constants';
|
|
18
|
+
|
|
19
|
+
/** Result of scanning messages for attached documents */
|
|
20
|
+
export interface DocumentDetectionResult {
|
|
21
|
+
/** Total unique documents detected */
|
|
22
|
+
count: number;
|
|
23
|
+
/** Names of detected documents */
|
|
24
|
+
names: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Scan messages for attached documents using known content patterns.
|
|
29
|
+
*
|
|
30
|
+
* Detects documents from:
|
|
31
|
+
* 1. `# "filename"` headers in "Attached document(s):" blocks (text content)
|
|
32
|
+
* 2. `**filename1, filename2**` in "The user has attached:" blocks (embedded files)
|
|
33
|
+
*
|
|
34
|
+
* @param messages - Conversation messages to scan
|
|
35
|
+
* @returns Document count and names (deduplicated)
|
|
36
|
+
*/
|
|
37
|
+
export function detectDocuments(
|
|
38
|
+
messages: BaseMessage[]
|
|
39
|
+
): DocumentDetectionResult {
|
|
40
|
+
const documentNames: string[] = [];
|
|
41
|
+
|
|
42
|
+
for (const msg of messages) {
|
|
43
|
+
const content = extractTextContent(msg);
|
|
44
|
+
|
|
45
|
+
// Pattern 1: # "filename" headers in attached document blocks
|
|
46
|
+
const docMatches = content.match(/# "([^"]+)"/g);
|
|
47
|
+
if (docMatches) {
|
|
48
|
+
for (const match of docMatches) {
|
|
49
|
+
const name = match.replace(/# "/, '').replace(/"$/, '');
|
|
50
|
+
if (!documentNames.includes(name)) {
|
|
51
|
+
documentNames.push(name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Pattern 2: "The user has attached: **file1, file2**" (embedded files)
|
|
57
|
+
const attachedMatch = content.match(
|
|
58
|
+
/user has attached:\s*\*\*([^*]+)\*\*/i
|
|
59
|
+
);
|
|
60
|
+
if (attachedMatch) {
|
|
61
|
+
const names = attachedMatch[1]
|
|
62
|
+
.split(',')
|
|
63
|
+
.map((n: string) => n.trim())
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
if (!documentNames.includes(name)) {
|
|
67
|
+
documentNames.push(name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { count: documentNames.length, names: documentNames };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Determine whether the multi-document delegation hint should be injected.
|
|
78
|
+
*
|
|
79
|
+
* Only fires on the first iteration (before any AI response) when the
|
|
80
|
+
* document count meets the threshold. This ensures the agent delegates
|
|
81
|
+
* upfront rather than trying to process all documents itself.
|
|
82
|
+
*
|
|
83
|
+
* @param documentCount - Number of detected documents
|
|
84
|
+
* @param hasAiResponse - Whether the agent has already responded in this chain
|
|
85
|
+
* @returns Whether to inject the delegation hint
|
|
86
|
+
*/
|
|
87
|
+
export function shouldInjectMultiDocHint(
|
|
88
|
+
documentCount: number,
|
|
89
|
+
hasAiResponse: boolean
|
|
90
|
+
): boolean {
|
|
91
|
+
return documentCount >= MULTI_DOCUMENT_THRESHOLD && !hasAiResponse;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the multi-document delegation hint message content.
|
|
96
|
+
*
|
|
97
|
+
* @param documentCount - Number of detected documents
|
|
98
|
+
* @param documentNames - Names of detected documents
|
|
99
|
+
* @returns Message content string for injection as HumanMessage
|
|
100
|
+
*/
|
|
101
|
+
export function buildMultiDocHintContent(
|
|
102
|
+
documentCount: number,
|
|
103
|
+
documentNames: string[]
|
|
104
|
+
): string {
|
|
105
|
+
return (
|
|
106
|
+
`[MULTI-DOCUMENT PROCESSING — ${documentCount} documents detected]\n` +
|
|
107
|
+
`Documents: ${documentNames.join(', ')}\n\n` +
|
|
108
|
+
`You have ${documentCount} documents attached. For thorough analysis, use the "task" tool ` +
|
|
109
|
+
'to delegate each document (or group of related documents) to a sub-agent.\n' +
|
|
110
|
+
'Each sub-agent has its own fresh context window and can use file_search to retrieve the full document content.\n' +
|
|
111
|
+
'After all sub-agents complete, synthesize their results into a comprehensive response.\n\n' +
|
|
112
|
+
'This approach ensures each document gets full attention without context limitations.'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the post-prune context note injected after messages are pruned
|
|
118
|
+
* and summarized. No token numbers — just a contextual signal that
|
|
119
|
+
* earlier conversation was compressed.
|
|
120
|
+
*
|
|
121
|
+
* @param discardedCount - Number of messages that were pruned
|
|
122
|
+
* @param hasSummary - Whether a summary was successfully generated
|
|
123
|
+
* @returns Message content string for injection as SystemMessage, or null if no note needed
|
|
124
|
+
*/
|
|
125
|
+
export function buildPostPruneNote(
|
|
126
|
+
discardedCount: number,
|
|
127
|
+
hasSummary: boolean
|
|
128
|
+
): string | null {
|
|
129
|
+
if (discardedCount <= 0) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (hasSummary) {
|
|
134
|
+
return (
|
|
135
|
+
'[Context Compressed] Earlier conversation messages have been summarized above. ' +
|
|
136
|
+
'For complex remaining work that requires deep analysis, consider delegating to ' +
|
|
137
|
+
'sub-agents using the "task" tool — each gets a fresh context window.'
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
'[Context Compressed] Some earlier conversation messages were removed to maintain context capacity. ' +
|
|
143
|
+
'For complex remaining work, consider delegating to sub-agents using the "task" tool.'
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check whether a tool named "task" exists in the agent's tool set.
|
|
149
|
+
*
|
|
150
|
+
* @param tools - Array of tool objects or structured tools
|
|
151
|
+
* @returns Whether the task tool is available
|
|
152
|
+
*/
|
|
153
|
+
export function hasTaskTool(
|
|
154
|
+
tools: Array<{ name?: string } | unknown> | undefined
|
|
155
|
+
): boolean {
|
|
156
|
+
if (!tools) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
return tools.some((tool) => {
|
|
160
|
+
const toolName =
|
|
161
|
+
typeof tool === 'object' && tool !== null && 'name' in tool
|
|
162
|
+
? (tool as { name: string }).name
|
|
163
|
+
: '';
|
|
164
|
+
return toolName === 'task';
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Extract text content from a BaseMessage, handling both string and
|
|
170
|
+
* array content formats.
|
|
171
|
+
*
|
|
172
|
+
* @param msg - A LangChain BaseMessage
|
|
173
|
+
* @returns Flattened text content
|
|
174
|
+
*/
|
|
175
|
+
function extractTextContent(msg: BaseMessage): string {
|
|
176
|
+
if (typeof msg.content === 'string') {
|
|
177
|
+
return msg.content;
|
|
178
|
+
}
|
|
179
|
+
if (Array.isArray(msg.content)) {
|
|
180
|
+
return msg.content
|
|
181
|
+
.map((p: unknown) => {
|
|
182
|
+
const part = p as Record<string, unknown>;
|
|
183
|
+
return String(part.text ?? part.content ?? '');
|
|
184
|
+
})
|
|
185
|
+
.join(' ');
|
|
186
|
+
}
|
|
187
|
+
return '';
|
|
188
|
+
}
|
package/src/utils/index.ts
CHANGED