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