@librechat/agents 3.3.3 → 3.3.4

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 (156) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +2 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +342 -109
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs +3 -1
  6. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  7. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +79 -6
  8. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +37 -10
  10. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  11. package/dist/cjs/llm/google/utils/common.cjs +19 -7
  12. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  13. package/dist/cjs/llm/invoke.cjs +64 -3
  14. package/dist/cjs/llm/invoke.cjs.map +1 -1
  15. package/dist/cjs/llm/openai/index.cjs +41 -5
  16. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  17. package/dist/cjs/llm/openai/utils/index.cjs +25 -4
  18. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  19. package/dist/cjs/llm/openrouter/index.cjs +4 -1
  20. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  21. package/dist/cjs/main.cjs +19 -2
  22. package/dist/cjs/messages/cache.cjs +54 -33
  23. package/dist/cjs/messages/cache.cjs.map +1 -1
  24. package/dist/cjs/messages/contextPruning.cjs +17 -43
  25. package/dist/cjs/messages/contextPruning.cjs.map +1 -1
  26. package/dist/cjs/messages/core.cjs +315 -23
  27. package/dist/cjs/messages/core.cjs.map +1 -1
  28. package/dist/cjs/messages/format.cjs +218 -60
  29. package/dist/cjs/messages/format.cjs.map +1 -1
  30. package/dist/cjs/messages/index.cjs +1 -1
  31. package/dist/cjs/messages/prune.cjs +503 -127
  32. package/dist/cjs/messages/prune.cjs.map +1 -1
  33. package/dist/cjs/stream.cjs +7 -3
  34. package/dist/cjs/stream.cjs.map +1 -1
  35. package/dist/cjs/summarization/node.cjs +28 -14
  36. package/dist/cjs/summarization/node.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +54 -41
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/toolOutputReferences.cjs +6 -0
  40. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  41. package/dist/cjs/utils/index.cjs +1 -1
  42. package/dist/cjs/utils/llm.cjs +1 -1
  43. package/dist/cjs/utils/llm.cjs.map +1 -1
  44. package/dist/cjs/utils/tokens.cjs +307 -20
  45. package/dist/cjs/utils/tokens.cjs.map +1 -1
  46. package/dist/cjs/utils/toolContent.cjs +1514 -0
  47. package/dist/cjs/utils/toolContent.cjs.map +1 -0
  48. package/dist/esm/agents/AgentContext.mjs +2 -1
  49. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  50. package/dist/esm/graphs/Graph.mjs +349 -116
  51. package/dist/esm/graphs/Graph.mjs.map +1 -1
  52. package/dist/esm/graphs/MultiAgentGraph.mjs +3 -1
  53. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  54. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +79 -6
  55. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  56. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +37 -10
  57. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  58. package/dist/esm/llm/google/utils/common.mjs +19 -7
  59. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  60. package/dist/esm/llm/invoke.mjs +65 -5
  61. package/dist/esm/llm/invoke.mjs.map +1 -1
  62. package/dist/esm/llm/openai/index.mjs +41 -5
  63. package/dist/esm/llm/openai/index.mjs.map +1 -1
  64. package/dist/esm/llm/openai/utils/index.mjs +25 -4
  65. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  66. package/dist/esm/llm/openrouter/index.mjs +4 -1
  67. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  68. package/dist/esm/main.mjs +7 -7
  69. package/dist/esm/messages/cache.mjs +55 -34
  70. package/dist/esm/messages/cache.mjs.map +1 -1
  71. package/dist/esm/messages/contextPruning.mjs +17 -43
  72. package/dist/esm/messages/contextPruning.mjs.map +1 -1
  73. package/dist/esm/messages/core.mjs +305 -24
  74. package/dist/esm/messages/core.mjs.map +1 -1
  75. package/dist/esm/messages/format.mjs +218 -61
  76. package/dist/esm/messages/format.mjs.map +1 -1
  77. package/dist/esm/messages/index.mjs +1 -1
  78. package/dist/esm/messages/prune.mjs +500 -128
  79. package/dist/esm/messages/prune.mjs.map +1 -1
  80. package/dist/esm/stream.mjs +7 -3
  81. package/dist/esm/stream.mjs.map +1 -1
  82. package/dist/esm/summarization/node.mjs +28 -14
  83. package/dist/esm/summarization/node.mjs.map +1 -1
  84. package/dist/esm/tools/ToolNode.mjs +54 -41
  85. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  86. package/dist/esm/tools/toolOutputReferences.mjs +6 -0
  87. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  88. package/dist/esm/utils/index.mjs +1 -1
  89. package/dist/esm/utils/llm.mjs +1 -1
  90. package/dist/esm/utils/llm.mjs.map +1 -1
  91. package/dist/esm/utils/tokens.mjs +307 -21
  92. package/dist/esm/utils/tokens.mjs.map +1 -1
  93. package/dist/esm/utils/toolContent.mjs +1503 -0
  94. package/dist/esm/utils/toolContent.mjs.map +1 -0
  95. package/dist/types/graphs/Graph.d.ts +1 -1
  96. package/dist/types/llm/invoke.d.ts +27 -1
  97. package/dist/types/llm/openai/index.d.ts +4 -0
  98. package/dist/types/llm/openai/utils/index.d.ts +2 -0
  99. package/dist/types/llm/openrouter/index.d.ts +5 -0
  100. package/dist/types/messages/contextPruning.d.ts +2 -1
  101. package/dist/types/messages/core.d.ts +36 -0
  102. package/dist/types/messages/format.d.ts +6 -0
  103. package/dist/types/messages/prune.d.ts +16 -12
  104. package/dist/types/types/stream.d.ts +1 -1
  105. package/dist/types/utils/tokens.d.ts +7 -0
  106. package/dist/types/utils/toolContent.d.ts +107 -0
  107. package/package.json +1 -1
  108. package/src/__tests__/stream.eagerEventExecution.test.ts +74 -0
  109. package/src/agents/AgentContext.ts +1 -0
  110. package/src/graphs/Graph.ts +698 -200
  111. package/src/graphs/MultiAgentGraph.ts +6 -1
  112. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1128 -4
  113. package/src/llm/anthropic/utils/message_inputs.ts +158 -12
  114. package/src/llm/anthropic/utils/tool-id-normalization.test.ts +109 -0
  115. package/src/llm/bedrock/utils/message_inputs.test.ts +49 -1
  116. package/src/llm/bedrock/utils/message_inputs.ts +86 -13
  117. package/src/llm/google/utils/common.test.ts +75 -9
  118. package/src/llm/google/utils/common.ts +34 -3
  119. package/src/llm/invoke.test.ts +488 -0
  120. package/src/llm/invoke.ts +196 -2
  121. package/src/llm/openai/index.ts +106 -6
  122. package/src/llm/openai/utils/index.ts +109 -53
  123. package/src/llm/openai/utils/messages.test.ts +330 -1
  124. package/src/llm/openrouter/index.ts +12 -2
  125. package/src/llm/openrouter/reasoning.test.ts +314 -0
  126. package/src/messages/__tests__/observationMasking.test.ts +93 -2
  127. package/src/messages/cache.tail.test.ts +193 -0
  128. package/src/messages/cache.test.ts +113 -0
  129. package/src/messages/cache.ts +92 -60
  130. package/src/messages/contextPruning.test.ts +184 -0
  131. package/src/messages/contextPruning.ts +49 -42
  132. package/src/messages/core.ts +653 -40
  133. package/src/messages/ensureThinkingBlock.test.ts +49 -1
  134. package/src/messages/foldToollessToolBlocks.test.ts +163 -5
  135. package/src/messages/format.ts +363 -91
  136. package/src/messages/formatAgentMessages.test.ts +604 -14
  137. package/src/messages/formatAgentMessages.tools.test.ts +103 -0
  138. package/src/messages/prune.ts +996 -183
  139. package/src/specs/prune.test.ts +1083 -6
  140. package/src/specs/summarization-unit.test.ts +105 -0
  141. package/src/specs/tokens.test.ts +609 -32
  142. package/src/stream.ts +23 -15
  143. package/src/summarization/__tests__/node.test.ts +77 -0
  144. package/src/summarization/node.ts +46 -18
  145. package/src/tools/ToolNode.ts +147 -68
  146. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +148 -1
  147. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +112 -3
  148. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +32 -0
  149. package/src/tools/__tests__/directToolHooks.test.ts +255 -7
  150. package/src/tools/toolOutputReferences.ts +6 -0
  151. package/src/types/stream.ts +1 -1
  152. package/src/utils/llm.test.ts +18 -0
  153. package/src/utils/llm.ts +4 -1
  154. package/src/utils/tokens.ts +586 -31
  155. package/src/utils/toolContent.test.ts +1432 -0
  156. package/src/utils/toolContent.ts +2707 -0
