@illuma-ai/agents 1.1.24 → 1.1.28

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 (45) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +20 -3
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +6 -0
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/graphs/HandoffRegistry.cjs +104 -0
  6. package/dist/cjs/graphs/HandoffRegistry.cjs.map +1 -0
  7. package/dist/cjs/graphs/MultiAgentGraph.cjs +224 -47
  8. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  9. package/dist/cjs/main.cjs +2 -0
  10. package/dist/cjs/main.cjs.map +1 -1
  11. package/dist/cjs/stream.cjs +4 -4
  12. package/dist/cjs/stream.cjs.map +1 -1
  13. package/dist/cjs/types/graph.cjs.map +1 -1
  14. package/dist/cjs/utils/events.cjs +3 -0
  15. package/dist/cjs/utils/events.cjs.map +1 -1
  16. package/dist/esm/agents/AgentContext.mjs +20 -3
  17. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  18. package/dist/esm/graphs/Graph.mjs +6 -0
  19. package/dist/esm/graphs/Graph.mjs.map +1 -1
  20. package/dist/esm/graphs/HandoffRegistry.mjs +102 -0
  21. package/dist/esm/graphs/HandoffRegistry.mjs.map +1 -0
  22. package/dist/esm/graphs/MultiAgentGraph.mjs +224 -47
  23. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  24. package/dist/esm/main.mjs +1 -0
  25. package/dist/esm/main.mjs.map +1 -1
  26. package/dist/esm/stream.mjs +4 -4
  27. package/dist/esm/stream.mjs.map +1 -1
  28. package/dist/esm/types/graph.mjs.map +1 -1
  29. package/dist/esm/utils/events.mjs +3 -0
  30. package/dist/esm/utils/events.mjs.map +1 -1
  31. package/dist/types/graphs/HandoffRegistry.d.ts +80 -0
  32. package/dist/types/graphs/MultiAgentGraph.d.ts +23 -3
  33. package/dist/types/graphs/index.d.ts +1 -0
  34. package/dist/types/types/graph.d.ts +6 -0
  35. package/package.json +1 -1
  36. package/src/agents/AgentContext.ts +20 -5
  37. package/src/graphs/Graph.ts +11 -0
  38. package/src/graphs/HandoffRegistry.ts +168 -0
  39. package/src/graphs/MultiAgentGraph.ts +274 -67
  40. package/src/graphs/__tests__/HandoffRegistry.test.ts +407 -0
  41. package/src/graphs/index.ts +1 -0
  42. package/src/stream.ts +4 -6
  43. package/src/tools/approval/__tests__/constants.test.ts +3 -3
  44. package/src/types/graph.ts +6 -0
  45. package/src/utils/events.ts +3 -0
