@librechat/agents 3.9.6 → 3.9.8

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 (79) hide show
  1. package/dist/cjs/common/enum.cjs +1 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +4 -3
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/instrumentation.cjs +3 -3
  6. package/dist/cjs/langfuse.cjs +49 -5
  7. package/dist/cjs/langfuse.cjs.map +1 -1
  8. package/dist/cjs/langfuseRuntimeScope.cjs +1 -1
  9. package/dist/cjs/langfuseToolOutputTracing.cjs +2 -2
  10. package/dist/cjs/llm/invoke.cjs +1 -1
  11. package/dist/cjs/run.cjs +39 -15
  12. package/dist/cjs/run.cjs.map +1 -1
  13. package/dist/cjs/stream.cjs +7 -4
  14. package/dist/cjs/stream.cjs.map +1 -1
  15. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  16. package/dist/cjs/tools/ToolNode.cjs +46 -11
  17. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  18. package/dist/cjs/tools/handlers.cjs +2 -2
  19. package/dist/cjs/tools/handlers.cjs.map +1 -1
  20. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +5 -1
  21. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -1
  22. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +74 -6
  23. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  24. package/dist/cjs/types/hitl.cjs +4 -0
  25. package/dist/cjs/types/hitl.cjs.map +1 -1
  26. package/dist/cjs/utils/events.cjs +20 -2
  27. package/dist/cjs/utils/events.cjs.map +1 -1
  28. package/dist/esm/common/enum.mjs +1 -0
  29. package/dist/esm/common/enum.mjs.map +1 -1
  30. package/dist/esm/graphs/Graph.mjs +4 -3
  31. package/dist/esm/graphs/Graph.mjs.map +1 -1
  32. package/dist/esm/instrumentation.mjs +3 -3
  33. package/dist/esm/langfuse.mjs +50 -7
  34. package/dist/esm/langfuse.mjs.map +1 -1
  35. package/dist/esm/langfuseRuntimeScope.mjs +1 -1
  36. package/dist/esm/langfuseToolOutputTracing.mjs +2 -2
  37. package/dist/esm/llm/invoke.mjs +1 -1
  38. package/dist/esm/run.mjs +40 -16
  39. package/dist/esm/run.mjs.map +1 -1
  40. package/dist/esm/stream.mjs +11 -7
  41. package/dist/esm/stream.mjs.map +1 -1
  42. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  43. package/dist/esm/tools/ToolNode.mjs +50 -14
  44. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  45. package/dist/esm/tools/handlers.mjs +2 -2
  46. package/dist/esm/tools/handlers.mjs.map +1 -1
  47. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +5 -1
  48. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -1
  49. package/dist/esm/tools/subagent/SubagentExecutor.mjs +75 -7
  50. package/dist/esm/tools/subagent/SubagentExecutor.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/esm/utils/events.mjs +20 -3
  54. package/dist/esm/utils/events.mjs.map +1 -1
  55. package/dist/types/common/enum.d.ts +2 -0
  56. package/dist/types/graphs/Graph.d.ts +2 -2
  57. package/dist/types/langfuse.d.ts +19 -0
  58. package/dist/types/tools/ToolNode.d.ts +1 -0
  59. package/dist/types/tools/handlers.d.ts +2 -1
  60. package/dist/types/types/graph.d.ts +3 -3
  61. package/dist/types/types/hitl.d.ts +11 -0
  62. package/dist/types/types/stream.d.ts +24 -0
  63. package/dist/types/types/tools.d.ts +4 -0
  64. package/dist/types/utils/events.d.ts +4 -0
  65. package/package.json +1 -1
  66. package/src/common/enum.ts +2 -0
  67. package/src/graphs/Graph.ts +5 -2
  68. package/src/langfuse.ts +100 -3
  69. package/src/run.ts +41 -23
  70. package/src/stream.ts +12 -3
  71. package/src/tools/ToolNode.ts +108 -17
  72. package/src/tools/handlers.ts +4 -1
  73. package/src/tools/local/LocalProgrammaticToolCalling.ts +16 -4
  74. package/src/tools/subagent/SubagentExecutor.ts +129 -10
  75. package/src/types/graph.ts +3 -0
  76. package/src/types/hitl.ts +20 -0
  77. package/src/types/stream.ts +21 -0
  78. package/src/types/tools.ts +4 -0
  79. package/src/utils/events.ts +21 -2
package/src/langfuse.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { tool } from '@langchain/core/tools';
2
2
  import { CallbackHandler } from '@langfuse/langchain';
3
3
  import { LangfuseOtelContextKeys } from '@langfuse/core';
