@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.
- package/dist/cjs/langfuse.cjs +46 -2
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/run.cjs +35 -12
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +26 -5
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +5 -1
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +52 -6
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/types/hitl.cjs +4 -0
- package/dist/cjs/types/hitl.cjs.map +1 -1
- package/dist/esm/langfuse.mjs +47 -4
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/run.mjs +36 -13
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +26 -5
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +5 -1
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +53 -7
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/types/hitl.mjs +4 -1
- package/dist/esm/types/hitl.mjs.map +1 -1
- package/dist/types/langfuse.d.ts +19 -0
- package/dist/types/tools/ToolNode.d.ts +1 -0
- package/dist/types/types/hitl.d.ts +11 -0
- package/dist/types/types/tools.d.ts +2 -0
- package/package.json +1 -1
- package/src/langfuse.ts +100 -3
- package/src/run.ts +32 -16
- package/src/tools/ToolNode.ts +76 -11
- package/src/tools/local/LocalProgrammaticToolCalling.ts +16 -4
- package/src/tools/subagent/SubagentExecutor.ts +105 -10
- package/src/types/hitl.ts +20 -0
- 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 {
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
2986
|
+
const reasoningEvidence = {
|
|
2987
2987
|
visibleReasoning,
|
|
2988
2988
|
status,
|
|
2989
2989
|
charLimit,
|
|
2990
2990
|
previousLabel,
|
|
2991
|
-
|
|
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
|
|
3191
|
+
const phaseEvidence = {
|
|
3184
3192
|
activities,
|
|
3185
3193
|
totalActivityCount,
|
|
3186
3194
|
charLimit,
|
|
3187
3195
|
assistantContext,
|
|
3188
|
-
|
|
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,
|
package/src/tools/ToolNode.ts
CHANGED
|
@@ -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:
|
|
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
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
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 =>
|
|
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(
|
|
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.
|
|
240
|
-
|
|
241
|
-
? `
|
|
242
|
-
:
|
|
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 (
|
|
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 =>
|
|
2961
|
+
}).catch((): AggregatedHookResult =>
|
|
2962
|
+
isBackgroundDenyMode(this.humanInTheLoop)
|
|
2963
|
+
? { ...HOOK_FALLBACK, hasHookFailures: true }
|
|
2964
|
+
: HOOK_FALLBACK
|
|
2965
|
+
);
|
|
2888
2966
|
|
|
2889
|
-
|
|
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
|
-
|
|
2895
|
-
|
|
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 (
|
|
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
|
/**
|
package/src/types/tools.ts
CHANGED
|
@@ -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;
|