@librechat/agents 3.2.33 → 3.2.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +47 -10
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +13 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +121 -3
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/llm/bedrock/index.cjs +21 -2
  8. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/utils/message_outputs.cjs +38 -2
  10. package/dist/cjs/llm/bedrock/utils/message_outputs.cjs.map +1 -1
  11. package/dist/cjs/llm/google/utils/common.cjs +6 -0
  12. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  13. package/dist/cjs/llm/invoke.cjs +49 -8
  14. package/dist/cjs/llm/invoke.cjs.map +1 -1
  15. package/dist/cjs/llm/openai/index.cjs +48 -1
  16. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  17. package/dist/cjs/llm/vertexai/index.cjs +19 -0
  18. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  19. package/dist/cjs/main.cjs +2 -0
  20. package/dist/cjs/messages/content.cjs +12 -14
  21. package/dist/cjs/messages/content.cjs.map +1 -1
  22. package/dist/cjs/messages/prune.cjs +31 -13
  23. package/dist/cjs/messages/prune.cjs.map +1 -1
  24. package/dist/cjs/run.cjs +7 -2
  25. package/dist/cjs/run.cjs.map +1 -1
  26. package/dist/cjs/stream.cjs +20 -2
  27. package/dist/cjs/stream.cjs.map +1 -1
  28. package/dist/cjs/summarization/node.cjs +12 -1
  29. package/dist/cjs/summarization/node.cjs.map +1 -1
  30. package/dist/cjs/tools/ToolNode.cjs +41 -4
  31. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  32. package/dist/cjs/tools/streamedToolCallSeals.cjs +30 -1
  33. package/dist/cjs/tools/streamedToolCallSeals.cjs.map +1 -1
  34. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +138 -2
  35. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  36. package/dist/cjs/utils/tokens.cjs +30 -0
  37. package/dist/cjs/utils/tokens.cjs.map +1 -1
  38. package/dist/esm/agents/AgentContext.mjs +47 -10
  39. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  40. package/dist/esm/common/enum.mjs +13 -0
  41. package/dist/esm/common/enum.mjs.map +1 -1
  42. package/dist/esm/graphs/Graph.mjs +122 -4
  43. package/dist/esm/graphs/Graph.mjs.map +1 -1
  44. package/dist/esm/llm/bedrock/index.mjs +22 -3
  45. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  46. package/dist/esm/llm/bedrock/utils/message_outputs.mjs +38 -3
  47. package/dist/esm/llm/bedrock/utils/message_outputs.mjs.map +1 -1
  48. package/dist/esm/llm/google/utils/common.mjs +6 -0
  49. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  50. package/dist/esm/llm/invoke.mjs +49 -8
  51. package/dist/esm/llm/invoke.mjs.map +1 -1
  52. package/dist/esm/llm/openai/index.mjs +48 -1
  53. package/dist/esm/llm/openai/index.mjs.map +1 -1
  54. package/dist/esm/llm/vertexai/index.mjs +19 -0
  55. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  56. package/dist/esm/main.mjs +3 -3
  57. package/dist/esm/messages/content.mjs +12 -15
  58. package/dist/esm/messages/content.mjs.map +1 -1
  59. package/dist/esm/messages/prune.mjs +31 -13
  60. package/dist/esm/messages/prune.mjs.map +1 -1
  61. package/dist/esm/run.mjs +7 -2
  62. package/dist/esm/run.mjs.map +1 -1
  63. package/dist/esm/stream.mjs +21 -3
  64. package/dist/esm/stream.mjs.map +1 -1
  65. package/dist/esm/summarization/node.mjs +12 -1
  66. package/dist/esm/summarization/node.mjs.map +1 -1
  67. package/dist/esm/tools/ToolNode.mjs +41 -4
  68. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  69. package/dist/esm/tools/streamedToolCallSeals.mjs +25 -2
  70. package/dist/esm/tools/streamedToolCallSeals.mjs.map +1 -1
  71. package/dist/esm/tools/subagent/SubagentExecutor.mjs +138 -2
  72. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  73. package/dist/esm/utils/tokens.mjs +30 -1
  74. package/dist/esm/utils/tokens.mjs.map +1 -1
  75. package/dist/types/agents/AgentContext.d.ts +7 -3
  76. package/dist/types/common/enum.d.ts +13 -0
  77. package/dist/types/graphs/Graph.d.ts +8 -1
  78. package/dist/types/llm/bedrock/utils/index.d.ts +1 -1
  79. package/dist/types/llm/bedrock/utils/message_outputs.d.ts +9 -0
  80. package/dist/types/llm/invoke.d.ts +1 -1
  81. package/dist/types/llm/vertexai/index.d.ts +10 -0
  82. package/dist/types/messages/content.d.ts +5 -0
  83. package/dist/types/messages/prune.d.ts +4 -0
  84. package/dist/types/run.d.ts +1 -0
  85. package/dist/types/tools/ToolNode.d.ts +8 -0
  86. package/dist/types/tools/streamedToolCallSeals.d.ts +5 -1
  87. package/dist/types/tools/subagent/SubagentExecutor.d.ts +11 -1
  88. package/dist/types/types/graph.d.ts +89 -3
  89. package/dist/types/types/run.d.ts +13 -0
  90. package/dist/types/types/tools.d.ts +10 -0
  91. package/dist/types/utils/tokens.d.ts +7 -0
  92. package/package.json +1 -1
  93. package/src/__tests__/stream.eagerEventExecution.test.ts +703 -0
  94. package/src/agents/AgentContext.ts +69 -6
  95. package/src/agents/__tests__/AgentContext.test.ts +6 -2
  96. package/src/common/enum.ts +13 -0
  97. package/src/graphs/Graph.ts +196 -0
  98. package/src/llm/bedrock/index.ts +40 -0
  99. package/src/llm/bedrock/streamSealDispatch.test.ts +158 -0
  100. package/src/llm/bedrock/utils/index.ts +1 -0
  101. package/src/llm/bedrock/utils/message_outputs.test.ts +85 -0
  102. package/src/llm/bedrock/utils/message_outputs.ts +43 -0
  103. package/src/llm/google/utils/common.test.ts +64 -0
  104. package/src/llm/google/utils/common.ts +18 -0
  105. package/src/llm/invoke.test.ts +79 -1
  106. package/src/llm/invoke.ts +58 -4
  107. package/src/llm/openai/index.ts +95 -1
  108. package/src/llm/openai/sequentialToolCallSeals.test.ts +199 -0
  109. package/src/llm/vertexai/index.ts +31 -0
  110. package/src/llm/vertexai/sealStreamedToolCalls.test.ts +88 -0
  111. package/src/llm/vertexai/streamSealDispatch.test.ts +148 -0
  112. package/src/messages/content.ts +24 -32
  113. package/src/messages/prune.ts +39 -2
  114. package/src/run.ts +5 -0
  115. package/src/scripts/subagent-usage-sink.ts +176 -0
  116. package/src/specs/context-accuracy.live.test.ts +409 -0
  117. package/src/specs/context-usage-event.test.ts +117 -0
  118. package/src/specs/context-usage.live.test.ts +297 -0
  119. package/src/specs/prune.test.ts +51 -1
  120. package/src/specs/subagent.test.ts +124 -1
  121. package/src/stream.ts +40 -6
  122. package/src/summarization/__tests__/node.test.ts +60 -1
  123. package/src/summarization/node.ts +20 -1
  124. package/src/tools/ToolNode.ts +85 -3
  125. package/src/tools/__tests__/SubagentExecutor.test.ts +443 -1
  126. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +368 -0
  127. package/src/tools/streamedToolCallSeals.ts +37 -9
  128. package/src/tools/subagent/SubagentExecutor.ts +221 -3
  129. package/src/types/graph.ts +94 -1
  130. package/src/types/run.ts +13 -0
  131. package/src/types/tools.ts +10 -0
  132. package/src/utils/__tests__/apportion.test.ts +32 -0
  133. package/src/utils/tokens.ts +33 -0
@@ -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
+ });
@@ -10,6 +10,7 @@ export {
10
10
 
11
11
  export {
12
12
  convertConverseMessageToLangChainMessage,
13
+ createConverseToolUseStopChunk,
13
14
  handleConverseStreamContentBlockStart,
14
15
  handleConverseStreamContentBlockDelta,
15
16
  handleConverseStreamMetadata,
@@ -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,
@@ -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?.metadata as Record<string, unknown> | undefined;
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?.metadata as Record<string, unknown> | undefined;
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
- config
386
+ fbConfig
333
387
  );
334
388
  return result;
335
389
  } catch (e) {