@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.
Files changed (66) hide show
  1. package/dist/cjs/graphs/Graph.cjs +52 -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/main.cjs +4 -0
  6. package/dist/cjs/main.cjs.map +1 -1
  7. package/dist/cjs/messages/prune.cjs +9 -2
  8. package/dist/cjs/messages/prune.cjs.map +1 -1
  9. package/dist/cjs/run.cjs +4 -0
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/tools/BashExecutor.cjs +43 -0
  12. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  13. package/dist/cjs/tools/ToolNode.cjs +482 -45
  14. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  15. package/dist/cjs/tools/toolOutputReferences.cjs +657 -0
  16. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -0
  17. package/dist/cjs/utils/truncation.cjs +28 -0
  18. package/dist/cjs/utils/truncation.cjs.map +1 -1
  19. package/dist/esm/graphs/Graph.mjs +52 -0
  20. package/dist/esm/graphs/Graph.mjs.map +1 -1
  21. package/dist/esm/llm/invoke.mjs +13 -2
  22. package/dist/esm/llm/invoke.mjs.map +1 -1
  23. package/dist/esm/main.mjs +2 -2
  24. package/dist/esm/messages/prune.mjs +9 -2
  25. package/dist/esm/messages/prune.mjs.map +1 -1
  26. package/dist/esm/run.mjs +4 -0
  27. package/dist/esm/run.mjs.map +1 -1
  28. package/dist/esm/tools/BashExecutor.mjs +42 -1
  29. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  30. package/dist/esm/tools/ToolNode.mjs +482 -45
  31. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  32. package/dist/esm/tools/toolOutputReferences.mjs +649 -0
  33. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -0
  34. package/dist/esm/utils/truncation.mjs +27 -1
  35. package/dist/esm/utils/truncation.mjs.map +1 -1
  36. package/dist/types/graphs/Graph.d.ts +28 -0
  37. package/dist/types/llm/invoke.d.ts +9 -0
  38. package/dist/types/run.d.ts +1 -0
  39. package/dist/types/tools/BashExecutor.d.ts +31 -0
  40. package/dist/types/tools/ToolNode.d.ts +84 -3
  41. package/dist/types/tools/toolOutputReferences.d.ts +236 -0
  42. package/dist/types/types/index.d.ts +1 -0
  43. package/dist/types/types/messages.d.ts +26 -0
  44. package/dist/types/types/run.d.ts +9 -1
  45. package/dist/types/types/tools.d.ts +70 -0
  46. package/dist/types/utils/truncation.d.ts +21 -0
  47. package/package.json +1 -1
  48. package/src/graphs/Graph.ts +55 -0
  49. package/src/llm/invoke.test.ts +442 -0
  50. package/src/llm/invoke.ts +23 -2
  51. package/src/messages/prune.ts +9 -2
  52. package/src/run.ts +4 -0
  53. package/src/specs/prune.test.ts +413 -0
  54. package/src/tools/BashExecutor.ts +45 -0
  55. package/src/tools/ToolNode.ts +631 -55
  56. package/src/tools/__tests__/BashExecutor.test.ts +36 -0
  57. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +1438 -0
  58. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +419 -0
  59. package/src/tools/__tests__/toolOutputReferences.test.ts +415 -0
  60. package/src/tools/toolOutputReferences.ts +813 -0
  61. package/src/types/index.ts +1 -0
  62. package/src/types/messages.ts +27 -0
  63. package/src/types/run.ts +9 -1
  64. package/src/types/tools.ts +71 -0
  65. package/src/utils/__tests__/truncation.test.ts +66 -0
  66. package/src/utils/truncation.ts +30 -0
