@illuma-ai/agents 1.1.14 → 1.1.15

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.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Unit tests for the delegate pattern in MultiAgentGraph.
3
+ *
4
+ * Tests cover:
5
+ * - Result extraction from child agent messages (extractDelegateResult)
6
+ * - Result truncation for parent context protection (truncateDelegateResult)
7
+ * - Constants and naming conventions
8
+ */
9
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
10
+ import type { BaseMessage } from '@langchain/core/messages';
11
+ import {
12
+ Constants,
13
+ EdgeType,
14
+ DEFAULT_DELEGATE_MAX_RESULT_CHARS,
15
+ DELEGATE_TIMEOUT_MS,
16
+ } from '@/common';
17
+
18
+ /**
19
+ * Import the static helper methods from MultiAgentGraph.
20
+ * These are static so they can be tested without instantiating the full graph.
21
+ */
22
+ import { MultiAgentGraph } from '../MultiAgentGraph';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+ describe('delegate constants', () => {
28
+ it('LC_DELEGATE_TO_ prefix matches expected pattern', () => {
29
+ expect(Constants.LC_DELEGATE_TO_).toBe('lc_delegate_to_');
30
+ });
31
+
32
+ it('LC_DELEGATE_TO_ is distinct from LC_TRANSFER_TO_', () => {
33
+ expect(Constants.LC_DELEGATE_TO_).not.toBe(Constants.LC_TRANSFER_TO_);
34
+ });
35
+
36
+ it('DEFAULT_DELEGATE_MAX_RESULT_CHARS is 32768', () => {
37
+ expect(DEFAULT_DELEGATE_MAX_RESULT_CHARS).toBe(32768);
38
+ });
39
+
40
+ it('DELEGATE_TIMEOUT_MS is 5 minutes', () => {
41
+ expect(DELEGATE_TIMEOUT_MS).toBe(300_000);
42
+ });
43
+
44
+ it('EdgeType.DELEGATE has correct value', () => {
45
+ expect(EdgeType.DELEGATE).toBe('delegate');
46
+ });
47
+ });
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // extractDelegateResult
51
+ // ---------------------------------------------------------------------------
52
+ describe('extractDelegateResult', () => {
53
+ it('extracts text from last AIMessage with string content', () => {
54
+ const messages: BaseMessage[] = [
55
+ new HumanMessage('find sales data'),
56
+ new AIMessage('Here are the sales figures for Q1...'),
57
+ ];
58
+ const result = MultiAgentGraph.extractDelegateResult(messages, 'researcher');
59
+ expect(result).toBe('Here are the sales figures for Q1...');
60
+ });
61
+
62
+ it('extracts text from last AIMessage with array content', () => {
63
+ const messages: BaseMessage[] = [
64
+ new HumanMessage('analyze this'),
65
+ new AIMessage({
66
+ content: [
67
+ { type: 'text', text: 'Analysis shows growth.' },
68
+ { type: 'text', text: 'Revenue up 15%.' },
69
+ ],
70
+ }),
71
+ ];
72
+ const result = MultiAgentGraph.extractDelegateResult(messages, 'analyst');
73
+ expect(result).toBe('Analysis shows growth.\nRevenue up 15%.');
74
+ });
75
+
76
+ it('skips non-AI messages and finds last AIMessage', () => {
77
+ const messages: BaseMessage[] = [
78
+ new HumanMessage('task'),
79
+ new AIMessage('intermediate result'),
80
+ new HumanMessage('continue'),
81
+ new AIMessage('final result here'),
82
+ ];
83
+ const result = MultiAgentGraph.extractDelegateResult(messages, 'agent');
84
+ expect(result).toBe('final result here');
85
+ });
86
+
87
+ it('skips AIMessages with empty content and finds previous', () => {
88
+ const messages: BaseMessage[] = [
89
+ new HumanMessage('task'),
90
+ new AIMessage('good result'),
91
+ new AIMessage(''),
92
+ ];
93
+ const result = MultiAgentGraph.extractDelegateResult(messages, 'agent');
94
+ expect(result).toBe('good result');
95
+ });
96
+
97
+ it('skips AIMessages with tool_calls only (no text)', () => {
98
+ const messages: BaseMessage[] = [
99
+ new HumanMessage('task'),
100
+ new AIMessage('found the data'),
101
+ new AIMessage({
102
+ content: '',
103
+ tool_calls: [{ name: 'search', args: {}, id: 'tc1', type: 'tool_call' }],
104
+ }),
105
+ new ToolMessage({ content: 'search result', tool_call_id: 'tc1' }),
106
+ ];
107
+ const result = MultiAgentGraph.extractDelegateResult(messages, 'agent');
108
+ expect(result).toBe('found the data');
109
+ });
110
+
111
+ it('returns fallback message when no AIMessage has text', () => {
112
+ const messages: BaseMessage[] = [
113
+ new HumanMessage('task'),
114
+ ];
115
+ const result = MultiAgentGraph.extractDelegateResult(messages, 'researcher');
116
+ expect(result).toBe('[Agent "researcher" completed but produced no text output]');
117
+ });
118
+
119
+ it('returns fallback for empty messages array', () => {
120
+ const result = MultiAgentGraph.extractDelegateResult([], 'agent');
121
+ expect(result).toBe('[Agent "agent" completed but produced no text output]');
122
+ });
123
+
124
+ it('trims whitespace from extracted text', () => {
125
+ const messages: BaseMessage[] = [
126
+ new AIMessage(' result with spaces \n\n'),
127
+ ];
128
+ const result = MultiAgentGraph.extractDelegateResult(messages, 'agent');
129
+ expect(result).toBe('result with spaces');
130
+ });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // truncateDelegateResult
135
+ // ---------------------------------------------------------------------------
136
+ describe('truncateDelegateResult', () => {
137
+ it('returns result unchanged when within budget', () => {
138
+ const result = 'short result';
139
+ expect(MultiAgentGraph.truncateDelegateResult(result, 1000)).toBe(result);
140
+ });
141
+
142
+ it('returns empty string unchanged', () => {
143
+ expect(MultiAgentGraph.truncateDelegateResult('', 1000)).toBe('');
144
+ });
145
+
146
+ it('truncates with head/tail split when over budget', () => {
147
+ const longText = 'A'.repeat(1000);
148
+ const truncated = MultiAgentGraph.truncateDelegateResult(longText, 500);
149
+
150
+ expect(truncated.length).toBeLessThanOrEqual(500);
151
+ expect(truncated).toContain('delegate output truncated');
152
+ // Head should be present
153
+ expect(truncated.startsWith('AAAA')).toBe(true);
154
+ // Tail should be present
155
+ expect(truncated.endsWith('AAAA')).toBe(true);
156
+ });
157
+
158
+ it('preserves 60/40 head/tail ratio', () => {
159
+ const longText = 'H'.repeat(500) + 'T'.repeat(500);
160
+ const maxChars = 200;
161
+ const truncated = MultiAgentGraph.truncateDelegateResult(longText, maxChars);
162
+
163
+ const notice = '\n\n[... delegate output truncated — middle section omitted to fit parent context ...]\n\n';
164
+ const available = maxChars - notice.length;
165
+ const expectedHead = Math.floor(available * 0.6);
166
+ const expectedTail = available - expectedHead;
167
+
168
+ // Head portion should be all H's
169
+ const headPortion = truncated.substring(0, expectedHead);
170
+ expect(headPortion).toMatch(/^H+$/);
171
+
172
+ // Tail portion should be all T's
173
+ const tailPortion = truncated.substring(truncated.length - expectedTail);
174
+ expect(tailPortion).toMatch(/^T+$/);
175
+ });
176
+
177
+ it('handles result exactly at maxChars boundary', () => {
178
+ const result = 'X'.repeat(100);
179
+ expect(MultiAgentGraph.truncateDelegateResult(result, 100)).toBe(result);
180
+ });
181
+
182
+ it('handles very small maxChars gracefully', () => {
183
+ const result = 'A'.repeat(200);
184
+ // When maxChars is smaller than the truncation notice itself
185
+ const truncated = MultiAgentGraph.truncateDelegateResult(result, 10);
186
+ expect(truncated.length).toBeLessThanOrEqual(200);
187
+ });
188
+ });
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Tool naming convention
192
+ // ---------------------------------------------------------------------------
193
+ describe('delegate tool naming', () => {
194
+ it('delegate tool name follows lc_delegate_to_ pattern', () => {
195
+ const agentId = 'researcher_agent_123';
196
+ const expectedToolName = `${Constants.LC_DELEGATE_TO_}${agentId}`;
197
+ expect(expectedToolName).toBe('lc_delegate_to_researcher_agent_123');
198
+ });
199
+
200
+ it('delegate tool prefix is distinct from transfer tool prefix', () => {
201
+ const agentId = 'test';
202
+ const delegateName = `${Constants.LC_DELEGATE_TO_}${agentId}`;
203
+ const transferName = `${Constants.LC_TRANSFER_TO_}${agentId}`;
204
+ expect(delegateName).not.toBe(transferName);
205
+ expect(delegateName).toBe('lc_delegate_to_test');
206
+ expect(transferName).toBe('lc_transfer_to_test');
207
+ });
208
+ });
@@ -19,12 +19,16 @@ import type { GraphEdge, BaseGraphState } from '@/types';
19
19
  function categorizeEdges(edges: GraphEdge[]): {
20
20
  directEdges: GraphEdge[];
21
21
  handoffEdges: GraphEdge[];
22
+ delegateEdges: GraphEdge[];
22
23
  } {
23
24
  const directEdges: GraphEdge[] = [];
24
25
  const handoffEdges: GraphEdge[] = [];
26
+ const delegateEdges: GraphEdge[] = [];
25
27
 
26
28
  for (const edge of edges) {
27
- if (edge.edgeType === EdgeType.DIRECT) {
29
+ if (edge.edgeType === EdgeType.DELEGATE) {
30
+ delegateEdges.push(edge);
31
+ } else if (edge.edgeType === EdgeType.DIRECT) {
28
32
  directEdges.push(edge);
29
33
  } else if (edge.edgeType === EdgeType.HANDOFF || edge.condition != null) {
30
34
  handoffEdges.push(edge);
@@ -40,7 +44,7 @@ function categorizeEdges(edges: GraphEdge[]): {
40
44
  }
41
45
  }
42
46
 
43
- return { directEdges, handoffEdges };
47
+ return { directEdges, handoffEdges, delegateEdges };
44
48
  }
45
49
 
46
50
  /**
@@ -149,9 +153,40 @@ describe('edge categorization', () => {
149
153
  });
150
154
 
151
155
  it('handles empty edges array', () => {
152
- const { directEdges, handoffEdges } = categorizeEdges([]);
156
+ const { directEdges, handoffEdges, delegateEdges } = categorizeEdges([]);
153
157
  expect(directEdges).toHaveLength(0);
154
158
  expect(handoffEdges).toHaveLength(0);
159
+ expect(delegateEdges).toHaveLength(0);
160
+ });
161
+
162
+ it('classifies explicit EdgeType.DELEGATE as delegate', () => {
163
+ const edges: GraphEdge[] = [
164
+ { from: 'supervisor', to: 'researcher', edgeType: EdgeType.DELEGATE },
165
+ ];
166
+ const { directEdges, handoffEdges, delegateEdges } = categorizeEdges(edges);
167
+ expect(delegateEdges).toHaveLength(1);
168
+ expect(handoffEdges).toHaveLength(0);
169
+ expect(directEdges).toHaveLength(0);
170
+ });
171
+
172
+ it('supports mixed handoff, direct, and delegate edges from same source', () => {
173
+ const edges: GraphEdge[] = [
174
+ { from: 'supervisor', to: 'researcher', edgeType: EdgeType.DELEGATE },
175
+ { from: 'supervisor', to: 'writer', edgeType: EdgeType.HANDOFF },
176
+ { from: 'supervisor', to: 'formatter', edgeType: EdgeType.DIRECT },
177
+ ];
178
+ const { directEdges, handoffEdges, delegateEdges } = categorizeEdges(edges);
179
+ expect(delegateEdges).toHaveLength(1);
180
+ expect(handoffEdges).toHaveLength(1);
181
+ expect(directEdges).toHaveLength(1);
182
+ });
183
+
184
+ it('categorization works with delegate string value cast as EdgeType', () => {
185
+ const edges: GraphEdge[] = [
186
+ { from: 'a', to: 'b', edgeType: 'delegate' as EdgeType },
187
+ ];
188
+ const { delegateEdges } = categorizeEdges(edges);
189
+ expect(delegateEdges).toHaveLength(1);
155
190
  });
156
191
  });
157
192
 
@@ -224,6 +259,10 @@ describe('EdgeType values match GraphEdge string literals', () => {
224
259
  expect(EdgeType.DIRECT).toBe('direct');
225
260
  });
226
261
 
262
+ it('DELEGATE matches the string used in MongoDB documents', () => {
263
+ expect(EdgeType.DELEGATE).toBe('delegate');
264
+ });
265
+
227
266
  it('categorization works with string values cast as EdgeType', () => {
228
267
  // Simulates data coming from MongoDB where edgeType is stored as string
229
268
  const edges: GraphEdge[] = [
@@ -0,0 +1,173 @@
1
+ import axios from 'axios';
2
+ import { createSearchAPI } from './search';
3
+
4
+ jest.mock('axios');
5
+ const mockedAxios = axios as jest.Mocked<typeof axios>;
6
+
7
+ describe('createSearchAPI', () => {
8
+ beforeEach(() => {
9
+ jest.clearAllMocks();
10
+ });
11
+
12
+ const mockSerperResponse = {
13
+ data: {
14
+ organic: [
15
+ { position: 1, title: 'Test', link: 'https://example.com', snippet: 'test' },
16
+ ],
17
+ topStories: [],
18
+ images: [],
19
+ videos: [],
20
+ news: [],
21
+ peopleAlsoAsk: [],
22
+ relatedSearches: [],
23
+ },
24
+ };
25
+
26
+ describe('domainBlocklist', () => {
27
+ it('appends -site: operators to query for blocked domains', async () => {
28
+ mockedAxios.post.mockResolvedValueOnce(mockSerperResponse);
29
+
30
+ const api = createSearchAPI({
31
+ searchProvider: 'serper',
32
+ serperApiKey: 'test-key',
33
+ domainBlocklist: ['reddit.com', 'twitter.com'],
34
+ });
35
+
36
+ await api.getSources({ query: 'test query' });
37
+
38
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1);
39
+ const payload = mockedAxios.post.mock.calls[0][1] as { q: string };
40
+ expect(payload.q).toBe('test query -site:reddit.com -site:twitter.com');
41
+ });
42
+
43
+ it('does not modify query when domainBlocklist is empty', async () => {
44
+ mockedAxios.post.mockResolvedValueOnce(mockSerperResponse);
45
+
46
+ const api = createSearchAPI({
47
+ searchProvider: 'serper',
48
+ serperApiKey: 'test-key',
49
+ domainBlocklist: [],
50
+ });
51
+
52
+ await api.getSources({ query: 'test query' });
53
+
54
+ const payload = mockedAxios.post.mock.calls[0][1] as { q: string };
55
+ expect(payload.q).toBe('test query');
56
+ });
57
+ });
58
+
59
+ describe('countryBlocklist', () => {
60
+ it('appends -site:.XX operators to query for blocked country TLDs', async () => {
61
+ mockedAxios.post.mockResolvedValueOnce(mockSerperResponse);
62
+
63
+ const api = createSearchAPI({
64
+ searchProvider: 'serper',
65
+ serperApiKey: 'test-key',
66
+ countryBlocklist: ['ru', 'ir'],
67
+ });
68
+
69
+ await api.getSources({ query: 'test query' });
70
+
71
+ expect(mockedAxios.post).toHaveBeenCalledTimes(1);
72
+ const payload = mockedAxios.post.mock.calls[0][1] as { q: string };
73
+ expect(payload.q).toBe('test query -site:.ru -site:.ir');
74
+ });
75
+
76
+ it('handles country codes with leading dots', async () => {
77
+ mockedAxios.post.mockResolvedValueOnce(mockSerperResponse);
78
+
79
+ const api = createSearchAPI({
80
+ searchProvider: 'serper',
81
+ serperApiKey: 'test-key',
82
+ countryBlocklist: ['.cn', '.ru'],
83
+ });
84
+
85
+ await api.getSources({ query: 'test query' });
86
+
87
+ const payload = mockedAxios.post.mock.calls[0][1] as { q: string };
88
+ expect(payload.q).toBe('test query -site:.cn -site:.ru');
89
+ });
90
+
91
+ it('lowercases country codes', async () => {
92
+ mockedAxios.post.mockResolvedValueOnce(mockSerperResponse);
93
+
94
+ const api = createSearchAPI({
95
+ searchProvider: 'serper',
96
+ serperApiKey: 'test-key',
97
+ countryBlocklist: ['RU', 'IR'],
98
+ });
99
+
100
+ await api.getSources({ query: 'test query' });
101
+
102
+ const payload = mockedAxios.post.mock.calls[0][1] as { q: string };
103
+ expect(payload.q).toBe('test query -site:.ru -site:.ir');
104
+ });
105
+
106
+ it('does not modify query when countryBlocklist is empty', async () => {
107
+ mockedAxios.post.mockResolvedValueOnce(mockSerperResponse);
108
+
109
+ const api = createSearchAPI({
110
+ searchProvider: 'serper',
111
+ serperApiKey: 'test-key',
112
+ countryBlocklist: [],
113
+ });
114
+
115
+ await api.getSources({ query: 'test query' });
116
+
117
+ const payload = mockedAxios.post.mock.calls[0][1] as { q: string };
118
+ expect(payload.q).toBe('test query');
119
+ });
120
+
121
+ it('does not modify query when countryBlocklist is undefined', async () => {
122
+ mockedAxios.post.mockResolvedValueOnce(mockSerperResponse);
123
+
124
+ const api = createSearchAPI({
125
+ searchProvider: 'serper',
126
+ serperApiKey: 'test-key',
127
+ });
128
+
129
+ await api.getSources({ query: 'test query' });
130
+
131
+ const payload = mockedAxios.post.mock.calls[0][1] as { q: string };
132
+ expect(payload.q).toBe('test query');
133
+ });
134
+ });
135
+
136
+ describe('domainBlocklist + countryBlocklist combined', () => {
137
+ it('appends both domain and country exclusions to query', async () => {
138
+ mockedAxios.post.mockResolvedValueOnce(mockSerperResponse);
139
+
140
+ const api = createSearchAPI({
141
+ searchProvider: 'serper',
142
+ serperApiKey: 'test-key',
143
+ domainBlocklist: ['reddit.com'],
144
+ countryBlocklist: ['ru', 'ir'],
145
+ });
146
+
147
+ await api.getSources({ query: 'test query' });
148
+
149
+ const payload = mockedAxios.post.mock.calls[0][1] as { q: string };
150
+ expect(payload.q).toBe('test query -site:reddit.com -site:.ru -site:.ir');
151
+ });
152
+ });
153
+
154
+ describe('searxng provider', () => {
155
+ it('does not apply countryBlocklist to searxng (only serper)', async () => {
156
+ mockedAxios.get.mockResolvedValueOnce({
157
+ data: { results: [], suggestions: [] },
158
+ });
159
+
160
+ const api = createSearchAPI({
161
+ searchProvider: 'searxng',
162
+ searxngInstanceUrl: 'http://localhost:8080',
163
+ countryBlocklist: ['ru', 'ir'],
164
+ });
165
+
166
+ await api.getSources({ query: 'test query' });
167
+
168
+ expect(mockedAxios.get).toHaveBeenCalledTimes(1);
169
+ const params = mockedAxios.get.mock.calls[0][1]?.params as { q: string };
170
+ expect(params.q).toBe('test query');
171
+ });
172
+ });
173
+ });
@@ -367,6 +367,12 @@ export type GraphEdge = {
367
367
  * Only applies when prompt is provided for handoff edges.
368
368
  */
369
369
  promptKey?: string;
370
+ /**
371
+ * For delegate edges: Maximum characters for the result returned to the parent.
372
+ * Uses head/tail truncation (60/40 split) to preserve key findings and conclusions.
373
+ * Defaults to Constants.DEFAULT_DELEGATE_MAX_RESULT_CHARS (32768 chars, ~8192 tokens).
374
+ */
375
+ maxResultChars?: number;
370
376
  };
371
377
 
372
378
  export type MultiAgentGraphInput = StandardGraphInput & {