@librechat/agents 3.2.33 → 3.2.34

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 (57) hide show
  1. package/dist/cjs/llm/bedrock/index.cjs +21 -2
  2. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  3. package/dist/cjs/llm/bedrock/utils/message_outputs.cjs +38 -2
  4. package/dist/cjs/llm/bedrock/utils/message_outputs.cjs.map +1 -1
  5. package/dist/cjs/llm/google/utils/common.cjs +6 -0
  6. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  7. package/dist/cjs/llm/openai/index.cjs +48 -1
  8. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  9. package/dist/cjs/llm/vertexai/index.cjs +19 -0
  10. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  11. package/dist/cjs/stream.cjs +20 -2
  12. package/dist/cjs/stream.cjs.map +1 -1
  13. package/dist/cjs/tools/ToolNode.cjs +41 -4
  14. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  15. package/dist/cjs/tools/streamedToolCallSeals.cjs +30 -1
  16. package/dist/cjs/tools/streamedToolCallSeals.cjs.map +1 -1
  17. package/dist/esm/llm/bedrock/index.mjs +22 -3
  18. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  19. package/dist/esm/llm/bedrock/utils/message_outputs.mjs +38 -3
  20. package/dist/esm/llm/bedrock/utils/message_outputs.mjs.map +1 -1
  21. package/dist/esm/llm/google/utils/common.mjs +6 -0
  22. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  23. package/dist/esm/llm/openai/index.mjs +48 -1
  24. package/dist/esm/llm/openai/index.mjs.map +1 -1
  25. package/dist/esm/llm/vertexai/index.mjs +19 -0
  26. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  27. package/dist/esm/stream.mjs +21 -3
  28. package/dist/esm/stream.mjs.map +1 -1
  29. package/dist/esm/tools/ToolNode.mjs +41 -4
  30. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  31. package/dist/esm/tools/streamedToolCallSeals.mjs +25 -2
  32. package/dist/esm/tools/streamedToolCallSeals.mjs.map +1 -1
  33. package/dist/types/llm/bedrock/utils/index.d.ts +1 -1
  34. package/dist/types/llm/bedrock/utils/message_outputs.d.ts +9 -0
  35. package/dist/types/llm/vertexai/index.d.ts +10 -0
  36. package/dist/types/tools/ToolNode.d.ts +8 -0
  37. package/dist/types/tools/streamedToolCallSeals.d.ts +5 -1
  38. package/dist/types/types/tools.d.ts +10 -0
  39. package/package.json +1 -1
  40. package/src/__tests__/stream.eagerEventExecution.test.ts +703 -0
  41. package/src/llm/bedrock/index.ts +40 -0
  42. package/src/llm/bedrock/streamSealDispatch.test.ts +158 -0
  43. package/src/llm/bedrock/utils/index.ts +1 -0
  44. package/src/llm/bedrock/utils/message_outputs.test.ts +85 -0
  45. package/src/llm/bedrock/utils/message_outputs.ts +43 -0
  46. package/src/llm/google/utils/common.test.ts +64 -0
  47. package/src/llm/google/utils/common.ts +18 -0
  48. package/src/llm/openai/index.ts +95 -1
  49. package/src/llm/openai/sequentialToolCallSeals.test.ts +199 -0
  50. package/src/llm/vertexai/index.ts +31 -0
  51. package/src/llm/vertexai/sealStreamedToolCalls.test.ts +88 -0
  52. package/src/llm/vertexai/streamSealDispatch.test.ts +148 -0
  53. package/src/stream.ts +40 -6
  54. package/src/tools/ToolNode.ts +85 -3
  55. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +368 -0
  56. package/src/tools/streamedToolCallSeals.ts +37 -9
  57. package/src/types/tools.ts +10 -0
@@ -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
+ });
package/src/stream.ts CHANGED
@@ -5,6 +5,12 @@ import type { AIMessageChunk } from '@langchain/core/messages';
5
5
  import type { AgentContext } from '@/agents/AgentContext';
6
6
  import type { StandardGraph } from '@/graphs';
7
7
  import type * as t from '@/types';
