@librechat/agents 3.3.13 → 3.4.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 (192) hide show
  1. package/dist/cjs/graphs/Graph.cjs +121 -27
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hooks/HookRegistry.cjs +83 -0
  4. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  5. package/dist/cjs/hooks/executeHooks.cjs +60 -14
  6. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  7. package/dist/cjs/hooks/index.cjs.map +1 -1
  8. package/dist/cjs/hooks/types.cjs +2 -0
  9. package/dist/cjs/hooks/types.cjs.map +1 -1
  10. package/dist/cjs/llm/anthropic/index.cjs +35 -206
  11. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  12. package/dist/cjs/llm/bedrock/index.cjs +121 -241
  13. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  14. package/dist/cjs/llm/google/index.cjs +19 -12
  15. package/dist/cjs/llm/google/index.cjs.map +1 -1
  16. package/dist/cjs/llm/init.cjs +3 -3
  17. package/dist/cjs/llm/invoke.cjs +2 -2
  18. package/dist/cjs/llm/mistral/index.cjs +26 -0
  19. package/dist/cjs/llm/mistral/index.cjs.map +1 -0
  20. package/dist/cjs/llm/openai/index.cjs +82 -80
  21. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  22. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  23. package/dist/cjs/llm/providers.cjs +19 -19
  24. package/dist/cjs/llm/providers.cjs.map +1 -1
  25. package/dist/cjs/llm/stream/chunkAdapters.cjs +198 -0
  26. package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -0
  27. package/dist/cjs/llm/stream/smoother.cjs +369 -0
  28. package/dist/cjs/llm/stream/smoother.cjs.map +1 -0
  29. package/dist/cjs/llm/vertexai/index.cjs +13 -1
  30. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  31. package/dist/cjs/main.cjs +25 -16
  32. package/dist/cjs/messages/format.cjs +40 -23
  33. package/dist/cjs/messages/format.cjs.map +1 -1
  34. package/dist/cjs/run.cjs +82 -15
  35. package/dist/cjs/run.cjs.map +1 -1
  36. package/dist/cjs/session/AgentSession.cjs +37 -2
  37. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  38. package/dist/cjs/stream.cjs +1 -1
  39. package/dist/cjs/summarization/node.cjs +6 -3
  40. package/dist/cjs/summarization/node.cjs.map +1 -1
  41. package/dist/cjs/tools/BashExecutor.cjs +1 -1
  42. package/dist/cjs/tools/CodeExecutor.cjs +1 -1
  43. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  44. package/dist/cjs/tools/ToolNode.cjs +316 -94
  45. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  46. package/dist/cjs/tools/ToolSearch.cjs +1 -1
  47. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +664 -80
  48. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  49. package/dist/cjs/tools/subagent/SubagentReplay.cjs +175 -0
  50. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
  51. package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
  52. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  53. package/dist/cjs/utils/index.cjs +2 -2
  54. package/dist/cjs/utils/tokens.cjs +76 -29
  55. package/dist/cjs/utils/tokens.cjs.map +1 -1
  56. package/dist/esm/graphs/Graph.mjs +121 -27
  57. package/dist/esm/graphs/Graph.mjs.map +1 -1
  58. package/dist/esm/hooks/HookRegistry.mjs +83 -0
  59. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  60. package/dist/esm/hooks/executeHooks.mjs +60 -14
  61. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  62. package/dist/esm/hooks/index.mjs.map +1 -1
  63. package/dist/esm/hooks/types.mjs +2 -1
  64. package/dist/esm/hooks/types.mjs.map +1 -1
  65. package/dist/esm/llm/anthropic/index.mjs +34 -205
  66. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  67. package/dist/esm/llm/bedrock/index.mjs +120 -240
  68. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  69. package/dist/esm/llm/google/index.mjs +19 -12
  70. package/dist/esm/llm/google/index.mjs.map +1 -1
  71. package/dist/esm/llm/init.mjs +1 -1
  72. package/dist/esm/llm/invoke.mjs +2 -2
  73. package/dist/esm/llm/mistral/index.mjs +26 -0
  74. package/dist/esm/llm/mistral/index.mjs.map +1 -0
  75. package/dist/esm/llm/openai/index.mjs +82 -80
  76. package/dist/esm/llm/openai/index.mjs.map +1 -1
  77. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  78. package/dist/esm/llm/providers.mjs +3 -3
  79. package/dist/esm/llm/providers.mjs.map +1 -1
  80. package/dist/esm/llm/stream/chunkAdapters.mjs +195 -0
  81. package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -0
  82. package/dist/esm/llm/stream/smoother.mjs +365 -0
  83. package/dist/esm/llm/stream/smoother.mjs.map +1 -0
  84. package/dist/esm/llm/vertexai/index.mjs +13 -1
  85. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  86. package/dist/esm/main.mjs +13 -11
  87. package/dist/esm/messages/format.mjs +40 -23
  88. package/dist/esm/messages/format.mjs.map +1 -1
  89. package/dist/esm/run.mjs +83 -16
  90. package/dist/esm/run.mjs.map +1 -1
  91. package/dist/esm/session/AgentSession.mjs +37 -2
  92. package/dist/esm/session/AgentSession.mjs.map +1 -1
  93. package/dist/esm/stream.mjs +1 -1
  94. package/dist/esm/summarization/node.mjs +6 -3
  95. package/dist/esm/summarization/node.mjs.map +1 -1
  96. package/dist/esm/tools/BashExecutor.mjs +1 -1
  97. package/dist/esm/tools/CodeExecutor.mjs +1 -1
  98. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  99. package/dist/esm/tools/ToolNode.mjs +317 -95
  100. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  101. package/dist/esm/tools/ToolSearch.mjs +1 -1
  102. package/dist/esm/tools/subagent/SubagentExecutor.mjs +665 -81
  103. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  104. package/dist/esm/tools/subagent/SubagentReplay.mjs +168 -0
  105. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
  106. package/dist/esm/tools/toolOutputReferences.mjs +20 -0
  107. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  108. package/dist/esm/utils/index.mjs +2 -2
  109. package/dist/esm/utils/tokens.mjs +76 -30
  110. package/dist/esm/utils/tokens.mjs.map +1 -1
  111. package/dist/types/graphs/Graph.d.ts +19 -1
  112. package/dist/types/hooks/HookRegistry.d.ts +10 -1
  113. package/dist/types/hooks/executeHooks.d.ts +5 -1
  114. package/dist/types/hooks/index.d.ts +2 -2
  115. package/dist/types/hooks/types.d.ts +12 -0
  116. package/dist/types/index.d.ts +3 -0
  117. package/dist/types/llm/google/index.d.ts +2 -0
  118. package/dist/types/llm/mistral/index.d.ts +11 -0
  119. package/dist/types/llm/openai/index.d.ts +20 -4
  120. package/dist/types/llm/openrouter/index.d.ts +4 -1
  121. package/dist/types/llm/stream/chunkAdapters.d.ts +48 -0
  122. package/dist/types/llm/stream/smoother.d.ts +95 -0
  123. package/dist/types/llm/vertexai/index.d.ts +2 -0
  124. package/dist/types/run.d.ts +4 -1
  125. package/dist/types/session/AgentSession.d.ts +1 -0
  126. package/dist/types/tools/ToolNode.d.ts +23 -15
  127. package/dist/types/tools/subagent/SubagentExecutor.d.ts +49 -7
  128. package/dist/types/tools/subagent/SubagentReplay.d.ts +81 -0
  129. package/dist/types/tools/toolOutputReferences.d.ts +12 -0
  130. package/dist/types/types/graph.d.ts +5 -5
  131. package/dist/types/types/hitl.d.ts +15 -0
  132. package/dist/types/types/llm.d.ts +21 -14
  133. package/dist/types/utils/tokens.d.ts +10 -0
  134. package/package.json +6 -2
  135. package/src/graphs/Graph.ts +257 -52
  136. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
  137. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
  138. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
  139. package/src/hooks/HookRegistry.ts +151 -1
  140. package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
  141. package/src/hooks/__tests__/executeHooks.test.ts +160 -0
  142. package/src/hooks/executeHooks.ts +133 -15
  143. package/src/hooks/index.ts +3 -1
  144. package/src/hooks/types.ts +16 -0
  145. package/src/index.ts +8 -0
  146. package/src/llm/anthropic/index.ts +85 -354
  147. package/src/llm/bedrock/index.ts +174 -390
  148. package/src/llm/bedrock/llm.spec.ts +2 -0
  149. package/src/llm/bedrock/streamSealDispatch.test.ts +76 -0
  150. package/src/llm/custom-chat-models.smoke.test.ts +16 -1
  151. package/src/llm/google/index.ts +17 -18
  152. package/src/llm/google/streamSmoothing.test.ts +121 -0
  153. package/src/llm/mistral/index.ts +33 -0
  154. package/src/llm/mistral/streamSmoothing.test.ts +97 -0
  155. package/src/llm/openai/deepseek.test.ts +56 -0
  156. package/src/llm/openai/index.ts +119 -126
  157. package/src/llm/openrouter/index.ts +4 -1
  158. package/src/llm/providers.ts +3 -3
  159. package/src/llm/stream/chunkAdapters.test.ts +202 -0
  160. package/src/llm/stream/chunkAdapters.ts +317 -0
  161. package/src/llm/stream/reassembly.test.ts +241 -0
  162. package/src/llm/stream/smoother.bench.test.ts +155 -0
  163. package/src/llm/stream/smoother.test.ts +519 -0
  164. package/src/llm/stream/smoother.ts +574 -0
  165. package/src/llm/vertexai/index.ts +17 -1
  166. package/src/llm/vertexai/streamSmoothing.test.ts +109 -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 +164 -18
  172. package/src/session/AgentSession.ts +52 -2
  173. package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
  174. package/src/specs/subagent.test.ts +44 -0
  175. package/src/specs/tokens.test.ts +159 -14
  176. package/src/summarization/__tests__/node.test.ts +32 -14
  177. package/src/summarization/node.ts +15 -11
  178. package/src/tools/ToolNode.ts +631 -157
  179. package/src/tools/__tests__/SubagentExecutor.test.ts +590 -7
  180. package/src/tools/__tests__/SubagentReplay.test.ts +300 -0
  181. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
  182. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +486 -2
  183. package/src/tools/__tests__/hitl.test.ts +234 -5
  184. package/src/tools/__tests__/subagentHooks.test.ts +1040 -62
  185. package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
  186. package/src/tools/subagent/SubagentExecutor.ts +1438 -136
  187. package/src/tools/subagent/SubagentReplay.ts +575 -0
  188. package/src/tools/toolOutputReferences.ts +40 -1
  189. package/src/types/graph.ts +5 -5
  190. package/src/types/hitl.ts +16 -0
  191. package/src/types/llm.ts +53 -36
  192. package/src/utils/tokens.ts +115 -30
