@librechat/agents 3.2.33 → 3.2.35

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 (133) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +47 -10
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +13 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +121 -3
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/llm/bedrock/index.cjs +21 -2
  8. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/utils/message_outputs.cjs +38 -2
  10. package/dist/cjs/llm/bedrock/utils/message_outputs.cjs.map +1 -1
  11. package/dist/cjs/llm/google/utils/common.cjs +6 -0
  12. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  13. package/dist/cjs/llm/invoke.cjs +49 -8
  14. package/dist/cjs/llm/invoke.cjs.map +1 -1
  15. package/dist/cjs/llm/openai/index.cjs +48 -1
  16. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  17. package/dist/cjs/llm/vertexai/index.cjs +19 -0
  18. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  19. package/dist/cjs/main.cjs +2 -0
  20. package/dist/cjs/messages/content.cjs +12 -14
  21. package/dist/cjs/messages/content.cjs.map +1 -1
  22. package/dist/cjs/messages/prune.cjs +31 -13
  23. package/dist/cjs/messages/prune.cjs.map +1 -1
  24. package/dist/cjs/run.cjs +7 -2
  25. package/dist/cjs/run.cjs.map +1 -1
  26. package/dist/cjs/stream.cjs +20 -2
  27. package/dist/cjs/stream.cjs.map +1 -1
  28. package/dist/cjs/summarization/node.cjs +12 -1
  29. package/dist/cjs/summarization/node.cjs.map +1 -1
  30. package/dist/cjs/tools/ToolNode.cjs +41 -4
  31. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  32. package/dist/cjs/tools/streamedToolCallSeals.cjs +30 -1
  33. package/dist/cjs/tools/streamedToolCallSeals.cjs.map +1 -1
  34. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +138 -2
  35. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  36. package/dist/cjs/utils/tokens.cjs +30 -0
  37. package/dist/cjs/utils/tokens.cjs.map +1 -1
  38. package/dist/esm/agents/AgentContext.mjs +47 -10
  39. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  40. package/dist/esm/common/enum.mjs +13 -0
  41. package/dist/esm/common/enum.mjs.map +1 -1
  42. package/dist/esm/graphs/Graph.mjs +122 -4
  43. package/dist/esm/graphs/Graph.mjs.map +1 -1
  44. package/dist/esm/llm/bedrock/index.mjs +22 -3
  45. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  46. package/dist/esm/llm/bedrock/utils/message_outputs.mjs +38 -3
  47. package/dist/esm/llm/bedrock/utils/message_outputs.mjs.map +1 -1
  48. package/dist/esm/llm/google/utils/common.mjs +6 -0
  49. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  50. package/dist/esm/llm/invoke.mjs +49 -8
  51. package/dist/esm/llm/invoke.mjs.map +1 -1
  52. package/dist/esm/llm/openai/index.mjs +48 -1
  53. package/dist/esm/llm/openai/index.mjs.map +1 -1
  54. package/dist/esm/llm/vertexai/index.mjs +19 -0
  55. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  56. package/dist/esm/main.mjs +3 -3
  57. package/dist/esm/messages/content.mjs +12 -15
  58. package/dist/esm/messages/content.mjs.map +1 -1
  59. package/dist/esm/messages/prune.mjs +31 -13
  60. package/dist/esm/messages/prune.mjs.map +1 -1
  61. package/dist/esm/run.mjs +7 -2
  62. package/dist/esm/run.mjs.map +1 -1
  63. package/dist/esm/stream.mjs +21 -3
  64. package/dist/esm/stream.mjs.map +1 -1
  65. package/dist/esm/summarization/node.mjs +12 -1
  66. package/dist/esm/summarization/node.mjs.map +1 -1
  67. package/dist/esm/tools/ToolNode.mjs +41 -4
  68. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  69. package/dist/esm/tools/streamedToolCallSeals.mjs +25 -2
  70. package/dist/esm/tools/streamedToolCallSeals.mjs.map +1 -1
  71. package/dist/esm/tools/subagent/SubagentExecutor.mjs +138 -2
  72. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  73. package/dist/esm/utils/tokens.mjs +30 -1
  74. package/dist/esm/utils/tokens.mjs.map +1 -1
  75. package/dist/types/agents/AgentContext.d.ts +7 -3
  76. package/dist/types/common/enum.d.ts +13 -0
  77. package/dist/types/graphs/Graph.d.ts +8 -1
  78. package/dist/types/llm/bedrock/utils/index.d.ts +1 -1
  79. package/dist/types/llm/bedrock/utils/message_outputs.d.ts +9 -0
  80. package/dist/types/llm/invoke.d.ts +1 -1
  81. package/dist/types/llm/vertexai/index.d.ts +10 -0
  82. package/dist/types/messages/content.d.ts +5 -0
  83. package/dist/types/messages/prune.d.ts +4 -0
  84. package/dist/types/run.d.ts +1 -0
  85. package/dist/types/tools/ToolNode.d.ts +8 -0
  86. package/dist/types/tools/streamedToolCallSeals.d.ts +5 -1
  87. package/dist/types/tools/subagent/SubagentExecutor.d.ts +11 -1
  88. package/dist/types/types/graph.d.ts +89 -3
  89. package/dist/types/types/run.d.ts +13 -0
  90. package/dist/types/types/tools.d.ts +10 -0
  91. package/dist/types/utils/tokens.d.ts +7 -0
  92. package/package.json +1 -1
  93. package/src/__tests__/stream.eagerEventExecution.test.ts +703 -0
  94. package/src/agents/AgentContext.ts +69 -6
  95. package/src/agents/__tests__/AgentContext.test.ts +6 -2
  96. package/src/common/enum.ts +13 -0
  97. package/src/graphs/Graph.ts +196 -0
  98. package/src/llm/bedrock/index.ts +40 -0
  99. package/src/llm/bedrock/streamSealDispatch.test.ts +158 -0
  100. package/src/llm/bedrock/utils/index.ts +1 -0
  101. package/src/llm/bedrock/utils/message_outputs.test.ts +85 -0
  102. package/src/llm/bedrock/utils/message_outputs.ts +43 -0
  103. package/src/llm/google/utils/common.test.ts +64 -0
  104. package/src/llm/google/utils/common.ts +18 -0
  105. package/src/llm/invoke.test.ts +79 -1
  106. package/src/llm/invoke.ts +58 -4
  107. package/src/llm/openai/index.ts +95 -1
  108. package/src/llm/openai/sequentialToolCallSeals.test.ts +199 -0
  109. package/src/llm/vertexai/index.ts +31 -0
  110. package/src/llm/vertexai/sealStreamedToolCalls.test.ts +88 -0
  111. package/src/llm/vertexai/streamSealDispatch.test.ts +148 -0
  112. package/src/messages/content.ts +24 -32
  113. package/src/messages/prune.ts +39 -2
  114. package/src/run.ts +5 -0
  115. package/src/scripts/subagent-usage-sink.ts +176 -0
  116. package/src/specs/context-accuracy.live.test.ts +409 -0
  117. package/src/specs/context-usage-event.test.ts +117 -0
  118. package/src/specs/context-usage.live.test.ts +297 -0
  119. package/src/specs/prune.test.ts +51 -1
  120. package/src/specs/subagent.test.ts +124 -1
  121. package/src/stream.ts +40 -6
  122. package/src/summarization/__tests__/node.test.ts +60 -1
  123. package/src/summarization/node.ts +20 -1
  124. package/src/tools/ToolNode.ts +85 -3
  125. package/src/tools/__tests__/SubagentExecutor.test.ts +443 -1
  126. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +368 -0
  127. package/src/tools/streamedToolCallSeals.ts +37 -9
  128. package/src/tools/subagent/SubagentExecutor.ts +221 -3
  129. package/src/types/graph.ts +94 -1
  130. package/src/types/run.ts +13 -0
  131. package/src/types/tools.ts +10 -0
  132. package/src/utils/__tests__/apportion.test.ts +32 -0
  133. package/src/utils/tokens.ts +33 -0
