@librechat/agents 3.1.70 → 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/graphs/Graph.cjs +45 -0
- package/dist/cjs/graphs/Graph.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 +453 -45
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- 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/graphs/Graph.mjs +45 -0
- package/dist/esm/graphs/Graph.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 +453 -45
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- 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/graphs/Graph.d.ts +21 -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 +86 -3
- package/dist/types/tools/toolOutputReferences.d.ts +205 -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 +48 -0
- 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 +618 -55
- package/src/tools/__tests__/BashExecutor.test.ts +36 -0
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +1395 -0
- package/src/tools/__tests__/toolOutputReferences.test.ts +415 -0
- package/src/tools/toolOutputReferences.ts +590 -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
|
@@ -10,6 +10,7 @@ import { RunnableCallable } from '../utils/run.mjs';
|
|
|
10
10
|
import 'ai-tokenizer';
|
|
11
11
|
import 'zod-to-json-schema';
|
|
12
12
|
import { executeHooks } from '../hooks/executeHooks.mjs';
|
|
13
|
+
import { ToolOutputReferenceRegistry, annotateToolOutputWithReference, buildReferenceKey } from './toolOutputReferences.mjs';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Helper to check if a value is a Send object
|
|
@@ -70,7 +71,27 @@ class ToolNode extends RunnableCallable {
|
|
|
70
71
|
maxToolResultChars;
|
|
71
72
|
/** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
|
|
72
73
|
hookRegistry;
|
|
73
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Registry of tool outputs keyed by `tool<idx>turn<turn>`.
|
|
76
|
+
*
|
|
77
|
+
* Populated only when `toolOutputReferences.enabled` is true. The
|
|
78
|
+
* registry owns the run-scoped state (turn counter, last-seen runId,
|
|
79
|
+
* warn-once memo, stored outputs), so sharing a single instance
|
|
80
|
+
* across multiple ToolNodes in a run lets cross-agent `{{…}}`
|
|
81
|
+
* references resolve — which is why multi-agent graphs pass the
|
|
82
|
+
* *same* instance to every ToolNode they compile rather than each
|
|
83
|
+
* ToolNode building its own.
|
|
84
|
+
*/
|
|
85
|
+
toolOutputRegistry;
|
|
86
|
+
/**
|
|
87
|
+
* Monotonic counter used to mint a unique scope id for anonymous
|
|
88
|
+
* batches (ones invoked without a `run_id` in
|
|
89
|
+
* `config.configurable`). Each such batch gets its own registry
|
|
90
|
+
* partition so concurrent anonymous invocations can't delete each
|
|
91
|
+
* other's in-flight state.
|
|
92
|
+
*/
|
|
93
|
+
anonBatchCounter = 0;
|
|
94
|
+
constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, toolOutputReferences, toolOutputRegistry, }) {
|
|
74
95
|
super({ name, tags, func: (input, config) => this.run(input, config) });
|
|
75
96
|
this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
|
|
76
97
|
this.toolCallStepIds = toolCallStepIds;
|
|
@@ -86,6 +107,37 @@ class ToolNode extends RunnableCallable {
|
|
|
86
107
|
this.maxToolResultChars =
|
|
87
108
|
maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
|
|
88
109
|
this.hookRegistry = hookRegistry;
|
|
110
|
+
/**
|
|
111
|
+
* Precedence: an explicitly passed `toolOutputRegistry` instance
|
|
112
|
+
* wins over a config object so a host (`Graph`) can share one
|
|
113
|
+
* registry across many ToolNodes. When only the config is
|
|
114
|
+
* provided (direct ToolNode usage), build a local registry so
|
|
115
|
+
* the feature still works without graph-level plumbing. Registry
|
|
116
|
+
* caps are intentionally decoupled from `maxToolResultChars`:
|
|
117
|
+
* the registry stores the raw untruncated output so a later
|
|
118
|
+
* `{{…}}` substitution pipes the full payload into the next
|
|
119
|
+
* tool, even when the LLM saw a truncated preview.
|
|
120
|
+
*/
|
|
121
|
+
if (toolOutputRegistry != null) {
|
|
122
|
+
this.toolOutputRegistry = toolOutputRegistry;
|
|
123
|
+
}
|
|
124
|
+
else if (toolOutputReferences?.enabled === true) {
|
|
125
|
+
this.toolOutputRegistry = new ToolOutputReferenceRegistry({
|
|
126
|
+
maxOutputSize: toolOutputReferences.maxOutputSize,
|
|
127
|
+
maxTotalSize: toolOutputReferences.maxTotalSize,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Returns the run-scoped tool output registry, or `undefined` when
|
|
133
|
+
* the feature is disabled.
|
|
134
|
+
*
|
|
135
|
+
* @internal Exposed for test observation only. Host code should rely
|
|
136
|
+
* on `{{tool<i>turn<n>}}` substitution at tool-invocation time and
|
|
137
|
+
* not mutate the registry directly.
|
|
138
|
+
*/
|
|
139
|
+
_unsafeGetToolOutputRegistry() {
|
|
140
|
+
return this.toolOutputRegistry;
|
|
89
141
|
}
|
|
90
142
|
/**
|
|
91
143
|
* Returns cached programmatic tools, computing once on first access.
|
|
@@ -117,28 +169,85 @@ class ToolNode extends RunnableCallable {
|
|
|
117
169
|
return new Map(this.toolUsageCount); // Return a copy
|
|
118
170
|
}
|
|
119
171
|
/**
|
|
120
|
-
* Runs a single tool call with error handling
|
|
172
|
+
* Runs a single tool call with error handling.
|
|
173
|
+
*
|
|
174
|
+
* `batchIndex` is the tool's position within the current ToolNode
|
|
175
|
+
* batch and, together with `this.currentTurn`, forms the key used to
|
|
176
|
+
* register the output for future `{{tool<idx>turn<turn>}}`
|
|
177
|
+
* substitutions. Omit when no registration should occur.
|
|
121
178
|
*/
|
|
122
|
-
async runTool(call, config) {
|
|
179
|
+
async runTool(call, config, batchContext = {}) {
|
|
180
|
+
const { batchIndex, turn, batchScopeId, resolvedArgsByCallId } = batchContext;
|
|
123
181
|
const tool = this.toolMap.get(call.name);
|
|
182
|
+
const registry = this.toolOutputRegistry;
|
|
183
|
+
/**
|
|
184
|
+
* Precompute the reference key once per call — captured locally
|
|
185
|
+
* so concurrent `invoke()` calls on the same ToolNode cannot race
|
|
186
|
+
* on a shared turn field.
|
|
187
|
+
*/
|
|
188
|
+
const refKey = registry != null && batchIndex != null && turn != null
|
|
189
|
+
? buildReferenceKey(batchIndex, turn)
|
|
190
|
+
: undefined;
|
|
191
|
+
/**
|
|
192
|
+
* Hoisted outside the try so the catch branch can append
|
|
193
|
+
* `[unresolved refs: …]` to error messages — otherwise the LLM
|
|
194
|
+
* only sees a generic error when it references a bad key, losing
|
|
195
|
+
* the self-correction signal this feature is meant to provide.
|
|
196
|
+
*/
|
|
197
|
+
let unresolvedRefs = [];
|
|
198
|
+
/**
|
|
199
|
+
* Use the caller-provided `batchScopeId` when threaded from
|
|
200
|
+
* `run()` (so anonymous batches get their own unique scope).
|
|
201
|
+
* Fall back to the config's `run_id` when runTool is invoked
|
|
202
|
+
* from a context that doesn't thread it — that still preserves
|
|
203
|
+
* the runId-based partitioning for named runs.
|
|
204
|
+
*/
|
|
205
|
+
const runId = batchScopeId ?? config.configurable?.run_id;
|
|
124
206
|
try {
|
|
125
207
|
if (tool === undefined) {
|
|
126
208
|
throw new Error(`Tool "${call.name}" not found.`);
|
|
127
209
|
}
|
|
128
|
-
|
|
129
|
-
|
|
210
|
+
/**
|
|
211
|
+
* `usageCount` is the per-tool-name invocation index that
|
|
212
|
+
* web-search and other tools observe via `invokeParams.turn`.
|
|
213
|
+
* It is intentionally distinct from the outer `turn` parameter
|
|
214
|
+
* (the batch turn used for ref keys); the latter is captured
|
|
215
|
+
* before the try block when constructing `refKey`.
|
|
216
|
+
*/
|
|
217
|
+
const usageCount = this.toolUsageCount.get(call.name) ?? 0;
|
|
218
|
+
this.toolUsageCount.set(call.name, usageCount + 1);
|
|
130
219
|
if (call.id != null && call.id !== '') {
|
|
131
|
-
this.toolCallTurns.set(call.id,
|
|
220
|
+
this.toolCallTurns.set(call.id, usageCount);
|
|
221
|
+
}
|
|
222
|
+
let args = call.args;
|
|
223
|
+
if (registry != null) {
|
|
224
|
+
const { resolved, unresolved } = registry.resolve(runId, args);
|
|
225
|
+
args = resolved;
|
|
226
|
+
unresolvedRefs = unresolved;
|
|
227
|
+
/**
|
|
228
|
+
* Expose the post-substitution args to downstream completion
|
|
229
|
+
* events so audit logs / host-side `ON_RUN_STEP_COMPLETED`
|
|
230
|
+
* handlers observe what actually ran, not the `{{…}}`
|
|
231
|
+
* template. Only string/object args are worth recording.
|
|
232
|
+
*/
|
|
233
|
+
if (resolvedArgsByCallId != null &&
|
|
234
|
+
call.id != null &&
|
|
235
|
+
call.id !== '' &&
|
|
236
|
+
resolved !== call.args &&
|
|
237
|
+
typeof resolved === 'object') {
|
|
238
|
+
resolvedArgsByCallId.set(call.id, resolved);
|
|
239
|
+
}
|
|
132
240
|
}
|
|
133
|
-
const args = call.args;
|
|
134
241
|
const stepId = this.toolCallStepIds?.get(call.id);
|
|
135
242
|
// Build invoke params - LangChain extracts non-schema fields to config.toolCall
|
|
243
|
+
// `turn` here is the per-tool usage count (matches what tools have
|
|
244
|
+
// observed historically via config.toolCall.turn — e.g. web search).
|
|
136
245
|
let invokeParams = {
|
|
137
246
|
...call,
|
|
138
247
|
args,
|
|
139
248
|
type: 'tool_call',
|
|
140
249
|
stepId,
|
|
141
|
-
turn,
|
|
250
|
+
turn: usageCount,
|
|
142
251
|
};
|
|
143
252
|
// Inject runtime data for special tools (becomes available at config.toolCall)
|
|
144
253
|
if (call.name === Constants.PROGRAMMATIC_TOOL_CALLING ||
|
|
@@ -183,19 +292,72 @@ class ToolNode extends RunnableCallable {
|
|
|
183
292
|
}
|
|
184
293
|
}
|
|
185
294
|
const output = await tool.invoke(invokeParams, config);
|
|
186
|
-
if ((
|
|
187
|
-
isCommand(output)) {
|
|
295
|
+
if (isCommand(output)) {
|
|
188
296
|
return output;
|
|
189
297
|
}
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
298
|
+
if (isBaseMessage(output) && output._getType() === 'tool') {
|
|
299
|
+
const toolMsg = output;
|
|
300
|
+
const isError = toolMsg.status === 'error';
|
|
301
|
+
if (isError) {
|
|
302
|
+
/**
|
|
303
|
+
* Error ToolMessages bypass registration/annotation but must
|
|
304
|
+
* still carry the unresolved-refs hint so the LLM can
|
|
305
|
+
* self-correct when its reference key caused the failure.
|
|
306
|
+
*/
|
|
307
|
+
if (unresolvedRefs.length > 0 &&
|
|
308
|
+
typeof toolMsg.content === 'string') {
|
|
309
|
+
toolMsg.content = this.applyOutputReference(runId, toolMsg.content, toolMsg.content, undefined, unresolvedRefs);
|
|
310
|
+
}
|
|
311
|
+
return toolMsg;
|
|
312
|
+
}
|
|
313
|
+
if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
|
|
314
|
+
if (typeof toolMsg.content === 'string') {
|
|
315
|
+
const rawContent = toolMsg.content;
|
|
316
|
+
const llmContent = truncateToolResultContent(rawContent, this.maxToolResultChars);
|
|
317
|
+
toolMsg.content = this.applyOutputReference(runId, llmContent, rawContent, refKey, unresolvedRefs);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
/**
|
|
321
|
+
* Non-string content (multi-part content blocks — text +
|
|
322
|
+
* image). Known limitation: we cannot register under a
|
|
323
|
+
* reference key because there's no canonical serialized
|
|
324
|
+
* form. Warn once per tool per run when the caller
|
|
325
|
+
* intended to register.
|
|
326
|
+
*
|
|
327
|
+
* Still surface unresolved-ref warnings so the LLM gets
|
|
328
|
+
* the self-correction signal that the string and error
|
|
329
|
+
* paths already emit. Prepended as a leading text block
|
|
330
|
+
* to keep the original content ordering intact.
|
|
331
|
+
*/
|
|
332
|
+
if (unresolvedRefs.length > 0 && Array.isArray(toolMsg.content)) {
|
|
333
|
+
const warningBlock = {
|
|
334
|
+
type: 'text',
|
|
335
|
+
text: `[unresolved refs: ${unresolvedRefs.join(', ')}]`,
|
|
336
|
+
};
|
|
337
|
+
toolMsg.content = [
|
|
338
|
+
warningBlock,
|
|
339
|
+
...toolMsg.content,
|
|
340
|
+
];
|
|
341
|
+
}
|
|
342
|
+
if (refKey != null &&
|
|
343
|
+
this.toolOutputRegistry.claimWarnOnce(runId, call.name)) {
|
|
344
|
+
// eslint-disable-next-line no-console
|
|
345
|
+
console.warn(`[ToolNode] Skipping tool output reference for "${call.name}": ` +
|
|
346
|
+
'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return toolMsg;
|
|
198
351
|
}
|
|
352
|
+
const rawContent = typeof output === 'string' ? output : JSON.stringify(output);
|
|
353
|
+
const truncated = truncateToolResultContent(rawContent, this.maxToolResultChars);
|
|
354
|
+
const content = this.applyOutputReference(runId, truncated, rawContent, refKey, unresolvedRefs);
|
|
355
|
+
return new ToolMessage({
|
|
356
|
+
status: 'success',
|
|
357
|
+
name: tool.name,
|
|
358
|
+
content,
|
|
359
|
+
tool_call_id: call.id,
|
|
360
|
+
});
|
|
199
361
|
}
|
|
200
362
|
catch (_e) {
|
|
201
363
|
const e = _e;
|
|
@@ -238,14 +400,52 @@ class ToolNode extends RunnableCallable {
|
|
|
238
400
|
});
|
|
239
401
|
}
|
|
240
402
|
}
|
|
403
|
+
let errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
|
|
404
|
+
if (unresolvedRefs.length > 0) {
|
|
405
|
+
errorContent = this.applyOutputReference(runId, errorContent, errorContent, undefined, unresolvedRefs);
|
|
406
|
+
}
|
|
241
407
|
return new ToolMessage({
|
|
242
408
|
status: 'error',
|
|
243
|
-
content:
|
|
409
|
+
content: errorContent,
|
|
244
410
|
name: call.name,
|
|
245
411
|
tool_call_id: call.id ?? '',
|
|
246
412
|
});
|
|
247
413
|
}
|
|
248
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* Finalizes the LLM-visible content for a tool call and (when a
|
|
417
|
+
* `refKey` is provided) registers the full, raw output under that
|
|
418
|
+
* key.
|
|
419
|
+
*
|
|
420
|
+
* @param llmContent The content string the LLM will see. This is
|
|
421
|
+
* the already-truncated, post-hook view; the annotation is
|
|
422
|
+
* applied on top of it.
|
|
423
|
+
* @param registryContent The full, untruncated output to store in
|
|
424
|
+
* the registry so `{{tool<i>turn<n>}}` substitutions deliver the
|
|
425
|
+
* complete payload. Ignored when `refKey` is undefined.
|
|
426
|
+
* @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
|
|
427
|
+
* the output is not to be registered (errors, disabled feature,
|
|
428
|
+
* unavailable batch/turn).
|
|
429
|
+
* @param unresolved Placeholder keys that did not resolve; appended
|
|
430
|
+
* as `[unresolved refs: …]` so the LLM can self-correct.
|
|
431
|
+
*
|
|
432
|
+
* `refKey` is passed in (rather than built from `this.currentTurn`)
|
|
433
|
+
* so parallel `invoke()` calls on the same ToolNode cannot race on
|
|
434
|
+
* the shared turn field.
|
|
435
|
+
*/
|
|
436
|
+
applyOutputReference(runId, llmContent, registryContent, refKey, unresolved) {
|
|
437
|
+
if (this.toolOutputRegistry != null && refKey != null) {
|
|
438
|
+
this.toolOutputRegistry.set(runId, refKey, registryContent);
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* `annotateToolOutputWithReference` handles both the ref-key and
|
|
442
|
+
* unresolved-refs cases together so JSON-object outputs stay
|
|
443
|
+
* parseable: unresolved refs land in an `_unresolved_refs` field
|
|
444
|
+
* instead of as a trailing text line that would break
|
|
445
|
+
* `JSON.parse` for downstream consumers.
|
|
446
|
+
*/
|
|
447
|
+
return annotateToolOutputWithReference(llmContent, refKey, unresolved);
|
|
448
|
+
}
|
|
249
449
|
/**
|
|
250
450
|
* Builds code session context for injection into event-driven tool calls.
|
|
251
451
|
* Mirrors the session injection logic in runTool() for direct execution.
|
|
@@ -304,8 +504,12 @@ class ToolNode extends RunnableCallable {
|
|
|
304
504
|
* By handling completions here in graph context (rather than in the
|
|
305
505
|
* stream consumer via ToolEndHandler), the race between the stream
|
|
306
506
|
* consumer and graph execution is eliminated.
|
|
507
|
+
*
|
|
508
|
+
* @param resolvedArgsByCallId Per-batch resolved-args sink populated
|
|
509
|
+
* by `runTool`. Threaded as a local map (instead of instance state)
|
|
510
|
+
* so concurrent batches cannot read each other's entries.
|
|
307
511
|
*/
|
|
308
|
-
handleRunToolCompletions(calls, outputs, config) {
|
|
512
|
+
handleRunToolCompletions(calls, outputs, config, resolvedArgsByCallId) {
|
|
309
513
|
for (let i = 0; i < calls.length; i++) {
|
|
310
514
|
const call = calls[i];
|
|
311
515
|
const output = outputs[i];
|
|
@@ -334,10 +538,17 @@ class ToolNode extends RunnableCallable {
|
|
|
334
538
|
const contentString = typeof toolMessage.content === 'string'
|
|
335
539
|
? toolMessage.content
|
|
336
540
|
: JSON.stringify(toolMessage.content);
|
|
541
|
+
/**
|
|
542
|
+
* Prefer the post-substitution args when a `{{…}}` placeholder
|
|
543
|
+
* was resolved in `runTool`. This keeps
|
|
544
|
+
* `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
|
|
545
|
+
* the tool actually received rather than leaking the template.
|
|
546
|
+
*/
|
|
547
|
+
const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
|
|
337
548
|
const tool_call = {
|
|
338
|
-
args: typeof
|
|
339
|
-
?
|
|
340
|
-
: JSON.stringify(
|
|
549
|
+
args: typeof effectiveArgs === 'string'
|
|
550
|
+
? effectiveArgs
|
|
551
|
+
: JSON.stringify(effectiveArgs ?? {}),
|
|
341
552
|
name: call.name,
|
|
342
553
|
id: toolCallId,
|
|
343
554
|
output: contentString,
|
|
@@ -367,14 +578,52 @@ class ToolNode extends RunnableCallable {
|
|
|
367
578
|
* 4. Injected messages from results are collected and returned alongside
|
|
368
579
|
* ToolMessages (appended AFTER to respect provider ordering).
|
|
369
580
|
*/
|
|
370
|
-
async dispatchToolEvents(toolCalls, config) {
|
|
581
|
+
async dispatchToolEvents(toolCalls, config, batchContext = {}) {
|
|
582
|
+
const { batchIndices, turn, batchScopeId, preResolvedArgs, preBatchSnapshot, } = batchContext;
|
|
371
583
|
const runId = config.configurable?.run_id ?? '';
|
|
584
|
+
/**
|
|
585
|
+
* Registry-facing scope id — prefers the caller-threaded
|
|
586
|
+
* `batchScopeId` so anonymous batches target their own unique
|
|
587
|
+
* bucket and don't step on concurrent anonymous invocations.
|
|
588
|
+
* Hooks and event payloads keep using the empty-string coerced
|
|
589
|
+
* `runId` for backward compat.
|
|
590
|
+
*/
|
|
591
|
+
const registryRunId = batchScopeId ?? config.configurable?.run_id;
|
|
372
592
|
const threadId = config.configurable?.thread_id;
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
593
|
+
const registry = this.toolOutputRegistry;
|
|
594
|
+
const unresolvedByCallId = new Map();
|
|
595
|
+
const preToolCalls = toolCalls.map((call, i) => {
|
|
596
|
+
const originalArgs = call.args;
|
|
597
|
+
let resolvedArgs = originalArgs;
|
|
598
|
+
/**
|
|
599
|
+
* When the caller provided a pre-resolved map (the mixed
|
|
600
|
+
* direct+event path snapshots event args synchronously before
|
|
601
|
+
* awaiting directs so they can't accidentally resolve
|
|
602
|
+
* same-turn direct outputs), use those entries verbatim instead
|
|
603
|
+
* of re-resolving against a registry that may have changed
|
|
604
|
+
* since the batch started.
|
|
605
|
+
*/
|
|
606
|
+
const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
|
|
607
|
+
if (pre != null) {
|
|
608
|
+
resolvedArgs = pre.resolved;
|
|
609
|
+
if (pre.unresolved.length > 0 && call.id != null) {
|
|
610
|
+
unresolvedByCallId.set(call.id, pre.unresolved);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
else if (registry != null) {
|
|
614
|
+
const { resolved, unresolved } = registry.resolve(registryRunId, originalArgs);
|
|
615
|
+
resolvedArgs = resolved;
|
|
616
|
+
if (unresolved.length > 0 && call.id != null) {
|
|
617
|
+
unresolvedByCallId.set(call.id, unresolved);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
call,
|
|
622
|
+
stepId: this.toolCallStepIds?.get(call.id) ?? '',
|
|
623
|
+
args: resolvedArgs,
|
|
624
|
+
batchIndex: batchIndices?.[i],
|
|
625
|
+
};
|
|
626
|
+
});
|
|
378
627
|
const messageByCallId = new Map();
|
|
379
628
|
const approvedEntries = [];
|
|
380
629
|
const HOOK_FALLBACK = Object.freeze({
|
|
@@ -434,7 +683,40 @@ class ToolNode extends RunnableCallable {
|
|
|
434
683
|
continue;
|
|
435
684
|
}
|
|
436
685
|
if (hookResult.updatedInput != null) {
|
|
437
|
-
|
|
686
|
+
/**
|
|
687
|
+
* Re-resolve after PreToolUse replaces the input: a hook may
|
|
688
|
+
* introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
|
|
689
|
+
* copying user-supplied text) that the pre-hook pass never
|
|
690
|
+
* saw. Re-running the resolver on the hook-rewritten args
|
|
691
|
+
* keeps substitution and the unresolved-refs record in sync
|
|
692
|
+
* with what the tool will actually receive.
|
|
693
|
+
*/
|
|
694
|
+
if (registry != null) {
|
|
695
|
+
/**
|
|
696
|
+
* Mixed direct+event batches must use the pre-batch
|
|
697
|
+
* snapshot so a hook-introduced placeholder cannot
|
|
698
|
+
* accidentally resolve to a same-turn direct output that
|
|
699
|
+
* has just registered. Pure event batches don't have a
|
|
700
|
+
* snapshot and resolve against the live registry — safe
|
|
701
|
+
* because no event-side registrations have happened yet.
|
|
702
|
+
*/
|
|
703
|
+
const view = preBatchSnapshot ?? {
|
|
704
|
+
resolve: (args) => registry.resolve(registryRunId, args),
|
|
705
|
+
};
|
|
706
|
+
const { resolved, unresolved } = view.resolve(hookResult.updatedInput);
|
|
707
|
+
entry.args = resolved;
|
|
708
|
+
if (entry.call.id != null) {
|
|
709
|
+
if (unresolved.length > 0) {
|
|
710
|
+
unresolvedByCallId.set(entry.call.id, unresolved);
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
unresolvedByCallId.delete(entry.call.id);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
entry.args = hookResult.updatedInput;
|
|
719
|
+
}
|
|
438
720
|
}
|
|
439
721
|
approvedEntries.push(entry);
|
|
440
722
|
}
|
|
@@ -443,10 +725,14 @@ class ToolNode extends RunnableCallable {
|
|
|
443
725
|
approvedEntries.push(...preToolCalls);
|
|
444
726
|
}
|
|
445
727
|
const injected = [];
|
|
728
|
+
const batchIndexByCallId = new Map();
|
|
446
729
|
if (approvedEntries.length > 0) {
|
|
447
730
|
const requests = approvedEntries.map((entry) => {
|
|
448
731
|
const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
|
|
449
732
|
this.toolUsageCount.set(entry.call.name, turn + 1);
|
|
733
|
+
if (entry.batchIndex != null && entry.call.id != null) {
|
|
734
|
+
batchIndexByCallId.set(entry.call.id, entry.batchIndex);
|
|
735
|
+
}
|
|
450
736
|
const request = {
|
|
451
737
|
id: entry.call.id,
|
|
452
738
|
name: entry.call.name,
|
|
@@ -492,6 +778,15 @@ class ToolNode extends RunnableCallable {
|
|
|
492
778
|
let toolMessage;
|
|
493
779
|
if (result.status === 'error') {
|
|
494
780
|
contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
|
|
781
|
+
/**
|
|
782
|
+
* Error results bypass registration/annotation but must still
|
|
783
|
+
* carry the unresolved-refs hint so the LLM can self-correct
|
|
784
|
+
* when its reference key caused the failure.
|
|
785
|
+
*/
|
|
786
|
+
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
787
|
+
if (unresolved.length > 0) {
|
|
788
|
+
contentString = this.applyOutputReference(registryRunId, contentString, contentString, undefined, unresolved);
|
|
789
|
+
}
|
|
495
790
|
toolMessage = new ToolMessage({
|
|
496
791
|
status: 'error',
|
|
497
792
|
content: contentString,
|
|
@@ -521,10 +816,10 @@ class ToolNode extends RunnableCallable {
|
|
|
521
816
|
}
|
|
522
817
|
}
|
|
523
818
|
else {
|
|
524
|
-
|
|
819
|
+
let registryRaw = typeof result.content === 'string'
|
|
525
820
|
? result.content
|
|
526
821
|
: JSON.stringify(result.content);
|
|
527
|
-
contentString = truncateToolResultContent(
|
|
822
|
+
contentString = truncateToolResultContent(registryRaw, this.maxToolResultChars);
|
|
528
823
|
if (hasPostHook) {
|
|
529
824
|
const hookResult = await executeHooks({
|
|
530
825
|
registry: this.hookRegistry,
|
|
@@ -547,9 +842,18 @@ class ToolNode extends RunnableCallable {
|
|
|
547
842
|
const replaced = typeof hookResult.updatedOutput === 'string'
|
|
548
843
|
? hookResult.updatedOutput
|
|
549
844
|
: JSON.stringify(hookResult.updatedOutput);
|
|
845
|
+
registryRaw = replaced;
|
|
550
846
|
contentString = truncateToolResultContent(replaced, this.maxToolResultChars);
|
|
551
847
|
}
|
|
552
848
|
}
|
|
849
|
+
const batchIndex = batchIndexByCallId.get(result.toolCallId);
|
|
850
|
+
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
851
|
+
const refKey = this.toolOutputRegistry != null &&
|
|
852
|
+
batchIndex != null &&
|
|
853
|
+
turn != null
|
|
854
|
+
? buildReferenceKey(batchIndex, turn)
|
|
855
|
+
: undefined;
|
|
856
|
+
contentString = this.applyOutputReference(registryRunId, contentString, registryRaw, refKey, unresolved);
|
|
553
857
|
toolMessage = new ToolMessage({
|
|
554
858
|
status: 'success',
|
|
555
859
|
name: toolName,
|
|
@@ -617,25 +921,61 @@ class ToolNode extends RunnableCallable {
|
|
|
617
921
|
* Injected messages are placed AFTER ToolMessages to respect provider
|
|
618
922
|
* message ordering (AIMessage tool_calls must be immediately followed
|
|
619
923
|
* by their ToolMessage results).
|
|
924
|
+
*
|
|
925
|
+
* `batchIndices` mirrors `toolCalls` and carries each call's position
|
|
926
|
+
* within the parent batch. `turn` is the per-`run()` batch index
|
|
927
|
+
* captured locally by the caller. Both are threaded so concurrent
|
|
928
|
+
* invocations cannot race on shared mutable state.
|
|
620
929
|
*/
|
|
621
930
|
async executeViaEvent(toolCalls, config,
|
|
622
931
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
623
|
-
input) {
|
|
624
|
-
const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config);
|
|
932
|
+
input, batchContext = {}) {
|
|
933
|
+
const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config, batchContext);
|
|
625
934
|
const outputs = [...toolMessages, ...injected];
|
|
626
935
|
return (Array.isArray(input) ? outputs : { messages: outputs });
|
|
627
936
|
}
|
|
628
937
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
629
938
|
async run(input, config) {
|
|
630
939
|
this.toolCallTurns.clear();
|
|
940
|
+
/**
|
|
941
|
+
* Per-batch local map for resolved (post-substitution) args.
|
|
942
|
+
* Lives on the stack so concurrent `run()` calls on the same
|
|
943
|
+
* ToolNode cannot read or wipe each other's entries.
|
|
944
|
+
*/
|
|
945
|
+
const resolvedArgsByCallId = new Map();
|
|
946
|
+
/**
|
|
947
|
+
* Claim this batch's turn synchronously from the registry (or
|
|
948
|
+
* fall back to 0 when the feature is disabled). The registry is
|
|
949
|
+
* partitioned by scope id so overlapping batches cannot
|
|
950
|
+
* overwrite each other's state even under a shared registry.
|
|
951
|
+
*
|
|
952
|
+
* For anonymous callers (no `run_id` in config), mint a unique
|
|
953
|
+
* per-batch scope id so two concurrent anonymous invocations
|
|
954
|
+
* don't target the same bucket. The scope is threaded down to
|
|
955
|
+
* every subsequent registry call on this batch.
|
|
956
|
+
*/
|
|
957
|
+
const incomingRunId = config.configurable?.run_id;
|
|
958
|
+
const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
|
|
959
|
+
const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
|
|
631
960
|
let outputs;
|
|
632
961
|
if (this.isSendInput(input)) {
|
|
633
962
|
const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
|
|
634
963
|
if (this.eventDrivenMode && isDirectTool !== true) {
|
|
635
|
-
return this.executeViaEvent([input.lg_tool_call], config, input
|
|
964
|
+
return this.executeViaEvent([input.lg_tool_call], config, input, {
|
|
965
|
+
batchIndices: [0],
|
|
966
|
+
turn,
|
|
967
|
+
batchScopeId,
|
|
968
|
+
});
|
|
636
969
|
}
|
|
637
|
-
outputs = [
|
|
638
|
-
|
|
970
|
+
outputs = [
|
|
971
|
+
await this.runTool(input.lg_tool_call, config, {
|
|
972
|
+
batchIndex: 0,
|
|
973
|
+
turn,
|
|
974
|
+
batchScopeId,
|
|
975
|
+
resolvedArgsByCallId,
|
|
976
|
+
}),
|
|
977
|
+
];
|
|
978
|
+
this.handleRunToolCompletions([input.lg_tool_call], outputs, config, resolvedArgsByCallId);
|
|
639
979
|
}
|
|
640
980
|
else {
|
|
641
981
|
let messages;
|
|
@@ -680,19 +1020,82 @@ class ToolNode extends RunnableCallable {
|
|
|
680
1020
|
false));
|
|
681
1021
|
}) ?? [];
|
|
682
1022
|
if (this.eventDrivenMode && filteredCalls.length > 0) {
|
|
1023
|
+
const filteredIndices = filteredCalls.map((_, idx) => idx);
|
|
683
1024
|
if (!this.directToolNames || this.directToolNames.size === 0) {
|
|
684
|
-
return this.executeViaEvent(filteredCalls, config, input
|
|
1025
|
+
return this.executeViaEvent(filteredCalls, config, input, {
|
|
1026
|
+
batchIndices: filteredIndices,
|
|
1027
|
+
turn,
|
|
1028
|
+
batchScopeId,
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
const directEntries = [];
|
|
1032
|
+
const eventEntries = [];
|
|
1033
|
+
for (let i = 0; i < filteredCalls.length; i++) {
|
|
1034
|
+
const call = filteredCalls[i];
|
|
1035
|
+
const entry = { call, batchIndex: i };
|
|
1036
|
+
if (this.directToolNames.has(call.name)) {
|
|
1037
|
+
directEntries.push(entry);
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
eventEntries.push(entry);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const directCalls = directEntries.map((e) => e.call);
|
|
1044
|
+
const directIndices = directEntries.map((e) => e.batchIndex);
|
|
1045
|
+
const eventCalls = eventEntries.map((e) => e.call);
|
|
1046
|
+
const eventIndices = eventEntries.map((e) => e.batchIndex);
|
|
1047
|
+
/**
|
|
1048
|
+
* Snapshot the event calls' args against the *pre-batch*
|
|
1049
|
+
* registry state synchronously, before any await runs. The
|
|
1050
|
+
* directs are then awaited first (preserving fail-fast
|
|
1051
|
+
* semantics — a thrown error in a direct tool, e.g. with
|
|
1052
|
+
* `handleToolErrors=false` or a `GraphInterrupt`, aborts
|
|
1053
|
+
* before we dispatch any event-driven tools to the host).
|
|
1054
|
+
* Because the event args were captured pre-await, they stay
|
|
1055
|
+
* isolated from same-turn direct outputs that register
|
|
1056
|
+
* during the await.
|
|
1057
|
+
*/
|
|
1058
|
+
const preResolvedEventArgs = new Map();
|
|
1059
|
+
/**
|
|
1060
|
+
* Take a frozen snapshot of the registry state before any
|
|
1061
|
+
* direct registrations land. The snapshot resolves
|
|
1062
|
+
* placeholders against this point-in-time view, so a
|
|
1063
|
+
* `PreToolUse` hook later rewriting event args via
|
|
1064
|
+
* `updatedInput` can introduce placeholders that resolve
|
|
1065
|
+
* cross-batch (against prior runs) without ever picking up
|
|
1066
|
+
* same-turn direct outputs.
|
|
1067
|
+
*/
|
|
1068
|
+
const preBatchSnapshot = this.toolOutputRegistry?.snapshot(batchScopeId);
|
|
1069
|
+
if (preBatchSnapshot != null) {
|
|
1070
|
+
for (const entry of eventEntries) {
|
|
1071
|
+
if (entry.call.id != null) {
|
|
1072
|
+
const { resolved, unresolved } = preBatchSnapshot.resolve(entry.call.args);
|
|
1073
|
+
preResolvedEventArgs.set(entry.call.id, {
|
|
1074
|
+
resolved: resolved,
|
|
1075
|
+
unresolved,
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
685
1079
|
}
|
|
686
|
-
const directCalls = filteredCalls.filter((c) => this.directToolNames.has(c.name));
|
|
687
|
-
const eventCalls = filteredCalls.filter((c) => !this.directToolNames.has(c.name));
|
|
688
1080
|
const directOutputs = directCalls.length > 0
|
|
689
|
-
? await Promise.all(directCalls.map((call) => this.runTool(call, config
|
|
1081
|
+
? await Promise.all(directCalls.map((call, i) => this.runTool(call, config, {
|
|
1082
|
+
batchIndex: directIndices[i],
|
|
1083
|
+
turn,
|
|
1084
|
+
batchScopeId,
|
|
1085
|
+
resolvedArgsByCallId,
|
|
1086
|
+
})))
|
|
690
1087
|
: [];
|
|
691
1088
|
if (directCalls.length > 0 && directOutputs.length > 0) {
|
|
692
|
-
this.handleRunToolCompletions(directCalls, directOutputs, config);
|
|
1089
|
+
this.handleRunToolCompletions(directCalls, directOutputs, config, resolvedArgsByCallId);
|
|
693
1090
|
}
|
|
694
1091
|
const eventResult = eventCalls.length > 0
|
|
695
|
-
? await this.dispatchToolEvents(eventCalls, config
|
|
1092
|
+
? await this.dispatchToolEvents(eventCalls, config, {
|
|
1093
|
+
batchIndices: eventIndices,
|
|
1094
|
+
turn,
|
|
1095
|
+
batchScopeId,
|
|
1096
|
+
preResolvedArgs: preResolvedEventArgs,
|
|
1097
|
+
preBatchSnapshot,
|
|
1098
|
+
})
|
|
696
1099
|
: {
|
|
697
1100
|
toolMessages: [],
|
|
698
1101
|
injected: [],
|
|
@@ -704,8 +1107,13 @@ class ToolNode extends RunnableCallable {
|
|
|
704
1107
|
];
|
|
705
1108
|
}
|
|
706
1109
|
else {
|
|
707
|
-
outputs = await Promise.all(filteredCalls.map((call) => this.runTool(call, config
|
|
708
|
-
|
|
1110
|
+
outputs = await Promise.all(filteredCalls.map((call, i) => this.runTool(call, config, {
|
|
1111
|
+
batchIndex: i,
|
|
1112
|
+
turn,
|
|
1113
|
+
batchScopeId,
|
|
1114
|
+
resolvedArgsByCallId,
|
|
1115
|
+
})));
|
|
1116
|
+
this.handleRunToolCompletions(filteredCalls, outputs, config, resolvedArgsByCallId);
|
|
709
1117
|
}
|
|
710
1118
|
}
|
|
711
1119
|
if (!outputs.some(isCommand)) {
|