@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.
- package/dist/cjs/llm/bedrock/index.cjs +21 -2
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_outputs.cjs +38 -2
- package/dist/cjs/llm/bedrock/utils/message_outputs.cjs.map +1 -1
- package/dist/cjs/llm/google/utils/common.cjs +6 -0
- package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +48 -1
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/vertexai/index.cjs +19 -0
- package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
- package/dist/cjs/stream.cjs +20 -2
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +41 -4
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/streamedToolCallSeals.cjs +30 -1
- package/dist/cjs/tools/streamedToolCallSeals.cjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +22 -3
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_outputs.mjs +38 -3
- package/dist/esm/llm/bedrock/utils/message_outputs.mjs.map +1 -1
- package/dist/esm/llm/google/utils/common.mjs +6 -0
- package/dist/esm/llm/google/utils/common.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +48 -1
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/vertexai/index.mjs +19 -0
- package/dist/esm/llm/vertexai/index.mjs.map +1 -1
- package/dist/esm/stream.mjs +21 -3
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +41 -4
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/streamedToolCallSeals.mjs +25 -2
- package/dist/esm/tools/streamedToolCallSeals.mjs.map +1 -1
- package/dist/types/llm/bedrock/utils/index.d.ts +1 -1
- package/dist/types/llm/bedrock/utils/message_outputs.d.ts +9 -0
- package/dist/types/llm/vertexai/index.d.ts +10 -0
- package/dist/types/tools/ToolNode.d.ts +8 -0
- package/dist/types/tools/streamedToolCallSeals.d.ts +5 -1
- package/dist/types/types/tools.d.ts +10 -0
- package/package.json +1 -1
- package/src/__tests__/stream.eagerEventExecution.test.ts +703 -0
- package/src/llm/bedrock/index.ts +40 -0
- package/src/llm/bedrock/streamSealDispatch.test.ts +158 -0
- package/src/llm/bedrock/utils/index.ts +1 -0
- package/src/llm/bedrock/utils/message_outputs.test.ts +85 -0
- package/src/llm/bedrock/utils/message_outputs.ts +43 -0
- package/src/llm/google/utils/common.test.ts +64 -0
- package/src/llm/google/utils/common.ts +18 -0
- package/src/llm/openai/index.ts +95 -1
- package/src/llm/openai/sequentialToolCallSeals.test.ts +199 -0
- package/src/llm/vertexai/index.ts +31 -0
- package/src/llm/vertexai/sealStreamedToolCalls.test.ts +88 -0
- package/src/llm/vertexai/streamSealDispatch.test.ts +148 -0
- package/src/stream.ts +40 -6
- package/src/tools/ToolNode.ts +85 -3
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +368 -0
- package/src/tools/streamedToolCallSeals.ts +37 -9
- 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 }) &&
|
package/src/tools/ToolNode.ts
CHANGED
|
@@ -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 (
|
|
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<
|
|
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
|
/**
|