@librechat/agents 3.9.6 → 3.9.7

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 (36) hide show
  1. package/dist/cjs/langfuse.cjs +46 -2
  2. package/dist/cjs/langfuse.cjs.map +1 -1
  3. package/dist/cjs/run.cjs +35 -12
  4. package/dist/cjs/run.cjs.map +1 -1
  5. package/dist/cjs/tools/ToolNode.cjs +26 -5
  6. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  7. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +5 -1
  8. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -1
  9. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +52 -6
  10. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  11. package/dist/cjs/types/hitl.cjs +4 -0
  12. package/dist/cjs/types/hitl.cjs.map +1 -1
  13. package/dist/esm/langfuse.mjs +47 -4
  14. package/dist/esm/langfuse.mjs.map +1 -1
  15. package/dist/esm/run.mjs +36 -13
  16. package/dist/esm/run.mjs.map +1 -1
  17. package/dist/esm/tools/ToolNode.mjs +26 -5
  18. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  19. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +5 -1
  20. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -1
  21. package/dist/esm/tools/subagent/SubagentExecutor.mjs +53 -7
  22. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  23. package/dist/esm/types/hitl.mjs +4 -1
  24. package/dist/esm/types/hitl.mjs.map +1 -1
  25. package/dist/types/langfuse.d.ts +19 -0
  26. package/dist/types/tools/ToolNode.d.ts +1 -0
  27. package/dist/types/types/hitl.d.ts +11 -0
  28. package/dist/types/types/tools.d.ts +2 -0
  29. package/package.json +1 -1
  30. package/src/langfuse.ts +100 -3
  31. package/src/run.ts +32 -16
  32. package/src/tools/ToolNode.ts +76 -11
  33. package/src/tools/local/LocalProgrammaticToolCalling.ts +16 -4
  34. package/src/tools/subagent/SubagentExecutor.ts +105 -10
  35. package/src/types/hitl.ts +20 -0
  36. package/src/types/tools.ts +2 -0
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
@@ -47,6 +47,7 @@ import {
47
47
  import {
48
48
  createLangfuseTraceMetadata,
49
49
  createLangfuseHandler,
50
+ maskRedactedLabelGeneration,
50
51
  disposeLangfuseHandler,
51
52
  getLangfuseTraceName,
52
53
  isLangfuseCallbackHandler,
@@ -2663,22 +2664,21 @@ export class Run<_T extends t.BaseGraphState> {
2663
2664
  };
2664
2665
  }
2665
2666
  }
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({
2667
+ const labelEvidence = {
2676
2668
  entries,
2677
2669
  charLimit,
2678
2670
  thinkingExcerpts,
2679
2671
  lastAssistantText:
2680
2672
  lastAssistantPhase === 'final_answer' ? undefined : lastAssistantText,
2681
2673
  previousLabels,
2674
+ };
2675
+ const userPrompt = buildActivityLabelPrompt(labelEvidence);
2676
+ maskRedactedLabelGeneration(labelLangfuseHandler, {
2677
+ modelPrompt: userPrompt,
2678
+ tracedPrompt:
2679
+ redaction == null
2680
+ ? userPrompt
2681
+ : buildActivityLabelPrompt({ ...labelEvidence, redaction }),
2682
2682
  redaction,
2683
2683
  });
2684
2684
 
@@ -2983,17 +2983,25 @@ export class Run<_T extends t.BaseGraphState> {
2983
2983
  reasoningContext?.langfuse
2984
2984
  )
2985
2985
  : undefined;
2986
- const userPrompt = buildReasoningLabelPrompt({
2986
+ const reasoningEvidence = {
2987
2987
  visibleReasoning,
2988
2988
  status,
2989
2989
  charLimit,
2990
2990
  previousLabel,
2991
- redaction,
2992
- });
2991
+ };
2992
+ const userPrompt = buildReasoningLabelPrompt(reasoningEvidence);
2993
2993
  if (userPrompt === '') {
2994
2994
  await disposeLangfuseHandler(reasoningLangfuseHandler);
2995
2995
  return {};
2996
2996
  }
2997
+ maskRedactedLabelGeneration(reasoningLangfuseHandler, {
2998
+ modelPrompt: userPrompt,
2999
+ tracedPrompt:
3000
+ redaction == null
3001
+ ? userPrompt
3002
+ : buildReasoningLabelPrompt({ ...reasoningEvidence, redaction }),
3003
+ redaction,
3004
+ });
2997
3005
 
2998
3006
  const model = initializeModel({
2999
3007
  provider,
@@ -3180,13 +3188,13 @@ export class Run<_T extends t.BaseGraphState> {
3180
3188
  };
3181
3189
  }
3182
3190
 
3183
- const userPrompt = buildActivityPhaseLabelPrompt({
3191
+ const phaseEvidence = {
3184
3192
  activities,
3185
3193
  totalActivityCount,
3186
3194
  charLimit,
3187
3195
  assistantContext,
3188
- redaction,
3189
- });
3196
+ };
3197
+ const userPrompt = buildActivityPhaseLabelPrompt(phaseEvidence);
3190
3198
  if (userPrompt === '') {
3191
3199
  return {};
3192
3200
  }
@@ -3305,6 +3313,14 @@ export class Run<_T extends t.BaseGraphState> {
3305
3313
  traceName: phaseParentSpanContext == null ? phaseTraceName : undefined,
3306
3314
  });
3307
3315
  }
3316
+ maskRedactedLabelGeneration(phaseLangfuseHandler, {
3317
+ modelPrompt: userPrompt,
3318
+ tracedPrompt:
3319
+ redaction == null
3320
+ ? userPrompt
3321
+ : buildActivityPhaseLabelPrompt({ ...phaseEvidence, redaction }),
3322
+ redaction,
3323
+ });
3308
3324
  if (phaseLangfuseHandler != null) {
3309
3325
  phaseChainOptions.callbacks = appendCallbacks(
3310
3326
  phaseChainOptions.callbacks,
@@ -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
@@ -1949,6 +1950,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1949
1950
  // they dispatch — including HITL gates on `write_file` / `edit_file`.
1950
1951
  hookContext: {
1951
1952
  registry: this.hookRegistry,
1953
+ failClosedOnHookError:
1954
+ isBackgroundDenyMode(this.humanInTheLoop),
1952
1955
  runId: (config.configurable?.run_id as string | undefined) ?? '',
1953
1956
  threadId: config.configurable?.thread_id as string | undefined,
1954
1957
  agentId: this.agentId,
@@ -2541,11 +2544,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2541
2544
  onceReplayKey: approvalReplayKey,
2542
2545
  onceReplaySessionId: approvalReplaySessionId,
2543
2546
  }).catch((): AggregatedHookResult | undefined =>
2544
- approvalReviewEvidence == null
2547
+ approvalReviewEvidence == null &&
2548
+ !isBackgroundDenyMode(this.humanInTheLoop)
2545
2549
  ? undefined
2546
2550
  : {
2547
2551
  decision: 'deny',
2548
- reason: 'Approval policy could not be evaluated on resume',
2552
+ reason:
2553
+ isBackgroundDenyMode(this.humanInTheLoop)
2554
+ ? 'Approval policy could not be evaluated'
2555
+ : 'Approval policy could not be evaluated on resume',
2556
+ ...(isBackgroundDenyMode(this.humanInTheLoop)
2557
+ ? { hasHookFailures: true }
2558
+ : {}),
2549
2559
  additionalContexts: [],
2550
2560
  injectedMessages: [],
2551
2561
  errors: [],
@@ -2582,6 +2592,26 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2582
2592
  };
2583
2593
  }
2584
2594
 
2595
+ if (
2596
+ preResult.hasHookFailures === true &&
2597
+ isBackgroundDenyMode(this.humanInTheLoop)
2598
+ ) {
2599
+ return persistOutput(
2600
+ this.blockDirectCall({
2601
+ call,
2602
+ resolvedArgs,
2603
+ reason: this.backgroundApprovalReason(
2604
+ call.name,
2605
+ 'Approval policy could not be evaluated'
2606
+ ),
2607
+ hookRegistry,
2608
+ runId,
2609
+ threadId,
2610
+ }),
2611
+ effectiveCall.args as Record<string, unknown>
2612
+ );
2613
+ }
2614
+
2585
2615
  if (preResult.decision === 'deny') {
2586
2616
  return persistOutput(
2587
2617
  this.blockDirectCall({
@@ -2621,12 +2651,14 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2621
2651
 
2622
2652
  if (preResult.decision === 'ask' || reviewedApproval != null) {
2623
2653
  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
- );
2654
+ // Fail closed when there is no foreground approval channel.
2655
+ const reason =
2656
+ isBackgroundDenyMode(this.humanInTheLoop)
2657
+ ? this.backgroundApprovalReason(call.name, preResult.reason)
2658
+ : this.resolveAskDecisionForDirectTool(
2659
+ preResult.reason,
2660
+ call.name
2661
+ );
2630
2662
  return persistOutput(
2631
2663
  this.blockDirectCall({
2632
2664
  call,
@@ -3008,6 +3040,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3008
3040
  * LangGraph `interrupt()` instead — see `runDirectToolWithLifecycleHooks`.
3009
3041
  */
3010
3042
  private askDirectWarningEmitted = false;
3043
+ private backgroundApprovalReason(toolName: string, reason?: string): string {
3044
+ return (
3045
+ `Approval required for "${toolName}"; unavailable in a background subagent. ` +
3046
+ `Ask the parent to run it in the foreground.${reason == null ? '' : ` Reason: ${reason}`}`
3047
+ );
3048
+ }
3049
+
3011
3050
  private resolveAskDecisionForDirectTool(
3012
3051
  reason: string | undefined,
3013
3052
  toolName: string
@@ -3626,7 +3665,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3626
3665
  matchQuery: entry.call.name,
3627
3666
  onceReplayKey: approvalReplayKey,
3628
3667
  onceReplaySessionId: approvalReplaySessionId,
3629
- }).catch((): AggregatedHookResult => HOOK_FALLBACK);
3668
+ }).catch((): AggregatedHookResult =>
3669
+ isBackgroundDenyMode(this.humanInTheLoop)
3670
+ ? { ...HOOK_FALLBACK, hasHookFailures: true }
3671
+ : HOOK_FALLBACK
3672
+ );
3630
3673
  })
3631
3674
  );
3632
3675
 
@@ -3793,6 +3836,20 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3793
3836
  batchAdditionalContexts.push(ctx);
3794
3837
  }
3795
3838
 
3839
+ if (
3840
+ hookResult.hasHookFailures === true &&
3841
+ isBackgroundDenyMode(this.humanInTheLoop)
3842
+ ) {
3843
+ blockEntry(
3844
+ entry,
3845
+ this.backgroundApprovalReason(
3846
+ entry.call.name,
3847
+ 'Approval policy could not be evaluated'
3848
+ )
3849
+ );
3850
+ continue;
3851
+ }
3852
+
3796
3853
  if (hookResult.decision === 'deny') {
3797
3854
  blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
3798
3855
  continue;
@@ -3810,7 +3867,15 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3810
3867
  * JSDoc for the full rationale and the migration plan.
3811
3868
  */
3812
3869
  if (this.humanInTheLoop?.enabled !== true) {
3813
- blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
3870
+ blockEntry(
3871
+ entry,
3872
+ isBackgroundDenyMode(this.humanInTheLoop)
3873
+ ? this.backgroundApprovalReason(
3874
+ entry.call.name,
3875
+ hookResult.reason
3876
+ )
3877
+ : (hookResult.reason ?? 'Blocked by hook')
3878
+ );
3814
3879
  continue;
3815
3880
  }
3816
3881
  /**
@@ -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 };
@@ -13,6 +13,7 @@ import {
13
13
  END,
14
14
  GraphInterrupt,
15
15
  INTERRUPT,
16
+ MemorySaver,
16
17
  MessagesAnnotation,
17
18
  START,
18
19
  StateGraph,
@@ -141,9 +142,10 @@ import { seedAgentInitialSessions } from '@/utils/toolSessions';
141
142
  import { stableStringify } from '@/tools/eagerEventExecution';
142
143
  import { convertInjectedMessages } from '@/messages/injected';
143
144
  import { resolveClientOptionsModel } from '@/llm/request';
145
+ import { isBackgroundDenyMode } from '@/types/hitl';
144
146
  import { composeAbortSignals } from '@/utils/misc';
145
- import { sleep } from '@/utils/run';
146
147
  import { HandlerRegistry } from '@/events';
148
+ import { sleep } from '@/utils/run';
147
149
 
148
150
  export {
149
151
  buildChildInputs,
@@ -875,6 +877,27 @@ function createSubagentFailure(
875
877
  return { content, messages: [], error };
876
878
  }
877
879
 
880
+ function excludeBackgroundQuestionTool(agentInputs: AgentInputs): void {
881
+ const questionTool = 'ask_user_question';
882
+ agentInputs.tools = agentInputs.tools?.filter(
883
+ (tool) => !('name' in tool) || tool.name !== questionTool
884
+ );
885
+ agentInputs.graphTools = agentInputs.graphTools?.filter(
886
+ (tool) => tool.name !== questionTool
887
+ );
888
+ agentInputs.toolDefinitions = agentInputs.toolDefinitions?.filter(
889
+ (tool) => tool.name !== questionTool
890
+ );
891
+ if (agentInputs.toolMap?.has(questionTool) === true) {
892
+ agentInputs.toolMap = new Map(agentInputs.toolMap);
893
+ agentInputs.toolMap.delete(questionTool);
894
+ }
895
+ if (agentInputs.toolRegistry?.has(questionTool) === true) {
896
+ agentInputs.toolRegistry = new Map(agentInputs.toolRegistry);
897
+ agentInputs.toolRegistry.delete(questionTool);
898
+ }
899
+ }
900
+
878
901
  /**
879
902
  * Factory that constructs a child graph for subagent execution. Injected
880
903
  * rather than imported so that `SubagentExecutor` does not have a runtime
@@ -1106,7 +1129,10 @@ export class SubagentExecutor {
1106
1129
  message: 'Maximum subagent nesting depth exceeded.',
1107
1130
  });
1108
1131
  }
1109
- if (this.humanInTheLoop?.enabled === true) {
1132
+ if (
1133
+ this.humanInTheLoop?.enabled === true &&
1134
+ this.humanInTheLoop.backgroundPausePolicy === 'reject'
1135
+ ) {
1110
1136
  return JSON.stringify({
1111
1137
  status: 'rejected',
1112
1138
  message:
@@ -1292,6 +1318,15 @@ export class SubagentExecutor {
1292
1318
  usageSink: this.usageSink,
1293
1319
  subagentContext: this.subagentContext,
1294
1320
  streamLimits: this.streamLimits,
1321
+ humanInTheLoop:
1322
+ this.humanInTheLoop?.enabled === true ||
1323
+ this.humanInTheLoop?.backgroundPausePolicy === 'deny'
1324
+ ? {
1325
+ enabled: false,
1326
+ backgroundPausePolicy: 'deny',
1327
+ backgroundDeny: true,
1328
+ }
1329
+ : this.humanInTheLoop,
1295
1330
  maxDepth: this.maxDepth,
1296
1331
  createChildGraph:
1297
1332
  detachedGraphFactory == null
@@ -1338,6 +1373,27 @@ export class SubagentExecutor {
1338
1373
  if (result.error != null) {
1339
1374
  throw new Error(result.error);
1340
1375
  }
1376
+ if (this.humanInTheLoop?.enabled === true) {
1377
+ const deniedTools = new Set<string>();
1378
+ for (const message of result.messages) {
1379
+ if (
1380
+ message instanceof ToolMessage &&
1381
+ message.status === 'error' &&
1382
+ typeof message.content === 'string' &&
1383
+ message.content.startsWith('Blocked: Approval required for "') &&
1384
+ message.name != null
1385
+ ) {
1386
+ deniedTools.add(message.name);
1387
+ if (deniedTools.size >= 10) break;
1388
+ }
1389
+ }
1390
+ if (deniedTools.size > 0) {
1391
+ return {
1392
+ ...result,
1393
+ content: `${result.content}\nBackground approval denied for: ${[...deniedTools].join(', ')}. Ask the parent to run those tools in the foreground.`,
1394
+ };
1395
+ }
1396
+ }
1341
1397
  return result;
1342
1398
  } finally {
1343
1399
  detached.clearHeavyState();
@@ -2489,6 +2545,11 @@ export class SubagentExecutor {
2489
2545
  parentMaxDepth: this.maxDepth,
2490
2546
  keepToolDefinitions: hasToolExecuteHandler,
2491
2547
  });
2548
+ if (isBackgroundDenyMode(this.humanInTheLoop)) {
2549
+ for (const agentInputs of childPlan.agents) {
2550
+ excludeBackgroundQuestionTool(agentInputs);
2551
+ }
2552
+ }
2492
2553
  const childAgentId = childPlan.subjectAgentId;
2493
2554
  const currentHookSessionId =
2494
2555
  asNonEmptyString(params.hookSessionId) ??
@@ -2638,6 +2699,18 @@ export class SubagentExecutor {
2638
2699
  });
2639
2700
  }
2640
2701
  childGraph ??= this.createChildGraph(childGraphInput);
2702
+ if (isBackgroundDenyMode(this.humanInTheLoop)) {
2703
+ childGraph.humanInTheLoop = this.humanInTheLoop;
2704
+ childGraph.eagerEventToolExecution = undefined;
2705
+ // Never reuse the HITL parent's saver: its checkpoint namespace may
2706
+ // contain parent messages. A private saver also lets an unexpected
2707
+ // interrupt surface as a task error instead of a missing-saver tool
2708
+ // error that the child could mistake for a completed task.
2709
+ childGraph.compileOptions = {
2710
+ ...childGraph.compileOptions,
2711
+ checkpointer: new MemorySaver(),
2712
+ };
2713
+ }
2641
2714
  if (params.taskRuntime != null) {
2642
2715
  childGraph.hookRegistry = this.hookRegistry;
2643
2716
  }
@@ -2756,7 +2829,8 @@ export class SubagentExecutor {
2756
2829
  childConfigurable[SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY] = resumeAttemptId;
2757
2830
  }
2758
2831
  childConfigurable.thread_id =
2759
- this.humanInTheLoop?.enabled === true
2832
+ this.humanInTheLoop?.enabled === true ||
2833
+ isBackgroundDenyMode(this.humanInTheLoop)
2760
2834
  ? childThreadId
2761
2835
  : (inheritedConfigurable.thread_id ?? childRunId);
2762
2836
  const childInvokeConfig = {
@@ -2884,16 +2958,31 @@ export class SubagentExecutor {
2884
2958
  },
2885
2959
  sessionId: currentHookSessionId,
2886
2960
  matchQuery: subagentType,
2887
- }).catch((): AggregatedHookResult => HOOK_FALLBACK);
2961
+ }).catch((): AggregatedHookResult =>
2962
+ isBackgroundDenyMode(this.humanInTheLoop)
2963
+ ? { ...HOOK_FALLBACK, hasHookFailures: true }
2964
+ : HOOK_FALLBACK
2965
+ );
2888
2966
 
2889
- if (hookResult.decision === 'deny' || hookResult.decision === 'ask') {
2967
+ const policyFailed =
2968
+ hookResult.hasHookFailures === true &&
2969
+ isBackgroundDenyMode(this.humanInTheLoop);
2970
+ if (
2971
+ policyFailed ||
2972
+ hookResult.decision === 'deny' ||
2973
+ hookResult.decision === 'ask'
2974
+ ) {
2890
2975
  this.clearChildGraph(childGraph);
2891
2976
  execution.releaseActiveRun();
2892
2977
  this.executions.remove(execution);
2893
- return {
2894
- content: `Blocked: ${hookResult.reason ?? 'Blocked by hook'}`,
2895
- messages: [],
2896
- };
2978
+ return policyFailed
2979
+ ? createSubagentFailure(
2980
+ 'Subagent start policy could not be evaluated; background execution denied.'
2981
+ )
2982
+ : {
2983
+ content: `Blocked: ${hookResult.reason ?? 'Blocked by hook'}`,
2984
+ messages: [],
2985
+ };
2897
2986
  }
2898
2987
  }
2899
2988
  execution.markStarted();
@@ -2956,7 +3045,8 @@ export class SubagentExecutor {
2956
3045
  };
2957
3046
  }
2958
3047
  }
2959
- } catch (error) {
3048
+ } catch (caught) {
3049
+ let error = caught;
2960
3050
  /** Stamped at failure, not after the error-envelope work below. */
2961
3051
  const childTerminalAt = Date.now();
2962
3052
  /**
@@ -2971,6 +3061,11 @@ export class SubagentExecutor {
2971
3061
  * `cancelled` here.
2972
3062
  */
2973
3063
  const abortedBeforeError = childSignal.aborted;
3064
+ if (isGraphInterrupt(error) && params.taskRuntime != null) {
3065
+ error = new Error(
3066
+ 'Background subagent cannot pause for human input. Run the tool in the foreground.'
3067
+ );
3068
+ }
2974
3069
  if (isGraphInterrupt(error)) {
2975
3070
  const activeChildRun = execution.activeRun;
2976
3071
  if (activeChildRun != null) {
package/src/types/hitl.ts CHANGED
@@ -407,6 +407,26 @@ export interface HumanInTheLoopConfig {
407
407
  * UI is ready to render and resolve `tool_approval` interrupts.
408
408
  */
409
409
  enabled?: boolean;
410
+ /**
411
+ * Background children cannot pause for human input. `deny` (the default)
412
+ * lets them run but blocks tools whose hooks request approval, and excludes
413
+ * `ask_user_question`. `reject` preserves the legacy admission check.
414
+ * Durable deferred approvals require a separate resume implementation.
415
+ */
416
+ backgroundPausePolicy?: 'deny' | 'reject';
417
+ /** @internal Set only on detached children and their descendants, never on the parent run. */
418
+ backgroundDeny?: true;
419
+ }
420
+
421
+ /** True only on an SDK-created background child, not a foreground HITL run. */
422
+ export function isBackgroundDenyMode(
423
+ config: HumanInTheLoopConfig | undefined
424
+ ): boolean {
425
+ return (
426
+ config?.backgroundDeny === true &&
427
+ config.enabled === false &&
428
+ config.backgroundPausePolicy === 'deny'
429
+ );
410
430
  }
411
431
 
412
432
  /**
@@ -1256,6 +1256,8 @@ export type ProgrammaticCache = {
1256
1256
 
1257
1257
  export type ProgrammaticHookContext = {
1258
1258
  registry: import('@/hooks').HookRegistry | undefined;
1259
+ /** A detached HITL child must never execute an inner tool if its policy hook fails. */
1260
+ failClosedOnHookError?: boolean;
1259
1261
  runId: string;
1260
1262
  threadId?: string;
1261
1263
  agentId?: string;