@@ -0,0 +1,442 @@
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
+ function buildCapturingModel(): {
32
+ invokeMessages: BaseMessage[][];
33
+ model: StubModel;
34
+ } {
35
+ const invokeMessages: BaseMessage[][] = [];
36
+ const responseMsg = new AIMessage({ content: 'ok' });
37
+ const model: StubModel = {
38
+ invoke: jest.fn(async (messages: BaseMessage[]): Promise<AIMessage> => {
39
+ invokeMessages.push(messages);
40
+ return responseMsg;
41
+ }),
42
+ };
43
+ return { invokeMessages, model };
44
+ }
45
+
46
+ function buildStreamingCapturingModel(): {
47
+ streamMessages: BaseMessage[][];
48
+ model: StubModel;
49
+ } {
50
+ const streamMessages: BaseMessage[][] = [];
51
+ const model: StubModel = {
52
+ stream: jest.fn(async function* (
53
+ messages: BaseMessage[]
54
+ ): AsyncGenerator<AIMessageChunk> {
55
+ streamMessages.push(messages);
56
+ yield new AIMessageChunk({ content: 'ok' });
57
+ }),
58
+ };
59
+ return { streamMessages, model };
60
+ }
61
+
62
+ describe('attemptInvoke applies lazy ref annotation', () => {
63
+ it('annotates ToolMessages with live _refKey before sending to provider (non-streaming)', async () => {
64
+ const registry = new ToolOutputReferenceRegistry();
65
+ registry.set('run-1', 'tool0turn0', 'stored');
66
+ const context = {
67
+ getOrCreateToolOutputRegistry: () => registry,
68
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
69
+
70
+ const messages: BaseMessage[] = [
71
+ new HumanMessage('hi'),
72
+ new ToolMessage({
73
+ name: 'echo',
74
+ tool_call_id: 'tc1',
75
+ status: 'success',
76
+ content: 'output',
77
+ additional_kwargs: { _refKey: 'tool0turn0' },
78
+ }),
79
+ ];
80
+
81
+ const { invokeMessages, model } = buildCapturingModel();
82
+
83
+ await attemptInvoke(
84
+ {
85
+ model: model as t.ChatModel,
86
+ messages,
87
+ provider: Providers.ANTHROPIC,
88
+ context,
89
+ },
90
+ { configurable: { run_id: 'run-1' } }
91
+ );
92
+
93
+ expect(invokeMessages).toHaveLength(1);
94
+ const sent = invokeMessages[0];
95
+ expect(sent[1].content).toBe('[ref: tool0turn0]\noutput');
96
+
97
+ const original = messages[1] as ToolMessage;
98
+ expect(original.content).toBe('output');
99
+ expect(original.additional_kwargs._refKey).toBe('tool0turn0');
100
+ expect(messages[1]).not.toBe(sent[1]);
101
+ });
102
+
103
+ it('annotates messages passed to model.stream (streaming path)', async () => {
104
+ const registry = new ToolOutputReferenceRegistry();
105
+ registry.set('run-2', 'tool0turn0', 'stored');
106
+ const context = {
107
+ getOrCreateToolOutputRegistry: () => registry,
108
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
109
+
110
+ const messages: BaseMessage[] = [
111
+ new ToolMessage({
112
+ name: 'echo',
113
+ tool_call_id: 'tc1',
114
+ status: 'success',
115
+ content: 'output',
116
+ additional_kwargs: { _refKey: 'tool0turn0' },
117
+ }),
118
+ ];
119
+
120
+ const { streamMessages, model } = buildStreamingCapturingModel();
121
+
122
+ await attemptInvoke(
123
+ {
124
+ model: model as t.ChatModel,
125
+ messages,
126
+ provider: Providers.ANTHROPIC,
127
+ context,
128
+ onChunk: () => {
129
+ /* swallow */
130
+ },
131
+ },
132
+ { configurable: { run_id: 'run-2' } }
133
+ );
134
+
135
+ expect(streamMessages).toHaveLength(1);
136
+ expect(streamMessages[0][0].content).toBe('[ref: tool0turn0]\noutput');
137
+ expect(messages[0].content).toBe('output');
138
+ });
139
+
140
+ it('passes messages unchanged when no registry is exposed on context (e.g. summarization)', async () => {
141
+ const messages: BaseMessage[] = [
142
+ new ToolMessage({
143
+ name: 'echo',
144
+ tool_call_id: 'tc1',
145
+ status: 'success',
146
+ content: 'output',
147
+ additional_kwargs: { _refKey: 'tool0turn0' },
148
+ }),
149
+ ];
150
+
151
+ const { invokeMessages, model } = buildCapturingModel();
152
+
153
+ await attemptInvoke({
154
+ model: model as t.ChatModel,
155
+ messages,
156
+ provider: Providers.ANTHROPIC,
157
+ });
158
+
159
+ expect(invokeMessages).toHaveLength(1);
160
+ expect(invokeMessages[0][0].content).toBe('output');
161
+ });
162
+
163
+ it('skips annotation for stale _refKey not present in current run registry (cross-run scenario)', async () => {
164
+ const registry = new ToolOutputReferenceRegistry();
165
+ // run-3 registry holds tool0turn0 - the current run's live ref
166
+ registry.set('run-3', 'tool0turn0', 'live-stored');
167
+
168
+ const context = {
169
+ getOrCreateToolOutputRegistry: () => registry,
170
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
171
+
172
+ const messages: BaseMessage[] = [
173
+ // Stale ToolMessage from a hydrated prior run - its _refKey points
174
+ // at a key that exists in registry, but conceptually different
175
+ // semantics. For this test, use a key that doesn't exist in the
176
+ // current registry to demonstrate the no-op behavior.
177
+ new ToolMessage({
178
+ name: 'echo',
179
+ tool_call_id: 'old',
180
+ status: 'success',
181
+ content: 'old-output',
182
+ additional_kwargs: { _refKey: 'tool5turn5' },
183
+ }),
184
+ new ToolMessage({
185
+ name: 'echo',
186
+ tool_call_id: 'new',
187
+ status: 'success',
188
+ content: 'new-output',
189
+ additional_kwargs: { _refKey: 'tool0turn0' },
190
+ }),
191
+ ];
192
+
193
+ const { invokeMessages, model } = buildCapturingModel();
194
+
195
+ await attemptInvoke(
196
+ {
197
+ model: model as t.ChatModel,
198
+ messages,
199
+ provider: Providers.ANTHROPIC,
200
+ context,
201
+ },
202
+ { configurable: { run_id: 'run-3' } }
203
+ );
204
+
205
+ const sent = invokeMessages[0];
206
+ expect(sent[0].content).toBe('old-output');
207
+ expect(sent[1].content).toBe('[ref: tool0turn0]\nnew-output');
208
+ });
209
+
210
+ it('applies unresolved-refs annotation regardless of registry presence', async () => {
211
+ const registry = new ToolOutputReferenceRegistry();
212
+ const context = {
213
+ getOrCreateToolOutputRegistry: () => registry,
214
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
215
+
216
+ const messages: BaseMessage[] = [
217
+ new ToolMessage({
218
+ name: 'echo',
219
+ tool_call_id: 'tc1',
220
+ status: 'error',
221
+ content: 'Error: bad ref',
222
+ additional_kwargs: { _unresolvedRefs: ['tool9turn9'] },
223
+ }),
224
+ ];
225
+
226
+ const { invokeMessages, model } = buildCapturingModel();
227
+
228
+ await attemptInvoke(
229
+ {
230
+ model: model as t.ChatModel,
231
+ messages,
232
+ provider: Providers.ANTHROPIC,
233
+ context,
234
+ },
235
+ { configurable: { run_id: 'run-err' } }
236
+ );
237
+
238
+ expect(invokeMessages[0][0].content).toBe(
239
+ 'Error: bad ref\n[unresolved refs: tool9turn9]'
240
+ );
241
+ });
242
+
243
+ it('annotates refs registered under an anonymous-batch scope (no run_id)', async () => {
244
+ /**
245
+ * Regression: anonymous ToolNode invocations register refs under
246
+ * a synthetic per-batch scope (`\0anon-<n>`) that
247
+ * `config.configurable.run_id` cannot recover. The transform must
248
+ * read the message-stamped `_refScope` rather than relying on the
249
+ * config-derived runId, otherwise the registry lookup misses and
250
+ * the LLM never sees the `[ref: …]` marker.
251
+ */
252
+ const registry = new ToolOutputReferenceRegistry();
253
+ const anonScope = '\0anon-0';
254
+ registry.set(anonScope, 'tool0turn0', 'stored');
255
+
256
+ const context = {
257
+ getOrCreateToolOutputRegistry: () => registry,
258
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
259
+
260
+ const messages: BaseMessage[] = [
261
+ new ToolMessage({
262
+ name: 'echo',
263
+ tool_call_id: 'tc1',
264
+ status: 'success',
265
+ content: 'output',
266
+ additional_kwargs: {
267
+ _refKey: 'tool0turn0',
268
+ _refScope: anonScope,
269
+ },
270
+ }),
271
+ ];
272
+
273
+ const { invokeMessages, model } = buildCapturingModel();
274
+
275
+ await attemptInvoke({
276
+ model: model as t.ChatModel,
277
+ messages,
278
+ provider: Providers.ANTHROPIC,
279
+ context,
280
+ });
281
+
282
+ expect(invokeMessages[0][0].content).toBe('[ref: tool0turn0]\noutput');
283
+ });
284
+ });
285
+
286
+ describe('tryFallbackProviders applies the same lazy annotation transform', () => {
287
+ it('threads context through to attemptInvoke so fallback messages are annotated', async () => {
288
+ const registry = new ToolOutputReferenceRegistry();
289
+ registry.set('run-fb', 'tool0turn0', 'stored');
290
+ const context = {
291
+ getOrCreateToolOutputRegistry: () => registry,
292
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
293
+
294
+ const messages: BaseMessage[] = [
295
+ new ToolMessage({
296
+ name: 'echo',
297
+ tool_call_id: 'tc1',
298
+ status: 'success',
299
+ content: 'output',
300
+ additional_kwargs: { _refKey: 'tool0turn0' },
301
+ }),
302
+ ];
303
+
304
+ const { invokeMessages, model } = buildCapturingModel();
305
+ /**
306
+ * Mock `initializeModel` indirectly by stubbing the LLM init via
307
+ * Jest's manual `mock` so the fallback path returns our capturing
308
+ * model. Skipping this here would require pulling in the real
309
+ * provider init chain (Anthropic, etc.) which the rest of this
310
+ * test layer does not bring in.
311
+ */
312
+ jest.doMock('@/llm/init', () => ({
313
+ initializeModel: (): unknown => model,
314
+ }));
315
+
316
+ // Reset the module so the doMock takes effect.
317
+ jest.resetModules();
318
+ const { tryFallbackProviders: freshTry } = (await import(
319
+ '@/llm/invoke'
320
+ )) as { tryFallbackProviders: typeof tryFallbackProviders };
321
+
322
+ await freshTry({
323
+ fallbacks: [{ provider: Providers.ANTHROPIC }],
324
+ messages,
325
+ primaryError: new Error('primary failed'),
326
+ context,
327
+ config: { configurable: { run_id: 'run-fb' } },
328
+ });
329
+
330
+ expect(invokeMessages.length).toBeGreaterThanOrEqual(1);
331
+ expect(invokeMessages[invokeMessages.length - 1][0].content).toBe(
332
+ '[ref: tool0turn0]\noutput'
333
+ );
334
+
335
+ jest.dontMock('@/llm/init');
336
+ jest.resetModules();
337
+ });
338
+ });
339
+
340
+ describe('cross-run hydration through ToolNode + attemptInvoke', () => {
341
+ it('annotates run 2 refs but leaves hydrated run 1 ToolMessages untouched', async () => {
342
+ /**
343
+ * Smoke test for the headline scenario: ToolMessages produced in
344
+ * run 1 are persisted with clean content + `_refKey`/`_refScope`
345
+ * metadata. When those messages are hydrated into run 2's state
346
+ * and run 2 produces its own tool output, the annotation transform
347
+ * must (a) annotate run 2's fresh tool message because its
348
+ * `_refScope` is live in run 2's registry, and (b) leave run 1's
349
+ * tool message clean because run 1's scope is not in run 2's
350
+ * registry. Same `tool0turn0` key collides across runs without any
351
+ * confusion.
352
+ */
353
+ const echo = tool(async (input) => (input as { command: string }).command, {
354
+ name: 'echo',
355
+ description: 'echoes its command back',
356
+ schema: z.object({ command: z.string() }),
357
+ }) as unknown as StructuredToolInterface;
358
+
359
+ /* Run 1 */
360
+ const run1Node = new ToolNode({
361
+ tools: [echo],
362
+ toolOutputReferences: { enabled: true },
363
+ });
364
+ const run1Result = (await run1Node.invoke(
365
+ {
366
+ messages: [
367
+ new AIMessage({
368
+ content: '',
369
+ tool_calls: [
370
+ { id: 'r1c1', name: 'echo', args: { command: 'run-1-output' } },
371
+ ],
372
+ }),
373
+ ],
374
+ },
375
+ { configurable: { run_id: 'run-1' } }
376
+ )) as { messages: ToolMessage[] };
377
+
378
+ const run1ToolMsg = run1Result.messages[0];
379
+ expect(run1ToolMsg.content).toBe('run-1-output');
380
+ expect(run1ToolMsg.additional_kwargs._refKey).toBe('tool0turn0');
381
+ expect(run1ToolMsg.additional_kwargs._refScope).toBe('run-1');
382
+
383
+ /* Run 2 - fresh ToolNode and registry, simulating a new session */
384
+ const run2Node = new ToolNode({
385
+ tools: [echo],
386
+ toolOutputReferences: { enabled: true },
387
+ });
388
+ const run2Result = (await run2Node.invoke(
389
+ {
390
+ messages: [
391
+ new AIMessage({
392
+ content: '',
393
+ tool_calls: [
394
+ { id: 'r2c1', name: 'echo', args: { command: 'run-2-output' } },
395
+ ],
396
+ }),
397
+ ],
398
+ },
399
+ { configurable: { run_id: 'run-2' } }
400
+ )) as { messages: ToolMessage[] };
401
+
402
+ const run2ToolMsg = run2Result.messages[0];
403
+ expect(run2ToolMsg.content).toBe('run-2-output');
404
+ expect(run2ToolMsg.additional_kwargs._refKey).toBe('tool0turn0');
405
+ expect(run2ToolMsg.additional_kwargs._refScope).toBe('run-2');
406
+
407
+ /* Hydrate run 1's message + run 2's message into a single state */
408
+ const hydrated: BaseMessage[] = [
409
+ new HumanMessage('first request'),
410
+ run1ToolMsg,
411
+ new HumanMessage('second request'),
412
+ run2ToolMsg,
413
+ ];
414
+
415
+ /* attemptInvoke with run 2's registry */
416
+ const context = {
417
+ getOrCreateToolOutputRegistry: () =>
418
+ run2Node._unsafeGetToolOutputRegistry(),
419
+ } as unknown as Parameters<typeof attemptInvoke>[0]['context'];
420
+
421
+ const { invokeMessages, model } = buildCapturingModel();
422
+ await attemptInvoke(
423
+ {
424
+ model: model as t.ChatModel,
425
+ messages: hydrated,
426
+ provider: Providers.ANTHROPIC,
427
+ context,
428
+ },
429
+ { configurable: { run_id: 'run-2' } }
430
+ );
431
+
432
+ const sent = invokeMessages[0];
433
+ /* Run 1's hydrated tool message stays clean — its scope is stale */
434
+ expect(sent[1].content).toBe('run-1-output');
435
+ /* Run 2's tool message gets annotated — its scope is live */
436
+ expect(sent[3].content).toBe('[ref: tool0turn0]\nrun-2-output');
437
+
438
+ /* Persisted state is unchanged */
439
+ expect(hydrated[1].content).toBe('run-1-output');
440
+ expect(hydrated[3].content).toBe('run-2-output');
441
+ });
442
+ });
package/src/llm/invoke.ts CHANGED
@@ -5,6 +5,7 @@ import type { ToolCall } from '@langchain/core/messages/tool';
5
5
  import type { BaseMessage } from '@langchain/core/messages';
6
6
  import type * as t from '@/types';
7
7
  import { manualToolStreamProviders } from '@/llm/providers';
8
+ import { annotateMessagesForLLM } from '@/tools/toolOutputReferences';
8
9
  import { modifyDeltaProperties } from '@/messages';
9
10
  import { ChatModelStreamHandler } from '@/stream';
10
11
  import { GraphEvents, Providers } from '@/common';
@@ -13,6 +14,15 @@ import { initializeModel } from '@/llm/init';
13
14
  /**
14
15
  * Context passed to `attemptInvoke` for the default stream handler.
15
16
  * Matches the subset of Graph that `ChatModelStreamHandler.handle` needs.
17
+ *
18
+ * `attemptInvoke` additionally calls `context?.getOrCreateToolOutputRegistry?.()`
19
+ * (if defined) to pull the run-scoped tool output registry off the graph
20
+ * so it can project each relevant `ToolMessage` into a transient
21
+ * annotated copy right before sending to the provider — annotations
22
+ * never persist back into graph state.
23
+ *
24
+ * Callers without a registry (e.g. summarization) simply pass no
25
+ * `context` and the transform safely no-ops.
16
26
  */
17
27
  export type InvokeContext = Parameters<ChatModelStreamHandler['handle']>[3];
18
28
 
@@ -47,8 +57,19 @@ export async function attemptInvoke(
47
57
  },
48
58
  config?: RunnableConfig
49
59
  ): Promise<Partial<t.BaseGraphState>> {
60
+ /**
61
+ * Pull the run-scoped tool output registry off the graph (when one
62
+ * exists) and project ToolMessages carrying ref metadata into a
63
+ * transient annotated copy. The original `messages` array stays
64
+ * untouched so the graph state never sees `[ref: …]` / `_ref`
65
+ * payload.
66
+ */
67
+ const registry = context?.getOrCreateToolOutputRegistry();
68
+ const runId = config?.configurable?.run_id as string | undefined;
69
+ const messagesForProvider = annotateMessagesForLLM(messages, registry, runId);
70
+
50
71
  if (model.stream) {
51
- const stream = await model.stream(messages, config);
72
+ const stream = await model.stream(messagesForProvider, config);
52
73
  let finalChunk: AIMessageChunk | undefined;
53
74
 
54
75
  if (onChunk) {
@@ -83,7 +104,7 @@ export async function attemptInvoke(
83
104
  return { messages: [finalChunk as AIMessageChunk] };
84
105
  }
85
106
 
86
- const finalMessage = await model.invoke(messages, config);
107
+ const finalMessage = await model.invoke(messagesForProvider, config);
87
108
  if ((finalMessage.tool_calls?.length ?? 0) > 0) {
88
109
  finalMessage.tool_calls = finalMessage.tool_calls?.filter(
89
110
  (tool_call: ToolCall) => !!tool_call.name
@@ -683,10 +683,17 @@ export function getMessagesWithinTokenLimit({
683
683
  ) as ThinkingContentText | undefined;
684
684
  thinkingStartIndex = thinkingBlock != null ? currentIndex : -1;
685
685
  }
686
- /** False start, the latest message was not part of a multi-assistant/tool sequence of messages */
686
+ /**
687
+ * Exited the trailing assistant/tool sequence without finding a
688
+ * thinking block. Anthropic does not require Claude to emit a
689
+ * thinking block before every tool call, so the absence of one is
690
+ * a valid sequence — clear thinkingEndIndex so the pruner does not
691
+ * treat it as malformed.
692
+ */
687
693
  if (
688
694
  thinkingEndIndex > -1 &&
689
- currentIndex === thinkingEndIndex - 1 &&
695
+ thinkingStartIndex < 0 &&
696
+ !thinkingBlock &&
690
697
  messageType !== 'ai' &&
691
698
  messageType !== 'tool'
692
699
  ) {
package/src/run.ts CHANGED
@@ -45,6 +45,7 @@ export class Run<_T extends t.BaseGraphState> {
45
45
  private tokenCounter?: t.TokenCounter;
46
46
  private handlerRegistry?: HandlerRegistry;
47
47
  private hookRegistry?: HookRegistry;
48
+ private toolOutputReferences?: t.ToolOutputReferencesConfig;
48
49
  private indexTokenCountMap?: Record<string, number>;
49
50
  calibrationRatio: number = 1;
50
51
  graphRunnable?: t.CompiledStateWorkflow;
@@ -78,6 +79,7 @@ export class Run<_T extends t.BaseGraphState> {
78
79
 
79
80
  this.handlerRegistry = handlerRegistry;
80
81
  this.hookRegistry = config.hooks;
82
+ this.toolOutputReferences = config.toolOutputReferences;
81
83
 
82
84
  if (!config.graphConfig) {
83
85
  throw new Error('Graph config not provided');
@@ -154,6 +156,7 @@ export class Run<_T extends t.BaseGraphState> {
154
156
  /** Propagate compile options from graph config */
155
157
  standardGraph.compileOptions = config.compileOptions;
156
158
  standardGraph.hookRegistry = this.hookRegistry;
159
+ standardGraph.toolOutputReferences = this.toolOutputReferences;
157
160
  this.Graph = standardGraph;
158
161
  return standardGraph.createWorkflow();
159
162
  }
@@ -177,6 +180,7 @@ export class Run<_T extends t.BaseGraphState> {
177
180
  }
178
181
 
179
182
  multiAgentGraph.hookRegistry = this.hookRegistry;
183
+ multiAgentGraph.toolOutputReferences = this.toolOutputReferences;
180
184
  this.Graph = multiAgentGraph;
181
185
  return multiAgentGraph.createWorkflow();
182
186
  }