@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,102 @@
1
+ /**
2
+ * Registry for async handoff execution.
3
+ *
4
+ * Enables the OpenClaw-style autonomous orchestration pattern:
5
+ * 1. Orchestrator spawns children (non-blocking)
6
+ * 2. Orchestrator stays alive to reason, spawn more, or check status
7
+ * 3. Orchestrator collects results when ready
8
+ *
9
+ * Scoped per MultiAgentGraph instance — each orchestrator graph gets its own registry.
10
+ */
11
+ class HandoffRegistry {
12
+ records = new Map();
13
+ /**
14
+ * Register a spawned handoff child.
15
+ * The promise runs in the background — not awaited here.
16
+ */
17
+ spawn(params) {
18
+ const record = {
19
+ id: params.id,
20
+ name: params.name,
21
+ task: params.task,
22
+ spawnedAt: Date.now(),
23
+ status: 'running',
24
+ promise: params.promise,
25
+ };
26
+ // Wire up the promise to update the record on completion
27
+ params.promise
28
+ .then((result) => {
29
+ const resultText = params.extractResult(result.messages, params.id);
30
+ const truncated = params.truncateResult(resultText, params.maxResultChars);
31
+ record.status = 'completed';
32
+ record.resultText = truncated;
33
+ record.durationMs = Date.now() - record.spawnedAt;
34
+ record.resultMessageCount = result.messages.length;
35
+ params.onComplete?.(record);
36
+ })
37
+ .catch((err) => {
38
+ record.status = 'failed';
39
+ record.error = err instanceof Error ? err.message : String(err);
40
+ record.durationMs = Date.now() - record.spawnedAt;
41
+ params.onComplete?.(record);
42
+ });
43
+ this.records.set(params.id, record);
44
+ }
45
+ /** List all pending (running) handoffs */
46
+ listPending() {
47
+ return Array.from(this.records.values()).filter((r) => r.status === 'running');
48
+ }
49
+ /** List all completed handoffs (not yet collected) */
50
+ listCompleted() {
51
+ return Array.from(this.records.values()).filter((r) => r.status === 'completed' || r.status === 'failed');
52
+ }
53
+ /** List all handoffs regardless of status */
54
+ listAll() {
55
+ return Array.from(this.records.values());
56
+ }
57
+ /** Get a specific handoff by ID */
58
+ get(id) {
59
+ return this.records.get(id);
60
+ }
61
+ /** Check if any handoffs are still running */
62
+ hasPending() {
63
+ return this.listPending().length > 0;
64
+ }
65
+ /**
66
+ * Wait for ALL pending handoffs to complete.
67
+ * Returns all completed records (including previously completed ones).
68
+ */
69
+ async waitForAll() {
70
+ const pending = this.listPending();
71
+ if (pending.length > 0) {
72
+ await Promise.allSettled(pending.map((r) => r.promise));
73
+ }
74
+ return this.listAll();
75
+ }
76
+ /**
77
+ * Wait for ANY pending handoff to complete.
78
+ * Returns the newly completed record(s).
79
+ */
80
+ async waitForAny() {
81
+ const pending = this.listPending();
82
+ if (pending.length === 0) {
83
+ return this.listCompleted();
84
+ }
85
+ // Race all pending promises — at least one will resolve
86
+ await Promise.race(pending.map((r) => r.promise.then(() => r).catch(() => r)));
87
+ // Small yield to let promise handlers update records
88
+ await new Promise((resolve) => setTimeout(resolve, 0));
89
+ return this.listCompleted();
90
+ }
91
+ /** Clear all records (for cleanup between graph invocations) */
92
+ clear() {
93
+ this.records.clear();
94
+ }
95
+ /** Number of total tracked handoffs */
96
+ get size() {
97
+ return this.records.size;
98
+ }
99
+ }
100
+
101
+ export { HandoffRegistry };
102
+ //# sourceMappingURL=HandoffRegistry.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HandoffRegistry.mjs","sources":["../../../src/graphs/HandoffRegistry.ts"],"sourcesContent":["import type { BaseMessage } from '@langchain/core/messages';\nimport type * as t from '@/types';\n\n/**\n * Tracks the lifecycle of a spawned handoff child agent.\n * Mirrors OpenClaw's SubagentRunRecord pattern.\n */\nexport type HandoffRecord = {\n /** Unique handoff ID (destination agentId) */\n id: string;\n /** Display name of the child agent */\n name: string;\n /** Task description / instructions passed to child */\n task: string;\n /** When the handoff was spawned */\n spawnedAt: number;\n /** Current status */\n status: 'pending' | 'running' | 'completed' | 'failed';\n /** The background promise executing the child subgraph */\n promise: Promise<t.BaseGraphState>;\n /** Resolved result text (populated on completion) */\n resultText?: string;\n /** Error message (populated on failure) */\n error?: string;\n /** Duration in ms (populated on completion/failure) */\n durationMs?: number;\n /** Number of messages in child's output */\n resultMessageCount?: number;\n};\n\n/**\n * Registry for async handoff execution.\n *\n * Enables the OpenClaw-style autonomous orchestration pattern:\n * 1. Orchestrator spawns children (non-blocking)\n * 2. Orchestrator stays alive to reason, spawn more, or check status\n * 3. Orchestrator collects results when ready\n *\n * Scoped per MultiAgentGraph instance — each orchestrator graph gets its own registry.\n */\nexport class HandoffRegistry {\n private records: Map<string, HandoffRecord> = new Map();\n\n /**\n * Register a spawned handoff child.\n * The promise runs in the background — not awaited here.\n */\n spawn(params: {\n id: string;\n name: string;\n task: string;\n promise: Promise<t.BaseGraphState>;\n extractResult: (messages: BaseMessage[], agentId: string) => string;\n truncateResult: (text: string, maxChars: number) => string;\n maxResultChars: number;\n /** Callback when child completes (for SSE events) */\n onComplete?: (record: HandoffRecord) => void;\n }): void {\n const record: HandoffRecord = {\n id: params.id,\n name: params.name,\n task: params.task,\n spawnedAt: Date.now(),\n status: 'running',\n promise: params.promise,\n };\n\n // Wire up the promise to update the record on completion\n params.promise\n .then((result) => {\n const resultText = params.extractResult(\n result.messages,\n params.id\n );\n const truncated = params.truncateResult(\n resultText,\n params.maxResultChars\n );\n record.status = 'completed';\n record.resultText = truncated;\n record.durationMs = Date.now() - record.spawnedAt;\n record.resultMessageCount = result.messages.length;\n params.onComplete?.(record);\n })\n .catch((err) => {\n record.status = 'failed';\n record.error = err instanceof Error ? err.message : String(err);\n record.durationMs = Date.now() - record.spawnedAt;\n params.onComplete?.(record);\n });\n\n this.records.set(params.id, record);\n }\n\n /** List all pending (running) handoffs */\n listPending(): HandoffRecord[] {\n return Array.from(this.records.values()).filter(\n (r) => r.status === 'running'\n );\n }\n\n /** List all completed handoffs (not yet collected) */\n listCompleted(): HandoffRecord[] {\n return Array.from(this.records.values()).filter(\n (r) => r.status === 'completed' || r.status === 'failed'\n );\n }\n\n /** List all handoffs regardless of status */\n listAll(): HandoffRecord[] {\n return Array.from(this.records.values());\n }\n\n /** Get a specific handoff by ID */\n get(id: string): HandoffRecord | undefined {\n return this.records.get(id);\n }\n\n /** Check if any handoffs are still running */\n hasPending(): boolean {\n return this.listPending().length > 0;\n }\n\n /**\n * Wait for ALL pending handoffs to complete.\n * Returns all completed records (including previously completed ones).\n */\n async waitForAll(): Promise<HandoffRecord[]> {\n const pending = this.listPending();\n if (pending.length > 0) {\n await Promise.allSettled(pending.map((r) => r.promise));\n }\n return this.listAll();\n }\n\n /**\n * Wait for ANY pending handoff to complete.\n * Returns the newly completed record(s).\n */\n async waitForAny(): Promise<HandoffRecord[]> {\n const pending = this.listPending();\n if (pending.length === 0) {\n return this.listCompleted();\n }\n\n // Race all pending promises — at least one will resolve\n await Promise.race(\n pending.map((r) =>\n r.promise.then(() => r).catch(() => r)\n )\n );\n\n // Small yield to let promise handlers update records\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n return this.listCompleted();\n }\n\n /** Clear all records (for cleanup between graph invocations) */\n clear(): void {\n this.records.clear();\n }\n\n /** Number of total tracked handoffs */\n get size(): number {\n return this.records.size;\n }\n}\n"],"names":[],"mappings":"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"}
@@ -11,6 +11,7 @@ import { summarize, createEmergencySummary } from '../messages/summarize.mjs';
11
11
  import { StandardGraph } from './Graph.mjs';
