@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
|
@@ -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,72 @@ 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/annotation but must
|
|
306
|
+
* still carry the unresolved-refs hint so the LLM can
|
|
307
|
+
* self-correct when its reference key caused the failure.
|
|
308
|
+
*/
|
|
309
|
+
if (unresolvedRefs.length > 0 &&
|
|
310
|
+
typeof toolMsg.content === 'string') {
|
|
311
|
+
toolMsg.content = this.applyOutputReference(runId, toolMsg.content, toolMsg.content, undefined, unresolvedRefs);
|
|
312
|
+
}
|
|
313
|
+
return toolMsg;
|
|
314
|
+
}
|
|
315
|
+
if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
|
|
316
|
+
if (typeof toolMsg.content === 'string') {
|
|
317
|
+
const rawContent = toolMsg.content;
|
|
318
|
+
const llmContent = truncation.truncateToolResultContent(rawContent, this.maxToolResultChars);
|
|
319
|
+
toolMsg.content = this.applyOutputReference(runId, llmContent, rawContent, refKey, unresolvedRefs);
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
/**
|
|
323
|
+
* Non-string content (multi-part content blocks — text +
|
|
324
|
+
* image). Known limitation: we cannot register under a
|
|
325
|
+
* reference key because there's no canonical serialized
|
|
326
|
+
* form. Warn once per tool per run when the caller
|
|
327
|
+
* intended to register.
|
|
328
|
+
*
|
|
329
|
+
* Still surface unresolved-ref warnings so the LLM gets
|
|
330
|
+
* the self-correction signal that the string and error
|
|
331
|
+
* paths already emit. Prepended as a leading text block
|
|
332
|
+
* to keep the original content ordering intact.
|
|
333
|
+
*/
|
|
334
|
+
if (unresolvedRefs.length > 0 && Array.isArray(toolMsg.content)) {
|
|
335
|
+
const warningBlock = {
|
|
336
|
+
type: 'text',
|
|
337
|
+
text: `[unresolved refs: ${unresolvedRefs.join(', ')}]`,
|
|
338
|
+
};
|
|
339
|
+
toolMsg.content = [
|
|
340
|
+
warningBlock,
|
|
341
|
+
...toolMsg.content,
|
|
342
|
+
];
|
|
343
|
+
}
|
|
344
|
+
if (refKey != null &&
|
|
345
|
+
this.toolOutputRegistry.claimWarnOnce(runId, call.name)) {
|
|
346
|
+
// eslint-disable-next-line no-console
|
|
347
|
+
console.warn(`[ToolNode] Skipping tool output reference for "${call.name}": ` +
|
|
348
|
+
'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).');
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return toolMsg;
|
|
200
353
|
}
|
|
354
|
+
const rawContent = typeof output === 'string' ? output : JSON.stringify(output);
|
|
355
|
+
const truncated = truncation.truncateToolResultContent(rawContent, this.maxToolResultChars);
|
|
356
|
+
const content = this.applyOutputReference(runId, truncated, rawContent, refKey, unresolvedRefs);
|
|
357
|
+
return new messages.ToolMessage({
|
|
358
|
+
status: 'success',
|
|
359
|
+
name: tool.name,
|
|
360
|
+
content,
|
|
361
|
+
tool_call_id: call.id,
|
|
362
|
+
});
|
|
201
363
|
}
|
|
202
364
|
catch (_e) {
|
|
203
365
|
const e = _e;
|
|
@@ -240,14 +402,52 @@ class ToolNode extends run.RunnableCallable {
|
|
|
240
402
|
});
|
|
241
403
|
}
|
|
242
404
|
}
|
|
405
|
+
let errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
|
|
406
|
+
if (unresolvedRefs.length > 0) {
|
|
407
|
+
errorContent = this.applyOutputReference(runId, errorContent, errorContent, undefined, unresolvedRefs);
|
|
408
|
+
}
|
|
243
409
|
return new messages.ToolMessage({
|
|
244
410
|
status: 'error',
|
|
245
|
-
content:
|
|
411
|
+
content: errorContent,
|
|
246
412
|
name: call.name,
|
|
247
413
|
tool_call_id: call.id ?? '',
|
|
248
414
|
});
|
|
249
415
|
}
|
|
250
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* Finalizes the LLM-visible content for a tool call and (when a
|
|
419
|
+
* `refKey` is provided) registers the full, raw output under that
|
|
420
|
+
* key.
|
|
421
|
+
*
|
|
422
|
+
* @param llmContent The content string the LLM will see. This is
|
|
423
|
+
* the already-truncated, post-hook view; the annotation is
|
|
424
|
+
* applied on top of it.
|
|
425
|
+
* @param registryContent The full, untruncated output to store in
|
|
426
|
+
* the registry so `{{tool<i>turn<n>}}` substitutions deliver the
|
|
427
|
+
* complete payload. Ignored when `refKey` is undefined.
|
|
428
|
+
* @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
|
|
429
|
+
* the output is not to be registered (errors, disabled feature,
|
|
430
|
+
* unavailable batch/turn).
|
|
431
|
+
* @param unresolved Placeholder keys that did not resolve; appended
|
|
432
|
+
* as `[unresolved refs: …]` so the LLM can self-correct.
|
|
433
|
+
*
|
|
434
|
+
* `refKey` is passed in (rather than built from `this.currentTurn`)
|
|
435
|
+
* so parallel `invoke()` calls on the same ToolNode cannot race on
|
|
436
|
+
* the shared turn field.
|
|
437
|
+
*/
|
|
438
|
+
applyOutputReference(runId, llmContent, registryContent, refKey, unresolved) {
|
|
439
|
+
if (this.toolOutputRegistry != null && refKey != null) {
|
|
440
|
+
this.toolOutputRegistry.set(runId, refKey, registryContent);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* `annotateToolOutputWithReference` handles both the ref-key and
|
|
444
|
+
* unresolved-refs cases together so JSON-object outputs stay
|
|
445
|
+
* parseable: unresolved refs land in an `_unresolved_refs` field
|
|
446
|
+
* instead of as a trailing text line that would break
|
|
447
|
+
* `JSON.parse` for downstream consumers.
|
|
448
|
+
*/
|
|
449
|
+
return toolOutputReferences.annotateToolOutputWithReference(llmContent, refKey, unresolved);
|
|
450
|
+
}
|
|
251
451
|
/**
|
|
252
452
|
* Builds code session context for injection into event-driven tool calls.
|
|
253
453
|
* Mirrors the session injection logic in runTool() for direct execution.
|
|
@@ -306,8 +506,12 @@ class ToolNode extends run.RunnableCallable {
|
|
|
306
506
|
* By handling completions here in graph context (rather than in the
|
|
307
507
|
* stream consumer via ToolEndHandler), the race between the stream
|
|
308
508
|
* consumer and graph execution is eliminated.
|
|
509
|
+
*
|
|
510
|
+
* @param resolvedArgsByCallId Per-batch resolved-args sink populated
|
|
511
|
+
* by `runTool`. Threaded as a local map (instead of instance state)
|
|
512
|
+
* so concurrent batches cannot read each other's entries.
|
|
309
513
|
*/
|
|
310
|
-
handleRunToolCompletions(calls, outputs, config) {
|
|
514
|
+
handleRunToolCompletions(calls, outputs, config, resolvedArgsByCallId) {
|
|
311
515
|
for (let i = 0; i < calls.length; i++) {
|
|
312
516
|
const call = calls[i];
|
|
313
517
|
const output = outputs[i];
|
|
@@ -336,10 +540,17 @@ class ToolNode extends run.RunnableCallable {
|
|
|
336
540
|
const contentString = typeof toolMessage.content === 'string'
|
|
337
541
|
? toolMessage.content
|
|
338
542
|
: JSON.stringify(toolMessage.content);
|
|
543
|
+
/**
|
|
544
|
+
* Prefer the post-substitution args when a `{{…}}` placeholder
|
|
545
|
+
* was resolved in `runTool`. This keeps
|
|
546
|
+
* `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
|
|
547
|
+
* the tool actually received rather than leaking the template.
|
|
548
|
+
*/
|
|
549
|
+
const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
|
|
339
550
|
const tool_call = {
|
|
340
|
-
args: typeof
|
|
341
|
-
?
|
|
342
|
-
: JSON.stringify(
|
|
551
|
+
args: typeof effectiveArgs === 'string'
|
|
552
|
+
? effectiveArgs
|
|
553
|
+
: JSON.stringify(effectiveArgs ?? {}),
|
|
343
554
|
name: call.name,
|
|
344
555
|
id: toolCallId,
|
|
345
556
|
output: contentString,
|
|
@@ -369,14 +580,52 @@ class ToolNode extends run.RunnableCallable {
|
|
|
369
580
|
* 4. Injected messages from results are collected and returned alongside
|
|
370
581
|
* ToolMessages (appended AFTER to respect provider ordering).
|
|
371
582
|
*/
|
|
372
|
-
async dispatchToolEvents(toolCalls, config) {
|
|
583
|
+
async dispatchToolEvents(toolCalls, config, batchContext = {}) {
|
|
584
|
+
const { batchIndices, turn, batchScopeId, preResolvedArgs, preBatchSnapshot, } = batchContext;
|
|
373
585
|
const runId = config.configurable?.run_id ?? '';
|
|
586
|
+
/**
|
|
587
|
+
* Registry-facing scope id — prefers the caller-threaded
|
|
588
|
+
* `batchScopeId` so anonymous batches target their own unique
|
|
589
|
+
* bucket and don't step on concurrent anonymous invocations.
|
|
590
|
+
* Hooks and event payloads keep using the empty-string coerced
|
|
591
|
+
* `runId` for backward compat.
|
|
592
|
+
*/
|
|
593
|
+
const registryRunId = batchScopeId ?? config.configurable?.run_id;
|
|
374
594
|
const threadId = config.configurable?.thread_id;
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
595
|
+
const registry = this.toolOutputRegistry;
|
|
596
|
+
const unresolvedByCallId = new Map();
|
|
597
|
+
const preToolCalls = toolCalls.map((call, i) => {
|
|
598
|
+
const originalArgs = call.args;
|
|
599
|
+
let resolvedArgs = originalArgs;
|
|
600
|
+
/**
|
|
601
|
+
* When the caller provided a pre-resolved map (the mixed
|
|
602
|
+
* direct+event path snapshots event args synchronously before
|
|
603
|
+
* awaiting directs so they can't accidentally resolve
|
|
604
|
+
* same-turn direct outputs), use those entries verbatim instead
|
|
605
|
+
* of re-resolving against a registry that may have changed
|
|
606
|
+
* since the batch started.
|
|
607
|
+
*/
|
|
608
|
+
const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
|
|
609
|
+
if (pre != null) {
|
|
610
|
+
resolvedArgs = pre.resolved;
|
|
611
|
+
if (pre.unresolved.length > 0 && call.id != null) {
|
|
612
|
+
unresolvedByCallId.set(call.id, pre.unresolved);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else if (registry != null) {
|
|
616
|
+
const { resolved, unresolved } = registry.resolve(registryRunId, originalArgs);
|
|
617
|
+
resolvedArgs = resolved;
|
|
618
|
+
if (unresolved.length > 0 && call.id != null) {
|
|
619
|
+
unresolvedByCallId.set(call.id, unresolved);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
call,
|
|
624
|
+
stepId: this.toolCallStepIds?.get(call.id) ?? '',
|
|
625
|
+
args: resolvedArgs,
|
|
626
|
+
batchIndex: batchIndices?.[i],
|
|
627
|
+
};
|
|
628
|
+
});
|
|
380
629
|
const messageByCallId = new Map();
|
|
381
630
|
const approvedEntries = [];
|
|
382
631
|
const HOOK_FALLBACK = Object.freeze({
|
|
@@ -436,7 +685,40 @@ class ToolNode extends run.RunnableCallable {
|
|
|
436
685
|
continue;
|
|
437
686
|
}
|
|
438
687
|
if (hookResult.updatedInput != null) {
|
|
439
|
-
|
|
688
|
+
/**
|
|
689
|
+
* Re-resolve after PreToolUse replaces the input: a hook may
|
|
690
|
+
* introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
|
|
691
|
+
* copying user-supplied text) that the pre-hook pass never
|
|
692
|
+
* saw. Re-running the resolver on the hook-rewritten args
|
|
693
|
+
* keeps substitution and the unresolved-refs record in sync
|
|
694
|
+
* with what the tool will actually receive.
|
|
695
|
+
*/
|
|
696
|
+
if (registry != null) {
|
|
697
|
+
/**
|
|
698
|
+
* Mixed direct+event batches must use the pre-batch
|
|
699
|
+
* snapshot so a hook-introduced placeholder cannot
|
|
700
|
+
* accidentally resolve to a same-turn direct output that
|
|
701
|
+
* has just registered. Pure event batches don't have a
|
|
702
|
+
* snapshot and resolve against the live registry — safe
|
|
703
|
+
* because no event-side registrations have happened yet.
|
|
704
|
+
*/
|
|
705
|
+
const view = preBatchSnapshot ?? {
|
|
706
|
+
resolve: (args) => registry.resolve(registryRunId, args),
|
|
707
|
+
};
|
|
708
|
+
const { resolved, unresolved } = view.resolve(hookResult.updatedInput);
|
|
709
|
+
entry.args = resolved;
|
|
710
|
+
if (entry.call.id != null) {
|
|
711
|
+
if (unresolved.length > 0) {
|
|
712
|
+
unresolvedByCallId.set(entry.call.id, unresolved);
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
unresolvedByCallId.delete(entry.call.id);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
entry.args = hookResult.updatedInput;
|
|
721
|
+
}
|
|
440
722
|
}
|
|
441
723
|
approvedEntries.push(entry);
|
|
442
724
|
}
|
|
@@ -445,10 +727,14 @@ class ToolNode extends run.RunnableCallable {
|
|
|
445
727
|
approvedEntries.push(...preToolCalls);
|
|
446
728
|
}
|
|
447
729
|
const injected = [];
|
|
730
|
+
const batchIndexByCallId = new Map();
|
|
448
731
|
if (approvedEntries.length > 0) {
|
|
449
732
|
const requests = approvedEntries.map((entry) => {
|
|
450
733
|
const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
|
|
451
734
|
this.toolUsageCount.set(entry.call.name, turn + 1);
|
|
735
|
+
if (entry.batchIndex != null && entry.call.id != null) {
|
|
736
|
+
batchIndexByCallId.set(entry.call.id, entry.batchIndex);
|
|
737
|
+
}
|
|
452
738
|
const request = {
|
|
453
739
|
id: entry.call.id,
|
|
454
740
|
name: entry.call.name,
|
|
@@ -494,6 +780,15 @@ class ToolNode extends run.RunnableCallable {
|
|
|
494
780
|
let toolMessage;
|
|
495
781
|
if (result.status === 'error') {
|
|
496
782
|
contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
|
|
783
|
+
/**
|
|
784
|
+
* Error results bypass registration/annotation but must still
|
|
785
|
+
* carry the unresolved-refs hint so the LLM can self-correct
|
|
786
|
+
* when its reference key caused the failure.
|
|
787
|
+
*/
|
|
788
|
+
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
789
|
+
if (unresolved.length > 0) {
|
|
790
|
+
contentString = this.applyOutputReference(registryRunId, contentString, contentString, undefined, unresolved);
|
|
791
|
+
}
|
|
497
792
|
toolMessage = new messages.ToolMessage({
|
|
498
793
|
status: 'error',
|
|
499
794
|
content: contentString,
|
|
@@ -523,10 +818,10 @@ class ToolNode extends run.RunnableCallable {
|
|
|
523
818
|
}
|
|
524
819
|
}
|
|
525
820
|
else {
|
|
526
|
-
|
|
821
|
+
let registryRaw = typeof result.content === 'string'
|
|
527
822
|
? result.content
|
|
528
823
|
: JSON.stringify(result.content);
|
|
529
|
-
contentString = truncation.truncateToolResultContent(
|
|
824
|
+
contentString = truncation.truncateToolResultContent(registryRaw, this.maxToolResultChars);
|
|
530
825
|
if (hasPostHook) {
|
|
531
826
|
const hookResult = await executeHooks.executeHooks({
|
|
532
827
|
registry: this.hookRegistry,
|
|
@@ -549,9 +844,18 @@ class ToolNode extends run.RunnableCallable {
|
|
|
549
844
|
const replaced = typeof hookResult.updatedOutput === 'string'
|
|
550
845
|
? hookResult.updatedOutput
|
|
551
846
|
: JSON.stringify(hookResult.updatedOutput);
|
|
847
|
+
registryRaw = replaced;
|
|
552
848
|
contentString = truncation.truncateToolResultContent(replaced, this.maxToolResultChars);
|
|
553
849
|
}
|
|
554
850
|
}
|
|
851
|
+
const batchIndex = batchIndexByCallId.get(result.toolCallId);
|
|
852
|
+
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
853
|
+
const refKey = this.toolOutputRegistry != null &&
|
|
854
|
+
batchIndex != null &&
|
|
855
|
+
turn != null
|
|
856
|
+
? toolOutputReferences.buildReferenceKey(batchIndex, turn)
|
|
857
|
+
: undefined;
|
|
858
|
+
contentString = this.applyOutputReference(registryRunId, contentString, registryRaw, refKey, unresolved);
|
|
555
859
|
toolMessage = new messages.ToolMessage({
|
|
556
860
|
status: 'success',
|
|
557
861
|
name: toolName,
|
|
@@ -619,25 +923,61 @@ class ToolNode extends run.RunnableCallable {
|
|
|
619
923
|
* Injected messages are placed AFTER ToolMessages to respect provider
|
|
620
924
|
* message ordering (AIMessage tool_calls must be immediately followed
|
|
621
925
|
* by their ToolMessage results).
|
|
926
|
+
*
|
|
927
|
+
* `batchIndices` mirrors `toolCalls` and carries each call's position
|
|
928
|
+
* within the parent batch. `turn` is the per-`run()` batch index
|
|
929
|
+
* captured locally by the caller. Both are threaded so concurrent
|
|
930
|
+
* invocations cannot race on shared mutable state.
|
|
622
931
|
*/
|
|
623
932
|
async executeViaEvent(toolCalls, config,
|
|
624
933
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
625
|
-
input) {
|
|
626
|
-
const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config);
|
|
934
|
+
input, batchContext = {}) {
|
|
935
|
+
const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config, batchContext);
|
|
627
936
|
const outputs = [...toolMessages, ...injected];
|
|
628
937
|
return (Array.isArray(input) ? outputs : { messages: outputs });
|
|
629
938
|
}
|
|
630
939
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
631
940
|
async run(input, config) {
|
|
632
941
|
this.toolCallTurns.clear();
|
|
942
|
+
/**
|
|
943
|
+
* Per-batch local map for resolved (post-substitution) args.
|
|
944
|
+
* Lives on the stack so concurrent `run()` calls on the same
|
|
945
|
+
* ToolNode cannot read or wipe each other's entries.
|
|
946
|
+
*/
|
|
947
|
+
const resolvedArgsByCallId = new Map();
|
|
948
|
+
/**
|
|
949
|
+
* Claim this batch's turn synchronously from the registry (or
|
|
950
|
+
* fall back to 0 when the feature is disabled). The registry is
|
|
951
|
+
* partitioned by scope id so overlapping batches cannot
|
|
952
|
+
* overwrite each other's state even under a shared registry.
|
|
953
|
+
*
|
|
954
|
+
* For anonymous callers (no `run_id` in config), mint a unique
|
|
955
|
+
* per-batch scope id so two concurrent anonymous invocations
|
|
956
|
+
* don't target the same bucket. The scope is threaded down to
|
|
957
|
+
* every subsequent registry call on this batch.
|
|
958
|
+
*/
|
|
959
|
+
const incomingRunId = config.configurable?.run_id;
|
|
960
|
+
const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
|
|
961
|
+
const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
|
|
633
962
|
let outputs;
|
|
634
963
|
if (this.isSendInput(input)) {
|
|
635
964
|
const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
|
|
636
965
|
if (this.eventDrivenMode && isDirectTool !== true) {
|
|
637
|
-
return this.executeViaEvent([input.lg_tool_call], config, input
|
|
966
|
+
return this.executeViaEvent([input.lg_tool_call], config, input, {
|
|
967
|
+
batchIndices: [0],
|
|
968
|
+
turn,
|
|
969
|
+
batchScopeId,
|
|
970
|
+
});
|
|
638
971
|
}
|
|
639
|
-
outputs = [
|
|
640
|
-
|
|
972
|
+
outputs = [
|
|
973
|
+
await this.runTool(input.lg_tool_call, config, {
|
|
974
|
+
batchIndex: 0,
|
|
975
|
+
turn,
|
|
976
|
+
batchScopeId,
|
|
977
|
+
resolvedArgsByCallId,
|
|
978
|
+
}),
|
|
979
|
+
];
|
|
980
|
+
this.handleRunToolCompletions([input.lg_tool_call], outputs, config, resolvedArgsByCallId);
|
|
641
981
|
}
|
|
642
982
|
else {
|
|
643
983
|
let messages$1;
|
|
@@ -682,19 +1022,82 @@ class ToolNode extends run.RunnableCallable {
|
|
|
682
1022
|
false));
|
|
683
1023
|
}) ?? [];
|
|
684
1024
|
if (this.eventDrivenMode && filteredCalls.length > 0) {
|
|
1025
|
+
const filteredIndices = filteredCalls.map((_, idx) => idx);
|
|
685
1026
|
if (!this.directToolNames || this.directToolNames.size === 0) {
|
|
686
|
-
return this.executeViaEvent(filteredCalls, config, input
|
|
1027
|
+
return this.executeViaEvent(filteredCalls, config, input, {
|
|
1028
|
+
batchIndices: filteredIndices,
|
|
1029
|
+
turn,
|
|
1030
|
+
batchScopeId,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
const directEntries = [];
|
|
1034
|
+
const eventEntries = [];
|
|
1035
|
+
for (let i = 0; i < filteredCalls.length; i++) {
|
|
1036
|
+
const call = filteredCalls[i];
|
|
1037
|
+
const entry = { call, batchIndex: i };
|
|
1038
|
+
if (this.directToolNames.has(call.name)) {
|
|
1039
|
+
directEntries.push(entry);
|
|
1040
|
+
}
|
|
1041
|
+
else {
|
|
1042
|
+
eventEntries.push(entry);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
const directCalls = directEntries.map((e) => e.call);
|
|
1046
|
+
const directIndices = directEntries.map((e) => e.batchIndex);
|
|
1047
|
+
const eventCalls = eventEntries.map((e) => e.call);
|
|
1048
|
+
const eventIndices = eventEntries.map((e) => e.batchIndex);
|
|
1049
|
+
/**
|
|
1050
|
+
* Snapshot the event calls' args against the *pre-batch*
|
|
1051
|
+
* registry state synchronously, before any await runs. The
|
|
1052
|
+
* directs are then awaited first (preserving fail-fast
|
|
1053
|
+
* semantics — a thrown error in a direct tool, e.g. with
|
|
1054
|
+
* `handleToolErrors=false` or a `GraphInterrupt`, aborts
|
|
1055
|
+
* before we dispatch any event-driven tools to the host).
|
|
1056
|
+
* Because the event args were captured pre-await, they stay
|
|
1057
|
+
* isolated from same-turn direct outputs that register
|
|
1058
|
+
* during the await.
|
|
1059
|
+
*/
|
|
1060
|
+
const preResolvedEventArgs = new Map();
|
|
1061
|
+
/**
|
|
1062
|
+
* Take a frozen snapshot of the registry state before any
|
|
1063
|
+
* direct registrations land. The snapshot resolves
|
|
1064
|
+
* placeholders against this point-in-time view, so a
|
|
1065
|
+
* `PreToolUse` hook later rewriting event args via
|
|
1066
|
+
* `updatedInput` can introduce placeholders that resolve
|
|
1067
|
+
* cross-batch (against prior runs) without ever picking up
|
|
1068
|
+
* same-turn direct outputs.
|
|
1069
|
+
*/
|
|
1070
|
+
const preBatchSnapshot = this.toolOutputRegistry?.snapshot(batchScopeId);
|
|
1071
|
+
if (preBatchSnapshot != null) {
|
|
1072
|
+
for (const entry of eventEntries) {
|
|
1073
|
+
if (entry.call.id != null) {
|
|
1074
|
+
const { resolved, unresolved } = preBatchSnapshot.resolve(entry.call.args);
|
|
1075
|
+
preResolvedEventArgs.set(entry.call.id, {
|
|
1076
|
+
resolved: resolved,
|
|
1077
|
+
unresolved,
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
687
1081
|
}
|
|
688
|
-
const directCalls = filteredCalls.filter((c) => this.directToolNames.has(c.name));
|
|
689
|
-
const eventCalls = filteredCalls.filter((c) => !this.directToolNames.has(c.name));
|
|
690
1082
|
const directOutputs = directCalls.length > 0
|
|
691
|
-
? await Promise.all(directCalls.map((call) => this.runTool(call, config
|
|
1083
|
+
? await Promise.all(directCalls.map((call, i) => this.runTool(call, config, {
|
|
1084
|
+
batchIndex: directIndices[i],
|
|
1085
|
+
turn,
|
|
1086
|
+
batchScopeId,
|
|
1087
|
+
resolvedArgsByCallId,
|
|
1088
|
+
})))
|
|
692
1089
|
: [];
|
|
693
1090
|
if (directCalls.length > 0 && directOutputs.length > 0) {
|
|
694
|
-
this.handleRunToolCompletions(directCalls, directOutputs, config);
|
|
1091
|
+
this.handleRunToolCompletions(directCalls, directOutputs, config, resolvedArgsByCallId);
|
|
695
1092
|
}
|
|
696
1093
|
const eventResult = eventCalls.length > 0
|
|
697
|
-
? await this.dispatchToolEvents(eventCalls, config
|
|
1094
|
+
? await this.dispatchToolEvents(eventCalls, config, {
|
|
1095
|
+
batchIndices: eventIndices,
|
|
1096
|
+
turn,
|
|
1097
|
+
batchScopeId,
|
|
1098
|
+
preResolvedArgs: preResolvedEventArgs,
|
|
1099
|
+
preBatchSnapshot,
|
|
1100
|
+
})
|
|
698
1101
|
: {
|
|
699
1102
|
toolMessages: [],
|
|
700
1103
|
injected: [],
|
|
@@ -706,8 +1109,13 @@ class ToolNode extends run.RunnableCallable {
|
|
|
706
1109
|
];
|
|
707
1110
|
}
|
|
708
1111
|
else {
|
|
709
|
-
outputs = await Promise.all(filteredCalls.map((call) => this.runTool(call, config
|
|
710
|
-
|
|
1112
|
+
outputs = await Promise.all(filteredCalls.map((call, i) => this.runTool(call, config, {
|
|
1113
|
+
batchIndex: i,
|
|
1114
|
+
turn,
|
|
1115
|
+
batchScopeId,
|
|
1116
|
+
resolvedArgsByCallId,
|
|
1117
|
+
})));
|
|
1118
|
+
this.handleRunToolCompletions(filteredCalls, outputs, config, resolvedArgsByCallId);
|
|
711
1119
|
}
|
|
712
1120
|
}
|
|
713
1121
|
if (!outputs.some(langgraph.isCommand)) {
|