@@ -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,12 @@ type RunToolBatchContext<T = unknown> = {
156
205
  errorOwnership?: ToolErrorOwnership;
157
206
  };
158
207
 
208
+ type SettledDirectToolResult = {
209
+ output: BaseMessage | Command;
210
+ additionalContexts: string[];
211
+ resolvedArgs?: Record<string, unknown>;
212
+ };
213
+
159
214
  /**
160
215
  * Batch-local record of who owns each failed call's completion event.
161
216
  *
@@ -315,6 +370,38 @@ function describeOfferedShape(value: unknown): string {
315
370
  return typeof value;
316
371
  }
317
372
 
373
+ type AssistantBatch = {
374
+ message: AIMessage;
375
+ index: number;
376
+ messageCount: number;
377
+ };
378
+
379
+ function findAssistantBatch(
380
+ messages: BaseMessage[]
381
+ ): AssistantBatch | undefined {
382
+ for (let i = messages.length - 1; i >= 0; i--) {
383
+ const message = messages[i];
384
+ if (isAIMessage(message)) {
385
+ return { message, index: i, messageCount: messages.length };
386
+ }
387
+ }
388
+ return undefined;
389
+ }
390
+
391
+ function getAssistantBatchReplayKey(
392
+ batch: AssistantBatch,
393
+ runId: string | undefined,
394
+ threadId: string | undefined
395
+ ): string {
396
+ return JSON.stringify([
397
+ runId ?? null,
398
+ threadId ?? null,
399
+ batch.message.id ?? null,
400
+ batch.index,
401
+ batch.messageCount,
402
+ ]);
403
+ }
404
+
318
405
  /**
319
406
  * Per-entry record collected during PreToolUse hook handling for tool
320
407
  * calls that need human approval. Carries everything
@@ -342,10 +429,14 @@ type AskEntry = {
342
429
  * needs `interrupt()` plus the AsyncLocalStorage anchoring shim).
343
430
  */
