@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
@@ -10,9 +10,11 @@ import {
10
10
  Send,
11
11
  Command,
12
12
  isCommand,
13
+ interrupt,
13
14
  isGraphInterrupt,
14
15
  MessagesAnnotation,
15
16
  } from '@langchain/langgraph';
17
+ import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons';
16
18
  import type {
17
19
  RunnableConfig,
18
20
  RunnableToolLike,
@@ -24,7 +26,11 @@ import type {
24
26
  PreResolvedArgsMap,
25
27
  ResolvedArgsByCallId,
26
28
  } from '@/tools/toolOutputReferences';
27
- import type { HookRegistry, AggregatedHookResult } from '@/hooks';
29
+ import type {
30
+ HookRegistry,
31
+ AggregatedHookResult,
32
+ PostToolBatchEntry,
33
+ } from '@/hooks';
28
34
  import type * as t from '@/types';
29
35
  import { RunnableCallable } from '@/utils';
30
36
  import {
@@ -33,6 +39,7 @@ import {
33
39
  } from '@/utils/truncation';
34
40
  import { safeDispatchCustomEvent } from '@/utils/events';
35
41
  import { executeHooks } from '@/hooks';
42
+ import { toLangChainContent } from '@/messages/langchain';
36
43
  import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
37
44
  import {
38
45
  buildReferenceKey,
@@ -89,6 +96,117 @@ function isSend(value: unknown): value is Send {
89
96
  return value instanceof Send;
90
97
  }
91
98
 
99
+ /**
100
+ * Format a fail-closed diagnostic for malformed approval-decision
101
+ * fields. Hosts deserialize resume payloads from untyped JSON, so
102
+ * `responseText` and `updatedInput` can land here as anything; the
103
+ * blocking ToolMessage carries this string so the host can debug the
104
+ * exact wire shape that was rejected.
105
+ */
106
+ function describeOfferedShape(value: unknown): string {
107
+ if (value === undefined) {
108
+ return '<missing>';
109
+ }
110
+ if (value === null) {
111
+ return 'null';
112
+ }
113
+ if (Array.isArray(value)) {
114
+ return 'array';
115
+ }
116
+ return typeof value;
117
+ }
118
+
119
+ /**
120
+ * Per-entry record collected during PreToolUse hook handling for tool
121
+ * calls that need human approval. Carries everything
122
+ * `buildToolApprovalInterruptPayload` needs to assemble the interrupt
123
+ * payload, plus the per-tool decision allowlist if the hook supplied
124
+ * one. Defined at module scope so the payload-builder helper can be
125
+ * extracted out of `dispatchToolEvents` without leaking the locally-
126
+ * inferred shape.
127
+ */
128
+ type AskEntry = {
129
+ entry: {
130
+ call: ToolCall;
131
+ args: Record<string, unknown>;
132
+ stepId: string;
133
+ };
134
+ reason?: string;
135
+ allowedDecisions?: ReadonlyArray<'approve' | 'reject' | 'edit' | 'respond'>;
136
+ };
137
+
138
+ /**
139
+ * Build the `tool_approval` interrupt payload from the set of pending
140
+ * `ask`-decision entries collected during PreToolUse hook handling.
141
+ * Pure function — doesn't touch ToolNode state — so it lives at module
142
+ * scope. The interrupt itself is raised by the caller (which still
143
+ * needs `interrupt()` plus the AsyncLocalStorage anchoring shim).
144
+ */
145
+ function buildToolApprovalInterruptPayload(
146
+ askEntries: ReadonlyArray<AskEntry>
147
+ ): t.ToolApprovalInterruptPayload {
148
+ return {
149
+ type: 'tool_approval',
150
+ action_requests: askEntries.map(({ entry, reason }) => {
151
+ const request: t.ToolApprovalRequest = {
152
+ tool_call_id: entry.call.id!,
153
+ name: entry.call.name,
154
+ arguments: entry.args,
155
+ };
156
+ if (reason != null) {
157
+ request.description = reason;
158
+ }
159
+ return request;
160
+ }),
161
+ review_configs: askEntries.map(({ entry, allowedDecisions }) => ({
162
+ action_name: entry.call.name,
163
+ tool_call_id: entry.call.id!,
164
+ allowed_decisions: (allowedDecisions ?? [
165
+ 'approve',
166
+ 'reject',
167
+ 'edit',
168
+ 'respond',
169
+ ]) as t.ToolApprovalDecisionType[],
170
+ })),
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Build a `tool_call_id → ToolApprovalDecision` map from the host's
176
+ * resume value. Hosts may return decisions either as an array (one per
177
+ * action_request, in order) or as a record keyed by `tool_call_id`. Any
178
+ * unrecognized shape (or a decision missing for a given call id) is
179
+ * treated as "no decision" by callers — typically rejected so the run
180
+ * doesn't silently invoke a tool the human never approved.
181
+ */
182
+ function normalizeApprovalDecisions(
183
+ callIds: string[],
184
+ resumeValue: t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap | undefined
185
+ ): Map<string, t.ToolApprovalDecision> {
186
+ const map = new Map<string, t.ToolApprovalDecision>();
187
+ if (resumeValue == null) {
188
+ return map;
189
+ }
190
+ if (Array.isArray(resumeValue)) {
191
+ const limit = Math.min(callIds.length, resumeValue.length);
192
+ for (let i = 0; i < limit; i++) {
193
+ map.set(callIds[i], resumeValue[i]);
194
+ }
195
+ return map;
196
+ }
197
+ if (typeof resumeValue === 'object') {
198
+ for (const callId of callIds) {
199
+ const decision = (resumeValue as Partial<t.ToolApprovalDecisionMap>)[
200
+ callId
201
+ ];
202
+ if (decision !== undefined) {
203
+ map.set(callId, decision);
204
+ }
205
+ }
206
+ }
207
+ return map;
208
+ }
209
+
92
210
  /**
93
211
  * Merges code execution session context into the sessions map.
94
212
  *
@@ -170,6 +288,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
170
288
  private maxToolResultChars: number;
171
289
  /** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
172
290
  private hookRegistry?: HookRegistry;
291
+ /**
292
+ * Run-scoped HITL config. When `enabled`, `ask` decisions from
293
+ * PreToolUse hooks raise a LangGraph `interrupt()` instead of being
294
+ * treated as fail-closed denies.
295
+ */
296
+ private humanInTheLoop?: t.HumanInTheLoopConfig;
173
297
  /**
174
298
  * Registry of tool outputs keyed by `tool<idx>turn<turn>`.
175
299
  *
@@ -208,6 +332,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
208
332
  maxContextTokens,
209
333
  maxToolResultChars,
210
334
  hookRegistry,
335
+ humanInTheLoop,
211
336
  toolOutputReferences,
212
337
  toolOutputRegistry,
213
338
  }: t.ToolNodeConstructorParams) {
@@ -226,6 +351,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
226
351
  this.maxToolResultChars =
227
352
  maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
228
353
  this.hookRegistry = hookRegistry;
354
+ this.humanInTheLoop = humanInTheLoop;
229
355
  /**
230
356
  * Precedence: an explicitly passed `toolOutputRegistry` instance
231
357
  * wins over a config object so a host (`Graph`) can share one
@@ -880,16 +1006,43 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
880
1006
 
881
1007
  const messageByCallId = new Map<string, ToolMessage>();
882
1008
  const approvedEntries: typeof preToolCalls = [];
1009
+ /**
1010
+ * Batch-level accumulator for `additionalContext` strings returned
1011
+ * by any PreToolUse / PostToolUse / PostToolUseFailure hook in this
1012
+ * dispatch. We emit one consolidated `HumanMessage` after all tool
1013
+ * results land so the next model turn sees the injected context
1014
+ * exactly once, ordered after the ToolMessages.
1015
+ */
1016
+ const batchAdditionalContexts: string[] = [];
1017
+ /**
1018
+ * Batch-level outcome record keyed by `tool_call_id`. Captures
1019
+ * every tool call's final result (success / error from the host,
1020
+ * blocked from HITL deny / reject, substituted from HITL respond)
1021
+ * across the three call sites that touch it. We materialize the
1022
+ * `PostToolBatch` entry array in `toolCalls` order at dispatch
1023
+ * time so hooks correlating outcomes by position see exactly the
1024
+ * same sequence the model emitted — independent of when each
1025
+ * outcome was recorded (deny entries land synchronously in the
1026
+ * hook loop, approved entries land after host execution, respond
1027
+ * entries land in the resume branch).
1028
+ */
1029
+ const postToolBatchEntryByCallId = new Map<string, PostToolBatchEntry>();
883
1030
  const HOOK_FALLBACK: AggregatedHookResult = Object.freeze({
884
1031
  additionalContexts: [] as string[],
885
1032
  errors: [] as string[],
886
1033
  });
887
1034
 
888
1035
  if (this.hookRegistry?.hasHookFor('PreToolUse', runId) === true) {
1036
+ /**
1037
+ * Capture as a non-null local so the inner `blockEntry` closure
1038
+ * doesn't lose narrowing on `this.hookRegistry` and we don't have
1039
+ * to defensively `?.` it across every reference inside.
1040
+ */
1041
+ const hookRegistry = this.hookRegistry;
889
1042
  const preResults = await Promise.all(
890
1043
  preToolCalls.map((entry) =>
891
1044
  executeHooks({
892
- registry: this.hookRegistry!,
1045
+ registry: hookRegistry,
893
1046
  input: {
894
1047
  hook_event_name: 'PreToolUse',
895
1048
  runId,
@@ -907,89 +1060,441 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
907
1060
  )
908
1061
  );
909
1062
 
910
- for (let i = 0; i < preToolCalls.length; i++) {
911
- const hookResult = preResults[i];
912
- const entry = preToolCalls[i];
913
- const isDenied =
914
- hookResult.decision === 'deny' || hookResult.decision === 'ask';
915
- if (isDenied) {
916
- const reason = hookResult.reason ?? 'Blocked by hook';
917
- const contentString = `Blocked: ${reason}`;
918
- messageByCallId.set(
919
- entry.call.id!,
920
- new ToolMessage({
921
- status: 'error',
922
- content: contentString,
923
- name: entry.call.name,
924
- tool_call_id: entry.call.id!,
925
- })
926
- );
1063
+ type PendingEntry = (typeof preToolCalls)[number];
1064
+
1065
+ /**
1066
+ * Side effects deferred from `blockEntry` until after any pending
1067
+ * `interrupt()` resolves. Without deferral, a batch that mixes a
1068
+ * `deny` decision with an `ask` decision would dispatch
1069
+ * `ON_RUN_STEP_COMPLETED` for the denied tool on the FIRST node
1070
+ * execution (before `interrupt()` throws), then dispatch the
1071
+ * same event AGAIN on the resume re-execution — hosts would
1072
+ * observe two completion events for one logical denial. By
1073
+ * queueing the dispatch + PermissionDenied hook here and
1074
+ * flushing after the interrupt block, we ensure each side effect
1075
+ * fires exactly once: never on the first pass when interrupt
1076
+ * throws (the flush is unreachable), once on resume / no-ask
1077
+ * passes when control reaches the flush.
1078
+ */
1079
+ const deferredBlockedSideEffects: Array<{
1080
+ callId: string;
1081
+ toolName: string;
1082
+ args: Record<string, unknown>;
1083
+ contentString: string;
1084
+ reason: string;
1085
+ }> = [];
1086
+
1087
+ const blockEntry = (entry: PendingEntry, reason: string): void => {
1088
+ const contentString = `Blocked: ${reason}`;
1089
+ messageByCallId.set(
1090
+ entry.call.id!,
1091
+ new ToolMessage({
1092
+ status: 'error',
1093
+ content: contentString,
1094
+ name: entry.call.name,
1095
+ tool_call_id: entry.call.id!,
1096
+ })
1097
+ );
1098
+ postToolBatchEntryByCallId.set(entry.call.id!, {
1099
+ toolName: entry.call.name,
1100
+ toolInput: entry.args,
1101
+ toolUseId: entry.call.id!,
1102
+ stepId: entry.stepId,
1103
+ /**
1104
+ * Records the pre-invocation turn count — the same value the
1105
+ * executed path captures before incrementing `toolUsageCount`.
1106
+ * For a blocked tool the counter is never incremented (no
1107
+ * invocation happened), so this is always the count of prior
1108
+ * successful invocations of this tool name in earlier batches.
1109
+ * Surfaces in the `PostToolBatch` entry so batch hooks see
1110
+ * a uniform shape regardless of outcome.
1111
+ */
1112
+ turn: this.toolUsageCount.get(entry.call.name) ?? 0,
1113
+ status: 'error',
1114
+ error: contentString,
1115
+ });
1116
+ deferredBlockedSideEffects.push({
1117
+ callId: entry.call.id!,
1118
+ toolName: entry.call.name,
1119
+ args: entry.args,
1120
+ contentString,
1121
+ reason,
1122
+ });
1123
+ };
1124
+
1125
+ const flushDeferredBlockedSideEffects = (): void => {
1126
+ for (const item of deferredBlockedSideEffects) {
927
1127
  this.dispatchStepCompleted(
928
- entry.call.id!,
929
- entry.call.name,
930
- entry.args,
931
- contentString,
1128
+ item.callId,
1129
+ item.toolName,
1130
+ item.args,
1131
+ item.contentString,
932
1132
  config
933
1133
  );
934
- if (this.hookRegistry.hasHookFor('PermissionDenied', runId)) {
1134
+ if (hookRegistry.hasHookFor('PermissionDenied', runId)) {
935
1135
  executeHooks({
936
- registry: this.hookRegistry,
1136
+ registry: hookRegistry,
937
1137
  input: {
938
1138
  hook_event_name: 'PermissionDenied',
939
1139
  runId,
940
1140
  threadId,
941
1141
  agentId: this.agentId,
942
- toolName: entry.call.name,
943
- toolInput: entry.args,
944
- toolUseId: entry.call.id!,
945
- reason,
1142
+ toolName: item.toolName,
1143
+ toolInput: item.args,
1144
+ toolUseId: item.callId,
1145
+ reason: item.reason,
946
1146
  },
947
1147
  sessionId: runId,
948
- matchQuery: entry.call.name,
1148
+ matchQuery: item.toolName,
949
1149
  }).catch(() => {
950
1150
  /* PermissionDenied is observational — swallow errors */
951
1151
  });
952
1152
  }
1153
+ }
1154
+ deferredBlockedSideEffects.length = 0;
1155
+ };
1156
+
1157
+ /**
1158
+ * Apply a hook-supplied or host-supplied input override to a pending
1159
+ * entry, re-running the `{{tool<i>turn<n>}}` resolver so any new
1160
+ * placeholders introduced by the override are substituted (and any
1161
+ * formerly-unresolved refs cleared from the unresolved set).
1162
+ *
1163
+ * Mixed direct+event batches must use the pre-batch snapshot so a
1164
+ * hook-introduced placeholder cannot accidentally resolve to a
1165
+ * same-turn direct output that has just registered. Pure event
1166
+ * batches don't have a snapshot and resolve against the live
1167
+ * registry — safe because no event-side registrations have happened
1168
+ * yet.
1169
+ */
1170
+ const applyInputOverride = (
1171
+ entry: PendingEntry,
1172
+ nextArgs: Record<string, unknown>
1173
+ ): void => {
1174
+ if (registry != null) {
1175
+ const view: ToolOutputResolveView = preBatchSnapshot ?? {
1176
+ resolve: <T>(args: T) => registry.resolve(registryRunId, args),
1177
+ };
1178
+ const { resolved, unresolved } = view.resolve(nextArgs);
1179
+ entry.args = resolved as Record<string, unknown>;
1180
+ if (entry.call.id != null) {
1181
+ if (unresolved.length > 0) {
1182
+ unresolvedByCallId.set(entry.call.id, unresolved);
1183
+ } else {
1184
+ unresolvedByCallId.delete(entry.call.id);
1185
+ }
1186
+ }
1187
+ return;
1188
+ }
1189
+ entry.args = nextArgs;
1190
+ };
1191
+
1192
+ const askEntries: Array<{
1193
+ entry: PendingEntry;
1194
+ reason?: string;
1195
+ allowedDecisions?: ReadonlyArray<
1196
+ 'approve' | 'reject' | 'edit' | 'respond'
1197
+ >;
1198
+ }> = [];
1199
+
1200
+ for (let i = 0; i < preToolCalls.length; i++) {
1201
+ const hookResult = preResults[i];
1202
+ const entry = preToolCalls[i];
1203
+
1204
+ for (const ctx of hookResult.additionalContexts) {
1205
+ batchAdditionalContexts.push(ctx);
1206
+ }
1207
+
1208
+ if (hookResult.decision === 'deny') {
1209
+ blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
953
1210
  continue;
954
1211
  }
1212
+
1213
+ if (hookResult.decision === 'ask') {
1214
+ /**
1215
+ * HITL is OFF by default — hosts must explicitly opt in via
1216
+ * `humanInTheLoop: { enabled: true }` to engage the
1217
+ * `interrupt()` path. When opted out (or omitted), `ask`
1218
+ * collapses into the pre-HITL fail-closed path: a blocked
1219
+ * tool with an error `ToolMessage`. The default stays
1220
+ * conservative until host UIs are ready to render
1221
+ * `tool_approval` interrupts; see `HumanInTheLoopConfig`
1222
+ * JSDoc for the full rationale and the migration plan.
1223
+ */
1224
+ if (this.humanInTheLoop?.enabled !== true) {
1225
+ blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
1226
+ continue;
1227
+ }
1228
+ /**
1229
+ * Apply `updatedInput` BEFORE queuing into `askEntries` —
1230
+ * a hook is allowed to return both a sanitization rewrite
1231
+ * and an `ask` decision (e.g. one matcher redacts secrets,
1232
+ * another matcher requires approval). Without this, the
1233
+ * interrupt payload would surface the original args to the
1234
+ * reviewer AND the post-approve execution would run with
1235
+ * the original args, silently dropping the hook's rewrite.
1236
+ */
1237
+ if (hookResult.updatedInput != null) {
1238
+ applyInputOverride(entry, hookResult.updatedInput);
1239
+ }
1240
+ askEntries.push({
1241
+ entry,
1242
+ reason: hookResult.reason,
1243
+ allowedDecisions: hookResult.allowedDecisions,
1244
+ });
1245
+ continue;
1246
+ }
1247
+
955
1248
  if (hookResult.updatedInput != null) {
1249
+ applyInputOverride(entry, hookResult.updatedInput);
1250
+ }
1251
+ approvedEntries.push(entry);
1252
+ }
1253
+
1254
+ /**
1255
+ * If any entries asked for approval, raise a single LangGraph
1256
+ * `interrupt()` carrying every pending request together. The host
1257
+ * pauses, gathers human input, and resumes the run with one
1258
+ * decision per request. On resume LangGraph re-executes this node
1259
+ * from the start; `interrupt()` then returns the resume value
1260
+ * instead of throwing, so the loop above re-runs and the same
1261
+ * `askEntries` list is rebuilt deterministically (assuming hooks
1262
+ * are pure — see `humanInTheLoop` docs).
1263
+ */
1264
+ if (askEntries.length > 0) {
1265
+ const payload = buildToolApprovalInterruptPayload(askEntries);
1266
+
1267
+ /**
1268
+ * `interrupt()` reads the current `RunnableConfig` from
1269
+ * AsyncLocalStorage, but our `RunnableCallable` sets
1270
+ * `trace = false` for ToolNode (intentional — avoids LangSmith
1271
+ * tracing per tool call). Without the trace path, the upstream
1272
+ * `runWithConfig` frame is never established, so we re-anchor
1273
+ * here using the node's own `config` — Pregel hands us a
1274
+ * config that already carries every checkpoint/scratchpad key
1275
+ * `interrupt()` needs to suspend and resume.
1276
+ */
1277
+ const resumeValue = AsyncLocalStorageProviderSingleton.runWithConfig(
1278
+ config,
1279
+ () =>
1280
+ interrupt<
1281
+ t.ToolApprovalInterruptPayload,
1282
+ t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap
1283
+ >(payload)
1284
+ );
1285
+
1286
+ const decisionByCallId = normalizeApprovalDecisions(
1287
+ askEntries.map(({ entry }) => entry.call.id!),
1288
+ resumeValue
1289
+ );
1290
+
1291
+ for (const {
1292
+ entry,
1293
+ reason: askReason,
1294
+ allowedDecisions,
1295
+ } of askEntries) {
1296
+ const decision = decisionByCallId.get(entry.call.id!) ?? {
1297
+ type: 'reject' as const,
1298
+ reason: 'No decision provided for tool approval',
1299
+ };
1300
+ /**
1301
+ * Read `decision.type` through a widened view once: hosts
1302
+ * deserialize resume payloads from untyped JSON, so the
1303
+ * runtime value can be a typo, the wrong type, or missing
1304
+ * entirely. Both the `allowedDecisions` enforcement
1305
+ * immediately below and the unknown-type fallthrough at the
1306
+ * end of this loop body share this single read so the
1307
+ * fail-closed checks compare against the same source.
1308
+ */
1309
+ const declaredType = (decision as { type?: unknown }).type;
1310
+
1311
+ /**
1312
+ * Enforce the per-tool `allowedDecisions` allowlist that the
1313
+ * `PreToolUse` hook surfaced in `review_configs`. The host
1314
+ * UI is supposed to honor this when collecting the user's
1315
+ * decision, but the wire is untrusted: a buggy or hostile
1316
+ * host could submit a decision type the policy explicitly
1317
+ * forbids (e.g. `'edit'` when the hook restricted to
1318
+ * `['approve', 'reject']`), bypassing argument-mutation /
1319
+ * response-substitution safeguards. Fail closed when the
1320
+ * declared type isn't in the allowlist.
1321
+ */
1322
+ if (
1323
+ allowedDecisions != null &&
1324
+ (typeof declaredType !== 'string' ||
1325
+ !allowedDecisions.includes(
1326
+ declaredType as t.ToolApprovalDecisionType
1327
+ ))
1328
+ ) {
1329
+ const offered =
1330
+ typeof declaredType === 'string' ? declaredType : '<missing>';
1331
+ blockEntry(
1332
+ entry,
1333
+ `Decision "${offered}" not in allowedDecisions [${allowedDecisions.join(', ')}] — failing closed`
1334
+ );
1335
+ continue;
1336
+ }
1337
+
1338
+ if (decision.type === 'reject') {
1339
+ blockEntry(
1340
+ entry,
1341
+ decision.reason ?? askReason ?? 'Rejected by user'
1342
+ );
1343
+ continue;
1344
+ }
1345
+
956
1346
  /**
957
- * Re-resolve after PreToolUse replaces the input: a hook may
958
- * introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
959
- * copying user-supplied text) that the pre-hook pass never
960
- * saw. Re-running the resolver on the hook-rewritten args
961
- * keeps substitution and the unresolved-refs record in sync
962
- * with what the tool will actually receive.
1347
+ * `respond` short-circuits tool execution: the human supplies
1348
+ * the result the model should see in place of running the
1349
+ * tool. We emit a successful `ToolMessage` directly and skip
1350
+ * dispatch no host event fires, no real tool side effect
1351
+ * occurs. Mirrors LangChain HITL middleware semantics.
963
1352
  */
964
- if (registry != null) {
1353
+ if (decision.type === 'respond') {
965
1354
  /**
966
- * Mixed direct+event batches must use the pre-batch
967
- * snapshot so a hook-introduced placeholder cannot
968
- * accidentally resolve to a same-turn direct output that
969
- * has just registered. Pure event batches don't have a
970
- * snapshot and resolve against the live registry — safe
971
- * because no event-side registrations have happened yet.
1355
+ * Validate the wire shape before touching it: hosts
1356
+ * deserialize resume payloads from untyped JSON, so a
1357
+ * malformed `{ type: 'respond' }` (no `responseText`) or
1358
+ * `{ type: 'respond', responseText: 42 }` would crash
1359
+ * `truncateToolResultContent` (which calls
1360
+ * `content.length`) and turn a fail-closed approval path
1361
+ * into a hard run failure. Route bad shapes through
1362
+ * `blockEntry` like any other unusable decision.
972
1363
  */
973
- const view: ToolOutputResolveView = preBatchSnapshot ?? {
974
- resolve: <T>(args: T) => registry.resolve(registryRunId, args),
975
- };
976
- const { resolved, unresolved } = view.resolve(
977
- hookResult.updatedInput
1364
+ const responseText = (decision as { responseText?: unknown })
1365
+ .responseText;
1366
+ if (typeof responseText !== 'string') {
1367
+ blockEntry(
1368
+ entry,
1369
+ `Decision "respond" missing string responseText (got ${describeOfferedShape(responseText)}) — failing closed`
1370
+ );
1371
+ continue;
1372
+ }
1373
+ /**
1374
+ * Truncate the human-supplied text just like the success
1375
+ * path does for real tool output. Without this, a user
1376
+ * pasting a large document as a manual response bypasses
1377
+ * `maxToolResultChars` and can blow past the model's
1378
+ * context window. The PostToolBatch entry surfaces the
1379
+ * truncated text too so batch hooks see what the model
1380
+ * will actually see.
1381
+ */
1382
+ const truncatedResponse = truncateToolResultContent(
1383
+ responseText,
1384
+ this.maxToolResultChars
978
1385
  );
979
- entry.args = resolved as Record<string, unknown>;
980
- if (entry.call.id != null) {
981
- if (unresolved.length > 0) {
982
- unresolvedByCallId.set(entry.call.id, unresolved);
983
- } else {
984
- unresolvedByCallId.delete(entry.call.id);
985
- }
1386
+ messageByCallId.set(
1387
+ entry.call.id!,
1388
+ new ToolMessage({
1389
+ status: 'success',
1390
+ content: truncatedResponse,
1391
+ name: entry.call.name,
1392
+ tool_call_id: entry.call.id!,
1393
+ })
1394
+ );
1395
+ postToolBatchEntryByCallId.set(entry.call.id!, {
1396
+ toolName: entry.call.name,
1397
+ toolInput: entry.args,
1398
+ toolUseId: entry.call.id!,
1399
+ stepId: entry.stepId,
1400
+ turn: this.toolUsageCount.get(entry.call.name) ?? 0,
1401
+ status: 'success',
1402
+ toolOutput: truncatedResponse,
1403
+ });
1404
+ /**
1405
+ * Safe to dispatch immediately — unlike `blockEntry` which
1406
+ * defers, `respond` only executes inside the decision-
1407
+ * processing loop, which is reachable only AFTER
1408
+ * `interrupt()` has returned (the resume pass). There is
1409
+ * no risk of being rolled back by a subsequent throw, so
1410
+ * no risk of a duplicate `ON_RUN_STEP_COMPLETED` event.
1411
+ */
1412
+ this.dispatchStepCompleted(
1413
+ entry.call.id!,
1414
+ entry.call.name,
1415
+ entry.args,
1416
+ truncatedResponse,
1417
+ config
1418
+ );
1419
+ continue;
1420
+ }
1421
+
1422
+ if (decision.type === 'edit') {
1423
+ /**
1424
+ * Validate the wire shape before touching it: hosts
1425
+ * deserialize resume payloads from untyped JSON, so a
1426
+ * malformed `{ type: 'edit' }` (no `updatedInput`),
1427
+ * `{ type: 'edit', updatedInput: 'string' }` (non-object),
1428
+ * or `{ type: 'edit', updatedInput: [...] }` (array, not a
1429
+ * plain object) would feed garbage into
1430
+ * `applyInputOverride` and silently approve a tool with
1431
+ * undefined / wrong-shape args. Same trust boundary as
1432
+ * the `respond` validation above — fail closed via
1433
+ * `blockEntry` with a diagnostic.
1434
+ */
1435
+ const updatedInput = (decision as { updatedInput?: unknown })
1436
+ .updatedInput;
1437
+ if (
1438
+ updatedInput === null ||
1439
+ typeof updatedInput !== 'object' ||
1440
+ Array.isArray(updatedInput)
1441
+ ) {
1442
+ blockEntry(
1443
+ entry,
1444
+ `Decision "edit" missing object updatedInput (got ${describeOfferedShape(updatedInput)}) — failing closed`
1445
+ );
1446
+ continue;
986
1447
  }
987
- } else {
988
- entry.args = hookResult.updatedInput;
1448
+ applyInputOverride(entry, updatedInput as Record<string, unknown>);
1449
+ approvedEntries.push(entry);
1450
+ continue;
1451
+ }
1452
+
1453
+ /**
1454
+ * Defensive type widening: hosts deserialize resume payloads
1455
+ * from untyped JSON, so the `decision.type` value at runtime
1456
+ * is whatever string the wire sent — not necessarily one of
1457
+ * the four union variants TS knows about. We compare against
1458
+ * the literal `'approve'` through the widened `declaredType`
1459
+ * captured at the top of this iteration, so a typo or schema
1460
+ * drift (`'aproved'`, `null`, `undefined`) hits the fail-
1461
+ * closed branch below instead of silently approving the
1462
+ * tool. Without this widening, TS narrows the union after
1463
+ * the three earlier branches and treats `=== 'approve'` as
1464
+ * trivially true.
1465
+ */
1466
+ if (declaredType === 'approve') {
1467
+ approvedEntries.push(entry);
1468
+ continue;
989
1469
  }
1470
+
1471
+ /**
1472
+ * Unknown / missing decision type — fail closed. The whole
1473
+ * point of an approval gate is that "no decision" or
1474
+ * "garbled decision" deny by default.
1475
+ */
1476
+ const unknownType =
1477
+ typeof declaredType === 'string' ? declaredType : '<missing>';
1478
+ blockEntry(
1479
+ entry,
1480
+ `Unknown approval decision type "${unknownType}" — failing closed`
1481
+ );
990
1482
  }
991
- approvedEntries.push(entry);
992
1483
  }
1484
+
1485
+ /**
1486
+ * Flush deferred denial side effects exactly once. On the FIRST
1487
+ * pass through a batch that contains an `ask`, `interrupt()`
1488
+ * threw above and we never reach this line — so no
1489
+ * `ON_RUN_STEP_COMPLETED` / `PermissionDenied` events fire
1490
+ * for blocked tools yet. On resume the node re-executes from
1491
+ * scratch, `blockEntry` re-queues the same entries, and the
1492
+ * flush below dispatches them once. For batches without any
1493
+ * `ask` (deny-only or empty), the flush still runs here and
1494
+ * dispatches in the same relative position as the pre-deferral
1495
+ * code did (after hook processing, before tool execution).
1496
+ */
1497
+ flushDeferredBlockedSideEffects();
993
1498
  } else {
994
1499
  approvedEntries.push(...preToolCalls);
995
1500
  }
@@ -1087,6 +1592,15 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1087
1592
 
1088
1593
  let contentString: string;
1089
1594
  let toolMessage: ToolMessage;
1595
+ /**
1596
+ * Tracks the post-PostToolUse-hook output so the
1597
+ * `PostToolBatch` entry below sees the final transformed value
1598
+ * even when a hook replaced the original via `updatedOutput`.
1599
+ * Lives at the loop-iteration scope so the success branch can
1600
+ * mutate it; the error branch leaves it unset (and the batch
1601
+ * entry uses `error` instead of `toolOutput` in that case).
1602
+ */
1603
+ let finalToolOutput: unknown = result.content;
1090
1604
 
1091
1605
  if (result.status === 'error') {
1092
1606
  contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
@@ -1118,7 +1632,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1118
1632
  });
1119
1633
 
1120
1634
  if (hasFailureHook) {
1121
- await executeHooks({
1635
+ const failureHookResult = await executeHooks({
1122
1636
  registry: this.hookRegistry!,
1123
1637
  input: {
1124
1638
  hook_event_name: 'PostToolUseFailure',
@@ -1134,9 +1648,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1134
1648
  },
1135
1649
  sessionId: runId,
1136
1650
  matchQuery: toolName,
1137
- }).catch(() => {
1138
- /* PostToolUseFailure is observational — swallow errors */
1139
- });
1651
+ }).catch((): undefined => undefined);
1652
+ /**
1653
+ * Collect `additionalContext` from failure hooks too. Without
1654
+ * this, recovery guidance returned on tool errors (e.g.
1655
+ * "if this tool errors with X, suggest Y to the user") is
1656
+ * silently dropped even though the API surface advertises
1657
+ * `additionalContext` for this event. PostToolUseFailure
1658
+ * remains observational for errors thrown by the hook
1659
+ * itself, but a successfully-returned result is honored.
1660
+ */
1661
+ if (failureHookResult != null) {
1662
+ for (const ctx of failureHookResult.additionalContexts) {
1663
+ batchAdditionalContexts.push(ctx);
1664
+ }
1665
+ }
1140
1666
  }
1141
1667
  } else {
1142
1668
  let registryRaw =
@@ -1166,6 +1692,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1166
1692
  sessionId: runId,
1167
1693
  matchQuery: toolName,
1168
1694
  }).catch((): undefined => undefined);
1695
+ if (hookResult != null) {
1696
+ for (const ctx of hookResult.additionalContexts) {
1697
+ batchAdditionalContexts.push(ctx);
1698
+ }
1699
+ }
1169
1700
  if (hookResult?.updatedOutput != null) {
1170
1701
  const replaced =
1171
1702
  typeof hookResult.updatedOutput === 'string'
@@ -1176,6 +1707,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1176
1707
  replaced,
1177
1708
  this.maxToolResultChars
1178
1709
  );
1710
+ finalToolOutput = hookResult.updatedOutput;
1179
1711
  }
1180
1712
  }
1181
1713
 
@@ -1215,6 +1747,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1215
1747
  request?.turn
1216
1748
  );
1217
1749
 
1750
+ postToolBatchEntryByCallId.set(result.toolCallId, {
1751
+ toolName,
1752
+ toolInput: request?.args ?? {},
1753
+ toolUseId: result.toolCallId,
1754
+ stepId: request?.stepId,
1755
+ turn: request?.turn,
1756
+ status: result.status === 'error' ? 'error' : 'success',
1757
+ ...(result.status === 'error'
1758
+ ? { error: result.errorMessage ?? 'Unknown error' }
1759
+ : { toolOutput: finalToolOutput }),
1760
+ });
1761
+
1218
1762
  messageByCallId.set(result.toolCallId, toolMessage);
1219
1763
  }
1220
1764
  }
@@ -1222,9 +1766,105 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1222
1766
  const toolMessages = toolCalls
1223
1767
  .map((call) => messageByCallId.get(call.id!))
1224
1768
  .filter((m): m is ToolMessage => m != null);
1769
+
1770
+ await this.dispatchPostToolBatchAndInjectContext({
1771
+ toolCalls,
1772
+ entriesByCallId: postToolBatchEntryByCallId,
1773
+ batchAdditionalContexts,
1774
+ injected,
1775
+ runId,
1776
+ threadId,
1777
+ });
1778
+
1225
1779
  return { toolMessages, injected };
1226
1780
  }
1227
1781
 
1782
+ /**
1783
+ * Fires the `PostToolBatch` hook (if registered) and appends the
1784
+ * accumulated batch-level `additionalContext` strings to `injected`
1785
+ * as a single `HumanMessage`. Entries are materialized in the
1786
+ * original `toolCalls` order so hooks correlating outcomes by
1787
+ * position (as the type docs promise) see exactly the sequence
1788
+ * the model emitted, regardless of when each individual outcome
1789
+ * was recorded into the map (deny synchronous, approved
1790
+ * post-execution, respond on resume).
1791
+ *
1792
+ * The PostToolBatch hook's `additionalContexts` flow into the same
1793
+ * batch accumulator per-tool hooks already use, so a single
1794
+ * batch-level convention message can be injected through one path.
1795
+ *
1796
+ * Mutates `batchAdditionalContexts` (push from batch hook) and
1797
+ * `injected` (push the consolidated HumanMessage). The caller owns
1798
+ * those arrays and consumes them right after this returns.
1799
+ */
1800
+ private async dispatchPostToolBatchAndInjectContext(args: {
1801
+ toolCalls: ToolCall[];
1802
+ entriesByCallId: Map<string, PostToolBatchEntry>;
1803
+ batchAdditionalContexts: string[];
1804
+ injected: BaseMessage[];
1805
+ runId: string;
1806
+ threadId: string | undefined;
1807
+ }): Promise<void> {
1808
+ const {
1809
+ toolCalls,
1810
+ entriesByCallId,
1811
+ batchAdditionalContexts,
1812
+ injected,
1813
+ runId,
1814
+ threadId,
1815
+ } = args;
1816
+
1817
+ const orderedBatchEntries: PostToolBatchEntry[] = [];
1818
+ for (const call of toolCalls) {
1819
+ const callId = call.id;
1820
+ if (callId == null) {
1821
+ continue;
1822
+ }
1823
+ const entry = entriesByCallId.get(callId);
1824
+ if (entry != null) {
1825
+ orderedBatchEntries.push(entry);
1826
+ }
1827
+ }
1828
+ if (
1829
+ this.hookRegistry?.hasHookFor('PostToolBatch', runId) === true &&
1830
+ orderedBatchEntries.length > 0
1831
+ ) {
1832
+ const batchHookResult = await executeHooks({
1833
+ registry: this.hookRegistry,
1834
+ input: {
1835
+ hook_event_name: 'PostToolBatch',
1836
+ runId,
1837
+ threadId,
1838
+ agentId: this.agentId,
1839
+ entries: orderedBatchEntries,
1840
+ },
1841
+ sessionId: runId,
1842
+ }).catch((): undefined => undefined);
1843
+ if (batchHookResult != null) {
1844
+ for (const ctx of batchHookResult.additionalContexts) {
1845
+ batchAdditionalContexts.push(ctx);
1846
+ }
1847
+ }
1848
+ }
1849
+
1850
+ if (batchAdditionalContexts.length > 0) {
1851
+ /**
1852
+ * `HumanMessage` carrying a metadata `role: 'system'` marker —
1853
+ * see `convertInjectedMessages` for the wider rationale. Anthropic
1854
+ * and Google reject mid-conversation `SystemMessage`s, so we use
1855
+ * a user-role message and surface the system intent through
1856
+ * `additional_kwargs` for hosts inspecting state. The model sees
1857
+ * a user message; `role` is metadata only.
1858
+ */
1859
+ injected.push(
1860
+ new HumanMessage({
1861
+ content: batchAdditionalContexts.join('\n\n'),
1862
+ additional_kwargs: { role: 'system', source: 'hook' },
1863
+ })
1864
+ );
1865
+ }
1866
+ }
1867
+
1228
1868
  private dispatchStepCompleted(
1229
1869
  toolCallId: string,
1230
1870
  toolName: string,
@@ -1282,7 +1922,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1282
1922
  if (msg.skillName != null) additional_kwargs.skillName = msg.skillName;
1283
1923
 
1284
1924
  converted.push(
1285
- new HumanMessage({ content: msg.content, additional_kwargs })
1925
+ new HumanMessage({
1926
+ content: toLangChainContent(msg.content),
1927
+ additional_kwargs,
1928
+ })
1286
1929
  );
1287
1930
  }
1288
1931
  return converted;