@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.
Files changed (76) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +6 -2
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/constants.cjs +78 -0
  4. package/dist/cjs/common/constants.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +191 -165
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/main.cjs +22 -0
  8. package/dist/cjs/main.cjs.map +1 -1
  9. package/dist/cjs/messages/dedup.cjs +95 -0
  10. package/dist/cjs/messages/dedup.cjs.map +1 -0
  11. package/dist/cjs/tools/CodeExecutor.cjs +22 -3
  12. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  13. package/dist/cjs/types/graph.cjs.map +1 -1
  14. package/dist/cjs/utils/contextPressure.cjs +154 -0
  15. package/dist/cjs/utils/contextPressure.cjs.map +1 -0
  16. package/dist/cjs/utils/pruneCalibration.cjs +78 -0
  17. package/dist/cjs/utils/pruneCalibration.cjs.map +1 -0
  18. package/dist/cjs/utils/run.cjs.map +1 -1
  19. package/dist/cjs/utils/tokens.cjs.map +1 -1
  20. package/dist/cjs/utils/toolDiscoveryCache.cjs +127 -0
  21. package/dist/cjs/utils/toolDiscoveryCache.cjs.map +1 -0
  22. package/dist/esm/agents/AgentContext.mjs +6 -2
  23. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  24. package/dist/esm/common/constants.mjs +71 -1
  25. package/dist/esm/common/constants.mjs.map +1 -1
  26. package/dist/esm/graphs/Graph.mjs +192 -166
  27. package/dist/esm/graphs/Graph.mjs.map +1 -1
  28. package/dist/esm/main.mjs +5 -1
  29. package/dist/esm/main.mjs.map +1 -1
  30. package/dist/esm/messages/dedup.mjs +93 -0
  31. package/dist/esm/messages/dedup.mjs.map +1 -0
  32. package/dist/esm/tools/CodeExecutor.mjs +22 -3
  33. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  34. package/dist/esm/types/graph.mjs.map +1 -1
  35. package/dist/esm/utils/contextPressure.mjs +148 -0
  36. package/dist/esm/utils/contextPressure.mjs.map +1 -0
  37. package/dist/esm/utils/pruneCalibration.mjs +74 -0
  38. package/dist/esm/utils/pruneCalibration.mjs.map +1 -0
  39. package/dist/esm/utils/run.mjs.map +1 -1
  40. package/dist/esm/utils/tokens.mjs.map +1 -1
  41. package/dist/esm/utils/toolDiscoveryCache.mjs +125 -0
  42. package/dist/esm/utils/toolDiscoveryCache.mjs.map +1 -0
  43. package/dist/types/agents/AgentContext.d.ts +4 -1
  44. package/dist/types/common/constants.d.ts +49 -0
  45. package/dist/types/graphs/Graph.d.ts +25 -0
  46. package/dist/types/messages/dedup.d.ts +25 -0
  47. package/dist/types/messages/index.d.ts +1 -0
  48. package/dist/types/types/graph.d.ts +63 -0
  49. package/dist/types/utils/contextPressure.d.ts +72 -0
  50. package/dist/types/utils/index.d.ts +3 -0
  51. package/dist/types/utils/pruneCalibration.d.ts +43 -0
  52. package/dist/types/utils/toolDiscoveryCache.d.ts +77 -0
  53. package/package.json +1 -1
  54. package/src/agents/AgentContext.ts +7 -0
  55. package/src/common/constants.ts +82 -0
  56. package/src/graphs/Graph.ts +254 -208
  57. package/src/graphs/contextManagement.e2e.test.ts +28 -20
  58. package/src/graphs/gapFeatures.test.ts +520 -0
  59. package/src/graphs/nonBlockingSummarization.test.ts +307 -0
  60. package/src/messages/__tests__/dedup.test.ts +166 -0
  61. package/src/messages/dedup.ts +104 -0
  62. package/src/messages/index.ts +1 -0
  63. package/src/specs/agent-handoffs-bedrock.integration.test.ts +7 -7
  64. package/src/specs/agent-handoffs.test.ts +36 -36
  65. package/src/specs/thinking-handoff.test.ts +10 -10
  66. package/src/tools/CodeExecutor.ts +22 -3
  67. package/src/types/graph.ts +73 -0
  68. package/src/utils/__tests__/pruneCalibration.test.ts +148 -0
  69. package/src/utils/__tests__/toolDiscoveryCache.test.ts +214 -0
  70. package/src/utils/contextPressure.test.ts +262 -0
  71. package/src/utils/contextPressure.ts +188 -0
  72. package/src/utils/index.ts +3 -0
  73. package/src/utils/pruneCalibration.ts +92 -0
  74. package/src/utils/run.ts +108 -108
  75. package/src/utils/tokens.ts +118 -118
  76. 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
+ }
@@ -8,3 +8,6 @@ export * from './toonFormat';
8
8
  export * from './contextAnalytics';
9
9
  export * from './schema';
10
10
  export * from './toolCallContinuation';
11
+ export * from './contextPressure';
12
+ export * from './toolDiscoveryCache';
13
+ export * from './pruneCalibration';