@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
package/src/tools/ToolNode.ts
CHANGED
|
@@ -19,8 +19,13 @@ import type {
|
|
|
19
19
|
} from '@langchain/core/runnables';
|
|
20
20
|
import type { BaseMessage, AIMessage } from '@langchain/core/messages';
|
|
21
21
|
import type { StructuredToolInterface } from '@langchain/core/tools';
|
|
22
|
-
import type
|
|
22
|
+
import type {
|
|
23
|
+
ToolOutputResolveView,
|
|
24
|
+
PreResolvedArgsMap,
|
|
25
|
+
ResolvedArgsByCallId,
|
|
26
|
+
} from '@/tools/toolOutputReferences';
|
|
23
27
|
import type { HookRegistry, AggregatedHookResult } from '@/hooks';
|
|
28
|
+
import type * as t from '@/types';
|
|
24
29
|
import { RunnableCallable } from '@/utils';
|
|
25
30
|
import {
|
|
26
31
|
calculateMaxToolResultChars,
|
|
@@ -29,6 +34,53 @@ import {
|
|
|
29
34
|
import { safeDispatchCustomEvent } from '@/utils/events';
|
|
30
35
|
import { executeHooks } from '@/hooks';
|
|
31
36
|
import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
|
|
37
|
+
import {
|
|
38
|
+
buildReferenceKey,
|
|
39
|
+
ToolOutputReferenceRegistry,
|
|
40
|
+
} from '@/tools/toolOutputReferences';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Per-call batch context for `runTool`. Bundles every optional
|
|
44
|
+
* batch-scoped value the method needs so the signature stays at
|
|
45
|
+
* three positional parameters even as new context fields are added.
|
|
46
|
+
*/
|
|
47
|
+
type RunToolBatchContext = {
|
|
48
|
+
/** Position of this call within the parent ToolNode batch. */
|
|
49
|
+
batchIndex?: number;
|
|
50
|
+
/** Batch turn shared across every call in the batch. */
|
|
51
|
+
turn?: number;
|
|
52
|
+
/** Registry partition scope (run id or anonymous batch id). */
|
|
53
|
+
batchScopeId?: string;
|
|
54
|
+
/** Batch-local sink for post-substitution args. */
|
|
55
|
+
resolvedArgsByCallId?: ResolvedArgsByCallId;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Per-batch context for `dispatchToolEvents` / `executeViaEvent`.
|
|
60
|
+
* Mirrors {@link RunToolBatchContext} for the event-driven path,
|
|
61
|
+
* with bulk indices and the snapshot/pre-resolved-args carriers
|
|
62
|
+
* used in the mixed direct+event flow.
|
|
63
|
+
*/
|
|
64
|
+
type DispatchBatchContext = {
|
|
65
|
+
/** Per-call batch indices, parallel to the `toolCalls` array. */
|
|
66
|
+
batchIndices?: number[];
|
|
67
|
+
/** Batch turn shared across every call in the batch. */
|
|
68
|
+
turn?: number;
|
|
69
|
+
/** Registry partition scope (run id or anonymous batch id). */
|
|
70
|
+
batchScopeId?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Pre-resolved args keyed by `toolCallId`. Populated by the mixed
|
|
73
|
+
* path so event calls don't re-resolve against a registry that
|
|
74
|
+
* already contains same-turn direct outputs.
|
|
75
|
+
*/
|
|
76
|
+
preResolvedArgs?: PreResolvedArgsMap;
|
|
77
|
+
/**
|
|
78
|
+
* Frozen pre-batch registry view used to re-resolve placeholders
|
|
79
|
+
* a `PreToolUse` hook injects via `updatedInput` — preserves the
|
|
80
|
+
* same-turn isolation guarantee for hook-rewritten args.
|
|
81
|
+
*/
|
|
82
|
+
preBatchSnapshot?: ToolOutputResolveView;
|
|
83
|
+
};
|
|
32
84
|
|
|
33
85
|
/**
|
|
34
86
|
* Helper to check if a value is a Send object
|
|
@@ -99,6 +151,26 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
99
151
|
private maxToolResultChars: number;
|
|
100
152
|
/** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
|
|
101
153
|
private hookRegistry?: HookRegistry;
|
|
154
|
+
/**
|
|
155
|
+
* Registry of tool outputs keyed by `tool<idx>turn<turn>`.
|
|
156
|
+
*
|
|
157
|
+
* Populated only when `toolOutputReferences.enabled` is true. The
|
|
158
|
+
* registry owns the run-scoped state (turn counter, last-seen runId,
|
|
159
|
+
* warn-once memo, stored outputs), so sharing a single instance
|
|
160
|
+
* across multiple ToolNodes in a run lets cross-agent `{{…}}`
|
|
161
|
+
* references resolve — which is why multi-agent graphs pass the
|
|
162
|
+
* *same* instance to every ToolNode they compile rather than each
|
|
163
|
+
* ToolNode building its own.
|
|
164
|
+
*/
|
|
165
|
+
private toolOutputRegistry?: ToolOutputReferenceRegistry;
|
|
166
|
+
/**
|
|
167
|
+
* Monotonic counter used to mint a unique scope id for anonymous
|
|
168
|
+
* batches (ones invoked without a `run_id` in
|
|
169
|
+
* `config.configurable`). Each such batch gets its own registry
|
|
170
|
+
* partition so concurrent anonymous invocations can't delete each
|
|
171
|
+
* other's in-flight state.
|
|
172
|
+
*/
|
|
173
|
+
private anonBatchCounter: number = 0;
|
|
102
174
|
|
|
103
175
|
constructor({
|
|
104
176
|
tools,
|
|
@@ -117,6 +189,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
117
189
|
maxContextTokens,
|
|
118
190
|
maxToolResultChars,
|
|
119
191
|
hookRegistry,
|
|
192
|
+
toolOutputReferences,
|
|
193
|
+
toolOutputRegistry,
|
|
120
194
|
}: t.ToolNodeConstructorParams) {
|
|
121
195
|
super({ name, tags, func: (input, config) => this.run(input, config) });
|
|
122
196
|
this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
|
|
@@ -133,6 +207,39 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
133
207
|
this.maxToolResultChars =
|
|
134
208
|
maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
|
|
135
209
|
this.hookRegistry = hookRegistry;
|
|
210
|
+
/**
|
|
211
|
+
* Precedence: an explicitly passed `toolOutputRegistry` instance
|
|
212
|
+
* wins over a config object so a host (`Graph`) can share one
|
|
213
|
+
* registry across many ToolNodes. When only the config is
|
|
214
|
+
* provided (direct ToolNode usage), build a local registry so
|
|
215
|
+
* the feature still works without graph-level plumbing. Registry
|
|
216
|
+
* caps are intentionally decoupled from `maxToolResultChars`:
|
|
217
|
+
* the registry stores the raw untruncated output so a later
|
|
218
|
+
* `{{…}}` substitution pipes the full payload into the next
|
|
219
|
+
* tool, even when the LLM saw a truncated preview.
|
|
220
|
+
*/
|
|
221
|
+
if (toolOutputRegistry != null) {
|
|
222
|
+
this.toolOutputRegistry = toolOutputRegistry;
|
|
223
|
+
} else if (toolOutputReferences?.enabled === true) {
|
|
224
|
+
this.toolOutputRegistry = new ToolOutputReferenceRegistry({
|
|
225
|
+
maxOutputSize: toolOutputReferences.maxOutputSize,
|
|
226
|
+
maxTotalSize: toolOutputReferences.maxTotalSize,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Returns the run-scoped tool output registry, or `undefined` when
|
|
233
|
+
* the feature is disabled.
|
|
234
|
+
*
|
|
235
|
+
* @internal Exposed for test observation only. Host code should rely
|
|
236
|
+
* on `{{tool<i>turn<n>}}` substitution at tool-invocation time and
|
|
237
|
+
* not mutate the registry directly.
|
|
238
|
+
*/
|
|
239
|
+
public _unsafeGetToolOutputRegistry():
|
|
240
|
+
| ToolOutputReferenceRegistry
|
|
241
|
+
| undefined {
|
|
242
|
+
return this.toolOutputRegistry;
|
|
136
243
|
}
|
|
137
244
|
|
|
138
245
|
/**
|
|
@@ -170,32 +277,98 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
170
277
|
}
|
|
171
278
|
|
|
172
279
|
/**
|
|
173
|
-
* Runs a single tool call with error handling
|
|
280
|
+
* Runs a single tool call with error handling.
|
|
281
|
+
*
|
|
282
|
+
* `batchIndex` is the tool's position within the current ToolNode
|
|
283
|
+
* batch and, together with `this.currentTurn`, forms the key used to
|
|
284
|
+
* register the output for future `{{tool<idx>turn<turn>}}`
|
|
285
|
+
* substitutions. Omit when no registration should occur.
|
|
174
286
|
*/
|
|
175
287
|
protected async runTool(
|
|
176
288
|
call: ToolCall,
|
|
177
|
-
config: RunnableConfig
|
|
289
|
+
config: RunnableConfig,
|
|
290
|
+
batchContext: RunToolBatchContext = {}
|
|
178
291
|
): Promise<BaseMessage | Command> {
|
|
292
|
+
const { batchIndex, turn, batchScopeId, resolvedArgsByCallId } =
|
|
293
|
+
batchContext;
|
|
179
294
|
const tool = this.toolMap.get(call.name);
|
|
295
|
+
const registry = this.toolOutputRegistry;
|
|
296
|
+
/**
|
|
297
|
+
* Precompute the reference key once per call — captured locally
|
|
298
|
+
* so concurrent `invoke()` calls on the same ToolNode cannot race
|
|
299
|
+
* on a shared turn field.
|
|
300
|
+
*/
|
|
301
|
+
const refKey =
|
|
302
|
+
registry != null && batchIndex != null && turn != null
|
|
303
|
+
? buildReferenceKey(batchIndex, turn)
|
|
304
|
+
: undefined;
|
|
305
|
+
/**
|
|
306
|
+
* Hoisted outside the try so the catch branch can append
|
|
307
|
+
* `[unresolved refs: …]` to error messages — otherwise the LLM
|
|
308
|
+
* only sees a generic error when it references a bad key, losing
|
|
309
|
+
* the self-correction signal this feature is meant to provide.
|
|
310
|
+
*/
|
|
311
|
+
let unresolvedRefs: string[] = [];
|
|
312
|
+
/**
|
|
313
|
+
* Use the caller-provided `batchScopeId` when threaded from
|
|
314
|
+
* `run()` (so anonymous batches get their own unique scope).
|
|
315
|
+
* Fall back to the config's `run_id` when runTool is invoked
|
|
316
|
+
* from a context that doesn't thread it — that still preserves
|
|
317
|
+
* the runId-based partitioning for named runs.
|
|
318
|
+
*/
|
|
319
|
+
const runId =
|
|
320
|
+
batchScopeId ?? (config.configurable?.run_id as string | undefined);
|
|
180
321
|
try {
|
|
181
322
|
if (tool === undefined) {
|
|
182
323
|
throw new Error(`Tool "${call.name}" not found.`);
|
|
183
324
|
}
|
|
184
|
-
|
|
185
|
-
|
|
325
|
+
/**
|
|
326
|
+
* `usageCount` is the per-tool-name invocation index that
|
|
327
|
+
* web-search and other tools observe via `invokeParams.turn`.
|
|
328
|
+
* It is intentionally distinct from the outer `turn` parameter
|
|
329
|
+
* (the batch turn used for ref keys); the latter is captured
|
|
330
|
+
* before the try block when constructing `refKey`.
|
|
331
|
+
*/
|
|
332
|
+
const usageCount = this.toolUsageCount.get(call.name) ?? 0;
|
|
333
|
+
this.toolUsageCount.set(call.name, usageCount + 1);
|
|
186
334
|
if (call.id != null && call.id !== '') {
|
|
187
|
-
this.toolCallTurns.set(call.id,
|
|
335
|
+
this.toolCallTurns.set(call.id, usageCount);
|
|
336
|
+
}
|
|
337
|
+
let args = call.args;
|
|
338
|
+
if (registry != null) {
|
|
339
|
+
const { resolved, unresolved } = registry.resolve(runId, args);
|
|
340
|
+
args = resolved;
|
|
341
|
+
unresolvedRefs = unresolved;
|
|
342
|
+
/**
|
|
343
|
+
* Expose the post-substitution args to downstream completion
|
|
344
|
+
* events so audit logs / host-side `ON_RUN_STEP_COMPLETED`
|
|
345
|
+
* handlers observe what actually ran, not the `{{…}}`
|
|
346
|
+
* template. Only string/object args are worth recording.
|
|
347
|
+
*/
|
|
348
|
+
if (
|
|
349
|
+
resolvedArgsByCallId != null &&
|
|
350
|
+
call.id != null &&
|
|
351
|
+
call.id !== '' &&
|
|
352
|
+
resolved !== call.args &&
|
|
353
|
+
typeof resolved === 'object'
|
|
354
|
+
) {
|
|
355
|
+
resolvedArgsByCallId.set(
|
|
356
|
+
call.id,
|
|
357
|
+
resolved as Record<string, unknown>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
188
360
|
}
|
|
189
|
-
const args = call.args;
|
|
190
361
|
const stepId = this.toolCallStepIds?.get(call.id!);
|
|
191
362
|
|
|
192
363
|
// Build invoke params - LangChain extracts non-schema fields to config.toolCall
|
|
364
|
+
// `turn` here is the per-tool usage count (matches what tools have
|
|
365
|
+
// observed historically via config.toolCall.turn — e.g. web search).
|
|
193
366
|
let invokeParams: Record<string, unknown> = {
|
|
194
367
|
...call,
|
|
195
368
|
args,
|
|
196
369
|
type: 'tool_call',
|
|
197
370
|
stepId,
|
|
198
|
-
turn,
|
|
371
|
+
turn: usageCount,
|
|
199
372
|
};
|
|
200
373
|
|
|
201
374
|
// Inject runtime data for special tools (becomes available at config.toolCall)
|
|
@@ -247,24 +420,100 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
247
420
|
}
|
|
248
421
|
|
|
249
422
|
const output = await tool.invoke(invokeParams, config);
|
|
250
|
-
if (
|
|
251
|
-
(isBaseMessage(output) && output._getType() === 'tool') ||
|
|
252
|
-
isCommand(output)
|
|
253
|
-
) {
|
|
423
|
+
if (isCommand(output)) {
|
|
254
424
|
return output;
|
|
255
|
-
} else {
|
|
256
|
-
const rawContent =
|
|
257
|
-
typeof output === 'string' ? output : JSON.stringify(output);
|
|
258
|
-
return new ToolMessage({
|
|
259
|
-
status: 'success',
|
|
260
|
-
name: tool.name,
|
|
261
|
-
content: truncateToolResultContent(
|
|
262
|
-
rawContent,
|
|
263
|
-
this.maxToolResultChars
|
|
264
|
-
),
|
|
265
|
-
tool_call_id: call.id!,
|
|
266
|
-
});
|
|
267
425
|
}
|
|
426
|
+
if (isBaseMessage(output) && output._getType() === 'tool') {
|
|
427
|
+
const toolMsg = output as ToolMessage;
|
|
428
|
+
const isError = toolMsg.status === 'error';
|
|
429
|
+
if (isError) {
|
|
430
|
+
/**
|
|
431
|
+
* Error ToolMessages bypass registration but still stamp the
|
|
432
|
+
* unresolved-refs hint into `additional_kwargs` so the lazy
|
|
433
|
+
* annotation transform surfaces it to the LLM, letting the
|
|
434
|
+
* model self-correct when its reference key caused the
|
|
435
|
+
* failure. Persisted `content` stays clean.
|
|
436
|
+
*/
|
|
437
|
+
if (unresolvedRefs.length > 0) {
|
|
438
|
+
toolMsg.additional_kwargs = {
|
|
439
|
+
...toolMsg.additional_kwargs,
|
|
440
|
+
_unresolvedRefs: unresolvedRefs,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
return toolMsg;
|
|
444
|
+
}
|
|
445
|
+
if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
|
|
446
|
+
if (typeof toolMsg.content === 'string') {
|
|
447
|
+
const rawContent = toolMsg.content;
|
|
448
|
+
const llmContent = truncateToolResultContent(
|
|
449
|
+
rawContent,
|
|
450
|
+
this.maxToolResultChars
|
|
451
|
+
);
|
|
452
|
+
toolMsg.content = llmContent;
|
|
453
|
+
const refMeta = this.recordOutputReference(
|
|
454
|
+
runId,
|
|
455
|
+
rawContent,
|
|
456
|
+
refKey,
|
|
457
|
+
unresolvedRefs
|
|
458
|
+
);
|
|
459
|
+
if (refMeta != null) {
|
|
460
|
+
toolMsg.additional_kwargs = {
|
|
461
|
+
...toolMsg.additional_kwargs,
|
|
462
|
+
...refMeta,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
} else {
|
|
466
|
+
/**
|
|
467
|
+
* Non-string content (multi-part content blocks — text +
|
|
468
|
+
* image). Known limitation: we cannot register under a
|
|
469
|
+
* reference key because there's no canonical serialized
|
|
470
|
+
* form. Warn once per tool per run when the caller
|
|
471
|
+
* intended to register. The unresolved-refs hint is still
|
|
472
|
+
* stamped as metadata; the lazy transform prepends a text
|
|
473
|
+
* block at request time so the LLM gets the self-correction
|
|
474
|
+
* signal.
|
|
475
|
+
*/
|
|
476
|
+
if (unresolvedRefs.length > 0) {
|
|
477
|
+
toolMsg.additional_kwargs = {
|
|
478
|
+
...toolMsg.additional_kwargs,
|
|
479
|
+
_unresolvedRefs: unresolvedRefs,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (
|
|
483
|
+
refKey != null &&
|
|
484
|
+
this.toolOutputRegistry!.claimWarnOnce(runId, call.name)
|
|
485
|
+
) {
|
|
486
|
+
// eslint-disable-next-line no-console
|
|
487
|
+
console.warn(
|
|
488
|
+
`[ToolNode] Skipping tool output reference for "${call.name}": ` +
|
|
489
|
+
'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).'
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return toolMsg;
|
|
495
|
+
}
|
|
496
|
+
const rawContent =
|
|
497
|
+
typeof output === 'string' ? output : JSON.stringify(output);
|
|
498
|
+
const truncated = truncateToolResultContent(
|
|
499
|
+
rawContent,
|
|
500
|
+
this.maxToolResultChars
|
|
501
|
+
);
|
|
502
|
+
const refMeta = this.recordOutputReference(
|
|
503
|
+
runId,
|
|
504
|
+
rawContent,
|
|
505
|
+
refKey,
|
|
506
|
+
unresolvedRefs
|
|
507
|
+
);
|
|
508
|
+
return new ToolMessage({
|
|
509
|
+
status: 'success',
|
|
510
|
+
name: tool.name,
|
|
511
|
+
content: truncated,
|
|
512
|
+
tool_call_id: call.id!,
|
|
513
|
+
...(refMeta != null && {
|
|
514
|
+
additional_kwargs: refMeta as Record<string, unknown>,
|
|
515
|
+
}),
|
|
516
|
+
});
|
|
268
517
|
} catch (_e: unknown) {
|
|
269
518
|
const e = _e as Error;
|
|
270
519
|
if (!this.handleToolErrors) {
|
|
@@ -309,15 +558,75 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
309
558
|
});
|
|
310
559
|
}
|
|
311
560
|
}
|
|
561
|
+
const errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
|
|
562
|
+
const refMeta =
|
|
563
|
+
unresolvedRefs.length > 0
|
|
564
|
+
? this.recordOutputReference(
|
|
565
|
+
runId,
|
|
566
|
+
errorContent,
|
|
567
|
+
undefined,
|
|
568
|
+
unresolvedRefs
|
|
569
|
+
)
|
|
570
|
+
: undefined;
|
|
312
571
|
return new ToolMessage({
|
|
313
572
|
status: 'error',
|
|
314
|
-
content:
|
|
573
|
+
content: errorContent,
|
|
315
574
|
name: call.name,
|
|
316
575
|
tool_call_id: call.id ?? '',
|
|
576
|
+
...(refMeta != null && {
|
|
577
|
+
additional_kwargs: refMeta as Record<string, unknown>,
|
|
578
|
+
}),
|
|
317
579
|
});
|
|
318
580
|
}
|
|
319
581
|
}
|
|
320
582
|
|
|
583
|
+
/**
|
|
584
|
+
* Registers the full, raw output under `refKey` (when provided) and
|
|
585
|
+
* builds the per-message ref metadata stamped onto the resulting
|
|
586
|
+
* `ToolMessage.additional_kwargs`. The metadata is read at LLM-
|
|
587
|
+
* request time by `annotateMessagesForLLM` to produce a transient
|
|
588
|
+
* annotated copy of the message — the persisted `content` itself
|
|
589
|
+
* stays clean.
|
|
590
|
+
*
|
|
591
|
+
* @param registryContent The full, untruncated output to store in
|
|
592
|
+
* the registry so `{{tool<i>turn<n>}}` substitutions deliver the
|
|
593
|
+
* complete payload. Ignored when `refKey` is undefined.
|
|
594
|
+
* @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
|
|
595
|
+
* the output is not to be registered (errors, disabled feature,
|
|
596
|
+
* unavailable batch/turn).
|
|
597
|
+
* @param unresolved Placeholder keys that did not resolve; surfaced
|
|
598
|
+
* to the LLM lazily so it can self-correct.
|
|
599
|
+
* @returns A `ToolMessageRefMetadata` object when there is anything
|
|
600
|
+
* to stamp, otherwise `undefined`.
|
|
601
|
+
*/
|
|
602
|
+
private recordOutputReference(
|
|
603
|
+
runId: string | undefined,
|
|
604
|
+
registryContent: string,
|
|
605
|
+
refKey: string | undefined,
|
|
606
|
+
unresolved: string[]
|
|
607
|
+
): t.ToolMessageRefMetadata | undefined {
|
|
608
|
+
if (this.toolOutputRegistry != null && refKey != null) {
|
|
609
|
+
this.toolOutputRegistry.set(runId, refKey, registryContent);
|
|
610
|
+
}
|
|
611
|
+
if (refKey == null && unresolved.length === 0) return undefined;
|
|
612
|
+
const meta: t.ToolMessageRefMetadata = {};
|
|
613
|
+
if (refKey != null) {
|
|
614
|
+
meta._refKey = refKey;
|
|
615
|
+
/**
|
|
616
|
+
* Stamp the registry scope alongside the key so the lazy
|
|
617
|
+
* annotation transform can look up the right bucket. Anonymous
|
|
618
|
+
* invocations get a synthetic per-batch scope (`\0anon-<n>`)
|
|
619
|
+
* that `attemptInvoke` cannot derive from
|
|
620
|
+
* `config.configurable.run_id` — without this, anonymous-run
|
|
621
|
+
* refs would silently fail registry lookup and the LLM would
|
|
622
|
+
* never see `[ref: …]` markers for outputs that were registered.
|
|
623
|
+
*/
|
|
624
|
+
if (runId != null) meta._refScope = runId;
|
|
625
|
+
}
|
|
626
|
+
if (unresolved.length > 0) meta._unresolvedRefs = unresolved;
|
|
627
|
+
return meta;
|
|
628
|
+
}
|
|
629
|
+
|
|
321
630
|
/**
|
|
322
631
|
* Builds code session context for injection into event-driven tool calls.
|
|
323
632
|
* Mirrors the session injection logic in runTool() for direct execution.
|
|
@@ -393,11 +702,16 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
393
702
|
* By handling completions here in graph context (rather than in the
|
|
394
703
|
* stream consumer via ToolEndHandler), the race between the stream
|
|
395
704
|
* consumer and graph execution is eliminated.
|
|
705
|
+
*
|
|
706
|
+
* @param resolvedArgsByCallId Per-batch resolved-args sink populated
|
|
707
|
+
* by `runTool`. Threaded as a local map (instead of instance state)
|
|
708
|
+
* so concurrent batches cannot read each other's entries.
|
|
396
709
|
*/
|
|
397
710
|
private handleRunToolCompletions(
|
|
398
711
|
calls: ToolCall[],
|
|
399
712
|
outputs: (BaseMessage | Command)[],
|
|
400
|
-
config: RunnableConfig
|
|
713
|
+
config: RunnableConfig,
|
|
714
|
+
resolvedArgsByCallId?: ResolvedArgsByCallId
|
|
401
715
|
): void {
|
|
402
716
|
for (let i = 0; i < calls.length; i++) {
|
|
403
717
|
const call = calls[i];
|
|
@@ -437,11 +751,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
437
751
|
? toolMessage.content
|
|
438
752
|
: JSON.stringify(toolMessage.content);
|
|
439
753
|
|
|
754
|
+
/**
|
|
755
|
+
* Prefer the post-substitution args when a `{{…}}` placeholder
|
|
756
|
+
* was resolved in `runTool`. This keeps
|
|
757
|
+
* `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
|
|
758
|
+
* the tool actually received rather than leaking the template.
|
|
759
|
+
*/
|
|
760
|
+
const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
|
|
440
761
|
const tool_call: t.ProcessedToolCall = {
|
|
441
762
|
args:
|
|
442
|
-
typeof
|
|
443
|
-
? (
|
|
444
|
-
: JSON.stringify((
|
|
763
|
+
typeof effectiveArgs === 'string'
|
|
764
|
+
? (effectiveArgs as string)
|
|
765
|
+
: JSON.stringify((effectiveArgs as unknown) ?? {}),
|
|
445
766
|
name: call.name,
|
|
446
767
|
id: toolCallId,
|
|
447
768
|
output: contentString,
|
|
@@ -479,16 +800,64 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
479
800
|
*/
|
|
480
801
|
private async dispatchToolEvents(
|
|
481
802
|
toolCalls: ToolCall[],
|
|
482
|
-
config: RunnableConfig
|
|
803
|
+
config: RunnableConfig,
|
|
804
|
+
batchContext: DispatchBatchContext = {}
|
|
483
805
|
): Promise<{ toolMessages: ToolMessage[]; injected: BaseMessage[] }> {
|
|
806
|
+
const {
|
|
807
|
+
batchIndices,
|
|
808
|
+
turn,
|
|
809
|
+
batchScopeId,
|
|
810
|
+
preResolvedArgs,
|
|
811
|
+
preBatchSnapshot,
|
|
812
|
+
} = batchContext;
|
|
484
813
|
const runId = (config.configurable?.run_id as string | undefined) ?? '';
|
|
814
|
+
/**
|
|
815
|
+
* Registry-facing scope id — prefers the caller-threaded
|
|
816
|
+
* `batchScopeId` so anonymous batches target their own unique
|
|
817
|
+
* bucket and don't step on concurrent anonymous invocations.
|
|
818
|
+
* Hooks and event payloads keep using the empty-string coerced
|
|
819
|
+
* `runId` for backward compat.
|
|
820
|
+
*/
|
|
821
|
+
const registryRunId =
|
|
822
|
+
batchScopeId ?? (config.configurable?.run_id as string | undefined);
|
|
485
823
|
const threadId = config.configurable?.thread_id as string | undefined;
|
|
824
|
+
const registry = this.toolOutputRegistry;
|
|
825
|
+
const unresolvedByCallId = new Map<string, string[]>();
|
|
486
826
|
|
|
487
|
-
const preToolCalls = toolCalls.map((call) =>
|
|
488
|
-
call,
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
827
|
+
const preToolCalls = toolCalls.map((call, i) => {
|
|
828
|
+
const originalArgs = call.args as Record<string, unknown>;
|
|
829
|
+
let resolvedArgs = originalArgs;
|
|
830
|
+
/**
|
|
831
|
+
* When the caller provided a pre-resolved map (the mixed
|
|
832
|
+
* direct+event path snapshots event args synchronously before
|
|
833
|
+
* awaiting directs so they can't accidentally resolve
|
|
834
|
+
* same-turn direct outputs), use those entries verbatim instead
|
|
835
|
+
* of re-resolving against a registry that may have changed
|
|
836
|
+
* since the batch started.
|
|
837
|
+
*/
|
|
838
|
+
const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
|
|
839
|
+
if (pre != null) {
|
|
840
|
+
resolvedArgs = pre.resolved;
|
|
841
|
+
if (pre.unresolved.length > 0 && call.id != null) {
|
|
842
|
+
unresolvedByCallId.set(call.id, pre.unresolved);
|
|
843
|
+
}
|
|
844
|
+
} else if (registry != null) {
|
|
845
|
+
const { resolved, unresolved } = registry.resolve(
|
|
846
|
+
registryRunId,
|
|
847
|
+
originalArgs
|
|
848
|
+
);
|
|
849
|
+
resolvedArgs = resolved as Record<string, unknown>;
|
|
850
|
+
if (unresolved.length > 0 && call.id != null) {
|
|
851
|
+
unresolvedByCallId.set(call.id, unresolved);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return {
|
|
855
|
+
call,
|
|
856
|
+
stepId: this.toolCallStepIds?.get(call.id!) ?? '',
|
|
857
|
+
args: resolvedArgs,
|
|
858
|
+
batchIndex: batchIndices?.[i],
|
|
859
|
+
};
|
|
860
|
+
});
|
|
492
861
|
|
|
493
862
|
const messageByCallId = new Map<string, ToolMessage>();
|
|
494
863
|
const approvedEntries: typeof preToolCalls = [];
|
|
@@ -565,7 +934,40 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
565
934
|
continue;
|
|
566
935
|
}
|
|
567
936
|
if (hookResult.updatedInput != null) {
|
|
568
|
-
|
|
937
|
+
/**
|
|
938
|
+
* Re-resolve after PreToolUse replaces the input: a hook may
|
|
939
|
+
* introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
|
|
940
|
+
* copying user-supplied text) that the pre-hook pass never
|
|
941
|
+
* saw. Re-running the resolver on the hook-rewritten args
|
|
942
|
+
* keeps substitution and the unresolved-refs record in sync
|
|
943
|
+
* with what the tool will actually receive.
|
|
944
|
+
*/
|
|
945
|
+
if (registry != null) {
|
|
946
|
+
/**
|
|
947
|
+
* Mixed direct+event batches must use the pre-batch
|
|
948
|
+
* snapshot so a hook-introduced placeholder cannot
|
|
949
|
+
* accidentally resolve to a same-turn direct output that
|
|
950
|
+
* has just registered. Pure event batches don't have a
|
|
951
|
+
* snapshot and resolve against the live registry — safe
|
|
952
|
+
* because no event-side registrations have happened yet.
|
|
953
|
+
*/
|
|
954
|
+
const view: ToolOutputResolveView = preBatchSnapshot ?? {
|
|
955
|
+
resolve: <T>(args: T) => registry.resolve(registryRunId, args),
|
|
956
|
+
};
|
|
957
|
+
const { resolved, unresolved } = view.resolve(
|
|
958
|
+
hookResult.updatedInput
|
|
959
|
+
);
|
|
960
|
+
entry.args = resolved as Record<string, unknown>;
|
|
961
|
+
if (entry.call.id != null) {
|
|
962
|
+
if (unresolved.length > 0) {
|
|
963
|
+
unresolvedByCallId.set(entry.call.id, unresolved);
|
|
964
|
+
} else {
|
|
965
|
+
unresolvedByCallId.delete(entry.call.id);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
} else {
|
|
969
|
+
entry.args = hookResult.updatedInput;
|
|
970
|
+
}
|
|
569
971
|
}
|
|
570
972
|
approvedEntries.push(entry);
|
|
571
973
|
}
|
|
@@ -575,11 +977,17 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
575
977
|
|
|
576
978
|
const injected: BaseMessage[] = [];
|
|
577
979
|
|
|
980
|
+
const batchIndexByCallId = new Map<string, number>();
|
|
981
|
+
|
|
578
982
|
if (approvedEntries.length > 0) {
|
|
579
983
|
const requests: t.ToolCallRequest[] = approvedEntries.map((entry) => {
|
|
580
984
|
const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
|
|
581
985
|
this.toolUsageCount.set(entry.call.name, turn + 1);
|
|
582
986
|
|
|
987
|
+
if (entry.batchIndex != null && entry.call.id != null) {
|
|
988
|
+
batchIndexByCallId.set(entry.call.id, entry.batchIndex);
|
|
989
|
+
}
|
|
990
|
+
|
|
583
991
|
const request: t.ToolCallRequest = {
|
|
584
992
|
id: entry.call.id!,
|
|
585
993
|
name: entry.call.name,
|
|
@@ -651,11 +1059,31 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
651
1059
|
|
|
652
1060
|
if (result.status === 'error') {
|
|
653
1061
|
contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
|
|
1062
|
+
/**
|
|
1063
|
+
* Error results bypass registration but stamp the
|
|
1064
|
+
* unresolved-refs hint into `additional_kwargs` so the lazy
|
|
1065
|
+
* annotation transform surfaces it to the LLM at request
|
|
1066
|
+
* time, letting the model self-correct when its reference
|
|
1067
|
+
* key caused the failure. Persisted `content` stays clean.
|
|
1068
|
+
*/
|
|
1069
|
+
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
1070
|
+
const errorRefMeta =
|
|
1071
|
+
unresolved.length > 0
|
|
1072
|
+
? this.recordOutputReference(
|
|
1073
|
+
registryRunId,
|
|
1074
|
+
contentString,
|
|
1075
|
+
undefined,
|
|
1076
|
+
unresolved
|
|
1077
|
+
)
|
|
1078
|
+
: undefined;
|
|
654
1079
|
toolMessage = new ToolMessage({
|
|
655
1080
|
status: 'error',
|
|
656
1081
|
content: contentString,
|
|
657
1082
|
name: toolName,
|
|
658
1083
|
tool_call_id: result.toolCallId,
|
|
1084
|
+
...(errorRefMeta != null && {
|
|
1085
|
+
additional_kwargs: errorRefMeta as Record<string, unknown>,
|
|
1086
|
+
}),
|
|
659
1087
|
});
|
|
660
1088
|
|
|
661
1089
|
if (hasFailureHook) {
|
|
@@ -680,12 +1108,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
680
1108
|
});
|
|
681
1109
|
}
|
|
682
1110
|
} else {
|
|
683
|
-
|
|
1111
|
+
let registryRaw =
|
|
684
1112
|
typeof result.content === 'string'
|
|
685
1113
|
? result.content
|
|
686
1114
|
: JSON.stringify(result.content);
|
|
687
1115
|
contentString = truncateToolResultContent(
|
|
688
|
-
|
|
1116
|
+
registryRaw,
|
|
689
1117
|
this.maxToolResultChars
|
|
690
1118
|
);
|
|
691
1119
|
|
|
@@ -712,6 +1140,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
712
1140
|
typeof hookResult.updatedOutput === 'string'
|
|
713
1141
|
? hookResult.updatedOutput
|
|
714
1142
|
: JSON.stringify(hookResult.updatedOutput);
|
|
1143
|
+
registryRaw = replaced;
|
|
715
1144
|
contentString = truncateToolResultContent(
|
|
716
1145
|
replaced,
|
|
717
1146
|
this.maxToolResultChars
|
|
@@ -719,12 +1148,30 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
719
1148
|
}
|
|
720
1149
|
}
|
|
721
1150
|
|
|
1151
|
+
const batchIndex = batchIndexByCallId.get(result.toolCallId);
|
|
1152
|
+
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
1153
|
+
const refKey =
|
|
1154
|
+
this.toolOutputRegistry != null &&
|
|
1155
|
+
batchIndex != null &&
|
|
1156
|
+
turn != null
|
|
1157
|
+
? buildReferenceKey(batchIndex, turn)
|
|
1158
|
+
: undefined;
|
|
1159
|
+
const successRefMeta = this.recordOutputReference(
|
|
1160
|
+
registryRunId,
|
|
1161
|
+
registryRaw,
|
|
1162
|
+
refKey,
|
|
1163
|
+
unresolved
|
|
1164
|
+
);
|
|
1165
|
+
|
|
722
1166
|
toolMessage = new ToolMessage({
|
|
723
1167
|
status: 'success',
|
|
724
1168
|
name: toolName,
|
|
725
1169
|
content: contentString,
|
|
726
1170
|
artifact: result.artifact,
|
|
727
1171
|
tool_call_id: result.toolCallId,
|
|
1172
|
+
...(successRefMeta != null && {
|
|
1173
|
+
additional_kwargs: successRefMeta as Record<string, unknown>,
|
|
1174
|
+
}),
|
|
728
1175
|
});
|
|
729
1176
|
}
|
|
730
1177
|
|
|
@@ -815,16 +1262,23 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
815
1262
|
* Injected messages are placed AFTER ToolMessages to respect provider
|
|
816
1263
|
* message ordering (AIMessage tool_calls must be immediately followed
|
|
817
1264
|
* by their ToolMessage results).
|
|
1265
|
+
*
|
|
1266
|
+
* `batchIndices` mirrors `toolCalls` and carries each call's position
|
|
1267
|
+
* within the parent batch. `turn` is the per-`run()` batch index
|
|
1268
|
+
* captured locally by the caller. Both are threaded so concurrent
|
|
1269
|
+
* invocations cannot race on shared mutable state.
|
|
818
1270
|
*/
|
|
819
1271
|
private async executeViaEvent(
|
|
820
1272
|
toolCalls: ToolCall[],
|
|
821
1273
|
config: RunnableConfig,
|
|
822
1274
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
823
|
-
input: any
|
|
1275
|
+
input: any,
|
|
1276
|
+
batchContext: DispatchBatchContext = {}
|
|
824
1277
|
): Promise<T> {
|
|
825
1278
|
const { toolMessages, injected } = await this.dispatchToolEvents(
|
|
826
1279
|
toolCalls,
|
|
827
|
-
config
|
|
1280
|
+
config,
|
|
1281
|
+
batchContext
|
|
828
1282
|
);
|
|
829
1283
|
const outputs: BaseMessage[] = [...toolMessages, ...injected];
|
|
830
1284
|
return (Array.isArray(input) ? outputs : { messages: outputs }) as T;
|
|
@@ -833,15 +1287,51 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
833
1287
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
834
1288
|
protected async run(input: any, config: RunnableConfig): Promise<T> {
|
|
835
1289
|
this.toolCallTurns.clear();
|
|
1290
|
+
/**
|
|
1291
|
+
* Per-batch local map for resolved (post-substitution) args.
|
|
1292
|
+
* Lives on the stack so concurrent `run()` calls on the same
|
|
1293
|
+
* ToolNode cannot read or wipe each other's entries.
|
|
1294
|
+
*/
|
|
1295
|
+
const resolvedArgsByCallId = new Map<string, Record<string, unknown>>();
|
|
1296
|
+
/**
|
|
1297
|
+
* Claim this batch's turn synchronously from the registry (or
|
|
1298
|
+
* fall back to 0 when the feature is disabled). The registry is
|
|
1299
|
+
* partitioned by scope id so overlapping batches cannot
|
|
1300
|
+
* overwrite each other's state even under a shared registry.
|
|
1301
|
+
*
|
|
1302
|
+
* For anonymous callers (no `run_id` in config), mint a unique
|
|
1303
|
+
* per-batch scope id so two concurrent anonymous invocations
|
|
1304
|
+
* don't target the same bucket. The scope is threaded down to
|
|
1305
|
+
* every subsequent registry call on this batch.
|
|
1306
|
+
*/
|
|
1307
|
+
const incomingRunId = config.configurable?.run_id as string | undefined;
|
|
1308
|
+
const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
|
|
1309
|
+
const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
|
|
836
1310
|
let outputs: (BaseMessage | Command)[];
|
|
837
1311
|
|
|
838
1312
|
if (this.isSendInput(input)) {
|
|
839
1313
|
const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
|
|
840
1314
|
if (this.eventDrivenMode && isDirectTool !== true) {
|
|
841
|
-
return this.executeViaEvent([input.lg_tool_call], config, input
|
|
1315
|
+
return this.executeViaEvent([input.lg_tool_call], config, input, {
|
|
1316
|
+
batchIndices: [0],
|
|
1317
|
+
turn,
|
|
1318
|
+
batchScopeId,
|
|
1319
|
+
});
|
|
842
1320
|
}
|
|
843
|
-
outputs = [
|
|
844
|
-
|
|
1321
|
+
outputs = [
|
|
1322
|
+
await this.runTool(input.lg_tool_call, config, {
|
|
1323
|
+
batchIndex: 0,
|
|
1324
|
+
turn,
|
|
1325
|
+
batchScopeId,
|
|
1326
|
+
resolvedArgsByCallId,
|
|
1327
|
+
}),
|
|
1328
|
+
];
|
|
1329
|
+
this.handleRunToolCompletions(
|
|
1330
|
+
[input.lg_tool_call],
|
|
1331
|
+
outputs,
|
|
1332
|
+
config,
|
|
1333
|
+
resolvedArgsByCallId
|
|
1334
|
+
);
|
|
845
1335
|
} else {
|
|
846
1336
|
let messages: BaseMessage[];
|
|
847
1337
|
if (Array.isArray(input)) {
|
|
@@ -900,31 +1390,105 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
900
1390
|
}) ?? [];
|
|
901
1391
|
|
|
902
1392
|
if (this.eventDrivenMode && filteredCalls.length > 0) {
|
|
1393
|
+
const filteredIndices = filteredCalls.map((_, idx) => idx);
|
|
1394
|
+
|
|
903
1395
|
if (!this.directToolNames || this.directToolNames.size === 0) {
|
|
904
|
-
return this.executeViaEvent(filteredCalls, config, input
|
|
1396
|
+
return this.executeViaEvent(filteredCalls, config, input, {
|
|
1397
|
+
batchIndices: filteredIndices,
|
|
1398
|
+
turn,
|
|
1399
|
+
batchScopeId,
|
|
1400
|
+
});
|
|
905
1401
|
}
|
|
906
1402
|
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
)
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1403
|
+
const directEntries: Array<{ call: ToolCall; batchIndex: number }> = [];
|
|
1404
|
+
const eventEntries: Array<{ call: ToolCall; batchIndex: number }> = [];
|
|
1405
|
+
for (let i = 0; i < filteredCalls.length; i++) {
|
|
1406
|
+
const call = filteredCalls[i];
|
|
1407
|
+
const entry = { call, batchIndex: i };
|
|
1408
|
+
if (this.directToolNames!.has(call.name)) {
|
|
1409
|
+
directEntries.push(entry);
|
|
1410
|
+
} else {
|
|
1411
|
+
eventEntries.push(entry);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const directCalls = directEntries.map((e) => e.call);
|
|
1416
|
+
const directIndices = directEntries.map((e) => e.batchIndex);
|
|
1417
|
+
const eventCalls = eventEntries.map((e) => e.call);
|
|
1418
|
+
const eventIndices = eventEntries.map((e) => e.batchIndex);
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Snapshot the event calls' args against the *pre-batch*
|
|
1422
|
+
* registry state synchronously, before any await runs. The
|
|
1423
|
+
* directs are then awaited first (preserving fail-fast
|
|
1424
|
+
* semantics — a thrown error in a direct tool, e.g. with
|
|
1425
|
+
* `handleToolErrors=false` or a `GraphInterrupt`, aborts
|
|
1426
|
+
* before we dispatch any event-driven tools to the host).
|
|
1427
|
+
* Because the event args were captured pre-await, they stay
|
|
1428
|
+
* isolated from same-turn direct outputs that register
|
|
1429
|
+
* during the await.
|
|
1430
|
+
*/
|
|
1431
|
+
const preResolvedEventArgs = new Map<
|
|
1432
|
+
string,
|
|
1433
|
+
{ resolved: Record<string, unknown>; unresolved: string[] }
|
|
1434
|
+
>();
|
|
1435
|
+
/**
|
|
1436
|
+
* Take a frozen snapshot of the registry state before any
|
|
1437
|
+
* direct registrations land. The snapshot resolves
|
|
1438
|
+
* placeholders against this point-in-time view, so a
|
|
1439
|
+
* `PreToolUse` hook later rewriting event args via
|
|
1440
|
+
* `updatedInput` can introduce placeholders that resolve
|
|
1441
|
+
* cross-batch (against prior runs) without ever picking up
|
|
1442
|
+
* same-turn direct outputs.
|
|
1443
|
+
*/
|
|
1444
|
+
const preBatchSnapshot =
|
|
1445
|
+
this.toolOutputRegistry?.snapshot(batchScopeId);
|
|
1446
|
+
if (preBatchSnapshot != null) {
|
|
1447
|
+
for (const entry of eventEntries) {
|
|
1448
|
+
if (entry.call.id != null) {
|
|
1449
|
+
const { resolved, unresolved } = preBatchSnapshot.resolve(
|
|
1450
|
+
entry.call.args as Record<string, unknown>
|
|
1451
|
+
);
|
|
1452
|
+
preResolvedEventArgs.set(entry.call.id, {
|
|
1453
|
+
resolved: resolved as Record<string, unknown>,
|
|
1454
|
+
unresolved,
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
913
1459
|
|
|
914
1460
|
const directOutputs: (BaseMessage | Command)[] =
|
|
915
1461
|
directCalls.length > 0
|
|
916
1462
|
? await Promise.all(
|
|
917
|
-
directCalls.map((call) =>
|
|
1463
|
+
directCalls.map((call, i) =>
|
|
1464
|
+
this.runTool(call, config, {
|
|
1465
|
+
batchIndex: directIndices[i],
|
|
1466
|
+
turn,
|
|
1467
|
+
batchScopeId,
|
|
1468
|
+
resolvedArgsByCallId,
|
|
1469
|
+
})
|
|
1470
|
+
)
|
|
918
1471
|
)
|
|
919
1472
|
: [];
|
|
920
1473
|
|
|
921
1474
|
if (directCalls.length > 0 && directOutputs.length > 0) {
|
|
922
|
-
this.handleRunToolCompletions(
|
|
1475
|
+
this.handleRunToolCompletions(
|
|
1476
|
+
directCalls,
|
|
1477
|
+
directOutputs,
|
|
1478
|
+
config,
|
|
1479
|
+
resolvedArgsByCallId
|
|
1480
|
+
);
|
|
923
1481
|
}
|
|
924
1482
|
|
|
925
1483
|
const eventResult =
|
|
926
1484
|
eventCalls.length > 0
|
|
927
|
-
? await this.dispatchToolEvents(eventCalls, config
|
|
1485
|
+
? await this.dispatchToolEvents(eventCalls, config, {
|
|
1486
|
+
batchIndices: eventIndices,
|
|
1487
|
+
turn,
|
|
1488
|
+
batchScopeId,
|
|
1489
|
+
preResolvedArgs: preResolvedEventArgs,
|
|
1490
|
+
preBatchSnapshot,
|
|
1491
|
+
})
|
|
928
1492
|
: {
|
|
929
1493
|
toolMessages: [] as ToolMessage[],
|
|
930
1494
|
injected: [] as BaseMessage[],
|
|
@@ -937,9 +1501,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
937
1501
|
];
|
|
938
1502
|
} else {
|
|
939
1503
|
outputs = await Promise.all(
|
|
940
|
-
filteredCalls.map((call) =>
|
|
1504
|
+
filteredCalls.map((call, i) =>
|
|
1505
|
+
this.runTool(call, config, {
|
|
1506
|
+
batchIndex: i,
|
|
1507
|
+
turn,
|
|
1508
|
+
batchScopeId,
|
|
1509
|
+
resolvedArgsByCallId,
|
|
1510
|
+
})
|
|
1511
|
+
)
|
|
1512
|
+
);
|
|
1513
|
+
this.handleRunToolCompletions(
|
|
1514
|
+
filteredCalls,
|
|
1515
|
+
outputs,
|
|
1516
|
+
config,
|
|
1517
|
+
resolvedArgsByCallId
|
|
941
1518
|
);
|
|
942
|
-
this.handleRunToolCompletions(filteredCalls, outputs, config);
|
|
943
1519
|
}
|
|
944
1520
|
}
|
|
945
1521
|
|