@librechat/agents 3.4.0 → 3.4.2

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 (204) hide show
  1. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  2. package/dist/cjs/graphs/Graph.cjs +165 -57
  3. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs +81 -33
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  6. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs +14 -0
  7. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs.map +1 -0
  8. package/dist/cjs/graphs/createGraph.cjs +15 -0
  9. package/dist/cjs/graphs/createGraph.cjs.map +1 -0
  10. package/dist/cjs/graphs/index.cjs +1 -0
  11. package/dist/cjs/hooks/HookRegistry.cjs +83 -0
  12. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  13. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  14. package/dist/cjs/hooks/executeHooks.cjs +60 -14
  15. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  16. package/dist/cjs/hooks/index.cjs.map +1 -1
  17. package/dist/cjs/hooks/types.cjs +2 -0
  18. package/dist/cjs/hooks/types.cjs.map +1 -1
  19. package/dist/cjs/llm/init.cjs +3 -3
  20. package/dist/cjs/llm/invoke.cjs +2 -2
  21. package/dist/cjs/main.cjs +34 -24
  22. package/dist/cjs/messages/format.cjs +40 -23
  23. package/dist/cjs/messages/format.cjs.map +1 -1
  24. package/dist/cjs/run.cjs +130 -53
  25. package/dist/cjs/run.cjs.map +1 -1
  26. package/dist/cjs/session/AgentSession.cjs +37 -2
  27. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  28. package/dist/cjs/session/JsonlSessionStore.cjs +2 -2
  29. package/dist/cjs/stream.cjs +1 -1
  30. package/dist/cjs/summarization/node.cjs +6 -3
  31. package/dist/cjs/summarization/node.cjs.map +1 -1
  32. package/dist/cjs/tools/BashExecutor.cjs +1 -1
  33. package/dist/cjs/tools/CodeExecutor.cjs +1 -1
  34. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  35. package/dist/cjs/tools/SubagentTool.cjs +4 -4
  36. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +321 -95
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/ToolSearch.cjs +1 -1
  40. package/dist/cjs/tools/eagerEventExecution.cjs +1 -0
  41. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +3 -3
  42. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +2 -2
  43. package/dist/cjs/tools/search/rerankers.cjs +154 -1
  44. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  45. package/dist/cjs/tools/search/tool.cjs +4 -1
  46. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  47. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs +601 -0
  48. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs.map +1 -0
  49. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1114 -233
  50. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  51. package/dist/cjs/tools/subagent/SubagentReplay.cjs +177 -0
  52. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
  53. package/dist/cjs/tools/subagent/childGraphConfig.cjs +278 -0
  54. package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -0
  55. package/dist/cjs/tools/subagent/index.cjs +1 -0
  56. package/dist/cjs/tools/subagent/runtimeLimits.cjs +5 -0
  57. package/dist/cjs/tools/subagent/runtimeLimits.cjs.map +1 -0
  58. package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
  59. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  60. package/dist/cjs/utils/index.cjs +2 -2
  61. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  62. package/dist/esm/graphs/Graph.mjs +165 -57
  63. package/dist/esm/graphs/Graph.mjs.map +1 -1
  64. package/dist/esm/graphs/MultiAgentGraph.mjs +81 -33
  65. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  66. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs +14 -0
  67. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs.map +1 -0
  68. package/dist/esm/graphs/createGraph.mjs +15 -0
  69. package/dist/esm/graphs/createGraph.mjs.map +1 -0
  70. package/dist/esm/graphs/index.mjs +1 -0
  71. package/dist/esm/hooks/HookRegistry.mjs +83 -0
  72. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  73. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  74. package/dist/esm/hooks/executeHooks.mjs +60 -14
  75. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  76. package/dist/esm/hooks/index.mjs.map +1 -1
  77. package/dist/esm/hooks/types.mjs +2 -1
  78. package/dist/esm/hooks/types.mjs.map +1 -1
  79. package/dist/esm/llm/init.mjs +1 -1
  80. package/dist/esm/llm/invoke.mjs +2 -2
  81. package/dist/esm/main.mjs +21 -19
  82. package/dist/esm/messages/format.mjs +40 -23
  83. package/dist/esm/messages/format.mjs.map +1 -1
  84. package/dist/esm/run.mjs +131 -54
  85. package/dist/esm/run.mjs.map +1 -1
  86. package/dist/esm/session/AgentSession.mjs +37 -2
  87. package/dist/esm/session/AgentSession.mjs.map +1 -1
  88. package/dist/esm/session/JsonlSessionStore.mjs +2 -2
  89. package/dist/esm/stream.mjs +1 -1
  90. package/dist/esm/summarization/node.mjs +6 -3
  91. package/dist/esm/summarization/node.mjs.map +1 -1
  92. package/dist/esm/tools/BashExecutor.mjs +1 -1
  93. package/dist/esm/tools/CodeExecutor.mjs +1 -1
  94. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  95. package/dist/esm/tools/SubagentTool.mjs +4 -4
  96. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  97. package/dist/esm/tools/ToolNode.mjs +322 -96
  98. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  99. package/dist/esm/tools/ToolSearch.mjs +1 -1
  100. package/dist/esm/tools/eagerEventExecution.mjs +1 -1
  101. package/dist/esm/tools/local/LocalExecutionEngine.mjs +3 -3
  102. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +2 -2
  103. package/dist/esm/tools/search/rerankers.mjs +154 -1
  104. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  105. package/dist/esm/tools/search/tool.mjs +4 -1
  106. package/dist/esm/tools/search/tool.mjs.map +1 -1
  107. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs +597 -0
  108. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs.map +1 -0
  109. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1114 -233
  110. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  111. package/dist/esm/tools/subagent/SubagentReplay.mjs +170 -0
  112. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
  113. package/dist/esm/tools/subagent/childGraphConfig.mjs +272 -0
  114. package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -0
  115. package/dist/esm/tools/subagent/index.mjs +1 -0
  116. package/dist/esm/tools/subagent/runtimeLimits.mjs +5 -0
  117. package/dist/esm/tools/subagent/runtimeLimits.mjs.map +1 -0
  118. package/dist/esm/tools/toolOutputReferences.mjs +20 -0
  119. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  120. package/dist/esm/utils/index.mjs +2 -2
  121. package/dist/types/agents/AgentContext.d.ts +1 -1
  122. package/dist/types/graphs/Graph.d.ts +22 -2
  123. package/dist/types/graphs/MultiAgentGraph.d.ts +7 -1
  124. package/dist/types/graphs/applyGraphRuntimeConfig.d.ts +3 -0
  125. package/dist/types/graphs/createGraph.d.ts +13 -0
  126. package/dist/types/graphs/graphFactory.d.ts +13 -0
  127. package/dist/types/graphs/index.d.ts +2 -0
  128. package/dist/types/hooks/HookRegistry.d.ts +10 -1
  129. package/dist/types/hooks/executeHooks.d.ts +5 -1
  130. package/dist/types/hooks/index.d.ts +2 -2
  131. package/dist/types/hooks/types.d.ts +12 -0
  132. package/dist/types/run.d.ts +6 -3
  133. package/dist/types/session/AgentSession.d.ts +1 -0
  134. package/dist/types/tools/SubagentTool.d.ts +1 -1
  135. package/dist/types/tools/ToolNode.d.ts +23 -15
  136. package/dist/types/tools/search/rerankers.d.ts +28 -0
  137. package/dist/types/tools/search/types.d.ts +38 -1
  138. package/dist/types/tools/subagent/SubagentExecutionRegistry.d.ts +171 -0
  139. package/dist/types/tools/subagent/SubagentExecutor.d.ts +70 -36
  140. package/dist/types/tools/subagent/SubagentReplay.d.ts +85 -0
  141. package/dist/types/tools/subagent/childGraphConfig.d.ts +37 -0
  142. package/dist/types/tools/subagent/index.d.ts +1 -1
  143. package/dist/types/tools/subagent/runtimeLimits.d.ts +4 -0
  144. package/dist/types/tools/toolOutputReferences.d.ts +12 -0
  145. package/dist/types/types/graph.d.ts +189 -24
  146. package/dist/types/types/hitl.d.ts +15 -0
  147. package/package.json +8 -2
  148. package/src/agents/AgentContext.ts +1 -1
  149. package/src/graphs/Graph.ts +349 -104
  150. package/src/graphs/MultiAgentGraph.ts +149 -47
  151. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
  152. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
  153. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
  154. package/src/graphs/__tests__/MultiAgentGraph.test.ts +86 -0
  155. package/src/graphs/__tests__/composition.smoke.test.ts +152 -0
  156. package/src/graphs/__tests__/createGraph.test.ts +206 -0
  157. package/src/graphs/applyGraphRuntimeConfig.ts +25 -0
  158. package/src/graphs/createGraph.ts +32 -0
  159. package/src/graphs/graphFactory.ts +12 -0
  160. package/src/graphs/index.ts +2 -0
  161. package/src/hooks/HookRegistry.ts +151 -1
  162. package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
  163. package/src/hooks/__tests__/executeHooks.test.ts +160 -0
  164. package/src/hooks/executeHooks.ts +133 -15
  165. package/src/hooks/index.ts +3 -1
  166. package/src/hooks/types.ts +16 -0
  167. package/src/messages/format.ts +54 -26
  168. package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
  169. package/src/messages/formatAgentMessages.steer.test.ts +16 -11
  170. package/src/messages/formatAgentMessages.test.ts +7 -2
  171. package/src/run.ts +214 -56
  172. package/src/session/AgentSession.ts +52 -2
  173. package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
  174. package/src/specs/graph-subagent.live.test.ts +242 -0
  175. package/src/specs/graph-subagent.test.ts +322 -0
  176. package/src/specs/langfuse-routing.integration.test.ts +120 -1
  177. package/src/specs/subagent.test.ts +130 -4
  178. package/src/summarization/__tests__/node.test.ts +32 -14
  179. package/src/summarization/node.ts +15 -11
  180. package/src/tools/SubagentTool.ts +4 -4
  181. package/src/tools/ToolNode.ts +643 -157
  182. package/src/tools/__tests__/SubagentExecutor.test.ts +740 -15
  183. package/src/tools/__tests__/SubagentReplay.test.ts +329 -0
  184. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
  185. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +512 -2
  186. package/src/tools/__tests__/hitl.test.ts +234 -5
  187. package/src/tools/__tests__/subagentHooks.test.ts +1126 -61
  188. package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
  189. package/src/tools/search/rag-api-reranker.test.ts +891 -0
  190. package/src/tools/search/rerankers.ts +260 -0
  191. package/src/tools/search/tool.ts +6 -0
  192. package/src/tools/search/types.ts +45 -1
  193. package/src/tools/subagent/SubagentExecutionRegistry.ts +1159 -0
  194. package/src/tools/subagent/SubagentExecutor.ts +2232 -312
  195. package/src/tools/subagent/SubagentReplay.ts +584 -0
  196. package/src/tools/subagent/__tests__/SubagentExecutionRegistry.test.ts +637 -0
  197. package/src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts +3054 -0
  198. package/src/tools/subagent/__tests__/childGraphConfig.test.ts +521 -0
  199. package/src/tools/subagent/childGraphConfig.ts +694 -0
  200. package/src/tools/subagent/index.ts +6 -0
  201. package/src/tools/subagent/runtimeLimits.ts +7 -0
  202. package/src/tools/toolOutputReferences.ts +40 -1
  203. package/src/types/graph.ts +222 -24
  204. package/src/types/hitl.ts +16 -0
