@librechat/agents 3.1.71-dev.0 → 3.1.71

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.
Files changed (38) hide show
  1. package/dist/cjs/graphs/Graph.cjs +7 -0
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/llm/invoke.cjs +13 -2
  4. package/dist/cjs/llm/invoke.cjs.map +1 -1
  5. package/dist/cjs/tools/BashExecutor.cjs +3 -1
  6. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  7. package/dist/cjs/tools/ToolNode.cjs +84 -55
  8. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  9. package/dist/cjs/tools/toolOutputReferences.cjs +195 -0
  10. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  11. package/dist/esm/graphs/Graph.mjs +7 -0
  12. package/dist/esm/graphs/Graph.mjs.map +1 -1
  13. package/dist/esm/llm/invoke.mjs +13 -2
  14. package/dist/esm/llm/invoke.mjs.map +1 -1
  15. package/dist/esm/tools/BashExecutor.mjs +3 -1
  16. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  17. package/dist/esm/tools/ToolNode.mjs +85 -56
  18. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  19. package/dist/esm/tools/toolOutputReferences.mjs +195 -1
  20. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  21. package/dist/types/graphs/Graph.d.ts +9 -2
  22. package/dist/types/llm/invoke.d.ts +29 -3
  23. package/dist/types/tools/ToolNode.d.ts +11 -13
  24. package/dist/types/tools/toolOutputReferences.d.ts +31 -0
  25. package/dist/types/types/index.d.ts +1 -0
  26. package/dist/types/types/messages.d.ts +26 -0
  27. package/package.json +1 -1
  28. package/src/graphs/Graph.ts +8 -1
  29. package/src/llm/invoke.test.ts +446 -0
  30. package/src/llm/invoke.ts +45 -5
  31. package/src/tools/BashExecutor.ts +3 -1
  32. package/src/tools/ToolNode.ts +94 -81
  33. package/src/tools/__tests__/BashExecutor.test.ts +13 -0
  34. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +98 -55
  35. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +479 -0
  36. package/src/tools/toolOutputReferences.ts +235 -0
  37. package/src/types/index.ts +1 -0
  38. package/src/types/messages.ts +27 -0
