@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
package/src/stream.ts CHANGED
@@ -741,7 +741,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
741
741
  const messageDelta = data as t.MessageDeltaEvent;
742
742
  const runStep = stepMap.get(messageDelta.id);
743
743
  if (!runStep) {
744
- console.warn('No run step or runId found for message delta event');
744
+ // Expected in handoff subgraphs reasoning/message deltas can arrive before ON_RUN_STEP
745
745
  return;
746
746
  }
747
747
 
@@ -765,7 +765,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
765
765
  const reasoningDelta = data as t.ReasoningDeltaEvent;
766
766
  const runStep = stepMap.get(reasoningDelta.id);
767
767
  if (!runStep) {
768
- console.warn('No run step or runId found for reasoning delta event');
768
+ // Expected in handoff subgraphs reasoning deltas arrive before ON_RUN_STEP
769
769
  return;
770
770
  }
771
771
 
@@ -786,7 +786,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
786
786
 
787
787
  const runStep = stepMap.get(runStepDelta.id);
788
788
  if (!runStep) {
789
- console.warn('No run step or runId found for run step delta event');
789
+ // Expected in handoff subgraphs step deltas can arrive before ON_RUN_STEP
790
790
  return;
791
791
  }
792
792
 
@@ -838,9 +838,7 @@ export function createContentAggregator(): t.ContentAggregatorResult {
838
838
 
839
839
  const runStep = stepMap.get(stepId);
840
840
  if (!runStep) {
841
- console.warn(
842
- 'No run step or runId found for completed tool call event'
843
- );
841
+ // Expected in handoff subgraphs — completion can arrive for untracked steps
844
842
  return;
845
843
  }
846
844
 
@@ -7,7 +7,12 @@ export const AskUserToolName = Constants.ASK_USER;
7
7
  export const AskUserDescription =
8
8
  'Ask the user clarification questions with structured options before taking action. ' +
9
9
  'For a single key question, use question+options. For multiple related questions, use steps[] (max 3 steps). ' +
10
- 'Each step can be single-select or multi-select. After receiving answers, proceed immediately.';
10
+ 'Each step can be single-select or multi-select. After receiving answers, proceed immediately. ' +
11
+ 'BEFORE calling this tool, ALWAYS emit one short sentence of plain-text output (one line, <= 20 words) ' +
12
+ 'explaining why you need more information — e.g. "I need a few details to help with that.". ' +
13
+ 'NEVER include an option like "I\'ll give it myself", "Other", "Something else", "Custom", "None of the above", ' +
14
+ 'or any equivalent free-text escape hatch — the UI already provides a dedicated "Something else" button ' +
15
+ 'for free-text responses. Only list concrete, mutually-exclusive choices.';
11
16
 
12
17
  const AskUserOptionSchema = z.object({
13
18
  label: z.string().describe('Short display label'),
@@ -68,7 +73,7 @@ const AskUserSchema = z
68
73
 
69
74
  /**
70
75
  * Represents a single step selection from the multi-step wizard UI.
71
- * Exported for use by both ranger web client and browser extension.
76
+ * Exported for use by both the host web client and browser extension.
72
77
  */
73
78
  export type StepSelection = {
74
79
  values: string[];
@@ -3,7 +3,7 @@ import { tool, DynamicStructuredTool } from '@langchain/core/tools';
3
3
  import type * as _t from '@/types';
4
4
 
5
5
  /**
6
- * Browser tool names - keep in sync with ranger-browser extension
6
+ * Browser tool names - keep in sync with the browser extension
7
7
  * These tools execute locally in the browser extension, NOT on the server
8
8
  */
9
9
  export const EBrowserTools = {
@@ -26,7 +26,7 @@ export type BrowserToolName =
26
26
 
27
27
  /**
28
28
  * Callback function type for waiting on browser action results
29
- * This allows the server (Ranger) to provide a callback that waits for the extension
29
+ * This allows the host server to provide a callback that waits for the extension
30
30
  * to POST results back to the server before returning to the LLM.
31
31
  *
32
32
  * @param action - The browser action (click, type, navigate, etc.)
@@ -87,9 +87,7 @@ const BrowserClickSchema = z.object({
87
87
  label: z
88
88
  .string()
89
89
  .optional()
90
- .describe(
91
- 'The fieldLabel or ariaLabel of the element, if available.'
92
- ),
90
+ .describe('The fieldLabel or ariaLabel of the element, if available.'),
93
91
  });
94
92
 
95
93
  const BrowserTypeSchema = z.object({
@@ -188,9 +188,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
188
188
  // Resolve the effective execution context for this agent.
189
189
  // Per-agent overrides take precedence — allows handoff agents to bypass HITL
190
190
  // while the primary agent retains interactive approval.
191
- const effectiveContext = (
192
- this.agentId && agentExecutionContextOverrides?.[this.agentId]
193
- ) ?? executionContext;
191
+ const effectiveContext =
192
+ (this.agentId && agentExecutionContextOverrides?.[this.agentId]) ??
193
+ executionContext;
194
194
 
195
195
  // Scheduled executions bypass all approval checks — no user is present
196
196
  if (effectiveContext === ExecutionContext.SCHEDULED) {
@@ -205,9 +205,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
205
205
  return false;
206
206
  }
207
207
 
208
- // Graph-managed tools (handoff/transfer tools) bypass HITL approval —
209
- // these are internal routing mechanisms, not user-facing tool executions
210
- if (this.directToolNames?.has(toolName)) {
208
+ // Graph-managed routing tools (handoff/transfer) bypass HITL approval —
209
+ // these are internal routing mechanisms, not user-facing tool executions.
210
+ //
211
+ // NOTE: `directToolNames` is used for two purposes — (1) marking tools that
212
+ // are loaded as full instances and don't need on-demand ON_TOOL_EXECUTE loading,
213
+ // and (2) bypassing HITL. In event-driven mode ALL built-in tools (including
214
+ // `ask_user`) end up in directToolNames for reason (1), so we cannot use
215
+ // `directToolNames.has(toolName)` as the HITL-bypass test — it would let
216
+ // `ask_user` execute without ever firing interrupt(), defeating the whole tool.
217
+ // Instead, gate the bypass on the actual routing-tool name prefix.
218
+ if (
219
+ this.directToolNames?.has(toolName) &&
220
+ (toolName.startsWith(Constants.LC_TRANSFER_TO_) ||
221
+ toolName.startsWith(Constants.LC_HANDOFF_TO_))
222
+ ) {
211
223
  return false;
212
224
  }
213
225
 
@@ -240,10 +252,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
240
252
  * @param config - The runnable config for event dispatch
241
253
  * @returns The approval response from the human
242
254
  */
243
- private requestApproval(
255
+ private async requestApproval(
244
256
  call: ToolCall,
245
257
  config: RunnableConfig
246
- ): t.ToolApprovalResponse {
258
+ ): Promise<t.ToolApprovalResponse> {
247
259
  const approvalRequest: t.ToolApprovalRequest = {
248
260
  type: 'tool_approval_required',
249
261
  toolCallId: call.id ?? '',
@@ -253,9 +265,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
253
265
  description: `Tool "${call.name}" wants to execute with the provided arguments.`,
254
266
  };
255
267
 
256
- // Notify host before interrupting allows SSE event emission and DB persistence.
257
- // This is a fire-and-forget notification, NOT the approval mechanism.
258
- safeDispatchCustomEvent(
268
+ // MUST awaitinterrupt() throws GraphInterrupt synchronously which unwinds
269
+ // the call stack. Any un-awaited dispatch Promise is abandoned before the
270
+ // host's ON_TOOL_APPROVAL_REQUIRED handler runs, so the MongoDB row never
271
+ // gets written and the subsequent approve-tool POST 404s with
272
+ // "No pending approval found". Awaiting guarantees the handler has persisted
273
+ // the request before we suspend the graph.
274
+ await safeDispatchCustomEvent(
259
275
  GraphEvents.ON_TOOL_APPROVAL_REQUIRED,
260
276
  approvalRequest,
261
277
  config
@@ -373,7 +389,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
373
389
  if (
374
390
  this.requiresApproval(call.name, call.args as Record<string, unknown>)
375
391
  ) {
376
- const approvalResponse = this.requestApproval(call, config);
392
+ const approvalResponse = await this.requestApproval(call, config);
377
393
  if (!approvalResponse.approved) {
378
394
  // Human denied the tool call - return a denial message
379
395
  return new ToolMessage({
@@ -825,7 +841,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
825
841
  if (
826
842
  this.requiresApproval(call.name, call.args as Record<string, unknown>)
827
843
  ) {
828
- const approvalResponse = this.requestApproval(call, config);
844
+ const approvalResponse = await this.requestApproval(call, config);
829
845
  if (!approvalResponse.approved) {
830
846
  denialMessages.push(
831
847
  new ToolMessage({
@@ -1152,6 +1168,127 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1152
1168
  }
1153
1169
  }
1154
1170
 
1171
+ /**
1172
+ * Dedupe handoffs targeting the same destination.
1173
+ *
1174
+ * Parent LLMs sometimes emit multiple parallel transfer_to_<agent> tool
1175
+ * calls for the same child agent in one model tick (e.g. "send email 1"
1176
+ * and "send email 2" both routed to the Productivity Assistant). LangGraph
1177
+ * would otherwise create two parallel Sends into the same subgraph, which
1178
+ * produces duplicate handoff entries in the UI and leaves extra
1179
+ * tool_call_ids without a clean result when the single child run finishes.
1180
+ *
1181
+ * Merge same-destination handoffs into one Command whose update.messages
1182
+ * carries every original ToolMessage — the primary one has its
1183
+ * Instructions field rebuilt from the combined set, and the rest are kept
1184
+ * verbatim so the parent message history has a ToolMessage for every
1185
+ * tool_call_id (LangChain requires every AI tool_call to be resolved).
1186
+ * The child's _extractTransferContext filters all transfer ToolMessages
1187
+ * out of the child's view, so it only ever sees the merged instructions.
1188
+ */
1189
+ if (handoffCommands.length > 1) {
1190
+ const byDestination = new Map<string, Command[]>();
1191
+ for (const cmd of handoffCommands) {
1192
+ const goto = cmd.goto;
1193
+ const dest = typeof goto === 'string' ? goto : (goto as string[])[0];
1194
+ const arr = byDestination.get(dest) ?? [];
1195
+ arr.push(cmd);
1196
+ byDestination.set(dest, arr);
1197
+ }
1198
+
1199
+ const hasDuplicates = Array.from(byDestination.values()).some(
1200
+ (arr) => arr.length > 1
1201
+ );
1202
+
1203
+ if (hasDuplicates) {
1204
+ const TRANSFER_INSTRUCTIONS_PATTERN =
1205
+ /(?:Instructions?|Context):\s*(.+)/is;
1206
+ const mergedHandoffs: Command[] = [];
1207
+
1208
+ for (const [dest, cmds] of byDestination) {
1209
+ if (cmds.length === 1) {
1210
+ mergedHandoffs.push(cmds[0]);
1211
+ continue;
1212
+ }
1213
+
1214
+ const allToolMessages: ToolMessage[] = [];
1215
+ const allOtherMessages: BaseMessage[] = [];
1216
+ for (const cmd of cmds) {
1217
+ const upd = cmd.update as { messages?: BaseMessage[] } | undefined;
1218
+ for (const m of upd?.messages ?? []) {
1219
+ if (m.getType() === 'tool') {
1220
+ allToolMessages.push(m as ToolMessage);
1221
+ } else {
1222
+ allOtherMessages.push(m);
1223
+ }
1224
+ }
1225
+ }
1226
+
1227
+ if (allToolMessages.length === 0) {
1228
+ mergedHandoffs.push(cmds[0]);
1229
+ continue;
1230
+ }
1231
+
1232
+ const primary = allToolMessages[0];
1233
+ const primaryContent =
1234
+ typeof primary.content === 'string'
1235
+ ? primary.content
1236
+ : JSON.stringify(primary.content);
1237
+
1238
+ const mergedInstructions: string[] = [];
1239
+ for (const tm of allToolMessages) {
1240
+ const c =
1241
+ typeof tm.content === 'string'
1242
+ ? tm.content
1243
+ : JSON.stringify(tm.content);
1244
+ const match = c.match(TRANSFER_INSTRUCTIONS_PATTERN);
1245
+ if (match?.[1]) {
1246
+ const instr = match[1].trim();
1247
+ if (instr && !mergedInstructions.includes(instr)) {
1248
+ mergedInstructions.push(instr);
1249
+ }
1250
+ }
1251
+ }
1252
+
1253
+ let mergedPrimaryContent = primaryContent;
1254
+ if (mergedInstructions.length > 1) {
1255
+ const header = primaryContent
1256
+ .replace(TRANSFER_INSTRUCTIONS_PATTERN, '')
1257
+ .trimEnd();
1258
+ const combinedInstr = mergedInstructions
1259
+ .map((s, i) => `${i + 1}. ${s}`)
1260
+ .join('\n');
1261
+ mergedPrimaryContent =
1262
+ `${header}\nInstructions: ${combinedInstr}`.trim();
1263
+ }
1264
+
1265
+ const mergedPrimary = new ToolMessage({
1266
+ content: mergedPrimaryContent,
1267
+ tool_call_id: primary.tool_call_id,
1268
+ name: primary.name,
1269
+ additional_kwargs: { ...primary.additional_kwargs },
1270
+ });
1271
+
1272
+ const mergedMessages: BaseMessage[] = [
1273
+ mergedPrimary,
1274
+ ...allToolMessages.slice(1),
1275
+ ...allOtherMessages,
1276
+ ];
1277
+
1278
+ mergedHandoffs.push(
1279
+ new Command({
1280
+ graph: Command.PARENT,
1281
+ goto: dest,
1282
+ update: { messages: mergedMessages },
1283
+ })
1284
+ );
1285
+ }
1286
+
1287
+ handoffCommands.length = 0;
1288
+ handoffCommands.push(...mergedHandoffs);
1289
+ }
1290
+ }
1291
+
1155
1292
  /**
1156
1293
  * Handle handoff commands - convert to Send objects for parallel execution
1157
1294
  * when multiple handoffs are requested
@@ -86,7 +86,10 @@ function createTestGraph(
86
86
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
87
87
  compiled: any;
88
88
  notifications: t.ToolApprovalNotification[];
89
- config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; configurable: { thread_id: string } };
89
+ config: Partial<RunnableConfig> & {
90
+ version: 'v1' | 'v2';
91
+ configurable: { thread_id: string };
92
+ };
90
93
  } {
91
94
  const StateAnnotation = Annotation.Root({
92
95
  messages: Annotation<BaseMessage[]>({
@@ -145,7 +148,10 @@ function createTestGraph(
145
148
  const notifications: t.ToolApprovalNotification[] = [];
146
149
 
147
150
  // Collect notification events (data-only, no resolve/reject)
148
- const config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; configurable: { thread_id: string } } = {
151
+ const config: Partial<RunnableConfig> & {
152
+ version: 'v1' | 'v2';
153
+ configurable: { thread_id: string };
154
+ } = {
149
155
  version: 'v2',
150
156
  configurable: {
151
157
  thread_id: `test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
@@ -434,7 +440,12 @@ describe('HITL Tool Approval - Interrupt/Resume Flow', () => {
434
440
 
435
441
  // Resume with denial
436
442
  const result = await compiled.invoke(
437
- new Command({ resume: { approved: false, feedback: 'Not now' } as t.ToolApprovalResponse }),
443
+ new Command({
444
+ resume: {
445
+ approved: false,
446
+ feedback: 'Not now',
447
+ } as t.ToolApprovalResponse,
448
+ }),
438
449
  config
439
450
  );
440
451
 
@@ -742,7 +753,10 @@ describe('HITL Tool Approval - Interrupt/Resume Flow', () => {
742
753
  const checkpointer = new MemorySaver();
743
754
  const compiled = workflow.compile({ checkpointer });
744
755
 
745
- const config: Partial<RunnableConfig> & { version: 'v1' | 'v2'; configurable: { thread_id: string } } = {
756
+ const config: Partial<RunnableConfig> & {
757
+ version: 'v1' | 'v2';
758
+ configurable: { thread_id: string };
759
+ } = {
746
760
  version: 'v2',
747
761
  configurable: {
748
762
  thread_id: `test-multi-${Date.now()}`,
@@ -762,10 +776,7 @@ describe('HITL Tool Approval - Interrupt/Resume Flow', () => {
762
776
  };
763
777
 
764
778
  // First invoke: hits first interrupt
765
- await compiled.invoke(
766
- { messages: [new HumanMessage('Do both')] },
767
- config
768
- );
779
+ await compiled.invoke({ messages: [new HumanMessage('Do both')] }, config);
769
780
 
770
781
  // First interrupt notification received
771
782
  expect(notifications.length).toBeGreaterThanOrEqual(1);
@@ -784,7 +795,9 @@ describe('HITL Tool Approval - Interrupt/Resume Flow', () => {
784
795
 
785
796
  // Notifications fire on every re-execution (interrupt() replays the node),
786
797
  // so we check unique tool names rather than total count.
787
- const uniqueTools = [...new Set(notifications.map((e) => e.toolName))].sort();
798
+ const uniqueTools = [
799
+ ...new Set(notifications.map((e) => e.toolName)),
800
+ ].sort();
788
801
  expect(uniqueTools).toEqual(['echo', 'send_email']);
789
802
  });
790
803
  });
@@ -14,10 +14,10 @@ describe('HITL Approval Constants', () => {
14
14
  expect(ExecutionContext.SCHEDULED).toBe('scheduled');
15
15
  });
16
16
 
17
- it('should only have 2 values (no dead "browser" context)', () => {
17
+ it('should only have 3 values (interactive, scheduled, handoff)', () => {
18
18
  const values = Object.values(ExecutionContext);
19
- expect(values).toHaveLength(2);
20
- expect(values).toEqual(['interactive', 'scheduled']);
19
+ expect(values).toHaveLength(3);
20
+ expect(values).toEqual(['interactive', 'scheduled', 'handoff']);
21
21
  });
22
22
  });
23
23
 
@@ -63,7 +63,7 @@ describe('HITL Approval Constants', () => {
63
63
  });
64
64
 
65
65
  describe('MCP_DELIMITER', () => {
66
- it('should match Constants.mcp_delimiter from @ranger/data-provider', () => {
66
+ it('should match the host data-provider mcp_delimiter constant', () => {
67
67
  expect(MCP_DELIMITER).toBe('_mcp_');
68
68
  });
69
69
 
@@ -2,7 +2,7 @@
2
2
  * Shared HITL (Human-in-the-Loop) constants and enums.
3
3
  *
4
4
  * Single source of truth for approval-related values consumed by
5
- * the agents library, ranger backend, and browser extension.
5
+ * the agents library, host backend, and browser extension.
6
6
  *
7
7
  * @module approval/constants
8
8
  */
@@ -108,6 +108,6 @@ export enum ActionCategory {
108
108
  *
109
109
  * Example: `send_email_mcp_outlook` → tool = `send_email`, server = `outlook`
110
110
  *
111
- * Must match `Constants.mcp_delimiter` in `@ranger/data-provider`.
111
+ * Must match the host data-provider delimiter constant.
112
112
  */
113
113
  export const MCP_DELIMITER = '_mcp_';
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Unit tests for the memory tool family.
3
+ * Exercise the read path, the phase gate on append, and the isolation
4
+ * closure — all through the LangChain tool interface, against a mock backend.
5
+ */
6
+ import { MockMemoryBackend } from '@/memory/__tests__/mockBackend';
7
+ import { buildMemoryTools } from '../index';
8
+ import {
9
+ MEMORY_APPEND_TOOL_NAME,
10
+ MEMORY_GET_TOOL_NAME,
11
+ MEMORY_PHASE_FLUSHING,
12
+ MEMORY_PHASE_NORMAL,
13
+ MEMORY_SEARCH_TOOL_NAME,
14
+ } from '@/memory/constants';
15
+ import type { MemoryPhase } from '@/memory/types';
16
+
17
+ describe('buildMemoryTools', () => {
18
+ const scope = { agentId: 'sales', userId: 'alice' };
19
+
20
+ async function callTool(t: unknown, args: Record<string, unknown>) {
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ return (t as any).invoke(args) as Promise<string>;
23
+ }
24
+
25
+ it('exposes memory_search and memory_get in readOnly mode', () => {
26
+ const backend = new MockMemoryBackend();
27
+ const tools = buildMemoryTools({ backend, scope, readOnly: true });
28
+ const names = tools.map((t) => (t as { name: string }).name);
29
+ expect(names).toEqual([MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME]);
30
+ });
31
+
32
+ it('attaches memory_append when not readOnly', () => {
33
+ const backend = new MockMemoryBackend();
34
+ let phase: MemoryPhase = MEMORY_PHASE_NORMAL;
35
+ const tools = buildMemoryTools({
36
+ backend,
37
+ scope,
38
+ getPhase: () => phase,
39
+ });
40
+ const names = tools.map((t) => (t as { name: string }).name);
41
+ expect(names).toContain(MEMORY_APPEND_TOOL_NAME);
42
+ });
43
+
44
+ it('memory_search returns hits from the agent workspace only', async () => {
45
+ const backend = new MockMemoryBackend();
46
+ await backend.append(scope, {
47
+ path: 'memory/agent/playbook.md',
48
+ content: 'chose Postgres over Mongo for vector search',
49
+ });
50
+ await backend.append(
51
+ { agentId: 'support', userId: 'alice' },
52
+ { path: 'memory/agent/playbook.md', content: 'unrelated support note' }
53
+ );
54
+ const [search] = buildMemoryTools({ backend, scope, readOnly: true });
55
+ const result = JSON.parse(await callTool(search, { query: 'Postgres' }));
56
+ expect(result.results).toHaveLength(1);
57
+ expect(result.results[0].content).toContain('Postgres');
58
+ });
59
+
60
+ it('different user of same agent sees shared workspace', async () => {
61
+ const backend = new MockMemoryBackend();
62
+ // Alice writes in her session.
63
+ await backend.append(
64
+ { agentId: 'sales', userId: 'alice' },
65
+ {
66
+ path: 'memory/agent/playbook.md',
67
+ content: 'Alice learned pricing tiers',
68
+ }
69
+ );
70
+ // Bob searches in his session — same agent, shared workspace.
71
+ const [search] = buildMemoryTools({
72
+ backend,
73
+ scope: { agentId: 'sales', userId: 'bob' },
74
+ readOnly: true,
75
+ });
76
+ const result = JSON.parse(await callTool(search, { query: 'pricing' }));
77
+ expect(result.results).toHaveLength(1);
78
+ expect(result.results[0].content).toContain('pricing');
79
+ });
80
+
81
+ it('memory_append rejects outside memory_flushing phase', async () => {
82
+ const backend = new MockMemoryBackend();
83
+ const tools = buildMemoryTools({
84
+ backend,
85
+ scope,
86
+ getPhase: () => MEMORY_PHASE_NORMAL,
87
+ });
88
+ const append = tools.find(
89
+ (t) => (t as { name: string }).name === MEMORY_APPEND_TOOL_NAME
90
+ )!;
91
+ const result = JSON.parse(
92
+ await callTool(append, {
93
+ path: 'memory/agent/playbook.md',
94
+ content: 'hello',
95
+ })
96
+ );
97
+ expect(result.ok).toBe(false);
98
+ expect(result.error).toMatch(/reflection/);
99
+ expect(backend.allRows()).toHaveLength(0);
100
+ });
101
+
102
+ it('memory_append persists during memory_flushing phase', async () => {
103
+ const backend = new MockMemoryBackend();
104
+ const phase: MemoryPhase = MEMORY_PHASE_FLUSHING;
105
+ const tools = buildMemoryTools({
106
+ backend,
107
+ scope,
108
+ getPhase: () => phase,
109
+ });
110
+ const append = tools.find(
111
+ (t) => (t as { name: string }).name === MEMORY_APPEND_TOOL_NAME
112
+ )!;
113
+ const result = JSON.parse(
114
+ await callTool(append, {
115
+ path: 'memory/agent/playbook.md',
116
+ content: 'I decided to use pgvector.',
117
+ })
118
+ );
119
+ expect(result.ok).toBe(true);
120
+ expect(backend.allRows()).toHaveLength(1);
121
+ // Agent-tier row — scoping user_id is NULL, latest writer recorded
122
+ // as provenance via lastUserId.
123
+ expect(backend.allRows()[0].userId).toBeNull();
124
+ expect(backend.allRows()[0].lastUserId).toBe('alice');
125
+ });
126
+
127
+ it('memory_append merges multiple notes into one row per path', async () => {
128
+ const backend = new MockMemoryBackend();
129
+ const tools = buildMemoryTools({
130
+ backend,
131
+ scope,
132
+ getPhase: () => MEMORY_PHASE_FLUSHING,
133
+ });
134
+ const append = tools.find(
135
+ (t) => (t as { name: string }).name === MEMORY_APPEND_TOOL_NAME
136
+ )!;
137
+ await callTool(append, {
138
+ path: 'memory/agent/playbook.md',
139
+ content: 'first note',
140
+ });
141
+ await callTool(append, {
142
+ path: 'memory/agent/playbook.md',
143
+ content: 'second note',
144
+ });
145
+ const rows = backend.allRows();
146
+ expect(rows).toHaveLength(1);
147
+ expect(rows[0].content).toContain('first note');
148
+ expect(rows[0].content).toContain('second note');
149
+ });
150
+
151
+ it('memory_append rejects paths outside memory/', async () => {
152
+ const backend = new MockMemoryBackend();
153
+ const tools = buildMemoryTools({
154
+ backend,
155
+ scope,
156
+ getPhase: () => MEMORY_PHASE_FLUSHING,
157
+ });
158
+ const append = tools.find(
159
+ (t) => (t as { name: string }).name === MEMORY_APPEND_TOOL_NAME
160
+ )!;
161
+ const result = JSON.parse(
162
+ await callTool(append, {
163
+ path: 'secret.md',
164
+ content: 'escape',
165
+ })
166
+ );
167
+ expect(result.ok).toBe(false);
168
+ expect(result.error).toMatch(/memory\//);
169
+ });
170
+
171
+ it('memory_append rejects non-whitelisted paths even with memory/ prefix', async () => {
172
+ const backend = new MockMemoryBackend();
173
+ const tools = buildMemoryTools({
174
+ backend,
175
+ scope,
176
+ getPhase: () => MEMORY_PHASE_FLUSHING,
177
+ });
178
+ const append = tools.find(
179
+ (t) => (t as { name: string }).name === MEMORY_APPEND_TOOL_NAME
180
+ )!;
181
+ const result = JSON.parse(
182
+ await callTool(append, {
183
+ path: 'memory/random-notes.md',
184
+ content: 'escape',
185
+ })
186
+ );
187
+ expect(result.ok).toBe(false);
188
+ expect(result.error).toMatch(/whitelist/i);
189
+ });
190
+
191
+ it('tool args cannot inject agent_id into scope', async () => {
192
+ const backend = new MockMemoryBackend();
193
+ await backend.append(
194
+ { agentId: 'victim', userId: 'alice' },
195
+ { path: 'memory/agent/playbook.md', content: 'secret data' }
196
+ );
197
+ const [search] = buildMemoryTools({ backend, scope, readOnly: true });
198
+ // Try to pass a forged agent_id — Zod strips it, closure wins.
199
+ const result = JSON.parse(
200
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
201
+ await callTool(search, { query: 'secret', agent_id: 'victim' } as any)
202
+ );
203
+ expect(result.results).toHaveLength(0);
204
+ });
205
+ });