@@ -0,0 +1,407 @@
1
+ import { HandoffRegistry } from '../HandoffRegistry';
2
+ import type { BaseGraphState } from '@/types';
3
+
4
+ /** Helper to create a resolved promise simulating a child agent completion */
5
+ function createChildPromise(
6
+ resultText: string,
7
+ delayMs = 0
8
+ ): Promise<BaseGraphState> {
9
+ return new Promise((resolve) => {
10
+ const fn = () =>
11
+ resolve({
12
+ messages: [
13
+ {
14
+ getType: () => 'ai',
15
+ content: resultText,
16
+ } as any,
17
+ ],
18
+ });
19
+ if (delayMs > 0) {
20
+ setTimeout(fn, delayMs);
21
+ } else {
22
+ fn();
23
+ }
24
+ });
25
+ }
26
+
27
+ /** Helper to create a failing promise */
28
+ function createFailingPromise(
29
+ errorMsg: string,
30
+ delayMs = 0
31
+ ): Promise<BaseGraphState> {
32
+ return new Promise((_resolve, reject) => {
33
+ const fn = () => reject(new Error(errorMsg));
34
+ if (delayMs > 0) {
35
+ setTimeout(fn, delayMs);
36
+ } else {
37
+ fn();
38
+ }
39
+ });
40
+ }
41
+
42
+ /** Stub extractResult — returns content string from last AI message */
43
+ function extractResult(messages: any[], _agentId: string): string {
44
+ for (let i = messages.length - 1; i >= 0; i--) {
45
+ if (messages[i].getType() === 'ai' && typeof messages[i].content === 'string') {
46
+ return messages[i].content;
47
+ }
48
+ }
49
+ return '[no result]';
50
+ }
51
+
52
+ /** Stub truncateResult — pass through */
53
+ function truncateResult(text: string, _maxChars: number): string {
54
+ return text;
55
+ }
56
+
57
+ describe('HandoffRegistry', () => {
58
+ let registry: HandoffRegistry;
59
+
60
+ beforeEach(() => {
61
+ registry = new HandoffRegistry();
62
+ });
63
+
64
+ describe('spawn', () => {
65
+ it('should register a new handoff record', () => {
66
+ const promise = createChildPromise('test result');
67
+
68
+ registry.spawn({
69
+ id: 'agent-1',
70
+ name: 'Researcher',
71
+ task: 'Find information',
72
+ promise,
73
+ extractResult,
74
+ truncateResult,
75
+ maxResultChars: 5000,
76
+ });
77
+
78
+ expect(registry.size).toBe(1);
79
+ expect(registry.hasPending()).toBe(true);
80
+
81
+ const record = registry.get('agent-1');
82
+ expect(record).toBeDefined();
83
+ expect(record!.name).toBe('Researcher');
84
+ expect(record!.task).toBe('Find information');
85
+ expect(record!.status).toBe('running');
86
+ });
87
+
88
+ it('should update record on completion', async () => {
89
+ const promise = createChildPromise('research findings');
90
+
91
+ registry.spawn({
92
+ id: 'agent-1',
93
+ name: 'Researcher',
94
+ task: 'Find info',
95
+ promise,
96
+ extractResult,
97
+ truncateResult,
98
+ maxResultChars: 5000,
99
+ });
100
+
101
+ // Wait for promise to resolve
102
+ await promise;
103
+ // Yield to let .then handler run
104
+ await new Promise((r) => setTimeout(r, 0));
105
+
106
+ const record = registry.get('agent-1')!;
107
+ expect(record.status).toBe('completed');
108
+ expect(record.resultText).toBe('research findings');
109
+ expect(record.durationMs).toBeGreaterThanOrEqual(0);
110
+ });
111
+
112
+ it('should update record on failure', async () => {
113
+ const promise = createFailingPromise('timeout');
114
+
115
+ registry.spawn({
116
+ id: 'agent-1',
117
+ name: 'Researcher',
118
+ task: 'Find info',
119
+ promise,
120
+ extractResult,
121
+ truncateResult,
122
+ maxResultChars: 5000,
123
+ });
124
+
125
+ try {
126
+ await promise;
127
+ } catch {
128
+ // expected
129
+ }
130
+ await new Promise((r) => setTimeout(r, 0));
131
+
132
+ const record = registry.get('agent-1')!;
133
+ expect(record.status).toBe('failed');
134
+ expect(record.error).toBe('timeout');
135
+ });
136
+
137
+ it('should call onComplete callback on completion', async () => {
138
+ const onComplete = jest.fn();
139
+ const promise = createChildPromise('done');
140
+
141
+ registry.spawn({
142
+ id: 'agent-1',
143
+ name: 'Worker',
144
+ task: 'Do work',
145
+ promise,
146
+ extractResult,
147
+ truncateResult,
148
+ maxResultChars: 5000,
149
+ onComplete,
150
+ });
151
+
152
+ await promise;
153
+ await new Promise((r) => setTimeout(r, 0));
154
+
155
+ expect(onComplete).toHaveBeenCalledTimes(1);
156
+ expect(onComplete).toHaveBeenCalledWith(
157
+ expect.objectContaining({
158
+ id: 'agent-1',
159
+ status: 'completed',
160
+ resultText: 'done',
161
+ })
162
+ );
163
+ });
164
+
165
+ it('should call onComplete callback on failure', async () => {
166
+ const onComplete = jest.fn();
167
+ const promise = createFailingPromise('oops');
168
+
169
+ registry.spawn({
170
+ id: 'agent-1',
171
+ name: 'Worker',
172
+ task: 'Do work',
173
+ promise,
174
+ extractResult,
175
+ truncateResult,
176
+ maxResultChars: 5000,
177
+ onComplete,
178
+ });
179
+
180
+ try {
181
+ await promise;
182
+ } catch {
183
+ // expected
184
+ }
185
+ await new Promise((r) => setTimeout(r, 0));
186
+
187
+ expect(onComplete).toHaveBeenCalledTimes(1);
188
+ expect(onComplete).toHaveBeenCalledWith(
189
+ expect.objectContaining({
190
+ id: 'agent-1',
191
+ status: 'failed',
192
+ error: 'oops',
193
+ })
194
+ );
195
+ });
196
+ });
197
+
198
+ describe('listing methods', () => {
199
+ it('should list pending and completed separately', async () => {
200
+ const p1 = createChildPromise('result 1');
201
+ const p2 = createChildPromise('result 2', 100);
202
+
203
+ registry.spawn({
204
+ id: 'agent-1',
205
+ name: 'Agent 1',
206
+ task: 'task 1',
207
+ promise: p1,
208
+ extractResult,
209
+ truncateResult,
210
+ maxResultChars: 5000,
211
+ });
212
+ registry.spawn({
213
+ id: 'agent-2',
214
+ name: 'Agent 2',
215
+ task: 'task 2',
216
+ promise: p2,
217
+ extractResult,
218
+ truncateResult,
219
+ maxResultChars: 5000,
220
+ });
221
+
222
+ // Wait for first to complete
223
+ await p1;
224
+ await new Promise((r) => setTimeout(r, 0));
225
+
226
+ expect(registry.listCompleted()).toHaveLength(1);
227
+ expect(registry.listCompleted()[0].id).toBe('agent-1');
228
+ expect(registry.listPending()).toHaveLength(1);
229
+ expect(registry.listPending()[0].id).toBe('agent-2');
230
+ expect(registry.listAll()).toHaveLength(2);
231
+
232
+ // Wait for second
233
+ await p2;
234
+ await new Promise((r) => setTimeout(r, 0));
235
+
236
+ expect(registry.listCompleted()).toHaveLength(2);
237
+ expect(registry.listPending()).toHaveLength(0);
238
+ });
239
+ });
240
+
241
+ describe('waitForAll', () => {
242
+ it('should wait for all pending handoffs', async () => {
243
+ const p1 = createChildPromise('result 1', 10);
244
+ const p2 = createChildPromise('result 2', 20);
245
+
246
+ registry.spawn({
247
+ id: 'agent-1',
248
+ name: 'Agent 1',
249
+ task: 'task 1',
250
+ promise: p1,
251
+ extractResult,
252
+ truncateResult,
253
+ maxResultChars: 5000,
254
+ });
255
+ registry.spawn({
256
+ id: 'agent-2',
257
+ name: 'Agent 2',
258
+ task: 'task 2',
259
+ promise: p2,
260
+ extractResult,
261
+ truncateResult,
262
+ maxResultChars: 5000,
263
+ });
264
+
265
+ const results = await registry.waitForAll();
266
+
267
+ expect(results).toHaveLength(2);
268
+ expect(results.every((r) => r.status === 'completed')).toBe(true);
269
+ expect(results[0].resultText).toBe('result 1');
270
+ expect(results[1].resultText).toBe('result 2');
271
+ });
272
+
273
+ it('should return immediately when no pending handoffs', async () => {
274
+ const results = await registry.waitForAll();
275
+ expect(results).toHaveLength(0);
276
+ });
277
+ });
278
+
279
+ describe('waitForAny', () => {
280
+ it('should return when any handoff completes', async () => {
281
+ const p1 = createChildPromise('fast result', 10);
282
+ const p2 = createChildPromise('slow result', 200);
283
+
284
+ registry.spawn({
285
+ id: 'agent-1',
286
+ name: 'Fast',
287
+ task: 'fast task',
288
+ promise: p1,
289
+ extractResult,
290
+ truncateResult,
291
+ maxResultChars: 5000,
292
+ });
293
+ registry.spawn({
294
+ id: 'agent-2',
295
+ name: 'Slow',
296
+ task: 'slow task',
297
+ promise: p2,
298
+ extractResult,
299
+ truncateResult,
300
+ maxResultChars: 5000,
301
+ });
302
+
303
+ const completed = await registry.waitForAny();
304
+
305
+ // At least the fast one should be completed
306
+ expect(completed.length).toBeGreaterThanOrEqual(1);
307
+ expect(completed.some((r) => r.id === 'agent-1')).toBe(true);
308
+ });
309
+ });
310
+
311
+ describe('clear', () => {
312
+ it('should clear all records', () => {
313
+ registry.spawn({
314
+ id: 'agent-1',
315
+ name: 'Agent',
316
+ task: 'task',
317
+ promise: createChildPromise('result'),
318
+ extractResult,
319
+ truncateResult,
320
+ maxResultChars: 5000,
321
+ });
322
+
323
+ expect(registry.size).toBe(1);
324
+ registry.clear();
325
+ expect(registry.size).toBe(0);
326
+ expect(registry.hasPending()).toBe(false);
327
+ });
328
+ });
329
+
330
+ describe('parallel spawn workflow', () => {
331
+ it('should handle the full orchestrator workflow: spawn → collect → synthesize', async () => {
332
+ // Step 1: Orchestrator spawns two agents in parallel
333
+ const p1 = createChildPromise('Researcher found: X=42', 10);
334
+ const p2 = createChildPromise('Analyst found: Y=100', 20);
335
+
336
+ registry.spawn({
337
+ id: 'researcher',
338
+ name: 'Researcher',
339
+ task: 'Find X',
340
+ promise: p1,
341
+ extractResult,
342
+ truncateResult,
343
+ maxResultChars: 5000,
344
+ });
345
+ registry.spawn({
346
+ id: 'analyst',
347
+ name: 'Analyst',
348
+ task: 'Find Y',
349
+ promise: p2,
350
+ extractResult,
351
+ truncateResult,
352
+ maxResultChars: 5000,
353
+ });
354
+
355
+ // Step 2: Orchestrator checks status (non-blocking)
356
+ expect(registry.hasPending()).toBe(true);
357
+ const status = registry.listAll();
358
+ expect(status).toHaveLength(2);
359
+
360
+ // Step 3: Orchestrator collects all results
361
+ const results = await registry.waitForAll();
362
+ expect(results).toHaveLength(2);
363
+ expect(results.every((r) => r.status === 'completed')).toBe(true);
364
+
365
+ // Step 4: Orchestrator uses results for next delegation
366
+ const researchResult = results.find((r) => r.id === 'researcher')!;
367
+ expect(researchResult.resultText).toBe('Researcher found: X=42');
368
+ });
369
+
370
+ it('should handle sequential spawn workflow: spawn → collect → spawn with data → collect', async () => {
371
+ // Round 1: Spawn researcher
372
+ const p1 = createChildPromise('Research: The answer is 42', 10);
373
+ registry.spawn({
374
+ id: 'researcher',
375
+ name: 'Researcher',
376
+ task: 'Find the answer',
377
+ promise: p1,
378
+ extractResult,
379
+ truncateResult,
380
+ maxResultChars: 5000,
381
+ });
382
+
383
+ // Collect round 1 results
384
+ const round1 = await registry.waitForAll();
385
+ const researchData = round1[0].resultText!;
386
+ expect(researchData).toContain('42');
387
+
388
+ // Round 2: Spawn emailer with real data from round 1
389
+ const p2 = createChildPromise('Email sent with answer: 42', 10);
390
+ registry.spawn({
391
+ id: 'emailer',
392
+ name: 'Email Agent',
393
+ task: `Send email with: ${researchData}`,
394
+ promise: p2,
395
+ extractResult,
396
+ truncateResult,
397
+ maxResultChars: 5000,
398
+ });
399
+
400
+ // Collect round 2 results
401
+ const round2 = await registry.waitForAll();
402
+ const emailResult = round2.find((r) => r.id === 'emailer')!;
403
+ expect(emailResult.status).toBe('completed');
404
+ expect(emailResult.resultText).toContain('Email sent');
405
+ });
406
+ });
407
+ });
@@ -1,2 +1,3 @@
1
1
  export * from './Graph';
