@librechat/agents 3.4.6 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1027) 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 +5 -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 +5 -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/dist/types/agents/AgentContext.d.ts +3 -3
  582. package/dist/types/agents/__tests__/promptCacheLiveHelpers.d.ts +3 -3
  583. package/dist/types/agents/projection.d.ts +1 -1
  584. package/dist/types/events.d.ts +2 -2
  585. package/dist/types/graphs/Graph.d.ts +11 -11
  586. package/dist/types/graphs/MultiAgentGraph.d.ts +2 -2
  587. package/dist/types/graphs/createGraph.d.ts +4 -4
  588. package/dist/types/graphs/graphFactory.d.ts +1 -1
  589. package/dist/types/hitl/askUserQuestion.d.ts +1 -1
  590. package/dist/types/hitl/askUserQuestions.d.ts +1 -1
  591. package/dist/types/hitl/askUserQuestionsInterrupt.d.ts +1 -1
  592. package/dist/types/hooks/types.d.ts +1 -1
  593. package/dist/types/instrumentation.d.ts +1 -1
  594. package/dist/types/langfuse.d.ts +3 -3
  595. package/dist/types/langfuseConfig.d.ts +2 -2
  596. package/dist/types/langfuseRuntimeContext.d.ts +1 -1
  597. package/dist/types/langfuseRuntimeScope.d.ts +2 -2
  598. package/dist/types/langfuseSpanRegistry.d.ts +1 -1
  599. package/dist/types/langfuseToolOutputTracing.d.ts +3 -3
  600. package/dist/types/llm/anthropic/index.d.ts +1 -1
  601. package/dist/types/llm/bedrock/index.d.ts +1 -1
  602. package/dist/types/llm/bedrock/toolCache.d.ts +2 -2
  603. package/dist/types/llm/contextOverflowRecovery.d.ts +2 -2
  604. package/dist/types/llm/google/index.d.ts +1 -1
  605. package/dist/types/llm/init.d.ts +2 -2
  606. package/dist/types/llm/invoke.d.ts +6 -6
  607. package/dist/types/llm/mistral/index.d.ts +1 -1
  608. package/dist/types/llm/openai/index.d.ts +2 -2
  609. package/dist/types/llm/openrouter/index.d.ts +2 -2
  610. package/dist/types/llm/openrouter/toolCache.d.ts +2 -2
  611. package/dist/types/llm/providers.d.ts +2 -2
  612. package/dist/types/llm/request.d.ts +2 -2
  613. package/dist/types/llm/stream/chunkAdapters.d.ts +1 -1
  614. package/dist/types/llm/streamLimits.d.ts +1 -1
  615. package/dist/types/llm/truncation.d.ts +1 -1
  616. package/dist/types/llm/vertexai/index.d.ts +1 -1
  617. package/dist/types/messages/alternation.d.ts +1 -1
  618. package/dist/types/messages/anthropicToolCache.d.ts +2 -2
  619. package/dist/types/messages/assistantPhase.d.ts +3 -3
  620. package/dist/types/messages/budget.d.ts +1 -1
  621. package/dist/types/messages/cache.d.ts +1 -1
  622. package/dist/types/messages/contextPruning.d.ts +2 -2
  623. package/dist/types/messages/core.d.ts +2 -2
  624. package/dist/types/messages/format.d.ts +2 -2
  625. package/dist/types/messages/ids.d.ts +2 -2
  626. package/dist/types/messages/injected.d.ts +1 -1
  627. package/dist/types/messages/langchain.d.ts +1 -1
  628. package/dist/types/messages/prune.d.ts +3 -3
  629. package/dist/types/openai/index.d.ts +1 -1
  630. package/dist/types/prompts/activityLabel.d.ts +2 -2
  631. package/dist/types/responses/index.d.ts +1 -1
  632. package/dist/types/run.d.ts +3 -3
  633. package/dist/types/session/handlers.d.ts +1 -1
  634. package/dist/types/session/types.d.ts +1 -1
  635. package/dist/types/stream.d.ts +4 -4
  636. package/dist/types/summarization/index.d.ts +1 -1
  637. package/dist/types/summarization/node.d.ts +6 -6
  638. package/dist/types/test/mockTools.d.ts +1 -1
  639. package/dist/types/tools/BashExecutor.d.ts +2 -2
  640. package/dist/types/tools/BashProgrammaticToolCalling.d.ts +2 -2
  641. package/dist/types/tools/CodeExecutor.d.ts +3 -3
  642. package/dist/types/tools/CodeSessionFileSummary.d.ts +1 -1
  643. package/dist/types/tools/ProgrammaticToolCalling.d.ts +2 -2
  644. package/dist/types/tools/ReadFile.d.ts +1 -1
  645. package/dist/types/tools/SkillTool.d.ts +1 -1
  646. package/dist/types/tools/SubagentTool.d.ts +3 -3
  647. package/dist/types/tools/ToolNode.d.ts +5 -5
  648. package/dist/types/tools/ToolSearch.d.ts +2 -2
  649. package/dist/types/tools/cloudflare/CloudflareBridgeRuntime.d.ts +1 -1
  650. package/dist/types/tools/cloudflare/CloudflareProgrammaticToolCalling.d.ts +1 -1
  651. package/dist/types/tools/cloudflare/CloudflareSandboxExecutionEngine.d.ts +2 -2
  652. package/dist/types/tools/cloudflare/CloudflareSandboxTools.d.ts +1 -1
  653. package/dist/types/tools/eagerEventExecution.d.ts +1 -1
  654. package/dist/types/tools/handlers.d.ts +3 -3
  655. package/dist/types/tools/intentArg.d.ts +1 -1
  656. package/dist/types/tools/local/CompileCheckTool.d.ts +2 -2
  657. package/dist/types/tools/local/FileCheckpointer.d.ts +1 -1
  658. package/dist/types/tools/local/LocalCodingTools.d.ts +2 -2
  659. package/dist/types/tools/local/LocalExecutionEngine.d.ts +1 -1
  660. package/dist/types/tools/local/LocalExecutionTools.d.ts +1 -1
  661. package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +1 -1
  662. package/dist/types/tools/local/bashAst.d.ts +1 -1
  663. package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +1 -1
  664. package/dist/types/tools/local/syntaxCheck.d.ts +1 -1
  665. package/dist/types/tools/ptcTimeout.d.ts +1 -1
  666. package/dist/types/tools/schema.d.ts +1 -1
  667. package/dist/types/tools/search/anthropic.d.ts +1 -1
  668. package/dist/types/tools/skillCatalog.d.ts +1 -1
  669. package/dist/types/tools/subagent/SubagentExecutor.d.ts +5 -5
  670. package/dist/types/tools/subagent/SubagentReplay.d.ts +3 -3
  671. package/dist/types/tools/subagent/childGraphConfig.d.ts +2 -2
  672. package/dist/types/types/activityLabel.d.ts +3 -3
  673. package/dist/types/types/graph.d.ts +7 -7
  674. package/dist/types/types/llm.d.ts +9 -9
  675. package/dist/types/types/run.d.ts +7 -7
  676. package/dist/types/types/stream.d.ts +5 -5
  677. package/dist/types/types/summarize.d.ts +2 -2
  678. package/dist/types/types/tools.d.ts +5 -5
  679. package/dist/types/utils/__tests__/fixtures/contextOverflowSignatures.d.ts +1 -1
  680. package/dist/types/utils/errors.d.ts +1 -1
  681. package/dist/types/utils/events.d.ts +1 -1
  682. package/dist/types/utils/handlers.d.ts +3 -3
  683. package/dist/types/utils/llm.d.ts +1 -1
  684. package/dist/types/utils/llmConfig.d.ts +1 -1
  685. package/dist/types/utils/title.d.ts +1 -1
  686. package/package.json +10 -3
  687. package/src/llm/google/utils/common.ts +6 -4
  688. package/src/__tests__/stream.eagerArgsDivergence.test.ts +0 -911
  689. package/src/__tests__/stream.eagerEventExecution.test.ts +0 -5046
  690. package/src/__tests__/stream.streamLimits.test.ts +0 -1982
  691. package/src/agents/__tests__/AgentContext.anthropic.live.test.ts +0 -448
  692. package/src/agents/__tests__/AgentContext.bedrock.live.test.ts +0 -655
  693. package/src/agents/__tests__/AgentContext.openrouter.live.test.ts +0 -128
  694. package/src/agents/__tests__/AgentContext.overflow.test.ts +0 -205
  695. package/src/agents/__tests__/AgentContext.test.ts +0 -2425
  696. package/src/agents/__tests__/projection.test.ts +0 -73
  697. package/src/agents/__tests__/promptCacheLiveHelpers.ts +0 -175
  698. package/src/aggregator.test.ts +0 -1214
  699. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +0 -273
  700. package/src/graphs/__tests__/Graph.closeRunStep.test.ts +0 -385
  701. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +0 -1766
  702. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +0 -126
  703. package/src/graphs/__tests__/Graph.reasoning.test.ts +0 -1717
  704. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +0 -80
  705. package/src/graphs/__tests__/MultiAgentGraph.test.ts +0 -177
  706. package/src/graphs/__tests__/composition.smoke.test.ts +0 -509
  707. package/src/graphs/__tests__/createGraph.test.ts +0 -206
  708. package/src/hooks/__tests__/HookRegistry.test.ts +0 -334
  709. package/src/hooks/__tests__/compactHooks.test.ts +0 -250
  710. package/src/hooks/__tests__/createToolPolicyHook.test.ts +0 -268
  711. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +0 -405
  712. package/src/hooks/__tests__/executeHooks.test.ts +0 -1294
  713. package/src/hooks/__tests__/integration.test.ts +0 -337
  714. package/src/hooks/__tests__/matchers.test.ts +0 -238
  715. package/src/hooks/__tests__/preemptBoundary.test.ts +0 -152
  716. package/src/hooks/__tests__/toolHooks.test.ts +0 -669
  717. package/src/llm/__tests__/contextOverflowRecovery.test.ts +0 -401
  718. package/src/llm/__tests__/fallbackOverflow.test.ts +0 -287
  719. package/src/llm/anthropic/Jacob_Lee_Resume_2023.pdf +0 -0
  720. package/src/llm/anthropic/inherited-content-utils.spec.ts +0 -249
  721. package/src/llm/anthropic/inherited-stream-events.spec.ts +0 -1259
  722. package/src/llm/anthropic/inherited-strict.spec.ts +0 -302
  723. package/src/llm/anthropic/llm.spec.ts +0 -3394
  724. package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +0 -383
  725. package/src/llm/anthropic/utils/cross-provider-server-tools.test.ts +0 -110
  726. package/src/llm/anthropic/utils/server-tool-inputs.test.ts +0 -508
  727. package/src/llm/anthropic/utils/streaming-tool-input.test.ts +0 -361
  728. package/src/llm/anthropic/utils/stripPrefillCache.test.ts +0 -111
  729. package/src/llm/anthropic/utils/tool-id-normalization.test.ts +0 -287
  730. package/src/llm/bedrock/inherited-cache.spec.ts +0 -144
  731. package/src/llm/bedrock/inherited.spec.ts +0 -724
  732. package/src/llm/bedrock/llm.spec.ts +0 -1533
  733. package/src/llm/bedrock/streamSealDispatch.test.ts +0 -331
  734. package/src/llm/bedrock/toolCache.test.ts +0 -194
  735. package/src/llm/bedrock/utils/cross-provider-reasoning.test.ts +0 -131
  736. package/src/llm/bedrock/utils/cross-provider-server-tools.test.ts +0 -122
  737. package/src/llm/bedrock/utils/message_inputs.test.ts +0 -650
  738. package/src/llm/bedrock/utils/message_outputs.test.ts +0 -85
  739. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +0 -118
  740. package/src/llm/custom-chat-models.smoke.test.ts +0 -1966
  741. package/src/llm/google/data/gettysburg10.wav +0 -0
  742. package/src/llm/google/data/hotdog.jpg +0 -0
  743. package/src/llm/google/inherited-stream-events.spec.ts +0 -350
  744. package/src/llm/google/llm.spec.ts +0 -1388
  745. package/src/llm/google/streamSmoothing.test.ts +0 -121
  746. package/src/llm/google/utils/common.test.ts +0 -185
  747. package/src/llm/invoke.alternation.test.ts +0 -87
  748. package/src/llm/invoke.handoffCue.test.ts +0 -112
  749. package/src/llm/invoke.streamLimits.test.ts +0 -142
  750. package/src/llm/invoke.test.ts +0 -1198
  751. package/src/llm/mistral/streamSmoothing.test.ts +0 -97
  752. package/src/llm/openai/cacheWriteTokens.test.ts +0 -112
  753. package/src/llm/openai/contentBlocks.test.ts +0 -381
  754. package/src/llm/openai/deepseek.test.ts +0 -747
  755. package/src/llm/openai/inherited-deepseek.spec.ts +0 -347
  756. package/src/llm/openai/inherited-xai.spec.ts +0 -416
  757. package/src/llm/openai/llm.spec.ts +0 -1774
  758. package/src/llm/openai/managedRequests.test.ts +0 -182
  759. package/src/llm/openai/sequentialToolCallSeals.test.ts +0 -199
  760. package/src/llm/openai/streamMetadata.spec.ts +0 -86
  761. package/src/llm/openai/streamMetadataDedup.spec.ts +0 -166
  762. package/src/llm/openai/utils/isReasoningModel.test.ts +0 -90
  763. package/src/llm/openai/utils/messages.test.ts +0 -488
  764. package/src/llm/openrouter/reasoning.test.ts +0 -561
  765. package/src/llm/openrouter/toolCache.test.ts +0 -134
  766. package/src/llm/preempt.test.ts +0 -323
  767. package/src/llm/stream/chunkAdapters.test.ts +0 -202
  768. package/src/llm/stream/reassembly.test.ts +0 -241
  769. package/src/llm/stream/smoother.bench.test.ts +0 -155
  770. package/src/llm/stream/smoother.test.ts +0 -519
  771. package/src/llm/streamLimits.test.ts +0 -450
  772. package/src/llm/truncation.test.ts +0 -242
  773. package/src/llm/vertexai/fixThoughtSignatures.test.ts +0 -154
  774. package/src/llm/vertexai/inherited-stream-events.spec.ts +0 -271
  775. package/src/llm/vertexai/llm.spec.ts +0 -159
  776. package/src/llm/vertexai/repairUsageMetadata.test.ts +0 -54
  777. package/src/llm/vertexai/sealStreamedToolCalls.test.ts +0 -88
  778. package/src/llm/vertexai/streamSealDispatch.test.ts +0 -148
  779. package/src/llm/vertexai/streamSmoothing.test.ts +0 -109
  780. package/src/messages/__tests__/anthropicToolCache.test.ts +0 -339
  781. package/src/messages/__tests__/observationMasking.test.ts +0 -312
  782. package/src/messages/__tests__/recency.test.ts +0 -267
  783. package/src/messages/__tests__/tools.test.ts +0 -473
  784. package/src/messages/alternation.test.ts +0 -212
  785. package/src/messages/assistantPhase.test.ts +0 -75
  786. package/src/messages/cache.tail.test.ts +0 -533
  787. package/src/messages/cache.test.ts +0 -2166
  788. package/src/messages/content.test.ts +0 -362
  789. package/src/messages/contextPruning.test.ts +0 -184
  790. package/src/messages/ensureThinkingBlock.test.ts +0 -1427
  791. package/src/messages/foldToollessToolBlocks.test.ts +0 -596
  792. package/src/messages/formatAgentMessages.reducer.test.ts +0 -162
  793. package/src/messages/formatAgentMessages.skills.test.ts +0 -513
  794. package/src/messages/formatAgentMessages.steer.test.ts +0 -598
  795. package/src/messages/formatAgentMessages.test.ts +0 -8111
  796. package/src/messages/formatAgentMessages.tools.test.ts +0 -522
  797. package/src/messages/formatMessage.test.ts +0 -693
  798. package/src/messages/handoffCue.test.ts +0 -96
  799. package/src/messages/injected.test.ts +0 -107
  800. package/src/messages/labelContentByAgent.test.ts +0 -962
  801. package/src/messages/reducer.spec.ts +0 -32
  802. package/src/messages/shiftIndexTokenCountMap.test.ts +0 -81
  803. package/src/messages/tailCacheConversion.test.ts +0 -161
  804. package/src/openai/__tests__/openai.test.ts +0 -337
  805. package/src/responses/__tests__/responses.test.ts +0 -652
  806. package/src/scripts/abort.ts +0 -157
  807. package/src/scripts/activity-labels/captured.json +0 -56
  808. package/src/scripts/activity-labels/checks.cjs +0 -205
  809. package/src/scripts/activity-labels/corpus.cjs +0 -473
  810. package/src/scripts/activity-labels/report.cjs +0 -203
  811. package/src/scripts/activity-labels/rescore.cjs +0 -102
  812. package/src/scripts/activity-labels/run.ts +0 -705
  813. package/src/scripts/activity-labels/variants.ts +0 -71
  814. package/src/scripts/ant_web_search.ts +0 -159
  815. package/src/scripts/ant_web_search_edge_case.ts +0 -163
  816. package/src/scripts/ant_web_search_error_edge_case.ts +0 -149
  817. package/src/scripts/args.ts +0 -48
  818. package/src/scripts/bedrock-cache-debug.ts +0 -250
  819. package/src/scripts/bedrock-content-aggregation-test.ts +0 -266
  820. package/src/scripts/bedrock-merge-test.ts +0 -107
  821. package/src/scripts/bedrock-parallel-tools-test.ts +0 -204
  822. package/src/scripts/bench-prompt-cache.ts +0 -479
  823. package/src/scripts/caching.ts +0 -132
  824. package/src/scripts/cli.ts +0 -172
  825. package/src/scripts/cli2.ts +0 -133
  826. package/src/scripts/cli3.ts +0 -184
  827. package/src/scripts/cli4.ts +0 -191
  828. package/src/scripts/cli5.ts +0 -191
  829. package/src/scripts/code_exec.ts +0 -214
  830. package/src/scripts/code_exec_files.ts +0 -237
  831. package/src/scripts/code_exec_multi_session.ts +0 -237
  832. package/src/scripts/code_exec_ptc.ts +0 -335
  833. package/src/scripts/code_exec_session.ts +0 -283
  834. package/src/scripts/code_exec_simple.ts +0 -148
  835. package/src/scripts/compare_pi_vs_ours.ts +0 -1259
  836. package/src/scripts/content.ts +0 -139
  837. package/src/scripts/context-overflow-probe.ts +0 -997
  838. package/src/scripts/empty_input.ts +0 -137
  839. package/src/scripts/handoff-test.ts +0 -134
  840. package/src/scripts/local_engine.ts +0 -166
  841. package/src/scripts/local_engine_checkpointer.ts +0 -205
  842. package/src/scripts/local_engine_compile.ts +0 -263
  843. package/src/scripts/local_engine_hooks.ts +0 -226
  844. package/src/scripts/local_engine_image.ts +0 -201
  845. package/src/scripts/local_engine_ptc.ts +0 -151
  846. package/src/scripts/local_engine_workspace.ts +0 -258
  847. package/src/scripts/memory.ts +0 -107
  848. package/src/scripts/multi-agent-chain.ts +0 -332
  849. package/src/scripts/multi-agent-conditional.ts +0 -222
  850. package/src/scripts/multi-agent-document-review-chain.ts +0 -198
  851. package/src/scripts/multi-agent-hybrid-flow.ts +0 -311
  852. package/src/scripts/multi-agent-parallel-start.ts +0 -265
  853. package/src/scripts/multi-agent-parallel.ts +0 -395
  854. package/src/scripts/multi-agent-sequence.ts +0 -217
  855. package/src/scripts/multi-agent-subagent.ts +0 -246
  856. package/src/scripts/multi-agent-supervisor.ts +0 -366
  857. package/src/scripts/multi-agent-test.ts +0 -187
  858. package/src/scripts/openrouter_prompt_cache_live.ts +0 -310
  859. package/src/scripts/parallel-asymmetric-tools-test.ts +0 -275
  860. package/src/scripts/parallel-full-metadata-test.ts +0 -241
  861. package/src/scripts/parallel-tools-test.ts +0 -341
  862. package/src/scripts/preempt-probe.ts +0 -330
  863. package/src/scripts/preempt-scenarios.ts +0 -388
  864. package/src/scripts/programmatic_exec.ts +0 -387
  865. package/src/scripts/programmatic_exec_agent.ts +0 -232
  866. package/src/scripts/search.ts +0 -147
  867. package/src/scripts/sequential-full-metadata-test.ts +0 -198
  868. package/src/scripts/session_live.ts +0 -548
  869. package/src/scripts/simple.ts +0 -226
  870. package/src/scripts/single-agent-metadata-test.ts +0 -196
  871. package/src/scripts/stream.ts +0 -141
  872. package/src/scripts/subagent-configurable-inheritance.ts +0 -252
  873. package/src/scripts/subagent-event-driven-debug.ts +0 -190
  874. package/src/scripts/subagent-tools-debug.ts +0 -160
  875. package/src/scripts/subagent-usage-sink.ts +0 -176
  876. package/src/scripts/summarization-recency.ts +0 -462
  877. package/src/scripts/test-custom-prompt-key.ts +0 -145
  878. package/src/scripts/test-handoff-input.ts +0 -170
  879. package/src/scripts/test-handoff-preamble.ts +0 -278
  880. package/src/scripts/test-handoff-steering.ts +0 -430
  881. package/src/scripts/test-multi-agent-list-handoff.ts +0 -418
  882. package/src/scripts/test-parallel-agent-labeling.ts +0 -327
  883. package/src/scripts/test-parallel-handoffs.ts +0 -292
  884. package/src/scripts/test-thinking-handoff-bedrock.ts +0 -154
  885. package/src/scripts/test-thinking-handoff.ts +0 -156
  886. package/src/scripts/test-thinking-to-thinking-handoff-bedrock.ts +0 -166
  887. package/src/scripts/test-tool-before-handoff-role-order.ts +0 -276
  888. package/src/scripts/test-tools-before-handoff.ts +0 -222
  889. package/src/scripts/test_code_api.ts +0 -354
  890. package/src/scripts/thinking-bedrock.ts +0 -160
  891. package/src/scripts/thinking-vertexai.ts +0 -168
  892. package/src/scripts/thinking.ts +0 -172
  893. package/src/scripts/tool_search.ts +0 -153
  894. package/src/scripts/tools.ts +0 -163
  895. package/src/session/__tests__/JsonlSessionStore.test.ts +0 -1463
  896. package/src/session/__tests__/handlers.test.ts +0 -161
  897. package/src/specs/activity-label-observability.live.test.ts +0 -262
  898. package/src/specs/activity-label-observability.test.ts +0 -167
  899. package/src/specs/activity-label-prompt.test.ts +0 -359
  900. package/src/specs/activity-label-trace-seed.test.ts +0 -47
  901. package/src/specs/activity-phase-label.test.ts +0 -306
  902. package/src/specs/agent-handoffs.live.test.ts +0 -140
  903. package/src/specs/agent-handoffs.test.ts +0 -2465
  904. package/src/specs/anthropic.simple.test.ts +0 -453
  905. package/src/specs/ask-user-question-batch.test.ts +0 -289
  906. package/src/specs/ask-user-questions.live.test.ts +0 -185
  907. package/src/specs/ask-user-questions.test.ts +0 -293
  908. package/src/specs/azure.simple.test.ts +0 -502
  909. package/src/specs/bedrock-toolless.live.test.ts +0 -123
  910. package/src/specs/bedrock-truncation.live.test.ts +0 -191
  911. package/src/specs/cache.simple.test.ts +0 -416
  912. package/src/specs/context-accuracy.live.test.ts +0 -409
  913. package/src/specs/context-overflow-recovery.live.test.ts +0 -213
  914. package/src/specs/context-usage-event.test.ts +0 -117
  915. package/src/specs/context-usage.live.test.ts +0 -297
  916. package/src/specs/custom-event-await.test.ts +0 -304
  917. package/src/specs/deepseek.simple.test.ts +0 -290
  918. package/src/specs/deterministic-trace-id.test.ts +0 -43
  919. package/src/specs/discovered-tools.test.ts +0 -217
  920. package/src/specs/durability-checkpoint.integration.test.ts +0 -243
  921. package/src/specs/graph-subagent.live.test.ts +0 -242
  922. package/src/specs/graph-subagent.test.ts +0 -322
  923. package/src/specs/handoffCue.test.ts +0 -165
  924. package/src/specs/langfuse-callbacks.test.ts +0 -1578
  925. package/src/specs/langfuse-config.test.ts +0 -247
  926. package/src/specs/langfuse-instrumentation.test.ts +0 -393
  927. package/src/specs/langfuse-metadata.test.ts +0 -245
  928. package/src/specs/langfuse-routing.integration.test.ts +0 -844
  929. package/src/specs/langfuse-runtime-context.test.ts +0 -92
  930. package/src/specs/langfuse-span-registry.test.ts +0 -87
  931. package/src/specs/langfuse-tool-output-tracing.test.ts +0 -1858
  932. package/src/specs/langfuse-trace-shaping.test.ts +0 -591
  933. package/src/specs/moonshot.simple.test.ts +0 -367
  934. package/src/specs/multi-agent-summarization.test.ts +0 -396
  935. package/src/specs/openai.simple.test.ts +0 -315
  936. package/src/specs/openrouter.simple.test.ts +0 -275
  937. package/src/specs/preemptSeal.test.ts +0 -678
  938. package/src/specs/prune.test.ts +0 -3741
  939. package/src/specs/reasoning.test.ts +0 -201
  940. package/src/specs/run-step-timestamps.test.ts +0 -414
  941. package/src/specs/spec.utils.ts +0 -15
  942. package/src/specs/subagent.test.ts +0 -864
  943. package/src/specs/summarization-unit.test.ts +0 -973
  944. package/src/specs/summarization.test.ts +0 -3823
  945. package/src/specs/summarize-prune.test.ts +0 -376
  946. package/src/specs/thinking-handoff.test.ts +0 -621
  947. package/src/specs/thinking-prune.test.ts +0 -827
  948. package/src/specs/token-accounting-e2e.test.ts +0 -1034
  949. package/src/specs/token-accounting-pipeline.test.ts +0 -1008
  950. package/src/specs/token-distribution-edge-case.test.ts +0 -315
  951. package/src/specs/token-memoization.test.ts +0 -40
  952. package/src/specs/tokens.test.ts +0 -1000
  953. package/src/specs/tool-error-resume.test.ts +0 -194
  954. package/src/specs/tool-error.test.ts +0 -198
  955. package/src/specs/vllm-reasoning-toolcalls.test.ts +0 -340
  956. package/src/stream.dispatch.test.ts +0 -63
  957. package/src/stream.test.ts +0 -144
  958. package/src/summarization/__tests__/aggregator.test.ts +0 -236
  959. package/src/summarization/__tests__/node.test.ts +0 -1996
  960. package/src/summarization/__tests__/trigger.test.ts +0 -149
  961. package/src/summarization/chunkHandler.test.ts +0 -196
  962. package/src/test/mockTools.ts +0 -386
  963. package/src/tools/Calculator.test.ts +0 -278
  964. package/src/tools/__tests__/BashExecutor.test.ts +0 -108
  965. package/src/tools/__tests__/CloudflareSandboxExecution.test.ts +0 -939
  966. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +0 -858
  967. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +0 -124
  968. package/src/tools/__tests__/LocalExecutionRoots.test.ts +0 -8
  969. package/src/tools/__tests__/LocalExecutionTools.test.ts +0 -2733
  970. package/src/tools/__tests__/ProgrammaticToolCalling.integration.test.ts +0 -318
  971. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +0 -1557
  972. package/src/tools/__tests__/ReadFile.test.ts +0 -44
  973. package/src/tools/__tests__/SkillTool.test.ts +0 -442
  974. package/src/tools/__tests__/SubagentExecutor.test.ts +0 -3728
  975. package/src/tools/__tests__/SubagentReplay.test.ts +0 -329
  976. package/src/tools/__tests__/SubagentTool.test.ts +0 -149
  977. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +0 -595
  978. package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +0 -1191
  979. package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +0 -757
  980. package/src/tools/__tests__/ToolNode.langfuse.test.ts +0 -57
  981. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +0 -736
  982. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +0 -1710
  983. package/src/tools/__tests__/ToolNode.runtimeState.test.ts +0 -120
  984. package/src/tools/__tests__/ToolNode.session.test.ts +0 -1352
  985. package/src/tools/__tests__/ToolNode.streamLimits.test.ts +0 -69
  986. package/src/tools/__tests__/ToolSearch.integration.test.ts +0 -161
  987. package/src/tools/__tests__/ToolSearch.test.ts +0 -1227
  988. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +0 -561
  989. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +0 -980
  990. package/src/tools/__tests__/directToolHooks.test.ts +0 -700
  991. package/src/tools/__tests__/eagerEventExecution.session.test.ts +0 -92
  992. package/src/tools/__tests__/handlers.test.ts +0 -997
  993. package/src/tools/__tests__/hitl.test.ts +0 -4663
  994. package/src/tools/__tests__/intentArg.test.ts +0 -357
  995. package/src/tools/__tests__/intentCoverage.test.ts +0 -139
  996. package/src/tools/__tests__/localToolNames.test.ts +0 -75
  997. package/src/tools/__tests__/skillCatalog.test.ts +0 -161
  998. package/src/tools/__tests__/subagentHooks.test.ts +0 -1651
  999. package/src/tools/__tests__/toolOutputReferences.test.ts +0 -515
  1000. package/src/tools/__tests__/workspaceSeam.test.ts +0 -147
  1001. package/src/tools/local/__tests__/FileCheckpointer.test.ts +0 -162
  1002. package/src/tools/local/__tests__/editStrategies.test.ts +0 -134
  1003. package/src/tools/search/content.test.ts +0 -173
  1004. package/src/tools/search/crw.test.ts +0 -836
  1005. package/src/tools/search/format.test.ts +0 -242
  1006. package/src/tools/search/http-agent.test.ts +0 -133
  1007. package/src/tools/search/jina-reranker.test.ts +0 -306
  1008. package/src/tools/search/keenable-scraper.test.ts +0 -153
  1009. package/src/tools/search/keenable.test.ts +0 -183
  1010. package/src/tools/search/outcome.test.ts +0 -90
  1011. package/src/tools/search/output.md +0 -2775
  1012. package/src/tools/search/rag-api-reranker.test.ts +0 -891
  1013. package/src/tools/search/source-processing.test.ts +0 -516
  1014. package/src/tools/search/tavily.test.ts +0 -965
  1015. package/src/tools/search/test.html +0 -884
  1016. package/src/tools/search/test.md +0 -643
  1017. package/src/tools/subagent/__tests__/SubagentExecutionRegistry.test.ts +0 -637
  1018. package/src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts +0 -3054
  1019. package/src/tools/subagent/__tests__/childGraphConfig.test.ts +0 -521
  1020. package/src/utils/__tests__/apportion.test.ts +0 -32
  1021. package/src/utils/__tests__/errors.test.ts +0 -270
  1022. package/src/utils/__tests__/fixtures/contextOverflowSignatures.ts +0 -336
  1023. package/src/utils/__tests__/redactSecrets.test.ts +0 -56
  1024. package/src/utils/__tests__/truncation.test.ts +0 -66
  1025. package/src/utils/llm.test.ts +0 -18
  1026. package/src/utils/proxy.test.ts +0 -176
  1027. 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
- });