@librechat/agents 3.1.75 → 3.1.77-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/dist/cjs/graphs/Graph.cjs +22 -3
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hitl/askUserQuestion.cjs +67 -0
  4. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -0
  5. package/dist/cjs/hooks/HookRegistry.cjs +54 -0
  6. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  7. package/dist/cjs/hooks/createToolPolicyHook.cjs +115 -0
  8. package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -0
  9. package/dist/cjs/hooks/executeHooks.cjs +40 -1
  10. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  11. package/dist/cjs/hooks/types.cjs +1 -0
  12. package/dist/cjs/hooks/types.cjs.map +1 -1
  13. package/dist/cjs/langchain/google-common.cjs +3 -0
  14. package/dist/cjs/langchain/google-common.cjs.map +1 -0
  15. package/dist/cjs/langchain/index.cjs +86 -0
  16. package/dist/cjs/langchain/index.cjs.map +1 -0
  17. package/dist/cjs/langchain/language_models/chat_models.cjs +3 -0
  18. package/dist/cjs/langchain/language_models/chat_models.cjs.map +1 -0
  19. package/dist/cjs/langchain/messages/tool.cjs +3 -0
  20. package/dist/cjs/langchain/messages/tool.cjs.map +1 -0
  21. package/dist/cjs/langchain/messages.cjs +51 -0
  22. package/dist/cjs/langchain/messages.cjs.map +1 -0
  23. package/dist/cjs/langchain/openai.cjs +3 -0
  24. package/dist/cjs/langchain/openai.cjs.map +1 -0
  25. package/dist/cjs/langchain/prompts.cjs +11 -0
  26. package/dist/cjs/langchain/prompts.cjs.map +1 -0
  27. package/dist/cjs/langchain/runnables.cjs +19 -0
  28. package/dist/cjs/langchain/runnables.cjs.map +1 -0
  29. package/dist/cjs/langchain/tools.cjs +23 -0
  30. package/dist/cjs/langchain/tools.cjs.map +1 -0
  31. package/dist/cjs/langchain/utils/env.cjs +11 -0
  32. package/dist/cjs/langchain/utils/env.cjs.map +1 -0
  33. package/dist/cjs/llm/anthropic/index.cjs +145 -52
  34. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  35. package/dist/cjs/llm/anthropic/types.cjs.map +1 -1
  36. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +21 -14
  37. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  38. package/dist/cjs/llm/anthropic/utils/message_outputs.cjs +84 -70
  39. package/dist/cjs/llm/anthropic/utils/message_outputs.cjs.map +1 -1
  40. package/dist/cjs/llm/bedrock/index.cjs +1 -1
  41. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  42. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +213 -3
  43. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  44. package/dist/cjs/llm/bedrock/utils/message_outputs.cjs +2 -1
  45. package/dist/cjs/llm/bedrock/utils/message_outputs.cjs.map +1 -1
  46. package/dist/cjs/llm/google/utils/common.cjs +5 -4
  47. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  48. package/dist/cjs/llm/openai/index.cjs +519 -655
  49. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  50. package/dist/cjs/llm/openai/utils/index.cjs +20 -458
  51. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  52. package/dist/cjs/llm/openrouter/index.cjs +57 -175
  53. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  54. package/dist/cjs/llm/vertexai/index.cjs +5 -3
  55. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  56. package/dist/cjs/main.cjs +112 -3
  57. package/dist/cjs/main.cjs.map +1 -1
  58. package/dist/cjs/messages/cache.cjs +2 -1
  59. package/dist/cjs/messages/cache.cjs.map +1 -1
  60. package/dist/cjs/messages/core.cjs +7 -6
  61. package/dist/cjs/messages/core.cjs.map +1 -1
  62. package/dist/cjs/messages/format.cjs +73 -15
  63. package/dist/cjs/messages/format.cjs.map +1 -1
  64. package/dist/cjs/messages/langchain.cjs +26 -0
  65. package/dist/cjs/messages/langchain.cjs.map +1 -0
  66. package/dist/cjs/messages/prune.cjs +7 -6
  67. package/dist/cjs/messages/prune.cjs.map +1 -1
  68. package/dist/cjs/run.cjs +400 -42
  69. package/dist/cjs/run.cjs.map +1 -1
  70. package/dist/cjs/tools/ToolNode.cjs +556 -56
  71. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  72. package/dist/cjs/tools/search/search.cjs +55 -66
  73. package/dist/cjs/tools/search/search.cjs.map +1 -1
  74. package/dist/cjs/tools/search/tavily-scraper.cjs +189 -0
  75. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -0
  76. package/dist/cjs/tools/search/tavily-search.cjs +372 -0
  77. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -0
  78. package/dist/cjs/tools/search/tool.cjs +26 -4
  79. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  80. package/dist/cjs/tools/search/utils.cjs +10 -3
  81. package/dist/cjs/tools/search/utils.cjs.map +1 -1
  82. package/dist/esm/graphs/Graph.mjs +22 -3
  83. package/dist/esm/graphs/Graph.mjs.map +1 -1
  84. package/dist/esm/hitl/askUserQuestion.mjs +65 -0
  85. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -0
  86. package/dist/esm/hooks/HookRegistry.mjs +54 -0
  87. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  88. package/dist/esm/hooks/createToolPolicyHook.mjs +113 -0
  89. package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -0
  90. package/dist/esm/hooks/executeHooks.mjs +40 -1
  91. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  92. package/dist/esm/hooks/types.mjs +1 -0
  93. package/dist/esm/hooks/types.mjs.map +1 -1
  94. package/dist/esm/langchain/google-common.mjs +2 -0
  95. package/dist/esm/langchain/google-common.mjs.map +1 -0
  96. package/dist/esm/langchain/index.mjs +5 -0
  97. package/dist/esm/langchain/index.mjs.map +1 -0
  98. package/dist/esm/langchain/language_models/chat_models.mjs +2 -0
  99. package/dist/esm/langchain/language_models/chat_models.mjs.map +1 -0
  100. package/dist/esm/langchain/messages/tool.mjs +2 -0
  101. package/dist/esm/langchain/messages/tool.mjs.map +1 -0
  102. package/dist/esm/langchain/messages.mjs +2 -0
  103. package/dist/esm/langchain/messages.mjs.map +1 -0
  104. package/dist/esm/langchain/openai.mjs +2 -0
  105. package/dist/esm/langchain/openai.mjs.map +1 -0
  106. package/dist/esm/langchain/prompts.mjs +2 -0
  107. package/dist/esm/langchain/prompts.mjs.map +1 -0
  108. package/dist/esm/langchain/runnables.mjs +2 -0
  109. package/dist/esm/langchain/runnables.mjs.map +1 -0
  110. package/dist/esm/langchain/tools.mjs +2 -0
  111. package/dist/esm/langchain/tools.mjs.map +1 -0
  112. package/dist/esm/langchain/utils/env.mjs +2 -0
  113. package/dist/esm/langchain/utils/env.mjs.map +1 -0
  114. package/dist/esm/llm/anthropic/index.mjs +146 -54
  115. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  116. package/dist/esm/llm/anthropic/types.mjs.map +1 -1
  117. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +21 -14
  118. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  119. package/dist/esm/llm/anthropic/utils/message_outputs.mjs +84 -71
  120. package/dist/esm/llm/anthropic/utils/message_outputs.mjs.map +1 -1
  121. package/dist/esm/llm/bedrock/index.mjs +1 -1
  122. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  123. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +214 -4
  124. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  125. package/dist/esm/llm/bedrock/utils/message_outputs.mjs +2 -1
  126. package/dist/esm/llm/bedrock/utils/message_outputs.mjs.map +1 -1
  127. package/dist/esm/llm/google/utils/common.mjs +5 -4
  128. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  129. package/dist/esm/llm/openai/index.mjs +520 -656
  130. package/dist/esm/llm/openai/index.mjs.map +1 -1
  131. package/dist/esm/llm/openai/utils/index.mjs +23 -459
  132. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  133. package/dist/esm/llm/openrouter/index.mjs +57 -175
  134. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  135. package/dist/esm/llm/vertexai/index.mjs +5 -3
  136. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  137. package/dist/esm/main.mjs +7 -0
  138. package/dist/esm/main.mjs.map +1 -1
  139. package/dist/esm/messages/cache.mjs +2 -1
  140. package/dist/esm/messages/cache.mjs.map +1 -1
  141. package/dist/esm/messages/core.mjs +7 -6
  142. package/dist/esm/messages/core.mjs.map +1 -1
  143. package/dist/esm/messages/format.mjs +73 -15
  144. package/dist/esm/messages/format.mjs.map +1 -1
  145. package/dist/esm/messages/langchain.mjs +23 -0
  146. package/dist/esm/messages/langchain.mjs.map +1 -0
  147. package/dist/esm/messages/prune.mjs +7 -6
  148. package/dist/esm/messages/prune.mjs.map +1 -1
  149. package/dist/esm/run.mjs +400 -42
  150. package/dist/esm/run.mjs.map +1 -1
  151. package/dist/esm/tools/ToolNode.mjs +557 -57
  152. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  153. package/dist/esm/tools/search/search.mjs +55 -66
  154. package/dist/esm/tools/search/search.mjs.map +1 -1
  155. package/dist/esm/tools/search/tavily-scraper.mjs +186 -0
  156. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -0
  157. package/dist/esm/tools/search/tavily-search.mjs +370 -0
  158. package/dist/esm/tools/search/tavily-search.mjs.map +1 -0
  159. package/dist/esm/tools/search/tool.mjs +26 -4
  160. package/dist/esm/tools/search/tool.mjs.map +1 -1
  161. package/dist/esm/tools/search/utils.mjs +10 -3
  162. package/dist/esm/tools/search/utils.mjs.map +1 -1
  163. package/dist/types/graphs/Graph.d.ts +7 -0
  164. package/dist/types/hitl/askUserQuestion.d.ts +55 -0
  165. package/dist/types/hitl/index.d.ts +6 -0
  166. package/dist/types/hooks/HookRegistry.d.ts +58 -0
  167. package/dist/types/hooks/createToolPolicyHook.d.ts +87 -0
  168. package/dist/types/hooks/index.d.ts +4 -1
  169. package/dist/types/hooks/types.d.ts +109 -3
  170. package/dist/types/index.d.ts +10 -0
  171. package/dist/types/langchain/google-common.d.ts +1 -0
  172. package/dist/types/langchain/index.d.ts +8 -0
  173. package/dist/types/langchain/language_models/chat_models.d.ts +1 -0
  174. package/dist/types/langchain/messages/tool.d.ts +1 -0
  175. package/dist/types/langchain/messages.d.ts +2 -0
  176. package/dist/types/langchain/openai.d.ts +1 -0
  177. package/dist/types/langchain/prompts.d.ts +1 -0
  178. package/dist/types/langchain/runnables.d.ts +2 -0
  179. package/dist/types/langchain/tools.d.ts +2 -0
  180. package/dist/types/langchain/utils/env.d.ts +1 -0
  181. package/dist/types/llm/anthropic/index.d.ts +22 -9
  182. package/dist/types/llm/anthropic/types.d.ts +5 -1
  183. package/dist/types/llm/anthropic/utils/message_outputs.d.ts +13 -6
  184. package/dist/types/llm/anthropic/utils/output_parsers.d.ts +1 -1
  185. package/dist/types/llm/openai/index.d.ts +21 -24
  186. package/dist/types/llm/openrouter/index.d.ts +11 -9
  187. package/dist/types/llm/vertexai/index.d.ts +1 -0
  188. package/dist/types/messages/cache.d.ts +4 -1
  189. package/dist/types/messages/format.d.ts +4 -1
  190. package/dist/types/messages/langchain.d.ts +27 -0
  191. package/dist/types/run.d.ts +117 -1
  192. package/dist/types/tools/ToolNode.d.ts +26 -1
  193. package/dist/types/tools/search/tavily-scraper.d.ts +19 -0
  194. package/dist/types/tools/search/tavily-search.d.ts +4 -0
  195. package/dist/types/tools/search/types.d.ts +99 -5
  196. package/dist/types/tools/search/utils.d.ts +2 -2
  197. package/dist/types/types/graph.d.ts +23 -37
  198. package/dist/types/types/hitl.d.ts +272 -0
  199. package/dist/types/types/index.d.ts +1 -0
  200. package/dist/types/types/llm.d.ts +3 -3
  201. package/dist/types/types/run.d.ts +33 -0
  202. package/dist/types/types/stream.d.ts +1 -1
  203. package/dist/types/types/tools.d.ts +19 -0
  204. package/package.json +80 -17
  205. package/src/graphs/Graph.ts +33 -4
  206. package/src/graphs/__tests__/composition.smoke.test.ts +188 -0
  207. package/src/hitl/askUserQuestion.ts +72 -0
  208. package/src/hitl/index.ts +7 -0
  209. package/src/hooks/HookRegistry.ts +71 -0
  210. package/src/hooks/__tests__/createToolPolicyHook.test.ts +259 -0
  211. package/src/hooks/createToolPolicyHook.ts +184 -0
  212. package/src/hooks/executeHooks.ts +50 -1
  213. package/src/hooks/index.ts +6 -0
  214. package/src/hooks/types.ts +112 -0
  215. package/src/index.ts +22 -0
  216. package/src/langchain/google-common.ts +1 -0
  217. package/src/langchain/index.ts +8 -0
  218. package/src/langchain/language_models/chat_models.ts +1 -0
  219. package/src/langchain/messages/tool.ts +5 -0
  220. package/src/langchain/messages.ts +21 -0
  221. package/src/langchain/openai.ts +1 -0
  222. package/src/langchain/prompts.ts +1 -0
  223. package/src/langchain/runnables.ts +7 -0
  224. package/src/langchain/tools.ts +8 -0
  225. package/src/langchain/utils/env.ts +1 -0
  226. package/src/llm/anthropic/index.ts +252 -84
  227. package/src/llm/anthropic/llm.spec.ts +751 -102
  228. package/src/llm/anthropic/types.ts +9 -1
  229. package/src/llm/anthropic/utils/message_inputs.ts +37 -19
  230. package/src/llm/anthropic/utils/message_outputs.ts +119 -101
  231. package/src/llm/bedrock/index.ts +2 -2
  232. package/src/llm/bedrock/llm.spec.ts +341 -0
  233. package/src/llm/bedrock/utils/message_inputs.ts +303 -4
  234. package/src/llm/bedrock/utils/message_outputs.ts +2 -1
  235. package/src/llm/custom-chat-models.smoke.test.ts +836 -0
  236. package/src/llm/google/llm.spec.ts +339 -57
  237. package/src/llm/google/utils/common.ts +53 -48
  238. package/src/llm/openai/contentBlocks.test.ts +346 -0
  239. package/src/llm/openai/index.ts +856 -833
  240. package/src/llm/openai/utils/index.ts +107 -78
  241. package/src/llm/openai/utils/messages.test.ts +159 -0
  242. package/src/llm/openrouter/index.ts +124 -247
  243. package/src/llm/openrouter/reasoning.test.ts +8 -1
  244. package/src/llm/vertexai/index.ts +11 -5
  245. package/src/llm/vertexai/llm.spec.ts +28 -1
  246. package/src/messages/cache.test.ts +4 -3
  247. package/src/messages/cache.ts +3 -2
  248. package/src/messages/core.ts +16 -9
  249. package/src/messages/format.ts +96 -16
  250. package/src/messages/formatAgentMessages.test.ts +166 -1
  251. package/src/messages/langchain.ts +39 -0
  252. package/src/messages/prune.ts +12 -8
  253. package/src/run.ts +456 -47
  254. package/src/scripts/caching.ts +2 -3
  255. package/src/specs/summarization.test.ts +51 -58
  256. package/src/tools/ToolNode.ts +706 -63
  257. package/src/tools/__tests__/hitl.test.ts +3593 -0
  258. package/src/tools/search/search.ts +83 -73
  259. package/src/tools/search/tavily-scraper.ts +235 -0
  260. package/src/tools/search/tavily-search.ts +424 -0
  261. package/src/tools/search/tavily.test.ts +965 -0
  262. package/src/tools/search/tool.ts +36 -26
  263. package/src/tools/search/types.ts +133 -8
  264. package/src/tools/search/utils.ts +13 -5
  265. package/src/types/graph.ts +32 -87
  266. package/src/types/hitl.ts +303 -0
  267. package/src/types/index.ts +1 -0
  268. package/src/types/llm.ts +3 -3
  269. package/src/types/run.ts +33 -0
  270. package/src/types/stream.ts +1 -1
  271. package/src/types/tools.ts +19 -0
  272. package/src/utils/llmConfig.ts +1 -6