2
2
  export * from './MultiAgentGraph';
3
+ export * from './HandoffRegistry';
package/src/stream.ts CHANGED
@@ -741,7 +741,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
741
741
  const messageDelta = data as t.MessageDeltaEvent;
742
742
  const runStep = stepMap.get(messageDelta.id);
743
743
  if (!runStep) {
744
- console.warn('No run step or runId found for message delta event');
744
+ // Expected in handoff subgraphs reasoning/message deltas can arrive before ON_RUN_STEP
745
745
  return;
746
746
  }
747
747
 
@@ -765,7 +765,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
765
765
  const reasoningDelta = data as t.ReasoningDeltaEvent;
766
766
  const runStep = stepMap.get(reasoningDelta.id);
767
767
  if (!runStep) {
768
- console.warn('No run step or runId found for reasoning delta event');
768
+ // Expected in handoff subgraphs reasoning deltas arrive before ON_RUN_STEP
769
769
  return;
770
770
  }
771
771
 
@@ -786,7 +786,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
786
786
 
787
787
  const runStep = stepMap.get(runStepDelta.id);
788
788
  if (!runStep) {
789
- console.warn('No run step or runId found for run step delta event');
789
+ // Expected in handoff subgraphs step deltas can arrive before ON_RUN_STEP
790
790
  return;
791
791
  }