12
12
  import { safeDispatchCustomEvent } from '../utils/events.mjs';
13
13
  import { getApprovalGateNodeId, createApprovalGateNode } from '../nodes/ApprovalGateNode.mjs';
14
+ import { HandoffRegistry } from './HandoffRegistry.mjs';
14
15
 
15
16
  /** Pattern to extract instructions from transfer ToolMessage content */
16
17
  const TRANSFER_INSTRUCTIONS_PATTERN = /(?:Instructions?|Context):\s*(.+)/is;
@@ -58,6 +59,12 @@ class MultiAgentGraph extends StandardGraph {
58
59
  * Used by auto-continuation to know which agent's context to preserve after handoff.
59
60
  */
60
61
  lastActiveAgentId;
62
+ /**
63
+ * Registry for async handoff execution.
64
+ * Enables OpenClaw-style autonomous orchestration: spawn children non-blocking,
65
+ * orchestrator stays alive to reason and collect results when ready.
66
+ */
67
+ handoffRegistry = new HandoffRegistry();
61
68
  /**
62
69
  * When set, the graph routes START to this agent instead of the default starting nodes.
63
70
  * Enables multi-turn resumption: follow-up messages go to the agent that last handled
@@ -260,6 +267,21 @@ class MultiAgentGraph extends StandardGraph {
260
267
  }
261
268
  agentContext.graphTools.push(...transferTools);
262
269
  console.debug(`[MultiAgentGraph] Transfer tools for "${agentId}": [${transferTools.map((t) => t.name).join(', ')}]`);
270
+ // Inject orchestration guidance for agents with transfer tools
271
+ const childDescs = edges.flatMap((e) => {
272
+ const dests = Array.isArray(e.to) ? e.to : [e.to];
273
+ return dests.map((d) => {
274
+ const ctx = this.agentContexts.get(d);
275
+ const name = ctx?.name ?? d;
276
+ const desc = ctx?.description ? ` — ${ctx.description}` : '';
277
+ return `${name}${desc}`;
278
+ });
279
+ });
280
+ const guidance = this.buildOrchestratorGuidance(childDescs, transferTools.length);
281
+ const existing = agentContext.additionalInstructions ?? '';
282
+ agentContext.additionalInstructions = existing
283
+ ? `${existing}\n\n${guidance}`
284
+ : guidance;
263
285
  }
264
286
  }
265
287
  /**
@@ -453,6 +475,41 @@ class MultiAgentGraph extends StandardGraph {
453
475
  }
454
476
  return tools;
455
477
  }
478
+ /**
479
+ * Builds orchestration guidance injected into the system message of agents
480
+ * that have handoff or transfer tools (i.e., orchestrator agents).
481
+ *
482
+ * Modeled after OpenClaw's battle-tested subagent orchestration patterns:
483
+ * - Push-based completion (results auto-return from child agents)
484
+ * - Multi-round execution for dependent tasks
485
+ * - Explicit rules against hallucinating data or acting on unavailable context
486
+ *
487
+ * @param childDescs - Display names (with optional descriptions) of child agents
488
+ * @param toolCount - Number of handoff/transfer tools available
489
+ */
490
+ buildOrchestratorGuidance(childDescs, toolCount) {
491
+ return [
492
+ '## Agent Orchestration',
493
+ '',
494
+ `You have ${toolCount} specialist agent(s) available for delegation:`,
495
+ ...childDescs.map((d) => `- ${d}`),
496
+ '',
497
+ 'If a task is more complex or takes longer, delegate it to a specialist agent. Completion is push-based: it will auto-return its result when done.',
498
+ 'Use `check_agents` to check status without waiting. Use `collect_results` to wait for and retrieve agent outputs.',
499
+ 'Default workflow: spawn work, continue reasoning, and call `collect_results` when ready.',
500
+ 'Coordinate agent work and synthesize results before responding to the user.',
501
+ 'For non-trivial multi-step work, keep a short plan updated for the user.',
502
+ 'Do not poll `check_agents` in a loop; only check status on-demand (for debugging or when explicitly asked).',
503
+ '',
504
+ '### Delegation Rules',
505
+ '- Delegate one clear, specific task per agent call.',
506
+ '- Independent tasks MAY be spawned in parallel (multiple calls in one turn, then one `collect_results`).',
507
+ '- Dependent tasks MUST be spawned in separate rounds — spawn, collect, analyze, then spawn the next with REAL data from prior results.',
508
+ '- NEVER fabricate, guess, or use placeholder data. Only pass real data from collected agent results.',
509
+ '- After collecting results, analyze them before proceeding. Explain what the agent found and what you will do next.',
510
+ '- If an agent fails, analyze the error and retry with clearer instructions before reporting failure.',
511
+ ].join('\n');
512
+ }
456
513
  /**
457
514
  * Builds a meaningful default description for a transfer tool when no explicit
458
515
  * edge.description is provided. Uses the destination agent's name and description
@@ -500,14 +557,92 @@ class MultiAgentGraph extends StandardGraph {
500
557
  agentContext.graphTools = [];
501
558
  }
502
559
  agentContext.graphTools.push(...handoffTools);
503
- console.debug(`[MultiAgentGraph] Handoff tools for "${agentId}": [${handoffTools.map((t) => t.name).join(', ')}]`);
560
+ /**
561
+ * Add orchestrator coordination tools: collect_results and check_agents.
562
+ * These enable the OpenClaw-style autonomous loop:
563
+ * spawn → reason → check/collect → reason → spawn more → synthesize
564
+ */
565
+ const handoffReg = this.handoffRegistry;
566
+ agentContext.graphTools.push(tool(async () => {
567
+ if (!handoffReg.hasPending() && handoffReg.size === 0) {
568
+ return 'No agents have been spawned yet.';
569
+ }
570
+ /** Wait for all pending handoffs to complete */
571
+ const records = await handoffReg.waitForAll();
572
+ const parts = [];
573
+ for (const record of records) {
574
+ if (record.status === 'completed') {
575
+ parts.push(`## ${record.name} (completed in ${record.durationMs}ms)\n${record.resultText}`);
576
+ }
577
+ else if (record.status === 'failed') {
578
+ parts.push(`## ${record.name} (FAILED after ${record.durationMs}ms)\nError: ${record.error}`);
579
+ }
580
+ else {
581
+ parts.push(`## ${record.name} (still running, ${Date.now() - record.spawnedAt}ms elapsed)`);
582
+ }
583
+ }
584
+ return parts.join('\n\n---\n\n');
585
+ }, {
586
+ name: 'collect_results',
587
+ schema: { type: 'object', properties: {}, required: [] },
588
+ description: 'Wait for all spawned agents to complete and collect their results. ' +
589
+ 'Call this after spawning one or more agents to get their output.',
590
+ }), tool(async () => {
591
+ const all = handoffReg.listAll();
592
+ if (all.length === 0) {
593
+ return 'No agents tracked.';
594
+ }
595
+ const lines = all.map((r) => {
596
+ const elapsed = Date.now() - r.spawnedAt;
597
+ if (r.status === 'running') {
598
+ return `- **${r.name}**: running (${elapsed}ms elapsed) — task: ${r.task.substring(0, 100)}`;
599
+ }
600
+ else if (r.status === 'completed') {
601
+ return `- **${r.name}**: completed (${r.durationMs}ms, ${r.resultText?.length ?? 0} chars)`;
602
+ }
603
+ else {
604
+ return `- **${r.name}**: failed (${r.durationMs}ms) — ${r.error}`;
605
+ }
606
+ });
607
+ const pending = all.filter((r) => r.status === 'running').length;
608
+ const completed = all.filter((r) => r.status === 'completed').length;
609
+ const failed = all.filter((r) => r.status === 'failed').length;
610
+ return [
611
+ `**Agent Status**: ${pending} running, ${completed} completed, ${failed} failed`,
612
+ '',
613
+ ...lines,
614
+ ].join('\n');
615
+ }, {
616
+ name: 'check_agents',
617
+ schema: { type: 'object', properties: {}, required: [] },
618
+ description: 'Check the status of all spawned agents without waiting. ' +
619
+ 'Shows which agents are running, completed, or failed.',
620
+ }));
621
+ console.debug(`[MultiAgentGraph] Handoff tools for "${agentId}": [${handoffTools.map((t) => t.name).join(', ')}, collect_results, check_agents]`);
622
+ // Inject autonomous orchestration guidance for agents with handoff tools.
623
+ // Modeled after OpenClaw's battle-tested subagent orchestration patterns.
624
+ const childDescs = edges.flatMap((e) => {
625
+ const dests = Array.isArray(e.to) ? e.to : [e.to];
626
+ return dests.map((d) => {
627
+ const ctx = this.agentContexts.get(d);
628
+ const name = ctx?.name ?? d;
629
+ const desc = ctx?.description ? ` — ${ctx.description}` : '';
630
+ return `${name}${desc}`;
631
+ });
632
+ });
633
+ const orchestrationGuidance = this.buildOrchestratorGuidance(childDescs, handoffTools.length);
634
+ const existing = agentContext.additionalInstructions ?? '';
635
+ agentContext.additionalInstructions = existing
636
+ ? `${existing}\n\n${orchestrationGuidance}`
637
+ : orchestrationGuidance;
504
638
  }