@@ -129,6 +129,13 @@ export abstract class Graph<
129
129
  invokedToolIds?: Set<string>;
130
130
  handlerRegistry: HandlerRegistry | undefined;
131
131
  hookRegistry: HookRegistry | undefined;
132
+ /**
133
+ * Run-scoped HITL configuration. When `humanInTheLoop?.enabled` is
134
+ * `true`, `ToolNode` raises a real `interrupt()` for `PreToolUse`
135
+ * `ask` decisions instead of treating them as a synchronous deny.
136
+ * Threaded from `RunConfig.humanInTheLoop`.
137
+ */
138
+ humanInTheLoop: t.HumanInTheLoopConfig | undefined;
132
139
  /**
133
140
  * Run-scoped config for the tool output reference registry. Threaded
134
141
  * from `RunConfig.toolOutputReferences` down into every ToolNode this
@@ -167,6 +174,7 @@ export abstract class Graph<
167
174
  this.invokedToolIds = undefined;
168
175
  this.handlerRegistry = undefined;
169
176
  this.hookRegistry = undefined;
177
+ this.humanInTheLoop = undefined;
170
178
  this.toolOutputReferences = undefined;
171
179
  /**
172
180
  * ToolNodes compiled from this graph captured the registry
@@ -399,12 +407,25 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
399
407
  ): (string | number | undefined)[] {
400
408
  if (!metadata) return [];
401
409
 
410
+ const configurable = this.config?.configurable;
411
+ const runId =
412
+ (metadata.run_id as string | undefined) ??
413
+ (configurable?.run_id as string | undefined) ??
414
+ this.runId;
415
+ const threadId =
416
+ (metadata.thread_id as string | undefined) ??
417
+ (configurable?.thread_id as string | undefined) ??
418
+ runId;
419
+ const checkpointNs =
420
+ (metadata.checkpoint_ns as string | undefined) ??
421
+ (metadata.langgraph_checkpoint_ns as string | undefined) ??
422
+ '';
402
423
  const keyList = [
403
- metadata.run_id as string,
404
- metadata.thread_id as string,
424
+ runId,
425
+ threadId,
405
426
  metadata.langgraph_node as string,
406
427
  metadata.langgraph_step as number,
407
- metadata.checkpoint_ns as string,
428
+ checkpointNs,
408
429
  ];
409
430
 
410
431
  const agentContext = this.getAgentContext(metadata);
@@ -566,6 +587,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
566
587
  toolCallStepIds: this.toolCallStepIds,
567
588
  toolRegistry: agentContext?.toolRegistry,
568
589
  hookRegistry: this.hookRegistry,
590
+ humanInTheLoop: this.humanInTheLoop,
569
591
  directToolNames: directToolNames.size > 0 ? directToolNames : undefined,
570
592
  maxContextTokens: agentContext?.maxContextTokens,
571
593
  maxToolResultChars: agentContext?.maxToolResultChars,
@@ -1461,7 +1483,14 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1461
1483
  }),
1462
1484
  });
1463
1485
  const workflow = new StateGraph(StateAnnotation)
1464
- .addNode(this.defaultAgentId, agentNode, { ends: [END] })
1486
+ .addNode(
1487
+ this.defaultAgentId,
1488
+ agentNode as Runnable<
1489
+ t.AgentSubgraphState,
1490
+ Partial<t.AgentSubgraphState>
1491
+ >,
1492
+ { ends: [END] }
1493
+ )
1465
1494
  .addEdge(START, this.defaultAgentId)
1466
1495
  // LangGraph compile() types are overly strict for opt-in options
1467
1496
  .compile(this.compileOptions as unknown as never);
@@ -0,0 +1,188 @@
1
+ import { HumanMessage } from '@langchain/core/messages';
2
+ import type { ToolCall } from '@langchain/core/messages/tool';
3
+ import type { RunnableConfig } from '@langchain/core/runnables';
4
+ import type * as t from '@/types';
5
+ import { MultiAgentGraph } from '../MultiAgentGraph';
6
+ import { Constants, Providers } from '@/common';
7
+ import { StandardGraph } from '../Graph';
8
+
9
+ const makeAgent = (agentId: string): t.AgentInputs => ({
10
+ agentId,
11
+ provider: Providers.OPENAI,
12
+ instructions: `You are ${agentId}.`,
13
+ });
14
+
15
+ const makeConfig = (threadId: string): RunnableConfig => ({
16
+ configurable: {
17
+ thread_id: threadId,
18
+ },
19
+ });
20
+
21
+ const makeStreamConfig = (threadId: string): t.WorkflowValuesStreamConfig => ({
22
+ ...makeConfig(threadId),
23
+ streamMode: 'values' as const,
24
+ });
25
+
26
+ const getAiContents = (messages: t.BaseGraphState['messages']): string[] =>
27
+ messages
28
+ .filter((message) => message.getType() === 'ai')
29
+ .map((message) => message.content)
30
+ .filter((content): content is string => typeof content === 'string');
31
+
32
+ const expectCompiledWorkflow = (
33
+ workflow: t.CompiledWorkflow | t.CompiledMultiAgentWorkflow
34
+ ): void => {
35
+ expect(typeof workflow.invoke).toBe('function');
36
+ expect(typeof workflow.stream).toBe('function');
37
+ };
38
+
39
+ describe('LangGraph composition smoke tests', () => {
40
+ it('compiles and invokes the standard single-agent graph', async () => {
41
+ const graph = new StandardGraph({
42
+ runId: 'standard-smoke',
43
+ agents: [makeAgent('agent')],
44
+ });
45
+ graph.overrideTestModel(['standard ok']);
46
+
47
+ const workflow = graph.createWorkflow();
48
+ expectCompiledWorkflow(workflow);
49
+
50
+ const result = await workflow.invoke(
51
+ { messages: [new HumanMessage('hello')] },
52
+ makeConfig('standard-smoke')
53
+ );
54
+
55
+ expect(getAiContents(result.messages)).toEqual(['standard ok']);
56
+ });
57
+
58
+ it('streams values from the standard single-agent graph', async () => {
59
+ const graph = new StandardGraph({
60
+ runId: 'standard-stream-smoke',
61
+ agents: [makeAgent('agent')],
62
+ });
63
+ graph.overrideTestModel(['standard stream ok']);
64
+
65
+ const workflow = graph.createWorkflow();
66
+ const stream = (await workflow.stream(
67
+ { messages: [new HumanMessage('hello')] },
68
+ makeStreamConfig('standard-stream-smoke')
69
+ )) as AsyncIterable<t.BaseGraphState>;
70
+ const chunks: t.BaseGraphState[] = [];
71
+
72
+ for await (const chunk of stream) {
73
+ chunks.push(chunk);
74
+ }
75
+
76
+ expect(chunks.length).toBeGreaterThan(0);
77
+ expect(
78
+ chunks.some((chunk) =>
79
+ getAiContents(chunk.messages).includes('standard stream ok')
80
+ )
81
+ ).toBe(true);
82
+ });
83
+
84
+ it('compiles and invokes a multi-agent graph with one agent and no edges', async () => {
85
+ const graph = new MultiAgentGraph({
86
+ runId: 'multi-single-smoke',
87
+ agents: [makeAgent('A')],
88
+ edges: [],
89
+ });
90
+ graph.overrideTestModel(['multi ok']);
91
+
92
+ const workflow = graph.createWorkflow();
93
+ expectCompiledWorkflow(workflow);
94
+
95
+ const result = await workflow.invoke(
96
+ { messages: [new HumanMessage('hello')] },
97
+ makeConfig('multi-single-smoke')
98
+ );
99
+
100
+ expect(getAiContents(result.messages)).toEqual(['multi ok']);
101
+ });
102
+
103
+ it('compiles and invokes direct sequential edges', async () => {
104
+ const graph = new MultiAgentGraph({
105
+ runId: 'direct-chain-smoke',
106
+ agents: [makeAgent('A'), makeAgent('B')],
107
+ edges: [{ from: 'A', to: 'B', edgeType: 'direct' }],
108
+ });
109
+ graph.overrideTestModel(['from A', 'from B']);
110
+
111
+ const workflow = graph.createWorkflow();
112
+ expectCompiledWorkflow(workflow);
113
+
114
+ const result = await workflow.invoke(
115
+ { messages: [new HumanMessage('start')] },
116
+ makeConfig('direct-chain-smoke')
117
+ );
118
+
119
+ expect(getAiContents(result.messages)).toEqual(['from A', 'from B']);
120
+ });
121
+
122
+ it('compiles and invokes a handoff edge using graph-managed transfer tools', async () => {
123
+ const transferToolCall: ToolCall = {
124
+ id: 'call_transfer_to_B',
125
+ name: `${Constants.LC_TRANSFER_TO_}B`,
126
+ args: { instructions: 'Take over from here.' },
127
+ type: 'tool_call',
128
+ };
129
+ const graph = new MultiAgentGraph({
130
+ runId: 'handoff-smoke',
131
+ agents: [makeAgent('A'), makeAgent('B')],
132
+ edges: [{ from: 'A', to: 'B', edgeType: 'handoff' }],
133
+ });
134
+ graph.overrideTestModel(['routing to B', 'handoff complete'], undefined, [
135
+ transferToolCall,
136
+ ]);
137
+
138
+ const workflow = graph.createWorkflow();
139
+ expectCompiledWorkflow(workflow);
140
+
141
+ const result = await workflow.invoke(
142
+ { messages: [new HumanMessage('start')] },
143
+ makeConfig('handoff-smoke')
144
+ );
145
+
146
+ expect(getAiContents(result.messages)).toContain('handoff complete');
147
+ });
148
+
149
+ it('compiles fan-out/fan-in direct composition with prompt wrapping', () => {
150
+ const graph = new MultiAgentGraph({
151
+ runId: 'fan-in-smoke',
152
+ agents: [
153
+ makeAgent('root'),
154
+ makeAgent('left'),
155
+ makeAgent('right'),
156
+ makeAgent('final'),
157
+ ],
158
+ edges: [
159
+ { from: 'root', to: ['left', 'right'], edgeType: 'direct' },
160
+ {
161
+ from: ['left', 'right'],
162
+ to: 'final',
163
+ edgeType: 'direct',
164
+ prompt: 'Summarize these results:\n{results}',
165
+ },
166
+ ],
167
+ });
168
+
169
+ expectCompiledWorkflow(graph.createWorkflow());
170
+ expect(graph.getParallelGroupId('root')).toBeUndefined();
171
+ expect(graph.getParallelGroupId('left')).toBe(1);
172
+ expect(graph.getParallelGroupId('right')).toBe(1);
173
+ expect(graph.getParallelGroupId('final')).toBeUndefined();
174
+ });
175
+
176
+ it('compiles mixed handoff and direct routing from the same agent', () => {
177
+ const graph = new MultiAgentGraph({
178
+ runId: 'mixed-routing-smoke',
179
+ agents: [makeAgent('router'), makeAgent('handoff'), makeAgent('direct')],
180
+ edges: [
181
+ { from: 'router', to: 'handoff', edgeType: 'handoff' },
182
+ { from: 'router', to: 'direct', edgeType: 'direct' },
183
+ ],
184
+ });
185
+
186
+ expectCompiledWorkflow(graph.createWorkflow());
187
+ });
188
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Typed convenience wrapper around LangGraph's `interrupt()` for the
3
+ * `ask_user_question` interrupt category. Lets a custom graph node
4
+ * (or a tool implementation) suspend execution to collect a free-form
5
+ * answer from the human, without the host having to assemble the
6
+ * interrupt payload by hand. The companion to `Run.resume(answer)` on
7
+ * the host side.
8
+ *
9
+ * AsyncLocalStorage anchoring: this helper does NOT call
10
+ * `runWithConfig` itself — it expects to be invoked from inside a
11
+ * LangGraph node where the framework has already established the
12
+ * runnable config. ToolNode is the one place in this codebase that
13
+ * needs the manual `runWithConfig` shim, because its
14
+ * `RunnableCallable.trace = false` skips the upstream tracing path
15
+ * that normally sets up the AsyncLocalStorage frame; ordinary user
16
+ * nodes (RunnableLambda, addNode callbacks) do not have that
17
+ * constraint.
18
+ */
19
+
20
+ import { interrupt } from '@langchain/langgraph';
21
+ import type {
22
+ AskUserQuestionRequest,
23
+ AskUserQuestionResolution,
24
+ AskUserQuestionInterruptPayload,
25
+ } from '@/types/hitl';
26
+
27
+ /**
28
+ * Suspend the current graph node to ask the human a question. Returns
29
+ * the host-supplied resolution after `Run.resume(resolution)` is
30
+ * called against a Run rebuilt with the same `thread_id` and
31
+ * checkpointer.
32
+ *
33
+ * On the FIRST call (no resume value available), `interrupt()` throws
34
+ * a `GraphInterrupt` that LangGraph catches; this function does not
35
+ * return — execution unwinds, the SDK persists the checkpoint, and
36
+ * the run completes with `run.getInterrupt()` returning a
37
+ * `RunInterruptResult` whose `payload` is an
38
+ * `AskUserQuestionInterruptPayload`.
39
+ *
40
+ * On RESUME, LangGraph re-runs the node from the start and this call
41
+ * returns the host's `AskUserQuestionResolution` directly.
42
+ *
43
+ * Hosts that prefer the raw `interrupt()` (e.g., to attach extra
44
+ * metadata) can construct an `AskUserQuestionInterruptPayload` and
45
+ * call `interrupt()` themselves — this helper is purely convenience.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * const builder = new StateGraph(MessagesAnnotation)
50
+ * .addNode('clarifier', () => {
51
+ * const { answer } = askUserQuestion({
52
+ * question: 'Which environment should I deploy to?',
53
+ * options: [
54
+ * { label: 'Staging', value: 'staging' },
55
+ * { label: 'Production', value: 'production' },
56
+ * ],
57
+ * });
58
+ * return { messages: [new HumanMessage(`Use ${answer}`)] };
59
+ * });
60
+ * ```
61
+ */
62
+ export function askUserQuestion(
63
+ question: AskUserQuestionRequest
64
+ ): AskUserQuestionResolution {
65
+ const payload: AskUserQuestionInterruptPayload = {
66
+ type: 'ask_user_question',
67
+ question,
68
+ };
69
+ return interrupt<AskUserQuestionInterruptPayload, AskUserQuestionResolution>(
70
+ payload
71
+ );
72
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Human-in-the-loop helpers. Type definitions live in `@/types/hitl`
3
+ * and re-export from the top-level types barrel; runtime helpers (like
4
+ * `askUserQuestion()`) live here.
5
+ */
6
+
7
+ export { askUserQuestion } from './askUserQuestion';
@@ -13,6 +13,20 @@ import type { HookEvent, HookMatcher } from './types';
13
13
  */
14
14
  type MatcherBucket = Partial<Record<HookEvent, HookMatcher<HookEvent>[]>>;
15
15
 
16
+ /**
17
+ * Snapshot of a halt request raised by a hook returning
18
+ * `preventContinuation: true`. The SDK's run loop polls for this between
19
+ * stream events and exits cleanly when set, skipping the `Stop` hook
20
+ * (the run is being halted, not naturally completing). One per registry
21
+ * instance — the first hook to halt wins; subsequent halts are ignored
22
+ * so the original reason isn't clobbered.
23
+ */
24
+ export interface HookHaltSignal {
25
+ reason: string;
26
+ /** Event of the hook that triggered the halt (for diagnostics). */
27
+ source: HookEvent;
28
+ }
29
+
16
30
  /**
17
31
  * Run-scoped storage for hook matchers with an additional layer for
18
32
  * session-scoped matchers that should be cleaned up between sessions.
@@ -34,6 +48,18 @@ type MatcherBucket = Partial<Record<HookEvent, HookMatcher<HookEvent>[]>>;
34
48
  export class HookRegistry {
35
49
  private readonly global: MatcherBucket = {};
36
50
  private readonly sessions: Map<string, MatcherBucket> = new Map();
51
+ /**
52
+ * Per-session halt signals. Scoped by `sessionId` (= the run id the
53
+ * hook fired under) so a host that shares one registry across
54
+ * concurrent runs cannot leak `preventContinuation` from one run
55
+ * into another. Without scoping, a halt raised by run A's hook
56
+ * would trip run B's stream-loop poll on the next iteration —
57
+ * silently terminating an unrelated run.
58
+ *
59
+ * Map storage mirrors the reasoning above for session matchers:
60
+ * O(1) insertion in hot paths, no spread-on-write.
61
+ */
62
+ private readonly haltSignals: Map<string, HookHaltSignal> = new Map();
37
63
 
38
64
  /**
39
65
  * Register a matcher for the lifetime of this registry (= one Run).
@@ -125,6 +151,51 @@ export class HookRegistry {
125
151
  this.sessions.delete(sessionId);
126
152
  }
127
153
 
154
+ /**
155
+ * Raise a halt signal scoped to `sessionId` (= the run id the hook
156
+ * fired under). The SDK's run loop polls for this between stream
157
+ * events with the run's own id. First-write-wins per session: a
158
+ * halt already raised by an earlier hook in the same run is
159
+ * preserved so the original `reason` / `source` aren't overwritten.
160
+ *
161
+ * Per-session scoping is critical when hosts share one registry
162
+ * across concurrent runs (e.g. a global policy registered once and
163
+ * reused). Without it, a `preventContinuation` from run A would
164
+ * trip run B's stream-loop poll on the next iteration and silently
165
+ * terminate an unrelated run.
166
+ *
167
+ * Called by the SDK after `executeHooks` returns an aggregate with
168
+ * `preventContinuation: true`. Hosts can also call it directly from
169
+ * inside a hook callback if they want to halt without going through
170
+ * the aggregated return value, but `preventContinuation` is the
171
+ * canonical path.
172
+ */
173
+ haltRun(sessionId: string, reason: string, source: HookEvent): void {
174
+ if (this.haltSignals.has(sessionId)) {
175
+ return;
176
+ }
177
+ this.haltSignals.set(sessionId, { reason, source });
178
+ }
179
+
180
+ /**
181
+ * Returns the halt signal raised by hooks running under `sessionId`,
182
+ * or `undefined` if no hook in that run has halted. Polled by
183
+ * `Run.processStream` between stream events using the run's own id.
184
+ */
185
+ getHaltSignal(sessionId: string): HookHaltSignal | undefined {
186
+ return this.haltSignals.get(sessionId);
187
+ }
188
+
189
+ /**
190
+ * Clears the halt signal for `sessionId`. Called by
191
+ * `Run.processStream` in its `finally` block so a subsequent
192
+ * invocation of the same Run (e.g. resume) starts with a fresh
193
+ * halt state. No-op when no signal exists for that session.
194
+ */
195
+ clearHaltSignal(sessionId: string): void {
196
+ this.haltSignals.delete(sessionId);
197
+ }
198
+
128
199
  /** True if at least one matcher exists for `event` (global + session). */
129
200
  hasHookFor(event: HookEvent, sessionId?: string): boolean {
130
201
  if (readList(this.global, event).length > 0) {
@@ -0,0 +1,259 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import type {
3
+ HookCallback,
4
+ PreToolUseHookInput,
5
+ PreToolUseHookOutput,
6
+ } from '../types';
7
+ import { createToolPolicyHook } from '../createToolPolicyHook';
8
+
9
+ const baseInput: Omit<PreToolUseHookInput, 'toolName'> = {
10
+ hook_event_name: 'PreToolUse',
11
+ runId: 'r-1',
12
+ toolInput: {},
13
+ toolUseId: 'call-1',
14
+ stepId: 'step-1',
15
+ turn: 0,
16
+ };
17
+
18
+ async function callHook(
19
+ hook: HookCallback<'PreToolUse'>,
20
+ toolName: string
21
+ ): Promise<PreToolUseHookOutput> {
22
+ const signal = new AbortController().signal;
23
+ return await hook({ ...baseInput, toolName }, signal);
24
+ }
25
+
26
+ describe('createToolPolicyHook — default mode', () => {
27
+ it('asks for tools that match no rule', async () => {
28
+ const hook = createToolPolicyHook({ mode: 'default' });
29
+ expect((await callHook(hook, 'unknown_tool')).decision).toBe('ask');
30
+ });
31
+
32
+ it('allows tools that match an allow pattern', async () => {
33
+ const hook = createToolPolicyHook({
34
+ mode: 'default',
35
+ allow: ['read_file', 'grep'],
36
+ });
37
+ expect((await callHook(hook, 'read_file')).decision).toBe('allow');
38
+ expect((await callHook(hook, 'grep')).decision).toBe('allow');
39
+ expect((await callHook(hook, 'write_file')).decision).toBe('ask');
40
+ });
41
+
42
+ it('denies tools that match a deny pattern', async () => {
43
+ const hook = createToolPolicyHook({
44
+ mode: 'default',
45
+ deny: ['delete_*'],
46
+ });
47
+ expect((await callHook(hook, 'delete_file')).decision).toBe('deny');
48
+ expect((await callHook(hook, 'read_file')).decision).toBe('ask');
49
+ });
50
+
51
+ it('asks tools that match an ask pattern (redundant in default mode but explicit)', async () => {
52
+ const hook = createToolPolicyHook({
53
+ mode: 'default',
54
+ ask: ['execute_*'],
55
+ });
56
+ expect((await callHook(hook, 'execute_code')).decision).toBe('ask');
57
+ });
58
+ });
59
+
60
+ describe('createToolPolicyHook — dontAsk mode', () => {
61
+ it('denies tools that match no rule (no human prompt)', async () => {
62
+ const hook = createToolPolicyHook({ mode: 'dontAsk' });
63
+ expect((await callHook(hook, 'unknown_tool')).decision).toBe('deny');
64
+ });
65
+
66
+ it('still allows tools that match an allow pattern', async () => {
67
+ const hook = createToolPolicyHook({
68
+ mode: 'dontAsk',
69
+ allow: ['read_*'],
70
+ });
71
+ expect((await callHook(hook, 'read_file')).decision).toBe('allow');
72
+ expect((await callHook(hook, 'write_file')).decision).toBe('deny');
73
+ });
74
+
75
+ it('still asks tools that match an explicit ask pattern (overrides dontAsk default)', async () => {
76
+ const hook = createToolPolicyHook({
77
+ mode: 'dontAsk',
78
+ ask: ['execute_*'],
79
+ });
80
+ expect((await callHook(hook, 'execute_code')).decision).toBe('ask');
81
+ expect((await callHook(hook, 'unknown_tool')).decision).toBe('deny');
82
+ });
83
+ });
84
+
85
+ describe('createToolPolicyHook — bypass mode', () => {
86
+ it('allows everything by default', async () => {
87
+ const hook = createToolPolicyHook({ mode: 'bypass' });
88
+ expect((await callHook(hook, 'anything')).decision).toBe('allow');
89
+ expect((await callHook(hook, 'execute_code')).decision).toBe('allow');
90
+ });
91
+
92
+ it('still denies tools that match a deny pattern (deny always wins)', async () => {
93
+ const hook = createToolPolicyHook({
94
+ mode: 'bypass',
95
+ deny: ['delete_*'],
96
+ });
97
+ expect((await callHook(hook, 'delete_file')).decision).toBe('deny');
98
+ expect((await callHook(hook, 'read_file')).decision).toBe('allow');
99
+ });
100
+
101
+ it('overrides explicit ask patterns (bypass means stop asking)', async () => {
102
+ const hook = createToolPolicyHook({
103
+ mode: 'bypass',
104
+ ask: ['execute_*'],
105
+ });
106
+ expect((await callHook(hook, 'execute_code')).decision).toBe('allow');
107
+ });
108
+ });
109
+
110
+ describe('createToolPolicyHook — pattern matching', () => {
111
+ it('matches glob `*` wildcards', async () => {
112
+ const hook = createToolPolicyHook({
113
+ mode: 'default',
114
+ allow: ['mcp:github:*'],
115
+ });
116
+ expect((await callHook(hook, 'mcp:github:create_issue')).decision).toBe(
117
+ 'allow'
118
+ );
119
+ expect((await callHook(hook, 'mcp:github:list_repos')).decision).toBe(
120
+ 'allow'
121
+ );
122
+ expect((await callHook(hook, 'mcp:slack:post')).decision).toBe('ask');
123
+ });
124
+
125
+ it('matches exact tool names', async () => {
126
+ const hook = createToolPolicyHook({
127
+ mode: 'default',
128
+ allow: ['read_file'],
129
+ });
130
+ expect((await callHook(hook, 'read_file')).decision).toBe('allow');
131
+ expect((await callHook(hook, 'read_file_lines')).decision).toBe('ask');
132
+ });
133
+
134
+ it('escapes regex metacharacters in literal portions', async () => {
135
+ const hook = createToolPolicyHook({
136
+ mode: 'default',
137
+ allow: ['tool.with.dots'],
138
+ });
139
+ expect((await callHook(hook, 'tool.with.dots')).decision).toBe('allow');
140
+ /** A literal regex `.` would also match `tool_with_dots`; glob shouldn't. */
141
+ expect((await callHook(hook, 'tool_with_dots')).decision).toBe('ask');
142
+ });
143
+
144
+ it('matches wildcards in the middle and end', async () => {
145
+ const hook = createToolPolicyHook({
146
+ mode: 'default',
147
+ ask: ['*search*'],
148
+ });
149
+ expect((await callHook(hook, 'web_search')).decision).toBe('ask');
150
+ expect((await callHook(hook, 'searcher')).decision).toBe('ask');
151
+ expect((await callHook(hook, 'read_file')).decision).toBe('ask'); // default mode
152
+ /** Confirm the ask path tagged it (not the fallthrough): explicit ask hits before mode fallthrough. */
153
+ });
154
+ });
155
+
156
+ describe('createToolPolicyHook — precedence', () => {
157
+ it('deny wins over allow', async () => {
158
+ const hook = createToolPolicyHook({
159
+ mode: 'default',
160
+ allow: ['read_*'],
161
+ deny: ['read_secret'],
162
+ });
163
+ expect((await callHook(hook, 'read_secret')).decision).toBe('deny');
164
+ expect((await callHook(hook, 'read_file')).decision).toBe('allow');
165
+ });
166
+
167
+ it('deny wins over bypass mode', async () => {
168
+ const hook = createToolPolicyHook({
169
+ mode: 'bypass',
170
+ deny: ['delete_*'],
171
+ });
172
+ expect((await callHook(hook, 'delete_file')).decision).toBe('deny');
173
+ expect((await callHook(hook, 'anything_else')).decision).toBe('allow');
174
+ });
175
+
176
+ it('allow wins over ask in default mode', async () => {
177
+ const hook = createToolPolicyHook({
178
+ mode: 'default',
179
+ allow: ['execute_safe'],
180
+ ask: ['execute_*'],
181
+ });
182
+ expect((await callHook(hook, 'execute_safe')).decision).toBe('allow');
183
+ expect((await callHook(hook, 'execute_dangerous')).decision).toBe('ask');
184
+ });
185
+ });
186
+
187
+ describe('createToolPolicyHook — reason', () => {
188
+ it('attaches the configured reason to ask and deny decisions', async () => {
189
+ const hook = createToolPolicyHook({
190
+ mode: 'default',
191
+ deny: ['delete_*'],
192
+ reason: 'Tool {tool} requires manual review',
193
+ });
194
+ const denied = await callHook(hook, 'delete_file');
195
+ expect(denied.decision).toBe('deny');
196
+ expect(denied.reason).toBe('Tool delete_file requires manual review');
197
+
198
+ const asked = await callHook(hook, 'unknown_tool');
199
+ expect(asked.decision).toBe('ask');
200
+ expect(asked.reason).toBe('Tool unknown_tool requires manual review');
201
+ });
202
+
203
+ it('omits the reason field for allow decisions', async () => {
204
+ const hook = createToolPolicyHook({
205
+ mode: 'default',
206
+ allow: ['read_*'],
207
+ reason: 'never seen',
208
+ });
209
+ const result = await callHook(hook, 'read_file');
210
+ expect(result.decision).toBe('allow');
211
+ expect(result.reason).toBeUndefined();
212
+ });
213
+
214
+ it('does not add a reason field when no template is configured', async () => {
215
+ const hook = createToolPolicyHook({ mode: 'dontAsk' });
216
+ const result = await callHook(hook, 'unknown_tool');
217
+ expect(result.decision).toBe('deny');
218
+ expect(result.reason).toBeUndefined();
219
+ });
220
+ });
221
+
222
+ describe('createToolPolicyHook — registry integration', () => {
223
+ it('works when registered as a PreToolUse hook (round-trip via executeHooks)', async () => {
224
+ const { HookRegistry, executeHooks } = await import('../index');
225
+ const registry = new HookRegistry();
226
+ registry.register('PreToolUse', {
227
+ hooks: [
228
+ createToolPolicyHook({
229
+ mode: 'default',
230
+ allow: ['read_file'],
231
+ deny: ['delete_*'],
232
+ reason: 'review {tool}',
233
+ }),
234
+ ],
235
+ });
236
+
237
+ const allow = await executeHooks({
238
+ registry,
239
+ input: { ...baseInput, toolName: 'read_file' },
240
+ matchQuery: 'read_file',
241
+ });
242
+ expect(allow.decision).toBe('allow');
243
+
244
+ const deny = await executeHooks({
245
+ registry,
246
+ input: { ...baseInput, toolName: 'delete_file' },
247
+ matchQuery: 'delete_file',
248
+ });
249
+ expect(deny.decision).toBe('deny');
250
+ expect(deny.reason).toBe('review delete_file');
251
+
252
+ const ask = await executeHooks({
253
+ registry,
254
+ input: { ...baseInput, toolName: 'mystery_tool' },
255
+ matchQuery: 'mystery_tool',
256
+ });
257
+ expect(ask.decision).toBe('ask');
258
+ });
259
+ });