@@ -29,7 +29,7 @@ function createAIMessageWithToolCalls(
29
29
  type CompletionEvent = {
30
30
  result: {
31
31
  id: string;
32
- tool_call: { id: string; output: string };
32
+ tool_call: { id: string; output: string; args?: string };
33
33
  };
34
34
  };
35
35
 
@@ -124,6 +124,153 @@ describe('ToolNode per-call onResult completion emission', () => {
124
124
  expect(result.messages.map((m) => m.content)).toEqual(['sunny', '42']);
125
125
  });
126
126
 
127
+ it('serializes bigint output before early and batch completion paths', async () => {
128
+ const completions: CompletionEvent[] = [];
129
+ const structuredOutput = [{ rowsRead: BigInt(42), status: 'complete' }];
130
+
131
+ jest
132
+ .spyOn(events, 'safeDispatchCustomEvent')
133
+ .mockImplementation(async (event, data): Promise<void> => {
134
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
135
+ completions.push(data as CompletionEvent);
136
+ return;
137
+ }
138
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
139
+ return;
140
+ }
141
+ const batch = data as t.ToolExecuteBatchRequest;
142
+ batch.onResult?.({
143
+ toolCallId: 'call_query',
144
+ status: 'success',
145
+ content: structuredOutput,
146
+ });
147
+ await flushAsync();
148
+ batch.resolve([
149
+ {
150
+ toolCallId: 'call_query',
151
+ status: 'success',
152
+ content: structuredOutput,
153
+ },
154
+ ]);
155
+ });
156
+
157
+ const toolNode = new ToolNode({
158
+ tools: [createDummyTool('query')],
159
+ eventDrivenMode: true,
160
+ toolCallStepIds: new Map([['call_query', 'step_query']]),
161
+ });
162
+ const result = (await toolNode.invoke({
163
+ messages: [
164
+ createAIMessageWithToolCalls([
165
+ { id: 'call_query', name: 'query', args: {} },
166
+ ]),
167
+ ],
168
+ })) as { messages: ToolMessage[] };
169
+
170
+ const serialized = '[{"rowsRead":"42","status":"complete"}]';
171
+ expect(completions).toHaveLength(1);
172
+ expect(completions[0].result.tool_call.output).toBe(serialized);
173
+ expect(result.messages[0].content).toBe(serialized);
174
+ });
175
+
176
+ it('omits native computer screenshots from completion events', async () => {
177
+ const completions: CompletionEvent[] = [];
178
+ const screenshot = `data:image/png;base64,${'A'.repeat(2_000)}`;
179
+ const computerOutput = new ToolMessage({
180
+ content: screenshot,
181
+ tool_call_id: 'call_computer',
182
+ additional_kwargs: { type: 'computer_call_output' },
183
+ });
184
+ const computer = createDummyTool('computer_use');
185
+ (
186
+ computer as unknown as {
187
+ invoke: () => Promise<ToolMessage>;
188
+ }
189
+ ).invoke = async () => computerOutput;
190
+ jest
191
+ .spyOn(events, 'safeDispatchCustomEvent')
192
+ .mockImplementation(async (event, data): Promise<void> => {
193
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
194
+ completions.push(data as CompletionEvent);
195
+ }
196
+ });
197
+ const toolNode = new ToolNode({
198
+ tools: [computer],
199
+ eventDrivenMode: true,
200
+ directToolNames: new Set(['computer_use']),
201
+ toolCallStepIds: new Map([['call_computer', 'step_computer']]),
202
+ maxToolResultChars: 80,
203
+ });
204
+
205
+ const result = (await toolNode.invoke({
206
+ messages: [
207
+ createAIMessageWithToolCalls([
208
+ { id: 'call_computer', name: 'computer_use', args: {} },
209
+ ]),
210
+ ],
211
+ })) as { messages: ToolMessage[] };
212
+
213
+ expect(result.messages[0]).toBe(computerOutput);
214
+ expect(result.messages[0].content).toBe(screenshot);
215
+ expect(completions).toHaveLength(1);
216
+ expect(completions[0].result.tool_call.output).toContain(
217
+ 'Computer screenshot omitted'
218
+ );
219
+ expect(completions[0].result.tool_call.output.length).toBeLessThanOrEqual(
220
+ 80
221
+ );
222
+ });
223
+
224
+ it('bounds cyclic tool args without losing the completion event', async () => {
225
+ const completions: CompletionEvent[] = [];
226
+ const cyclicArgs: Record<string, unknown> = { city: 'NYC' };
227
+ cyclicArgs.self = cyclicArgs;
228
+
229
+ jest
230
+ .spyOn(events, 'safeDispatchCustomEvent')
231
+ .mockImplementation(async (event, data): Promise<void> => {
232
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
233
+ completions.push(data as CompletionEvent);
234
+ return;
235
+ }
236
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
237
+ return;
238
+ }
239
+ const batch = data as t.ToolExecuteBatchRequest;
240
+ batch.onResult?.({
241
+ toolCallId: 'call_weather',
242
+ status: 'success',
243
+ content: 'sunny',
244
+ });
245
+ await flushAsync();
246
+ batch.resolve([
247
+ {
248
+ toolCallId: 'call_weather',
249
+ status: 'success',
250
+ content: 'sunny',
251
+ },
252
+ ]);
253
+ });
254
+
255
+ const toolNode = new ToolNode({
256
+ tools: [createDummyTool('weather')],
257
+ eventDrivenMode: true,
258
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
259
+ });
260
+
261
+ await toolNode.invoke({
262
+ messages: [
263
+ createAIMessageWithToolCalls([
264
+ { id: 'call_weather', name: 'weather', args: cyclicArgs },
265
+ ]),
266
+ ],
267
+ });
268
+
269
+ expect(completions).toHaveLength(1);
270
+ expect(completions[0].result.tool_call.args).toContain('[Circular]');
271
+ expect(completions[0].result.tool_call.output).toBe('sunny');
272
+ });
273
+
127
274
  it('ignores duplicate and unknown onResult reports', async () => {
128
275
  const completions: CompletionEvent[] = [];
129
276
 
@@ -113,6 +113,54 @@ describe('ToolNode tool output references', () => {
113
113
 
114
114
  expect(capturedArgs).toEqual(['raw {{tool0turn0}}']);
115
115
  });
116
+
117
+ it('bounds structured ToolMessage output before its first model call', async () => {
118
+ const structuredTool = tool(
119
+ async () =>
120
+ new ToolMessage({
121
+ status: 'success',
122
+ content: [
123
+ {
124
+ type: 'json',
125
+ rows: Array.from({ length: 20 }, (_, index) => ({
126
+ id: index,
127
+ value: `${'x'.repeat(100)}-${index}`,
128
+ })),
129
+ },
130
+ ],
131
+ artifact: { source: 'clickhouse' },
132
+ metadata: { requestId: 'request-1' },
133
+ name: 'run_select_query',
134
+ tool_call_id: 'c1',
135
+ }),
136
+ {
137
+ name: 'run_select_query',
138
+ description: 'returns structured rows',
139
+ schema: z.object({ command: z.string() }),
140
+ }
141
+ ) as unknown as StructuredToolInterface;
142
+ const node = new ToolNode({
143
+ tools: [structuredTool],
144
+ maxToolResultChars: 200,
145
+ });
146
+
147
+ const [msg] = await invokeBatch(node, [
148
+ {
149
+ id: 'c1',
150
+ name: 'run_select_query',
151
+ command: 'SELECT * FROM events',
152
+ },
153
+ ]);
154
+
155
+ expect(typeof msg.content).toBe('string');
156
+ expect(msg.content).toContain('truncated');
157
+ expect((msg.content as string).length).toBeLessThanOrEqual(200);
158
+ expect(msg.tool_call_id).toBe('c1');
159
+ expect(msg.name).toBe('run_select_query');
160
+ expect(msg.status).toBe('success');
161
+ expect(msg.artifact).toEqual({ source: 'clickhouse' });
162
+ expect(msg.metadata).toEqual({ requestId: 'request-1' });
163
+ });
116
164
  });
