@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
@@ -390,7 +390,9 @@ function _convertLangChainContentToPart(
390
390
  }
391
391
 
392
392
  if (content.type === 'text') {
393
- return { text: content.text };
393
+ return typeof content.text === 'string' && content.text !== ''
394
+ ? { text: content.text }
395
+ : undefined;
394
396
  } else if (content.type === 'executableCode') {
395
397
  return { executableCode: content.executableCode };
396
398
  } else if (content.type === 'codeExecutionResult') {
@@ -429,10 +431,14 @@ function _convertLangChainContentToPart(
429
431
  } else if (content.type === 'media') {
430
432
  return messageContentMedia(content);
431
433
  } else if (content.type === 'tool_use') {
434
+ const functionId = getGoogleFunctionId(
435
+ typeof content.id === 'string' ? content.id : undefined
436
+ );
432
437
  return {
433
438
  functionCall: {
434
439
  name: content.name,
435
440
  args: content.input,
441
+ ...(functionId != null ? { id: functionId } : {}),
436
442
  },
437
443
  };
438
444
  } else if (
@@ -553,7 +559,28 @@ export function convertMessageContentToParts(
553
559
  });
554
560
  }
555
561
 
556
- return [...messageParts, ...functionCalls];
562
+ const parsedFunctionCallIds = new Set(
563
+ functionCalls.flatMap((part) => {
564
+ const functionCall = part.functionCall as GoogleFunctionCallWithId;
565
+ return functionCall.id != null ? [functionCall.id] : [];
566
+ })
567
+ );
568
+ const parsedFunctionCallNames = new Set(
569
+ functionCalls.map((part) => part.functionCall.name)
570
+ );
571
+ const contentWithoutParsedMirrors = messageParts.filter((part) => {
572
+ if (!('functionCall' in part) || part.functionCall == null) {
573
+ return true;
574
+ }
575
+ const functionCall = part.functionCall as GoogleFunctionCallWithId;
576
+ return !(
577
+ (functionCall.id != null && parsedFunctionCallIds.has(functionCall.id)) ||
578
+ (functionCall.id == null &&
579
+ parsedFunctionCallNames.has(functionCall.name))
580
+ );
581
+ });
582
+
583
+ return [...contentWithoutParsedMirrors, ...functionCalls];
557
584
  }
558
585
 