8
+ import {
9
+ getStreamedToolCallSeal,
10
+ getStreamedToolCallAdapter,
11
+ streamedToolCallAdapterAllowsSequentialSeal,
12
+ type StreamedToolCallSeal,
13
+ } from '@/tools/streamedToolCallSeals';
8
14
  import {
9
15
  ToolCallTypes,
10
16
  ContentTypes,
@@ -15,11 +21,6 @@ import {
15
21
  CODE_EXECUTION_TOOLS,
16
22
  LOCAL_CODING_BUNDLE_NAMES,
17
23
  } from '@/common';
18
- import {
19
- getStreamedToolCallSeal,
20
- getStreamedToolCallAdapter,
21
- type StreamedToolCallSeal,
22
- } from '@/tools/streamedToolCallSeals';
23
24
  import {
24
25
  buildToolExecutionRequestPlan,
25
26
  coerceRecordArgs,
@@ -265,6 +266,21 @@ function hasExplicitStreamedToolCallSeals(
265
266
  );
266
267
  }
267
268
 
269
+ /**
270
+ * True when a provider adapter marked every tool call on this chunk as
271
+ * complete on arrival (seal kind `all`), e.g. Google GenAI / Vertex AI, whose
272
+ * protocol delivers function calls as whole objects rather than arg deltas.
273
+ */
274
+ function hasOnArrivalToolCallSeal(chunk: Partial<AIMessageChunk>): boolean {
275
+ const metadata = chunk.response_metadata as
276
+ | Record<string, unknown>
277
+ | undefined;
278
+ return (
279
+ getStreamedToolCallAdapter(metadata) != null &&
280
+ getStreamedToolCallSeal(metadata)?.kind === 'all'
281
+ );
282
+ }
283
+
268
284
  function hasDirectToolCallInBatch(args: {
269
285
  graph: StandardGraph;
270
286
  agentContext?: AgentContext;
@@ -1405,6 +1421,21 @@ export class ChatModelStreamHandler implements t.EventHandler {
1405
1421
  if (!hasToolCallChunks) {
1406
1422
  pruneEagerToolCallChunkStates({ graph, stepKey, clearStep: true });
1407
1423
  }
1424
+ } else if (
1425
+ hasOnArrivalToolCallSeal(chunk) &&
1426
+ !hasPotentialDirectToolInStreamContext({ graph, agentContext })
1427
+ ) {
1428
+ // Providers like Google never signal `tool_calls`/`tool_use` as the
1429
+ // finish reason, but their adapters seal calls on arrival — prestart
1430
+ // these mid-stream under the same direct-tool guard as streamed
1431
+ // chunk sealing.
1432
+ startEagerToolExecutions({
1433
+ graph,
1434
+ metadata,
1435
+ agentContext,
1436
+ toolCalls: chunk.tool_calls,
1437
+ skipExisting: true,
1438
+ });
1408
1439
  }
1409
1440
  }
1410
1441
 
@@ -1435,7 +1466,10 @@ export class ChatModelStreamHandler implements t.EventHandler {
1435
1466
  chunk.response_metadata as Record<string, unknown> | undefined
1436
1467
  );
1437
1468
  const allowSequentialSeal =
1438
- canPrestartSequentialStreamedToolChunks(agentContext);
1469
+ canPrestartSequentialStreamedToolChunks(agentContext) ||
1470
+ streamedToolCallAdapterAllowsSequentialSeal(
1471
+ chunk.response_metadata as Record<string, unknown> | undefined
1472
+ );
1439
1473
  const canStreamEager =
1440
1474
  (allowSequentialSeal || hasExplicitStreamedToolCallSeals(chunk)) &&
1441
1475
  !hasPotentialDirectToolInStreamContext({ graph, agentContext }) &&
@@ -2476,6 +2476,49 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2476
2476
  }
2477
2477
  }
2478
2478
 
2479
+ /**
2480
+ * Per-call completion fast-path: when the host reports a result
2481
+ * through `onResult` before the batch resolves, emit that call's
2482
+ * completed run step immediately instead of waiting for the slowest
2483
+ * call in the batch. Safe only when nothing can change the result
2484
+ * after execution — post-tool hooks may rewrite output and HITL may
2485
+ * deny a call, so those configurations keep batch-time emission.
2486
+ * Ids are claimed synchronously before the async dispatch and
2487
+ * released if the dispatch fails, letting the batch path re-emit.
2488
+ */
2489
+ const canEmitEarlyCompletions =
2490
+ this.hookRegistry == null && this.humanInTheLoop?.enabled !== true;
2491
+ const earlyCompletionDispatchedIds = new Set<string>();
2492
+ const earlyCompletionDispatches: Array<Promise<void>> = [];
2493
+ const dispatchRequestById = new Map(
2494
+ dispatchRequests.map((request) => [request.id, request])
2495
+ );
2496
+ const onResult = (result: t.ToolExecuteResult): void => {
2497
+ const request =
2498
+ result.toolCallId != null
2499
+ ? dispatchRequestById.get(result.toolCallId)
2500
+ : undefined;
2501
+ if (
2502
+ request == null ||
2503
+ earlyCompletionDispatchedIds.has(result.toolCallId)
2504
+ ) {
2505
+ return;
2506
+ }
2507
+ earlyCompletionDispatchedIds.add(result.toolCallId);
2508
+ earlyCompletionDispatches.push(
2509
+ this.dispatchEarlyToolCompletion(result, request, config).then(
2510
+ (dispatched) => {
2511
+ if (!dispatched) {
2512
+ earlyCompletionDispatchedIds.delete(result.toolCallId);
2513
+ }
2514
+ },
2515
+ () => {
2516
+ earlyCompletionDispatchedIds.delete(result.toolCallId);
2517
+ }
2518
+ )
2519
+ );
2520
+ };
2521
+
2479
2522
  const dispatchPromise =