@@ -12,6 +12,7 @@ import {
12
12
  END,
13
13
  Send,
14
14
  Command,
15
+ GraphInterrupt,
15
16
  isCommand,
16
17
  interrupt,
17
18
  isGraphInterrupt,
@@ -25,8 +26,8 @@ import type {
25
26
  ToolRuntime,
26
27
  StructuredToolInterface,
27
28
  } from '@langchain/core/tools';
28
- import type { BaseMessage } from '@langchain/core/messages';
29
29
  import type { LangGraphRunnableConfig } from '@langchain/langgraph';
30
+ import type { BaseMessage } from '@langchain/core/messages';
30
31
  import type {
31
32
  ToolOutputResolveView,
32
33
  PreResolvedArgsMap,
@@ -34,11 +35,18 @@ import type {
34
35
  ResolveResult,
35
36
  ResolveOptions,
36
37
  } from '@/tools/toolOutputReferences';
38
+ import type {
39
+ ReplayableSubagentTool,
40
+ SubagentResumeManifest,
41
+ SubagentToolNodeResumeState,
42
+ } from '@/tools/subagent/SubagentReplay';
37
43
  import type {
38
44
  HookRegistry,
39
45
  AggregatedHookResult,
40
46
  PostToolBatchEntry,
47
+ ToolApprovalReplayKey,
41
48
  } from '@/hooks';
49
+ import type { RunBreakerScope } from '@/llm/streamLimits';
42
50
  import type * as t from '@/types';
43
51
  import {
44
52
  cloneToolMessageWithContent,
@@ -49,6 +57,11 @@ import {
49
57
  serializeStructuredValueBounded,
50
58
  serializeToolContentBounded,
51
59
  } from '@/utils/toolContent';
60
+ import {
61
+ attachSubagentResumeManifest,
62
+ SUBAGENT_PARENT_BATCH_CONFIG_KEY,
63
+ SUBAGENT_REPLAY_CONTROLLER,
64
+ } from '@/tools/subagent/SubagentReplay';
52
65
  import {
53
66
  INTENT_ARG,
54
67
  readOutcomeFields,
@@ -73,17 +86,16 @@ import {
73
86
  calculateMaxToolResultChars,
74
87
  truncateToolResultContent,
75
88
  } from '@/utils/truncation';
89
+ import {
90
+ StreamLimitExceededError,
91
+ RUN_BREAKER_SCOPE_CONFIG_KEY,
92
+ } from '@/llm/streamLimits';
76
93
  import {
77
94
  resolveLocalToolRegistry,
78
95
  resolveLocalExecutionTools,
79
96
  } from '@/tools/local';
80
97
  import { stripCodeSessionFileSummary } from '@/tools/CodeSessionFileSummary';
81
98
  import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
82
- import type { RunBreakerScope } from '@/llm/streamLimits';
83
- import {
84
- StreamLimitExceededError,
85
- RUN_BREAKER_SCOPE_CONFIG_KEY,
86
- } from '@/llm/streamLimits';
87
99
 
88
100
  /** Host-facing batch requests must not carry the batch's breaker scope —
89
101
  * hosts spread `configurable` into their own run configs. */
@@ -99,7 +111,42 @@ function stripRunBreakerScope(
99
111
  import { convertInjectedMessages } from '@/messages/injected';
100
112
  import { safeDispatchCustomEvent } from '@/utils/events';
101
113
  import { RunnableCallable, composeAbortSignals } from '@/utils';
102
- import { executeHooks } from '@/hooks';
114
+ import {
115
+ executeHooks,
116
+ TOOL_APPROVAL_EXECUTION_SCOPE_CONFIG_KEY,
117
+ } from '@/hooks';
118
+
119
+ function createToolApprovalReplayKey(
120
+ config: RunnableConfig,
121
+ agentId: string,
122
+ toolUseId: string
123
+ ): ToolApprovalReplayKey {
124
+ const configuredScope =
125
+ config.configurable?.[TOOL_APPROVAL_EXECUTION_SCOPE_CONFIG_KEY];
126
+ const threadId = config.configurable?.thread_id;
127
+ let executionScope = '';
128
+ if (typeof configuredScope === 'string') {
129
+ executionScope = configuredScope;
130
+ } else if (typeof threadId === 'string') {
131
+ executionScope = threadId;
132
+ }
133
+ return {
134
+ executionScope,
135
+ agentId,
136
+ toolUseId,
137
+ };
138
+ }
139
+
140
+ function getToolApprovalReplaySessionId(
141
+ config: RunnableConfig,
142
+ hookSessionId: string
143
+ ): string {
144
+ const configuredScope =
145
+ config.configurable?.[TOOL_APPROVAL_EXECUTION_SCOPE_CONFIG_KEY];
146
+ return typeof configuredScope === 'string' && configuredScope.length > 0
147
+ ? configuredScope
148
+ : hookSessionId;
149
+ }
103
150
 
104
151
  /**
105
152
  * Per-call batch context for `runTool`. Bundles every optional
@@ -145,6 +192,8 @@ type RunToolBatchContext<T = unknown> = {
145
192
  * contract for hosts relying on it for policy / recovery guidance.
146
193
  */
147
194
  additionalContextsSink?: string[];
195
+ /** Stable identity of the assistant tool-call batch across HITL replay. */
196
+ replayBatchKey?: string;
148
197
  /**
149
198
  * Graph state the ToolNode was invoked with, threaded from `run()`
150
199
  * so `tool.invoke` can forward it as langgraph 1.4's `runtime.state`
@@ -156,6 +205,28 @@ type RunToolBatchContext<T = unknown> = {
156
205
  errorOwnership?: ToolErrorOwnership;
157
206
  };
158
207
 
208
+ function withSubagentReplayBatch(
209
+ config: RunnableConfig,
210
+ replayBatchKey: string | undefined
211
+ ): RunnableConfig {
212
+ if (replayBatchKey == null) {
213
+ return config;
214
+ }
215
+ return {
216
+ ...config,
217
+ configurable: {
218
+ ...config.configurable,
219
+ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: replayBatchKey,
220
+ },
221
+ };
222
+ }
223
+
224
+ type SettledDirectToolResult = {
225
+ output: BaseMessage | Command;
226
+ additionalContexts: string[];
227
+ resolvedArgs?: Record<string, unknown>;
228
+ };
229
+
159
230
  /**
160
231
  * Batch-local record of who owns each failed call's completion event.
161
232
  *
@@ -315,6 +386,38 @@ function describeOfferedShape(value: unknown): string {
315
386
  return typeof value;
316
387
  }
317
388
 
389
+ type AssistantBatch = {
390
+ message: AIMessage;
391
+ index: number;
392
+ messageCount: number;
393
+ };
394
+
395
+ function findAssistantBatch(
396
+ messages: BaseMessage[]
397
+ ): AssistantBatch | undefined {
398
+ for (let i = messages.length - 1; i >= 0; i--) {
399
+ const message = messages[i];
400
+ if (isAIMessage(message)) {
401
+ return { message, index: i, messageCount: messages.length };
402
+ }
403
+ }
404
+ return undefined;
405
+ }
406
+
407
+ function getAssistantBatchReplayKey(
408
+ batch: AssistantBatch,
409
+ runId: string | undefined,
410
+ threadId: string | undefined
411
+ ): string {
412
+ return JSON.stringify([
413
+ runId ?? null,
414
+ threadId ?? null,
415
+ batch.message.id ?? null,
416
+ batch.index,
417
+ batch.messageCount,
418
+ ]);
419
+ }
420
+
318
421
  /**
319
422
  * Per-entry record collected during PreToolUse hook handling for tool
320
423
  * calls that need human approval. Carries everything
@@ -342,10 +445,14 @@ type AskEntry = {
342
445
  * needs `interrupt()` plus the AsyncLocalStorage anchoring shim).
343
446
  */
344
447
  function buildToolApprovalInterruptPayload(
345
- askEntries: ReadonlyArray<AskEntry>
448
+ askEntries: ReadonlyArray<AskEntry>,
449
+ hookSessionId?: string
346
450
  ): t.ToolApprovalInterruptPayload {
347
451
  return {
348
452
  type: 'tool_approval',
453
+ ...(hookSessionId == null || hookSessionId === ''
454
+ ? {}
455
+ : { hook_session_id: hookSessionId }),
349
456
  action_requests: askEntries.map(({ entry, reason }) => {
350
457
  const request: t.ToolApprovalRequest = {
351
458
  tool_call_id: entry.call.id!,
@@ -574,6 +681,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
574
681
  * the Run ends.
575
682
  */
576
683
  private directPathTurns: Map<string, number> = new Map();
684
+ /** Terminal results from interrupting siblings that must survive a
685
+ * LangGraph replay of the containing ToolNode. Includes the sidecar data
686
+ * the fresh batch needs for hook-context injection and completion events. */
687
+ private settledInterruptingResults = new Map<
688
+ string,
689
+ Map<string, SettledDirectToolResult>
690
+ >();
577
691
  /** Tool registry for filtering (lazy computation of programmatic maps) */
578
692
  private toolRegistry?: t.LCToolRegistry;
579
693
  /** Cached programmatic tools (computed once on first PTC call) */
@@ -931,16 +1045,16 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
931
1045
  }
932
1046
 
933
1047
  /**
934
- * Flush the per-Run direct-path turn cache. Called by the Graph at
935
- * end-of-Run via `clearHeavyState`. The map intentionally survives
936
- * `run()` re-entry so an interrupt + resume reuses the original
937
- * slot (Codex P2 #30), but it would otherwise grow linearly with
938
- * tool calls and could collide across Runs if a provider reused
939
- * call IDs (Codex P2 #33). Hosts can also call this directly if
1048
+ * Flush per-Run direct replay state. Called by the Graph at end-of-Run via
1049
+ * `clearHeavyState`. The state intentionally survives `run()` re-entry so
1050
+ * interrupt + resume keeps both original turn slots and terminal sibling
1051
+ * outputs, but it would otherwise grow linearly and could collide across
1052
+ * Runs if a provider reused call IDs. Hosts can also call this directly if
940
1053
  * they reuse a ToolNode across batches outside of a Graph.
941
1054
  */
942
1055
  clearDirectPathTurns(): void {
943
1056
  this.directPathTurns.clear();
1057
+ this.settledInterruptingResults.clear();
944
1058
  }
945
1059
 
946
1060
  /**
@@ -977,6 +1091,35 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
977
1091
  return new Map(this.toolUsageCount); // Return a copy
978
1092
  }
979
1093
 
1094
+ createSubagentResumeState(): SubagentToolNodeResumeState {
1095
+ return {
1096
+ stateKey: JSON.stringify([
1097
+ this.executingAgentId ?? '',
1098
+ this.agentId ?? '',
1099
+ this.name,
1100
+ ]),
1101
+ toolUsageCounts: [...this.toolUsageCount].map(([toolName, count]) => ({
1102
+ toolName,
1103
+ count,
1104
+ })),
1105
+ directPathTurns: [...this.directPathTurns].map(([toolCallId, turn]) => ({
1106
+ toolCallId,
1107
+ turn,
1108
+ })),
1109
+ };
1110
+ }
1111
+
1112
+ restoreSubagentResumeState(state: SubagentToolNodeResumeState): void {
1113
+ this.toolUsageCount.clear();
1114
+ for (const { toolName, count } of state.toolUsageCounts) {
1115
+ this.toolUsageCount.set(toolName, count);
1116
+ }
1117
+ this.directPathTurns.clear();
1118
+ for (const { toolCallId, turn } of state.directPathTurns) {
1119
+ this.directPathTurns.set(toolCallId, turn);
1120
+ }
1121
+ }
1122
+
980
1123
  private recordToolUsageTurn(
981
1124
  toolName: string,
982
1125
  turn: number,
@@ -1475,9 +1618,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1475
1618
  * `blockDirectCall`, `respond` returns the host-supplied
1476
1619
  * `responseText` as a synthetic success ToolMessage,
1477
1620
  * `edit` re-runs with edited args. LangGraph re-enters
1478
- * ToolNode.run from the start on resume; the hook fires
1479
- * again and the resume value distinguishes "first ask" from
1480
- * "second pass with decision".
1621
+ * ToolNode.run from the start on resume. Reusable hooks fire
1622
+ * again; a consumed one-shot hook replays its pending approval
1623
+ * result. In both cases `interrupt()` consumes the resume value.
1481
1624
  * • When HITL is off: collapses to a fail-closed deny (matches
1482
1625
  * the rest of the SDK's HITL-disabled default). One-time
1483
1626
  * warning logged so hosts notice the gap.
@@ -1501,21 +1644,122 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1501
1644
  config: RunnableConfig,
1502
1645
  batchContext: RunToolBatchContext<T> = {}
1503
1646
  ): Promise<BaseMessage | Command> {
1647
+ const replayController = (
1648
+ this.toolMap.get(call.name) as ReplayableSubagentTool | undefined
1649
+ )?.[SUBAGENT_REPLAY_CONTROLLER];
1650
+ const replayConfig =
1651
+ replayController == null
1652
+ ? config
1653
+ : withSubagentReplayBatch(config, batchContext.replayBatchKey);
1654
+ const settledOutput = await replayController?.getSettledOutput(
1655
+ call,
1656
+ replayConfig
1657
+ );
1658
+ if (settledOutput != null) {
1659
+ if (
1660
+ batchContext.additionalContextsSink != null &&
1661
+ settledOutput.additionalContexts.length > 0
1662
+ ) {
1663
+ batchContext.additionalContextsSink.push(
1664
+ ...settledOutput.additionalContexts
1665
+ );
1666
+ }
1667
+ if (
1668
+ batchContext.resolvedArgsByCallId != null &&
1669
+ call.id != null &&
1670
+ settledOutput.resolvedArgs != null
1671
+ ) {
1672
+ batchContext.resolvedArgsByCallId.set(
1673
+ call.id,
1674
+ settledOutput.resolvedArgs
1675
+ );
1676
+ }
1677
+ const refMeta = settledOutput.output.additional_kwargs as
1678
+ | t.ToolMessageRefMetadata
1679
+ | undefined;
1680
+ if (
1681
+ this.toolOutputRegistry != null &&
1682
+ refMeta?._refKey != null &&
1683
+ settledOutput.referenceContent != null
1684
+ ) {
1685
+ this.toolOutputRegistry.set(
1686
+ refMeta._refScope,
1687
+ refMeta._refKey,
1688
+ settledOutput.referenceContent
1689
+ );
1690
+ }
1691
+ return settledOutput.output;
1692
+ }
1693
+ const replayAdditionalContexts: string[] = [];
1694
+ const persistOutput = async (
1695
+ output: ToolMessage,
1696
+ terminalArgs?: Record<string, unknown>
1697
+ ): Promise<ToolMessage> => {
1698
+ const refMeta = output.additional_kwargs as
1699
+ | t.ToolMessageRefMetadata
1700
+ | undefined;
1701
+ if (
1702
+ terminalArgs != null &&
1703
+ call.id != null &&
1704
+ batchContext.resolvedArgsByCallId != null
1705
+ ) {
1706
+ batchContext.resolvedArgsByCallId.set(call.id, terminalArgs);
1707
+ }
1708
+ const resolvedArgs =
1709
+ terminalArgs ??
1710
+ (call.id == null
1711
+ ? undefined
1712
+ : batchContext.resolvedArgsByCallId?.get(call.id));
1713
+ const referenceContent =
1714
+ this.toolOutputRegistry == null || refMeta?._refKey == null
1715
+ ? undefined
1716
+ : this.toolOutputRegistry.get(refMeta._refScope, refMeta._refKey);
1717
+ await replayController?.persistSettledOutput(call, replayConfig, {
1718
+ output,
1719
+ additionalContexts: replayAdditionalContexts,
1720
+ ...(resolvedArgs == null ? {} : { resolvedArgs }),
1721
+ ...(referenceContent == null ? {} : { referenceContent }),
1722
+ });
1723
+ return output;
1724
+ };
1504
1725
  const runId = (config.configurable?.run_id as string | undefined) ?? '';
1726
+ const threadId = config.configurable?.thread_id as string | undefined;
1505
1727
  const hookRegistry = this.hookRegistry;
1506
1728
  const hasPreHook = hookRegistry?.hasHookFor('PreToolUse', runId) === true;
1729
+ const approvalReplayKey =
1730
+ call.id == null || call.id === ''
1731
+ ? undefined
1732
+ : createToolApprovalReplayKey(
1733
+ config,
1734
+ this.executingAgentId ?? this.agentId ?? '',
1735
+ call.id
1736
+ );
1737
+ const approvalReplaySessionId = getToolApprovalReplaySessionId(
1738
+ config,
1739
+ runId
1740
+ );
1741
+ const pendingApproval =
1742
+ approvalReplayKey == null
1743
+ ? undefined
1744
+ : hookRegistry?.getPendingToolApproval(
1745
+ approvalReplaySessionId,
1746
+ approvalReplayKey
1747
+ );
1507
1748
  const hasPostHook = hookRegistry?.hasHookFor('PostToolUse', runId) === true;
1508
1749
  const hasFailureHook =
1509
1750
  hookRegistry?.hasHookFor('PostToolUseFailure', runId) === true;
1510
1751
 
1511
1752
  if (
1512
1753
  hookRegistry == null ||
1513
- (!hasPreHook && !hasPostHook && !hasFailureHook)
1754
+ (!hasPreHook &&
1755
+ pendingApproval == null &&
1756
+ !hasPostHook &&
1757
+ !hasFailureHook)
1514
1758
  ) {
1515
- return this.runTool(call, config, batchContext);
1759
+ const output = await this.runTool(call, replayConfig, batchContext);
1760
+ return output instanceof ToolMessage ? persistOutput(output) : output;
1516
1761
  }
1517
1762
 
1518
- const threadId = config.configurable?.thread_id as string | undefined;
1519
1763
  const registryRunId =
1520
1764
  batchContext.batchScopeId ??
1521
1765
  (config.configurable?.run_id as string | undefined);
@@ -1583,7 +1827,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1583
1827
  }
1584
1828
 
1585
1829
  let effectiveCall = call;
1586
- if (hasPreHook) {
1830
+ if (hasPreHook || pendingApproval != null) {
1587
1831
  const preResult = await executeHooks({
1588
1832
  registry: hookRegistry,
1589
1833
  input: {
@@ -1600,6 +1844,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1600
1844
  },
1601
1845
  sessionId: runId,
1602
1846
  matchQuery: call.name,
1847
+ onceReplayKey: approvalReplayKey,
1848
+ onceReplaySessionId: approvalReplaySessionId,
1603
1849
  }).catch(() => undefined);
1604
1850
 
1605
1851
  if (preResult != null) {
@@ -1607,11 +1853,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1607
1853
  // the per-batch sink so the caller materializes them as a
1608
1854
  // HumanMessage for the next model turn — same shape as the
1609
1855
  // event-driven path's `injected[]`. Codex P2 #39.
1610
- if (
1611
- batchContext.additionalContextsSink != null &&
1612
- preResult.additionalContexts.length > 0
1613
- ) {
1614
- batchContext.additionalContextsSink.push(
1856
+ if (preResult.additionalContexts.length > 0) {
1857
+ replayAdditionalContexts.push(...preResult.additionalContexts);
1858
+ batchContext.additionalContextsSink?.push(
1615
1859
  ...preResult.additionalContexts
1616
1860
  );
1617
1861
  }
@@ -1626,14 +1870,17 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1626
1870
  }
1627
1871
 
1628
1872
  if (preResult.decision === 'deny') {
1629
- return this.blockDirectCall({
1630
- call,
1631
- resolvedArgs,
1632
- reason: preResult.reason ?? 'Blocked by hook',
1633
- hookRegistry,
1634
- runId,
1635
- threadId,
1636
- });
1873
+ return persistOutput(
1874
+ this.blockDirectCall({
1875
+ call,
1876
+ resolvedArgs,
1877
+ reason: preResult.reason ?? 'Blocked by hook',
1878
+ hookRegistry,
1879
+ runId,
1880
+ threadId,
1881
+ }),
1882
+ effectiveCall.args as Record<string, unknown>
1883
+ );
1637
1884
  }
1638
1885
 
1639
1886
  if (preResult.decision === 'ask') {
@@ -1644,27 +1891,49 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1644
1891
  preResult.reason,
1645
1892
  call.name
1646
1893
  );
1647
- return this.blockDirectCall({
1648
- call,
1649
- resolvedArgs,
1650
- reason,
1651
- hookRegistry,
1652
- runId,
1653
- threadId,
1654
- });
1894
+ return persistOutput(
1895
+ this.blockDirectCall({
1896
+ call,
1897
+ resolvedArgs,
1898
+ reason,
1899
+ hookRegistry,
1900
+ runId,
1901
+ threadId,
1902
+ }),
1903
+ effectiveCall.args as Record<string, unknown>
1904
+ );
1905
+ }
1906
+ const toolCallId = call.id;
1907
+ if (
1908
+ toolCallId == null ||
1909
+ toolCallId === '' ||
1910
+ approvalReplayKey == null
1911
+ ) {
1912
+ return persistOutput(
1913
+ this.blockDirectCall({
1914
+ call,
1915
+ resolvedArgs,
1916
+ reason:
1917
+ 'Tool approval requires a non-empty tool call ID — failing closed',
1918
+ hookRegistry,
1919
+ runId,
1920
+ threadId,
1921
+ }),
1922
+ effectiveCall.args as Record<string, unknown>
1923
+ );
1655
1924
  }
1656
1925
 
1657
1926
  // Raise a single-tool tool_approval interrupt. LangGraph
1658
1927
  // throws on the first execution (host gets the interrupt)
1659
1928
  // and returns the resume value on re-entry. Because direct
1660
- // tools re-enter the entire ToolNode.run on resume, the
1661
- // PreToolUse hook fires AGAIN which is fine: the hook is
1662
- // expected to be deterministic, and the resume value is what
1663
- // distinguishes "first call asking" from "second call after
1664
- // approve/reject". We anchor `interrupt()` against the
1929
+ // tools re-enter the entire ToolNode.run on resume. Reusable
1930
+ // hooks fire again; a consumed one-shot hook instead replays
1931
+ // the pending approval. We anchor `interrupt()` against the
1665
1932
  // node's RunnableConfig the same way `dispatchToolEvents`
1666
- // does (ToolNode disables LangSmith tracing, so the
1667
- // AsyncLocalStorage frame must be re-established here).
1933
+ // does. A one-shot hook's pending contribution reconstructs this
1934
+ // same ask entry without dispatching the consumed hook again.
1935
+ // ToolNode disables LangSmith tracing, so the AsyncLocalStorage
1936
+ // frame must be re-established here.
1668
1937
  const askEntry: AskEntry = {
1669
1938
  entry: {
1670
1939
  call: effectiveCall,
@@ -1674,7 +1943,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1674
1943
  reason: preResult.reason,
1675
1944
  allowedDecisions: preResult.allowedDecisions,
1676
1945
  };
1677
- const payload = buildToolApprovalInterruptPayload([askEntry]);
1946
+ const payload = buildToolApprovalInterruptPayload([askEntry], runId);
1678
1947
  const resumeValue = AsyncLocalStorageProviderSingleton.runWithConfig(
1679
1948
  config,
1680
1949
  () =>
@@ -1683,11 +1952,15 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1683
1952
  t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap
1684
1953
  >(payload)
1685
1954
  );
1955
+ hookRegistry.clearPendingToolApproval(
1956
+ approvalReplaySessionId,
1957
+ approvalReplayKey
1958
+ );
1686
1959
  const decisionByCallId = normalizeApprovalDecisions(
1687
- [call.id!],
1960
+ [toolCallId],
1688
1961
  resumeValue
1689
1962
  );
1690
- const decision = decisionByCallId.get(call.id!) ?? {
1963
+ const decision = decisionByCallId.get(toolCallId) ?? {
1691
1964
  type: 'reject' as const,
1692
1965
  reason: 'No decision provided for tool approval',
1693
1966
  };
@@ -1700,47 +1973,63 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1700
1973
  declaredType as t.ToolApprovalDecisionType
1701
1974
  ))
1702
1975
  ) {
1703
- return this.blockDirectCall({
1704
- call,
1705
- resolvedArgs,
1706
- reason: `Decision "${typeof declaredType === 'string' ? declaredType : '<missing>'}" not in allowedDecisions [${preResult.allowedDecisions.join(', ')}] — failing closed`,
1707
- hookRegistry,
1708
- runId,
1709
- threadId,
1710
- });
1976
+ return persistOutput(
1977
+ this.blockDirectCall({
1978
+ call,
1979
+ resolvedArgs,
1980
+ reason: `Decision "${typeof declaredType === 'string' ? declaredType : '<missing>'}" not in allowedDecisions [${preResult.allowedDecisions.join(', ')}] — failing closed`,
1981
+ hookRegistry,
1982
+ runId,
1983
+ threadId,
1984
+ }),
1985
+ effectiveCall.args as Record<string, unknown>
1986
+ );
1711
1987
  }
1712
1988
 
1713
1989
  if (decision.type === 'reject') {
1714
- return this.blockDirectCall({
1715
- call,
1716
- resolvedArgs,
1717
- reason: decision.reason ?? preResult.reason ?? 'Rejected by user',
1718
- hookRegistry,
1719
- runId,
1720
- threadId,
1721
- });
1722
- }
1723
-
1724
- if (decision.type === 'respond') {
1725
- const responseText = (decision as { responseText?: unknown })
1726
- .responseText;
1727
- if (typeof responseText !== 'string') {
1728
- return this.blockDirectCall({
1990
+ return persistOutput(
1991
+ this.blockDirectCall({
1729
1992
  call,
1730
1993
  resolvedArgs,
1731
1994
  reason:
1732
- 'Approval payload `respond` was missing a string `responseText`',
1995
+ decision.reason ?? preResult.reason ?? 'Rejected by user',
1733
1996
  hookRegistry,
1734
1997
  runId,
1735
1998
  threadId,
1736
- });
1999
+ }),
2000
+ effectiveCall.args as Record<string, unknown>
2001
+ );
2002
+ }
2003
+
2004
+ if (decision.type === 'respond') {
2005
+ const responseText = (decision as { responseText?: unknown })
2006
+ .responseText;
2007
+ if (typeof responseText !== 'string') {
2008
+ return persistOutput(
2009
+ this.blockDirectCall({
2010
+ call,
2011
+ resolvedArgs,
2012
+ reason:
2013
+ 'Approval payload `respond` was missing a string `responseText`',
2014
+ hookRegistry,
2015
+ runId,
2016
+ threadId,
2017
+ }),
2018
+ effectiveCall.args as Record<string, unknown>
2019
+ );
1737
2020
  }
1738
- return new ToolMessage({
1739
- status: 'success',
1740
- content: responseText,
1741
- name: call.name,
1742
- tool_call_id: call.id ?? '',
1743
- });
2021
+ return persistOutput(
2022
+ new ToolMessage({
2023
+ status: 'success',
2024
+ content: truncateToolResultContent(
2025
+ responseText,
2026
+ this.maxToolResultChars
2027
+ ),
2028
+ name: call.name,
2029
+ tool_call_id: call.id ?? '',
2030
+ }),
2031
+ effectiveCall.args as Record<string, unknown>
2032
+ );
1744
2033
  }
1745
2034
 
1746
2035
  if (decision.type === 'edit') {
@@ -1759,13 +2048,16 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1759
2048
  typeof updatedInput !== 'object' ||
1760
2049
  Array.isArray(updatedInput)
1761
2050
  ) {
1762
- return new ToolMessage({
1763
- status: 'error',
1764
- content:
1765
- 'Decision "edit" missing object updatedInput — failing closed.',
1766
- name: call.name,
1767
- tool_call_id: call.id ?? '',
1768
- });
2051
+ return persistOutput(
2052
+ new ToolMessage({
2053
+ status: 'error',
2054
+ content:
2055
+ 'Decision "edit" missing object updatedInput — failing closed.',
2056
+ name: call.name,
2057
+ tool_call_id: call.id ?? '',
2058
+ }),
2059
+ effectiveCall.args as Record<string, unknown>
2060
+ );
1769
2061
  }
1770
2062
  effectiveCall = {
1771
2063
  ...call,
@@ -1773,6 +2065,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1773
2065
  };
1774
2066
  // fall through to executing the edited call
1775
2067
  }
2068
+ if (declaredType !== 'approve' && declaredType !== 'edit') {
2069
+ const unknownType =
2070
+ typeof declaredType === 'string' ? declaredType : '<missing>';
2071
+ return persistOutput(
2072
+ this.blockDirectCall({
2073
+ call,
2074
+ resolvedArgs,
2075
+ reason: `Unknown approval decision type "${unknownType}" — failing closed`,
2076
+ hookRegistry,
2077
+ runId,
2078
+ threadId,
2079
+ }),
2080
+ effectiveCall.args as Record<string, unknown>
2081
+ );
2082
+ }
1776
2083
  // 'approve' (or 'edit' after applying edits) → fall through
1777
2084
  }
1778
2085
  }
@@ -1799,7 +2106,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1799
2106
  );
1800
2107
  }
1801
2108
 
1802
- const output = await this.runTool(effectiveCall, config, {
2109
+ const output = await this.runTool(effectiveCall, replayConfig, {
1803
2110
  ...batchContext,
1804
2111
  usageCount,
1805
2112
  });
@@ -1839,14 +2146,14 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1839
2146
  }).catch(() => undefined);
1840
2147
  if (
1841
2148
  failureResult != null &&
1842
- batchContext.additionalContextsSink != null &&
1843
2149
  failureResult.additionalContexts.length > 0
1844
2150
  ) {
1845
- batchContext.additionalContextsSink.push(
2151
+ replayAdditionalContexts.push(...failureResult.additionalContexts);
2152
+ batchContext.additionalContextsSink?.push(
1846
2153
  ...failureResult.additionalContexts
1847
2154
  );
1848
2155
  }
1849
- return output;
2156
+ return persistOutput(output);
1850
2157
  }
1851
2158
 
1852
2159
  if (output.status !== 'error' && hasPostHook) {
@@ -1871,12 +2178,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1871
2178
 
1872
2179
  // Forward additionalContexts from the PostToolUse hook into
1873
2180
  // the per-batch sink (Codex P2 #39).
1874
- if (
1875
- postResult != null &&
1876
- batchContext.additionalContextsSink != null &&
1877
- postResult.additionalContexts.length > 0
1878
- ) {
1879
- batchContext.additionalContextsSink.push(
2181
+ if (postResult != null && postResult.additionalContexts.length > 0) {
2182
+ replayAdditionalContexts.push(...postResult.additionalContexts);
2183
+ batchContext.additionalContextsSink?.push(
1880
2184
  ...postResult.additionalContexts
1881
2185
  );
1882
2186
  }
@@ -1888,7 +2192,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1888
2192
  'PostToolUse updatedOutput for a computer call must be a valid screenshot URL or screenshot content block.'
1889
2193
  );
1890
2194
  }
1891
- return cloneToolMessageWithContent(output, postResult.updatedOutput);
2195
+ return persistOutput(
2196
+ cloneToolMessageWithContent(output, postResult.updatedOutput)
2197
+ );
1892
2198
  }
1893
2199
  // Keep the tool-output registry in sync with what the model
1894
2200
  // actually sees. Without this, `runTool` already registered
@@ -1918,11 +2224,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1918
2224
  replaced.registryContent
1919
2225
  );
1920
2226
  }
1921
- return cloneToolMessageWithContent(output, replaced.content);
2227
+ return persistOutput(
2228
+ cloneToolMessageWithContent(output, replaced.content)
2229
+ );
1922
2230
  }
1923
2231
  }
1924
2232
 
1925
- return output;
2233
+ return persistOutput(output);
1926
2234
  }
1927
2235
 
1928
2236
  /**
@@ -2381,13 +2689,27 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2381
2689
  errors: [] as string[],
2382
2690
  });
2383
2691
 
2384
- if (this.hookRegistry?.hasHookFor('PreToolUse', runId) === true) {
2385
- /**
2386
- * Capture as a non-null local so the inner `blockEntry` closure
2387
- * doesn't lose narrowing on `this.hookRegistry` and we don't have
2388
- * to defensively `?.` it across every reference inside.
2389
- */
2390
- const hookRegistry = this.hookRegistry;
2692
+ const hookRegistry = this.hookRegistry;
2693
+ const approvalReplaySessionId = getToolApprovalReplaySessionId(
2694
+ config,
2695
+ runId
2696
+ );
2697
+ const hasPendingApproval = preToolCalls.some(
2698
+ (entry) =>
2699
+ entry.call.id != null &&
2700
+ hookRegistry?.getPendingToolApproval(
2701
+ approvalReplaySessionId,
2702
+ createToolApprovalReplayKey(
2703
+ config,
2704
+ this.executingAgentId ?? this.agentId ?? '',
2705
+ entry.call.id
2706
+ )
2707
+ ) != null
2708
+ );
2709
+ if (
2710
+ hookRegistry != null &&
2711
+ (hookRegistry.hasHookFor('PreToolUse', runId) || hasPendingApproval)
2712
+ ) {
2391
2713
  /**
2392
2714
  * Pull each call's prestarted eager record BEFORE awaiting the hooks:
2393
2715
  * an async deny must never race the eager host promise into emitting a
@@ -2415,8 +2737,17 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2415
2737
  }
2416
2738
  }
2417
2739
  const preResults = await Promise.all(
2418
- preToolCalls.map((entry) =>
2419
- executeHooks({
2740
+ preToolCalls.map((entry) => {
2741
+ const toolUseId = entry.call.id;
2742
+ const approvalReplayKey =
2743
+ toolUseId == null
2744
+ ? undefined
2745
+ : createToolApprovalReplayKey(
2746
+ config,
2747
+ this.executingAgentId ?? this.agentId ?? '',
2748
+ toolUseId
2749
+ );
2750
+ return executeHooks({
2420
2751
  registry: hookRegistry,
2421
2752
  input: {
2422
2753
  hook_event_name: 'PreToolUse',
@@ -2432,8 +2763,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2432
2763
  },
2433
2764
  sessionId: runId,
2434
2765
  matchQuery: entry.call.name,
2435
- }).catch((): AggregatedHookResult => HOOK_FALLBACK)
2436
- )
2766
+ onceReplayKey: approvalReplayKey,
2767
+ onceReplaySessionId: approvalReplaySessionId,
2768
+ }).catch((): AggregatedHookResult => HOOK_FALLBACK);
2769
+ })
2437
2770
  );
2438
2771
 
2439
2772
  type PendingEntry = (typeof preToolCalls)[number];
@@ -2653,12 +2986,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2653
2986
  * pauses, gathers human input, and resumes the run with one
2654
2987
  * decision per request. On resume LangGraph re-executes this node
2655
2988
  * from the start; `interrupt()` then returns the resume value
2656
- * instead of throwing, so the loop above re-runs and the same
2657
- * `askEntries` list is rebuilt deterministically (assuming hooks
2658
- * are pure — see `humanInTheLoop` docs).
2989
+ * instead of throwing. Reusable hooks rebuild their entries, while
2990
+ * consumed one-shot hooks replay their pending approval aggregate.
2659
2991
  */
2660
2992
  if (askEntries.length > 0) {
2661
- const payload = buildToolApprovalInterruptPayload(askEntries);
2993
+ const payload = buildToolApprovalInterruptPayload(askEntries, runId);
2662
2994
 
2663
2995
  /**
2664
2996
  * `interrupt()` reads the current `RunnableConfig` from
@@ -2678,6 +3010,17 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2678
3010
  >(payload)
2679
3011
  );
2680
3012
 
3013
+ for (const { entry } of askEntries) {
3014
+ hookRegistry.clearPendingToolApproval(
3015
+ approvalReplaySessionId,
3016
+ createToolApprovalReplayKey(
3017
+ config,
3018
+ this.executingAgentId ?? this.agentId ?? '',
3019
+ entry.call.id!
3020
+ )
3021
+ );
3022
+ }
3023
+
2681
3024
  const decisionByCallId = normalizeApprovalDecisions(
2682
3025
  askEntries.map(({ entry }) => entry.call.id!),
2683
3026
  resumeValue
@@ -3051,7 +3394,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3051
3394
  // match it at the top level too.
3052
3395
  agentId: this.executingAgentId,
3053
3396
  configurable: stripRunBreakerScope(
3054
- config.configurable as Record<string, unknown> | undefined
3397
+ config.configurable as Record<string, unknown> | undefined
3055
3398
  ),
3056
3399
  metadata: config.metadata as
3057
3400
  | Record<string, unknown>
@@ -3680,16 +4023,16 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3680
4023
  * call throws a `GraphInterrupt`, the `await` below rejects and unwinds the
3681
4024
  * whole ToolNode *before* any non-interrupting sibling has started — so a
3682
4025
  * sibling with real side effects (send_email, billing) never executes on
3683
- * the first pass. On the resume pass LangGraph re-runs the batch from the
3684
- * top; the interrupting tool resolves with the host's answer instead of
3685
- * throwing, and the siblings execute for the FIRST time, exactly once.
4026
+ * the first pass. Terminal interrupting siblings are cached by call id, so
4027
+ * LangGraph replay reuses their complete lifecycle output instead of
4028
+ * repeating model calls, hooks, or side effects.
3686
4029
  *
3687
4030
  * Without this ordering, a flat `Promise.all` starts every sibling
3688
4031
  * concurrently, so a non-idempotent sibling can complete its side effect
3689
4032
  * before the interrupt unwinds and then run a SECOND time on resume — the
3690
- * duplicate side effect this method exists to prevent. Interrupting tools
3691
- * are expected to be side-effect-free (they only suspend), so running them
3692
- * as a group and re-running them on resume is harmless.
4033
+ * duplicate side effect this method exists to prevent. A tool that actually
4034
+ * suspends re-enters until it reaches a terminal result; siblings that
4035
+ * already settled do not re-enter.
3693
4036
  *
3694
4037
  * `batchIndices[i]` is `directCalls[i]`'s position within the parent
3695
4038
  * ToolNode batch (used for `{{tool<i>turn<n>}}` registration); it is
@@ -3703,14 +4046,51 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3703
4046
  config: RunnableConfig,
3704
4047
  baseContext: Omit<RunToolBatchContext<T>, 'batchIndex'>
3705
4048
  ): Promise<(BaseMessage | Command)[]> {
3706
- const runOne = (
4049
+ const settledBatchResults =
4050
+ baseContext.replayBatchKey == null
4051
+ ? undefined
4052
+ : this.settledInterruptingResults.get(baseContext.replayBatchKey);
4053
+ const restoreResult = (
4054
+ call: ToolCall,
4055
+ result: SettledDirectToolResult
4056
+ ): SettledDirectToolResult => {
4057
+ baseContext.additionalContextsSink?.push(...result.additionalContexts);
4058
+ if (
4059
+ call.id != null &&
4060
+ result.resolvedArgs != null &&
4061
+ baseContext.resolvedArgsByCallId != null
4062
+ ) {
4063
+ baseContext.resolvedArgsByCallId.set(call.id, result.resolvedArgs);
4064
+ }
4065
+ return result;
4066
+ };
4067
+ const runOne = async (
3707
4068
  call: ToolCall,
3708
4069
  position: number
3709
- ): Promise<BaseMessage | Command> =>
3710
- this.runDirectToolWithLifecycleHooks(call, config, {
4070
+ ): Promise<SettledDirectToolResult> => {
4071
+ const cachedResult =
4072
+ typeof call.id === 'string'
4073
+ ? settledBatchResults?.get(call.id)
4074
+ : undefined;
4075
+ if (cachedResult != null) {
4076
+ return restoreResult(call, cachedResult);
4077
+ }
4078
+ const additionalContexts: string[] = [];
4079
+ const output = await this.runDirectToolWithLifecycleHooks(call, config, {
3711
4080
  ...baseContext,
3712
4081
  batchIndex: batchIndices[position],
4082
+ additionalContextsSink: additionalContexts,
4083
+ });
4084
+ const resolvedArgs =
4085
+ call.id == null
4086
+ ? undefined
4087
+ : baseContext.resolvedArgsByCallId?.get(call.id);
4088
+ return restoreResult(call, {
4089
+ output,
4090
+ additionalContexts,
4091
+ ...(resolvedArgs == null ? {} : { resolvedArgs }),
3713
4092
  });
4093
+ };
3714
4094
 
3715
4095
  const interrupting = this.interruptingToolNames;
3716
4096
  const hasInterrupting =
@@ -3718,7 +4098,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3718
4098
  directCalls.some((call) => interrupting.has(call.name));
3719
4099
 
3720
4100
  if (!hasInterrupting) {
3721
- return Promise.all(directCalls.map((call, i) => runOne(call, i)));
4101
+ const results = await Promise.all(
4102
+ directCalls.map((call, i) => runOne(call, i))
4103
+ );
4104
+ return results.map((result) => result.output);
3722
4105
  }
3723
4106
 
3724
4107
  const outputs: (BaseMessage | Command)[] = new Array(directCalls.length);
@@ -3732,14 +4115,96 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3732
4115
  }
3733
4116
  }
3734
4117
 
3735
- // Interrupting group first. A GraphInterrupt here propagates out of the
3736
- // `await` before any regular sibling is dispatched below.
3737
- const interruptingOutputs = await Promise.all(
4118
+ // Interrupting group first. Wait for every sibling to settle before
4119
+ // propagating an interrupt so approved subagents cannot keep running in
4120
+ // the background while the parent exposes the next pending approval.
4121
+ const interruptingResults = await Promise.allSettled(
3738
4122
  interruptingPositions.map((i) => runOne(directCalls[i], i))
3739
4123
  );
3740
- interruptingPositions.forEach((i, k) => {
3741
- outputs[i] = interruptingOutputs[k];
3742
- });
4124
+ /** A sibling may have tripped the run-wide breaker while another raised
4125
+ * a GraphInterrupt. Safety failures take precedence over approval pauses
4126
+ * regardless of input order. */
4127
+ this.throwIfBreakerTripped(config);
4128
+ let hasInterruptingError = false;
4129
+ let interruptingError: unknown;
4130
+ for (let i = 0; i < interruptingResults.length; i++) {
4131
+ const result = interruptingResults[i];
4132
+ if (result.status === 'rejected') {
4133
+ if (!hasInterruptingError) {
4134
+ hasInterruptingError = true;
4135
+ interruptingError = result.reason;
4136
+ }
4137
+ continue;
4138
+ }
4139
+ const position = interruptingPositions[i];
4140
+ outputs[position] = result.value.output;
4141
+ const callId = directCalls[position].id;
4142
+ if (
4143
+ baseContext.replayBatchKey != null &&
4144
+ typeof callId === 'string' &&
4145
+ callId !== ''
4146
+ ) {
4147
+ const batchResults =
4148
+ this.settledInterruptingResults.get(baseContext.replayBatchKey) ??
4149
+ new Map<string, SettledDirectToolResult>();
4150
+ batchResults.set(callId, result.value);
4151
+ this.settledInterruptingResults.set(
4152
+ baseContext.replayBatchKey,
4153
+ batchResults
4154
+ );
4155
+ }
4156
+ }
4157
+ if (hasInterruptingError) {
4158
+ if (isGraphInterrupt(interruptingError)) {
4159
+ const controllers = new Set<
4160
+ NonNullable<ReplayableSubagentTool[typeof SUBAGENT_REPLAY_CONTROLLER]>
4161
+ >();
4162
+ const parentToolCallIds = new Set<string>();
4163
+ for (const call of directCalls) {
4164
+ const controller = (
4165
+ this.toolMap.get(call.name) as ReplayableSubagentTool | undefined
4166
+ )?.[SUBAGENT_REPLAY_CONTROLLER];
4167
+ if (controller?.getResumeManifest != null) {
4168
+ controllers.add(controller);
4169
+ }
4170
+ if (call.id != null && call.id !== '') {
4171
+ parentToolCallIds.add(call.id);
4172
+ }
4173
+ }
4174
+ const executionsByParentCall = new Map<
4175
+ string,
4176
+ SubagentResumeManifest['executions'][number]
4177
+ >();
4178
+ for (const controller of controllers) {
4179
+ const manifest = await controller.getResumeManifest?.(
4180
+ parentToolCallIds,
4181
+ withSubagentReplayBatch(config, baseContext.replayBatchKey)
4182
+ );
4183
+ if (manifest != null) {
4184
+ for (const execution of manifest.executions) {
4185
+ executionsByParentCall.set(execution.parentToolCallId, execution);
4186
+ }
4187
+ }
4188
+ }
4189
+ const executions = [...executionsByParentCall.values()];
4190
+ if (executions.length > 0) {
4191
+ const manifest: SubagentResumeManifest = {
4192
+ version: 1,
4193
+ executions,
4194
+ };
4195
+ throw new GraphInterrupt(
4196
+ interruptingError.interrupts.map((pendingInterrupt) => ({
4197
+ ...pendingInterrupt,
4198
+ value: attachSubagentResumeManifest(
4199
+ pendingInterrupt.value,
4200
+ manifest
4201
+ ),
4202
+ }))
4203
+ );
4204
+ }
4205
+ }
4206
+ throw interruptingError;
4207
+ }
3743
4208
 
3744
4209
  /** The breaker can trip while the interrupting group is awaited — e.g.
3745
4210
  * a tool that ignores cancellation and completes normally. Recheck
@@ -3751,7 +4216,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3751
4216
  regularPositions.map((i) => runOne(directCalls[i], i))
3752
4217
  );
3753
4218
  regularPositions.forEach((i, k) => {
3754
- outputs[i] = regularOutputs[k];
4219
+ outputs[i] = regularOutputs[k].output;
3755
4220
  });
3756
4221
 
3757
4222
  return outputs;
@@ -3842,9 +4307,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3842
4307
  * every subsequent registry call on this batch.
3843
4308
  */
3844
4309
  const incomingRunId = config.configurable?.run_id as string | undefined;
4310
+ const incomingThreadId = config.configurable?.thread_id as
4311
+ | string
4312
+ | undefined;
3845
4313
  const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
3846
4314
  const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
3847
4315
  let outputs: (BaseMessage | Command)[];
4316
+ let replayBatchKey: string | undefined;
3848
4317
  /** Hoisted from the messages-state branch so the Command tail can carry
3849
4318
  * the promotion into handoff updates (same-id state copies there would
3850
4319
  * otherwise overwrite the replacement message). */
@@ -3872,6 +4341,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3872
4341
  // Mirror langgraph's prebuilt ToolNode: the Send-input state is
3873
4342
  // the input minus the `lg_tool_call` envelope key.
3874
4343
  const { lg_tool_call: _sendToolCall, ...sendState } = input;
4344
+ const sendMessages = (sendState as { messages?: unknown }).messages;
4345
+ const sendAssistantBatch = Array.isArray(sendMessages)
4346
+ ? findAssistantBatch(sendMessages as BaseMessage[])
4347
+ : undefined;
4348
+ const sendReplayBatchKey =
4349
+ sendAssistantBatch == null
4350
+ ? undefined
4351
+ : getAssistantBatchReplayKey(
4352
+ sendAssistantBatch,
4353
+ incomingRunId,
4354
+ incomingThreadId
4355
+ );
3875
4356
  const sendOutput = await this.runDirectToolWithLifecycleHooks(
3876
4357
  input.lg_tool_call,
3877
4358
  config,
@@ -3882,6 +4363,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3882
4363
  resolvedArgsByCallId,
3883
4364
  errorOwnership,
3884
4365
  additionalContextsSink: directAdditionalContexts,
4366
+ replayBatchKey: sendReplayBatchKey,
3885
4367
  runInput: sendState as T,
3886
4368
  }
3887
4369
  );
@@ -3925,18 +4407,16 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3925
4407
  .map((msg) => (msg as ToolMessage).tool_call_id)
3926
4408
  );
3927
4409
 
3928
- let aiMessage: AIMessage | undefined;
3929
- for (let i = messages.length - 1; i >= 0; i--) {
3930
- const message = messages[i];
3931
- if (isAIMessage(message)) {
3932
- aiMessage = message;
3933
- break;
3934
- }
3935
- }
3936
-
3937
- if (aiMessage == null || !isAIMessage(aiMessage)) {
4410
+ const assistantBatch = findAssistantBatch(messages);
4411
+ if (assistantBatch == null) {
3938
4412
  throw new Error('ToolNode only accepts AIMessages as input.');
3939
4413
  }
4414
+ const aiMessage = assistantBatch.message;
4415
+ replayBatchKey = getAssistantBatchReplayKey(
4416
+ assistantBatch,
4417
+ incomingRunId,
4418
+ incomingThreadId
4419
+ );
3940
4420
 
3941
4421
  if (this.loadRuntimeTools) {
3942
4422
  const { tools, toolMap } = this.loadRuntimeTools(
@@ -3999,9 +4479,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3999
4479
  : (aiMessage.invalid_tool_calls ?? []).filter(
4000
4480
  (call) =>
4001
4481
  call.id != null &&
4002
- call.id !== '' &&
4003
- !toolMessageIds.has(call.id) &&
4004
- !call.id.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX)
4482
+ call.id !== '' &&
4483
+ !toolMessageIds.has(call.id) &&
4484
+ !call.id.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX)
4005
4485
  );
4006
4486
  invalidCallResults = attributableInvalidCalls.map(
4007
4487
  (call) =>
@@ -4156,6 +4636,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4156
4636
  errorOwnership,
4157
4637
  preBatchSnapshot,
4158
4638
  additionalContextsSink: directAdditionalContexts,
4639
+ replayBatchKey,
4159
4640
  runInput: input as T,
4160
4641
  }
4161
4642
  )
@@ -4228,6 +4709,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4228
4709
  errorOwnership,
4229
4710
  preBatchSnapshot,
4230
4711
  additionalContextsSink: directAdditionalContexts,
4712
+ replayBatchKey,
4231
4713
  runInput: input as T,
4232
4714
  }
4233
4715
  );
@@ -4240,7 +4722,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4240
4722
  );
4241
4723
  // Append accumulated additionalContexts as a single
4242
4724
  // HumanMessage so the next model turn sees them. Codex P2 #39.
4243
- const promotedPrefix = promotedAiMessage != null ? [promotedAiMessage] : [];
4725
+ const promotedPrefix =
4726
+ promotedAiMessage != null ? [promotedAiMessage] : [];
4244
4727
  outputs =
4245
4728
  directAdditionalContexts.length > 0
4246
4729
  ? [
@@ -4283,6 +4766,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4283
4766
  }
4284
4767
 
4285
4768
  if (!outputs.some(isCommand)) {
4769
+ if (replayBatchKey != null) {
4770
+ this.settledInterruptingResults.delete(replayBatchKey);
4771
+ }
4286
4772
  return (Array.isArray(input) ? outputs : { messages: outputs }) as T;
4287
4773
  }
4288
4774
 
@@ -4301,8 +4787,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4301
4787
  isCommand(output)
4302
4788
  ? patchCommandUpdateForPromotedInvalidCalls(
4303
4789
  output,
4304
- promotedAiMessage!,
4305
- invalidCallResults
4790
+ promotedAiMessage!,
4791
+ invalidCallResults
4306
4792
  )
4307
4793
  : output
4308
4794
  );
@@ -4423,6 +4909,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4423
4909
  combinedOutputs.push(parentCommand);
4424
4910
  }
4425
4911
 
4912
+ if (replayBatchKey != null) {
4913
+ this.settledInterruptingResults.delete(replayBatchKey);
4914
+ }
4426
4915
  return combinedOutputs as T;
4427
4916
  }
4428
4917
 
@@ -4498,8 +4987,7 @@ function sanitizeInvalidToolUseBlocks(
4498
4987
  /** `name` normalizes with the SAME fallback the promoted `tool_calls`
4499
4988
  * entry uses — a nameless block would fail provider validation on its
4500
4989
  * own even with a valid input. */
4501
- const nameIsValid =
4502
- typeof toolUse.name === 'string' && toolUse.name !== '';
4990
+ const nameIsValid = typeof toolUse.name === 'string' && toolUse.name !== '';
4503
4991
  if (inputIsObject && nameIsValid) {
4504
4992
  return block;
4505
4993
  }
@@ -4571,9 +5059,7 @@ function patchCommandUpdateForPromotedInvalidCalls(
4571
5059
  if (!isAIMessage(msg) || msg.id !== promoted.id) {
4572
5060
  return msg;
4573
5061
  }
4574
- const existingIds = new Set(
4575
- (msg.tool_calls ?? []).map((call) => call.id)
4576
- );
5062
+ const existingIds = new Set((msg.tool_calls ?? []).map((call) => call.id));
4577
5063
  const promotedEntries = invalidResults
4578
5064
  .filter((result) => !existingIds.has(result.tool_call_id))
4579
5065
  .map((result) => ({