@illuma-ai/agents 1.1.25 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) 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/common/spawnPath.cjs +104 -0
  4. package/dist/cjs/common/spawnPath.cjs.map +1 -0
  5. package/dist/cjs/graphs/Graph.cjs +87 -31
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/graphs/HandoffRegistry.cjs +143 -0
  8. package/dist/cjs/graphs/HandoffRegistry.cjs.map +1 -0
  9. package/dist/cjs/graphs/MultiAgentGraph.cjs +587 -184
  10. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  11. package/dist/cjs/graphs/phases/flushLoop.cjs +214 -0
  12. package/dist/cjs/graphs/phases/flushLoop.cjs.map +1 -0
  13. package/dist/cjs/graphs/phases/memoryFlushPhase.cjs +102 -0
  14. package/dist/cjs/graphs/phases/memoryFlushPhase.cjs.map +1 -0
  15. package/dist/cjs/llm/bedrock/index.cjs +4 -3
  16. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  17. package/dist/cjs/main.cjs +115 -0
  18. package/dist/cjs/main.cjs.map +1 -1
  19. package/dist/cjs/memory/citations.cjs +69 -0
  20. package/dist/cjs/memory/citations.cjs.map +1 -0
  21. package/dist/cjs/memory/compositeBackend.cjs +60 -0
  22. package/dist/cjs/memory/compositeBackend.cjs.map +1 -0
  23. package/dist/cjs/memory/constants.cjs +232 -0
  24. package/dist/cjs/memory/constants.cjs.map +1 -0
  25. package/dist/cjs/memory/embeddings.cjs +151 -0
  26. package/dist/cjs/memory/embeddings.cjs.map +1 -0
  27. package/dist/cjs/memory/factory.cjs +95 -0
  28. package/dist/cjs/memory/factory.cjs.map +1 -0
  29. package/dist/cjs/memory/migrate.cjs +81 -0
  30. package/dist/cjs/memory/migrate.cjs.map +1 -0
  31. package/dist/cjs/memory/mmr.cjs +138 -0
  32. package/dist/cjs/memory/mmr.cjs.map +1 -0
  33. package/dist/cjs/memory/paths.cjs +217 -0
  34. package/dist/cjs/memory/paths.cjs.map +1 -0
  35. package/dist/cjs/memory/pgvectorStore.cjs +225 -0
  36. package/dist/cjs/memory/pgvectorStore.cjs.map +1 -0
  37. package/dist/cjs/memory/recallTracking.cjs +98 -0
  38. package/dist/cjs/memory/recallTracking.cjs.map +1 -0
  39. package/dist/cjs/memory/schema.sql +51 -0
  40. package/dist/cjs/memory/temporalDecay.cjs +118 -0
  41. package/dist/cjs/memory/temporalDecay.cjs.map +1 -0
  42. package/dist/cjs/nodes/ApprovalGateNode.cjs +1 -1
  43. package/dist/cjs/nodes/ApprovalGateNode.cjs.map +1 -1
  44. package/dist/cjs/prompts/memoryFlushPrompt.cjs +49 -0
  45. package/dist/cjs/prompts/memoryFlushPrompt.cjs.map +1 -0
  46. package/dist/cjs/run.cjs +16 -3
  47. package/dist/cjs/run.cjs.map +1 -1
  48. package/dist/cjs/stream.cjs +4 -4
  49. package/dist/cjs/stream.cjs.map +1 -1
  50. package/dist/cjs/tools/AskUser.cjs +6 -1
  51. package/dist/cjs/tools/AskUser.cjs.map +1 -1
  52. package/dist/cjs/tools/BrowserTools.cjs +1 -1
  53. package/dist/cjs/tools/BrowserTools.cjs.map +1 -1
  54. package/dist/cjs/tools/ToolNode.cjs +127 -10
  55. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  56. package/dist/cjs/tools/approval/constants.cjs +2 -2
  57. package/dist/cjs/tools/approval/constants.cjs.map +1 -1
  58. package/dist/cjs/tools/memory/index.cjs +58 -0
  59. package/dist/cjs/tools/memory/index.cjs.map +1 -0
  60. package/dist/cjs/tools/memory/memoryAppendTool.cjs +69 -0
  61. package/dist/cjs/tools/memory/memoryAppendTool.cjs.map +1 -0
  62. package/dist/cjs/tools/memory/memoryGetTool.cjs +49 -0
  63. package/dist/cjs/tools/memory/memoryGetTool.cjs.map +1 -0
  64. package/dist/cjs/tools/memory/memorySearchTool.cjs +65 -0
  65. package/dist/cjs/tools/memory/memorySearchTool.cjs.map +1 -0
  66. package/dist/cjs/tools/memory/shared.cjs +106 -0
  67. package/dist/cjs/tools/memory/shared.cjs.map +1 -0
  68. package/dist/cjs/types/graph.cjs.map +1 -1
  69. package/dist/cjs/utils/childAgentContext.cjs +242 -0
  70. package/dist/cjs/utils/childAgentContext.cjs.map +1 -0
  71. package/dist/cjs/utils/events.cjs +36 -4
  72. package/dist/cjs/utils/events.cjs.map +1 -1
  73. package/dist/cjs/utils/finishReasons.cjs +44 -0
  74. package/dist/cjs/utils/finishReasons.cjs.map +1 -0
  75. package/dist/cjs/utils/llm.cjs.map +1 -1
  76. package/dist/cjs/utils/logging.cjs +34 -0
  77. package/dist/cjs/utils/logging.cjs.map +1 -0
  78. package/dist/cjs/utils/toolCallNormalization.cjs +250 -0
  79. package/dist/cjs/utils/toolCallNormalization.cjs.map +1 -0
  80. package/dist/esm/agents/AgentContext.mjs +20 -3
  81. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  82. package/dist/esm/common/spawnPath.mjs +95 -0
  83. package/dist/esm/common/spawnPath.mjs.map +1 -0
  84. package/dist/esm/graphs/Graph.mjs +87 -31
  85. package/dist/esm/graphs/Graph.mjs.map +1 -1
  86. package/dist/esm/graphs/HandoffRegistry.mjs +141 -0
  87. package/dist/esm/graphs/HandoffRegistry.mjs.map +1 -0
  88. package/dist/esm/graphs/MultiAgentGraph.mjs +587 -184
  89. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  90. package/dist/esm/graphs/phases/flushLoop.mjs +209 -0
  91. package/dist/esm/graphs/phases/flushLoop.mjs.map +1 -0
  92. package/dist/esm/graphs/phases/memoryFlushPhase.mjs +99 -0
  93. package/dist/esm/graphs/phases/memoryFlushPhase.mjs.map +1 -0
  94. package/dist/esm/llm/bedrock/index.mjs +4 -3
  95. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  96. package/dist/esm/main.mjs +21 -0
  97. package/dist/esm/main.mjs.map +1 -1
  98. package/dist/esm/memory/citations.mjs +64 -0
  99. package/dist/esm/memory/citations.mjs.map +1 -0
  100. package/dist/esm/memory/compositeBackend.mjs +58 -0
  101. package/dist/esm/memory/compositeBackend.mjs.map +1 -0
  102. package/dist/esm/memory/constants.mjs +198 -0
  103. package/dist/esm/memory/constants.mjs.map +1 -0
  104. package/dist/esm/memory/embeddings.mjs +148 -0
  105. package/dist/esm/memory/embeddings.mjs.map +1 -0
  106. package/dist/esm/memory/factory.mjs +93 -0
  107. package/dist/esm/memory/factory.mjs.map +1 -0
  108. package/dist/esm/memory/migrate.mjs +78 -0
  109. package/dist/esm/memory/migrate.mjs.map +1 -0
  110. package/dist/esm/memory/mmr.mjs +130 -0
  111. package/dist/esm/memory/mmr.mjs.map +1 -0
  112. package/dist/esm/memory/paths.mjs +207 -0
  113. package/dist/esm/memory/paths.mjs.map +1 -0
  114. package/dist/esm/memory/pgvectorStore.mjs +223 -0
  115. package/dist/esm/memory/pgvectorStore.mjs.map +1 -0
  116. package/dist/esm/memory/recallTracking.mjs +94 -0
  117. package/dist/esm/memory/recallTracking.mjs.map +1 -0
  118. package/dist/esm/memory/schema.sql +51 -0
  119. package/dist/esm/memory/temporalDecay.mjs +110 -0
  120. package/dist/esm/memory/temporalDecay.mjs.map +1 -0
  121. package/dist/esm/nodes/ApprovalGateNode.mjs +1 -1
  122. package/dist/esm/nodes/ApprovalGateNode.mjs.map +1 -1
  123. package/dist/esm/prompts/memoryFlushPrompt.mjs +44 -0
  124. package/dist/esm/prompts/memoryFlushPrompt.mjs.map +1 -0
  125. package/dist/esm/run.mjs +16 -3
  126. package/dist/esm/run.mjs.map +1 -1
  127. package/dist/esm/stream.mjs +4 -4
  128. package/dist/esm/stream.mjs.map +1 -1
  129. package/dist/esm/tools/AskUser.mjs +6 -1
  130. package/dist/esm/tools/AskUser.mjs.map +1 -1
  131. package/dist/esm/tools/BrowserTools.mjs +1 -1
  132. package/dist/esm/tools/BrowserTools.mjs.map +1 -1
  133. package/dist/esm/tools/ToolNode.mjs +128 -11
  134. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  135. package/dist/esm/tools/approval/constants.mjs +2 -2
  136. package/dist/esm/tools/approval/constants.mjs.map +1 -1
  137. package/dist/esm/tools/memory/index.mjs +46 -0
  138. package/dist/esm/tools/memory/index.mjs.map +1 -0
  139. package/dist/esm/tools/memory/memoryAppendTool.mjs +67 -0
  140. package/dist/esm/tools/memory/memoryAppendTool.mjs.map +1 -0
  141. package/dist/esm/tools/memory/memoryGetTool.mjs +47 -0
  142. package/dist/esm/tools/memory/memoryGetTool.mjs.map +1 -0
  143. package/dist/esm/tools/memory/memorySearchTool.mjs +63 -0
  144. package/dist/esm/tools/memory/memorySearchTool.mjs.map +1 -0
  145. package/dist/esm/tools/memory/shared.mjs +98 -0
  146. package/dist/esm/tools/memory/shared.mjs.map +1 -0
  147. package/dist/esm/types/graph.mjs.map +1 -1
  148. package/dist/esm/utils/childAgentContext.mjs +237 -0
  149. package/dist/esm/utils/childAgentContext.mjs.map +1 -0
  150. package/dist/esm/utils/events.mjs +36 -5
  151. package/dist/esm/utils/events.mjs.map +1 -1
  152. package/dist/esm/utils/finishReasons.mjs +41 -0
  153. package/dist/esm/utils/finishReasons.mjs.map +1 -0
  154. package/dist/esm/utils/llm.mjs.map +1 -1
  155. package/dist/esm/utils/logging.mjs +31 -0
  156. package/dist/esm/utils/logging.mjs.map +1 -0
  157. package/dist/esm/utils/toolCallNormalization.mjs +247 -0
  158. package/dist/esm/utils/toolCallNormalization.mjs.map +1 -0
  159. package/dist/types/common/index.d.ts +1 -0
  160. package/dist/types/common/spawnPath.d.ts +59 -0
  161. package/dist/types/graphs/HandoffRegistry.d.ts +97 -0
  162. package/dist/types/graphs/MultiAgentGraph.d.ts +58 -18
  163. package/dist/types/graphs/index.d.ts +1 -0
  164. package/dist/types/graphs/phases/flushLoop.d.ts +106 -0
  165. package/dist/types/graphs/phases/memoryFlushPhase.d.ts +100 -0
  166. package/dist/types/index.d.ts +7 -0
  167. package/dist/types/memory/__tests__/mockBackend.d.ts +40 -0
  168. package/dist/types/memory/citations.d.ts +39 -0
  169. package/dist/types/memory/compositeBackend.d.ts +30 -0
  170. package/dist/types/memory/constants.d.ts +121 -0
  171. package/dist/types/memory/embeddings.d.ts +15 -0
  172. package/dist/types/memory/factory.d.ts +23 -0
  173. package/dist/types/memory/index.d.ts +21 -0
  174. package/dist/types/memory/migrate.d.ts +14 -0
  175. package/dist/types/memory/mmr.d.ts +50 -0
  176. package/dist/types/memory/paths.d.ts +107 -0
  177. package/dist/types/memory/pgvectorStore.d.ts +56 -0
  178. package/dist/types/memory/recallTracking.d.ts +30 -0
  179. package/dist/types/memory/temporalDecay.d.ts +53 -0
  180. package/dist/types/memory/types.d.ts +182 -0
  181. package/dist/types/prompts/memoryFlushPrompt.d.ts +54 -0
  182. package/dist/types/run.d.ts +1 -0
  183. package/dist/types/tools/AskUser.d.ts +1 -1
  184. package/dist/types/tools/BrowserTools.d.ts +2 -2
  185. package/dist/types/tools/approval/constants.d.ts +2 -2
  186. package/dist/types/tools/memory/index.d.ts +39 -0
  187. package/dist/types/tools/memory/memoryAppendTool.d.ts +27 -0
  188. package/dist/types/tools/memory/memoryGetTool.d.ts +22 -0
  189. package/dist/types/tools/memory/memorySearchTool.d.ts +22 -0
  190. package/dist/types/tools/memory/shared.d.ts +106 -0
  191. package/dist/types/types/graph.d.ts +16 -3
  192. package/dist/types/utils/childAgentContext.d.ts +99 -0
  193. package/dist/types/utils/events.d.ts +21 -0
  194. package/dist/types/utils/finishReasons.d.ts +32 -0
  195. package/dist/types/utils/logging.d.ts +2 -0
  196. package/dist/types/utils/toolCallNormalization.d.ts +44 -0
  197. package/package.json +6 -4
  198. package/src/agents/AgentContext.ts +26 -3
  199. package/src/common/__tests__/enum.test.ts +4 -2
  200. package/src/common/__tests__/spawnPath.test.ts +110 -0
  201. package/src/common/index.ts +1 -0
  202. package/src/common/spawnPath.ts +101 -0
  203. package/src/graphs/Graph.ts +94 -43
  204. package/src/graphs/HandoffRegistry.ts +199 -0
  205. package/src/graphs/MultiAgentGraph.ts +694 -226
  206. package/src/graphs/__tests__/HandoffRegistry.test.ts +410 -0
  207. package/src/graphs/__tests__/multi-agent-delegate.test.ts +61 -16
  208. package/src/graphs/__tests__/multi-agent-edges.test.ts +4 -2
  209. package/src/graphs/__tests__/multi-agent-nested-subgraph.test.ts +221 -0
  210. package/src/graphs/__tests__/structured-output.integration.test.ts +212 -118
  211. package/src/graphs/contextManagement.e2e.test.ts +1 -1
  212. package/src/graphs/index.ts +1 -0
  213. package/src/graphs/phases/__tests__/flushLoop.test.ts +264 -0
  214. package/src/graphs/phases/__tests__/memoryFlushPhase.test.ts +37 -0
  215. package/src/graphs/phases/__tests__/runMemoryFlush.test.ts +150 -0
  216. package/src/graphs/phases/flushLoop.ts +303 -0
  217. package/src/graphs/phases/memoryFlushPhase.ts +209 -0
  218. package/src/index.ts +30 -1
  219. package/src/llm/bedrock/index.ts +4 -5
  220. package/src/memory/__tests__/citations.test.ts +61 -0
  221. package/src/memory/__tests__/compositeBackend.test.ts +79 -0
  222. package/src/memory/__tests__/isolation.test.ts +206 -0
  223. package/src/memory/__tests__/mmr.test.ts +148 -0
  224. package/src/memory/__tests__/mockBackend.ts +161 -0
  225. package/src/memory/__tests__/paths.test.ts +168 -0
  226. package/src/memory/__tests__/recallTracking.test.ts +96 -0
  227. package/src/memory/__tests__/temporalDecay.test.ts +151 -0
  228. package/src/memory/citations.ts +80 -0
  229. package/src/memory/compositeBackend.ts +99 -0
  230. package/src/memory/constants.ts +229 -0
  231. package/src/memory/embeddings.ts +188 -0
  232. package/src/memory/factory.ts +111 -0
  233. package/src/memory/index.ts +46 -0
  234. package/src/memory/migrate.ts +116 -0
  235. package/src/memory/mmr.ts +161 -0
  236. package/src/memory/paths.ts +258 -0
  237. package/src/memory/pgvectorStore.ts +324 -0
  238. package/src/memory/recallTracking.ts +127 -0
  239. package/src/memory/schema.sql +51 -0
  240. package/src/memory/temporalDecay.ts +134 -0
  241. package/src/memory/types.ts +185 -0
  242. package/src/nodes/ApprovalGateNode.ts +4 -10
  243. package/src/nodes/__tests__/ApprovalGateNode.test.ts +11 -20
  244. package/src/prompts/memoryFlushPrompt.ts +78 -0
  245. package/src/run.ts +17 -6
  246. package/src/scripts/test-bedrock-handoff-autonomous.ts +56 -20
  247. package/src/specs/agent-handoffs-bedrock.integration.test.ts +8 -5
  248. package/src/specs/agent-handoffs.test.ts +8 -2
  249. package/src/stream.ts +4 -6
  250. package/src/tools/AskUser.ts +7 -2
  251. package/src/tools/BrowserTools.ts +3 -5
  252. package/src/tools/ToolNode.ts +150 -13
  253. package/src/tools/__tests__/ToolApproval.test.ts +22 -9
  254. package/src/tools/approval/__tests__/constants.test.ts +4 -4
  255. package/src/tools/approval/constants.ts +2 -2
  256. package/src/tools/memory/__tests__/memoryTools.test.ts +205 -0
  257. package/src/tools/memory/index.ts +96 -0
  258. package/src/tools/memory/memoryAppendTool.ts +101 -0
  259. package/src/tools/memory/memoryGetTool.ts +53 -0
  260. package/src/tools/memory/memorySearchTool.ts +80 -0
  261. package/src/tools/memory/shared.ts +169 -0
  262. package/src/tools/search/search.test.ts +6 -1
  263. package/src/types/graph.ts +16 -3
  264. package/src/utils/__tests__/childAgentContext.test.ts +217 -0
  265. package/src/utils/__tests__/finishReasons.test.ts +55 -0
  266. package/src/utils/__tests__/toolCallNormalization.test.ts +181 -0
  267. package/src/utils/childAgentContext.ts +259 -0
  268. package/src/utils/events.ts +37 -4
  269. package/src/utils/finishReasons.ts +40 -0
  270. package/src/utils/llm.ts +0 -1
  271. package/src/utils/logging.ts +45 -8
  272. package/src/utils/toolCallNormalization.ts +271 -0
