@librechat/agents 3.1.68 → 3.1.71-dev.0
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/agents/AgentContext.cjs +23 -3
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/common/enum.cjs +16 -1
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +136 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +162 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -0
- package/dist/cjs/hooks/executeHooks.cjs +276 -0
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -0
- package/dist/cjs/hooks/matchers.cjs +256 -0
- package/dist/cjs/hooks/matchers.cjs.map +1 -0
- package/dist/cjs/hooks/types.cjs +27 -0
- package/dist/cjs/hooks/types.cjs.map +1 -0
- package/dist/cjs/main.cjs +57 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +74 -12
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +9 -2
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +115 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +44 -0
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +208 -0
- package/dist/cjs/tools/BashExecutor.cjs.map +1 -0
- package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +287 -0
- package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -0
- package/dist/cjs/tools/CodeExecutor.cjs +0 -9
- package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +7 -23
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/ReadFile.cjs +43 -0
- package/dist/cjs/tools/ReadFile.cjs.map +1 -0
- package/dist/cjs/tools/SkillTool.cjs +50 -0
- package/dist/cjs/tools/SkillTool.cjs.map +1 -0
- package/dist/cjs/tools/SubagentTool.cjs +92 -0
- package/dist/cjs/tools/SubagentTool.cjs.map +1 -0
- package/dist/cjs/tools/ToolNode.cjs +746 -174
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +2 -13
- package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
- package/dist/cjs/tools/skillCatalog.cjs +84 -0
- package/dist/cjs/tools/skillCatalog.cjs.map +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +511 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -0
- package/dist/cjs/tools/toolOutputReferences.cjs +475 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -0
- package/dist/cjs/utils/truncation.cjs +28 -0
- package/dist/cjs/utils/truncation.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +23 -3
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/common/enum.mjs +15 -2
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +136 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +160 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -0
- package/dist/esm/hooks/executeHooks.mjs +273 -0
- package/dist/esm/hooks/executeHooks.mjs.map +1 -0
- package/dist/esm/hooks/matchers.mjs +251 -0
- package/dist/esm/hooks/matchers.mjs.map +1 -0
- package/dist/esm/hooks/types.mjs +25 -0
- package/dist/esm/hooks/types.mjs.map +1 -0
- package/dist/esm/main.mjs +13 -2
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +66 -4
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +9 -2
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +115 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +44 -0
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +200 -0
- package/dist/esm/tools/BashExecutor.mjs.map +1 -0
- package/dist/esm/tools/BashProgrammaticToolCalling.mjs +278 -0
- package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -0
- package/dist/esm/tools/CodeExecutor.mjs +0 -9
- package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +8 -24
- package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/ReadFile.mjs +38 -0
- package/dist/esm/tools/ReadFile.mjs.map +1 -0
- package/dist/esm/tools/SkillTool.mjs +45 -0
- package/dist/esm/tools/SkillTool.mjs.map +1 -0
- package/dist/esm/tools/SubagentTool.mjs +85 -0
- package/dist/esm/tools/SubagentTool.mjs.map +1 -0
- package/dist/esm/tools/ToolNode.mjs +748 -176
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +3 -14
- package/dist/esm/tools/ToolSearch.mjs.map +1 -1
- package/dist/esm/tools/skillCatalog.mjs +82 -0
- package/dist/esm/tools/skillCatalog.mjs.map +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +505 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -0
- package/dist/esm/tools/toolOutputReferences.mjs +468 -0
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -0
- package/dist/esm/utils/truncation.mjs +27 -1
- package/dist/esm/utils/truncation.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +6 -0
- package/dist/types/common/enum.d.ts +10 -2
- package/dist/types/graphs/Graph.d.ts +23 -0
- package/dist/types/hooks/HookRegistry.d.ts +56 -0
- package/dist/types/hooks/executeHooks.d.ts +79 -0
- package/dist/types/hooks/index.d.ts +6 -0
- package/dist/types/hooks/matchers.d.ts +95 -0
- package/dist/types/hooks/types.d.ts +320 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/messages/format.d.ts +2 -1
- package/dist/types/run.d.ts +2 -0
- package/dist/types/summarization/node.d.ts +2 -0
- package/dist/types/tools/BashExecutor.d.ts +76 -0
- package/dist/types/tools/BashProgrammaticToolCalling.d.ts +72 -0
- package/dist/types/tools/ProgrammaticToolCalling.d.ts +4 -9
- package/dist/types/tools/ReadFile.d.ts +28 -0
- package/dist/types/tools/SkillTool.d.ts +40 -0
- package/dist/types/tools/SubagentTool.d.ts +36 -0
- package/dist/types/tools/ToolNode.d.ts +109 -4
- package/dist/types/tools/ToolSearch.d.ts +2 -2
- package/dist/types/tools/skillCatalog.d.ts +19 -0
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +137 -0
- package/dist/types/tools/subagent/index.d.ts +2 -0
- package/dist/types/tools/toolOutputReferences.d.ts +205 -0
- package/dist/types/types/graph.d.ts +61 -2
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/run.d.ts +28 -0
- package/dist/types/types/skill.d.ts +9 -0
- package/dist/types/types/tools.d.ts +108 -10
- package/dist/types/utils/truncation.d.ts +21 -0
- package/package.json +5 -1
- package/src/agents/AgentContext.ts +26 -2
- package/src/common/enum.ts +15 -1
- package/src/graphs/Graph.ts +161 -0
- package/src/hooks/HookRegistry.ts +208 -0
- package/src/hooks/__tests__/HookRegistry.test.ts +190 -0
- package/src/hooks/__tests__/compactHooks.test.ts +214 -0
- package/src/hooks/__tests__/executeHooks.test.ts +1013 -0
- package/src/hooks/__tests__/integration.test.ts +337 -0
- package/src/hooks/__tests__/matchers.test.ts +238 -0
- package/src/hooks/__tests__/toolHooks.test.ts +669 -0
- package/src/hooks/executeHooks.ts +375 -0
- package/src/hooks/index.ts +57 -0
- package/src/hooks/matchers.ts +280 -0
- package/src/hooks/types.ts +404 -0
- package/src/index.ts +10 -0
- package/src/messages/format.ts +74 -4
- package/src/messages/formatAgentMessages.skills.test.ts +334 -0
- package/src/messages/prune.ts +9 -2
- package/src/run.ts +130 -0
- package/src/scripts/multi-agent-subagent.ts +246 -0
- package/src/scripts/programmatic_exec.ts +1 -10
- package/src/scripts/subagent-event-driven-debug.ts +190 -0
- package/src/scripts/subagent-tools-debug.ts +160 -0
- package/src/scripts/test_code_api.ts +0 -7
- package/src/scripts/tool_search.ts +1 -10
- package/src/specs/prune.test.ts +413 -0
- package/src/specs/subagent.test.ts +305 -0
- package/src/summarization/node.ts +53 -0
- package/src/tools/BashExecutor.ts +238 -0
- package/src/tools/BashProgrammaticToolCalling.ts +381 -0
- package/src/tools/CodeExecutor.ts +0 -11
- package/src/tools/ProgrammaticToolCalling.ts +4 -29
- package/src/tools/ReadFile.ts +39 -0
- package/src/tools/SkillTool.ts +46 -0
- package/src/tools/SubagentTool.ts +100 -0
- package/src/tools/ToolNode.ts +999 -214
- package/src/tools/ToolSearch.ts +3 -19
- package/src/tools/__tests__/BashExecutor.test.ts +36 -0
- package/src/tools/__tests__/ProgrammaticToolCalling.integration.test.ts +7 -8
- package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +0 -1
- package/src/tools/__tests__/ReadFile.test.ts +44 -0
- package/src/tools/__tests__/SkillTool.test.ts +442 -0
- package/src/tools/__tests__/SubagentExecutor.test.ts +1148 -0
- package/src/tools/__tests__/SubagentTool.test.ts +149 -0
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +1395 -0
- package/src/tools/__tests__/ToolNode.session.test.ts +12 -12
- package/src/tools/__tests__/ToolSearch.integration.test.ts +7 -8
- package/src/tools/__tests__/skillCatalog.test.ts +161 -0
- package/src/tools/__tests__/subagentHooks.test.ts +215 -0
- package/src/tools/__tests__/toolOutputReferences.test.ts +415 -0
- package/src/tools/skillCatalog.ts +126 -0
- package/src/tools/subagent/SubagentExecutor.ts +676 -0
- package/src/tools/subagent/index.ts +13 -0
- package/src/tools/toolOutputReferences.ts +590 -0
- package/src/types/graph.ts +80 -1
- package/src/types/index.ts +1 -0
- package/src/types/run.ts +28 -0
- package/src/types/skill.ts +11 -0
- package/src/types/tools.ts +112 -10
- package/src/utils/__tests__/truncation.test.ts +66 -0
- package/src/utils/truncation.ts +30 -0
package/src/tools/ToolNode.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ToolCall } from '@langchain/core/messages/tool';
|
|
2
2
|
import {
|
|
3
3
|
ToolMessage,
|
|
4
|
+
HumanMessage,
|
|
4
5
|
isAIMessage,
|
|
5
6
|
isBaseMessage,
|
|
6
7
|
} from '@langchain/core/messages';
|
|
@@ -18,6 +19,12 @@ import type {
|
|
|
18
19
|
} from '@langchain/core/runnables';
|
|
19
20
|
import type { BaseMessage, AIMessage } from '@langchain/core/messages';
|
|
20
21
|
import type { StructuredToolInterface } from '@langchain/core/tools';
|
|
22
|
+
import type {
|
|
23
|
+
ToolOutputResolveView,
|
|
24
|
+
PreResolvedArgsMap,
|
|
25
|
+
ResolvedArgsByCallId,
|
|
26
|
+
} from '@/tools/toolOutputReferences';
|
|
27
|
+
import type { HookRegistry, AggregatedHookResult } from '@/hooks';
|
|
21
28
|
import type * as t from '@/types';
|
|
22
29
|
import { RunnableCallable } from '@/utils';
|
|
23
30
|
import {
|
|
@@ -25,7 +32,56 @@ import {
|
|
|
25
32
|
truncateToolResultContent,
|
|
26
33
|
} from '@/utils/truncation';
|
|
27
34
|
import { safeDispatchCustomEvent } from '@/utils/events';
|
|
28
|
-
import {
|
|
35
|
+
import { executeHooks } from '@/hooks';
|
|
36
|
+
import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
|
|
37
|
+
import {
|
|
38
|
+
buildReferenceKey,
|
|
39
|
+
annotateToolOutputWithReference,
|
|
40
|
+
ToolOutputReferenceRegistry,
|
|
41
|
+
} from '@/tools/toolOutputReferences';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Per-call batch context for `runTool`. Bundles every optional
|
|
45
|
+
* batch-scoped value the method needs so the signature stays at
|
|
46
|
+
* three positional parameters even as new context fields are added.
|
|
47
|
+
*/
|
|
48
|
+
type RunToolBatchContext = {
|
|
49
|
+
/** Position of this call within the parent ToolNode batch. */
|
|
50
|
+
batchIndex?: number;
|
|
51
|
+
/** Batch turn shared across every call in the batch. */
|
|
52
|
+
turn?: number;
|
|
53
|
+
/** Registry partition scope (run id or anonymous batch id). */
|
|
54
|
+
batchScopeId?: string;
|
|
55
|
+
/** Batch-local sink for post-substitution args. */
|
|
56
|
+
resolvedArgsByCallId?: ResolvedArgsByCallId;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Per-batch context for `dispatchToolEvents` / `executeViaEvent`.
|
|
61
|
+
* Mirrors {@link RunToolBatchContext} for the event-driven path,
|
|
62
|
+
* with bulk indices and the snapshot/pre-resolved-args carriers
|
|
63
|
+
* used in the mixed direct+event flow.
|
|
64
|
+
*/
|
|
65
|
+
type DispatchBatchContext = {
|
|
66
|
+
/** Per-call batch indices, parallel to the `toolCalls` array. */
|
|
67
|
+
batchIndices?: number[];
|
|
68
|
+
/** Batch turn shared across every call in the batch. */
|
|
69
|
+
turn?: number;
|
|
70
|
+
/** Registry partition scope (run id or anonymous batch id). */
|
|
71
|
+
batchScopeId?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Pre-resolved args keyed by `toolCallId`. Populated by the mixed
|
|
74
|
+
* path so event calls don't re-resolve against a registry that
|
|
75
|
+
* already contains same-turn direct outputs.
|
|
76
|
+
*/
|
|
77
|
+
preResolvedArgs?: PreResolvedArgsMap;
|
|
78
|
+
/**
|
|
79
|
+
* Frozen pre-batch registry view used to re-resolve placeholders
|
|
80
|
+
* a `PreToolUse` hook injects via `updatedInput` — preserves the
|
|
81
|
+
* same-turn isolation guarantee for hook-rewritten args.
|
|
82
|
+
*/
|
|
83
|
+
preBatchSnapshot?: ToolOutputResolveView;
|
|
84
|
+
};
|
|
29
85
|
|
|
30
86
|
/**
|
|
31
87
|
* Helper to check if a value is a Send object
|
|
@@ -34,6 +90,41 @@ function isSend(value: unknown): value is Send {
|
|
|
34
90
|
return value instanceof Send;
|
|
35
91
|
}
|
|
36
92
|
|
|
93
|
+
/** Merges code execution session context into the sessions map. */
|
|
94
|
+
function updateCodeSession(
|
|
95
|
+
sessions: t.ToolSessionMap,
|
|
96
|
+
sessionId: string,
|
|
97
|
+
files: t.FileRefs | undefined
|
|
98
|
+
): void {
|
|
99
|
+
const newFiles = files ?? [];
|
|
100
|
+
const existingSession = sessions.get(Constants.EXECUTE_CODE) as
|
|
101
|
+
| t.CodeSessionContext
|
|
102
|
+
| undefined;
|
|
103
|
+
const existingFiles = existingSession?.files ?? [];
|
|
104
|
+
|
|
105
|
+
if (newFiles.length > 0) {
|
|
106
|
+
const filesWithSession: t.FileRefs = newFiles.map((file) => ({
|
|
107
|
+
...file,
|
|
108
|
+
session_id: sessionId,
|
|
109
|
+
}));
|
|
110
|
+
const newFileNames = new Set(filesWithSession.map((f) => f.name));
|
|
111
|
+
const filteredExisting = existingFiles.filter(
|
|
112
|
+
(f) => !newFileNames.has(f.name)
|
|
113
|
+
);
|
|
114
|
+
sessions.set(Constants.EXECUTE_CODE, {
|
|
115
|
+
session_id: sessionId,
|
|
116
|
+
files: [...filteredExisting, ...filesWithSession],
|
|
117
|
+
lastUpdated: Date.now(),
|
|
118
|
+
});
|
|
119
|
+
} else {
|
|
120
|
+
sessions.set(Constants.EXECUTE_CODE, {
|
|
121
|
+
session_id: sessionId,
|
|
122
|
+
files: existingFiles,
|
|
123
|
+
lastUpdated: Date.now(),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
37
128
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
129
|
export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
39
130
|
private toolMap: Map<string, StructuredToolInterface | RunnableToolLike>;
|
|
@@ -59,6 +150,28 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
59
150
|
private directToolNames?: Set<string>;
|
|
60
151
|
/** Maximum characters allowed in a single tool result before truncation. */
|
|
61
152
|
private maxToolResultChars: number;
|
|
153
|
+
/** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
|
|
154
|
+
private hookRegistry?: HookRegistry;
|
|
155
|
+
/**
|
|
156
|
+
* Registry of tool outputs keyed by `tool<idx>turn<turn>`.
|
|
157
|
+
*
|
|
158
|
+
* Populated only when `toolOutputReferences.enabled` is true. The
|
|
159
|
+
* registry owns the run-scoped state (turn counter, last-seen runId,
|
|
160
|
+
* warn-once memo, stored outputs), so sharing a single instance
|
|
161
|
+
* across multiple ToolNodes in a run lets cross-agent `{{…}}`
|
|
162
|
+
* references resolve — which is why multi-agent graphs pass the
|
|
163
|
+
* *same* instance to every ToolNode they compile rather than each
|
|
164
|
+
* ToolNode building its own.
|
|
165
|
+
*/
|
|
166
|
+
private toolOutputRegistry?: ToolOutputReferenceRegistry;
|
|
167
|
+
/**
|
|
168
|
+
* Monotonic counter used to mint a unique scope id for anonymous
|
|
169
|
+
* batches (ones invoked without a `run_id` in
|
|
170
|
+
* `config.configurable`). Each such batch gets its own registry
|
|
171
|
+
* partition so concurrent anonymous invocations can't delete each
|
|
172
|
+
* other's in-flight state.
|
|
173
|
+
*/
|
|
174
|
+
private anonBatchCounter: number = 0;
|
|
62
175
|
|
|
63
176
|
constructor({
|
|
64
177
|
tools,
|
|
@@ -76,6 +189,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
76
189
|
directToolNames,
|
|
77
190
|
maxContextTokens,
|
|
78
191
|
maxToolResultChars,
|
|
192
|
+
hookRegistry,
|
|
193
|
+
toolOutputReferences,
|
|
194
|
+
toolOutputRegistry,
|
|
79
195
|
}: t.ToolNodeConstructorParams) {
|
|
80
196
|
super({ name, tags, func: (input, config) => this.run(input, config) });
|
|
81
197
|
this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
|
|
@@ -91,6 +207,40 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
91
207
|
this.directToolNames = directToolNames;
|
|
92
208
|
this.maxToolResultChars =
|
|
93
209
|
maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
|
|
210
|
+
this.hookRegistry = hookRegistry;
|
|
211
|
+
/**
|
|
212
|
+
* Precedence: an explicitly passed `toolOutputRegistry` instance
|
|
213
|
+
* wins over a config object so a host (`Graph`) can share one
|
|
214
|
+
* registry across many ToolNodes. When only the config is
|
|
215
|
+
* provided (direct ToolNode usage), build a local registry so
|
|
216
|
+
* the feature still works without graph-level plumbing. Registry
|
|
217
|
+
* caps are intentionally decoupled from `maxToolResultChars`:
|
|
218
|
+
* the registry stores the raw untruncated output so a later
|
|
219
|
+
* `{{…}}` substitution pipes the full payload into the next
|
|
220
|
+
* tool, even when the LLM saw a truncated preview.
|
|
221
|
+
*/
|
|
222
|
+
if (toolOutputRegistry != null) {
|
|
223
|
+
this.toolOutputRegistry = toolOutputRegistry;
|
|
224
|
+
} else if (toolOutputReferences?.enabled === true) {
|
|
225
|
+
this.toolOutputRegistry = new ToolOutputReferenceRegistry({
|
|
226
|
+
maxOutputSize: toolOutputReferences.maxOutputSize,
|
|
227
|
+
maxTotalSize: toolOutputReferences.maxTotalSize,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Returns the run-scoped tool output registry, or `undefined` when
|
|
234
|
+
* the feature is disabled.
|
|
235
|
+
*
|
|
236
|
+
* @internal Exposed for test observation only. Host code should rely
|
|
237
|
+
* on `{{tool<i>turn<n>}}` substitution at tool-invocation time and
|
|
238
|
+
* not mutate the registry directly.
|
|
239
|
+
*/
|
|
240
|
+
public _unsafeGetToolOutputRegistry():
|
|
241
|
+
| ToolOutputReferenceRegistry
|
|
242
|
+
| undefined {
|
|
243
|
+
return this.toolOutputRegistry;
|
|
94
244
|
}
|
|
95
245
|
|
|
96
246
|
/**
|
|
@@ -128,36 +278,105 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
128
278
|
}
|
|
129
279
|
|
|
130
280
|
/**
|
|
131
|
-
* Runs a single tool call with error handling
|
|
281
|
+
* Runs a single tool call with error handling.
|
|
282
|
+
*
|
|
283
|
+
* `batchIndex` is the tool's position within the current ToolNode
|
|
284
|
+
* batch and, together with `this.currentTurn`, forms the key used to
|
|
285
|
+
* register the output for future `{{tool<idx>turn<turn>}}`
|
|
286
|
+
* substitutions. Omit when no registration should occur.
|
|
132
287
|
*/
|
|
133
288
|
protected async runTool(
|
|
134
289
|
call: ToolCall,
|
|
135
|
-
config: RunnableConfig
|
|
290
|
+
config: RunnableConfig,
|
|
291
|
+
batchContext: RunToolBatchContext = {}
|
|
136
292
|
): Promise<BaseMessage | Command> {
|
|
293
|
+
const { batchIndex, turn, batchScopeId, resolvedArgsByCallId } =
|
|
294
|
+
batchContext;
|
|
137
295
|
const tool = this.toolMap.get(call.name);
|
|
296
|
+
const registry = this.toolOutputRegistry;
|
|
297
|
+
/**
|
|
298
|
+
* Precompute the reference key once per call — captured locally
|
|
299
|
+
* so concurrent `invoke()` calls on the same ToolNode cannot race
|
|
300
|
+
* on a shared turn field.
|
|
301
|
+
*/
|
|
302
|
+
const refKey =
|
|
303
|
+
registry != null && batchIndex != null && turn != null
|
|
304
|
+
? buildReferenceKey(batchIndex, turn)
|
|
305
|
+
: undefined;
|
|
306
|
+
/**
|
|
307
|
+
* Hoisted outside the try so the catch branch can append
|
|
308
|
+
* `[unresolved refs: …]` to error messages — otherwise the LLM
|
|
309
|
+
* only sees a generic error when it references a bad key, losing
|
|
310
|
+
* the self-correction signal this feature is meant to provide.
|
|
311
|
+
*/
|
|
312
|
+
let unresolvedRefs: string[] = [];
|
|
313
|
+
/**
|
|
314
|
+
* Use the caller-provided `batchScopeId` when threaded from
|
|
315
|
+
* `run()` (so anonymous batches get their own unique scope).
|
|
316
|
+
* Fall back to the config's `run_id` when runTool is invoked
|
|
317
|
+
* from a context that doesn't thread it — that still preserves
|
|
318
|
+
* the runId-based partitioning for named runs.
|
|
319
|
+
*/
|
|
320
|
+
const runId =
|
|
321
|
+
batchScopeId ?? (config.configurable?.run_id as string | undefined);
|
|
138
322
|
try {
|
|
139
323
|
if (tool === undefined) {
|
|
140
324
|
throw new Error(`Tool "${call.name}" not found.`);
|
|
141
325
|
}
|
|
142
|
-
|
|
143
|
-
|
|
326
|
+
/**
|
|
327
|
+
* `usageCount` is the per-tool-name invocation index that
|
|
328
|
+
* web-search and other tools observe via `invokeParams.turn`.
|
|
329
|
+
* It is intentionally distinct from the outer `turn` parameter
|
|
330
|
+
* (the batch turn used for ref keys); the latter is captured
|
|
331
|
+
* before the try block when constructing `refKey`.
|
|
332
|
+
*/
|
|
333
|
+
const usageCount = this.toolUsageCount.get(call.name) ?? 0;
|
|
334
|
+
this.toolUsageCount.set(call.name, usageCount + 1);
|
|
144
335
|
if (call.id != null && call.id !== '') {
|
|
145
|
-
this.toolCallTurns.set(call.id,
|
|
336
|
+
this.toolCallTurns.set(call.id, usageCount);
|
|
337
|
+
}
|
|
338
|
+
let args = call.args;
|
|
339
|
+
if (registry != null) {
|
|
340
|
+
const { resolved, unresolved } = registry.resolve(runId, args);
|
|
341
|
+
args = resolved;
|
|
342
|
+
unresolvedRefs = unresolved;
|
|
343
|
+
/**
|
|
344
|
+
* Expose the post-substitution args to downstream completion
|
|
345
|
+
* events so audit logs / host-side `ON_RUN_STEP_COMPLETED`
|
|
346
|
+
* handlers observe what actually ran, not the `{{…}}`
|
|
347
|
+
* template. Only string/object args are worth recording.
|
|
348
|
+
*/
|
|
349
|
+
if (
|
|
350
|
+
resolvedArgsByCallId != null &&
|
|
351
|
+
call.id != null &&
|
|
352
|
+
call.id !== '' &&
|
|
353
|
+
resolved !== call.args &&
|
|
354
|
+
typeof resolved === 'object'
|
|
355
|
+
) {
|
|
356
|
+
resolvedArgsByCallId.set(
|
|
357
|
+
call.id,
|
|
358
|
+
resolved as Record<string, unknown>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
146
361
|
}
|
|
147
|
-
const args = call.args;
|
|
148
362
|
const stepId = this.toolCallStepIds?.get(call.id!);
|
|
149
363
|
|
|
150
364
|
// Build invoke params - LangChain extracts non-schema fields to config.toolCall
|
|
365
|
+
// `turn` here is the per-tool usage count (matches what tools have
|
|
366
|
+
// observed historically via config.toolCall.turn — e.g. web search).
|
|
151
367
|
let invokeParams: Record<string, unknown> = {
|
|
152
368
|
...call,
|
|
153
369
|
args,
|
|
154
370
|
type: 'tool_call',
|
|
155
371
|
stepId,
|
|
156
|
-
turn,
|
|
372
|
+
turn: usageCount,
|
|
157
373
|
};
|
|
158
374
|
|
|
159
375
|
// Inject runtime data for special tools (becomes available at config.toolCall)
|
|
160
|
-
if (
|
|
376
|
+
if (
|
|
377
|
+
call.name === Constants.PROGRAMMATIC_TOOL_CALLING ||
|
|
378
|
+
call.name === Constants.BASH_PROGRAMMATIC_TOOL_CALLING
|
|
379
|
+
) {
|
|
161
380
|
const { toolMap, toolDefs } = this.getProgrammaticTools();
|
|
162
381
|
invokeParams = {
|
|
163
382
|
...invokeParams,
|
|
@@ -180,10 +399,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
180
399
|
* session_id is always injected when available (even without tracked files)
|
|
181
400
|
* so the CodeExecutor can fall back to the /files endpoint for session continuity.
|
|
182
401
|
*/
|
|
183
|
-
if (
|
|
184
|
-
call.name === Constants.EXECUTE_CODE ||
|
|
185
|
-
call.name === Constants.PROGRAMMATIC_TOOL_CALLING
|
|
186
|
-
) {
|
|
402
|
+
if (CODE_EXECUTION_TOOLS.has(call.name)) {
|
|
187
403
|
const codeSession = this.sessions?.get(Constants.EXECUTE_CODE) as
|
|
188
404
|
| t.CodeSessionContext
|
|
189
405
|
| undefined;
|
|
@@ -205,24 +421,102 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
205
421
|
}
|
|
206
422
|
|
|
207
423
|
const output = await tool.invoke(invokeParams, config);
|
|
208
|
-
if (
|
|
209
|
-
(isBaseMessage(output) && output._getType() === 'tool') ||
|
|
210
|
-
isCommand(output)
|
|
211
|
-
) {
|
|
424
|
+
if (isCommand(output)) {
|
|
212
425
|
return output;
|
|
213
|
-
} else {
|
|
214
|
-
const rawContent =
|
|
215
|
-
typeof output === 'string' ? output : JSON.stringify(output);
|
|
216
|
-
return new ToolMessage({
|
|
217
|
-
status: 'success',
|
|
218
|
-
name: tool.name,
|
|
219
|
-
content: truncateToolResultContent(
|
|
220
|
-
rawContent,
|
|
221
|
-
this.maxToolResultChars
|
|
222
|
-
),
|
|
223
|
-
tool_call_id: call.id!,
|
|
224
|
-
});
|
|
225
426
|
}
|
|
427
|
+
if (isBaseMessage(output) && output._getType() === 'tool') {
|
|
428
|
+
const toolMsg = output as ToolMessage;
|
|
429
|
+
const isError = toolMsg.status === 'error';
|
|
430
|
+
if (isError) {
|
|
431
|
+
/**
|
|
432
|
+
* Error ToolMessages bypass registration/annotation but must
|
|
433
|
+
* still carry the unresolved-refs hint so the LLM can
|
|
434
|
+
* self-correct when its reference key caused the failure.
|
|
435
|
+
*/
|
|
436
|
+
if (
|
|
437
|
+
unresolvedRefs.length > 0 &&
|
|
438
|
+
typeof toolMsg.content === 'string'
|
|
439
|
+
) {
|
|
440
|
+
toolMsg.content = this.applyOutputReference(
|
|
441
|
+
runId,
|
|
442
|
+
toolMsg.content,
|
|
443
|
+
toolMsg.content,
|
|
444
|
+
undefined,
|
|
445
|
+
unresolvedRefs
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return toolMsg;
|
|
449
|
+
}
|
|
450
|
+
if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
|
|
451
|
+
if (typeof toolMsg.content === 'string') {
|
|
452
|
+
const rawContent = toolMsg.content;
|
|
453
|
+
const llmContent = truncateToolResultContent(
|
|
454
|
+
rawContent,
|
|
455
|
+
this.maxToolResultChars
|
|
456
|
+
);
|
|
457
|
+
toolMsg.content = this.applyOutputReference(
|
|
458
|
+
runId,
|
|
459
|
+
llmContent,
|
|
460
|
+
rawContent,
|
|
461
|
+
refKey,
|
|
462
|
+
unresolvedRefs
|
|
463
|
+
);
|
|
464
|
+
} else {
|
|
465
|
+
/**
|
|
466
|
+
* Non-string content (multi-part content blocks — text +
|
|
467
|
+
* image). Known limitation: we cannot register under a
|
|
468
|
+
* reference key because there's no canonical serialized
|
|
469
|
+
* form. Warn once per tool per run when the caller
|
|
470
|
+
* intended to register.
|
|
471
|
+
*
|
|
472
|
+
* Still surface unresolved-ref warnings so the LLM gets
|
|
473
|
+
* the self-correction signal that the string and error
|
|
474
|
+
* paths already emit. Prepended as a leading text block
|
|
475
|
+
* to keep the original content ordering intact.
|
|
476
|
+
*/
|
|
477
|
+
if (unresolvedRefs.length > 0 && Array.isArray(toolMsg.content)) {
|
|
478
|
+
const warningBlock = {
|
|
479
|
+
type: 'text',
|
|
480
|
+
text: `[unresolved refs: ${unresolvedRefs.join(', ')}]`,
|
|
481
|
+
};
|
|
482
|
+
toolMsg.content = [
|
|
483
|
+
warningBlock,
|
|
484
|
+
...toolMsg.content,
|
|
485
|
+
] as typeof toolMsg.content;
|
|
486
|
+
}
|
|
487
|
+
if (
|
|
488
|
+
refKey != null &&
|
|
489
|
+
this.toolOutputRegistry!.claimWarnOnce(runId, call.name)
|
|
490
|
+
) {
|
|
491
|
+
// eslint-disable-next-line no-console
|
|
492
|
+
console.warn(
|
|
493
|
+
`[ToolNode] Skipping tool output reference for "${call.name}": ` +
|
|
494
|
+
'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).'
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return toolMsg;
|
|
500
|
+
}
|
|
501
|
+
const rawContent =
|
|
502
|
+
typeof output === 'string' ? output : JSON.stringify(output);
|
|
503
|
+
const truncated = truncateToolResultContent(
|
|
504
|
+
rawContent,
|
|
505
|
+
this.maxToolResultChars
|
|
506
|
+
);
|
|
507
|
+
const content = this.applyOutputReference(
|
|
508
|
+
runId,
|
|
509
|
+
truncated,
|
|
510
|
+
rawContent,
|
|
511
|
+
refKey,
|
|
512
|
+
unresolvedRefs
|
|
513
|
+
);
|
|
514
|
+
return new ToolMessage({
|
|
515
|
+
status: 'success',
|
|
516
|
+
name: tool.name,
|
|
517
|
+
content,
|
|
518
|
+
tool_call_id: call.id!,
|
|
519
|
+
});
|
|
226
520
|
} catch (_e: unknown) {
|
|
227
521
|
const e = _e as Error;
|
|
228
522
|
if (!this.handleToolErrors) {
|
|
@@ -267,15 +561,66 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
267
561
|
});
|
|
268
562
|
}
|
|
269
563
|
}
|
|
564
|
+
let errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
|
|
565
|
+
if (unresolvedRefs.length > 0) {
|
|
566
|
+
errorContent = this.applyOutputReference(
|
|
567
|
+
runId,
|
|
568
|
+
errorContent,
|
|
569
|
+
errorContent,
|
|
570
|
+
undefined,
|
|
571
|
+
unresolvedRefs
|
|
572
|
+
);
|
|
573
|
+
}
|
|
270
574
|
return new ToolMessage({
|
|
271
575
|
status: 'error',
|
|
272
|
-
content:
|
|
576
|
+
content: errorContent,
|
|
273
577
|
name: call.name,
|
|
274
578
|
tool_call_id: call.id ?? '',
|
|
275
579
|
});
|
|
276
580
|
}
|
|
277
581
|
}
|
|
278
582
|
|
|
583
|
+
/**
|
|
584
|
+
* Finalizes the LLM-visible content for a tool call and (when a
|
|
585
|
+
* `refKey` is provided) registers the full, raw output under that
|
|
586
|
+
* key.
|
|
587
|
+
*
|
|
588
|
+
* @param llmContent The content string the LLM will see. This is
|
|
589
|
+
* the already-truncated, post-hook view; the annotation is
|
|
590
|
+
* applied on top of it.
|
|
591
|
+
* @param registryContent The full, untruncated output to store in
|
|
592
|
+
* the registry so `{{tool<i>turn<n>}}` substitutions deliver the
|
|
593
|
+
* complete payload. Ignored when `refKey` is undefined.
|
|
594
|
+
* @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
|
|
595
|
+
* the output is not to be registered (errors, disabled feature,
|
|
596
|
+
* unavailable batch/turn).
|
|
597
|
+
* @param unresolved Placeholder keys that did not resolve; appended
|
|
598
|
+
* as `[unresolved refs: …]` so the LLM can self-correct.
|
|
599
|
+
*
|
|
600
|
+
* `refKey` is passed in (rather than built from `this.currentTurn`)
|
|
601
|
+
* so parallel `invoke()` calls on the same ToolNode cannot race on
|
|
602
|
+
* the shared turn field.
|
|
603
|
+
*/
|
|
604
|
+
private applyOutputReference(
|
|
605
|
+
runId: string | undefined,
|
|
606
|
+
llmContent: string,
|
|
607
|
+
registryContent: string,
|
|
608
|
+
refKey: string | undefined,
|
|
609
|
+
unresolved: string[]
|
|
610
|
+
): string {
|
|
611
|
+
if (this.toolOutputRegistry != null && refKey != null) {
|
|
612
|
+
this.toolOutputRegistry.set(runId, refKey, registryContent);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* `annotateToolOutputWithReference` handles both the ref-key and
|
|
616
|
+
* unresolved-refs cases together so JSON-object outputs stay
|
|
617
|
+
* parseable: unresolved refs land in an `_unresolved_refs` field
|
|
618
|
+
* instead of as a trailing text line that would break
|
|
619
|
+
* `JSON.parse` for downstream consumers.
|
|
620
|
+
*/
|
|
621
|
+
return annotateToolOutputWithReference(llmContent, refKey, unresolved);
|
|
622
|
+
}
|
|
623
|
+
|
|
279
624
|
/**
|
|
280
625
|
* Builds code session context for injection into event-driven tool calls.
|
|
281
626
|
* Mirrors the session injection logic in runTool() for direct execution.
|
|
@@ -313,7 +658,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
313
658
|
*/
|
|
314
659
|
private storeCodeSessionFromResults(
|
|
315
660
|
results: t.ToolExecuteResult[],
|
|
316
|
-
|
|
661
|
+
requestMap: Map<string, t.ToolCallRequest>
|
|
317
662
|
): void {
|
|
318
663
|
if (!this.sessions) {
|
|
319
664
|
return;
|
|
@@ -325,10 +670,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
325
670
|
continue;
|
|
326
671
|
}
|
|
327
672
|
|
|
328
|
-
const request =
|
|
673
|
+
const request = requestMap.get(result.toolCallId);
|
|
329
674
|
if (
|
|
330
|
-
request?.name
|
|
331
|
-
request
|
|
675
|
+
!request?.name ||
|
|
676
|
+
(!CODE_EXECUTION_TOOLS.has(request.name) &&
|
|
677
|
+
request.name !== Constants.SKILL_TOOL)
|
|
332
678
|
) {
|
|
333
679
|
continue;
|
|
334
680
|
}
|
|
@@ -338,35 +684,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
338
684
|
continue;
|
|
339
685
|
}
|
|
340
686
|
|
|
341
|
-
|
|
342
|
-
const existingSession = this.sessions.get(Constants.EXECUTE_CODE) as
|
|
343
|
-
| t.CodeSessionContext
|
|
344
|
-
| undefined;
|
|
345
|
-
const existingFiles = existingSession?.files ?? [];
|
|
346
|
-
|
|
347
|
-
if (newFiles.length > 0) {
|
|
348
|
-
const filesWithSession: t.FileRefs = newFiles.map((file) => ({
|
|
349
|
-
...file,
|
|
350
|
-
session_id: artifact.session_id,
|
|
351
|
-
}));
|
|
352
|
-
|
|
353
|
-
const newFileNames = new Set(filesWithSession.map((f) => f.name));
|
|
354
|
-
const filteredExisting = existingFiles.filter(
|
|
355
|
-
(f) => !newFileNames.has(f.name)
|
|
356
|
-
);
|
|
357
|
-
|
|
358
|
-
this.sessions.set(Constants.EXECUTE_CODE, {
|
|
359
|
-
session_id: artifact.session_id,
|
|
360
|
-
files: [...filteredExisting, ...filesWithSession],
|
|
361
|
-
lastUpdated: Date.now(),
|
|
362
|
-
});
|
|
363
|
-
} else {
|
|
364
|
-
this.sessions.set(Constants.EXECUTE_CODE, {
|
|
365
|
-
session_id: artifact.session_id,
|
|
366
|
-
files: existingFiles,
|
|
367
|
-
lastUpdated: Date.now(),
|
|
368
|
-
});
|
|
369
|
-
}
|
|
687
|
+
updateCodeSession(this.sessions, artifact.session_id!, artifact.files);
|
|
370
688
|
}
|
|
371
689
|
}
|
|
372
690
|
|
|
@@ -378,11 +696,16 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
378
696
|
* By handling completions here in graph context (rather than in the
|
|
379
697
|
* stream consumer via ToolEndHandler), the race between the stream
|
|
380
698
|
* consumer and graph execution is eliminated.
|
|
699
|
+
*
|
|
700
|
+
* @param resolvedArgsByCallId Per-batch resolved-args sink populated
|
|
701
|
+
* by `runTool`. Threaded as a local map (instead of instance state)
|
|
702
|
+
* so concurrent batches cannot read each other's entries.
|
|
381
703
|
*/
|
|
382
704
|
private handleRunToolCompletions(
|
|
383
705
|
calls: ToolCall[],
|
|
384
706
|
outputs: (BaseMessage | Command)[],
|
|
385
|
-
config: RunnableConfig
|
|
707
|
+
config: RunnableConfig,
|
|
708
|
+
resolvedArgsByCallId?: ResolvedArgsByCallId
|
|
386
709
|
): void {
|
|
387
710
|
for (let i = 0; i < calls.length; i++) {
|
|
388
711
|
const call = calls[i];
|
|
@@ -402,43 +725,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
402
725
|
continue;
|
|
403
726
|
}
|
|
404
727
|
|
|
405
|
-
|
|
406
|
-
if (
|
|
407
|
-
this.sessions &&
|
|
408
|
-
(call.name === Constants.EXECUTE_CODE ||
|
|
409
|
-
call.name === Constants.PROGRAMMATIC_TOOL_CALLING)
|
|
410
|
-
) {
|
|
728
|
+
if (this.sessions && CODE_EXECUTION_TOOLS.has(call.name)) {
|
|
411
729
|
const artifact = toolMessage.artifact as
|
|
412
730
|
| t.CodeExecutionArtifact
|
|
413
731
|
| undefined;
|
|
414
732
|
if (artifact?.session_id != null && artifact.session_id !== '') {
|
|
415
|
-
|
|
416
|
-
const existingSession = this.sessions.get(Constants.EXECUTE_CODE) as
|
|
417
|
-
| t.CodeSessionContext
|
|
418
|
-
| undefined;
|
|
419
|
-
const existingFiles = existingSession?.files ?? [];
|
|
420
|
-
|
|
421
|
-
if (newFiles.length > 0) {
|
|
422
|
-
const filesWithSession: t.FileRefs = newFiles.map((file) => ({
|
|
423
|
-
...file,
|
|
424
|
-
session_id: artifact.session_id,
|
|
425
|
-
}));
|
|
426
|
-
const newFileNames = new Set(filesWithSession.map((f) => f.name));
|
|
427
|
-
const filteredExisting = existingFiles.filter(
|
|
428
|
-
(f) => !newFileNames.has(f.name)
|
|
429
|
-
);
|
|
430
|
-
this.sessions.set(Constants.EXECUTE_CODE, {
|
|
431
|
-
session_id: artifact.session_id,
|
|
432
|
-
files: [...filteredExisting, ...filesWithSession],
|
|
433
|
-
lastUpdated: Date.now(),
|
|
434
|
-
});
|
|
435
|
-
} else {
|
|
436
|
-
this.sessions.set(Constants.EXECUTE_CODE, {
|
|
437
|
-
session_id: artifact.session_id,
|
|
438
|
-
files: existingFiles,
|
|
439
|
-
lastUpdated: Date.now(),
|
|
440
|
-
});
|
|
441
|
-
}
|
|
733
|
+
updateCodeSession(this.sessions, artifact.session_id, artifact.files);
|
|
442
734
|
}
|
|
443
735
|
}
|
|
444
736
|
|
|
@@ -453,11 +745,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
453
745
|
? toolMessage.content
|
|
454
746
|
: JSON.stringify(toolMessage.content);
|
|
455
747
|
|
|
748
|
+
/**
|
|
749
|
+
* Prefer the post-substitution args when a `{{…}}` placeholder
|
|
750
|
+
* was resolved in `runTool`. This keeps
|
|
751
|
+
* `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
|
|
752
|
+
* the tool actually received rather than leaking the template.
|
|
753
|
+
*/
|
|
754
|
+
const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
|
|
456
755
|
const tool_call: t.ProcessedToolCall = {
|
|
457
756
|
args:
|
|
458
|
-
typeof
|
|
459
|
-
? (
|
|
460
|
-
: JSON.stringify((
|
|
757
|
+
typeof effectiveArgs === 'string'
|
|
758
|
+
? (effectiveArgs as string)
|
|
759
|
+
: JSON.stringify((effectiveArgs as unknown) ?? {}),
|
|
461
760
|
name: call.name,
|
|
462
761
|
id: toolCallId,
|
|
463
762
|
output: contentString,
|
|
@@ -482,151 +781,544 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
482
781
|
/**
|
|
483
782
|
* Dispatches tool calls to the host via ON_TOOL_EXECUTE event and returns raw ToolMessages.
|
|
484
783
|
* Core logic for event-driven execution, separated from output shaping.
|
|
784
|
+
*
|
|
785
|
+
* Hook lifecycle (when `hookRegistry` is set):
|
|
786
|
+
* 1. **PreToolUse** fires per call in parallel before dispatch. Denied
|
|
787
|
+
* calls produce error ToolMessages and fire **PermissionDenied**;
|
|
788
|
+
* surviving calls proceed with optional `updatedInput`.
|
|
789
|
+
* 2. Surviving calls are dispatched to the host via `ON_TOOL_EXECUTE`.
|
|
790
|
+
* 3. **PostToolUse** / **PostToolUseFailure** fire per result. Post hooks
|
|
791
|
+
* can replace tool output via `updatedOutput`.
|
|
792
|
+
* 4. Injected messages from results are collected and returned alongside
|
|
793
|
+
* ToolMessages (appended AFTER to respect provider ordering).
|
|
485
794
|
*/
|
|
486
795
|
private async dispatchToolEvents(
|
|
487
796
|
toolCalls: ToolCall[],
|
|
488
|
-
config: RunnableConfig
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
797
|
+
config: RunnableConfig,
|
|
798
|
+
batchContext: DispatchBatchContext = {}
|
|
799
|
+
): Promise<{ toolMessages: ToolMessage[]; injected: BaseMessage[] }> {
|
|
800
|
+
const {
|
|
801
|
+
batchIndices,
|
|
802
|
+
turn,
|
|
803
|
+
batchScopeId,
|
|
804
|
+
preResolvedArgs,
|
|
805
|
+
preBatchSnapshot,
|
|
806
|
+
} = batchContext;
|
|
807
|
+
const runId = (config.configurable?.run_id as string | undefined) ?? '';
|
|
808
|
+
/**
|
|
809
|
+
* Registry-facing scope id — prefers the caller-threaded
|
|
810
|
+
* `batchScopeId` so anonymous batches target their own unique
|
|
811
|
+
* bucket and don't step on concurrent anonymous invocations.
|
|
812
|
+
* Hooks and event payloads keep using the empty-string coerced
|
|
813
|
+
* `runId` for backward compat.
|
|
814
|
+
*/
|
|
815
|
+
const registryRunId =
|
|
816
|
+
batchScopeId ?? (config.configurable?.run_id as string | undefined);
|
|
817
|
+
const threadId = config.configurable?.thread_id as string | undefined;
|
|
818
|
+
const registry = this.toolOutputRegistry;
|
|
819
|
+
const unresolvedByCallId = new Map<string, string[]>();
|
|
820
|
+
|
|
821
|
+
const preToolCalls = toolCalls.map((call, i) => {
|
|
822
|
+
const originalArgs = call.args as Record<string, unknown>;
|
|
823
|
+
let resolvedArgs = originalArgs;
|
|
824
|
+
/**
|
|
825
|
+
* When the caller provided a pre-resolved map (the mixed
|
|
826
|
+
* direct+event path snapshots event args synchronously before
|
|
827
|
+
* awaiting directs so they can't accidentally resolve
|
|
828
|
+
* same-turn direct outputs), use those entries verbatim instead
|
|
829
|
+
* of re-resolving against a registry that may have changed
|
|
830
|
+
* since the batch started.
|
|
831
|
+
*/
|
|
832
|
+
const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
|
|
833
|
+
if (pre != null) {
|
|
834
|
+
resolvedArgs = pre.resolved;
|
|
835
|
+
if (pre.unresolved.length > 0 && call.id != null) {
|
|
836
|
+
unresolvedByCallId.set(call.id, pre.unresolved);
|
|
837
|
+
}
|
|
838
|
+
} else if (registry != null) {
|
|
839
|
+
const { resolved, unresolved } = registry.resolve(
|
|
840
|
+
registryRunId,
|
|
841
|
+
originalArgs
|
|
842
|
+
);
|
|
843
|
+
resolvedArgs = resolved as Record<string, unknown>;
|
|
844
|
+
if (unresolved.length > 0 && call.id != null) {
|
|
845
|
+
unresolvedByCallId.set(call.id, unresolved);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return {
|
|
849
|
+
call,
|
|
850
|
+
stepId: this.toolCallStepIds?.get(call.id!) ?? '',
|
|
851
|
+
args: resolvedArgs,
|
|
852
|
+
batchIndex: batchIndices?.[i],
|
|
500
853
|
};
|
|
854
|
+
});
|
|
501
855
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
856
|
+
const messageByCallId = new Map<string, ToolMessage>();
|
|
857
|
+
const approvedEntries: typeof preToolCalls = [];
|
|
858
|
+
const HOOK_FALLBACK: AggregatedHookResult = Object.freeze({
|
|
859
|
+
additionalContexts: [] as string[],
|
|
860
|
+
errors: [] as string[],
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
if (this.hookRegistry?.hasHookFor('PreToolUse', runId) === true) {
|
|
864
|
+
const preResults = await Promise.all(
|
|
865
|
+
preToolCalls.map((entry) =>
|
|
866
|
+
executeHooks({
|
|
867
|
+
registry: this.hookRegistry!,
|
|
868
|
+
input: {
|
|
869
|
+
hook_event_name: 'PreToolUse',
|
|
870
|
+
runId,
|
|
871
|
+
threadId,
|
|
872
|
+
agentId: this.agentId,
|
|
873
|
+
toolName: entry.call.name,
|
|
874
|
+
toolInput: entry.args,
|
|
875
|
+
toolUseId: entry.call.id!,
|
|
876
|
+
stepId: entry.stepId,
|
|
877
|
+
turn: this.toolUsageCount.get(entry.call.name) ?? 0,
|
|
878
|
+
},
|
|
879
|
+
sessionId: runId,
|
|
880
|
+
matchQuery: entry.call.name,
|
|
881
|
+
}).catch((): AggregatedHookResult => HOOK_FALLBACK)
|
|
882
|
+
)
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
for (let i = 0; i < preToolCalls.length; i++) {
|
|
886
|
+
const hookResult = preResults[i];
|
|
887
|
+
const entry = preToolCalls[i];
|
|
888
|
+
const isDenied =
|
|
889
|
+
hookResult.decision === 'deny' || hookResult.decision === 'ask';
|
|
890
|
+
if (isDenied) {
|
|
891
|
+
const reason = hookResult.reason ?? 'Blocked by hook';
|
|
892
|
+
const contentString = `Blocked: ${reason}`;
|
|
893
|
+
messageByCallId.set(
|
|
894
|
+
entry.call.id!,
|
|
895
|
+
new ToolMessage({
|
|
896
|
+
status: 'error',
|
|
897
|
+
content: contentString,
|
|
898
|
+
name: entry.call.name,
|
|
899
|
+
tool_call_id: entry.call.id!,
|
|
900
|
+
})
|
|
901
|
+
);
|
|
902
|
+
this.dispatchStepCompleted(
|
|
903
|
+
entry.call.id!,
|
|
904
|
+
entry.call.name,
|
|
905
|
+
entry.args,
|
|
906
|
+
contentString,
|
|
907
|
+
config
|
|
908
|
+
);
|
|
909
|
+
if (this.hookRegistry.hasHookFor('PermissionDenied', runId)) {
|
|
910
|
+
executeHooks({
|
|
911
|
+
registry: this.hookRegistry,
|
|
912
|
+
input: {
|
|
913
|
+
hook_event_name: 'PermissionDenied',
|
|
914
|
+
runId,
|
|
915
|
+
threadId,
|
|
916
|
+
agentId: this.agentId,
|
|
917
|
+
toolName: entry.call.name,
|
|
918
|
+
toolInput: entry.args,
|
|
919
|
+
toolUseId: entry.call.id!,
|
|
920
|
+
reason,
|
|
921
|
+
},
|
|
922
|
+
sessionId: runId,
|
|
923
|
+
matchQuery: entry.call.name,
|
|
924
|
+
}).catch(() => {
|
|
925
|
+
/* PermissionDenied is observational — swallow errors */
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (hookResult.updatedInput != null) {
|
|
931
|
+
/**
|
|
932
|
+
* Re-resolve after PreToolUse replaces the input: a hook may
|
|
933
|
+
* introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
|
|
934
|
+
* copying user-supplied text) that the pre-hook pass never
|
|
935
|
+
* saw. Re-running the resolver on the hook-rewritten args
|
|
936
|
+
* keeps substitution and the unresolved-refs record in sync
|
|
937
|
+
* with what the tool will actually receive.
|
|
938
|
+
*/
|
|
939
|
+
if (registry != null) {
|
|
940
|
+
/**
|
|
941
|
+
* Mixed direct+event batches must use the pre-batch
|
|
942
|
+
* snapshot so a hook-introduced placeholder cannot
|
|
943
|
+
* accidentally resolve to a same-turn direct output that
|
|
944
|
+
* has just registered. Pure event batches don't have a
|
|
945
|
+
* snapshot and resolve against the live registry — safe
|
|
946
|
+
* because no event-side registrations have happened yet.
|
|
947
|
+
*/
|
|
948
|
+
const view: ToolOutputResolveView = preBatchSnapshot ?? {
|
|
949
|
+
resolve: <T>(args: T) => registry.resolve(registryRunId, args),
|
|
950
|
+
};
|
|
951
|
+
const { resolved, unresolved } = view.resolve(
|
|
952
|
+
hookResult.updatedInput
|
|
953
|
+
);
|
|
954
|
+
entry.args = resolved as Record<string, unknown>;
|
|
955
|
+
if (entry.call.id != null) {
|
|
956
|
+
if (unresolved.length > 0) {
|
|
957
|
+
unresolvedByCallId.set(entry.call.id, unresolved);
|
|
958
|
+
} else {
|
|
959
|
+
unresolvedByCallId.delete(entry.call.id);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
} else {
|
|
963
|
+
entry.args = hookResult.updatedInput;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
approvedEntries.push(entry);
|
|
507
967
|
}
|
|
968
|
+
} else {
|
|
969
|
+
approvedEntries.push(...preToolCalls);
|
|
970
|
+
}
|
|
508
971
|
|
|
509
|
-
|
|
510
|
-
|
|
972
|
+
const injected: BaseMessage[] = [];
|
|
973
|
+
|
|
974
|
+
const batchIndexByCallId = new Map<string, number>();
|
|
511
975
|
|
|
512
|
-
|
|
513
|
-
(
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
976
|
+
if (approvedEntries.length > 0) {
|
|
977
|
+
const requests: t.ToolCallRequest[] = approvedEntries.map((entry) => {
|
|
978
|
+
const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
|
|
979
|
+
this.toolUsageCount.set(entry.call.name, turn + 1);
|
|
980
|
+
|
|
981
|
+
if (entry.batchIndex != null && entry.call.id != null) {
|
|
982
|
+
batchIndexByCallId.set(entry.call.id, entry.batchIndex);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const request: t.ToolCallRequest = {
|
|
986
|
+
id: entry.call.id!,
|
|
987
|
+
name: entry.call.name,
|
|
988
|
+
args: entry.args,
|
|
989
|
+
stepId: entry.stepId,
|
|
990
|
+
turn,
|
|
524
991
|
};
|
|
525
992
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
993
|
+
if (
|
|
994
|
+
CODE_EXECUTION_TOOLS.has(entry.call.name) ||
|
|
995
|
+
entry.call.name === Constants.SKILL_TOOL
|
|
996
|
+
) {
|
|
997
|
+
request.codeSessionContext = this.getCodeSessionContext();
|
|
998
|
+
}
|
|
529
999
|
|
|
530
|
-
|
|
1000
|
+
return request;
|
|
1001
|
+
});
|
|
531
1002
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
1003
|
+
const requestMap = new Map(requests.map((r) => [r.id, r]));
|
|
1004
|
+
|
|
1005
|
+
const results = await new Promise<t.ToolExecuteResult[]>(
|
|
1006
|
+
(resolve, reject) => {
|
|
1007
|
+
const batchRequest: t.ToolExecuteBatchRequest = {
|
|
1008
|
+
toolCalls: requests,
|
|
1009
|
+
userId: config.configurable?.user_id as string | undefined,
|
|
1010
|
+
agentId: this.agentId,
|
|
1011
|
+
configurable: config.configurable as
|
|
1012
|
+
| Record<string, unknown>
|
|
1013
|
+
| undefined,
|
|
1014
|
+
metadata: config.metadata as Record<string, unknown> | undefined,
|
|
1015
|
+
resolve,
|
|
1016
|
+
reject,
|
|
1017
|
+
};
|
|
544
1018
|
|
|
545
|
-
|
|
546
|
-
|
|
1019
|
+
safeDispatchCustomEvent(
|
|
1020
|
+
GraphEvents.ON_TOOL_EXECUTE,
|
|
1021
|
+
batchRequest,
|
|
1022
|
+
config
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
);
|
|
547
1026
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
1027
|
+
this.storeCodeSessionFromResults(results, requestMap);
|
|
1028
|
+
|
|
1029
|
+
const hasPostHook =
|
|
1030
|
+
this.hookRegistry?.hasHookFor('PostToolUse', runId) === true;
|
|
1031
|
+
const hasFailureHook =
|
|
1032
|
+
this.hookRegistry?.hasHookFor('PostToolUseFailure', runId) === true;
|
|
1033
|
+
|
|
1034
|
+
for (const result of results) {
|
|
1035
|
+
if (result.injectedMessages && result.injectedMessages.length > 0) {
|
|
1036
|
+
try {
|
|
1037
|
+
injected.push(
|
|
1038
|
+
...this.convertInjectedMessages(result.injectedMessages)
|
|
1039
|
+
);
|
|
1040
|
+
} catch (e) {
|
|
1041
|
+
// eslint-disable-next-line no-console
|
|
1042
|
+
console.warn(
|
|
1043
|
+
`[ToolNode] Failed to convert injectedMessages for toolCallId=${result.toolCallId}:`,
|
|
1044
|
+
e instanceof Error ? e.message : e
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const request = requestMap.get(result.toolCallId);
|
|
1049
|
+
const toolName = request?.name ?? 'unknown';
|
|
1050
|
+
|
|
1051
|
+
let contentString: string;
|
|
1052
|
+
let toolMessage: ToolMessage;
|
|
1053
|
+
|
|
1054
|
+
if (result.status === 'error') {
|
|
1055
|
+
contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
|
|
1056
|
+
/**
|
|
1057
|
+
* Error results bypass registration/annotation but must still
|
|
1058
|
+
* carry the unresolved-refs hint so the LLM can self-correct
|
|
1059
|
+
* when its reference key caused the failure.
|
|
1060
|
+
*/
|
|
1061
|
+
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
1062
|
+
if (unresolved.length > 0) {
|
|
1063
|
+
contentString = this.applyOutputReference(
|
|
1064
|
+
registryRunId,
|
|
1065
|
+
contentString,
|
|
1066
|
+
contentString,
|
|
1067
|
+
undefined,
|
|
1068
|
+
unresolved
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
toolMessage = new ToolMessage({
|
|
1072
|
+
status: 'error',
|
|
1073
|
+
content: contentString,
|
|
1074
|
+
name: toolName,
|
|
1075
|
+
tool_call_id: result.toolCallId,
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
if (hasFailureHook) {
|
|
1079
|
+
await executeHooks({
|
|
1080
|
+
registry: this.hookRegistry!,
|
|
1081
|
+
input: {
|
|
1082
|
+
hook_event_name: 'PostToolUseFailure',
|
|
1083
|
+
runId,
|
|
1084
|
+
threadId,
|
|
1085
|
+
agentId: this.agentId,
|
|
1086
|
+
toolName,
|
|
1087
|
+
toolInput: request?.args ?? {},
|
|
1088
|
+
toolUseId: result.toolCallId,
|
|
1089
|
+
error: result.errorMessage ?? 'Unknown error',
|
|
1090
|
+
stepId: request?.stepId,
|
|
1091
|
+
turn: request?.turn,
|
|
1092
|
+
},
|
|
1093
|
+
sessionId: runId,
|
|
1094
|
+
matchQuery: toolName,
|
|
1095
|
+
}).catch(() => {
|
|
1096
|
+
/* PostToolUseFailure is observational — swallow errors */
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
} else {
|
|
1100
|
+
let registryRaw =
|
|
1101
|
+
typeof result.content === 'string'
|
|
1102
|
+
? result.content
|
|
1103
|
+
: JSON.stringify(result.content);
|
|
1104
|
+
contentString = truncateToolResultContent(
|
|
1105
|
+
registryRaw,
|
|
1106
|
+
this.maxToolResultChars
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
if (hasPostHook) {
|
|
1110
|
+
const hookResult = await executeHooks({
|
|
1111
|
+
registry: this.hookRegistry!,
|
|
1112
|
+
input: {
|
|
1113
|
+
hook_event_name: 'PostToolUse',
|
|
1114
|
+
runId,
|
|
1115
|
+
threadId,
|
|
1116
|
+
agentId: this.agentId,
|
|
1117
|
+
toolName,
|
|
1118
|
+
toolInput: request?.args ?? {},
|
|
1119
|
+
toolOutput: result.content,
|
|
1120
|
+
toolUseId: result.toolCallId,
|
|
1121
|
+
stepId: request?.stepId,
|
|
1122
|
+
turn: request?.turn,
|
|
1123
|
+
},
|
|
1124
|
+
sessionId: runId,
|
|
1125
|
+
matchQuery: toolName,
|
|
1126
|
+
}).catch((): undefined => undefined);
|
|
1127
|
+
if (hookResult?.updatedOutput != null) {
|
|
1128
|
+
const replaced =
|
|
1129
|
+
typeof hookResult.updatedOutput === 'string'
|
|
1130
|
+
? hookResult.updatedOutput
|
|
1131
|
+
: JSON.stringify(hookResult.updatedOutput);
|
|
1132
|
+
registryRaw = replaced;
|
|
1133
|
+
contentString = truncateToolResultContent(
|
|
1134
|
+
replaced,
|
|
1135
|
+
this.maxToolResultChars
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const batchIndex = batchIndexByCallId.get(result.toolCallId);
|
|
1141
|
+
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
1142
|
+
const refKey =
|
|
1143
|
+
this.toolOutputRegistry != null &&
|
|
1144
|
+
batchIndex != null &&
|
|
1145
|
+
turn != null
|
|
1146
|
+
? buildReferenceKey(batchIndex, turn)
|
|
1147
|
+
: undefined;
|
|
1148
|
+
contentString = this.applyOutputReference(
|
|
1149
|
+
registryRunId,
|
|
1150
|
+
contentString,
|
|
1151
|
+
registryRaw,
|
|
1152
|
+
refKey,
|
|
1153
|
+
unresolved
|
|
1154
|
+
);
|
|
1155
|
+
|
|
1156
|
+
toolMessage = new ToolMessage({
|
|
1157
|
+
status: 'success',
|
|
1158
|
+
name: toolName,
|
|
1159
|
+
content: contentString,
|
|
1160
|
+
artifact: result.artifact,
|
|
1161
|
+
tool_call_id: result.toolCallId,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
this.dispatchStepCompleted(
|
|
1166
|
+
result.toolCallId,
|
|
1167
|
+
toolName,
|
|
1168
|
+
request?.args ?? {},
|
|
1169
|
+
contentString,
|
|
1170
|
+
config,
|
|
1171
|
+
request?.turn
|
|
564
1172
|
);
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
name: toolName,
|
|
568
|
-
content: contentString,
|
|
569
|
-
artifact: result.artifact,
|
|
570
|
-
tool_call_id: result.toolCallId,
|
|
571
|
-
});
|
|
1173
|
+
|
|
1174
|
+
messageByCallId.set(result.toolCallId, toolMessage);
|
|
572
1175
|
}
|
|
1176
|
+
}
|
|
573
1177
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
1178
|
+
const toolMessages = toolCalls
|
|
1179
|
+
.map((call) => messageByCallId.get(call.id!))
|
|
1180
|
+
.filter((m): m is ToolMessage => m != null);
|
|
1181
|
+
return { toolMessages, injected };
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
private dispatchStepCompleted(
|
|
1185
|
+
toolCallId: string,
|
|
1186
|
+
toolName: string,
|
|
1187
|
+
args: Record<string, unknown>,
|
|
1188
|
+
output: string,
|
|
1189
|
+
config: RunnableConfig,
|
|
1190
|
+
turn?: number
|
|
1191
|
+
): void {
|
|
1192
|
+
const stepId = this.toolCallStepIds?.get(toolCallId) ?? '';
|
|
1193
|
+
if (!stepId) {
|
|
1194
|
+
// eslint-disable-next-line no-console
|
|
1195
|
+
console.warn(
|
|
1196
|
+
`[ToolNode] toolCallStepIds missing entry for toolCallId=${toolCallId} (tool=${toolName}). ` +
|
|
1197
|
+
'This indicates a race between the stream consumer and graph execution. ' +
|
|
1198
|
+
`Map size: ${this.toolCallStepIds?.size ?? 0}`
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
584
1201
|
|
|
585
|
-
|
|
1202
|
+
safeDispatchCustomEvent(
|
|
1203
|
+
GraphEvents.ON_RUN_STEP_COMPLETED,
|
|
1204
|
+
{
|
|
586
1205
|
result: {
|
|
587
1206
|
id: stepId,
|
|
588
|
-
index:
|
|
1207
|
+
index: turn ?? this.toolUsageCount.get(toolName) ?? 0,
|
|
589
1208
|
type: 'tool_call' as const,
|
|
590
|
-
tool_call
|
|
1209
|
+
tool_call: {
|
|
1210
|
+
args: JSON.stringify(args),
|
|
1211
|
+
name: toolName,
|
|
1212
|
+
id: toolCallId,
|
|
1213
|
+
output,
|
|
1214
|
+
progress: 1,
|
|
1215
|
+
} as t.ProcessedToolCall,
|
|
591
1216
|
},
|
|
1217
|
+
},
|
|
1218
|
+
config
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Converts InjectedMessage instances to LangChain HumanMessage objects.
|
|
1224
|
+
* Both 'user' and 'system' roles become HumanMessage to avoid provider
|
|
1225
|
+
* rejections (Anthropic/Google reject non-leading SystemMessages).
|
|
1226
|
+
* The original role is preserved in additional_kwargs for downstream consumers.
|
|
1227
|
+
*/
|
|
1228
|
+
private convertInjectedMessages(
|
|
1229
|
+
messages: t.InjectedMessage[]
|
|
1230
|
+
): BaseMessage[] {
|
|
1231
|
+
const converted: BaseMessage[] = [];
|
|
1232
|
+
for (const msg of messages) {
|
|
1233
|
+
const additional_kwargs: Record<string, unknown> = {
|
|
1234
|
+
role: msg.role,
|
|
592
1235
|
};
|
|
1236
|
+
if (msg.isMeta != null) additional_kwargs.isMeta = msg.isMeta;
|
|
1237
|
+
if (msg.source != null) additional_kwargs.source = msg.source;
|
|
1238
|
+
if (msg.skillName != null) additional_kwargs.skillName = msg.skillName;
|
|
593
1239
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
runStepCompletedData,
|
|
597
|
-
config
|
|
1240
|
+
converted.push(
|
|
1241
|
+
new HumanMessage({ content: msg.content, additional_kwargs })
|
|
598
1242
|
);
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
});
|
|
1243
|
+
}
|
|
1244
|
+
return converted;
|
|
602
1245
|
}
|
|
603
1246
|
|
|
604
1247
|
/**
|
|
605
1248
|
* Execute all tool calls via ON_TOOL_EXECUTE event dispatch.
|
|
606
|
-
*
|
|
1249
|
+
* Injected messages are placed AFTER ToolMessages to respect provider
|
|
1250
|
+
* message ordering (AIMessage tool_calls must be immediately followed
|
|
1251
|
+
* by their ToolMessage results).
|
|
1252
|
+
*
|
|
1253
|
+
* `batchIndices` mirrors `toolCalls` and carries each call's position
|
|
1254
|
+
* within the parent batch. `turn` is the per-`run()` batch index
|
|
1255
|
+
* captured locally by the caller. Both are threaded so concurrent
|
|
1256
|
+
* invocations cannot race on shared mutable state.
|
|
607
1257
|
*/
|
|
608
1258
|
private async executeViaEvent(
|
|
609
1259
|
toolCalls: ToolCall[],
|
|
610
1260
|
config: RunnableConfig,
|
|
611
1261
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
612
|
-
input: any
|
|
1262
|
+
input: any,
|
|
1263
|
+
batchContext: DispatchBatchContext = {}
|
|
613
1264
|
): Promise<T> {
|
|
614
|
-
const
|
|
1265
|
+
const { toolMessages, injected } = await this.dispatchToolEvents(
|
|
1266
|
+
toolCalls,
|
|
1267
|
+
config,
|
|
1268
|
+
batchContext
|
|
1269
|
+
);
|
|
1270
|
+
const outputs: BaseMessage[] = [...toolMessages, ...injected];
|
|
615
1271
|
return (Array.isArray(input) ? outputs : { messages: outputs }) as T;
|
|
616
1272
|
}
|
|
617
1273
|
|
|
618
1274
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
619
1275
|
protected async run(input: any, config: RunnableConfig): Promise<T> {
|
|
620
1276
|
this.toolCallTurns.clear();
|
|
1277
|
+
/**
|
|
1278
|
+
* Per-batch local map for resolved (post-substitution) args.
|
|
1279
|
+
* Lives on the stack so concurrent `run()` calls on the same
|
|
1280
|
+
* ToolNode cannot read or wipe each other's entries.
|
|
1281
|
+
*/
|
|
1282
|
+
const resolvedArgsByCallId = new Map<string, Record<string, unknown>>();
|
|
1283
|
+
/**
|
|
1284
|
+
* Claim this batch's turn synchronously from the registry (or
|
|
1285
|
+
* fall back to 0 when the feature is disabled). The registry is
|
|
1286
|
+
* partitioned by scope id so overlapping batches cannot
|
|
1287
|
+
* overwrite each other's state even under a shared registry.
|
|
1288
|
+
*
|
|
1289
|
+
* For anonymous callers (no `run_id` in config), mint a unique
|
|
1290
|
+
* per-batch scope id so two concurrent anonymous invocations
|
|
1291
|
+
* don't target the same bucket. The scope is threaded down to
|
|
1292
|
+
* every subsequent registry call on this batch.
|
|
1293
|
+
*/
|
|
1294
|
+
const incomingRunId = config.configurable?.run_id as string | undefined;
|
|
1295
|
+
const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
|
|
1296
|
+
const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
|
|
621
1297
|
let outputs: (BaseMessage | Command)[];
|
|
622
1298
|
|
|
623
1299
|
if (this.isSendInput(input)) {
|
|
624
1300
|
const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
|
|
625
1301
|
if (this.eventDrivenMode && isDirectTool !== true) {
|
|
626
|
-
return this.executeViaEvent([input.lg_tool_call], config, input
|
|
1302
|
+
return this.executeViaEvent([input.lg_tool_call], config, input, {
|
|
1303
|
+
batchIndices: [0],
|
|
1304
|
+
turn,
|
|
1305
|
+
batchScopeId,
|
|
1306
|
+
});
|
|
627
1307
|
}
|
|
628
|
-
outputs = [
|
|
629
|
-
|
|
1308
|
+
outputs = [
|
|
1309
|
+
await this.runTool(input.lg_tool_call, config, {
|
|
1310
|
+
batchIndex: 0,
|
|
1311
|
+
turn,
|
|
1312
|
+
batchScopeId,
|
|
1313
|
+
resolvedArgsByCallId,
|
|
1314
|
+
}),
|
|
1315
|
+
];
|
|
1316
|
+
this.handleRunToolCompletions(
|
|
1317
|
+
[input.lg_tool_call],
|
|
1318
|
+
outputs,
|
|
1319
|
+
config,
|
|
1320
|
+
resolvedArgsByCallId
|
|
1321
|
+
);
|
|
630
1322
|
} else {
|
|
631
1323
|
let messages: BaseMessage[];
|
|
632
1324
|
if (Array.isArray(input)) {
|
|
@@ -685,39 +1377,132 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
685
1377
|
}) ?? [];
|
|
686
1378
|
|
|
687
1379
|
if (this.eventDrivenMode && filteredCalls.length > 0) {
|
|
1380
|
+
const filteredIndices = filteredCalls.map((_, idx) => idx);
|
|
1381
|
+
|
|
688
1382
|
if (!this.directToolNames || this.directToolNames.size === 0) {
|
|
689
|
-
return this.executeViaEvent(filteredCalls, config, input
|
|
1383
|
+
return this.executeViaEvent(filteredCalls, config, input, {
|
|
1384
|
+
batchIndices: filteredIndices,
|
|
1385
|
+
turn,
|
|
1386
|
+
batchScopeId,
|
|
1387
|
+
});
|
|
690
1388
|
}
|
|
691
1389
|
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
)
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1390
|
+
const directEntries: Array<{ call: ToolCall; batchIndex: number }> = [];
|
|
1391
|
+
const eventEntries: Array<{ call: ToolCall; batchIndex: number }> = [];
|
|
1392
|
+
for (let i = 0; i < filteredCalls.length; i++) {
|
|
1393
|
+
const call = filteredCalls[i];
|
|
1394
|
+
const entry = { call, batchIndex: i };
|
|
1395
|
+
if (this.directToolNames!.has(call.name)) {
|
|
1396
|
+
directEntries.push(entry);
|
|
1397
|
+
} else {
|
|
1398
|
+
eventEntries.push(entry);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const directCalls = directEntries.map((e) => e.call);
|
|
1403
|
+
const directIndices = directEntries.map((e) => e.batchIndex);
|
|
1404
|
+
const eventCalls = eventEntries.map((e) => e.call);
|
|
1405
|
+
const eventIndices = eventEntries.map((e) => e.batchIndex);
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Snapshot the event calls' args against the *pre-batch*
|
|
1409
|
+
* registry state synchronously, before any await runs. The
|
|
1410
|
+
* directs are then awaited first (preserving fail-fast
|
|
1411
|
+
* semantics — a thrown error in a direct tool, e.g. with
|
|
1412
|
+
* `handleToolErrors=false` or a `GraphInterrupt`, aborts
|
|
1413
|
+
* before we dispatch any event-driven tools to the host).
|
|
1414
|
+
* Because the event args were captured pre-await, they stay
|
|
1415
|
+
* isolated from same-turn direct outputs that register
|
|
1416
|
+
* during the await.
|
|
1417
|
+
*/
|
|
1418
|
+
const preResolvedEventArgs = new Map<
|
|
1419
|
+
string,
|
|
1420
|
+
{ resolved: Record<string, unknown>; unresolved: string[] }
|
|
1421
|
+
>();
|
|
1422
|
+
/**
|
|
1423
|
+
* Take a frozen snapshot of the registry state before any
|
|
1424
|
+
* direct registrations land. The snapshot resolves
|
|
1425
|
+
* placeholders against this point-in-time view, so a
|
|
1426
|
+
* `PreToolUse` hook later rewriting event args via
|
|
1427
|
+
* `updatedInput` can introduce placeholders that resolve
|
|
1428
|
+
* cross-batch (against prior runs) without ever picking up
|
|
1429
|
+
* same-turn direct outputs.
|
|
1430
|
+
*/
|
|
1431
|
+
const preBatchSnapshot =
|
|
1432
|
+
this.toolOutputRegistry?.snapshot(batchScopeId);
|
|
1433
|
+
if (preBatchSnapshot != null) {
|
|
1434
|
+
for (const entry of eventEntries) {
|
|
1435
|
+
if (entry.call.id != null) {
|
|
1436
|
+
const { resolved, unresolved } = preBatchSnapshot.resolve(
|
|
1437
|
+
entry.call.args as Record<string, unknown>
|
|
1438
|
+
);
|
|
1439
|
+
preResolvedEventArgs.set(entry.call.id, {
|
|
1440
|
+
resolved: resolved as Record<string, unknown>,
|
|
1441
|
+
unresolved,
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
698
1446
|
|
|
699
1447
|
const directOutputs: (BaseMessage | Command)[] =
|
|
700
1448
|
directCalls.length > 0
|
|
701
1449
|
? await Promise.all(
|
|
702
|
-
directCalls.map((call) =>
|
|
1450
|
+
directCalls.map((call, i) =>
|
|
1451
|
+
this.runTool(call, config, {
|
|
1452
|
+
batchIndex: directIndices[i],
|
|
1453
|
+
turn,
|
|
1454
|
+
batchScopeId,
|
|
1455
|
+
resolvedArgsByCallId,
|
|
1456
|
+
})
|
|
1457
|
+
)
|
|
703
1458
|
)
|
|
704
1459
|
: [];
|
|
705
1460
|
|
|
706
1461
|
if (directCalls.length > 0 && directOutputs.length > 0) {
|
|
707
|
-
this.handleRunToolCompletions(
|
|
1462
|
+
this.handleRunToolCompletions(
|
|
1463
|
+
directCalls,
|
|
1464
|
+
directOutputs,
|
|
1465
|
+
config,
|
|
1466
|
+
resolvedArgsByCallId
|
|
1467
|
+
);
|
|
708
1468
|
}
|
|
709
1469
|
|
|
710
|
-
const
|
|
1470
|
+
const eventResult =
|
|
711
1471
|
eventCalls.length > 0
|
|
712
|
-
? await this.dispatchToolEvents(eventCalls, config
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1472
|
+
? await this.dispatchToolEvents(eventCalls, config, {
|
|
1473
|
+
batchIndices: eventIndices,
|
|
1474
|
+
turn,
|
|
1475
|
+
batchScopeId,
|
|
1476
|
+
preResolvedArgs: preResolvedEventArgs,
|
|
1477
|
+
preBatchSnapshot,
|
|
1478
|
+
})
|
|
1479
|
+
: {
|
|
1480
|
+
toolMessages: [] as ToolMessage[],
|
|
1481
|
+
injected: [] as BaseMessage[],
|
|
1482
|
+
};
|
|
1483
|
+
|
|
1484
|
+
outputs = [
|
|
1485
|
+
...directOutputs,
|
|
1486
|
+
...eventResult.toolMessages,
|
|
1487
|
+
...eventResult.injected,
|
|
1488
|
+
];
|
|
716
1489
|
} else {
|
|
717
1490
|
outputs = await Promise.all(
|
|
718
|
-
filteredCalls.map((call) =>
|
|
1491
|
+
filteredCalls.map((call, i) =>
|
|
1492
|
+
this.runTool(call, config, {
|
|
1493
|
+
batchIndex: i,
|
|
1494
|
+
turn,
|
|
1495
|
+
batchScopeId,
|
|
1496
|
+
resolvedArgsByCallId,
|
|
1497
|
+
})
|
|
1498
|
+
)
|
|
1499
|
+
);
|
|
1500
|
+
this.handleRunToolCompletions(
|
|
1501
|
+
filteredCalls,
|
|
1502
|
+
outputs,
|
|
1503
|
+
config,
|
|
1504
|
+
resolvedArgsByCallId
|
|
719
1505
|
);
|
|
720
|
-
this.handleRunToolCompletions(filteredCalls, outputs, config);
|
|
721
1506
|
}
|
|
722
1507
|
}
|
|
723
1508
|
|