@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
@@ -2,10 +2,12 @@
2
2
 
3
3
  var messages = require('@langchain/core/messages');
4
4
  var langgraph = require('@langchain/langgraph');
5
+ var singletons = require('@langchain/core/singletons');
5
6
  var _enum = require('../common/enum.cjs');
6
7
  require('nanoid');
7
8
  require('../messages/core.cjs');
8
9
  var truncation = require('../utils/truncation.cjs');
10
+ var langchain = require('../messages/langchain.cjs');
9
11
  var events = require('../utils/events.cjs');
10
12
  require('uuid');
11
13
  var run = require('../utils/run.cjs');
@@ -20,6 +22,88 @@ var toolOutputReferences = require('./toolOutputReferences.cjs');
20
22
  function isSend(value) {
21
23
  return value instanceof langgraph.Send;
22
24
  }
25
+ /**
26
+ * Format a fail-closed diagnostic for malformed approval-decision
27
+ * fields. Hosts deserialize resume payloads from untyped JSON, so
28
+ * `responseText` and `updatedInput` can land here as anything; the
29
+ * blocking ToolMessage carries this string so the host can debug the
30
+ * exact wire shape that was rejected.
31
+ */
32
+ function describeOfferedShape(value) {
33
+ if (value === undefined) {
34
+ return '<missing>';
35
+ }
36
+ if (value === null) {
37
+ return 'null';
38
+ }
39
+ if (Array.isArray(value)) {
40
+ return 'array';
41
+ }
42
+ return typeof value;
43
+ }
44
+ /**
45
+ * Build the `tool_approval` interrupt payload from the set of pending
46
+ * `ask`-decision entries collected during PreToolUse hook handling.
47
+ * Pure function — doesn't touch ToolNode state — so it lives at module
48
+ * scope. The interrupt itself is raised by the caller (which still
49
+ * needs `interrupt()` plus the AsyncLocalStorage anchoring shim).
50
+ */
51
+ function buildToolApprovalInterruptPayload(askEntries) {
52
+ return {
53
+ type: 'tool_approval',
54
+ action_requests: askEntries.map(({ entry, reason }) => {
55
+ const request = {
56
+ tool_call_id: entry.call.id,
57
+ name: entry.call.name,
58
+ arguments: entry.args,
59
+ };
60
+ if (reason != null) {
61
+ request.description = reason;
62
+ }
63
+ return request;
64
+ }),
65
+ review_configs: askEntries.map(({ entry, allowedDecisions }) => ({
66
+ action_name: entry.call.name,
67
+ tool_call_id: entry.call.id,
68
+ allowed_decisions: (allowedDecisions ?? [
69
+ 'approve',
70
+ 'reject',
71
+ 'edit',
72
+ 'respond',
73
+ ]),
74
+ })),
75
+ };
76
+ }
77
+ /**
78
+ * Build a `tool_call_id → ToolApprovalDecision` map from the host's
79
+ * resume value. Hosts may return decisions either as an array (one per
80
+ * action_request, in order) or as a record keyed by `tool_call_id`. Any
81
+ * unrecognized shape (or a decision missing for a given call id) is
82
+ * treated as "no decision" by callers — typically rejected so the run
83
+ * doesn't silently invoke a tool the human never approved.
84
+ */
85
+ function normalizeApprovalDecisions(callIds, resumeValue) {
86
+ const map = new Map();
87
+ if (resumeValue == null) {
88
+ return map;
89
+ }
90
+ if (Array.isArray(resumeValue)) {
91
+ const limit = Math.min(callIds.length, resumeValue.length);
92
+ for (let i = 0; i < limit; i++) {
93
+ map.set(callIds[i], resumeValue[i]);
94
+ }
95
+ return map;
96
+ }
97
+ if (typeof resumeValue === 'object') {
98
+ for (const callId of callIds) {
99
+ const decision = resumeValue[callId];
100
+ if (decision !== undefined) {
101
+ map.set(callId, decision);
102
+ }
103
+ }
104
+ }
105
+ return map;
106
+ }
23
107
  /**
24
108
  * Merges code execution session context into the sessions map.
25
109
  *
@@ -92,6 +176,12 @@ class ToolNode extends run.RunnableCallable {
92
176
  maxToolResultChars;
93
177
  /** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
94
178
  hookRegistry;
179
+ /**
180
+ * Run-scoped HITL config. When `enabled`, `ask` decisions from
181
+ * PreToolUse hooks raise a LangGraph `interrupt()` instead of being
182
+ * treated as fail-closed denies.
183
+ */
184
+ humanInTheLoop;
95
185
  /**
96
186
  * Registry of tool outputs keyed by `tool<idx>turn<turn>`.
97
187
  *
@@ -112,7 +202,7 @@ class ToolNode extends run.RunnableCallable {
112
202
  * other's in-flight state.
113
203
  */