4
- import { AIMessage, AIMessageChunk } from '@langchain/core/messages';
4
+ import {
5
+ AIMessage,
6
+ HumanMessage,
7
+ AIMessageChunk,
8
+ } from '@langchain/core/messages';
5
9
  import { isGraphInterrupt, isParentCommand } from '@langchain/langgraph';
6
10
  import { context as otelContext, trace as otelTrace } from '@opentelemetry/api';
7
11
  import {
@@ -67,6 +71,13 @@ const GRAPH_INTERRUPT_TOOL_OUTPUT = JSON.stringify(
67
71
 
68
72
  export type LangfuseTraceMetadata = Record<string, string>;
69
73
  export type LangfuseTraceAttributes = Record<string, string | number | boolean>;
74
+
75
+ /**
76
+ * What a label call's Langfuse generation records in place of the prompt the
77
+ * model received and the text it returned, when the model saw evidence the
78
+ * tool-output redaction policy keeps out of traces.
79
+ */
80
+ export type LangfuseGenerationMask = { input: string; output: string };
70
81
  type LangfuseMetadata = NonNullable<t.LangfuseConfig['metadata']>;
71
82
  type LangfuseConfigTraceAttributes = NonNullable<
72
83
  t.LangfuseConfig['librechatTraceAttributes']
@@ -310,6 +321,7 @@ class ScopedLangfuseCallbackHandler extends CallbackHandler {
310
321
  private readonly identity: HandlerIdentity;
311
322
  private readonly toolOutputTracing?: ResolvedLangfuseToolOutputTracingConfig;
312
323
  private readonly trackedRunIds = new Set<string>();
324
+ private generationMask?: LangfuseGenerationMask;
313
325
  private deferredRootStarted = false;
314
326
  private deferredRootOutcome:
315
327
  | {
@@ -607,12 +619,56 @@ class ScopedLangfuseCallbackHandler extends CallbackHandler {
607
619
  ): ReturnType<CallbackHandler['handleChainEnd']> {
608
620
  const [output, runId, parentRunId] = args;
609
621
  if (runId === this.deferredRootRunId && parentRunId == null) {
610
- this.deferredRootOutcome = { type: 'end', output };
622
+ this.deferredRootOutcome = {
623
+ type: 'end',
624
+ output: this.maskOutputs(output),
625
+ };
611
626
  return Promise.resolve();
612
627
  }
628
+ args[0] = this.maskOutputs(output);
613
629
  return super.handleChainEnd(...args);
614
630
  }
615
631
 
632
+ /** Keeps usage and response metadata so cost tracking survives the mask. */
633
+ private maskGenerations(output: LLMResult): LLMResult {
634
+ const mask = this.generationMask;
635
+ if (mask == null) {
636
+ return output;
637
+ }
638
+ return {
639
+ ...output,
640
+ generations: output.generations.map((generations) =>
641
+ generations.map((generation) => {
642
+ if (!('message' in generation) || generation.message == null) {
643
+ return { ...generation, text: mask.output };
644
+ }
645
+ const message = generation.message as AIMessage;
646
+ return {
647
+ ...generation,
648
+ text: mask.output,
649
+ message: new AIMessage({
650
+ content: mask.output,
651
+ usage_metadata: message.usage_metadata,
652
+ response_metadata: message.response_metadata,
653
+ }),
654
+ };
655
+ })
656
+ ),
657
+ };
658
+ }
659
+
660
+ private maskOutputs(
661
+ output: Parameters<CallbackHandler['handleChainEnd']>[0]
662
+ ): Parameters<CallbackHandler['handleChainEnd']>[0] {
663
+ const mask = this.generationMask;
664
+ return mask == null ? output : { output: mask.output };
665
+ }
666
+
667
+ /** Records `mask` for every model call and chain result this handler traces. */
668
+ maskGeneration(mask: LangfuseGenerationMask): void {
669
+ this.generationMask = mask;
670
+ }
671
+
616
672
  async finishDeferredRoot(): Promise<void> {
617
673
  const runId = this.deferredRootRunId;
618
674
  const outcome = this.deferredRootOutcome;
@@ -650,6 +706,13 @@ class ScopedLangfuseCallbackHandler extends CallbackHandler {
650
706
  override handleChatModelStart(
651
707
  ...args: Parameters<CallbackHandler['handleChatModelStart']>
652
708
  ): ReturnType<CallbackHandler['handleChatModelStart']> {
709
+ const mask = this.generationMask;
710
+ if (mask != null) {
711
+ args[1] = args[1].map((messages) => [
712
+ ...messages.filter((message) => message.getType() === 'system'),
713
+ new HumanMessage(mask.input),
714
+ ]);
715
+ }
653
716
  return this.withRuntimeContext(
654
717
  () => super.handleChatModelStart(...args),
655
718
  this.startsDetachedRun(args[2], args[3]),
@@ -660,6 +723,10 @@ class ScopedLangfuseCallbackHandler extends CallbackHandler {
660
723
  override handleLLMStart(
661
724
  ...args: Parameters<CallbackHandler['handleLLMStart']>
662
725
  ): ReturnType<CallbackHandler['handleLLMStart']> {
726
+ const mask = this.generationMask;
727
+ if (mask != null) {
728
+ args[1] = args[1].map(() => mask.input);
729
+ }
663
730
  return this.withRuntimeContext(
664
731
  () => super.handleLLMStart(...args),
665
732
  this.startsDetachedRun(args[2], args[3]),
@@ -673,7 +740,7 @@ class ScopedLangfuseCallbackHandler extends CallbackHandler {
673
740
  parentRunId?: string
674
741
  ): Promise<void> {
675
742
  return super.handleLLMEnd(
676
- normalizeBedrockUsageForLangfuse(output),
743
+ this.maskGenerations(normalizeBedrockUsageForLangfuse(output)),
677
744
  runId,
678
745
  parentRunId
679
746
  );
@@ -937,6 +1004,36 @@ export function createLegacyLangfuseHandler(
937
1004
  return new ScopedLangfuseCallbackHandler(params);
938
1005
  }
939
1006
 
1007
+ /**
1008
+ * Label calls send the model every piece of evidence. When the redacted copy
1009
+ * differs, the trace records that copy and hides the reply, which may restate
1010
+ * what was redacted. A no-op for any callback other than a label handler.
1011
+ */
1012
+ export function maskRedactedLabelGeneration(
1013
+ handler: unknown,
1014
+ {
1015
+ modelPrompt,
1016
+ tracedPrompt,
1017
+ redaction,
1018
+ }: {
1019
+ modelPrompt: string;
1020
+ tracedPrompt: string;
1021
+ redaction?: ResolvedLangfuseToolOutputTracingConfig;
1022
+ }
1023
+ ): void {
1024
+ if (
1025
+ !(handler instanceof ScopedLangfuseCallbackHandler) ||
1026
+ redaction == null ||
1027
+ tracedPrompt === modelPrompt
1028
+ ) {
1029
+ return;
1030
+ }
1031
+ handler.maskGeneration({
1032
+ input: tracedPrompt === '' ? redaction.redactionText : tracedPrompt,
1033
+ output: redaction.redactionText,
1034
+ });
1035
+ }
1036
+
940
1037
  export function createLangfuseHandler({
941
1038
  langfuse,
942
1039
  userId,
package/src/run.ts CHANGED
@@ -37,21 +37,22 @@ import {
37
37
  DEFAULT_MAX_STOP_CONTINUATIONS,
38
38
  DEFAULT_RECURSION_LIMIT,
39
39
  } from '@/common';
40
- import {
41
- ACTIVITY_PHASE_LABEL_PROMPT,
42
- ACTIVITY_LABEL_PROMPT,
43
- buildActivityLabelPrompt,
44
- buildActivityPhaseLabelPrompt,
45
- normalizeActivityPhaseLabel,
46
- } from '@/prompts/activityLabel';
47
40
  import {
48
41
  createLangfuseTraceMetadata,
49
42
  createLangfuseHandler,
43
+ maskRedactedLabelGeneration,
50
44
  disposeLangfuseHandler,
51
45
  getLangfuseTraceName,
52
46
  isLangfuseCallbackHandler,
53
47
  withLangfuseAttributes,
54
48
  } from '@/langfuse';
49
+ import {
50
+ ACTIVITY_PHASE_LABEL_PROMPT,
51
+ ACTIVITY_LABEL_PROMPT,
52
+ buildActivityLabelPrompt,
53
+ buildActivityPhaseLabelPrompt,
54
+ normalizeActivityPhaseLabel,
55
+ } from '@/prompts/activityLabel';
55
56
  import {
56
57
  requireValidSubagentResumeManifest,
57
58
  SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY,
@@ -203,6 +204,7 @@ const CUSTOM_GRAPH_EVENTS = new Set<string>([
203
204
  GraphEvents.ON_RUN_STEP_DELTA,
204
205
  GraphEvents.ON_RUN_STEP_COMPLETED,
205
206
  GraphEvents.ON_RUN_STEP_CLOSED,
207
+ GraphEvents.ON_TOOL_CALLS_DISPATCHED,
206
208
  GraphEvents.ON_MESSAGE_DELTA,
207
209
  GraphEvents.ON_REASONING_DELTA,
208
210
  GraphEvents.ON_TOOL_EXECUTE,
@@ -1094,6 +1096,7 @@ export class Run<_T extends t.BaseGraphState> {
1094
1096
  | t.RunStep
1095
1097
  | t.RunStepDeltaEvent
1096
1098
  | t.RunStepClosedEvent
1099
+ | t.ToolCallsDispatchedEvent
1097
1100
  | t.MessageDeltaEvent
1098
1101
  | t.ReasoningDeltaEvent
1099
1102
  | { result: t.ToolEndEvent },
@@ -2663,22 +2666,21 @@ export class Run<_T extends t.BaseGraphState> {
2663
2666
  };
2664
2667
  }
2665
2668
  }
2666
- /** An active redaction policy suppresses free-form reasoning/intent, so
2667
- * a reasoning-only block has nothing describable left — skip the model
2668
- * call rather than paying for a label built from the prompt alone. */
2669
- const freeFormSuppressed =
2670
- redaction != null &&
2671
- (redaction.enabled === false || redaction.redactedToolNames.size > 0);
2672
- if (entries.length === 0 && freeFormSuppressed) {
2673
- return {};
2674
- }
2675
- const userPrompt = buildActivityLabelPrompt({
2669
+ const labelEvidence = {
2676
2670
  entries,
2677
2671
  charLimit,
2678
2672
  thinkingExcerpts,
2679
2673
  lastAssistantText:
2680
2674
  lastAssistantPhase === 'final_answer' ? undefined : lastAssistantText,
2681
2675
  previousLabels,
2676
+ };
2677
+ const userPrompt = buildActivityLabelPrompt(labelEvidence);
2678
+ maskRedactedLabelGeneration(labelLangfuseHandler, {
2679
+ modelPrompt: userPrompt,
2680
+ tracedPrompt:
2681
+ redaction == null
2682
+ ? userPrompt
2683
+ : buildActivityLabelPrompt({ ...labelEvidence, redaction }),
2682
2684
  redaction,
2683
2685
  });
2684
2686
 
@@ -2983,17 +2985,25 @@ export class Run<_T extends t.BaseGraphState> {
2983
2985
  reasoningContext?.langfuse
2984
2986
  )
2985
2987
  : undefined;
2986
- const userPrompt = buildReasoningLabelPrompt({
2988
+ const reasoningEvidence = {
2987
2989
  visibleReasoning,
2988
2990
  status,
2989
2991
  charLimit,
2990
2992
  previousLabel,
2991
- redaction,
2992
- });
2993
+ };
2994
+ const userPrompt = buildReasoningLabelPrompt(reasoningEvidence);
2993
2995
  if (userPrompt === '') {
2994
2996
  await disposeLangfuseHandler(reasoningLangfuseHandler);
2995
2997
  return {};
2996
2998
  }
2999
+ maskRedactedLabelGeneration(reasoningLangfuseHandler, {
3000
+ modelPrompt: userPrompt,
3001
+ tracedPrompt:
3002
+ redaction == null
3003
+ ? userPrompt
3004
+ : buildReasoningLabelPrompt({ ...reasoningEvidence, redaction }),
3005
+ redaction,
3006
+ });
2997
3007
 
2998
3008
  const model = initializeModel({
2999
3009
  provider,
@@ -3180,13 +3190,13 @@ export class Run<_T extends t.BaseGraphState> {
3180
3190
  };
3181
3191
  }
3182
3192
 
3183
- const userPrompt = buildActivityPhaseLabelPrompt({
3193
+ const phaseEvidence = {
3184
3194
  activities,
3185
3195
  totalActivityCount,
3186
3196
  charLimit,
3187
3197
  assistantContext,
3188
- redaction,
3189
- });
3198
+ };
3199
+ const userPrompt = buildActivityPhaseLabelPrompt(phaseEvidence);
3190
3200
  if (userPrompt === '') {
3191
3201
  return {};
3192
3202
  }
@@ -3305,6 +3315,14 @@ export class Run<_T extends t.BaseGraphState> {
3305
3315
  traceName: phaseParentSpanContext == null ? phaseTraceName : undefined,
3306
3316
  });
3307
3317
  }
3318
+ maskRedactedLabelGeneration(phaseLangfuseHandler, {
3319
+ modelPrompt: userPrompt,
3320
+ tracedPrompt:
3321
+ redaction == null
3322
+ ? userPrompt
3323
+ : buildActivityPhaseLabelPrompt({ ...phaseEvidence, redaction }),
3324
+ redaction,
3325
+ });
3308
3326
  if (phaseLangfuseHandler != null) {
3309
3327
  phaseChainOptions.callbacks = appendCallbacks(
3310
3328
  phaseChainOptions.callbacks,
package/src/stream.ts CHANGED
@@ -55,13 +55,13 @@ import {
55
55
  calculateMaxToolResultChars,
56
56
  truncateToolResultContent,
57
57
  } from '@/utils/truncation';
58
+ import { createToolCallsDispatchedEvent, safeDispatchCustomEvent } from '@/utils/events';
58
59
  import { resolveToolOutcome, outcomeFieldsFromResult } from '@/tools/intentArg';
59
- import { formatToolErrorContent } from '@/tools/toolErrorContent';
60
60
  import { snapshotValidatedModelChunk } from '@/graphs/acceptedModelResponse';
61
61
  import { TOOL_OUTPUT_REF_PATTERN } from '@/tools/toolOutputReferences';
62
+ import { formatToolErrorContent } from '@/tools/toolErrorContent';
62
63
  import { PreparedSubagentError } from '@/tools/preparedSubagents';
63
64
  import { isReasoningContentBlock } from '@/messages/core';
64
- import { safeDispatchCustomEvent } from '@/utils/events';
65
65
  import { composeAbortSignals } from '@/utils/misc';
66
66
  import { isGoogleLike } from '@/utils/llm';
67
67
  import { getMessageId } from '@/messages';
@@ -871,6 +871,13 @@ function startEagerToolExecutions(args: {
871
871
  reject,
872
872
  };
873
873
 
874
+ if (graph.config != null) {
875
+ void safeDispatchCustomEvent(
876
+ GraphEvents.ON_TOOL_CALLS_DISPATCHED,
877
+ createToolCallsDispatchedEvent(graph.config, batchRequest.toolCalls),
878
+ graph.config
879
+ );
880
+ }
874
881
  void safeDispatchCustomEvent(
875
882
  GraphEvents.ON_TOOL_EXECUTE,
876
883
  batchRequest,
@@ -980,7 +987,7 @@ async function dispatchEagerToolCompletions(args: {
980
987
  progress: 1,
981
988
  ...(outcome != null && { outcome }),
982
989
  } as t.ProcessedToolCall,
983
- completed_at: Date.now(),
990
+ completed_at: result.received_at ?? Date.now(),
984
991
  },
985
992
  },
986
993
  graph.config
@@ -1701,6 +1708,7 @@ export class ChatModelStreamHandler implements t.EventHandler {
1701
1708
  // Callback delivery can beat the producer's iterator. Validate before
1702
1709
  // accounting, run steps, or eager dispatch reads raw tool descriptors.
1703
1710
  chunk = snapshotValidatedModelChunk(chunk as AIMessageChunk);
1711
+ const observedAt = Date.now();
1704
1712
 
1705
1713
  /**
1706
1714
  * Enforced before every content-specific early return below
@@ -1923,6 +1931,7 @@ export class ChatModelStreamHandler implements t.EventHandler {
1923
1931
  stepKey,
1924
1932
  toolCallChunks: chunk.tool_call_chunks,
1925
1933
  metadata,
1934
+ observedAt,
1926
1935
  });
1927
1936
  if (canStreamEager) {
1928
1937
  if (runScopeInvalidated()) {
@@ -129,10 +129,11 @@ import {
129
129
  resolveLocalExecutionTools,
130
130
  } from '@/tools/local';
131
131
  import { stripCodeSessionFileSummary } from '@/tools/CodeSessionFileSummary';
132
- import { formatToolErrorContent } from '@/tools/toolErrorContent';
133
132
  import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
133
+ import { formatToolErrorContent } from '@/tools/toolErrorContent';
134
134
  import { PreparedSubagentError } from '@/tools/preparedSubagents';
135
135
  import { attachRunStepResumeState } from '@/tools/runStepResume';
136
+ import { isBackgroundDenyMode } from '@/types/hitl';
136
137
 
137
138
  function stripToolApprovalReviewConfig(
138
139
  configurable: Record<string, unknown> | undefined
@@ -169,7 +170,7 @@ function stripHostExecutionConfig(
169
170
  }
170
171
  import { convertInjectedMessages } from '@/messages/injected';
171
172
  import { stampSyntheticProviderMessage } from '@/messages/provenance';
172
- import { safeDispatchCustomEvent } from '@/utils/events';
173
+ import { createToolCallsDispatchedEvent, safeDispatchCustomEvent } from '@/utils/events';
173
174
  import { RunnableCallable, composeAbortSignals } from '@/utils';
174
175
  import {
175
176
  executeHooks,
@@ -1671,6 +1672,15 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1671
1672
  };
1672
1673
  this.throwIfBreakerTripped(config);
1673
1674
  config.signal?.throwIfAborted();
1675
+ if (call.id != null && call.id !== '') {
1676
+ void safeDispatchCustomEvent(
1677
+ GraphEvents.ON_TOOL_CALLS_DISPATCHED,
1678
+ createToolCallsDispatchedEvent(config, [
1679
+ { id: call.id, name: call.name, stepId: this.toolCallStepIds?.get(call.id) },
1680
+ ]),
1681
+ config
1682
+ );
1683
+ }
1674
1684
  return tool.invoke(invokeParams, runtime);
1675
1685
  }
1676
1686
 
@@ -1949,6 +1959,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1949
1959
  // they dispatch — including HITL gates on `write_file` / `edit_file`.
1950
1960
  hookContext: {
1951
1961
  registry: this.hookRegistry,
1962
+ failClosedOnHookError:
1963
+ isBackgroundDenyMode(this.humanInTheLoop),
1952
1964
  runId: (config.configurable?.run_id as string | undefined) ?? '',
1953
1965
  threadId: config.configurable?.thread_id as string | undefined,
1954
1966
  agentId: this.agentId,
@@ -2541,11 +2553,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2541
2553
  onceReplayKey: approvalReplayKey,
2542
2554
  onceReplaySessionId: approvalReplaySessionId,
2543
2555
  }).catch((): AggregatedHookResult | undefined =>
2544
- approvalReviewEvidence == null
2556
+ approvalReviewEvidence == null &&
2557
+ !isBackgroundDenyMode(this.humanInTheLoop)
2545
2558
  ? undefined
2546
2559
  : {
2547
2560
  decision: 'deny',
2548
- reason: 'Approval policy could not be evaluated on resume',
2561
+ reason:
2562
+ isBackgroundDenyMode(this.humanInTheLoop)
2563
+ ? 'Approval policy could not be evaluated'
2564
+ : 'Approval policy could not be evaluated on resume',
2565
+ ...(isBackgroundDenyMode(this.humanInTheLoop)
2566
+ ? { hasHookFailures: true }
2567
+ : {}),
2549
2568
  additionalContexts: [],
2550
2569
  injectedMessages: [],
2551
2570
  errors: [],
@@ -2582,6 +2601,26 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2582
2601
  };
2583
2602
  }
2584
2603
 
2604
+ if (
2605
+ preResult.hasHookFailures === true &&
2606
+ isBackgroundDenyMode(this.humanInTheLoop)
2607
+ ) {
2608
+ return persistOutput(
2609
+ this.blockDirectCall({
2610
+ call,
2611
+ resolvedArgs,
2612
+ reason: this.backgroundApprovalReason(
2613
+ call.name,
2614
+ 'Approval policy could not be evaluated'
2615
+ ),
2616
+ hookRegistry,
2617
+ runId,
2618
+ threadId,
2619
+ }),
2620
+ effectiveCall.args as Record<string, unknown>
2621
+ );
2622
+ }
2623
+
2585
2624
  if (preResult.decision === 'deny') {
2586
2625
  return persistOutput(
2587
2626
  this.blockDirectCall({
@@ -2621,12 +2660,14 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2621
2660
 
2622
2661
  if (preResult.decision === 'ask' || reviewedApproval != null) {
2623
2662
  if (this.humanInTheLoop?.enabled !== true) {
2624
- // Fail-closed: no HITL UI configured, so we can't actually
2625
- // ask. Logged once via the existing helper.
2626
- const reason = this.resolveAskDecisionForDirectTool(
2627
- preResult.reason,
2628
- call.name
2629
- );
2663
+ // Fail closed when there is no foreground approval channel.
2664
+ const reason =
2665
+ isBackgroundDenyMode(this.humanInTheLoop)
2666
+ ? this.backgroundApprovalReason(call.name, preResult.reason)
2667
+ : this.resolveAskDecisionForDirectTool(
2668
+ preResult.reason,
2669
+ call.name
2670
+ );
2630
2671
  return persistOutput(
2631
2672
  this.blockDirectCall({
2632
2673
  call,
@@ -3008,6 +3049,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3008
3049
  * LangGraph `interrupt()` instead — see `runDirectToolWithLifecycleHooks`.
3009
3050
  */
3010
3051
  private askDirectWarningEmitted = false;
3052
+ private backgroundApprovalReason(toolName: string, reason?: string): string {
3053
+ return (
3054
+ `Approval required for "${toolName}"; unavailable in a background subagent. ` +
3055
+ `Ask the parent to run it in the foreground.${reason == null ? '' : ` Reason: ${reason}`}`
3056
+ );
3057
+ }
3058
+
3011
3059
  private resolveAskDecisionForDirectTool(
3012
3060
  reason: string | undefined,
3013
3061
  toolName: string
@@ -3626,7 +3674,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3626
3674
  matchQuery: entry.call.name,
3627
3675
  onceReplayKey: approvalReplayKey,
3628
3676
  onceReplaySessionId: approvalReplaySessionId,
3629
- }).catch((): AggregatedHookResult => HOOK_FALLBACK);
3677
+ }).catch((): AggregatedHookResult =>
3678
+ isBackgroundDenyMode(this.humanInTheLoop)
3679
+ ? { ...HOOK_FALLBACK, hasHookFailures: true }
3680
+ : HOOK_FALLBACK
3681
+ );
3630
3682
  })
3631
3683
  );
3632
3684
 
@@ -3793,6 +3845,20 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3793
3845
  batchAdditionalContexts.push(ctx);
3794
3846
  }
3795
3847
 
3848
+ if (
3849
+ hookResult.hasHookFailures === true &&
3850
+ isBackgroundDenyMode(this.humanInTheLoop)
3851
+ ) {
3852
+ blockEntry(
3853
+ entry,
3854
+ this.backgroundApprovalReason(
3855
+ entry.call.name,
3856
+ 'Approval policy could not be evaluated'
3857
+ )
3858
+ );
3859
+ continue;
3860
+ }
3861
+
3796
3862
  if (hookResult.decision === 'deny') {
3797
3863
  blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
3798
3864
  continue;
@@ -3810,7 +3876,15 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3810
3876
  * JSDoc for the full rationale and the migration plan.
3811
3877
  */
3812
3878
  if (this.humanInTheLoop?.enabled !== true) {
3813
- blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
3879
+ blockEntry(
3880
+ entry,
3881
+ isBackgroundDenyMode(this.humanInTheLoop)
3882
+ ? this.backgroundApprovalReason(
3883
+ entry.call.name,
3884
+ hookResult.reason
3885
+ )
3886
+ : (hookResult.reason ?? 'Blocked by hook')
3887
+ );
3814
3888
  continue;
3815
3889
  }
3816
3890
  /**
@@ -4269,7 +4343,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4269
4343
  const dispatchRequestById = new Map(
4270
4344
  dispatchRequests.map((request) => [request.id, request])
4271
4345
  );
4346
+ const receivedAtById = new Map<string, number>();
4272
4347
  const onResult = (result: t.ToolExecuteResult): void => {
4348
+ const receivedAt = Date.now();
4273
4349
  /** Runtime-honest widening: hosts may omit the id despite the
4274
4350
  * field type. */
4275
4351
  const resultToolCallId = result.toolCallId as string | undefined;
@@ -4284,8 +4360,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4284
4360
  return;
4285
4361
  }
4286
4362
  earlyCompletionDispatchedIds.add(result.toolCallId);
4363
+ receivedAtById.set(result.toolCallId, receivedAt);
4287
4364
  earlyCompletionDispatches.push(
4288
- this.dispatchEarlyToolCompletion(result, request, config).then(
4365
+ this.dispatchEarlyToolCompletion({ ...result, received_at: receivedAt }, request, config).then(
4289
4366
  (dispatched) => {
4290
4367
  if (!dispatched) {
4291
4368
  earlyCompletionDispatchedIds.delete(result.toolCallId);
@@ -4337,6 +4414,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4337
4414
  | undefined,
4338
4415
  signal: config.signal,
4339
4416
  resolve: (results): void => {
4417
+ const receivedAt = Date.now();
4418
+ for (const result of results) {
4419
+ if (dispatchRequestById.has(result.toolCallId)) {
4420
+ receivedAtById.set(result.toolCallId, result.received_at ?? receivedAt);
4421
+ }
4422
+ }
4340
4423
  resultSettled = true;
4341
4424
  settledResults = results;
4342
4425
  maybeResolve();
@@ -4345,6 +4428,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4345
4428
  ...(canEmitEarlyCompletions && { onResult }),
4346
4429
  };
4347
4430
 
4431
+ void safeDispatchCustomEvent(
4432
+ GraphEvents.ON_TOOL_CALLS_DISPATCHED,
4433
+ createToolCallsDispatchedEvent(config, dispatchRequests),
4434
+ config
4435
+ );
4348
4436
  void safeDispatchCustomEvent(
4349
4437
  GraphEvents.ON_TOOL_EXECUTE,
4350
4438
  batchRequest,
@@ -4630,7 +4718,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4630
4718
  request?.turn,
4631
4719
  resolveToolOutcome(request?.args, outcomeFieldsFromResult(result), {
4632
4720
  isError: result.status === 'error',
4633
- })
4721
+ }),
4722
+ receivedAtById.get(result.toolCallId) ?? result.received_at
4634
4723
  );
4635
4724
  }
4636
4725
 
@@ -4932,7 +5021,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4932
5021
  output: string,
4933
5022
  config: RunnableConfig,
4934
5023
  turn?: number,
4935
- outcome?: string
5024
+ outcome?: string,
5025
+ receivedAt?: number
4936
5026
  ): Promise<boolean> {
4937
5027
  const stepId = this.toolCallStepIds?.get(toolCallId) ?? '';
4938
5028
  if (!stepId) {
@@ -4959,7 +5049,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4959
5049
  progress: 1,
4960
5050
  ...(outcome != null && { outcome }),
4961
5051
  } as t.ProcessedToolCall,
4962
- completed_at: Date.now(),
5052
+ completed_at: receivedAt ?? Date.now(),
4963
5053
  },
4964
5054
  },
4965
5055
  config
@@ -4999,7 +5089,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
4999
5089
  request.turn,
5000
5090
  resolveToolOutcome(request.args, outcomeFieldsFromResult(result), {
5001
5091
  isError: result.status === 'error',
5002
- })
5092
+ }),
5093
+ result.received_at
5003
5094
  );
5004
5095
  }
5005
5096
 
@@ -26,11 +26,13 @@ export async function handleToolCallChunks({
26
26
  stepKey,
27
27
  toolCallChunks,
28
28
  metadata,
29
+ observedAt = Date.now(),
29
30
  }: {
30
31
  graph: StandardGraph | MultiAgentGraph;
31
32
  stepKey: string;
32
33
  toolCallChunks: ToolCallChunk[];
33
34
  metadata?: Record<string, unknown>;
35
+ observedAt?: number;
34
36
  }): Promise<void> {
35
37
  let prevStepId: string;
36
38
  let prevRunStep: t.RunStep | undefined;
@@ -124,7 +126,8 @@ export async function handleToolCallChunks({
124
126
  type: StepTypes.TOOL_CALLS,
125
127
  tool_calls: toolCallChunks,
126
128
  },
127
- metadata
129
+ metadata,
130
+ observedAt
128
131
  );
129
132
  }
130
133
 
@@ -225,6 +225,15 @@ export async function applyPreToolUseHooksForBridge(
225
225
  sessionId: hookContext.runId,
226
226
  matchQuery: toolName,
227
227
  }).catch(() => undefined);
228
+ if (
229
+ hookContext.failClosedOnHookError === true &&
230
+ (result == null || result.hasHookFailures === true)
231
+ ) {
232
+ return {
233
+ input: toolInput,
234
+ denyReason: `Approval policy could not be evaluated for "${toolName}" in a background subagent. Ask the parent to run it in the foreground.`,
235
+ };
236
+ }
228
237
  if (result == null) {
229
238
  return { input: toolInput };
230
239
  }
@@ -236,10 +245,13 @@ export async function applyPreToolUseHooksForBridge(
236
245
  return {
237
246
  input: nextInput,
238
247
  denyReason:
239
- result.reason ??
240
- (result.decision === 'ask'
241
- ? `Tool "${toolName}" requires human approval; bridge cannot raise an interrupt — denying.`
242
- : `Tool "${toolName}" denied by PreToolUse hook.`),
248
+ result.decision === 'ask' &&
249
+ hookContext.failClosedOnHookError === true
250
+ ? `Approval required for "${toolName}"; unavailable in a background subagent. Ask the parent to run it in the foreground.${result.reason == null ? '' : ` Reason: ${result.reason}`}`
251
+ : (result.reason ??
252
+ (result.decision === 'ask'
253
+ ? `Tool "${toolName}" requires human approval; bridge cannot raise an interrupt — denying.`
254
+ : `Tool "${toolName}" denied by PreToolUse hook.`)),
243
255
  };
244
256
  }
245
257
  return { input: nextInput };