792
792
 
@@ -838,9 +838,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
838
838
 
839
839
  const runStep = stepMap.get(stepId);
840
840
  if (!runStep) {
841
- console.warn(
842
- 'No run step or runId found for completed tool call event'
843
- );
841
+ // Expected in handoff subgraphs — completion can arrive for untracked steps
844
842
  return;
845
843
  }
846
844
 
@@ -14,10 +14,10 @@ describe('HITL Approval Constants', () => {
14
14
  expect(ExecutionContext.SCHEDULED).toBe('scheduled');
15
15
  });
16
16
 
17
- it('should only have 2 values (no dead "browser" context)', () => {
17
+ it('should only have 3 values (interactive, scheduled, handoff)', () => {
18
18
  const values = Object.values(ExecutionContext);
19
- expect(values).toHaveLength(2);
20
- expect(values).toEqual(['interactive', 'scheduled']);
19
+ expect(values).toHaveLength(3);
20
+ expect(values).toEqual(['interactive', 'scheduled', 'handoff']);
21
21
  });
22
22
  });
23
23
 
@@ -56,6 +56,12 @@ export type AgentTransitionEvent = {
56
56
  destinationAgentName: string;
57
57
  edgeType: string; // 'handoff' | 'transfer' | 'sequence'
58
58
  timestamp: number;
59
+ /** When true, this event signals handoff completion (child → parent return) */
60
+ isCompletion?: boolean;
61
+ /** Duration of child agent execution in milliseconds (only on completion events) */
62
+ durationMs?: number;
63
+ /** Length of child agent result text in characters (only on completion events) */
64
+ resultLength?: number;
59
65
  };
60
66
 
61
67
  export type GraphNode = GraphNodeKeys | typeof START;
@@ -13,6 +13,9 @@ export async function safeDispatchCustomEvent(
13
13
  config?: RunnableConfig
14
14
  ): Promise<void> {
15
15
  try {
16
+ if (event === 'on_agent_transition') {
17
+ console.log(`[safeDispatchCustomEvent] Dispatching: ${event}, payload=${JSON.stringify(payload)}`);
18
+ }
16
19
  await dispatchCustomEvent(event, payload, config);
17
20
  } catch (e) {
18
21
  // Check if this is the known EventStreamCallbackHandler error