114
204
  anonBatchCounter = 0;
115
- constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, toolOutputReferences: toolOutputReferences$1, toolOutputRegistry, }) {
205
+ constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, humanInTheLoop, toolOutputReferences: toolOutputReferences$1, toolOutputRegistry, }) {
116
206
  super({ name, tags, func: (input, config) => this.run(input, config) });
117
207
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
118
208
  this.toolCallStepIds = toolCallStepIds;
@@ -128,6 +218,7 @@ class ToolNode extends run.RunnableCallable {
128
218
  this.maxToolResultChars =
129
219
  maxToolResultChars ?? truncation.calculateMaxToolResultChars(maxContextTokens);
130
220
  this.hookRegistry = hookRegistry;
221
+ this.humanInTheLoop = humanInTheLoop;
131
222
  /**
132
223
  * Precedence: an explicitly passed `toolOutputRegistry` instance
133
224
  * wins over a config object so a host (`Graph`) can share one
@@ -668,13 +759,40 @@ class ToolNode extends run.RunnableCallable {
668
759
  });
669
760
  const messageByCallId = new Map();
670
761
  const approvedEntries = [];
762
+ /**
763
+ * Batch-level accumulator for `additionalContext` strings returned
764
+ * by any PreToolUse / PostToolUse / PostToolUseFailure hook in this
765
+ * dispatch. We emit one consolidated `HumanMessage` after all tool
766
+ * results land so the next model turn sees the injected context
767
+ * exactly once, ordered after the ToolMessages.
768
+ */
769
+ const batchAdditionalContexts = [];
770
+ /**
771
+ * Batch-level outcome record keyed by `tool_call_id`. Captures
772
+ * every tool call's final result (success / error from the host,
773
+ * blocked from HITL deny / reject, substituted from HITL respond)
774
+ * across the three call sites that touch it. We materialize the
775
+ * `PostToolBatch` entry array in `toolCalls` order at dispatch
776
+ * time so hooks correlating outcomes by position see exactly the
777
+ * same sequence the model emitted — independent of when each
778
+ * outcome was recorded (deny entries land synchronously in the
779
+ * hook loop, approved entries land after host execution, respond
780
+ * entries land in the resume branch).
781
+ */
782
+ const postToolBatchEntryByCallId = new Map();
671
783
  const HOOK_FALLBACK = Object.freeze({
672
784
  additionalContexts: [],
673
785
  errors: [],
674
786
  });
675
787
  if (this.hookRegistry?.hasHookFor('PreToolUse', runId) === true) {
788
+ /**
789
+ * Capture as a non-null local so the inner `blockEntry` closure
790
+ * doesn't lose narrowing on `this.hookRegistry` and we don't have
791
+ * to defensively `?.` it across every reference inside.
792
+ */
793
+ const hookRegistry = this.hookRegistry;
676
794
  const preResults = await Promise.all(preToolCalls.map((entry) => executeHooks.executeHooks({
677
- registry: this.hookRegistry,
795
+ registry: hookRegistry,
678
796
  input: {
679
797
  hook_event_name: 'PreToolUse',
680
798
  runId,
@@ -689,79 +807,347 @@ class ToolNode extends run.RunnableCallable {
689
807
  sessionId: runId,
690
808
  matchQuery: entry.call.name,
691
809
  }).catch(() => HOOK_FALLBACK)));
692
- for (let i = 0; i < preToolCalls.length; i++) {
693
- const hookResult = preResults[i];
694
- const entry = preToolCalls[i];
695
- const isDenied = hookResult.decision === 'deny' || hookResult.decision === 'ask';
696
- if (isDenied) {
697
- const reason = hookResult.reason ?? 'Blocked by hook';
698
- const contentString = `Blocked: ${reason}`;
699
- messageByCallId.set(entry.call.id, new messages.ToolMessage({
700
- status: 'error',
701
- content: contentString,
702
- name: entry.call.name,
703
- tool_call_id: entry.call.id,
704
- }));
705
- this.dispatchStepCompleted(entry.call.id, entry.call.name, entry.args, contentString, config);
706
- if (this.hookRegistry.hasHookFor('PermissionDenied', runId)) {
810
+ /**
811
+ * Side effects deferred from `blockEntry` until after any pending
812
+ * `interrupt()` resolves. Without deferral, a batch that mixes a
813
+ * `deny` decision with an `ask` decision would dispatch
814
+ * `ON_RUN_STEP_COMPLETED` for the denied tool on the FIRST node
815
+ * execution (before `interrupt()` throws), then dispatch the
816
+ * same event AGAIN on the resume re-execution — hosts would
817
+ * observe two completion events for one logical denial. By
818
+ * queueing the dispatch + PermissionDenied hook here and
819
+ * flushing after the interrupt block, we ensure each side effect
820
+ * fires exactly once: never on the first pass when interrupt
821
+ * throws (the flush is unreachable), once on resume / no-ask
822
+ * passes when control reaches the flush.
823
+ */
824
+ const deferredBlockedSideEffects = [];
825
+ const blockEntry = (entry, reason) => {
826
+ const contentString = `Blocked: ${reason}`;
827
+ messageByCallId.set(entry.call.id, new messages.ToolMessage({
828
+ status: 'error',
829
+ content: contentString,
830
+ name: entry.call.name,
831
+ tool_call_id: entry.call.id,
832
+ }));
833
+ postToolBatchEntryByCallId.set(entry.call.id, {
834
+ toolName: entry.call.name,
835
+ toolInput: entry.args,
836
+ toolUseId: entry.call.id,
837
+ stepId: entry.stepId,
838
+ /**
839
+ * Records the pre-invocation turn count — the same value the
840
+ * executed path captures before incrementing `toolUsageCount`.
841
+ * For a blocked tool the counter is never incremented (no
842
+ * invocation happened), so this is always the count of prior
843
+ * successful invocations of this tool name in earlier batches.
844
+ * Surfaces in the `PostToolBatch` entry so batch hooks see
845
+ * a uniform shape regardless of outcome.
846
+ */
847
+ turn: this.toolUsageCount.get(entry.call.name) ?? 0,
848
+ status: 'error',
849
+ error: contentString,
850
+ });
851
+ deferredBlockedSideEffects.push({
852
+ callId: entry.call.id,
853
+ toolName: entry.call.name,
854
+ args: entry.args,
855
+ contentString,
856
+ reason,
857
+ });
858
+ };
859
+ const flushDeferredBlockedSideEffects = () => {
860
+ for (const item of deferredBlockedSideEffects) {
861
+ this.dispatchStepCompleted(item.callId, item.toolName, item.args, item.contentString, config);
862
+ if (hookRegistry.hasHookFor('PermissionDenied', runId)) {
707
863
  executeHooks.executeHooks({
708
- registry: this.hookRegistry,
864
+ registry: hookRegistry,
709
865
  input: {
710
866
  hook_event_name: 'PermissionDenied',
711
867
  runId,
712
868
  threadId,
713
869
  agentId: this.agentId,
714
- toolName: entry.call.name,
715
- toolInput: entry.args,
716
- toolUseId: entry.call.id,
717
- reason,
870
+ toolName: item.toolName,
871
+ toolInput: item.args,
872
+ toolUseId: item.callId,
873
+ reason: item.reason,
718
874
  },
719
875
  sessionId: runId,
720
- matchQuery: entry.call.name,
876
+ matchQuery: item.toolName,
721
877
  }).catch(() => {
722
878
  /* PermissionDenied is observational — swallow errors */
723
879
  });
724
880
  }
881
+ }
882
+ deferredBlockedSideEffects.length = 0;
883
+ };
884
+ /**
885
+ * Apply a hook-supplied or host-supplied input override to a pending
886
+ * entry, re-running the `{{tool<i>turn<n>}}` resolver so any new
887
+ * placeholders introduced by the override are substituted (and any
888
+ * formerly-unresolved refs cleared from the unresolved set).
889
+ *
890
+ * Mixed direct+event batches must use the pre-batch snapshot so a
891
+ * hook-introduced placeholder cannot accidentally resolve to a
892
+ * same-turn direct output that has just registered. Pure event
893
+ * batches don't have a snapshot and resolve against the live
894
+ * registry — safe because no event-side registrations have happened
895
+ * yet.
896
+ */
897
+ const applyInputOverride = (entry, nextArgs) => {
898
+ if (registry != null) {
899
+ const view = preBatchSnapshot ?? {
900
+ resolve: (args) => registry.resolve(registryRunId, args),
901
+ };
902
+ const { resolved, unresolved } = view.resolve(nextArgs);
903
+ entry.args = resolved;
904
+ if (entry.call.id != null) {
905
+ if (unresolved.length > 0) {
906
+ unresolvedByCallId.set(entry.call.id, unresolved);
907
+ }
908
+ else {
909
+ unresolvedByCallId.delete(entry.call.id);
910
+ }
911
+ }
912
+ return;
913
+ }
914
+ entry.args = nextArgs;
915
+ };
916
+ const askEntries = [];
917
+ for (let i = 0; i < preToolCalls.length; i++) {
918
+ const hookResult = preResults[i];
919
+ const entry = preToolCalls[i];
920
+ for (const ctx of hookResult.additionalContexts) {
921
+ batchAdditionalContexts.push(ctx);
922
+ }
923
+ if (hookResult.decision === 'deny') {
924
+ blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
925
+ continue;
926
+ }
927
+ if (hookResult.decision === 'ask') {
928
+ /**
929
+ * HITL is OFF by default — hosts must explicitly opt in via
930
+ * `humanInTheLoop: { enabled: true }` to engage the
931
+ * `interrupt()` path. When opted out (or omitted), `ask`
932
+ * collapses into the pre-HITL fail-closed path: a blocked
933
+ * tool with an error `ToolMessage`. The default stays
934
+ * conservative until host UIs are ready to render
935
+ * `tool_approval` interrupts; see `HumanInTheLoopConfig`
936
+ * JSDoc for the full rationale and the migration plan.
937
+ */
938
+ if (this.humanInTheLoop?.enabled !== true) {
939
+ blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
940
+ continue;
941
+ }
942
+ /**
943
+ * Apply `updatedInput` BEFORE queuing into `askEntries` —
944
+ * a hook is allowed to return both a sanitization rewrite
945
+ * and an `ask` decision (e.g. one matcher redacts secrets,
946
+ * another matcher requires approval). Without this, the
947
+ * interrupt payload would surface the original args to the
948
+ * reviewer AND the post-approve execution would run with
949
+ * the original args, silently dropping the hook's rewrite.
950
+ */
951
+ if (hookResult.updatedInput != null) {
952
+ applyInputOverride(entry, hookResult.updatedInput);
953
+ }
954
+ askEntries.push({
955
+ entry,
956
+ reason: hookResult.reason,
957
+ allowedDecisions: hookResult.allowedDecisions,
958
+ });
725
959
  continue;
726
960
  }
727
961
  if (hookResult.updatedInput != null) {
962
+ applyInputOverride(entry, hookResult.updatedInput);
963
+ }
964
+ approvedEntries.push(entry);
965
+ }
966
+ /**
967
+ * If any entries asked for approval, raise a single LangGraph
968
+ * `interrupt()` carrying every pending request together. The host
969
+ * pauses, gathers human input, and resumes the run with one
970
+ * decision per request. On resume LangGraph re-executes this node
971
+ * from the start; `interrupt()` then returns the resume value
972
+ * instead of throwing, so the loop above re-runs and the same
973
+ * `askEntries` list is rebuilt deterministically (assuming hooks
974
+ * are pure — see `humanInTheLoop` docs).
975
+ */
976
+ if (askEntries.length > 0) {
977
+ const payload = buildToolApprovalInterruptPayload(askEntries);
978
+ /**
979
+ * `interrupt()` reads the current `RunnableConfig` from
980
+ * AsyncLocalStorage, but our `RunnableCallable` sets
981
+ * `trace = false` for ToolNode (intentional — avoids LangSmith
982
+ * tracing per tool call). Without the trace path, the upstream
983
+ * `runWithConfig` frame is never established, so we re-anchor
984
+ * here using the node's own `config` — Pregel hands us a
985
+ * config that already carries every checkpoint/scratchpad key
986
+ * `interrupt()` needs to suspend and resume.
987
+ */
988
+ const resumeValue = singletons.AsyncLocalStorageProviderSingleton.runWithConfig(config, () => langgraph.interrupt(payload));
989
+ const decisionByCallId = normalizeApprovalDecisions(askEntries.map(({ entry }) => entry.call.id), resumeValue);
990
+ for (const { entry, reason: askReason, allowedDecisions, } of askEntries) {
991
+ const decision = decisionByCallId.get(entry.call.id) ?? {
992
+ type: 'reject',
993
+ reason: 'No decision provided for tool approval',
994
+ };
995
+ /**
996
+ * Read `decision.type` through a widened view once: hosts
997
+ * deserialize resume payloads from untyped JSON, so the
998
+ * runtime value can be a typo, the wrong type, or missing
999
+ * entirely. Both the `allowedDecisions` enforcement
1000
+ * immediately below and the unknown-type fallthrough at the
1001
+ * end of this loop body share this single read so the
1002
+ * fail-closed checks compare against the same source.
1003
+ */
1004
+ const declaredType = decision.type;
728
1005
  /**
729
- * Re-resolve after PreToolUse replaces the input: a hook may
730
- * introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
731
- * copying user-supplied text) that the pre-hook pass never
732
- * saw. Re-running the resolver on the hook-rewritten args
733
- * keeps substitution and the unresolved-refs record in sync
734
- * with what the tool will actually receive.
1006
+ * Enforce the per-tool `allowedDecisions` allowlist that the
1007
+ * `PreToolUse` hook surfaced in `review_configs`. The host
1008
+ * UI is supposed to honor this when collecting the user's
1009
+ * decision, but the wire is untrusted: a buggy or hostile
1010
+ * host could submit a decision type the policy explicitly
1011
+ * forbids (e.g. `'edit'` when the hook restricted to
1012
+ * `['approve', 'reject']`), bypassing argument-mutation /
1013
+ * response-substitution safeguards. Fail closed when the
1014
+ * declared type isn't in the allowlist.
735
1015
  */
736
- if (registry != null) {
1016
+ if (allowedDecisions != null &&
1017
+ (typeof declaredType !== 'string' ||
1018
+ !allowedDecisions.includes(declaredType))) {
1019
+ const offered = typeof declaredType === 'string' ? declaredType : '<missing>';
1020
+ blockEntry(entry, `Decision "${offered}" not in allowedDecisions [${allowedDecisions.join(', ')}] — failing closed`);
1021
+ continue;
1022
+ }
1023
+ if (decision.type === 'reject') {
1024
+ blockEntry(entry, decision.reason ?? askReason ?? 'Rejected by user');
1025
+ continue;
1026
+ }
1027
+ /**
1028
+ * `respond` short-circuits tool execution: the human supplies
1029
+ * the result the model should see in place of running the
1030
+ * tool. We emit a successful `ToolMessage` directly and skip
1031
+ * dispatch — no host event fires, no real tool side effect
1032
+ * occurs. Mirrors LangChain HITL middleware semantics.
1033
+ */
1034
+ if (decision.type === 'respond') {
737
1035
  /**
738
- * Mixed direct+event batches must use the pre-batch
739
- * snapshot so a hook-introduced placeholder cannot
740
- * accidentally resolve to a same-turn direct output that
741
- * has just registered. Pure event batches don't have a
742
- * snapshot and resolve against the live registry — safe
743
- * because no event-side registrations have happened yet.
1036
+ * Validate the wire shape before touching it: hosts
1037
+ * deserialize resume payloads from untyped JSON, so a
1038
+ * malformed `{ type: 'respond' }` (no `responseText`) or
1039
+ * `{ type: 'respond', responseText: 42 }` would crash
1040
+ * `truncateToolResultContent` (which calls
1041
+ * `content.length`) and turn a fail-closed approval path
1042
+ * into a hard run failure. Route bad shapes through
1043
+ * `blockEntry` like any other unusable decision.
744
1044
  */
745
- const view = preBatchSnapshot ?? {
746
- resolve: (args) => registry.resolve(registryRunId, args),
747
- };
748
- const { resolved, unresolved } = view.resolve(hookResult.updatedInput);
749
- entry.args = resolved;
750
- if (entry.call.id != null) {
751
- if (unresolved.length > 0) {
752
- unresolvedByCallId.set(entry.call.id, unresolved);
753
- }
754
- else {
755
- unresolvedByCallId.delete(entry.call.id);
756
- }
1045
+ const responseText = decision
1046
+ .responseText;
1047
+ if (typeof responseText !== 'string') {
1048
+ blockEntry(entry, `Decision "respond" missing string responseText (got ${describeOfferedShape(responseText)}) — failing closed`);
1049
+ continue;
757
1050
  }
1051
+ /**
1052
+ * Truncate the human-supplied text just like the success
1053
+ * path does for real tool output. Without this, a user
1054
+ * pasting a large document as a manual response bypasses
1055
+ * `maxToolResultChars` and can blow past the model's
1056
+ * context window. The PostToolBatch entry surfaces the
1057
+ * truncated text too so batch hooks see what the model
1058
+ * will actually see.
1059
+ */
1060
+ const truncatedResponse = truncation.truncateToolResultContent(responseText, this.maxToolResultChars);
1061
+ messageByCallId.set(entry.call.id, new messages.ToolMessage({
1062
+ status: 'success',
1063
+ content: truncatedResponse,
1064
+ name: entry.call.name,
1065
+ tool_call_id: entry.call.id,
1066
+ }));
1067
+ postToolBatchEntryByCallId.set(entry.call.id, {
1068
+ toolName: entry.call.name,
1069
+ toolInput: entry.args,
1070
+ toolUseId: entry.call.id,
1071
+ stepId: entry.stepId,
1072
+ turn: this.toolUsageCount.get(entry.call.name) ?? 0,
1073
+ status: 'success',
1074
+ toolOutput: truncatedResponse,
1075
+ });
1076
+ /**
1077
+ * Safe to dispatch immediately — unlike `blockEntry` which
1078
+ * defers, `respond` only executes inside the decision-
1079
+ * processing loop, which is reachable only AFTER
1080
+ * `interrupt()` has returned (the resume pass). There is
1081
+ * no risk of being rolled back by a subsequent throw, so
1082
+ * no risk of a duplicate `ON_RUN_STEP_COMPLETED` event.
1083
+ */
1084
+ this.dispatchStepCompleted(entry.call.id, entry.call.name, entry.args, truncatedResponse, config);
1085
+ continue;
758
1086
  }
759
- else {
760
- entry.args = hookResult.updatedInput;
1087
+ if (decision.type === 'edit') {
1088
+ /**
1089
+ * Validate the wire shape before touching it: hosts
1090
+ * deserialize resume payloads from untyped JSON, so a
1091
+ * malformed `{ type: 'edit' }` (no `updatedInput`),
1092
+ * `{ type: 'edit', updatedInput: 'string' }` (non-object),
1093
+ * or `{ type: 'edit', updatedInput: [...] }` (array, not a
1094
+ * plain object) would feed garbage into
1095
+ * `applyInputOverride` and silently approve a tool with
1096
+ * undefined / wrong-shape args. Same trust boundary as
1097
+ * the `respond` validation above — fail closed via
1098
+ * `blockEntry` with a diagnostic.
1099
+ */
1100
+ const updatedInput = decision
1101
+ .updatedInput;
1102
+ if (updatedInput === null ||
1103
+ typeof updatedInput !== 'object' ||
1104
+ Array.isArray(updatedInput)) {
1105
+ blockEntry(entry, `Decision "edit" missing object updatedInput (got ${describeOfferedShape(updatedInput)}) — failing closed`);
1106
+ continue;
1107
+ }
1108
+ applyInputOverride(entry, updatedInput);
1109
+ approvedEntries.push(entry);
1110
+ continue;
761
1111
  }
1112
+ /**
1113
+ * Defensive type widening: hosts deserialize resume payloads
1114
+ * from untyped JSON, so the `decision.type` value at runtime
1115
+ * is whatever string the wire sent — not necessarily one of
1116
+ * the four union variants TS knows about. We compare against
1117
+ * the literal `'approve'` through the widened `declaredType`
1118
+ * captured at the top of this iteration, so a typo or schema
1119
+ * drift (`'aproved'`, `null`, `undefined`) hits the fail-
1120
+ * closed branch below instead of silently approving the
1121
+ * tool. Without this widening, TS narrows the union after
1122
+ * the three earlier branches and treats `=== 'approve'` as
1123
+ * trivially true.
1124
+ */
1125
+ if (declaredType === 'approve') {
1126
+ approvedEntries.push(entry);
1127
+ continue;
1128
+ }
1129
+ /**
1130
+ * Unknown / missing decision type — fail closed. The whole
1131
+ * point of an approval gate is that "no decision" or
1132
+ * "garbled decision" deny by default.
1133
+ */
1134
+ const unknownType = typeof declaredType === 'string' ? declaredType : '<missing>';
1135
+ blockEntry(entry, `Unknown approval decision type "${unknownType}" — failing closed`);
762
1136
  }
763
- approvedEntries.push(entry);
764
1137
  }
1138
+ /**
1139
+ * Flush deferred denial side effects exactly once. On the FIRST
1140
+ * pass through a batch that contains an `ask`, `interrupt()`
1141
+ * threw above and we never reach this line — so no
1142
+ * `ON_RUN_STEP_COMPLETED` / `PermissionDenied` events fire
1143
+ * for blocked tools yet. On resume the node re-executes from
1144
+ * scratch, `blockEntry` re-queues the same entries, and the
1145
+ * flush below dispatches them once. For batches without any
1146
+ * `ask` (deny-only or empty), the flush still runs here and
1147
+ * dispatches in the same relative position as the pre-deferral
1148
+ * code did (after hook processing, before tool execution).
1149
+ */
1150
+ flushDeferredBlockedSideEffects();
765
1151
  }
766
1152
  else {
767
1153
  approvedEntries.push(...preToolCalls);
@@ -830,6 +1216,15 @@ class ToolNode extends run.RunnableCallable {
830
1216
  const toolName = request?.name ?? 'unknown';
831
1217
  let contentString;
832
1218
  let toolMessage;
1219
+ /**
1220
+ * Tracks the post-PostToolUse-hook output so the
1221
+ * `PostToolBatch` entry below sees the final transformed value
1222
+ * even when a hook replaced the original via `updatedOutput`.
1223
+ * Lives at the loop-iteration scope so the success branch can
1224
+ * mutate it; the error branch leaves it unset (and the batch
1225
+ * entry uses `error` instead of `toolOutput` in that case).
1226
+ */
1227
+ let finalToolOutput = result.content;
833
1228
  if (result.status === 'error') {
834
1229
  contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
835
1230
  /**
@@ -853,7 +1248,7 @@ class ToolNode extends run.RunnableCallable {
853
1248
  }),
854
1249
  });
855
1250
  if (hasFailureHook) {
856
- await executeHooks.executeHooks({
1251
+ const failureHookResult = await executeHooks.executeHooks({
857
1252
  registry: this.hookRegistry,
858
1253
  input: {
859
1254
  hook_event_name: 'PostToolUseFailure',
@@ -869,9 +1264,21 @@ class ToolNode extends run.RunnableCallable {
869
1264
  },
870
1265
  sessionId: runId,
871
1266
  matchQuery: toolName,
872
- }).catch(() => {
873
- /* PostToolUseFailure is observational — swallow errors */
874
- });
1267
+ }).catch(() => undefined);
1268
+ /**
1269
+ * Collect `additionalContext` from failure hooks too. Without
1270
+ * this, recovery guidance returned on tool errors (e.g.
1271
+ * "if this tool errors with X, suggest Y to the user") is
1272
+ * silently dropped even though the API surface advertises
1273
+ * `additionalContext` for this event. PostToolUseFailure
1274
+ * remains observational for errors thrown by the hook
1275
+ * itself, but a successfully-returned result is honored.
1276
+ */
1277
+ if (failureHookResult != null) {
1278
+ for (const ctx of failureHookResult.additionalContexts) {
1279
+ batchAdditionalContexts.push(ctx);
1280
+ }
1281
+ }
875
1282
  }
876
1283
  }
877
1284
  else {
@@ -897,12 +1304,18 @@ class ToolNode extends run.RunnableCallable {
897
1304
  sessionId: runId,
898
1305
  matchQuery: toolName,
899
1306
  }).catch(() => undefined);
1307
+ if (hookResult != null) {
1308
+ for (const ctx of hookResult.additionalContexts) {
1309
+ batchAdditionalContexts.push(ctx);
1310
+ }
1311
+ }
900
1312
  if (hookResult?.updatedOutput != null) {
901
1313
  const replaced = typeof hookResult.updatedOutput === 'string'
902
1314
  ? hookResult.updatedOutput
903
1315
  : JSON.stringify(hookResult.updatedOutput);
904
1316
  registryRaw = replaced;
905
1317
  contentString = truncation.truncateToolResultContent(replaced, this.maxToolResultChars);
1318
+ finalToolOutput = hookResult.updatedOutput;
906
1319
  }
907
1320
  }
908
1321
  const batchIndex = batchIndexByCallId.get(result.toolCallId);
@@ -925,14 +1338,98 @@ class ToolNode extends run.RunnableCallable {
925
1338
  });
926
1339
  }
927
1340
  this.dispatchStepCompleted(result.toolCallId, toolName, request?.args ?? {}, contentString, config, request?.turn);
1341
+ postToolBatchEntryByCallId.set(result.toolCallId, {
1342
+ toolName,
1343
+ toolInput: request?.args ?? {},
1344
+ toolUseId: result.toolCallId,
1345
+ stepId: request?.stepId,
1346
+ turn: request?.turn,
1347
+ status: result.status === 'error' ? 'error' : 'success',
1348
+ ...(result.status === 'error'
1349
+ ? { error: result.errorMessage ?? 'Unknown error' }
1350
+ : { toolOutput: finalToolOutput }),
1351
+ });
928
1352
  messageByCallId.set(result.toolCallId, toolMessage);
929
1353
  }
930
1354
  }
931
1355
  const toolMessages = toolCalls
932
1356
  .map((call) => messageByCallId.get(call.id))
933
1357
  .filter((m) => m != null);
1358
+ await this.dispatchPostToolBatchAndInjectContext({
1359
+ toolCalls,
1360
+ entriesByCallId: postToolBatchEntryByCallId,
1361
+ batchAdditionalContexts,
1362
+ injected,
1363
+ runId,
1364
+ threadId,
1365
+ });
934
1366
  return { toolMessages, injected };
935
1367
  }
1368
+ /**
1369
+ * Fires the `PostToolBatch` hook (if registered) and appends the
1370
+ * accumulated batch-level `additionalContext` strings to `injected`
1371
+ * as a single `HumanMessage`. Entries are materialized in the
1372
+ * original `toolCalls` order so hooks correlating outcomes by
1373
+ * position (as the type docs promise) see exactly the sequence
1374
+ * the model emitted, regardless of when each individual outcome
1375
+ * was recorded into the map (deny synchronous, approved
1376
+ * post-execution, respond on resume).
1377
+ *
1378
+ * The PostToolBatch hook's `additionalContexts` flow into the same
1379
+ * batch accumulator per-tool hooks already use, so a single
1380
+ * batch-level convention message can be injected through one path.
1381
+ *
1382
+ * Mutates `batchAdditionalContexts` (push from batch hook) and
1383
+ * `injected` (push the consolidated HumanMessage). The caller owns
1384
+ * those arrays and consumes them right after this returns.
1385
+ */
1386
+ async dispatchPostToolBatchAndInjectContext(args) {
1387
+ const { toolCalls, entriesByCallId, batchAdditionalContexts, injected, runId, threadId, } = args;
1388
+ const orderedBatchEntries = [];
1389
+ for (const call of toolCalls) {
1390
+ const callId = call.id;
1391
+ if (callId == null) {
1392
+ continue;
1393
+ }
1394
+ const entry = entriesByCallId.get(callId);
1395
+ if (entry != null) {
1396
+ orderedBatchEntries.push(entry);
1397
+ }
1398
+ }
1399
+ if (this.hookRegistry?.hasHookFor('PostToolBatch', runId) === true &&
1400
+ orderedBatchEntries.length > 0) {
1401
+ const batchHookResult = await executeHooks.executeHooks({
1402
+ registry: this.hookRegistry,
1403
+ input: {
1404
+ hook_event_name: 'PostToolBatch',
1405
+ runId,
1406
+ threadId,
1407
+ agentId: this.agentId,
1408
+ entries: orderedBatchEntries,
1409
+ },
1410
+ sessionId: runId,
1411
+ }).catch(() => undefined);
1412
+ if (batchHookResult != null) {
1413
+ for (const ctx of batchHookResult.additionalContexts) {
1414
+ batchAdditionalContexts.push(ctx);
1415
+ }
1416
+ }
1417
+ }
1418
+ if (batchAdditionalContexts.length > 0) {
1419
+ /**
1420
+ * `HumanMessage` carrying a metadata `role: 'system'` marker —
1421
+ * see `convertInjectedMessages` for the wider rationale. Anthropic
1422
+ * and Google reject mid-conversation `SystemMessage`s, so we use
1423
+ * a user-role message and surface the system intent through
1424
+ * `additional_kwargs` for hosts inspecting state. The model sees
1425
+ * a user message; `role` is metadata only.
1426
+ */
1427
+ injected.push(new messages.HumanMessage({
1428
+ content: batchAdditionalContexts.join('\n\n'),
1429
+ additional_kwargs: { role: 'system', source: 'hook' },
1430
+ }));
1431
+ }
1432
+ }
936
1433
  dispatchStepCompleted(toolCallId, toolName, args, output, config, turn) {
937
1434
  const stepId = this.toolCallStepIds?.get(toolCallId) ?? '';
938
1435
  if (!stepId) {
@@ -974,7 +1471,10 @@ class ToolNode extends run.RunnableCallable {
974
1471
  additional_kwargs.source = msg.source;
975
1472
  if (msg.skillName != null)
976
1473
  additional_kwargs.skillName = msg.skillName;
977
- converted.push(new messages.HumanMessage({ content: msg.content, additional_kwargs }));
1474
+ converted.push(new messages.HumanMessage({
1475
+ content: langchain.toLangChainContent(msg.content),
1476
+ additional_kwargs,
1477
+ }));
978
1478
  }
979
1479
  return converted;
980
1480
  }