@librechat/agents 3.4.7 → 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 (1026) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +0 -356
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/agents/projection.cjs +0 -9
  4. package/dist/cjs/agents/projection.cjs.map +1 -1
  5. package/dist/cjs/common/constants.cjs +0 -26
  6. package/dist/cjs/common/constants.cjs.map +1 -1
  7. package/dist/cjs/common/enum.cjs +0 -85
  8. package/dist/cjs/common/enum.cjs.map +1 -1
  9. package/dist/cjs/events.cjs +0 -14
  10. package/dist/cjs/events.cjs.map +1 -1
  11. package/dist/cjs/graphs/Graph.cjs +1 -909
  12. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  13. package/dist/cjs/graphs/MultiAgentGraph.cjs +7 -272
  14. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  15. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs.map +1 -1
  16. package/dist/cjs/graphs/createGraph.cjs.map +1 -1
  17. package/dist/cjs/hitl/askUserQuestion.cjs +0 -53
  18. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
  19. package/dist/cjs/hitl/askUserQuestions.cjs +0 -20
  20. package/dist/cjs/hitl/askUserQuestions.cjs.map +1 -1
  21. package/dist/cjs/hitl/askUserQuestionsInterrupt.cjs +0 -6
  22. package/dist/cjs/hitl/askUserQuestionsInterrupt.cjs.map +1 -1
  23. package/dist/cjs/hooks/HookRegistry.cjs +0 -127
  24. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  25. package/dist/cjs/hooks/createToolPolicyHook.cjs +0 -38
  26. package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -1
  27. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +0 -77
  28. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -1
  29. package/dist/cjs/hooks/executeHooks.cjs +0 -79
  30. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  31. package/dist/cjs/hooks/index.cjs +0 -16
  32. package/dist/cjs/hooks/index.cjs.map +1 -1
  33. package/dist/cjs/hooks/matchers.cjs +0 -104
  34. package/dist/cjs/hooks/matchers.cjs.map +1 -1
  35. package/dist/cjs/hooks/types.cjs +0 -8
  36. package/dist/cjs/hooks/types.cjs.map +1 -1
  37. package/dist/cjs/instrumentation.cjs +0 -14
  38. package/dist/cjs/instrumentation.cjs.map +1 -1
  39. package/dist/cjs/langfuse.cjs +0 -75
  40. package/dist/cjs/langfuse.cjs.map +1 -1
  41. package/dist/cjs/langfuseConfig.cjs.map +1 -1
  42. package/dist/cjs/langfuseRuntimeContext.cjs +0 -13
  43. package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
  44. package/dist/cjs/langfuseRuntimeScope.cjs +0 -4
  45. package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
  46. package/dist/cjs/langfuseSpanRegistry.cjs +0 -28
  47. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -1
  48. package/dist/cjs/langfuseToolOutputTracing.cjs +0 -3
  49. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  50. package/dist/cjs/langfuseTraceShaping.cjs +0 -74
  51. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  52. package/dist/cjs/llm/anthropic/index.cjs +0 -3
  53. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  54. package/dist/cjs/llm/anthropic/types.cjs.map +1 -1
  55. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +1 -111
  56. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  57. package/dist/cjs/llm/anthropic/utils/message_outputs.cjs +0 -1
  58. package/dist/cjs/llm/anthropic/utils/message_outputs.cjs.map +1 -1
  59. package/dist/cjs/llm/anthropic/utils/stream_events.cjs +0 -8
  60. package/dist/cjs/llm/anthropic/utils/stream_events.cjs.map +1 -1
  61. package/dist/cjs/llm/anthropic/utils/tools.cjs.map +1 -1
  62. package/dist/cjs/llm/bedrock/cachePoints.cjs.map +1 -1
  63. package/dist/cjs/llm/bedrock/index.cjs +1 -95
  64. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  65. package/dist/cjs/llm/bedrock/toolCache.cjs.map +1 -1
  66. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +0 -78
  67. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  68. package/dist/cjs/llm/bedrock/utils/message_outputs.cjs +0 -24
  69. package/dist/cjs/llm/bedrock/utils/message_outputs.cjs.map +1 -1
  70. package/dist/cjs/llm/contextOverflowRecovery.cjs +0 -62
  71. package/dist/cjs/llm/contextOverflowRecovery.cjs.map +1 -1
  72. package/dist/cjs/llm/fake.cjs.map +1 -1
  73. package/dist/cjs/llm/google/index.cjs +2 -21
  74. package/dist/cjs/llm/google/index.cjs.map +1 -1
  75. package/dist/cjs/llm/google/utils/common.cjs +0 -35
  76. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  77. package/dist/cjs/llm/init.cjs +0 -9
  78. package/dist/cjs/llm/init.cjs.map +1 -1
  79. package/dist/cjs/llm/invoke.cjs +1 -270
  80. package/dist/cjs/llm/invoke.cjs.map +1 -1
  81. package/dist/cjs/llm/mistral/index.cjs.map +1 -1
  82. package/dist/cjs/llm/openai/index.cjs +0 -99
  83. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  84. package/dist/cjs/llm/openai/streamMetadata.cjs +0 -21
  85. package/dist/cjs/llm/openai/streamMetadata.cjs.map +1 -1
  86. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  87. package/dist/cjs/llm/openrouter/index.cjs +0 -1
  88. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  89. package/dist/cjs/llm/openrouter/toolCache.cjs +0 -6
  90. package/dist/cjs/llm/openrouter/toolCache.cjs.map +1 -1
  91. package/dist/cjs/llm/preempt.cjs +0 -75
  92. package/dist/cjs/llm/preempt.cjs.map +1 -1
  93. package/dist/cjs/llm/providers.cjs.map +1 -1
  94. package/dist/cjs/llm/request.cjs +0 -10
  95. package/dist/cjs/llm/request.cjs.map +1 -1
  96. package/dist/cjs/llm/stream/chunkAdapters.cjs +0 -62
  97. package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -1
  98. package/dist/cjs/llm/stream/smoother.cjs +0 -58
  99. package/dist/cjs/llm/stream/smoother.cjs.map +1 -1
  100. package/dist/cjs/llm/streamLimits.cjs +4 -293
  101. package/dist/cjs/llm/streamLimits.cjs.map +1 -1
  102. package/dist/cjs/llm/truncation.cjs +0 -36
  103. package/dist/cjs/llm/truncation.cjs.map +1 -1
  104. package/dist/cjs/llm/vertexai/index.cjs +0 -330
  105. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  106. package/dist/cjs/messages/alternation.cjs +0 -43
  107. package/dist/cjs/messages/alternation.cjs.map +1 -1
  108. package/dist/cjs/messages/anthropicToolCache.cjs +0 -45
  109. package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -1
  110. package/dist/cjs/messages/assistantPhase.cjs +0 -12
  111. package/dist/cjs/messages/assistantPhase.cjs.map +1 -1
  112. package/dist/cjs/messages/budget.cjs +0 -9
  113. package/dist/cjs/messages/budget.cjs.map +1 -1
  114. package/dist/cjs/messages/cache.cjs +0 -172
  115. package/dist/cjs/messages/cache.cjs.map +1 -1
  116. package/dist/cjs/messages/content.cjs +0 -9
  117. package/dist/cjs/messages/content.cjs.map +1 -1
  118. package/dist/cjs/messages/contextPruning.cjs +0 -15
  119. package/dist/cjs/messages/contextPruning.cjs.map +1 -1
  120. package/dist/cjs/messages/contextPruningSettings.cjs +0 -3
  121. package/dist/cjs/messages/contextPruningSettings.cjs.map +1 -1
  122. package/dist/cjs/messages/core.cjs +0 -48
  123. package/dist/cjs/messages/core.cjs.map +1 -1
  124. package/dist/cjs/messages/format.cjs +1 -345
  125. package/dist/cjs/messages/format.cjs.map +1 -1
  126. package/dist/cjs/messages/handoffCue.cjs +0 -36
  127. package/dist/cjs/messages/handoffCue.cjs.map +1 -1
  128. package/dist/cjs/messages/ids.cjs.map +1 -1
  129. package/dist/cjs/messages/injected.cjs +0 -33
  130. package/dist/cjs/messages/injected.cjs.map +1 -1
  131. package/dist/cjs/messages/langchain.cjs +0 -14
  132. package/dist/cjs/messages/langchain.cjs.map +1 -1
  133. package/dist/cjs/messages/prune.cjs +1 -184
  134. package/dist/cjs/messages/prune.cjs.map +1 -1
  135. package/dist/cjs/messages/recency.cjs +0 -32
  136. package/dist/cjs/messages/recency.cjs.map +1 -1
  137. package/dist/cjs/messages/reducer.cjs +0 -30
  138. package/dist/cjs/messages/reducer.cjs.map +1 -1
  139. package/dist/cjs/messages/tools.cjs +0 -14
  140. package/dist/cjs/messages/tools.cjs.map +1 -1
  141. package/dist/cjs/openai/index.cjs.map +1 -1
  142. package/dist/cjs/prompts/activityLabel.cjs +9 -79
  143. package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
  144. package/dist/cjs/responses/index.cjs.map +1 -1
  145. package/dist/cjs/run.cjs +2 -538
  146. package/dist/cjs/run.cjs.map +1 -1
  147. package/dist/cjs/session/AgentSession.cjs +1 -17
  148. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  149. package/dist/cjs/session/JsonlSessionStore.cjs.map +1 -1
  150. package/dist/cjs/session/handlers.cjs +0 -7
  151. package/dist/cjs/session/handlers.cjs.map +1 -1
  152. package/dist/cjs/session/ids.cjs.map +1 -1
  153. package/dist/cjs/session/messageSerialization.cjs +0 -4
  154. package/dist/cjs/session/messageSerialization.cjs.map +1 -1
  155. package/dist/cjs/stream.cjs +1 -93
  156. package/dist/cjs/stream.cjs.map +1 -1
  157. package/dist/cjs/summarization/index.cjs +0 -27
  158. package/dist/cjs/summarization/index.cjs.map +1 -1
  159. package/dist/cjs/summarization/node.cjs +0 -273
  160. package/dist/cjs/summarization/node.cjs.map +1 -1
  161. package/dist/cjs/tools/BashExecutor.cjs +0 -35
  162. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  163. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +0 -24
  164. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  165. package/dist/cjs/tools/Calculator.cjs.map +1 -1
  166. package/dist/cjs/tools/CodeExecutor.cjs +0 -13
  167. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  168. package/dist/cjs/tools/CodeSessionFileSummary.cjs.map +1 -1
  169. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +0 -101
  170. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  171. package/dist/cjs/tools/ReadFile.cjs.map +1 -1
  172. package/dist/cjs/tools/SkillTool.cjs +0 -5
  173. package/dist/cjs/tools/SkillTool.cjs.map +1 -1
  174. package/dist/cjs/tools/SubagentTool.cjs +0 -10
  175. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  176. package/dist/cjs/tools/ToolNode.cjs +6 -1038
  177. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  178. package/dist/cjs/tools/ToolSearch.cjs +0 -179
  179. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  180. package/dist/cjs/tools/cloudflare/CloudflareBridgeRuntime.cjs.map +1 -1
  181. package/dist/cjs/tools/cloudflare/CloudflareProgrammaticToolCalling.cjs.map +1 -1
  182. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs +0 -39
  183. package/dist/cjs/tools/cloudflare/CloudflareSandboxExecutionEngine.cjs.map +1 -1
  184. package/dist/cjs/tools/cloudflare/CloudflareSandboxTools.cjs.map +1 -1
  185. package/dist/cjs/tools/eagerEventExecution.cjs +0 -6
  186. package/dist/cjs/tools/eagerEventExecution.cjs.map +1 -1
  187. package/dist/cjs/tools/handlers.cjs +1 -41
  188. package/dist/cjs/tools/handlers.cjs.map +1 -1
  189. package/dist/cjs/tools/intentArg.cjs +1 -132
  190. package/dist/cjs/tools/intentArg.cjs.map +1 -1
  191. package/dist/cjs/tools/local/CompileCheckTool.cjs +0 -25
  192. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
  193. package/dist/cjs/tools/local/FileCheckpointer.cjs +0 -23
  194. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -1
  195. package/dist/cjs/tools/local/LocalCodingTools.cjs +0 -80
  196. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
  197. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +0 -126
  198. package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -1
  199. package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -1
  200. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +0 -12
  201. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -1
  202. package/dist/cjs/tools/local/attachments.cjs +0 -44
  203. package/dist/cjs/tools/local/attachments.cjs.map +1 -1
  204. package/dist/cjs/tools/local/bashAst.cjs +0 -16
  205. package/dist/cjs/tools/local/bashAst.cjs.map +1 -1
  206. package/dist/cjs/tools/local/editStrategies.cjs +0 -14
  207. package/dist/cjs/tools/local/editStrategies.cjs.map +1 -1
  208. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -1
  209. package/dist/cjs/tools/local/syntaxCheck.cjs +0 -31
  210. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -1
  211. package/dist/cjs/tools/local/textEncoding.cjs +0 -11
  212. package/dist/cjs/tools/local/textEncoding.cjs.map +1 -1
  213. package/dist/cjs/tools/local/workspaceFS.cjs +0 -33
  214. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -1
  215. package/dist/cjs/tools/ptcTimeout.cjs.map +1 -1
  216. package/dist/cjs/tools/schema.cjs +0 -8
  217. package/dist/cjs/tools/schema.cjs.map +1 -1
  218. package/dist/cjs/tools/search/anthropic.cjs +0 -9
  219. package/dist/cjs/tools/search/anthropic.cjs.map +1 -1
  220. package/dist/cjs/tools/search/content.cjs.map +1 -1
  221. package/dist/cjs/tools/search/crw-scraper.cjs +0 -33
  222. package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -1
  223. package/dist/cjs/tools/search/crw-search.cjs +0 -2
  224. package/dist/cjs/tools/search/crw-search.cjs.map +1 -1
  225. package/dist/cjs/tools/search/firecrawl.cjs +0 -25
  226. package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
  227. package/dist/cjs/tools/search/format.cjs +0 -30
  228. package/dist/cjs/tools/search/format.cjs.map +1 -1
  229. package/dist/cjs/tools/search/highlights.cjs +0 -16
  230. package/dist/cjs/tools/search/highlights.cjs.map +1 -1
  231. package/dist/cjs/tools/search/keenable-scraper.cjs +0 -8
  232. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -1
  233. package/dist/cjs/tools/search/keenable-search.cjs +0 -7
  234. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -1
  235. package/dist/cjs/tools/search/rerankers.cjs +0 -35
  236. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  237. package/dist/cjs/tools/search/schema.cjs +0 -1
  238. package/dist/cjs/tools/search/schema.cjs.map +1 -1
  239. package/dist/cjs/tools/search/search.cjs +1 -14
  240. package/dist/cjs/tools/search/search.cjs.map +1 -1
  241. package/dist/cjs/tools/search/serper-scraper.cjs +0 -46
  242. package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
  243. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  244. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  245. package/dist/cjs/tools/search/tool.cjs +0 -25
  246. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  247. package/dist/cjs/tools/search/utils.cjs +0 -8
  248. package/dist/cjs/tools/search/utils.cjs.map +1 -1
  249. package/dist/cjs/tools/skillCatalog.cjs +0 -6
  250. package/dist/cjs/tools/skillCatalog.cjs.map +1 -1
  251. package/dist/cjs/tools/streamedToolCallSeals.cjs +0 -6
  252. package/dist/cjs/tools/streamedToolCallSeals.cjs.map +1 -1
  253. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs +0 -2
  254. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs.map +1 -1
  255. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +3 -213
  256. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  257. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
  258. package/dist/cjs/tools/subagent/childGraphConfig.cjs +0 -4
  259. package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -1
  260. package/dist/cjs/tools/subagent/runtimeLimits.cjs.map +1 -1
  261. package/dist/cjs/tools/toolOutputReferences.cjs +1 -322
  262. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  263. package/dist/cjs/types/hitl.cjs +0 -5
  264. package/dist/cjs/types/hitl.cjs.map +1 -1
  265. package/dist/cjs/utils/callbacks.cjs.map +1 -1
  266. package/dist/cjs/utils/errors.cjs +14 -165
  267. package/dist/cjs/utils/errors.cjs.map +1 -1
  268. package/dist/cjs/utils/events.cjs +0 -10
  269. package/dist/cjs/utils/events.cjs.map +1 -1
  270. package/dist/cjs/utils/graph.cjs.map +1 -1
  271. package/dist/cjs/utils/handlers.cjs +0 -3
  272. package/dist/cjs/utils/handlers.cjs.map +1 -1
  273. package/dist/cjs/utils/llm.cjs +0 -1
  274. package/dist/cjs/utils/llm.cjs.map +1 -1
  275. package/dist/cjs/utils/misc.cjs +0 -16
  276. package/dist/cjs/utils/misc.cjs.map +1 -1
  277. package/dist/cjs/utils/proxy.cjs +0 -26
  278. package/dist/cjs/utils/proxy.cjs.map +1 -1
  279. package/dist/cjs/utils/run.cjs +0 -6
  280. package/dist/cjs/utils/run.cjs.map +1 -1
  281. package/dist/cjs/utils/schema.cjs +0 -5
  282. package/dist/cjs/utils/schema.cjs.map +1 -1
  283. package/dist/cjs/utils/title.cjs +0 -6
  284. package/dist/cjs/utils/title.cjs.map +1 -1
  285. package/dist/cjs/utils/tokens.cjs +0 -113
  286. package/dist/cjs/utils/tokens.cjs.map +1 -1
  287. package/dist/cjs/utils/toolContent.cjs +0 -41
  288. package/dist/cjs/utils/toolContent.cjs.map +1 -1
  289. package/dist/cjs/utils/truncation.cjs +0 -61
  290. package/dist/cjs/utils/truncation.cjs.map +1 -1
  291. package/dist/esm/agents/AgentContext.mjs +0 -356
  292. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  293. package/dist/esm/agents/projection.mjs +0 -9
  294. package/dist/esm/agents/projection.mjs.map +1 -1
  295. package/dist/esm/common/constants.mjs +0 -26
  296. package/dist/esm/common/constants.mjs.map +1 -1
  297. package/dist/esm/common/enum.mjs +0 -85
  298. package/dist/esm/common/enum.mjs.map +1 -1
  299. package/dist/esm/events.mjs +0 -14
  300. package/dist/esm/events.mjs.map +1 -1
  301. package/dist/esm/graphs/Graph.mjs +1 -909
  302. package/dist/esm/graphs/Graph.mjs.map +1 -1
  303. package/dist/esm/graphs/MultiAgentGraph.mjs +7 -272
  304. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  305. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs.map +1 -1
  306. package/dist/esm/graphs/createGraph.mjs.map +1 -1
  307. package/dist/esm/hitl/askUserQuestion.mjs +0 -53
  308. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
  309. package/dist/esm/hitl/askUserQuestions.mjs +0 -20
  310. package/dist/esm/hitl/askUserQuestions.mjs.map +1 -1
  311. package/dist/esm/hitl/askUserQuestionsInterrupt.mjs +0 -6
  312. package/dist/esm/hitl/askUserQuestionsInterrupt.mjs.map +1 -1
  313. package/dist/esm/hooks/HookRegistry.mjs +0 -127
  314. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  315. package/dist/esm/hooks/createToolPolicyHook.mjs +0 -38
  316. package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -1
  317. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +0 -77
  318. package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -1
  319. package/dist/esm/hooks/executeHooks.mjs +0 -79
  320. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  321. package/dist/esm/hooks/index.mjs +0 -16
  322. package/dist/esm/hooks/index.mjs.map +1 -1
  323. package/dist/esm/hooks/matchers.mjs +0 -104
  324. package/dist/esm/hooks/matchers.mjs.map +1 -1
  325. package/dist/esm/hooks/types.mjs +0 -8
  326. package/dist/esm/hooks/types.mjs.map +1 -1
  327. package/dist/esm/instrumentation.mjs +0 -14
  328. package/dist/esm/instrumentation.mjs.map +1 -1
  329. package/dist/esm/langfuse.mjs +0 -75
  330. package/dist/esm/langfuse.mjs.map +1 -1
  331. package/dist/esm/langfuseConfig.mjs.map +1 -1
  332. package/dist/esm/langfuseRuntimeContext.mjs +0 -13
  333. package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
  334. package/dist/esm/langfuseRuntimeScope.mjs +0 -4
  335. package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
  336. package/dist/esm/langfuseSpanRegistry.mjs +0 -28
  337. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -1
  338. package/dist/esm/langfuseToolOutputTracing.mjs +0 -3
  339. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  340. package/dist/esm/langfuseTraceShaping.mjs +0 -74
  341. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  342. package/dist/esm/llm/anthropic/index.mjs +0 -3
  343. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  344. package/dist/esm/llm/anthropic/types.mjs.map +1 -1
  345. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +1 -111
  346. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  347. package/dist/esm/llm/anthropic/utils/message_outputs.mjs +0 -1
  348. package/dist/esm/llm/anthropic/utils/message_outputs.mjs.map +1 -1
  349. package/dist/esm/llm/anthropic/utils/stream_events.mjs +0 -8
  350. package/dist/esm/llm/anthropic/utils/stream_events.mjs.map +1 -1
  351. package/dist/esm/llm/anthropic/utils/tools.mjs.map +1 -1
  352. package/dist/esm/llm/bedrock/cachePoints.mjs.map +1 -1
  353. package/dist/esm/llm/bedrock/index.mjs +1 -95
  354. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  355. package/dist/esm/llm/bedrock/toolCache.mjs.map +1 -1
  356. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +0 -78
  357. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  358. package/dist/esm/llm/bedrock/utils/message_outputs.mjs +0 -24
  359. package/dist/esm/llm/bedrock/utils/message_outputs.mjs.map +1 -1
  360. package/dist/esm/llm/contextOverflowRecovery.mjs +0 -62
  361. package/dist/esm/llm/contextOverflowRecovery.mjs.map +1 -1
  362. package/dist/esm/llm/fake.mjs.map +1 -1
  363. package/dist/esm/llm/google/index.mjs +2 -21
  364. package/dist/esm/llm/google/index.mjs.map +1 -1
  365. package/dist/esm/llm/google/utils/common.mjs +0 -35
  366. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  367. package/dist/esm/llm/init.mjs +0 -9
  368. package/dist/esm/llm/init.mjs.map +1 -1
  369. package/dist/esm/llm/invoke.mjs +1 -270
  370. package/dist/esm/llm/invoke.mjs.map +1 -1
  371. package/dist/esm/llm/mistral/index.mjs.map +1 -1
  372. package/dist/esm/llm/openai/index.mjs +0 -99
  373. package/dist/esm/llm/openai/index.mjs.map +1 -1
  374. package/dist/esm/llm/openai/streamMetadata.mjs +0 -21
  375. package/dist/esm/llm/openai/streamMetadata.mjs.map +1 -1
  376. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  377. package/dist/esm/llm/openrouter/index.mjs +0 -1
  378. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  379. package/dist/esm/llm/openrouter/toolCache.mjs +0 -6
  380. package/dist/esm/llm/openrouter/toolCache.mjs.map +1 -1
  381. package/dist/esm/llm/preempt.mjs +0 -75
  382. package/dist/esm/llm/preempt.mjs.map +1 -1
  383. package/dist/esm/llm/providers.mjs.map +1 -1
  384. package/dist/esm/llm/request.mjs +0 -10
  385. package/dist/esm/llm/request.mjs.map +1 -1
  386. package/dist/esm/llm/stream/chunkAdapters.mjs +0 -62
  387. package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -1
  388. package/dist/esm/llm/stream/smoother.mjs +0 -58
  389. package/dist/esm/llm/stream/smoother.mjs.map +1 -1
  390. package/dist/esm/llm/streamLimits.mjs +4 -293
  391. package/dist/esm/llm/streamLimits.mjs.map +1 -1
  392. package/dist/esm/llm/truncation.mjs +0 -36
  393. package/dist/esm/llm/truncation.mjs.map +1 -1
  394. package/dist/esm/llm/vertexai/index.mjs +0 -330
  395. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  396. package/dist/esm/messages/alternation.mjs +0 -43
  397. package/dist/esm/messages/alternation.mjs.map +1 -1
  398. package/dist/esm/messages/anthropicToolCache.mjs +0 -45
  399. package/dist/esm/messages/anthropicToolCache.mjs.map +1 -1
  400. package/dist/esm/messages/assistantPhase.mjs +0 -12
  401. package/dist/esm/messages/assistantPhase.mjs.map +1 -1
  402. package/dist/esm/messages/budget.mjs +0 -9
  403. package/dist/esm/messages/budget.mjs.map +1 -1
  404. package/dist/esm/messages/cache.mjs +0 -172
  405. package/dist/esm/messages/cache.mjs.map +1 -1
  406. package/dist/esm/messages/content.mjs +0 -9
  407. package/dist/esm/messages/content.mjs.map +1 -1
  408. package/dist/esm/messages/contextPruning.mjs +0 -15
  409. package/dist/esm/messages/contextPruning.mjs.map +1 -1
  410. package/dist/esm/messages/contextPruningSettings.mjs +0 -3
  411. package/dist/esm/messages/contextPruningSettings.mjs.map +1 -1
  412. package/dist/esm/messages/core.mjs +0 -48
  413. package/dist/esm/messages/core.mjs.map +1 -1
  414. package/dist/esm/messages/format.mjs +1 -345
  415. package/dist/esm/messages/format.mjs.map +1 -1
  416. package/dist/esm/messages/handoffCue.mjs +0 -36
  417. package/dist/esm/messages/handoffCue.mjs.map +1 -1
  418. package/dist/esm/messages/ids.mjs.map +1 -1
  419. package/dist/esm/messages/injected.mjs +0 -33
  420. package/dist/esm/messages/injected.mjs.map +1 -1
  421. package/dist/esm/messages/langchain.mjs +0 -14
  422. package/dist/esm/messages/langchain.mjs.map +1 -1
  423. package/dist/esm/messages/prune.mjs +1 -184
  424. package/dist/esm/messages/prune.mjs.map +1 -1
  425. package/dist/esm/messages/recency.mjs +0 -32
  426. package/dist/esm/messages/recency.mjs.map +1 -1
  427. package/dist/esm/messages/reducer.mjs +0 -30
  428. package/dist/esm/messages/reducer.mjs.map +1 -1
  429. package/dist/esm/messages/tools.mjs +0 -14
  430. package/dist/esm/messages/tools.mjs.map +1 -1
  431. package/dist/esm/openai/index.mjs.map +1 -1
  432. package/dist/esm/prompts/activityLabel.mjs +9 -79
  433. package/dist/esm/prompts/activityLabel.mjs.map +1 -1
  434. package/dist/esm/responses/index.mjs.map +1 -1
  435. package/dist/esm/run.mjs +2 -538
  436. package/dist/esm/run.mjs.map +1 -1
  437. package/dist/esm/session/AgentSession.mjs +1 -17
  438. package/dist/esm/session/AgentSession.mjs.map +1 -1
  439. package/dist/esm/session/JsonlSessionStore.mjs.map +1 -1
  440. package/dist/esm/session/handlers.mjs +0 -7
  441. package/dist/esm/session/handlers.mjs.map +1 -1
  442. package/dist/esm/session/ids.mjs.map +1 -1
  443. package/dist/esm/session/messageSerialization.mjs +0 -4
  444. package/dist/esm/session/messageSerialization.mjs.map +1 -1
  445. package/dist/esm/stream.mjs +1 -93
  446. package/dist/esm/stream.mjs.map +1 -1
  447. package/dist/esm/summarization/index.mjs +0 -27
  448. package/dist/esm/summarization/index.mjs.map +1 -1
  449. package/dist/esm/summarization/node.mjs +0 -273
  450. package/dist/esm/summarization/node.mjs.map +1 -1
  451. package/dist/esm/tools/BashExecutor.mjs +0 -35
  452. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  453. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +0 -24
  454. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  455. package/dist/esm/tools/Calculator.mjs.map +1 -1
  456. package/dist/esm/tools/CodeExecutor.mjs +0 -13
  457. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  458. package/dist/esm/tools/CodeSessionFileSummary.mjs.map +1 -1
  459. package/dist/esm/tools/ProgrammaticToolCalling.mjs +0 -101
  460. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  461. package/dist/esm/tools/ReadFile.mjs.map +1 -1
  462. package/dist/esm/tools/SkillTool.mjs +0 -5
  463. package/dist/esm/tools/SkillTool.mjs.map +1 -1
  464. package/dist/esm/tools/SubagentTool.mjs +0 -10
  465. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  466. package/dist/esm/tools/ToolNode.mjs +6 -1038
  467. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  468. package/dist/esm/tools/ToolSearch.mjs +0 -179
  469. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  470. package/dist/esm/tools/cloudflare/CloudflareBridgeRuntime.mjs.map +1 -1
  471. package/dist/esm/tools/cloudflare/CloudflareProgrammaticToolCalling.mjs.map +1 -1
  472. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs +0 -39
  473. package/dist/esm/tools/cloudflare/CloudflareSandboxExecutionEngine.mjs.map +1 -1
  474. package/dist/esm/tools/cloudflare/CloudflareSandboxTools.mjs.map +1 -1
  475. package/dist/esm/tools/eagerEventExecution.mjs +0 -6
  476. package/dist/esm/tools/eagerEventExecution.mjs.map +1 -1
  477. package/dist/esm/tools/handlers.mjs +1 -41
  478. package/dist/esm/tools/handlers.mjs.map +1 -1
  479. package/dist/esm/tools/intentArg.mjs +1 -132
  480. package/dist/esm/tools/intentArg.mjs.map +1 -1
  481. package/dist/esm/tools/local/CompileCheckTool.mjs +0 -25
  482. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
  483. package/dist/esm/tools/local/FileCheckpointer.mjs +0 -23
  484. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -1
  485. package/dist/esm/tools/local/LocalCodingTools.mjs +0 -80
  486. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
  487. package/dist/esm/tools/local/LocalExecutionEngine.mjs +0 -126
  488. package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -1
  489. package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -1
  490. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +0 -12
  491. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -1
  492. package/dist/esm/tools/local/attachments.mjs +0 -44
  493. package/dist/esm/tools/local/attachments.mjs.map +1 -1
  494. package/dist/esm/tools/local/bashAst.mjs +0 -16
  495. package/dist/esm/tools/local/bashAst.mjs.map +1 -1
  496. package/dist/esm/tools/local/editStrategies.mjs +0 -14
  497. package/dist/esm/tools/local/editStrategies.mjs.map +1 -1
  498. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -1
  499. package/dist/esm/tools/local/syntaxCheck.mjs +0 -31
  500. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -1
  501. package/dist/esm/tools/local/textEncoding.mjs +0 -11
  502. package/dist/esm/tools/local/textEncoding.mjs.map +1 -1
  503. package/dist/esm/tools/local/workspaceFS.mjs +0 -33
  504. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -1
  505. package/dist/esm/tools/ptcTimeout.mjs.map +1 -1
  506. package/dist/esm/tools/schema.mjs +0 -8
  507. package/dist/esm/tools/schema.mjs.map +1 -1
  508. package/dist/esm/tools/search/anthropic.mjs +0 -9
  509. package/dist/esm/tools/search/anthropic.mjs.map +1 -1
  510. package/dist/esm/tools/search/content.mjs.map +1 -1
  511. package/dist/esm/tools/search/crw-scraper.mjs +0 -33
  512. package/dist/esm/tools/search/crw-scraper.mjs.map +1 -1
  513. package/dist/esm/tools/search/crw-search.mjs +0 -2
  514. package/dist/esm/tools/search/crw-search.mjs.map +1 -1
  515. package/dist/esm/tools/search/firecrawl.mjs +0 -25
  516. package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
  517. package/dist/esm/tools/search/format.mjs +0 -30
  518. package/dist/esm/tools/search/format.mjs.map +1 -1
  519. package/dist/esm/tools/search/highlights.mjs +0 -16
  520. package/dist/esm/tools/search/highlights.mjs.map +1 -1
  521. package/dist/esm/tools/search/keenable-scraper.mjs +0 -8
  522. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -1
  523. package/dist/esm/tools/search/keenable-search.mjs +0 -7
  524. package/dist/esm/tools/search/keenable-search.mjs.map +1 -1
  525. package/dist/esm/tools/search/rerankers.mjs +0 -35
  526. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  527. package/dist/esm/tools/search/schema.mjs +0 -1
  528. package/dist/esm/tools/search/schema.mjs.map +1 -1
  529. package/dist/esm/tools/search/search.mjs +1 -14
  530. package/dist/esm/tools/search/search.mjs.map +1 -1
  531. package/dist/esm/tools/search/serper-scraper.mjs +0 -46
  532. package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
  533. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  534. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  535. package/dist/esm/tools/search/tool.mjs +0 -25
  536. package/dist/esm/tools/search/tool.mjs.map +1 -1
  537. package/dist/esm/tools/search/utils.mjs +0 -8
  538. package/dist/esm/tools/search/utils.mjs.map +1 -1
  539. package/dist/esm/tools/skillCatalog.mjs +0 -6
  540. package/dist/esm/tools/skillCatalog.mjs.map +1 -1
  541. package/dist/esm/tools/streamedToolCallSeals.mjs +0 -6
  542. package/dist/esm/tools/streamedToolCallSeals.mjs.map +1 -1
  543. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs +0 -2
  544. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs.map +1 -1
  545. package/dist/esm/tools/subagent/SubagentExecutor.mjs +3 -213
  546. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  547. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
  548. package/dist/esm/tools/subagent/childGraphConfig.mjs +0 -4
  549. package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -1
  550. package/dist/esm/tools/subagent/runtimeLimits.mjs.map +1 -1
  551. package/dist/esm/tools/toolOutputReferences.mjs +1 -322
  552. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  553. package/dist/esm/types/hitl.mjs +0 -5
  554. package/dist/esm/types/hitl.mjs.map +1 -1
  555. package/dist/esm/utils/callbacks.mjs.map +1 -1
  556. package/dist/esm/utils/errors.mjs +14 -165
  557. package/dist/esm/utils/errors.mjs.map +1 -1
  558. package/dist/esm/utils/events.mjs +0 -10
  559. package/dist/esm/utils/events.mjs.map +1 -1
  560. package/dist/esm/utils/graph.mjs.map +1 -1
  561. package/dist/esm/utils/handlers.mjs +0 -3
  562. package/dist/esm/utils/handlers.mjs.map +1 -1
  563. package/dist/esm/utils/llm.mjs +0 -1
  564. package/dist/esm/utils/llm.mjs.map +1 -1
  565. package/dist/esm/utils/misc.mjs +0 -16
  566. package/dist/esm/utils/misc.mjs.map +1 -1
  567. package/dist/esm/utils/proxy.mjs +0 -26
  568. package/dist/esm/utils/proxy.mjs.map +1 -1
  569. package/dist/esm/utils/run.mjs +0 -6
  570. package/dist/esm/utils/run.mjs.map +1 -1
  571. package/dist/esm/utils/schema.mjs +0 -5
  572. package/dist/esm/utils/schema.mjs.map +1 -1
  573. package/dist/esm/utils/title.mjs +0 -6
  574. package/dist/esm/utils/title.mjs.map +1 -1
  575. package/dist/esm/utils/tokens.mjs +0 -113
  576. package/dist/esm/utils/tokens.mjs.map +1 -1
  577. package/dist/esm/utils/toolContent.mjs +0 -41
  578. package/dist/esm/utils/toolContent.mjs.map +1 -1
  579. package/dist/esm/utils/truncation.mjs +0 -61
  580. package/dist/esm/utils/truncation.mjs.map +1 -1
  581. package/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/__tests__/stream.eagerArgsDivergence.test.ts +0 -911
  688. package/src/__tests__/stream.eagerEventExecution.test.ts +0 -5046
  689. package/src/__tests__/stream.streamLimits.test.ts +0 -1982
  690. package/src/agents/__tests__/AgentContext.anthropic.live.test.ts +0 -448
  691. package/src/agents/__tests__/AgentContext.bedrock.live.test.ts +0 -655
  692. package/src/agents/__tests__/AgentContext.openrouter.live.test.ts +0 -128
  693. package/src/agents/__tests__/AgentContext.overflow.test.ts +0 -205
  694. package/src/agents/__tests__/AgentContext.test.ts +0 -2425
  695. package/src/agents/__tests__/projection.test.ts +0 -73
  696. package/src/agents/__tests__/promptCacheLiveHelpers.ts +0 -175
  697. package/src/aggregator.test.ts +0 -1214
  698. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +0 -273
  699. package/src/graphs/__tests__/Graph.closeRunStep.test.ts +0 -385
  700. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +0 -1766
  701. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +0 -126
  702. package/src/graphs/__tests__/Graph.reasoning.test.ts +0 -1717
  703. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +0 -80
  704. package/src/graphs/__tests__/MultiAgentGraph.test.ts +0 -177
  705. package/src/graphs/__tests__/composition.smoke.test.ts +0 -509
  706. package/src/graphs/__tests__/createGraph.test.ts +0 -206
  707. package/src/hooks/__tests__/HookRegistry.test.ts +0 -334
  708. package/src/hooks/__tests__/compactHooks.test.ts +0 -250
  709. package/src/hooks/__tests__/createToolPolicyHook.test.ts +0 -268
  710. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +0 -405
  711. package/src/hooks/__tests__/executeHooks.test.ts +0 -1294
  712. package/src/hooks/__tests__/integration.test.ts +0 -337
  713. package/src/hooks/__tests__/matchers.test.ts +0 -238
  714. package/src/hooks/__tests__/preemptBoundary.test.ts +0 -152
  715. package/src/hooks/__tests__/toolHooks.test.ts +0 -669
  716. package/src/llm/__tests__/contextOverflowRecovery.test.ts +0 -401
  717. package/src/llm/__tests__/fallbackOverflow.test.ts +0 -287
  718. package/src/llm/anthropic/Jacob_Lee_Resume_2023.pdf +0 -0
  719. package/src/llm/anthropic/inherited-content-utils.spec.ts +0 -249
  720. package/src/llm/anthropic/inherited-stream-events.spec.ts +0 -1259
  721. package/src/llm/anthropic/inherited-strict.spec.ts +0 -302
  722. package/src/llm/anthropic/llm.spec.ts +0 -3394
  723. package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +0 -383
  724. package/src/llm/anthropic/utils/cross-provider-server-tools.test.ts +0 -110
  725. package/src/llm/anthropic/utils/server-tool-inputs.test.ts +0 -508
  726. package/src/llm/anthropic/utils/streaming-tool-input.test.ts +0 -361
  727. package/src/llm/anthropic/utils/stripPrefillCache.test.ts +0 -111
  728. package/src/llm/anthropic/utils/tool-id-normalization.test.ts +0 -287
  729. package/src/llm/bedrock/inherited-cache.spec.ts +0 -144
  730. package/src/llm/bedrock/inherited.spec.ts +0 -724
  731. package/src/llm/bedrock/llm.spec.ts +0 -1533
  732. package/src/llm/bedrock/streamSealDispatch.test.ts +0 -331
  733. package/src/llm/bedrock/toolCache.test.ts +0 -194
  734. package/src/llm/bedrock/utils/cross-provider-reasoning.test.ts +0 -131
  735. package/src/llm/bedrock/utils/cross-provider-server-tools.test.ts +0 -122
  736. package/src/llm/bedrock/utils/message_inputs.test.ts +0 -650
  737. package/src/llm/bedrock/utils/message_outputs.test.ts +0 -85
  738. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +0 -118
  739. package/src/llm/custom-chat-models.smoke.test.ts +0 -1966
  740. package/src/llm/google/data/gettysburg10.wav +0 -0
  741. package/src/llm/google/data/hotdog.jpg +0 -0
  742. package/src/llm/google/inherited-stream-events.spec.ts +0 -350
  743. package/src/llm/google/llm.spec.ts +0 -1388
  744. package/src/llm/google/streamSmoothing.test.ts +0 -121
  745. package/src/llm/google/utils/common.test.ts +0 -196
  746. package/src/llm/invoke.alternation.test.ts +0 -87
  747. package/src/llm/invoke.handoffCue.test.ts +0 -112
  748. package/src/llm/invoke.streamLimits.test.ts +0 -142
  749. package/src/llm/invoke.test.ts +0 -1198
  750. package/src/llm/mistral/streamSmoothing.test.ts +0 -97
  751. package/src/llm/openai/cacheWriteTokens.test.ts +0 -112
  752. package/src/llm/openai/contentBlocks.test.ts +0 -381
  753. package/src/llm/openai/deepseek.test.ts +0 -747
  754. package/src/llm/openai/inherited-deepseek.spec.ts +0 -347
  755. package/src/llm/openai/inherited-xai.spec.ts +0 -416
  756. package/src/llm/openai/llm.spec.ts +0 -1774
  757. package/src/llm/openai/managedRequests.test.ts +0 -182
  758. package/src/llm/openai/sequentialToolCallSeals.test.ts +0 -199
  759. package/src/llm/openai/streamMetadata.spec.ts +0 -86
  760. package/src/llm/openai/streamMetadataDedup.spec.ts +0 -166
  761. package/src/llm/openai/utils/isReasoningModel.test.ts +0 -90
  762. package/src/llm/openai/utils/messages.test.ts +0 -488
  763. package/src/llm/openrouter/reasoning.test.ts +0 -561
  764. package/src/llm/openrouter/toolCache.test.ts +0 -134
  765. package/src/llm/preempt.test.ts +0 -323
  766. package/src/llm/stream/chunkAdapters.test.ts +0 -202
  767. package/src/llm/stream/reassembly.test.ts +0 -241
  768. package/src/llm/stream/smoother.bench.test.ts +0 -155
  769. package/src/llm/stream/smoother.test.ts +0 -519
  770. package/src/llm/streamLimits.test.ts +0 -450
  771. package/src/llm/truncation.test.ts +0 -242
  772. package/src/llm/vertexai/fixThoughtSignatures.test.ts +0 -154
  773. package/src/llm/vertexai/inherited-stream-events.spec.ts +0 -271
  774. package/src/llm/vertexai/llm.spec.ts +0 -159
  775. package/src/llm/vertexai/repairUsageMetadata.test.ts +0 -54
  776. package/src/llm/vertexai/sealStreamedToolCalls.test.ts +0 -88
  777. package/src/llm/vertexai/streamSealDispatch.test.ts +0 -148
  778. package/src/llm/vertexai/streamSmoothing.test.ts +0 -109
  779. package/src/messages/__tests__/anthropicToolCache.test.ts +0 -339
  780. package/src/messages/__tests__/observationMasking.test.ts +0 -312
  781. package/src/messages/__tests__/recency.test.ts +0 -267
  782. package/src/messages/__tests__/tools.test.ts +0 -473
  783. package/src/messages/alternation.test.ts +0 -212
  784. package/src/messages/assistantPhase.test.ts +0 -75
  785. package/src/messages/cache.tail.test.ts +0 -533
  786. package/src/messages/cache.test.ts +0 -2166
  787. package/src/messages/content.test.ts +0 -362
  788. package/src/messages/contextPruning.test.ts +0 -184
  789. package/src/messages/ensureThinkingBlock.test.ts +0 -1427
  790. package/src/messages/foldToollessToolBlocks.test.ts +0 -596
  791. package/src/messages/formatAgentMessages.reducer.test.ts +0 -162
  792. package/src/messages/formatAgentMessages.skills.test.ts +0 -513
  793. package/src/messages/formatAgentMessages.steer.test.ts +0 -598
  794. package/src/messages/formatAgentMessages.test.ts +0 -8111
  795. package/src/messages/formatAgentMessages.tools.test.ts +0 -522
  796. package/src/messages/formatMessage.test.ts +0 -693
  797. package/src/messages/handoffCue.test.ts +0 -96
  798. package/src/messages/injected.test.ts +0 -107
  799. package/src/messages/labelContentByAgent.test.ts +0 -962
  800. package/src/messages/reducer.spec.ts +0 -32
  801. package/src/messages/shiftIndexTokenCountMap.test.ts +0 -81
  802. package/src/messages/tailCacheConversion.test.ts +0 -161
  803. package/src/openai/__tests__/openai.test.ts +0 -337
  804. package/src/responses/__tests__/responses.test.ts +0 -652
  805. package/src/scripts/abort.ts +0 -157
  806. package/src/scripts/activity-labels/captured.json +0 -56
  807. package/src/scripts/activity-labels/checks.cjs +0 -205
  808. package/src/scripts/activity-labels/corpus.cjs +0 -473
  809. package/src/scripts/activity-labels/report.cjs +0 -203
  810. package/src/scripts/activity-labels/rescore.cjs +0 -102
  811. package/src/scripts/activity-labels/run.ts +0 -705
  812. package/src/scripts/activity-labels/variants.ts +0 -71
  813. package/src/scripts/ant_web_search.ts +0 -159
  814. package/src/scripts/ant_web_search_edge_case.ts +0 -163
  815. package/src/scripts/ant_web_search_error_edge_case.ts +0 -149
  816. package/src/scripts/args.ts +0 -48
  817. package/src/scripts/bedrock-cache-debug.ts +0 -250
  818. package/src/scripts/bedrock-content-aggregation-test.ts +0 -266
  819. package/src/scripts/bedrock-merge-test.ts +0 -107
  820. package/src/scripts/bedrock-parallel-tools-test.ts +0 -204
  821. package/src/scripts/bench-prompt-cache.ts +0 -479
  822. package/src/scripts/caching.ts +0 -132
  823. package/src/scripts/cli.ts +0 -172
  824. package/src/scripts/cli2.ts +0 -133
  825. package/src/scripts/cli3.ts +0 -184
  826. package/src/scripts/cli4.ts +0 -191
  827. package/src/scripts/cli5.ts +0 -191
  828. package/src/scripts/code_exec.ts +0 -214
  829. package/src/scripts/code_exec_files.ts +0 -237
  830. package/src/scripts/code_exec_multi_session.ts +0 -237
  831. package/src/scripts/code_exec_ptc.ts +0 -335
  832. package/src/scripts/code_exec_session.ts +0 -283
  833. package/src/scripts/code_exec_simple.ts +0 -148
  834. package/src/scripts/compare_pi_vs_ours.ts +0 -1259
  835. package/src/scripts/content.ts +0 -139
  836. package/src/scripts/context-overflow-probe.ts +0 -997
  837. package/src/scripts/empty_input.ts +0 -137
  838. package/src/scripts/handoff-test.ts +0 -134
  839. package/src/scripts/local_engine.ts +0 -166
  840. package/src/scripts/local_engine_checkpointer.ts +0 -205
  841. package/src/scripts/local_engine_compile.ts +0 -263
  842. package/src/scripts/local_engine_hooks.ts +0 -226
  843. package/src/scripts/local_engine_image.ts +0 -201
  844. package/src/scripts/local_engine_ptc.ts +0 -151
  845. package/src/scripts/local_engine_workspace.ts +0 -258
  846. package/src/scripts/memory.ts +0 -107
  847. package/src/scripts/multi-agent-chain.ts +0 -332
  848. package/src/scripts/multi-agent-conditional.ts +0 -222
  849. package/src/scripts/multi-agent-document-review-chain.ts +0 -198
  850. package/src/scripts/multi-agent-hybrid-flow.ts +0 -311
  851. package/src/scripts/multi-agent-parallel-start.ts +0 -265
  852. package/src/scripts/multi-agent-parallel.ts +0 -395
  853. package/src/scripts/multi-agent-sequence.ts +0 -217
  854. package/src/scripts/multi-agent-subagent.ts +0 -246
  855. package/src/scripts/multi-agent-supervisor.ts +0 -366
  856. package/src/scripts/multi-agent-test.ts +0 -187
  857. package/src/scripts/openrouter_prompt_cache_live.ts +0 -310
  858. package/src/scripts/parallel-asymmetric-tools-test.ts +0 -275
  859. package/src/scripts/parallel-full-metadata-test.ts +0 -241
  860. package/src/scripts/parallel-tools-test.ts +0 -341
  861. package/src/scripts/preempt-probe.ts +0 -330
  862. package/src/scripts/preempt-scenarios.ts +0 -388
  863. package/src/scripts/programmatic_exec.ts +0 -387
  864. package/src/scripts/programmatic_exec_agent.ts +0 -232
  865. package/src/scripts/search.ts +0 -147
  866. package/src/scripts/sequential-full-metadata-test.ts +0 -198
  867. package/src/scripts/session_live.ts +0 -548
  868. package/src/scripts/simple.ts +0 -226
  869. package/src/scripts/single-agent-metadata-test.ts +0 -196
  870. package/src/scripts/stream.ts +0 -141
  871. package/src/scripts/subagent-configurable-inheritance.ts +0 -252
  872. package/src/scripts/subagent-event-driven-debug.ts +0 -190
  873. package/src/scripts/subagent-tools-debug.ts +0 -160
  874. package/src/scripts/subagent-usage-sink.ts +0 -176
  875. package/src/scripts/summarization-recency.ts +0 -462
  876. package/src/scripts/test-custom-prompt-key.ts +0 -145
  877. package/src/scripts/test-handoff-input.ts +0 -170
  878. package/src/scripts/test-handoff-preamble.ts +0 -278
  879. package/src/scripts/test-handoff-steering.ts +0 -430
  880. package/src/scripts/test-multi-agent-list-handoff.ts +0 -418
  881. package/src/scripts/test-parallel-agent-labeling.ts +0 -327
  882. package/src/scripts/test-parallel-handoffs.ts +0 -292
  883. package/src/scripts/test-thinking-handoff-bedrock.ts +0 -154
  884. package/src/scripts/test-thinking-handoff.ts +0 -156
  885. package/src/scripts/test-thinking-to-thinking-handoff-bedrock.ts +0 -166
  886. package/src/scripts/test-tool-before-handoff-role-order.ts +0 -276
  887. package/src/scripts/test-tools-before-handoff.ts +0 -222
  888. package/src/scripts/test_code_api.ts +0 -354
  889. package/src/scripts/thinking-bedrock.ts +0 -160
  890. package/src/scripts/thinking-vertexai.ts +0 -168
  891. package/src/scripts/thinking.ts +0 -172
  892. package/src/scripts/tool_search.ts +0 -153
  893. package/src/scripts/tools.ts +0 -163
  894. package/src/session/__tests__/JsonlSessionStore.test.ts +0 -1463
  895. package/src/session/__tests__/handlers.test.ts +0 -161
  896. package/src/specs/activity-label-observability.live.test.ts +0 -262
  897. package/src/specs/activity-label-observability.test.ts +0 -167
  898. package/src/specs/activity-label-prompt.test.ts +0 -359
  899. package/src/specs/activity-label-trace-seed.test.ts +0 -47
  900. package/src/specs/activity-phase-label.test.ts +0 -306
  901. package/src/specs/agent-handoffs.live.test.ts +0 -140
  902. package/src/specs/agent-handoffs.test.ts +0 -2465
  903. package/src/specs/anthropic.simple.test.ts +0 -453
  904. package/src/specs/ask-user-question-batch.test.ts +0 -289
  905. package/src/specs/ask-user-questions.live.test.ts +0 -185
  906. package/src/specs/ask-user-questions.test.ts +0 -293
  907. package/src/specs/azure.simple.test.ts +0 -502
  908. package/src/specs/bedrock-toolless.live.test.ts +0 -123
  909. package/src/specs/bedrock-truncation.live.test.ts +0 -191
  910. package/src/specs/cache.simple.test.ts +0 -416
  911. package/src/specs/context-accuracy.live.test.ts +0 -409
  912. package/src/specs/context-overflow-recovery.live.test.ts +0 -213
  913. package/src/specs/context-usage-event.test.ts +0 -117
  914. package/src/specs/context-usage.live.test.ts +0 -297
  915. package/src/specs/custom-event-await.test.ts +0 -304
  916. package/src/specs/deepseek.simple.test.ts +0 -290
  917. package/src/specs/deterministic-trace-id.test.ts +0 -43
  918. package/src/specs/discovered-tools.test.ts +0 -217
  919. package/src/specs/durability-checkpoint.integration.test.ts +0 -243
  920. package/src/specs/graph-subagent.live.test.ts +0 -242
  921. package/src/specs/graph-subagent.test.ts +0 -322
  922. package/src/specs/handoffCue.test.ts +0 -165
  923. package/src/specs/langfuse-callbacks.test.ts +0 -1578
  924. package/src/specs/langfuse-config.test.ts +0 -247
  925. package/src/specs/langfuse-instrumentation.test.ts +0 -393
  926. package/src/specs/langfuse-metadata.test.ts +0 -245
  927. package/src/specs/langfuse-routing.integration.test.ts +0 -844
  928. package/src/specs/langfuse-runtime-context.test.ts +0 -92
  929. package/src/specs/langfuse-span-registry.test.ts +0 -87
  930. package/src/specs/langfuse-tool-output-tracing.test.ts +0 -1858
  931. package/src/specs/langfuse-trace-shaping.test.ts +0 -591
  932. package/src/specs/moonshot.simple.test.ts +0 -367
  933. package/src/specs/multi-agent-summarization.test.ts +0 -396
  934. package/src/specs/openai.simple.test.ts +0 -315
  935. package/src/specs/openrouter.simple.test.ts +0 -275
  936. package/src/specs/preemptSeal.test.ts +0 -678
  937. package/src/specs/prune.test.ts +0 -3741
  938. package/src/specs/reasoning.test.ts +0 -201
  939. package/src/specs/run-step-timestamps.test.ts +0 -414
  940. package/src/specs/spec.utils.ts +0 -15
  941. package/src/specs/subagent.test.ts +0 -864
  942. package/src/specs/summarization-unit.test.ts +0 -973
  943. package/src/specs/summarization.test.ts +0 -3823
  944. package/src/specs/summarize-prune.test.ts +0 -376
  945. package/src/specs/thinking-handoff.test.ts +0 -621
  946. package/src/specs/thinking-prune.test.ts +0 -827
  947. package/src/specs/token-accounting-e2e.test.ts +0 -1034
  948. package/src/specs/token-accounting-pipeline.test.ts +0 -1008
  949. package/src/specs/token-distribution-edge-case.test.ts +0 -315
  950. package/src/specs/token-memoization.test.ts +0 -40
  951. package/src/specs/tokens.test.ts +0 -1000
  952. package/src/specs/tool-error-resume.test.ts +0 -194
  953. package/src/specs/tool-error.test.ts +0 -198
  954. package/src/specs/vllm-reasoning-toolcalls.test.ts +0 -340
  955. package/src/stream.dispatch.test.ts +0 -63
  956. package/src/stream.test.ts +0 -144
  957. package/src/summarization/__tests__/aggregator.test.ts +0 -236
  958. package/src/summarization/__tests__/node.test.ts +0 -1996
  959. package/src/summarization/__tests__/trigger.test.ts +0 -149
  960. package/src/summarization/chunkHandler.test.ts +0 -196
  961. package/src/test/mockTools.ts +0 -386
  962. package/src/tools/Calculator.test.ts +0 -278
  963. package/src/tools/__tests__/BashExecutor.test.ts +0 -108
  964. package/src/tools/__tests__/CloudflareSandboxExecution.test.ts +0 -939
  965. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +0 -858
  966. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +0 -124
  967. package/src/tools/__tests__/LocalExecutionRoots.test.ts +0 -8
  968. package/src/tools/__tests__/LocalExecutionTools.test.ts +0 -2733
  969. package/src/tools/__tests__/ProgrammaticToolCalling.integration.test.ts +0 -318
  970. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +0 -1557
  971. package/src/tools/__tests__/ReadFile.test.ts +0 -44
  972. package/src/tools/__tests__/SkillTool.test.ts +0 -442
  973. package/src/tools/__tests__/SubagentExecutor.test.ts +0 -3728
  974. package/src/tools/__tests__/SubagentReplay.test.ts +0 -329
  975. package/src/tools/__tests__/SubagentTool.test.ts +0 -149
  976. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +0 -595
  977. package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +0 -1191
  978. package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +0 -757
  979. package/src/tools/__tests__/ToolNode.langfuse.test.ts +0 -57
  980. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +0 -736
  981. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +0 -1710
  982. package/src/tools/__tests__/ToolNode.runtimeState.test.ts +0 -120
  983. package/src/tools/__tests__/ToolNode.session.test.ts +0 -1352
  984. package/src/tools/__tests__/ToolNode.streamLimits.test.ts +0 -69
  985. package/src/tools/__tests__/ToolSearch.integration.test.ts +0 -161
  986. package/src/tools/__tests__/ToolSearch.test.ts +0 -1227
  987. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +0 -561
  988. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +0 -980
  989. package/src/tools/__tests__/directToolHooks.test.ts +0 -700
  990. package/src/tools/__tests__/eagerEventExecution.session.test.ts +0 -92
  991. package/src/tools/__tests__/handlers.test.ts +0 -997
  992. package/src/tools/__tests__/hitl.test.ts +0 -4663
  993. package/src/tools/__tests__/intentArg.test.ts +0 -357
  994. package/src/tools/__tests__/intentCoverage.test.ts +0 -139
  995. package/src/tools/__tests__/localToolNames.test.ts +0 -75
  996. package/src/tools/__tests__/skillCatalog.test.ts +0 -161
  997. package/src/tools/__tests__/subagentHooks.test.ts +0 -1651
  998. package/src/tools/__tests__/toolOutputReferences.test.ts +0 -515
  999. package/src/tools/__tests__/workspaceSeam.test.ts +0 -147
  1000. package/src/tools/local/__tests__/FileCheckpointer.test.ts +0 -162
  1001. package/src/tools/local/__tests__/editStrategies.test.ts +0 -134
  1002. package/src/tools/search/content.test.ts +0 -173
  1003. package/src/tools/search/crw.test.ts +0 -836
  1004. package/src/tools/search/format.test.ts +0 -242
  1005. package/src/tools/search/http-agent.test.ts +0 -133
  1006. package/src/tools/search/jina-reranker.test.ts +0 -306
  1007. package/src/tools/search/keenable-scraper.test.ts +0 -153
  1008. package/src/tools/search/keenable.test.ts +0 -183
  1009. package/src/tools/search/outcome.test.ts +0 -90
  1010. package/src/tools/search/output.md +0 -2775
  1011. package/src/tools/search/rag-api-reranker.test.ts +0 -891
  1012. package/src/tools/search/source-processing.test.ts +0 -516
  1013. package/src/tools/search/tavily.test.ts +0 -965
  1014. package/src/tools/search/test.html +0 -884
  1015. package/src/tools/search/test.md +0 -643
  1016. package/src/tools/subagent/__tests__/SubagentExecutionRegistry.test.ts +0 -637
  1017. package/src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts +0 -3054
  1018. package/src/tools/subagent/__tests__/childGraphConfig.test.ts +0 -521
  1019. package/src/utils/__tests__/apportion.test.ts +0 -32
  1020. package/src/utils/__tests__/errors.test.ts +0 -270
  1021. package/src/utils/__tests__/fixtures/contextOverflowSignatures.ts +0 -336
  1022. package/src/utils/__tests__/redactSecrets.test.ts +0 -56
  1023. package/src/utils/__tests__/truncation.test.ts +0 -66
  1024. package/src/utils/llm.test.ts +0 -18
  1025. package/src/utils/proxy.test.ts +0 -176
  1026. 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
- });