@@ -34,6 +34,10 @@ import type { ChatGeneration, ChatResult } from '@langchain/core/outputs';
34
34
  import type { ChatXAIInput } from '@langchain/xai';
35
35
  import type * as t from '@langchain/openai';
36
36
  import type { HeaderValue, HeadersLike } from './types';
37
+ import {
38
+ STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
39
+ OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER,
40
+ } from '@/tools/streamedToolCallSeals';
37
41
  import { isReasoningModel, _convertMessagesToOpenAIParams } from './utils';
38
42
 
39
43
  // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
@@ -670,6 +674,69 @@ export class CustomAzureOpenAIClient extends AzureOpenAIClient {
670
674
  }
671
675
  }
672
676
 
677
+ const OFFICIAL_OPENAI_BASE_URL_PATTERN = /^https:\/\/api\.openai\.com(\/|$)/;
678
+
679
+ /**
680
+ * Official OpenAI (api.openai.com) and Azure OpenAI Chat Completions streams
681
+ * emit tool-call deltas strictly sequentially by index: once a delta for a
682
+ * later index appears, a prior index's arguments never change. Stamping this
683
+ * adapter lets the stream handler seal a prior call for eager execution the
684
+ * moment the next call begins. OpenAI-compatible endpoints (custom baseURL)
685
+ * must NOT be stamped — e.g. live Kimi/Moonshot streams revise prior-index
686
+ * args after advancing — so callers gate on the wire endpoint, not the class.
687
+ */
688
+ function stampSequentialStreamedToolCallAdapter(
689
+ message: BaseMessageChunk
690
+ ): BaseMessageChunk {
691
+ if (
692
+ message instanceof AIMessageChunk &&
693
+ (message.tool_call_chunks?.length ?? 0) > 0
694
+ ) {
695
+ message.response_metadata = {
696
+ ...message.response_metadata,
697
+ [STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
698
+ OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER,
699
+ };
700
+ }
701
+ return message;
702
+ }
703
+
704
+ function isOfficialOpenAIBaseURL(baseURL: string | null | undefined): boolean {
705
+ // The OpenAI SDK falls back to OPENAI_BASE_URL when the client has no
706
+ // explicit baseURL, so an unset constructor value can still route to an
707
+ // OpenAI-compatible endpoint.
708
+ const effectiveBaseURL =
709
+ baseURL != null && baseURL !== '' ? baseURL : process.env.OPENAI_BASE_URL;
710
+ if (effectiveBaseURL == null || effectiveBaseURL === '') {
711
+ return true;
712
+ }
713
+ return OFFICIAL_OPENAI_BASE_URL_PATTERN.test(effectiveBaseURL);
714
+ }
715
+
716
+ const AZURE_FIRST_PARTY_BASE_PATH_PATTERN =
717
+ /^https:\/\/[^/]+\.(openai\.azure\.com|cognitiveservices\.azure\.com|api\.cognitive\.microsoft\.com)(:\d+)?(\/|$)/;
718
+
719
+ /**
720
+ * Azure OpenAI is first-party when requests resolve to an instance-name
721
+ * endpoint or an *.openai.azure.com / *.cognitiveservices.azure.com /
722
+ * regional *.api.cognitive.microsoft.com base path. A custom
723
+ * `clientConfig.baseURL` or a non-Azure `azureOpenAIBasePath` routes through
724
+ * a proxy or Azure-compatible endpoint whose stream contract is unknown, so
725
+ * those are not stamped.
726
+ */
727
+ function isFirstPartyAzureEndpoint(args: {
728
+ baseURL: string | null | undefined;
729
+ azureOpenAIBasePath: string | undefined;
730
+ }): boolean {
731
+ if (args.baseURL != null && args.baseURL !== '') {
732
+ return false;
733
+ }
734
+ if (args.azureOpenAIBasePath == null || args.azureOpenAIBasePath === '') {
735
+ return true;
736
+ }
737
+ return AZURE_FIRST_PARTY_BASE_PATH_PATTERN.test(args.azureOpenAIBasePath);
738
+ }
739
+
673
740
  class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {
674
741
  private includeReasoningContent?: boolean;
675
742
  private includeReasoningDetails?: boolean;
@@ -721,7 +788,7 @@ class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {
721
788
  rawResponse: OpenAIClient.Chat.Completions.ChatCompletionChunk,
722
789
  defaultRole?: OpenAIClient.Chat.ChatCompletionRole
723
790
  ): BaseMessageChunk {
724
- return attachLibreChatDeltaFields(
791
+ const message = attachLibreChatDeltaFields(
725
792
  super._convertCompletionsDeltaToBaseMessageChunk(
726
793
  delta,
727
794
  rawResponse,
@@ -729,6 +796,10 @@ class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {
729
796
  ),
730
797
  delta
731
798
  );
799
+ if (isOfficialOpenAIBaseURL(this.clientConfig.baseURL)) {
800
+ return stampSequentialStreamedToolCallAdapter(message);
801
+ }
802
+ return message;
732
803
  }
733
804
 
734
805
  protected _convertCompletionsMessageToBaseMessage(
@@ -1090,6 +1161,29 @@ class LibreChatAzureOpenAICompletions extends OriginalAzureChatOpenAICompletions
1090
1161
  return getGatedReasoningParams(this.model, this.reasoning, options);
1091
1162
  }
1092
1163
 
1164
+ protected _convertCompletionsDeltaToBaseMessageChunk(
1165
+ delta: Record<string, unknown>,
1166
+ rawResponse: OpenAIClient.Chat.Completions.ChatCompletionChunk,
1167
+ defaultRole?: OpenAIClient.Chat.ChatCompletionRole
1168
+ ): BaseMessageChunk {
1169
+ const message = super._convertCompletionsDeltaToBaseMessageChunk(
1170
+ delta,
1171
+ rawResponse,
1172
+ defaultRole
1173
+ );
1174
+ if (
1175
+ isFirstPartyAzureEndpoint({
1176
+ baseURL: this.clientConfig.baseURL,
1177
+ azureOpenAIBasePath: this.azureOpenAIBasePath,
1178
+ })
1179
+ ) {
1180
+ // First-party Azure OpenAI: same sequential-by-index stream contract
1181
+ // as api.openai.com.
1182
+ return stampSequentialStreamedToolCallAdapter(message);
1183
+ }
1184
+ return message;
1185
+ }
1186
+
1093
1187
  _getClientOptions(
1094
1188
  options: OpenAICoreRequestOptions | undefined
1095
1189
  ): OpenAICoreRequestOptions {
@@ -0,0 +1,199 @@
1
+ import { AIMessageChunk } from '@langchain/core/messages';
2
+ import { expect, test, describe, beforeEach, afterAll } from '@jest/globals';
3
+ import type { BaseMessageChunk } from '@langchain/core/messages';
4
+ import {
5
+ STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
6
+ OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER,
7
+ } from '@/tools/streamedToolCallSeals';
8
+ import { ChatOpenAI, AzureChatOpenAI } from './index';
9
+
10
+ type DeltaConverter = {
11
+ _convertCompletionsDeltaToBaseMessageChunk(
12
+ delta: Record<string, unknown>,
13
+ rawResponse: Record<string, unknown>
14
+ ): BaseMessageChunk;
15
+ };
16
+
17
+ const rawResponse = {
18
+ id: 'chatcmpl-1',
19
+ object: 'chat.completion.chunk',
20
+ created: 1,
21
+ model: 'gpt-5.5',
22
+ choices: [],
23
+ };
24
+
25
+ const toolCallDelta = {
26
+ role: 'assistant',
27
+ tool_calls: [
28
+ {
29
+ index: 0,
30
+ id: 'call_1',
31
+ type: 'function',
32
+ function: { name: 'weather', arguments: '{"ci' },
33
+ },
34
+ ],
35
+ };
36
+
37
+ function convertDelta(
38
+ model: unknown,
39
+ delta: Record<string, unknown>
40
+ ): AIMessageChunk {
41
+ const converter = (model as { completions: DeltaConverter }).completions;
42
+ const message = converter._convertCompletionsDeltaToBaseMessageChunk(
43
+ delta,
44
+ rawResponse
45
+ );
46
+ expect(message).toBeInstanceOf(AIMessageChunk);
47
+ return message as AIMessageChunk;
48
+ }
49
+
50
+ function adapterOf(message: AIMessageChunk): unknown {
51
+ return (message.response_metadata as Record<string, unknown>)[
52
+ STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY
53
+ ];
54
+ }
55
+
56
+ describe('Chat Completions sequential tool-call seal stamping', () => {
57
+ // Both the implementation (OPENAI_BASE_URL fallback) and the Azure
58
+ // constructor (AZURE_OPENAI_BASE_PATH fallback) read the environment, so
59
+ // isolate these vars to keep the suite deterministic across shells.
60
+ const ISOLATED_ENV_VARS = ['OPENAI_BASE_URL', 'AZURE_OPENAI_BASE_PATH'];
61
+ const originalEnv = new Map(
62
+ ISOLATED_ENV_VARS.map((name) => [name, process.env[name]])
63
+ );
64
+
65
+ beforeEach(() => {
66
+ for (const name of ISOLATED_ENV_VARS) {
67
+ delete process.env[name];
68
+ }
69
+ });
70
+
71
+ afterAll(() => {
72
+ for (const [name, value] of originalEnv) {
73
+ if (value == null) {
74
+ delete process.env[name];
75
+ } else {
76
+ process.env[name] = value;
77
+ }
78
+ }
79
+ });
80
+
81
+ test('stamps tool-call deltas when no baseURL is configured (official)', () => {
82
+ const model = new ChatOpenAI({ model: 'gpt-5.5', apiKey: 'test' });
83
+ const message = convertDelta(model, toolCallDelta);
84
+ expect(adapterOf(message)).toBe(
85
+ OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER
86
+ );
87
+ });
88
+
89
+ test('stamps tool-call deltas for an explicit api.openai.com baseURL', () => {
90
+ const model = new ChatOpenAI({
91
+ model: 'gpt-5.5',
92
+ apiKey: 'test',
93
+ configuration: { baseURL: 'https://api.openai.com/v1' },
94
+ });
95
+ const message = convertDelta(model, toolCallDelta);
96
+ expect(adapterOf(message)).toBe(
97
+ OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER
98
+ );
99
+ });
100
+
101
+ test('does not stamp tool-call deltas for OpenAI-compatible endpoints', () => {
102
+ const model = new ChatOpenAI({
103
+ model: 'kimi-k2',
104
+ apiKey: 'test',
105
+ configuration: { baseURL: 'https://api.moonshot.ai/v1' },
106
+ });
107
+ const message = convertDelta(model, toolCallDelta);
108
+ expect(adapterOf(message)).toBeUndefined();
109
+ });
110
+
111
+ test('does not stamp text-only deltas', () => {
112
+ const model = new ChatOpenAI({ model: 'gpt-5.5', apiKey: 'test' });
113
+ const message = convertDelta(model, {
114
+ role: 'assistant',
115
+ content: 'hello',
116
+ });
117
+ expect(adapterOf(message)).toBeUndefined();
118
+ });
119
+
120
+ test('does not stamp when OPENAI_BASE_URL routes to a compatible endpoint', () => {
121
+ process.env.OPENAI_BASE_URL = 'https://api.moonshot.ai/v1';
122
+ const model = new ChatOpenAI({ model: 'gpt-5.5', apiKey: 'test' });
123
+ const message = convertDelta(model, toolCallDelta);
124
+ expect(adapterOf(message)).toBeUndefined();
125
+ });
126
+
127
+ test('stamps when OPENAI_BASE_URL points at api.openai.com', () => {
128
+ process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1';
129
+ const model = new ChatOpenAI({ model: 'gpt-5.5', apiKey: 'test' });
130
+ const message = convertDelta(model, toolCallDelta);
131
+ expect(adapterOf(message)).toBe(
132
+ OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER
133
+ );
134
+ });
135
+
136
+ test('stamps Azure OpenAI tool-call deltas (first-party endpoint)', () => {
137
+ const model = new AzureChatOpenAI({
138
+ azureOpenAIApiKey: 'test',
139
+ azureOpenAIApiInstanceName: 'test-instance',
140
+ azureOpenAIApiDeploymentName: 'test-deployment',
141
+ azureOpenAIApiVersion: '2024-08-01-preview',
142
+ });
143
+ const message = convertDelta(model, toolCallDelta);
144
+ expect(adapterOf(message)).toBe(
145
+ OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER
146
+ );
147
+ });
148
+
149
+ test('stamps Azure deltas for an *.openai.azure.com base path', () => {
150
+ const model = new AzureChatOpenAI({
151
+ azureOpenAIApiKey: 'test',
152
+ azureOpenAIApiDeploymentName: 'test-deployment',
153
+ azureOpenAIApiVersion: '2024-08-01-preview',
154
+ azureOpenAIBasePath:
155
+ 'https://test-resource.openai.azure.com/openai/deployments',
156
+ });
157
+ const message = convertDelta(model, toolCallDelta);
158
+ expect(adapterOf(message)).toBe(
159
+ OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER
160
+ );
161
+ });
162
+
163
+ test('stamps Azure deltas for a regional cognitive services base path', () => {
164
+ const model = new AzureChatOpenAI({
165
+ azureOpenAIApiKey: 'test',
166
+ azureOpenAIApiDeploymentName: 'test-deployment',
167
+ azureOpenAIApiVersion: '2024-08-01-preview',
168
+ azureOpenAIBasePath:
169
+ 'https://westeurope.api.cognitive.microsoft.com/openai/deployments',
170
+ });
171
+ const message = convertDelta(model, toolCallDelta);
172
+ expect(adapterOf(message)).toBe(
173
+ OPENAI_CHAT_SEQUENTIAL_STREAMED_TOOL_CALL_ADAPTER
174
+ );
175
+ });
176
+
177
+ test('does not stamp Azure deltas routed through a proxy base path', () => {
178
+ const model = new AzureChatOpenAI({
179
+ azureOpenAIApiKey: 'test',
180
+ azureOpenAIApiDeploymentName: 'test-deployment',
181
+ azureOpenAIApiVersion: '2024-08-01-preview',
182
+ azureOpenAIBasePath: 'https://proxy.example.com/openai/deployments',
183
+ });
184
+ const message = convertDelta(model, toolCallDelta);
185
+ expect(adapterOf(message)).toBeUndefined();
186
+ });
187
+
188
+ test('does not stamp Azure deltas with a custom client baseURL', () => {
189
+ const model = new AzureChatOpenAI({
190
+ azureOpenAIApiKey: 'test',
191
+ azureOpenAIApiInstanceName: 'test-instance',
192
+ azureOpenAIApiDeploymentName: 'test-deployment',
193
+ azureOpenAIApiVersion: '2024-08-01-preview',
194
+ configuration: { baseURL: 'https://gateway.example.com/azure' },
195
+ } as unknown as ConstructorParameters<typeof AzureChatOpenAI>[0]);
196
+ const message = convertDelta(model, toolCallDelta);
197
+ expect(adapterOf(message)).toBeUndefined();
198
+ });
199
+ });
@@ -11,6 +11,11 @@ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager
11
11
  import type { BaseMessage, UsageMetadata } from '@langchain/core/messages';
12
12
  import type { ChatGenerationChunk } from '@langchain/core/outputs';
13
13
  import type { GoogleThinkingConfig, VertexAIClientOptions } from '@/types';
14
+ import {
15
+ STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
16
+ STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
17
+ GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
18
+ } from '@/tools/streamedToolCallSeals';
14
19
 
15
20
  /**
16
21
  * `@langchain/google-common`'s `_streamResponseChunks` emits usage on TWO
@@ -48,6 +53,31 @@ export function repairStreamUsageMetadata(
48
53
  return generationInfoUsage;
49
54
  }
50
55
 
56
+ /**
57
+ * The Gemini API delivers function calls as complete objects — never as
58
+ * partial arg deltas. `@langchain/google-common` pre-parses each streamed
59
+ * functionCall part into `tool_calls` (invalid args land in
60
+ * `invalid_tool_calls` instead), so a chunk whose tool-call chunks all parsed
61
+ * cleanly is sealed on arrival for eager tool execution. Anything that fails
62
+ * the parse check is left unstamped and falls back to the lazy path.
63
+ */
64
+ export function sealCompleteStreamedToolCalls(message: AIMessageChunk): void {
65
+ const chunkCount = message.tool_call_chunks?.length ?? 0;
66
+ if (
67
+ chunkCount === 0 ||
68
+ (message.invalid_tool_calls?.length ?? 0) > 0 ||
69
+ (message.tool_calls?.length ?? 0) !== chunkCount
70
+ ) {
71
+ return;
72
+ }
73
+ message.response_metadata = {
74
+ ...message.response_metadata,
75
+ [STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
76
+ GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
77
+ [STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: { kind: 'all' },
78
+ };
79
+ }
80
+
51
81
  type AdditionalKwargs =
52
82
  | undefined
53
83
  | (BaseMessage['additional_kwargs'] & {
@@ -503,6 +533,7 @@ export class ChatVertexAI extends ChatGoogle {
503
533
  if (repaired !== chunk.message.usage_metadata) {
504
534
  chunk.message.usage_metadata = repaired;
505
535
  }
536
+ sealCompleteStreamedToolCalls(chunk.message);
506
537
  }
507
538
  yield chunk;
508
539
  }
@@ -0,0 +1,88 @@
1
+ import { expect, test, describe } from '@jest/globals';
2
+ import { AIMessageChunk } from '@langchain/core/messages';
3
+ import {
4
+ STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
5
+ STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
6
+ GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
7
+ } from '@/tools/streamedToolCallSeals';
8
+ import { sealCompleteStreamedToolCalls } from './index';
9
+
10
+ describe('sealCompleteStreamedToolCalls', () => {
11
+ test('stamps an on-arrival seal when every tool-call chunk parsed cleanly', () => {
12
+ const message = new AIMessageChunk({
13
+ content: '',
14
+ tool_call_chunks: [
15
+ {
16
+ id: 'call_1',
17
+ name: 'weather',
18
+ args: '{"city":"NYC"}',
19
+ type: 'tool_call_chunk',
20
+ },
21
+ ],
22
+ });
23
+
24
+ sealCompleteStreamedToolCalls(message);
25
+
26
+ expect(message.response_metadata).toMatchObject({
27
+ [STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
28
+ GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
29
+ [STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: { kind: 'all' },
30
+ });
31
+ });
32
+
33
+ test('stamps multi-call chunks when all calls are complete', () => {
34
+ const message = new AIMessageChunk({
35
+ content: '',
36
+ tool_call_chunks: [
37
+ {
38
+ id: 'call_1',
39
+ name: 'weather',
40
+ args: '{"city":"NYC"}',
41
+ type: 'tool_call_chunk',
42
+ },
43
+ {
44
+ id: 'call_2',
45
+ name: 'stock',
46
+ args: '{"ticker":"CH"}',
47
+ type: 'tool_call_chunk',
48
+ },
49
+ ],
50
+ });
51
+
52
+ sealCompleteStreamedToolCalls(message);
53
+
54
+ expect(
55
+ message.response_metadata[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]
56
+ ).toEqual({ kind: 'all' });
57
+ });
58
+
59
+ test('leaves chunks without tool calls unstamped', () => {
60
+ const message = new AIMessageChunk({ content: 'hello' });
61
+
62
+ sealCompleteStreamedToolCalls(message);
63
+
64
+ expect(
65
+ message.response_metadata[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]
66
+ ).toBeUndefined();
67
+ });
68
+
69
+ test('leaves chunks with unparsable tool calls unstamped', () => {
70
+ // No id forces the parse into invalid_tool_calls.
71
+ const message = new AIMessageChunk({
72
+ content: '',
73
+ tool_call_chunks: [
74
+ {
75
+ name: 'weather',
76
+ args: '{"city":',
77
+ type: 'tool_call_chunk',
78
+ },
79
+ ],
80
+ });
81
+
82
+ sealCompleteStreamedToolCalls(message);
83
+
84
+ expect(
85
+ message.response_metadata[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]
86
+ ).toBeUndefined();
87
+ });
88
+ });
@@ -0,0 +1,148 @@
1
+ import { expect, test, describe, jest } from '@jest/globals';
2
+ import { HumanMessage, AIMessageChunk } from '@langchain/core/messages';
3
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
4
+ import type { ChatGenerationChunk } from '@langchain/core/outputs';
5
+ import {
6
+ STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
7
+ STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
8
+ GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
9
+ } from '@/tools/streamedToolCallSeals';
10
+ import { ChatVertexAI } from './index';
11
+
12
+ /**
13
+ * Registered stream handlers consume chunks through `handleLLMNewToken`
14
+ * callback events. `@langchain/google-common` yields each chunk BEFORE
15
+ * dispatching that callback, and the generator only resumes (firing the
16
+ * callback) after this package's `_streamResponseChunks` override has
17
+ * stamped the seal on the same message object — so callback consumers must
18
+ * observe sealed chunks. This drives the real google-common stream loop and
19
+ * conversion with a stubbed connection to lock that ordering in.
20
+ */
21
+ describe('Vertex stream seal dispatch', () => {
22
+ async function runStream(outputs: unknown[]): Promise<{
23
+ yielded: AIMessageChunk[];
24
+ dispatched: AIMessageChunk[];
25
+ }> {
26
+ const model = new ChatVertexAI({
27
+ model: 'gemini-2.5-flash',
28
+ authOptions: {
29
+ projectId: 'test-project',
30
+ credentials: { client_email: 'test@test', private_key: 'test' },
31
+ },
32
+ });
33
+
34
+ let index = 0;
35
+ const fakeStream = {
36
+ get streamDone(): boolean {
37
+ return index > outputs.length;
38
+ },
39
+ async nextChunk(): Promise<unknown> {
40
+ const output = index < outputs.length ? outputs[index] : null;
41
+ index += 1;
42
+ return output;
43
+ },
44
+ };
45
+ (
46
+ model as unknown as {
47
+ streamedConnection: { request: unknown };
48
+ }
49
+ ).streamedConnection.request = jest.fn(async () => ({ data: fakeStream }));
50
+
51
+ const dispatched: AIMessageChunk[] = [];
52
+ const runManager = {
53
+ handleCustomEvent: jest.fn(async () => undefined),
54
+ handleLLMNewToken: jest.fn(
55
+ async (
56
+ _token: string,
57
+ _idx?: unknown,
58
+ _runId?: unknown,
59
+ _parentRunId?: unknown,
60
+ _tags?: unknown,
61
+ fields?: { chunk?: ChatGenerationChunk }
62
+ ) => {
63
+ const message = fields?.chunk?.message;
64
+ if (message instanceof AIMessageChunk) {
65
+ dispatched.push(message);
66
+ }
67
+ }
68
+ ),
69
+ } as unknown as CallbackManagerForLLMRun;
70
+
71
+ const yielded: AIMessageChunk[] = [];
72
+ for await (const chunk of model._streamResponseChunks(
73
+ [new HumanMessage('hi')],
74
+ {} as Parameters<ChatVertexAI['_streamResponseChunks']>[1],
75
+ runManager
76
+ )) {
77
+ if (chunk.message instanceof AIMessageChunk) {
78
+ yielded.push(chunk.message);
79
+ }
80
+ }
81
+ return { yielded, dispatched };
82
+ }
83
+
84
+ test('callback consumers receive function-call chunks already sealed', async () => {
85
+ const { yielded, dispatched } = await runStream([
86
+ {
87
+ candidates: [
88
+ {
89
+ content: {
90
+ role: 'model',
91
+ parts: [
92
+ { functionCall: { name: 'weather', args: { city: 'NYC' } } },
93
+ ],
94
+ },
95
+ index: 0,
96
+ },
97
+ ],
98
+ },
99
+ ]);
100
+
101
+ const metadataOf = (m: AIMessageChunk): Record<string, unknown> =>
102
+ m.response_metadata as Record<string, unknown>;
103
+
104
+ const yieldedCall = yielded.find(
105
+ (m) => (m.tool_call_chunks?.length ?? 0) > 0
106
+ );
107
+ expect(yieldedCall).toBeDefined();
108
+ expect(
109
+ metadataOf(yieldedCall!)[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]
110
+ ).toEqual({ kind: 'all' });
111
+
112
+ const dispatchedCall = dispatched.find(
113
+ (m) => (m.tool_call_chunks?.length ?? 0) > 0
114
+ );
115
+ expect(dispatchedCall).toBeDefined();
116
+ expect(dispatchedCall!.tool_calls?.[0]).toMatchObject({
117
+ name: 'weather',
118
+ args: { city: 'NYC' },
119
+ });
120
+ expect(
121
+ metadataOf(dispatchedCall!)[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]
122
+ ).toEqual({ kind: 'all' });
123
+ expect(
124
+ metadataOf(dispatchedCall!)[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]
125
+ ).toBe(GOOGLE_STREAMED_TOOL_CALL_ADAPTER);
126
+ });
127
+
128
+ test('text-only chunks are not sealed on either path', async () => {
129
+ const { yielded, dispatched } = await runStream([
130
+ {
131
+ candidates: [
132
+ {
133
+ content: { role: 'model', parts: [{ text: 'hello' }] },
134
+ index: 0,
135
+ },
136
+ ],
137
+ },
138
+ ]);
139
+
140
+ const hasSeal = (m: AIMessageChunk): boolean =>
141
+ (m.response_metadata as Record<string, unknown>)[
142
+ STREAMED_TOOL_CALL_SEAL_METADATA_KEY
143
+ ] != null;
144
+
145
+ expect(yielded.some(hasSeal)).toBe(false);
146
+ expect(dispatched.some(hasSeal)).toBe(false);
147
+ });
148
+ });
@@ -1,6 +1,26 @@
1
- import type { BaseMessage } from '@langchain/core/messages';
1
+ import type {
2
+ BaseMessage,
3
+ MessageContentComplex,
4
+ } from '@langchain/core/messages';
2
5
  import { ContentTypes } from '@/common';
3
6
 
7
+ /**
8
+ * Whether {@link formatContentStrings} will flatten this message's content:
9
+ * a human/ai/system message whose content is an array of text-only blocks.
10
+ */
11
+ export const isLegacyConvertible = (message: BaseMessage): boolean => {
12
+ const messageType = message.getType();
13
+ const isValidMessage =
14
+ messageType === 'human' || messageType === 'ai' || messageType === 'system';
15
+ if (!isValidMessage) {
16
+ return false;
17
+ }
18
+ if (!Array.isArray(message.content)) {
19
+ return false;
20
+ }
21
+ return message.content.every((block) => block.type === ContentTypes.TEXT);
22
+ };
23
+
4
24
  /**
5
25
  * Formats an array of messages for LangChain, making sure all content fields are strings
6
26
  * @param {Array<HumanMessage | AIMessage | SystemMessage | ToolMessage>} payload - The array of messages to format.
@@ -13,42 +33,14 @@ export const formatContentStrings = (
13
33
  const result: Array<BaseMessage> = [];
14
34
 
15
35
  for (const message of payload) {
16
- const messageType = message.getType();
17
- const isValidMessage =
18
- messageType === 'human' ||
19
- messageType === 'ai' ||
20
- messageType === 'system';
21
-
22
- if (!isValidMessage) {
23
- result.push(message);
24
- continue;
25
- }
26
-
27
- // If content is already a string, add as-is
28
- if (typeof message.content === 'string') {
29
- result.push(message);
30
- continue;
31
- }
32
-
33
- // If content is not an array, add as-is
34
- if (!Array.isArray(message.content)) {
35
- result.push(message);
36
- continue;
37
- }
38
-
39
- // Check if all content blocks are text type
40
- const allTextBlocks = message.content.every(
41
- (block) => block.type === ContentTypes.TEXT
42
- );
43
-
44
- // Only convert to string if all blocks are text type
45
- if (!allTextBlocks) {
36
+ if (!isLegacyConvertible(message)) {
46
37
  result.push(message);
47
38
  continue;
48
39
  }
49
40
 
50
41
  // Reduce text types to a single string
51
- const content = message.content.reduce((acc, curr) => {
42
+ const blocks = message.content as MessageContentComplex[];
43
+ const content = blocks.reduce((acc, curr) => {
52
44
  if (curr.type === ContentTypes.TEXT) {
53
45
  return `${acc}${curr[ContentTypes.TEXT] || ''}\n`;
54
46
  }