505
639
  }
506
640
  /**
507
641
  * Create handoff tools for an edge (handles multiple destinations).
508
- * Each handoff tool invokes the child agent's compiled subgraph inline,
509
- * extracts the final AI message text, truncates it, and returns it as
510
- * a string (which becomes a ToolMessage in the parent's context).
642
+ * Each handoff tool spawns the child agent's compiled subgraph asynchronously
643
+ * and returns immediately. The orchestrator uses `collect_results` to retrieve
644
+ * outputs and `check_agents` to monitor status matching OpenClaw's
645
+ * push-based autonomous orchestration pattern.
511
646
  *
512
647
  * @param edge - The graph edge defining the handoff
513
648
  * @param sourceAgentId - The ID of the parent/supervisor agent
@@ -524,11 +659,12 @@ class MultiAgentGraph extends StandardGraph {
524
659
  const hasPromptInput = edge.prompt != null && typeof edge.prompt === 'string';
525
660
  const promptInputDescription = hasPromptInput ? edge.prompt : undefined;
526
661
  const promptKey = edge.promptKey ?? 'instructions';
527
- /** Capture registry reference — Map populated in createWorkflow() */
528
- const registry = this.subgraphRegistry;
662
+ /** Capture registry references — Map populated in createWorkflow() */
663
+ const subgraphReg = this.subgraphRegistry;
664
+ const handoffReg = this.handoffRegistry;
529
665
  tools.push(tool(async (rawInput, config) => {
530
666
  const input = rawInput;
531
- const subgraph = registry.get(destination);
667
+ const subgraph = subgraphReg.get(destination);
532
668
  if (!subgraph) {
533
669
  throw new Error(`Handoff target "${destination}" subgraph not found in registry. ` +
534
670
  'This is a bug: createWorkflow() should have populated the subgraph registry.');
@@ -536,52 +672,75 @@ class MultiAgentGraph extends StandardGraph {
536
672
  const state = getCurrentTaskInput();
537
673
  let childMessages = MultiAgentGraph.prepareHandoffMessages([...state.messages]);
538
674
  /** Inject instructions as HumanMessage if provided by the parent LLM */
539
- if (hasPromptInput &&
540
- promptKey in input &&
541
- input[promptKey] != null) {
675
+ const taskDescription = (hasPromptInput && promptKey in input && input[promptKey] != null)
676
+ ? String(input[promptKey])
677
+ : '';
678
+ if (taskDescription) {
542
679
  childMessages = [
543
680
  ...childMessages,
544
- new HumanMessage(String(input[promptKey])),
681
+ new HumanMessage(taskDescription),
545
682
  ];
546
683
  }
547
684
  const childState = {
548
685
  messages: childMessages,
549
686
  };
550
- console.debug(`[MultiAgentGraph] Handoff "${sourceAgentId}" -> "${destination}" START ` +
551
- `(messages: ${childMessages.length})`);
552
- try {
553
- /**
554
- * Dispatch transition BEFORE invoking the child subgraph so that
555
- * callbacks.js sets multiAgentTrace.isMultiAgent = true before the
556
- * child's ON_RUN_STEP events fire. This ensures child tool calls
557
- * are attributed to the correct agent in admin traces.
558
- */
559
- await safeDispatchCustomEvent(GraphEvents.ON_AGENT_TRANSITION, {
560
- sourceAgentId: sourceAgentId,
561
- sourceAgentName: this.agentContexts.get(sourceAgentId)?.name ?? sourceAgentId,
562
- destinationAgentId: destination,
563
- destinationAgentName: destContext?.name ?? destination,
564
- edgeType: EdgeType.HANDOFF,
565
- timestamp: Date.now(),
566
- }, config);
567
- /**
568
- * Invoke the child subgraph with config propagation.
569
- * Config carries callbacks (for SSE streaming), abort signal,
570
- * and configurable data (thread_id, user_id) to the child.
571
- */
572
- const result = await subgraph.invoke(childState, config);
573
- const resultText = MultiAgentGraph.extractHandoffResult(result.messages, destination);
574
- const truncatedResult = MultiAgentGraph.truncateHandoffResult(resultText, maxResultChars);
575
- console.debug(`[MultiAgentGraph] Handoff "${sourceAgentId}" -> "${destination}" DONE ` +
576
- `(result: ${resultText.length} chars` +
577
- `${truncatedResult.length < resultText.length ? `, truncated to ${truncatedResult.length}` : ''})`);
578
- return truncatedResult;
579
- }
580
- catch (err) {
581
- const errorMessage = err instanceof Error ? err.message : String(err);
582
- console.error(`[MultiAgentGraph] Handoff "${sourceAgentId}" -> "${destination}" ERROR:`, errorMessage);
583
- return `[Handoff to "${destination}" failed: ${errorMessage}]`;
584
- }
687
+ const childContext = this.agentContexts.get(destination);
688
+ const destName = destContext?.name ?? destination;
689
+ console.debug(`[MultiAgentGraph] Handoff "${sourceAgentId}" -> "${destination}" SPAWN (async)\n` +
690
+ ` messages: ${childMessages.length}\n` +
691
+ ` childTools: ${childContext?.tools?.length ?? 0} instances\n` +
692
+ ` childToolDefs: ${childContext?.toolDefinitions?.length ?? 0} definitions`);
693
+ /**
694
+ * Dispatch transition BEFORE spawning the child subgraph so that
695
+ * callbacks.js sets multiAgentTrace.isMultiAgent = true before the
696
+ * child's ON_RUN_STEP events fire.
697
+ */
698
+ await safeDispatchCustomEvent(GraphEvents.ON_AGENT_TRANSITION, {
699
+ sourceAgentId: sourceAgentId,
700
+ sourceAgentName: this.agentContexts.get(sourceAgentId)?.name ?? sourceAgentId,
701
+ destinationAgentId: destination,
702
+ destinationAgentName: destName,
703
+ edgeType: EdgeType.HANDOFF,
704
+ timestamp: Date.now(),
705
+ }, config);
706
+ /**
707
+ * Spawn child execution as a background promise (non-blocking).
708
+ * The orchestrator gets an immediate response and can reason,
709
+ * spawn more agents, or call collect_results when ready.
710
+ *
711
+ * The config is passed through so SSE callbacks, abort signal,
712
+ * and configurable data still propagate to the child.
713
+ */
714
+ const childPromise = subgraph.invoke(childState, config);
715
+ const capturedConfig = config;
716
+ const parentAgentId = sourceAgentId;
717
+ const parentCtx = this.agentContexts.get(sourceAgentId);
718
+ handoffReg.spawn({
719
+ id: destination,
720
+ name: destName,
721
+ task: taskDescription || '(no explicit instructions)',
722
+ promise: childPromise,
723
+ extractResult: MultiAgentGraph.extractHandoffResult,
724
+ truncateResult: MultiAgentGraph.truncateHandoffResult,
725
+ maxResultChars,
726
+ onComplete: (record) => {
727
+ console.debug(`[MultiAgentGraph] Handoff "${parentAgentId}" -> "${destination}" ${record.status.toUpperCase()} ` +
728
+ `(${record.durationMs}ms, ${record.resultText?.length ?? 0} chars)`);
729
+ /** Dispatch completion event for UI update */
730
+ safeDispatchCustomEvent(GraphEvents.ON_AGENT_TRANSITION, {
731
+ sourceAgentId: destination,
732
+ sourceAgentName: destName,
733
+ destinationAgentId: parentAgentId,
734
+ destinationAgentName: parentCtx?.name ?? parentAgentId,
735
+ edgeType: EdgeType.HANDOFF,
736
+ timestamp: Date.now(),
737
+ isCompletion: true,
738
+ durationMs: record.durationMs,
739
+ resultLength: record.resultText?.length ?? 0,
740
+ }, capturedConfig).catch(() => { });
741
+ },
742
+ });
743
+ return `[Agent "${destName}" spawned. Use collect_results to get the output when ready.]`;
585
744
  }, {
586
745
  name: toolName,
587
746
  schema: hasPromptInput
@@ -1371,7 +1530,25 @@ class MultiAgentGraph extends StandardGraph {
1371
1530
  }
1372
1531
  }
1373
1532
  }
1374
- /** No special routing needed - return state normally */
1533
+ /**
1534
+ * No Command routing needed — dispatch ON_AGENT_TRANSITION for all
1535
+ * destinations so callbacks.js can register child agents for event
1536
+ * isolation BEFORE they start streaming.
1537
+ */
1538
+ const allDests = new Set([...transferDestinations, ...sequenceDestinations]);
1539
+ if (allDests.size > 0) {
1540
+ const edgeType = hasTransferEdges ? EdgeType.TRANSFER : EdgeType.SEQUENCE;
1541
+ for (const dest of allDests) {
1542
+ await safeDispatchCustomEvent(GraphEvents.ON_AGENT_TRANSITION, {
1543
+ sourceAgentId: agentId,
1544
+ sourceAgentName: this.agentContexts.get(agentId)?.name ?? agentId,
1545
+ destinationAgentId: dest,
1546
+ destinationAgentName: this.agentContexts.get(dest)?.name ?? dest,
1547
+ edgeType,
1548
+ timestamp: Date.now(),
1549
+ }, config);
1550
+ }
1551
+ }
1375
1552
  return result;
1376
1553
  };
1377
1554
  /** Wrapped agent as a node with its possible destinations */