@illuma-ai/agents 1.1.14 → 1.1.16
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/common/enum.cjs +14 -3
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +304 -106
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/main.cjs +2 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/types/graph.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +12 -4
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +306 -108
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/main.mjs +1 -1
- package/dist/esm/types/graph.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +11 -3
- package/dist/types/graphs/MultiAgentGraph.d.ts +72 -18
- package/dist/types/types/graph.d.ts +17 -5
- package/package.json +1 -1
- package/src/common/__tests__/enum.test.ts +15 -7
- package/src/common/enum.ts +13 -3
- package/src/graphs/MultiAgentGraph.ts +385 -107
- package/src/graphs/__tests__/multi-agent-delegate.test.ts +208 -0
- package/src/graphs/__tests__/multi-agent-edges.test.ts +98 -61
- package/src/scripts/multi-agent-chain.js +1 -1
- package/src/scripts/multi-agent-chain.ts +1 -1
- package/src/scripts/multi-agent-document-review-chain.js +1 -1
- package/src/scripts/multi-agent-document-review-chain.ts +1 -1
- package/src/scripts/multi-agent-hybrid-flow.js +3 -3
- package/src/scripts/multi-agent-hybrid-flow.ts +3 -3
- package/src/scripts/multi-agent-parallel.js +2 -2
- package/src/scripts/multi-agent-parallel.ts +2 -2
- package/src/scripts/multi-agent-sequence.js +2 -2
- package/src/scripts/multi-agent-sequence.ts +2 -2
- package/src/scripts/multi-agent-supervisor.js +5 -5
- package/src/scripts/multi-agent-supervisor.ts +5 -5
- package/src/scripts/poc-multi-agent-comprehensive.ts +7 -7
- package/src/scripts/sequential-full-metadata-test.js +1 -1
- package/src/scripts/sequential-full-metadata-test.ts +1 -1
- package/src/scripts/test-custom-prompt-key.js +3 -3
- package/src/scripts/test-custom-prompt-key.ts +3 -3
- package/src/scripts/test-handoff-input.js +1 -1
- package/src/scripts/test-handoff-input.ts +1 -1
- package/src/scripts/test-handoff-preamble.js +1 -1
- package/src/scripts/test-handoff-preamble.ts +1 -1
- package/src/scripts/test-handoff-steering.js +3 -3
- package/src/scripts/test-handoff-steering.ts +3 -3
- package/src/scripts/test-multi-agent-list-handoff.js +1 -1
- package/src/scripts/test-multi-agent-list-handoff.ts +1 -1
- package/src/scripts/test-parallel-agent-labeling.js +2 -2
- package/src/scripts/test-parallel-agent-labeling.ts +2 -2
- package/src/scripts/test-parallel-handoffs.js +2 -2
- package/src/scripts/test-parallel-handoffs.ts +2 -2
- package/src/scripts/test-thinking-handoff-bedrock.js +1 -1
- package/src/scripts/test-thinking-handoff-bedrock.ts +1 -1
- package/src/scripts/test-thinking-handoff.js +1 -1
- package/src/scripts/test-thinking-handoff.ts +1 -1
- package/src/scripts/test-thinking-to-thinking-handoff-bedrock.js +1 -1
- package/src/scripts/test-thinking-to-thinking-handoff-bedrock.ts +1 -1
- package/src/scripts/test-tool-before-handoff-role-order.js +1 -1
- package/src/scripts/test-tool-before-handoff-role-order.ts +1 -1
- package/src/scripts/test-tools-before-handoff.js +1 -1
- package/src/scripts/test-tools-before-handoff.ts +1 -1
- package/src/specs/agent-handoffs-bedrock.integration.test.ts +6 -6
- package/src/specs/agent-handoffs.test.ts +35 -35
- package/src/specs/thinking-handoff.test.ts +9 -9
- package/src/tools/search/search.test.ts +173 -0
- package/src/types/graph.ts +17 -5
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the handoff pattern (supervisor-delegate) in MultiAgentGraph.
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* - Result extraction from child agent messages (extractHandoffResult)
|
|
6
|
+
* - Result truncation for parent context protection (truncateHandoffResult)
|
|
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_HANDOFF_MAX_RESULT_CHARS,
|
|
15
|
+
HANDOFF_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('handoff constants', () => {
|
|
28
|
+
it('LC_HANDOFF_TO_ prefix matches expected pattern', () => {
|
|
29
|
+
expect(Constants.LC_HANDOFF_TO_).toBe('lc_handoff_to_');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('LC_HANDOFF_TO_ is distinct from LC_TRANSFER_TO_', () => {
|
|
33
|
+
expect(Constants.LC_HANDOFF_TO_).not.toBe(Constants.LC_TRANSFER_TO_);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('DEFAULT_HANDOFF_MAX_RESULT_CHARS is 32768', () => {
|
|
37
|
+
expect(DEFAULT_HANDOFF_MAX_RESULT_CHARS).toBe(32768);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('HANDOFF_TIMEOUT_MS is 5 minutes', () => {
|
|
41
|
+
expect(HANDOFF_TIMEOUT_MS).toBe(300_000);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('EdgeType.HANDOFF has correct value', () => {
|
|
45
|
+
expect(EdgeType.HANDOFF).toBe('handoff');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// extractHandoffResult
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
describe('extractHandoffResult', () => {
|
|
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.extractHandoffResult(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.extractHandoffResult(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.extractHandoffResult(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.extractHandoffResult(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.extractHandoffResult(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.extractHandoffResult(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.extractHandoffResult([], '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.extractHandoffResult(messages, 'agent');
|
|
129
|
+
expect(result).toBe('result with spaces');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// truncateHandoffResult
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
describe('truncateHandoffResult', () => {
|
|
137
|
+
it('returns result unchanged when within budget', () => {
|
|
138
|
+
const result = 'short result';
|
|
139
|
+
expect(MultiAgentGraph.truncateHandoffResult(result, 1000)).toBe(result);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns empty string unchanged', () => {
|
|
143
|
+
expect(MultiAgentGraph.truncateHandoffResult('', 1000)).toBe('');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('truncates with head/tail split when over budget', () => {
|
|
147
|
+
const longText = 'A'.repeat(1000);
|
|
148
|
+
const truncated = MultiAgentGraph.truncateHandoffResult(longText, 500);
|
|
149
|
+
|
|
150
|
+
expect(truncated.length).toBeLessThanOrEqual(500);
|
|
151
|
+
expect(truncated).toContain('handoff 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.truncateHandoffResult(longText, maxChars);
|
|
162
|
+
|
|
163
|
+
const notice = '\n\n[... handoff 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.truncateHandoffResult(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.truncateHandoffResult(result, 10);
|
|
186
|
+
expect(truncated.length).toBeLessThanOrEqual(200);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Tool naming convention
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
describe('handoff tool naming', () => {
|
|
194
|
+
it('handoff tool name follows lc_handoff_to_ pattern', () => {
|
|
195
|
+
const agentId = 'researcher_agent_123';
|
|
196
|
+
const expectedToolName = `${Constants.LC_HANDOFF_TO_}${agentId}`;
|
|
197
|
+
expect(expectedToolName).toBe('lc_handoff_to_researcher_agent_123');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('handoff tool prefix is distinct from transfer tool prefix', () => {
|
|
201
|
+
const agentId = 'test';
|
|
202
|
+
const handoffName = `${Constants.LC_HANDOFF_TO_}${agentId}`;
|
|
203
|
+
const transferName = `${Constants.LC_TRANSFER_TO_}${agentId}`;
|
|
204
|
+
expect(handoffName).not.toBe(transferName);
|
|
205
|
+
expect(handoffName).toBe('lc_handoff_to_test');
|
|
206
|
+
expect(transferName).toBe('lc_transfer_to_test');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* and parallel group computation in MultiAgentGraph.
|
|
4
4
|
*
|
|
5
5
|
* These test the edge classification rules without instantiating the full graph:
|
|
6
|
-
* - Explicit EdgeType.
|
|
7
|
-
* - Explicit EdgeType.
|
|
8
|
-
* - Condition-based edges →
|
|
9
|
-
* - Default single→single →
|
|
10
|
-
* - Default single→many →
|
|
6
|
+
* - Explicit EdgeType.TRANSFER → transfer
|
|
7
|
+
* - Explicit EdgeType.SEQUENCE → sequence
|
|
8
|
+
* - Condition-based edges → transfer (always, regardless of edgeType)
|
|
9
|
+
* - Default single→single → transfer
|
|
10
|
+
* - Default single→many → sequence (fan-out pattern)
|
|
11
11
|
*/
|
|
12
12
|
import { EdgeType } from '@/common';
|
|
13
13
|
import type { GraphEdge, BaseGraphState } from '@/types';
|
|
@@ -17,30 +17,34 @@ import type { GraphEdge, BaseGraphState } from '@/types';
|
|
|
17
17
|
* Kept in sync with the private method for testability.
|
|
18
18
|
*/
|
|
19
19
|
function categorizeEdges(edges: GraphEdge[]): {
|
|
20
|
-
|
|
20
|
+
sequenceEdges: GraphEdge[];
|
|
21
|
+
transferEdges: GraphEdge[];
|
|
21
22
|
handoffEdges: GraphEdge[];
|
|
22
23
|
} {
|
|
23
|
-
const
|
|
24
|
+
const sequenceEdges: GraphEdge[] = [];
|
|
25
|
+
const transferEdges: GraphEdge[] = [];
|
|
24
26
|
const handoffEdges: GraphEdge[] = [];
|
|
25
27
|
|
|
26
28
|
for (const edge of edges) {
|
|
27
|
-
if (edge.edgeType === EdgeType.
|
|
28
|
-
directEdges.push(edge);
|
|
29
|
-
} else if (edge.edgeType === EdgeType.HANDOFF || edge.condition != null) {
|
|
29
|
+
if (edge.edgeType === EdgeType.HANDOFF) {
|
|
30
30
|
handoffEdges.push(edge);
|
|
31
|
+
} else if (edge.edgeType === EdgeType.SEQUENCE) {
|
|
32
|
+
sequenceEdges.push(edge);
|
|
33
|
+
} else if (edge.edgeType === EdgeType.TRANSFER || edge.condition != null) {
|
|
34
|
+
transferEdges.push(edge);
|
|
31
35
|
} else {
|
|
32
36
|
const destinations = Array.isArray(edge.to) ? edge.to : [edge.to];
|
|
33
37
|
const sources = Array.isArray(edge.from) ? edge.from : [edge.from];
|
|
34
38
|
|
|
35
39
|
if (sources.length === 1 && destinations.length > 1) {
|
|
36
|
-
|
|
40
|
+
sequenceEdges.push(edge);
|
|
37
41
|
} else {
|
|
38
|
-
|
|
42
|
+
transferEdges.push(edge);
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
return {
|
|
47
|
+
return { sequenceEdges, transferEdges, handoffEdges };
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
/**
|
|
@@ -75,84 +79,115 @@ function findStartingNodes(
|
|
|
75
79
|
// Edge categorization
|
|
76
80
|
// ---------------------------------------------------------------------------
|
|
77
81
|
describe('edge categorization', () => {
|
|
78
|
-
it('classifies explicit EdgeType.
|
|
82
|
+
it('classifies explicit EdgeType.TRANSFER as transfer', () => {
|
|
79
83
|
const edges: GraphEdge[] = [
|
|
80
|
-
{ from: 'a', to: 'b', edgeType: EdgeType.
|
|
84
|
+
{ from: 'a', to: 'b', edgeType: EdgeType.TRANSFER },
|
|
81
85
|
];
|
|
82
|
-
const {
|
|
83
|
-
expect(
|
|
84
|
-
expect(
|
|
86
|
+
const { sequenceEdges, transferEdges } = categorizeEdges(edges);
|
|
87
|
+
expect(transferEdges).toHaveLength(1);
|
|
88
|
+
expect(sequenceEdges).toHaveLength(0);
|
|
85
89
|
});
|
|
86
90
|
|
|
87
|
-
it('classifies explicit EdgeType.
|
|
91
|
+
it('classifies explicit EdgeType.SEQUENCE as sequence', () => {
|
|
88
92
|
const edges: GraphEdge[] = [
|
|
89
|
-
{ from: 'a', to: 'b', edgeType: EdgeType.
|
|
93
|
+
{ from: 'a', to: 'b', edgeType: EdgeType.SEQUENCE },
|
|
90
94
|
];
|
|
91
|
-
const {
|
|
92
|
-
expect(
|
|
93
|
-
expect(
|
|
95
|
+
const { sequenceEdges, transferEdges } = categorizeEdges(edges);
|
|
96
|
+
expect(sequenceEdges).toHaveLength(1);
|
|
97
|
+
expect(transferEdges).toHaveLength(0);
|
|
94
98
|
});
|
|
95
99
|
|
|
96
|
-
it('classifies condition-based edges as
|
|
100
|
+
it('classifies condition-based edges as transfer regardless of edgeType', () => {
|
|
97
101
|
const condition = (_state: BaseGraphState) => true;
|
|
98
102
|
const edges: GraphEdge[] = [
|
|
99
103
|
{ from: 'a', to: 'b', condition },
|
|
100
|
-
{ from: 'c', to: 'd', condition, edgeType: EdgeType.
|
|
104
|
+
{ from: 'c', to: 'd', condition, edgeType: EdgeType.TRANSFER },
|
|
101
105
|
];
|
|
102
|
-
const {
|
|
103
|
-
expect(
|
|
104
|
-
expect(
|
|
106
|
+
const { sequenceEdges, transferEdges } = categorizeEdges(edges);
|
|
107
|
+
expect(transferEdges).toHaveLength(2);
|
|
108
|
+
expect(sequenceEdges).toHaveLength(0);
|
|
105
109
|
});
|
|
106
110
|
|
|
107
|
-
it('defaults single→single edges to
|
|
111
|
+
it('defaults single→single edges to transfer', () => {
|
|
108
112
|
const edges: GraphEdge[] = [{ from: 'supervisor', to: 'worker' }];
|
|
109
|
-
const {
|
|
110
|
-
expect(
|
|
111
|
-
expect(
|
|
113
|
+
const { sequenceEdges, transferEdges } = categorizeEdges(edges);
|
|
114
|
+
expect(transferEdges).toHaveLength(1);
|
|
115
|
+
expect(sequenceEdges).toHaveLength(0);
|
|
112
116
|
});
|
|
113
117
|
|
|
114
|
-
it('defaults single→many edges to
|
|
118
|
+
it('defaults single→many edges to sequence (fan-out pattern)', () => {
|
|
115
119
|
const edges: GraphEdge[] = [
|
|
116
120
|
{ from: 'coordinator', to: ['analyst1', 'analyst2', 'analyst3'] },
|
|
117
121
|
];
|
|
118
|
-
const {
|
|
119
|
-
expect(
|
|
120
|
-
expect(
|
|
122
|
+
const { sequenceEdges, transferEdges } = categorizeEdges(edges);
|
|
123
|
+
expect(sequenceEdges).toHaveLength(1);
|
|
124
|
+
expect(transferEdges).toHaveLength(0);
|
|
121
125
|
});
|
|
122
126
|
|
|
123
|
-
it('defaults many→single edges to
|
|
127
|
+
it('defaults many→single edges to transfer', () => {
|
|
124
128
|
const edges: GraphEdge[] = [
|
|
125
129
|
{ from: ['analyst1', 'analyst2'], to: 'summarizer' },
|
|
126
130
|
];
|
|
127
|
-
const {
|
|
128
|
-
expect(
|
|
129
|
-
expect(
|
|
131
|
+
const { sequenceEdges, transferEdges } = categorizeEdges(edges);
|
|
132
|
+
expect(transferEdges).toHaveLength(1);
|
|
133
|
+
expect(sequenceEdges).toHaveLength(0);
|
|
130
134
|
});
|
|
131
135
|
|
|
132
136
|
it('correctly categorizes a mixed set of edges', () => {
|
|
133
137
|
const edges: GraphEdge[] = [
|
|
134
|
-
// Explicit
|
|
135
|
-
{ from: 'researcher', to: 'analyst', edgeType: EdgeType.
|
|
136
|
-
// Fan-out: defaults to
|
|
138
|
+
// Explicit sequence: sequential chain
|
|
139
|
+
{ from: 'researcher', to: 'analyst', edgeType: EdgeType.SEQUENCE },
|
|
140
|
+
// Fan-out: defaults to sequence
|
|
137
141
|
{ from: 'analyst', to: ['reviewer1', 'reviewer2'] },
|
|
138
|
-
// Explicit
|
|
139
|
-
{ from: 'supervisor', to: 'specialist', edgeType: EdgeType.
|
|
140
|
-
// Default single→single:
|
|
142
|
+
// Explicit transfer: dynamic routing
|
|
143
|
+
{ from: 'supervisor', to: 'specialist', edgeType: EdgeType.TRANSFER },
|
|
144
|
+
// Default single→single: transfer
|
|
141
145
|
{ from: 'triage', to: 'handler' },
|
|
142
|
-
// Condition-based: always
|
|
146
|
+
// Condition-based: always transfer
|
|
143
147
|
{ from: 'router', to: 'a', condition: () => true },
|
|
144
148
|
];
|
|
145
149
|
|
|
146
|
-
const {
|
|
147
|
-
expect(
|
|
148
|
-
expect(
|
|
150
|
+
const { sequenceEdges, transferEdges } = categorizeEdges(edges);
|
|
151
|
+
expect(sequenceEdges).toHaveLength(2); // explicit sequence + fan-out
|
|
152
|
+
expect(transferEdges).toHaveLength(3); // explicit transfer + default single→single + condition
|
|
149
153
|
});
|
|
150
154
|
|
|
151
155
|
it('handles empty edges array', () => {
|
|
152
|
-
const {
|
|
153
|
-
expect(
|
|
156
|
+
const { sequenceEdges, transferEdges, handoffEdges } = categorizeEdges([]);
|
|
157
|
+
expect(sequenceEdges).toHaveLength(0);
|
|
158
|
+
expect(transferEdges).toHaveLength(0);
|
|
154
159
|
expect(handoffEdges).toHaveLength(0);
|
|
155
160
|
});
|
|
161
|
+
|
|
162
|
+
it('classifies explicit EdgeType.HANDOFF as handoff', () => {
|
|
163
|
+
const edges: GraphEdge[] = [
|
|
164
|
+
{ from: 'supervisor', to: 'researcher', edgeType: EdgeType.HANDOFF },
|
|
165
|
+
];
|
|
166
|
+
const { sequenceEdges, transferEdges, handoffEdges } = categorizeEdges(edges);
|
|
167
|
+
expect(handoffEdges).toHaveLength(1);
|
|
168
|
+
expect(transferEdges).toHaveLength(0);
|
|
169
|
+
expect(sequenceEdges).toHaveLength(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('supports mixed transfer, sequence, and handoff edges from same source', () => {
|
|
173
|
+
const edges: GraphEdge[] = [
|
|
174
|
+
{ from: 'supervisor', to: 'researcher', edgeType: EdgeType.HANDOFF },
|
|
175
|
+
{ from: 'supervisor', to: 'writer', edgeType: EdgeType.TRANSFER },
|
|
176
|
+
{ from: 'supervisor', to: 'formatter', edgeType: EdgeType.SEQUENCE },
|
|
177
|
+
];
|
|
178
|
+
const { sequenceEdges, transferEdges, handoffEdges } = categorizeEdges(edges);
|
|
179
|
+
expect(handoffEdges).toHaveLength(1);
|
|
180
|
+
expect(transferEdges).toHaveLength(1);
|
|
181
|
+
expect(sequenceEdges).toHaveLength(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('categorization works with handoff string value cast as EdgeType', () => {
|
|
185
|
+
const edges: GraphEdge[] = [
|
|
186
|
+
{ from: 'a', to: 'b', edgeType: 'handoff' as EdgeType },
|
|
187
|
+
];
|
|
188
|
+
const { handoffEdges } = categorizeEdges(edges);
|
|
189
|
+
expect(handoffEdges).toHaveLength(1);
|
|
190
|
+
});
|
|
156
191
|
});
|
|
157
192
|
|
|
158
193
|
// ---------------------------------------------------------------------------
|
|
@@ -215,23 +250,25 @@ describe('starting node identification', () => {
|
|
|
215
250
|
// ---------------------------------------------------------------------------
|
|
216
251
|
describe('EdgeType values match GraphEdge string literals', () => {
|
|
217
252
|
it('HANDOFF matches the string used in MongoDB documents', () => {
|
|
218
|
-
// ranger stores edges in MongoDB with string values
|
|
219
|
-
// This ensures the enum stays compatible
|
|
220
253
|
expect(EdgeType.HANDOFF).toBe('handoff');
|
|
221
254
|
});
|
|
222
255
|
|
|
223
|
-
it('
|
|
224
|
-
expect(EdgeType.
|
|
256
|
+
it('TRANSFER matches the string used in MongoDB documents', () => {
|
|
257
|
+
expect(EdgeType.TRANSFER).toBe('transfer');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('SEQUENCE matches the string used in MongoDB documents', () => {
|
|
261
|
+
expect(EdgeType.SEQUENCE).toBe('sequence');
|
|
225
262
|
});
|
|
226
263
|
|
|
227
264
|
it('categorization works with string values cast as EdgeType', () => {
|
|
228
265
|
// Simulates data coming from MongoDB where edgeType is stored as string
|
|
229
266
|
const edges: GraphEdge[] = [
|
|
230
|
-
{ from: 'a', to: 'b', edgeType: '
|
|
231
|
-
{ from: 'c', to: 'd', edgeType: '
|
|
267
|
+
{ from: 'a', to: 'b', edgeType: 'transfer' as EdgeType },
|
|
268
|
+
{ from: 'c', to: 'd', edgeType: 'sequence' as EdgeType },
|
|
232
269
|
];
|
|
233
|
-
const {
|
|
234
|
-
expect(
|
|
235
|
-
expect(
|
|
270
|
+
const { sequenceEdges, transferEdges } = categorizeEdges(edges);
|
|
271
|
+
expect(transferEdges).toHaveLength(1);
|
|
272
|
+
expect(sequenceEdges).toHaveLength(1);
|
|
236
273
|
});
|
|
237
274
|
});
|
|
@@ -19,7 +19,7 @@ function createSequentialChainEdges(agentIds) {
|
|
|
19
19
|
edges.push({
|
|
20
20
|
from: fromAgent,
|
|
21
21
|
to: toAgent,
|
|
22
|
-
edgeType: '
|
|
22
|
+
edgeType: 'sequence',
|
|
23
23
|
// Use a prompt function to create the buffer string from all previous results
|
|
24
24
|
prompt: (messages, startIndex) => {
|
|
25
25
|
// Get only the messages from this run (after startIndex)
|
|
@@ -28,7 +28,7 @@ function createSequentialChainEdges(agentIds: string[]): t.GraphEdge[] {
|
|
|
28
28
|
edges.push({
|
|
29
29
|
from: fromAgent,
|
|
30
30
|
to: toAgent,
|
|
31
|
-
edgeType: '
|
|
31
|
+
edgeType: 'sequence',
|
|
32
32
|
// Use a prompt function to create the buffer string from all previous results
|
|
33
33
|
prompt: (messages: BaseMessage[], startIndex: number) => {
|
|
34
34
|
// Get only the messages from this run (after startIndex)
|
|
@@ -13,7 +13,7 @@ function createDocumentReviewChain(agentIds) {
|
|
|
13
13
|
edges.push({
|
|
14
14
|
from: agentIds[i],
|
|
15
15
|
to: agentIds[i + 1],
|
|
16
|
-
edgeType: '
|
|
16
|
+
edgeType: 'sequence',
|
|
17
17
|
prompt: (messages, startIndex) => {
|
|
18
18
|
const runMessages = messages.slice(startIndex);
|
|
19
19
|
const bufferString = getBufferString(runMessages);
|
|
@@ -22,7 +22,7 @@ function createDocumentReviewChain(agentIds: string[]): t.GraphEdge[] {
|
|
|
22
22
|
edges.push({
|
|
23
23
|
from: agentIds[i],
|
|
24
24
|
to: agentIds[i + 1],
|
|
25
|
-
edgeType: '
|
|
25
|
+
edgeType: 'sequence',
|
|
26
26
|
prompt: (messages: BaseMessage[], startIndex: number) => {
|
|
27
27
|
const runMessages = messages.slice(startIndex);
|
|
28
28
|
const bufferString = getBufferString(runMessages);
|
|
@@ -102,7 +102,7 @@ async function testHybridMultiAgent() {
|
|
|
102
102
|
{
|
|
103
103
|
from: 'primary_agent',
|
|
104
104
|
to: 'standalone_agent',
|
|
105
|
-
edgeType: '
|
|
105
|
+
edgeType: 'transfer',
|
|
106
106
|
description: 'Transfer to standalone specialist for complex requests',
|
|
107
107
|
prompt: 'Specific instructions for the specialist',
|
|
108
108
|
},
|
|
@@ -110,14 +110,14 @@ async function testHybridMultiAgent() {
|
|
|
110
110
|
{
|
|
111
111
|
from: 'primary_agent',
|
|
112
112
|
to: 'agent_b',
|
|
113
|
-
edgeType: '
|
|
113
|
+
edgeType: 'sequence',
|
|
114
114
|
description: 'Continue to Agent B only if no handoff occurs',
|
|
115
115
|
},
|
|
116
116
|
// Direct edge: agent_b automatically continues to agent_c
|
|
117
117
|
{
|
|
118
118
|
from: 'agent_b',
|
|
119
119
|
to: 'agent_c',
|
|
120
|
-
edgeType: '
|
|
120
|
+
edgeType: 'sequence',
|
|
121
121
|
description: 'Automatic progression from B to C',
|
|
122
122
|
},
|
|
123
123
|
];
|
|
@@ -108,7 +108,7 @@ async function testHybridMultiAgent() {
|
|
|
108
108
|
{
|
|
109
109
|
from: 'primary_agent',
|
|
110
110
|
to: 'standalone_agent',
|
|
111
|
-
edgeType: '
|
|
111
|
+
edgeType: 'transfer',
|
|
112
112
|
description: 'Transfer to standalone specialist for complex requests',
|
|
113
113
|
prompt: 'Specific instructions for the specialist',
|
|
114
114
|
},
|
|
@@ -116,14 +116,14 @@ async function testHybridMultiAgent() {
|
|
|
116
116
|
{
|
|
117
117
|
from: 'primary_agent',
|
|
118
118
|
to: 'agent_b',
|
|
119
|
-
edgeType: '
|
|
119
|
+
edgeType: 'sequence',
|
|
120
120
|
description: 'Continue to Agent B only if no handoff occurs',
|
|
121
121
|
},
|
|
122
122
|
// Direct edge: agent_b automatically continues to agent_c
|
|
123
123
|
{
|
|
124
124
|
from: 'agent_b',
|
|
125
125
|
to: 'agent_c',
|
|
126
|
-
edgeType: '
|
|
126
|
+
edgeType: 'sequence',
|
|
127
127
|
description: 'Automatic progression from B to C',
|
|
128
128
|
},
|
|
129
129
|
];
|
|
@@ -152,13 +152,13 @@ async function testParallelMultiAgent() {
|
|
|
152
152
|
from: 'researcher',
|
|
153
153
|
to: ['analyst1', 'analyst2', 'analyst3'], // Fan-out to multiple analysts
|
|
154
154
|
description: 'Distribute research to specialist analysts',
|
|
155
|
-
edgeType: '
|
|
155
|
+
edgeType: 'sequence', // Explicitly set as direct for automatic transition (enables parallel execution)
|
|
156
156
|
},
|
|
157
157
|
{
|
|
158
158
|
from: ['analyst1', 'analyst2', 'analyst3'], // Fan-in from multiple sources
|
|
159
159
|
to: 'summarizer',
|
|
160
160
|
description: 'Aggregate analysis results',
|
|
161
|
-
edgeType: '
|
|
161
|
+
edgeType: 'sequence', // Fan-in is also direct
|
|
162
162
|
// Add prompt when all analysts have provided input
|
|
163
163
|
// prompt: (messages, runStartIndex) => {
|
|
164
164
|
// // Check if we have analysis content from all three analysts
|
|
@@ -160,13 +160,13 @@ async function testParallelMultiAgent() {
|
|
|
160
160
|
from: 'researcher',
|
|
161
161
|
to: ['analyst1', 'analyst2', 'analyst3'], // Fan-out to multiple analysts
|
|
162
162
|
description: 'Distribute research to specialist analysts',
|
|
163
|
-
edgeType: '
|
|
163
|
+
edgeType: 'sequence', // Explicitly set as direct for automatic transition (enables parallel execution)
|
|
164
164
|
},
|
|
165
165
|
{
|
|
166
166
|
from: ['analyst1', 'analyst2', 'analyst3'], // Fan-in from multiple sources
|
|
167
167
|
to: 'summarizer',
|
|
168
168
|
description: 'Aggregate analysis results',
|
|
169
|
-
edgeType: '
|
|
169
|
+
edgeType: 'sequence', // Fan-in is also direct
|
|
170
170
|
// Add prompt when all analysts have provided input
|
|
171
171
|
// prompt: (messages, runStartIndex) => {
|
|
172
172
|
// // Check if we have analysis content from all three analysts
|
|
@@ -75,13 +75,13 @@ async function testSequentialMultiAgent() {
|
|
|
75
75
|
{
|
|
76
76
|
from: 'agent_a',
|
|
77
77
|
to: 'agent_b',
|
|
78
|
-
edgeType: '
|
|
78
|
+
edgeType: 'sequence', // This creates direct edges without tools
|
|
79
79
|
description: 'Automatic transition from A to B',
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
from: 'agent_b',
|
|
83
83
|
to: 'agent_c',
|
|
84
|
-
edgeType: '
|
|
84
|
+
edgeType: 'sequence', // This creates direct edges without tools
|
|
85
85
|
description: 'Automatic transition from B to C',
|
|
86
86
|
},
|
|
87
87
|
];
|
|
@@ -82,13 +82,13 @@ async function testSequentialMultiAgent() {
|
|
|
82
82
|
{
|
|
83
83
|
from: 'agent_a',
|
|
84
84
|
to: 'agent_b',
|
|
85
|
-
edgeType: '
|
|
85
|
+
edgeType: 'sequence', // This creates direct edges without tools
|
|
86
86
|
description: 'Automatic transition from A to B',
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
from: 'agent_b',
|
|
90
90
|
to: 'agent_c',
|
|
91
|
-
edgeType: '
|
|
91
|
+
edgeType: 'sequence', // This creates direct edges without tools
|
|
92
92
|
description: 'Automatic transition from B to C',
|
|
93
93
|
},
|
|
94
94
|
];
|
|
@@ -228,31 +228,31 @@ async function testSupervisorMultiAgent() {
|
|
|
228
228
|
from: 'supervisor',
|
|
229
229
|
to: 'data_analyst',
|
|
230
230
|
description: 'Transfer to data analyst for statistical analysis and metrics',
|
|
231
|
-
edgeType: '
|
|
231
|
+
edgeType: 'transfer',
|
|
232
232
|
},
|
|
233
233
|
{
|
|
234
234
|
from: 'supervisor',
|
|
235
235
|
to: 'security_expert',
|
|
236
236
|
description: 'Transfer to security expert for cybersecurity assessment',
|
|
237
|
-
edgeType: '
|
|
237
|
+
edgeType: 'transfer',
|
|
238
238
|
},
|
|
239
239
|
{
|
|
240
240
|
from: 'supervisor',
|
|
241
241
|
to: 'product_designer',
|
|
242
242
|
description: 'Transfer to product designer for UX/UI design',
|
|
243
|
-
edgeType: '
|
|
243
|
+
edgeType: 'transfer',
|
|
244
244
|
},
|
|
245
245
|
{
|
|
246
246
|
from: 'supervisor',
|
|
247
247
|
to: 'devops_engineer',
|
|
248
248
|
description: 'Transfer to DevOps engineer for infrastructure and deployment',
|
|
249
|
-
edgeType: '
|
|
249
|
+
edgeType: 'transfer',
|
|
250
250
|
},
|
|
251
251
|
{
|
|
252
252
|
from: 'supervisor',
|
|
253
253
|
to: 'legal_advisor',
|
|
254
254
|
description: 'Transfer to legal advisor for compliance and licensing',
|
|
255
|
-
edgeType: '
|
|
255
|
+
edgeType: 'transfer',
|
|
256
256
|
},
|
|
257
257
|
];
|
|
258
258
|
return {
|