@librechat/agents 3.4.7 → 3.5.1

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 (924) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +0 -356
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/agents/projection.cjs +0 -9
  4. package/dist/cjs/agents/projection.cjs.map +1 -1
  5. package/dist/cjs/common/constants.cjs +0 -26
  6. package/dist/cjs/common/constants.cjs.map +1 -1
  7. package/dist/cjs/common/enum.cjs +0 -85
  8. package/dist/cjs/common/enum.cjs.map +1 -1
  9. package/dist/cjs/events.cjs +0 -14
  10. package/dist/cjs/events.cjs.map +1 -1
  11. package/dist/cjs/graphs/Graph.cjs +1 -909
  12. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  13. package/dist/cjs/graphs/MultiAgentGraph.cjs +7 -272
  14. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  15. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs.map +1 -1
  16. package/dist/cjs/graphs/createGraph.cjs.map +1 -1
  17. package/dist/cjs/hitl/askUserQuestion.cjs +0 -53
  18. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
  19. package/dist/cjs/hitl/askUserQuestions.cjs +0 -20
  20. package/dist/cjs/hitl/askUserQuestions.cjs.map +1 -1
  21. package/dist/cjs/hitl/askUserQuestionsInterrupt.cjs +0 -6
  22. package/dist/cjs/hitl/askUserQuestionsInterrupt.cjs.map +1 -1
  23. package/dist/cjs/hooks/HookRegistry.cjs +0 -127
  24. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  25. package/dist/cjs/hooks/createToolPolicyHook.cjs +0 -38
  26. package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -1
  27. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +0 -77
  28. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -1
  29. package/dist/cjs/hooks/executeHooks.cjs +0 -79
  30. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  31. package/dist/cjs/hooks/index.cjs +0 -16
  32. package/dist/cjs/hooks/index.cjs.map +1 -1
  33. package/dist/cjs/hooks/matchers.cjs +0 -104
  34. package/dist/cjs/hooks/matchers.cjs.map +1 -1
  35. package/dist/cjs/hooks/types.cjs +0 -8
  36. package/dist/cjs/hooks/types.cjs.map +1 -1
  37. package/dist/cjs/instrumentation.cjs +0 -14
  38. package/dist/cjs/instrumentation.cjs.map +1 -1
  39. package/dist/cjs/langfuse.cjs +0 -75
  40. package/dist/cjs/langfuse.cjs.map +1 -1
  41. package/dist/cjs/langfuseConfig.cjs.map +1 -1
  42. package/dist/cjs/langfuseRuntimeContext.cjs +0 -13
  43. package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
  44. package/dist/cjs/langfuseRuntimeScope.cjs +0 -4
  45. package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
  46. package/dist/cjs/langfuseSpanRegistry.cjs +0 -28
  47. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -1
  48. package/dist/cjs/langfuseToolOutputTracing.cjs +0 -3
  49. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  50. package/dist/cjs/langfuseTraceShaping.cjs +0 -74
  51. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  52. package/dist/cjs/llm/anthropic/index.cjs +0 -3
  53. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  54. package/dist/cjs/llm/anthropic/types.cjs.map +1 -1
  55. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +1 -111
  56. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  57. package/dist/cjs/llm/anthropic/utils/message_outputs.cjs +0 -1
  58. package/dist/cjs/llm/anthropic/utils/message_outputs.cjs.map +1 -1
  59. package/dist/cjs/llm/anthropic/utils/stream_events.cjs +0 -8
  60. package/dist/cjs/llm/anthropic/utils/stream_events.cjs.map +1 -1
  61. package/dist/cjs/llm/anthropic/utils/tools.cjs.map +1 -1
  62. package/dist/cjs/llm/bedrock/cachePoints.cjs.map +1 -1
  63. package/dist/cjs/llm/bedrock/index.cjs +1 -95
  64. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  65. package/dist/cjs/llm/bedrock/toolCache.cjs.map +1 -1
  66. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +0 -78
  67. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  68. package/dist/cjs/llm/bedrock/utils/message_outputs.cjs +0 -24
  69. package/dist/cjs/llm/bedrock/utils/message_outputs.cjs.map +1 -1
  70. package/dist/cjs/llm/contextOverflowRecovery.cjs +0 -62
  71. package/dist/cjs/llm/contextOverflowRecovery.cjs.map +1 -1
  72. package/dist/cjs/llm/fake.cjs.map +1 -1
  73. package/dist/cjs/llm/google/index.cjs +2 -21
  74. package/dist/cjs/llm/google/index.cjs.map +1 -1
  75. package/dist/cjs/llm/google/utils/common.cjs +0 -35
  76. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  77. package/dist/cjs/llm/init.cjs +0 -9
  78. package/dist/cjs/llm/init.cjs.map +1 -1
  79. package/dist/cjs/llm/invoke.cjs +1 -270
  80. package/dist/cjs/llm/invoke.cjs.map +1 -1
  81. package/dist/cjs/llm/mistral/index.cjs.map +1 -1
  82. package/dist/cjs/llm/openai/index.cjs +0 -99
  83. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  84. package/dist/cjs/llm/openai/streamMetadata.cjs +0 -21
  85. package/dist/cjs/llm/openai/streamMetadata.cjs.map +1 -1
  86. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  87. package/dist/cjs/llm/openrouter/index.cjs +0 -1
  88. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  89. package/dist/cjs/llm/openrouter/toolCache.cjs +0 -6
  90. package/dist/cjs/llm/openrouter/toolCache.cjs.map +1 -1
  91. package/dist/cjs/llm/preempt.cjs +0 -75
  92. package/dist/cjs/llm/preempt.cjs.map +1 -1
  93. package/dist/cjs/llm/providers.cjs.map +1 -1
  94. package/dist/cjs/llm/request.cjs +0 -10
  95. package/dist/cjs/llm/request.cjs.map +1 -1
  96. package/dist/cjs/llm/stream/chunkAdapters.cjs +0 -62
  97. package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -1
  98. package/dist/cjs/llm/stream/smoother.cjs +0 -58
  99. package/dist/cjs/llm/stream/smoother.cjs.map +1 -1
  100. package/dist/cjs/llm/streamLimits.cjs +4 -293
  101. package/dist/cjs/llm/streamLimits.cjs.map +1 -1
  102. package/dist/cjs/llm/truncation.cjs +0 -36
  103. package/dist/cjs/llm/truncation.cjs.map +1 -1
  104. package/dist/cjs/llm/vertexai/index.cjs +0 -330
  105. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  106. package/dist/cjs/messages/alternation.cjs +0 -43
  107. package/dist/cjs/messages/alternation.cjs.map +1 -1
  108. package/dist/cjs/messages/anthropicToolCache.cjs +0 -45
  109. package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -1
  110. package/dist/cjs/messages/assistantPhase.cjs +0 -12
  111. package/dist/cjs/messages/assistantPhase.cjs.map +1 -1
  112. package/dist/cjs/messages/budget.cjs +0 -9
  113. package/dist/cjs/messages/budget.cjs.map +1 -1
  114. package/dist/cjs/messages/cache.cjs +0 -172
  115. package/dist/cjs/messages/cache.cjs.map +1 -1
  116. package/dist/cjs/messages/content.cjs +0 -9
  117. package/dist/cjs/messages/content.cjs.map +1 -1
  118. package/dist/cjs/messages/contextPruning.cjs +0 -15
  119. package/dist/cjs/messages/contextPruning.cjs.map +1 -1
  120. package/dist/cjs/messages/contextPruningSettings.cjs +0 -3
  121. package/dist/cjs/messages/contextPruningSettings.cjs.map +1 -1
  122. package/dist/cjs/messages/core.cjs +0 -48
  123. package/dist/cjs/messages/core.cjs.map +1 -1
  124. package/dist/cjs/messages/format.cjs +1 -345
  125. package/dist/cjs/messages/format.cjs.map +1 -1
  126. package/dist/cjs/messages/handoffCue.cjs +0 -36
  127. package/dist/cjs/messages/handoffCue.cjs.map +1 -1
  128. package/dist/cjs/messages/ids.cjs.map +1 -1
  129. package/dist/cjs/messages/injected.cjs +0 -33
  130. package/dist/cjs/messages/injected.cjs.map +1 -1
  131. package/dist/cjs/messages/langchain.cjs +0 -14
  132. package/dist/cjs/messages/langchain.cjs.map +1 -1
  133. package/dist/cjs/messages/prune.cjs +1 -184
  134. package/dist/cjs/messages/prune.cjs.map +1 -1
  135. package/dist/cjs/messages/recency.cjs +0 -32
  136. package/dist/cjs/messages/recency.cjs.map +1 -1
  137. package/dist/cjs/messages/reducer.cjs +0 -30
  138. package/dist/cjs/messages/reducer.cjs.map +1 -1
  139. package/dist/cjs/messages/tools.cjs +0 -14
  140. package/dist/cjs/messages/tools.cjs.map +1 -1
  141. package/dist/cjs/openai/index.cjs.map +1 -1
  142. package/dist/cjs/prompts/activityLabel.cjs +9 -79
  143. package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
  144. package/dist/cjs/responses/index.cjs.map +1 -1
  145. package/dist/cjs/run.cjs +2 -538
  146. package/dist/cjs/run.cjs.map +1 -1
  147. package/dist/cjs/session/AgentSession.cjs +1 -17
  148. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  149. package/dist/cjs/session/JsonlSessionStore.cjs.map +1 -1
  150. package/dist/cjs/session/handlers.cjs +0 -7
  151. package/dist/cjs/session/handlers.cjs.map +1 -1
  152. package/dist/cjs/session/ids.cjs.map +1 -1
  153. package/dist/cjs/session/messageSerialization.cjs +0 -4
  154. package/dist/cjs/session/messageSerialization.cjs.map +1 -1
  155. package/dist/cjs/stream.cjs +1 -93
  156. package/dist/cjs/stream.cjs.map +1 -1
  157. package/dist/cjs/summarization/index.cjs +0 -27
  158. package/dist/cjs/summarization/index.cjs.map +1 -1
  159. package/dist/cjs/summarization/node.cjs +0 -273
  160. package/dist/cjs/summarization/node.cjs.map +1 -1
  161. package/dist/cjs/tools/BashExecutor.cjs +0 -35
  162. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  163. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +0 -24
  164. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  165. package/dist/cjs/tools/Calculator.cjs.map +1 -1
  166. package/dist/cjs/tools/CodeExecutor.cjs +0 -13
  167. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  168. package/dist/cjs/tools/CodeSessionFileSummary.cjs.map +1 -1
  169. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +0 -101
  170. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  171. package/dist/cjs/tools/ReadFile.cjs.map +1 -1
  172. package/dist/cjs/tools/SkillTool.cjs +0 -5
  173. package/dist/cjs/tools/SkillTool.cjs.map +1 -1
  174. package/dist/cjs/tools/SubagentTool.cjs +0 -10
  175. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  176. package/dist/cjs/tools/ToolNode.cjs +6 -1038
  177. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  178. package/dist/cjs/tools/ToolSearch.cjs +0 -179
  179. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  180. package/dist/cjs/tools/cloudflare/CloudflareBridgeRuntime.cjs.map +1 -1
  181. package/dist/cjs/tools/cloudflare/CloudflareProgrammaticToolCalling.cjs.map +1 -1
  182. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs +0 -39
  183. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs.map +1 -1
  184. package/dist/cjs/tools/cloudflare/CloudflareSandboxTools.cjs.map +1 -1
  185. package/dist/cjs/tools/eagerEventExecution.cjs +0 -6
  186. package/dist/cjs/tools/eagerEventExecution.cjs.map +1 -1
  187. package/dist/cjs/tools/handlers.cjs +1 -41
  188. package/dist/cjs/tools/handlers.cjs.map +1 -1
  189. package/dist/cjs/tools/intentArg.cjs +1 -132
  190. package/dist/cjs/tools/intentArg.cjs.map +1 -1
  191. package/dist/cjs/tools/local/CompileCheckTool.cjs +0 -25
  192. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
  193. package/dist/cjs/tools/local/FileCheckpointer.cjs +0 -23
  194. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -1
  195. package/dist/cjs/tools/local/LocalCodingTools.cjs +0 -80
  196. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
  197. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +0 -126
  198. package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -1
  199. package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -1
  200. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +0 -12
  201. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -1
  202. package/dist/cjs/tools/local/attachments.cjs +0 -44
  203. package/dist/cjs/tools/local/attachments.cjs.map +1 -1
  204. package/dist/cjs/tools/local/bashAst.cjs +0 -16
  205. package/dist/cjs/tools/local/bashAst.cjs.map +1 -1
  206. package/dist/cjs/tools/local/editStrategies.cjs +0 -14
  207. package/dist/cjs/tools/local/editStrategies.cjs.map +1 -1
  208. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -1
  209. package/dist/cjs/tools/local/syntaxCheck.cjs +0 -31
  210. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -1
  211. package/dist/cjs/tools/local/textEncoding.cjs +0 -11
  212. package/dist/cjs/tools/local/textEncoding.cjs.map +1 -1
  213. package/dist/cjs/tools/local/workspaceFS.cjs +0 -33
  214. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -1
  215. package/dist/cjs/tools/ptcTimeout.cjs.map +1 -1
  216. package/dist/cjs/tools/schema.cjs +0 -8
  217. package/dist/cjs/tools/schema.cjs.map +1 -1
  218. package/dist/cjs/tools/search/anthropic.cjs +0 -9
  219. package/dist/cjs/tools/search/anthropic.cjs.map +1 -1
  220. package/dist/cjs/tools/search/content.cjs.map +1 -1
  221. package/dist/cjs/tools/search/crw-scraper.cjs +0 -33
  222. package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -1
  223. package/dist/cjs/tools/search/crw-search.cjs +0 -2
  224. package/dist/cjs/tools/search/crw-search.cjs.map +1 -1
  225. package/dist/cjs/tools/search/firecrawl.cjs +0 -25
  226. package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
  227. package/dist/cjs/tools/search/format.cjs +0 -30
  228. package/dist/cjs/tools/search/format.cjs.map +1 -1
  229. package/dist/cjs/tools/search/highlights.cjs +0 -16
  230. package/dist/cjs/tools/search/highlights.cjs.map +1 -1
  231. package/dist/cjs/tools/search/keenable-scraper.cjs +0 -8
  232. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -1
  233. package/dist/cjs/tools/search/keenable-search.cjs +0 -7
  234. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -1
  235. package/dist/cjs/tools/search/rerankers.cjs +0 -35
  236. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  237. package/dist/cjs/tools/search/schema.cjs +0 -1
  238. package/dist/cjs/tools/search/schema.cjs.map +1 -1
  239. package/dist/cjs/tools/search/search.cjs +1 -14
  240. package/dist/cjs/tools/search/search.cjs.map +1 -1
  241. package/dist/cjs/tools/search/serper-scraper.cjs +0 -46
  242. package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
  243. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  244. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  245. package/dist/cjs/tools/search/tool.cjs +0 -25
  246. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  247. package/dist/cjs/tools/search/utils.cjs +0 -8
  248. package/dist/cjs/tools/search/utils.cjs.map +1 -1
  249. package/dist/cjs/tools/skillCatalog.cjs +0 -6
  250. package/dist/cjs/tools/skillCatalog.cjs.map +1 -1
  251. package/dist/cjs/tools/streamedToolCallSeals.cjs +0 -6
  252. package/dist/cjs/tools/streamedToolCallSeals.cjs.map +1 -1
  253. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs +0 -2
  254. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs.map +1 -1
  255. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +3 -213
  256. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  257. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
  258. package/dist/cjs/tools/subagent/childGraphConfig.cjs +0 -4
  259. package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -1
  260. package/dist/cjs/tools/subagent/runtimeLimits.cjs.map +1 -1
  261. package/dist/cjs/tools/toolOutputReferences.cjs +1 -322
  262. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  263. package/dist/cjs/types/hitl.cjs +0 -5
  264. package/dist/cjs/types/hitl.cjs.map +1 -1
  265. package/dist/cjs/utils/callbacks.cjs.map +1 -1
  266. package/dist/cjs/utils/errors.cjs +14 -165
  267. package/dist/cjs/utils/errors.cjs.map +1 -1
  268. package/dist/cjs/utils/events.cjs +0 -10
  269. package/dist/cjs/utils/events.cjs.map +1 -1
  270. package/dist/cjs/utils/graph.cjs.map +1 -1
  271. package/dist/cjs/utils/handlers.cjs +0 -3
  272. package/dist/cjs/utils/handlers.cjs.map +1 -1
  273. package/dist/cjs/utils/llm.cjs +0 -1
  274. package/dist/cjs/utils/llm.cjs.map +1 -1
  275. package/dist/cjs/utils/misc.cjs +0 -16
  276. package/dist/cjs/utils/misc.cjs.map +1 -1
  277. package/dist/cjs/utils/proxy.cjs +0 -26
  278. package/dist/cjs/utils/proxy.cjs.map +1 -1
  279. package/dist/cjs/utils/run.cjs +0 -6
  280. package/dist/cjs/utils/run.cjs.map +1 -1
  281. package/dist/cjs/utils/schema.cjs +0 -5
  282. package/dist/cjs/utils/schema.cjs.map +1 -1
  283. package/dist/cjs/utils/title.cjs +0 -6
  284. package/dist/cjs/utils/title.cjs.map +1 -1
  285. package/dist/cjs/utils/tokens.cjs +0 -113
  286. package/dist/cjs/utils/tokens.cjs.map +1 -1
  287. package/dist/cjs/utils/toolContent.cjs +0 -41
  288. package/dist/cjs/utils/toolContent.cjs.map +1 -1
  289. package/dist/cjs/utils/truncation.cjs +0 -61
  290. package/dist/cjs/utils/truncation.cjs.map +1 -1
  291. package/dist/esm/agents/AgentContext.mjs +0 -356
  292. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  293. package/dist/esm/agents/projection.mjs +0 -9
  294. package/dist/esm/agents/projection.mjs.map +1 -1
  295. package/dist/esm/common/constants.mjs +0 -26
  296. package/dist/esm/common/constants.mjs.map +1 -1
  297. package/dist/esm/common/enum.mjs +0 -85
  298. package/dist/esm/common/enum.mjs.map +1 -1
  299. package/dist/esm/events.mjs +0 -14
  300. package/dist/esm/events.mjs.map +1 -1
  301. package/dist/esm/graphs/Graph.mjs +1 -909
  302. package/dist/esm/graphs/Graph.mjs.map +1 -1
  303. package/dist/esm/graphs/MultiAgentGraph.mjs +7 -272
  304. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  305. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs.map +1 -1
  306. package/dist/esm/graphs/createGraph.mjs.map +1 -1
  307. package/dist/esm/hitl/askUserQuestion.mjs +0 -53
  308. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
  309. package/dist/esm/hitl/askUserQuestions.mjs +0 -20
  310. package/dist/esm/hitl/askUserQuestions.mjs.map +1 -1
  311. package/dist/esm/hitl/askUserQuestionsInterrupt.mjs +0 -6
  312. package/dist/esm/hitl/askUserQuestionsInterrupt.mjs.map +1 -1
  313. package/dist/esm/hooks/HookRegistry.mjs +0 -127
  314. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  315. package/dist/esm/hooks/createToolPolicyHook.mjs +0 -38
  316. package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -1
  317. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +0 -77
  318. package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -1
  319. package/dist/esm/hooks/executeHooks.mjs +0 -79
  320. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  321. package/dist/esm/hooks/index.mjs +0 -16
  322. package/dist/esm/hooks/index.mjs.map +1 -1
  323. package/dist/esm/hooks/matchers.mjs +0 -104
  324. package/dist/esm/hooks/matchers.mjs.map +1 -1
  325. package/dist/esm/hooks/types.mjs +0 -8
  326. package/dist/esm/hooks/types.mjs.map +1 -1
  327. package/dist/esm/instrumentation.mjs +0 -14
  328. package/dist/esm/instrumentation.mjs.map +1 -1
  329. package/dist/esm/langfuse.mjs +0 -75
  330. package/dist/esm/langfuse.mjs.map +1 -1
  331. package/dist/esm/langfuseConfig.mjs.map +1 -1
  332. package/dist/esm/langfuseRuntimeContext.mjs +0 -13
  333. package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
  334. package/dist/esm/langfuseRuntimeScope.mjs +0 -4
  335. package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
  336. package/dist/esm/langfuseSpanRegistry.mjs +0 -28
  337. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -1
  338. package/dist/esm/langfuseToolOutputTracing.mjs +0 -3
  339. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  340. package/dist/esm/langfuseTraceShaping.mjs +0 -74
  341. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  342. package/dist/esm/llm/anthropic/index.mjs +0 -3
  343. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  344. package/dist/esm/llm/anthropic/types.mjs.map +1 -1
  345. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +1 -111
  346. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  347. package/dist/esm/llm/anthropic/utils/message_outputs.mjs +0 -1
  348. package/dist/esm/llm/anthropic/utils/message_outputs.mjs.map +1 -1
  349. package/dist/esm/llm/anthropic/utils/stream_events.mjs +0 -8
  350. package/dist/esm/llm/anthropic/utils/stream_events.mjs.map +1 -1
  351. package/dist/esm/llm/anthropic/utils/tools.mjs.map +1 -1
  352. package/dist/esm/llm/bedrock/cachePoints.mjs.map +1 -1
  353. package/dist/esm/llm/bedrock/index.mjs +1 -95
  354. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  355. package/dist/esm/llm/bedrock/toolCache.mjs.map +1 -1
  356. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +0 -78
  357. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  358. package/dist/esm/llm/bedrock/utils/message_outputs.mjs +0 -24
  359. package/dist/esm/llm/bedrock/utils/message_outputs.mjs.map +1 -1
  360. package/dist/esm/llm/contextOverflowRecovery.mjs +0 -62
  361. package/dist/esm/llm/contextOverflowRecovery.mjs.map +1 -1
  362. package/dist/esm/llm/fake.mjs.map +1 -1
  363. package/dist/esm/llm/google/index.mjs +2 -21
  364. package/dist/esm/llm/google/index.mjs.map +1 -1
  365. package/dist/esm/llm/google/utils/common.mjs +0 -35
  366. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  367. package/dist/esm/llm/init.mjs +0 -9
  368. package/dist/esm/llm/init.mjs.map +1 -1
  369. package/dist/esm/llm/invoke.mjs +1 -270
  370. package/dist/esm/llm/invoke.mjs.map +1 -1
  371. package/dist/esm/llm/mistral/index.mjs.map +1 -1
  372. package/dist/esm/llm/openai/index.mjs +0 -99
  373. package/dist/esm/llm/openai/index.mjs.map +1 -1
  374. package/dist/esm/llm/openai/streamMetadata.mjs +0 -21
  375. package/dist/esm/llm/openai/streamMetadata.mjs.map +1 -1
  376. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  377. package/dist/esm/llm/openrouter/index.mjs +0 -1
  378. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  379. package/dist/esm/llm/openrouter/toolCache.mjs +0 -6
  380. package/dist/esm/llm/openrouter/toolCache.mjs.map +1 -1
  381. package/dist/esm/llm/preempt.mjs +0 -75
  382. package/dist/esm/llm/preempt.mjs.map +1 -1
  383. package/dist/esm/llm/providers.mjs.map +1 -1
  384. package/dist/esm/llm/request.mjs +0 -10
  385. package/dist/esm/llm/request.mjs.map +1 -1
  386. package/dist/esm/llm/stream/chunkAdapters.mjs +0 -62
  387. package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -1
  388. package/dist/esm/llm/stream/smoother.mjs +0 -58
  389. package/dist/esm/llm/stream/smoother.mjs.map +1 -1
  390. package/dist/esm/llm/streamLimits.mjs +4 -293
  391. package/dist/esm/llm/streamLimits.mjs.map +1 -1
  392. package/dist/esm/llm/truncation.mjs +0 -36
  393. package/dist/esm/llm/truncation.mjs.map +1 -1
  394. package/dist/esm/llm/vertexai/index.mjs +0 -330
  395. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  396. package/dist/esm/messages/alternation.mjs +0 -43
  397. package/dist/esm/messages/alternation.mjs.map +1 -1
  398. package/dist/esm/messages/anthropicToolCache.mjs +0 -45
  399. package/dist/esm/messages/anthropicToolCache.mjs.map +1 -1
  400. package/dist/esm/messages/assistantPhase.mjs +0 -12
  401. package/dist/esm/messages/assistantPhase.mjs.map +1 -1
  402. package/dist/esm/messages/budget.mjs +0 -9
  403. package/dist/esm/messages/budget.mjs.map +1 -1
  404. package/dist/esm/messages/cache.mjs +0 -172
  405. package/dist/esm/messages/cache.mjs.map +1 -1
  406. package/dist/esm/messages/content.mjs +0 -9
  407. package/dist/esm/messages/content.mjs.map +1 -1
  408. package/dist/esm/messages/contextPruning.mjs +0 -15
  409. package/dist/esm/messages/contextPruning.mjs.map +1 -1
  410. package/dist/esm/messages/contextPruningSettings.mjs +0 -3
  411. package/dist/esm/messages/contextPruningSettings.mjs.map +1 -1
  412. package/dist/esm/messages/core.mjs +0 -48
  413. package/dist/esm/messages/core.mjs.map +1 -1
  414. package/dist/esm/messages/format.mjs +1 -345
  415. package/dist/esm/messages/format.mjs.map +1 -1
  416. package/dist/esm/messages/handoffCue.mjs +0 -36
  417. package/dist/esm/messages/handoffCue.mjs.map +1 -1
  418. package/dist/esm/messages/ids.mjs.map +1 -1
  419. package/dist/esm/messages/injected.mjs +0 -33
  420. package/dist/esm/messages/injected.mjs.map +1 -1
  421. package/dist/esm/messages/langchain.mjs +0 -14
  422. package/dist/esm/messages/langchain.mjs.map +1 -1
  423. package/dist/esm/messages/prune.mjs +1 -184
  424. package/dist/esm/messages/prune.mjs.map +1 -1
  425. package/dist/esm/messages/recency.mjs +0 -32
  426. package/dist/esm/messages/recency.mjs.map +1 -1
  427. package/dist/esm/messages/reducer.mjs +0 -30
  428. package/dist/esm/messages/reducer.mjs.map +1 -1
  429. package/dist/esm/messages/tools.mjs +0 -14
  430. package/dist/esm/messages/tools.mjs.map +1 -1
  431. package/dist/esm/openai/index.mjs.map +1 -1
  432. package/dist/esm/prompts/activityLabel.mjs +9 -79
  433. package/dist/esm/prompts/activityLabel.mjs.map +1 -1
  434. package/dist/esm/responses/index.mjs.map +1 -1
  435. package/dist/esm/run.mjs +2 -538
  436. package/dist/esm/run.mjs.map +1 -1
  437. package/dist/esm/session/AgentSession.mjs +1 -17
  438. package/dist/esm/session/AgentSession.mjs.map +1 -1
  439. package/dist/esm/session/JsonlSessionStore.mjs.map +1 -1
  440. package/dist/esm/session/handlers.mjs +0 -7
  441. package/dist/esm/session/handlers.mjs.map +1 -1
  442. package/dist/esm/session/ids.mjs.map +1 -1
  443. package/dist/esm/session/messageSerialization.mjs +0 -4
  444. package/dist/esm/session/messageSerialization.mjs.map +1 -1
  445. package/dist/esm/stream.mjs +1 -93
  446. package/dist/esm/stream.mjs.map +1 -1
  447. package/dist/esm/summarization/index.mjs +0 -27
  448. package/dist/esm/summarization/index.mjs.map +1 -1
  449. package/dist/esm/summarization/node.mjs +0 -273
  450. package/dist/esm/summarization/node.mjs.map +1 -1
  451. package/dist/esm/tools/BashExecutor.mjs +0 -35
  452. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  453. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +0 -24
  454. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  455. package/dist/esm/tools/Calculator.mjs.map +1 -1
  456. package/dist/esm/tools/CodeExecutor.mjs +0 -13
  457. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  458. package/dist/esm/tools/CodeSessionFileSummary.mjs.map +1 -1
  459. package/dist/esm/tools/ProgrammaticToolCalling.mjs +0 -101
  460. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  461. package/dist/esm/tools/ReadFile.mjs.map +1 -1
  462. package/dist/esm/tools/SkillTool.mjs +0 -5
  463. package/dist/esm/tools/SkillTool.mjs.map +1 -1
  464. package/dist/esm/tools/SubagentTool.mjs +0 -10
  465. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  466. package/dist/esm/tools/ToolNode.mjs +6 -1038
  467. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  468. package/dist/esm/tools/ToolSearch.mjs +0 -179
  469. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  470. package/dist/esm/tools/cloudflare/CloudflareBridgeRuntime.mjs.map +1 -1
  471. package/dist/esm/tools/cloudflare/CloudflareProgrammaticToolCalling.mjs.map +1 -1
  472. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs +0 -39
  473. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs.map +1 -1
  474. package/dist/esm/tools/cloudflare/CloudflareSandboxTools.mjs.map +1 -1
  475. package/dist/esm/tools/eagerEventExecution.mjs +0 -6
  476. package/dist/esm/tools/eagerEventExecution.mjs.map +1 -1
  477. package/dist/esm/tools/handlers.mjs +1 -41
  478. package/dist/esm/tools/handlers.mjs.map +1 -1
  479. package/dist/esm/tools/intentArg.mjs +1 -132
  480. package/dist/esm/tools/intentArg.mjs.map +1 -1
  481. package/dist/esm/tools/local/CompileCheckTool.mjs +0 -25
  482. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
  483. package/dist/esm/tools/local/FileCheckpointer.mjs +0 -23
  484. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -1
  485. package/dist/esm/tools/local/LocalCodingTools.mjs +0 -80
  486. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
  487. package/dist/esm/tools/local/LocalExecutionEngine.mjs +0 -126
  488. package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -1
  489. package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -1
  490. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +0 -12
  491. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -1
  492. package/dist/esm/tools/local/attachments.mjs +0 -44
  493. package/dist/esm/tools/local/attachments.mjs.map +1 -1
  494. package/dist/esm/tools/local/bashAst.mjs +0 -16
  495. package/dist/esm/tools/local/bashAst.mjs.map +1 -1
  496. package/dist/esm/tools/local/editStrategies.mjs +0 -14
  497. package/dist/esm/tools/local/editStrategies.mjs.map +1 -1
  498. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -1
  499. package/dist/esm/tools/local/syntaxCheck.mjs +0 -31
  500. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -1
  501. package/dist/esm/tools/local/textEncoding.mjs +0 -11
  502. package/dist/esm/tools/local/textEncoding.mjs.map +1 -1
  503. package/dist/esm/tools/local/workspaceFS.mjs +0 -33
  504. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -1
  505. package/dist/esm/tools/ptcTimeout.mjs.map +1 -1
  506. package/dist/esm/tools/schema.mjs +0 -8
  507. package/dist/esm/tools/schema.mjs.map +1 -1
  508. package/dist/esm/tools/search/anthropic.mjs +0 -9
  509. package/dist/esm/tools/search/anthropic.mjs.map +1 -1
  510. package/dist/esm/tools/search/content.mjs.map +1 -1
  511. package/dist/esm/tools/search/crw-scraper.mjs +0 -33
  512. package/dist/esm/tools/search/crw-scraper.mjs.map +1 -1
  513. package/dist/esm/tools/search/crw-search.mjs +0 -2
  514. package/dist/esm/tools/search/crw-search.mjs.map +1 -1
  515. package/dist/esm/tools/search/firecrawl.mjs +0 -25
  516. package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
  517. package/dist/esm/tools/search/format.mjs +0 -30
  518. package/dist/esm/tools/search/format.mjs.map +1 -1
  519. package/dist/esm/tools/search/highlights.mjs +0 -16
  520. package/dist/esm/tools/search/highlights.mjs.map +1 -1
  521. package/dist/esm/tools/search/keenable-scraper.mjs +0 -8
  522. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -1
  523. package/dist/esm/tools/search/keenable-search.mjs +0 -7
  524. package/dist/esm/tools/search/keenable-search.mjs.map +1 -1
  525. package/dist/esm/tools/search/rerankers.mjs +0 -35
  526. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  527. package/dist/esm/tools/search/schema.mjs +0 -1
  528. package/dist/esm/tools/search/schema.mjs.map +1 -1
  529. package/dist/esm/tools/search/search.mjs +1 -14
  530. package/dist/esm/tools/search/search.mjs.map +1 -1
  531. package/dist/esm/tools/search/serper-scraper.mjs +0 -46
  532. package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
  533. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  534. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  535. package/dist/esm/tools/search/tool.mjs +0 -25
  536. package/dist/esm/tools/search/tool.mjs.map +1 -1
  537. package/dist/esm/tools/search/utils.mjs +0 -8
  538. package/dist/esm/tools/search/utils.mjs.map +1 -1
  539. package/dist/esm/tools/skillCatalog.mjs +0 -6
  540. package/dist/esm/tools/skillCatalog.mjs.map +1 -1
  541. package/dist/esm/tools/streamedToolCallSeals.mjs +0 -6
  542. package/dist/esm/tools/streamedToolCallSeals.mjs.map +1 -1
  543. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs +0 -2
  544. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs.map +1 -1
  545. package/dist/esm/tools/subagent/SubagentExecutor.mjs +3 -213
  546. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  547. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
  548. package/dist/esm/tools/subagent/childGraphConfig.mjs +0 -4
  549. package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -1
  550. package/dist/esm/tools/subagent/runtimeLimits.mjs.map +1 -1
  551. package/dist/esm/tools/toolOutputReferences.mjs +1 -322
  552. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  553. package/dist/esm/types/hitl.mjs +0 -5
  554. package/dist/esm/types/hitl.mjs.map +1 -1
  555. package/dist/esm/utils/callbacks.mjs.map +1 -1
  556. package/dist/esm/utils/errors.mjs +14 -165
  557. package/dist/esm/utils/errors.mjs.map +1 -1
  558. package/dist/esm/utils/events.mjs +0 -10
  559. package/dist/esm/utils/events.mjs.map +1 -1
  560. package/dist/esm/utils/graph.mjs.map +1 -1
  561. package/dist/esm/utils/handlers.mjs +0 -3
  562. package/dist/esm/utils/handlers.mjs.map +1 -1
  563. package/dist/esm/utils/llm.mjs +0 -1
  564. package/dist/esm/utils/llm.mjs.map +1 -1
  565. package/dist/esm/utils/misc.mjs +0 -16
  566. package/dist/esm/utils/misc.mjs.map +1 -1
  567. package/dist/esm/utils/proxy.mjs +0 -26
  568. package/dist/esm/utils/proxy.mjs.map +1 -1
  569. package/dist/esm/utils/run.mjs +0 -6
  570. package/dist/esm/utils/run.mjs.map +1 -1
  571. package/dist/esm/utils/schema.mjs +0 -5
  572. package/dist/esm/utils/schema.mjs.map +1 -1
  573. package/dist/esm/utils/title.mjs +0 -6
  574. package/dist/esm/utils/title.mjs.map +1 -1
  575. package/dist/esm/utils/tokens.mjs +0 -113
  576. package/dist/esm/utils/tokens.mjs.map +1 -1
  577. package/dist/esm/utils/toolContent.mjs +0 -41
  578. package/dist/esm/utils/toolContent.mjs.map +1 -1
  579. package/dist/esm/utils/truncation.mjs +0 -61
  580. package/dist/esm/utils/truncation.mjs.map +1 -1
  581. package/package.json +35 -32
  582. package/dist/types/agents/__tests__/promptCacheLiveHelpers.d.ts +0 -52
  583. package/dist/types/test/mockTools.d.ts +0 -28
  584. package/dist/types/utils/__tests__/fixtures/contextOverflowSignatures.d.ts +0 -40
  585. package/src/__tests__/stream.eagerArgsDivergence.test.ts +0 -911
  586. package/src/__tests__/stream.eagerEventExecution.test.ts +0 -5046
  587. package/src/__tests__/stream.streamLimits.test.ts +0 -1982
  588. package/src/agents/__tests__/AgentContext.anthropic.live.test.ts +0 -448
  589. package/src/agents/__tests__/AgentContext.bedrock.live.test.ts +0 -655
  590. package/src/agents/__tests__/AgentContext.openrouter.live.test.ts +0 -128
  591. package/src/agents/__tests__/AgentContext.overflow.test.ts +0 -205
  592. package/src/agents/__tests__/AgentContext.test.ts +0 -2425
  593. package/src/agents/__tests__/projection.test.ts +0 -73
  594. package/src/agents/__tests__/promptCacheLiveHelpers.ts +0 -175
  595. package/src/aggregator.test.ts +0 -1214
  596. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +0 -273
  597. package/src/graphs/__tests__/Graph.closeRunStep.test.ts +0 -385
  598. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +0 -1766
  599. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +0 -126
  600. package/src/graphs/__tests__/Graph.reasoning.test.ts +0 -1717
  601. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +0 -80
  602. package/src/graphs/__tests__/MultiAgentGraph.test.ts +0 -177
  603. package/src/graphs/__tests__/composition.smoke.test.ts +0 -509
  604. package/src/graphs/__tests__/createGraph.test.ts +0 -206
  605. package/src/hooks/__tests__/HookRegistry.test.ts +0 -334
  606. package/src/hooks/__tests__/compactHooks.test.ts +0 -250
  607. package/src/hooks/__tests__/createToolPolicyHook.test.ts +0 -268
  608. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +0 -405
  609. package/src/hooks/__tests__/executeHooks.test.ts +0 -1294
  610. package/src/hooks/__tests__/integration.test.ts +0 -337
  611. package/src/hooks/__tests__/matchers.test.ts +0 -238
  612. package/src/hooks/__tests__/preemptBoundary.test.ts +0 -152
  613. package/src/hooks/__tests__/toolHooks.test.ts +0 -669
  614. package/src/llm/__tests__/contextOverflowRecovery.test.ts +0 -401
  615. package/src/llm/__tests__/fallbackOverflow.test.ts +0 -287
  616. package/src/llm/anthropic/Jacob_Lee_Resume_2023.pdf +0 -0
  617. package/src/llm/anthropic/inherited-content-utils.spec.ts +0 -249
  618. package/src/llm/anthropic/inherited-stream-events.spec.ts +0 -1259
  619. package/src/llm/anthropic/inherited-strict.spec.ts +0 -302
  620. package/src/llm/anthropic/llm.spec.ts +0 -3394
  621. package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +0 -383
  622. package/src/llm/anthropic/utils/cross-provider-server-tools.test.ts +0 -110
  623. package/src/llm/anthropic/utils/server-tool-inputs.test.ts +0 -508
  624. package/src/llm/anthropic/utils/streaming-tool-input.test.ts +0 -361
  625. package/src/llm/anthropic/utils/stripPrefillCache.test.ts +0 -111
  626. package/src/llm/anthropic/utils/tool-id-normalization.test.ts +0 -287
  627. package/src/llm/bedrock/inherited-cache.spec.ts +0 -144
  628. package/src/llm/bedrock/inherited.spec.ts +0 -724
  629. package/src/llm/bedrock/llm.spec.ts +0 -1533
  630. package/src/llm/bedrock/streamSealDispatch.test.ts +0 -331
  631. package/src/llm/bedrock/toolCache.test.ts +0 -194
  632. package/src/llm/bedrock/utils/cross-provider-reasoning.test.ts +0 -131
  633. package/src/llm/bedrock/utils/cross-provider-server-tools.test.ts +0 -122
  634. package/src/llm/bedrock/utils/message_inputs.test.ts +0 -650
  635. package/src/llm/bedrock/utils/message_outputs.test.ts +0 -85
  636. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +0 -118
  637. package/src/llm/custom-chat-models.smoke.test.ts +0 -1966
  638. package/src/llm/google/data/gettysburg10.wav +0 -0
  639. package/src/llm/google/data/hotdog.jpg +0 -0
  640. package/src/llm/google/inherited-stream-events.spec.ts +0 -350
  641. package/src/llm/google/llm.spec.ts +0 -1388
  642. package/src/llm/google/streamSmoothing.test.ts +0 -121
  643. package/src/llm/google/utils/common.test.ts +0 -196
  644. package/src/llm/invoke.alternation.test.ts +0 -87
  645. package/src/llm/invoke.handoffCue.test.ts +0 -112
  646. package/src/llm/invoke.streamLimits.test.ts +0 -142
  647. package/src/llm/invoke.test.ts +0 -1198
  648. package/src/llm/mistral/streamSmoothing.test.ts +0 -97
  649. package/src/llm/openai/cacheWriteTokens.test.ts +0 -112
  650. package/src/llm/openai/contentBlocks.test.ts +0 -381
  651. package/src/llm/openai/deepseek.test.ts +0 -747
  652. package/src/llm/openai/inherited-deepseek.spec.ts +0 -347
  653. package/src/llm/openai/inherited-xai.spec.ts +0 -416
  654. package/src/llm/openai/llm.spec.ts +0 -1774
  655. package/src/llm/openai/managedRequests.test.ts +0 -182
  656. package/src/llm/openai/sequentialToolCallSeals.test.ts +0 -199
  657. package/src/llm/openai/streamMetadata.spec.ts +0 -86
  658. package/src/llm/openai/streamMetadataDedup.spec.ts +0 -166
  659. package/src/llm/openai/utils/isReasoningModel.test.ts +0 -90
  660. package/src/llm/openai/utils/messages.test.ts +0 -488
  661. package/src/llm/openrouter/reasoning.test.ts +0 -561
  662. package/src/llm/openrouter/toolCache.test.ts +0 -134
  663. package/src/llm/preempt.test.ts +0 -323
  664. package/src/llm/stream/chunkAdapters.test.ts +0 -202
  665. package/src/llm/stream/reassembly.test.ts +0 -241
  666. package/src/llm/stream/smoother.bench.test.ts +0 -155
  667. package/src/llm/stream/smoother.test.ts +0 -519
  668. package/src/llm/streamLimits.test.ts +0 -450
  669. package/src/llm/truncation.test.ts +0 -242
  670. package/src/llm/vertexai/fixThoughtSignatures.test.ts +0 -154
  671. package/src/llm/vertexai/inherited-stream-events.spec.ts +0 -271
  672. package/src/llm/vertexai/llm.spec.ts +0 -159
  673. package/src/llm/vertexai/repairUsageMetadata.test.ts +0 -54
  674. package/src/llm/vertexai/sealStreamedToolCalls.test.ts +0 -88
  675. package/src/llm/vertexai/streamSealDispatch.test.ts +0 -148
  676. package/src/llm/vertexai/streamSmoothing.test.ts +0 -109
  677. package/src/messages/__tests__/anthropicToolCache.test.ts +0 -339
  678. package/src/messages/__tests__/observationMasking.test.ts +0 -312
  679. package/src/messages/__tests__/recency.test.ts +0 -267
  680. package/src/messages/__tests__/tools.test.ts +0 -473
  681. package/src/messages/alternation.test.ts +0 -212
  682. package/src/messages/assistantPhase.test.ts +0 -75
  683. package/src/messages/cache.tail.test.ts +0 -533
  684. package/src/messages/cache.test.ts +0 -2166
  685. package/src/messages/content.test.ts +0 -362
  686. package/src/messages/contextPruning.test.ts +0 -184
  687. package/src/messages/ensureThinkingBlock.test.ts +0 -1427
  688. package/src/messages/foldToollessToolBlocks.test.ts +0 -596
  689. package/src/messages/formatAgentMessages.reducer.test.ts +0 -162
  690. package/src/messages/formatAgentMessages.skills.test.ts +0 -513
  691. package/src/messages/formatAgentMessages.steer.test.ts +0 -598
  692. package/src/messages/formatAgentMessages.test.ts +0 -8111
  693. package/src/messages/formatAgentMessages.tools.test.ts +0 -522
  694. package/src/messages/formatMessage.test.ts +0 -693
  695. package/src/messages/handoffCue.test.ts +0 -96
  696. package/src/messages/injected.test.ts +0 -107
  697. package/src/messages/labelContentByAgent.test.ts +0 -962
  698. package/src/messages/reducer.spec.ts +0 -32
  699. package/src/messages/shiftIndexTokenCountMap.test.ts +0 -81
  700. package/src/messages/tailCacheConversion.test.ts +0 -161
  701. package/src/openai/__tests__/openai.test.ts +0 -337
  702. package/src/responses/__tests__/responses.test.ts +0 -652
  703. package/src/scripts/abort.ts +0 -157
  704. package/src/scripts/activity-labels/captured.json +0 -56
  705. package/src/scripts/activity-labels/checks.cjs +0 -205
  706. package/src/scripts/activity-labels/corpus.cjs +0 -473
  707. package/src/scripts/activity-labels/report.cjs +0 -203
  708. package/src/scripts/activity-labels/rescore.cjs +0 -102
  709. package/src/scripts/activity-labels/run.ts +0 -705
  710. package/src/scripts/activity-labels/variants.ts +0 -71
  711. package/src/scripts/ant_web_search.ts +0 -159
  712. package/src/scripts/ant_web_search_edge_case.ts +0 -163
  713. package/src/scripts/ant_web_search_error_edge_case.ts +0 -149
  714. package/src/scripts/args.ts +0 -48
  715. package/src/scripts/bedrock-cache-debug.ts +0 -250
  716. package/src/scripts/bedrock-content-aggregation-test.ts +0 -266
  717. package/src/scripts/bedrock-merge-test.ts +0 -107
  718. package/src/scripts/bedrock-parallel-tools-test.ts +0 -204
  719. package/src/scripts/bench-prompt-cache.ts +0 -479
  720. package/src/scripts/caching.ts +0 -132
  721. package/src/scripts/cli.ts +0 -172
  722. package/src/scripts/cli2.ts +0 -133
  723. package/src/scripts/cli3.ts +0 -184
  724. package/src/scripts/cli4.ts +0 -191
  725. package/src/scripts/cli5.ts +0 -191
  726. package/src/scripts/code_exec.ts +0 -214
  727. package/src/scripts/code_exec_files.ts +0 -237
  728. package/src/scripts/code_exec_multi_session.ts +0 -237
  729. package/src/scripts/code_exec_ptc.ts +0 -335
  730. package/src/scripts/code_exec_session.ts +0 -283
  731. package/src/scripts/code_exec_simple.ts +0 -148
  732. package/src/scripts/compare_pi_vs_ours.ts +0 -1259
  733. package/src/scripts/content.ts +0 -139
  734. package/src/scripts/context-overflow-probe.ts +0 -997
  735. package/src/scripts/empty_input.ts +0 -137
  736. package/src/scripts/handoff-test.ts +0 -134
  737. package/src/scripts/local_engine.ts +0 -166
  738. package/src/scripts/local_engine_checkpointer.ts +0 -205
  739. package/src/scripts/local_engine_compile.ts +0 -263
  740. package/src/scripts/local_engine_hooks.ts +0 -226
  741. package/src/scripts/local_engine_image.ts +0 -201
  742. package/src/scripts/local_engine_ptc.ts +0 -151
  743. package/src/scripts/local_engine_workspace.ts +0 -258
  744. package/src/scripts/memory.ts +0 -107
  745. package/src/scripts/multi-agent-chain.ts +0 -332
  746. package/src/scripts/multi-agent-conditional.ts +0 -222
  747. package/src/scripts/multi-agent-document-review-chain.ts +0 -198
  748. package/src/scripts/multi-agent-hybrid-flow.ts +0 -311
  749. package/src/scripts/multi-agent-parallel-start.ts +0 -265
  750. package/src/scripts/multi-agent-parallel.ts +0 -395
  751. package/src/scripts/multi-agent-sequence.ts +0 -217
  752. package/src/scripts/multi-agent-subagent.ts +0 -246
  753. package/src/scripts/multi-agent-supervisor.ts +0 -366
  754. package/src/scripts/multi-agent-test.ts +0 -187
  755. package/src/scripts/openrouter_prompt_cache_live.ts +0 -310
  756. package/src/scripts/parallel-asymmetric-tools-test.ts +0 -275
  757. package/src/scripts/parallel-full-metadata-test.ts +0 -241
  758. package/src/scripts/parallel-tools-test.ts +0 -341
  759. package/src/scripts/preempt-probe.ts +0 -330
  760. package/src/scripts/preempt-scenarios.ts +0 -388
  761. package/src/scripts/programmatic_exec.ts +0 -387
  762. package/src/scripts/programmatic_exec_agent.ts +0 -232
  763. package/src/scripts/search.ts +0 -147
  764. package/src/scripts/sequential-full-metadata-test.ts +0 -198
  765. package/src/scripts/session_live.ts +0 -548
  766. package/src/scripts/simple.ts +0 -226
  767. package/src/scripts/single-agent-metadata-test.ts +0 -196
  768. package/src/scripts/stream.ts +0 -141
  769. package/src/scripts/subagent-configurable-inheritance.ts +0 -252
  770. package/src/scripts/subagent-event-driven-debug.ts +0 -190
  771. package/src/scripts/subagent-tools-debug.ts +0 -160
  772. package/src/scripts/subagent-usage-sink.ts +0 -176
  773. package/src/scripts/summarization-recency.ts +0 -462
  774. package/src/scripts/test-custom-prompt-key.ts +0 -145
  775. package/src/scripts/test-handoff-input.ts +0 -170
  776. package/src/scripts/test-handoff-preamble.ts +0 -278
  777. package/src/scripts/test-handoff-steering.ts +0 -430
  778. package/src/scripts/test-multi-agent-list-handoff.ts +0 -418
  779. package/src/scripts/test-parallel-agent-labeling.ts +0 -327
  780. package/src/scripts/test-parallel-handoffs.ts +0 -292
  781. package/src/scripts/test-thinking-handoff-bedrock.ts +0 -154
  782. package/src/scripts/test-thinking-handoff.ts +0 -156
  783. package/src/scripts/test-thinking-to-thinking-handoff-bedrock.ts +0 -166
  784. package/src/scripts/test-tool-before-handoff-role-order.ts +0 -276
  785. package/src/scripts/test-tools-before-handoff.ts +0 -222
  786. package/src/scripts/test_code_api.ts +0 -354
  787. package/src/scripts/thinking-bedrock.ts +0 -160
  788. package/src/scripts/thinking-vertexai.ts +0 -168
  789. package/src/scripts/thinking.ts +0 -172
  790. package/src/scripts/tool_search.ts +0 -153
  791. package/src/scripts/tools.ts +0 -163
  792. package/src/session/__tests__/JsonlSessionStore.test.ts +0 -1463
  793. package/src/session/__tests__/handlers.test.ts +0 -161
  794. package/src/specs/activity-label-observability.live.test.ts +0 -262
  795. package/src/specs/activity-label-observability.test.ts +0 -167
  796. package/src/specs/activity-label-prompt.test.ts +0 -359
  797. package/src/specs/activity-label-trace-seed.test.ts +0 -47
  798. package/src/specs/activity-phase-label.test.ts +0 -306
  799. package/src/specs/agent-handoffs.live.test.ts +0 -140
  800. package/src/specs/agent-handoffs.test.ts +0 -2465
  801. package/src/specs/anthropic.simple.test.ts +0 -453
  802. package/src/specs/ask-user-question-batch.test.ts +0 -289
  803. package/src/specs/ask-user-questions.live.test.ts +0 -185
  804. package/src/specs/ask-user-questions.test.ts +0 -293
  805. package/src/specs/azure.simple.test.ts +0 -502
  806. package/src/specs/bedrock-toolless.live.test.ts +0 -123
  807. package/src/specs/bedrock-truncation.live.test.ts +0 -191
  808. package/src/specs/cache.simple.test.ts +0 -416
  809. package/src/specs/context-accuracy.live.test.ts +0 -409
  810. package/src/specs/context-overflow-recovery.live.test.ts +0 -213
  811. package/src/specs/context-usage-event.test.ts +0 -117
  812. package/src/specs/context-usage.live.test.ts +0 -297
  813. package/src/specs/custom-event-await.test.ts +0 -304
  814. package/src/specs/deepseek.simple.test.ts +0 -290
  815. package/src/specs/deterministic-trace-id.test.ts +0 -43
  816. package/src/specs/discovered-tools.test.ts +0 -217
  817. package/src/specs/durability-checkpoint.integration.test.ts +0 -243
  818. package/src/specs/graph-subagent.live.test.ts +0 -242
  819. package/src/specs/graph-subagent.test.ts +0 -322
  820. package/src/specs/handoffCue.test.ts +0 -165
  821. package/src/specs/langfuse-callbacks.test.ts +0 -1578
  822. package/src/specs/langfuse-config.test.ts +0 -247
  823. package/src/specs/langfuse-instrumentation.test.ts +0 -393
  824. package/src/specs/langfuse-metadata.test.ts +0 -245
  825. package/src/specs/langfuse-routing.integration.test.ts +0 -844
  826. package/src/specs/langfuse-runtime-context.test.ts +0 -92
  827. package/src/specs/langfuse-span-registry.test.ts +0 -87
  828. package/src/specs/langfuse-tool-output-tracing.test.ts +0 -1858
  829. package/src/specs/langfuse-trace-shaping.test.ts +0 -591
  830. package/src/specs/moonshot.simple.test.ts +0 -367
  831. package/src/specs/multi-agent-summarization.test.ts +0 -396
  832. package/src/specs/openai.simple.test.ts +0 -315
  833. package/src/specs/openrouter.simple.test.ts +0 -275
  834. package/src/specs/preemptSeal.test.ts +0 -678
  835. package/src/specs/prune.test.ts +0 -3741
  836. package/src/specs/reasoning.test.ts +0 -201
  837. package/src/specs/run-step-timestamps.test.ts +0 -414
  838. package/src/specs/spec.utils.ts +0 -15
  839. package/src/specs/subagent.test.ts +0 -864
  840. package/src/specs/summarization-unit.test.ts +0 -973
  841. package/src/specs/summarization.test.ts +0 -3823
  842. package/src/specs/summarize-prune.test.ts +0 -376
  843. package/src/specs/thinking-handoff.test.ts +0 -621
  844. package/src/specs/thinking-prune.test.ts +0 -827
  845. package/src/specs/token-accounting-e2e.test.ts +0 -1034
  846. package/src/specs/token-accounting-pipeline.test.ts +0 -1008
  847. package/src/specs/token-distribution-edge-case.test.ts +0 -315
  848. package/src/specs/token-memoization.test.ts +0 -40
  849. package/src/specs/tokens.test.ts +0 -1000
  850. package/src/specs/tool-error-resume.test.ts +0 -194
  851. package/src/specs/tool-error.test.ts +0 -198
  852. package/src/specs/vllm-reasoning-toolcalls.test.ts +0 -340
  853. package/src/stream.dispatch.test.ts +0 -63
  854. package/src/stream.test.ts +0 -144
  855. package/src/summarization/__tests__/aggregator.test.ts +0 -236
  856. package/src/summarization/__tests__/node.test.ts +0 -1996
  857. package/src/summarization/__tests__/trigger.test.ts +0 -149
  858. package/src/summarization/chunkHandler.test.ts +0 -196
  859. package/src/test/mockTools.ts +0 -386
  860. package/src/tools/Calculator.test.ts +0 -278
  861. package/src/tools/__tests__/BashExecutor.test.ts +0 -108
  862. package/src/tools/__tests__/CloudflareSandboxExecution.test.ts +0 -939
  863. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +0 -858
  864. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +0 -124
  865. package/src/tools/__tests__/LocalExecutionRoots.test.ts +0 -8
  866. package/src/tools/__tests__/LocalExecutionTools.test.ts +0 -2733
  867. package/src/tools/__tests__/ProgrammaticToolCalling.integration.test.ts +0 -318
  868. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +0 -1557
  869. package/src/tools/__tests__/ReadFile.test.ts +0 -44
  870. package/src/tools/__tests__/SkillTool.test.ts +0 -442
  871. package/src/tools/__tests__/SubagentExecutor.test.ts +0 -3728
  872. package/src/tools/__tests__/SubagentReplay.test.ts +0 -329
  873. package/src/tools/__tests__/SubagentTool.test.ts +0 -149
  874. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +0 -595
  875. package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +0 -1191
  876. package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +0 -757
  877. package/src/tools/__tests__/ToolNode.langfuse.test.ts +0 -57
  878. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +0 -736
  879. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +0 -1710
  880. package/src/tools/__tests__/ToolNode.runtimeState.test.ts +0 -120
  881. package/src/tools/__tests__/ToolNode.session.test.ts +0 -1352
  882. package/src/tools/__tests__/ToolNode.streamLimits.test.ts +0 -69
  883. package/src/tools/__tests__/ToolSearch.integration.test.ts +0 -161
  884. package/src/tools/__tests__/ToolSearch.test.ts +0 -1227
  885. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +0 -561
  886. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +0 -980
  887. package/src/tools/__tests__/directToolHooks.test.ts +0 -700
  888. package/src/tools/__tests__/eagerEventExecution.session.test.ts +0 -92
  889. package/src/tools/__tests__/handlers.test.ts +0 -997
  890. package/src/tools/__tests__/hitl.test.ts +0 -4663
  891. package/src/tools/__tests__/intentArg.test.ts +0 -357
  892. package/src/tools/__tests__/intentCoverage.test.ts +0 -139
  893. package/src/tools/__tests__/localToolNames.test.ts +0 -75
  894. package/src/tools/__tests__/skillCatalog.test.ts +0 -161
  895. package/src/tools/__tests__/subagentHooks.test.ts +0 -1651
  896. package/src/tools/__tests__/toolOutputReferences.test.ts +0 -515
  897. package/src/tools/__tests__/workspaceSeam.test.ts +0 -147
  898. package/src/tools/local/__tests__/FileCheckpointer.test.ts +0 -162
  899. package/src/tools/local/__tests__/editStrategies.test.ts +0 -134
  900. package/src/tools/search/content.test.ts +0 -173
  901. package/src/tools/search/crw.test.ts +0 -836
  902. package/src/tools/search/format.test.ts +0 -242
  903. package/src/tools/search/http-agent.test.ts +0 -133
  904. package/src/tools/search/jina-reranker.test.ts +0 -306
  905. package/src/tools/search/keenable-scraper.test.ts +0 -153
  906. package/src/tools/search/keenable.test.ts +0 -183
  907. package/src/tools/search/outcome.test.ts +0 -90
  908. package/src/tools/search/output.md +0 -2775
  909. package/src/tools/search/rag-api-reranker.test.ts +0 -891
  910. package/src/tools/search/source-processing.test.ts +0 -516
  911. package/src/tools/search/tavily.test.ts +0 -965
  912. package/src/tools/search/test.html +0 -884
  913. package/src/tools/search/test.md +0 -643
  914. package/src/tools/subagent/__tests__/SubagentExecutionRegistry.test.ts +0 -637
  915. package/src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts +0 -3054
  916. package/src/tools/subagent/__tests__/childGraphConfig.test.ts +0 -521
  917. package/src/utils/__tests__/apportion.test.ts +0 -32
  918. package/src/utils/__tests__/errors.test.ts +0 -270
  919. package/src/utils/__tests__/fixtures/contextOverflowSignatures.ts +0 -336
  920. package/src/utils/__tests__/redactSecrets.test.ts +0 -56
  921. package/src/utils/__tests__/truncation.test.ts +0 -66
  922. package/src/utils/llm.test.ts +0 -18
  923. package/src/utils/proxy.test.ts +0 -176
  924. package/src/utils/toolContent.test.ts +0 -1432
