@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
|
@@ -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, 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,80 @@ 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 but still stamp the
|
|
304
|
+
* unresolved-refs hint into `additional_kwargs` so the lazy
|
|
305
|
+
* annotation transform surfaces it to the LLM, letting the
|
|
306
|
+
* model self-correct when its reference key caused the
|
|
307
|
+
* failure. Persisted `content` stays clean.
|
|
308
|
+
*/
|
|
309
|
+
if (unresolvedRefs.length > 0) {
|
|
310
|
+
toolMsg.additional_kwargs = {
|
|
311
|
+
...toolMsg.additional_kwargs,
|
|
312
|
+
_unresolvedRefs: unresolvedRefs,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return toolMsg;
|
|
316
|
+
}
|
|
317
|
+
if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
|
|
318
|
+
if (typeof toolMsg.content === 'string') {
|
|
319
|
+
const rawContent = toolMsg.content;
|
|
320
|
+
const llmContent = truncateToolResultContent(rawContent, this.maxToolResultChars);
|
|
321
|
+
toolMsg.content = llmContent;
|
|
322
|
+
const refMeta = this.recordOutputReference(runId, rawContent, refKey, unresolvedRefs);
|
|
323
|
+
if (refMeta != null) {
|
|
324
|
+
toolMsg.additional_kwargs = {
|
|
325
|
+
...toolMsg.additional_kwargs,
|
|
326
|
+
...refMeta,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
/**
|
|
332
|
+
* Non-string content (multi-part content blocks — text +
|
|
333
|
+
* image). Known limitation: we cannot register under a
|
|
334
|
+
* reference key because there's no canonical serialized
|
|
335
|
+
* form. Warn once per tool per run when the caller
|
|
336
|
+
* intended to register. The unresolved-refs hint is still
|
|
337
|
+
* stamped as metadata; the lazy transform prepends a text
|
|
338
|
+
* block at request time so the LLM gets the self-correction
|
|
339
|
+
* signal.
|
|
340
|
+
*/
|
|
341
|
+
if (unresolvedRefs.length > 0) {
|
|
342
|
+
toolMsg.additional_kwargs = {
|
|
343
|
+
...toolMsg.additional_kwargs,
|
|
344
|
+
_unresolvedRefs: unresolvedRefs,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (refKey != null &&
|
|
348
|
+
this.toolOutputRegistry.claimWarnOnce(runId, call.name)) {
|
|
349
|
+
// eslint-disable-next-line no-console
|
|
350
|
+
console.warn(`[ToolNode] Skipping tool output reference for "${call.name}": ` +
|
|
351
|
+
'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return toolMsg;
|
|
198
356
|
}
|
|
357
|
+
const rawContent = typeof output === 'string' ? output : JSON.stringify(output);
|
|
358
|
+
const truncated = truncateToolResultContent(rawContent, this.maxToolResultChars);
|
|
359
|
+
const refMeta = this.recordOutputReference(runId, rawContent, refKey, unresolvedRefs);
|
|
360
|
+
return new ToolMessage({
|
|
361
|
+
status: 'success',
|
|
362
|
+
name: tool.name,
|
|
363
|
+
content: truncated,
|
|
364
|
+
tool_call_id: call.id,
|
|
365
|
+
...(refMeta != null && {
|
|
366
|
+
additional_kwargs: refMeta,
|
|
367
|
+
}),
|
|
368
|
+
});
|
|
199
369
|
}
|
|
200
370
|
catch (_e) {
|
|
201
371
|
const e = _e;
|
|
@@ -238,14 +408,65 @@ class ToolNode extends RunnableCallable {
|
|
|
238
408
|
});
|
|
239
409
|
}
|
|
240
410
|
}
|
|
411
|
+
const errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
|
|
412
|
+
const refMeta = unresolvedRefs.length > 0
|
|
413
|
+
? this.recordOutputReference(runId, errorContent, undefined, unresolvedRefs)
|
|
414
|
+
: undefined;
|
|
241
415
|
return new ToolMessage({
|
|
242
416
|
status: 'error',
|
|
243
|
-
content:
|
|
417
|
+
content: errorContent,
|
|
244
418
|
name: call.name,
|
|
245
419
|
tool_call_id: call.id ?? '',
|
|
420
|
+
...(refMeta != null && {
|
|
421
|
+
additional_kwargs: refMeta,
|
|
422
|
+
}),
|
|
246
423
|
});
|
|
247
424
|
}
|
|
248
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* Registers the full, raw output under `refKey` (when provided) and
|
|
428
|
+
* builds the per-message ref metadata stamped onto the resulting
|
|
429
|
+
* `ToolMessage.additional_kwargs`. The metadata is read at LLM-
|
|
430
|
+
* request time by `annotateMessagesForLLM` to produce a transient
|
|
431
|
+
* annotated copy of the message — the persisted `content` itself
|
|
432
|
+
* stays clean.
|
|
433
|
+
*
|
|
434
|
+
* @param registryContent The full, untruncated output to store in
|
|
435
|
+
* the registry so `{{tool<i>turn<n>}}` substitutions deliver the
|
|
436
|
+
* complete payload. Ignored when `refKey` is undefined.
|
|
437
|
+
* @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
|
|
438
|
+
* the output is not to be registered (errors, disabled feature,
|
|
439
|
+
* unavailable batch/turn).
|
|
440
|
+
* @param unresolved Placeholder keys that did not resolve; surfaced
|
|
441
|
+
* to the LLM lazily so it can self-correct.
|
|
442
|
+
* @returns A `ToolMessageRefMetadata` object when there is anything
|
|
443
|
+
* to stamp, otherwise `undefined`.
|
|
444
|
+
*/
|
|
445
|
+
recordOutputReference(runId, registryContent, refKey, unresolved) {
|
|
446
|
+
if (this.toolOutputRegistry != null && refKey != null) {
|
|
447
|
+
this.toolOutputRegistry.set(runId, refKey, registryContent);
|
|
448
|
+
}
|
|
449
|
+
if (refKey == null && unresolved.length === 0)
|
|
450
|
+
return undefined;
|
|
451
|
+
const meta = {};
|
|
452
|
+
if (refKey != null) {
|
|
453
|
+
meta._refKey = refKey;
|
|
454
|
+
/**
|
|
455
|
+
* Stamp the registry scope alongside the key so the lazy
|
|
456
|
+
* annotation transform can look up the right bucket. Anonymous
|
|
457
|
+
* invocations get a synthetic per-batch scope (`\0anon-<n>`)
|
|
458
|
+
* that `attemptInvoke` cannot derive from
|
|
459
|
+
* `config.configurable.run_id` — without this, anonymous-run
|
|
460
|
+
* refs would silently fail registry lookup and the LLM would
|
|
461
|
+
* never see `[ref: …]` markers for outputs that were registered.
|
|
462
|
+
*/
|
|
463
|
+
if (runId != null)
|
|
464
|
+
meta._refScope = runId;
|
|
465
|
+
}
|
|
466
|
+
if (unresolved.length > 0)
|
|
467
|
+
meta._unresolvedRefs = unresolved;
|
|
468
|
+
return meta;
|
|
469
|
+
}
|
|
249
470
|
/**
|
|
250
471
|
* Builds code session context for injection into event-driven tool calls.
|
|
251
472
|
* Mirrors the session injection logic in runTool() for direct execution.
|
|
@@ -304,8 +525,12 @@ class ToolNode extends RunnableCallable {
|
|
|
304
525
|
* By handling completions here in graph context (rather than in the
|
|
305
526
|
* stream consumer via ToolEndHandler), the race between the stream
|
|
306
527
|
* consumer and graph execution is eliminated.
|
|
528
|
+
*
|
|
529
|
+
* @param resolvedArgsByCallId Per-batch resolved-args sink populated
|
|
530
|
+
* by `runTool`. Threaded as a local map (instead of instance state)
|
|
531
|
+
* so concurrent batches cannot read each other's entries.
|
|
307
532
|
*/
|
|
308
|
-
handleRunToolCompletions(calls, outputs, config) {
|
|
533
|
+
handleRunToolCompletions(calls, outputs, config, resolvedArgsByCallId) {
|
|
309
534
|
for (let i = 0; i < calls.length; i++) {
|
|
310
535
|
const call = calls[i];
|
|
311
536
|
const output = outputs[i];
|
|
@@ -334,10 +559,17 @@ class ToolNode extends RunnableCallable {
|
|
|
334
559
|
const contentString = typeof toolMessage.content === 'string'
|
|
335
560
|
? toolMessage.content
|
|
336
561
|
: JSON.stringify(toolMessage.content);
|
|
562
|
+
/**
|
|
563
|
+
* Prefer the post-substitution args when a `{{…}}` placeholder
|
|
564
|
+
* was resolved in `runTool`. This keeps
|
|
565
|
+
* `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
|
|
566
|
+
* the tool actually received rather than leaking the template.
|
|
567
|
+
*/
|
|
568
|
+
const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
|
|
337
569
|
const tool_call = {
|
|
338
|
-
args: typeof
|
|
339
|
-
?
|
|
340
|
-
: JSON.stringify(
|
|
570
|
+
args: typeof effectiveArgs === 'string'
|
|
571
|
+
? effectiveArgs
|
|
572
|
+
: JSON.stringify(effectiveArgs ?? {}),
|
|
341
573
|
name: call.name,
|
|
342
574
|
id: toolCallId,
|
|
343
575
|
output: contentString,
|
|
@@ -367,14 +599,52 @@ class ToolNode extends RunnableCallable {
|
|
|
367
599
|
* 4. Injected messages from results are collected and returned alongside
|
|
368
600
|
* ToolMessages (appended AFTER to respect provider ordering).
|
|
369
601
|
*/
|
|
370
|
-
async dispatchToolEvents(toolCalls, config) {
|
|
602
|
+
async dispatchToolEvents(toolCalls, config, batchContext = {}) {
|
|
603
|
+
const { batchIndices, turn, batchScopeId, preResolvedArgs, preBatchSnapshot, } = batchContext;
|
|
371
604
|
const runId = config.configurable?.run_id ?? '';
|
|
605
|
+
/**
|
|
606
|
+
* Registry-facing scope id — prefers the caller-threaded
|
|
607
|
+
* `batchScopeId` so anonymous batches target their own unique
|
|
608
|
+
* bucket and don't step on concurrent anonymous invocations.
|
|
609
|
+
* Hooks and event payloads keep using the empty-string coerced
|
|
610
|
+
* `runId` for backward compat.
|
|
611
|
+
*/
|
|
612
|
+
const registryRunId = batchScopeId ?? config.configurable?.run_id;
|
|
372
613
|
const threadId = config.configurable?.thread_id;
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
614
|
+
const registry = this.toolOutputRegistry;
|
|
615
|
+
const unresolvedByCallId = new Map();
|
|
616
|
+
const preToolCalls = toolCalls.map((call, i) => {
|
|
617
|
+
const originalArgs = call.args;
|
|
618
|
+
let resolvedArgs = originalArgs;
|
|
619
|
+
/**
|
|
620
|
+
* When the caller provided a pre-resolved map (the mixed
|
|
621
|
+
* direct+event path snapshots event args synchronously before
|
|
622
|
+
* awaiting directs so they can't accidentally resolve
|
|
623
|
+
* same-turn direct outputs), use those entries verbatim instead
|
|
624
|
+
* of re-resolving against a registry that may have changed
|
|
625
|
+
* since the batch started.
|
|
626
|
+
*/
|
|
627
|
+
const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
|
|
628
|
+
if (pre != null) {
|
|
629
|
+
resolvedArgs = pre.resolved;
|
|
630
|
+
if (pre.unresolved.length > 0 && call.id != null) {
|
|
631
|
+
unresolvedByCallId.set(call.id, pre.unresolved);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
else if (registry != null) {
|
|
635
|
+
const { resolved, unresolved } = registry.resolve(registryRunId, originalArgs);
|
|
636
|
+
resolvedArgs = resolved;
|
|
637
|
+
if (unresolved.length > 0 && call.id != null) {
|
|
638
|
+
unresolvedByCallId.set(call.id, unresolved);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
call,
|
|
643
|
+
stepId: this.toolCallStepIds?.get(call.id) ?? '',
|
|
644
|
+
args: resolvedArgs,
|
|
645
|
+
batchIndex: batchIndices?.[i],
|
|
646
|
+
};
|
|
647
|
+
});
|
|
378
648
|
const messageByCallId = new Map();
|
|
379
649
|
const approvedEntries = [];
|
|
380
650
|
const HOOK_FALLBACK = Object.freeze({
|
|
@@ -434,7 +704,40 @@ class ToolNode extends RunnableCallable {
|
|
|
434
704
|
continue;
|
|
435
705
|
}
|
|
436
706
|
if (hookResult.updatedInput != null) {
|
|
437
|
-
|
|
707
|
+
/**
|
|
708
|
+
* Re-resolve after PreToolUse replaces the input: a hook may
|
|
709
|
+
* introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
|
|
710
|
+
* copying user-supplied text) that the pre-hook pass never
|
|
711
|
+
* saw. Re-running the resolver on the hook-rewritten args
|
|
712
|
+
* keeps substitution and the unresolved-refs record in sync
|
|
713
|
+
* with what the tool will actually receive.
|
|
714
|
+
*/
|
|
715
|
+
if (registry != null) {
|
|
716
|
+
/**
|
|
717
|
+
* Mixed direct+event batches must use the pre-batch
|
|
718
|
+
* snapshot so a hook-introduced placeholder cannot
|
|
719
|
+
* accidentally resolve to a same-turn direct output that
|
|
720
|
+
* has just registered. Pure event batches don't have a
|
|
721
|
+
* snapshot and resolve against the live registry — safe
|
|
722
|
+
* because no event-side registrations have happened yet.
|
|
723
|
+
*/
|
|
724
|
+
const view = preBatchSnapshot ?? {
|
|
725
|
+
resolve: (args) => registry.resolve(registryRunId, args),
|
|
726
|
+
};
|
|
727
|
+
const { resolved, unresolved } = view.resolve(hookResult.updatedInput);
|
|
728
|
+
entry.args = resolved;
|
|
729
|
+
if (entry.call.id != null) {
|
|
730
|
+
if (unresolved.length > 0) {
|
|
731
|
+
unresolvedByCallId.set(entry.call.id, unresolved);
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
unresolvedByCallId.delete(entry.call.id);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
entry.args = hookResult.updatedInput;
|
|
740
|
+
}
|
|
438
741
|
}
|
|
439
742
|
approvedEntries.push(entry);
|
|
440
743
|
}
|
|
@@ -443,10 +746,14 @@ class ToolNode extends RunnableCallable {
|
|
|
443
746
|
approvedEntries.push(...preToolCalls);
|
|
444
747
|
}
|
|
445
748
|
const injected = [];
|
|
749
|
+
const batchIndexByCallId = new Map();
|
|
446
750
|
if (approvedEntries.length > 0) {
|
|
447
751
|
const requests = approvedEntries.map((entry) => {
|
|
448
752
|
const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
|
|
449
753
|
this.toolUsageCount.set(entry.call.name, turn + 1);
|
|
754
|
+
if (entry.batchIndex != null && entry.call.id != null) {
|
|
755
|
+
batchIndexByCallId.set(entry.call.id, entry.batchIndex);
|
|
756
|
+
}
|
|
450
757
|
const request = {
|
|
451
758
|
id: entry.call.id,
|
|
452
759
|
name: entry.call.name,
|
|
@@ -492,11 +799,25 @@ class ToolNode extends RunnableCallable {
|
|
|
492
799
|
let toolMessage;
|
|
493
800
|
if (result.status === 'error') {
|
|
494
801
|
contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
|
|
802
|
+
/**
|
|
803
|
+
* Error results bypass registration but stamp the
|
|
804
|
+
* unresolved-refs hint into `additional_kwargs` so the lazy
|
|
805
|
+
* annotation transform surfaces it to the LLM at request
|
|
806
|
+
* time, letting the model self-correct when its reference
|
|
807
|
+
* key caused the failure. Persisted `content` stays clean.
|
|
808
|
+
*/
|
|
809
|
+
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
810
|
+
const errorRefMeta = unresolved.length > 0
|
|
811
|
+
? this.recordOutputReference(registryRunId, contentString, undefined, unresolved)
|
|
812
|
+
: undefined;
|
|
495
813
|
toolMessage = new ToolMessage({
|
|
496
814
|
status: 'error',
|
|
497
815
|
content: contentString,
|
|
498
816
|
name: toolName,
|
|
499
817
|
tool_call_id: result.toolCallId,
|
|
818
|
+
...(errorRefMeta != null && {
|
|
819
|
+
additional_kwargs: errorRefMeta,
|
|
820
|
+
}),
|
|
500
821
|
});
|
|
501
822
|
if (hasFailureHook) {
|
|
502
823
|
await executeHooks({
|
|
@@ -521,10 +842,10 @@ class ToolNode extends RunnableCallable {
|
|
|
521
842
|
}
|
|
522
843
|
}
|
|
523
844
|
else {
|
|
524
|
-
|
|
845
|
+
let registryRaw = typeof result.content === 'string'
|
|
525
846
|
? result.content
|
|
526
847
|
: JSON.stringify(result.content);
|
|
527
|
-
contentString = truncateToolResultContent(
|
|
848
|
+
contentString = truncateToolResultContent(registryRaw, this.maxToolResultChars);
|
|
528
849
|
if (hasPostHook) {
|
|
529
850
|
const hookResult = await executeHooks({
|
|
530
851
|
registry: this.hookRegistry,
|
|
@@ -547,15 +868,27 @@ class ToolNode extends RunnableCallable {
|
|
|
547
868
|
const replaced = typeof hookResult.updatedOutput === 'string'
|
|
548
869
|
? hookResult.updatedOutput
|
|
549
870
|
: JSON.stringify(hookResult.updatedOutput);
|
|
871
|
+
registryRaw = replaced;
|
|
550
872
|
contentString = truncateToolResultContent(replaced, this.maxToolResultChars);
|
|
551
873
|
}
|
|
552
874
|
}
|
|
875
|
+
const batchIndex = batchIndexByCallId.get(result.toolCallId);
|
|
876
|
+
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
877
|
+
const refKey = this.toolOutputRegistry != null &&
|
|
878
|
+
batchIndex != null &&
|
|
879
|
+
turn != null
|
|
880
|
+
? buildReferenceKey(batchIndex, turn)
|
|
881
|
+
: undefined;
|
|
882
|
+
const successRefMeta = this.recordOutputReference(registryRunId, registryRaw, refKey, unresolved);
|
|
553
883
|
toolMessage = new ToolMessage({
|
|
554
884
|
status: 'success',
|
|
555
885
|
name: toolName,
|
|
556
886
|
content: contentString,
|
|
557
887
|
artifact: result.artifact,
|
|
558
888
|
tool_call_id: result.toolCallId,
|
|
889
|
+
...(successRefMeta != null && {
|
|
890
|
+
additional_kwargs: successRefMeta,
|
|
891
|
+
}),
|
|
559
892
|
});
|
|
560
893
|
}
|
|
561
894
|
this.dispatchStepCompleted(result.toolCallId, toolName, request?.args ?? {}, contentString, config, request?.turn);
|
|
@@ -617,25 +950,61 @@ class ToolNode extends RunnableCallable {
|
|
|
617
950
|
* Injected messages are placed AFTER ToolMessages to respect provider
|
|
618
951
|
* message ordering (AIMessage tool_calls must be immediately followed
|
|
619
952
|
* by their ToolMessage results).
|
|
953
|
+
*
|
|
954
|
+
* `batchIndices` mirrors `toolCalls` and carries each call's position
|
|
955
|
+
* within the parent batch. `turn` is the per-`run()` batch index
|
|
956
|
+
* captured locally by the caller. Both are threaded so concurrent
|
|
957
|
+
* invocations cannot race on shared mutable state.
|
|
620
958
|
*/
|
|
621
959
|
async executeViaEvent(toolCalls, config,
|
|
622
960
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
623
|
-
input) {
|
|
624
|
-
const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config);
|
|
961
|
+
input, batchContext = {}) {
|
|
962
|
+
const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config, batchContext);
|
|
625
963
|
const outputs = [...toolMessages, ...injected];
|
|
626
964
|
return (Array.isArray(input) ? outputs : { messages: outputs });
|
|
627
965
|
}
|
|
628
966
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
629
967
|
async run(input, config) {
|
|
630
968
|
this.toolCallTurns.clear();
|
|
969
|
+
/**
|
|
970
|
+
* Per-batch local map for resolved (post-substitution) args.
|
|
971
|
+
* Lives on the stack so concurrent `run()` calls on the same
|
|
972
|
+
* ToolNode cannot read or wipe each other's entries.
|
|
973
|
+
*/
|
|
974
|
+
const resolvedArgsByCallId = new Map();
|
|
975
|
+
/**
|
|
976
|
+
* Claim this batch's turn synchronously from the registry (or
|
|
977
|
+
* fall back to 0 when the feature is disabled). The registry is
|
|
978
|
+
* partitioned by scope id so overlapping batches cannot
|
|
979
|
+
* overwrite each other's state even under a shared registry.
|
|
980
|
+
*
|
|
981
|
+
* For anonymous callers (no `run_id` in config), mint a unique
|
|
982
|
+
* per-batch scope id so two concurrent anonymous invocations
|
|
983
|
+
* don't target the same bucket. The scope is threaded down to
|
|
984
|
+
* every subsequent registry call on this batch.
|
|
985
|
+
*/
|
|
986
|
+
const incomingRunId = config.configurable?.run_id;
|
|
987
|
+
const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
|
|
988
|
+
const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
|
|
631
989
|
let outputs;
|
|
632
990
|
if (this.isSendInput(input)) {
|
|
633
991
|
const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
|
|
634
992
|
if (this.eventDrivenMode && isDirectTool !== true) {
|
|
635
|
-
return this.executeViaEvent([input.lg_tool_call], config, input
|
|
993
|
+
return this.executeViaEvent([input.lg_tool_call], config, input, {
|
|
994
|
+
batchIndices: [0],
|
|
995
|
+
turn,
|
|
996
|
+
batchScopeId,
|
|
997
|
+
});
|
|
636
998
|
}
|
|
637
|
-
outputs = [
|
|
638
|
-
|
|
999
|
+
outputs = [
|
|
1000
|
+
await this.runTool(input.lg_tool_call, config, {
|
|
1001
|
+
batchIndex: 0,
|
|
1002
|
+
turn,
|
|
1003
|
+
batchScopeId,
|
|
1004
|
+
resolvedArgsByCallId,
|
|
1005
|
+
}),
|
|
1006
|
+
];
|
|
1007
|
+
this.handleRunToolCompletions([input.lg_tool_call], outputs, config, resolvedArgsByCallId);
|
|
639
1008
|
}
|
|
640
1009
|
else {
|
|
641
1010
|
let messages;
|
|
@@ -680,19 +1049,82 @@ class ToolNode extends RunnableCallable {
|
|
|
680
1049
|
false));
|
|
681
1050
|
}) ?? [];
|
|
682
1051
|
if (this.eventDrivenMode && filteredCalls.length > 0) {
|
|
1052
|
+
const filteredIndices = filteredCalls.map((_, idx) => idx);
|
|
683
1053
|
if (!this.directToolNames || this.directToolNames.size === 0) {
|
|
684
|
-
return this.executeViaEvent(filteredCalls, config, input
|
|
1054
|
+
return this.executeViaEvent(filteredCalls, config, input, {
|
|
1055
|
+
batchIndices: filteredIndices,
|
|
1056
|
+
turn,
|
|
1057
|
+
batchScopeId,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
const directEntries = [];
|
|
1061
|
+
const eventEntries = [];
|
|
1062
|
+
for (let i = 0; i < filteredCalls.length; i++) {
|
|
1063
|
+
const call = filteredCalls[i];
|
|
1064
|
+
const entry = { call, batchIndex: i };
|
|
1065
|
+
if (this.directToolNames.has(call.name)) {
|
|
1066
|
+
directEntries.push(entry);
|
|
1067
|
+
}
|
|
1068
|
+
else {
|
|
1069
|
+
eventEntries.push(entry);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
const directCalls = directEntries.map((e) => e.call);
|
|
1073
|
+
const directIndices = directEntries.map((e) => e.batchIndex);
|
|
1074
|
+
const eventCalls = eventEntries.map((e) => e.call);
|
|
1075
|
+
const eventIndices = eventEntries.map((e) => e.batchIndex);
|
|
1076
|
+
/**
|
|
1077
|
+
* Snapshot the event calls' args against the *pre-batch*
|
|
1078
|
+
* registry state synchronously, before any await runs. The
|
|
1079
|
+
* directs are then awaited first (preserving fail-fast
|
|
1080
|
+
* semantics — a thrown error in a direct tool, e.g. with
|
|
1081
|
+
* `handleToolErrors=false` or a `GraphInterrupt`, aborts
|
|
1082
|
+
* before we dispatch any event-driven tools to the host).
|
|
1083
|
+
* Because the event args were captured pre-await, they stay
|
|
1084
|
+
* isolated from same-turn direct outputs that register
|
|
1085
|
+
* during the await.
|
|
1086
|
+
*/
|
|
1087
|
+
const preResolvedEventArgs = new Map();
|
|
1088
|
+
/**
|
|
1089
|
+
* Take a frozen snapshot of the registry state before any
|
|
1090
|
+
* direct registrations land. The snapshot resolves
|
|
1091
|
+
* placeholders against this point-in-time view, so a
|
|
1092
|
+
* `PreToolUse` hook later rewriting event args via
|
|
1093
|
+
* `updatedInput` can introduce placeholders that resolve
|
|
1094
|
+
* cross-batch (against prior runs) without ever picking up
|
|
1095
|
+
* same-turn direct outputs.
|
|
1096
|
+
*/
|
|
1097
|
+
const preBatchSnapshot = this.toolOutputRegistry?.snapshot(batchScopeId);
|
|
1098
|
+
if (preBatchSnapshot != null) {
|
|
1099
|
+
for (const entry of eventEntries) {
|
|
1100
|
+
if (entry.call.id != null) {
|
|
1101
|
+
const { resolved, unresolved } = preBatchSnapshot.resolve(entry.call.args);
|
|
1102
|
+
preResolvedEventArgs.set(entry.call.id, {
|
|
1103
|
+
resolved: resolved,
|
|
1104
|
+
unresolved,
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
685
1108
|
}
|
|
686
|
-
const directCalls = filteredCalls.filter((c) => this.directToolNames.has(c.name));
|
|
687
|
-
const eventCalls = filteredCalls.filter((c) => !this.directToolNames.has(c.name));
|
|
688
1109
|
const directOutputs = directCalls.length > 0
|
|
689
|
-
? await Promise.all(directCalls.map((call) => this.runTool(call, config
|
|
1110
|
+
? await Promise.all(directCalls.map((call, i) => this.runTool(call, config, {
|
|
1111
|
+
batchIndex: directIndices[i],
|
|
1112
|
+
turn,
|
|
1113
|
+
batchScopeId,
|
|
1114
|
+
resolvedArgsByCallId,
|
|
1115
|
+
})))
|
|
690
1116
|
: [];
|
|
691
1117
|
if (directCalls.length > 0 && directOutputs.length > 0) {
|
|
692
|
-
this.handleRunToolCompletions(directCalls, directOutputs, config);
|
|
1118
|
+
this.handleRunToolCompletions(directCalls, directOutputs, config, resolvedArgsByCallId);
|
|
693
1119
|
}
|
|
694
1120
|
const eventResult = eventCalls.length > 0
|
|
695
|
-
? await this.dispatchToolEvents(eventCalls, config
|
|
1121
|
+
? await this.dispatchToolEvents(eventCalls, config, {
|
|
1122
|
+
batchIndices: eventIndices,
|
|
1123
|
+
turn,
|
|
1124
|
+
batchScopeId,
|
|
1125
|
+
preResolvedArgs: preResolvedEventArgs,
|
|
1126
|
+
preBatchSnapshot,
|
|
1127
|
+
})
|
|
696
1128
|
: {
|
|
697
1129
|
toolMessages: [],
|
|
698
1130
|
injected: [],
|
|
@@ -704,8 +1136,13 @@ class ToolNode extends RunnableCallable {
|
|
|
704
1136
|
];
|
|
705
1137
|
}
|
|
706
1138
|
else {
|
|
707
|
-
outputs = await Promise.all(filteredCalls.map((call) => this.runTool(call, config
|
|
708
|
-
|
|
1139
|
+
outputs = await Promise.all(filteredCalls.map((call, i) => this.runTool(call, config, {
|
|
1140
|
+
batchIndex: i,
|
|
1141
|
+
turn,
|
|
1142
|
+
batchScopeId,
|
|
1143
|
+
resolvedArgsByCallId,
|
|
1144
|
+
})));
|
|
1145
|
+
this.handleRunToolCompletions(filteredCalls, outputs, config, resolvedArgsByCallId);
|
|
709
1146
|
}
|
|
710
1147
|
}
|
|
711
1148
|
if (!outputs.some(isCommand)) {
|