344
431
  function buildToolApprovalInterruptPayload(
345
- askEntries: ReadonlyArray<AskEntry>
432
+ askEntries: ReadonlyArray<AskEntry>,
433
+ hookSessionId?: string
346
434
  ): t.ToolApprovalInterruptPayload {
347
435
  return {
348
436
  type: 'tool_approval',
437
+ ...(hookSessionId == null || hookSessionId === ''
438
+ ? {}
439
+ : { hook_session_id: hookSessionId }),
349
440
  action_requests: askEntries.map(({ entry, reason }) => {
350
441
  const request: t.ToolApprovalRequest = {
351
442
  tool_call_id: entry.call.id!,
@@ -574,6 +665,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
574
665
  * the Run ends.
575
666
  */
576
667
  private directPathTurns: Map<string, number> = new Map();
668
+ /** Terminal results from interrupting siblings that must survive a
669
+ * LangGraph replay of the containing ToolNode. Includes the sidecar data
670
+ * the fresh batch needs for hook-context injection and completion events. */
671
+ private settledInterruptingResults = new Map<
672
+ string,
673
+ Map<string, SettledDirectToolResult>
674
+ >();
577
675
  /** Tool registry for filtering (lazy computation of programmatic maps) */
578
676
  private toolRegistry?: t.LCToolRegistry;
579
677
  /** Cached programmatic tools (computed once on first PTC call) */
@@ -931,16 +1029,16 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
931
1029
  }
932
1030
 
933
1031
  /**
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
1032
+ * Flush per-Run direct replay state. Called by the Graph at end-of-Run via
1033
+ * `clearHeavyState`. The state intentionally survives `run()` re-entry so
1034
+ * interrupt + resume keeps both original turn slots and terminal sibling
1035
+ * outputs, but it would otherwise grow linearly and could collide across
1036
+ * Runs if a provider reused call IDs. Hosts can also call this directly if
940
1037
  * they reuse a ToolNode across batches outside of a Graph.
941
1038
  */
942
1039
  clearDirectPathTurns(): void {
943
1040
  this.directPathTurns.clear();
1041
+ this.settledInterruptingResults.clear();
944
1042
  }
945
1043
 
946
1044
  /**
@@ -977,6 +1075,35 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
977
1075
  return new Map(this.toolUsageCount); // Return a copy
978
1076
  }
979
1077
 
1078
+ createSubagentResumeState(): SubagentToolNodeResumeState {
1079
+ return {
1080
+ stateKey: JSON.stringify([
1081
+ this.executingAgentId ?? '',
1082
+ this.agentId ?? '',
1083
+ this.name,
1084
+ ]),
1085
+ toolUsageCounts: [...this.toolUsageCount].map(([toolName, count]) => ({
1086
+ toolName,
1087
+ count,
1088
+ })),
1089
+ directPathTurns: [...this.directPathTurns].map(([toolCallId, turn]) => ({
1090
+ toolCallId,
1091
+ turn,
1092
+ })),
1093
+ };
1094
+ }
1095
+
1096
+ restoreSubagentResumeState(state: SubagentToolNodeResumeState): void {
1097
+ this.toolUsageCount.clear();
1098
+ for (const { toolName, count } of state.toolUsageCounts) {
1099
+ this.toolUsageCount.set(toolName, count);
1100
+ }
1101
+ this.directPathTurns.clear();
1102
+ for (const { toolCallId, turn } of state.directPathTurns) {
1103
+ this.directPathTurns.set(toolCallId, turn);
1104
+ }
1105
+ }
1106
+
980
1107
  private recordToolUsageTurn(
981
1108
  toolName: string,
982
1109
  turn: number,
@@ -1475,9 +1602,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1475
1602
  * `blockDirectCall`, `respond` returns the host-supplied
1476
1603
  * `responseText` as a synthetic success ToolMessage,
1477
1604
  * `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".
1605
+ * ToolNode.run from the start on resume. Reusable hooks fire
1606
+ * again; a consumed one-shot hook replays its pending approval
1607
+ * result. In both cases `interrupt()` consumes the resume value.
1481
1608
  * • When HITL is off: collapses to a fail-closed deny (matches
1482
1609
  * the rest of the SDK's HITL-disabled default). One-time
1483
1610
  * warning logged so hosts notice the gap.
@@ -1501,21 +1628,128 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1501
1628
  config: RunnableConfig,
1502
1629
  batchContext: RunToolBatchContext<T> = {}
1503
1630
  ): Promise<BaseMessage | Command> {
1631
+ const replayController = (
1632
+ this.toolMap.get(call.name) as ReplayableSubagentTool | undefined
1633
+ )?.[SUBAGENT_REPLAY_CONTROLLER];
1634
+ const replayConfig =
1635
+ replayController == null || batchContext.replayBatchKey == null
1636
+ ? config
1637
+ : {
1638
+ ...config,
1639
+ configurable: {
1640
+ ...config.configurable,
1641
+ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: batchContext.replayBatchKey,
1642
+ },
1643
+ };
1644
+ const settledOutput = await replayController?.getSettledOutput(
1645
+ call,
1646
+ replayConfig
1647
+ );
1648
+ if (settledOutput != null) {
1649
+ if (
1650
+ batchContext.additionalContextsSink != null &&
1651
+ settledOutput.additionalContexts.length > 0
1652
+ ) {
1653
+ batchContext.additionalContextsSink.push(
1654
+ ...settledOutput.additionalContexts
1655
+ );
1656
+ }
1657
+ if (
1658
+ batchContext.resolvedArgsByCallId != null &&
1659
+ call.id != null &&
1660
+ settledOutput.resolvedArgs != null
1661
+ ) {
1662
+ batchContext.resolvedArgsByCallId.set(
1663
+ call.id,
1664
+ settledOutput.resolvedArgs
1665
+ );
1666
+ }
1667
+ const refMeta = settledOutput.output.additional_kwargs as
1668
+ | t.ToolMessageRefMetadata
1669
+ | undefined;
1670
+ if (
1671
+ this.toolOutputRegistry != null &&
1672
+ refMeta?._refKey != null &&
1673
+ settledOutput.referenceContent != null
1674
+ ) {
1675
+ this.toolOutputRegistry.set(
1676
+ refMeta._refScope,
1677
+ refMeta._refKey,
1678
+ settledOutput.referenceContent
1679
+ );
1680
+ }
1681
+ return settledOutput.output;
1682
+ }
1683
+ const replayAdditionalContexts: string[] = [];
1684
+ const persistOutput = async (
1685
+ output: ToolMessage,
1686
+ terminalArgs?: Record<string, unknown>
1687
+ ): Promise<ToolMessage> => {
1688
+ const refMeta = output.additional_kwargs as
1689
+ | t.ToolMessageRefMetadata
1690
+ | undefined;
1691
+ if (
1692
+ terminalArgs != null &&
1693
+ call.id != null &&
1694
+ batchContext.resolvedArgsByCallId != null
1695
+ ) {
1696
+ batchContext.resolvedArgsByCallId.set(call.id, terminalArgs);
1697
+ }
1698
+ const resolvedArgs =
1699
+ terminalArgs ??
1700
+ (call.id == null
1701
+ ? undefined
1702
+ : batchContext.resolvedArgsByCallId?.get(call.id));
1703
+ const referenceContent =
1704
+ this.toolOutputRegistry == null || refMeta?._refKey == null
1705
+ ? undefined
1706
+ : this.toolOutputRegistry.get(refMeta._refScope, refMeta._refKey);
1707
+ await replayController?.persistSettledOutput(call, replayConfig, {
1708
+ output,
1709
+ additionalContexts: replayAdditionalContexts,
1710
+ ...(resolvedArgs == null ? {} : { resolvedArgs }),
1711
+ ...(referenceContent == null ? {} : { referenceContent }),
1712
+ });
1713
+ return output;
1714
+ };
1504
1715
  const runId = (config.configurable?.run_id as string | undefined) ?? '';
1716
+ const threadId = config.configurable?.thread_id as string | undefined;
1505
1717
  const hookRegistry = this.hookRegistry;
1506
1718
  const hasPreHook = hookRegistry?.hasHookFor('PreToolUse', runId) === true;
1719
+ const approvalReplayKey =
1720
+ call.id == null || call.id === ''
1721
+ ? undefined
1722
+ : createToolApprovalReplayKey(
1723
+ config,
1724
+ this.executingAgentId ?? this.agentId ?? '',
1725
+ call.id
1726
+ );
1727
+ const approvalReplaySessionId = getToolApprovalReplaySessionId(
1728
+ config,
1729
+ runId
1730
+ );
1731
+ const pendingApproval =
1732
+ approvalReplayKey == null
1733
+ ? undefined
1734
+ : hookRegistry?.getPendingToolApproval(
1735
+ approvalReplaySessionId,
1736
+ approvalReplayKey
1737
+ );
1507
1738
  const hasPostHook = hookRegistry?.hasHookFor('PostToolUse', runId) === true;
1508
1739
  const hasFailureHook =
1509
1740
  hookRegistry?.hasHookFor('PostToolUseFailure', runId) === true;
1510
1741
 
1511
1742
  if (
1512
1743
  hookRegistry == null ||
1513
- (!hasPreHook && !hasPostHook && !hasFailureHook)
1744
+ (!hasPreHook &&
1745
+ pendingApproval == null &&
1746
+ !hasPostHook &&
1747
+ !hasFailureHook)
1514
1748
  ) {
1515
- return this.runTool(call, config, batchContext);
1749
+ const output = await this.runTool(call, replayConfig, batchContext);
1750
+ return output instanceof ToolMessage ? persistOutput(output) : output;
1516
1751
  }
1517
1752
 
1518
- const threadId = config.configurable?.thread_id as string | undefined;
1519
1753
  const registryRunId =
1520
1754
  batchContext.batchScopeId ??
1521
1755
  (config.configurable?.run_id as string | undefined);
@@ -1583,7 +1817,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1583
1817
  }
1584
1818
 
1585
1819
  let effectiveCall = call;
1586
- if (hasPreHook) {
1820
+ if (hasPreHook || pendingApproval != null) {
1587
1821
  const preResult = await executeHooks({
1588
1822
  registry: hookRegistry,
1589
1823
  input: {
@@ -1600,6 +1834,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1600
1834
  },
1601
1835
  sessionId: runId,
1602
1836
  matchQuery: call.name,
1837
+ onceReplayKey: approvalReplayKey,
1838
+ onceReplaySessionId: approvalReplaySessionId,
1603
1839
  }).catch(() => undefined);
1604
1840
 
1605
1841
  if (preResult != null) {
@@ -1607,11 +1843,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1607
1843
  // the per-batch sink so the caller materializes them as a
1608
1844
  // HumanMessage for the next model turn — same shape as the
1609
1845
  // event-driven path's `injected[]`. Codex P2 #39.
1610
- if (
1611
- batchContext.additionalContextsSink != null &&
1612
- preResult.additionalContexts.length > 0
1613
- ) {
1614
- batchContext.additionalContextsSink.push(
1846
+ if (preResult.additionalContexts.length > 0) {
1847
+ replayAdditionalContexts.push(...preResult.additionalContexts);
1848
+ batchContext.additionalContextsSink?.push(
1615
1849
  ...preResult.additionalContexts
1616
1850
  );
1617
1851
  }
@@ -1626,14 +1860,17 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1626
1860
  }
1627
1861
 
1628
1862
  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
- });
1863
+ return persistOutput(
1864
+ this.blockDirectCall({
1865
+ call,
1866
+ resolvedArgs,
1867
+ reason: preResult.reason ?? 'Blocked by hook',
1868
+ hookRegistry,
1869
+ runId,
1870
+ threadId,
1871
+ }),
1872
+ effectiveCall.args as Record<string, unknown>
1873
+ );
1637
1874
  }
1638
1875
 
1639
1876
  if (preResult.decision === 'ask') {
@@ -1644,27 +1881,49 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1644
1881
  preResult.reason,
1645
1882
  call.name
1646
1883
  );
1647
- return this.blockDirectCall({
1648
- call,
1649
- resolvedArgs,
1650
- reason,
1651
- hookRegistry,
1652
- runId,
1653
- threadId,
1654
- });
1884
+ return persistOutput(
1885
+ this.blockDirectCall({
1886
+ call,
1887
+ resolvedArgs,
1888
+ reason,
1889
+ hookRegistry,
1890
+ runId,
1891
+ threadId,
1892
+ }),
1893
+ effectiveCall.args as Record<string, unknown>
1894
+ );
1895
+ }
1896
+ const toolCallId = call.id;
1897
+ if (
1898
+ toolCallId == null ||
1899
+ toolCallId === '' ||
1900
+ approvalReplayKey == null
1901
+ ) {
1902
+ return persistOutput(
1903
+ this.blockDirectCall({
1904
+ call,
1905
+ resolvedArgs,
1906
+ reason:
1907
+ 'Tool approval requires a non-empty tool call ID — failing closed',
1908
+ hookRegistry,
1909
+ runId,
1910
+ threadId,
1911
+ }),
1912
+ effectiveCall.args as Record<string, unknown>
1913
+ );
1655
1914
  }
1656
1915
 
1657
1916
  // Raise a single-tool tool_approval interrupt. LangGraph
1658
1917
  // throws on the first execution (host gets the interrupt)
1659
1918
  // 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
1919
+ // tools re-enter the entire ToolNode.run on resume. Reusable
1920
+ // hooks fire again; a consumed one-shot hook instead replays
1921
+ // the pending approval. We anchor `interrupt()` against the
1665
1922
  // node's RunnableConfig the same way `dispatchToolEvents`
1666
- // does (ToolNode disables LangSmith tracing, so the
1667
- // AsyncLocalStorage frame must be re-established here).
1923
+ // does. A one-shot hook's pending contribution reconstructs this
1924
+ // same ask entry without dispatching the consumed hook again.
1925
+ // ToolNode disables LangSmith tracing, so the AsyncLocalStorage
1926
+ // frame must be re-established here.
1668
1927
  const askEntry: AskEntry = {
1669
1928
  entry: {
1670
1929
  call: effectiveCall,
@@ -1674,7 +1933,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1674
1933
  reason: preResult.reason,
1675
1934
  allowedDecisions: preResult.allowedDecisions,
1676
1935
  };
1677
- const payload = buildToolApprovalInterruptPayload([askEntry]);
1936
+ const payload = buildToolApprovalInterruptPayload([askEntry], runId);
1678
1937
  const resumeValue = AsyncLocalStorageProviderSingleton.runWithConfig(
1679
1938
  config,
1680
1939
  () =>
@@ -1683,11 +1942,15 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1683
1942
  t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap
1684
1943
  >(payload)
1685
1944
  );
1945
+ hookRegistry.clearPendingToolApproval(
1946
+ approvalReplaySessionId,
1947
+ approvalReplayKey
1948
+ );
1686
1949
  const decisionByCallId = normalizeApprovalDecisions(
1687
- [call.id!],
1950
+ [toolCallId],
1688
1951
  resumeValue
1689
1952
  );
1690
- const decision = decisionByCallId.get(call.id!) ?? {
1953
+ const decision = decisionByCallId.get(toolCallId) ?? {
1691
1954
  type: 'reject' as const,
1692
1955
  reason: 'No decision provided for tool approval',
1693
1956
  };
@@ -1700,47 +1963,63 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1700
1963
  declaredType as t.ToolApprovalDecisionType
1701
1964
  ))
1702
1965
  ) {
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
- });
1966
+ return persistOutput(
1967
+ this.blockDirectCall({
1968
+ call,
1969
+ resolvedArgs,
1970
+ reason: `Decision "${typeof declaredType === 'string' ? declaredType : '<missing>'}" not in allowedDecisions [${preResult.allowedDecisions.join(', ')}] — failing closed`,
1971
+ hookRegistry,
1972
+ runId,
1973
+ threadId,
1974
+ }),
1975
+ effectiveCall.args as Record<string, unknown>
1976
+ );
1711
1977
  }
1712
1978
 
1713
1979
  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({
1980
+ return persistOutput(
1981
+ this.blockDirectCall({
1729
1982
  call,
1730
1983
  resolvedArgs,
1731
1984
  reason:
1732
- 'Approval payload `respond` was missing a string `responseText`',
1985
+ decision.reason ?? preResult.reason ?? 'Rejected by user',
1733
1986
  hookRegistry,
1734
1987
  runId,
1735
1988
  threadId,
1736
- });
1989
+ }),
1990
+ effectiveCall.args as Record<string, unknown>
1991
+ );
1992
+ }
1993
+
1994
+ if (decision.type === 'respond') {
1995
+ const responseText = (decision as { responseText?: unknown })
1996
+ .responseText;
1997
+ if (typeof responseText !== 'string') {
1998
+ return persistOutput(
1999
+ this.blockDirectCall({
2000
+ call,
2001
+ resolvedArgs,
2002
+ reason:
2003
+ 'Approval payload `respond` was missing a string `responseText`',
2004
+ hookRegistry,
2005
+ runId,
2006
+ threadId,
2007
+ }),
2008
+ effectiveCall.args as Record<string, unknown>
2009
+ );
1737
2010
  }
1738
- return new ToolMessage({
1739
- status: 'success',
1740
- content: responseText,
1741
- name: call.name,
1742
- tool_call_id: call.id ?? '',
1743
- });
2011
+ return persistOutput(
2012
+ new ToolMessage({
2013
+ status: 'success',
2014
+ content: truncateToolResultContent(
2015
+ responseText,
2016
+ this.maxToolResultChars
2017
+ ),
2018
+ name: call.name,
2019
+ tool_call_id: call.id ?? '',
2020
+ }),
2021
+ effectiveCall.args as Record<string, unknown>
2022
+ );
1744
2023
  }
1745
2024
 
1746
2025
  if (decision.type === 'edit') {
@@ -1759,13 +2038,16 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1759
2038
  typeof updatedInput !== 'object' ||
1760
2039
  Array.isArray(updatedInput)
1761
2040
  ) {
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
- });
2041
+ return persistOutput(
2042
+ new ToolMessage({
2043
+ status: 'error',
2044
+ content:
2045
+ 'Decision "edit" missing object updatedInput — failing closed.',
2046
+ name: call.name,
2047
+ tool_call_id: call.id ?? '',
2048
+ }),
2049
+ effectiveCall.args as Record<string, unknown>
2050
+ );
1769
2051
  }
1770
2052
  effectiveCall = {
1771
2053
  ...call,
@@ -1773,6 +2055,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1773
2055
  };
1774
2056
  // fall through to executing the edited call
1775
2057
  }
2058
+ if (declaredType !== 'approve' && declaredType !== 'edit') {
2059
+ const unknownType =
2060
+ typeof declaredType === 'string' ? declaredType : '<missing>';
2061
+ return persistOutput(
2062
+ this.blockDirectCall({
2063
+ call,
2064
+ resolvedArgs,
2065
+ reason: `Unknown approval decision type "${unknownType}" — failing closed`,
2066
+ hookRegistry,
2067
+ runId,
2068
+ threadId,
2069
+ }),
2070
+ effectiveCall.args as Record<string, unknown>
2071
+ );
2072
+ }
1776
2073
  // 'approve' (or 'edit' after applying edits) → fall through
1777
2074
  }
1778
2075
  }
@@ -1799,7 +2096,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1799
2096
  );
1800
2097
  }
1801
2098
 
1802
- const output = await this.runTool(effectiveCall, config, {
2099
+ const output = await this.runTool(effectiveCall, replayConfig, {
1803
2100
  ...batchContext,
1804
2101
  usageCount,
1805
2102
  });
@@ -1839,14 +2136,14 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1839
2136
  }).catch(() => undefined);
1840
2137
  if (
1841
2138
  failureResult != null &&
1842
- batchContext.additionalContextsSink != null &&
1843
2139
  failureResult.additionalContexts.length > 0
1844
2140
  ) {
1845
- batchContext.additionalContextsSink.push(
2141
+ replayAdditionalContexts.push(...failureResult.additionalContexts);
2142
+ batchContext.additionalContextsSink?.push(
1846
2143
  ...failureResult.additionalContexts
1847
2144
  );
1848
2145
  }
1849
- return output;
2146
+ return persistOutput(output);
1850
2147
  }
1851
2148
 
1852
2149
  if (output.status !== 'error' && hasPostHook) {
@@ -1871,12 +2168,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1871
2168
 
1872
2169
  // Forward additionalContexts from the PostToolUse hook into
1873
2170
  // 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(
2171
+ if (postResult != null && postResult.additionalContexts.length > 0) {
2172
+ replayAdditionalContexts.push(...postResult.additionalContexts);
2173
+ batchContext.additionalContextsSink?.push(
1880
2174
  ...postResult.additionalContexts
1881
2175
  );
1882
2176
  }
@@ -1888,7 +2182,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1888
2182
  'PostToolUse updatedOutput for a computer call must be a valid screenshot URL or screenshot content block.'
1889
2183
  );
1890
2184
  }
1891
- return cloneToolMessageWithContent(output, postResult.updatedOutput);
2185
+ return persistOutput(
2186
+ cloneToolMessageWithContent(output, postResult.updatedOutput)
2187
+ );
1892
2188
  }
1893
2189
  // Keep the tool-output registry in sync with what the model
1894
2190
  // actually sees. Without this, `runTool` already registered
@@ -1918,11 +2214,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1918
2214
  replaced.registryContent
1919
2215
  );
1920
2216
  }
1921
- return cloneToolMessageWithContent(output, replaced.content);
2217
+ return persistOutput(
2218
+ cloneToolMessageWithContent(output, replaced.content)
2219
+ );
1922
2220
  }
1923
2221
  }
1924
2222
 
1925
- return output;
2223
+ return persistOutput(output);
1926
2224
  }
1927
2225
 
1928
2226
  /**
@@ -2381,13 +2679,27 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2381
2679
  errors: [] as string[],
2382
2680
  });
2383
2681
 
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;
2682
+ const hookRegistry = this.hookRegistry;
2683
+ const approvalReplaySessionId = getToolApprovalReplaySessionId(
2684
+ config,
2685
+ runId
2686
+ );
2687
+ const hasPendingApproval = preToolCalls.some(
2688
+ (entry) =>
2689
+ entry.call.id != null &&
2690
+ hookRegistry?.getPendingToolApproval(
2691
+ approvalReplaySessionId,
2692
+ createToolApprovalReplayKey(
2693
+ config,
2694
+ this.executingAgentId ?? this.agentId ?? '',
2695
+ entry.call.id
2696
+ )
2697
+ ) != null
2698
+ );
2699
+ if (
2700
+ hookRegistry != null &&
2701
+ (hookRegistry.hasHookFor('PreToolUse', runId) || hasPendingApproval)
2702
+ ) {
2391
2703
  /**
2392
2704
  * Pull each call's prestarted eager record BEFORE awaiting the hooks:
2393
2705
  * an async deny must never race the eager host promise into emitting a
@@ -2415,8 +2727,17 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2415
2727
  }
2416
2728
  }
2417
2729
  const preResults = await Promise.all(
2418
- preToolCalls.map((entry) =>
2419
- executeHooks({
2730
+ preToolCalls.map((entry) => {
2731
+ const toolUseId = entry.call.id;
2732
+ const approvalReplayKey =
2733
+ toolUseId == null
2734
+ ? undefined
2735
+ : createToolApprovalReplayKey(
2736
+ config,
2737
+ this.executingAgentId ?? this.agentId ?? '',
2738
+ toolUseId
2739
+ );
2740
+ return executeHooks({
2420
2741
  registry: hookRegistry,
2421
2742
  input: {
2422
2743
  hook_event_name: 'PreToolUse',
@@ -2432,8 +2753,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2432
2753
  },
2433
2754
  sessionId: runId,
2434
2755
  matchQuery: entry.call.name,
2435
- }).catch((): AggregatedHookResult => HOOK_FALLBACK)
2436
- )
2756
+ onceReplayKey: approvalReplayKey,
2757
+ onceReplaySessionId: approvalReplaySessionId,
2758
+ }).catch((): AggregatedHookResult => HOOK_FALLBACK);
2759
+ })
2437
2760
  );
2438
2761
 
2439
2762
  type PendingEntry = (typeof preToolCalls)[number];
@@ -2653,12 +2976,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2653
2976
  * pauses, gathers human input, and resumes the run with one
2654
2977
  * decision per request. On resume LangGraph re-executes this node
2655
2978
  * 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).
2979
+ * instead of throwing. Reusable hooks rebuild their entries, while
2980
+ * consumed one-shot hooks replay their pending approval aggregate.
2659
2981
  */
2660
2982
  if (askEntries.length > 0) {
2661
- const payload = buildToolApprovalInterruptPayload(askEntries);
2983
+ const payload = buildToolApprovalInterruptPayload(askEntries, runId);
2662
2984
 
2663
2985
  /**
2664
2986
  * `interrupt()` reads the current `RunnableConfig` from
@@ -2678,6 +3000,17 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2678
3000
  >(payload)
2679
3001
  );
2680
3002
 
3003
+ for (const { entry } of askEntries) {
3004
+ hookRegistry.clearPendingToolApproval(
3005
+ approvalReplaySessionId,
3006
+ createToolApprovalReplayKey(
3007
+ config,
3008
+ this.executingAgentId ?? this.agentId ?? '',
3009
+ entry.call.id!
3010
+ )
3011
+ );
3012
+ }
3013
+
2681
3014
  const decisionByCallId = normalizeApprovalDecisions(
2682
3015
  askEntries.map(({ entry }) => entry.call.id!),
2683
3016
  resumeValue
@@ -3051,7 +3384,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3051
3384
  // match it at the top level too.
3052
3385
  agentId: this.executingAgentId,
3053
3386
  configurable: stripRunBreakerScope(
3054
- config.configurable as Record<string, unknown> | undefined
3387
+ config.configurable as Record<string, unknown> | undefined
3055
3388
  ),
3056
3389
  metadata: config.metadata as
3057
3390
  | Record<string, unknown>
@@ -3680,16 +4013,16 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3680
4013
  * call throws a `GraphInterrupt`, the `await` below rejects and unwinds the
3681
4014
  * whole ToolNode *before* any non-interrupting sibling has started — so a
3682
4015
  * 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.
4016
+ * the first pass. Terminal interrupting siblings are cached by call id, so
4017
+ * LangGraph replay reuses their complete lifecycle output instead of
4018
+ * repeating model calls, hooks, or side effects.
3686
4019
  *
3687
4020
  * Without this ordering, a flat `Promise.all` starts every sibling
3688
4021
  * concurrently, so a non-idempotent sibling can complete its side effect
3689
4022
  * 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.
4023
+ * duplicate side effect this method exists to prevent. A tool that actually
4024
+ * suspends re-enters until it reaches a terminal result; siblings that
4025
+ * already settled do not re-enter.
3693
4026
  *
3694
4027
  * `batchIndices[i]` is `directCalls[i]`'s position within the parent
3695
4028
  * ToolNode batch (used for `{{tool<i>turn<n>}}` registration); it is
@@ -3703,14 +4036,51 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3703
4036
  config: RunnableConfig,
3704
4037
  baseContext: Omit<RunToolBatchContext<T>, 'batchIndex'>
3705
4038
  ): Promise<(BaseMessage | Command)[]> {
3706
- const runOne = (
4039
+ const settledBatchResults =
4040
+ baseContext.replayBatchKey == null
4041
+ ? undefined
4042
+ : this.settledInterruptingResults.get(baseContext.replayBatchKey);
4043
+ const restoreResult = (
4044
+ call: ToolCall,
4045
+ result: SettledDirectToolResult
4046
+ ): SettledDirectToolResult => {
4047
+ baseContext.additionalContextsSink?.push(...result.additionalContexts);
4048
+ if (
4049
+ call.id != null &&
4050
+ result.resolvedArgs != null &&
4051
+ baseContext.resolvedArgsByCallId != null
4052
+ ) {
4053
+ baseContext.resolvedArgsByCallId.set(call.id, result.resolvedArgs);
4054
+ }
4055
+ return result;
4056
+ };
4057
+ const runOne = async (
3707
4058
  call: ToolCall,
3708
4059
  position: number
3709
- ): Promise<BaseMessage | Command> =>
3710
- this.runDirectToolWithLifecycleHooks(call, config, {
4060
+ ): Promise<SettledDirectToolResult> => {
4061
+ const cachedResult =
4062
+ typeof call.id === 'string'
4063
+ ? settledBatchResults?.get(call.id)
4064
+ : undefined;
4065
+ if (cachedResult != null) {
4066
+ return restoreResult(call, cachedResult);
4067
+ }
4068
+ const additionalContexts: string[] = [];
4069
+ const output = await this.runDirectToolWithLifecycleHooks(call, config, {
3711
4070
  ...baseContext,
3712
4071
  batchIndex: batchIndices[position],
4072
+ additionalContextsSink: additionalContexts,
4073
+ });
4074
+ const resolvedArgs =
4075
+ call.id == null
4076
+ ? undefined
4077
+ : baseContext.resolvedArgsByCallId?.get(call.id);
4078
+ return restoreResult(call, {
4079
+ output,
4080
+ additionalContexts,
4081
+ ...(resolvedArgs == null ? {} : { resolvedArgs }),
3713
4082
  });
4083
+ };
3714
4084
 
3715
4085
  const interrupting = this.interruptingToolNames;
3716
4086
  const hasInterrupting =
@@ -3718,7 +4088,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3718
4088
  directCalls.some((call) => interrupting.has(call.name));
3719
4089
 
3720
4090
  if (!hasInterrupting) {
3721
- return Promise.all(directCalls.map((call, i) => runOne(call, i)));
4091
+ const results = await Promise.all(
4092
+ directCalls.map((call, i) => runOne(call, i))
4093
+ );
4094
+ return results.map((result) => result.output);
3722
4095
  }
3723
4096
 
3724
4097
  const outputs: (BaseMessage | Command)[] = new Array(directCalls.length);
@@ -3732,14 +4105,94 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3732
4105
  }
3733
4106
  }
3734
4107
 
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(
4108
+ // Interrupting group first. Wait for every sibling to settle before
4109
+ // propagating an interrupt so approved subagents cannot keep running in
4110
+ // the background while the parent exposes the next pending approval.
4111
+ const interruptingResults = await Promise.allSettled(
3738
4112
  interruptingPositions.map((i) => runOne(directCalls[i], i))
3739
4113
  );
3740
- interruptingPositions.forEach((i, k) => {
3741
- outputs[i] = interruptingOutputs[k];
3742
- });
4114
+ /** A sibling may have tripped the run-wide breaker while another raised
4115
+ * a GraphInterrupt. Safety failures take precedence over approval pauses
4116
+ * regardless of input order. */
4117
+ this.throwIfBreakerTripped(config);
4118
+ let hasInterruptingError = false;
4119
+ let interruptingError: unknown;
4120
+ for (let i = 0; i < interruptingResults.length; i++) {
4121
+ const result = interruptingResults[i];
4122
+ if (result.status === 'rejected') {
4123
+ if (!hasInterruptingError) {
4124
+ hasInterruptingError = true;
4125
+ interruptingError = result.reason;
4126
+ }
4127
+ continue;
4128
+ }
4129
+ const position = interruptingPositions[i];
4130
+ outputs[position] = result.value.output;
4131
+ const callId = directCalls[position].id;
4132
+ if (
4133
+ baseContext.replayBatchKey != null &&
4134
+ typeof callId === 'string' &&
4135
+ callId !== ''
4136
+ ) {
4137
+ const batchResults =
4138
+ this.settledInterruptingResults.get(baseContext.replayBatchKey) ??
4139
+ new Map<string, SettledDirectToolResult>();
4140
+ batchResults.set(callId, result.value);
4141
+ this.settledInterruptingResults.set(
4142
+ baseContext.replayBatchKey,
4143
+ batchResults
4144
+ );
4145
+ }
4146
+ }
4147
+ if (hasInterruptingError) {
4148
+ if (isGraphInterrupt(interruptingError)) {
4149
+ const controllers = new Set<
4150
+ NonNullable<ReplayableSubagentTool[typeof SUBAGENT_REPLAY_CONTROLLER]>
4151
+ >();
4152
+ const parentToolCallIds = new Set<string>();
4153
+ for (const call of directCalls) {
4154
+ const controller = (
4155
+ this.toolMap.get(call.name) as ReplayableSubagentTool | undefined
4156
+ )?.[SUBAGENT_REPLAY_CONTROLLER];
4157
+ if (controller?.getResumeManifest != null) {
4158
+ controllers.add(controller);
4159
+ }
4160
+ if (call.id != null && call.id !== '') {
4161
+ parentToolCallIds.add(call.id);
4162
+ }
4163
+ }
4164
+ const executionsByParentCall = new Map<
4165
+ string,
4166
+ SubagentResumeManifest['executions'][number]
4167
+ >();
4168
+ for (const controller of controllers) {
4169
+ const manifest =
4170
+ await controller.getResumeManifest?.(parentToolCallIds);
4171
+ if (manifest != null) {
4172
+ for (const execution of manifest.executions) {
4173
+ executionsByParentCall.set(execution.parentToolCallId, execution);
4174
+ }
4175
+ }
4176
+ }
4177
+ const executions = [...executionsByParentCall.values()];
4178
+ if (executions.length > 0) {
4179
+ const manifest: SubagentResumeManifest = {
4180
+ version: 1,
4181
+ executions,
4182
+ };
4183
+ throw new GraphInterrupt(
4184
+ interruptingError.interrupts.map((pendingInterrupt) => ({
4185
+ ...pendingInterrupt,
4186
+ value: attachSubagentResumeManifest(
4187
+ pendingInterrupt.value,
4188
+ manifest
4189
+ ),
4190
+ }))
4191
+ );
4192
+ }
4193
+ }
4194
+ throw interruptingError;
4195
+ }
3743
4196
 
3744
4197
  /** The breaker can trip while the interrupting group is awaited — e.g.
3745
4198
  * a tool that ignores cancellation and completes normally. Recheck
@@ -3751,7 +4204,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3751
4204
  regularPositions.map((i) => runOne(directCalls[i], i))
3752
4205
  );
3753
4206
  regularPositions.forEach((i, k) => {
3754
- outputs[i] = regularOutputs[k];
4207
+ outputs[i] = regularOutputs[k].output;
3755
4208
  });
3756
4209
 
3757
4210
  return outputs;
@@ -3842,9 +4295,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3842
4295
  * every subsequent registry call on this batch.
3843
4296
  */
3844
4297
  const incomingRunId = config.configurable?.run_id as string | undefined;
4298
+ const incomingThreadId = config.configurable?.thread_id as
4299
+ | string
4300
+ | undefined;
3845
4301
  const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
3846
4302
  const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
3847
4303
  let outputs: (BaseMessage | Command)[];
4304
+ let replayBatchKey: string | undefined;
3848
4305
  /** Hoisted from the messages-state branch so the Command tail can carry
3849
4306
  * the promotion into handoff updates (same-id state copies there would
3850
4307
  * otherwise overwrite the replacement message). */
@@ -3872,6 +4329,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3872
4329
  // Mirror langgraph's prebuilt ToolNode: the Send-input state is
3873
4330
  // the input minus the `lg_tool_call` envelope key.
3874
4331
  const { lg_tool_call: _sendToolCall, ...sendState } = input;
4332
+ const sendMessages = (sendState as { messages?: unknown }).messages;
4333
+ const sendAssistantBatch = Array.isArray(sendMessages)
4334
+ ? findAssistantBatch(sendMessages as BaseMessage[])
4335
+ : undefined;
4336
+ const sendReplayBatchKey =
4337
+ sendAssistantBatch == null
4338
+ ? undefined
4339
+ : getAssistantBatchReplayKey(
4340
+ sendAssistantBatch,
4341
+ incomingRunId,
4342
+ incomingThreadId
4343
+ );
3875
4344
  const sendOutput = await this.runDirectToolWithLifecycleHooks(
3876
4345
  input.lg_tool_call,
3877
4346
  config,
@@ -3882,6 +4351,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3882
4351
  resolvedArgsByCallId,
3883
4352
  errorOwnership,
3884
4353
  additionalContextsSink: directAdditionalContexts,
4354
+ replayBatchKey: sendReplayBatchKey,
3885
4355
  runInput: sendState as T,
3886
4356
  }
3887
4357
  );
@@ -3925,18 +4395,16 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3925
4395
  .map((msg) => (msg as ToolMessage).tool_call_id)
3926
4396
  );
3927
4397
 
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)) {
4398
+ const assistantBatch = findAssistantBatch(messages);
4399
+ if (assistantBatch == null) {
3938
4400
  throw new Error('ToolNode only accepts AIMessages as input.');
3939
4401
  }
4402
+ const aiMessage = assistantBatch.message;
4403
+ replayBatchKey = getAssistantBatchReplayKey(
4404
+ assistantBatch,
4405
+ incomingRunId,
4406
+ incomingThreadId
4407
+ );
3940
4408
 
3941
4409
  if (this.loadRuntimeTools) {
3942
4410
  const { tools, toolMap } = this.loadRuntimeTools(
@@ -3999,9 +4467,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3999
4467
  : (aiMessage.invalid_tool_calls ?? []).filter(
4000
4468
  (call) =>
4001
4469
  call.id != null &&
4002
- call.id !== '' &&
4003
- !toolMessageIds.has(call.id) &&
4004
- !call.id.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX)
4470
+ call.id !== '' &&
4471
+ !toolMessageIds.has(call.id) &&
4472
+ !call.id.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX)
4005
4473
  );
4006
4474
  invalidCallResults = attributableInvalidCalls.map(
4007
4475
  (call) =>
@@ -4156,6 +4624,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4156
4624
  errorOwnership,
4157
4625
  preBatchSnapshot,
4158
4626
  additionalContextsSink: directAdditionalContexts,
4627
+ replayBatchKey,
4159
4628
  runInput: input as T,
4160
4629
  }
4161
4630
  )
@@ -4228,6 +4697,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4228
4697
  errorOwnership,
4229
4698
  preBatchSnapshot,
4230
4699
  additionalContextsSink: directAdditionalContexts,
4700
+ replayBatchKey,
4231
4701
  runInput: input as T,
4232
4702
  }
