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