@@ -27,12 +27,21 @@ import {
27
27
  EdgeType,
28
28
  GraphEvents,
29
29
  DEFAULT_HANDOFF_MAX_RESULT_CHARS,
30
+ buildSpawnPath,
31
+ spawnPathDepth,
30
32
  } from '@/common';
31
33
  import { safeDispatchCustomEvent } from '@/utils/events';
34
+ import { mlog, mwarn } from '@/utils/logging';
35
+ import {
36
+ prepareHandoffMessages as prepareHandoffMessagesUtil,
37
+ prepareIsolatedChildMessages as prepareIsolatedChildMessagesUtil,
38
+ } from '@/utils/childAgentContext';
32
39
  import {
33
40
  createApprovalGateNode,
34
41
  getApprovalGateNodeId,
35
42
  } from '@/nodes/ApprovalGateNode';
43
+ // HandoffRegistry no longer needed — handoff tools use synchronous
44
+ // browser-tool callback pattern (spawn → wait → return result)
36
45
 
37
46
  /** Pattern to extract instructions from transfer ToolMessage content */
38
47
  const TRANSFER_INSTRUCTIONS_PATTERN = /(?:Instructions?|Context):\s*(.+)/is;
@@ -82,6 +91,13 @@ export class MultiAgentGraph extends StandardGraph {
82
91
  */
83
92
  private lastActiveAgentId: string | undefined;
84
93
 
94
+ /**
95
+ * Registry for async handoff execution.
96
+ * Enables autonomous orchestration: spawn children non-blocking,
97
+ * orchestrator stays alive to reason and collect results when ready.
98
+ */
99
+ // HandoffRegistry removed — handoff tools are synchronous (callback pattern)
100
+
85
101
  /**
86
102
  * When set, the graph routes START to this agent instead of the default starting nodes.
87
103
  * Enables multi-turn resumption: follow-up messages go to the agent that last handled
@@ -97,7 +113,7 @@ export class MultiAgentGraph extends StandardGraph {
97
113
  this.analyzeGraph();
98
114
  this.createTransferTools();
99
115
  this.createHandoffTools();
100
- console.debug(
116
+ mlog(
101
117
  `[MultiAgentGraph] Constructor complete: ${this.agentContexts.size} agents, ${this.edges.length} edges`
102
118
  );
103
119
  }
@@ -111,7 +127,10 @@ export class MultiAgentGraph extends StandardGraph {
111
127
  this.handoffEdges.push(edge);
112
128
  } else if (edge.edgeType === EdgeType.SEQUENCE) {
113
129
  this.sequenceEdges.push(edge);
114
- } else if (edge.edgeType === EdgeType.TRANSFER || edge.condition != null) {
130
+ } else if (
131
+ edge.edgeType === EdgeType.TRANSFER ||
132
+ edge.condition != null
133
+ ) {
115
134
  this.transferEdges.push(edge);
116
135
  } else {
117
136
  // Default: single-to-single edges are transfer, single-to-multiple are sequence
@@ -127,7 +146,7 @@ export class MultiAgentGraph extends StandardGraph {
127
146
  }
128
147
  }
129
148
  }
130
- console.debug(
149
+ mlog(
131
150
  `[MultiAgentGraph] Edge categorization: ${this.handoffEdges.length} handoff, ${this.transferEdges.length} transfer, ${this.sequenceEdges.length} sequence (of ${this.edges.length} total)`
132
151
  );
133
152
  }
@@ -156,7 +175,7 @@ export class MultiAgentGraph extends StandardGraph {
156
175
  this.startingNodes.add(this.agentContexts.keys().next().value!);
157
176
  }
158
177
 
159
- console.debug(
178
+ mlog(
160
179
  `[MultiAgentGraph] Starting nodes identified: [${Array.from(this.startingNodes).join(', ')}]`
161
180
  );
162
181
 
@@ -311,9 +330,28 @@ export class MultiAgentGraph extends StandardGraph {
311
330
  agentContext.graphTools = [];
312
331
  }
313
332
  agentContext.graphTools.push(...transferTools);
314
- console.debug(
333
+ mlog(
315
334
  `[MultiAgentGraph] Transfer tools for "${agentId}": [${transferTools.map((t) => t.name).join(', ')}]`
316
335
  );
336
+
337
+ // Inject orchestration guidance for agents with transfer tools
338
+ const childDescs = edges.flatMap((e) => {
339
+ const dests = Array.isArray(e.to) ? e.to : [e.to];
340
+ return dests.map((d) => {
341
+ const ctx = this.agentContexts.get(d);
342
+ const name = ctx?.name ?? d;
343
+ const desc = ctx?.description ? ` — ${ctx.description}` : '';
344
+ return `${name}${desc}`;
345
+ });
346
+ });
347
+ const guidance = this.buildOrchestratorGuidance(
348
+ childDescs,
349
+ transferTools.length
350
+ );
351
+ const existing = agentContext.additionalInstructions ?? '';
352
+ agentContext.additionalInstructions = existing
353
+ ? `${existing}\n\n${guidance}`
354
+ : guidance;
317
355
  }
318
356
  }
319
357
 
@@ -341,7 +379,9 @@ export class MultiAgentGraph extends StandardGraph {
341
379
  /** Check if we have a prompt for handoff input */
342
380
  const hasTransferInput =
343
381
  edge.prompt != null && typeof edge.prompt === 'string';
344
- const transferInputDescription = hasTransferInput ? edge.prompt : undefined;
382
+ const transferInputDescription = hasTransferInput
383
+ ? edge.prompt
384
+ : undefined;
345
385
  const promptKey = edge.promptKey ?? 'instructions';
346
386
 
347
387
  tools.push(
@@ -553,6 +593,88 @@ export class MultiAgentGraph extends StandardGraph {
553
593
  return tools;
554
594
  }
555
595
 
596
+ /**
597
+ * Builds orchestration guidance injected into the system message of agents
598
+ * that have handoff or transfer tools (i.e., orchestrator agents).
599
+ *
600
+ * Implements two orchestration primitives:
601
+ * - Execution bias guidance injected into the system prompt
602
+ * - Multi-round autonomous execution for dependent tasks
603
+ *
604
+ * Handoff tools are synchronous (browser-tool callback pattern): spawn the
605
+ * child, await completion, return the real text as the tool output. Parallel
606
+ * handoff tool calls in one turn run concurrently via LangGraph's ToolNode,
607
+ * so independent children run in parallel without explicit orchestration.
608
+ *
609
+ * @param childDescs - Display names (with optional descriptions) of child agents
610
+ * @param toolCount - Number of handoff/transfer tools available
611
+ */
612
+ private buildOrchestratorGuidance(
613
+ childDescs: string[],
614
+ toolCount: number
615
+ ): string {
616
+ return [
617
+ '## Agent Orchestration',
618
+ '',
619
+ `You have ${toolCount} specialist agent(s) available for delegation:`,
620
+ ...childDescs.map((d) => `- ${d}`),
621
+ '',
622
+ '## Execution Bias',
623
+ 'If the user asks you to do the work, start doing it in the same turn.',
624
+ 'Use a real tool call or concrete action first when the task is actionable; do not stop at a plan or promise-to-act reply.',
625
+ 'Commentary-only turns are incomplete when tools are available and the next action is clear.',
626
+ 'If the work will take multiple steps or a while to finish, send one short progress update before or while acting.',
627
+ '',
628
+ '## How Delegation Works',
629
+ 'Each handoff tool call spawns a sub-agent, waits for it to complete, and returns the real result directly — like a function call.',
630
+ 'Independent tasks MAY be called in parallel (multiple handoff tool calls in one turn). They run concurrently and all results return together.',
631
+ 'Dependent tasks MUST be sequential: call one agent, get the result, then call the next agent using that real data.',
632
+ '',
633
+ '## Agent Isolation',
634
+ "Sub-agents CANNOT see your conversation or the user's original message. They ONLY see what you write in the `instructions` parameter.",
635
+ "When writing instructions, include ALL data the agent needs. Copy exact values from the user's message — email addresses, names, URLs, dates, numbers.",
636
+ 'When delegating follow-up work, include the real data from prior agent results directly in the instructions text.',
637
+ 'Do NOT re-delegate a task that was already completed. If you have the data, pass it directly.',
638
+ ].join('\n');
639
+ }
640
+
641
+ /**
642
+ * Builds subagent context instructions injected into child agents that are
643
+ * handoff destinations. This tells the child agent it is a subagent with
644
+ * a focused task.
645
+ *
646
+ * @param orchestratorName - Display name of the parent orchestrator agent
647
+ */
648
+ private buildChildAgentContext(orchestratorName: string): string {
649
+ return [
650
+ '# Subagent Context',
651
+ '',
652
+ `You are a **subagent** spawned by the ${orchestratorName} for a specific task.`,
653
+ '',
654
+ '## Your Role',
655
+ "- Complete this task. That's your entire purpose.",
656
+ `- You are NOT the ${orchestratorName}. Don't try to be.`,
657
+ '',
658
+ '## Rules',
659
+ '1. **Stay focused** - Do your assigned task, nothing else',
660
+ `2. **Complete the task** - Your final message will be automatically reported to the ${orchestratorName}`,
661
+ "3. **Don't initiate** - No heartbeats, no proactive actions, no side quests",
662
+ "4. **Be ephemeral** - You may be terminated after task completion. That's fine.",
663
+ '',
664
+ '## Output Format',
665
+ 'When complete, your final response should include:',
666
+ '- What you accomplished or found',
667
+ `- Any relevant details the ${orchestratorName} should know`,
668
+ '- Keep it concise but informative',
669
+ '',
670
+ "## What You DON'T Do",
671
+ `- NO user conversations (that's ${orchestratorName}'s job)`,
672
+ '- NO external messages (email, tweets, etc.) unless explicitly tasked with a specific recipient/channel',
673
+ '- NO cron jobs or persistent state',
674
+ `- NO pretending to be the ${orchestratorName}`,
675
+ ].join('\n');
676
+ }
677
+
556
678
  /**
557
679
  * Builds a meaningful default description for a transfer tool when no explicit
558
680
  * edge.description is provided. Uses the destination agent's name and description
@@ -601,26 +723,69 @@ export class MultiAgentGraph extends StandardGraph {
601
723
 
602
724
  const handoffTools: t.GenericTool[] = [];
603
725
  for (const edge of edges) {
604
- handoffTools.push(
605
- ...this.createHandoffToolsForEdge(edge, agentId)
606
- );
726
+ handoffTools.push(...this.createHandoffToolsForEdge(edge, agentId));
607
727
  }
608
728
 
609
729
  if (!agentContext.graphTools) {
610
730
  agentContext.graphTools = [];
611
731
  }
612
732
  agentContext.graphTools.push(...handoffTools);
613
- console.debug(
733
+
734
+ // No collect_results tool needed — handoff tools use the browser-tool
735
+ // callback pattern: spawn child, wait for completion, return real result.
736
+ // The LLM naturally gets child results as tool return values.
737
+
738
+ mlog(
614
739
  `[MultiAgentGraph] Handoff tools for "${agentId}": [${handoffTools.map((t) => t.name).join(', ')}]`
615
740
  );
741
+
742
+ // Inject autonomous orchestration guidance for agents with handoff tools.
743
+ const childDescs = edges.flatMap((e) => {
744
+ const dests = Array.isArray(e.to) ? e.to : [e.to];
745
+ return dests.map((d) => {
746
+ const ctx = this.agentContexts.get(d);
747
+ const name = ctx?.name ?? d;
748
+ const desc = ctx?.description ? ` — ${ctx.description}` : '';
749
+ return `${name}${desc}`;
750
+ });
751
+ });
752
+ const orchestrationGuidance = this.buildOrchestratorGuidance(
753
+ childDescs,
754
+ handoffTools.length
755
+ );
756
+
757
+ const existing = agentContext.additionalInstructions ?? '';
758
+ agentContext.additionalInstructions = existing
759
+ ? `${existing}\n\n${orchestrationGuidance}`
760
+ : orchestrationGuidance;
761
+
762
+ // Inject subagent context into each child/destination agent.
763
+ // This tells child agents they are subagents with a focused task — stay focused,
764
+ // execute (don't plan), and return results to the orchestrator.
765
+ const orchestratorName = agentContext.name ?? agentId;
766
+ const childAgentContext = this.buildChildAgentContext(orchestratorName);
767
+ for (const edge of edges) {
768
+ const dests = Array.isArray(edge.to) ? edge.to : [edge.to];
769
+ for (const destId of dests) {
770
+ const destCtx = this.agentContexts.get(destId);
771
+ if (!destCtx) continue;
772
+ const existingChild = destCtx.additionalInstructions ?? '';
773
+ // Avoid duplicate injection if agent is destination of multiple edges
774
+ if (existingChild.includes('# Subagent Context')) continue;
775
+ destCtx.additionalInstructions = existingChild
776
+ ? `${existingChild}\n\n${childAgentContext}`
777
+ : childAgentContext;
778
+ }
779
+ }
616
780
  }
617
781
  }
618
782
 
619
783
  /**
620
784
  * Create handoff tools for an edge (handles multiple destinations).
621
- * Each handoff tool invokes the child agent's compiled subgraph inline,
622
- * extracts the final AI message text, truncates it, and returns it as
623
- * a string (which becomes a ToolMessage in the parent's context).
785
+ * Each handoff tool spawns the child agent's compiled subgraph asynchronously
786
+ * and returns immediately. The orchestrator uses `collect_results` to retrieve
787
+ * outputs and `check_agents` to monitor status a push-based autonomous
788
+ * orchestration pattern.
624
789
  *
625
790
  * @param edge - The graph edge defining the handoff
626
791
  * @param sourceAgentId - The ID of the parent/supervisor agent
@@ -641,19 +806,27 @@ export class MultiAgentGraph extends StandardGraph {
641
806
  edge.description ??
642
807
  this.buildDefaultHandoffDescription(destContext, destination);
643
808
 
644
- const hasPromptInput =
645
- edge.prompt != null && typeof edge.prompt === 'string';
646
- const promptInputDescription = hasPromptInput ? edge.prompt : undefined;
809
+ /**
810
+ * Always include an instructions parameter so the orchestrator can
811
+ * pass scoped task descriptions to each child agent. Without this,
812
+ * the child gets no context about what to do.
813
+ */
647
814
  const promptKey = edge.promptKey ?? 'instructions';
815
+ const destDesc = destContext?.description;
816
+ const promptInputDescription =
817
+ edge.prompt ??
818
+ (destDesc
819
+ ? `Task instructions for this agent (${destDesc}). Describe exactly what it should do.`
820
+ : 'Specific task instructions for this agent. Describe exactly what it should do and what data to use.');
648
821
 
649
822
  /** Capture registry reference — Map populated in createWorkflow() */
650
- const registry = this.subgraphRegistry;
823
+ const subgraphReg = this.subgraphRegistry;
651
824
 
652
825
  tools.push(
653
826
  tool(
654
827
  async (rawInput, config) => {
655
828
  const input = rawInput as Record<string, unknown>;
656
- const subgraph = registry.get(destination);
829
+ const subgraph = subgraphReg.get(destination);
657
830
  if (!subgraph) {
658
831
  throw new Error(
659
832
  `Handoff target "${destination}" subgraph not found in registry. ` +
@@ -661,100 +834,277 @@ export class MultiAgentGraph extends StandardGraph {
661
834
  );
662
835
  }
663
836
 
664
- const state = getCurrentTaskInput() as t.BaseGraphState;
665
- let childMessages = MultiAgentGraph.prepareHandoffMessages(
666
- [...state.messages]
667
- );
837
+ /**
838
+ * Per-spawn unique key = the orchestrator's tool_call.id.
839
+ * LangChain's ToolNode passes `config.toolCall.id` to the tool
840
+ * function; this is the same id the frontend sees on the parent's
841
+ * handoff content part, so the UI can match each AgentHandoff
842
+ * indicator to its own sidebar task without collision when the
843
+ * same agent is invoked multiple times.
844
+ */
845
+ const toolCallCfg = (
846
+ config as { toolCall?: { id?: string } } | undefined
847
+ )?.toolCall;
848
+ const spawnKey =
849
+ toolCallCfg?.id ??
850
+ `${destination}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
851
+
852
+ /**
853
+ * Hierarchical spawnPath: parent's spawnPath (from metadata) + this spawnKey.
854
+ * Root invocations have empty parentSpawnPath. Threaded through childConfig
855
+ * so nested handoffs/sequences inherit the full ancestry.
856
+ * See docs/multi-agent-nesting-architecture.md §4.
857
+ */
858
+ const parentMetadata = (
859
+ config as { metadata?: Record<string, unknown> } | undefined
860
+ )?.metadata;
861
+ const parentSpawnPath =
862
+ typeof parentMetadata?.spawnPath === 'string'
863
+ ? parentMetadata.spawnPath
864
+ : '';
865
+ const childSpawnPath = buildSpawnPath(parentSpawnPath, spawnKey);
866
+ const childDepth = spawnPathDepth(childSpawnPath);
867
+
868
+ /**
869
+ * Child agent message construction — three modes:
870
+ *
871
+ * 1. Default (isolated-session pattern): Child gets ONLY the orchestrator's
872
+ * task instructions as a single HumanMessage. No parent conversation
873
+ * leaks. Orchestrator controls exactly what context the child sees.
874
+ *
875
+ * 2. Passthrough (edge.passthrough = true): Child gets the full parent
876
+ * conversation + orchestrator's instructions appended. Use this when
877
+ * the child needs the full user context (e.g., a transfer-like handoff).
878
+ *
879
+ * 3. Fallback: If no instructions provided AND not passthrough, child
880
+ * gets the agent's description as its task.
881
+ */
882
+ const taskDescription =
883
+ promptKey in input && input[promptKey] != null
884
+ ? String(input[promptKey])
885
+ : '';
668
886
 
669
- /** Inject instructions as HumanMessage if provided by the parent LLM */
670
- if (
671
- hasPromptInput &&
672
- promptKey in input &&
673
- input[promptKey] != null
674
- ) {
887
+ let childMessages: BaseMessage[];
888
+ if (edge.passthrough) {
889
+ // Passthrough: full parent context + instructions appended
890
+ const state = getCurrentTaskInput() as t.BaseGraphState;
891
+ childMessages = MultiAgentGraph.prepareHandoffMessages([
892
+ ...state.messages,
893
+ ]);
894
+ if (taskDescription) {
895
+ childMessages.push(new HumanMessage(taskDescription));
896
+ }
897
+ } else {
898
+ // Default: isolated — only orchestrator's instructions
899
+ const fallbackTask =
900
+ destContext?.description ?? 'Complete your assigned task.';
675
901
  childMessages = [
676
- ...childMessages,
677
- new HumanMessage(String(input[promptKey])),
902
+ new HumanMessage(taskDescription || fallbackTask),
678
903
  ];
679
904
  }
680
905
 
681
-
682
906
  const childState: t.BaseGraphState = {
683
907
  messages: childMessages,
684
908
  };
685
909
 
686
- console.debug(
687
- `[MultiAgentGraph] Handoff "${sourceAgentId}" -> "${destination}" START ` +
688
- `(messages: ${childMessages.length})`
910
+ const childContext = this.agentContexts.get(destination);
911
+ const destName = destContext?.name ?? destination;
912
+ mlog(
913
+ `[MultiAgentGraph] Handoff "${sourceAgentId}" -> "${destination}" SPAWN (async)\n` +
914
+ ` messages: ${childMessages.length}\n` +
915
+ ` childTools: ${childContext?.tools?.length ?? 0} instances\n` +
916
+ ` childToolDefs: ${childContext?.toolDefinitions?.length ?? 0} definitions`
689
917
  );
690
918
 
691
- try {
692
- /**
693
- * Dispatch transition BEFORE invoking the child subgraph so that
694
- * callbacks.js sets multiAgentTrace.isMultiAgent = true before the
695
- * child's ON_RUN_STEP events fire. This ensures child tool calls
696
- * are attributed to the correct agent in admin traces.
697
- */
698
- await safeDispatchCustomEvent(
699
- GraphEvents.ON_AGENT_TRANSITION,
700
- {
701
- sourceAgentId: sourceAgentId,
702
- sourceAgentName: this.agentContexts.get(sourceAgentId)?.name ?? sourceAgentId,
703
- destinationAgentId: destination,
704
- destinationAgentName: destContext?.name ?? destination,
705
- edgeType: EdgeType.HANDOFF,
706
- timestamp: Date.now(),
707
- },
708
- config
709
- );
919
+ /**
920
+ * Dispatch transition BEFORE spawning the child subgraph so that
921
+ * callbacks.js sets multiAgentTrace.isMultiAgent = true before the
922
+ * child's ON_RUN_STEP events fire. spawnKey lets the UI create a
923
+ * distinct sidebar task for this specific invocation.
924
+ */
925
+ mlog(
926
+ `[MultiAgentGraph] Handoff SPAWN "${sourceAgentId}" -> "${destination}" spawnKey=${spawnKey}`
927
+ );
928
+ await safeDispatchCustomEvent(
929
+ GraphEvents.ON_AGENT_TRANSITION,
930
+ {
931
+ sourceAgentId: sourceAgentId,
932
+ sourceAgentName:
933
+ this.agentContexts.get(sourceAgentId)?.name ?? sourceAgentId,
934
+ destinationAgentId: destination,
935
+ destinationAgentName: destName,
936
+ edgeType: EdgeType.HANDOFF,
937
+ timestamp: Date.now(),
938
+ spawnKey,
939
+ spawnPath: childSpawnPath,
940
+ parentSpawnPath: parentSpawnPath || null,
941
+ spawnDepth: childDepth,
942
+ },
943
+ config
944
+ );
710
945
 
711
- /**
712
- * Invoke the child subgraph with config propagation.
713
- * Config carries callbacks (for SSE streaming), abort signal,
714
- * and configurable data (thread_id, user_id) to the child.
715
- */
716
- const result = await subgraph.invoke(childState, config);
946
+ /**
947
+ * Child events need to carry spawnKey so callbacks.js can route
948
+ * them to the correct child aggregator. LangChain propagates
949
+ * `metadata` and `tags` from the parent config to all descendants,
950
+ * so everything dispatched inside subgraph.invoke will have
951
+ * metadata.spawnKey populated on the event's runtime metadata.
952
+ */
953
+ const childConfig = {
954
+ ...(config ?? {}),
955
+ metadata: {
956
+ ...((
957
+ config as { metadata?: Record<string, unknown> } | undefined
958
+ )?.metadata ?? {}),
959
+ spawnKey,
960
+ spawnAgentId: destination,
961
+ /** Hierarchical identity — see spawnPath.ts */
962
+ spawnPath: childSpawnPath,
963
+ parentSpawnPath: parentSpawnPath || null,
964
+ spawnDepth: childDepth,
965
+ },
966
+ tags: [
967
+ ...((config as { tags?: string[] } | undefined)?.tags ?? []),
968
+ `spawn:${spawnKey}`,
969
+ `depth:${childDepth}`,
970
+ ],
971
+ };
972
+
973
+ /**
974
+ * Callback pattern: spawn child, WAIT for completion, return real
975
+ * result. The parent naturally sees child results in its tool
976
+ * return, so no manual "collect_results" step is needed.
977
+ *
978
+ * Parallelism still works: when the LLM emits multiple handoff
979
+ * tool calls in one response, LangGraph runs all tool functions
980
+ * concurrently. Each waits for its child. All results land in
981
+ * the LLM's next turn together.
982
+ */
983
+ const spawnedAt = Date.now();
984
+
985
+ try {
986
+ const result = await subgraph.invoke(childState, childConfig);
987
+ const durationMs = Date.now() - spawnedAt;
717
988
 
718
989
  const resultText = MultiAgentGraph.extractHandoffResult(
719
990
  result.messages,
720
991
  destination
721
992
  );
722
- const truncatedResult = MultiAgentGraph.truncateHandoffResult(
993
+ const truncated = MultiAgentGraph.truncateHandoffResult(
723
994
  resultText,
724
995
  maxResultChars
725
996
  );
726
997
 
727
- console.debug(
728
- `[MultiAgentGraph] Handoff "${sourceAgentId}" -> "${destination}" DONE ` +
729
- `(result: ${resultText.length} chars` +
730
- `${truncatedResult.length < resultText.length ? `, truncated to ${truncatedResult.length}` : ''})`
998
+ mlog(
999
+ `[MultiAgentGraph] Handoff COMPLETED "${sourceAgentId}" -> "${destination}" ` +
1000
+ `spawnKey=${spawnKey} (${durationMs}ms, ${truncated.length} chars)`
731
1001
  );
732
1002
 
733
- return truncatedResult;
734
- } catch (err) {
735
- const errorMessage =
736
- err instanceof Error ? err.message : String(err);
1003
+ /** Dispatch completion event for UI update — carries spawnKey so
1004
+ * the frontend can mark the correct sidebar task as completed. */
1005
+ safeDispatchCustomEvent(
1006
+ GraphEvents.ON_AGENT_TRANSITION,
1007
+ {
1008
+ sourceAgentId: destination,
1009
+ sourceAgentName: destName,
1010
+ destinationAgentId: sourceAgentId,
1011
+ destinationAgentName:
1012
+ this.agentContexts.get(sourceAgentId)?.name ??
1013
+ sourceAgentId,
1014
+ edgeType: EdgeType.HANDOFF,
1015
+ timestamp: Date.now(),
1016
+ isCompletion: true,
1017
+ durationMs,
1018
+ resultLength: truncated.length,
1019
+ spawnKey,
1020
+ spawnPath: childSpawnPath,
1021
+ parentSpawnPath: parentSpawnPath || null,
1022
+ spawnDepth: childDepth,
1023
+ },
1024
+ config
1025
+ ).catch(() => {
1026
+ /* best-effort event dispatch */
1027
+ });
1028
+
1029
+ return truncated;
1030
+ } catch (err: unknown) {
1031
+ const durationMs = Date.now() - spawnedAt;
1032
+ const errMsg = err instanceof Error ? err.message : String(err);
1033
+
1034
+ // EPIPE from console.debug is non-fatal
1035
+ if (errMsg.includes('EPIPE')) {
1036
+ mwarn(
1037
+ `[MultiAgentGraph] Child "${destination}" hit EPIPE (non-fatal) spawnKey=${spawnKey}`
1038
+ );
1039
+ safeDispatchCustomEvent(
1040
+ GraphEvents.ON_AGENT_TRANSITION,
1041
+ {
1042
+ sourceAgentId: destination,
1043
+ sourceAgentName: destName,
1044
+ destinationAgentId: sourceAgentId,
1045
+ destinationAgentName:
1046
+ this.agentContexts.get(sourceAgentId)?.name ??
1047
+ sourceAgentId,
1048
+ edgeType: EdgeType.HANDOFF,
1049
+ timestamp: Date.now(),
1050
+ isCompletion: true,
1051
+ durationMs,
1052
+ spawnKey,
1053
+ spawnPath: childSpawnPath,
1054
+ parentSpawnPath: parentSpawnPath || null,
1055
+ spawnDepth: childDepth,
1056
+ },
1057
+ config
1058
+ ).catch(() => {
1059
+ /* best-effort */
1060
+ });
1061
+ return `Agent "${destName}" completed but output was lost due to stream closure.`;
1062
+ }
1063
+
737
1064
  console.error(
738
- `[MultiAgentGraph] Handoff "${sourceAgentId}" -> "${destination}" ERROR:`,
739
- errorMessage
1065
+ `[MultiAgentGraph] Handoff FAILED "${sourceAgentId}" -> "${destination}" ` +
1066
+ `spawnKey=${spawnKey} (${durationMs}ms): ${errMsg}`
740
1067
  );
741
- return `[Handoff to "${destination}" failed: ${errorMessage}]`;
1068
+
1069
+ safeDispatchCustomEvent(
1070
+ GraphEvents.ON_AGENT_TRANSITION,
1071
+ {
1072
+ sourceAgentId: destination,
1073
+ sourceAgentName: destName,
1074
+ destinationAgentId: sourceAgentId,
1075
+ destinationAgentName:
1076
+ this.agentContexts.get(sourceAgentId)?.name ??
1077
+ sourceAgentId,
1078
+ edgeType: EdgeType.HANDOFF,
1079
+ timestamp: Date.now(),
1080
+ isCompletion: true,
1081
+ durationMs,
1082
+ spawnKey,
1083
+ spawnPath: childSpawnPath,
1084
+ parentSpawnPath: parentSpawnPath || null,
1085
+ spawnDepth: childDepth,
1086
+ error: errMsg,
1087
+ },
1088
+ config
1089
+ ).catch(() => {
1090
+ /* best-effort */
1091
+ });
1092
+
1093
+ return `Agent "${destName}" failed after ${durationMs}ms: ${errMsg}`;
742
1094
  }
743
1095
  },
744
1096
  {
745
1097
  name: toolName,
746
- schema: hasPromptInput
747
- ? {
748
- type: 'object',
749
- properties: {
750
- [promptKey]: {
751
- type: 'string',
752
- description: promptInputDescription as string,
753
- },
754
- },
755
- required: [],
756
- }
757
- : { type: 'object', properties: {}, required: [] },
1098
+ schema: {
1099
+ type: 'object',
1100
+ properties: {
1101
+ [promptKey]: {
1102
+ type: 'string',
1103
+ description: promptInputDescription,
1104
+ },
1105
+ },
1106
+ required: [promptKey],
1107
+ },
758
1108
  description: toolDescription,
759
1109
  }
760
1110
  )
@@ -814,7 +1164,7 @@ export class MultiAgentGraph extends StandardGraph {
814
1164
  /**
815
1165
  * Truncate handoff result using head/tail strategy (60/40 split).
816
1166
  * Preserves the beginning (key findings) and end (conclusions).
817
- * Matches the TaskTool.truncateResult pattern from Ranger.
1167
+ * Matches the TaskTool.truncateResult pattern used by host orchestrators.
818
1168
  * @param result - The full result text
819
1169
  * @param maxChars - Maximum allowed characters
820
1170
  */
@@ -862,144 +1212,186 @@ export class MultiAgentGraph extends StandardGraph {
862
1212
  * Create a complete agent subgraph (similar to createReactAgent)
863
1213
  */
864
1214
  private createAgentSubgraph(agentId: string): t.CompiledAgentWorfklow {
865
- /** This is essentially the same as `createAgentNode` from `StandardGraph` */
866
- return this.createAgentNode(agentId);
1215
+ /**
1216
+ * Scoped subgraph build for handoff targets.
1217
+ *
1218
+ * If the handoff target has outgoing sequence/transfer edges (e.g. a
1219
+ * "researcher" agent with its own sequence `[researcher → prod_assistant]`),
1220
+ * we compile a mini-StateGraph containing the agent and all agents reachable
1221
+ * from it via non-handoff edges. This way, when the parent hands off to
1222
+ * researcher via `subgraph.invoke()`, the nested sequence runs to completion
1223
+ * before the result is returned to the parent.
1224
+ *
1225
+ * Fast path: if no downstream agents are reachable, fall back to the
1226
+ * previous single-node behavior (`createAgentNode`).
1227
+ *
1228
+ * See docs/multi-agent-nesting-architecture.md §6.
1229
+ */
1230
+ const reachable = this.computeReachableViaNonHandoff(agentId);
1231
+ if (reachable.size === 1) {
1232
+ return this.createAgentNode(agentId);
1233
+ }
1234
+ mlog(
1235
+ `[MultiAgentGraph] Scoped subgraph for "${agentId}": ${reachable.size} nodes [${Array.from(reachable).join(', ')}]`
1236
+ );
1237
+ return this.buildScopedSubgraph(agentId, reachable);
867
1238
  }
868
1239
 
869
1240
  /**
870
- * Detects if the current agent is receiving a handoff and processes the messages accordingly.
871
- * Returns filtered messages with the transfer tool call/message removed, plus any instructions,
872
- * source agent, and parallel sibling information extracted from the transfer.
873
- *
874
- * Supports both single handoffs (last message is the transfer) and parallel handoffs
875
- * (multiple transfer ToolMessages, need to find the one targeting this agent).
876
- *
877
- * @param messages - Current state messages
878
- * @param agentId - The agent ID to check for handoff reception
879
- * @returns Object with filtered messages, extracted instructions, source agent, and parallel siblings
1241
+ * BFS from `rootAgentId` across sequence + transfer edges (NOT handoff edges).
1242
+ * Returns the set of agents reachable in this agent's "local workflow".
880
1243
  */
1244
+ private computeReachableViaNonHandoff(rootAgentId: string): Set<string> {
1245
+ const reachable = new Set<string>([rootAgentId]);
1246
+ const queue: string[] = [rootAgentId];
1247
+ const localEdges = [...this.sequenceEdges, ...this.transferEdges];
1248
+ while (queue.length > 0) {
1249
+ const current = queue.shift()!;
1250
+ for (const edge of localEdges) {
1251
+ const sources = Array.isArray(edge.from) ? edge.from : [edge.from];
1252
+ if (!sources.includes(current)) continue;
1253
+ const dests = Array.isArray(edge.to) ? edge.to : [edge.to];
1254
+ for (const dest of dests) {
1255
+ if (!reachable.has(dest) && this.agentContexts.has(dest)) {
1256
+ reachable.add(dest);
1257
+ queue.push(dest);
1258
+ }
1259
+ }
1260
+ }
1261
+ }
1262
+ return reachable;
1263
+ }
881
1264
 
882
1265
  /**
883
- * Prepare messages for a handoff child agent.
884
- *
885
- * Handles two problems:
886
- * 1. **Orphaned tool_use**: The parent's AI message contains a `tool_use` block
887
- * for the handoff tool itself, with no matching `tool_result`. Providers
888
- * (Bedrock/Anthropic) reject this.
889
- * 2. **Paired tool_use/tool_result in history**: The child may not have the same
890
- * tools as the parent. Bedrock requires `toolConfig` when tool_use/tool_result
891
- * blocks exist in the message history. Compacting these into text summaries
892
- * avoids the requirement and reduces context bloat.
1266
+ * Build a compiled scoped StateGraph containing `agentIds` as nodes, rooted
1267
+ * at `rootAgentId`. Linear sequence edges where both endpoints are in scope
1268
+ * are wired directly; nodes with no outgoing in-scope edges route to END.
893
1269
  *
894
- * Strategy:
895
- * - Remove orphaned tool_use blocks (no matching tool_result)
896
- * - Compact paired tool_use/tool_result interactions into text summaries
1270
+ * Each node is wrapped around the per-agent `createAgentNode` compiled
1271
+ * workflow (agent + tools loop) to preserve isolated tool context.
897
1272
  */
898
- static prepareHandoffMessages(messages: BaseMessage[]): BaseMessage[] {
899
- if (messages.length === 0) return messages;
1273
+ private buildScopedSubgraph(
1274
+ rootAgentId: string,
1275
+ agentIds: Set<string>
1276
+ ): t.CompiledAgentWorfklow {
1277
+ const StateAnnotation = Annotation.Root({
1278
+ messages: Annotation<BaseMessage[]>({
1279
+ reducer: messagesStateReducer,
1280
+ default: () => [],
1281
+ }),
1282
+ });
1283
+ const builder = new StateGraph(StateAnnotation);
900
1284
 
901
- /** Collect all tool_result IDs so we know which tool_use blocks are paired */
902
- const pairedToolCallIds = new Set<string>();
903
- for (const msg of messages) {
904
- if (msg.getType() === 'tool') {
905
- const tm = msg as ToolMessage;
906
- if (tm.tool_call_id) {
907
- pairedToolCallIds.add(tm.tool_call_id);
908
- }
909
- }
1285
+ // Precompile each scoped agent's inner workflow and wrap as a node.
1286
+ //
1287
+ // Two different isolation strategies depending on position:
1288
+ //
1289
+ // • ROOT node (the handoff target itself): receives the parent
1290
+ // orchestrator's handoff frame. Use `prepareHandoffMessages` — drops
1291
+ // orphaned tool_use, compacts paired tool calls, guarantees trailing
1292
+ // HumanMessage for Bedrock/VertexAI compatibility. The root needs
1293
+ // orchestrator context because it's responding to the handoff.
1294
+ //
1295
+ // • DOWNSTREAM nodes (sequence targets of the root): run as FULLY
1296
+ // ISOLATED child sessions. They receive only:
1297
+ // [original user request, synthetic HumanMessage describing what
1298
+ // the upstream agent produced and asking them to act]
1299
+ // No raw tool_use / tool_result blocks from the upstream agent —
1300
+ // prevents schema confusion when a downstream agent sees noisy
1301
+ // upstream context and produces malformed tool_use JSON.
1302
+ //
1303
+ // Each wrapper returns only the DELTA (new messages produced by the
1304
+ // inner invoke), not the prepared input — otherwise messagesStateReducer
1305
+ // would double-append the synthetic instruction into the scoped state.
1306
+ for (const aid of agentIds) {
1307
+ const inner = this.createAgentNode(aid);
1308
+ const isRoot = aid === rootAgentId;
1309
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1310
+ builder.addNode(aid as any, async (state: t.BaseGraphState, config) => {
1311
+ const prepared = isRoot
1312
+ ? MultiAgentGraph.prepareHandoffMessages(state.messages)
1313
+ : MultiAgentGraph.prepareIsolatedChildMessages(state.messages);
1314
+ mlog(
1315
+ `[MultiAgentGraph] scoped node "${aid}" entering (isRoot=${isRoot}, stateMessages=${state.messages.length}, prepared=${prepared.length})`
1316
+ );
1317
+ const result = await inner.invoke(
1318
+ { ...state, messages: prepared },
1319
+ config
1320
+ );
1321
+ // Return only the messages the inner node appended beyond its input,
1322
+ // so messagesStateReducer doesn't duplicate the synthetic wrapper
1323
+ // prompt into the scoped state.
1324
+ const delta =
1325
+ result.messages.length > prepared.length
1326
+ ? result.messages.slice(prepared.length)
1327
+ : result.messages;
1328
+ return { messages: delta };
1329
+ });
910
1330
  }
911
1331
 
912
- /**
913
- * Pass 1: Remove orphaned tool_use blocks (no matching tool_result).
914
- * Also skip ToolMessages since we'll compact paired ones in pass 2.
915
- */
916
- const cleaned: BaseMessage[] = [];
917
-
918
- for (const msg of messages) {
919
- /** Skip all ToolMessages — paired ones will be compacted in pass 2 */
920
- if (msg.getType() === 'tool') {
921
- continue;
922
- }
923
-
924
- if (msg.getType() !== 'ai') {
925
- cleaned.push(msg);
926
- continue;
927
- }
928
-
929
- const aiMsg = msg as AIMessage | AIMessageChunk;
930
- const toolCalls = aiMsg.tool_calls ?? [];
931
-
932
- if (toolCalls.length === 0) {
933
- cleaned.push(msg);
934
- continue;
935
- }
1332
+ // START → root
1333
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1334
+ // @ts-ignore LangGraph string typing is too strict for dynamic agent ids
1335
+ builder.addEdge(START, rootAgentId);
936
1336
 
937
- /** Extract text content from the AI message */
938
- const textContent =
939
- typeof aiMsg.content === 'string'
940
- ? aiMsg.content
941
- : Array.isArray(aiMsg.content)
942
- ? (aiMsg.content as { type?: string; text?: string }[])
943
- .filter((b) => b.type === 'text' && 'text' in b)
944
- .map((b) => b.text ?? '')
945
- .join('\n')
946
- : '';
947
-
948
- /** Build text summaries of paired tool calls */
949
- const toolSummaries: string[] = [];
950
- for (const tc of toolCalls) {
951
- if (tc.id != null && pairedToolCallIds.has(tc.id)) {
952
- /** Find the matching ToolMessage result */
953
- const toolResult = messages.find(
954
- (m) => m.getType() === 'tool' && (m as ToolMessage).tool_call_id === tc.id
955
- ) as ToolMessage | undefined;
956
-
957
- const resultContent = toolResult
958
- ? typeof toolResult.content === 'string'
959
- ? toolResult.content.slice(0, 500)
960
- : '[complex result]'
961
- : '[no result]';
962
-
963
- toolSummaries.push(`[Tool "${tc.name}": ${resultContent}]`);
1337
+ // Wire sequence edges in scope (linear chain support)
1338
+ const hasOutgoing = new Set<string>();
1339
+ for (const edge of this.sequenceEdges) {
1340
+ const sources = Array.isArray(edge.from) ? edge.from : [edge.from];
1341
+ const dests = Array.isArray(edge.to) ? edge.to : [edge.to];
1342
+ for (const source of sources) {
1343
+ if (!agentIds.has(source)) continue;
1344
+ for (const dest of dests) {
1345
+ if (!agentIds.has(dest)) continue;
1346
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1347
+ // @ts-ignore
1348
+ builder.addEdge(source, dest);
1349
+ hasOutgoing.add(source);
964
1350
  }
965
- // Orphaned tool_use blocks (no matching result) are silently dropped
966
- }
967
-
968
- /** Reconstruct as plain text AI message (no tool_calls) */
969
- const parts = [textContent, ...toolSummaries].filter(Boolean);
970
- if (parts.length > 0) {
971
- cleaned.push(
972
- new AIMessage({
973
- content: parts.join('\n\n'),
974
- id: aiMsg.id,
975
- })
976
- );
977
1351
  }
978
1352
  }
979
1353
 
980
- /**
981
- * Ensure messages end with a HumanMessage.
982
- * After stripping tool artifacts, the last message may be an AIMessage
983
- * (orchestrator's reasoning before the handoff). Some providers (Bedrock,
984
- * Google/VertexAI) reject conversations ending with an assistant message.
985
- * Convert the trailing AIMessage to a HumanMessage to preserve any useful
986
- * context (e.g., compacted tool summaries) while satisfying the API requirement.
987
- */
988
- if (cleaned.length > 0 && cleaned[cleaned.length - 1].getType() === 'ai') {
989
- const lastAI = cleaned[cleaned.length - 1];
990
- const content = typeof lastAI.content === 'string'
991
- ? lastAI.content
992
- : '';
993
- if (content.trim()) {
994
- cleaned[cleaned.length - 1] = new HumanMessage(
995
- `[Context from orchestrator]: ${content}`
996
- );
997
- } else {
998
- cleaned.pop();
1354
+ // Leaves (no outgoing in-scope edges) route to END
1355
+ for (const aid of agentIds) {
1356
+ if (!hasOutgoing.has(aid)) {
1357
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1358
+ // @ts-ignore
1359
+ builder.addEdge(aid, END);
999
1360
  }
1000
1361
  }
1001
1362
 
1002
- return cleaned;
1363
+ return builder.compile(this.compileOptions as unknown as never);
1364
+ }
1365
+
1366
+ /**
1367
+ * Detects if the current agent is receiving a handoff and processes the messages accordingly.
1368
+ * Returns filtered messages with the transfer tool call/message removed, plus any instructions,
1369
+ * source agent, and parallel sibling information extracted from the transfer.
1370
+ *
1371
+ * Supports both single handoffs (last message is the transfer) and parallel handoffs
1372
+ * (multiple transfer ToolMessages, need to find the one targeting this agent).
1373
+ *
1374
+ * @param messages - Current state messages
1375
+ * @param agentId - The agent ID to check for handoff reception
1376
+ * @returns Object with filtered messages, extracted instructions, source agent, and parallel siblings
1377
+ */
1378
+
1379
+ /**
1380
+ * Prepare messages for a handoff child agent. See
1381
+ * {@link prepareHandoffMessagesUtil} for the full implementation and
1382
+ * semantics — this static method is a thin delegate preserved for
1383
+ * backward compatibility with existing call sites and unit tests.
1384
+ */
1385
+ static prepareHandoffMessages(messages: BaseMessage[]): BaseMessage[] {
1386
+ return prepareHandoffMessagesUtil(messages);
1387
+ }
1388
+
1389
+ /**
1390
+ * Build an isolated message context for a downstream scoped-subgraph
1391
+ * node. See {@link prepareIsolatedChildMessagesUtil} for details.
1392
+ */
1393
+ static prepareIsolatedChildMessages(messages: BaseMessage[]): BaseMessage[] {
1394
+ return prepareIsolatedChildMessagesUtil(messages);
1003
1395
  }
1004
1396
 
1005
1397
  private processTransferReception(
@@ -1043,7 +1435,8 @@ export class MultiAgentGraph extends StandardGraph {
1043
1435
  destinationAgent = toolName.replace(Constants.LC_TRANSFER_TO_, '');
1044
1436
  } else if (isConditionalTransfer) {
1045
1437
  const transferDest = candidateMsg.additional_kwargs.handoff_destination;
1046
- destinationAgent = typeof transferDest === 'string' ? transferDest : null;
1438
+ destinationAgent =
1439
+ typeof transferDest === 'string' ? transferDest : null;
1047
1440
  }
1048
1441
 
1049
1442
  /** Check if this transfer targets our agent */
@@ -1345,8 +1738,36 @@ export class MultiAgentGraph extends StandardGraph {
1345
1738
  handoffOnlyDestinations.delete(startNode);
1346
1739
  }
1347
1740
 
1741
+ /**
1742
+ * Nested-sequence expansion: for each handoff-only target, its downstream
1743
+ * sequence/transfer agents MUST also become handoff-only — they exist only
1744
+ * inside the target's scoped subgraph, not at top level. Without this,
1745
+ * those downstream nodes would be added as top-level orphans and LangGraph
1746
+ * would fail compilation (UNREACHABLE_NODE).
1747
+ *
1748
+ * See docs/multi-agent-nesting-architecture.md §6.
1749
+ */
1750
+ const nestedHandoffOnly = new Set<string>();
1751
+ for (const target of handoffOnlyDestinations) {
1752
+ const reachable = this.computeReachableViaNonHandoff(target);
1753
+ for (const agent of reachable) {
1754
+ if (agent === target) continue;
1755
+ // Skip if this agent is legitimately a top-level starting node
1756
+ if (this.startingNodes.has(agent)) continue;
1757
+ nestedHandoffOnly.add(agent);
1758
+ }
1759
+ }
1760
+ for (const agent of nestedHandoffOnly) {
1761
+ handoffOnlyDestinations.add(agent);
1762
+ }
1763
+ if (nestedHandoffOnly.size > 0) {
1764
+ mlog(
1765
+ `[MultiAgentGraph] Nested handoff-only (scoped subgraph downstream): [${Array.from(nestedHandoffOnly).join(', ')}]`
1766
+ );
1767
+ }
1768
+
1348
1769
  if (handoffOnlyDestinations.size > 0) {
1349
- console.debug(
1770
+ mlog(
1350
1771
  `[MultiAgentGraph] Handoff-only children (subgraph only, no top-level node): [${Array.from(handoffOnlyDestinations).join(', ')}]`
1351
1772
  );
1352
1773
  }
@@ -1409,7 +1830,7 @@ export class MultiAgentGraph extends StandardGraph {
1409
1830
  state: t.MultiAgentGraphState,
1410
1831
  config?: LangGraphRunnableConfig
1411
1832
  ): Promise<t.MultiAgentGraphState | Command> => {
1412
- console.debug(
1833
+ mlog(
1413
1834
  `[MultiAgentGraph] Agent "${agentId}" wrapper ENTRY (messages: ${state.messages.length}, needsCommandRouting: ${needsCommandRouting})`
1414
1835
  );
1415
1836
  let result: t.MultiAgentGraphState;
@@ -1432,7 +1853,7 @@ export class MultiAgentGraph extends StandardGraph {
1432
1853
  sourceAgentName,
1433
1854
  parallelSiblings,
1434
1855
  } = transferContext;
1435
- console.debug(
1856
+ mlog(
1436
1857
  `[MultiAgentGraph] Agent "${agentId}" receiving transfer from "${sourceAgentName}" (instructions: ${instructions != null}, parallelSiblings: ${parallelSiblings.length})`
1437
1858
  );
1438
1859
 
@@ -1574,7 +1995,7 @@ export class MultiAgentGraph extends StandardGraph {
1574
1995
  /** Track the last agent that produced output for continuation support */
1575
1996
  this.lastActiveAgentId = agentId;
1576
1997
 
1577
- console.debug(
1998
+ mlog(
1578
1999
  `[MultiAgentGraph] Agent "${agentId}" wrapper EXIT (result messages: ${result.messages.length})`
1579
2000
  );
1580
2001
 
@@ -1595,7 +2016,7 @@ export class MultiAgentGraph extends StandardGraph {
1595
2016
  Constants.LC_TRANSFER_TO_,
1596
2017
  ''
1597
2018
  );
1598
- console.debug(
2019
+ mlog(
1599
2020
  `[MultiAgentGraph] Command routing: "${agentId}" -> transfer to "${transferDest}" (sequence edges skipped: [${Array.from(sequenceDestinations).join(', ')}])`
1600
2021
  );
1601
2022
 
@@ -1634,7 +2055,7 @@ export class MultiAgentGraph extends StandardGraph {
1634
2055
  const receiverBudget = receiverContext.maxContextTokens;
1635
2056
 
1636
2057
  if (currentSize > receiverBudget * 0.7) {
1637
- console.warn(
2058
+ mwarn(
1638
2059
  `[MultiAgentGraph] Pre-handoff compaction: context (${currentSize} tokens) exceeds ` +
1639
2060
  `70% of receiver "${transferDest}" budget (${receiverBudget} tokens)`
1640
2061
  );
@@ -1713,9 +2134,11 @@ export class MultiAgentGraph extends StandardGraph {
1713
2134
  GraphEvents.ON_AGENT_TRANSITION,
1714
2135
  {
1715
2136
  sourceAgentId: agentId,
1716
- sourceAgentName: this.agentContexts.get(agentId)?.name ?? agentId,
2137
+ sourceAgentName:
2138
+ this.agentContexts.get(agentId)?.name ?? agentId,
1717
2139
  destinationAgentId: transferDest,
1718
- destinationAgentName: this.agentContexts.get(transferDest)?.name ?? transferDest,
2140
+ destinationAgentName:
2141
+ this.agentContexts.get(transferDest)?.name ?? transferDest,
1719
2142
  edgeType: EdgeType.TRANSFER,
1720
2143
  timestamp: Date.now(),
1721
2144
  },
@@ -1728,7 +2151,7 @@ export class MultiAgentGraph extends StandardGraph {
1728
2151
  });
1729
2152
  } else {
1730
2153
  /** No transfer - proceed with sequence edges */
1731
- console.debug(
2154
+ mlog(
1732
2155
  `[MultiAgentGraph] Command routing: "${agentId}" -> no transfer, following sequence edges: [${Array.from(sequenceDestinations).join(', ')}]`
1733
2156
  );
1734
2157
  const directDests = Array.from(sequenceDestinations);
@@ -1737,9 +2160,11 @@ export class MultiAgentGraph extends StandardGraph {
1737
2160
  GraphEvents.ON_AGENT_TRANSITION,
1738
2161
  {
1739
2162
  sourceAgentId: agentId,
1740
- sourceAgentName: this.agentContexts.get(agentId)?.name ?? agentId,
2163
+ sourceAgentName:
2164
+ this.agentContexts.get(agentId)?.name ?? agentId,
1741
2165
  destinationAgentId: dest,
1742
- destinationAgentName: this.agentContexts.get(dest)?.name ?? dest,
2166
+ destinationAgentName:
2167
+ this.agentContexts.get(dest)?.name ?? dest,
1743
2168
  edgeType: EdgeType.SEQUENCE,
1744
2169
  timestamp: Date.now(),
1745
2170
  },
@@ -1761,7 +2186,37 @@ export class MultiAgentGraph extends StandardGraph {
1761
2186
  }
1762
2187
  }
1763
2188
 
1764
- /** No special routing needed - return state normally */
2189
+ /**
2190
+ * No Command routing needed — dispatch ON_AGENT_TRANSITION for all
2191
+ * destinations so callbacks.js can register child agents for event
2192
+ * isolation BEFORE they start streaming.
2193
+ */
2194
+ const allDests = new Set([
2195
+ ...transferDestinations,
2196
+ ...sequenceDestinations,
2197
+ ]);
2198
+ if (allDests.size > 0) {
2199
+ const edgeType = hasTransferEdges
2200
+ ? EdgeType.TRANSFER
2201
+ : EdgeType.SEQUENCE;
2202
+ for (const dest of allDests) {
2203
+ await safeDispatchCustomEvent(
2204
+ GraphEvents.ON_AGENT_TRANSITION,
2205
+ {
2206
+ sourceAgentId: agentId,
2207
+ sourceAgentName:
2208
+ this.agentContexts.get(agentId)?.name ?? agentId,
2209
+ destinationAgentId: dest,
2210
+ destinationAgentName:
2211
+ this.agentContexts.get(dest)?.name ?? dest,
2212
+ edgeType,
2213
+ timestamp: Date.now(),
2214
+ },
2215
+ config
2216
+ );
2217
+ }
2218
+ }
2219
+
1765
2220
  return result;
1766
2221
  };
1767
2222
 
@@ -1787,7 +2242,7 @@ export class MultiAgentGraph extends StandardGraph {
1787
2242
 
1788
2243
  if (validResumeAgent) {
1789
2244
  const resumeAgentId = this.resumeFromAgentId!;
1790
- console.debug(
2245
+ mlog(
1791
2246
  `[MultiAgentGraph] Multi-turn resumption: routing START → "${resumeAgentId}" (skipping default starting nodes: [${Array.from(this.startingNodes).join(', ')}])`
1792
2247
  );
1793
2248
 
@@ -1796,10 +2251,7 @@ export class MultiAgentGraph extends StandardGraph {
1796
2251
  * nodes. This is required by LangGraph — all possible destinations must
1797
2252
  * be declared even if the router always picks one.
1798
2253
  */
1799
- const allPossibleStarts = new Set([
1800
- ...this.startingNodes,
1801
- resumeAgentId,
1802
- ]);
2254
+ const allPossibleStarts = new Set([...this.startingNodes, resumeAgentId]);
1803
2255
  const routeMap: Record<string, string> = {};
1804
2256
  for (const nodeId of allPossibleStarts) {
1805
2257
  routeMap[nodeId] = nodeId;
@@ -1812,7 +2264,7 @@ export class MultiAgentGraph extends StandardGraph {
1812
2264
  );
1813
2265
  } else {
1814
2266
  if (this.resumeFromAgentId != null) {
1815
- console.warn(
2267
+ mwarn(
1816
2268
  `[MultiAgentGraph] resumeFromAgentId "${this.resumeFromAgentId}" not found in graph — falling back to default starting nodes`
1817
2269
  );
1818
2270
  }
@@ -1847,7 +2299,7 @@ export class MultiAgentGraph extends StandardGraph {
1847
2299
  const gateNode = createApprovalGateNode(
1848
2300
  edge.approvalGate,
1849
2301
  source,
1850
- dest,
2302
+ dest
1851
2303
  );
1852
2304
 
1853
2305
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -1892,8 +2344,21 @@ export class MultiAgentGraph extends StandardGraph {
1892
2344
  if (gatedEdges.has(edge)) {
1893
2345
  continue;
1894
2346
  }
1895
- const destinations = Array.isArray(edge.to) ? edge.to : [edge.to];
1896
- for (const destination of destinations) {
2347
+ /**
2348
+ * Skip sequence edges where either endpoint lives only inside a scoped
2349
+ * handoff subgraph. Those edges are wired inside `buildScopedSubgraph`,
2350
+ * not at the top level — adding them here would reference non-existent
2351
+ * top-level nodes and fail compilation.
2352
+ */
2353
+ const sources = Array.isArray(edge.from) ? edge.from : [edge.from];
2354
+ const dests = Array.isArray(edge.to) ? edge.to : [edge.to];
2355
+ const anyEndpointHandoffOnly = [...sources, ...dests].some((n) =>
2356
+ handoffOnlyDestinations.has(n)
2357
+ );
2358
+ if (anyEndpointHandoffOnly) {
2359
+ continue;
2360
+ }
2361
+ for (const destination of dests) {
1897
2362
  if (!edgesByDestination.has(destination)) {
1898
2363
  edgesByDestination.set(destination, []);
1899
2364
  }
@@ -2001,7 +2466,10 @@ export class MultiAgentGraph extends StandardGraph {
2001
2466
  });
2002
2467
 
2003
2468
  /** Skip adding edge if source uses Command routing (has both types) */
2004
- if (sourceTransferEdges.length > 0 && sourceSequenceEdges.length > 0) {
2469
+ if (
2470
+ sourceTransferEdges.length > 0 &&
2471
+ sourceSequenceEdges.length > 0
2472
+ ) {
2005
2473
  continue;
2006
2474
  }
2007
2475