4233
4703
  );
@@ -4240,7 +4710,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4240
4710
  );
4241
4711
  // Append accumulated additionalContexts as a single
4242
4712
  // HumanMessage so the next model turn sees them. Codex P2 #39.
4243
- const promotedPrefix = promotedAiMessage != null ? [promotedAiMessage] : [];
4713
+ const promotedPrefix =
4714
+ promotedAiMessage != null ? [promotedAiMessage] : [];
4244
4715
  outputs =
4245
4716
  directAdditionalContexts.length > 0
4246
4717
  ? [
@@ -4283,6 +4754,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4283
4754
  }
4284
4755
 
4285
4756
  if (!outputs.some(isCommand)) {
4757
+ if (replayBatchKey != null) {
4758
+ this.settledInterruptingResults.delete(replayBatchKey);
4759
+ }
4286
4760
  return (Array.isArray(input) ? outputs : { messages: outputs }) as T;
4287
4761
  }
4288
4762
 
@@ -4301,8 +4775,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4301
4775
  isCommand(output)
4302
4776
  ? patchCommandUpdateForPromotedInvalidCalls(
4303
4777
  output,
4304
- promotedAiMessage!,
4305
- invalidCallResults
4778
+ promotedAiMessage!,
4779
+ invalidCallResults
4306
4780
  )
