@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.
- package/dist/cjs/agents/AgentContext.cjs +47 -10
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/common/enum.cjs +13 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +121 -3
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- 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/invoke.cjs +49 -8
- package/dist/cjs/llm/invoke.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/main.cjs +2 -0
- package/dist/cjs/messages/content.cjs +12 -14
- package/dist/cjs/messages/content.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +31 -13
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +7 -2
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/stream.cjs +20 -2
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +12 -1
- package/dist/cjs/summarization/node.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/cjs/tools/subagent/SubagentExecutor.cjs +138 -2
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +30 -0
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +47 -10
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/common/enum.mjs +13 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +122 -4
- package/dist/esm/graphs/Graph.mjs.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/invoke.mjs +49 -8
- package/dist/esm/llm/invoke.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/main.mjs +3 -3
- package/dist/esm/messages/content.mjs +12 -15
- package/dist/esm/messages/content.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +31 -13
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +7 -2
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/stream.mjs +21 -3
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +12 -1
- package/dist/esm/summarization/node.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/esm/tools/subagent/SubagentExecutor.mjs +138 -2
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +30 -1
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +7 -3
- package/dist/types/common/enum.d.ts +13 -0
- package/dist/types/graphs/Graph.d.ts +8 -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/invoke.d.ts +1 -1
- package/dist/types/llm/vertexai/index.d.ts +10 -0
- package/dist/types/messages/content.d.ts +5 -0
- package/dist/types/messages/prune.d.ts +4 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +8 -0
- package/dist/types/tools/streamedToolCallSeals.d.ts +5 -1
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +11 -1
- package/dist/types/types/graph.d.ts +89 -3
- package/dist/types/types/run.d.ts +13 -0
- package/dist/types/types/tools.d.ts +10 -0
- package/dist/types/utils/tokens.d.ts +7 -0
- package/package.json +1 -1
- package/src/__tests__/stream.eagerEventExecution.test.ts +703 -0
- package/src/agents/AgentContext.ts +69 -6
- package/src/agents/__tests__/AgentContext.test.ts +6 -2
- package/src/common/enum.ts +13 -0
- package/src/graphs/Graph.ts +196 -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/invoke.test.ts +79 -1
- package/src/llm/invoke.ts +58 -4
- 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/messages/content.ts +24 -32
- package/src/messages/prune.ts +39 -2
- package/src/run.ts +5 -0
- package/src/scripts/subagent-usage-sink.ts +176 -0
- package/src/specs/context-accuracy.live.test.ts +409 -0
- package/src/specs/context-usage-event.test.ts +117 -0
- package/src/specs/context-usage.live.test.ts +297 -0
- package/src/specs/prune.test.ts +51 -1
- package/src/specs/subagent.test.ts +124 -1
- package/src/stream.ts +40 -6
- package/src/summarization/__tests__/node.test.ts +60 -1
- package/src/summarization/node.ts +20 -1
- package/src/tools/ToolNode.ts +85 -3
- package/src/tools/__tests__/SubagentExecutor.test.ts +443 -1
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +368 -0
- package/src/tools/streamedToolCallSeals.ts +37 -9
- package/src/tools/subagent/SubagentExecutor.ts +221 -3
- package/src/types/graph.ts +94 -1
- package/src/types/run.ts +13 -0
- package/src/types/tools.ts +10 -0
- package/src/utils/__tests__/apportion.test.ts +32 -0
- package/src/utils/tokens.ts +33 -0
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
|
|
9
|
+
} from '@/tools/streamedToolCallSeals';
|
|
10
|
+
import { CustomChatBedrockConverse } from './index';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Registered stream handlers consume chunks through `handleLLMNewToken`
|
|
14
|
+
* callback events, not the yielded generator (`attemptInvoke` skips manual
|
|
15
|
+
* dispatch when a handler is registered). These tests drive the Converse
|
|
16
|
+
* stream loop with a stubbed client and assert that toolUse start and stop
|
|
17
|
+
* seal chunks reach BOTH paths.
|
|
18
|
+
*/
|
|
19
|
+
describe('Converse stream seal dispatch', () => {
|
|
20
|
+
async function runStream(
|
|
21
|
+
events: Array<Record<string, unknown>>,
|
|
22
|
+
modelFields: Record<string, unknown> = {}
|
|
23
|
+
): Promise<{
|
|
24
|
+
yielded: AIMessageChunk[];
|
|
25
|
+
dispatched: AIMessageChunk[];
|
|
26
|
+
}> {
|
|
27
|
+
const model = new CustomChatBedrockConverse({
|
|
28
|
+
model: 'anthropic.claude-3-5-sonnet-20240620-v1:0',
|
|
29
|
+
region: 'us-east-1',
|
|
30
|
+
credentials: { accessKeyId: 'test', secretAccessKey: 'test' },
|
|
31
|
+
...modelFields,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
(model as unknown as { client: { send: unknown } }).client.send = jest.fn(
|
|
35
|
+
async () => ({
|
|
36
|
+
stream: (async function* () {
|
|
37
|
+
yield* events;
|
|
38
|
+
})(),
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const dispatched: AIMessageChunk[] = [];
|
|
43
|
+
const runManager = {
|
|
44
|
+
handleLLMNewToken: jest.fn(
|
|
45
|
+
async (
|
|
46
|
+
_token: string,
|
|
47
|
+
_idx?: unknown,
|
|
48
|
+
_runId?: unknown,
|
|
49
|
+
_parentRunId?: unknown,
|
|
50
|
+
_tags?: unknown,
|
|
51
|
+
fields?: { chunk?: ChatGenerationChunk }
|
|
52
|
+
) => {
|
|
53
|
+
const message = fields?.chunk?.message;
|
|
54
|
+
if (message instanceof AIMessageChunk) {
|
|
55
|
+
dispatched.push(message);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
} as unknown as CallbackManagerForLLMRun;
|
|
60
|
+
|
|
61
|
+
const yielded: AIMessageChunk[] = [];
|
|
62
|
+
for await (const chunk of model._streamResponseChunks(
|
|
63
|
+
[new HumanMessage('hi')],
|
|
64
|
+
{} as Parameters<CustomChatBedrockConverse['_streamResponseChunks']>[1],
|
|
65
|
+
runManager
|
|
66
|
+
)) {
|
|
67
|
+
if (chunk.message instanceof AIMessageChunk) {
|
|
68
|
+
yielded.push(chunk.message);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { yielded, dispatched };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const toolUseEvents = [
|
|
75
|
+
{
|
|
76
|
+
contentBlockStart: {
|
|
77
|
+
contentBlockIndex: 1,
|
|
78
|
+
start: { toolUse: { toolUseId: 'call_1', name: 'weather' } },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
contentBlockDelta: {
|
|
83
|
+
contentBlockIndex: 1,
|
|
84
|
+
delta: { toolUse: { input: '{"city":"NYC"}' } },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{ contentBlockStop: { contentBlockIndex: 1 } },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
test('dispatches toolUse start and stop seal chunks to callbacks', async () => {
|
|
91
|
+
const { yielded, dispatched } = await runStream(toolUseEvents);
|
|
92
|
+
|
|
93
|
+
const sealOf = (m: AIMessageChunk): unknown =>
|
|
94
|
+
(m.response_metadata as Record<string, unknown>)[
|
|
95
|
+
STREAMED_TOOL_CALL_SEAL_METADATA_KEY
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
expect(yielded.some((m) => sealOf(m) != null)).toBe(true);
|
|
99
|
+
|
|
100
|
+
expect(dispatched).toHaveLength(3);
|
|
101
|
+
expect(dispatched[0].tool_call_chunks).toMatchObject([
|
|
102
|
+
{ id: 'call_1', name: 'weather', index: 1 },
|
|
103
|
+
]);
|
|
104
|
+
expect(dispatched[1].tool_call_chunks).toMatchObject([
|
|
105
|
+
{ args: '{"city":"NYC"}', index: 1 },
|
|
106
|
+
]);
|
|
107
|
+
expect(sealOf(dispatched[2])).toEqual({ kind: 'single', index: 1 });
|
|
108
|
+
expect(
|
|
109
|
+
(dispatched[2].response_metadata as Record<string, unknown>)[
|
|
110
|
+
STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY
|
|
111
|
+
]
|
|
112
|
+
).toBe(BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('does not emit seal chunks when guardrails are configured', async () => {
|
|
116
|
+
const { yielded, dispatched } = await runStream(toolUseEvents, {
|
|
117
|
+
guardrailConfig: {
|
|
118
|
+
guardrailIdentifier: 'guardrail_1',
|
|
119
|
+
guardrailVersion: '1',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const hasSeal = (m: AIMessageChunk): boolean =>
|
|
124
|
+
(m.response_metadata as Record<string, unknown>)[
|
|
125
|
+
STREAMED_TOOL_CALL_SEAL_METADATA_KEY
|
|
126
|
+
] != null;
|
|
127
|
+
|
|
128
|
+
// Guardrails can reject the turn at messageStop after contentBlockStop,
|
|
129
|
+
// so no eager seal may be emitted — but tool chunks still stream.
|
|
130
|
+
expect(yielded.some(hasSeal)).toBe(false);
|
|
131
|
+
expect(dispatched.some(hasSeal)).toBe(false);
|
|
132
|
+
expect(dispatched).toHaveLength(2);
|
|
133
|
+
expect(dispatched[0].tool_call_chunks).toMatchObject([
|
|
134
|
+
{ id: 'call_1', name: 'weather', index: 1 },
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('does not emit seal chunks for non-toolUse block stops', async () => {
|
|
139
|
+
const { yielded, dispatched } = await runStream([
|
|
140
|
+
{
|
|
141
|
+
contentBlockDelta: {
|
|
142
|
+
contentBlockIndex: 0,
|
|
143
|
+
delta: { text: 'hello' },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{ contentBlockStop: { contentBlockIndex: 0 } },
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
const hasSeal = (m: AIMessageChunk): boolean =>
|
|
150
|
+
(m.response_metadata as Record<string, unknown>)[
|
|
151
|
+
STREAMED_TOOL_CALL_SEAL_METADATA_KEY
|
|
152
|
+
] != null;
|
|
153
|
+
|
|
154
|
+
expect(yielded.some(hasSeal)).toBe(false);
|
|
155
|
+
expect(dispatched.some(hasSeal)).toBe(false);
|
|
156
|
+
expect(dispatched).toHaveLength(1);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { expect, test, describe } from '@jest/globals';
|
|
2
|
+
import { AIMessageChunk } from '@langchain/core/messages';
|
|
3
|
+
import type { ContentBlockDeltaEvent, ContentBlockStartEvent } from '../types';
|
|
4
|
+
import {
|
|
5
|
+
STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
|
|
6
|
+
STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
|
|
7
|
+
BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
|
|
8
|
+
} from '@/tools/streamedToolCallSeals';
|
|
9
|
+
import {
|
|
10
|
+
createConverseToolUseStopChunk,
|
|
11
|
+
handleConverseStreamContentBlockStart,
|
|
12
|
+
handleConverseStreamContentBlockDelta,
|
|
13
|
+
} from './message_outputs';
|
|
14
|
+
|
|
15
|
+
function asAIMessageChunk(message: unknown): AIMessageChunk {
|
|
16
|
+
expect(message).toBeInstanceOf(AIMessageChunk);
|
|
17
|
+
return message as AIMessageChunk;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('Converse streamed tool-call seal metadata', () => {
|
|
21
|
+
test('stamps the adapter on toolUse content block starts', () => {
|
|
22
|
+
const chunk = handleConverseStreamContentBlockStart({
|
|
23
|
+
contentBlockIndex: 1,
|
|
24
|
+
start: {
|
|
25
|
+
toolUse: { toolUseId: 'call_1', name: 'weather' },
|
|
26
|
+
},
|
|
27
|
+
} as ContentBlockStartEvent);
|
|
28
|
+
|
|
29
|
+
const message = asAIMessageChunk(chunk?.message);
|
|
30
|
+
expect(message.response_metadata).toMatchObject({
|
|
31
|
+
contentBlockIndex: 1,
|
|
32
|
+
[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
|
|
33
|
+
BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
|
|
34
|
+
});
|
|
35
|
+
expect(message.tool_call_chunks).toEqual([
|
|
36
|
+
{
|
|
37
|
+
id: 'call_1',
|
|
38
|
+
name: 'weather',
|
|
39
|
+
index: 1,
|
|
40
|
+
type: 'tool_call_chunk',
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('stamps the adapter on toolUse deltas but not text deltas', () => {
|
|
46
|
+
const toolChunk = handleConverseStreamContentBlockDelta({
|
|
47
|
+
contentBlockIndex: 1,
|
|
48
|
+
delta: { toolUse: { input: '{"city":' } },
|
|
49
|
+
} as ContentBlockDeltaEvent);
|
|
50
|
+
const toolMetadata = asAIMessageChunk(toolChunk.message)
|
|
51
|
+
.response_metadata as Record<string, unknown>;
|
|
52
|
+
expect(toolMetadata[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]).toBe(
|
|
53
|
+
BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const textChunk = handleConverseStreamContentBlockDelta({
|
|
57
|
+
contentBlockIndex: 0,
|
|
58
|
+
delta: { text: 'hello' },
|
|
59
|
+
} as ContentBlockDeltaEvent);
|
|
60
|
+
const textMetadata = asAIMessageChunk(textChunk.message)
|
|
61
|
+
.response_metadata as Record<string, unknown>;
|
|
62
|
+
expect(
|
|
63
|
+
textMetadata[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]
|
|
64
|
+
).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('builds an explicit single seal chunk for a stopped toolUse block', () => {
|
|
68
|
+
const chunk = createConverseToolUseStopChunk(2);
|
|
69
|
+
|
|
70
|
+
const message = asAIMessageChunk(chunk.message);
|
|
71
|
+
expect(message.response_metadata).toEqual({
|
|
72
|
+
[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
|
|
73
|
+
BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
|
|
74
|
+
[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: { kind: 'single', index: 2 },
|
|
75
|
+
});
|
|
76
|
+
expect(message.tool_call_chunks).toEqual([
|
|
77
|
+
{
|
|
78
|
+
args: '',
|
|
79
|
+
index: 2,
|
|
80
|
+
type: 'tool_call_chunk',
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
expect(message.content).toBe('');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -17,6 +17,11 @@ import type {
|
|
|
17
17
|
MessageContentReasoningBlockReasoningTextPartial,
|
|
18
18
|
MessageContentReasoningBlockRedacted,
|
|
19
19
|
} from '../types';
|
|
20
|
+
import {
|
|
21
|
+
STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
|
|
22
|
+
STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
|
|
23
|
+
BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
|
|
24
|
+
} from '@/tools/streamedToolCallSeals';
|
|
20
25
|
import { toLangChainContent } from '@/messages/langchain';
|
|
21
26
|
|
|
22
27
|
/**
|
|
@@ -235,6 +240,8 @@ export function handleConverseStreamContentBlockDelta(
|
|
|
235
240
|
],
|
|
236
241
|
response_metadata: {
|
|
237
242
|
contentBlockIndex: contentBlockDelta.contentBlockIndex,
|
|
243
|
+
[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
|
|
244
|
+
BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
|
|
238
245
|
},
|
|
239
246
|
}),
|
|
240
247
|
});
|
|
@@ -292,6 +299,8 @@ export function handleConverseStreamContentBlockStart(
|
|
|
292
299
|
],
|
|
293
300
|
response_metadata: {
|
|
294
301
|
contentBlockIndex: index,
|
|
302
|
+
[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
|
|
303
|
+
BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
|
|
295
304
|
},
|
|
296
305
|
}),
|
|
297
306
|
});
|
|
@@ -301,6 +310,40 @@ export function handleConverseStreamContentBlockStart(
|
|
|
301
310
|
return null;
|
|
302
311
|
}
|
|
303
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Build the chunk emitted when a Converse `contentBlockStop` event closes a
|
|
315
|
+
* toolUse block. The Converse protocol guarantees a block's input is complete
|
|
316
|
+
* at `contentBlockStop`, so this chunk carries an explicit streamed tool-call
|
|
317
|
+
* seal for that block index. The empty `args` delta merges as a no-op into the
|
|
318
|
+
* accumulated tool call; id/name are omitted so the chunk matches the existing
|
|
319
|
+
* entry purely by index.
|
|
320
|
+
*/
|
|
321
|
+
export function createConverseToolUseStopChunk(
|
|
322
|
+
contentBlockIndex: number
|
|
323
|
+
): ChatGenerationChunk {
|
|
324
|
+
return new ChatGenerationChunk({
|
|
325
|
+
text: '',
|
|
326
|
+
message: new AIMessageChunk({
|
|
327
|
+
content: '',
|
|
328
|
+
tool_call_chunks: [
|
|
329
|
+
{
|
|
330
|
+
args: '',
|
|
331
|
+
index: contentBlockIndex,
|
|
332
|
+
type: 'tool_call_chunk',
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
response_metadata: {
|
|
336
|
+
[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
|
|
337
|
+
BEDROCK_CONVERSE_STREAMED_TOOL_CALL_ADAPTER,
|
|
338
|
+
[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: {
|
|
339
|
+
kind: 'single',
|
|
340
|
+
index: contentBlockIndex,
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
}),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
304
347
|
/**
|
|
305
348
|
* Handle a metadata event from Bedrock Converse stream.
|
|
306
349
|
*/
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { expect, test, describe } from '@jest/globals';
|
|
2
|
+
import { AIMessageChunk } from '@langchain/core/messages';
|
|
3
|
+
import type { EnhancedGenerateContentResponse } from '@google/generative-ai';
|
|
4
|
+
import {
|
|
5
|
+
STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
|
|
6
|
+
STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
|
|
7
|
+
GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
|
|
8
|
+
} from '@/tools/streamedToolCallSeals';
|
|
9
|
+
import { convertResponseContentToChatGenerationChunk } from './common';
|
|
10
|
+
|
|
11
|
+
function buildResponse(
|
|
12
|
+
parts: Array<Record<string, unknown>>
|
|
13
|
+
): EnhancedGenerateContentResponse {
|
|
14
|
+
return {
|
|
15
|
+
candidates: [
|
|
16
|
+
{
|
|
17
|
+
content: { role: 'model', parts },
|
|
18
|
+
index: 0,
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
} as unknown as EnhancedGenerateContentResponse;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function asAIMessageChunk(message: unknown): AIMessageChunk {
|
|
25
|
+
expect(message).toBeInstanceOf(AIMessageChunk);
|
|
26
|
+
return message as AIMessageChunk;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('convertResponseContentToChatGenerationChunk seal metadata', () => {
|
|
30
|
+
test('stamps an on-arrival seal on function call chunks', () => {
|
|
31
|
+
const chunk = convertResponseContentToChatGenerationChunk(
|
|
32
|
+
buildResponse([
|
|
33
|
+
{
|
|
34
|
+
functionCall: { name: 'weather', args: { city: 'NYC' } },
|
|
35
|
+
},
|
|
36
|
+
]),
|
|
37
|
+
{ usageMetadata: undefined, index: 0 }
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const message = asAIMessageChunk(chunk?.message);
|
|
41
|
+
expect(message.response_metadata).toMatchObject({
|
|
42
|
+
[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
|
|
43
|
+
GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
|
|
44
|
+
[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: { kind: 'all' },
|
|
45
|
+
});
|
|
46
|
+
expect(message.tool_call_chunks).toHaveLength(1);
|
|
47
|
+
expect(message.tool_calls?.[0]).toMatchObject({
|
|
48
|
+
name: 'weather',
|
|
49
|
+
args: { city: 'NYC' },
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('does not stamp seal metadata on text-only chunks', () => {
|
|
54
|
+
const chunk = convertResponseContentToChatGenerationChunk(
|
|
55
|
+
buildResponse([{ text: 'hello' }]),
|
|
56
|
+
{ usageMetadata: undefined, index: 0 }
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const metadata = asAIMessageChunk(chunk?.message)
|
|
60
|
+
.response_metadata as Record<string, unknown>;
|
|
61
|
+
expect(metadata[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]).toBeUndefined();
|
|
62
|
+
expect(metadata[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -35,6 +35,11 @@ import {
|
|
|
35
35
|
type FunctionDeclarationsTool as GoogleGenerativeAIFunctionDeclarationsTool,
|
|
36
36
|
} from '@google/generative-ai';
|
|
37
37
|
import type { ChatGeneration, ChatResult } from '@langchain/core/outputs';
|
|
38
|
+
import {
|
|
39
|
+
STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
|
|
40
|
+
STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
|
|
41
|
+
GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
|
|
42
|
+
} from '@/tools/streamedToolCallSeals';
|
|
38
43
|
import {
|
|
39
44
|
jsonSchemaToGeminiParameters,
|
|
40
45
|
schemaToGenerativeAIParameters,
|
|
@@ -770,6 +775,18 @@ export function convertResponseContentToChatGenerationChunk(
|
|
|
770
775
|
response.candidates[0]?.finishReason === 'MAX_TOKENS' ||
|
|
771
776
|
response.candidates[0]?.finishReason === 'SAFETY';
|
|
772
777
|
|
|
778
|
+
// The GenAI API delivers function calls as complete objects (never partial
|
|
779
|
+
// arg deltas), so every call on this chunk is sealed on arrival for eager
|
|
780
|
+
// tool execution.
|
|
781
|
+
const response_metadata: Record<string, unknown> | undefined =
|
|
782
|
+
toolCallChunks.length > 0
|
|
783
|
+
? {
|
|
784
|
+
[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY]:
|
|
785
|
+
GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
|
|
786
|
+
[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]: { kind: 'all' },
|
|
787
|
+
}
|
|
788
|
+
: undefined;
|
|
789
|
+
|
|
773
790
|
return new ChatGenerationChunk({
|
|
774
791
|
text,
|
|
775
792
|
message: new AIMessageChunk({
|
|
@@ -779,6 +796,7 @@ export function convertResponseContentToChatGenerationChunk(
|
|
|
779
796
|
// Each chunk can have unique "generationInfo", and merging strategy is unclear,
|
|
780
797
|
// so leave blank for now.
|
|
781
798
|
additional_kwargs,
|
|
799
|
+
response_metadata,
|
|
782
800
|
usage_metadata: isFinalChunk ? extra.usageMetadata : undefined,
|
|
783
801
|
}),
|
|
784
802
|
generationInfo,
|
package/src/llm/invoke.test.ts
CHANGED
|
@@ -12,8 +12,8 @@ import type { BaseMessage } from '@langchain/core/messages';
|
|
|
12
12
|
import type * as t from '@/types';
|
|
13
13
|
import { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
|
|
14
14
|
import { attemptInvoke, tryFallbackProviders } from '@/llm/invoke';
|
|
15
|
+
import { Constants, Providers } from '@/common';
|
|
15
16
|
import { ToolNode } from '@/tools/ToolNode';
|
|
16
|
-
import { Providers } from '@/common';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Minimal stub model shape `attemptInvoke` reads. Either `invoke` or
|
|
@@ -341,6 +341,84 @@ describe('tryFallbackProviders applies the same lazy annotation transform', () =
|
|
|
341
341
|
});
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
+
describe('invocation attribution metadata', () => {
|
|
345
|
+
it('stamps INVOKED_PROVIDER on the config passed to the model', async () => {
|
|
346
|
+
const capturedConfigs: unknown[] = [];
|
|
347
|
+
const model: StubModel = {
|
|
348
|
+
invoke: jest.fn(
|
|
349
|
+
async (_m: BaseMessage[], config?: unknown): Promise<AIMessage> => {
|
|
350
|
+
capturedConfigs.push(config);
|
|
351
|
+
return new AIMessage({ content: 'ok' });
|
|
352
|
+
}
|
|
353
|
+
),
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
await attemptInvoke(
|
|
357
|
+
{
|
|
358
|
+
model: model as t.ChatModel,
|
|
359
|
+
messages: [new HumanMessage('hi')],
|
|
360
|
+
/** A ChatOpenAI-derived provider — `ls_provider` would lie here. */
|
|
361
|
+
provider: Providers.DEEPSEEK,
|
|
362
|
+
},
|
|
363
|
+
{ configurable: { run_id: 'run-attr' }, metadata: { existing: true } }
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const config = capturedConfigs[0] as {
|
|
367
|
+
metadata?: Record<string, unknown>;
|
|
368
|
+
};
|
|
369
|
+
expect(config.metadata?.[Constants.INVOKED_PROVIDER]).toBe(
|
|
370
|
+
Providers.DEEPSEEK
|
|
371
|
+
);
|
|
372
|
+
/** Pre-existing metadata is preserved, not replaced. */
|
|
373
|
+
expect(config.metadata?.existing).toBe(true);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('stamps INVOKED_MODEL from the fallback clientOptions in tryFallbackProviders', async () => {
|
|
377
|
+
const capturedConfigs: unknown[] = [];
|
|
378
|
+
const model: StubModel = {
|
|
379
|
+
invoke: jest.fn(
|
|
380
|
+
async (_m: BaseMessage[], config?: unknown): Promise<AIMessage> => {
|
|
381
|
+
capturedConfigs.push(config);
|
|
382
|
+
return new AIMessage({ content: 'ok' });
|
|
383
|
+
}
|
|
384
|
+
),
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
jest.doMock('@/llm/init', () => ({
|
|
388
|
+
initializeModel: (): unknown => model,
|
|
389
|
+
}));
|
|
390
|
+
jest.resetModules();
|
|
391
|
+
const { tryFallbackProviders: freshTry } = (await import(
|
|
392
|
+
'@/llm/invoke'
|
|
393
|
+
)) as { tryFallbackProviders: typeof tryFallbackProviders };
|
|
394
|
+
|
|
395
|
+
await freshTry({
|
|
396
|
+
fallbacks: [
|
|
397
|
+
{
|
|
398
|
+
provider: Providers.ANTHROPIC,
|
|
399
|
+
clientOptions: { model: 'claude-fallback-1' },
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
messages: [new HumanMessage('hi')],
|
|
403
|
+
primaryError: new Error('primary failed'),
|
|
404
|
+
config: { configurable: { run_id: 'run-attr-fb' } },
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const config = capturedConfigs[0] as {
|
|
408
|
+
metadata?: Record<string, unknown>;
|
|
409
|
+
};
|
|
410
|
+
expect(config.metadata?.[Constants.INVOKED_MODEL]).toBe(
|
|
411
|
+
'claude-fallback-1'
|
|
412
|
+
);
|
|
413
|
+
expect(config.metadata?.[Constants.INVOKED_PROVIDER]).toBe(
|
|
414
|
+
Providers.ANTHROPIC
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
jest.dontMock('@/llm/init');
|
|
418
|
+
jest.resetModules();
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
344
422
|
describe('cross-run hydration through ToolNode + attemptInvoke', () => {
|
|
345
423
|
it('annotates run 2 refs but leaves hydrated run 1 ToolMessages untouched', async () => {
|
|
346
424
|
/**
|
package/src/llm/invoke.ts
CHANGED
|
@@ -6,10 +6,10 @@ import type { BaseMessage } from '@langchain/core/messages';
|
|
|
6
6
|
import type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
|
|
7
7
|
import type * as t from '@/types';
|
|
8
8
|
import { annotateMessagesForLLM } from '@/tools/toolOutputReferences';
|
|
9
|
+
import { Constants, GraphEvents, Providers } from '@/common';
|
|
9
10
|
import { manualToolStreamProviders } from '@/llm/providers';
|
|
10
11
|
import { modifyDeltaProperties } from '@/messages';
|
|
11
12
|
import { ChatModelStreamHandler } from '@/stream';
|
|
12
|
-
import { GraphEvents, Providers } from '@/common';
|
|
13
13
|
import { initializeModel } from '@/llm/init';
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -208,6 +208,23 @@ export async function attemptInvoke(
|
|
|
208
208
|
const runId = config?.configurable?.run_id as string | undefined;
|
|
209
209
|
const messagesForProvider = annotateMessagesForLLM(messages, registry, runId);
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Stamp the provider that is ACTUALLY serving this invocation onto the
|
|
213
|
+
* callback metadata. `attemptInvoke` is the single funnel for primary,
|
|
214
|
+
* fallback, and summarization model calls, so consumers that need
|
|
215
|
+
* provider attribution per call (the subagent usage-capture handler)
|
|
216
|
+
* read this key instead of trusting static agent config — which is
|
|
217
|
+
* wrong for fallback-served calls — or `ls_provider` — which derived
|
|
218
|
+
* providers inherit from their base class.
|
|
219
|
+
*/
|
|
220
|
+
config = {
|
|
221
|
+
...config,
|
|
222
|
+
metadata: {
|
|
223
|
+
...(config?.metadata ?? {}),
|
|
224
|
+
[Constants.INVOKED_PROVIDER]: provider,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
|
|
211
228
|
if (model.stream) {
|
|
212
229
|
const stream = await model.stream(messagesForProvider, config);
|
|
213
230
|
let finalChunk: AIMessageChunk | undefined;
|
|
@@ -224,7 +241,7 @@ export async function attemptInvoke(
|
|
|
224
241
|
});
|
|
225
242
|
}
|
|
226
243
|
} else if (registeredStreamHandler == null) {
|
|
227
|
-
const metadata = config
|
|
244
|
+
const metadata = config.metadata as Record<string, unknown> | undefined;
|
|
228
245
|
const streamHandler = new ChatModelStreamHandler();
|
|
229
246
|
for await (const chunk of stream) {
|
|
230
247
|
const handlingChunk = getStreamHandlingChunk({
|
|
@@ -247,7 +264,7 @@ export async function attemptInvoke(
|
|
|
247
264
|
});
|
|
248
265
|
}
|
|
249
266
|
} else {
|
|
250
|
-
const metadata = config
|
|
267
|
+
const metadata = config.metadata as Record<string, unknown> | undefined;
|
|
251
268
|
for await (const chunk of stream) {
|
|
252
269
|
const handlingChunk = getStreamHandlingChunk({
|
|
253
270
|
current: finalChunk,
|
|
@@ -292,6 +309,25 @@ export async function attemptInvoke(
|
|
|
292
309
|
return { messages: [finalMessage] };
|
|
293
310
|
}
|
|
294
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Best-effort read of the configured model name from client options.
|
|
314
|
+
* Providers disagree on the key (`model` vs `modelName`).
|
|
315
|
+
*/
|
|
316
|
+
function extractClientOptionsModel(
|
|
317
|
+
clientOptions: t.ClientOptions | undefined
|
|
318
|
+
): string | undefined {
|
|
319
|
+
const options = clientOptions as
|
|
320
|
+
| { model?: unknown; modelName?: unknown }
|
|
321
|
+
| undefined;
|
|
322
|
+
if (typeof options?.model === 'string' && options.model !== '') {
|
|
323
|
+
return options.model;
|
|
324
|
+
}
|
|
325
|
+
if (typeof options?.modelName === 'string' && options.modelName !== '') {
|
|
326
|
+
return options.modelName;
|
|
327
|
+
}
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
295
331
|
/**
|
|
296
332
|
* Attempts each fallback provider in order until one succeeds.
|
|
297
333
|
* Throws the last error if all fallbacks fail.
|
|
@@ -321,6 +357,24 @@ export async function tryFallbackProviders({
|
|
|
321
357
|
clientOptions: fb.clientOptions,
|
|
322
358
|
tools,
|
|
323
359
|
});
|
|
360
|
+
/**
|
|
361
|
+
* Stamp the fallback's configured model onto callback metadata so
|
|
362
|
+
* per-call attribution (subagent usage capture) doesn't fall back to
|
|
363
|
+
* the PRIMARY config's model when the provider reports no
|
|
364
|
+
* `ls_model_name`. The serving provider is stamped uniformly by
|
|
365
|
+
* `attemptInvoke` (`INVOKED_PROVIDER`).
|
|
366
|
+
*/
|
|
367
|
+
const fbModelName = extractClientOptionsModel(fb.clientOptions);
|
|
368
|
+
const fbConfig: RunnableConfig | undefined =
|
|
369
|
+
fbModelName == null
|
|
370
|
+
? config
|
|
371
|
+
: {
|
|
372
|
+
...config,
|
|
373
|
+
metadata: {
|
|
374
|
+
...(config?.metadata ?? {}),
|
|
375
|
+
[Constants.INVOKED_MODEL]: fbModelName,
|
|
376
|
+
},
|
|
377
|
+
};
|
|
324
378
|
const result = await attemptInvoke(
|
|
325
379
|
{
|
|
326
380
|
model: fbModel as t.ChatModel,
|
|
@@ -329,7 +383,7 @@ export async function tryFallbackProviders({
|
|
|
329
383
|
context,
|
|
330
384
|
onChunk,
|
|
331
385
|
},
|
|
332
|
-
|
|
386
|
+
fbConfig
|
|
333
387
|
);
|
|
334
388
|
return result;
|
|
335
389
|
} catch (e) {
|