@librechat/agents 3.8.1 → 3.8.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 (65) hide show
  1. package/dist/cjs/graphs/Graph.cjs +2 -2
  2. package/dist/cjs/graphs/MultiAgentGraph.cjs +1 -1
  3. package/dist/cjs/hitl/approvalReview.cjs +89 -0
  4. package/dist/cjs/hitl/approvalReview.cjs.map +1 -0
  5. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +1 -1
  6. package/dist/cjs/main.cjs +3 -3
  7. package/dist/cjs/messages/core.cjs +1 -1
  8. package/dist/cjs/messages/format.cjs +1 -1
  9. package/dist/cjs/messages/prune.cjs +2 -2
  10. package/dist/cjs/run.cjs +17 -6
  11. package/dist/cjs/run.cjs.map +1 -1
  12. package/dist/cjs/session/AgentSession.cjs +1 -1
  13. package/dist/cjs/stream.cjs +2 -2
  14. package/dist/cjs/summarization/node.cjs +1 -1
  15. package/dist/cjs/tools/ToolNode.cjs +256 -84
  16. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  17. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +24 -9
  18. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  19. package/dist/cjs/tools/subagent/SubagentReplay.cjs +2 -0
  20. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
  21. package/dist/cjs/tools/toolBatchReplay.cjs +187 -0
  22. package/dist/cjs/tools/toolBatchReplay.cjs.map +1 -0
  23. package/dist/cjs/tools/toolOutputReferences.cjs +12 -0
  24. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  25. package/dist/cjs/types/hitl.cjs +4 -0
  26. package/dist/cjs/types/hitl.cjs.map +1 -1
  27. package/dist/esm/graphs/Graph.mjs +2 -2
  28. package/dist/esm/graphs/MultiAgentGraph.mjs +1 -1
  29. package/dist/esm/hitl/approvalReview.mjs +83 -0
  30. package/dist/esm/hitl/approvalReview.mjs.map +1 -0
  31. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +1 -1
  32. package/dist/esm/main.mjs +3 -3
  33. package/dist/esm/messages/core.mjs +1 -1
  34. package/dist/esm/messages/format.mjs +1 -1
  35. package/dist/esm/messages/prune.mjs +2 -2
  36. package/dist/esm/run.mjs +18 -7
  37. package/dist/esm/run.mjs.map +1 -1
  38. package/dist/esm/session/AgentSession.mjs +1 -1
  39. package/dist/esm/stream.mjs +2 -2
  40. package/dist/esm/summarization/node.mjs +1 -1
  41. package/dist/esm/tools/ToolNode.mjs +256 -84
  42. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  43. package/dist/esm/tools/subagent/SubagentExecutor.mjs +24 -9
  44. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  45. package/dist/esm/tools/subagent/SubagentReplay.mjs +1 -1
  46. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
  47. package/dist/esm/tools/toolBatchReplay.mjs +176 -0
  48. package/dist/esm/tools/toolBatchReplay.mjs.map +1 -0
  49. package/dist/esm/tools/toolOutputReferences.mjs +12 -0
  50. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  51. package/dist/esm/types/hitl.mjs +4 -1
  52. package/dist/esm/types/hitl.mjs.map +1 -1
  53. package/dist/types/hitl/approvalReview.d.ts +32 -0
  54. package/dist/types/tools/ToolNode.d.ts +12 -6
  55. package/dist/types/tools/subagent/SubagentReplay.d.ts +2 -0
  56. package/dist/types/tools/toolBatchReplay.d.ts +54 -0
  57. package/dist/types/tools/toolOutputReferences.d.ts +2 -0
  58. package/package.json +1 -1
  59. package/src/hitl/approvalReview.ts +209 -0
  60. package/src/run.ts +34 -20
  61. package/src/tools/ToolNode.ts +518 -184
  62. package/src/tools/subagent/SubagentExecutor.ts +35 -6
  63. package/src/tools/subagent/SubagentReplay.ts +2 -2
  64. package/src/tools/toolBatchReplay.ts +395 -0
  65. package/src/tools/toolOutputReferences.ts +16 -0
@@ -1,4 +1,5 @@
1
1
  import { nanoid } from 'nanoid';
2
+ import { createHash } from 'crypto';
2
3
  import { ToolCall } from '@langchain/core/messages/tool';
3
4
  import { RunnableLambda } from '@langchain/core/runnables';
4
5
  import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons';
@@ -31,6 +32,7 @@ import type { LangGraphRunnableConfig } from '@langchain/langgraph';
31
32
  import type { BaseMessage } from '@langchain/core/messages';