4307
4781
  : output
4308
4782
  );
@@ -4423,6 +4897,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4423
4897
  combinedOutputs.push(parentCommand);
4424
4898
  }
4425
4899
 
4900
+ if (replayBatchKey != null) {
4901
+ this.settledInterruptingResults.delete(replayBatchKey);
4902
+ }
4426
4903
  return combinedOutputs as T;
4427
4904
  }
4428
4905
 
@@ -4498,8 +4975,7 @@ function sanitizeInvalidToolUseBlocks(
4498
4975
  /** `name` normalizes with the SAME fallback the promoted `tool_calls`
4499
4976
  * entry uses — a nameless block would fail provider validation on its
4500
4977
  * own even with a valid input. */
4501
- const nameIsValid =
4502
- typeof toolUse.name === 'string' && toolUse.name !== '';
4978
+ const nameIsValid = typeof toolUse.name === 'string' && toolUse.name !== '';
4503
4979
  if (inputIsObject && nameIsValid) {
4504
4980
  return block;
4505
4981
  }
@@ -4571,9 +5047,7 @@ function patchCommandUpdateForPromotedInvalidCalls(
4571
5047
  if (!isAIMessage(msg) || msg.id !== promoted.id) {
4572
5048
  return msg;
4573
5049
  }
4574
- const existingIds = new Set(
4575
- (msg.tool_calls ?? []).map((call) => call.id)
4576
- );
5050
+ const existingIds = new Set((msg.tool_calls ?? []).map((call) => call.id));
4577
5051
  const promotedEntries = invalidResults
4578
5052
  .filter((result) => !existingIds.has(result.tool_call_id))
4579
5053
  .map((result) => ({