559
586
  export function convertBaseMessagesToContent(
@@ -665,7 +692,11 @@ export function dropUnsupportedModelTurnPrefill(
665
692
  contents: Content[] | undefined,
666
693
  model?: string
667
694
  ): Content[] | undefined {
668
- if (contents == null || contents.length === 0 || !rejectsModelTurnPrefill(model)) {
695
+ if (
696
+ contents == null ||
697
+ contents.length === 0 ||
698
+ !rejectsModelTurnPrefill(model)
699
+ ) {
669
700
  return contents;
670
701
  }
671
702
  let end = contents.length;
@@ -7,13 +7,22 @@ import {
7
7
  HumanMessage,
8
8
  ToolMessage,
9
9
  } from '@langchain/core/messages';
10
+ import {
11
+ convertMessagesToCompletionsMessageParams,
12
+ convertMessagesToResponsesInput,
13
+ tools as openAITools,
14
+ } from '@langchain/openai';
10
15
  import type { StructuredToolInterface } from '@langchain/core/tools';
11
16
  import type { BaseMessage } from '@langchain/core/messages';
12
17
  import type * as t from '@/types';
18
+ import { _convertMessagesToAnthropicPayload } from '@/llm/anthropic/utils/message_inputs';
13
19
  import { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
20
+ import { convertMessageContentToParts } from '@/llm/google/utils/common';
14
21
  import { attemptInvoke, tryFallbackProviders } from '@/llm/invoke';
22
+ import { toLangChainContent } from '@/messages/langchain';
15
23
  import { Constants, Providers } from '@/common';
16
24
  import { ToolNode } from '@/tools/ToolNode';
25
+ import { ChatOpenAI } from '@/llm/openai';
17
26
 
18
27
  /**
19
28
  * Minimal stub model shape `attemptInvoke` reads. Either `invoke` or
@@ -21,6 +30,9 @@ import { ToolNode } from '@/tools/ToolNode';
21
30
  * extending the real `BaseChatModel` would pull in too much surface.
22
31
  */
23
32
  type StubModel = {
33
+ _useResponsesApi?: (options?: unknown) => boolean;
34
+ defaultOptions?: unknown;
35
+ last?: unknown;
24
36
  invoke?: (messages: BaseMessage[], config?: unknown) => Promise<AIMessage>;
25
37
  stream?: (
26
38
  messages: BaseMessage[],
@@ -164,6 +176,482 @@ describe('attemptInvoke applies lazy ref annotation', () => {
164
176
  expect(invokeMessages[0][0].content).toBe('output');
165
177
  });
166
178
 
179
+ it('replaces native computer screenshots for non-Responses providers', async () => {
180
+ const screenshot = `data:image/png;base64,${'A'.repeat(100_000)}`;
181
+ const computerOutput = new ToolMessage({
182
+ content: screenshot,
183
+ tool_call_id: 'computer-output',
184
+ additional_kwargs: { type: 'computer_call_output' },
185
+ });
186
+ const { invokeMessages, model } = buildCapturingModel();
187
+
188
+ await attemptInvoke({
189
+ model: model as t.ChatModel,
190
+ messages: [computerOutput],
191
+ provider: Providers.ANTHROPIC,
192
+ });
193
+
194
+ expect(invokeMessages[0][0].content).toBe(
195
+ '[Computer screenshot omitted for this provider]'
196
+ );
197
+ expect(computerOutput.content).toBe(screenshot);
198
+ });
199
+
200
+ it('scopes cache blocks to providers that support them', async () => {
201
+ const cachedOutput = new ToolMessage({
202
+ content: [
203
+ {
204
+ type: 'text',
205
+ text: 'result body',
206
+ cache_control: { type: 'ephemeral' },
207
+ },
208
+ ] as ToolMessage['content'],
209
+ tool_call_id: 'call_cached_tool',
210
+ });
211
+ const messages = [
212
+ new AIMessage({
213
+ content: '',
214
+ tool_calls: [{ id: 'call_cached_tool', name: 'search', args: {} }],
215
+ }),
216
+ cachedOutput,
217
+ ];
218
+ const openRouter = buildCapturingModel();
219
+ const openAI = buildCapturingModel();
220
+ const anthropic = buildCapturingModel();
221
+ const google = buildCapturingModel();
222
+
223
+ await attemptInvoke({
224
+ model: openRouter.model as t.ChatModel,
225
+ messages,
226
+ provider: Providers.OPENROUTER,
227
+ });
228
+ await attemptInvoke({
229
+ model: openAI.model as t.ChatModel,
230
+ messages,
231
+ provider: Providers.OPENAI,
232
+ });
233
+ await attemptInvoke({
234
+ model: anthropic.model as t.ChatModel,
235
+ messages,
236
+ provider: Providers.ANTHROPIC,
237
+ });
238
+ await attemptInvoke({
239
+ model: google.model as t.ChatModel,
240
+ messages,
241
+ provider: Providers.GOOGLE,
242
+ });
243
+
244
+ expect(openRouter.invokeMessages[0][1].content).toEqual(
245
+ cachedOutput.content
246
+ );
247
+ expect(openAI.invokeMessages[0][1].content).toBe('result body');
248
+ expect(anthropic.invokeMessages[0][1].content).toEqual(
249
+ cachedOutput.content
250
+ );
251
+ expect(google.invokeMessages[0][1].content).toBe('result body');
252
+ expect(cachedOutput.content).toEqual([
253
+ {
254
+ type: 'text',
255
+ text: 'result body',
256
+ cache_control: { type: 'ephemeral' },
257
+ },
258
+ ]);
259
+ });
260
+
261
+ it('removes foreign cache markers from official OpenAI Chat payloads', async () => {
262
+ const messages = [
263
+ new HumanMessage({
264
+ content: toLangChainContent([
265
+ {
266
+ type: 'text',
267
+ text: 'question',
268
+ cache_control: { type: 'ephemeral' },
269
+ },
270
+ ]),
271
+ }),
272
+ new AIMessage({
273
+ content: toLangChainContent([
274
+ {
275
+ type: 'text',
276
+ text: 'answer',
277
+ cache_control: { type: 'ephemeral' },
278
+ },
279
+ ]),
280
+ }),
281
+ ];
282
+ const { invokeMessages, model } = buildCapturingModel();
283
+
284
+ await attemptInvoke({
285
+ model: model as t.ChatModel,
286
+ messages,
287
+ provider: Providers.OPENAI,
288
+ });
289
+
290
+ const payload = convertMessagesToCompletionsMessageParams({
291
+ messages: invokeMessages[0],
292
+ model: 'gpt-4o',
293
+ });
294
+ expect(JSON.stringify(payload)).not.toContain('cache_control');
295
+ expect(JSON.stringify(messages)).toContain('cache_control');
296
+ });
297
+
298
+ it('removes Bedrock cache points before an Anthropic fallback', async () => {
299
+ const messages = [
300
+ new HumanMessage('search'),
301
+ new AIMessage({
302
+ content: '',
303
+ tool_calls: [{ id: 'call_bedrock_cache', name: 'search', args: {} }],
304
+ }),
305
+ new ToolMessage({
306
+ content: [
307
+ { type: 'text', text: 'result body' },
308
+ { cachePoint: { type: 'default' } },
309
+ ] as ToolMessage['content'],
310
+ tool_call_id: 'call_bedrock_cache',
311
+ }),
312
+ ];
313
+ const { invokeMessages, model } = buildCapturingModel();
314
+
315
+ await attemptInvoke({
316
+ model: model as t.ChatModel,
317
+ messages,
318
+ provider: Providers.ANTHROPIC,
319
+ });
320
+
321
+ expect((invokeMessages[0][2] as ToolMessage).content).toBe('result body');
322
+ expect(() =>
323
+ _convertMessagesToAnthropicPayload(invokeMessages[0])
324
+ ).not.toThrow();
325
+ expect(JSON.stringify(invokeMessages[0])).not.toContain('cachePoint');
326
+ expect(JSON.stringify(messages)).toContain('cachePoint');
327
+ });
328
+
329
+ it('preserves error tool metadata while stripping foreign cache markers', async () => {
330
+ const cachedError = new ToolMessage({
331
+ content: toLangChainContent([
332
+ {
333
+ type: 'text',
334
+ text: 'boom',
335
+ cache_control: { type: 'ephemeral' },
336
+ },
337
+ ]),
338
+ tool_call_id: 'call_error',
339
+ name: 'search',
340
+ status: 'error',
341
+ artifact: { source: 'test' },
342
+ metadata: { trace: 'trace-1' },
343
+ });
344
+ const messages = [
345
+ new AIMessage({
346
+ content: '',
347
+ tool_calls: [{ id: 'call_error', name: 'search', args: {} }],
348
+ }),
349
+ cachedError,
350
+ ];
351
+ const { invokeMessages, model } = buildCapturingModel();
352
+
353
+ await attemptInvoke({
354
+ model: model as t.ChatModel,
355
+ messages,
356
+ provider: Providers.GOOGLE,
357
+ });
358
+
359
+ const sent = invokeMessages[0][1] as ToolMessage;
360
+ expect(sent).toMatchObject({
361
+ content: 'boom',
362
+ status: 'error',
363
+ artifact: { source: 'test' },
364
+ metadata: { trace: 'trace-1' },
365
+ });
366
+ expect(convertMessageContentToParts(sent, false, messages)).toEqual([
367
+ expect.objectContaining({
368
+ functionResponse: expect.objectContaining({
369
+ response: { error: { details: 'boom' } },
370
+ }),
371
+ }),
372
+ ]);
373
+ expect(cachedError.content).not.toBe('boom');
374
+ });
375
+
376
+ it('removes per-block cache markers from OpenRouter Responses input', async () => {
377
+ const messages = [
378
+ new HumanMessage({
379
+ content: toLangChainContent([
380
+ {
381
+ type: 'text',
382
+ text: 'question',
383
+ cache_control: { type: 'ephemeral' },
384
+ },
385
+ ]),
386
+ }),
387
+ ];
388
+ const { invokeMessages, model } = buildCapturingModel();
389
+ model.last = { _useResponsesApi: () => true };
390
+
391
+ await attemptInvoke({
392
+ model: model as t.ChatModel,
393
+ messages,
394
+ provider: Providers.OPENROUTER,
395
+ });
396
+
397
+ const input = convertMessagesToResponsesInput({
398
+ messages: invokeMessages[0],
399
+ model: 'openai/gpt-5',
400
+ zdrEnabled: false,
401
+ });
402
+ expect(JSON.stringify(input)).not.toContain('cache_control');
403
+ expect(JSON.stringify(messages)).toContain('cache_control');
404
+ });
405
+
406
+ it.each([
407
+ [Providers.OPENAI, false],
408
+ [Providers.OPENAI, true],
409
+ [Providers.OPENROUTER, false],
410
+ [Providers.OPENROUTER, true],
411
+ [Providers.ANTHROPIC, false],
412
+ [Providers.BEDROCK, false],
413
+ [Providers.GOOGLE, false],
414
+ ])(
415
+ 'drops incomplete streamed tool-input fragments for %s (Responses=%s)',
416
+ async (provider, responses) => {
417
+ let typeGetterCalls = 0;
418
+ const accessorBlock = {};
419
+ Object.defineProperty(accessorBlock, 'type', {
420
+ enumerable: true,
421
+ get() {
422
+ typeGetterCalls++;
423
+ throw new Error('type getter must not run');
424
+ },
425
+ });
426
+ const proxyBlock = new Proxy(
427
+ {},
428
+ {
429
+ getOwnPropertyDescriptor() {
430
+ throw new Error('proxy descriptor trap must not run');
431
+ },
432
+ }
433
+ );
434
+ const streamedToolCall = new AIMessage({
435
+ content: toLangChainContent([
436
+ { type: 'text', text: 'calling' },
437
+ {
438
+ type: 'tool_use',
439
+ id: 'call_streamed',
440
+ name: 'search',
441
+ input: '',
442
+ index: 0,
443
+ },
444
+ {
445
+ type: 'text',
446
+ index: 0,
447
+ input: '{"query":"records"}',
448
+ },
449
+ accessorBlock,
450
+ proxyBlock,
451
+ ]),
452
+ tool_calls: [
453
+ {
454
+ id: 'call_streamed',
455
+ name: 'search',
456
+ args: { query: 'records' },
457
+ type: 'tool_call',
458
+ },
459
+ ],
460
+ });
461
+ const messages = [
462
+ new HumanMessage('run the search'),
463
+ streamedToolCall,
464
+ new ToolMessage({
465
+ content: 'result',
466
+ tool_call_id: 'call_streamed',
467
+ name: 'search',
468
+ }),
469
+ ];
470
+ const { invokeMessages, model } = buildCapturingModel();
471
+ if (responses) {
472
+ model.last = { _useResponsesApi: () => true };
473
+ }
474
+
475
+ await attemptInvoke({
476
+ model: model as t.ChatModel,
477
+ messages,
478
+ provider,
479
+ });
480
+
481
+ const sent = invokeMessages[0][1] as AIMessage;
482
+ expect(sent.content).toEqual([
483
+ { type: 'text', text: 'calling' },
484
+ {
485
+ type: 'tool_use',
486
+ id: 'call_streamed',
487
+ name: 'search',
488
+ input: '',
489
+ index: 0,
490
+ },
491
+ ]);
492
+ expect(sent.tool_calls).toEqual(streamedToolCall.tool_calls);
493
+ expect(typeGetterCalls).toBe(0);
494
+ expect(streamedToolCall.content).toHaveLength(5);
495
+ }
496
+ );
497
+
498
+ it.each([Providers.OPENAI, Providers.OPENROUTER])(
499
+ 'canonicalizes computer screenshots for an actual %s Responses attempt',
500
+ async (provider) => {
501
+ const { invokeMessages, model } = buildCapturingModel();
502
+ model.last = { _useResponsesApi: () => true };
503
+ const computerCall = new AIMessage({
504
+ content: '',
505
+ response_metadata: {
506
+ output: [
507
+ {
508
+ type: 'computer_call',
509
+ call_id: 'computer-output',
510
+ action: { type: 'screenshot' },
511
+ },
512
+ ],
513
+ },
514
+ });
515
+ const computerOutput = new ToolMessage({
516
+ content: 'data:image/png;base64,AA==',
517
+ tool_call_id: 'computer-output',
518
+ additional_kwargs: { type: 'computer_call_output' },
519
+ });
520
+
521
+ await attemptInvoke({
522
+ model: model as t.ChatModel,
523
+ messages: [computerCall, computerOutput],
524
+ provider,
525
+ });
526
+
527
+ expect(invokeMessages[0][1].content).toEqual([
528
+ {
529
+ type: 'input_image',
530
+ image_url: 'data:image/png;base64,AA==',
531
+ },
532
+ ]);
533
+ }
534
+ );
535
+
536
+ it('merges per-call options when selecting Chat versus Responses', async () => {
537
+ const computerCall = new AIMessage({
538
+ content: '',
539
+ response_metadata: {
540
+ output: [
541
+ {
542
+ type: 'computer_call',
543
+ call_id: 'call_per_call_computer',
544
+ action: { type: 'screenshot' },
545
+ },
546
+ ],
547
+ },
548
+ });
549
+ const computerOutput = new ToolMessage({
550
+ content: 'data:image/png;base64,AA==',
551
+ tool_call_id: 'call_per_call_computer',
552
+ additional_kwargs: { type: 'computer_call_output' },
553
+ });
554
+ const responses = buildCapturingModel();
555
+ responses.model.defaultOptions = { tools: [] };
556
+ responses.model._useResponsesApi = (options) =>
557
+ Array.isArray((options as { tools?: unknown[] } | undefined)?.tools) &&
558
+ ((options as { tools: unknown[] }).tools.length ?? 0) > 0;
559
+
560
+ await attemptInvoke(
561
+ {
562
+ model: responses.model as t.ChatModel,
563
+ messages: [computerCall, computerOutput],
564
+ provider: Providers.OPENAI,
565
+ },
566
+ {
567
+ tools: [{ type: 'computer_20251124' }],
568
+ } as unknown as Parameters<typeof attemptInvoke>[1]
569
+ );
570
+
571
+ expect(responses.invokeMessages[0][1].content).toEqual([
572
+ {
573
+ type: 'input_image',
574
+ image_url: 'data:image/png;base64,AA==',
575
+ },
576
+ ]);
577
+
578
+ const chat = buildCapturingModel();
579
+ chat.model.defaultOptions = {
580
+ tools: [{ type: 'computer_20251124' }],
581
+ };
582
+ chat.model._useResponsesApi = responses.model._useResponsesApi;
583
+
584
+ await attemptInvoke(
585
+ {
586
+ model: chat.model as t.ChatModel,
587
+ messages: [computerCall, computerOutput],
588
+ provider: Providers.OPENAI,
589
+ },
590
+ { tools: [] } as unknown as Parameters<typeof attemptInvoke>[1]
591
+ );
592
+
593
+ expect(chat.invokeMessages[0][1].content).toBe(
594
+ '[Computer screenshot omitted for this provider]'
595
+ );
596
+ });
597
+
598
+ it('detects Responses selected by a bound built-in computer tool', async () => {
599
+ const boundModel = new ChatOpenAI({
600
+ apiKey: 'test-key',
601
+ model: 'computer-use-preview',
602
+ useResponsesApi: false,
603
+ }).bindTools([
604
+ openAITools.computerUse({
605
+ displayWidth: 1024,
606
+ displayHeight: 768,
607
+ environment: 'browser',
608
+ execute: async () => 'data:image/png;base64,AA==',
609
+ }),
610
+ ]);
611
+ const invokeMessages: BaseMessage[][] = [];
612
+ Object.defineProperty(boundModel, 'stream', {
613
+ configurable: true,
614
+ value: undefined,
615
+ });
616
+ Object.defineProperty(boundModel, 'invoke', {
617
+ configurable: true,
618
+ value: jest.fn(async (messages: BaseMessage[]) => {
619
+ invokeMessages.push(messages);
620
+ return new AIMessage({ content: 'ok' });
621
+ }),
622
+ });
623
+ const computerCall = new AIMessage({
624
+ content: '',
625
+ response_metadata: {
626
+ output: [
627
+ {
628
+ type: 'computer_call',
629
+ call_id: 'bound-computer-output',
630
+ action: { type: 'screenshot' },
631
+ },
632
+ ],
633
+ },
634
+ });
635
+ const computerOutput = new ToolMessage({
636
+ content: 'data:image/png;base64,AA==',
637
+ tool_call_id: 'bound-computer-output',
638
+ additional_kwargs: { type: 'computer_call_output' },
639
+ });
640
+
641
+ await attemptInvoke({
642
+ model: boundModel as t.ChatModel,
643
+ messages: [computerCall, computerOutput],
644
+ provider: Providers.OPENAI,
645
+ });
646
+
647
+ expect(invokeMessages[0][1].content).toEqual([
648
+ {
649
+ type: 'input_image',
650
+ image_url: 'data:image/png;base64,AA==',
651
+ },
652
+ ]);
653
+ });
654
+
167
655
  it('skips annotation for stale _refKey not present in current run registry (cross-run scenario)', async () => {
168
656
  const registry = new ToolOutputReferenceRegistry();
169
657
  // run-3 registry holds tool0turn0 - the current run's live ref