@librechat/agents 3.1.70 → 3.1.71-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 +52 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +13 -2
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/main.cjs +4 -0
- package/dist/cjs/main.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 +4 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +43 -0
- package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +482 -45
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/toolOutputReferences.cjs +657 -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/graphs/Graph.mjs +52 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +13 -2
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/main.mjs +2 -2
- package/dist/esm/messages/prune.mjs +9 -2
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +4 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +42 -1
- package/dist/esm/tools/BashExecutor.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +482 -45
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/toolOutputReferences.mjs +649 -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/graphs/Graph.d.ts +28 -0
- package/dist/types/llm/invoke.d.ts +9 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/BashExecutor.d.ts +31 -0
- package/dist/types/tools/ToolNode.d.ts +84 -3
- package/dist/types/tools/toolOutputReferences.d.ts +236 -0
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/messages.d.ts +26 -0
- package/dist/types/types/run.d.ts +9 -1
- package/dist/types/types/tools.d.ts +70 -0
- package/dist/types/utils/truncation.d.ts +21 -0
- package/package.json +1 -1
- package/src/graphs/Graph.ts +55 -0
- package/src/llm/invoke.test.ts +442 -0
- package/src/llm/invoke.ts +23 -2
- package/src/messages/prune.ts +9 -2
- package/src/run.ts +4 -0
- package/src/specs/prune.test.ts +413 -0
- package/src/tools/BashExecutor.ts +45 -0
- package/src/tools/ToolNode.ts +631 -55
- package/src/tools/__tests__/BashExecutor.test.ts +36 -0
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +1438 -0
- package/src/tools/__tests__/annotateMessagesForLLM.test.ts +419 -0
- package/src/tools/__tests__/toolOutputReferences.test.ts +415 -0
- package/src/tools/toolOutputReferences.ts +813 -0
- package/src/types/index.ts +1 -0
- package/src/types/messages.ts +27 -0
- package/src/types/run.ts +9 -1
- package/src/types/tools.ts +71 -0
- package/src/utils/__tests__/truncation.test.ts +66 -0
- package/src/utils/truncation.ts +30 -0
|
@@ -12,6 +12,7 @@ var run = require('../utils/run.cjs');
|
|
|
12
12
|
require('ai-tokenizer');
|
|
13
13
|
require('zod-to-json-schema');
|
|
14
14
|
var executeHooks = require('../hooks/executeHooks.cjs');
|
|
15
|
+
var toolOutputReferences = require('./toolOutputReferences.cjs');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Helper to check if a value is a Send object
|
|
@@ -72,7 +73,27 @@ class ToolNode extends run.RunnableCallable {
|
|
|
72
73
|
maxToolResultChars;
|
|
73
74
|
/** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
|
|
74
75
|
hookRegistry;
|
|
75
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Registry of tool outputs keyed by `tool<idx>turn<turn>`.
|
|
78
|
+
*
|
|
79
|
+
* Populated only when `toolOutputReferences.enabled` is true. The
|
|
80
|
+
* registry owns the run-scoped state (turn counter, last-seen runId,
|
|
81
|
+
* warn-once memo, stored outputs), so sharing a single instance
|
|
82
|
+
* across multiple ToolNodes in a run lets cross-agent `{{…}}`
|
|
83
|
+
* references resolve — which is why multi-agent graphs pass the
|
|
84
|
+
* *same* instance to every ToolNode they compile rather than each
|
|
85
|
+
* ToolNode building its own.
|
|
86
|
+
*/
|
|
87
|
+
toolOutputRegistry;
|
|
88
|
+
/**
|
|
89
|
+
* Monotonic counter used to mint a unique scope id for anonymous
|
|
90
|
+
* batches (ones invoked without a `run_id` in
|
|
91
|
+
* `config.configurable`). Each such batch gets its own registry
|
|
92
|
+
* partition so concurrent anonymous invocations can't delete each
|
|
93
|
+
* other's in-flight state.
|
|
94
|
+
*/
|
|
95
|
+
anonBatchCounter = 0;
|
|
96
|
+
constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, toolOutputReferences: toolOutputReferences$1, toolOutputRegistry, }) {
|
|
76
97
|
super({ name, tags, func: (input, config) => this.run(input, config) });
|
|
77
98
|
this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
|
|
78
99
|
this.toolCallStepIds = toolCallStepIds;
|
|
@@ -88,6 +109,37 @@ class ToolNode extends run.RunnableCallable {
|
|
|
88
109
|
this.maxToolResultChars =
|
|
89
110
|
maxToolResultChars ?? truncation.calculateMaxToolResultChars(maxContextTokens);
|
|
90
111
|
this.hookRegistry = hookRegistry;
|
|
112
|
+
/**
|
|
113
|
+
* Precedence: an explicitly passed `toolOutputRegistry` instance
|
|
114
|
+
* wins over a config object so a host (`Graph`) can share one
|
|
115
|
+
* registry across many ToolNodes. When only the config is
|
|
116
|
+
* provided (direct ToolNode usage), build a local registry so
|
|
117
|
+
* the feature still works without graph-level plumbing. Registry
|
|
118
|
+
* caps are intentionally decoupled from `maxToolResultChars`:
|
|
119
|
+
* the registry stores the raw untruncated output so a later
|
|
120
|
+
* `{{…}}` substitution pipes the full payload into the next
|
|
121
|
+
* tool, even when the LLM saw a truncated preview.
|
|
122
|
+
*/
|
|
123
|
+
if (toolOutputRegistry != null) {
|
|
124
|
+
this.toolOutputRegistry = toolOutputRegistry;
|
|
125
|
+
}
|
|
126
|
+
else if (toolOutputReferences$1?.enabled === true) {
|
|
127
|
+
this.toolOutputRegistry = new toolOutputReferences.ToolOutputReferenceRegistry({
|
|
128
|
+
maxOutputSize: toolOutputReferences$1.maxOutputSize,
|
|
129
|
+
maxTotalSize: toolOutputReferences$1.maxTotalSize,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Returns the run-scoped tool output registry, or `undefined` when
|
|
135
|
+
* the feature is disabled.
|
|
136
|
+
*
|
|
137
|
+
* @internal Exposed for test observation only. Host code should rely
|
|
138
|
+
* on `{{tool<i>turn<n>}}` substitution at tool-invocation time and
|
|
139
|
+
* not mutate the registry directly.
|
|
140
|
+
*/
|
|
141
|
+
_unsafeGetToolOutputRegistry() {
|
|
142
|
+
return this.toolOutputRegistry;
|
|
91
143
|
}
|
|
92
144
|
/**
|
|
93
145
|
* Returns cached programmatic tools, computing once on first access.
|
|
@@ -119,28 +171,85 @@ class ToolNode extends run.RunnableCallable {
|
|
|
119
171
|
return new Map(this.toolUsageCount); // Return a copy
|
|
120
172
|
}
|
|
121
173
|
/**
|
|
122
|
-
* Runs a single tool call with error handling
|
|
174
|
+
* Runs a single tool call with error handling.
|
|
175
|
+
*
|
|
176
|
+
* `batchIndex` is the tool's position within the current ToolNode
|
|
177
|
+
* batch and, together with `this.currentTurn`, forms the key used to
|
|
178
|
+
* register the output for future `{{tool<idx>turn<turn>}}`
|
|
179
|
+
* substitutions. Omit when no registration should occur.
|
|
123
180
|
*/
|
|
124
|
-
async runTool(call, config) {
|
|
181
|
+
async runTool(call, config, batchContext = {}) {
|
|
182
|
+
const { batchIndex, turn, batchScopeId, resolvedArgsByCallId } = batchContext;
|
|
125
183
|
const tool = this.toolMap.get(call.name);
|
|
184
|
+
const registry = this.toolOutputRegistry;
|
|
185
|
+
/**
|
|
186
|
+
* Precompute the reference key once per call — captured locally
|
|
187
|
+
* so concurrent `invoke()` calls on the same ToolNode cannot race
|
|
188
|
+
* on a shared turn field.
|
|
189
|
+
*/
|
|
190
|
+
const refKey = registry != null && batchIndex != null && turn != null
|
|
191
|
+
? toolOutputReferences.buildReferenceKey(batchIndex, turn)
|
|
192
|
+
: undefined;
|
|
193
|
+
/**
|
|
194
|
+
* Hoisted outside the try so the catch branch can append
|
|
195
|
+
* `[unresolved refs: …]` to error messages — otherwise the LLM
|
|
196
|
+
* only sees a generic error when it references a bad key, losing
|
|
197
|
+
* the self-correction signal this feature is meant to provide.
|
|
198
|
+
*/
|
|
199
|
+
let unresolvedRefs = [];
|
|
200
|
+
/**
|
|
201
|
+
* Use the caller-provided `batchScopeId` when threaded from
|
|
202
|
+
* `run()` (so anonymous batches get their own unique scope).
|
|
203
|
+
* Fall back to the config's `run_id` when runTool is invoked
|
|
204
|
+
* from a context that doesn't thread it — that still preserves
|
|
205
|
+
* the runId-based partitioning for named runs.
|
|
206
|
+
*/
|
|
207
|
+
const runId = batchScopeId ?? config.configurable?.run_id;
|
|
126
208
|
try {
|
|
127
209
|
if (tool === undefined) {
|
|
128
210
|
throw new Error(`Tool "${call.name}" not found.`);
|
|
129
211
|
}
|
|
130
|
-
|
|
131
|
-
|
|
212
|
+
/**
|
|
213
|
+
* `usageCount` is the per-tool-name invocation index that
|
|
214
|
+
* web-search and other tools observe via `invokeParams.turn`.
|
|
215
|
+
* It is intentionally distinct from the outer `turn` parameter
|
|
216
|
+
* (the batch turn used for ref keys); the latter is captured
|
|
217
|
+
* before the try block when constructing `refKey`.
|
|
218
|
+
*/
|
|
219
|
+
const usageCount = this.toolUsageCount.get(call.name) ?? 0;
|
|
220
|
+
this.toolUsageCount.set(call.name, usageCount + 1);
|
|
132
221
|
if (call.id != null && call.id !== '') {
|
|
133
|
-
this.toolCallTurns.set(call.id,
|
|
222
|
+
this.toolCallTurns.set(call.id, usageCount);
|
|
223
|
+
}
|
|
224
|
+
let args = call.args;
|
|
225
|
+
if (registry != null) {
|
|
226
|
+
const { resolved, unresolved } = registry.resolve(runId, args);
|
|
227
|
+
args = resolved;
|
|
228
|
+
unresolvedRefs = unresolved;
|
|
229
|
+
/**
|
|
230
|
+
* Expose the post-substitution args to downstream completion
|
|
231
|
+
* events so audit logs / host-side `ON_RUN_STEP_COMPLETED`
|
|
232
|
+
* handlers observe what actually ran, not the `{{…}}`
|
|
233
|
+
* template. Only string/object args are worth recording.
|
|
234
|
+
*/
|
|
235
|
+
if (resolvedArgsByCallId != null &&
|
|
236
|
+
call.id != null &&
|
|
237
|
+
call.id !== '' &&
|
|
238
|
+
resolved !== call.args &&
|
|
239
|
+
typeof resolved === 'object') {
|
|
240
|
+
resolvedArgsByCallId.set(call.id, resolved);
|
|
241
|
+
}
|
|
134
242
|
}
|
|
135
|
-
const args = call.args;
|
|
136
243
|
const stepId = this.toolCallStepIds?.get(call.id);
|
|
137
244
|
// Build invoke params - LangChain extracts non-schema fields to config.toolCall
|
|
245
|
+
// `turn` here is the per-tool usage count (matches what tools have
|
|
246
|
+
// observed historically via config.toolCall.turn — e.g. web search).
|
|
138
247
|
let invokeParams = {
|
|
139
248
|
...call,
|
|
140
249
|
args,
|
|
141
250
|
type: 'tool_call',
|
|
142
251
|
stepId,
|
|
143
|
-
turn,
|
|
252
|
+
turn: usageCount,
|
|
144
253
|
};
|
|
145
254
|
// Inject runtime data for special tools (becomes available at config.toolCall)
|
|
146
255
|
if (call.name === _enum.Constants.PROGRAMMATIC_TOOL_CALLING ||
|
|
@@ -185,19 +294,80 @@ class ToolNode extends run.RunnableCallable {
|
|
|
185
294
|
}
|
|
186
295
|
}
|
|
187
296
|
const output = await tool.invoke(invokeParams, config);
|
|
188
|
-
if (
|
|
189
|
-
langgraph.isCommand(output)) {
|
|
297
|
+
if (langgraph.isCommand(output)) {
|
|
190
298
|
return output;
|
|
191
299
|
}
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
300
|
+
if (messages.isBaseMessage(output) && output._getType() === 'tool') {
|
|
301
|
+
const toolMsg = output;
|
|
302
|
+
const isError = toolMsg.status === 'error';
|
|
303
|
+
if (isError) {
|
|
304
|
+
/**
|
|
305
|
+
* Error ToolMessages bypass registration but still stamp the
|
|
306
|
+
* unresolved-refs hint into `additional_kwargs` so the lazy
|
|
307
|
+
* annotation transform surfaces it to the LLM, letting the
|
|
308
|
+
* model self-correct when its reference key caused the
|
|
309
|
+
* failure. Persisted `content` stays clean.
|
|
310
|
+
*/
|
|
311
|
+
if (unresolvedRefs.length > 0) {
|
|
312
|
+
toolMsg.additional_kwargs = {
|
|
313
|
+
...toolMsg.additional_kwargs,
|
|
314
|
+
_unresolvedRefs: unresolvedRefs,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return toolMsg;
|
|
318
|
+
}
|
|
319
|
+
if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
|
|
320
|
+
if (typeof toolMsg.content === 'string') {
|
|
321
|
+
const rawContent = toolMsg.content;
|
|
322
|
+
const llmContent = truncation.truncateToolResultContent(rawContent, this.maxToolResultChars);
|
|
323
|
+
toolMsg.content = llmContent;
|
|
324
|
+
const refMeta = this.recordOutputReference(runId, rawContent, refKey, unresolvedRefs);
|
|
325
|
+
if (refMeta != null) {
|
|
326
|
+
toolMsg.additional_kwargs = {
|
|
327
|
+
...toolMsg.additional_kwargs,
|
|
328
|
+
...refMeta,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
/**
|
|
334
|
+
* Non-string content (multi-part content blocks — text +
|
|
335
|
+
* image). Known limitation: we cannot register under a
|
|
336
|
+
* reference key because there's no canonical serialized
|
|
337
|
+
* form. Warn once per tool per run when the caller
|
|
338
|
+
* intended to register. The unresolved-refs hint is still
|
|
339
|
+
* stamped as metadata; the lazy transform prepends a text
|
|
340
|
+
* block at request time so the LLM gets the self-correction
|
|
341
|
+
* signal.
|
|
342
|
+
*/
|
|
343
|
+
if (unresolvedRefs.length > 0) {
|
|
344
|
+
toolMsg.additional_kwargs = {
|
|
345
|
+
...toolMsg.additional_kwargs,
|
|
346
|
+
_unresolvedRefs: unresolvedRefs,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (refKey != null &&
|
|
350
|
+
this.toolOutputRegistry.claimWarnOnce(runId, call.name)) {
|
|
351
|
+
// eslint-disable-next-line no-console
|
|
352
|
+
console.warn(`[ToolNode] Skipping tool output reference for "${call.name}": ` +
|
|
353
|
+
'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return toolMsg;
|
|
200
358
|
}
|
|
359
|
+
const rawContent = typeof output === 'string' ? output : JSON.stringify(output);
|
|
360
|
+
const truncated = truncation.truncateToolResultContent(rawContent, this.maxToolResultChars);
|
|
361
|
+
const refMeta = this.recordOutputReference(runId, rawContent, refKey, unresolvedRefs);
|
|
362
|
+
return new messages.ToolMessage({
|
|
363
|
+
status: 'success',
|
|
364
|
+
name: tool.name,
|
|
365
|
+
content: truncated,
|
|
366
|
+
tool_call_id: call.id,
|
|
367
|
+
...(refMeta != null && {
|
|
368
|
+
additional_kwargs: refMeta,
|
|
369
|
+
}),
|
|
370
|
+
});
|
|
201
371
|
}
|
|
202
372
|
catch (_e) {
|
|
203
373
|
const e = _e;
|
|
@@ -240,14 +410,65 @@ class ToolNode extends run.RunnableCallable {
|
|
|
240
410
|
});
|
|
241
411
|
}
|
|
242
412
|
}
|
|
413
|
+
const errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
|
|
414
|
+
const refMeta = unresolvedRefs.length > 0
|
|
415
|
+
? this.recordOutputReference(runId, errorContent, undefined, unresolvedRefs)
|
|
416
|
+
: undefined;
|
|
243
417
|
return new messages.ToolMessage({
|
|
244
418
|
status: 'error',
|
|
245
|
-
content:
|
|
419
|
+
content: errorContent,
|
|
246
420
|
name: call.name,
|
|
247
421
|
tool_call_id: call.id ?? '',
|
|
422
|
+
...(refMeta != null && {
|
|
423
|
+
additional_kwargs: refMeta,
|
|
424
|
+
}),
|
|
248
425
|
});
|
|
249
426
|
}
|
|
250
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* Registers the full, raw output under `refKey` (when provided) and
|
|
430
|
+
* builds the per-message ref metadata stamped onto the resulting
|
|
431
|
+
* `ToolMessage.additional_kwargs`. The metadata is read at LLM-
|
|
432
|
+
* request time by `annotateMessagesForLLM` to produce a transient
|
|
433
|
+
* annotated copy of the message — the persisted `content` itself
|
|
434
|
+
* stays clean.
|
|
435
|
+
*
|
|
436
|
+
* @param registryContent The full, untruncated output to store in
|
|
437
|
+
* the registry so `{{tool<i>turn<n>}}` substitutions deliver the
|
|
438
|
+
* complete payload. Ignored when `refKey` is undefined.
|
|
439
|
+
* @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
|
|
440
|
+
* the output is not to be registered (errors, disabled feature,
|
|
441
|
+
* unavailable batch/turn).
|
|
442
|
+
* @param unresolved Placeholder keys that did not resolve; surfaced
|
|
443
|
+
* to the LLM lazily so it can self-correct.
|
|
444
|
+
* @returns A `ToolMessageRefMetadata` object when there is anything
|
|
445
|
+
* to stamp, otherwise `undefined`.
|
|
446
|
+
*/
|
|
447
|
+
recordOutputReference(runId, registryContent, refKey, unresolved) {
|
|
448
|
+
if (this.toolOutputRegistry != null && refKey != null) {
|
|
449
|
+
this.toolOutputRegistry.set(runId, refKey, registryContent);
|
|
450
|
+
}
|
|
451
|
+
if (refKey == null && unresolved.length === 0)
|
|
452
|
+
return undefined;
|
|
453
|
+
const meta = {};
|
|
454
|
+
if (refKey != null) {
|
|
455
|
+
meta._refKey = refKey;
|
|
456
|
+
/**
|
|
457
|
+
* Stamp the registry scope alongside the key so the lazy
|
|
458
|
+
* annotation transform can look up the right bucket. Anonymous
|
|
459
|
+
* invocations get a synthetic per-batch scope (`\0anon-<n>`)
|
|
460
|
+
* that `attemptInvoke` cannot derive from
|
|
461
|
+
* `config.configurable.run_id` — without this, anonymous-run
|
|
462
|
+
* refs would silently fail registry lookup and the LLM would
|
|
463
|
+
* never see `[ref: …]` markers for outputs that were registered.
|
|
464
|
+
*/
|
|
465
|
+
if (runId != null)
|
|
466
|
+
meta._refScope = runId;
|
|
467
|
+
}
|
|
468
|
+
if (unresolved.length > 0)
|
|
469
|
+
meta._unresolvedRefs = unresolved;
|
|
470
|
+
return meta;
|
|
471
|
+
}
|
|
251
472
|
/**
|
|
252
473
|
* Builds code session context for injection into event-driven tool calls.
|
|
253
474
|
* Mirrors the session injection logic in runTool() for direct execution.
|
|
@@ -306,8 +527,12 @@ class ToolNode extends run.RunnableCallable {
|
|
|
306
527
|
* By handling completions here in graph context (rather than in the
|
|
307
528
|
* stream consumer via ToolEndHandler), the race between the stream
|
|
308
529
|
* consumer and graph execution is eliminated.
|
|
530
|
+
*
|
|
531
|
+
* @param resolvedArgsByCallId Per-batch resolved-args sink populated
|
|
532
|
+
* by `runTool`. Threaded as a local map (instead of instance state)
|
|
533
|
+
* so concurrent batches cannot read each other's entries.
|
|
309
534
|
*/
|
|
310
|
-
handleRunToolCompletions(calls, outputs, config) {
|
|
535
|
+
handleRunToolCompletions(calls, outputs, config, resolvedArgsByCallId) {
|
|
311
536
|
for (let i = 0; i < calls.length; i++) {
|
|
312
537
|
const call = calls[i];
|
|
313
538
|
const output = outputs[i];
|
|
@@ -336,10 +561,17 @@ class ToolNode extends run.RunnableCallable {
|
|
|
336
561
|
const contentString = typeof toolMessage.content === 'string'
|
|
337
562
|
? toolMessage.content
|
|
338
563
|
: JSON.stringify(toolMessage.content);
|
|
564
|
+
/**
|
|
565
|
+
* Prefer the post-substitution args when a `{{…}}` placeholder
|
|
566
|
+
* was resolved in `runTool`. This keeps
|
|
567
|
+
* `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
|
|
568
|
+
* the tool actually received rather than leaking the template.
|
|
569
|
+
*/
|
|
570
|
+
const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
|
|
339
571
|
const tool_call = {
|
|
340
|
-
args: typeof
|
|
341
|
-
?
|
|
342
|
-
: JSON.stringify(
|
|
572
|
+
args: typeof effectiveArgs === 'string'
|
|
573
|
+
? effectiveArgs
|
|
574
|
+
: JSON.stringify(effectiveArgs ?? {}),
|
|
343
575
|
name: call.name,
|
|
344
576
|
id: toolCallId,
|
|
345
577
|
output: contentString,
|
|
@@ -369,14 +601,52 @@ class ToolNode extends run.RunnableCallable {
|
|
|
369
601
|
* 4. Injected messages from results are collected and returned alongside
|
|
370
602
|
* ToolMessages (appended AFTER to respect provider ordering).
|
|
371
603
|
*/
|
|
372
|
-
async dispatchToolEvents(toolCalls, config) {
|
|
604
|
+
async dispatchToolEvents(toolCalls, config, batchContext = {}) {
|
|
605
|
+
const { batchIndices, turn, batchScopeId, preResolvedArgs, preBatchSnapshot, } = batchContext;
|
|
373
606
|
const runId = config.configurable?.run_id ?? '';
|
|
607
|
+
/**
|
|
608
|
+
* Registry-facing scope id — prefers the caller-threaded
|
|
609
|
+
* `batchScopeId` so anonymous batches target their own unique
|
|
610
|
+
* bucket and don't step on concurrent anonymous invocations.
|
|
611
|
+
* Hooks and event payloads keep using the empty-string coerced
|
|
612
|
+
* `runId` for backward compat.
|
|
613
|
+
*/
|
|
614
|
+
const registryRunId = batchScopeId ?? config.configurable?.run_id;
|
|
374
615
|
const threadId = config.configurable?.thread_id;
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
616
|
+
const registry = this.toolOutputRegistry;
|
|
617
|
+
const unresolvedByCallId = new Map();
|
|
618
|
+
const preToolCalls = toolCalls.map((call, i) => {
|
|
619
|
+
const originalArgs = call.args;
|
|
620
|
+
let resolvedArgs = originalArgs;
|
|
621
|
+
/**
|
|
622
|
+
* When the caller provided a pre-resolved map (the mixed
|
|
623
|
+
* direct+event path snapshots event args synchronously before
|
|
624
|
+
* awaiting directs so they can't accidentally resolve
|
|
625
|
+
* same-turn direct outputs), use those entries verbatim instead
|
|
626
|
+
* of re-resolving against a registry that may have changed
|
|
627
|
+
* since the batch started.
|
|
628
|
+
*/
|
|
629
|
+
const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
|
|
630
|
+
if (pre != null) {
|
|
631
|
+
resolvedArgs = pre.resolved;
|
|
632
|
+
if (pre.unresolved.length > 0 && call.id != null) {
|
|
633
|
+
unresolvedByCallId.set(call.id, pre.unresolved);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
else if (registry != null) {
|
|
637
|
+
const { resolved, unresolved } = registry.resolve(registryRunId, originalArgs);
|
|
638
|
+
resolvedArgs = resolved;
|
|
639
|
+
if (unresolved.length > 0 && call.id != null) {
|
|
640
|
+
unresolvedByCallId.set(call.id, unresolved);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
call,
|
|
645
|
+
stepId: this.toolCallStepIds?.get(call.id) ?? '',
|
|
646
|
+
args: resolvedArgs,
|
|
647
|
+
batchIndex: batchIndices?.[i],
|
|
648
|
+
};
|
|
649
|
+
});
|
|
380
650
|
const messageByCallId = new Map();
|
|
381
651
|
const approvedEntries = [];
|
|
382
652
|
const HOOK_FALLBACK = Object.freeze({
|
|
@@ -436,7 +706,40 @@ class ToolNode extends run.RunnableCallable {
|
|
|
436
706
|
continue;
|
|
437
707
|
}
|
|
438
708
|
if (hookResult.updatedInput != null) {
|
|
439
|
-
|
|
709
|
+
/**
|
|
710
|
+
* Re-resolve after PreToolUse replaces the input: a hook may
|
|
711
|
+
* introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
|
|
712
|
+
* copying user-supplied text) that the pre-hook pass never
|
|
713
|
+
* saw. Re-running the resolver on the hook-rewritten args
|
|
714
|
+
* keeps substitution and the unresolved-refs record in sync
|
|
715
|
+
* with what the tool will actually receive.
|
|
716
|
+
*/
|
|
717
|
+
if (registry != null) {
|
|
718
|
+
/**
|
|
719
|
+
* Mixed direct+event batches must use the pre-batch
|
|
720
|
+
* snapshot so a hook-introduced placeholder cannot
|
|
721
|
+
* accidentally resolve to a same-turn direct output that
|
|
722
|
+
* has just registered. Pure event batches don't have a
|
|
723
|
+
* snapshot and resolve against the live registry — safe
|
|
724
|
+
* because no event-side registrations have happened yet.
|
|
725
|
+
*/
|
|
726
|
+
const view = preBatchSnapshot ?? {
|
|
727
|
+
resolve: (args) => registry.resolve(registryRunId, args),
|
|
728
|
+
};
|
|
729
|
+
const { resolved, unresolved } = view.resolve(hookResult.updatedInput);
|
|
730
|
+
entry.args = resolved;
|
|
731
|
+
if (entry.call.id != null) {
|
|
732
|
+
if (unresolved.length > 0) {
|
|
733
|
+
unresolvedByCallId.set(entry.call.id, unresolved);
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
unresolvedByCallId.delete(entry.call.id);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
entry.args = hookResult.updatedInput;
|
|
742
|
+
}
|
|
440
743
|
}
|
|
441
744
|
approvedEntries.push(entry);
|
|
442
745
|
}
|
|
@@ -445,10 +748,14 @@ class ToolNode extends run.RunnableCallable {
|
|
|
445
748
|
approvedEntries.push(...preToolCalls);
|
|
446
749
|
}
|
|
447
750
|
const injected = [];
|
|
751
|
+
const batchIndexByCallId = new Map();
|
|
448
752
|
if (approvedEntries.length > 0) {
|
|
449
753
|
const requests = approvedEntries.map((entry) => {
|
|
450
754
|
const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
|
|
451
755
|
this.toolUsageCount.set(entry.call.name, turn + 1);
|
|
756
|
+
if (entry.batchIndex != null && entry.call.id != null) {
|
|
757
|
+
batchIndexByCallId.set(entry.call.id, entry.batchIndex);
|
|
758
|
+
}
|
|
452
759
|
const request = {
|
|
453
760
|
id: entry.call.id,
|
|
454
761
|
name: entry.call.name,
|
|
@@ -494,11 +801,25 @@ class ToolNode extends run.RunnableCallable {
|
|
|
494
801
|
let toolMessage;
|
|
495
802
|
if (result.status === 'error') {
|
|
496
803
|
contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
|
|
804
|
+
/**
|
|
805
|
+
* Error results bypass registration but stamp the
|
|
806
|
+
* unresolved-refs hint into `additional_kwargs` so the lazy
|
|
807
|
+
* annotation transform surfaces it to the LLM at request
|
|
808
|
+
* time, letting the model self-correct when its reference
|
|
809
|
+
* key caused the failure. Persisted `content` stays clean.
|
|
810
|
+
*/
|
|
811
|
+
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
812
|
+
const errorRefMeta = unresolved.length > 0
|
|
813
|
+
? this.recordOutputReference(registryRunId, contentString, undefined, unresolved)
|
|
814
|
+
: undefined;
|
|
497
815
|
toolMessage = new messages.ToolMessage({
|
|
498
816
|
status: 'error',
|
|
499
817
|
content: contentString,
|
|
500
818
|
name: toolName,
|
|
501
819
|
tool_call_id: result.toolCallId,
|
|
820
|
+
...(errorRefMeta != null && {
|
|
821
|
+
additional_kwargs: errorRefMeta,
|
|
822
|
+
}),
|
|
502
823
|
});
|
|
503
824
|
if (hasFailureHook) {
|
|
504
825
|
await executeHooks.executeHooks({
|
|
@@ -523,10 +844,10 @@ class ToolNode extends run.RunnableCallable {
|
|
|
523
844
|
}
|
|
524
845
|
}
|
|
525
846
|
else {
|
|
526
|
-
|
|
847
|
+
let registryRaw = typeof result.content === 'string'
|
|
527
848
|
? result.content
|
|
528
849
|
: JSON.stringify(result.content);
|
|
529
|
-
contentString = truncation.truncateToolResultContent(
|
|
850
|
+
contentString = truncation.truncateToolResultContent(registryRaw, this.maxToolResultChars);
|
|
530
851
|
if (hasPostHook) {
|
|
531
852
|
const hookResult = await executeHooks.executeHooks({
|
|
532
853
|
registry: this.hookRegistry,
|
|
@@ -549,15 +870,27 @@ class ToolNode extends run.RunnableCallable {
|
|
|
549
870
|
const replaced = typeof hookResult.updatedOutput === 'string'
|
|
550
871
|
? hookResult.updatedOutput
|
|
551
872
|
: JSON.stringify(hookResult.updatedOutput);
|
|
873
|
+
registryRaw = replaced;
|
|
552
874
|
contentString = truncation.truncateToolResultContent(replaced, this.maxToolResultChars);
|
|
553
875
|
}
|
|
554
876
|
}
|
|
877
|
+
const batchIndex = batchIndexByCallId.get(result.toolCallId);
|
|
878
|
+
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
879
|
+
const refKey = this.toolOutputRegistry != null &&
|
|
880
|
+
batchIndex != null &&
|
|
881
|
+
turn != null
|
|
882
|
+
? toolOutputReferences.buildReferenceKey(batchIndex, turn)
|
|
883
|
+
: undefined;
|
|
884
|
+
const successRefMeta = this.recordOutputReference(registryRunId, registryRaw, refKey, unresolved);
|
|
555
885
|
toolMessage = new messages.ToolMessage({
|
|
556
886
|
status: 'success',
|
|
557
887
|
name: toolName,
|
|
558
888
|
content: contentString,
|
|
559
889
|
artifact: result.artifact,
|
|
560
890
|
tool_call_id: result.toolCallId,
|
|
891
|
+
...(successRefMeta != null && {
|
|
892
|
+
additional_kwargs: successRefMeta,
|
|
893
|
+
}),
|
|
561
894
|
});
|
|
562
895
|
}
|
|
563
896
|
this.dispatchStepCompleted(result.toolCallId, toolName, request?.args ?? {}, contentString, config, request?.turn);
|
|
@@ -619,25 +952,61 @@ class ToolNode extends run.RunnableCallable {
|
|
|
619
952
|
* Injected messages are placed AFTER ToolMessages to respect provider
|
|
620
953
|
* message ordering (AIMessage tool_calls must be immediately followed
|
|
621
954
|
* by their ToolMessage results).
|
|
955
|
+
*
|
|
956
|
+
* `batchIndices` mirrors `toolCalls` and carries each call's position
|
|
957
|
+
* within the parent batch. `turn` is the per-`run()` batch index
|
|
958
|
+
* captured locally by the caller. Both are threaded so concurrent
|
|
959
|
+
* invocations cannot race on shared mutable state.
|
|
622
960
|
*/
|
|
623
961
|
async executeViaEvent(toolCalls, config,
|
|
624
962
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
625
|
-
input) {
|
|
626
|
-
const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config);
|
|
963
|
+
input, batchContext = {}) {
|
|
964
|
+
const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config, batchContext);
|
|
627
965
|
const outputs = [...toolMessages, ...injected];
|
|
628
966
|
return (Array.isArray(input) ? outputs : { messages: outputs });
|
|
629
967
|
}
|
|
630
968
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
631
969
|
async run(input, config) {
|
|
632
970
|
this.toolCallTurns.clear();
|
|
971
|
+
/**
|
|
972
|
+
* Per-batch local map for resolved (post-substitution) args.
|
|
973
|
+
* Lives on the stack so concurrent `run()` calls on the same
|
|
974
|
+
* ToolNode cannot read or wipe each other's entries.
|
|
975
|
+
*/
|
|
976
|
+
const resolvedArgsByCallId = new Map();
|
|
977
|
+
/**
|
|
978
|
+
* Claim this batch's turn synchronously from the registry (or
|
|
979
|
+
* fall back to 0 when the feature is disabled). The registry is
|
|
980
|
+
* partitioned by scope id so overlapping batches cannot
|
|
981
|
+
* overwrite each other's state even under a shared registry.
|
|
982
|
+
*
|
|
983
|
+
* For anonymous callers (no `run_id` in config), mint a unique
|
|
984
|
+
* per-batch scope id so two concurrent anonymous invocations
|
|
985
|
+
* don't target the same bucket. The scope is threaded down to
|
|
986
|
+
* every subsequent registry call on this batch.
|
|
987
|
+
*/
|
|
988
|
+
const incomingRunId = config.configurable?.run_id;
|
|
989
|
+
const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
|
|
990
|
+
const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
|
|
633
991
|
let outputs;
|
|
634
992
|
if (this.isSendInput(input)) {
|
|
635
993
|
const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
|
|
636
994
|
if (this.eventDrivenMode && isDirectTool !== true) {
|
|
637
|
-
return this.executeViaEvent([input.lg_tool_call], config, input
|
|
995
|
+
return this.executeViaEvent([input.lg_tool_call], config, input, {
|
|
996
|
+
batchIndices: [0],
|
|
997
|
+
turn,
|
|
998
|
+
batchScopeId,
|
|
999
|
+
});
|
|
638
1000
|
}
|
|
639
|
-
outputs = [
|
|
640
|
-
|
|
1001
|
+
outputs = [
|
|
1002
|
+
await this.runTool(input.lg_tool_call, config, {
|
|
1003
|
+
batchIndex: 0,
|
|
1004
|
+
turn,
|
|
1005
|
+
batchScopeId,
|
|
1006
|
+
resolvedArgsByCallId,
|
|
1007
|
+
}),
|
|
1008
|
+
];
|
|
1009
|
+
this.handleRunToolCompletions([input.lg_tool_call], outputs, config, resolvedArgsByCallId);
|
|
641
1010
|
}
|
|
642
1011
|
else {
|
|
643
1012
|
let messages$1;
|
|
@@ -682,19 +1051,82 @@ class ToolNode extends run.RunnableCallable {
|
|
|
682
1051
|
false));
|
|
683
1052
|
}) ?? [];
|
|
684
1053
|
if (this.eventDrivenMode && filteredCalls.length > 0) {
|
|
1054
|
+
const filteredIndices = filteredCalls.map((_, idx) => idx);
|
|
685
1055
|
if (!this.directToolNames || this.directToolNames.size === 0) {
|
|
686
|
-
return this.executeViaEvent(filteredCalls, config, input
|
|
1056
|
+
return this.executeViaEvent(filteredCalls, config, input, {
|
|
1057
|
+
batchIndices: filteredIndices,
|
|
1058
|
+
turn,
|
|
1059
|
+
batchScopeId,
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
const directEntries = [];
|
|
1063
|
+
const eventEntries = [];
|
|
1064
|
+
for (let i = 0; i < filteredCalls.length; i++) {
|
|
1065
|
+
const call = filteredCalls[i];
|
|
1066
|
+
const entry = { call, batchIndex: i };
|
|
1067
|
+
if (this.directToolNames.has(call.name)) {
|
|
1068
|
+
directEntries.push(entry);
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
eventEntries.push(entry);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
const directCalls = directEntries.map((e) => e.call);
|
|
1075
|
+
const directIndices = directEntries.map((e) => e.batchIndex);
|
|
1076
|
+
const eventCalls = eventEntries.map((e) => e.call);
|
|
1077
|
+
const eventIndices = eventEntries.map((e) => e.batchIndex);
|
|
1078
|
+
/**
|
|
1079
|
+
* Snapshot the event calls' args against the *pre-batch*
|
|
1080
|
+
* registry state synchronously, before any await runs. The
|
|
1081
|
+
* directs are then awaited first (preserving fail-fast
|
|
1082
|
+
* semantics — a thrown error in a direct tool, e.g. with
|
|
1083
|
+
* `handleToolErrors=false` or a `GraphInterrupt`, aborts
|
|
1084
|
+
* before we dispatch any event-driven tools to the host).
|
|
1085
|
+
* Because the event args were captured pre-await, they stay
|
|
1086
|
+
* isolated from same-turn direct outputs that register
|
|
1087
|
+
* during the await.
|
|
1088
|
+
*/
|
|
1089
|
+
const preResolvedEventArgs = new Map();
|
|
1090
|
+
/**
|
|
1091
|
+
* Take a frozen snapshot of the registry state before any
|
|
1092
|
+
* direct registrations land. The snapshot resolves
|
|
1093
|
+
* placeholders against this point-in-time view, so a
|
|
1094
|
+
* `PreToolUse` hook later rewriting event args via
|
|
1095
|
+
* `updatedInput` can introduce placeholders that resolve
|
|
1096
|
+
* cross-batch (against prior runs) without ever picking up
|
|
1097
|
+
* same-turn direct outputs.
|
|
1098
|
+
*/
|
|
1099
|
+
const preBatchSnapshot = this.toolOutputRegistry?.snapshot(batchScopeId);
|
|
1100
|
+
if (preBatchSnapshot != null) {
|
|
1101
|
+
for (const entry of eventEntries) {
|
|
1102
|
+
if (entry.call.id != null) {
|
|
1103
|
+
const { resolved, unresolved } = preBatchSnapshot.resolve(entry.call.args);
|
|
1104
|
+
preResolvedEventArgs.set(entry.call.id, {
|
|
1105
|
+
resolved: resolved,
|
|
1106
|
+
unresolved,
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
687
1110
|
}
|
|
688
|
-
const directCalls = filteredCalls.filter((c) => this.directToolNames.has(c.name));
|
|
689
|
-
const eventCalls = filteredCalls.filter((c) => !this.directToolNames.has(c.name));
|
|
690
1111
|
const directOutputs = directCalls.length > 0
|
|
691
|
-
? await Promise.all(directCalls.map((call) => this.runTool(call, config
|
|
1112
|
+
? await Promise.all(directCalls.map((call, i) => this.runTool(call, config, {
|
|
1113
|
+
batchIndex: directIndices[i],
|
|
1114
|
+
turn,
|
|
1115
|
+
batchScopeId,
|
|
1116
|
+
resolvedArgsByCallId,
|
|
1117
|
+
})))
|
|
692
1118
|
: [];
|
|
693
1119
|
if (directCalls.length > 0 && directOutputs.length > 0) {
|
|
694
|
-
this.handleRunToolCompletions(directCalls, directOutputs, config);
|
|
1120
|
+
this.handleRunToolCompletions(directCalls, directOutputs, config, resolvedArgsByCallId);
|
|
695
1121
|
}
|
|
696
1122
|
const eventResult = eventCalls.length > 0
|
|
697
|
-
? await this.dispatchToolEvents(eventCalls, config
|
|
1123
|
+
? await this.dispatchToolEvents(eventCalls, config, {
|
|
1124
|
+
batchIndices: eventIndices,
|
|
1125
|
+
turn,
|
|
1126
|
+
batchScopeId,
|
|
1127
|
+
preResolvedArgs: preResolvedEventArgs,
|
|
1128
|
+
preBatchSnapshot,
|
|
1129
|
+
})
|
|
698
1130
|
: {
|
|
699
1131
|
toolMessages: [],
|
|
700
1132
|
injected: [],
|
|
@@ -706,8 +1138,13 @@ class ToolNode extends run.RunnableCallable {
|
|
|
706
1138
|
];
|
|
707
1139
|
}
|
|
708
1140
|
else {
|
|
709
|
-
outputs = await Promise.all(filteredCalls.map((call) => this.runTool(call, config
|
|
710
|
-
|
|
1141
|
+
outputs = await Promise.all(filteredCalls.map((call, i) => this.runTool(call, config, {
|
|
1142
|
+
batchIndex: i,
|
|
1143
|
+
turn,
|
|
1144
|
+
batchScopeId,
|
|
1145
|
+
resolvedArgsByCallId,
|
|
1146
|
+
})));
|
|
1147
|
+
this.handleRunToolCompletions(filteredCalls, outputs, config, resolvedArgsByCallId);
|
|
711
1148
|
}
|
|
712
1149
|
}
|
|
713
1150
|
if (!outputs.some(langgraph.isCommand)) {
|