@librechat/agents 3.1.75 → 3.1.77-dev.1
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/graphs/Graph.cjs +22 -3
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hitl/askUserQuestion.cjs +67 -0
- package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -0
- package/dist/cjs/hooks/HookRegistry.cjs +54 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/createToolPolicyHook.cjs +115 -0
- package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -0
- package/dist/cjs/hooks/executeHooks.cjs +40 -1
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/langchain/google-common.cjs +3 -0
- package/dist/cjs/langchain/google-common.cjs.map +1 -0
- package/dist/cjs/langchain/index.cjs +86 -0
- package/dist/cjs/langchain/index.cjs.map +1 -0
- package/dist/cjs/langchain/language_models/chat_models.cjs +3 -0
- package/dist/cjs/langchain/language_models/chat_models.cjs.map +1 -0
- package/dist/cjs/langchain/messages/tool.cjs +3 -0
- package/dist/cjs/langchain/messages/tool.cjs.map +1 -0
- package/dist/cjs/langchain/messages.cjs +51 -0
- package/dist/cjs/langchain/messages.cjs.map +1 -0
- package/dist/cjs/langchain/openai.cjs +3 -0
- package/dist/cjs/langchain/openai.cjs.map +1 -0
- package/dist/cjs/langchain/prompts.cjs +11 -0
- package/dist/cjs/langchain/prompts.cjs.map +1 -0
- package/dist/cjs/langchain/runnables.cjs +19 -0
- package/dist/cjs/langchain/runnables.cjs.map +1 -0
- package/dist/cjs/langchain/tools.cjs +23 -0
- package/dist/cjs/langchain/tools.cjs.map +1 -0
- package/dist/cjs/langchain/utils/env.cjs +11 -0
- package/dist/cjs/langchain/utils/env.cjs.map +1 -0
- package/dist/cjs/llm/anthropic/index.cjs +145 -52
- package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/types.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +21 -14
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_outputs.cjs +84 -70
- package/dist/cjs/llm/anthropic/utils/message_outputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +1 -1
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +213 -3
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_outputs.cjs +2 -1
- package/dist/cjs/llm/bedrock/utils/message_outputs.cjs.map +1 -1
- package/dist/cjs/llm/google/utils/common.cjs +5 -4
- package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +519 -655
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/utils/index.cjs +20 -458
- package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs +57 -175
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/llm/vertexai/index.cjs +5 -3
- package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +112 -3
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/cache.cjs +2 -1
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/core.cjs +7 -6
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +73 -15
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/langchain.cjs +26 -0
- package/dist/cjs/messages/langchain.cjs.map +1 -0
- package/dist/cjs/messages/prune.cjs +7 -6
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +400 -42
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +556 -56
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/search.cjs +55 -66
- package/dist/cjs/tools/search/search.cjs.map +1 -1
- package/dist/cjs/tools/search/tavily-scraper.cjs +189 -0
- package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -0
- package/dist/cjs/tools/search/tavily-search.cjs +372 -0
- package/dist/cjs/tools/search/tavily-search.cjs.map +1 -0
- package/dist/cjs/tools/search/tool.cjs +26 -4
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/search/utils.cjs +10 -3
- package/dist/cjs/tools/search/utils.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +22 -3
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hitl/askUserQuestion.mjs +65 -0
- package/dist/esm/hitl/askUserQuestion.mjs.map +1 -0
- package/dist/esm/hooks/HookRegistry.mjs +54 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/createToolPolicyHook.mjs +113 -0
- package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -0
- package/dist/esm/hooks/executeHooks.mjs +40 -1
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/langchain/google-common.mjs +2 -0
- package/dist/esm/langchain/google-common.mjs.map +1 -0
- package/dist/esm/langchain/index.mjs +5 -0
- package/dist/esm/langchain/index.mjs.map +1 -0
- package/dist/esm/langchain/language_models/chat_models.mjs +2 -0
- package/dist/esm/langchain/language_models/chat_models.mjs.map +1 -0
- package/dist/esm/langchain/messages/tool.mjs +2 -0
- package/dist/esm/langchain/messages/tool.mjs.map +1 -0
- package/dist/esm/langchain/messages.mjs +2 -0
- package/dist/esm/langchain/messages.mjs.map +1 -0
- package/dist/esm/langchain/openai.mjs +2 -0
- package/dist/esm/langchain/openai.mjs.map +1 -0
- package/dist/esm/langchain/prompts.mjs +2 -0
- package/dist/esm/langchain/prompts.mjs.map +1 -0
- package/dist/esm/langchain/runnables.mjs +2 -0
- package/dist/esm/langchain/runnables.mjs.map +1 -0
- package/dist/esm/langchain/tools.mjs +2 -0
- package/dist/esm/langchain/tools.mjs.map +1 -0
- package/dist/esm/langchain/utils/env.mjs +2 -0
- package/dist/esm/langchain/utils/env.mjs.map +1 -0
- package/dist/esm/llm/anthropic/index.mjs +146 -54
- package/dist/esm/llm/anthropic/index.mjs.map +1 -1
- package/dist/esm/llm/anthropic/types.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +21 -14
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_outputs.mjs +84 -71
- package/dist/esm/llm/anthropic/utils/message_outputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +1 -1
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +214 -4
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_outputs.mjs +2 -1
- package/dist/esm/llm/bedrock/utils/message_outputs.mjs.map +1 -1
- package/dist/esm/llm/google/utils/common.mjs +5 -4
- package/dist/esm/llm/google/utils/common.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +520 -656
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openai/utils/index.mjs +23 -459
- package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs +57 -175
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/llm/vertexai/index.mjs +5 -3
- package/dist/esm/llm/vertexai/index.mjs.map +1 -1
- package/dist/esm/main.mjs +7 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/cache.mjs +2 -1
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/core.mjs +7 -6
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +73 -15
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/langchain.mjs +23 -0
- package/dist/esm/messages/langchain.mjs.map +1 -0
- package/dist/esm/messages/prune.mjs +7 -6
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +400 -42
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +557 -57
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/search.mjs +55 -66
- package/dist/esm/tools/search/search.mjs.map +1 -1
- package/dist/esm/tools/search/tavily-scraper.mjs +186 -0
- package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -0
- package/dist/esm/tools/search/tavily-search.mjs +370 -0
- package/dist/esm/tools/search/tavily-search.mjs.map +1 -0
- package/dist/esm/tools/search/tool.mjs +26 -4
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/search/utils.mjs +10 -3
- package/dist/esm/tools/search/utils.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +7 -0
- package/dist/types/hitl/askUserQuestion.d.ts +55 -0
- package/dist/types/hitl/index.d.ts +6 -0
- package/dist/types/hooks/HookRegistry.d.ts +58 -0
- package/dist/types/hooks/createToolPolicyHook.d.ts +87 -0
- package/dist/types/hooks/index.d.ts +4 -1
- package/dist/types/hooks/types.d.ts +109 -3
- package/dist/types/index.d.ts +10 -0
- package/dist/types/langchain/google-common.d.ts +1 -0
- package/dist/types/langchain/index.d.ts +8 -0
- package/dist/types/langchain/language_models/chat_models.d.ts +1 -0
- package/dist/types/langchain/messages/tool.d.ts +1 -0
- package/dist/types/langchain/messages.d.ts +2 -0
- package/dist/types/langchain/openai.d.ts +1 -0
- package/dist/types/langchain/prompts.d.ts +1 -0
- package/dist/types/langchain/runnables.d.ts +2 -0
- package/dist/types/langchain/tools.d.ts +2 -0
- package/dist/types/langchain/utils/env.d.ts +1 -0
- package/dist/types/llm/anthropic/index.d.ts +22 -9
- package/dist/types/llm/anthropic/types.d.ts +5 -1
- package/dist/types/llm/anthropic/utils/message_outputs.d.ts +13 -6
- package/dist/types/llm/anthropic/utils/output_parsers.d.ts +1 -1
- package/dist/types/llm/openai/index.d.ts +21 -24
- package/dist/types/llm/openrouter/index.d.ts +11 -9
- package/dist/types/llm/vertexai/index.d.ts +1 -0
- package/dist/types/messages/cache.d.ts +4 -1
- package/dist/types/messages/format.d.ts +4 -1
- package/dist/types/messages/langchain.d.ts +27 -0
- package/dist/types/run.d.ts +117 -1
- package/dist/types/tools/ToolNode.d.ts +26 -1
- package/dist/types/tools/search/tavily-scraper.d.ts +19 -0
- package/dist/types/tools/search/tavily-search.d.ts +4 -0
- package/dist/types/tools/search/types.d.ts +99 -5
- package/dist/types/tools/search/utils.d.ts +2 -2
- package/dist/types/types/graph.d.ts +23 -37
- package/dist/types/types/hitl.d.ts +272 -0
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/llm.d.ts +3 -3
- package/dist/types/types/run.d.ts +33 -0
- package/dist/types/types/stream.d.ts +1 -1
- package/dist/types/types/tools.d.ts +19 -0
- package/package.json +80 -17
- package/src/graphs/Graph.ts +33 -4
- package/src/graphs/__tests__/composition.smoke.test.ts +188 -0
- package/src/hitl/askUserQuestion.ts +72 -0
- package/src/hitl/index.ts +7 -0
- package/src/hooks/HookRegistry.ts +71 -0
- package/src/hooks/__tests__/createToolPolicyHook.test.ts +259 -0
- package/src/hooks/createToolPolicyHook.ts +184 -0
- package/src/hooks/executeHooks.ts +50 -1
- package/src/hooks/index.ts +6 -0
- package/src/hooks/types.ts +112 -0
- package/src/index.ts +22 -0
- package/src/langchain/google-common.ts +1 -0
- package/src/langchain/index.ts +8 -0
- package/src/langchain/language_models/chat_models.ts +1 -0
- package/src/langchain/messages/tool.ts +5 -0
- package/src/langchain/messages.ts +21 -0
- package/src/langchain/openai.ts +1 -0
- package/src/langchain/prompts.ts +1 -0
- package/src/langchain/runnables.ts +7 -0
- package/src/langchain/tools.ts +8 -0
- package/src/langchain/utils/env.ts +1 -0
- package/src/llm/anthropic/index.ts +252 -84
- package/src/llm/anthropic/llm.spec.ts +751 -102
- package/src/llm/anthropic/types.ts +9 -1
- package/src/llm/anthropic/utils/message_inputs.ts +37 -19
- package/src/llm/anthropic/utils/message_outputs.ts +119 -101
- package/src/llm/bedrock/index.ts +2 -2
- package/src/llm/bedrock/llm.spec.ts +341 -0
- package/src/llm/bedrock/utils/message_inputs.ts +303 -4
- package/src/llm/bedrock/utils/message_outputs.ts +2 -1
- package/src/llm/custom-chat-models.smoke.test.ts +836 -0
- package/src/llm/google/llm.spec.ts +339 -57
- package/src/llm/google/utils/common.ts +53 -48
- package/src/llm/openai/contentBlocks.test.ts +346 -0
- package/src/llm/openai/index.ts +856 -833
- package/src/llm/openai/utils/index.ts +107 -78
- package/src/llm/openai/utils/messages.test.ts +159 -0
- package/src/llm/openrouter/index.ts +124 -247
- package/src/llm/openrouter/reasoning.test.ts +8 -1
- package/src/llm/vertexai/index.ts +11 -5
- package/src/llm/vertexai/llm.spec.ts +28 -1
- package/src/messages/cache.test.ts +4 -3
- package/src/messages/cache.ts +3 -2
- package/src/messages/core.ts +16 -9
- package/src/messages/format.ts +96 -16
- package/src/messages/formatAgentMessages.test.ts +166 -1
- package/src/messages/langchain.ts +39 -0
- package/src/messages/prune.ts +12 -8
- package/src/run.ts +456 -47
- package/src/scripts/caching.ts +2 -3
- package/src/specs/summarization.test.ts +51 -58
- package/src/tools/ToolNode.ts +706 -63
- package/src/tools/__tests__/hitl.test.ts +3593 -0
- package/src/tools/search/search.ts +83 -73
- package/src/tools/search/tavily-scraper.ts +235 -0
- package/src/tools/search/tavily-search.ts +424 -0
- package/src/tools/search/tavily.test.ts +965 -0
- package/src/tools/search/tool.ts +36 -26
- package/src/tools/search/types.ts +133 -8
- package/src/tools/search/utils.ts +13 -5
- package/src/types/graph.ts +32 -87
- package/src/types/hitl.ts +303 -0
- package/src/types/index.ts +1 -0
- package/src/types/llm.ts +3 -3
- package/src/types/run.ts +33 -0
- package/src/types/stream.ts +1 -1
- package/src/types/tools.ts +19 -0
- package/src/utils/llmConfig.ts +1 -6
package/src/tools/ToolNode.ts
CHANGED
|
@@ -10,9 +10,11 @@ import {
|
|
|
10
10
|
Send,
|
|
11
11
|
Command,
|
|
12
12
|
isCommand,
|
|
13
|
+
interrupt,
|
|
13
14
|
isGraphInterrupt,
|
|
14
15
|
MessagesAnnotation,
|
|
15
16
|
} from '@langchain/langgraph';
|
|
17
|
+
import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons';
|
|
16
18
|
import type {
|
|
17
19
|
RunnableConfig,
|
|
18
20
|
RunnableToolLike,
|
|
@@ -24,7 +26,11 @@ import type {
|
|
|
24
26
|
PreResolvedArgsMap,
|
|
25
27
|
ResolvedArgsByCallId,
|
|
26
28
|
} from '@/tools/toolOutputReferences';
|
|
27
|
-
import type {
|
|
29
|
+
import type {
|
|
30
|
+
HookRegistry,
|
|
31
|
+
AggregatedHookResult,
|
|
32
|
+
PostToolBatchEntry,
|
|
33
|
+
} from '@/hooks';
|
|
28
34
|
import type * as t from '@/types';
|
|
29
35
|
import { RunnableCallable } from '@/utils';
|
|
30
36
|
import {
|
|
@@ -33,6 +39,7 @@ import {
|
|
|
33
39
|
} from '@/utils/truncation';
|
|
34
40
|
import { safeDispatchCustomEvent } from '@/utils/events';
|
|
35
41
|
import { executeHooks } from '@/hooks';
|
|
42
|
+
import { toLangChainContent } from '@/messages/langchain';
|
|
36
43
|
import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
|
|
37
44
|
import {
|
|
38
45
|
buildReferenceKey,
|
|
@@ -89,6 +96,117 @@ function isSend(value: unknown): value is Send {
|
|
|
89
96
|
return value instanceof Send;
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Format a fail-closed diagnostic for malformed approval-decision
|
|
101
|
+
* fields. Hosts deserialize resume payloads from untyped JSON, so
|
|
102
|
+
* `responseText` and `updatedInput` can land here as anything; the
|
|
103
|
+
* blocking ToolMessage carries this string so the host can debug the
|
|
104
|
+
* exact wire shape that was rejected.
|
|
105
|
+
*/
|
|
106
|
+
function describeOfferedShape(value: unknown): string {
|
|
107
|
+
if (value === undefined) {
|
|
108
|
+
return '<missing>';
|
|
109
|
+
}
|
|
110
|
+
if (value === null) {
|
|
111
|
+
return 'null';
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(value)) {
|
|
114
|
+
return 'array';
|
|
115
|
+
}
|
|
116
|
+
return typeof value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Per-entry record collected during PreToolUse hook handling for tool
|
|
121
|
+
* calls that need human approval. Carries everything
|
|
122
|
+
* `buildToolApprovalInterruptPayload` needs to assemble the interrupt
|
|
123
|
+
* payload, plus the per-tool decision allowlist if the hook supplied
|
|
124
|
+
* one. Defined at module scope so the payload-builder helper can be
|
|
125
|
+
* extracted out of `dispatchToolEvents` without leaking the locally-
|
|
126
|
+
* inferred shape.
|
|
127
|
+
*/
|
|
128
|
+
type AskEntry = {
|
|
129
|
+
entry: {
|
|
130
|
+
call: ToolCall;
|
|
131
|
+
args: Record<string, unknown>;
|
|
132
|
+
stepId: string;
|
|
133
|
+
};
|
|
134
|
+
reason?: string;
|
|
135
|
+
allowedDecisions?: ReadonlyArray<'approve' | 'reject' | 'edit' | 'respond'>;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build the `tool_approval` interrupt payload from the set of pending
|
|
140
|
+
* `ask`-decision entries collected during PreToolUse hook handling.
|
|
141
|
+
* Pure function — doesn't touch ToolNode state — so it lives at module
|
|
142
|
+
* scope. The interrupt itself is raised by the caller (which still
|
|
143
|
+
* needs `interrupt()` plus the AsyncLocalStorage anchoring shim).
|
|
144
|
+
*/
|
|
145
|
+
function buildToolApprovalInterruptPayload(
|
|
146
|
+
askEntries: ReadonlyArray<AskEntry>
|
|
147
|
+
): t.ToolApprovalInterruptPayload {
|
|
148
|
+
return {
|
|
149
|
+
type: 'tool_approval',
|
|
150
|
+
action_requests: askEntries.map(({ entry, reason }) => {
|
|
151
|
+
const request: t.ToolApprovalRequest = {
|
|
152
|
+
tool_call_id: entry.call.id!,
|
|
153
|
+
name: entry.call.name,
|
|
154
|
+
arguments: entry.args,
|
|
155
|
+
};
|
|
156
|
+
if (reason != null) {
|
|
157
|
+
request.description = reason;
|
|
158
|
+
}
|
|
159
|
+
return request;
|
|
160
|
+
}),
|
|
161
|
+
review_configs: askEntries.map(({ entry, allowedDecisions }) => ({
|
|
162
|
+
action_name: entry.call.name,
|
|
163
|
+
tool_call_id: entry.call.id!,
|
|
164
|
+
allowed_decisions: (allowedDecisions ?? [
|
|
165
|
+
'approve',
|
|
166
|
+
'reject',
|
|
167
|
+
'edit',
|
|
168
|
+
'respond',
|
|
169
|
+
]) as t.ToolApprovalDecisionType[],
|
|
170
|
+
})),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build a `tool_call_id → ToolApprovalDecision` map from the host's
|
|
176
|
+
* resume value. Hosts may return decisions either as an array (one per
|
|
177
|
+
* action_request, in order) or as a record keyed by `tool_call_id`. Any
|
|
178
|
+
* unrecognized shape (or a decision missing for a given call id) is
|
|
179
|
+
* treated as "no decision" by callers — typically rejected so the run
|
|
180
|
+
* doesn't silently invoke a tool the human never approved.
|
|
181
|
+
*/
|
|
182
|
+
function normalizeApprovalDecisions(
|
|
183
|
+
callIds: string[],
|
|
184
|
+
resumeValue: t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap | undefined
|
|
185
|
+
): Map<string, t.ToolApprovalDecision> {
|
|
186
|
+
const map = new Map<string, t.ToolApprovalDecision>();
|
|
187
|
+
if (resumeValue == null) {
|
|
188
|
+
return map;
|
|
189
|
+
}
|
|
190
|
+
if (Array.isArray(resumeValue)) {
|
|
191
|
+
const limit = Math.min(callIds.length, resumeValue.length);
|
|
192
|
+
for (let i = 0; i < limit; i++) {
|
|
193
|
+
map.set(callIds[i], resumeValue[i]);
|
|
194
|
+
}
|
|
195
|
+
return map;
|
|
196
|
+
}
|
|
197
|
+
if (typeof resumeValue === 'object') {
|
|
198
|
+
for (const callId of callIds) {
|
|
199
|
+
const decision = (resumeValue as Partial<t.ToolApprovalDecisionMap>)[
|
|
200
|
+
callId
|
|
201
|
+
];
|
|
202
|
+
if (decision !== undefined) {
|
|
203
|
+
map.set(callId, decision);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return map;
|
|
208
|
+
}
|
|
209
|
+
|
|
92
210
|
/**
|
|
93
211
|
* Merges code execution session context into the sessions map.
|
|
94
212
|
*
|
|
@@ -170,6 +288,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
170
288
|
private maxToolResultChars: number;
|
|
171
289
|
/** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
|
|
172
290
|
private hookRegistry?: HookRegistry;
|
|
291
|
+
/**
|
|
292
|
+
* Run-scoped HITL config. When `enabled`, `ask` decisions from
|
|
293
|
+
* PreToolUse hooks raise a LangGraph `interrupt()` instead of being
|
|
294
|
+
* treated as fail-closed denies.
|
|
295
|
+
*/
|
|
296
|
+
private humanInTheLoop?: t.HumanInTheLoopConfig;
|
|
173
297
|
/**
|
|
174
298
|
* Registry of tool outputs keyed by `tool<idx>turn<turn>`.
|
|
175
299
|
*
|
|
@@ -208,6 +332,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
208
332
|
maxContextTokens,
|
|
209
333
|
maxToolResultChars,
|
|
210
334
|
hookRegistry,
|
|
335
|
+
humanInTheLoop,
|
|
211
336
|
toolOutputReferences,
|
|
212
337
|
toolOutputRegistry,
|
|
213
338
|
}: t.ToolNodeConstructorParams) {
|
|
@@ -226,6 +351,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
226
351
|
this.maxToolResultChars =
|
|
227
352
|
maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
|
|
228
353
|
this.hookRegistry = hookRegistry;
|
|
354
|
+
this.humanInTheLoop = humanInTheLoop;
|
|
229
355
|
/**
|
|
230
356
|
* Precedence: an explicitly passed `toolOutputRegistry` instance
|
|
231
357
|
* wins over a config object so a host (`Graph`) can share one
|
|
@@ -880,16 +1006,43 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
880
1006
|
|
|
881
1007
|
const messageByCallId = new Map<string, ToolMessage>();
|
|
882
1008
|
const approvedEntries: typeof preToolCalls = [];
|
|
1009
|
+
/**
|
|
1010
|
+
* Batch-level accumulator for `additionalContext` strings returned
|
|
1011
|
+
* by any PreToolUse / PostToolUse / PostToolUseFailure hook in this
|
|
1012
|
+
* dispatch. We emit one consolidated `HumanMessage` after all tool
|
|
1013
|
+
* results land so the next model turn sees the injected context
|
|
1014
|
+
* exactly once, ordered after the ToolMessages.
|
|
1015
|
+
*/
|
|
1016
|
+
const batchAdditionalContexts: string[] = [];
|
|
1017
|
+
/**
|
|
1018
|
+
* Batch-level outcome record keyed by `tool_call_id`. Captures
|
|
1019
|
+
* every tool call's final result (success / error from the host,
|
|
1020
|
+
* blocked from HITL deny / reject, substituted from HITL respond)
|
|
1021
|
+
* across the three call sites that touch it. We materialize the
|
|
1022
|
+
* `PostToolBatch` entry array in `toolCalls` order at dispatch
|
|
1023
|
+
* time so hooks correlating outcomes by position see exactly the
|
|
1024
|
+
* same sequence the model emitted — independent of when each
|
|
1025
|
+
* outcome was recorded (deny entries land synchronously in the
|
|
1026
|
+
* hook loop, approved entries land after host execution, respond
|
|
1027
|
+
* entries land in the resume branch).
|
|
1028
|
+
*/
|
|
1029
|
+
const postToolBatchEntryByCallId = new Map<string, PostToolBatchEntry>();
|
|
883
1030
|
const HOOK_FALLBACK: AggregatedHookResult = Object.freeze({
|
|
884
1031
|
additionalContexts: [] as string[],
|
|
885
1032
|
errors: [] as string[],
|
|
886
1033
|
});
|
|
887
1034
|
|
|
888
1035
|
if (this.hookRegistry?.hasHookFor('PreToolUse', runId) === true) {
|
|
1036
|
+
/**
|
|
1037
|
+
* Capture as a non-null local so the inner `blockEntry` closure
|
|
1038
|
+
* doesn't lose narrowing on `this.hookRegistry` and we don't have
|
|
1039
|
+
* to defensively `?.` it across every reference inside.
|
|
1040
|
+
*/
|
|
1041
|
+
const hookRegistry = this.hookRegistry;
|
|
889
1042
|
const preResults = await Promise.all(
|
|
890
1043
|
preToolCalls.map((entry) =>
|
|
891
1044
|
executeHooks({
|
|
892
|
-
registry:
|
|
1045
|
+
registry: hookRegistry,
|
|
893
1046
|
input: {
|
|
894
1047
|
hook_event_name: 'PreToolUse',
|
|
895
1048
|
runId,
|
|
@@ -907,89 +1060,441 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
907
1060
|
)
|
|
908
1061
|
);
|
|
909
1062
|
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1063
|
+
type PendingEntry = (typeof preToolCalls)[number];
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Side effects deferred from `blockEntry` until after any pending
|
|
1067
|
+
* `interrupt()` resolves. Without deferral, a batch that mixes a
|
|
1068
|
+
* `deny` decision with an `ask` decision would dispatch
|
|
1069
|
+
* `ON_RUN_STEP_COMPLETED` for the denied tool on the FIRST node
|
|
1070
|
+
* execution (before `interrupt()` throws), then dispatch the
|
|
1071
|
+
* same event AGAIN on the resume re-execution — hosts would
|
|
1072
|
+
* observe two completion events for one logical denial. By
|
|
1073
|
+
* queueing the dispatch + PermissionDenied hook here and
|
|
1074
|
+
* flushing after the interrupt block, we ensure each side effect
|
|
1075
|
+
* fires exactly once: never on the first pass when interrupt
|
|
1076
|
+
* throws (the flush is unreachable), once on resume / no-ask
|
|
1077
|
+
* passes when control reaches the flush.
|
|
1078
|
+
*/
|
|
1079
|
+
const deferredBlockedSideEffects: Array<{
|
|
1080
|
+
callId: string;
|
|
1081
|
+
toolName: string;
|
|
1082
|
+
args: Record<string, unknown>;
|
|
1083
|
+
contentString: string;
|
|
1084
|
+
reason: string;
|
|
1085
|
+
}> = [];
|
|
1086
|
+
|
|
1087
|
+
const blockEntry = (entry: PendingEntry, reason: string): void => {
|
|
1088
|
+
const contentString = `Blocked: ${reason}`;
|
|
1089
|
+
messageByCallId.set(
|
|
1090
|
+
entry.call.id!,
|
|
1091
|
+
new ToolMessage({
|
|
1092
|
+
status: 'error',
|
|
1093
|
+
content: contentString,
|
|
1094
|
+
name: entry.call.name,
|
|
1095
|
+
tool_call_id: entry.call.id!,
|
|
1096
|
+
})
|
|
1097
|
+
);
|
|
1098
|
+
postToolBatchEntryByCallId.set(entry.call.id!, {
|
|
1099
|
+
toolName: entry.call.name,
|
|
1100
|
+
toolInput: entry.args,
|
|
1101
|
+
toolUseId: entry.call.id!,
|
|
1102
|
+
stepId: entry.stepId,
|
|
1103
|
+
/**
|
|
1104
|
+
* Records the pre-invocation turn count — the same value the
|
|
1105
|
+
* executed path captures before incrementing `toolUsageCount`.
|
|
1106
|
+
* For a blocked tool the counter is never incremented (no
|
|
1107
|
+
* invocation happened), so this is always the count of prior
|
|
1108
|
+
* successful invocations of this tool name in earlier batches.
|
|
1109
|
+
* Surfaces in the `PostToolBatch` entry so batch hooks see
|
|
1110
|
+
* a uniform shape regardless of outcome.
|
|
1111
|
+
*/
|
|
1112
|
+
turn: this.toolUsageCount.get(entry.call.name) ?? 0,
|
|
1113
|
+
status: 'error',
|
|
1114
|
+
error: contentString,
|
|
1115
|
+
});
|
|
1116
|
+
deferredBlockedSideEffects.push({
|
|
1117
|
+
callId: entry.call.id!,
|
|
1118
|
+
toolName: entry.call.name,
|
|
1119
|
+
args: entry.args,
|
|
1120
|
+
contentString,
|
|
1121
|
+
reason,
|
|
1122
|
+
});
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
const flushDeferredBlockedSideEffects = (): void => {
|
|
1126
|
+
for (const item of deferredBlockedSideEffects) {
|
|
927
1127
|
this.dispatchStepCompleted(
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
contentString,
|
|
1128
|
+
item.callId,
|
|
1129
|
+
item.toolName,
|
|
1130
|
+
item.args,
|
|
1131
|
+
item.contentString,
|
|
932
1132
|
config
|
|
933
1133
|
);
|
|
934
|
-
if (
|
|
1134
|
+
if (hookRegistry.hasHookFor('PermissionDenied', runId)) {
|
|
935
1135
|
executeHooks({
|
|
936
|
-
registry:
|
|
1136
|
+
registry: hookRegistry,
|
|
937
1137
|
input: {
|
|
938
1138
|
hook_event_name: 'PermissionDenied',
|
|
939
1139
|
runId,
|
|
940
1140
|
threadId,
|
|
941
1141
|
agentId: this.agentId,
|
|
942
|
-
toolName:
|
|
943
|
-
toolInput:
|
|
944
|
-
toolUseId:
|
|
945
|
-
reason,
|
|
1142
|
+
toolName: item.toolName,
|
|
1143
|
+
toolInput: item.args,
|
|
1144
|
+
toolUseId: item.callId,
|
|
1145
|
+
reason: item.reason,
|
|
946
1146
|
},
|
|
947
1147
|
sessionId: runId,
|
|
948
|
-
matchQuery:
|
|
1148
|
+
matchQuery: item.toolName,
|
|
949
1149
|
}).catch(() => {
|
|
950
1150
|
/* PermissionDenied is observational — swallow errors */
|
|
951
1151
|
});
|
|
952
1152
|
}
|
|
1153
|
+
}
|
|
1154
|
+
deferredBlockedSideEffects.length = 0;
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Apply a hook-supplied or host-supplied input override to a pending
|
|
1159
|
+
* entry, re-running the `{{tool<i>turn<n>}}` resolver so any new
|
|
1160
|
+
* placeholders introduced by the override are substituted (and any
|
|
1161
|
+
* formerly-unresolved refs cleared from the unresolved set).
|
|
1162
|
+
*
|
|
1163
|
+
* Mixed direct+event batches must use the pre-batch snapshot so a
|
|
1164
|
+
* hook-introduced placeholder cannot accidentally resolve to a
|
|
1165
|
+
* same-turn direct output that has just registered. Pure event
|
|
1166
|
+
* batches don't have a snapshot and resolve against the live
|
|
1167
|
+
* registry — safe because no event-side registrations have happened
|
|
1168
|
+
* yet.
|
|
1169
|
+
*/
|
|
1170
|
+
const applyInputOverride = (
|
|
1171
|
+
entry: PendingEntry,
|
|
1172
|
+
nextArgs: Record<string, unknown>
|
|
1173
|
+
): void => {
|
|
1174
|
+
if (registry != null) {
|
|
1175
|
+
const view: ToolOutputResolveView = preBatchSnapshot ?? {
|
|
1176
|
+
resolve: <T>(args: T) => registry.resolve(registryRunId, args),
|
|
1177
|
+
};
|
|
1178
|
+
const { resolved, unresolved } = view.resolve(nextArgs);
|
|
1179
|
+
entry.args = resolved as Record<string, unknown>;
|
|
1180
|
+
if (entry.call.id != null) {
|
|
1181
|
+
if (unresolved.length > 0) {
|
|
1182
|
+
unresolvedByCallId.set(entry.call.id, unresolved);
|
|
1183
|
+
} else {
|
|
1184
|
+
unresolvedByCallId.delete(entry.call.id);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
entry.args = nextArgs;
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
const askEntries: Array<{
|
|
1193
|
+
entry: PendingEntry;
|
|
1194
|
+
reason?: string;
|
|
1195
|
+
allowedDecisions?: ReadonlyArray<
|
|
1196
|
+
'approve' | 'reject' | 'edit' | 'respond'
|
|
1197
|
+
>;
|
|
1198
|
+
}> = [];
|
|
1199
|
+
|
|
1200
|
+
for (let i = 0; i < preToolCalls.length; i++) {
|
|
1201
|
+
const hookResult = preResults[i];
|
|
1202
|
+
const entry = preToolCalls[i];
|
|
1203
|
+
|
|
1204
|
+
for (const ctx of hookResult.additionalContexts) {
|
|
1205
|
+
batchAdditionalContexts.push(ctx);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
if (hookResult.decision === 'deny') {
|
|
1209
|
+
blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
|
|
953
1210
|
continue;
|
|
954
1211
|
}
|
|
1212
|
+
|
|
1213
|
+
if (hookResult.decision === 'ask') {
|
|
1214
|
+
/**
|
|
1215
|
+
* HITL is OFF by default — hosts must explicitly opt in via
|
|
1216
|
+
* `humanInTheLoop: { enabled: true }` to engage the
|
|
1217
|
+
* `interrupt()` path. When opted out (or omitted), `ask`
|
|
1218
|
+
* collapses into the pre-HITL fail-closed path: a blocked
|
|
1219
|
+
* tool with an error `ToolMessage`. The default stays
|
|
1220
|
+
* conservative until host UIs are ready to render
|
|
1221
|
+
* `tool_approval` interrupts; see `HumanInTheLoopConfig`
|
|
1222
|
+
* JSDoc for the full rationale and the migration plan.
|
|
1223
|
+
*/
|
|
1224
|
+
if (this.humanInTheLoop?.enabled !== true) {
|
|
1225
|
+
blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Apply `updatedInput` BEFORE queuing into `askEntries` —
|
|
1230
|
+
* a hook is allowed to return both a sanitization rewrite
|
|
1231
|
+
* and an `ask` decision (e.g. one matcher redacts secrets,
|
|
1232
|
+
* another matcher requires approval). Without this, the
|
|
1233
|
+
* interrupt payload would surface the original args to the
|
|
1234
|
+
* reviewer AND the post-approve execution would run with
|
|
1235
|
+
* the original args, silently dropping the hook's rewrite.
|
|
1236
|
+
*/
|
|
1237
|
+
if (hookResult.updatedInput != null) {
|
|
1238
|
+
applyInputOverride(entry, hookResult.updatedInput);
|
|
1239
|
+
}
|
|
1240
|
+
askEntries.push({
|
|
1241
|
+
entry,
|
|
1242
|
+
reason: hookResult.reason,
|
|
1243
|
+
allowedDecisions: hookResult.allowedDecisions,
|
|
1244
|
+
});
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
955
1248
|
if (hookResult.updatedInput != null) {
|
|
1249
|
+
applyInputOverride(entry, hookResult.updatedInput);
|
|
1250
|
+
}
|
|
1251
|
+
approvedEntries.push(entry);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* If any entries asked for approval, raise a single LangGraph
|
|
1256
|
+
* `interrupt()` carrying every pending request together. The host
|
|
1257
|
+
* pauses, gathers human input, and resumes the run with one
|
|
1258
|
+
* decision per request. On resume LangGraph re-executes this node
|
|
1259
|
+
* from the start; `interrupt()` then returns the resume value
|
|
1260
|
+
* instead of throwing, so the loop above re-runs and the same
|
|
1261
|
+
* `askEntries` list is rebuilt deterministically (assuming hooks
|
|
1262
|
+
* are pure — see `humanInTheLoop` docs).
|
|
1263
|
+
*/
|
|
1264
|
+
if (askEntries.length > 0) {
|
|
1265
|
+
const payload = buildToolApprovalInterruptPayload(askEntries);
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* `interrupt()` reads the current `RunnableConfig` from
|
|
1269
|
+
* AsyncLocalStorage, but our `RunnableCallable` sets
|
|
1270
|
+
* `trace = false` for ToolNode (intentional — avoids LangSmith
|
|
1271
|
+
* tracing per tool call). Without the trace path, the upstream
|
|
1272
|
+
* `runWithConfig` frame is never established, so we re-anchor
|
|
1273
|
+
* here using the node's own `config` — Pregel hands us a
|
|
1274
|
+
* config that already carries every checkpoint/scratchpad key
|
|
1275
|
+
* `interrupt()` needs to suspend and resume.
|
|
1276
|
+
*/
|
|
1277
|
+
const resumeValue = AsyncLocalStorageProviderSingleton.runWithConfig(
|
|
1278
|
+
config,
|
|
1279
|
+
() =>
|
|
1280
|
+
interrupt<
|
|
1281
|
+
t.ToolApprovalInterruptPayload,
|
|
1282
|
+
t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap
|
|
1283
|
+
>(payload)
|
|
1284
|
+
);
|
|
1285
|
+
|
|
1286
|
+
const decisionByCallId = normalizeApprovalDecisions(
|
|
1287
|
+
askEntries.map(({ entry }) => entry.call.id!),
|
|
1288
|
+
resumeValue
|
|
1289
|
+
);
|
|
1290
|
+
|
|
1291
|
+
for (const {
|
|
1292
|
+
entry,
|
|
1293
|
+
reason: askReason,
|
|
1294
|
+
allowedDecisions,
|
|
1295
|
+
} of askEntries) {
|
|
1296
|
+
const decision = decisionByCallId.get(entry.call.id!) ?? {
|
|
1297
|
+
type: 'reject' as const,
|
|
1298
|
+
reason: 'No decision provided for tool approval',
|
|
1299
|
+
};
|
|
1300
|
+
/**
|
|
1301
|
+
* Read `decision.type` through a widened view once: hosts
|
|
1302
|
+
* deserialize resume payloads from untyped JSON, so the
|
|
1303
|
+
* runtime value can be a typo, the wrong type, or missing
|
|
1304
|
+
* entirely. Both the `allowedDecisions` enforcement
|
|
1305
|
+
* immediately below and the unknown-type fallthrough at the
|
|
1306
|
+
* end of this loop body share this single read so the
|
|
1307
|
+
* fail-closed checks compare against the same source.
|
|
1308
|
+
*/
|
|
1309
|
+
const declaredType = (decision as { type?: unknown }).type;
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Enforce the per-tool `allowedDecisions` allowlist that the
|
|
1313
|
+
* `PreToolUse` hook surfaced in `review_configs`. The host
|
|
1314
|
+
* UI is supposed to honor this when collecting the user's
|
|
1315
|
+
* decision, but the wire is untrusted: a buggy or hostile
|
|
1316
|
+
* host could submit a decision type the policy explicitly
|
|
1317
|
+
* forbids (e.g. `'edit'` when the hook restricted to
|
|
1318
|
+
* `['approve', 'reject']`), bypassing argument-mutation /
|
|
1319
|
+
* response-substitution safeguards. Fail closed when the
|
|
1320
|
+
* declared type isn't in the allowlist.
|
|
1321
|
+
*/
|
|
1322
|
+
if (
|
|
1323
|
+
allowedDecisions != null &&
|
|
1324
|
+
(typeof declaredType !== 'string' ||
|
|
1325
|
+
!allowedDecisions.includes(
|
|
1326
|
+
declaredType as t.ToolApprovalDecisionType
|
|
1327
|
+
))
|
|
1328
|
+
) {
|
|
1329
|
+
const offered =
|
|
1330
|
+
typeof declaredType === 'string' ? declaredType : '<missing>';
|
|
1331
|
+
blockEntry(
|
|
1332
|
+
entry,
|
|
1333
|
+
`Decision "${offered}" not in allowedDecisions [${allowedDecisions.join(', ')}] — failing closed`
|
|
1334
|
+
);
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
if (decision.type === 'reject') {
|
|
1339
|
+
blockEntry(
|
|
1340
|
+
entry,
|
|
1341
|
+
decision.reason ?? askReason ?? 'Rejected by user'
|
|
1342
|
+
);
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
956
1346
|
/**
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
*
|
|
961
|
-
*
|
|
962
|
-
* with what the tool will actually receive.
|
|
1347
|
+
* `respond` short-circuits tool execution: the human supplies
|
|
1348
|
+
* the result the model should see in place of running the
|
|
1349
|
+
* tool. We emit a successful `ToolMessage` directly and skip
|
|
1350
|
+
* dispatch — no host event fires, no real tool side effect
|
|
1351
|
+
* occurs. Mirrors LangChain HITL middleware semantics.
|
|
963
1352
|
*/
|
|
964
|
-
if (
|
|
1353
|
+
if (decision.type === 'respond') {
|
|
965
1354
|
/**
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
970
|
-
*
|
|
971
|
-
*
|
|
1355
|
+
* Validate the wire shape before touching it: hosts
|
|
1356
|
+
* deserialize resume payloads from untyped JSON, so a
|
|
1357
|
+
* malformed `{ type: 'respond' }` (no `responseText`) or
|
|
1358
|
+
* `{ type: 'respond', responseText: 42 }` would crash
|
|
1359
|
+
* `truncateToolResultContent` (which calls
|
|
1360
|
+
* `content.length`) and turn a fail-closed approval path
|
|
1361
|
+
* into a hard run failure. Route bad shapes through
|
|
1362
|
+
* `blockEntry` like any other unusable decision.
|
|
972
1363
|
*/
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1364
|
+
const responseText = (decision as { responseText?: unknown })
|
|
1365
|
+
.responseText;
|
|
1366
|
+
if (typeof responseText !== 'string') {
|
|
1367
|
+
blockEntry(
|
|
1368
|
+
entry,
|
|
1369
|
+
`Decision "respond" missing string responseText (got ${describeOfferedShape(responseText)}) — failing closed`
|
|
1370
|
+
);
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Truncate the human-supplied text just like the success
|
|
1375
|
+
* path does for real tool output. Without this, a user
|
|
1376
|
+
* pasting a large document as a manual response bypasses
|
|
1377
|
+
* `maxToolResultChars` and can blow past the model's
|
|
1378
|
+
* context window. The PostToolBatch entry surfaces the
|
|
1379
|
+
* truncated text too so batch hooks see what the model
|
|
1380
|
+
* will actually see.
|
|
1381
|
+
*/
|
|
1382
|
+
const truncatedResponse = truncateToolResultContent(
|
|
1383
|
+
responseText,
|
|
1384
|
+
this.maxToolResultChars
|
|
978
1385
|
);
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1386
|
+
messageByCallId.set(
|
|
1387
|
+
entry.call.id!,
|
|
1388
|
+
new ToolMessage({
|
|
1389
|
+
status: 'success',
|
|
1390
|
+
content: truncatedResponse,
|
|
1391
|
+
name: entry.call.name,
|
|
1392
|
+
tool_call_id: entry.call.id!,
|
|
1393
|
+
})
|
|
1394
|
+
);
|
|
1395
|
+
postToolBatchEntryByCallId.set(entry.call.id!, {
|
|
1396
|
+
toolName: entry.call.name,
|
|
1397
|
+
toolInput: entry.args,
|
|
1398
|
+
toolUseId: entry.call.id!,
|
|
1399
|
+
stepId: entry.stepId,
|
|
1400
|
+
turn: this.toolUsageCount.get(entry.call.name) ?? 0,
|
|
1401
|
+
status: 'success',
|
|
1402
|
+
toolOutput: truncatedResponse,
|
|
1403
|
+
});
|
|
1404
|
+
/**
|
|
1405
|
+
* Safe to dispatch immediately — unlike `blockEntry` which
|
|
1406
|
+
* defers, `respond` only executes inside the decision-
|
|
1407
|
+
* processing loop, which is reachable only AFTER
|
|
1408
|
+
* `interrupt()` has returned (the resume pass). There is
|
|
1409
|
+
* no risk of being rolled back by a subsequent throw, so
|
|
1410
|
+
* no risk of a duplicate `ON_RUN_STEP_COMPLETED` event.
|
|
1411
|
+
*/
|
|
1412
|
+
this.dispatchStepCompleted(
|
|
1413
|
+
entry.call.id!,
|
|
1414
|
+
entry.call.name,
|
|
1415
|
+
entry.args,
|
|
1416
|
+
truncatedResponse,
|
|
1417
|
+
config
|
|
1418
|
+
);
|
|
1419
|
+
continue;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
if (decision.type === 'edit') {
|
|
1423
|
+
/**
|
|
1424
|
+
* Validate the wire shape before touching it: hosts
|
|
1425
|
+
* deserialize resume payloads from untyped JSON, so a
|
|
1426
|
+
* malformed `{ type: 'edit' }` (no `updatedInput`),
|
|
1427
|
+
* `{ type: 'edit', updatedInput: 'string' }` (non-object),
|
|
1428
|
+
* or `{ type: 'edit', updatedInput: [...] }` (array, not a
|
|
1429
|
+
* plain object) would feed garbage into
|
|
1430
|
+
* `applyInputOverride` and silently approve a tool with
|
|
1431
|
+
* undefined / wrong-shape args. Same trust boundary as
|
|
1432
|
+
* the `respond` validation above — fail closed via
|
|
1433
|
+
* `blockEntry` with a diagnostic.
|
|
1434
|
+
*/
|
|
1435
|
+
const updatedInput = (decision as { updatedInput?: unknown })
|
|
1436
|
+
.updatedInput;
|
|
1437
|
+
if (
|
|
1438
|
+
updatedInput === null ||
|
|
1439
|
+
typeof updatedInput !== 'object' ||
|
|
1440
|
+
Array.isArray(updatedInput)
|
|
1441
|
+
) {
|
|
1442
|
+
blockEntry(
|
|
1443
|
+
entry,
|
|
1444
|
+
`Decision "edit" missing object updatedInput (got ${describeOfferedShape(updatedInput)}) — failing closed`
|
|
1445
|
+
);
|
|
1446
|
+
continue;
|
|
986
1447
|
}
|
|
987
|
-
|
|
988
|
-
entry
|
|
1448
|
+
applyInputOverride(entry, updatedInput as Record<string, unknown>);
|
|
1449
|
+
approvedEntries.push(entry);
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Defensive type widening: hosts deserialize resume payloads
|
|
1455
|
+
* from untyped JSON, so the `decision.type` value at runtime
|
|
1456
|
+
* is whatever string the wire sent — not necessarily one of
|
|
1457
|
+
* the four union variants TS knows about. We compare against
|
|
1458
|
+
* the literal `'approve'` through the widened `declaredType`
|
|
1459
|
+
* captured at the top of this iteration, so a typo or schema
|
|
1460
|
+
* drift (`'aproved'`, `null`, `undefined`) hits the fail-
|
|
1461
|
+
* closed branch below instead of silently approving the
|
|
1462
|
+
* tool. Without this widening, TS narrows the union after
|
|
1463
|
+
* the three earlier branches and treats `=== 'approve'` as
|
|
1464
|
+
* trivially true.
|
|
1465
|
+
*/
|
|
1466
|
+
if (declaredType === 'approve') {
|
|
1467
|
+
approvedEntries.push(entry);
|
|
1468
|
+
continue;
|
|
989
1469
|
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* Unknown / missing decision type — fail closed. The whole
|
|
1473
|
+
* point of an approval gate is that "no decision" or
|
|
1474
|
+
* "garbled decision" deny by default.
|
|
1475
|
+
*/
|
|
1476
|
+
const unknownType =
|
|
1477
|
+
typeof declaredType === 'string' ? declaredType : '<missing>';
|
|
1478
|
+
blockEntry(
|
|
1479
|
+
entry,
|
|
1480
|
+
`Unknown approval decision type "${unknownType}" — failing closed`
|
|
1481
|
+
);
|
|
990
1482
|
}
|
|
991
|
-
approvedEntries.push(entry);
|
|
992
1483
|
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Flush deferred denial side effects exactly once. On the FIRST
|
|
1487
|
+
* pass through a batch that contains an `ask`, `interrupt()`
|
|
1488
|
+
* threw above and we never reach this line — so no
|
|
1489
|
+
* `ON_RUN_STEP_COMPLETED` / `PermissionDenied` events fire
|
|
1490
|
+
* for blocked tools yet. On resume the node re-executes from
|
|
1491
|
+
* scratch, `blockEntry` re-queues the same entries, and the
|
|
1492
|
+
* flush below dispatches them once. For batches without any
|
|
1493
|
+
* `ask` (deny-only or empty), the flush still runs here and
|
|
1494
|
+
* dispatches in the same relative position as the pre-deferral
|
|
1495
|
+
* code did (after hook processing, before tool execution).
|
|
1496
|
+
*/
|
|
1497
|
+
flushDeferredBlockedSideEffects();
|
|
993
1498
|
} else {
|
|
994
1499
|
approvedEntries.push(...preToolCalls);
|
|
995
1500
|
}
|
|
@@ -1087,6 +1592,15 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1087
1592
|
|
|
1088
1593
|
let contentString: string;
|
|
1089
1594
|
let toolMessage: ToolMessage;
|
|
1595
|
+
/**
|
|
1596
|
+
* Tracks the post-PostToolUse-hook output so the
|
|
1597
|
+
* `PostToolBatch` entry below sees the final transformed value
|
|
1598
|
+
* even when a hook replaced the original via `updatedOutput`.
|
|
1599
|
+
* Lives at the loop-iteration scope so the success branch can
|
|
1600
|
+
* mutate it; the error branch leaves it unset (and the batch
|
|
1601
|
+
* entry uses `error` instead of `toolOutput` in that case).
|
|
1602
|
+
*/
|
|
1603
|
+
let finalToolOutput: unknown = result.content;
|
|
1090
1604
|
|
|
1091
1605
|
if (result.status === 'error') {
|
|
1092
1606
|
contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
|
|
@@ -1118,7 +1632,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1118
1632
|
});
|
|
1119
1633
|
|
|
1120
1634
|
if (hasFailureHook) {
|
|
1121
|
-
await executeHooks({
|
|
1635
|
+
const failureHookResult = await executeHooks({
|
|
1122
1636
|
registry: this.hookRegistry!,
|
|
1123
1637
|
input: {
|
|
1124
1638
|
hook_event_name: 'PostToolUseFailure',
|
|
@@ -1134,9 +1648,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1134
1648
|
},
|
|
1135
1649
|
sessionId: runId,
|
|
1136
1650
|
matchQuery: toolName,
|
|
1137
|
-
}).catch(() =>
|
|
1138
|
-
|
|
1139
|
-
|
|
1651
|
+
}).catch((): undefined => undefined);
|
|
1652
|
+
/**
|
|
1653
|
+
* Collect `additionalContext` from failure hooks too. Without
|
|
1654
|
+
* this, recovery guidance returned on tool errors (e.g.
|
|
1655
|
+
* "if this tool errors with X, suggest Y to the user") is
|
|
1656
|
+
* silently dropped even though the API surface advertises
|
|
1657
|
+
* `additionalContext` for this event. PostToolUseFailure
|
|
1658
|
+
* remains observational for errors thrown by the hook
|
|
1659
|
+
* itself, but a successfully-returned result is honored.
|
|
1660
|
+
*/
|
|
1661
|
+
if (failureHookResult != null) {
|
|
1662
|
+
for (const ctx of failureHookResult.additionalContexts) {
|
|
1663
|
+
batchAdditionalContexts.push(ctx);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1140
1666
|
}
|
|
1141
1667
|
} else {
|
|
1142
1668
|
let registryRaw =
|
|
@@ -1166,6 +1692,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1166
1692
|
sessionId: runId,
|
|
1167
1693
|
matchQuery: toolName,
|
|
1168
1694
|
}).catch((): undefined => undefined);
|
|
1695
|
+
if (hookResult != null) {
|
|
1696
|
+
for (const ctx of hookResult.additionalContexts) {
|
|
1697
|
+
batchAdditionalContexts.push(ctx);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1169
1700
|
if (hookResult?.updatedOutput != null) {
|
|
1170
1701
|
const replaced =
|
|
1171
1702
|
typeof hookResult.updatedOutput === 'string'
|
|
@@ -1176,6 +1707,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1176
1707
|
replaced,
|
|
1177
1708
|
this.maxToolResultChars
|
|
1178
1709
|
);
|
|
1710
|
+
finalToolOutput = hookResult.updatedOutput;
|
|
1179
1711
|
}
|
|
1180
1712
|
}
|
|
1181
1713
|
|
|
@@ -1215,6 +1747,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1215
1747
|
request?.turn
|
|
1216
1748
|
);
|
|
1217
1749
|
|
|
1750
|
+
postToolBatchEntryByCallId.set(result.toolCallId, {
|
|
1751
|
+
toolName,
|
|
1752
|
+
toolInput: request?.args ?? {},
|
|
1753
|
+
toolUseId: result.toolCallId,
|
|
1754
|
+
stepId: request?.stepId,
|
|
1755
|
+
turn: request?.turn,
|
|
1756
|
+
status: result.status === 'error' ? 'error' : 'success',
|
|
1757
|
+
...(result.status === 'error'
|
|
1758
|
+
? { error: result.errorMessage ?? 'Unknown error' }
|
|
1759
|
+
: { toolOutput: finalToolOutput }),
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1218
1762
|
messageByCallId.set(result.toolCallId, toolMessage);
|
|
1219
1763
|
}
|
|
1220
1764
|
}
|
|
@@ -1222,9 +1766,105 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1222
1766
|
const toolMessages = toolCalls
|
|
1223
1767
|
.map((call) => messageByCallId.get(call.id!))
|
|
1224
1768
|
.filter((m): m is ToolMessage => m != null);
|
|
1769
|
+
|
|
1770
|
+
await this.dispatchPostToolBatchAndInjectContext({
|
|
1771
|
+
toolCalls,
|
|
1772
|
+
entriesByCallId: postToolBatchEntryByCallId,
|
|
1773
|
+
batchAdditionalContexts,
|
|
1774
|
+
injected,
|
|
1775
|
+
runId,
|
|
1776
|
+
threadId,
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1225
1779
|
return { toolMessages, injected };
|
|
1226
1780
|
}
|
|
1227
1781
|
|
|
1782
|
+
/**
|
|
1783
|
+
* Fires the `PostToolBatch` hook (if registered) and appends the
|
|
1784
|
+
* accumulated batch-level `additionalContext` strings to `injected`
|
|
1785
|
+
* as a single `HumanMessage`. Entries are materialized in the
|
|
1786
|
+
* original `toolCalls` order so hooks correlating outcomes by
|
|
1787
|
+
* position (as the type docs promise) see exactly the sequence
|
|
1788
|
+
* the model emitted, regardless of when each individual outcome
|
|
1789
|
+
* was recorded into the map (deny synchronous, approved
|
|
1790
|
+
* post-execution, respond on resume).
|
|
1791
|
+
*
|
|
1792
|
+
* The PostToolBatch hook's `additionalContexts` flow into the same
|
|
1793
|
+
* batch accumulator per-tool hooks already use, so a single
|
|
1794
|
+
* batch-level convention message can be injected through one path.
|
|
1795
|
+
*
|
|
1796
|
+
* Mutates `batchAdditionalContexts` (push from batch hook) and
|
|
1797
|
+
* `injected` (push the consolidated HumanMessage). The caller owns
|
|
1798
|
+
* those arrays and consumes them right after this returns.
|
|
1799
|
+
*/
|
|
1800
|
+
private async dispatchPostToolBatchAndInjectContext(args: {
|
|
1801
|
+
toolCalls: ToolCall[];
|
|
1802
|
+
entriesByCallId: Map<string, PostToolBatchEntry>;
|
|
1803
|
+
batchAdditionalContexts: string[];
|
|
1804
|
+
injected: BaseMessage[];
|
|
1805
|
+
runId: string;
|
|
1806
|
+
threadId: string | undefined;
|
|
1807
|
+
}): Promise<void> {
|
|
1808
|
+
const {
|
|
1809
|
+
toolCalls,
|
|
1810
|
+
entriesByCallId,
|
|
1811
|
+
batchAdditionalContexts,
|
|
1812
|
+
injected,
|
|
1813
|
+
runId,
|
|
1814
|
+
threadId,
|
|
1815
|
+
} = args;
|
|
1816
|
+
|
|
1817
|
+
const orderedBatchEntries: PostToolBatchEntry[] = [];
|
|
1818
|
+
for (const call of toolCalls) {
|
|
1819
|
+
const callId = call.id;
|
|
1820
|
+
if (callId == null) {
|
|
1821
|
+
continue;
|
|
1822
|
+
}
|
|
1823
|
+
const entry = entriesByCallId.get(callId);
|
|
1824
|
+
if (entry != null) {
|
|
1825
|
+
orderedBatchEntries.push(entry);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
if (
|
|
1829
|
+
this.hookRegistry?.hasHookFor('PostToolBatch', runId) === true &&
|
|
1830
|
+
orderedBatchEntries.length > 0
|
|
1831
|
+
) {
|
|
1832
|
+
const batchHookResult = await executeHooks({
|
|
1833
|
+
registry: this.hookRegistry,
|
|
1834
|
+
input: {
|
|
1835
|
+
hook_event_name: 'PostToolBatch',
|
|
1836
|
+
runId,
|
|
1837
|
+
threadId,
|
|
1838
|
+
agentId: this.agentId,
|
|
1839
|
+
entries: orderedBatchEntries,
|
|
1840
|
+
},
|
|
1841
|
+
sessionId: runId,
|
|
1842
|
+
}).catch((): undefined => undefined);
|
|
1843
|
+
if (batchHookResult != null) {
|
|
1844
|
+
for (const ctx of batchHookResult.additionalContexts) {
|
|
1845
|
+
batchAdditionalContexts.push(ctx);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
if (batchAdditionalContexts.length > 0) {
|
|
1851
|
+
/**
|
|
1852
|
+
* `HumanMessage` carrying a metadata `role: 'system'` marker —
|
|
1853
|
+
* see `convertInjectedMessages` for the wider rationale. Anthropic
|
|
1854
|
+
* and Google reject mid-conversation `SystemMessage`s, so we use
|
|
1855
|
+
* a user-role message and surface the system intent through
|
|
1856
|
+
* `additional_kwargs` for hosts inspecting state. The model sees
|
|
1857
|
+
* a user message; `role` is metadata only.
|
|
1858
|
+
*/
|
|
1859
|
+
injected.push(
|
|
1860
|
+
new HumanMessage({
|
|
1861
|
+
content: batchAdditionalContexts.join('\n\n'),
|
|
1862
|
+
additional_kwargs: { role: 'system', source: 'hook' },
|
|
1863
|
+
})
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1228
1868
|
private dispatchStepCompleted(
|
|
1229
1869
|
toolCallId: string,
|
|
1230
1870
|
toolName: string,
|
|
@@ -1282,7 +1922,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1282
1922
|
if (msg.skillName != null) additional_kwargs.skillName = msg.skillName;
|
|
1283
1923
|
|
|
1284
1924
|
converted.push(
|
|
1285
|
-
new HumanMessage({
|
|
1925
|
+
new HumanMessage({
|
|
1926
|
+
content: toLangChainContent(msg.content),
|
|
1927
|
+
additional_kwargs,
|
|
1928
|
+
})
|
|
1286
1929
|
);
|
|
1287
1930
|
}
|
|
1288
1931
|
return converted;
|