@@ -0,0 +1,446 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
3
+ import {
4
+ AIMessage,
5
+ AIMessageChunk,
6
+ HumanMessage,
7
+ ToolMessage,
8
+ } from '@langchain/core/messages';
9
+ import { describe, it, expect, jest } from '@jest/globals';
10
+ import type { BaseMessage } from '@langchain/core/messages';
11
+ import type { StructuredToolInterface } from '@langchain/core/tools';
12
+ import type * as t from '@/types';
13
+ import { attemptInvoke, tryFallbackProviders } from '@/llm/invoke';
14
+ import { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
15
+ import { ToolNode } from '@/tools/ToolNode';
16
+ import { Providers } from '@/common';
17
+
18
+ /**
19
+ * Minimal stub model shape `attemptInvoke` reads. Either `invoke` or
20
+ * `stream` is populated depending on which path the test exercises;
21
+ * extending the real `BaseChatModel` would pull in too much surface.
22
+ */
23
+ type StubModel = {
24
+ invoke?: (messages: BaseMessage[], config?: unknown) => Promise<AIMessage>;
25
+ stream?: (
26
+ messages: BaseMessage[],
27
+ config?: unknown
28
+ ) => AsyncGenerator<AIMessageChunk>;
29
+ };
30
+
31
+ type CapturingModel = {
32
+ invokeMessages: BaseMessage[][];
33
+ model: StubModel;
34
+ };
35
+
36
+ type StreamingCapturingModel = {
37
+ streamMessages: BaseMessage[][];
38
+ model: StubModel;
39
+ };
40
+
41
+ function buildCapturingModel(): CapturingModel {
42
+ const invokeMessages: BaseMessage[][] = [];
43
+ const responseMsg = new AIMessage({ content: 'ok' });
44
+ const model: StubModel = {
45
+ invoke: jest.fn(async (messages: BaseMessage[]): Promise<AIMessage> => {
46
+ invokeMessages.push(messages);
47
+ return responseMsg;
48
+ }),
49
+ };
50
+ return { invokeMessages, model };
51
+ }
52
+
53
+ function buildStreamingCapturingModel(): StreamingCapturingModel {
54
+ const streamMessages: BaseMessage[][] = [];
55
+ const model: StubModel = {
56
+ stream: jest.fn(async function* (
57
+ messages: BaseMessage[]
58
+ ): AsyncGenerator<AIMessageChunk> {
59
+ streamMessages.push(messages);
60
+ yield new AIMessageChunk({ content: 'ok' });
61
+ }),
62
+ };
63
+ return { streamMessages, model };
64
+ }
65
+
66
+ describe('attemptInvoke applies lazy ref annotation', () => {
67
+ it('annotates ToolMessages with live _refKey before sending to provider (non-streaming)', async () => {
68
+ const registry = new ToolOutputReferenceRegistry();
69
+ registry.set('run-1', 'tool0turn0', 'stored');
70
+ const context = {
71
+ getOrCreateToolOutputRegistry: () => registry,
72
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
73
+
74
+ const messages: BaseMessage[] = [
75
+ new HumanMessage('hi'),
76
+ new ToolMessage({
77
+ name: 'echo',
78
+ tool_call_id: 'tc1',
79
+ status: 'success',
80
+ content: 'output',
81
+ additional_kwargs: { _refKey: 'tool0turn0' },
82
+ }),
83
+ ];
84
+
85
+ const { invokeMessages, model } = buildCapturingModel();
86
+
87
+ await attemptInvoke(
88
+ {
89
+ model: model as t.ChatModel,
90
+ messages,
91
+ provider: Providers.ANTHROPIC,
92
+ context,
93
+ },
94
+ { configurable: { run_id: 'run-1' } }
95
+ );
96
+
97
+ expect(invokeMessages).toHaveLength(1);
98
+ const sent = invokeMessages[0];
99
+ expect(sent[1].content).toBe('[ref: tool0turn0]\noutput');
100
+
101
+ const original = messages[1] as ToolMessage;
102
+ expect(original.content).toBe('output');
103
+ expect(original.additional_kwargs._refKey).toBe('tool0turn0');
104
+ expect(messages[1]).not.toBe(sent[1]);
105
+ });
106
+
107
+ it('annotates messages passed to model.stream (streaming path)', async () => {
108
+ const registry = new ToolOutputReferenceRegistry();
109
+ registry.set('run-2', 'tool0turn0', 'stored');
110
+ const context = {
111
+ getOrCreateToolOutputRegistry: () => registry,
112
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
113
+
114
+ const messages: BaseMessage[] = [
115
+ new ToolMessage({
116
+ name: 'echo',
117
+ tool_call_id: 'tc1',
118
+ status: 'success',
119
+ content: 'output',
120
+ additional_kwargs: { _refKey: 'tool0turn0' },
121
+ }),
122
+ ];
123
+
124
+ const { streamMessages, model } = buildStreamingCapturingModel();
125
+
126
+ await attemptInvoke(
127
+ {
128
+ model: model as t.ChatModel,
129
+ messages,
130
+ provider: Providers.ANTHROPIC,
131
+ context,
132
+ onChunk: () => {
133
+ /* swallow */
134
+ },
135
+ },
136
+ { configurable: { run_id: 'run-2' } }
137
+ );
138
+
139
+ expect(streamMessages).toHaveLength(1);
140
+ expect(streamMessages[0][0].content).toBe('[ref: tool0turn0]\noutput');
141
+ expect(messages[0].content).toBe('output');
142
+ });
143
+
144
+ it('passes messages unchanged when no registry is exposed on context (e.g. summarization)', async () => {
145
+ const messages: BaseMessage[] = [
146
+ new ToolMessage({
147
+ name: 'echo',
148
+ tool_call_id: 'tc1',
149
+ status: 'success',
150
+ content: 'output',
151
+ additional_kwargs: { _refKey: 'tool0turn0' },
152
+ }),
153
+ ];
154
+
155
+ const { invokeMessages, model } = buildCapturingModel();
156
+
157
+ await attemptInvoke({
158
+ model: model as t.ChatModel,
159
+ messages,
160
+ provider: Providers.ANTHROPIC,
161
+ });
162
+
163
+ expect(invokeMessages).toHaveLength(1);
164
+ expect(invokeMessages[0][0].content).toBe('output');
165
+ });
166
+
167
+ it('skips annotation for stale _refKey not present in current run registry (cross-run scenario)', async () => {
168
+ const registry = new ToolOutputReferenceRegistry();
169
+ // run-3 registry holds tool0turn0 - the current run's live ref
170
+ registry.set('run-3', 'tool0turn0', 'live-stored');
171
+
172
+ const context = {
173
+ getOrCreateToolOutputRegistry: () => registry,
174
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
175
+
176
+ const messages: BaseMessage[] = [
177
+ // Stale ToolMessage from a hydrated prior run - its _refKey points
178
+ // at a key that exists in registry, but conceptually different
179
+ // semantics. For this test, use a key that doesn't exist in the
180
+ // current registry to demonstrate the no-op behavior.
181
+ new ToolMessage({
182
+ name: 'echo',
183
+ tool_call_id: 'old',
184
+ status: 'success',
185
+ content: 'old-output',
186
+ additional_kwargs: { _refKey: 'tool5turn5' },
187
+ }),
188
+ new ToolMessage({
189
+ name: 'echo',
190
+ tool_call_id: 'new',
191
+ status: 'success',
192
+ content: 'new-output',
193
+ additional_kwargs: { _refKey: 'tool0turn0' },
194
+ }),
195
+ ];
196
+
197
+ const { invokeMessages, model } = buildCapturingModel();
198
+
199
+ await attemptInvoke(
200
+ {
201
+ model: model as t.ChatModel,
202
+ messages,
203
+ provider: Providers.ANTHROPIC,
204
+ context,
205
+ },
206
+ { configurable: { run_id: 'run-3' } }
207
+ );
208
+
209
+ const sent = invokeMessages[0];
210
+ expect(sent[0].content).toBe('old-output');
211
+ expect(sent[1].content).toBe('[ref: tool0turn0]\nnew-output');
212
+ });
213
+
214
+ it('applies unresolved-refs annotation regardless of registry presence', async () => {
215
+ const registry = new ToolOutputReferenceRegistry();
216
+ const context = {
217
+ getOrCreateToolOutputRegistry: () => registry,
218
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
219
+
220
+ const messages: BaseMessage[] = [
221
+ new ToolMessage({
222
+ name: 'echo',
223
+ tool_call_id: 'tc1',
224
+ status: 'error',
225
+ content: 'Error: bad ref',
226
+ additional_kwargs: { _unresolvedRefs: ['tool9turn9'] },
227
+ }),
228
+ ];
229
+
230
+ const { invokeMessages, model } = buildCapturingModel();
231
+
232
+ await attemptInvoke(
233
+ {
234
+ model: model as t.ChatModel,
235
+ messages,
236
+ provider: Providers.ANTHROPIC,
237
+ context,
238
+ },
239
+ { configurable: { run_id: 'run-err' } }
240
+ );
241
+
242
+ expect(invokeMessages[0][0].content).toBe(
243
+ 'Error: bad ref\n[unresolved refs: tool9turn9]'
244
+ );
245
+ });
246
+
247
+ it('annotates refs registered under an anonymous-batch scope (no run_id)', async () => {
248
+ /**
249
+ * Regression: anonymous ToolNode invocations register refs under
250
+ * a synthetic per-batch scope (`\0anon-<n>`) that
251
+ * `config.configurable.run_id` cannot recover. The transform must
252
+ * read the message-stamped `_refScope` rather than relying on the
253
+ * config-derived runId, otherwise the registry lookup misses and
254
+ * the LLM never sees the `[ref: …]` marker.
255
+ */
256
+ const registry = new ToolOutputReferenceRegistry();
257
+ const anonScope = '\0anon-0';
258
+ registry.set(anonScope, 'tool0turn0', 'stored');
259
+
260
+ const context = {
261
+ getOrCreateToolOutputRegistry: () => registry,
262
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
263
+
264
+ const messages: BaseMessage[] = [
265
+ new ToolMessage({
266
+ name: 'echo',
267
+ tool_call_id: 'tc1',
268
+ status: 'success',
269
+ content: 'output',
270
+ additional_kwargs: {
271
+ _refKey: 'tool0turn0',
272
+ _refScope: anonScope,
273
+ },
274
+ }),
275
+ ];
276
+
277
+ const { invokeMessages, model } = buildCapturingModel();
278
+
279
+ await attemptInvoke({
280
+ model: model as t.ChatModel,
281
+ messages,
282
+ provider: Providers.ANTHROPIC,
283
+ context,
284
+ });
285
+
286
+ expect(invokeMessages[0][0].content).toBe('[ref: tool0turn0]\noutput');
287
+ });
288
+ });
289
+
290
+ describe('tryFallbackProviders applies the same lazy annotation transform', () => {
291
+ it('threads context through to attemptInvoke so fallback messages are annotated', async () => {
292
+ const registry = new ToolOutputReferenceRegistry();
293
+ registry.set('run-fb', 'tool0turn0', 'stored');
294
+ const context = {
295
+ getOrCreateToolOutputRegistry: () => registry,
296
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
297
+
298
+ const messages: BaseMessage[] = [
299
+ new ToolMessage({
300
+ name: 'echo',
301
+ tool_call_id: 'tc1',
302
+ status: 'success',
303
+ content: 'output',
304
+ additional_kwargs: { _refKey: 'tool0turn0' },
305
+ }),
306
+ ];
307
+
308
+ const { invokeMessages, model } = buildCapturingModel();
309
+ /**
310
+ * Mock `initializeModel` indirectly by stubbing the LLM init via
311
+ * Jest's manual `mock` so the fallback path returns our capturing
312
+ * model. Skipping this here would require pulling in the real
313
+ * provider init chain (Anthropic, etc.) which the rest of this
314
+ * test layer does not bring in.
315
+ */
316
+ jest.doMock('@/llm/init', () => ({
317
+ initializeModel: (): unknown => model,
318
+ }));
319
+
320
+ // Reset the module so the doMock takes effect.
321
+ jest.resetModules();
322
+ const { tryFallbackProviders: freshTry } = (await import(
323
+ '@/llm/invoke'
324
+ )) as { tryFallbackProviders: typeof tryFallbackProviders };
325
+
326
+ await freshTry({
327
+ fallbacks: [{ provider: Providers.ANTHROPIC }],
328
+ messages,
329
+ primaryError: new Error('primary failed'),
330
+ context,
331
+ config: { configurable: { run_id: 'run-fb' } },
332
+ });
333
+
334
+ expect(invokeMessages.length).toBeGreaterThanOrEqual(1);
335
+ expect(invokeMessages[invokeMessages.length - 1][0].content).toBe(
336
+ '[ref: tool0turn0]\noutput'
337
+ );
338
+
339
+ jest.dontMock('@/llm/init');
340
+ jest.resetModules();
341
+ });
342
+ });
343
+
344
+ describe('cross-run hydration through ToolNode + attemptInvoke', () => {
345
+ it('annotates run 2 refs but leaves hydrated run 1 ToolMessages untouched', async () => {
346
+ /**
347
+ * Smoke test for the headline scenario: ToolMessages produced in
348
+ * run 1 are persisted with clean content + `_refKey`/`_refScope`
349
+ * metadata. When those messages are hydrated into run 2's state
350
+ * and run 2 produces its own tool output, the annotation transform
351
+ * must (a) annotate run 2's fresh tool message because its
352
+ * `_refScope` is live in run 2's registry, and (b) leave run 1's
353
+ * tool message clean because run 1's scope is not in run 2's
354
+ * registry. Same `tool0turn0` key collides across runs without any
355
+ * confusion.
356
+ */
357
+ const echo = tool(async (input) => (input as { command: string }).command, {
358
+ name: 'echo',
359
+ description: 'echoes its command back',
360
+ schema: z.object({ command: z.string() }),
361
+ }) as unknown as StructuredToolInterface;
362
+
363
+ /* Run 1 */
364
+ const run1Node = new ToolNode({
365
+ tools: [echo],
366
+ toolOutputReferences: { enabled: true },
367
+ });
368
+ const run1Result = (await run1Node.invoke(
369
+ {
370
+ messages: [
371
+ new AIMessage({
372
+ content: '',
373
+ tool_calls: [
374
+ { id: 'r1c1', name: 'echo', args: { command: 'run-1-output' } },
375
+ ],
376
+ }),
377
+ ],
378
+ },
379
+ { configurable: { run_id: 'run-1' } }
380
+ )) as { messages: ToolMessage[] };
381
+
382
+ const run1ToolMsg = run1Result.messages[0];
383
+ expect(run1ToolMsg.content).toBe('run-1-output');
384
+ expect(run1ToolMsg.additional_kwargs._refKey).toBe('tool0turn0');
385
+ expect(run1ToolMsg.additional_kwargs._refScope).toBe('run-1');
386
+
387
+ /* Run 2 - fresh ToolNode and registry, simulating a new session */
388
+ const run2Node = new ToolNode({
389
+ tools: [echo],
390
+ toolOutputReferences: { enabled: true },
391
+ });
392
+ const run2Result = (await run2Node.invoke(
393
+ {
394
+ messages: [
395
+ new AIMessage({
396
+ content: '',
397
+ tool_calls: [
398
+ { id: 'r2c1', name: 'echo', args: { command: 'run-2-output' } },
399
+ ],
400
+ }),
401
+ ],
402
+ },
403
+ { configurable: { run_id: 'run-2' } }
404
+ )) as { messages: ToolMessage[] };
405
+
406
+ const run2ToolMsg = run2Result.messages[0];
407
+ expect(run2ToolMsg.content).toBe('run-2-output');
408
+ expect(run2ToolMsg.additional_kwargs._refKey).toBe('tool0turn0');
409
+ expect(run2ToolMsg.additional_kwargs._refScope).toBe('run-2');
410
+
411
+ /* Hydrate run 1's message + run 2's message into a single state */
412
+ const hydrated: BaseMessage[] = [
413
+ new HumanMessage('first request'),
414
+ run1ToolMsg,
415
+ new HumanMessage('second request'),
416
+ run2ToolMsg,
417
+ ];
418
+
419
+ /* attemptInvoke with run 2's registry */
420
+ const context = {
421
+ getOrCreateToolOutputRegistry: () =>
422
+ run2Node._unsafeGetToolOutputRegistry(),
423
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
424
+
425
+ const { invokeMessages, model } = buildCapturingModel();
426
+ await attemptInvoke(
427
+ {
428
+ model: model as t.ChatModel,
429
+ messages: hydrated,
430
+ provider: Providers.ANTHROPIC,
431
+ context,
432
+ },
433
+ { configurable: { run_id: 'run-2' } }
434
+ );
435
+
436
+ const sent = invokeMessages[0];
437
+ /* Run 1's hydrated tool message stays clean — its scope is stale */
438
+ expect(sent[1].content).toBe('run-1-output');
439
+ /* Run 2's tool message gets annotated — its scope is live */
440
+ expect(sent[3].content).toBe('[ref: tool0turn0]\nrun-2-output');
441
+
442
+ /* Persisted state is unchanged */
443
+ expect(hydrated[1].content).toBe('run-1-output');
444
+ expect(hydrated[3].content).toBe('run-2-output');
445
+ });
446
+ });
package/src/llm/invoke.ts CHANGED
@@ -3,18 +3,47 @@ import { AIMessageChunk } from '@langchain/core/messages';
3
3
  import type { RunnableConfig } from '@langchain/core/runnables';
4
4
  import type { ToolCall } from '@langchain/core/messages/tool';
5
5
  import type { BaseMessage } from '@langchain/core/messages';
6
+ import type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
6
7
  import type * as t from '@/types';
7
8
  import { manualToolStreamProviders } from '@/llm/providers';
9
+ import { annotateMessagesForLLM } from '@/tools/toolOutputReferences';
8
10
  import { modifyDeltaProperties } from '@/messages';
9
11
  import { ChatModelStreamHandler } from '@/stream';
10
12
  import { GraphEvents, Providers } from '@/common';
11
13
  import { initializeModel } from '@/llm/init';
12
14
 
13
15
  /**
14
- * Context passed to `attemptInvoke` for the default stream handler.
15
- * Matches the subset of Graph that `ChatModelStreamHandler.handle` needs.
16
+ * Context passed to `attemptInvoke`. Matches the subset of Graph that
17
+ * `ChatModelStreamHandler.handle` needs *plus* the explicit
18
+ * `getOrCreateToolOutputRegistry()` accessor that `attemptInvoke`
19
+ * itself calls to pull the run-scoped tool-output registry off the
20
+ * graph and project each relevant ToolMessage into a transient
21
+ * annotated copy before the provider call.
22
+ *
23
+ * The intersection is intentional: `Parameters<...>[3]` resolves
24
+ * indirectly through the stream handler's signature (which returns
25
+ * `StandardGraph` and already exposes the accessor since #117), but
26
+ * stating it explicitly here surfaces the contract at the call site —
27
+ * a developer reading `attemptInvoke` doesn't have to chase the
28
+ * upstream handler's parameter list to discover that
29
+ * `context?.getOrCreateToolOutputRegistry()` is a real thing. Single
30
+ * optional chain only — the method itself is required on the
31
+ * `StandardGraph` branch of the intersection, so the second `?.` is
32
+ * unnecessary at the call site.
33
+ *
34
+ * `NonNullable<...>` strips `undefined` from the upstream parameter
35
+ * type so the intersection doesn't collapse to `never` on the
36
+ * undefined branch; callers express optionality via `context?:
37
+ * InvokeContext` on the function signature instead.
38
+ *
39
+ * Callers without a registry (e.g. summarization) simply pass no
40
+ * `context` and the transform safely no-ops.
16
41
  */
17
- export type InvokeContext = Parameters<ChatModelStreamHandler['handle']>[3];
42
+ export type InvokeContext = NonNullable<
43
+ Parameters<ChatModelStreamHandler['handle']>[3]
44
+ > & {
45
+ getOrCreateToolOutputRegistry?(): ToolOutputReferenceRegistry | undefined;
46
+ };
18
47
 
19
48
  /**
20
49
  * Per-chunk callback for custom stream processing.
@@ -47,8 +76,19 @@ export async function attemptInvoke(
47
76
  },
48
77
  config?: RunnableConfig
49
78
  ): Promise<Partial<t.BaseGraphState>> {
79
+ /**
80
+ * Pull the run-scoped tool output registry off the graph (when one
81
+ * exists) and project ToolMessages carrying ref metadata into a
82
+ * transient annotated copy. The original `messages` array stays
83
+ * untouched so the graph state never sees `[ref: …]` / `_ref`
84
+ * payload.
85
+ */
86
+ const registry = context?.getOrCreateToolOutputRegistry();
87
+ const runId = config?.configurable?.run_id as string | undefined;
88
+ const messagesForProvider = annotateMessagesForLLM(messages, registry, runId);
89
+
50
90
  if (model.stream) {
51
- const stream = await model.stream(messages, config);
91
+ const stream = await model.stream(messagesForProvider, config);
52
92
  let finalChunk: AIMessageChunk | undefined;
53
93
 
54
94
  if (onChunk) {
@@ -83,7 +123,7 @@ export async function attemptInvoke(
83
123
  return { messages: [finalChunk as AIMessageChunk] };
84
124
  }
85
125
 
86
- const finalMessage = await model.invoke(messages, config);
126
+ const finalMessage = await model.invoke(messagesForProvider, config);
87
127
  if ((finalMessage.tool_calls?.length ?? 0) > 0) {
88
128
  finalMessage.tool_calls = finalMessage.tool_calls?.filter(
89
129
  (tool_call: ToolCall) => !!tool_call.name
@@ -66,7 +66,9 @@ Referencing previous tool outputs:
66
66
  - Every successful tool result is tagged with a reference key of the form \`tool<idx>turn<turn>\` (e.g., \`tool0turn0\`). The key appears either as a \`[ref: tool0turn0]\` prefix line or, when the output is a JSON object, as a \`_ref\` field on the object.
67
67
  - To pipe a previous tool output into this tool, embed the placeholder \`{{tool<idx>turn<turn>}}\` literally anywhere in the \`command\` string (or any string arg). It will be substituted with the stored output verbatim before the command runs.
68
68
  - The substituted value is the original output string (no \`[ref: …]\` prefix, no \`_ref\` key), so it is safe to pipe directly into \`jq\`, \`grep\`, \`awk\`, etc.
69
- - Example: \`echo '{{tool0turn0}}' | jq '.foo'\` takes the full output of the first tool from the first turn and pipes it into jq.
69
+ - Example (simple ASCII output): \`echo '{{tool0turn0}}' | jq '.foo'\` takes the full output of the first tool from the first turn and pipes it into jq.
70
+ - For payloads that may contain quotes, parentheses, backticks, or arbitrary bytes (random/binary data, JSON with embedded quotes, multi-line strings), prefer a quoted-delimiter heredoc over \`echo '…'\`. The heredoc body is not interpreted by the shell, so substituted payloads pass through unchanged.
71
+ - Heredoc example: \`wc -c << 'EOF'\\n{{tool0turn0}}\\nEOF\` (the quotes around \`'EOF'\` disable interpolation inside the body).
70
72
  - Unknown reference keys are left in place and surfaced as \`[unresolved refs: …]\` after the output.
71
73
  `.trim();
72
74