32
33
  import type {
33
34
  ToolOutputResolveView,
35
+ ToolOutputReferenceState,
34
36
  PreResolvedArgsMap,
35
37
  ResolvedArgsByCallId,
36
38
  ResolveResult,
@@ -48,6 +50,7 @@ import type {
48
50
  ToolApprovalReplayKey,
49
51
  } from '@/hooks';
50
52
  import type { PreparedSubagents } from '@/tools/preparedSubagents';
53
+ import type { SettledToolBatchResult } from '@/tools/toolBatchReplay';
51
54
  import type { RunBreakerScope } from '@/llm/streamLimits';
52
55
  import type * as t from '@/types';
53
56
  import {
@@ -67,12 +70,20 @@ import {
67
70
  serializeStructuredValueBounded,
68
71
  serializeToolContentBounded,
69
72
  } from '@/utils/toolContent';
73
+ import {
74
+ getReviewedToolApproval,
75
+ getToolApprovalReviewEvidence,
76
+ toolApprovalPayloadMatches,
77
+ toolApprovalProposalMatches,
78
+ TOOL_APPROVAL_REVIEW_CONFIG_KEY,
79
+ } from '@/hitl/approvalReview';
70
80
  import {
71
81
  buildToolExecutionRequestPlan,
72
82
  coerceArgsForSchema,
73
83
  coerceRecordArgs,
74
84
  resolveRuntimeSessionHint,
75
85
  recordArgsEqual,
86
+ stableStringify,
76
87
  } from '@/tools/eagerEventExecution';
77
88
  import {
78
89
  attachSubagentResumeManifest,
@@ -110,16 +121,47 @@ import { stripCodeSessionFileSummary } from '@/tools/CodeSessionFileSummary';
110
121
  import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
111
122
  import { PreparedSubagentError } from '@/tools/preparedSubagents';
112
123
  import { attachRunStepResumeState } from '@/tools/runStepResume';
113
-
114
- /** Host-facing batch requests must not carry the batch's breaker scope —
115
- * hosts spread `configurable` into their own run configs. */
116
- function stripRunBreakerScope(
124
+ import {
125
+ TOOL_BATCH_REPLAY_KEY,
126
+ getToolBatchReplayOwner,
127
+ getToolBatchReplayScope,
128
+ attachToolBatchReplayState,
129
+ restoreToolBatchReplayState,
130
+ getToolBatchReplayState,
131
+ isChildToolApprovalOwner,
132
+ } from '@/tools/toolBatchReplay';
133
+
134
+ function stripToolApprovalReviewConfig(
117
135
  configurable: Record<string, unknown> | undefined
118
136
  ): Record<string, unknown> | undefined {
119
- if (configurable == null || !(RUN_BREAKER_SCOPE_CONFIG_KEY in configurable)) {
137
+ if (
138
+ configurable == null ||
139
+ !(TOOL_APPROVAL_REVIEW_CONFIG_KEY in configurable) &&
140
+ !(TOOL_BATCH_REPLAY_KEY in configurable)
141
+ ) {
120
142
  return configurable;
121
143
  }
122
- const { [RUN_BREAKER_SCOPE_CONFIG_KEY]: _scope, ...rest } = configurable;
144
+ const {
145
+ [TOOL_APPROVAL_REVIEW_CONFIG_KEY]: _review,
146
+ [TOOL_BATCH_REPLAY_KEY]: _replay,
147
+ ...rest
148
+ } = configurable;
149
+ return rest;
150
+ }
151
+
152
+ /** Host-facing requests must not carry SDK-private execution state — hosts
153
+ * spread `configurable` into their own run configs. */
154
+ function stripHostExecutionConfig(
155
+ configurable: Record<string, unknown> | undefined
156
+ ): Record<string, unknown> | undefined {
157
+ const withoutReview = stripToolApprovalReviewConfig(configurable);
158
+ if (
159
+ withoutReview == null ||
160
+ !(RUN_BREAKER_SCOPE_CONFIG_KEY in withoutReview)
161
+ ) {
162
+ return withoutReview;
163
+ }
164
+ const { [RUN_BREAKER_SCOPE_CONFIG_KEY]: _scope, ...rest } = withoutReview;
123
165
  return rest;
124
166
  }
125
167
  import { convertInjectedMessages } from '@/messages/injected';
@@ -209,6 +251,10 @@ type RunToolBatchContext<T = unknown> = {
209
251
  additionalContextsSink?: string[];
210
252
  /** Stable identity of the assistant tool-call batch across HITL replay. */
211
253
  replayBatchKey?: string;
254
+ /** Positions in the original proposal, before completed calls are filtered. */
255
+ replayCallPositions?: ReadonlyMap<ToolCall, number>;
256
+ /** Tool-call identities owned by this ToolNode invocation. */
257
+ approvalScopeCallIds?: ReadonlySet<string>;
212
258
  /**
213
259
  * Graph state the ToolNode was invoked with, threaded from `run()`
214
260
  * so `tool.invoke` can forward it as langgraph 1.4's `runtime.state`
@@ -236,11 +282,14 @@ function withSubagentReplayBatch(
236
282
  };
237
283
  }
238
284
 
239
- type SettledDirectToolResult = {
240
- output: BaseMessage | Command;
241
- additionalContexts: string[];
242
- resolvedArgs?: Record<string, unknown>;
243
- };
285
+ type SettledDirectToolResult = SettledToolBatchResult;
286
+
287
+ /** Positional identity is safe within the immutable full-proposal batch key. */
288
+ function directReplayKey(call: ToolCall, batchIndex: number): string {
289
+ return call.id == null || call.id === ''
290
+ ? JSON.stringify(['position', batchIndex])
291
+ : JSON.stringify(['id', call.id]);
292
+ }
244
293
 
245
294
  /**
246
295
  * Batch-local record of who owns each failed call's completion event.
@@ -403,8 +452,6 @@ function describeOfferedShape(value: unknown): string {
403
452
 
404
453
  type AssistantBatch = {
405
454
  message: AIMessage;
406
- index: number;
407
- messageCount: number;
408
455
  };
409
456
 
410
457
  function findAssistantBatch(
@@ -413,7 +460,7 @@ function findAssistantBatch(
413
460
  for (let i = messages.length - 1; i >= 0; i--) {
414
461
  const message = messages[i];
415
462
  if (isAIMessage(message)) {
416
- return { message, index: i, messageCount: messages.length };
463
+ return { message };
417
464
  }
418
465
  }
419
466
  return undefined;
@@ -421,15 +468,12 @@ function findAssistantBatch(
421
468
 
422
469
  function getAssistantBatchReplayKey(
423
470
  batch: AssistantBatch,
424
- runId: string | undefined,
425
471
  threadId: string | undefined
426
472
  ): string {
427
473
  return JSON.stringify([
428
- runId ?? null,
429
474
  threadId ?? null,
430
- batch.message.id ?? null,
431
- batch.index,
432
- batch.messageCount,
475
+ batch.message.id == null || batch.message.id === '' ? null : batch.message.id,
476
+ createHash('sha256').update(stableStringify(batch.message.tool_calls ?? [])).digest('hex'),
433
477
  ]);
434
478
  }
435
479
 
@@ -697,10 +741,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
697
741
  * the Run ends.
698
742
  */
699
743
  private directPathTurns: Map<string, number> = new Map();
700
- /** Terminal results from interrupting siblings that must survive a
701
- * LangGraph replay of the containing ToolNode. Includes the sidecar data
702
- * the fresh batch needs for hook-context injection and completion events. */
703
- private settledInterruptingResults = new Map<
744
+ /** Terminal direct results that must survive a LangGraph replay of the
745
+ * containing ToolNode. Includes the sidecar data the fresh batch needs for
746
+ * hook-context injection and completion-event deduplication. */
747
+ private settledDirectResultsByBatch = new Map<
704
748
  string,
705
749
  Map<string, SettledDirectToolResult>
706
750
  >();
@@ -854,23 +898,78 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
854
898
  name: name ?? TOOL_NODE_RUN_NAME,
855
899
  tags,
856
900
  func: async (input, config) => {
901
+ const messages = Array.isArray(input) ? input as BaseMessage[] :
902
+ (input as { messages?: BaseMessage[] }).messages;
903
+ const assistantBatch = messages == null ? undefined : findAssistantBatch(messages);
904
+ const activeReplayKey = assistantBatch == null || getToolBatchReplayScope(config) == null ? undefined : getAssistantBatchReplayKey(
905
+ assistantBatch, getToolBatchReplayScope(config)
906
+ );
907
+ const replayOwner = getToolBatchReplayOwner(
908
+ config,
909
+ this.executingAgentId ?? this.agentId ?? ''
910
+ );
911
+ const reviewEvidence = getToolApprovalReviewEvidence(config);
912
+ if (reviewEvidence?.owner != null && reviewEvidence.owner !== replayOwner) {
913
+ const calls = this.isSendInput(input) ? [input.lg_tool_call] : assistantBatch?.message.tool_calls ?? [];
914
+ const trustedParentCallIds = new Set(calls.filter((call) =>
915
+ call.name === Constants.SUBAGENT &&
916
+ (this.toolMap.get(call.name) as ReplayableSubagentTool | undefined)?.[SUBAGENT_REPLAY_CONTROLLER] != null
917
+ ).map((call) => call.id ?? ''));
918
+ const ownsParentBatch = getToolBatchReplayState(config.configurable)?.records.some(
919
+ (record) => record.owner === replayOwner && record.batch === activeReplayKey
920
+ ) === true;
921
+ if (!ownsParentBatch || !isChildToolApprovalOwner(config, reviewEvidence.owner, trustedParentCallIds)) {
922
+ throw new Error('Tool approval execution owner changed before resume — failing closed');
923
+ }
924
+ }
925
+ const referenceReplay: { state?: ToolOutputReferenceState } = {};
926
+ const restoredBatches = await restoreToolBatchReplayState(config, replayOwner);
927
+ if (restoredBatches.length > 0 && !restoredBatches.some(({ batch }) => batch === activeReplayKey)) {
928
+ throw new Error('Tool batch proposal changed before approval replay — failing closed');
929
+ }
930
+ if (activeReplayKey != null && getToolBatchReplayState(config.configurable) != null) {
931
+ this.settledDirectResultsByBatch.delete(activeReplayKey);
932
+ }
933
+ for (const { batch, results, turnState, referenceState } of restoredBatches) {
934
+ if (batch !== activeReplayKey) {
935
+ continue;
936
+ }
937
+ this.settledDirectResultsByBatch.set(batch, new Map(results));
938
+ if (turnState != null) {
939
+ this.restoreSubagentResumeState(turnState);
940
+ }
941
+ referenceReplay.state = referenceState;
942
+ }
857
943
  const state = input as T & Pick<t.BaseGraphState, 'runStepState'>;
858
944
  restoreRunStepResumeState?.(state.runStepState, config);
859
945
  let result: T;
860
946
  try {
861
- result = await this.run(input, config);
947
+ result = await this.run(input, config, referenceReplay);
862
948
  } catch (error) {
863
- if (!isGraphInterrupt(error) || createRunStepResumeState == null) {
949
+ if (!isGraphInterrupt(error)) {
864
950
  throw error;
865
951
  }
866
- const resumeState = createRunStepResumeState();
952
+ const resumeState = createRunStepResumeState?.();
953
+ const activeResults = activeReplayKey == null ? undefined : this.settledDirectResultsByBatch.get(activeReplayKey);
954
+ const activeBatches = activeReplayKey == null
955
+ ? new Map<string, Map<string, SettledDirectToolResult>>()
956
+ : new Map([[activeReplayKey, activeResults ?? new Map<string, SettledDirectToolResult>()]]);
957
+ const turnState = this.createSubagentResumeState();
958
+ const activeCallIds = new Set(assistantBatch?.message.tool_calls?.map((call) => call.id));
959
+ turnState.directPathTurns = turnState.directPathTurns.filter(({ toolCallId }) => activeCallIds.has(toolCallId));
867
960
  throw new GraphInterrupt(
868
- error.interrupts.map((pendingInterrupt) => ({
869
- ...pendingInterrupt,
870
- value: attachRunStepResumeState(
961
+ await Promise.all(error.interrupts.map(async (pendingInterrupt) => {
962
+ const value = await attachToolBatchReplayState(
871
963
  pendingInterrupt.value,
872
- resumeState
873
- ),
964
+ replayOwner,
965
+ activeBatches,
966
+ turnState,
967
+ referenceReplay.state
968
+ );
969
+ return {
970
+ ...pendingInterrupt,
971
+ value: resumeState == null ? value : attachRunStepResumeState(value, resumeState),
972
+ };
874
973
  }))
875
974
  );
876
975
  }
@@ -1146,14 +1245,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1146
1245
  */
1147
1246
  clearDirectPathTurns(): void {
1148
1247
  this.directPathTurns.clear();
1149
- this.settledInterruptingResults.clear();
1248
+ this.settledDirectResultsByBatch.clear();
1150
1249
  }
1151
1250
 
1152
1251
  /** Returns the live caller projection used by direct and event execution. */
1153
1252
  private getCallerCapabilityProjection(): CallerCapabilityProjection {
1154
- const discoveredToolNames = new Set(
1155
- this.getDiscoveredToolNames?.() ?? []
1156
- );
1253
+ const discoveredToolNames = new Set(this.getDiscoveredToolNames?.() ?? []);
1157
1254
  return resolveCallerCapabilityProjection(
1158
1255
  mergeCallerCapabilityDefinitions(
1159
1256
  this.toolDefinitions?.values(),
@@ -1194,9 +1291,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1194
1291
  private getProgrammaticTools(): t.ProgrammaticCache {
1195
1292
  const toolMap: t.ToolMap = new Map();
1196
1293
  const toolDefs: t.LCTool[] = [];
1197
- const discoveredToolNames = new Set(
1198
- this.getDiscoveredToolNames?.() ?? []
1199
- );
1294
+ const discoveredToolNames = new Set(this.getDiscoveredToolNames?.() ?? []);
1200
1295
  const executableCapabilities = resolveCallerCapabilityProjection(
1201
1296
  this.toolRegistry?.values() ?? [],
1202
1297
  (toolDef) => isToolDefinitionActive(toolDef, discoveredToolNames)
@@ -1307,11 +1402,22 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1307
1402
  runInput?: RunToolBatchContext<T>['runInput']
1308
1403
  ): Promise<unknown> {
1309
1404
  const lgConfig = config as LangGraphRunnableConfig;
1310
- const runtime: ToolRuntime<T> = {
1405
+ const isTrustedSubagentHop =
1406
+ call.name === Constants.SUBAGENT &&
1407
+ (tool as ReplayableSubagentTool)[SUBAGENT_REPLAY_CONTROLLER] != null;
1408
+ const toolConfig: RunnableConfig = {
1311
1409
  ...config,
1410
+ configurable: isTrustedSubagentHop
1411
+ ? config.configurable
1412
+ : stripToolApprovalReviewConfig(
1413
+ config.configurable as Record<string, unknown> | undefined
1414
+ ),
1415
+ };
1416
+ const runtime: ToolRuntime<T> = {
1417
+ ...toolConfig,
1312
1418
  state: runInput as ToolRuntime<T>['state'],
1313
1419
  toolCallId: call.id ?? '',
1314
- config,
1420
+ config: toolConfig,
1315
1421
  context: lgConfig.context as ToolRuntime<T>['context'],
1316
1422
  store: (lgConfig.store ?? null) as ToolRuntime<T>['store'],
1317
1423
  writer:
@@ -1533,6 +1639,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1533
1639
  }
1534
1640
  return next;
1535
1641
  })();
1642
+ this.recordToolUsageTurn(call.name, usageCount, call.id);
1643
+ if (batchContext.replayBatchKey != null && call.id != null && call.id !== '') {
1644
+ this.directPathTurns.set(call.id, usageCount);
1645
+ }
1536
1646
  let args = call.args;
1537
1647
  if (resolveFn != null) {
1538
1648
  const { resolved, unresolved } = resolveFn(runId, args);
@@ -1915,17 +2025,22 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1915
2025
  batchContext: RunToolBatchContext<T> = {}
1916
2026
  ): Promise<BaseMessage | Command> {
1917
2027
  if (
1918
- this.preparedSubagents?.owns(this.executingAgentId ?? '', call) === true &&
2028
+ this.preparedSubagents?.owns(this.executingAgentId ?? '', call) ===
2029
+ true &&
1919
2030
  this.hookRegistry?.hasResultAlteringHooks(
1920
2031
  (config.configurable?.run_id as string | undefined) ?? ''
1921
2032
  ) === true
1922
2033
  ) {
1923
2034
  this.preparedSubagents.clear();
1924
- throw new PreparedSubagentError('Tool hooks changed after a subagent started.');
2035
+ throw new PreparedSubagentError(
2036
+ 'Tool hooks changed after a subagent started.'
2037
+ );
1925
2038
  }
1926
2039
  const replayController = (
1927
2040
  this.toolMap.get(call.name) as ReplayableSubagentTool | undefined
1928
2041
  )?.[SUBAGENT_REPLAY_CONTROLLER];
2042
+ const isTrustedSubagentReviewHop =
2043
+ call.name === Constants.SUBAGENT && replayController != null;
1929
2044
  const replayConfig =
1930
2045
  replayController == null
1931
2046
  ? config
@@ -2024,16 +2139,29 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2024
2139
  approvalReplaySessionId,
2025
2140
  approvalReplayKey
2026
2141
  );
2142
+ const approvalReviewEvidence = getToolApprovalReviewEvidence(config,
2143
+ getToolBatchReplayOwner(config, this.executingAgentId ?? this.agentId ?? ''));
2144
+ const reviewedApproval = getReviewedToolApproval(
2145
+ approvalReviewEvidence?.payload,
2146
+ call.id
2147
+ );
2148
+ const approvalEvidenceAppliesToBatch =
2149
+ approvalReviewEvidence?.payload.action_requests.some((request) =>
2150
+ batchContext.approvalScopeCallIds == null
2151
+ ? request.tool_call_id === call.id
2152
+ : batchContext.approvalScopeCallIds.has(request.tool_call_id)
2153
+ ) === true;
2027
2154
  const hasPostHook = hookRegistry?.hasHookFor('PostToolUse', runId) === true;
2028
2155
  const hasFailureHook =
2029
2156
  hookRegistry?.hasHookFor('PostToolUseFailure', runId) === true;
2030
2157
 
2031
2158
  if (
2032
- hookRegistry == null ||
2033
- (!hasPreHook &&
2034
- pendingApproval == null &&
2035
- !hasPostHook &&
2036
- !hasFailureHook)
2159
+ !hasPreHook &&
2160
+ pendingApproval == null &&
2161
+ reviewedApproval == null &&
2162
+ approvalReviewEvidence == null &&
2163
+ !hasPostHook &&
2164
+ !hasFailureHook
2037
2165
  ) {
2038
2166
  const output = await this.runTool(call, replayConfig, batchContext);
2039
2167
  return output instanceof ToolMessage ? persistOutput(output) : output;
@@ -2086,46 +2214,74 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2086
2214
  // value the PreToolUse hook observes matches the value the
2087
2215
  // (later-awaited) `runTool` will actually run with — both are
2088
2216
  // anchored to the pre-batch registry state.
2089
- let resolvedArgs = call.args as Record<string, unknown>;
2090
2217
  const hookResolveOptions = {
2091
2218
  substituteIntentKey: this.toolDeclaresBusinessIntent(call.name),
2092
2219
  };
2093
- if (batchContext.preBatchSnapshot != null) {
2094
- const { resolved } = batchContext.preBatchSnapshot.resolve(
2095
- call.args,
2096
- hookResolveOptions
2097
- );
2098
- resolvedArgs = resolved as Record<string, unknown>;
2099
- } else if (this.toolOutputRegistry != null) {
2100
- const { resolved } = this.toolOutputRegistry.resolve(
2101
- registryRunId,
2102
- call.args,
2103
- hookResolveOptions
2104
- );
2105
- resolvedArgs = resolved as Record<string, unknown>;
2106
- }
2220
+ const resolveDirectArgs = (
2221
+ args: Record<string, unknown>
2222
+ ): Record<string, unknown> => {
2223
+ if (batchContext.preBatchSnapshot != null) {
2224
+ return batchContext.preBatchSnapshot.resolve(args, hookResolveOptions)
2225
+ .resolved as Record<string, unknown>;
2226
+ }
2227
+ if (this.toolOutputRegistry != null) {
2228
+ return this.toolOutputRegistry.resolve(
2229
+ registryRunId,
2230
+ args,
2231
+ hookResolveOptions
2232
+ ).resolved as Record<string, unknown>;
2233
+ }
2234
+ return args;
2235
+ };
2236
+ const originalResolvedArgs = resolveDirectArgs(
2237
+ call.args as Record<string, unknown>
2238
+ );
2239
+ /** Reusable and pending hooks must replay from the original proposal so
2240
+ * non-idempotent rewrites are applied exactly once. A rebuilt runtime
2241
+ * without that policy instead falls back to the resolved arguments the
2242
+ * user actually reviewed. */
2243
+ let resolvedArgs =
2244
+ hasPreHook || pendingApproval != null
2245
+ ? originalResolvedArgs
2246
+ : (reviewedApproval?.request.arguments ?? originalResolvedArgs);
2107
2247
 
2108
2248
  let effectiveCall = call;
2109
- if (hasPreHook || pendingApproval != null) {
2110
- const preResult = await executeHooks({
2111
- registry: hookRegistry,
2112
- input: {
2113
- hook_event_name: 'PreToolUse',
2114
- runId,
2115
- threadId,
2116
- agentId: this.agentId,
2117
- executingAgentId: this.executingAgentId,
2118
- toolName: call.name,
2119
- toolInput: resolvedArgs,
2120
- toolUseId: call.id ?? '',
2121
- stepId,
2122
- turn,
2123
- },
2124
- sessionId: runId,
2125
- matchQuery: call.name,
2126
- onceReplayKey: approvalReplayKey,
2127
- onceReplaySessionId: approvalReplaySessionId,
2128
- }).catch(() => undefined);
2249
+ if (
2250
+ hasPreHook ||
2251
+ pendingApproval != null ||
2252
+ reviewedApproval != null ||
2253
+ approvalReviewEvidence != null
2254
+ ) {
2255
+ const preResult: AggregatedHookResult | undefined =
2256
+ hookRegistry == null
2257
+ ? {
2258
+ additionalContexts: [],
2259
+ injectedMessages: [],
2260
+ errors: [],
2261
+ }
2262
+ : await executeHooks({
2263
+ registry: hookRegistry,
2264
+ input: {
2265
+ hook_event_name: 'PreToolUse',
2266
+ runId,
2267
+ threadId,
2268
+ agentId: this.agentId,
2269
+ executingAgentId: this.executingAgentId,
2270
+ toolName: call.name,
2271
+ toolInput: resolvedArgs,
2272
+ toolUseId: call.id ?? '',
2273
+ stepId,
2274
+ turn,
2275
+ },
2276
+ sessionId: runId,
2277
+ matchQuery: call.name,
2278
+ onceReplayKey: approvalReplayKey,
2279
+ onceReplaySessionId: approvalReplaySessionId,
2280
+ }).catch((): AggregatedHookResult | undefined => approvalReviewEvidence == null ? undefined : {
2281
+ decision: 'deny',
2282
+ reason: 'Approval policy could not be evaluated on resume',
2283
+ additionalContexts: [], injectedMessages: [], errors: [],
2284
+ });
2129
2285
 
2130
2286
  if (preResult != null) {
2131
2287
  // Forward any additionalContext strings hooks returned into
@@ -2142,9 +2298,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2142
2298
  // a valid combination (one matcher sanitises args, another asks
2143
2299
  // for approval); the reviewer should see the sanitised args.
2144
2300
  if (preResult.updatedInput != null) {
2301
+ resolvedArgs = resolveDirectArgs(
2302
+ preResult.updatedInput as Record<string, unknown>
2303
+ );
2304
+ effectiveCall = {
2305
+ ...call,
2306
+ args: resolvedArgs,
2307
+ };
2308
+ } else if (reviewedApproval != null) {
2309
+ resolvedArgs = reviewedApproval.request.arguments;
2145
2310
  effectiveCall = {
2146
2311
  ...call,
2147
- args: preResult.updatedInput as Record<string, unknown>,
2312
+ args: resolvedArgs,
2148
2313
  };
2149
2314
  }
2150
2315
 
@@ -2162,7 +2327,30 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2162
2327
  );
2163
2328
  }
2164
2329
 
2165
- if (preResult.decision === 'ask') {
2330
+ /** A sibling absent from restored evidence may have raised a parallel
2331
+ * approval that LangGraph did not preserve. A current hook can ask
2332
+ * again; otherwise never let that unreviewed sibling execute. */
2333
+ if (
2334
+ approvalEvidenceAppliesToBatch &&
2335
+ reviewedApproval == null &&
2336
+ !isTrustedSubagentReviewHop &&
2337
+ preResult.decision !== 'ask'
2338
+ ) {
2339
+ return persistOutput(
2340
+ this.blockDirectCall({
2341
+ call,
2342
+ resolvedArgs,
2343
+ reason:
2344
+ 'Tool call was not included in the restored approval batch — failing closed',
2345
+ hookRegistry,
2346
+ runId,
2347
+ threadId,
2348
+ }),
2349
+ resolvedArgs
2350
+ );
2351
+ }
2352
+
2353
+ if (preResult.decision === 'ask' || reviewedApproval != null) {
2166
2354
  if (this.humanInTheLoop?.enabled !== true) {
2167
2355
  // Fail-closed: no HITL UI configured, so we can't actually
2168
2356
  // ask. Logged once via the existing helper.
@@ -2213,14 +2401,25 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2213
2401
  // same ask entry without dispatching the consumed hook again.
2214
2402
  // ToolNode disables LangSmith tracing, so the AsyncLocalStorage
2215
2403
  // frame must be re-established here.
2404
+ const allowedDecisions =
2405
+ preResult.decision === 'ask'
2406
+ ? preResult.allowedDecisions
2407
+ : reviewedApproval?.reviewConfig.allowed_decisions;
2408
+ const askReason =
2409
+ preResult.decision === 'ask'
2410
+ ? preResult.reason
2411
+ : reviewedApproval?.request.description;
2412
+ if (effectiveCall === call) {
2413
+ effectiveCall = { ...call, args: resolvedArgs };
2414
+ }
2216
2415
  const askEntry: AskEntry = {
2217
2416
  entry: {
2218
2417
  call: effectiveCall,
2219
2418
  args: effectiveCall.args as Record<string, unknown>,
2220
2419
  stepId,
2221
2420
  },
2222
- reason: preResult.reason,
2223
- allowedDecisions: preResult.allowedDecisions,
2421
+ reason: askReason,
2422
+ allowedDecisions,
2224
2423
  };
2225
2424
  const payload = buildToolApprovalInterruptPayload([askEntry], runId);
2226
2425
  const resumeValue = AsyncLocalStorageProviderSingleton.runWithConfig(
@@ -2231,10 +2430,29 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2231
2430
  t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap
2232
2431
  >(payload)
2233
2432
  );
2234
- hookRegistry.clearPendingToolApproval(
2433
+ hookRegistry?.clearPendingToolApproval(
2235
2434
  approvalReplaySessionId,
2236
2435
  approvalReplayKey
2237
2436
  );
2437
+ const currentApproval = getReviewedToolApproval(payload, toolCallId);
2438
+ if (
2439
+ reviewedApproval != null &&
2440
+ (currentApproval == null ||
2441
+ !toolApprovalProposalMatches(currentApproval, reviewedApproval))
2442
+ ) {
2443
+ return persistOutput(
2444
+ this.blockDirectCall({
2445
+ call,
2446
+ resolvedArgs,
2447
+ reason:
2448
+ 'Reviewed tool proposal changed before resume; retry approval',
2449
+ hookRegistry,
2450
+ runId,
2451
+ threadId,
2452
+ }),
2453
+ effectiveCall.args as Record<string, unknown>
2454
+ );
2455
+ }
2238
2456
  const decisionByCallId = normalizeApprovalDecisions(
2239
2457
  [toolCallId],
2240
2458
  resumeValue
@@ -2246,9 +2464,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2246
2464
  const declaredType = (decision as { type?: unknown }).type;
2247
2465
 
2248
2466
  if (
2249
- preResult.allowedDecisions != null &&
2467
+ allowedDecisions != null &&
2250
2468
  (typeof declaredType !== 'string' ||
2251
- !preResult.allowedDecisions.includes(
2469
+ !allowedDecisions.includes(
2252
2470
  declaredType as t.ToolApprovalDecisionType
2253
2471
  ))
2254
2472
  ) {
@@ -2256,7 +2474,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2256
2474
  this.blockDirectCall({
2257
2475
  call,
2258
2476
  resolvedArgs,
2259
- reason: `Decision "${typeof declaredType === 'string' ? declaredType : '<missing>'}" not in allowedDecisions [${preResult.allowedDecisions.join(', ')}] — failing closed`,
2477
+ reason: `Decision "${typeof declaredType === 'string' ? declaredType : '<missing>'}" not in allowedDecisions [${allowedDecisions.join(', ')}] — failing closed`,
2260
2478
  hookRegistry,
2261
2479
  runId,
2262
2480
  threadId,
@@ -2270,8 +2488,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2270
2488
  this.blockDirectCall({
2271
2489
  call,
2272
2490
  resolvedArgs,
2273
- reason:
2274
- decision.reason ?? preResult.reason ?? 'Rejected by user',
2491
+ reason: decision.reason ?? askReason ?? 'Rejected by user',
2275
2492
  hookRegistry,
2276
2493
  runId,
2277
2494
  threadId,
@@ -2546,12 +2763,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2546
2763
  call: ToolCall;
2547
2764
  resolvedArgs: Record<string, unknown>;
2548
2765
  reason: string;
2549
- hookRegistry: HookRegistry;
2766
+ hookRegistry: HookRegistry | undefined;
2550
2767
  runId: string;
2551
2768
  threadId: string | undefined;
2552
2769
  }): ToolMessage {
2553
2770
  const { call, resolvedArgs, reason, hookRegistry, runId, threadId } = args;
2554
- if (hookRegistry.hasHookFor('PermissionDenied', runId) === true) {
2771
+ if (hookRegistry?.hasHookFor('PermissionDenied', runId) === true) {
2555
2772
  executeHooks({
2556
2773
  registry: hookRegistry,
2557
2774
  input: {
@@ -2881,7 +3098,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2881
3098
  const signal = config.signal;
2882
3099
  if (
2883
3100
  signal?.aborted === true &&
2884
- (signal.reason instanceof StreamLimitExceededError || signal.reason instanceof PreparedSubagentError)
3101
+ (signal.reason instanceof StreamLimitExceededError ||
3102
+ signal.reason instanceof PreparedSubagentError)
2885
3103
  ) {
2886
3104
  throw signal.reason;
2887
3105
  }
@@ -2932,11 +3150,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2932
3150
  unresolvedByCallId.set(call.id, pre.unresolved);
2933
3151
  }
2934
3152
  } else if (registry != null) {
2935
- const { resolved, unresolved } = registry.resolve(
2936
- registryRunId,
2937
- originalArgs,
2938
- { substituteIntentKey: this.toolDeclaresBusinessIntent(call.name) }
2939
- );
3153
+ const options = { substituteIntentKey: this.toolDeclaresBusinessIntent(call.name) };
3154
+ const { resolved, unresolved } = preBatchSnapshot != null
3155
+ ? preBatchSnapshot.resolve(originalArgs, options)
3156
+ : registry.resolve(registryRunId, originalArgs, options);
2940
3157
  resolvedArgs = resolved as Record<string, unknown>;
2941
3158
  if (unresolved.length > 0 && call.id != null) {
2942
3159
  unresolvedByCallId.set(call.id, unresolved);
@@ -2984,6 +3201,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2984
3201
  config,
2985
3202
  runId
2986
3203
  );
3204
+ const approvalReviewEvidence = getToolApprovalReviewEvidence(config,
3205
+ getToolBatchReplayOwner(config, this.executingAgentId ?? this.agentId ?? ''));
2987
3206
  const hasPendingApproval = preToolCalls.some(
2988
3207
  (entry) =>
2989
3208
  entry.call.id != null &&
@@ -2997,8 +3216,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2997
3216
  ) != null
2998
3217
  );
2999
3218
  if (
3000
- hookRegistry != null &&
3001
- (hookRegistry.hasHookFor('PreToolUse', runId) || hasPendingApproval)
3219
+ hookRegistry?.hasHookFor('PreToolUse', runId) === true ||
3220
+ hasPendingApproval ||
3221
+ approvalReviewEvidence != null
3002
3222
  ) {
3003
3223
  /**
3004
3224
  * Pull each call's prestarted eager record BEFORE awaiting the hooks:
@@ -3026,38 +3246,41 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3026
3246
  }
3027
3247
  }
3028
3248
  }
3029
- const preResults = await Promise.all(
3030
- preToolCalls.map((entry) => {
3031
- const toolUseId = entry.call.id;
3032
- const approvalReplayKey =
3033
- toolUseId == null
3034
- ? undefined
3035
- : createToolApprovalReplayKey(
3036
- config,
3037
- this.executingAgentId ?? this.agentId ?? '',
3038
- toolUseId
3039
- );
3040
- return executeHooks({
3041
- registry: hookRegistry,
3042
- input: {
3043
- hook_event_name: 'PreToolUse',
3044
- runId,
3045
- threadId,
3046
- agentId: this.agentId,
3047
- executingAgentId: this.executingAgentId,
3048
- toolName: entry.call.name,
3049
- toolInput: entry.args,
3050
- toolUseId: entry.call.id!,
3051
- stepId: entry.stepId,
3052
- turn: this.toolUsageCount.get(entry.call.name) ?? 0,
3053
- },
3054
- sessionId: runId,
3055
- matchQuery: entry.call.name,
3056
- onceReplayKey: approvalReplayKey,
3057
- onceReplaySessionId: approvalReplaySessionId,
3058
- }).catch((): AggregatedHookResult => HOOK_FALLBACK);
3059
- })
3060
- );
3249
+ const preResults =
3250
+ hookRegistry == null
3251
+ ? preToolCalls.map(() => HOOK_FALLBACK)
3252
+ : await Promise.all(
3253
+ preToolCalls.map((entry) => {
3254
+ const toolUseId = entry.call.id;
3255
+ const approvalReplayKey =
3256
+ toolUseId == null
3257
+ ? undefined
3258
+ : createToolApprovalReplayKey(
3259
+ config,
3260
+ this.executingAgentId ?? this.agentId ?? '',
3261
+ toolUseId
3262
+ );
3263
+ return executeHooks({
3264
+ registry: hookRegistry,
3265
+ input: {
3266
+ hook_event_name: 'PreToolUse',
3267
+ runId,
3268
+ threadId,
3269
+ agentId: this.agentId,
3270
+ executingAgentId: this.executingAgentId,
3271
+ toolName: entry.call.name,
3272
+ toolInput: entry.args,
3273
+ toolUseId: entry.call.id!,
3274
+ stepId: entry.stepId,
3275
+ turn: this.toolUsageCount.get(entry.call.name) ?? 0,
3276
+ },
3277
+ sessionId: runId,
3278
+ matchQuery: entry.call.name,
3279
+ onceReplayKey: approvalReplayKey,
3280
+ onceReplaySessionId: approvalReplaySessionId,
3281
+ }).catch((): AggregatedHookResult => HOOK_FALLBACK);
3282
+ })
3283
+ );
3061
3284
 
3062
3285
  type PendingEntry = (typeof preToolCalls)[number];
3063
3286
 
@@ -3138,7 +3361,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3138
3361
  item.contentString,
3139
3362
  config
3140
3363
  );
3141
- if (hookRegistry.hasHookFor('PermissionDenied', runId)) {
3364
+ if (hookRegistry?.hasHookFor('PermissionDenied', runId) === true) {
3142
3365
  executeHooks({
3143
3366
  registry: hookRegistry,
3144
3367
  input: {
@@ -3252,6 +3475,14 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3252
3475
  */
3253
3476
  if (hookResult.updatedInput != null) {
3254
3477
  applyInputOverride(entry, hookResult.updatedInput);
3478
+ } else {
3479
+ const reviewed = getReviewedToolApproval(approvalReviewEvidence?.payload, entry.call.id);
3480
+ if (reviewed != null) {
3481
+ entry.args = this.coerceEventToolArgs(entry.call.name, reviewed.request.arguments);
3482
+ if (entry.call.id != null) {
3483
+ unresolvedByCallId.delete(entry.call.id);
3484
+ }
3485
+ }
3255
3486
  }
3256
3487
  askEntries.push({
3257
3488
  entry,
@@ -3264,6 +3495,27 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3264
3495
  if (hookResult.updatedInput != null) {
3265
3496
  applyInputOverride(entry, hookResult.updatedInput);
3266
3497
  }
3498
+ const reviewedApproval = getReviewedToolApproval(
3499
+ approvalReviewEvidence?.payload,
3500
+ entry.call.id
3501
+ );
3502
+ if (reviewedApproval != null) {
3503
+ if (hookResult.updatedInput == null) {
3504
+ entry.args = this.coerceEventToolArgs(
3505
+ entry.call.name,
3506
+ reviewedApproval.request.arguments
3507
+ );
3508
+ if (entry.call.id != null) {
3509
+ unresolvedByCallId.delete(entry.call.id);
3510
+ }
3511
+ }
3512
+ askEntries.push({
3513
+ entry,
3514
+ reason: reviewedApproval.request.description,
3515
+ allowedDecisions: reviewedApproval.reviewConfig.allowed_decisions,
3516
+ });
3517
+ continue;
3518
+ }
3267
3519
  if (entry.call.id != null) {
3268
3520
  const preempted = preemptedEagerRecords.get(entry.call.id);
3269
3521
  if (preempted != null) {
@@ -3304,7 +3556,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3304
3556
  );
3305
3557
 
3306
3558
  for (const { entry } of askEntries) {
3307
- hookRegistry.clearPendingToolApproval(
3559
+ hookRegistry?.clearPendingToolApproval(
3308
3560
  approvalReplaySessionId,
3309
3561
  createToolApprovalReplayKey(
3310
3562
  config,
@@ -3314,8 +3566,20 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3314
3566
  );
3315
3567
  }
3316
3568
 
3569
+ const proposalMatches =
3570
+ approvalReviewEvidence == null ||
3571
+ toolApprovalPayloadMatches(payload, approvalReviewEvidence.payload);
3572
+ if (!proposalMatches) {
3573
+ for (const { entry } of askEntries) {
3574
+ blockEntry(
3575
+ entry,
3576
+ 'Reviewed tool proposal changed before resume; retry approval'
3577
+ );
3578
+ }
3579
+ }
3580
+ const decisionEntries = proposalMatches ? askEntries : [];
3317
3581
  const decisionByCallId = normalizeApprovalDecisions(
3318
- askEntries.map(({ entry }) => entry.call.id!),
3582
+ decisionEntries.map(({ entry }) => entry.call.id!),
3319
3583
  resumeValue
3320
3584
  );
3321
3585
 
@@ -3323,7 +3587,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3323
3587
  entry,
3324
3588
  reason: askReason,
3325
3589
  allowedDecisions,
3326
- } of askEntries) {
3590
+ } of decisionEntries) {
3327
3591
  const decision = decisionByCallId.get(entry.call.id!) ?? {
3328
3592
  type: 'reject' as const,
3329
3593
  reason: 'No decision provided for tool approval',
@@ -3688,8 +3952,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3688
3952
  // match it at the top level too.
3689
3953
  agentId: this.executingAgentId,
3690
3954
  callerCapabilityProjection:
3691
- this.getCallerCapabilityProjectionSnapshot(),
3692
- configurable: stripRunBreakerScope(
3955
+ this.getCallerCapabilityProjectionSnapshot(),
3956
+ configurable: stripHostExecutionConfig(
3693
3957
  config.configurable as Record<string, unknown> | undefined
3694
3958
  ),
3695
3959
  metadata: config.metadata as
@@ -4348,12 +4612,44 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4348
4612
  const settledBatchResults =
4349
4613
  baseContext.replayBatchKey == null
4350
4614
  ? undefined
4351
- : this.settledInterruptingResults.get(baseContext.replayBatchKey);
4615
+ : (this.settledDirectResultsByBatch.get(baseContext.replayBatchKey) ??
4616
+ new Map<string, SettledDirectToolResult>());
4617
+ const cacheResult = (
4618
+ call: ToolCall,
4619
+ position: number,
4620
+ result: SettledDirectToolResult
4621
+ ): void => {
4622
+ if (
4623
+ baseContext.replayBatchKey == null ||
4624
+ settledBatchResults == null
4625
+ ) {
4626
+ return;
4627
+ }
4628
+ settledBatchResults.set(directReplayKey(call, baseContext.replayCallPositions?.get(call) ?? batchIndices[position]), result);
4629
+ this.settledDirectResultsByBatch.set(
4630
+ baseContext.replayBatchKey,
4631
+ settledBatchResults
4632
+ );
4633
+ };
4352
4634
  const restoreResult = (
4353
4635
  call: ToolCall,
4354
4636
  result: SettledDirectToolResult
4355
4637
  ): SettledDirectToolResult => {
4356
4638
  baseContext.additionalContextsSink?.push(...result.additionalContexts);
4639
+ const turn = result.turn;
4640
+ if (turn != null) {
4641
+ this.recordToolUsageTurn(call.name, turn, call.id);
4642
+ }
4643
+ const refMeta = isBaseMessage(result.output) ? result.output.additional_kwargs as t.ToolMessageRefMetadata : undefined;
4644
+ if (refMeta?._refKey != null && result.referenceContent != null) {
4645
+ this.toolOutputRegistry?.set(baseContext.batchScopeId, refMeta._refKey, result.referenceContent);
4646
+ if (isBaseMessage(result.output)) {
4647
+ result = { ...result, output: new ToolMessage({
4648
+ ...(result.output as ToolMessage),
4649
+ additional_kwargs: { ...result.output.additional_kwargs, _refScope: baseContext.batchScopeId },
4650
+ }) };
4651
+ }
4652
+ }
4357
4653
  if (
4358
4654
  call.id != null &&
4359
4655
  result.resolvedArgs != null &&
@@ -4367,11 +4663,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4367
4663
  call: ToolCall,
4368
4664
  position: number
4369
4665
  ): Promise<SettledDirectToolResult> => {
4370
- const cachedResult =
4371
- typeof call.id === 'string'
4372
- ? settledBatchResults?.get(call.id)
4373
- : undefined;
4666
+ const cachedResult = settledBatchResults?.get(directReplayKey(call, baseContext.replayCallPositions?.get(call) ?? batchIndices[position]));
4374
4667
  if (cachedResult != null) {
4668
+ if (cachedResult.proposal.name !== call.name ||
4669
+ !recordArgsEqual(cachedResult.proposal.args, call.args as Record<string, unknown>)) {
4670
+ throw new Error('Cannot change an already completed tool call during approval replay');
4671
+ }
4375
4672
  return restoreResult(call, cachedResult);
4376
4673
  }
4377
4674
  const additionalContexts: string[] = [];
@@ -4384,11 +4681,19 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4384
4681
  call.id == null
4385
4682
  ? undefined
4386
4683
  : baseContext.resolvedArgsByCallId?.get(call.id);
4387
- return restoreResult(call, {
4684
+ const result: SettledDirectToolResult = {
4685
+ proposal: structuredClone({ name: call.name, args: call.args as Record<string, unknown> }),
4686
+ turn: call.id == null ? undefined : this.toolCallTurns.get(call.id) ?? this.directPathTurns.get(call.id),
4388
4687
  output,
4389
4688
  additionalContexts,
4390
4689
  ...(resolvedArgs == null ? {} : { resolvedArgs }),
4391
- });
4690
+ };
4691
+ const refMeta = isBaseMessage(output) ? output.additional_kwargs as t.ToolMessageRefMetadata : undefined;
4692
+ if (refMeta?._refKey != null) {
4693
+ result.referenceContent = this.toolOutputRegistry?.get(refMeta._refScope, refMeta._refKey);
4694
+ }
4695
+ cacheResult(call, position, result);
4696
+ return restoreResult(call, result);
4392
4697
  };
4393
4698
 
4394
4699
  const interrupting = this.interruptingToolNames;
@@ -4397,10 +4702,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4397
4702
  directCalls.some((call) => interrupting.has(call.name));
4398
4703
 
4399
4704
  if (!hasInterrupting) {
4400
- const results = await Promise.all(
4705
+ const results = await Promise.allSettled(
4401
4706
  directCalls.map((call, i) => runOne(call, i))
4402
4707
  );
4403
- return results.map((result) => result.output);
4708
+ this.throwIfBreakerTripped(config);
4709
+ const failed = results.find((result) => result.status === 'rejected' && !isGraphInterrupt(result.reason)) ??
4710
+ results.find((result) => result.status === 'rejected');
4711
+ if (failed?.status === 'rejected') {
4712
+ throw failed.reason;
4713
+ }
4714
+ return results.map((result) => {
4715
+ if (result.status === 'rejected') {
4716
+ throw result.reason;
4717
+ }
4718
+ return result.value.output;
4719
+ });
4404
4720
  }
4405
4721
 
4406
4722
  const outputs: (BaseMessage | Command)[] = new Array(directCalls.length);
@@ -4437,21 +4753,6 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4437
4753
  }
4438
4754
  const position = interruptingPositions[i];
4439
4755
  outputs[position] = result.value.output;
4440
- const callId = directCalls[position].id;
4441
- if (
4442
- baseContext.replayBatchKey != null &&
4443
- typeof callId === 'string' &&
4444
- callId !== ''
4445
- ) {
4446
- const batchResults =
4447
- this.settledInterruptingResults.get(baseContext.replayBatchKey) ??
4448
- new Map<string, SettledDirectToolResult>();
4449
- batchResults.set(callId, result.value);
4450
- this.settledInterruptingResults.set(
4451
- baseContext.replayBatchKey,
4452
- batchResults
4453
- );
4454
- }
4455
4756
  }
4456
4757
  if (hasInterruptingError) {
4457
4758
  if (isGraphInterrupt(interruptingError)) {
@@ -4549,7 +4850,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4549
4850
  }
4550
4851
 
4551
4852
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
4552
- protected async run(input: any, config: RunnableConfig): Promise<T> {
4853
+ protected async run(input: any, config: RunnableConfig, referenceReplay: { state?: ToolOutputReferenceState } = {}): Promise<T> {
4553
4854
  /**
4554
4855
  * Breaker read once at batch entry: every downstream path (direct
4555
4856
  * `tool.invoke` runtimes and the ON_TOOL_EXECUTE dispatch) inherits this
@@ -4581,7 +4882,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4581
4882
  * otherwise perform side effects on a failed run. */
4582
4883
  if (
4583
4884
  composedSignal?.aborted === true &&
4584
- (composedSignal.reason instanceof StreamLimitExceededError || composedSignal.reason instanceof PreparedSubagentError)
4885
+ (composedSignal.reason instanceof StreamLimitExceededError ||
4886
+ composedSignal.reason instanceof PreparedSubagentError)
4585
4887
  ) {
4586
4888
  throw composedSignal.reason;
4587
4889
  }
@@ -4606,11 +4908,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4606
4908
  * every subsequent registry call on this batch.
4607
4909
  */
4608
4910
  const incomingRunId = config.configurable?.run_id as string | undefined;
4609
- const incomingThreadId = config.configurable?.thread_id as
4610
- | string
4611
- | undefined;
4612
4911
  const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
4613
- const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
4912
+ const resumedReferenceState = referenceReplay.state;
4913
+ const replayInputSnapshot = resumedReferenceState == null ? undefined :
4914
+ this.toolOutputRegistry?.resumeBatch(batchScopeId, resumedReferenceState);
4915
+ referenceReplay.state = resumedReferenceState ?? this.toolOutputRegistry?.snapshotState(batchScopeId);
4916
+ const turn = resumedReferenceState?.turnCounter ?? this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
4614
4917
  let outputs: (BaseMessage | Command)[];
4615
4918
  let replayBatchKey: string | undefined;
4616
4919
  /** Hoisted from the messages-state branch so the Command tail can carry
@@ -4628,6 +4931,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4628
4931
  batchIndices: [0],
4629
4932
  turn,
4630
4933
  batchScopeId,
4934
+ preBatchSnapshot: replayInputSnapshot,
4631
4935
  });
4632
4936
  }
4633
4937
  // Same per-batch sink the message-state branches use so
@@ -4645,12 +4949,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4645
4949
  ? findAssistantBatch(sendMessages as BaseMessage[])
4646
4950
  : undefined;
4647
4951
  const sendReplayBatchKey =
4648
- sendAssistantBatch == null
4952
+ sendAssistantBatch == null || getToolBatchReplayScope(config) == null
4649
4953
  ? undefined
4650
4954
  : getAssistantBatchReplayKey(
4651
4955
  sendAssistantBatch,
4652
- incomingRunId,
4653
- incomingThreadId
4956
+ getToolBatchReplayScope(config)
4654
4957
  );
4655
4958
  const sendOutput = await this.runDirectToolWithLifecycleHooks(
4656
4959
  input.lg_tool_call,
@@ -4659,6 +4962,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4659
4962
  batchIndex: 0,
4660
4963
  turn,
4661
4964
  batchScopeId,
4965
+ preBatchSnapshot: replayInputSnapshot,
4662
4966
  resolvedArgsByCallId,
4663
4967
  errorOwnership,
4664
4968
  additionalContextsSink: directAdditionalContexts,
@@ -4713,10 +5017,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4713
5017
  throw new Error('ToolNode only accepts AIMessages as input.');
4714
5018
  }
4715
5019
  const aiMessage = assistantBatch.message;
4716
- replayBatchKey = getAssistantBatchReplayKey(
5020
+ replayBatchKey = getToolBatchReplayScope(config) == null ? undefined : getAssistantBatchReplayKey(
4717
5021
  assistantBatch,
4718
- incomingRunId,
4719
- incomingThreadId
5022
+ getToolBatchReplayScope(config)
4720
5023
  );
4721
5024
 
4722
5025
  if (this.loadRuntimeTools) {
@@ -4728,6 +5031,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4728
5031
  this.applyToolExecutionOverrides();
4729
5032
  }
4730
5033
 
5034
+ const replayCallPositions = new Map((aiMessage.tool_calls ?? []).map((call, i) => [call, i]));
4731
5035
  const filteredCalls =
4732
5036
  aiMessage.tool_calls?.filter((call) => {
4733
5037
  /**
@@ -4737,7 +5041,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4737
5041
  * which are executed by the provider's API and don't require invocation
4738
5042
  */
4739
5043
  return (
4740
- (call.id == null || !toolMessageIds.has(call.id)) &&
5044
+ (call.id == null || call.id === '' || !toolMessageIds.has(call.id)) &&
4741
5045
  !(
4742
5046
  call.id?.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX) ??
4743
5047
  false
@@ -4865,6 +5169,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4865
5169
  batchIndices: eventEntries.map((entry) => entry.batchIndex),
4866
5170
  turn,
4867
5171
  batchScopeId,
5172
+ preBatchSnapshot: replayInputSnapshot,
4868
5173
  });
4869
5174
  }
4870
5175
 
@@ -4872,6 +5177,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4872
5177
  const directIndices = directEntries.map((e) => e.batchIndex);
4873
5178
  const eventCalls = eventEntries.map((e) => e.call);
4874
5179
  const eventIndices = eventEntries.map((e) => e.batchIndex);
5180
+ const approvalScopeCallIds = new Set(
5181
+ filteredCalls.flatMap((call) => (call.id == null ? [] : [call.id]))
5182
+ );
4875
5183
 
4876
5184
  /**
4877
5185
  * Snapshot the event calls' args against the *pre-batch*
@@ -4898,7 +5206,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4898
5206
  * same-turn direct outputs.
4899
5207
  */
4900
5208
  const preBatchSnapshot =
4901
- this.toolOutputRegistry?.snapshot(batchScopeId);
5209
+ replayInputSnapshot ?? this.toolOutputRegistry?.snapshot(batchScopeId);
4902
5210
  if (preBatchSnapshot != null) {
4903
5211
  for (const entry of eventEntries) {
4904
5212
  if (entry.call.id != null) {
@@ -4937,19 +5245,40 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4937
5245
  preBatchSnapshot,
4938
5246
  additionalContextsSink: directAdditionalContexts,
4939
5247
  replayBatchKey,
5248
+ replayCallPositions,
5249
+ approvalScopeCallIds,
4940
5250
  runInput: input as T,
4941
5251
  }
4942
5252
  )
4943
5253
  : [];
4944
5254
 
4945
- if (directCalls.length > 0 && directOutputs.length > 0) {
5255
+ const settledDirectResults =
5256
+ replayBatchKey == null ? undefined : this.settledDirectResultsByBatch.get(replayBatchKey);
5257
+ const unhandledDirectCalls: ToolCall[] = [];
5258
+ const unhandledDirectOutputs: (BaseMessage | Command)[] = [];
5259
+ for (let i = 0; i < directCalls.length; i++) {
5260
+ const settled = settledDirectResults?.get(directReplayKey(directCalls[i], replayCallPositions.get(directCalls[i])!));
5261
+ if (settled?.completionHandled === true) {
5262
+ continue;
5263
+ }
5264
+ unhandledDirectCalls.push(directCalls[i]);
5265
+ unhandledDirectOutputs.push(directOutputs[i]);
5266
+ }
5267
+
5268
+ if (unhandledDirectCalls.length > 0) {
4946
5269
  await this.handleRunToolCompletions(
4947
- directCalls,
4948
- directOutputs,
5270
+ unhandledDirectCalls,
5271
+ unhandledDirectOutputs,
4949
5272
  config,
4950
5273
  resolvedArgsByCallId,
4951
5274
  errorOwnership
4952
5275
  );
5276
+ for (let i = 0; i < directCalls.length; i++) {
5277
+ const settled = settledDirectResults?.get(directReplayKey(directCalls[i], replayCallPositions.get(directCalls[i])!));
5278
+ if (settled != null) {
5279
+ settled.completionHandled = true;
5280
+ }
5281
+ }
4953
5282
  }
4954
5283
 
4955
5284
  const eventResult =
@@ -4998,8 +5327,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4998
5327
  // leak a sibling's just-registered output into a sister
4999
5328
  // call's args mid-await (Codex P1 #18).
5000
5329
  const preBatchSnapshot =
5001
- this.toolOutputRegistry?.snapshot(batchScopeId);
5330
+ replayInputSnapshot ?? this.toolOutputRegistry?.snapshot(batchScopeId);
5002
5331
  const directAdditionalContexts: string[] = [];
5332
+ const approvalScopeCallIds = new Set(
5333
+ filteredCalls.flatMap((call) => (call.id == null ? [] : [call.id]))
5334
+ );
5003
5335
  const toolOutputs = await this.runDirectBatchInterruptSafe(
5004
5336
  filteredCalls,
5005
5337
  filteredCalls.map((_call, i) => i),
@@ -5012,6 +5344,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
5012
5344
  preBatchSnapshot,
5013
5345
  additionalContextsSink: directAdditionalContexts,
5014
5346
  replayBatchKey,
5347
+ replayCallPositions,
5348
+ approvalScopeCallIds,
5015
5349
  runInput: input as T,
5016
5350
  }
5017
5351
  );
@@ -5071,7 +5405,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
5071
5405
 
5072
5406
  if (!outputs.some(isCommand)) {
5073
5407
  if (replayBatchKey != null) {
5074
- this.settledInterruptingResults.delete(replayBatchKey);
5408
+ this.settledDirectResultsByBatch.delete(replayBatchKey);
5075
5409
  }
5076
5410
  return (Array.isArray(input) ? outputs : { messages: outputs }) as T;
5077
5411
  }
@@ -5214,7 +5548,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
5214
5548
  }
5215
5549
 
5216
5550
  if (replayBatchKey != null) {
5217
- this.settledInterruptingResults.delete(replayBatchKey);
5551
+ this.settledDirectResultsByBatch.delete(replayBatchKey);
5218
5552
  }
5219
5553
  return combinedOutputs as T;
5220
5554
  }