@@ -1,4663 +0,0 @@
1
- import { z } from 'zod';
2
- import { tool } from '@langchain/core/tools';
3
- import { AIMessage, ToolMessage } from '@langchain/core/messages';
4
- import {
5
- describe,
6
- it,
7
- expect,
8
- jest,
9
- afterEach,
10
- beforeEach,
11
- } from '@jest/globals';
12
- import {
13
- END,
14
- START,
15
- Command,
16
- StateGraph,
17
- MemorySaver,
18
- isInterrupted,
19
- MessagesAnnotation,
20
- } from '@langchain/langgraph';
21
- import type { Runnable, RunnableConfig } from '@langchain/core/runnables';
22
- import type { StructuredToolInterface } from '@langchain/core/tools';
23
- import type { BaseMessage } from '@langchain/core/messages';
24
- import type {
25
- PreToolUseHookOutput,
26
- PostToolUseHookOutput,
27
- PostToolUseFailureHookOutput,
28
- PostToolBatchEntry,
29
- PostToolBatchHookInput,
30
- PostToolBatchHookOutput,
31
- RunStartHookOutput,
32
- UserPromptSubmitHookOutput,
33
- } from '@/hooks';
34
- import type * as t from '@/types';
35
- import { Constants, Providers as providers, GraphEvents } from '@/common';
36
- import { HookRegistry, createToolPolicyHook } from '@/hooks';
37
- import * as events from '@/utils/events';
38
- import { askUserQuestion } from '@/hitl';
39
- import { ToolNode } from '../ToolNode';
40
-
41
- async function flushAsyncWork(): Promise<void> {
42
- await Promise.resolve();
43
- await new Promise<void>((resolve) => setImmediate(resolve));
44
- await new Promise<void>((resolve) => setTimeout(resolve, 0));
45
- await new Promise<void>((resolve) => setImmediate(resolve));
46
- await Promise.resolve();
47
- }
48
-
49
- afterEach(async () => {
50
- await flushAsyncWork();
51
- jest.restoreAllMocks();
52
- await flushAsyncWork();
53
- });
54
-
55
- /**
56
- * Schema-only tool stub. ToolNode in event-driven mode uses the schema
57
- * for binding/discovery but routes execution through the host via
58
- * `ON_TOOL_EXECUTE`, so the actual `func` here is never called.
59
- */
60
- function createSchemaStub(name: string): StructuredToolInterface {
61
- return tool(async () => 'unused', {
62
- name,
63
- description: 'schema-only stub; host executes via ON_TOOL_EXECUTE',
64
- schema: z.object({ command: z.string() }),
65
- }) as unknown as StructuredToolInterface;
66
- }
67
-
68
- /**
69
- * Wires a fake host that responds to every `ON_TOOL_EXECUTE` event by
70
- * resolving the request promise with `mockResults`. Mirrors the pattern
71
- * used in `ToolNode.outputReferences.test.ts` so the event-driven path
72
- * actually returns ToolMessages without spinning up a real host.
73
- */
74
- function mockEventDispatch(mockResults: t.ToolExecuteResult[]): void {
75
- jest
76
- .spyOn(events, 'safeDispatchCustomEvent')
77
- .mockImplementation(async (event, data) => {
78
- if (event !== 'on_tool_execute') {
79
- return;
80
- }
81
- const request = data as Record<string, unknown>;
82
- if (typeof request.resolve === 'function') {
83
- (request.resolve as (r: t.ToolExecuteResult[]) => void)(mockResults);
84
- }
85
- });
86
- }
87
-
88
- type MessagesUpdate = { messages: BaseMessage[] };
89
- type InterruptStateSnapshot = {
90
- config?: RunnableConfig;
91
- tasks?: Array<{
92
- interrupts?: Array<{ id?: string }>;
93
- }>;
94
- };
95
- type CompiledMessagesGraph = Runnable<unknown, { messages: BaseMessage[] }> & {
96
- invoke(input: unknown, config?: RunnableConfig): Promise<unknown>;
97
- getState?(
98
- config: RunnableConfig
99
- ): Promise<{ config?: RunnableConfig } | undefined>;
100
- getStateHistory?(
101
- config: RunnableConfig
102
- ): AsyncIterableIterator<InterruptStateSnapshot>;
103
- };
104
-
105
- /** Factory for a minimal `agent → tools → END` graph wrapping the ToolNode. */
106
- function buildHITLGraph(
107
- toolNode: ToolNode,
108
- toolCalls: Array<{ id: string; name: string; args: Record<string, unknown> }>
109
- ): CompiledMessagesGraph {
110
- const toolCallIds = new Set(toolCalls.map((call) => call.id));
111
- const builder = new StateGraph(MessagesAnnotation)
112
- .addNode('agent', (state: { messages?: BaseMessage[] }): MessagesUpdate => {
113
- /**
114
- * Emit the AIMessage carrying tool_calls until this test graph
115
- * actually has a matching ToolMessage in state. LangGraph usually
116
- * resumes at the interrupted `tools` node, but under full-suite
117
- * async callback pressure it can re-enter this tiny test graph from
118
- * START while still carrying the resume value. A call-count based
119
- * fake agent then returned "done" too early and made HITL resume
120
- * assertions order-dependent. State is the stable contract here:
121
- * no tool result means the tool node still needs work.
122
- */
123
- const hasMatchingToolResult =
124
- state.messages?.some(
125
- (message): boolean =>
126
- message._getType() === 'tool' &&
127
- toolCallIds.has((message as ToolMessage).tool_call_id)
128
- ) === true;
129
- if (!hasMatchingToolResult) {
130
- return {
131
- messages: [new AIMessage({ content: '', tool_calls: toolCalls })],
132
- };
133
- }
134
- return { messages: [new AIMessage({ content: 'done' })] };
135
- })
136
- .addNode('tools', toolNode)
137
- .addEdge(START, 'agent')
138
- .addEdge('agent', 'tools')
139
- .addEdge('tools', END);
140
- return builder.compile({
141
- checkpointer: new MemorySaver(),
142
- }) as unknown as CompiledMessagesGraph;
143
- }
144
-
145
- function makeHookRegistry(
146
- decision: 'allow' | 'deny' | 'ask',
147
- reason?: string
148
- ): HookRegistry {
149
- const registry = new HookRegistry();
150
- registry.register('PreToolUse', {
151
- hooks: [
152
- async (): Promise<PreToolUseHookOutput> => ({
153
- decision,
154
- ...(reason != null ? { reason } : {}),
155
- }),
156
- ],
157
- });
158
- return registry;
159
- }
160
-
161
- function resumeFromInterrupt<TResume>(
162
- interrupted: unknown,
163
- resume: TResume
164
- ): Command {
165
- if (isInterrupted<unknown>(interrupted)) {
166
- const interruptId = interrupted.__interrupt__[0]?.id;
167
- if (typeof interruptId === 'string' && interruptId.length > 0) {
168
- return new Command({ resume: { [interruptId]: resume } });
169
- }
170
- }
171
- return new Command({ resume });
172
- }
173
-
174
- async function resumeGraph<TResume>(
175
- graph: CompiledMessagesGraph,
176
- interrupted: unknown,
177
- resume: TResume,
178
- config: RunnableConfig
179
- ): Promise<unknown> {
180
- const interruptId = isInterrupted<unknown>(interrupted)
181
- ? interrupted.__interrupt__[0]?.id
182
- : undefined;
183
- let checkpointConfig = config;
184
- if (typeof interruptId === 'string' && graph.getStateHistory != null) {
185
- for await (const snapshot of graph.getStateHistory(config)) {
186
- const hasMatchingInterrupt =
187
- snapshot.tasks?.some(
188
- (task) =>
189
- task.interrupts?.some(
190
- (interrupt) => interrupt.id === interruptId
191
- ) === true
192
- ) === true;
193
- if (hasMatchingInterrupt && snapshot.config != null) {
194
- checkpointConfig = snapshot.config;
195
- break;
196
- }
197
- }
198
- } else {
199
- checkpointConfig = (await graph.getState?.(config))?.config ?? config;
200
- }
201
- return graph.invoke(
202
- resumeFromInterrupt(interrupted, resume),
203
- checkpointConfig
204
- );
205
- }
206
-
207
- describe('ToolNode HITL — `ask` decision raises interrupt() when humanInTheLoop is enabled', () => {
208
- afterEach(() => {
209
- jest.restoreAllMocks();
210
- });
211
-
212
- it('raises a tool_approval interrupt with the pending tool call payload', async () => {
213
- mockEventDispatch([
214
- { toolCallId: 'call_1', content: 'should-not-run', status: 'success' },
215
- ]);
216
- const node = new ToolNode({
217
- tools: [createSchemaStub('echo')],
218
- eventDrivenMode: true,
219
- agentId: 'agent-x',
220
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
221
- hookRegistry: makeHookRegistry('ask', 'review tool args'),
222
- humanInTheLoop: { enabled: true },
223
- });
224
-
225
- const graph = buildHITLGraph(node, [
226
- { id: 'call_1', name: 'echo', args: { command: 'list /' } },
227
- ]);
228
- const config = { configurable: { thread_id: 'thread-hitl-1' } };
229
-
230
- const result = await graph.invoke({ messages: [] }, config);
231
-
232
- expect(isInterrupted<t.HumanInterruptPayload>(result)).toBe(true);
233
- if (!isInterrupted<t.HumanInterruptPayload>(result)) {
234
- throw new Error('expected interrupt');
235
- }
236
- const interrupts = result.__interrupt__;
237
- expect(interrupts).toHaveLength(1);
238
- const payload = interrupts[0].value!;
239
- if (payload.type !== 'tool_approval') {
240
- throw new Error('expected tool_approval payload');
241
- }
242
- expect(payload.action_requests).toEqual([
243
- {
244
- tool_call_id: 'call_1',
245
- name: 'echo',
246
- arguments: { command: 'list /' },
247
- description: 'review tool args',
248
- },
249
- ]);
250
- expect(payload.review_configs).toEqual([
251
- {
252
- action_name: 'echo',
253
- tool_call_id: 'call_1',
254
- allowed_decisions: ['approve', 'reject', 'edit', 'respond'],
255
- },
256
- ]);
257
- });
258
-
259
- it('waits for approval before executing an explicit ask rule in bypass mode', async () => {
260
- let toolExecuted = false;
261
- jest
262
- .spyOn(events, 'safeDispatchCustomEvent')
263
- .mockImplementation(async (event, data) => {
264
- if (event !== 'on_tool_execute') {
265
- return;
266
- }
267
- toolExecuted = true;
268
- const request = data as {
269
- resolve: (results: t.ToolExecuteResult[]) => void;
270
- };
271
- request.resolve([
272
- { toolCallId: 'call_1', content: 'deleted', status: 'success' },
273
- ]);
274
- });
275
- const registry = new HookRegistry();
276
- registry.register('PreToolUse', {
277
- hooks: [
278
- createToolPolicyHook({
279
- mode: 'bypass',
280
- ask: ['dangerous_*'],
281
- }),
282
- ],
283
- });
284
- const node = new ToolNode({
285
- tools: [createSchemaStub('dangerous_tool')],
286
- eventDrivenMode: true,
287
- agentId: 'agent-x',
288
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
289
- hookRegistry: registry,
290
- humanInTheLoop: { enabled: true },
291
- });
292
- const graph = buildHITLGraph(node, [
293
- {
294
- id: 'call_1',
295
- name: 'dangerous_tool',
296
- args: { command: 'delete data' },
297
- },
298
- ]);
299
- const config = {
300
- configurable: { thread_id: 'thread-bypass-explicit-ask' },
301
- };
302
-
303
- const interrupted = await graph.invoke({ messages: [] }, config);
304
-
305
- expect(isInterrupted<t.HumanInterruptPayload>(interrupted)).toBe(true);
306
- expect(toolExecuted).toBe(false);
307
-
308
- const resumed = (await resumeGraph(
309
- graph,
310
- interrupted,
311
- [{ type: 'approve' }],
312
- config
313
- )) as { messages: BaseMessage[] };
314
-
315
- expect(toolExecuted).toBe(true);
316
- expect(
317
- resumed.messages.some(
318
- (message) =>
319
- message._getType() === 'tool' &&
320
- (message as ToolMessage).tool_call_id === 'call_1' &&
321
- message.content === 'deleted'
322
- )
323
- ).toBe(true);
324
- });
325
-
326
- it('executes an unmatched tool without interruption in bypass mode', async () => {
327
- let toolExecuted = false;
328
- jest
329
- .spyOn(events, 'safeDispatchCustomEvent')
330
- .mockImplementation(async (event, data) => {
331
- if (event !== 'on_tool_execute') {
332
- return;
333
- }
334
- toolExecuted = true;
335
- const request = data as {
336
- resolve: (results: t.ToolExecuteResult[]) => void;
337
- };
338
- request.resolve([
339
- { toolCallId: 'call_1', content: 'read result', status: 'success' },
340
- ]);
341
- });
342
- const registry = new HookRegistry();
343
- registry.register('PreToolUse', {
344
- hooks: [
345
- createToolPolicyHook({
346
- mode: 'bypass',
347
- ask: ['dangerous_*'],
348
- }),
349
- ],
350
- });
351
- const node = new ToolNode({
352
- tools: [createSchemaStub('read_tool')],
353
- eventDrivenMode: true,
354
- agentId: 'agent-x',
355
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
356
- hookRegistry: registry,
357
- humanInTheLoop: { enabled: true },
358
- });
359
- const graph = buildHITLGraph(node, [
360
- { id: 'call_1', name: 'read_tool', args: { command: 'read data' } },
361
- ]);
362
-
363
- const result = await graph.invoke(
364
- { messages: [] },
365
- { configurable: { thread_id: 'thread-bypass-unmatched' } }
366
- );
367
-
368
- expect(isInterrupted(result)).toBe(false);
369
- expect(toolExecuted).toBe(true);
370
- });
371
-
372
- it('resume with approve runs the tool through the host event path', async () => {
373
- mockEventDispatch([
374
- { toolCallId: 'call_1', content: 'host-result', status: 'success' },
375
- ]);
376
- const node = new ToolNode({
377
- tools: [createSchemaStub('echo')],
378
- eventDrivenMode: true,
379
- agentId: 'agent-x',
380
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
381
- hookRegistry: makeHookRegistry('ask'),
382
- humanInTheLoop: { enabled: true },
383
- });
384
-
385
- const graph = buildHITLGraph(node, [
386
- { id: 'call_1', name: 'echo', args: { command: 'do-it' } },
387
- ]);
388
- const config = { configurable: { thread_id: 'thread-hitl-approve' } };
389
-
390
- const interrupted = await graph.invoke({ messages: [] }, config);
391
- expect(isInterrupted(interrupted)).toBe(true);
392
-
393
- const resumed = (await resumeGraph(
394
- graph,
395
- interrupted,
396
- [{ type: 'approve' }],
397
- config
398
- )) as {
399
- messages: BaseMessage[];
400
- };
401
-
402
- const toolMessages = resumed.messages.filter(
403
- (m): m is ToolMessage => m._getType() === 'tool'
404
- );
405
- expect(toolMessages).toHaveLength(1);
406
- expect(toolMessages[0].tool_call_id).toBe('call_1');
407
- expect(toolMessages[0].content).toBe('host-result');
408
- expect(toolMessages[0].status).not.toBe('error');
409
- });
410
-
411
- it('resume with reject blocks the tool and emits an error ToolMessage', async () => {
412
- mockEventDispatch([]);
413
- const node = new ToolNode({
414
- tools: [createSchemaStub('echo')],
415
- eventDrivenMode: true,
416
- agentId: 'agent-x',
417
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
418
- hookRegistry: makeHookRegistry('ask'),
419
- humanInTheLoop: { enabled: true },
420
- });
421
-
422
- const graph = buildHITLGraph(node, [
423
- { id: 'call_1', name: 'echo', args: { command: 'rm -rf /' } },
424
- ]);
425
- const config = { configurable: { thread_id: 'thread-hitl-reject' } };
426
-
427
- const interrupted = await graph.invoke({ messages: [] }, config);
428
-
429
- const resumed = (await resumeGraph(
430
- graph,
431
- interrupted,
432
- [{ type: 'reject', reason: 'destructive command' }],
433
- config
434
- )) as { messages: BaseMessage[] };
435
-
436
- const toolMessages = resumed.messages.filter(
437
- (m): m is ToolMessage => m._getType() === 'tool'
438
- );
439
- expect(toolMessages).toHaveLength(1);
440
- expect(toolMessages[0].status).toBe('error');
441
- expect(String(toolMessages[0].content)).toContain('destructive command');
442
- });
443
-
444
- it('resume with edit substitutes the tool input before invocation', async () => {
445
- const capturedRequests: t.ToolCallRequest[] = [];
446
- jest
447
- .spyOn(events, 'safeDispatchCustomEvent')
448
- .mockImplementation(async (event, data) => {
449
- if (event !== 'on_tool_execute') {
450
- return;
451
- }
452
- const request = data as {
453
- toolCalls: t.ToolCallRequest[];
454
- resolve: (r: t.ToolExecuteResult[]) => void;
455
- };
456
- capturedRequests.push(...request.toolCalls);
457
- request.resolve(
458
- request.toolCalls.map((c) => ({
459
- toolCallId: c.id,
460
- content: 'host-result',
461
- status: 'success' as const,
462
- }))
463
- );
464
- });
465
-
466
- const node = new ToolNode({
467
- tools: [createSchemaStub('echo')],
468
- eventDrivenMode: true,
469
- agentId: 'agent-x',
470
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
471
- hookRegistry: makeHookRegistry('ask'),
472
- humanInTheLoop: { enabled: true },
473
- });
474
-
475
- const graph = buildHITLGraph(node, [
476
- { id: 'call_1', name: 'echo', args: { command: 'original' } },
477
- ]);
478
- const config = { configurable: { thread_id: 'thread-hitl-edit' } };
479
-
480
- const interrupted = await graph.invoke({ messages: [] }, config);
481
-
482
- await resumeGraph(
483
- graph,
484
- interrupted,
485
- [{ type: 'edit', updatedInput: { command: 'patched' } }],
486
- config
487
- );
488
-
489
- expect(capturedRequests).toHaveLength(1);
490
- expect(capturedRequests[0].args).toEqual({ command: 'patched' });
491
- });
492
-
493
- it('resume with respond emits the user-supplied text as a successful ToolMessage and skips host execution', async () => {
494
- const dispatchSpy = jest
495
- .spyOn(events, 'safeDispatchCustomEvent')
496
- .mockImplementation(async (event, data) => {
497
- if (event !== 'on_tool_execute') {
498
- return;
499
- }
500
- const request = data as {
501
- toolCalls: t.ToolCallRequest[];
502
- resolve: (r: t.ToolExecuteResult[]) => void;
503
- };
504
- request.resolve([]);
505
- });
506
-
507
- const node = new ToolNode({
508
- tools: [createSchemaStub('echo')],
509
- eventDrivenMode: true,
510
- agentId: 'agent-x',
511
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
512
- hookRegistry: makeHookRegistry('ask'),
513
- humanInTheLoop: { enabled: true },
514
- });
515
-
516
- const graph = buildHITLGraph(node, [
517
- { id: 'call_1', name: 'echo', args: { command: 'search' } },
518
- ]);
519
- const config = { configurable: { thread_id: 'thread-hitl-respond' } };
520
-
521
- const interrupted = await graph.invoke({ messages: [] }, config);
522
-
523
- const dispatchCallsBefore = dispatchSpy.mock.calls.filter(
524
- ([event]) => event === 'on_tool_execute'
525
- ).length;
526
-
527
- const resumed = (await resumeGraph(
528
- graph,
529
- interrupted,
530
- [{ type: 'respond', responseText: 'no relevant results' }],
531
- config
532
- )) as { messages: BaseMessage[] };
533
-
534
- const dispatchCallsAfter = dispatchSpy.mock.calls.filter(
535
- ([event]) => event === 'on_tool_execute'
536
- ).length;
537
-
538
- const toolMessages = resumed.messages.filter(
539
- (m): m is ToolMessage => m._getType() === 'tool'
540
- );
541
- expect(toolMessages).toHaveLength(1);
542
- expect(toolMessages[0].tool_call_id).toBe('call_1');
543
- expect(toolMessages[0].content).toBe('no relevant results');
544
- expect(toolMessages[0].status).not.toBe('error');
545
- expect(dispatchCallsAfter).toBe(dispatchCallsBefore);
546
- });
547
-
548
- it('advertises respond in review_configs.allowed_decisions', async () => {
549
- mockEventDispatch([]);
550
- const node = new ToolNode({
551
- tools: [createSchemaStub('echo')],
552
- eventDrivenMode: true,
553
- agentId: 'agent-x',
554
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
555
- hookRegistry: makeHookRegistry('ask'),
556
- humanInTheLoop: { enabled: true },
557
- });
558
-
559
- const graph = buildHITLGraph(node, [
560
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
561
- ]);
562
- const config = {
563
- configurable: { thread_id: 'thread-hitl-allowed-decisions' },
564
- };
565
-
566
- const interrupted = await graph.invoke({ messages: [] }, config);
567
- if (!isInterrupted<t.HumanInterruptPayload>(interrupted)) {
568
- throw new Error('expected interrupt');
569
- }
570
- const payload = interrupted.__interrupt__[0].value!;
571
- if (payload.type !== 'tool_approval') {
572
- throw new Error('expected tool_approval payload');
573
- }
574
- expect(payload.review_configs[0].allowed_decisions).toEqual([
575
- 'approve',
576
- 'reject',
577
- 'edit',
578
- 'respond',
579
- ]);
580
- });
581
-
582
- it('resume with a record keyed by tool_call_id is accepted', async () => {
583
- mockEventDispatch([
584
- { toolCallId: 'call_1', content: 'host-result', status: 'success' },
585
- ]);
586
- const node = new ToolNode({
587
- tools: [createSchemaStub('echo')],
588
- eventDrivenMode: true,
589
- agentId: 'agent-x',
590
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
591
- hookRegistry: makeHookRegistry('ask'),
592
- humanInTheLoop: { enabled: true },
593
- });
594
-
595
- const graph = buildHITLGraph(node, [
596
- { id: 'call_1', name: 'echo', args: { command: 'do-it' } },
597
- ]);
598
- const config = { configurable: { thread_id: 'thread-hitl-map' } };
599
-
600
- const interrupted = await graph.invoke({ messages: [] }, config);
601
-
602
- const resumed = (await resumeGraph(
603
- graph,
604
- interrupted,
605
- { call_1: { type: 'approve' } },
606
- config
607
- )) as { messages: BaseMessage[] };
608
-
609
- const toolMessages = resumed.messages.filter(
610
- (m): m is ToolMessage => m._getType() === 'tool'
611
- );
612
- expect(toolMessages).toHaveLength(1);
613
- expect(toolMessages[0].content).toBe('host-result');
614
- });
615
- });
616
-
617
- describe('ToolNode HITL — opt-out (`humanInTheLoop: { enabled: false }`) is fail-closed', () => {
618
- afterEach(() => {
619
- jest.restoreAllMocks();
620
- });
621
-
622
- it('blocks the tool with a ToolMessage error and never raises an interrupt', async () => {
623
- mockEventDispatch([]);
624
- const node = new ToolNode({
625
- tools: [createSchemaStub('echo')],
626
- eventDrivenMode: true,
627
- agentId: 'agent-x',
628
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
629
- hookRegistry: makeHookRegistry('ask', 'HITL explicitly disabled'),
630
- humanInTheLoop: { enabled: false },
631
- });
632
-
633
- const graph = buildHITLGraph(node, [
634
- { id: 'call_1', name: 'echo', args: { command: 'list /' } },
635
- ]);
636
- const config = { configurable: { thread_id: 'thread-hitl-optout' } };
637
-
638
- const result = (await graph.invoke({ messages: [] }, config)) as {
639
- messages: BaseMessage[];
640
- };
641
-
642
- expect(isInterrupted(result)).toBe(false);
643
- const toolMessages = result.messages.filter(
644
- (m): m is ToolMessage => m._getType() === 'tool'
645
- );
646
- expect(toolMessages).toHaveLength(1);
647
- expect(toolMessages[0].status).toBe('error');
648
- expect(String(toolMessages[0].content)).toContain(
649
- 'HITL explicitly disabled'
650
- );
651
- });
652
-
653
- it('blocks the tool when `humanInTheLoop` is omitted (default-off)', async () => {
654
- /**
655
- * Default is OFF until host UIs (notably LibreChat) ship the
656
- * approval-rendering affordances. With HITL omitted, an `ask`
657
- * decision must collapse into a synchronous block — same fail-
658
- * closed behavior as the explicit `{ enabled: false }` opt-out.
659
- * This test guards against accidentally re-enabling the default-on
660
- * path before the consumer ecosystem is ready.
661
- */
662
- mockEventDispatch([
663
- { toolCallId: 'call_1', content: 'host-result', status: 'success' },
664
- ]);
665
- const node = new ToolNode({
666
- tools: [createSchemaStub('echo')],
667
- eventDrivenMode: true,
668
- agentId: 'agent-x',
669
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
670
- hookRegistry: makeHookRegistry('ask', 'default-off-blocks'),
671
- // humanInTheLoop intentionally omitted — should default to disabled
672
- });
673
-
674
- const graph = buildHITLGraph(node, [
675
- { id: 'call_1', name: 'echo', args: { command: 'list /' } },
676
- ]);
677
- const config = { configurable: { thread_id: 'thread-hitl-default' } };
678
-
679
- const out = (await graph.invoke({ messages: [] }, config)) as {
680
- messages: BaseMessage[];
681
- };
682
- expect(isInterrupted<t.HumanInterruptPayload>(out)).toBe(false);
683
- const toolMessages = out.messages.filter(
684
- (m): m is ToolMessage => m._getType() === 'tool'
685
- );
686
- expect(toolMessages).toHaveLength(1);
687
- expect(toolMessages[0].tool_call_id).toBe('call_1');
688
- expect(toolMessages[0].status).toBe('error');
689
- expect(String(toolMessages[0].content)).toContain('default-off-blocks');
690
- });
691
- });
692
-
693
- describe('ToolNode HITL — multi-tool batches', () => {
694
- afterEach(() => {
695
- jest.restoreAllMocks();
696
- });
697
-
698
- it('bundles multiple ask decisions into a single interrupt and resolves per call', async () => {
699
- const capturedRequests: t.ToolCallRequest[] = [];
700
- jest
701
- .spyOn(events, 'safeDispatchCustomEvent')
702
- .mockImplementation(async (event, data) => {
703
- if (event !== 'on_tool_execute') {
704
- return;
705
- }
706
- const request = data as {
707
- toolCalls: t.ToolCallRequest[];
708
- resolve: (r: t.ToolExecuteResult[]) => void;
709
- };
710
- capturedRequests.push(...request.toolCalls);
711
- request.resolve(
712
- request.toolCalls.map(
713
- (c): t.ToolExecuteResult => ({
714
- toolCallId: c.id,
715
- content: `ran:${c.name}`,
716
- status: 'success',
717
- })
718
- )
719
- );
720
- });
721
-
722
- const registry = new HookRegistry();
723
- registry.register('PreToolUse', {
724
- hooks: [
725
- async (): Promise<PreToolUseHookOutput> => ({
726
- decision: 'ask',
727
- reason: 'review',
728
- }),
729
- ],
730
- });
731
-
732
- const node = new ToolNode({
733
- tools: [createSchemaStub('echo'), createSchemaStub('cat')],
734
- eventDrivenMode: true,
735
- agentId: 'agent-x',
736
- toolCallStepIds: new Map([
737
- ['call_1', 'step_call_1'],
738
- ['call_2', 'step_call_2'],
739
- ]),
740
- hookRegistry: registry,
741
- humanInTheLoop: { enabled: true },
742
- });
743
-
744
- const graph = buildHITLGraph(node, [
745
- { id: 'call_1', name: 'echo', args: { command: 'one' } },
746
- { id: 'call_2', name: 'cat', args: { command: 'two' } },
747
- ]);
748
- const config = { configurable: { thread_id: 'thread-hitl-batch' } };
749
-
750
- const interrupted = await graph.invoke({ messages: [] }, config);
751
- expect(isInterrupted<t.HumanInterruptPayload>(interrupted)).toBe(true);
752
- if (!isInterrupted<t.HumanInterruptPayload>(interrupted)) {
753
- throw new Error('expected interrupt');
754
- }
755
- const payload = interrupted.__interrupt__[0].value!;
756
- if (payload.type !== 'tool_approval') {
757
- throw new Error('expected tool_approval payload');
758
- }
759
- expect(payload.action_requests.map((r) => r.tool_call_id)).toEqual([
760
- 'call_1',
761
- 'call_2',
762
- ]);
763
-
764
- const resumed = (await resumeGraph(
765
- graph,
766
- interrupted,
767
- [{ type: 'approve' }, { type: 'reject', reason: 'too risky' }],
768
- config
769
- )) as { messages: BaseMessage[] };
770
-
771
- const toolMessages = resumed.messages.filter(
772
- (m): m is ToolMessage => m._getType() === 'tool'
773
- );
774
- expect(toolMessages).toHaveLength(2);
775
- const byId = new Map(toolMessages.map((m) => [m.tool_call_id, m]));
776
- expect(byId.get('call_1')!.content).toBe('ran:echo');
777
- expect(byId.get('call_1')!.status).not.toBe('error');
778
- expect(byId.get('call_2')!.status).toBe('error');
779
- expect(String(byId.get('call_2')!.content)).toContain('too risky');
780
-
781
- expect(capturedRequests).toHaveLength(1);
782
- expect(capturedRequests[0].id).toBe('call_1');
783
- });
784
- });
785
-
786
- describe('Run integration — HITL fallback checkpointer + resume', () => {
787
- beforeEach(() => {
788
- jest.restoreAllMocks();
789
- });
790
- afterEach(() => {
791
- jest.restoreAllMocks();
792
- });
793
-
794
- it('Run.create does NOT install a MemorySaver fallback by default (HITL is off until host UI ships)', async () => {
795
- /**
796
- * Default-off rationale: HITL ships the interrupt machinery but
797
- * stays opt-in until host UIs (notably LibreChat) can render and
798
- * resolve `tool_approval` interrupts. With HITL omitted, the SDK
799
- * must NOT silently install a checkpointer — that would suggest
800
- * the run can pause/resume when in fact the `ask` path will
801
- * fail-closed. Plan of record: flip the default to ON in a future
802
- * minor once the consumer ecosystem is ready.
803
- */
804
- const { Run } = await import('@/run');
805
- const { Providers } = await import('@/common');
806
-
807
- const run = await Run.create<t.IState>({
808
- runId: 'hitl-default-run',
809
- graphConfig: {
810
- type: 'standard',
811
- agents: [
812
- {
813
- agentId: 'a',
814
- provider: Providers.OPENAI,
815
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
816
- instructions: 'noop',
817
- maxContextTokens: 8000,
818
- },
819
- ],
820
- },
821
- // humanInTheLoop intentionally omitted — default is OFF
822
- });
823
-
824
- expect(run.Graph?.compileOptions?.checkpointer).toBeUndefined();
825
- });
826
-
827
- it('Run.create installs a MemorySaver fallback when HITL is explicitly enabled', async () => {
828
- const { Run } = await import('@/run');
829
- const { Providers } = await import('@/common');
830
-
831
- const run = await Run.create<t.IState>({
832
- runId: 'hitl-explicit-run',
833
- graphConfig: {
834
- type: 'standard',
835
- agents: [
836
- {
837
- agentId: 'a',
838
- provider: Providers.OPENAI,
839
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
840
- instructions: 'noop',
841
- maxContextTokens: 8000,
842
- },
843
- ],
844
- },
845
- humanInTheLoop: { enabled: true },
846
- });
847
-
848
- expect(run.Graph?.compileOptions?.checkpointer).toBeInstanceOf(MemorySaver);
849
- expect(run.Graph?.humanInTheLoop?.enabled).toBe(true);
850
- });
851
-
852
- it('Run.create preserves a host-supplied checkpointer when HITL is explicitly enabled', async () => {
853
- const { Run } = await import('@/run');
854
- const { Providers } = await import('@/common');
855
-
856
- const hostCheckpointer = new MemorySaver();
857
- const run = await Run.create<t.IState>({
858
- runId: 'hitl-host-checkpointer',
859
- graphConfig: {
860
- type: 'standard',
861
- agents: [
862
- {
863
- agentId: 'a',
864
- provider: Providers.OPENAI,
865
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
866
- instructions: 'noop',
867
- maxContextTokens: 8000,
868
- },
869
- ],
870
- compileOptions: { checkpointer: hostCheckpointer },
871
- },
872
- humanInTheLoop: { enabled: true },
873
- });
874
-
875
- expect(run.Graph?.compileOptions?.checkpointer).toBe(hostCheckpointer);
876
- });
877
-
878
- it('processStream defaults durability to "exit" when a checkpointer is active', async () => {
879
- const { Run } = await import('@/run');
880
- const { Providers } = await import('@/common');
881
-
882
- const run = await Run.create<t.IState>({
883
- runId: 'durability-exit-default',
884
- graphConfig: {
885
- type: 'standard',
886
- agents: [
887
- {
888
- agentId: 'a',
889
- provider: Providers.OPENAI,
890
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
891
- instructions: 'noop',
892
- maxContextTokens: 8000,
893
- },
894
- ],
895
- },
896
- humanInTheLoop: { enabled: true },
897
- });
898
- expect(run.Graph?.compileOptions?.checkpointer).toBeInstanceOf(MemorySaver);
899
-
900
- const graph = new StateGraph(MessagesAnnotation)
901
- .addNode('noop', (): MessagesUpdate => ({ messages: [] }))
902
- .addEdge(START, 'noop')
903
- .addEdge('noop', END)
904
- .compile();
905
- const spy = jest.spyOn(graph, 'streamEvents');
906
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
907
-
908
- await run.processStream(
909
- { messages: [] },
910
- { version: 'v2', configurable: { thread_id: 't' } }
911
- );
912
-
913
- const streamedConfig = spy.mock.calls[0]?.[1] as
914
- | t.RunStreamConfig
915
- | undefined;
916
- expect(streamedConfig?.durability).toBe('exit');
917
- });
918
-
919
- it('processStream respects an explicit caller durability over the checkpointer default', async () => {
920
- const { Run } = await import('@/run');
921
- const { Providers } = await import('@/common');
922
-
923
- const run = await Run.create<t.IState>({
924
- runId: 'durability-explicit-override',
925
- graphConfig: {
926
- type: 'standard',
927
- agents: [
928
- {
929
- agentId: 'a',
930
- provider: Providers.OPENAI,
931
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
932
- instructions: 'noop',
933
- maxContextTokens: 8000,
934
- },
935
- ],
936
- },
937
- humanInTheLoop: { enabled: true },
938
- });
939
-
940
- const graph = new StateGraph(MessagesAnnotation)
941
- .addNode('noop', (): MessagesUpdate => ({ messages: [] }))
942
- .addEdge(START, 'noop')
943
- .addEdge('noop', END)
944
- .compile();
945
- const spy = jest.spyOn(graph, 'streamEvents');
946
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
947
-
948
- await run.processStream(
949
- { messages: [] },
950
- { version: 'v2', durability: 'sync', configurable: { thread_id: 't' } }
951
- );
952
-
953
- const streamedConfig = spy.mock.calls[0]?.[1] as
954
- | t.RunStreamConfig
955
- | undefined;
956
- expect(streamedConfig?.durability).toBe('sync');
957
- });
958
-
959
- it('processStream leaves durability unset when no checkpointer is active', async () => {
960
- const { Run } = await import('@/run');
961
- const { Providers } = await import('@/common');
962
-
963
- const run = await Run.create<t.IState>({
964
- runId: 'durability-no-checkpointer',
965
- graphConfig: {
966
- type: 'standard',
967
- agents: [
968
- {
969
- agentId: 'a',
970
- provider: Providers.OPENAI,
971
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
972
- instructions: 'noop',
973
- maxContextTokens: 8000,
974
- },
975
- ],
976
- },
977
- // humanInTheLoop omitted — no checkpointer installed
978
- });
979
- expect(run.Graph?.compileOptions?.checkpointer).toBeUndefined();
980
-
981
- const graph = new StateGraph(MessagesAnnotation)
982
- .addNode('noop', (): MessagesUpdate => ({ messages: [] }))
983
- .addEdge(START, 'noop')
984
- .addEdge('noop', END)
985
- .compile();
986
- const spy = jest.spyOn(graph, 'streamEvents');
987
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
988
-
989
- await run.processStream(
990
- { messages: [] },
991
- { version: 'v2', configurable: { thread_id: 't' } }
992
- );
993
-
994
- const streamedConfig = spy.mock.calls[0]?.[1] as
995
- | t.RunStreamConfig
996
- | undefined;
997
- expect(streamedConfig?.durability).toBeUndefined();
998
- });
999
-
1000
- it('defaults durability to "exit" when HITL installs the fallback checkpointer but caller compileOptions omit it', async () => {
1001
- const { Run } = await import('@/run');
1002
- const { Providers } = await import('@/common');
1003
-
1004
- const run = await Run.create<t.IState>({
1005
- runId: 'durability-hitl-fallback-compileopts',
1006
- graphConfig: {
1007
- type: 'standard',
1008
- agents: [
1009
- {
1010
- agentId: 'a',
1011
- provider: Providers.OPENAI,
1012
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
1013
- instructions: 'noop',
1014
- maxContextTokens: 8000,
1015
- },
1016
- ],
1017
- // Caller compileOptions without a checkpointer: HITL adds a MemorySaver
1018
- // fallback while preserving the caller's remaining compile options.
1019
- compileOptions: { interruptBefore: [] },
1020
- },
1021
- humanInTheLoop: { enabled: true },
1022
- });
1023
- expect(run.Graph?.compileOptions?.checkpointer).toBeInstanceOf(MemorySaver);
1024
- expect(run.Graph?.compileOptions?.interruptBefore).toEqual([]);
1025
-
1026
- const graph = new StateGraph(MessagesAnnotation)
1027
- .addNode('noop', (): MessagesUpdate => ({ messages: [] }))
1028
- .addEdge(START, 'noop')
1029
- .addEdge('noop', END)
1030
- .compile();
1031
- const spy = jest.spyOn(graph, 'streamEvents');
1032
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
1033
-
1034
- await run.processStream(
1035
- { messages: [] },
1036
- { version: 'v2', configurable: { thread_id: 't' } }
1037
- );
1038
-
1039
- const streamedConfig = spy.mock.calls[0]?.[1] as
1040
- | t.RunStreamConfig
1041
- | undefined;
1042
- expect(streamedConfig?.durability).toBe('exit');
1043
- });
1044
-
1045
- it('Run.resume forwards update + goto into the resume Command (langgraph 1.4.5)', async () => {
1046
- const { Run } = await import('@/run');
1047
- const { Providers } = await import('@/common');
1048
-
1049
- const run = await Run.create<t.IState>({
1050
- runId: 'hitl-resume-update-goto',
1051
- graphConfig: {
1052
- type: 'standard',
1053
- agents: [
1054
- {
1055
- agentId: 'a',
1056
- provider: Providers.OPENAI,
1057
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
1058
- instructions: 'noop',
1059
- maxContextTokens: 8000,
1060
- },
1061
- ],
1062
- },
1063
- });
1064
-
1065
- const spy = jest.spyOn(run, 'processStream').mockResolvedValue(undefined);
1066
-
1067
- const decision = [{ type: 'approve' as const }];
1068
- const update = { messages: [new AIMessage('host-edit')] };
1069
- await run.resume(
1070
- decision,
1071
- { version: 'v1', configurable: { thread_id: 't' } },
1072
- undefined,
1073
- { update, goto: 'agent' }
1074
- );
1075
-
1076
- const cmd = spy.mock.calls[0]?.[0] as Command;
1077
- expect(cmd).toBeInstanceOf(Command);
1078
- // No interrupt was captured, so the resume value passes through unscoped.
1079
- expect(cmd.resume).toEqual(decision);
1080
- expect(cmd.update).toEqual(update);
1081
- expect(cmd.goto).toEqual(['agent']); // langgraph normalizes goto to an array
1082
-
1083
- // Backward-compat: omitting commandOptions leaves update unset, goto empty.
1084
- await run.resume(decision, {
1085
- version: 'v1',
1086
- configurable: { thread_id: 't' },
1087
- });
1088
- const cmd2 = spy.mock.calls[1]?.[0] as Command;
1089
- expect(cmd2.update).toBeUndefined();
1090
- expect(cmd2.goto).toEqual([]);
1091
- });
1092
-
1093
- it('Run.resume restores a persisted interrupt before scoping a rebuilt resume', async () => {
1094
- const { Run } = await import('@/run');
1095
- const { Providers } = await import('@/common');
1096
-
1097
- const registry = new HookRegistry();
1098
- const persistedMatcher = {
1099
- hooks: [async (): Promise<PreToolUseHookOutput> => ({ decision: 'ask' })],
1100
- };
1101
- registry.registerSession(
1102
- 'persisted-hook-session',
1103
- 'PreToolUse',
1104
- persistedMatcher
1105
- );
1106
-
1107
- const run = await Run.create<t.IState>({
1108
- runId: 'hitl-rebuilt-scope',
1109
- graphConfig: {
1110
- type: 'standard',
1111
- agents: [
1112
- {
1113
- agentId: 'a',
1114
- provider: Providers.OPENAI,
1115
- clientOptions: {
1116
- modelName: 'gpt-4o-mini',
1117
- apiKey: 'test-key',
1118
- },
1119
- instructions: 'noop',
1120
- maxContextTokens: 8000,
1121
- },
1122
- ],
1123
- },
1124
- hooks: registry,
1125
- humanInTheLoop: { enabled: true },
1126
- });
1127
- const persistedState = {
1128
- config: {
1129
- configurable: {
1130
- thread_id: 'durable-thread',
1131
- checkpoint_id: 'interrupted-checkpoint',
1132
- checkpoint_ns: '',
1133
- },
1134
- },
1135
- tasks: [
1136
- {
1137
- interrupts: [
1138
- {
1139
- id: 'persisted-interrupt',
1140
- value: {
1141
- type: 'tool_approval',
1142
- hook_session_id: 'persisted-hook-session',
1143
- },
1144
- },
1145
- ],
1146
- },
1147
- ],
1148
- };
1149
- const getState = jest.fn(async (_config: RunnableConfig) => persistedState);
1150
- run.graphRunnable = { getState } as unknown as t.CompiledStateWorkflow;
1151
- const processSpy = jest
1152
- .spyOn(run, 'processStream')
1153
- .mockResolvedValue(undefined);
1154
- const callerConfig = {
1155
- version: 'v2' as const,
1156
- configurable: { thread_id: 'durable-thread' },
1157
- };
1158
- const decision = [{ type: 'approve' as const }];
1159
-
1160
- await run.resume(decision, callerConfig);
1161
-
1162
- expect(getState).toHaveBeenCalledWith(callerConfig);
1163
- const command = processSpy.mock.calls[0]?.[0] as Command;
1164
- expect(command.resume).toEqual({ 'persisted-interrupt': decision });
1165
- expect(processSpy.mock.calls[0]?.[1].configurable).toMatchObject({
1166
- thread_id: 'durable-thread',
1167
- checkpoint_id: 'interrupted-checkpoint',
1168
- checkpoint_ns: '',
1169
- });
1170
- expect(run.getInterrupt()).toMatchObject({
1171
- interruptId: 'persisted-interrupt',
1172
- threadId: 'durable-thread',
1173
- payload: {
1174
- type: 'tool_approval',
1175
- hook_session_id: 'persisted-hook-session',
1176
- },
1177
- });
1178
- expect(
1179
- registry.getMatchers('PreToolUse', 'persisted-hook-session')
1180
- ).toEqual([persistedMatcher]);
1181
- expect(registry.getMatchers('PreToolUse', run.id)).toEqual([
1182
- persistedMatcher,
1183
- ]);
1184
- });
1185
-
1186
- it('re-exports langgraph HITL primitives from the SDK barrel for host use', async () => {
1187
- const indexExports = await import('@/index');
1188
- expect(indexExports.MemorySaver).toBe(MemorySaver);
1189
- expect(indexExports.Command).toBe(Command);
1190
- expect(indexExports.INTERRUPT).toBeDefined();
1191
- expect(typeof indexExports.interrupt).toBe('function');
1192
- expect(typeof indexExports.isInterrupted).toBe('function');
1193
- expect(typeof indexExports.BaseCheckpointSaver).toBe('function');
1194
- });
1195
-
1196
- it('Run.create does not attach a checkpointer when HITL is explicitly disabled', async () => {
1197
- const { Run } = await import('@/run');
1198
- const { Providers } = await import('@/common');
1199
-
1200
- const run = await Run.create<t.IState>({
1201
- runId: 'hitl-optout-run',
1202
- graphConfig: {
1203
- type: 'standard',
1204
- agents: [
1205
- {
1206
- agentId: 'a',
1207
- provider: Providers.OPENAI,
1208
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
1209
- instructions: 'noop',
1210
- maxContextTokens: 8000,
1211
- },
1212
- ],
1213
- },
1214
- humanInTheLoop: { enabled: false },
1215
- });
1216
-
1217
- expect(run.Graph?.compileOptions?.checkpointer).toBeUndefined();
1218
- });
1219
-
1220
- it('Run.resume() drives the host all the way through the resume command path', async () => {
1221
- /** End-to-end on the Run wrapper: build a HITL graph that
1222
- * interrupts on first invoke, then drive resume via the Run's
1223
- * own `resume()` method (not raw graph.invoke + Command).
1224
- * Validates the full Run.resume → processStream(Command) path. */
1225
- let dispatchCount = 0;
1226
- jest
1227
- .spyOn(events, 'safeDispatchCustomEvent')
1228
- .mockImplementation(async (event, data) => {
1229
- if (event !== 'on_tool_execute') {
1230
- return;
1231
- }
1232
- dispatchCount += 1;
1233
- const request = data as {
1234
- toolCalls: t.ToolCallRequest[];
1235
- resolve: (r: t.ToolExecuteResult[]) => void;
1236
- };
1237
- request.resolve(
1238
- request.toolCalls.map((c) => ({
1239
- toolCallId: c.id,
1240
- content: 'host-result',
1241
- status: 'success' as const,
1242
- }))
1243
- );
1244
- });
1245
-
1246
- const registry = new HookRegistry();
1247
- registry.register('PreToolUse', {
1248
- hooks: [
1249
- async (): Promise<PreToolUseHookOutput> => ({
1250
- decision: 'ask',
1251
- reason: 'review',
1252
- }),
1253
- ],
1254
- });
1255
-
1256
- const hexToolCallId = '0123456789abcdef0123456789abcdef';
1257
- const node = new ToolNode({
1258
- tools: [createSchemaStub('echo')],
1259
- eventDrivenMode: true,
1260
- agentId: 'agent-x',
1261
- toolCallStepIds: new Map([[hexToolCallId, 'step_1']]),
1262
- hookRegistry: registry,
1263
- humanInTheLoop: { enabled: true },
1264
- });
1265
-
1266
- const builder = new StateGraph(MessagesAnnotation)
1267
- .addNode(
1268
- 'agent',
1269
- (): MessagesUpdate => ({
1270
- messages: [
1271
- new AIMessage({
1272
- content: '',
1273
- tool_calls: [
1274
- { id: hexToolCallId, name: 'echo', args: { command: 'x' } },
1275
- ],
1276
- }),
1277
- ],
1278
- })
1279
- )
1280
- .addNode('tools', node)
1281
- .addEdge(START, 'agent')
1282
- .addEdge('agent', 'tools')
1283
- .addEdge('tools', END);
1284
- const graph = builder.compile({ checkpointer: new MemorySaver() });
1285
-
1286
- const { Run } = await import('@/run');
1287
- const run = await Run.create<t.IState>({
1288
- runId: 'run-resume-direct',
1289
- graphConfig: {
1290
- type: 'standard',
1291
- agents: [
1292
- {
1293
- agentId: 'a',
1294
- provider: providers.OPENAI,
1295
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
1296
- instructions: 'noop',
1297
- maxContextTokens: 8000,
1298
- },
1299
- ],
1300
- },
1301
- hooks: registry,
1302
- humanInTheLoop: { enabled: true },
1303
- });
1304
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
1305
-
1306
- const callerConfig = {
1307
- configurable: { thread_id: 'run-resume-thread' },
1308
- version: 'v2' as const,
1309
- };
1310
-
1311
- await run.processStream({ messages: [] }, callerConfig);
1312
- expect(run.getInterrupt()).toBeDefined();
1313
- expect(dispatchCount).toBe(0);
1314
-
1315
- /** This is the API contract under test: Run.resume() with a
1316
- * tool_call_id-keyed decision map (not graph.invoke + Command).
1317
- * The tool_call_id intentionally looks like a LangGraph interrupt
1318
- * id; Run.resume must still wrap it under the real interrupt id. */
1319
- await run.resume({ [hexToolCallId]: { type: 'approve' } }, callerConfig);
1320
-
1321
- expect(dispatchCount).toBe(1);
1322
- /** Resume completed naturally: interrupt cleared, no halt
1323
- * reason carried over from the previous pass. */
1324
- expect(run.getInterrupt()).toBeUndefined();
1325
- expect(run.getHaltReason()).toBeUndefined();
1326
- });
1327
-
1328
- it('Run.resume() forwards `update` so langgraph applies the channel edit through streamEvents', async () => {
1329
- /** Executing proof (not a spy): interrupt on a tool, resume with an
1330
- * injected message via `update`, then read the committed checkpoint.
1331
- * langgraph 1.4.5 maps an INPUT resume Command through `mapCommand`
1332
- * (pregel/io.js), which applies resume AND update AND goto, so the
1333
- * injected message must land in the messages channel. */
1334
- jest
1335
- .spyOn(events, 'safeDispatchCustomEvent')
1336
- .mockImplementation(async (event, data) => {
1337
- if (event !== 'on_tool_execute') {
1338
- return;
1339
- }
1340
- const request = data as {
1341
- toolCalls: t.ToolCallRequest[];
1342
- resolve: (r: t.ToolExecuteResult[]) => void;
1343
- };
1344
- request.resolve(
1345
- request.toolCalls.map((c) => ({
1346
- toolCallId: c.id,
1347
- content: 'host-result',
1348
- status: 'success' as const,
1349
- }))
1350
- );
1351
- });
1352
-
1353
- const registry = new HookRegistry();
1354
- registry.register('PreToolUse', {
1355
- hooks: [
1356
- async (): Promise<PreToolUseHookOutput> => ({
1357
- decision: 'ask',
1358
- reason: 'review',
1359
- }),
1360
- ],
1361
- });
1362
-
1363
- const hexToolCallId = '0123456789abcdef0123456789abcdef';
1364
- const node = new ToolNode({
1365
- tools: [createSchemaStub('echo')],
1366
- eventDrivenMode: true,
1367
- agentId: 'agent-x',
1368
- toolCallStepIds: new Map([[hexToolCallId, 'step_1']]),
1369
- hookRegistry: registry,
1370
- humanInTheLoop: { enabled: true },
1371
- });
1372
-
1373
- const builder = new StateGraph(MessagesAnnotation)
1374
- .addNode(
1375
- 'agent',
1376
- (): MessagesUpdate => ({
1377
- messages: [
1378
- new AIMessage({
1379
- content: '',
1380
- tool_calls: [
1381
- { id: hexToolCallId, name: 'echo', args: { command: 'x' } },
1382
- ],
1383
- }),
1384
- ],
1385
- })
1386
- )
1387
- .addNode('tools', node)
1388
- .addEdge(START, 'agent')
1389
- .addEdge('agent', 'tools')
1390
- .addEdge('tools', END);
1391
- const graph = builder.compile({ checkpointer: new MemorySaver() });
1392
-
1393
- const { Run } = await import('@/run');
1394
- const { HumanMessage } = await import('@langchain/core/messages');
1395
- const run = await Run.create<t.IState>({
1396
- runId: 'run-resume-update',
1397
- graphConfig: {
1398
- type: 'standard',
1399
- agents: [
1400
- {
1401
- agentId: 'a',
1402
- provider: providers.OPENAI,
1403
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
1404
- instructions: 'noop',
1405
- maxContextTokens: 8000,
1406
- },
1407
- ],
1408
- },
1409
- hooks: registry,
1410
- humanInTheLoop: { enabled: true },
1411
- });
1412
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
1413
-
1414
- const callerConfig = {
1415
- configurable: { thread_id: 'run-resume-update-thread' },
1416
- version: 'v2' as const,
1417
- };
1418
-
1419
- await run.processStream({ messages: [] }, callerConfig);
1420
- expect(run.getInterrupt()).toBeDefined();
1421
-
1422
- const injected = new HumanMessage({ content: 'human-injected-on-resume' });
1423
- await run.resume(
1424
- { [hexToolCallId]: { type: 'approve' } },
1425
- callerConfig,
1426
- undefined,
1427
- { update: { messages: [injected] } }
1428
- );
1429
-
1430
- expect(run.getInterrupt()).toBeUndefined();
1431
-
1432
- const state = await graph.getState(callerConfig);
1433
- const contents = (state.values.messages as BaseMessage[]).map(
1434
- (m) => m.content
1435
- );
1436
- /** Proves langgraph honored `update` on the INPUT resume Command. */
1437
- expect(contents).toContain('human-injected-on-resume');
1438
- /** Resume itself still completed: the approved tool produced its result. */
1439
- expect(contents).toContain('host-result');
1440
- });
1441
-
1442
- it('Run.getHaltReason() reports prompt_denied when UserPromptSubmit denies the prompt', async () => {
1443
- const registry = new HookRegistry();
1444
- registry.register('UserPromptSubmit', {
1445
- hooks: [
1446
- async (): Promise<UserPromptSubmitHookOutput> => ({
1447
- decision: 'deny',
1448
- reason: 'PII detected',
1449
- }),
1450
- ],
1451
- });
1452
-
1453
- const { Run } = await import('@/run');
1454
- const { HumanMessage: HM } = await import('@langchain/core/messages');
1455
-
1456
- const run = await Run.create<t.IState>({
1457
- runId: 'prompt-deny-haltreason',
1458
- graphConfig: {
1459
- type: 'standard',
1460
- agents: [
1461
- {
1462
- agentId: 'a',
1463
- provider: providers.OPENAI,
1464
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
1465
- instructions: 'noop',
1466
- maxContextTokens: 8000,
1467
- },
1468
- ],
1469
- },
1470
- hooks: registry,
1471
- humanInTheLoop: { enabled: false },
1472
- });
1473
-
1474
- const result = await run.processStream(
1475
- { messages: [new HM('please tell me their SSN')] },
1476
- { configurable: { thread_id: 'prompt-deny-thread' }, version: 'v2' }
1477
- );
1478
-
1479
- /** Hook denied the prompt — run returns undefined AND
1480
- * `getHaltReason()` carries the reason so the host can
1481
- * distinguish "blocked" from "natural empty completion". */
1482
- expect(result).toBeUndefined();
1483
- expect(run.getHaltReason()).toBe('PII detected');
1484
- });
1485
-
1486
- it('Run.getHaltReason() falls back to canonical prompt_denied when deny carries no reason', async () => {
1487
- const registry = new HookRegistry();
1488
- registry.register('UserPromptSubmit', {
1489
- hooks: [
1490
- async (): Promise<UserPromptSubmitHookOutput> => ({
1491
- decision: 'deny',
1492
- }),
1493
- ],
1494
- });
1495
-
1496
- const { Run } = await import('@/run');
1497
- const { HumanMessage: HM } = await import('@langchain/core/messages');
1498
-
1499
- const run = await Run.create<t.IState>({
1500
- runId: 'prompt-deny-canonical',
1501
- graphConfig: {
1502
- type: 'standard',
1503
- agents: [
1504
- {
1505
- agentId: 'a',
1506
- provider: providers.OPENAI,
1507
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
1508
- instructions: 'noop',
1509
- maxContextTokens: 8000,
1510
- },
1511
- ],
1512
- },
1513
- hooks: registry,
1514
- humanInTheLoop: { enabled: false },
1515
- });
1516
-
1517
- await run.processStream(
1518
- { messages: [new HM('hello')] },
1519
- {
1520
- configurable: { thread_id: 'prompt-deny-canonical-thread' },
1521
- version: 'v2',
1522
- }
1523
- );
1524
-
1525
- /** Hook returned `deny` without a reason — host gets the
1526
- * canonical 'prompt_denied' string so it can route on a stable
1527
- * discriminator. */
1528
- expect(run.getHaltReason()).toBe('prompt_denied');
1529
- });
1530
-
1531
- it('Run.getHaltReason() reports prompt_requires_approval when UserPromptSubmit asks', async () => {
1532
- const registry = new HookRegistry();
1533
- registry.register('UserPromptSubmit', {
1534
- hooks: [
1535
- async (): Promise<UserPromptSubmitHookOutput> => ({
1536
- decision: 'ask',
1537
- }),
1538
- ],
1539
- });
1540
-
1541
- const { Run } = await import('@/run');
1542
- const { HumanMessage: HM } = await import('@langchain/core/messages');
1543
-
1544
- const run = await Run.create<t.IState>({
1545
- runId: 'prompt-ask-haltreason',
1546
- graphConfig: {
1547
- type: 'standard',
1548
- agents: [
1549
- {
1550
- agentId: 'a',
1551
- provider: providers.OPENAI,
1552
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
1553
- instructions: 'noop',
1554
- maxContextTokens: 8000,
1555
- },
1556
- ],
1557
- },
1558
- hooks: registry,
1559
- humanInTheLoop: { enabled: false },
1560
- });
1561
-
1562
- await run.processStream(
1563
- { messages: [new HM('hello')] },
1564
- { configurable: { thread_id: 'prompt-ask-thread' }, version: 'v2' }
1565
- );
1566
-
1567
- /** Default reason when the hook didn't supply one — host can
1568
- * route on the canonical string. */
1569
- expect(run.getHaltReason()).toBe('prompt_requires_approval');
1570
- });
1571
- });
1572
-
1573
- describe('ToolNode HITL — additionalContext injection from hooks', () => {
1574
- afterEach(() => {
1575
- jest.restoreAllMocks();
1576
- });
1577
-
1578
- it('injects PreToolUse + PostToolUse additionalContexts as a single HumanMessage', async () => {
1579
- mockEventDispatch([
1580
- { toolCallId: 'call_1', content: 'host-result', status: 'success' },
1581
- ]);
1582
-
1583
- const registry = new HookRegistry();
1584
- registry.register('PreToolUse', {
1585
- hooks: [
1586
- async (): Promise<PreToolUseHookOutput> => ({
1587
- decision: 'allow',
1588
- additionalContext: 'pre-context: be careful',
1589
- }),
1590
- ],
1591
- });
1592
- registry.register('PostToolUse', {
1593
- hooks: [
1594
- async (): Promise<PostToolUseHookOutput> => ({
1595
- additionalContext: 'post-context: tool ran',
1596
- }),
1597
- ],
1598
- });
1599
-
1600
- const node = new ToolNode({
1601
- tools: [createSchemaStub('echo')],
1602
- eventDrivenMode: true,
1603
- agentId: 'agent-x',
1604
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
1605
- hookRegistry: registry,
1606
- humanInTheLoop: { enabled: false },
1607
- });
1608
-
1609
- const graph = buildHITLGraph(node, [
1610
- { id: 'call_1', name: 'echo', args: { command: 'do' } },
1611
- ]);
1612
- const result = (await graph.invoke(
1613
- { messages: [] },
1614
- { configurable: { thread_id: 'ctx-thread-1' } }
1615
- )) as { messages: BaseMessage[] };
1616
-
1617
- const injected = result.messages.find(
1618
- (m) =>
1619
- m._getType() === 'human' &&
1620
- (m as { additional_kwargs?: { source?: string } }).additional_kwargs
1621
- ?.source === 'hook'
1622
- );
1623
- expect(injected).toBeDefined();
1624
- expect(String(injected!.content)).toContain('pre-context: be careful');
1625
- expect(String(injected!.content)).toContain('post-context: tool ran');
1626
- });
1627
-
1628
- it('does not inject anything when no hook returns additionalContext', async () => {
1629
- mockEventDispatch([
1630
- { toolCallId: 'call_1', content: 'host-result', status: 'success' },
1631
- ]);
1632
-
1633
- const registry = new HookRegistry();
1634
- registry.register('PreToolUse', {
1635
- hooks: [
1636
- async (): Promise<PreToolUseHookOutput> => ({ decision: 'allow' }),
1637
- ],
1638
- });
1639
-
1640
- const node = new ToolNode({
1641
- tools: [createSchemaStub('echo')],
1642
- eventDrivenMode: true,
1643
- agentId: 'agent-x',
1644
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
1645
- hookRegistry: registry,
1646
- humanInTheLoop: { enabled: false },
1647
- });
1648
-
1649
- const graph = buildHITLGraph(node, [
1650
- { id: 'call_1', name: 'echo', args: { command: 'do' } },
1651
- ]);
1652
- const result = (await graph.invoke(
1653
- { messages: [] },
1654
- { configurable: { thread_id: 'ctx-thread-2' } }
1655
- )) as { messages: BaseMessage[] };
1656
-
1657
- const injected = result.messages.find(
1658
- (m) =>
1659
- m._getType() === 'human' &&
1660
- (m as { additional_kwargs?: { source?: string } }).additional_kwargs
1661
- ?.source === 'hook'
1662
- );
1663
- expect(injected).toBeUndefined();
1664
- });
1665
- });
1666
-
1667
- describe('ToolNode HITL — PostToolBatch hook', () => {
1668
- afterEach(() => {
1669
- jest.restoreAllMocks();
1670
- });
1671
-
1672
- it('fires once per dispatch with all entries (success + error mix), in batch order', async () => {
1673
- jest
1674
- .spyOn(events, 'safeDispatchCustomEvent')
1675
- .mockImplementation(async (event, data) => {
1676
- if (event !== 'on_tool_execute') {
1677
- return;
1678
- }
1679
- const request = data as {
1680
- toolCalls: t.ToolCallRequest[];
1681
- resolve: (r: t.ToolExecuteResult[]) => void;
1682
- };
1683
- request.resolve([
1684
- { toolCallId: 'call_1', content: 'ok', status: 'success' },
1685
- {
1686
- toolCallId: 'call_2',
1687
- content: '',
1688
- status: 'error',
1689
- errorMessage: 'boom',
1690
- },
1691
- ]);
1692
- });
1693
-
1694
- const registry = new HookRegistry();
1695
- let captured: PostToolBatchEntry[] | undefined;
1696
- registry.register('PostToolBatch', {
1697
- hooks: [
1698
- async (input): Promise<PostToolBatchHookOutput> => {
1699
- captured = (input as PostToolBatchHookInput).entries;
1700
- return {};
1701
- },
1702
- ],
1703
- });
1704
-
1705
- const node = new ToolNode({
1706
- tools: [createSchemaStub('echo'), createSchemaStub('cat')],
1707
- eventDrivenMode: true,
1708
- agentId: 'agent-x',
1709
- toolCallStepIds: new Map([
1710
- ['call_1', 'step_1'],
1711
- ['call_2', 'step_2'],
1712
- ]),
1713
- hookRegistry: registry,
1714
- humanInTheLoop: { enabled: false },
1715
- });
1716
-
1717
- const graph = buildHITLGraph(node, [
1718
- { id: 'call_1', name: 'echo', args: { command: 'a' } },
1719
- { id: 'call_2', name: 'cat', args: { command: 'b' } },
1720
- ]);
1721
- await graph.invoke(
1722
- { messages: [] },
1723
- { configurable: { thread_id: 'batch-thread' } }
1724
- );
1725
-
1726
- expect(captured).toBeDefined();
1727
- expect(captured!).toHaveLength(2);
1728
- expect(captured![0].toolUseId).toBe('call_1');
1729
- expect(captured![0].status).toBe('success');
1730
- expect(captured![0].toolOutput).toBe('ok');
1731
- expect(captured![1].toolUseId).toBe('call_2');
1732
- expect(captured![1].status).toBe('error');
1733
- expect(captured![1].error).toContain('boom');
1734
- });
1735
-
1736
- it('a PostToolBatch additionalContext gets injected as a HumanMessage', async () => {
1737
- mockEventDispatch([
1738
- { toolCallId: 'call_1', content: 'ok', status: 'success' },
1739
- ]);
1740
-
1741
- const registry = new HookRegistry();
1742
- registry.register('PostToolBatch', {
1743
- hooks: [
1744
- async (): Promise<PostToolBatchHookOutput> => ({
1745
- additionalContext: 'remember to format the response as JSON',
1746
- }),
1747
- ],
1748
- });
1749
-
1750
- const node = new ToolNode({
1751
- tools: [createSchemaStub('echo')],
1752
- eventDrivenMode: true,
1753
- agentId: 'agent-x',
1754
- toolCallStepIds: new Map([['call_1', 'step_1']]),
1755
- hookRegistry: registry,
1756
- humanInTheLoop: { enabled: false },
1757
- });
1758
-
1759
- const graph = buildHITLGraph(node, [
1760
- { id: 'call_1', name: 'echo', args: { command: 'a' } },
1761
- ]);
1762
- const result = (await graph.invoke(
1763
- { messages: [] },
1764
- { configurable: { thread_id: 'batch-ctx-thread' } }
1765
- )) as { messages: BaseMessage[] };
1766
-
1767
- const injected = result.messages.find(
1768
- (m) =>
1769
- m._getType() === 'human' &&
1770
- (m as { additional_kwargs?: { source?: string } }).additional_kwargs
1771
- ?.source === 'hook'
1772
- );
1773
- expect(injected).toBeDefined();
1774
- expect(String(injected!.content)).toContain('format the response as JSON');
1775
- });
1776
-
1777
- it('PostToolBatch injectedMessages land as individual HumanMessages after the consolidated context', async () => {
1778
- mockEventDispatch([
1779
- { toolCallId: 'call_1', content: 'ok', status: 'success' },
1780
- ]);
1781
-
1782
- const registry = new HookRegistry();
1783
- registry.register('PostToolBatch', {
1784
- hooks: [
1785
- async (): Promise<PostToolBatchHookOutput> => ({
1786
- additionalContext: 'batch convention',
1787
- injectedMessages: [
1788
- { role: 'user', content: 'steer one', source: 'steer' },
1789
- { role: 'user', content: 'steer two', source: 'steer' },
1790
- ],
1791
- }),
1792
- ],
1793
- });
1794
-
1795
- const node = new ToolNode({
1796
- tools: [createSchemaStub('echo')],
1797
- eventDrivenMode: true,
1798
- agentId: 'agent-x',
1799
- toolCallStepIds: new Map([['call_1', 'step_1']]),
1800
- hookRegistry: registry,
1801
- humanInTheLoop: { enabled: false },
1802
- });
1803
-
1804
- const graph = buildHITLGraph(node, [
1805
- { id: 'call_1', name: 'echo', args: { command: 'a' } },
1806
- ]);
1807
- const result = (await graph.invoke(
1808
- { messages: [] },
1809
- { configurable: { thread_id: 'batch-steer-thread' } }
1810
- )) as { messages: BaseMessage[] };
1811
-
1812
- type KwargMessage = {
1813
- additional_kwargs?: { source?: string; role?: string };
1814
- };
1815
- const humanMessages = result.messages.filter(
1816
- (m) => m._getType() === 'human'
1817
- );
1818
- const contextIndex = humanMessages.findIndex(
1819
- (m) => (m as KwargMessage).additional_kwargs?.source === 'hook'
1820
- );
1821
- const steerMessages = humanMessages.filter(
1822
- (m) => (m as KwargMessage).additional_kwargs?.source === 'steer'
1823
- );
1824
-
1825
- expect(contextIndex).toBeGreaterThanOrEqual(0);
1826
- expect(steerMessages).toHaveLength(2);
1827
- expect(String(steerMessages[0].content)).toBe('steer one');
1828
- expect(String(steerMessages[1].content)).toBe('steer two');
1829
- for (const steer of steerMessages) {
1830
- expect((steer as KwargMessage).additional_kwargs?.role).toBe('user');
1831
- expect(humanMessages.indexOf(steer)).toBeGreaterThan(contextIndex);
1832
- }
1833
- const toolIndex = result.messages.findIndex((m) => m._getType() === 'tool');
1834
- const firstSteerIndex = result.messages.indexOf(steerMessages[0]);
1835
- expect(firstSteerIndex).toBeGreaterThan(toolIndex);
1836
- });
1837
-
1838
- it('PostToolBatch injectedMessages work without additionalContext', async () => {
1839
- mockEventDispatch([
1840
- { toolCallId: 'call_1', content: 'ok', status: 'success' },
1841
- ]);
1842
-
1843
- const registry = new HookRegistry();
1844
- registry.register('PostToolBatch', {
1845
- hooks: [
1846
- async (): Promise<PostToolBatchHookOutput> => ({
1847
- injectedMessages: [
1848
- { role: 'user', content: 'solo steer', source: 'steer' },
1849
- ],
1850
- }),
1851
- ],
1852
- });
1853
-
1854
- const node = new ToolNode({
1855
- tools: [createSchemaStub('echo')],
1856
- eventDrivenMode: true,
1857
- agentId: 'agent-x',
1858
- toolCallStepIds: new Map([['call_1', 'step_1']]),
1859
- hookRegistry: registry,
1860
- humanInTheLoop: { enabled: false },
1861
- });
1862
-
1863
- const graph = buildHITLGraph(node, [
1864
- { id: 'call_1', name: 'echo', args: { command: 'a' } },
1865
- ]);
1866
- const result = (await graph.invoke(
1867
- { messages: [] },
1868
- { configurable: { thread_id: 'solo-steer-thread' } }
1869
- )) as { messages: BaseMessage[] };
1870
-
1871
- type KwargMessage = {
1872
- additional_kwargs?: { source?: string; role?: string };
1873
- };
1874
- const consolidated = result.messages.find(
1875
- (m) =>
1876
- m._getType() === 'human' &&
1877
- (m as KwargMessage).additional_kwargs?.source === 'hook'
1878
- );
1879
- const steer = result.messages.find(
1880
- (m) =>
1881
- m._getType() === 'human' &&
1882
- (m as KwargMessage).additional_kwargs?.source === 'steer'
1883
- );
1884
- expect(consolidated).toBeUndefined();
1885
- expect(steer).toBeDefined();
1886
- expect(String(steer!.content)).toBe('solo steer');
1887
- });
1888
- });
1889
-
1890
- describe('ToolNode HITL — per-hook allowedDecisions override', () => {
1891
- afterEach(() => {
1892
- jest.restoreAllMocks();
1893
- });
1894
-
1895
- it('restricts the interrupt review_configs.allowed_decisions to the hook-supplied subset', async () => {
1896
- const registry = new HookRegistry();
1897
- registry.register('PreToolUse', {
1898
- hooks: [
1899
- async (): Promise<PreToolUseHookOutput> => ({
1900
- decision: 'ask',
1901
- allowedDecisions: ['approve', 'reject'],
1902
- }),
1903
- ],
1904
- });
1905
-
1906
- const node = new ToolNode({
1907
- tools: [createSchemaStub('echo')],
1908
- eventDrivenMode: true,
1909
- agentId: 'agent-x',
1910
- toolCallStepIds: new Map([['call_1', 'step_1']]),
1911
- hookRegistry: registry,
1912
- humanInTheLoop: { enabled: true },
1913
- });
1914
-
1915
- const graph = buildHITLGraph(node, [
1916
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
1917
- ]);
1918
- const interrupted = await graph.invoke(
1919
- { messages: [] },
1920
- { configurable: { thread_id: 'allowed-thread' } }
1921
- );
1922
- if (!isInterrupted<t.HumanInterruptPayload>(interrupted)) {
1923
- throw new Error('expected interrupt');
1924
- }
1925
- const payload = interrupted.__interrupt__[0].value!;
1926
- if (payload.type !== 'tool_approval') {
1927
- throw new Error('expected tool_approval');
1928
- }
1929
- expect(payload.review_configs[0].allowed_decisions).toEqual([
1930
- 'approve',
1931
- 'reject',
1932
- ]);
1933
- });
1934
- });
1935
-
1936
- describe('Run — preventContinuation honored for pre-stream hooks', () => {
1937
- beforeEach(() => {
1938
- jest.restoreAllMocks();
1939
- });
1940
- afterEach(() => {
1941
- jest.restoreAllMocks();
1942
- });
1943
-
1944
- it('returns undefined without invoking the graph when RunStart hook returns preventContinuation', async () => {
1945
- const { Run } = await import('@/run');
1946
- const { Providers } = await import('@/common');
1947
- const { HumanMessage: HM } = await import('@langchain/core/messages');
1948
-
1949
- const registry = new HookRegistry();
1950
- let runStartFired = false;
1951
- registry.register('RunStart', {
1952
- hooks: [
1953
- async (): Promise<RunStartHookOutput> => {
1954
- runStartFired = true;
1955
- return {
1956
- preventContinuation: true,
1957
- stopReason: 'pre-flight policy halted run',
1958
- };
1959
- },
1960
- ],
1961
- });
1962
-
1963
- const run = await Run.create<t.IState>({
1964
- runId: 'pc-runstart',
1965
- graphConfig: {
1966
- type: 'standard',
1967
- agents: [
1968
- {
1969
- agentId: 'a',
1970
- provider: Providers.OPENAI,
1971
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
1972
- instructions: 'noop',
1973
- maxContextTokens: 8000,
1974
- },
1975
- ],
1976
- },
1977
- hooks: registry,
1978
- humanInTheLoop: { enabled: false },
1979
- });
1980
-
1981
- const result = await run.processStream(
1982
- { messages: [new HM('hello')] },
1983
- {
1984
- configurable: { thread_id: 'pc-thread-1' },
1985
- version: 'v2',
1986
- }
1987
- );
1988
-
1989
- expect(runStartFired).toBe(true);
1990
- expect(result).toBeUndefined();
1991
- /** Graph should not have been run — no messages added beyond the input. */
1992
- expect(run.getInterrupt()).toBeUndefined();
1993
- });
1994
-
1995
- it('returns undefined when UserPromptSubmit hook returns preventContinuation', async () => {
1996
- const { Run } = await import('@/run');
1997
- const { Providers } = await import('@/common');
1998
- const { HumanMessage: HM } = await import('@langchain/core/messages');
1999
-
2000
- const registry = new HookRegistry();
2001
- let promptFired = false;
2002
- registry.register('UserPromptSubmit', {
2003
- hooks: [
2004
- async (): Promise<UserPromptSubmitHookOutput> => {
2005
- promptFired = true;
2006
- return {
2007
- preventContinuation: true,
2008
- stopReason: 'rate limit reached',
2009
- };
2010
- },
2011
- ],
2012
- });
2013
-
2014
- const run = await Run.create<t.IState>({
2015
- runId: 'pc-prompt',
2016
- graphConfig: {
2017
- type: 'standard',
2018
- agents: [
2019
- {
2020
- agentId: 'a',
2021
- provider: Providers.OPENAI,
2022
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
2023
- instructions: 'noop',
2024
- maxContextTokens: 8000,
2025
- },
2026
- ],
2027
- },
2028
- hooks: registry,
2029
- humanInTheLoop: { enabled: false },
2030
- });
2031
-
2032
- const result = await run.processStream(
2033
- { messages: [new HM('hello')] },
2034
- {
2035
- configurable: { thread_id: 'pc-thread-2' },
2036
- version: 'v2',
2037
- }
2038
- );
2039
-
2040
- expect(promptFired).toBe(true);
2041
- expect(result).toBeUndefined();
2042
- });
2043
- });
2044
-
2045
- describe('Mid-flight preventContinuation halts the run after the current step', () => {
2046
- afterEach(() => {
2047
- jest.restoreAllMocks();
2048
- });
2049
-
2050
- it('PostToolBatch hook with preventContinuation breaks the stream loop and skips Stop', async () => {
2051
- mockEventDispatch([
2052
- { toolCallId: 'call_1', content: 'ok', status: 'success' },
2053
- ]);
2054
-
2055
- const registry = new HookRegistry();
2056
- let stopFired = false;
2057
- registry.register('PostToolBatch', {
2058
- hooks: [
2059
- async (): Promise<PostToolBatchHookOutput> => ({
2060
- preventContinuation: true,
2061
- stopReason: 'rate-limit policy halt',
2062
- }),
2063
- ],
2064
- });
2065
- registry.register('Stop', {
2066
- hooks: [
2067
- async (): Promise<Record<string, never>> => {
2068
- stopFired = true;
2069
- return {};
2070
- },
2071
- ],
2072
- });
2073
-
2074
- const node = new ToolNode({
2075
- tools: [createSchemaStub('echo')],
2076
- eventDrivenMode: true,
2077
- agentId: 'agent-x',
2078
- toolCallStepIds: new Map([['call_1', 'step_1']]),
2079
- hookRegistry: registry,
2080
- humanInTheLoop: { enabled: false },
2081
- });
2082
-
2083
- const builder = new StateGraph(MessagesAnnotation)
2084
- .addNode('agent', () => ({
2085
- messages: [
2086
- new AIMessage({
2087
- content: '',
2088
- tool_calls: [
2089
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
2090
- ],
2091
- }),
2092
- ],
2093
- }))
2094
- .addNode('tools', node)
2095
- .addEdge(START, 'agent')
2096
- .addEdge('agent', 'tools')
2097
- .addEdge('tools', END);
2098
- const graph = builder.compile({ checkpointer: new MemorySaver() });
2099
-
2100
- const { Run } = await import('@/run');
2101
- const run = await Run.create<t.IState>({
2102
- runId: 'halt-mid-flight-1',
2103
- graphConfig: {
2104
- type: 'standard',
2105
- agents: [
2106
- {
2107
- agentId: 'a',
2108
- provider: providers.OPENAI,
2109
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
2110
- instructions: 'noop',
2111
- maxContextTokens: 8000,
2112
- },
2113
- ],
2114
- },
2115
- hooks: registry,
2116
- humanInTheLoop: { enabled: false },
2117
- });
2118
- /** Replace the SDK-built graph runnable with our handcrafted one so the
2119
- * PostToolBatch hook fires under a real LangGraph stream. */
2120
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
2121
-
2122
- await run.processStream(
2123
- { messages: [] },
2124
- {
2125
- configurable: { thread_id: 'halt-thread-1' },
2126
- version: 'v2',
2127
- }
2128
- );
2129
-
2130
- expect(run.getHaltReason()).toBe('rate-limit policy halt');
2131
- expect(stopFired).toBe(false);
2132
- });
2133
-
2134
- it('clears halt signal between processStream invocations', async () => {
2135
- const registry = new HookRegistry();
2136
- registry.register('RunStart', {
2137
- hooks: [
2138
- async (): Promise<RunStartHookOutput> => ({
2139
- preventContinuation: true,
2140
- stopReason: 'first run halted',
2141
- }),
2142
- ],
2143
- });
2144
-
2145
- const { Run } = await import('@/run');
2146
- const { HumanMessage: HM } = await import('@langchain/core/messages');
2147
-
2148
- const run = await Run.create<t.IState>({
2149
- runId: 'halt-clear-1',
2150
- graphConfig: {
2151
- type: 'standard',
2152
- agents: [
2153
- {
2154
- agentId: 'a',
2155
- provider: providers.OPENAI,
2156
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
2157
- instructions: 'noop',
2158
- maxContextTokens: 8000,
2159
- },
2160
- ],
2161
- },
2162
- hooks: registry,
2163
- humanInTheLoop: { enabled: false },
2164
- });
2165
-
2166
- await run.processStream(
2167
- { messages: [new HM('first')] },
2168
- { configurable: { thread_id: 't-1' }, version: 'v2' }
2169
- );
2170
- /** RunStart preventContinuation is a pre-stream early return, but
2171
- * `processStream` should still have cleared the registry signal
2172
- * for this run id so a subsequent call starts fresh. */
2173
- expect(registry.getHaltSignal('halt-clear-1')).toBeUndefined();
2174
- });
2175
- });
2176
-
2177
- describe('Async fire-and-forget hooks ignore decision/context fields', () => {
2178
- afterEach(() => {
2179
- jest.restoreAllMocks();
2180
- });
2181
-
2182
- it('PreToolUse with `async: true` does not block the tool even when decision is `deny`', async () => {
2183
- mockEventDispatch([
2184
- { toolCallId: 'call_1', content: 'ran', status: 'success' },
2185
- ]);
2186
-
2187
- let bgFired = false;
2188
- const registry = new HookRegistry();
2189
- registry.register('PreToolUse', {
2190
- hooks: [
2191
- async (): Promise<PreToolUseHookOutput> => {
2192
- /** Side effect runs in background; agent doesn't wait. */
2193
- void Promise.resolve().then(() => {
2194
- bgFired = true;
2195
- });
2196
- return {
2197
- async: true,
2198
- decision: 'deny',
2199
- reason: 'this should be ignored',
2200
- additionalContext: 'this should also be ignored',
2201
- };
2202
- },
2203
- ],
2204
- });
2205
-
2206
- const node = new ToolNode({
2207
- tools: [createSchemaStub('echo')],
2208
- eventDrivenMode: true,
2209
- agentId: 'agent-x',
2210
- toolCallStepIds: new Map([['call_1', 'step_1']]),
2211
- hookRegistry: registry,
2212
- humanInTheLoop: { enabled: false },
2213
- });
2214
-
2215
- const graph = buildHITLGraph(node, [
2216
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
2217
- ]);
2218
- const result = (await graph.invoke(
2219
- { messages: [] },
2220
- { configurable: { thread_id: 'async-1' } }
2221
- )) as { messages: BaseMessage[] };
2222
-
2223
- const toolMsg = result.messages.find(
2224
- (m): m is ToolMessage => m._getType() === 'tool'
2225
- );
2226
- expect(toolMsg).toBeDefined();
2227
- /** Tool ran (no Blocked: prefix) — async output's `decision: 'deny'` was
2228
- * ignored as documented. */
2229
- expect(toolMsg!.status).not.toBe('error');
2230
- expect(toolMsg!.content).toBe('ran');
2231
- /** Background work runs even though we ignored the output. */
2232
- await new Promise((r) => setImmediate(r));
2233
- expect(bgFired).toBe(true);
2234
- /** No injected context message — `additionalContext` was also ignored. */
2235
- const injected = result.messages.find(
2236
- (m) =>
2237
- m._getType() === 'human' &&
2238
- (m as { additional_kwargs?: { source?: string } }).additional_kwargs
2239
- ?.source === 'hook'
2240
- );
2241
- expect(injected).toBeUndefined();
2242
- });
2243
-
2244
- it('PostToolUse with `async: true` does not halt the run even when preventContinuation is set', async () => {
2245
- mockEventDispatch([
2246
- { toolCallId: 'call_1', content: 'ran', status: 'success' },
2247
- ]);
2248
-
2249
- const registry = new HookRegistry();
2250
- registry.register('PostToolUse', {
2251
- hooks: [
2252
- async (): Promise<PostToolUseHookOutput> => ({
2253
- async: true,
2254
- preventContinuation: true,
2255
- stopReason: 'should not halt',
2256
- }),
2257
- ],
2258
- });
2259
-
2260
- const node = new ToolNode({
2261
- tools: [createSchemaStub('echo')],
2262
- eventDrivenMode: true,
2263
- agentId: 'agent-x',
2264
- toolCallStepIds: new Map([['call_1', 'step_1']]),
2265
- hookRegistry: registry,
2266
- humanInTheLoop: { enabled: false },
2267
- });
2268
-
2269
- const graph = buildHITLGraph(node, [
2270
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
2271
- ]);
2272
- await graph.invoke(
2273
- { messages: [] },
2274
- { configurable: { thread_id: 'async-2' } }
2275
- );
2276
-
2277
- /** preventContinuation was on an async output → ignored → no halt
2278
- * signal raised under any session id. The standalone graph here
2279
- * runs with `runId = ''` (no `config.configurable.run_id` set),
2280
- * so check that key explicitly. */
2281
- expect(registry.getHaltSignal('')).toBeUndefined();
2282
- });
2283
- });
2284
-
2285
- describe('Codex review fixes', () => {
2286
- afterEach(() => {
2287
- jest.restoreAllMocks();
2288
- });
2289
-
2290
- it('reruns live hooks while replaying a consumed event-tool approval', async () => {
2291
- let dispatchCalls = 0;
2292
- jest
2293
- .spyOn(events, 'safeDispatchCustomEvent')
2294
- .mockImplementation(async (event, data) => {
2295
- if (event !== 'on_tool_execute') {
2296
- return;
2297
- }
2298
- dispatchCalls += 1;
2299
- const request = data as {
2300
- resolve: (results: t.ToolExecuteResult[]) => void;
2301
- };
2302
- request.resolve([
2303
- { toolCallId: 'call_event', content: 'ran', status: 'success' },
2304
- ]);
2305
- });
2306
-
2307
- const registry = new HookRegistry();
2308
- let onceCalls = 0;
2309
- let liveCalls = 0;
2310
- registry.register('PreToolUse', {
2311
- once: true,
2312
- pattern: '^echo$',
2313
- hooks: [
2314
- async (): Promise<PreToolUseHookOutput> => {
2315
- onceCalls += 1;
2316
- return { decision: 'ask', reason: 'review once' };
2317
- },
2318
- ],
2319
- });
2320
- registry.register('PreToolUse', {
2321
- pattern: '^echo$',
2322
- hooks: [
2323
- async (): Promise<PreToolUseHookOutput> => {
2324
- liveCalls += 1;
2325
- return liveCalls === 1
2326
- ? { decision: 'allow' }
2327
- : { decision: 'deny', reason: 'policy changed before resume' };
2328
- },
2329
- ],
2330
- });
2331
- const node = new ToolNode({
2332
- tools: [createSchemaStub('echo')],
2333
- eventDrivenMode: true,
2334
- agentId: 'agent-event-replay',
2335
- toolCallStepIds: new Map([['call_event', 'step_event']]),
2336
- hookRegistry: registry,
2337
- humanInTheLoop: { enabled: true },
2338
- });
2339
- const graph = buildHITLGraph(node, [
2340
- { id: 'call_event', name: 'echo', args: { command: 'run' } },
2341
- ]);
2342
- const config = {
2343
- configurable: { thread_id: 'thread-event-hook-replay' },
2344
- };
2345
-
2346
- const interrupted = await graph.invoke({ messages: [] }, config);
2347
- expect(isInterrupted(interrupted)).toBe(true);
2348
- const resumed = await graph.invoke(
2349
- new Command({ resume: [{ type: 'approve' }] }),
2350
- config
2351
- );
2352
-
2353
- expect(onceCalls).toBe(1);
2354
- expect(liveCalls).toBe(2);
2355
- expect(dispatchCalls).toBe(0);
2356
- const messages = (resumed as { messages: ToolMessage[] }).messages;
2357
- const result = messages.find(
2358
- (message) =>
2359
- message instanceof ToolMessage && message.tool_call_id === 'call_event'
2360
- );
2361
- expect(result?.status).toBe('error');
2362
- expect(String(result?.content)).toContain('policy changed before resume');
2363
- });
2364
-
2365
- it('preserves session-scoped hooks across HITL interrupt so the policy still fires on resume', async () => {
2366
- let dispatchCalls = 0;
2367
- jest
2368
- .spyOn(events, 'safeDispatchCustomEvent')
2369
- .mockImplementation(async (event, data) => {
2370
- if (event !== 'on_tool_execute') {
2371
- return;
2372
- }
2373
- dispatchCalls += 1;
2374
- const request = data as {
2375
- toolCalls: t.ToolCallRequest[];
2376
- resolve: (r: t.ToolExecuteResult[]) => void;
2377
- };
2378
- request.resolve(
2379
- request.toolCalls.map((c) => ({
2380
- toolCallId: c.id,
2381
- content: 'host-result',
2382
- status: 'success' as const,
2383
- }))
2384
- );
2385
- });
2386
-
2387
- const registry = new HookRegistry();
2388
- let preCallCount = 0;
2389
- /**
2390
- * Register the policy hook against the runId via `registerSession`
2391
- * (mirrors how a host scopes per-run policy without leaking it to
2392
- * concurrent runs). The fix under test: this matcher MUST still be
2393
- * present when `Run.resume()` re-runs the node so the policy
2394
- * decision applies the second time too.
2395
- */
2396
- const runId = 'session-hook-preserve';
2397
- registry.registerSession(runId, 'PreToolUse', {
2398
- hooks: [
2399
- async (): Promise<PreToolUseHookOutput> => {
2400
- preCallCount += 1;
2401
- return { decision: 'ask', reason: 'session policy' };
2402
- },
2403
- ],
2404
- });
2405
-
2406
- const node = new ToolNode({
2407
- tools: [createSchemaStub('echo')],
2408
- eventDrivenMode: true,
2409
- agentId: 'agent-x',
2410
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
2411
- hookRegistry: registry,
2412
- humanInTheLoop: { enabled: true },
2413
- });
2414
-
2415
- const builder = new StateGraph(MessagesAnnotation)
2416
- .addNode(
2417
- 'agent',
2418
- (): MessagesUpdate => ({
2419
- messages: [
2420
- new AIMessage({
2421
- content: '',
2422
- tool_calls: [
2423
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
2424
- ],
2425
- }),
2426
- ],
2427
- })
2428
- )
2429
- .addNode('tools', node)
2430
- .addEdge(START, 'agent')
2431
- .addEdge('agent', 'tools')
2432
- .addEdge('tools', END);
2433
- const graph = builder.compile({ checkpointer: new MemorySaver() });
2434
-
2435
- const { Run } = await import('@/run');
2436
- const run = await Run.create<t.IState>({
2437
- runId,
2438
- graphConfig: {
2439
- type: 'standard',
2440
- agents: [
2441
- {
2442
- agentId: 'a',
2443
- provider: providers.OPENAI,
2444
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
2445
- instructions: 'noop',
2446
- maxContextTokens: 8000,
2447
- },
2448
- ],
2449
- },
2450
- hooks: registry,
2451
- humanInTheLoop: { enabled: true },
2452
- });
2453
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
2454
-
2455
- const callerConfig = {
2456
- configurable: { thread_id: 'session-thread-1' },
2457
- version: 'v2' as const,
2458
- };
2459
-
2460
- await run.processStream({ messages: [] }, callerConfig);
2461
-
2462
- /** Interrupt fired; one hook invocation so far. Session matcher
2463
- * MUST still be present — the regression was that finally cleared
2464
- * it, leaving the resume to bypass the policy entirely. */
2465
- expect(run.getInterrupt()).toBeDefined();
2466
- expect(run.getInterrupt()?.payload).toMatchObject({
2467
- type: 'tool_approval',
2468
- hook_session_id: runId,
2469
- });
2470
- expect(preCallCount).toBe(1);
2471
- expect(registry.hasHookFor('PreToolUse', runId)).toBe(true);
2472
- expect(dispatchCalls).toBe(0);
2473
-
2474
- await run.resume([{ type: 'approve' }], callerConfig);
2475
-
2476
- /** Hook fired AGAIN on resume — policy was actually applied a
2477
- * second time, not skipped. Tool then executed. */
2478
- expect(preCallCount).toBe(2);
2479
- expect(dispatchCalls).toBe(1);
2480
- /** After natural completion, session matchers ARE cleared so the
2481
- * next run on this registry starts clean. */
2482
- expect(registry.hasHookFor('PreToolUse', runId)).toBe(false);
2483
- });
2484
-
2485
- it('persists the parent hook session when a direct subagent approval interrupts', async () => {
2486
- const runId = 'parent-subagent-policy';
2487
- const executeSubagent = jest.fn(async () => 'child result');
2488
- const subagentTool = tool(executeSubagent, {
2489
- name: Constants.SUBAGENT,
2490
- description: 'execute a child agent',
2491
- schema: z.object({
2492
- description: z.string(),
2493
- subagent_type: z.string(),
2494
- }),
2495
- }) as unknown as StructuredToolInterface;
2496
- const registry = new HookRegistry();
2497
- registry.registerSession(runId, 'PreToolUse', {
2498
- hooks: [
2499
- async (): Promise<PreToolUseHookOutput> => ({
2500
- decision: 'ask',
2501
- reason: 'approve child delegation',
2502
- }),
2503
- ],
2504
- });
2505
- const node = new ToolNode({
2506
- tools: [subagentTool],
2507
- hookRegistry: registry,
2508
- directToolNames: new Set([Constants.SUBAGENT]),
2509
- humanInTheLoop: { enabled: true },
2510
- });
2511
- const graph = buildHITLGraph(node, [
2512
- {
2513
- id: 'call_subagent',
2514
- name: Constants.SUBAGENT,
2515
- args: { description: 'inspect logs', subagent_type: 'researcher' },
2516
- },
2517
- ]);
2518
-
2519
- const interrupted = await graph.invoke(
2520
- { messages: [] },
2521
- {
2522
- configurable: {
2523
- thread_id: 'parent-subagent-thread',
2524
- run_id: runId,
2525
- },
2526
- }
2527
- );
2528
-
2529
- if (!isInterrupted<t.ToolApprovalInterruptPayload>(interrupted)) {
2530
- throw new Error('expected a subagent approval interrupt');
2531
- }
2532
- expect(interrupted.__interrupt__[0].value).toMatchObject({
2533
- type: 'tool_approval',
2534
- hook_session_id: runId,
2535
- action_requests: [
2536
- { tool_call_id: 'call_subagent', name: Constants.SUBAGENT },
2537
- ],
2538
- });
2539
- expect(executeSubagent).not.toHaveBeenCalled();
2540
- });
2541
-
2542
- it('denied tool in a deny+ask batch dispatches ON_RUN_STEP_COMPLETED exactly once across interrupt + resume', async () => {
2543
- const stepCompletedDispatches: string[] = [];
2544
- /** Spy on the underlying custom event dispatcher to capture every
2545
- * ON_RUN_STEP_COMPLETED event with its tool_call_id. Without the
2546
- * blockEntry deferral, this would record `call_a` twice for one
2547
- * logical denial (once before interrupt, once after resume
2548
- * re-execution). */
2549
- jest
2550
- .spyOn(events, 'safeDispatchCustomEvent')
2551
- .mockImplementation(async (event, data) => {
2552
- if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
2553
- const payload = data as {
2554
- result?: { tool_call?: { id?: string } };
2555
- };
2556
- const id = payload.result?.tool_call?.id;
2557
- if (id != null) {
2558
- stepCompletedDispatches.push(id);
2559
- }
2560
- return;
2561
- }
2562
- if (event !== 'on_tool_execute') {
2563
- return;
2564
- }
2565
- const request = data as {
2566
- toolCalls: t.ToolCallRequest[];
2567
- resolve: (r: t.ToolExecuteResult[]) => void;
2568
- };
2569
- request.resolve(
2570
- request.toolCalls.map((c) => ({
2571
- toolCallId: c.id,
2572
- content: `ran:${c.name}`,
2573
- status: 'success' as const,
2574
- }))
2575
- );
2576
- });
2577
-
2578
- const registry = new HookRegistry();
2579
- registry.register('PreToolUse', {
2580
- hooks: [
2581
- async (input): Promise<PreToolUseHookOutput> => {
2582
- if (input.toolName === 'tool_a') {
2583
- return { decision: 'deny', reason: 'policy:a' };
2584
- }
2585
- return { decision: 'ask', reason: 'policy:b-needs-review' };
2586
- },
2587
- ],
2588
- });
2589
-
2590
- const node = new ToolNode({
2591
- tools: [createSchemaStub('tool_a'), createSchemaStub('tool_b')],
2592
- eventDrivenMode: true,
2593
- agentId: 'agent-x',
2594
- toolCallStepIds: new Map([
2595
- ['call_a', 'step_a'],
2596
- ['call_b', 'step_b'],
2597
- ]),
2598
- hookRegistry: registry,
2599
- humanInTheLoop: { enabled: true },
2600
- });
2601
-
2602
- const graph = buildHITLGraph(node, [
2603
- { id: 'call_a', name: 'tool_a', args: { command: 'a' } },
2604
- { id: 'call_b', name: 'tool_b', args: { command: 'b' } },
2605
- ]);
2606
- const config = { configurable: { thread_id: 'dedup-thread' } };
2607
-
2608
- const interrupted = await graph.invoke({ messages: [] }, config);
2609
- /** First pass: interrupt() threw, so the deferred denial side
2610
- * effects were not flushed. Zero step-completed events for the
2611
- * denied tool yet. */
2612
- expect(stepCompletedDispatches.filter((id) => id === 'call_a')).toEqual([]);
2613
-
2614
- await resumeGraph(graph, interrupted, [{ type: 'approve' }], config);
2615
-
2616
- /** After resume: the denied tool dispatches exactly once (deferred
2617
- * flush on the resume re-execution); the approved tool dispatches
2618
- * once via the normal execution path. */
2619
- expect(stepCompletedDispatches.filter((id) => id === 'call_a')).toEqual([
2620
- 'call_a',
2621
- ]);
2622
- expect(stepCompletedDispatches.filter((id) => id === 'call_b')).toEqual([
2623
- 'call_b',
2624
- ]);
2625
- });
2626
-
2627
- it('enforces allowedDecisions on resume — host-submitted decision outside the allowlist is rejected', async () => {
2628
- const dispatchedToolNames: string[] = [];
2629
- jest
2630
- .spyOn(events, 'safeDispatchCustomEvent')
2631
- .mockImplementation(async (event, data) => {
2632
- if (event !== 'on_tool_execute') {
2633
- return;
2634
- }
2635
- const request = data as {
2636
- toolCalls: t.ToolCallRequest[];
2637
- resolve: (r: t.ToolExecuteResult[]) => void;
2638
- };
2639
- for (const c of request.toolCalls) {
2640
- dispatchedToolNames.push(c.name);
2641
- }
2642
- request.resolve(
2643
- request.toolCalls.map((c) => ({
2644
- toolCallId: c.id,
2645
- content: 'ran',
2646
- status: 'success' as const,
2647
- }))
2648
- );
2649
- });
2650
-
2651
- /** Hook restricts to approve/reject only — edit/respond are
2652
- * forbidden. Even if a buggy or hostile host UI submits an
2653
- * `edit`, the SDK must fail closed instead of mutating the args
2654
- * and running the tool. */
2655
- const registry = new HookRegistry();
2656
- registry.register('PreToolUse', {
2657
- hooks: [
2658
- async (): Promise<PreToolUseHookOutput> => ({
2659
- decision: 'ask',
2660
- allowedDecisions: ['approve', 'reject'],
2661
- }),
2662
- ],
2663
- });
2664
-
2665
- const node = new ToolNode({
2666
- tools: [createSchemaStub('echo')],
2667
- eventDrivenMode: true,
2668
- agentId: 'agent-x',
2669
- toolCallStepIds: new Map([['call_1', 'step_1']]),
2670
- hookRegistry: registry,
2671
- humanInTheLoop: { enabled: true },
2672
- });
2673
-
2674
- const graph = buildHITLGraph(node, [
2675
- { id: 'call_1', name: 'echo', args: { command: 'original' } },
2676
- ]);
2677
- const config = { configurable: { thread_id: 'allowed-enforce' } };
2678
-
2679
- const interrupted = await graph.invoke({ messages: [] }, config);
2680
-
2681
- /** Submit `edit` — outside the advertised allowlist. */
2682
- const resumed = (await resumeGraph(
2683
- graph,
2684
- interrupted,
2685
- [{ type: 'edit', updatedInput: { command: 'malicious' } }],
2686
- config
2687
- )) as { messages: BaseMessage[] };
2688
-
2689
- const toolMessages = resumed.messages.filter(
2690
- (m): m is ToolMessage => m._getType() === 'tool'
2691
- );
2692
- expect(toolMessages).toHaveLength(1);
2693
- /** Tool was blocked; arg-mutation never reached the host. */
2694
- expect(toolMessages[0].status).toBe('error');
2695
- expect(String(toolMessages[0].content)).toContain(
2696
- 'not in allowedDecisions'
2697
- );
2698
- expect(String(toolMessages[0].content)).toContain('approve');
2699
- expect(String(toolMessages[0].content)).toContain('reject');
2700
- expect(dispatchedToolNames).toEqual([]);
2701
- });
2702
-
2703
- it('enforces allowedDecisions on resume — approved decision passes through when in the allowlist', async () => {
2704
- const dispatchedArgs: Array<Record<string, unknown>> = [];
2705
- jest
2706
- .spyOn(events, 'safeDispatchCustomEvent')
2707
- .mockImplementation(async (event, data) => {
2708
- if (event !== 'on_tool_execute') {
2709
- return;
2710
- }
2711
- const request = data as {
2712
- toolCalls: t.ToolCallRequest[];
2713
- resolve: (r: t.ToolExecuteResult[]) => void;
2714
- };
2715
- for (const c of request.toolCalls) {
2716
- dispatchedArgs.push(c.args);
2717
- }
2718
- request.resolve(
2719
- request.toolCalls.map((c) => ({
2720
- toolCallId: c.id,
2721
- content: 'ran',
2722
- status: 'success' as const,
2723
- }))
2724
- );
2725
- });
2726
-
2727
- const registry = new HookRegistry();
2728
- registry.register('PreToolUse', {
2729
- hooks: [
2730
- async (): Promise<PreToolUseHookOutput> => ({
2731
- decision: 'ask',
2732
- allowedDecisions: ['approve', 'reject'],
2733
- }),
2734
- ],
2735
- });
2736
-
2737
- const node = new ToolNode({
2738
- tools: [createSchemaStub('echo')],
2739
- eventDrivenMode: true,
2740
- agentId: 'agent-x',
2741
- toolCallStepIds: new Map([['call_1', 'step_1']]),
2742
- hookRegistry: registry,
2743
- humanInTheLoop: { enabled: true },
2744
- });
2745
-
2746
- const graph = buildHITLGraph(node, [
2747
- { id: 'call_1', name: 'echo', args: { command: 'original' } },
2748
- ]);
2749
- const config = { configurable: { thread_id: 'allowed-pass' } };
2750
-
2751
- const interrupted = await graph.invoke({ messages: [] }, config);
2752
-
2753
- /** Submit `approve` — explicitly in the allowlist. */
2754
- await resumeGraph(graph, interrupted, [{ type: 'approve' }], config);
2755
-
2756
- expect(dispatchedArgs).toEqual([{ command: 'original' }]);
2757
- });
2758
-
2759
- it('getInterrupt<T>() returns the captured payload typed as the host-asserted shape', async () => {
2760
- /**
2761
- * Custom graph node raises an interrupt with a payload shape the
2762
- * SDK doesn't know about. `run.getInterrupt<MyCustomPayload>()`
2763
- * returns the payload typed as the host's assertion — the SDK
2764
- * doesn't validate, it just transports.
2765
- */
2766
- interface MyCustomPayload {
2767
- type: 'custom_review';
2768
- diff: string;
2769
- reviewerHints: string[];
2770
- }
2771
-
2772
- const langgraph = await import('@langchain/langgraph');
2773
-
2774
- const builder = new StateGraph(MessagesAnnotation)
2775
- .addNode('clarifier', () => {
2776
- langgraph.interrupt({
2777
- type: 'custom_review',
2778
- diff: '+ added line',
2779
- reviewerHints: ['check formatting'],
2780
- } satisfies MyCustomPayload);
2781
- return { messages: [] };
2782
- })
2783
- .addEdge(START, 'clarifier')
2784
- .addEdge('clarifier', END);
2785
- const graph = builder.compile({ checkpointer: new MemorySaver() });
2786
-
2787
- const { Run } = await import('@/run');
2788
- const run = await Run.create<t.IState>({
2789
- runId: 'custom-interrupt',
2790
- graphConfig: {
2791
- type: 'standard',
2792
- agents: [
2793
- {
2794
- agentId: 'a',
2795
- provider: providers.OPENAI,
2796
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
2797
- instructions: 'noop',
2798
- maxContextTokens: 8000,
2799
- },
2800
- ],
2801
- },
2802
- humanInTheLoop: { enabled: true },
2803
- });
2804
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
2805
-
2806
- await run.processStream(
2807
- { messages: [] },
2808
- {
2809
- configurable: { thread_id: 'custom-interrupt-thread' },
2810
- version: 'v2',
2811
- }
2812
- );
2813
-
2814
- const interrupt = run.getInterrupt<MyCustomPayload>();
2815
- expect(interrupt).toBeDefined();
2816
- expect(interrupt!.payload.type).toBe('custom_review');
2817
- expect(interrupt!.payload.diff).toBe('+ added line');
2818
- expect(interrupt!.payload.reviewerHints).toEqual(['check formatting']);
2819
- });
2820
-
2821
- it('isToolApprovalInterrupt / isAskUserQuestionInterrupt narrow safely from `unknown` (defensive)', async () => {
2822
- const { isToolApprovalInterrupt, isAskUserQuestionInterrupt } =
2823
- await import('@/types/hitl');
2824
-
2825
- /** The guards must accept arbitrary runtime values without throwing,
2826
- * since hosts can pass anything from custom interrupts. */
2827
- expect(isToolApprovalInterrupt(null as unknown)).toBe(false);
2828
- expect(isToolApprovalInterrupt(undefined as unknown)).toBe(false);
2829
- expect(isToolApprovalInterrupt('string' as unknown)).toBe(false);
2830
- expect(isToolApprovalInterrupt(42 as unknown)).toBe(false);
2831
- expect(isToolApprovalInterrupt({} as unknown)).toBe(false);
2832
- expect(isToolApprovalInterrupt({ type: 'something_else' } as unknown)).toBe(
2833
- false
2834
- );
2835
- expect(
2836
- isToolApprovalInterrupt({
2837
- type: 'tool_approval',
2838
- action_requests: [],
2839
- review_configs: [],
2840
- } as unknown)
2841
- ).toBe(true);
2842
-
2843
- expect(isAskUserQuestionInterrupt(null as unknown)).toBe(false);
2844
- expect(
2845
- isAskUserQuestionInterrupt({ type: 'tool_approval' } as unknown)
2846
- ).toBe(false);
2847
- expect(
2848
- isAskUserQuestionInterrupt({
2849
- type: 'ask_user_question',
2850
- question: { question: 'why' },
2851
- } as unknown)
2852
- ).toBe(true);
2853
- });
2854
-
2855
- it('hook returning ask + updatedInput rewrites args BEFORE the interrupt and BEFORE host execution', async () => {
2856
- const dispatchedArgs: Array<Record<string, unknown>> = [];
2857
- jest
2858
- .spyOn(events, 'safeDispatchCustomEvent')
2859
- .mockImplementation(async (event, data) => {
2860
- if (event !== 'on_tool_execute') {
2861
- return;
2862
- }
2863
- const request = data as {
2864
- toolCalls: t.ToolCallRequest[];
2865
- resolve: (r: t.ToolExecuteResult[]) => void;
2866
- };
2867
- for (const c of request.toolCalls) {
2868
- dispatchedArgs.push(c.args);
2869
- }
2870
- request.resolve(
2871
- request.toolCalls.map((c) => ({
2872
- toolCallId: c.id,
2873
- content: 'ran',
2874
- status: 'success' as const,
2875
- }))
2876
- );
2877
- });
2878
-
2879
- /**
2880
- * Hook returns BOTH a sanitization rewrite AND `ask`. Real-world
2881
- * pattern: one matcher redacts secrets in the args, another
2882
- * matcher requires human approval. Both signals must apply.
2883
- */
2884
- const registry = new HookRegistry();
2885
- registry.register('PreToolUse', {
2886
- hooks: [
2887
- async (): Promise<PreToolUseHookOutput> => ({
2888
- decision: 'ask',
2889
- reason: 'review redacted args',
2890
- updatedInput: { command: 'redacted-command' },
2891
- }),
2892
- ],
2893
- });
2894
-
2895
- const node = new ToolNode({
2896
- tools: [createSchemaStub('echo')],
2897
- eventDrivenMode: true,
2898
- agentId: 'agent-x',
2899
- toolCallStepIds: new Map([['call_1', 'step_1']]),
2900
- hookRegistry: registry,
2901
- humanInTheLoop: { enabled: true },
2902
- });
2903
-
2904
- const graph = buildHITLGraph(node, [
2905
- { id: 'call_1', name: 'echo', args: { command: 'original-secret' } },
2906
- ]);
2907
- const config = { configurable: { thread_id: 'ask-with-update' } };
2908
-
2909
- const interrupted = await graph.invoke({ messages: [] }, config);
2910
- if (!isInterrupted<t.HumanInterruptPayload>(interrupted)) {
2911
- throw new Error('expected interrupt');
2912
- }
2913
- const payload = interrupted.__interrupt__[0].value!;
2914
- if (payload.type !== 'tool_approval') {
2915
- throw new Error('expected tool_approval');
2916
- }
2917
- /** The interrupt payload surfaces the REWRITTEN args to the
2918
- * reviewer, not the original. Without the fix, the reviewer
2919
- * would see the secret. */
2920
- expect(payload.action_requests[0].arguments).toEqual({
2921
- command: 'redacted-command',
2922
- });
2923
-
2924
- await resumeGraph(graph, interrupted, [{ type: 'approve' }], config);
2925
-
2926
- /** And the host execution dispatches the rewritten args, not
2927
- * the original. Without the fix, the policy redaction would be
2928
- * silently dropped after approval. */
2929
- expect(dispatchedArgs).toEqual([{ command: 'redacted-command' }]);
2930
- });
2931
-
2932
- it('captures interrupt even when payload is null (custom node calling interrupt(null))', async () => {
2933
- const langgraph = await import('@langchain/langgraph');
2934
-
2935
- let stopFired = false;
2936
- const registry = new HookRegistry();
2937
- registry.register('Stop', {
2938
- hooks: [
2939
- async (): Promise<Record<string, never>> => {
2940
- stopFired = true;
2941
- return {};
2942
- },
2943
- ],
2944
- });
2945
-
2946
- const builder = new StateGraph(MessagesAnnotation)
2947
- .addNode('pauser', () => {
2948
- /** Custom node pauses without payload — valid use case (the
2949
- * pause itself is the signal; no metadata needed). */
2950
- langgraph.interrupt(null);
2951
- return { messages: [] };
2952
- })
2953
- .addEdge(START, 'pauser')
2954
- .addEdge('pauser', END);
2955
- const graph = builder.compile({ checkpointer: new MemorySaver() });
2956
-
2957
- const { Run } = await import('@/run');
2958
- const run = await Run.create<t.IState>({
2959
- runId: 'null-payload-interrupt',
2960
- graphConfig: {
2961
- type: 'standard',
2962
- agents: [
2963
- {
2964
- agentId: 'a',
2965
- provider: providers.OPENAI,
2966
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
2967
- instructions: 'noop',
2968
- maxContextTokens: 8000,
2969
- },
2970
- ],
2971
- },
2972
- hooks: registry,
2973
- humanInTheLoop: { enabled: true },
2974
- });
2975
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
2976
-
2977
- await run.processStream(
2978
- { messages: [] },
2979
- {
2980
- configurable: { thread_id: 'null-payload-thread' },
2981
- version: 'v2',
2982
- }
2983
- );
2984
-
2985
- /** Run was paused, NOT completed — getInterrupt returns a result
2986
- * (with the null payload preserved) and the Stop hook does not
2987
- * fire. Without the fix, both inversions held. */
2988
- const interrupt = run.getInterrupt<unknown>();
2989
- expect(interrupt).toBeDefined();
2990
- expect(interrupt!.payload).toBeNull();
2991
- expect(stopFired).toBe(false);
2992
- });
2993
-
2994
- it('halt signal raised by run A does not bleed into a concurrent run B sharing the same registry', async () => {
2995
- /**
2996
- * One registry, two runs. RunStart hook for run A raises
2997
- * preventContinuation; run B has no halt signal. Without
2998
- * per-session scoping, run B's stream-loop poll would see A's
2999
- * signal and silently terminate. With scoping, each run reads
3000
- * only its own halt entry.
3001
- */
3002
- const registry = new HookRegistry();
3003
- let runStartFires = 0;
3004
- registry.register('RunStart', {
3005
- hooks: [
3006
- async (input): Promise<RunStartHookOutput> => {
3007
- runStartFires += 1;
3008
- /** Halt only run A, not run B. */
3009
- if (input.runId === 'run-a') {
3010
- return {
3011
- preventContinuation: true,
3012
- stopReason: 'A halted',
3013
- };
3014
- }
3015
- return {};
3016
- },
3017
- ],
3018
- });
3019
-
3020
- const { Run } = await import('@/run');
3021
- const { HumanMessage: HM } = await import('@langchain/core/messages');
3022
-
3023
- /** No-op graph so we never hit the real model. */
3024
- const makeNoopGraph = (): t.CompiledStateWorkflow => {
3025
- const builder = new StateGraph(MessagesAnnotation)
3026
- .addNode('noop', (): MessagesUpdate => ({ messages: [] }))
3027
- .addEdge(START, 'noop')
3028
- .addEdge('noop', END);
3029
- return builder.compile() as unknown as t.CompiledStateWorkflow;
3030
- };
3031
-
3032
- const makeRun = async (
3033
- runId: string
3034
- ): Promise<Awaited<ReturnType<typeof Run.create<t.IState>>>> => {
3035
- const r = await Run.create<t.IState>({
3036
- runId,
3037
- graphConfig: {
3038
- type: 'standard',
3039
- agents: [
3040
- {
3041
- agentId: 'a',
3042
- provider: providers.OPENAI,
3043
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
3044
- instructions: 'noop',
3045
- maxContextTokens: 8000,
3046
- },
3047
- ],
3048
- },
3049
- hooks: registry,
3050
- humanInTheLoop: { enabled: false },
3051
- });
3052
- r.graphRunnable = makeNoopGraph();
3053
- return r;
3054
- };
3055
-
3056
- const runA = await makeRun('run-a');
3057
- const runB = await makeRun('run-b');
3058
-
3059
- /** Run A — its preventContinuation lands in the per-session halt
3060
- * map under key `'run-a'` and triggers a pre-stream early
3061
- * return. Note that the early-return path also clears its own
3062
- * halt signal in the same step, so run B can never observe it
3063
- * even momentarily. */
3064
- await runA.processStream(
3065
- { messages: [new HM('a')] },
3066
- { configurable: { thread_id: 'thread-a' }, version: 'v2' }
3067
- );
3068
- expect(runA.getHaltReason()).toBe('A halted');
3069
-
3070
- /** Run B's signal must be undefined — A's halt is scoped to A's
3071
- * session id, and was cleared in A's pre-stream finally path. */
3072
- expect(registry.getHaltSignal('run-b')).toBeUndefined();
3073
- expect(registry.getHaltSignal('run-a')).toBeUndefined();
3074
-
3075
- /** Run B — RunStart returns no halt, so processStream proceeds
3076
- * past the pre-stream gate, executes the no-op graph, and
3077
- * completes without halt. */
3078
- runStartFires = 0;
3079
- await runB.processStream(
3080
- { messages: [new HM('b')] },
3081
- { configurable: { thread_id: 'thread-b' }, version: 'v2' }
3082
- );
3083
- expect(runStartFires).toBe(1);
3084
- expect(runB.getHaltReason()).toBeUndefined();
3085
- });
3086
-
3087
- it('review_configs entries carry tool_call_id so duplicate-tool batches map unambiguously', async () => {
3088
- mockEventDispatch([]);
3089
-
3090
- const registry = new HookRegistry();
3091
- registry.register('PreToolUse', {
3092
- hooks: [
3093
- async (): Promise<PreToolUseHookOutput> => ({
3094
- decision: 'ask',
3095
- reason: 'review',
3096
- }),
3097
- ],
3098
- });
3099
-
3100
- /** Same tool name called twice in one batch — by-position
3101
- * mapping breaks down for hosts that reorder; tool_call_id
3102
- * lets the UI map review_configs → action_requests directly. */
3103
- const node = new ToolNode({
3104
- tools: [createSchemaStub('echo')],
3105
- eventDrivenMode: true,
3106
- agentId: 'agent-x',
3107
- toolCallStepIds: new Map([
3108
- ['call_first', 'step_first'],
3109
- ['call_second', 'step_second'],
3110
- ]),
3111
- hookRegistry: registry,
3112
- humanInTheLoop: { enabled: true },
3113
- });
3114
-
3115
- const graph = buildHITLGraph(node, [
3116
- { id: 'call_first', name: 'echo', args: { command: 'a' } },
3117
- { id: 'call_second', name: 'echo', args: { command: 'b' } },
3118
- ]);
3119
- const config = { configurable: { thread_id: 'duplicate-tool' } };
3120
-
3121
- const interrupted = await graph.invoke({ messages: [] }, config);
3122
- if (!isInterrupted<t.HumanInterruptPayload>(interrupted)) {
3123
- throw new Error('expected interrupt');
3124
- }
3125
- const payload = interrupted.__interrupt__[0].value!;
3126
- if (payload.type !== 'tool_approval') {
3127
- throw new Error('expected tool_approval');
3128
- }
3129
-
3130
- /** Each review_config carries its own tool_call_id matching the
3131
- * action_request at the same index. UI can build a Map keyed by
3132
- * tool_call_id rather than relying on positional order. */
3133
- expect(payload.review_configs).toEqual([
3134
- {
3135
- action_name: 'echo',
3136
- tool_call_id: 'call_first',
3137
- allowed_decisions: ['approve', 'reject', 'edit', 'respond'],
3138
- },
3139
- {
3140
- action_name: 'echo',
3141
- tool_call_id: 'call_second',
3142
- allowed_decisions: ['approve', 'reject', 'edit', 'respond'],
3143
- },
3144
- ]);
3145
- /** And the action_requests carry the same ids — pairing is
3146
- * always derivable from id even when names collide. */
3147
- expect(payload.action_requests.map((r) => r.tool_call_id)).toEqual([
3148
- 'call_first',
3149
- 'call_second',
3150
- ]);
3151
- });
3152
-
3153
- it('malformed edit decision (missing updatedInput) is blocked, not approved with garbage args', async () => {
3154
- let dispatchCount = 0;
3155
- jest
3156
- .spyOn(events, 'safeDispatchCustomEvent')
3157
- .mockImplementation(async (event, data) => {
3158
- if (event !== 'on_tool_execute') {
3159
- return;
3160
- }
3161
- dispatchCount += 1;
3162
- const request = data as {
3163
- toolCalls: t.ToolCallRequest[];
3164
- resolve: (r: t.ToolExecuteResult[]) => void;
3165
- };
3166
- request.resolve([]);
3167
- });
3168
-
3169
- const node = new ToolNode({
3170
- tools: [createSchemaStub('echo')],
3171
- eventDrivenMode: true,
3172
- agentId: 'agent-x',
3173
- toolCallStepIds: new Map([['call_1', 'step_1']]),
3174
- hookRegistry: makeHookRegistry('ask'),
3175
- humanInTheLoop: { enabled: true },
3176
- });
3177
-
3178
- const graph = buildHITLGraph(node, [
3179
- { id: 'call_1', name: 'echo', args: { command: 'original' } },
3180
- ]);
3181
- const config = { configurable: { thread_id: 'edit-malformed' } };
3182
-
3183
- const interrupted = await graph.invoke({ messages: [] }, config);
3184
-
3185
- /** `{ type: 'edit' }` with no updatedInput — same trust-boundary
3186
- * issue as malformed respond. Must fail closed, NOT pass undefined
3187
- * into applyInputOverride and approve a tool with garbage args. */
3188
- const resumed = (await resumeGraph(
3189
- graph,
3190
- interrupted,
3191
- [{ type: 'edit' } as unknown as t.ToolApprovalDecision],
3192
- config
3193
- )) as { messages: BaseMessage[] };
3194
-
3195
- const toolMessages = resumed.messages.filter(
3196
- (m): m is ToolMessage => m._getType() === 'tool'
3197
- );
3198
- expect(toolMessages).toHaveLength(1);
3199
- expect(toolMessages[0].status).toBe('error');
3200
- expect(String(toolMessages[0].content)).toContain(
3201
- 'missing object updatedInput'
3202
- );
3203
- expect(String(toolMessages[0].content)).toContain('<missing>');
3204
- expect(dispatchCount).toBe(0);
3205
- });
3206
-
3207
- it('malformed edit decision (non-object updatedInput) is blocked', async () => {
3208
- let dispatchCount = 0;
3209
- jest
3210
- .spyOn(events, 'safeDispatchCustomEvent')
3211
- .mockImplementation(async (event, data) => {
3212
- if (event !== 'on_tool_execute') {
3213
- return;
3214
- }
3215
- dispatchCount += 1;
3216
- const request = data as {
3217
- toolCalls: t.ToolCallRequest[];
3218
- resolve: (r: t.ToolExecuteResult[]) => void;
3219
- };
3220
- request.resolve([]);
3221
- });
3222
-
3223
- const node = new ToolNode({
3224
- tools: [createSchemaStub('echo')],
3225
- eventDrivenMode: true,
3226
- agentId: 'agent-x',
3227
- toolCallStepIds: new Map([['call_1', 'step_1']]),
3228
- hookRegistry: makeHookRegistry('ask'),
3229
- humanInTheLoop: { enabled: true },
3230
- });
3231
-
3232
- const graph = buildHITLGraph(node, [
3233
- { id: 'call_1', name: 'echo', args: { command: 'original' } },
3234
- ]);
3235
- const config = { configurable: { thread_id: 'edit-nonobject' } };
3236
-
3237
- const interrupted = await graph.invoke({ messages: [] }, config);
3238
-
3239
- /** `updatedInput: 'string'` — wire deserializer didn't enforce
3240
- * object shape; SDK must reject. */
3241
- const resumed = (await resumeGraph(
3242
- graph,
3243
- interrupted,
3244
- [
3245
- {
3246
- type: 'edit',
3247
- updatedInput: 'not-an-object' as unknown as Record<string, unknown>,
3248
- },
3249
- ],
3250
- config
3251
- )) as { messages: BaseMessage[] };
3252
-
3253
- const toolMessages = resumed.messages.filter(
3254
- (m): m is ToolMessage => m._getType() === 'tool'
3255
- );
3256
- expect(toolMessages).toHaveLength(1);
3257
- expect(toolMessages[0].status).toBe('error');
3258
- expect(String(toolMessages[0].content)).toContain(
3259
- 'missing object updatedInput'
3260
- );
3261
- expect(String(toolMessages[0].content)).toContain('string');
3262
- expect(dispatchCount).toBe(0);
3263
- });
3264
-
3265
- it('malformed edit decision (array updatedInput) is blocked — arrays are objects but not plain records', async () => {
3266
- jest
3267
- .spyOn(events, 'safeDispatchCustomEvent')
3268
- .mockImplementation(async () => {
3269
- return;
3270
- });
3271
-
3272
- const node = new ToolNode({
3273
- tools: [createSchemaStub('echo')],
3274
- eventDrivenMode: true,
3275
- agentId: 'agent-x',
3276
- toolCallStepIds: new Map([['call_1', 'step_1']]),
3277
- hookRegistry: makeHookRegistry('ask'),
3278
- humanInTheLoop: { enabled: true },
3279
- });
3280
-
3281
- const graph = buildHITLGraph(node, [
3282
- { id: 'call_1', name: 'echo', args: { command: 'original' } },
3283
- ]);
3284
- const config = { configurable: { thread_id: 'edit-array' } };
3285
-
3286
- const interrupted = await graph.invoke({ messages: [] }, config);
3287
-
3288
- const resumed = (await resumeGraph(
3289
- graph,
3290
- interrupted,
3291
- [
3292
- {
3293
- type: 'edit',
3294
- updatedInput: [1, 2, 3] as unknown as Record<string, unknown>,
3295
- },
3296
- ],
3297
- config
3298
- )) as { messages: BaseMessage[] };
3299
-
3300
- const toolMessages = resumed.messages.filter(
3301
- (m): m is ToolMessage => m._getType() === 'tool'
3302
- );
3303
- expect(toolMessages).toHaveLength(1);
3304
- expect(toolMessages[0].status).toBe('error');
3305
- expect(String(toolMessages[0].content)).toContain('array');
3306
- });
3307
-
3308
- it('malformed respond decision (missing responseText) is blocked, not crashed', async () => {
3309
- let dispatchCount = 0;
3310
- jest
3311
- .spyOn(events, 'safeDispatchCustomEvent')
3312
- .mockImplementation(async (event, data) => {
3313
- if (event !== 'on_tool_execute') {
3314
- return;
3315
- }
3316
- dispatchCount += 1;
3317
- const request = data as {
3318
- toolCalls: t.ToolCallRequest[];
3319
- resolve: (r: t.ToolExecuteResult[]) => void;
3320
- };
3321
- request.resolve([]);
3322
- });
3323
-
3324
- const node = new ToolNode({
3325
- tools: [createSchemaStub('echo')],
3326
- eventDrivenMode: true,
3327
- agentId: 'agent-x',
3328
- toolCallStepIds: new Map([['call_1', 'step_1']]),
3329
- hookRegistry: makeHookRegistry('ask'),
3330
- humanInTheLoop: { enabled: true },
3331
- });
3332
-
3333
- const graph = buildHITLGraph(node, [
3334
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
3335
- ]);
3336
- const config = { configurable: { thread_id: 'respond-malformed' } };
3337
-
3338
- const interrupted = await graph.invoke({ messages: [] }, config);
3339
-
3340
- /** Submit a `respond` decision with NO responseText — wire shape
3341
- * the SDK can't honor. Must fail closed (blockEntry path), NOT
3342
- * crash truncateToolResultContent on `undefined.length`. */
3343
- const resumed = (await resumeGraph(
3344
- graph,
3345
- interrupted,
3346
- [{ type: 'respond' } as unknown as t.ToolApprovalDecision],
3347
- config
3348
- )) as { messages: BaseMessage[] };
3349
-
3350
- const toolMessages = resumed.messages.filter(
3351
- (m): m is ToolMessage => m._getType() === 'tool'
3352
- );
3353
- expect(toolMessages).toHaveLength(1);
3354
- expect(toolMessages[0].status).toBe('error');
3355
- expect(String(toolMessages[0].content)).toContain(
3356
- 'missing string responseText'
3357
- );
3358
- expect(String(toolMessages[0].content)).toContain('<missing>');
3359
- /** Tool was never dispatched — fail-closed worked. */
3360
- expect(dispatchCount).toBe(0);
3361
- });
3362
-
3363
- it('malformed respond decision (non-string responseText) is blocked, not crashed', async () => {
3364
- jest
3365
- .spyOn(events, 'safeDispatchCustomEvent')
3366
- .mockImplementation(async () => {
3367
- return;
3368
- });
3369
-
3370
- const node = new ToolNode({
3371
- tools: [createSchemaStub('echo')],
3372
- eventDrivenMode: true,
3373
- agentId: 'agent-x',
3374
- toolCallStepIds: new Map([['call_1', 'step_1']]),
3375
- hookRegistry: makeHookRegistry('ask'),
3376
- humanInTheLoop: { enabled: true },
3377
- });
3378
-
3379
- const graph = buildHITLGraph(node, [
3380
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
3381
- ]);
3382
- const config = { configurable: { thread_id: 'respond-nonstring' } };
3383
-
3384
- const interrupted = await graph.invoke({ messages: [] }, config);
3385
-
3386
- /** `responseText: 42` — wire deserializer didn't enforce string;
3387
- * SDK must reject without crashing. */
3388
- const resumed = (await resumeGraph(
3389
- graph,
3390
- interrupted,
3391
- [
3392
- {
3393
- type: 'respond',
3394
- responseText: 42 as unknown as string,
3395
- },
3396
- ],
3397
- config
3398
- )) as { messages: BaseMessage[] };
3399
-
3400
- const toolMessages = resumed.messages.filter(
3401
- (m): m is ToolMessage => m._getType() === 'tool'
3402
- );
3403
- expect(toolMessages).toHaveLength(1);
3404
- expect(toolMessages[0].status).toBe('error');
3405
- expect(String(toolMessages[0].content)).toContain(
3406
- 'missing string responseText'
3407
- );
3408
- expect(String(toolMessages[0].content)).toContain('number');
3409
- });
3410
-
3411
- it('respond decision truncates oversized text the same way real tool output is truncated', async () => {
3412
- mockEventDispatch([]);
3413
-
3414
- /** Build a ToolNode with a tiny `maxToolResultChars` so the
3415
- * truncation kicks in for a 200-char response. Without the fix,
3416
- * the full string would land in the ToolMessage and PostToolBatch
3417
- * entry — bypassing the model context budget. */
3418
- const registry = new HookRegistry();
3419
- registry.register('PreToolUse', {
3420
- hooks: [async (): Promise<PreToolUseHookOutput> => ({ decision: 'ask' })],
3421
- });
3422
- let captured: PostToolBatchEntry | undefined;
3423
- registry.register('PostToolBatch', {
3424
- hooks: [
3425
- async (input): Promise<PostToolBatchHookOutput> => {
3426
- captured = (input as PostToolBatchHookInput).entries[0];
3427
- return {};
3428
- },
3429
- ],
3430
- });
3431
-
3432
- const node = new ToolNode({
3433
- tools: [createSchemaStub('echo')],
3434
- eventDrivenMode: true,
3435
- agentId: 'agent-x',
3436
- toolCallStepIds: new Map([['call_1', 'step_1']]),
3437
- hookRegistry: registry,
3438
- humanInTheLoop: { enabled: true },
3439
- maxToolResultChars: 50,
3440
- });
3441
-
3442
- const graph = buildHITLGraph(node, [
3443
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
3444
- ]);
3445
- const config = { configurable: { thread_id: 'respond-truncate' } };
3446
-
3447
- const interrupted = await graph.invoke({ messages: [] }, config);
3448
-
3449
- /** 200-char response — well over the 50-char cap. */
3450
- const oversized = 'A'.repeat(200);
3451
- const resumed = (await resumeGraph(
3452
- graph,
3453
- interrupted,
3454
- [{ type: 'respond', responseText: oversized }],
3455
- config
3456
- )) as { messages: BaseMessage[] };
3457
-
3458
- const toolMessages = resumed.messages.filter(
3459
- (m): m is ToolMessage => m._getType() === 'tool'
3460
- );
3461
- expect(toolMessages).toHaveLength(1);
3462
- /** The ToolMessage content is truncated; not the raw 200 chars. */
3463
- const content = String(toolMessages[0].content);
3464
- expect(content.length).toBeLessThan(oversized.length);
3465
- /** And the PostToolBatch entry sees the SAME truncated value
3466
- * — batch hooks observe what the model will actually see. */
3467
- expect(captured).toBeDefined();
3468
- expect(typeof captured!.toolOutput).toBe('string');
3469
- expect(captured!.toolOutput).toBe(content);
3470
- });
3471
-
3472
- it('hook returning both ask + preventContinuation halts cleanly and clears session hooks', async () => {
3473
- mockEventDispatch([]);
3474
-
3475
- const registry = new HookRegistry();
3476
- /** Session-scoped policy hook returns BOTH `ask` (which would
3477
- * raise an interrupt) AND `preventContinuation: true` (which
3478
- * raises a halt signal). The halt wins — no resume is expected,
3479
- * sessions must clear. */
3480
- const runId = 'ask-and-halt';
3481
- registry.registerSession(runId, 'PreToolUse', {
3482
- hooks: [
3483
- async (): Promise<PreToolUseHookOutput> => ({
3484
- decision: 'ask',
3485
- preventContinuation: true,
3486
- stopReason: 'policy halted ask',
3487
- }),
3488
- ],
3489
- });
3490
-
3491
- const node = new ToolNode({
3492
- tools: [createSchemaStub('echo')],
3493
- eventDrivenMode: true,
3494
- agentId: 'agent-x',
3495
- toolCallStepIds: new Map([['call_1', 'step_1']]),
3496
- hookRegistry: registry,
3497
- humanInTheLoop: { enabled: true },
3498
- });
3499
-
3500
- const builder = new StateGraph(MessagesAnnotation)
3501
- .addNode(
3502
- 'agent',
3503
- (): MessagesUpdate => ({
3504
- messages: [
3505
- new AIMessage({
3506
- content: '',
3507
- tool_calls: [
3508
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
3509
- ],
3510
- }),
3511
- ],
3512
- })
3513
- )
3514
- .addNode('tools', node)
3515
- .addEdge(START, 'agent')
3516
- .addEdge('agent', 'tools')
3517
- .addEdge('tools', END);
3518
- const graph = builder.compile({ checkpointer: new MemorySaver() });
3519
-
3520
- const { Run } = await import('@/run');
3521
- const run = await Run.create<t.IState>({
3522
- runId,
3523
- graphConfig: {
3524
- type: 'standard',
3525
- agents: [
3526
- {
3527
- agentId: 'a',
3528
- provider: providers.OPENAI,
3529
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
3530
- instructions: 'noop',
3531
- maxContextTokens: 8000,
3532
- },
3533
- ],
3534
- },
3535
- hooks: registry,
3536
- humanInTheLoop: { enabled: true },
3537
- });
3538
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
3539
-
3540
- await run.processStream(
3541
- { messages: [] },
3542
- {
3543
- configurable: { thread_id: 'ask-and-halt-thread' },
3544
- version: 'v2',
3545
- }
3546
- );
3547
-
3548
- /** Both signals landed: interrupt was captured AND halt fired. */
3549
- expect(run.getInterrupt()).toBeDefined();
3550
- expect(run.getHaltReason()).toBe('policy halted ask');
3551
- /** Session hooks MUST be cleared — no resume is expected on a
3552
- * halted run, even one that also captured an interrupt. */
3553
- expect(registry.hasHookFor('PreToolUse', runId)).toBe(false);
3554
- });
3555
-
3556
- it('preserves Graph sidecars across HITL interrupt + resume so tool completions keep their step ids', async () => {
3557
- /**
3558
- * Regression test for the cleanup-vs-resume bug: previously
3559
- * `processStream` always called `Graph.clearHeavyState()` in its
3560
- * `finally` block AND `Graph.resetValues()` on entry, even when
3561
- * pausing on a HITL interrupt. That wiped `toolCallStepIds`,
3562
- * `_toolOutputRegistry`, and `sessions` between pause and resume,
3563
- * so the resumed `ToolNode` could no longer find the original
3564
- * step id and dispatched `ON_RUN_STEP_COMPLETED` with an empty id
3565
- * — the host's stream consumer would then drop the result.
3566
- *
3567
- * The fix is two gated cleanups:
3568
- * - `clearHeavyState` skipped when `_interrupt != null && _haltedReason == null && !streamThrew`
3569
- * - `resetValues` skipped when entering processStream via `Command` (resume)
3570
- *
3571
- * To exercise the SDK Graph's actual sidecar state (not a private
3572
- * test ToolNode), this test wires the custom ToolNode to share
3573
- * the SDK Graph's `toolCallStepIds` Map by reference. After the
3574
- * interrupt fires AND after the resume completes, the
3575
- * pre-populated entry must still be present.
3576
- */
3577
- const dispatchedStepIds: string[] = [];
3578
- jest
3579
- .spyOn(events, 'safeDispatchCustomEvent')
3580
- .mockImplementation(async (event, data) => {
3581
- if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
3582
- const payload = data as { result?: { id?: string } };
3583
- if (payload.result?.id != null) {
3584
- dispatchedStepIds.push(payload.result.id);
3585
- }
3586
- return;
3587
- }
3588
- if (event !== 'on_tool_execute') {
3589
- return;
3590
- }
3591
- const request = data as {
3592
- toolCalls: t.ToolCallRequest[];
3593
- resolve: (r: t.ToolExecuteResult[]) => void;
3594
- };
3595
- request.resolve(
3596
- request.toolCalls.map((c) => ({
3597
- toolCallId: c.id,
3598
- content: 'host-result',
3599
- status: 'success' as const,
3600
- }))
3601
- );
3602
- });
3603
-
3604
- const registry = new HookRegistry();
3605
- registry.register('PreToolUse', {
3606
- hooks: [
3607
- async (): Promise<PreToolUseHookOutput> => ({
3608
- decision: 'ask',
3609
- reason: 'review',
3610
- }),
3611
- ],
3612
- });
3613
-
3614
- const { Run } = await import('@/run');
3615
- const run = await Run.create<t.IState>({
3616
- runId: 'sidecar-preserve',
3617
- graphConfig: {
3618
- type: 'standard',
3619
- agents: [
3620
- {
3621
- agentId: 'a',
3622
- provider: providers.OPENAI,
3623
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
3624
- instructions: 'noop',
3625
- maxContextTokens: 8000,
3626
- },
3627
- ],
3628
- },
3629
- hooks: registry,
3630
- humanInTheLoop: { enabled: true },
3631
- });
3632
-
3633
- /** Wire the test ToolNode to share the SDK Graph's
3634
- * `toolCallStepIds` Map by reference — this is how the real
3635
- * StandardGraph builds its inner ToolNode at Graph.ts:587. */
3636
- const toolNode = new ToolNode({
3637
- tools: [createSchemaStub('echo')],
3638
- eventDrivenMode: true,
3639
- agentId: 'a',
3640
- toolCallStepIds: run.Graph!.toolCallStepIds,
3641
- hookRegistry: registry,
3642
- humanInTheLoop: { enabled: true },
3643
- });
3644
-
3645
- /** The agent node simulates `attemptInvoke`'s sidecar-population
3646
- * step: in a real run, the model invocation creates a run step
3647
- * and writes its id into `toolCallStepIds` before tools dispatch.
3648
- * Doing it here means the entry lands AFTER `processStream`'s
3649
- * `resetValues` (which fires once on entry) and BEFORE the
3650
- * ToolNode's hook + interrupt — exactly mirroring the production
3651
- * timing the cleanup gate has to preserve. */
3652
- const builder = new StateGraph(MessagesAnnotation)
3653
- .addNode('agent', (): MessagesUpdate => {
3654
- run.Graph!.toolCallStepIds.set('call_1', 'step_real_id');
3655
- return {
3656
- messages: [
3657
- new AIMessage({
3658
- content: '',
3659
- tool_calls: [
3660
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
3661
- ],
3662
- }),
3663
- ],
3664
- };
3665
- })
3666
- .addNode('tools', toolNode)
3667
- .addEdge(START, 'agent')
3668
- .addEdge('agent', 'tools')
3669
- .addEdge('tools', END);
3670
- const graph = builder.compile({ checkpointer: new MemorySaver() });
3671
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
3672
-
3673
- const callerConfig = {
3674
- configurable: { thread_id: 'sidecar-thread' },
3675
- version: 'v2' as const,
3676
- };
3677
-
3678
- await run.processStream({ messages: [] }, callerConfig);
3679
-
3680
- /** After interrupt: sidecar entry MUST still be present. Without
3681
- * the fix, `clearHeavyState` in the `finally` block would have
3682
- * wiped this map. */
3683
- expect(run.getInterrupt()).toBeDefined();
3684
- expect(run.Graph!.toolCallStepIds.has('call_1')).toBe(true);
3685
- expect(run.Graph!.toolCallStepIds.get('call_1')).toBe('step_real_id');
3686
-
3687
- /** Resume: without the resetValues gate, this would also wipe
3688
- * the map at the START of the second processStream invocation. */
3689
- await run.resume([{ type: 'approve' }], callerConfig);
3690
-
3691
- /** After resume completes naturally: dispatch fired with the real
3692
- * step id (not an empty string from a wiped map). Without either
3693
- * fix, `dispatchedStepIds` would contain `''`. */
3694
- expect(dispatchedStepIds).toContain('step_real_id');
3695
- expect(dispatchedStepIds).not.toContain('');
3696
- /** And clearHeavyState DID fire on the natural-completion side
3697
- * — sidecar map is now empty after the resume settled. */
3698
- expect(run.Graph!.toolCallStepIds.size).toBe(0);
3699
- });
3700
-
3701
- it('clears Graph sidecars on natural completion when no interrupt was raised', async () => {
3702
- /** Negative case: when no interrupt fires, `clearHeavyState`
3703
- * MUST run as before. This pins the gate so a future change
3704
- * doesn't accidentally preserve sidecars on natural completion
3705
- * (memory leak across runs). */
3706
- mockEventDispatch([]);
3707
-
3708
- const { Run } = await import('@/run');
3709
- const run = await Run.create<t.IState>({
3710
- runId: 'sidecar-clear-natural',
3711
- graphConfig: {
3712
- type: 'standard',
3713
- agents: [
3714
- {
3715
- agentId: 'a',
3716
- provider: providers.OPENAI,
3717
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
3718
- instructions: 'noop',
3719
- maxContextTokens: 8000,
3720
- },
3721
- ],
3722
- },
3723
- humanInTheLoop: { enabled: false },
3724
- });
3725
-
3726
- /** No-op graph — runs to completion without an interrupt. */
3727
- const builder = new StateGraph(MessagesAnnotation)
3728
- .addNode('noop', (): MessagesUpdate => ({ messages: [] }))
3729
- .addEdge(START, 'noop')
3730
- .addEdge('noop', END);
3731
- const graph = builder.compile();
3732
- run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
3733
-
3734
- /** Stash an entry so we can verify clearHeavyState wiped it. */
3735
- run.Graph!.toolCallStepIds.set('stale_call', 'stale_step');
3736
-
3737
- await run.processStream(
3738
- { messages: [] },
3739
- {
3740
- configurable: { thread_id: 'sidecar-clear-thread' },
3741
- version: 'v2',
3742
- }
3743
- );
3744
-
3745
- /** No interrupt → clearHeavyState ran → sidecar wiped. */
3746
- expect(run.getInterrupt()).toBeUndefined();
3747
- expect(run.Graph!.toolCallStepIds.size).toBe(0);
3748
- });
3749
-
3750
- it('clears session hooks when the stream throws AFTER an interrupt is captured (stale interrupt)', async () => {
3751
- jest
3752
- .spyOn(events, 'safeDispatchCustomEvent')
3753
- .mockImplementation(async () => {
3754
- return;
3755
- });
3756
-
3757
- const registry = new HookRegistry();
3758
- const runId = 'stream-error-after-interrupt';
3759
- registry.registerSession(runId, 'PreToolUse', {
3760
- hooks: [
3761
- async (): Promise<PreToolUseHookOutput> => ({
3762
- decision: 'ask',
3763
- reason: 'session policy',
3764
- }),
3765
- ],
3766
- });
3767
-
3768
- const node = new ToolNode({
3769
- tools: [createSchemaStub('echo')],
3770
- eventDrivenMode: true,
3771
- agentId: 'agent-x',
3772
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
3773
- hookRegistry: registry,
3774
- humanInTheLoop: { enabled: true },
3775
- });
3776
-
3777
- const builder = new StateGraph(MessagesAnnotation)
3778
- .addNode(
3779
- 'agent',
3780
- (): MessagesUpdate => ({
3781
- messages: [
3782
- new AIMessage({
3783
- content: '',
3784
- tool_calls: [
3785
- { id: 'call_1', name: 'echo', args: { command: 'x' } },
3786
- ],
3787
- }),
3788
- ],
3789
- })
3790
- )
3791
- .addNode('tools', node)
3792
- .addEdge(START, 'agent')
3793
- .addEdge('agent', 'tools')
3794
- .addEdge('tools', END);
3795
- const graph = builder.compile({ checkpointer: new MemorySaver() });
3796
-
3797
- const { Run } = await import('@/run');
3798
- /**
3799
- * Holder for forward-referencing the run inside the sentinel
3800
- * handler closure. The handler is constructed before `Run.create`
3801
- * runs (it's passed into `customHandlers`) but needs to read
3802
- * `run.getInterrupt()` at firing time.
3803
- */
3804
- const holder: {
3805
- run: Awaited<ReturnType<typeof Run.create<t.IState>>> | undefined;
3806
- } = { run: undefined };
3807
-
3808
- /**
3809
- * Handler keyed to a chain-stream event that throws ONLY after the
3810
- * interrupt has been captured. The stream loop captures the
3811
- * interrupt on the chunk that carries `__interrupt__`, then
3812
- * dispatches to handlers in the same iteration — so the throw
3813
- * exits the loop with `_interrupt != null`. Without the
3814
- * `streamThrew` guard, the `finally` block would preserve session
3815
- * hooks on this stale interrupt.
3816
- */
3817
- const sentinelHandler = {
3818
- handle: async (): Promise<void> => {
3819
- if (holder.run?.getInterrupt() != null) {
3820
- throw new Error('post-interrupt handler failure');
3821
- }
3822
- },
3823
- };
3824
-
3825
- holder.run = await Run.create<t.IState>({
3826
- runId,
3827
- graphConfig: {
3828
- type: 'standard',
3829
- agents: [
3830
- {
3831
- agentId: 'a',
3832
- provider: providers.OPENAI,
3833
- clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
3834
- instructions: 'noop',
3835
- maxContextTokens: 8000,
3836
- },
3837
- ],
3838
- },
3839
- hooks: registry,
3840
- humanInTheLoop: { enabled: true },
3841
- customHandlers: {
3842
- [GraphEvents.CHAIN_STREAM]: sentinelHandler,
3843
- [GraphEvents.CHAIN_END]: sentinelHandler,
3844
- },
3845
- });
3846
- holder.run.graphRunnable = graph as unknown as t.CompiledStateWorkflow;
3847
-
3848
- const callerConfig = {
3849
- configurable: { thread_id: 'stale-interrupt-thread' },
3850
- version: 'v2' as const,
3851
- };
3852
-
3853
- await expect(
3854
- holder.run.processStream({ messages: [] }, callerConfig)
3855
- ).rejects.toThrow('post-interrupt handler failure');
3856
-
3857
- /** Interrupt WAS captured on the run instance, but because the
3858
- * stream subsequently threw, session hooks must be cleared so the
3859
- * next run on this registry isn't poisoned by stale state. */
3860
- expect(holder.run.getInterrupt()).toBeDefined();
3861
- expect(registry.hasHookFor('PreToolUse', runId)).toBe(false);
3862
- });
3863
-
3864
- it('mixed deny/ask/allow batch: deny short-circuits, allow runs immediately, ask interrupts; resume completes the asked tool', async () => {
3865
- const dispatchedToolNames: string[] = [];
3866
- jest
3867
- .spyOn(events, 'safeDispatchCustomEvent')
3868
- .mockImplementation(async (event, data) => {
3869
- if (event !== 'on_tool_execute') {
3870
- return;
3871
- }
3872
- const request = data as {
3873
- toolCalls: t.ToolCallRequest[];
3874
- resolve: (r: t.ToolExecuteResult[]) => void;
3875
- };
3876
- for (const c of request.toolCalls) {
3877
- dispatchedToolNames.push(c.name);
3878
- }
3879
- request.resolve(
3880
- request.toolCalls.map((c) => ({
3881
- toolCallId: c.id,
3882
- content: `ran:${c.name}`,
3883
- status: 'success' as const,
3884
- }))
3885
- );
3886
- });
3887
-
3888
- /**
3889
- * Per-tool policy hook: tool_a denied, tool_b asks, tool_c allowed.
3890
- * The hook is registered without a pattern so it fires once per
3891
- * tool call and dispatches by tool name.
3892
- */
3893
- const registry = new HookRegistry();
3894
- registry.register('PreToolUse', {
3895
- hooks: [
3896
- async (input): Promise<PreToolUseHookOutput> => {
3897
- if (input.toolName === 'tool_a') {
3898
- return { decision: 'deny', reason: 'policy:a' };
3899
- }
3900
- if (input.toolName === 'tool_b') {
3901
- return { decision: 'ask', reason: 'policy:b-needs-review' };
3902
- }
3903
- return { decision: 'allow' };
3904
- },
3905
- ],
3906
- });
3907
- /**
3908
- * Listen on PostToolBatch to verify the batch entry shape after
3909
- * resume reflects the final outcomes (deny + run + run), not
3910
- * stale state from the first pass.
3911
- */
3912
- const batchSnapshots: PostToolBatchEntry[][] = [];
3913
- registry.register('PostToolBatch', {
3914
- hooks: [
3915
- async (input): Promise<PostToolBatchHookOutput> => {
3916
- batchSnapshots.push(
3917
- (input as PostToolBatchHookInput).entries.map((e) => ({ ...e }))
3918
- );
3919
- return {};
3920
- },
3921
- ],
3922
- });
3923
-
3924
- const node = new ToolNode({
3925
- tools: [
3926
- createSchemaStub('tool_a'),
3927
- createSchemaStub('tool_b'),
3928
- createSchemaStub('tool_c'),
3929
- ],
3930
- eventDrivenMode: true,
3931
- agentId: 'agent-x',
3932
- toolCallStepIds: new Map([
3933
- ['call_a', 'step_a'],
3934
- ['call_b', 'step_b'],
3935
- ['call_c', 'step_c'],
3936
- ]),
3937
- hookRegistry: registry,
3938
- humanInTheLoop: { enabled: true },
3939
- });
3940
-
3941
- const graph = buildHITLGraph(node, [
3942
- { id: 'call_a', name: 'tool_a', args: { command: 'a' } },
3943
- { id: 'call_b', name: 'tool_b', args: { command: 'b' } },
3944
- { id: 'call_c', name: 'tool_c', args: { command: 'c' } },
3945
- ]);
3946
- const config = { configurable: { thread_id: 'mixed-thread' } };
3947
-
3948
- const interrupted = await graph.invoke({ messages: [] }, config);
3949
- if (!isInterrupted<t.HumanInterruptPayload>(interrupted)) {
3950
- throw new Error('expected interrupt');
3951
- }
3952
- const payload = interrupted.__interrupt__[0].value!;
3953
- if (payload.type !== 'tool_approval') {
3954
- throw new Error('expected tool_approval payload');
3955
- }
3956
- /** Only tool_b appears in the interrupt — deny short-circuited
3957
- * locally, allow was queued for dispatch but never reached it
3958
- * because `interrupt()` threw inside the same node first. LangGraph
3959
- * rolls back the entire node's effects on throw, so no host event
3960
- * fires for any tool until after resume. This is the safe
3961
- * semantic: partial execution while a human is being asked would
3962
- * leak side effects ahead of approval. */
3963
- expect(payload.action_requests).toHaveLength(1);
3964
- expect(payload.action_requests[0].tool_call_id).toBe('call_b');
3965
- expect(dispatchedToolNames).toEqual([]);
3966
-
3967
- const resumed = (await resumeGraph(
3968
- graph,
3969
- interrupted,
3970
- [{ type: 'approve' }],
3971
- config
3972
- )) as { messages: BaseMessage[] };
3973
-
3974
- /**
3975
- * After resume, all three tools have ToolMessages: tool_a blocked
3976
- * (deny), tool_b ran (host approved), tool_c ran (allow). The
3977
- * ToolNode re-executed from scratch, so both tool_b and tool_c
3978
- * dispatch in this pass.
3979
- */
3980
- const toolMessages = resumed.messages.filter(
3981
- (m): m is ToolMessage => m._getType() === 'tool'
3982
- );
3983
- expect(toolMessages).toHaveLength(3);
3984
- const byId = new Map(toolMessages.map((m) => [m.tool_call_id, m]));
3985
- expect(byId.get('call_a')!.status).toBe('error');
3986
- expect(String(byId.get('call_a')!.content)).toContain('policy:a');
3987
- expect(byId.get('call_b')!.status).not.toBe('error');
3988
- expect(byId.get('call_b')!.content).toBe('ran:tool_b');
3989
- expect(byId.get('call_c')!.status).not.toBe('error');
3990
- expect(byId.get('call_c')!.content).toBe('ran:tool_c');
3991
- /** Both approved tools dispatched on resume; tool_a (deny) never did. */
3992
- expect(new Set(dispatchedToolNames)).toEqual(new Set(['tool_b', 'tool_c']));
3993
- expect(dispatchedToolNames).not.toContain('tool_a');
3994
-
3995
- /**
3996
- * PostToolBatch is dispatched at the bottom of `dispatchToolEvents`,
3997
- * after tool execution. On the FIRST pass `interrupt()` throws
3998
- * before reaching that line, so PostToolBatch does NOT fire for
3999
- * the interrupted pass. Only the resume pass yields a snapshot —
4000
- * carrying all three entries with their final outcomes (tool_a
4001
- * blocked by deny, tool_b approved + ran, tool_c approved + ran).
4002
- */
4003
- expect(batchSnapshots).toHaveLength(1);
4004
- const finalSnapshot = batchSnapshots[0];
4005
- /**
4006
- * Order assertion: entries must match the original toolCalls
4007
- * sequence (`call_a`, `call_b`, `call_c`) regardless of when each
4008
- * outcome was recorded — `call_a` was denied synchronously in the
4009
- * hook loop, `call_b` was approved through the resume branch,
4010
- * `call_c` was approved+executed via the host event path. Hooks
4011
- * correlating outcomes by position (per the API doc) depend on
4012
- * this stability.
4013
- */
4014
- expect(finalSnapshot.map((e) => e.toolUseId)).toEqual([
4015
- 'call_a',
4016
- 'call_b',
4017
- 'call_c',
4018
- ]);
4019
- const byCallId = new Map(finalSnapshot.map((e) => [e.toolUseId, e]));
4020
- expect(byCallId.size).toBe(3);
4021
- expect(byCallId.get('call_a')!.status).toBe('error');
4022
- expect(byCallId.get('call_a')!.error).toContain('policy:a');
4023
- expect(byCallId.get('call_b')!.status).toBe('success');
4024
- expect(byCallId.get('call_b')!.toolOutput).toBe('ran:tool_b');
4025
- expect(byCallId.get('call_c')!.status).toBe('success');
4026
- expect(byCallId.get('call_c')!.toolOutput).toBe('ran:tool_c');
4027
- });
4028
-
4029
- it('mixed respond + reject in the same resume: dispatches once each, batch entries in toolCalls order', async () => {
4030
- const stepCompletedDispatches: string[] = [];
4031
- jest
4032
- .spyOn(events, 'safeDispatchCustomEvent')
4033
- .mockImplementation(async (event, data) => {
4034
- if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
4035
- const payload = data as {
4036
- result?: { tool_call?: { id?: string } };
4037
- };
4038
- const id = payload.result?.tool_call?.id;
4039
- if (id != null) {
4040
- stepCompletedDispatches.push(id);
4041
- }
4042
- return;
4043
- }
4044
- if (event !== 'on_tool_execute') {
4045
- return;
4046
- }
4047
- const request = data as {
4048
- toolCalls: t.ToolCallRequest[];
4049
- resolve: (r: t.ToolExecuteResult[]) => void;
4050
- };
4051
- request.resolve([]);
4052
- });
4053
-
4054
- const registry = new HookRegistry();
4055
- /** Both tools `ask`; the resume picks `respond` for one and
4056
- * `reject` for the other. Exercises the timing interaction
4057
- * between respond's immediate dispatch and reject's deferred
4058
- * flush in the same resume pass. */
4059
- registry.register('PreToolUse', {
4060
- hooks: [
4061
- async (): Promise<PreToolUseHookOutput> => ({
4062
- decision: 'ask',
4063
- reason: 'review',
4064
- }),
4065
- ],
4066
- });
4067
- const batchSnapshots: PostToolBatchEntry[][] = [];
4068
- registry.register('PostToolBatch', {
4069
- hooks: [
4070
- async (input): Promise<PostToolBatchHookOutput> => {
4071
- batchSnapshots.push(
4072
- (input as PostToolBatchHookInput).entries.map((e) => ({ ...e }))
4073
- );
4074
- return {};
4075
- },
4076
- ],
4077
- });
4078
-
4079
- const node = new ToolNode({
4080
- tools: [
4081
- createSchemaStub('respond_tool'),
4082
- createSchemaStub('reject_tool'),
4083
- ],
4084
- eventDrivenMode: true,
4085
- agentId: 'agent-x',
4086
- toolCallStepIds: new Map([
4087
- ['call_respond', 'step_respond'],
4088
- ['call_reject', 'step_reject'],
4089
- ]),
4090
- hookRegistry: registry,
4091
- humanInTheLoop: { enabled: true },
4092
- });
4093
-
4094
- const graph = buildHITLGraph(node, [
4095
- { id: 'call_respond', name: 'respond_tool', args: { command: 'r' } },
4096
- { id: 'call_reject', name: 'reject_tool', args: { command: 'j' } },
4097
- ]);
4098
- const config = { configurable: { thread_id: 'mixed-respond-reject' } };
4099
-
4100
- const interrupted = await graph.invoke({ messages: [] }, config);
4101
- /** First pass: interrupt fires before either dispatch path runs. */
4102
- expect(stepCompletedDispatches).toEqual([]);
4103
-
4104
- const resumed = (await resumeGraph(
4105
- graph,
4106
- interrupted,
4107
- [
4108
- { type: 'respond', responseText: 'fake answer' },
4109
- { type: 'reject', reason: 'no thanks' },
4110
- ],
4111
- config
4112
- )) as { messages: BaseMessage[] };
4113
-
4114
- /** Each tool dispatched ON_RUN_STEP_COMPLETED exactly once on
4115
- * resume — respond via its immediate path, reject via the
4116
- * deferred flush. */
4117
- expect(
4118
- stepCompletedDispatches.filter((id) => id === 'call_respond')
4119
- ).toEqual(['call_respond']);
4120
- expect(
4121
- stepCompletedDispatches.filter((id) => id === 'call_reject')
4122
- ).toEqual(['call_reject']);
4123
-
4124
- /** PostToolBatch fires once on the resume pass, with entries in
4125
- * the original toolCalls order (respond first, reject second)
4126
- * regardless of which dispatch path landed first into the Map. */
4127
- expect(batchSnapshots).toHaveLength(1);
4128
- expect(batchSnapshots[0].map((e) => e.toolUseId)).toEqual([
4129
- 'call_respond',
4130
- 'call_reject',
4131
- ]);
4132
- expect(batchSnapshots[0][0].status).toBe('success');
4133
- expect(batchSnapshots[0][0].toolOutput).toBe('fake answer');
4134
- expect(batchSnapshots[0][1].status).toBe('error');
4135
- expect(String(batchSnapshots[0][1].error)).toContain('no thanks');
4136
-
4137
- /** ToolMessage state matches: success with response text, error with reason. */
4138
- const toolMessages = resumed.messages.filter(
4139
- (m): m is ToolMessage => m._getType() === 'tool'
4140
- );
4141
- expect(toolMessages).toHaveLength(2);
4142
- const byId = new Map(toolMessages.map((m) => [m.tool_call_id, m]));
4143
- expect(byId.get('call_respond')!.status).not.toBe('error');
4144
- expect(byId.get('call_respond')!.content).toBe('fake answer');
4145
- expect(byId.get('call_reject')!.status).toBe('error');
4146
- expect(String(byId.get('call_reject')!.content)).toContain('no thanks');
4147
- });
4148
-
4149
- it('PostToolBatch entries preserve toolCalls order even when first call is denied and second is approved', async () => {
4150
- jest
4151
- .spyOn(events, 'safeDispatchCustomEvent')
4152
- .mockImplementation(async (event, data) => {
4153
- if (event !== 'on_tool_execute') {
4154
- return;
4155
- }
4156
- const request = data as {
4157
- toolCalls: t.ToolCallRequest[];
4158
- resolve: (r: t.ToolExecuteResult[]) => void;
4159
- };
4160
- request.resolve(
4161
- request.toolCalls.map((c) => ({
4162
- toolCallId: c.id,
4163
- content: `ran:${c.name}`,
4164
- status: 'success' as const,
4165
- }))
4166
- );
4167
- });
4168
-
4169
- /**
4170
- * Two different orderings to verify the asserted order really
4171
- * tracks the input — not just incidental ordering from one path
4172
- * landing first.
4173
- */
4174
- const cases: Array<{
4175
- thread: string;
4176
- input: Array<{ id: string; name: string; args: Record<string, unknown> }>;
4177
- expected: string[];
4178
- }> = [
4179
- {
4180
- thread: 'order-deny-first',
4181
- input: [
4182
- { id: 'call_first', name: 'denied_tool', args: { command: 'a' } },
4183
- { id: 'call_second', name: 'allowed_tool', args: { command: 'b' } },
4184
- ],
4185
- expected: ['call_first', 'call_second'],
4186
- },
4187
- {
4188
- thread: 'order-approve-first',
4189
- input: [
4190
- { id: 'call_first', name: 'allowed_tool', args: { command: 'a' } },
4191
- { id: 'call_second', name: 'denied_tool', args: { command: 'b' } },
4192
- ],
4193
- expected: ['call_first', 'call_second'],
4194
- },
4195
- ];
4196
-
4197
- for (const { thread, input, expected } of cases) {
4198
- const registry = new HookRegistry();
4199
- registry.register('PreToolUse', {
4200
- hooks: [
4201
- async (hookInput): Promise<PreToolUseHookOutput> => {
4202
- if (hookInput.toolName === 'denied_tool') {
4203
- return { decision: 'deny', reason: 'no' };
4204
- }
4205
- return { decision: 'allow' };
4206
- },
4207
- ],
4208
- });
4209
- const captured: PostToolBatchEntry[] = [];
4210
- registry.register('PostToolBatch', {
4211
- hooks: [
4212
- async (i): Promise<PostToolBatchHookOutput> => {
4213
- captured.push(...(i as PostToolBatchHookInput).entries);
4214
- return {};
4215
- },
4216
- ],
4217
- });
4218
-
4219
- const node = new ToolNode({
4220
- tools: [
4221
- createSchemaStub('denied_tool'),
4222
- createSchemaStub('allowed_tool'),
4223
- ],
4224
- eventDrivenMode: true,
4225
- agentId: 'agent-x',
4226
- toolCallStepIds: new Map(input.map((c) => [c.id, `step_${c.id}`])),
4227
- hookRegistry: registry,
4228
- humanInTheLoop: { enabled: false },
4229
- });
4230
-
4231
- const graph = buildHITLGraph(node, input);
4232
- await graph.invoke(
4233
- { messages: [] },
4234
- { configurable: { thread_id: thread } }
4235
- );
4236
-
4237
- expect(captured.map((e) => e.toolUseId)).toEqual(expected);
4238
- }
4239
- });
4240
-
4241
- it('fails closed when the host resume payload carries an unknown decision type', async () => {
4242
- /** Spy MUST be reachable inside Promise.resolve handlers — must not run after mock is restored. */
4243
- let dispatchCalls = 0;
4244
- jest
4245
- .spyOn(events, 'safeDispatchCustomEvent')
4246
- .mockImplementation(async (event, data) => {
4247
- if (event !== 'on_tool_execute') {
4248
- return;
4249
- }
4250
- dispatchCalls += 1;
4251
- const request = data as {
4252
- toolCalls: t.ToolCallRequest[];
4253
- resolve: (r: t.ToolExecuteResult[]) => void;
4254
- };
4255
- request.resolve([]);
4256
- });
4257
-
4258
- const node = new ToolNode({
4259
- tools: [createSchemaStub('echo')],
4260
- eventDrivenMode: true,
4261
- agentId: 'agent-x',
4262
- toolCallStepIds: new Map([['call_1', 'step_call_1']]),
4263
- hookRegistry: makeHookRegistry('ask'),
4264
- humanInTheLoop: { enabled: true },
4265
- });
4266
-
4267
- const graph = buildHITLGraph(node, [
4268
- { id: 'call_1', name: 'echo', args: { command: 'sensitive' } },
4269
- ]);
4270
- const config = { configurable: { thread_id: 'unknown-decision' } };
4271
-
4272
- const interrupted = await graph.invoke({ messages: [] }, config);
4273
-
4274
- /** Host sends a typo'd / malformed decision. Must NOT silently approve. */
4275
- const resumed = (await resumeGraph(
4276
- graph,
4277
- interrupted,
4278
- [{ type: 'aproved' as 'approve' }],
4279
- config
4280
- )) as { messages: BaseMessage[] };
4281
-
4282
- const toolMessages = resumed.messages.filter(
4283
- (m): m is ToolMessage => m._getType() === 'tool'
4284
- );
4285
- expect(toolMessages).toHaveLength(1);
4286
- expect(toolMessages[0].status).toBe('error');
4287
- expect(String(toolMessages[0].content)).toContain(
4288
- 'Unknown approval decision type'
4289
- );
4290
- /** Tool was never dispatched — fail-closed worked. */
4291
- expect(dispatchCalls).toBe(0);
4292
- });
4293
-
4294
- it('PostToolBatch entry sees the PostToolUse-rewritten output, not the original', async () => {
4295
- mockEventDispatch([
4296
- { toolCallId: 'call_1', content: 'raw-secret-1234', status: 'success' },
4297
- ]);
4298
-
4299
- const registry = new HookRegistry();
4300
- /** PostToolUse redacts the output before the model sees it. */
4301
- registry.register('PostToolUse', {
4302
- hooks: [
4303
- async (): Promise<PostToolUseHookOutput> => ({
4304
- updatedOutput: 'raw-secret-[REDACTED]',
4305
- }),
4306
- ],
4307
- });
4308
- let batchEntries: PostToolBatchEntry[] | undefined;
4309
- registry.register('PostToolBatch', {
4310
- hooks: [
4311
- async (input): Promise<PostToolBatchHookOutput> => {
4312
- batchEntries = (input as PostToolBatchHookInput).entries;
4313
- return {};
4314
- },
4315
- ],
4316
- });
4317
-
4318
- const node = new ToolNode({
4319
- tools: [createSchemaStub('echo')],
4320
- eventDrivenMode: true,
4321
- agentId: 'agent-x',
4322
- toolCallStepIds: new Map([['call_1', 'step_1']]),
4323
- hookRegistry: registry,
4324
- humanInTheLoop: { enabled: false },
4325
- });
4326
-
4327
- const graph = buildHITLGraph(node, [
4328
- { id: 'call_1', name: 'echo', args: { command: 'fetch' } },
4329
- ]);
4330
- await graph.invoke(
4331
- { messages: [] },
4332
- { configurable: { thread_id: 'batch-rewrite' } }
4333
- );
4334
-
4335
- expect(batchEntries).toBeDefined();
4336
- expect(batchEntries).toHaveLength(1);
4337
- /** Batch hook sees the redacted value, not the raw secret. */
4338
- expect(batchEntries![0].toolOutput).toBe('raw-secret-[REDACTED]');
4339
- expect(batchEntries![0].toolOutput).not.toContain('raw-secret-1234');
4340
- });
4341
-
4342
- it('PostToolUseFailure additionalContext is injected for the next model turn', async () => {
4343
- /** Force the host event dispatch to return an error so the failure path runs. */
4344
- jest
4345
- .spyOn(events, 'safeDispatchCustomEvent')
4346
- .mockImplementation(async (event, data) => {
4347
- if (event !== 'on_tool_execute') {
4348
- return;
4349
- }
4350
- const request = data as {
4351
- toolCalls: t.ToolCallRequest[];
4352
- resolve: (r: t.ToolExecuteResult[]) => void;
4353
- };
4354
- request.resolve([
4355
- {
4356
- toolCallId: 'call_1',
4357
- content: '',
4358
- status: 'error',
4359
- errorMessage: 'network timeout',
4360
- },
4361
- ]);
4362
- });
4363
-
4364
- const registry = new HookRegistry();
4365
- registry.register('PostToolUseFailure', {
4366
- hooks: [
4367
- async (): Promise<PostToolUseFailureHookOutput> => ({
4368
- additionalContext:
4369
- 'Tool failed — suggest the user retry with a smaller batch size',
4370
- }),
4371
- ],
4372
- });
4373
-
4374
- const node = new ToolNode({
4375
- tools: [createSchemaStub('echo')],
4376
- eventDrivenMode: true,
4377
- agentId: 'agent-x',
4378
- toolCallStepIds: new Map([['call_1', 'step_1']]),
4379
- hookRegistry: registry,
4380
- humanInTheLoop: { enabled: false },
4381
- });
4382
-
4383
- const graph = buildHITLGraph(node, [
4384
- { id: 'call_1', name: 'echo', args: { command: 'fetch' } },
4385
- ]);
4386
- const result = (await graph.invoke(
4387
- { messages: [] },
4388
- { configurable: { thread_id: 'failure-ctx' } }
4389
- )) as { messages: BaseMessage[] };
4390
-
4391
- const injected = result.messages.find(
4392
- (m) =>
4393
- m._getType() === 'human' &&
4394
- (m as { additional_kwargs?: { source?: string } }).additional_kwargs
4395
- ?.source === 'hook'
4396
- );
4397
- expect(injected).toBeDefined();
4398
- expect(String(injected!.content)).toContain(
4399
- 'suggest the user retry with a smaller batch size'
4400
- );
4401
- });
4402
- });
4403
-
4404
- describe('AskUserQuestion — interrupt + resume', () => {
4405
- afterEach(() => {
4406
- jest.restoreAllMocks();
4407
- });
4408
-
4409
- it('a node calling askUserQuestion() raises an ask_user_question interrupt and resumes with the answer', async () => {
4410
- const { askUserQuestion } = await import('@/hitl');
4411
-
4412
- let resumedAnswer: string | undefined;
4413
-
4414
- const builder = new StateGraph(MessagesAnnotation)
4415
- .addNode('clarifier', () => {
4416
- const resolution = askUserQuestion({
4417
- question: 'Which environment?',
4418
- options: [
4419
- { label: 'Staging', value: 'staging' },
4420
- { label: 'Production', value: 'production' },
4421
- ],
4422
- });
4423
- resumedAnswer = resolution.answer;
4424
- return { messages: [] };
4425
- })
4426
- .addEdge(START, 'clarifier')
4427
- .addEdge('clarifier', END);
4428
- const graph = builder.compile({ checkpointer: new MemorySaver() });
4429
-
4430
- const config = { configurable: { thread_id: 'ask-q-thread' } };
4431
-
4432
- const interrupted = (await graph.invoke({ messages: [] }, config)) as {
4433
- __interrupt__?: Array<{ id?: string; value?: t.HumanInterruptPayload }>;
4434
- };
4435
- expect(interrupted.__interrupt__).toBeDefined();
4436
- const payload = interrupted.__interrupt__![0].value!;
4437
- if (payload.type !== 'ask_user_question') {
4438
- throw new Error('expected ask_user_question');
4439
- }
4440
- expect(payload.question.question).toBe('Which environment?');
4441
- expect(payload.question.options).toHaveLength(2);
4442
-
4443
- const resolution: t.AskUserQuestionResolution = { answer: 'production' };
4444
- await resumeGraph(
4445
- graph as unknown as CompiledMessagesGraph,
4446
- interrupted,
4447
- resolution,
4448
- config
4449
- );
4450
-
4451
- expect(resumedAnswer).toBe('production');
4452
- });
4453
-
4454
- it('carries multiSelect through the interrupt payload and resumes with the joined option values', async () => {
4455
- const { askUserQuestion } = await import('@/hitl');
4456
-
4457
- let resumedAnswer: string | undefined;
4458
-
4459
- const builder = new StateGraph(MessagesAnnotation)
4460
- .addNode('clarifier', () => {
4461
- const resolution = askUserQuestion({
4462
- question: 'Which environments?',
4463
- options: [
4464
- { label: 'Staging', value: 'staging' },
4465
- { label: 'Production', value: 'production' },
4466
- ],
4467
- multiSelect: true,
4468
- });
4469
- resumedAnswer = resolution.answer;
4470
- return { messages: [] };
4471
- })
4472
- .addEdge(START, 'clarifier')
4473
- .addEdge('clarifier', END);
4474
- const graph = builder.compile({ checkpointer: new MemorySaver() });
4475
-
4476
- const config = { configurable: { thread_id: 'ask-q-multi-thread' } };
4477
-
4478
- const interrupted = (await graph.invoke({ messages: [] }, config)) as {
4479
- __interrupt__?: Array<{ id?: string; value?: t.HumanInterruptPayload }>;
4480
- };
4481
- const payload = interrupted.__interrupt__![0].value!;
4482
- if (payload.type !== 'ask_user_question') {
4483
- throw new Error('expected ask_user_question');
4484
- }
4485
- expect(payload.question.multiSelect).toBe(true);
4486
- expect(payload.question.options).toHaveLength(2);
4487
-
4488
- // Host joins the selected option values with ", ".
4489
- const resolution: t.AskUserQuestionResolution = {
4490
- answer: 'staging, production',
4491
- };
4492
- await resumeGraph(
4493
- graph as unknown as CompiledMessagesGraph,
4494
- interrupted,
4495
- resolution,
4496
- config
4497
- );
4498
-
4499
- expect(resumedAnswer).toBe('staging, production');
4500
- });
4501
-
4502
- it('a DIRECT tool in event-driven mode can raise ask_user_question from its body and resume with the answer as its ToolMessage', async () => {
4503
- /**
4504
- * The production host shape (e.g. LibreChat's `AgentInputs.graphTools`
4505
- * plumb): the run is event-driven (other tools are schema-only
4506
- * definitions dispatched to the host), but an interrupt-capable tool is
4507
- * supplied as a real instance and marked direct so it executes inside
4508
- * the Pregel task frame. Event dispatch must never see the call — a
4509
- * host-side handler runs outside the graph, where `interrupt()` throws.
4510
- */
4511
- const { askUserQuestion } = await import('@/hitl');
4512
-
4513
- const dispatchSpy = jest
4514
- .spyOn(events, 'safeDispatchCustomEvent')
4515
- .mockImplementation(async () => {});
4516
-
4517
- let bodyRuns = 0;
4518
- const askTool = tool(
4519
- async (input: { question: string }) => {
4520
- bodyRuns += 1;
4521
- const resolution = askUserQuestion(input);
4522
- return resolution.answer;
4523
- },
4524
- {
4525
- name: 'ask_user_question',
4526
- description: 'Ask the user a clarifying question.',
4527
- schema: z.object({ question: z.string() }),
4528
- }
4529
- ) as unknown as StructuredToolInterface;
4530
-
4531
- const node = new ToolNode({
4532
- tools: [createSchemaStub('echo'), askTool],
4533
- toolMap: new Map([
4534
- ['echo', createSchemaStub('echo')],
4535
- ['ask_user_question', askTool],
4536
- ]),
4537
- eventDrivenMode: true,
4538
- agentId: 'agent-ask-direct',
4539
- toolCallStepIds: new Map([['call_ask_1', 'step_call_ask_1']]),
4540
- directToolNames: new Set(['ask_user_question']),
4541
- });
4542
-
4543
- const graph = buildHITLGraph(node, [
4544
- {
4545
- id: 'call_ask_1',
4546
- name: 'ask_user_question',
4547
- args: { question: 'Which environment?' },
4548
- },
4549
- ]);
4550
- const config = { configurable: { thread_id: 'thread-ask-direct' } };
4551
-
4552
- const interrupted = await graph.invoke({ messages: [] }, config);
4553
- expect(isInterrupted<t.HumanInterruptPayload>(interrupted)).toBe(true);
4554
- if (!isInterrupted<t.HumanInterruptPayload>(interrupted)) {
4555
- throw new Error('expected interrupt');
4556
- }
4557
- const payload = interrupted.__interrupt__[0].value!;
4558
- if (payload.type !== 'ask_user_question') {
4559
- throw new Error('expected ask_user_question payload');
4560
- }
4561
- expect(payload.question.question).toBe('Which environment?');
4562
- expect(bodyRuns).toBe(1);
4563
-
4564
- /** The interrupt came from the direct path — never dispatched to the host. */
4565
- const toolExecuteDispatches = dispatchSpy.mock.calls.filter(
4566
- ([event]) => event === 'on_tool_execute'
4567
- );
4568
- expect(toolExecuteDispatches).toHaveLength(0);
4569
-
4570
- const resumed = (await resumeGraph(
4571
- graph,
4572
- interrupted,
4573
- { answer: 'staging' } satisfies t.AskUserQuestionResolution,
4574
- config
4575
- )) as MessagesUpdate;
4576
-
4577
- expect(bodyRuns).toBe(2); // body re-runs from the top on the resume pass
4578
- const toolMessage = resumed.messages.find(
4579
- (m): m is ToolMessage =>
4580
- m._getType() === 'tool' &&
4581
- (m as ToolMessage).tool_call_id === 'call_ask_1'
4582
- );
4583
- expect(toolMessage).toBeDefined();
4584
- expect(String(toolMessage!.content)).toBe('staging');
4585
- });
4586
-
4587
- it('askUserQuestion surfaces the calling tool_call_id on the interrupt payload when the body supplies it', async () => {
4588
- /**
4589
- * LangChain stamps the full ToolCall onto the config a `tool(fn, …)`
4590
- * body receives, so the body can attribute its interrupt to the exact
4591
- * call. Hosts use `payload.tool_call_id` to stamp the question/answer
4592
- * onto the right content part when a model emits several ask calls in
4593
- * one turn — positional guessing mislabels the cards.
4594
- */
4595
- const askTool = tool(
4596
- async (input: { question: string }, config) => {
4597
- const resolution = askUserQuestion(input, {
4598
- toolCallId: config.toolCall?.id,
4599
- });
4600
- return resolution.answer;
4601
- },
4602
- {
4603
- name: 'ask_user_question',
4604
- description: 'Ask the user a clarifying question.',
4605
- schema: z.object({ question: z.string() }),
4606
- }
4607
- ) as unknown as StructuredToolInterface;
4608
-
4609
- const node = new ToolNode({ tools: [askTool] });
4610
- const graph = buildHITLGraph(node, [
4611
- {
4612
- id: 'call_ask_id_1',
4613
- name: 'ask_user_question',
4614
- args: { question: 'Which region?' },
4615
- },
4616
- ]);
4617
- const config = { configurable: { thread_id: 'thread-ask-call-id' } };
4618
-
4619
- const interrupted = await graph.invoke({ messages: [] }, config);
4620
- if (!isInterrupted<t.HumanInterruptPayload>(interrupted)) {
4621
- throw new Error('expected interrupt');
4622
- }
4623
- const payload = interrupted.__interrupt__[0].value!;
4624
- if (payload.type !== 'ask_user_question') {
4625
- throw new Error('expected ask_user_question payload');
4626
- }
4627
- expect(payload.tool_call_id).toBe('call_ask_id_1');
4628
- expect(payload.question.question).toBe('Which region?');
4629
-
4630
- const resumed = (await resumeGraph(
4631
- graph,
4632
- interrupted,
4633
- { answer: 'us-east' } satisfies t.AskUserQuestionResolution,
4634
- config
4635
- )) as MessagesUpdate;
4636
- const toolMessage = resumed.messages.find(
4637
- (m): m is ToolMessage =>
4638
- m._getType() === 'tool' &&
4639
- (m as ToolMessage).tool_call_id === 'call_ask_id_1'
4640
- );
4641
- expect(String(toolMessage!.content)).toBe('us-east');
4642
- });
4643
-
4644
- it('isAskUserQuestionInterrupt narrows the payload union correctly', async () => {
4645
- const { isAskUserQuestionInterrupt, isToolApprovalInterrupt } =
4646
- await import('@/types/hitl');
4647
-
4648
- const askPayload: t.HumanInterruptPayload = {
4649
- type: 'ask_user_question',
4650
- question: { question: 'why?' },
4651
- };
4652
- const approvalPayload: t.HumanInterruptPayload = {
4653
- type: 'tool_approval',
4654
- action_requests: [],
4655
- review_configs: [],
4656
- };
4657
-
4658
- expect(isAskUserQuestionInterrupt(askPayload)).toBe(true);
4659
- expect(isAskUserQuestionInterrupt(approvalPayload)).toBe(false);
4660
- expect(isToolApprovalInterrupt(approvalPayload)).toBe(true);
4661
- expect(isToolApprovalInterrupt(askPayload)).toBe(false);
4662
- });
4663
- });