@librechat/agents 3.8.1 → 3.8.3

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 (96) 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/hooks/index.cjs +2 -0
  6. package/dist/cjs/hooks/index.cjs.map +1 -1
  7. package/dist/cjs/hooks/types.cjs.map +1 -1
  8. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +1 -1
  9. package/dist/cjs/main.cjs +5 -3
  10. package/dist/cjs/messages/core.cjs +1 -1
  11. package/dist/cjs/messages/format.cjs +1 -1
  12. package/dist/cjs/messages/prune.cjs +2 -2
  13. package/dist/cjs/run.cjs +17 -6
  14. package/dist/cjs/run.cjs.map +1 -1
  15. package/dist/cjs/session/AgentSession.cjs +1 -1
  16. package/dist/cjs/stream.cjs +2 -2
  17. package/dist/cjs/summarization/node.cjs +1 -1
  18. package/dist/cjs/tools/BashExecutor.cjs +2 -3
  19. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  20. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +3 -2
  21. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  22. package/dist/cjs/tools/CodeExecutor.cjs +63 -11
  23. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  24. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +8 -7
  25. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  26. package/dist/cjs/tools/ToolNode.cjs +271 -84
  27. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  28. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +24 -9
  29. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  30. package/dist/cjs/tools/subagent/SubagentReplay.cjs +2 -0
  31. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
  32. package/dist/cjs/tools/toolBatchReplay.cjs +198 -0
  33. package/dist/cjs/tools/toolBatchReplay.cjs.map +1 -0
  34. package/dist/cjs/tools/toolOutputReferences.cjs +12 -0
  35. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  36. package/dist/cjs/types/hitl.cjs +4 -0
  37. package/dist/cjs/types/hitl.cjs.map +1 -1
  38. package/dist/esm/graphs/Graph.mjs +2 -2
  39. package/dist/esm/graphs/MultiAgentGraph.mjs +1 -1
  40. package/dist/esm/hitl/approvalReview.mjs +83 -0
  41. package/dist/esm/hitl/approvalReview.mjs.map +1 -0
  42. package/dist/esm/hooks/index.mjs +2 -1
  43. package/dist/esm/hooks/index.mjs.map +1 -1
  44. package/dist/esm/hooks/types.mjs.map +1 -1
  45. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +1 -1
  46. package/dist/esm/main.mjs +6 -6
  47. package/dist/esm/messages/core.mjs +1 -1
  48. package/dist/esm/messages/format.mjs +1 -1
  49. package/dist/esm/messages/prune.mjs +2 -2
  50. package/dist/esm/run.mjs +18 -7
  51. package/dist/esm/run.mjs.map +1 -1
  52. package/dist/esm/session/AgentSession.mjs +1 -1
  53. package/dist/esm/stream.mjs +2 -2
  54. package/dist/esm/summarization/node.mjs +1 -1
  55. package/dist/esm/tools/BashExecutor.mjs +2 -3
  56. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  57. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +3 -2
  58. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  59. package/dist/esm/tools/CodeExecutor.mjs +63 -12
  60. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  61. package/dist/esm/tools/ProgrammaticToolCalling.mjs +8 -7
  62. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  63. package/dist/esm/tools/ToolNode.mjs +271 -84
  64. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  65. package/dist/esm/tools/subagent/SubagentExecutor.mjs +24 -9
  66. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  67. package/dist/esm/tools/subagent/SubagentReplay.mjs +1 -1
  68. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
  69. package/dist/esm/tools/toolBatchReplay.mjs +186 -0
  70. package/dist/esm/tools/toolBatchReplay.mjs.map +1 -0
  71. package/dist/esm/tools/toolOutputReferences.mjs +12 -0
  72. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  73. package/dist/esm/types/hitl.mjs +4 -1
  74. package/dist/esm/types/hitl.mjs.map +1 -1
  75. package/dist/types/hitl/approvalReview.d.ts +32 -0
  76. package/dist/types/hooks/index.d.ts +2 -0
  77. package/dist/types/hooks/types.d.ts +6 -0
  78. package/dist/types/tools/CodeExecutor.d.ts +2 -0
  79. package/dist/types/tools/ToolNode.d.ts +12 -6
  80. package/dist/types/tools/subagent/SubagentReplay.d.ts +2 -0
  81. package/dist/types/tools/toolBatchReplay.d.ts +63 -0
  82. package/dist/types/tools/toolOutputReferences.d.ts +2 -0
  83. package/package.json +1 -1
  84. package/src/hitl/approvalReview.ts +209 -0
  85. package/src/hooks/index.ts +3 -0
  86. package/src/hooks/types.ts +6 -0
  87. package/src/run.ts +34 -20
  88. package/src/tools/BashExecutor.ts +2 -2
  89. package/src/tools/BashProgrammaticToolCalling.ts +5 -3
  90. package/src/tools/CodeExecutor.ts +111 -17
  91. package/src/tools/ProgrammaticToolCalling.ts +10 -8
  92. package/src/tools/ToolNode.ts +536 -184
  93. package/src/tools/subagent/SubagentExecutor.ts +35 -6
  94. package/src/tools/subagent/SubagentReplay.ts +2 -2
  95. package/src/tools/toolBatchReplay.ts +423 -0
  96. 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,48 @@ 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