2480
2523
  dispatchRequests.length === 0
2481
2524
  ? Promise.resolve([] as t.ToolExecuteResult[])
@@ -2506,6 +2549,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2506
2549
  maybeResolve();
2507
2550
  },
2508
2551
  reject,
2552
+ ...(canEmitEarlyCompletions && { onResult }),
2509
2553
  };
2510
2554
 
2511
2555
  void safeDispatchCustomEvent(
@@ -2540,6 +2584,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2540
2584
  eagerResultsPromise,
2541
2585
  dispatchPromise,
2542
2586
  ]);
2587
+ // Settle in-flight early completion dispatches before the batch loop
2588
+ // below decides which completions still need emitting.
2589
+ await Promise.allSettled(earlyCompletionDispatches);
2543
2590
  const eagerCompletionDispatchedIds = new Set(
2544
2591
  eagerResults
2545
2592
  .filter((result) => result.completionDispatched)
@@ -2728,7 +2775,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2728
2775
  });
2729
2776
  }
2730
2777
 
2731
- if (!eagerCompletionDispatchedIds.has(result.toolCallId)) {
2778
+ if (
2779
+ !eagerCompletionDispatchedIds.has(result.toolCallId) &&
2780
+ !earlyCompletionDispatchedIds.has(result.toolCallId)
2781
+ ) {
2732
2782
  await this.dispatchStepCompleted(
2733
2783
  result.toolCallId,
2734
2784
  toolName,
@@ -2946,7 +2996,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2946
2996
  output: string,
2947
2997
  config: RunnableConfig,
2948
2998
  turn?: number
2949
- ): Promise<void> {
2999
+ ): Promise<boolean> {
2950
3000
  const stepId = this.toolCallStepIds?.get(toolCallId) ?? '';
2951
3001
  if (!stepId) {
2952
3002
  // eslint-disable-next-line no-console
@@ -2957,7 +3007,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2957
3007
  );
2958
3008
  }
2959
3009
 
2960
- await safeDispatchCustomEvent(
3010
+ const dispatched = await safeDispatchCustomEvent(
2961
3011
  GraphEvents.ON_RUN_STEP_COMPLETED,
2962
3012
  {
2963
3013
  result: {
@@ -2975,6 +3025,38 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2975
3025
  },
2976
3026
  config
2977
3027
  );
3028
+ return dispatched !== false;
3029
+ }
3030
+
3031
+ /**
3032
+ * Emits the completed run step for a single host-reported result before
3033
+ * the batch resolves. Mirrors the batch loop's output formatting exactly;
3034
+ * callers gate on the no-hooks/no-HITL configuration, so the raw result
3035
+ * content here is also the final content. Returns whether the event was
3036
+ * actually dispatched so the caller can fall back to batch-time emission.
3037
+ */
3038
+ private async dispatchEarlyToolCompletion(
3039
+ result: t.ToolExecuteResult,
3040
+ request: t.ToolCallRequest,
3041
+ config: RunnableConfig
3042
+ ): Promise<boolean> {
3043
+ const output =
3044
+ result.status === 'error'
3045
+ ? `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`
3046
+ : truncateToolResultContent(
3047
+ typeof result.content === 'string'
3048
+ ? result.content
3049
+ : JSON.stringify(result.content),
3050
+ this.maxToolResultChars
3051
+ );
3052
+ return this.dispatchStepCompleted(
3053
+ result.toolCallId,
3054
+ request.name,
3055
+ request.args,
3056
+ output,
3057
+ config,
3058
+ request.turn
3059
+ );
2978
3060
  }
2979
3061
 
2980
3062
  /**