117
165
 
118
166
  describe('enabled', () => {
@@ -364,6 +412,67 @@ describe('ToolNode tool output references', () => {
364
412
  ).toBe(raw);
365
413
  });
366
414
 
415
+ it('preserves structured output exactly up to the registry limit', async () => {
416
+ const structured = {
417
+ rows: Array.from({ length: 10 }, (_, index) => ({
418
+ id: index,
419
+ value: `row-${index}-${'x'.repeat(100)}`,
420
+ })),
421
+ };
422
+ const exact = JSON.stringify(structured);
423
+ const capturedArgs: string[] = [];
424
+ let callCount = 0;
425
+ const structuredTool = createEchoTool({
426
+ capturedArgs: [],
427
+ outputs: ['unused'],
428
+ name: 'structured',
429
+ });
430
+ (
431
+ structuredTool as unknown as {
432
+ invoke: (input: {
433
+ args: { command: string };
434
+ }) => Promise<typeof structured | string>;
435
+ }
436
+ ).invoke = async (input) => {
437
+ capturedArgs.push(input.args.command);
438
+ return callCount++ === 0 ? structured : 'done';
439
+ };
440
+ const node = new ToolNode({
441
+ tools: [structuredTool],
442
+ maxToolResultChars: 100,
443
+ toolOutputReferences: {
444
+ enabled: true,
445
+ maxOutputSize: exact.length,
446
+ },
447
+ });
448
+
449
+ const [first] = await invokeBatch(
450
+ node,
451
+ [{ id: 'c1', name: 'structured', command: 'first' }],
452
+ 'structured-raw-preservation'
453
+ );
454
+ await invokeBatch(
455
+ node,
456
+ [
457
+ {
458
+ id: 'c2',
459
+ name: 'structured',
460
+ command: '{{tool0turn0}}',
461
+ },
462
+ ],
463
+ 'structured-raw-preservation'
464
+ );
465
+
466
+ expect((first.content as string).length).toBeLessThanOrEqual(100);
467
+ expect(first.content).toContain('truncated');
468
+ expect(capturedArgs[1]).toBe(exact);
469
+ expect(
470
+ node
471
+ ._unsafeGetToolOutputRegistry()!
472
+ .get('structured-raw-preservation', 'tool0turn0')
473
+ ).toBe(exact);
474
+ });
475
+
367
476
  it('uses each batch\'s own turn when ToolNode is invoked concurrently within a run', async () => {
368
477
  const gates: Record<string, () => void> = {};
369
478
  const slowTool = tool(
@@ -823,7 +932,7 @@ describe('ToolNode tool output references', () => {
823
932
  expect(JSON.parse(stepCompletedArgs[1]).command).toBe('echo STORED');
824
933
  });
825
934
 
826
- it('records unresolved refs as metadata on non-string ToolMessage content (content untouched)', async () => {
935
+ it('records unresolved refs on multipart ToolMessage content within the cap', async () => {
827
936
  const complexTool = tool(
828
937
  async () =>
829
938
  new ToolMessage({
@@ -855,8 +964,8 @@ describe('ToolNode tool output references', () => {
855
964
 
856
965
  expect(Array.isArray(msg.content)).toBe(true);
857
966
  const blocks = msg.content as Array<{ type: string; text?: string }>;
858
- // Multi-part content is untouched at storage time — the lazy
859
- // transform handles the unresolved-refs warning at request time.
967
+ // In-budget multi-part content stays intact; the lazy transform handles
968
+ // the unresolved-refs warning at request time.
860
969
  expect(blocks).toHaveLength(2);
861
970
  expect(blocks[0].type).toBe('text');
862
971
  expect(blocks[0].text).toBe('data');
@@ -90,6 +90,38 @@ describe('annotateMessagesForLLM', () => {
90
90
  expect(out[0]).not.toBe(tm);
91
91
  });
92
92
 
93
+ it('does not annotate native computer screenshot strings', () => {
94
+ const registry = new ToolOutputReferenceRegistry();
95
+ registry.set('r1', 'tool0turn0', 'stored-raw');
96
+ const screenshot = `data:image/png;base64,${'A'.repeat(2_000)}`;
97
+ const tm = makeToolMessage({
98
+ content: screenshot,
99
+ additional_kwargs: {
100
+ type: 'computer_call_output',
101
+ _refKey: 'tool0turn0',
102
+ },
103
+ });
104
+
105
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
106
+
107
+ expect(out[0].content).toBe(screenshot);
108
+ expect((out[0] as ToolMessage).additional_kwargs.type).toBe(
109
+ 'computer_call_output'
110
+ );
111
+ expect((out[0] as ToolMessage).additional_kwargs._refKey).toBeUndefined();
112
+ });
113
+
114
+ it('leaves native computer screenshots reference-equal without ref metadata', () => {
115
+ const registry = new ToolOutputReferenceRegistry();
116
+ const tm = makeToolMessage({
117
+ content: 'data:image/png;base64,AAAA',
118
+ additional_kwargs: { type: 'computer_call_output' },
119
+ });
120
+ const messages = [tm];
121
+
122
+ expect(annotateMessagesForLLM(messages, registry, 'r1')).toBe(messages);
123
+ });
124
+
93
125
  it('leaves content untouched but strips framework metadata when _refKey is stale', () => {
94
126
  /**
95
127
  * Stale `_refKey` (not in registry) doesn't trigger annotation,
@@ -20,7 +20,7 @@ import { ToolNode } from '../ToolNode';
20
20
  */
21
21
  function createDirectTool(
22
22
  name: string,
23
- impl: (args: Record<string, unknown>) => string | Promise<string>
23
+ impl: (args: Record<string, unknown>) => unknown | Promise<unknown>
24
24
  ): StructuredToolInterface {
25
25
  return tool(async (args: Record<string, unknown>) => impl(args), {
26
26
  name,
@@ -147,6 +147,217 @@ describe('Direct-path lifecycle hooks (in-process tools)', () => {
147
147
  expect(String(message.content)).toBe('ran:ls');
148
148
  });
149
149
 
150
+ it('serializes bigint values in direct structured tool output', async () => {
151
+ const query = createDirectTool('query', () => 'unused');
152
+ (
153
+ query as unknown as {
154
+ invoke: () => Promise<Array<Record<string, unknown>>>;
155
+ }
156
+ ).invoke = async () => [{ rowsRead: BigInt(42), status: 'complete' }];
157
+ const node = new ToolNode({
158
+ tools: [query],
159
+ eventDrivenMode: true,
160
+ directToolNames: new Set(['query']),
161
+ });
162
+
163
+ const result = await node.invoke({
164
+ messages: [aiCall('call_bigint', 'query', {})],
165
+ });
166
+ const [message] = toolMessages(result);
167
+
168
+ expect(message.status).toBe('success');
169
+ expect(message.content).toBe('[{"rowsRead":"42","status":"complete"}]');
170
+ });
171
+
172
+ it('bounds a large structured result without invoking toJSON', async () => {
173
+ let toJSONCalls = 0;
174
+ const structuredOutput = {
175
+ rows: [{ value: 'x'.repeat(20_000) }],
176
+ toJSON() {
177
+ toJSONCalls++;
178
+ return 'y'.repeat(100_000);
179
+ },
180
+ };
181
+ const query = createDirectTool('large_query', () => 'unused');
182
+ (
183
+ query as unknown as {
184
+ invoke: () => Promise<typeof structuredOutput>;
185
+ }
186
+ ).invoke = async () => structuredOutput;
187
+ const node = new ToolNode({
188
+ tools: [query],
189
+ eventDrivenMode: true,
190
+ directToolNames: new Set(['large_query']),
191
+ maxToolResultChars: 200,
192
+ });
193
+
194
+ const result = await node.invoke({
195
+ messages: [aiCall('call_large', 'large_query', {})],
196
+ });
197
+ const [message] = toolMessages(result);
198
+
199
+ expect(toJSONCalls).toBe(0);
200
+ expect(typeof message.content).toBe('string');
201
+ expect((message.content as string).length).toBeLessThanOrEqual(200);
202
+ expect(message.content).toContain('truncated');
203
+ });
204
+
205
+ it('keeps native computer screenshots intact despite the generic result cap', async () => {
206
+ const screenshot = `data:image/png;base64,${'A'.repeat(2_000)}`;
207
+ const contents: ToolMessage['content'][] = [
208
+ screenshot,
209
+ [{ type: 'computer_screenshot', image_url: screenshot }],
210
+ ];
211
+
212
+ for (let i = 0; i < contents.length; i++) {
213
+ const name = `computer_output_${i}`;
214
+ const computerOutput = new ToolMessage({
215
+ content: contents[i],
216
+ tool_call_id: `computer-call-${i}`,
217
+ additional_kwargs: { type: 'computer_call_output' },
218
+ });
219
+ const computer = createDirectTool(name, () => 'unused');
220
+ (
221
+ computer as unknown as {
222
+ invoke: () => Promise<ToolMessage>;
223
+ }
224
+ ).invoke = async () => computerOutput;
225
+ const node = new ToolNode({
226
+ tools: [computer],
227
+ eventDrivenMode: true,
228
+ directToolNames: new Set([name]),
229
+ maxToolResultChars: 80,
230
+ });
231
+
232
+ const result = await node.invoke({
233
+ messages: [aiCall(`computer-call-${i}`, name, {})],
234
+ });
235
+ const [message] = toolMessages(result);
236
+
237
+ expect(message).toBe(computerOutput);
238
+ expect(message.content).toBe(contents[i]);
239
+ expect(message.additional_kwargs.type).toBe('computer_call_output');
240
+ }
241
+ });
242
+
243
+ it('preserves a valid PostToolUse computer screenshot replacement', async () => {
244
+ const original = `data:image/png;base64,${'A'.repeat(2_000)}`;
245
+ const replacement = `data:image/png;base64,${'B'.repeat(2_000)}`;
246
+ const computerOutput = new ToolMessage({
247
+ content: original,
248
+ tool_call_id: 'computer-hook-valid',
249
+ additional_kwargs: { type: 'computer_call_output' },
250
+ });
251
+ const computer = createDirectTool('computer_hook_valid', () => 'unused');
252
+ (
253
+ computer as unknown as {
254
+ invoke: () => Promise<ToolMessage>;
255
+ }
256
+ ).invoke = async () => computerOutput;
257
+ const registry = new HookRegistry();
258
+ registry.register('PostToolUse', {
259
+ hooks: [
260
+ async (): Promise<PostToolUseHookOutput> => ({
261
+ updatedOutput: replacement,
262
+ }),
263
+ ],
264
+ });
265
+ const node = new ToolNode({
266
+ tools: [computer],
267
+ eventDrivenMode: true,
268
+ hookRegistry: registry,
269
+ directToolNames: new Set(['computer_hook_valid']),
270
+ maxToolResultChars: 80,
271
+ });
272
+
273
+ const result = await node.invoke({
274
+ messages: [aiCall('computer-hook-valid', 'computer_hook_valid', {})],
275
+ });
276
+ const [message] = toolMessages(result);
277
+
278
+ expect(message.content).toBe(replacement);
279
+ expect(message.additional_kwargs.type).toBe('computer_call_output');
280
+ });
281
+
282
+ it('rejects an invalid PostToolUse computer output replacement', async () => {
283
+ const screenshot = `data:image/png;base64,${'A'.repeat(2_000)}`;
284
+ const computerOutput = new ToolMessage({
285
+ content: screenshot,
286
+ tool_call_id: 'computer-hook-invalid',
287
+ additional_kwargs: { type: 'computer_call_output' },
288
+ });
289
+ const computer = createDirectTool('computer_hook_invalid', () => 'unused');
290
+ (
291
+ computer as unknown as {
292
+ invoke: () => Promise<ToolMessage>;
293
+ }
294
+ ).invoke = async () => computerOutput;
295
+ const registry = new HookRegistry();
296
+ registry.register('PostToolUse', {
297
+ hooks: [
298
+ async (): Promise<PostToolUseHookOutput> => ({
299
+ updatedOutput: 'redacted screenshot',
300
+ }),
301
+ ],
302
+ });
303
+ const node = new ToolNode({
304
+ tools: [computer],
305
+ eventDrivenMode: true,
306
+ hookRegistry: registry,
307
+ directToolNames: new Set(['computer_hook_invalid']),
308
+ maxToolResultChars: 80,
309
+ });
310
+
311
+ await expect(
312
+ node.invoke({
313
+ messages: [
314
+ aiCall('computer-hook-invalid', 'computer_hook_invalid', {}),
315
+ ],
316
+ })
317
+ ).rejects.toThrow(
318
+ 'PostToolUse updatedOutput for a computer call must be a valid screenshot'
319
+ );
320
+ });
321
+
322
+ it('caps returned and thrown direct-tool errors before storing them', async () => {
323
+ const returnedError = createDirectTool(
324
+ 'returned_error',
325
+ () =>
326
+ new ToolMessage({
327
+ status: 'error',
328
+ content: `returned:${'x'.repeat(2_000)}`,
329
+ tool_call_id: 'returned',
330
+ })
331
+ );
332
+ const thrownError = createDirectTool('thrown_error', () => {
333
+ throw new Error(`thrown:${'y'.repeat(2_000)}`);
334
+ });
335
+ const node = new ToolNode({
336
+ tools: [returnedError, thrownError],
337
+ eventDrivenMode: true,
338
+ directToolNames: new Set(['returned_error', 'thrown_error']),
339
+ maxToolResultChars: 200,
340
+ });
341
+
342
+ const returned = toolMessages(
343
+ await node.invoke({
344
+ messages: [aiCall('returned', 'returned_error', {})],
345
+ })
346
+ )[0];
347
+ const thrown = toolMessages(
348
+ await node.invoke({
349
+ messages: [aiCall('thrown', 'thrown_error', {})],
350
+ })
351
+ )[0];
352
+
353
+ expect(returned.status).toBe('error');
354
+ expect(String(returned.content).length).toBeLessThanOrEqual(200);
355
+ expect(returned.content).toContain('[truncated:');
356
+ expect(thrown.status).toBe('error');
357
+ expect(String(thrown.content).length).toBeLessThanOrEqual(200);
358
+ expect(thrown.content).toContain('[truncated:');
359
+ });
360
+
150
361
  it('executingAgentId defaults to agentId for direct callers, and an explicit value wins', async () => {
151
362
  const echo = createDirectTool('echo', (args) => `ran:${args.command}`);
152
363
  const captured: Array<string | undefined> = [];
@@ -247,6 +458,37 @@ describe('Direct-path lifecycle hooks (in-process tools)', () => {
247
458
  expect(message.status).toBe('success');
248
459
  });
249
460
 
461
+ it('caps PostToolUse updatedOutput after the hook rewrite', async () => {
462
+ const echo = createDirectTool('echo', () => 'ORIGINAL');
463
+ const replacement = 'R'.repeat(500);
464
+
465
+ const registry = new HookRegistry();
466
+ registry.register('PostToolUse', {
467
+ hooks: [
468
+ async (): Promise<PostToolUseHookOutput> => ({
469
+ updatedOutput: replacement,
470
+ }),
471
+ ],
472
+ });
473
+
474
+ const node = new ToolNode({
475
+ tools: [echo],
476
+ eventDrivenMode: true,
477
+ hookRegistry: registry,
478
+ directToolNames: new Set(['echo']),
479
+ maxToolResultChars: 50,
480
+ });
481
+
482
+ const result = await node.invoke({
483
+ messages: [aiCall('call_5_capped', 'echo', { command: 'x' })],
484
+ });
485
+ const [message] = toolMessages(result);
486
+
487
+ expect(typeof message.content).toBe('string');
488
+ expect((message.content as string).length).toBeLessThanOrEqual(50);
489
+ expect(message.content).not.toBe(replacement);
490
+ });
491
+
250
492
  it('PostToolUseFailure observes errors thrown by the tool', async () => {
251
493
  const failing = createDirectTool('boom', () => {
252
494
  throw new Error('kaboom');
@@ -376,11 +618,16 @@ describe('Direct-path lifecycle hooks (in-process tools)', () => {
376
618
 
377
619
  // Patch the tool's `func` to record the turn the body sees via the
378
620
  // standard LangChain config.toolCall.turn channel.
379
- const originalFunc = (echo as unknown as { func: (input: unknown, config: unknown) => Promise<string> }).func;
380
- (echo as unknown as { func: (input: unknown, config: unknown) => Promise<string> }).func = async (
381
- input,
382
- config
383
- ): Promise<string> => {
621
+ const originalFunc = (
622
+ echo as unknown as {
623
+ func: (input: unknown, config: unknown) => Promise<string>;
624
+ }
625
+ ).func;
626
+ (
627
+ echo as unknown as {
628
+ func: (input: unknown, config: unknown) => Promise<string>;
629
+ }
630
+ ).func = async (input, config): Promise<string> => {
384
631
  const t = (config as { toolCall?: { turn?: number } } | undefined)
385
632
  ?.toolCall?.turn;
386
633
  if (typeof t === 'number') bodyTurns.push(t);
@@ -424,7 +671,8 @@ describe('Direct-path lifecycle hooks (in-process tools)', () => {
424
671
  hooks: [
425
672
  async (): Promise<PreToolUseHookOutput> => ({
426
673
  decision: 'allow',
427
- additionalContext: 'POLICY-NOTE: writes here require approval next time',
674
+ additionalContext:
675
+ 'POLICY-NOTE: writes here require approval next time',
428
676
  }),
429
677
  ],
430
678
  });
@@ -29,6 +29,7 @@ import {
29
29
  HARD_MAX_TOOL_RESULT_CHARS,
30
30
  HARD_MAX_TOTAL_TOOL_OUTPUT_SIZE,
31
31
  } from '@/utils/truncation';
32
+ import { isComputerCallOutputMessage } from '@/utils/toolContent';
32
33
 
33
34
  /**
34
35
  * Non-global matcher for a single `{{tool<i>turn<n>}}` placeholder.
@@ -656,6 +657,11 @@ export function annotateMessagesForLLM(
656
657
  const hasRefScope = '_refScope' in meta;
657
658
  const hasUnresolvedField = '_unresolvedRefs' in meta;
658
659
  if (!hasRefKey && !hasRefScope && !hasUnresolvedField) continue;
660
+ if (isComputerCallOutputMessage(m)) {
661
+ out ??= messages.slice();
662
+ out[i] = cloneToolMessageWithContent(m as ToolMessage, m.content);
663
+ continue;
664
+ }
659
665
 
660
666
  const refKey = readRefKey(meta);
661
667
  const unresolved = readUnresolvedRefs(meta);
@@ -336,7 +336,7 @@ export type ToolCallPart = {
336
336
  /** If provided, an identifier associated with the tool call */
337
337
  id?: string;
338
338
  /** If provided, the output of the tool call */
339
- output?: string;
339
+ output?: ToolResultContent['content'];
340
340
  /** Auth URL */
341
341
  auth?: string;
342
342
  /** Expiration time */
@@ -0,0 +1,18 @@
1
+ import { isAnthropicLike } from './llm';
2
+ import { Providers } from '@/common';
3
+
4
+ describe('isAnthropicLike', () => {
5
+ it('treats the default Bedrock model as Claude', () => {
6
+ expect(isAnthropicLike(Providers.BEDROCK)).toBe(true);
7
+ expect(
8
+ isAnthropicLike(Providers.BEDROCK, {
9
+ model: 'anthropic.claude-sonnet-4-5',
10
+ })
11
+ ).toBe(true);
12
+ expect(
13
+ isAnthropicLike(Providers.BEDROCK, {
14
+ model: 'amazon.nova-pro-v1:0',
15
+ })
16
+ ).toBe(false);
17
+ });
18
+ });