+ isChildToolReplayOwner,
132
+ isToolReplayResume,
133
+ } from '@/tools/toolBatchReplay';
134
+
135
+ function stripToolApprovalReviewConfig(
117
136
  configurable: Record<string, unknown> | undefined
118
137
  ): Record<string, unknown> | undefined {
119
- if (configurable == null || !(RUN_BREAKER_SCOPE_CONFIG_KEY in configurable)) {
138
+ if (
139
+ configurable == null ||
140
+ !(TOOL_APPROVAL_REVIEW_CONFIG_KEY in configurable) &&
141
+ !(TOOL_BATCH_REPLAY_KEY in configurable)
142
+ ) {
120
143
  return configurable;
121
144
  }
122
- const { [RUN_BREAKER_SCOPE_CONFIG_KEY]: _scope, ...rest } = configurable;
145
+ const {
146
+ [TOOL_APPROVAL_REVIEW_CONFIG_KEY]: _review,
147
+ [TOOL_BATCH_REPLAY_KEY]: _replay,
148
+ ...rest
149
+ } = configurable;
150
+ return rest;
151
+ }
152
+
153
+ /** Host-facing requests must not carry SDK-private execution state — hosts
154
+ * spread `configurable` into their own run configs. */
155
+ function stripHostExecutionConfig(
156
+ configurable: Record<string, unknown> | undefined
157
+ ): Record<string, unknown> | undefined {
158
+ const withoutReview = stripToolApprovalReviewConfig(configurable);
159
+ if (
160
+ withoutReview == null ||
161
+ !(RUN_BREAKER_SCOPE_CONFIG_KEY in withoutReview)
162
+ ) {
163
+ return withoutReview;
164
+ }
165
+ const { [RUN_BREAKER_SCOPE_CONFIG_KEY]: _scope, ...rest } = withoutReview;
123
166
  return rest;
124
167
  }
125
168
  import { convertInjectedMessages } from '@/messages/injected';
@@ -209,6 +252,10 @@ type RunToolBatchContext<T = unknown> = {
209
252
  additionalContextsSink?: string[];
210
253
  /** Stable identity of the assistant tool-call batch across HITL replay. */
211
254
  replayBatchKey?: string;
255
+ /** Positions in the original proposal, before completed calls are filtered. */
256
+ replayCallPositions?: ReadonlyMap<ToolCall, number>;
257
+ /** Tool-call identities owned by this ToolNode invocation. */
258
+ approvalScopeCallIds?: ReadonlySet<string>;
212
259
  /**
213
260
  * Graph state the ToolNode was invoked with, threaded from `run()`
214
261
  * so `tool.invoke` can forward it as langgraph 1.4's `runtime.state`
@@ -236,11 +283,14 @@ function withSubagentReplayBatch(
236
283
  };
237
284
  }
238
285
 
239
- type SettledDirectToolResult = {
240
- output: BaseMessage | Command;
241
- additionalContexts: string[];
242
- resolvedArgs?: Record<string, unknown>;
243
- };
286
+ type SettledDirectToolResult = SettledToolBatchResult;
287
+
288
+ /** Positional identity is safe within the immutable full-proposal batch key. */
289
+ function directReplayKey(call: ToolCall, batchIndex: number): string {
290
+ return call.id == null || call.id === ''
291
+ ? JSON.stringify(['position', batchIndex])
292
+ : JSON.stringify(['id', call.id]);
293
+ }
244
294
 
245
295
  /**
246
296
  * Batch-local record of who owns each failed call's completion event.
@@ -403,8 +453,6 @@ function describeOfferedShape(value: unknown): string {
403
453
 
404
454
  type AssistantBatch = {
405
455
  message: AIMessage;
406
- index: number;
407
- messageCount: number;
408
456
  };
409
457
 
410
458
  function findAssistantBatch(
@@ -413,7 +461,7 @@ function findAssistantBatch(
413
461
  for (let i = messages.length - 1; i >= 0; i--) {
414
462
  const message = messages[i];
415
463
  if (isAIMessage(message)) {
416
- return { message, index: i, messageCount: messages.length };
464
+ return { message };
417
465
  }
418
466
  }
419
467
  return undefined;
@@ -421,15 +469,12 @@ function findAssistantBatch(
421
469
 
422
470
  function getAssistantBatchReplayKey(
423
471
  batch: AssistantBatch,
424
- runId: string | undefined,
425
472
  threadId: string | undefined
426
473
  ): string {
427
474
  return JSON.stringify([
428
- runId ?? null,
429
475
  threadId ?? null,
430
- batch.message.id ?? null,
431
- batch.index,
432
- batch.messageCount,
476
+ batch.message.id == null || batch.message.id === '' ? null : batch.message.id,
477
+ createHash('sha256').update(stableStringify(batch.message.tool_calls ?? [])).digest('hex'),
433
478
  ]);
434
479
  }
435
480
 
@@ -697,10 +742,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
697
742
  * the Run ends.
698
743
  */
699
744
  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<
745
+ /** Terminal direct results that must survive a LangGraph replay of the
746
+ * containing ToolNode. Includes the sidecar data the fresh batch needs for
747
+ * hook-context injection and completion-event deduplication. */
748
+ private settledDirectResultsByBatch = new Map<
704
749
  string,
705
750
  Map<string, SettledDirectToolResult>
706
751
  >();
@@ -854,23 +899,95 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
854
899
  name: name ?? TOOL_NODE_RUN_NAME,
855
900
  tags,
856
901
  func: async (input, config) => {
902
+ const messages = Array.isArray(input) ? input as BaseMessage[] :
903
+ (input as { messages?: BaseMessage[] }).messages;
904
+ const assistantBatch = messages == null ? undefined : findAssistantBatch(messages);
905
+ const activeReplayKey = assistantBatch == null || getToolBatchReplayScope(config) == null ? undefined : getAssistantBatchReplayKey(
906
+ assistantBatch, getToolBatchReplayScope(config)
907
+ );
908
+ const replayOwner = getToolBatchReplayOwner(
909
+ config,
910
+ this.executingAgentId ?? this.agentId ?? ''
911
+ );
912
+ let reviewEvidence = getToolApprovalReviewEvidence(config);
913
+ const replayState = getToolBatchReplayState(config.configurable);
914
+ const ownsParentBatch = replayState?.records.some(
915
+ (record) => record.owner === replayOwner && record.batch === activeReplayKey
916
+ ) === true;
917
+ let isChildReplay = false;
918
+ if (ownsParentBatch) {
919
+ const calls = this.isSendInput(input) ? [input.lg_tool_call] : assistantBatch?.message.tool_calls ?? [];
920
+ const trustedParentCallIds = new Set(calls.filter((call) =>
921
+ call.name === Constants.SUBAGENT &&
922
+ (this.toolMap.get(call.name) as ReplayableSubagentTool | undefined)?.[SUBAGENT_REPLAY_CONTROLLER] != null
923
+ ).map((call) => call.id ?? ''));
924
+ isChildReplay = reviewEvidence?.owner != null
925
+ ? isChildToolReplayOwner(config, reviewEvidence.owner, trustedParentCallIds)
926
+ : replayState.records.some((record) => isChildToolReplayOwner(config, record.owner, trustedParentCallIds));
927
+ }
928
+ if ((reviewEvidence?.owner != null || replayState != null) &&
929
+ !isToolReplayResume(config, reviewEvidence?.interruptId ?? replayState?.interruptId, isChildReplay)) {
930
+ config = {
931
+ ...config,
932
+ configurable: {
933
+ ...config.configurable,
934
+ [TOOL_APPROVAL_REVIEW_CONFIG_KEY]: undefined,
935
+ [TOOL_BATCH_REPLAY_KEY]: undefined,
936
+ },
937
+ };
938
+ reviewEvidence = undefined;
939
+ }
940
+ if (reviewEvidence?.owner != null && reviewEvidence.owner !== replayOwner && !isChildReplay) {
941
+ throw new Error('Tool approval execution owner changed before resume — failing closed');
942
+ }
943
+ const referenceReplay: { state?: ToolOutputReferenceState } = {};
944
+ const restoredBatches = await restoreToolBatchReplayState(config, replayOwner);
945
+ if (restoredBatches.length > 0 && !restoredBatches.some(({ batch }) => batch === activeReplayKey)) {
946
+ throw new Error('Tool batch proposal changed before approval replay — failing closed');
947
+ }
948
+ if (activeReplayKey != null && getToolBatchReplayState(config.configurable) != null) {
949
+ this.settledDirectResultsByBatch.delete(activeReplayKey);
950
+ }
951
+ for (const { batch, results, turnState, referenceState } of restoredBatches) {
952
+ if (batch !== activeReplayKey) {
953
+ continue;
954
+ }
955
+ this.settledDirectResultsByBatch.set(batch, new Map(results));
956
+ if (turnState != null) {
957
+ this.restoreSubagentResumeState(turnState);
958
+ }
959
+ referenceReplay.state = referenceState;
960
+ }
857
961
  const state = input as T & Pick<t.BaseGraphState, 'runStepState'>;
858
962
  restoreRunStepResumeState?.(state.runStepState, config);
859
963
  let result: T;
860
964
  try {
861
- result = await this.run(input, config);
965
+ result = await this.run(input, config, referenceReplay);
862
966
  } catch (error) {
863
- if (!isGraphInterrupt(error) || createRunStepResumeState == null) {
967
+ if (!isGraphInterrupt(error)) {
864
968
  throw error;
865
969
  }
866
- const resumeState = createRunStepResumeState();
970
+ const resumeState = createRunStepResumeState?.();
971
+ const activeResults = activeReplayKey == null ? undefined : this.settledDirectResultsByBatch.get(activeReplayKey);
972
+ const activeBatches = activeReplayKey == null
973
+ ? new Map<string, Map<string, SettledDirectToolResult>>()
974
+ : new Map([[activeReplayKey, activeResults ?? new Map<string, SettledDirectToolResult>()]]);
975
+ const turnState = this.createSubagentResumeState();
976
+ const activeCallIds = new Set(assistantBatch?.message.tool_calls?.map((call) => call.id));
977
+ turnState.directPathTurns = turnState.directPathTurns.filter(({ toolCallId }) => activeCallIds.has(toolCallId));
867
978
  throw new GraphInterrupt(
868
- error.interrupts.map((pendingInterrupt) => ({
869
- ...pendingInterrupt,
870
- value: attachRunStepResumeState(
979
+ await Promise.all(error.interrupts.map(async (pendingInterrupt) => {
980
+ const value = await attachToolBatchReplayState(
871
981
  pendingInterrupt.value,
872
- resumeState
873
- ),
982
+ replayOwner,
983
+ activeBatches,
984
+ turnState,
985
+ referenceReplay.state
986
+ );
987
+ return {
988
+ ...pendingInterrupt,
989
+ value: resumeState == null ? value : attachRunStepResumeState(value, resumeState),
990
+ };
874
991
  }))
875
992
  );
876
993
  }
@@ -1146,14 +1263,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1146
1263
  */
1147
1264
  clearDirectPathTurns(): void {
1148
1265
  this.directPathTurns.clear();
1149
- this.settledInterruptingResults.clear();
1266
+ this.settledDirectResultsByBatch.clear();
1150
1267
  }
1151
1268
 
1152
1269
  /** Returns the live caller projection used by direct and event execution. */
1153
1270
  private getCallerCapabilityProjection(): CallerCapabilityProjection {
1154
- const discoveredToolNames = new Set(
1155
- this.getDiscoveredToolNames?.() ?? []
1156
- );
1271
+ const discoveredToolNames = new Set(this.getDiscoveredToolNames?.() ?? []);
1157
1272
  return resolveCallerCapabilityProjection(
1158
1273
  mergeCallerCapabilityDefinitions(
1159
1274
  this.toolDefinitions?.values(),
@@ -1194,9 +1309,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1194
1309
  private getProgrammaticTools(): t.ProgrammaticCache {
1195
1310
  const toolMap: t.ToolMap = new Map();
1196
1311
  const toolDefs: t.LCTool[] = [];
1197
- const discoveredToolNames = new Set(
1198
- this.getDiscoveredToolNames?.() ?? []
1199
- );
1312
+ const discoveredToolNames = new Set(this.getDiscoveredToolNames?.() ?? []);
1200
1313
  const executableCapabilities = resolveCallerCapabilityProjection(
1201
1314
  this.toolRegistry?.values() ?? [],
1202
1315
  (toolDef) => isToolDefinitionActive(toolDef, discoveredToolNames)
@@ -1307,11 +1420,22 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1307
1420
  runInput?: RunToolBatchContext<T>['runInput']
1308
1421
  ): Promise<unknown> {
1309
1422
  const lgConfig = config as LangGraphRunnableConfig;
1310
- const runtime: ToolRuntime<T> = {
1423
+ const isTrustedSubagentHop =
1424
+ call.name === Constants.SUBAGENT &&
1425
+ (tool as ReplayableSubagentTool)[SUBAGENT_REPLAY_CONTROLLER] != null;
1426
+ const toolConfig: RunnableConfig = {
1311
1427
  ...config,
1428
+ configurable: isTrustedSubagentHop
1429
+ ? config.configurable
1430
+ : stripToolApprovalReviewConfig(
1431
+ config.configurable as Record<string, unknown> | undefined
1432
+ ),
1433
+ };
1434
+ const runtime: ToolRuntime<T> = {
1435
+ ...toolConfig,
1312
1436
  state: runInput as ToolRuntime<T>['state'],
1313
1437
  toolCallId: call.id ?? '',
1314
- config,
1438
+ config: toolConfig,
1315
1439
  context: lgConfig.context as ToolRuntime<T>['context'],
1316
1440
  store: (lgConfig.store ?? null) as ToolRuntime<T>['store'],
1317
1441
  writer:
@@ -1533,6 +1657,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1533
1657
  }
1534
1658
  return next;
1535
1659
  })();
1660
+ this.recordToolUsageTurn(call.name, usageCount, call.id);
1661
+ if (batchContext.replayBatchKey != null && call.id != null && call.id !== '') {
1662
+ this.directPathTurns.set(call.id, usageCount);
1663
+ }
1536
1664
  let args = call.args;
1537
1665
  if (resolveFn != null) {
1538
1666
  const { resolved, unresolved } = resolveFn(runId, args);
@@ -1915,17 +2043,22 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1915
2043
  batchContext: RunToolBatchContext<T> = {}
1916
2044
  ): Promise<BaseMessage | Command> {
1917
2045
  if (
1918
- this.preparedSubagents?.owns(this.executingAgentId ?? '', call) === true &&
2046
+ this.preparedSubagents?.owns(this.executingAgentId ?? '', call) ===
2047
+ true &&
1919
2048
  this.hookRegistry?.hasResultAlteringHooks(
1920
2049
  (config.configurable?.run_id as string | undefined) ?? ''
1921
2050
  ) === true
1922
2051
  ) {
1923
2052
  this.preparedSubagents.clear();
1924
- throw new PreparedSubagentError('Tool hooks changed after a subagent started.');
2053
+ throw new PreparedSubagentError(
2054
+ 'Tool hooks changed after a subagent started.'
2055
+ );
1925
2056
  }
1926
2057
  const replayController = (
1927
2058
  this.toolMap.get(call.name) as ReplayableSubagentTool | undefined
1928
2059
  )?.[SUBAGENT_REPLAY_CONTROLLER];
2060
+ const isTrustedSubagentReviewHop =
2061
+ call.name === Constants.SUBAGENT && replayController != null;
1929
2062
  const replayConfig =
1930
2063
  replayController == null
1931
2064
  ? config
@@ -2024,16 +2157,29 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2024
2157
  approvalReplaySessionId,
2025
2158
  approvalReplayKey
2026
2159
  );
2160
+ const approvalReviewEvidence = getToolApprovalReviewEvidence(config,
2161
+ getToolBatchReplayOwner(config, this.executingAgentId ?? this.agentId ?? ''));
2162
+ const reviewedApproval = getReviewedToolApproval(
2163
+ approvalReviewEvidence?.payload,
2164
+ call.id
2165
+ );
2166
+ const approvalEvidenceAppliesToBatch =
2167
+ approvalReviewEvidence?.payload.action_requests.some((request) =>
2168
+ batchContext.approvalScopeCallIds == null
2169
+ ? request.tool_call_id === call.id
2170
+ : batchContext.approvalScopeCallIds.has(request.tool_call_id)
2171
+ ) === true;
2027
2172
  const hasPostHook = hookRegistry?.hasHookFor('PostToolUse', runId) === true;
2028
2173
  const hasFailureHook =
2029
2174
  hookRegistry?.hasHookFor('PostToolUseFailure', runId) === true;
2030
2175
 
2031
2176
  if (
2032
- hookRegistry == null ||
2033
- (!hasPreHook &&
2034
- pendingApproval == null &&
2035
- !hasPostHook &&
2036
- !hasFailureHook)
2177
+ !hasPreHook &&
2178
+ pendingApproval == null &&
2179
+ reviewedApproval == null &&
2180
+ approvalReviewEvidence == null &&
2181
+ !hasPostHook &&
2182
+ !hasFailureHook
2037
2183
  ) {
2038
2184
  const output = await this.runTool(call, replayConfig, batchContext);
2039
2185
  return output instanceof ToolMessage ? persistOutput(output) : output;
@@ -2086,46 +2232,74 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2086
2232
  // value the PreToolUse hook observes matches the value the
2087
2233
  // (later-awaited) `runTool` will actually run with — both are
2088
2234
  // anchored to the pre-batch registry state.
2089
- let resolvedArgs = call.args as Record<string, unknown>;
2090
2235
  const hookResolveOptions = {
2091
2236
  substituteIntentKey: this.toolDeclaresBusinessIntent(call.name),
2092
2237
  };
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
- }
2238
+ const resolveDirectArgs = (
2239
+ args: Record<string, unknown>
2240
+ ): Record<string, unknown> => {
2241
+ if (batchContext.preBatchSnapshot != null) {
2242
+ return batchContext.preBatchSnapshot.resolve(args, hookResolveOptions)
2243
+ .resolved as Record<string, unknown>;
2244
+ }
2245
+ if (this.toolOutputRegistry != null) {
2246
+ return this.toolOutputRegistry.resolve(
2247
+ registryRunId,
2248
+ args,
2249
+ hookResolveOptions
2250
+ ).resolved as Record<string, unknown>;
2251
+ }
2252
+ return args;
2253
+ };
2254
+ const originalResolvedArgs = resolveDirectArgs(
2255
+ call.args as Record<string, unknown>
2256
+ );
2257
+ /** Reusable and pending hooks must replay from the original proposal so
2258
+ * non-idempotent rewrites are applied exactly once. A rebuilt runtime
2259
+ * without that policy instead falls back to the resolved arguments the
2260
+ * user actually reviewed. */
2261
+ let resolvedArgs =
2262
+ hasPreHook || pendingApproval != null
2263
+ ? originalResolvedArgs
2264
+ : (reviewedApproval?.request.arguments ?? originalResolvedArgs);
2107
2265
 
2108
2266
  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);
2267
+ if (
2268
+ hasPreHook ||
2269
+ pendingApproval != null ||
2270
+ reviewedApproval != null ||
2271
+ approvalReviewEvidence != null
2272
+ ) {
2273
+ const preResult: AggregatedHookResult | undefined =
2274
+ hookRegistry == null
2275
+ ? {
2276
+ additionalContexts: [],
2277
+ injectedMessages: [],
2278
+ errors: [],
2279
+ }
2280
+ : await executeHooks({
2281
+ registry: hookRegistry,
2282
+ input: {
2283
+ hook_event_name: 'PreToolUse',
2284
+ runId,
2285
+ threadId,
2286
+ agentId: this.agentId,
2287
+ executingAgentId: this.executingAgentId,
2288
+ toolName: call.name,
2289
+ toolInput: resolvedArgs,
2290
+ toolUseId: call.id ?? '',
2291
+ stepId,
2292
+ turn,
2293
+ },
2294
+ sessionId: runId,
2295
+ matchQuery: call.name,
2296
+ onceReplayKey: approvalReplayKey,
2297
+ onceReplaySessionId: approvalReplaySessionId,
2298
+ }).catch((): AggregatedHookResult | undefined => approvalReviewEvidence == null ? undefined : {
2299
+ decision: 'deny',
2300
+ reason: 'Approval policy could not be evaluated on resume',
2301
+ additionalContexts: [], injectedMessages: [], errors: [],
2302
+ });
2129
2303
 
2130
2304
  if (preResult != null) {
2131
2305
  // Forward any additionalContext strings hooks returned into
@@ -2142,9 +2316,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2142
2316
  // a valid combination (one matcher sanitises args, another asks
2143
2317
  // for approval); the reviewer should see the sanitised args.
2144
2318
  if (preResult.updatedInput != null) {
2319
+ resolvedArgs = resolveDirectArgs(
2320
+ preResult.updatedInput as Record<string, unknown>
2321
+ );
2322
+ effectiveCall = {
2323
+ ...call,
2324
+ args: resolvedArgs,
2325
+ };
2326
+ } else if (reviewedApproval != null) {
2327
+ resolvedArgs = reviewedApproval.request.arguments;
2145
2328
  effectiveCall = {
2146
2329
  ...call,
2147
- args: preResult.updatedInput as Record<string, unknown>,
2330
+ args: resolvedArgs,
2148
2331
  };
2149
2332
  }
2150
2333
 
@@ -2162,7 +2345,30 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2162
2345
  );
2163
2346
  }
2164
2347
 
2165
- if (preResult.decision === 'ask') {
2348
+ /** A sibling absent from restored evidence may have raised a parallel
2349
+ * approval that LangGraph did not preserve. A current hook can ask
2350
+ * again; otherwise never let that unreviewed sibling execute. */
2351
+ if (
2352
+ approvalEvidenceAppliesToBatch &&
2353
+ reviewedApproval == null &&
2354
+ !isTrustedSubagentReviewHop &&
2355
+ preResult.decision !== 'ask'
2356
+ ) {
2357
+ return persistOutput(
2358
+ this.blockDirectCall({
2359
+ call,
2360
+ resolvedArgs,
2361
+ reason:
2362
+ 'Tool call was not included in the restored approval batch — failing closed',
2363
+ hookRegistry,
2364
+ runId,
2365
+ threadId,
2366
+ }),
2367
+ resolvedArgs
2368
+ );
2369
+ }
2370
+
2371
+ if (preResult.decision === 'ask' || reviewedApproval != null) {
2166
2372
  if (this.humanInTheLoop?.enabled !== true) {
2167
2373
  // Fail-closed: no HITL UI configured, so we can't actually
2168
2374
  // ask. Logged once via the existing helper.
@@ -2213,14 +2419,25 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2213
2419
  // same ask entry without dispatching the consumed hook again.
2214
2420
  // ToolNode disables LangSmith tracing, so the AsyncLocalStorage
2215
2421
  // frame must be re-established here.
2422
+ const allowedDecisions =
2423
+ preResult.decision === 'ask'
2424
+ ? preResult.allowedDecisions
2425
+ : reviewedApproval?.reviewConfig.allowed_decisions;
2426
+ const askReason =
2427
+ preResult.decision === 'ask'
2428
+ ? preResult.reason
2429
+ : reviewedApproval?.request.description;
2430
+ if (effectiveCall === call) {
2431
+ effectiveCall = { ...call, args: resolvedArgs };
2432
+ }
2216
2433
  const askEntry: AskEntry = {
2217
2434
  entry: {
2218
2435
  call: effectiveCall,
2219
2436
  args: effectiveCall.args as Record<string, unknown>,
2220
2437
  stepId,
2221
2438
  },
2222
- reason: preResult.reason,
2223
- allowedDecisions: preResult.allowedDecisions,
2439
+ reason: askReason,
2440
+ allowedDecisions,
2224
2441
  };
2225
2442
  const payload = buildToolApprovalInterruptPayload([askEntry], runId);
2226
2443
  const resumeValue = AsyncLocalStorageProviderSingleton.runWithConfig(
@@ -2231,10 +2448,29 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2231
2448
  t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap
2232
2449
  >(payload)
2233
2450
  );
2234
- hookRegistry.clearPendingToolApproval(
2451
+ hookRegistry?.clearPendingToolApproval(
2235
2452
  approvalReplaySessionId,
2236
2453
  approvalReplayKey
2237
2454
  );
2455
+ const currentApproval = getReviewedToolApproval(payload, toolCallId);
2456
+ if (
2457
+ reviewedApproval != null &&
2458
+ (currentApproval == null ||
2459
+ !toolApprovalProposalMatches(currentApproval, reviewedApproval))
2460
+ ) {
2461
+ return persistOutput(
2462
+ this.blockDirectCall({
2463
+ call,
2464
+ resolvedArgs,
2465
+ reason:
2466
+ 'Reviewed tool proposal changed before resume; retry approval',
2467
+ hookRegistry,
2468
+ runId,
2469
+ threadId,
2470
+ }),
2471
+ effectiveCall.args as Record<string, unknown>
2472
+ );
2473
+ }
2238
2474
  const decisionByCallId = normalizeApprovalDecisions(
2239
2475
  [toolCallId],
2240
2476
  resumeValue
@@ -2246,9 +2482,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2246
2482
  const declaredType = (decision as { type?: unknown }).type;
2247
2483
 
2248
2484
  if (
2249
- preResult.allowedDecisions != null &&
2485
+ allowedDecisions != null &&
2250
2486
  (typeof declaredType !== 'string' ||
2251
- !preResult.allowedDecisions.includes(
2487
+ !allowedDecisions.includes(
2252
2488
  declaredType as t.ToolApprovalDecisionType
2253
2489
  ))
2254
2490
  ) {
@@ -2256,7 +2492,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2256
2492
  this.blockDirectCall({
2257
2493
  call,
2258
2494
  resolvedArgs,
2259
- reason: `Decision "${typeof declaredType === 'string' ? declaredType : '<missing>'}" not in allowedDecisions [${preResult.allowedDecisions.join(', ')}] — failing closed`,
2495
+ reason: `Decision "${typeof declaredType === 'string' ? declaredType : '<missing>'}" not in allowedDecisions [${allowedDecisions.join(', ')}] — failing closed`,
2260
2496
  hookRegistry,
2261
2497
  runId,
2262
2498
  threadId,
@@ -2270,8 +2506,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2270
2506
  this.blockDirectCall({
2271
2507
  call,
2272
2508
  resolvedArgs,
2273
- reason:
2274
- decision.reason ?? preResult.reason ?? 'Rejected by user',
2509
+ reason: decision.reason ?? askReason ?? 'Rejected by user',
2275
2510
  hookRegistry,
2276
2511
  runId,
2277
2512
  threadId,
@@ -2546,12 +2781,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2546
2781
  call: ToolCall;
2547
2782
  resolvedArgs: Record<string, unknown>;
2548
2783
  reason: string;
2549
- hookRegistry: HookRegistry;
2784
+ hookRegistry: HookRegistry | undefined;
2550
2785
  runId: string;
2551
2786
  threadId: string | undefined;
2552
2787
  }): ToolMessage {
2553
2788
  const { call, resolvedArgs, reason, hookRegistry, runId, threadId } = args;
2554
- if (hookRegistry.hasHookFor('PermissionDenied', runId) === true) {
2789
+ if (hookRegistry?.hasHookFor('PermissionDenied', runId) === true) {
2555
2790
  executeHooks({
2556
2791
  registry: hookRegistry,
2557
2792
  input: {
@@ -2881,7 +3116,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2881
3116
  const signal = config.signal;
2882
3117
  if (
2883
3118
  signal?.aborted === true &&
2884
- (signal.reason instanceof StreamLimitExceededError || signal.reason instanceof PreparedSubagentError)
3119
+ (signal.reason instanceof StreamLimitExceededError ||
3120
+ signal.reason instanceof PreparedSubagentError)
2885
3121
  ) {
2886
3122
  throw signal.reason;
2887
3123
  }
@@ -2932,11 +3168,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2932
3168
  unresolvedByCallId.set(call.id, pre.unresolved);
2933
3169
  }
2934
3170
  } else if (registry != null) {
2935
- const { resolved, unresolved } = registry.resolve(
2936
- registryRunId,
2937
- originalArgs,
2938
- { substituteIntentKey: this.toolDeclaresBusinessIntent(call.name) }
2939
- );
3171
+ const options = { substituteIntentKey: this.toolDeclaresBusinessIntent(call.name) };
3172
+ const { resolved, unresolved } = preBatchSnapshot != null
3173
+ ? preBatchSnapshot.resolve(originalArgs, options)
3174
+ : registry.resolve(registryRunId, originalArgs, options);
2940
3175
  resolvedArgs = resolved as Record<string, unknown>;
2941
3176
  if (unresolved.length > 0 && call.id != null) {
2942
3177
  unresolvedByCallId.set(call.id, unresolved);
@@ -2984,6 +3219,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2984
3219
  config,
2985
3220
  runId
2986
3221
  );
3222
+ const approvalReviewEvidence = getToolApprovalReviewEvidence(config,
3223
+ getToolBatchReplayOwner(config, this.executingAgentId ?? this.agentId ?? ''));
2987
3224
  const hasPendingApproval = preToolCalls.some(
2988
3225
  (entry) =>
2989
3226
  entry.call.id != null &&
@@ -2997,8 +3234,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2997
3234
  ) != null
2998
3235
  );
2999
3236
  if (
3000
- hookRegistry != null &&
3001
- (hookRegistry.hasHookFor('PreToolUse', runId) || hasPendingApproval)
3237
+ hookRegistry?.hasHookFor('PreToolUse', runId) === true ||
3238
+ hasPendingApproval ||
3239
+ approvalReviewEvidence != null
3002
3240
  ) {
3003
3241
  /**
3004
3242
  * Pull each call's prestarted eager record BEFORE awaiting the hooks:
@@ -3026,38 +3264,41 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3026
3264
  }
3027
3265
  }
3028
3266
  }
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
- );
3267
+ const preResults =
3268
+ hookRegistry == null
3269
+ ? preToolCalls.map(() => HOOK_FALLBACK)
3270
+ : await Promise.all(
3271
+ preToolCalls.map((entry) => {
3272
+ const toolUseId = entry.call.id;
3273
+ const approvalReplayKey =
3274
+ toolUseId == null
3275
+ ? undefined
3276
+ : createToolApprovalReplayKey(
3277
+ config,
3278
+ this.executingAgentId ?? this.agentId ?? '',
3279
+ toolUseId
3280
+ );
3281
+ return executeHooks({
3282
+ registry: hookRegistry,
3283
+ input: {
3284
+ hook_event_name: 'PreToolUse',
3285
+ runId,
3286
+ threadId,
3287
+ agentId: this.agentId,
3288
+ executingAgentId: this.executingAgentId,
3289
+ toolName: entry.call.name,
3290
+ toolInput: entry.args,
3291
+ toolUseId: entry.call.id!,
3292
+ stepId: entry.stepId,
3293
+ turn: this.toolUsageCount.get(entry.call.name) ?? 0,
3294
+ },
3295
+ sessionId: runId,
3296
+ matchQuery: entry.call.name,
3297
+ onceReplayKey: approvalReplayKey,
3298
+ onceReplaySessionId: approvalReplaySessionId,
3299
+ }).catch((): AggregatedHookResult => HOOK_FALLBACK);
3300
+ })
3301
+ );
3061
3302
 
3062
3303
  type PendingEntry = (typeof preToolCalls)[number];
3063
3304
 
@@ -3138,7 +3379,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3138
3379
  item.contentString,
3139
3380
  config
3140
3381
  );
3141
- if (hookRegistry.hasHookFor('PermissionDenied', runId)) {
3382
+ if (hookRegistry?.hasHookFor('PermissionDenied', runId) === true) {
3142
3383
  executeHooks({
3143
3384
  registry: hookRegistry,
3144
3385
  input: {
@@ -3252,6 +3493,14 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3252
3493
  */
3253
3494
  if (hookResult.updatedInput != null) {
3254
3495
  applyInputOverride(entry, hookResult.updatedInput);
3496
+ } else {
3497
+ const reviewed = getReviewedToolApproval(approvalReviewEvidence?.payload, entry.call.id);
3498
+ if (reviewed != null) {
3499
+ entry.args = this.coerceEventToolArgs(entry.call.name, reviewed.request.arguments);
3500
+ if (entry.call.id != null) {
3501
+ unresolvedByCallId.delete(entry.call.id);
3502
+ }
3503
+ }
3255
3504
  }
3256
3505
  askEntries.push({
3257
3506
  entry,
@@ -3264,6 +3513,27 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3264
3513
  if (hookResult.updatedInput != null) {
3265
3514
  applyInputOverride(entry, hookResult.updatedInput);
3266
3515
  }
3516
+ const reviewedApproval = getReviewedToolApproval(
3517
+ approvalReviewEvidence?.payload,
3518
+ entry.call.id
3519
+ );
3520
+ if (reviewedApproval != null) {
3521
+ if (hookResult.updatedInput == null) {
3522
+ entry.args = this.coerceEventToolArgs(
3523
+ entry.call.name,
3524
+ reviewedApproval.request.arguments
3525
+ );
3526
+ if (entry.call.id != null) {
3527
+ unresolvedByCallId.delete(entry.call.id);
3528
+ }
3529
+ }
3530
+ askEntries.push({
3531
+ entry,
3532
+ reason: reviewedApproval.request.description,
3533
+ allowedDecisions: reviewedApproval.reviewConfig.allowed_decisions,
3534
+ });
3535
+ continue;
3536
+ }
3267
3537
  if (entry.call.id != null) {
3268
3538
  const preempted = preemptedEagerRecords.get(entry.call.id);
3269
3539
  if (preempted != null) {
@@ -3304,7 +3574,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3304
3574
  );
3305
3575
 
3306
3576
  for (const { entry } of askEntries) {
3307
- hookRegistry.clearPendingToolApproval(
3577
+ hookRegistry?.clearPendingToolApproval(
3308
3578
  approvalReplaySessionId,
3309
3579
  createToolApprovalReplayKey(
3310
3580
  config,
@@ -3314,8 +3584,20 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3314
3584
  );
3315
3585
  }
3316
3586
 
3587
+ const proposalMatches =
3588
+ approvalReviewEvidence == null ||
3589
+ toolApprovalPayloadMatches(payload, approvalReviewEvidence.payload);
3590
+ if (!proposalMatches) {
3591
+ for (const { entry } of askEntries) {
3592
+ blockEntry(
3593
+ entry,
3594
+ 'Reviewed tool proposal changed before resume; retry approval'
3595
+ );
3596
+ }
3597
+ }
3598
+ const decisionEntries = proposalMatches ? askEntries : [];
3317
3599
  const decisionByCallId = normalizeApprovalDecisions(
3318
- askEntries.map(({ entry }) => entry.call.id!),
3600
+ decisionEntries.map(({ entry }) => entry.call.id!),
3319
3601
  resumeValue
3320
3602
  );
3321
3603
 
@@ -3323,7 +3605,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3323
3605
  entry,
3324
3606
  reason: askReason,
3325
3607
  allowedDecisions,
3326
- } of askEntries) {
3608
+ } of decisionEntries) {
3327
3609
  const decision = decisionByCallId.get(entry.call.id!) ?? {
3328
3610
  type: 'reject' as const,
3329
3611
  reason: 'No decision provided for tool approval',
@@ -3688,8 +3970,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3688
3970
  // match it at the top level too.
3689
3971
  agentId: this.executingAgentId,
3690
3972
  callerCapabilityProjection:
3691
- this.getCallerCapabilityProjectionSnapshot(),
3692
- configurable: stripRunBreakerScope(
3973
+ this.getCallerCapabilityProjectionSnapshot(),
3974
+ configurable: stripHostExecutionConfig(
3693
3975
  config.configurable as Record<string, unknown> | undefined
3694
3976
  ),
3695
3977
  metadata: config.metadata as
@@ -4348,12 +4630,44 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4348
4630
  const settledBatchResults =
4349
4631
  baseContext.replayBatchKey == null
4350
4632
  ? undefined
4351
- : this.settledInterruptingResults.get(baseContext.replayBatchKey);
4633
+ : (this.settledDirectResultsByBatch.get(baseContext.replayBatchKey) ??
4634
+ new Map<string, SettledDirectToolResult>());
4635
+ const cacheResult = (
4636
+ call: ToolCall,
4637
+ position: number,
4638
+ result: SettledDirectToolResult
4639
+ ): void => {
4640
+ if (
4641
+ baseContext.replayBatchKey == null ||
4642
+ settledBatchResults == null
4643
+ ) {
4644
+ return;
4645
+ }
4646
+ settledBatchResults.set(directReplayKey(call, baseContext.replayCallPositions?.get(call) ?? batchIndices[position]), result);
4647
+ this.settledDirectResultsByBatch.set(
4648
+ baseContext.replayBatchKey,
4649
+ settledBatchResults
4650
+ );
4651
+ };
4352
4652
  const restoreResult = (
4353
4653
  call: ToolCall,
4354
4654
  result: SettledDirectToolResult
4355
4655
  ): SettledDirectToolResult => {
4356
4656
  baseContext.additionalContextsSink?.push(...result.additionalContexts);
4657
+ const turn = result.turn;
4658
+ if (turn != null) {
4659
+ this.recordToolUsageTurn(call.name, turn, call.id);
4660
+ }
4661
+ const refMeta = isBaseMessage(result.output) ? result.output.additional_kwargs as t.ToolMessageRefMetadata : undefined;
4662
+ if (refMeta?._refKey != null && result.referenceContent != null) {
4663
+ this.toolOutputRegistry?.set(baseContext.batchScopeId, refMeta._refKey, result.referenceContent);
4664
+ if (isBaseMessage(result.output)) {
4665
+ result = { ...result, output: new ToolMessage({
4666
+ ...(result.output as ToolMessage),
4667
+ additional_kwargs: { ...result.output.additional_kwargs, _refScope: baseContext.batchScopeId },
4668
+ }) };
4669
+ }
4670
+ }
4357
4671
  if (
4358
4672
  call.id != null &&
4359
4673
  result.resolvedArgs != null &&
@@ -4367,11 +4681,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4367
4681
  call: ToolCall,
4368
4682
  position: number
4369
4683
  ): Promise<SettledDirectToolResult> => {
4370
- const cachedResult =
4371
- typeof call.id === 'string'
4372
- ? settledBatchResults?.get(call.id)
4373
- : undefined;
4684
+ const cachedResult = settledBatchResults?.get(directReplayKey(call, baseContext.replayCallPositions?.get(call) ?? batchIndices[position]));
4374
4685
  if (cachedResult != null) {
4686
+ if (cachedResult.proposal.name !== call.name ||
4687
+ !recordArgsEqual(cachedResult.proposal.args, call.args as Record<string, unknown>)) {
4688
+ throw new Error('Cannot change an already completed tool call during approval replay');
4689
+ }
4375
4690
  return restoreResult(call, cachedResult);
4376
4691
  }
4377
4692
  const additionalContexts: string[] = [];
@@ -4384,11 +4699,19 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4384
4699
  call.id == null
4385
4700
  ? undefined
4386
4701
  : baseContext.resolvedArgsByCallId?.get(call.id);
4387
- return restoreResult(call, {
4702
+ const result: SettledDirectToolResult = {
4703
+ proposal: structuredClone({ name: call.name, args: call.args as Record<string, unknown> }),
4704
+ turn: call.id == null ? undefined : this.toolCallTurns.get(call.id) ?? this.directPathTurns.get(call.id),
4388
4705
  output,
4389
4706
  additionalContexts,
4390
4707
  ...(resolvedArgs == null ? {} : { resolvedArgs }),
4391
- });
4708
+ };
4709
+ const refMeta = isBaseMessage(output) ? output.additional_kwargs as t.ToolMessageRefMetadata : undefined;
4710
+ if (refMeta?._refKey != null) {
4711
+ result.referenceContent = this.toolOutputRegistry?.get(refMeta._refScope, refMeta._refKey);
4712
+ }
4713
+ cacheResult(call, position, result);
4714
+ return restoreResult(call, result);
4392
4715
  };
4393
4716
 
4394
4717
  const interrupting = this.interruptingToolNames;
@@ -4397,10 +4720,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4397
4720
  directCalls.some((call) => interrupting.has(call.name));
4398
4721
 
4399
4722
  if (!hasInterrupting) {
4400
- const results = await Promise.all(
4723
+ const results = await Promise.allSettled(
4401
4724
  directCalls.map((call, i) => runOne(call, i))
4402
4725
  );
4403
- return results.map((result) => result.output);
4726
+ this.throwIfBreakerTripped(config);
4727
+ const failed = results.find((result) => result.status === 'rejected' && !isGraphInterrupt(result.reason)) ??
4728
+ results.find((result) => result.status === 'rejected');
4729
+ if (failed?.status === 'rejected') {
4730
+ throw failed.reason;
4731
+ }
4732
+ return results.map((result) => {
4733
+ if (result.status === 'rejected') {
4734
+ throw result.reason;
4735
+ }
4736
+ return result.value.output;
4737
+ });
4404
4738
  }
4405
4739
 
4406
4740
  const outputs: (BaseMessage | Command)[] = new Array(directCalls.length);
@@ -4437,21 +4771,6 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4437
4771
  }
4438
4772
  const position = interruptingPositions[i];
4439
4773
  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
4774
  }
4456
4775
  if (hasInterruptingError) {
4457
4776
  if (isGraphInterrupt(interruptingError)) {
@@ -4549,7 +4868,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4549
4868
  }
4550
4869
 
4551
4870
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
4552
- protected async run(input: any, config: RunnableConfig): Promise<T> {
4871
+ protected async run(input: any, config: RunnableConfig, referenceReplay: { state?: ToolOutputReferenceState } = {}): Promise<T> {
4553
4872
  /**
4554
4873
  * Breaker read once at batch entry: every downstream path (direct
4555
4874
  * `tool.invoke` runtimes and the ON_TOOL_EXECUTE dispatch) inherits this
@@ -4581,7 +4900,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4581
4900
  * otherwise perform side effects on a failed run. */
4582
4901
  if (
4583
4902
  composedSignal?.aborted === true &&
4584
- (composedSignal.reason instanceof StreamLimitExceededError || composedSignal.reason instanceof PreparedSubagentError)
4903
+ (composedSignal.reason instanceof StreamLimitExceededError ||
4904
+ composedSignal.reason instanceof PreparedSubagentError)
4585
4905
  ) {
4586
4906
  throw composedSignal.reason;
4587
4907
  }
@@ -4606,11 +4926,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4606
4926
  * every subsequent registry call on this batch.
4607
4927
  */
4608
4928
  const incomingRunId = config.configurable?.run_id as string | undefined;
4609
- const incomingThreadId = config.configurable?.thread_id as
4610
- | string
4611
- | undefined;
4612
4929
  const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
4613
- const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
4930
+ const resumedReferenceState = referenceReplay.state;
4931
+ const replayInputSnapshot = resumedReferenceState == null ? undefined :
4932
+ this.toolOutputRegistry?.resumeBatch(batchScopeId, resumedReferenceState);
4933
+ referenceReplay.state = resumedReferenceState ?? this.toolOutputRegistry?.snapshotState(batchScopeId);
4934
+ const turn = resumedReferenceState?.turnCounter ?? this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
4614
4935
  let outputs: (BaseMessage | Command)[];
4615
4936
  let replayBatchKey: string | undefined;
4616
4937
  /** Hoisted from the messages-state branch so the Command tail can carry
@@ -4628,6 +4949,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4628
4949
  batchIndices: [0],
4629
4950
  turn,
4630
4951
  batchScopeId,
4952
+ preBatchSnapshot: replayInputSnapshot,
4631
4953
  });
4632
4954
  }
4633
4955
  // Same per-batch sink the message-state branches use so
@@ -4645,12 +4967,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4645
4967
  ? findAssistantBatch(sendMessages as BaseMessage[])
4646
4968
  : undefined;
4647
4969
  const sendReplayBatchKey =
4648
- sendAssistantBatch == null
4970
+ sendAssistantBatch == null || getToolBatchReplayScope(config) == null
4649
4971
  ? undefined
4650
4972
  : getAssistantBatchReplayKey(
4651
4973
  sendAssistantBatch,
4652
- incomingRunId,
4653
- incomingThreadId
4974
+ getToolBatchReplayScope(config)
4654
4975
  );
4655
4976
  const sendOutput = await this.runDirectToolWithLifecycleHooks(
4656
4977
  input.lg_tool_call,
@@ -4659,6 +4980,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4659
4980
  batchIndex: 0,
4660
4981
  turn,
4661
4982
  batchScopeId,
4983
+ preBatchSnapshot: replayInputSnapshot,
4662
4984
  resolvedArgsByCallId,
4663
4985
  errorOwnership,
4664
4986
  additionalContextsSink: directAdditionalContexts,
@@ -4713,10 +5035,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4713
5035
  throw new Error('ToolNode only accepts AIMessages as input.');
4714
5036
  }
4715
5037
  const aiMessage = assistantBatch.message;
4716
- replayBatchKey = getAssistantBatchReplayKey(
5038
+ replayBatchKey = getToolBatchReplayScope(config) == null ? undefined : getAssistantBatchReplayKey(
4717
5039
  assistantBatch,
4718
- incomingRunId,
4719
- incomingThreadId
5040
+ getToolBatchReplayScope(config)
4720
5041
  );
4721
5042
 
4722
5043
  if (this.loadRuntimeTools) {
@@ -4728,6 +5049,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4728
5049
  this.applyToolExecutionOverrides();
4729
5050
  }
4730
5051
 
5052
+ const replayCallPositions = new Map((aiMessage.tool_calls ?? []).map((call, i) => [call, i]));
4731
5053
  const filteredCalls =
4732
5054
  aiMessage.tool_calls?.filter((call) => {
4733
5055
  /**
@@ -4737,7 +5059,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4737
5059
  * which are executed by the provider's API and don't require invocation
4738
5060
  */
4739
5061
  return (
4740
- (call.id == null || !toolMessageIds.has(call.id)) &&
5062
+ (call.id == null || call.id === '' || !toolMessageIds.has(call.id)) &&
4741
5063
  !(
4742
5064
  call.id?.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX) ??
4743
5065
  false
@@ -4865,6 +5187,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4865
5187
  batchIndices: eventEntries.map((entry) => entry.batchIndex),
4866
5188
  turn,
4867
5189
  batchScopeId,
5190
+ preBatchSnapshot: replayInputSnapshot,
4868
5191
  });
4869
5192
  }
4870
5193
 
@@ -4872,6 +5195,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4872
5195
  const directIndices = directEntries.map((e) => e.batchIndex);
4873
5196
  const eventCalls = eventEntries.map((e) => e.call);
4874
5197
  const eventIndices = eventEntries.map((e) => e.batchIndex);
5198
+ const approvalScopeCallIds = new Set(
5199
+ filteredCalls.flatMap((call) => (call.id == null ? [] : [call.id]))
5200
+ );
4875
5201
 
4876
5202
  /**
4877
5203
  * Snapshot the event calls' args against the *pre-batch*
@@ -4898,7 +5224,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4898
5224
  * same-turn direct outputs.
4899
5225
  */
4900
5226
  const preBatchSnapshot =
4901
- this.toolOutputRegistry?.snapshot(batchScopeId);
5227
+ replayInputSnapshot ?? this.toolOutputRegistry?.snapshot(batchScopeId);
4902
5228
  if (preBatchSnapshot != null) {
4903
5229
  for (const entry of eventEntries) {
4904
5230
  if (entry.call.id != null) {
@@ -4937,19 +5263,40 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4937
5263
  preBatchSnapshot,
4938
5264
  additionalContextsSink: directAdditionalContexts,
4939
5265
  replayBatchKey,
5266
+ replayCallPositions,
5267
+ approvalScopeCallIds,
4940
5268
  runInput: input as T,
4941
5269
  }
4942
5270
  )
4943
5271
  : [];
4944
5272
 
4945
- if (directCalls.length > 0 && directOutputs.length > 0) {
5273
+ const settledDirectResults =
5274
+ replayBatchKey == null ? undefined : this.settledDirectResultsByBatch.get(replayBatchKey);
5275
+ const unhandledDirectCalls: ToolCall[] = [];
5276
+ const unhandledDirectOutputs: (BaseMessage | Command)[] = [];
5277
+ for (let i = 0; i < directCalls.length; i++) {
5278
+ const settled = settledDirectResults?.get(directReplayKey(directCalls[i], replayCallPositions.get(directCalls[i])!));
5279
+ if (settled?.completionHandled === true) {
5280
+ continue;
5281
+ }
5282
+ unhandledDirectCalls.push(directCalls[i]);
5283
+ unhandledDirectOutputs.push(directOutputs[i]);
5284
+ }
5285
+
5286
+ if (unhandledDirectCalls.length > 0) {
4946
5287
  await this.handleRunToolCompletions(
4947
- directCalls,
4948
- directOutputs,
5288
+ unhandledDirectCalls,
5289
+ unhandledDirectOutputs,
4949
5290
  config,
4950
5291
  resolvedArgsByCallId,
4951
5292
  errorOwnership
4952
5293
  );
5294
+ for (let i = 0; i < directCalls.length; i++) {
5295
+ const settled = settledDirectResults?.get(directReplayKey(directCalls[i], replayCallPositions.get(directCalls[i])!));
5296
+ if (settled != null) {
5297
+ settled.completionHandled = true;
5298
+ }
5299
+ }
4953
5300
  }
4954
5301
 
4955
5302
  const eventResult =
@@ -4998,8 +5345,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4998
5345
  // leak a sibling's just-registered output into a sister
4999
5346
  // call's args mid-await (Codex P1 #18).
5000
5347
  const preBatchSnapshot =
5001
- this.toolOutputRegistry?.snapshot(batchScopeId);
5348
+ replayInputSnapshot ?? this.toolOutputRegistry?.snapshot(batchScopeId);
5002
5349
  const directAdditionalContexts: string[] = [];
5350
+ const approvalScopeCallIds = new Set(
5351
+ filteredCalls.flatMap((call) => (call.id == null ? [] : [call.id]))
5352
+ );
5003
5353
  const toolOutputs = await this.runDirectBatchInterruptSafe(
5004
5354
  filteredCalls,
5005
5355
  filteredCalls.map((_call, i) => i),
@@ -5012,6 +5362,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
5012
5362
  preBatchSnapshot,
5013
5363
  additionalContextsSink: directAdditionalContexts,
5014
5364
  replayBatchKey,
5365
+ replayCallPositions,
5366
+ approvalScopeCallIds,
5015
5367
  runInput: input as T,
5016
5368
  }
5017
5369
  );
@@ -5071,7 +5423,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
5071
5423
 
5072
5424
  if (!outputs.some(isCommand)) {
5073
5425
  if (replayBatchKey != null) {
5074
- this.settledInterruptingResults.delete(replayBatchKey);
5426
+ this.settledDirectResultsByBatch.delete(replayBatchKey);
5075
5427
  }
5076
5428
  return (Array.isArray(input) ? outputs : { messages: outputs }) as T;
5077
5429
  }
@@ -5214,7 +5566,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
5214
5566
  }
5215
5567
 
5216
5568
  if (replayBatchKey != null) {
5217
- this.settledInterruptingResults.delete(replayBatchKey);
5569
+ this.settledDirectResultsByBatch.delete(replayBatchKey);
5218
5570
  }
5219
5571
  return combinedOutputs as T;
5220
5572
  }