@librechat/agents 3.2.53 → 3.2.55
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/anthropic/utils/message_inputs.cjs +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +3 -0
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/truncation.cjs +110 -0
- package/dist/cjs/llm/truncation.cjs.map +1 -0
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +3 -0
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/truncation.mjs +110 -0
- package/dist/esm/llm/truncation.mjs.map +1 -0
- package/dist/types/llm/truncation.d.ts +45 -0
- package/package.json +1 -1
- package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +66 -0
- package/src/llm/anthropic/utils/message_inputs.ts +9 -1
- package/src/llm/invoke.ts +3 -0
- package/src/llm/truncation.test.ts +242 -0
- package/src/llm/truncation.ts +178 -0
- package/src/specs/bedrock-truncation.live.test.ts +191 -0
|
@@ -315,3 +315,69 @@ describe('_convertMessagesToAnthropicPayload — cross-provider reasoning blocks
|
|
|
315
315
|
expect(blocks.every((b) => b.type === 'text')).toBe(true);
|
|
316
316
|
});
|
|
317
317
|
});
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Regression for Opus 4.7+ "thinking omitted by default": the Messages API
|
|
321
|
+
* returns a signed `thinking` block whose `thinking` text is absent (only the
|
|
322
|
+
* signature carries the encrypted reasoning). Forwarding it as
|
|
323
|
+
* `{ thinking: undefined, signature }` drops the field at JSON.stringify time
|
|
324
|
+
* and the API rejects the replay with
|
|
325
|
+
* `messages.N.content.M.thinking.thinking: Field required`. The converter must
|
|
326
|
+
* coerce the missing text to '' so the required field is always present.
|
|
327
|
+
*/
|
|
328
|
+
describe('_convertMessagesToAnthropicPayload — signed text-less thinking (Opus 4.7+ omitted)', () => {
|
|
329
|
+
interface ThinkingBlock {
|
|
330
|
+
type?: string;
|
|
331
|
+
thinking?: string;
|
|
332
|
+
signature?: string;
|
|
333
|
+
text?: string;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const signedTextlessHistory = (): BaseMessage[] => [
|
|
337
|
+
new HumanMessage('summarize the repo'),
|
|
338
|
+
new AIMessage({
|
|
339
|
+
content: [
|
|
340
|
+
{ type: 'thinking', signature: 'sig-encrypted-reasoning' },
|
|
341
|
+
{ type: 'text', text: 'Here is the summary.' },
|
|
342
|
+
],
|
|
343
|
+
}),
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
it('keeps the signed block and emits an empty (not undefined) thinking field', () => {
|
|
347
|
+
const payload = _convertMessagesToAnthropicPayload(signedTextlessHistory());
|
|
348
|
+
const blocks = assistantBlocks(payload) as ThinkingBlock[];
|
|
349
|
+
const thinking = blocks.find((b) => b.type === 'thinking');
|
|
350
|
+
|
|
351
|
+
expect(thinking).toBeDefined();
|
|
352
|
+
expect(thinking?.signature).toBe('sig-encrypted-reasoning');
|
|
353
|
+
expect(thinking?.thinking).toBe('');
|
|
354
|
+
expect('thinking' in (thinking as object)).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('serializes the thinking field rather than dropping it (the 400 trigger)', () => {
|
|
358
|
+
const payload = _convertMessagesToAnthropicPayload(signedTextlessHistory());
|
|
359
|
+
const blocks = assistantBlocks(payload) as ThinkingBlock[];
|
|
360
|
+
const thinking = blocks.find((b) => b.type === 'thinking');
|
|
361
|
+
|
|
362
|
+
expect(JSON.stringify(thinking)).toContain('"thinking":""');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('still drops an unsigned text-less thinking block (foreign reasoning)', () => {
|
|
366
|
+
const history: BaseMessage[] = [
|
|
367
|
+
new HumanMessage('hi'),
|
|
368
|
+
new AIMessage({
|
|
369
|
+
content: [
|
|
370
|
+
{ type: 'thinking' },
|
|
371
|
+
{ type: 'text', text: 'Hello!' },
|
|
372
|
+
],
|
|
373
|
+
}),
|
|
374
|
+
];
|
|
375
|
+
const blocks = assistantBlocks(
|
|
376
|
+
_convertMessagesToAnthropicPayload(history)
|
|
377
|
+
) as ThinkingBlock[];
|
|
378
|
+
expect(blocks.find((b) => b.type === 'thinking')).toBeUndefined();
|
|
379
|
+
expect(
|
|
380
|
+
blocks.some((b) => b.type === 'text' && b.text === 'Hello!')
|
|
381
|
+
).toBe(true);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -621,9 +621,17 @@ function _formatContent(message: BaseMessage) {
|
|
|
621
621
|
if (isAIMessage(message) && (signature == null || signature === '')) {
|
|
622
622
|
return null;
|
|
623
623
|
}
|
|
624
|
+
// Opus 4.7+ omits thinking text by default (signed but text-less
|
|
625
|
+
// block), so `thinking` may be absent on a signed block even though the
|
|
626
|
+
// type declares it required. Coerce to '' — JSON.stringify drops an
|
|
627
|
+
// undefined value, which would send a `thinking` block missing its
|
|
628
|
+
// required `thinking` field and trip a 400
|
|
629
|
+
// `messages.N.content.M.thinking.thinking: Field required`.
|
|
630
|
+
const thinkingText =
|
|
631
|
+
(thinkingPart as { thinking?: string }).thinking ?? '';
|
|
624
632
|
const block: AnthropicThinkingBlockParam = {
|
|
625
633
|
type: 'thinking' as const, // Explicitly setting the type as "thinking"
|
|
626
|
-
thinking:
|
|
634
|
+
thinking: thinkingText,
|
|
627
635
|
signature: thinkingPart.signature,
|
|
628
636
|
...(cacheControl != null ? { cache_control: cacheControl } : {}),
|
|
629
637
|
};
|
package/src/llm/invoke.ts
CHANGED
|
@@ -6,6 +6,7 @@ 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 { assertNotTruncatedToolCall } from '@/llm/truncation';
|
|
9
10
|
import { Constants, GraphEvents, Providers } from '@/common';
|
|
10
11
|
import { manualToolStreamProviders } from '@/llm/providers';
|
|
11
12
|
import { modifyDeltaProperties } from '@/messages';
|
|
@@ -297,6 +298,7 @@ export async function attemptInvoke(
|
|
|
297
298
|
);
|
|
298
299
|
}
|
|
299
300
|
|
|
301
|
+
assertNotTruncatedToolCall(finalChunk, provider);
|
|
300
302
|
return { messages: [finalChunk as AIMessageChunk] };
|
|
301
303
|
}
|
|
302
304
|
|
|
@@ -306,6 +308,7 @@ export async function attemptInvoke(
|
|
|
306
308
|
(tool_call: ToolCall) => !!tool_call.name
|
|
307
309
|
);
|
|
308
310
|
}
|
|
311
|
+
assertNotTruncatedToolCall(finalMessage, provider);
|
|
309
312
|
return { messages: [finalMessage] };
|
|
310
313
|
}
|
|
311
314
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { AIMessage, AIMessageChunk } from '@langchain/core/messages';
|
|
3
|
+
import {
|
|
4
|
+
OutputTruncationError,
|
|
5
|
+
assertNotTruncatedToolCall,
|
|
6
|
+
getTruncationStopReason,
|
|
7
|
+
} from '@/llm/truncation';
|
|
8
|
+
import { Providers } from '@/common';
|
|
9
|
+
|
|
10
|
+
describe('getTruncationStopReason', () => {
|
|
11
|
+
it('returns null when there is no response metadata', () => {
|
|
12
|
+
expect(getTruncationStopReason(new AIMessage('hi'))).toBeNull();
|
|
13
|
+
expect(getTruncationStopReason(undefined)).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('detects Bedrock Converse streaming shape (messageStop.stopReason)', () => {
|
|
17
|
+
const message = new AIMessageChunk({
|
|
18
|
+
content: '',
|
|
19
|
+
response_metadata: { messageStop: { stopReason: 'max_tokens' } },
|
|
20
|
+
});
|
|
21
|
+
expect(getTruncationStopReason(message)).toBe('max_tokens');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('detects Bedrock Converse non-streaming shape (stopReason)', () => {
|
|
25
|
+
const message = new AIMessage({
|
|
26
|
+
content: '',
|
|
27
|
+
response_metadata: { stopReason: 'max_tokens' },
|
|
28
|
+
});
|
|
29
|
+
expect(getTruncationStopReason(message)).toBe('max_tokens');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('detects Anthropic shape (stop_reason)', () => {
|
|
33
|
+
const message = new AIMessage({
|
|
34
|
+
content: '',
|
|
35
|
+
response_metadata: { stop_reason: 'max_tokens' },
|
|
36
|
+
});
|
|
37
|
+
expect(getTruncationStopReason(message)).toBe('max_tokens');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('detects OpenAI shape (finish_reason: length)', () => {
|
|
41
|
+
const message = new AIMessage({
|
|
42
|
+
content: '',
|
|
43
|
+
response_metadata: { finish_reason: 'length' },
|
|
44
|
+
});
|
|
45
|
+
expect(getTruncationStopReason(message)).toBe('length');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('detects Google shape (finishReason: MAX_TOKENS, case-insensitive)', () => {
|
|
49
|
+
const message = new AIMessage({
|
|
50
|
+
content: '',
|
|
51
|
+
response_metadata: { finishReason: 'MAX_TOKENS' },
|
|
52
|
+
});
|
|
53
|
+
expect(getTruncationStopReason(message)).toBe('max_tokens');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('detects Anthropic streaming shape (additional_kwargs.stop_reason)', () => {
|
|
57
|
+
const message = new AIMessageChunk({
|
|
58
|
+
content: '',
|
|
59
|
+
additional_kwargs: { stop_reason: 'max_tokens' },
|
|
60
|
+
response_metadata: { model_provider: 'anthropic' },
|
|
61
|
+
});
|
|
62
|
+
expect(getTruncationStopReason(message)).toBe('max_tokens');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('detects OpenAI Responses shape (incomplete_details.reason)', () => {
|
|
66
|
+
const message = new AIMessage({
|
|
67
|
+
content: '',
|
|
68
|
+
response_metadata: {
|
|
69
|
+
status: 'incomplete',
|
|
70
|
+
incomplete_details: { reason: 'max_output_tokens' },
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
expect(getTruncationStopReason(message)).toBe('max_tokens');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns null for a non-token incomplete reason (content_filter)', () => {
|
|
77
|
+
const message = new AIMessage({
|
|
78
|
+
content: '',
|
|
79
|
+
response_metadata: {
|
|
80
|
+
status: 'incomplete',
|
|
81
|
+
incomplete_details: { reason: 'content_filter' },
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
expect(getTruncationStopReason(message)).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns null for normal stop reasons', () => {
|
|
88
|
+
expect(
|
|
89
|
+
getTruncationStopReason(
|
|
90
|
+
new AIMessage({
|
|
91
|
+
content: 'x',
|
|
92
|
+
response_metadata: { stopReason: 'end_turn' },
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
).toBeNull();
|
|
96
|
+
expect(
|
|
97
|
+
getTruncationStopReason(
|
|
98
|
+
new AIMessage({
|
|
99
|
+
content: 'x',
|
|
100
|
+
response_metadata: { finish_reason: 'stop' },
|
|
101
|
+
})
|
|
102
|
+
)
|
|
103
|
+
).toBeNull();
|
|
104
|
+
expect(
|
|
105
|
+
getTruncationStopReason(
|
|
106
|
+
new AIMessage({
|
|
107
|
+
content: 'x',
|
|
108
|
+
response_metadata: { stopReason: 'tool_use' },
|
|
109
|
+
})
|
|
110
|
+
)
|
|
111
|
+
).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('assertNotTruncatedToolCall', () => {
|
|
116
|
+
it('no-ops for a normal completion', () => {
|
|
117
|
+
expect(() =>
|
|
118
|
+
assertNotTruncatedToolCall(
|
|
119
|
+
new AIMessage({
|
|
120
|
+
content: 'done',
|
|
121
|
+
response_metadata: { stopReason: 'end_turn' },
|
|
122
|
+
})
|
|
123
|
+
)
|
|
124
|
+
).not.toThrow();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('no-ops for a truncated plain-text turn (no tool calls)', () => {
|
|
128
|
+
expect(() =>
|
|
129
|
+
assertNotTruncatedToolCall(
|
|
130
|
+
new AIMessage({
|
|
131
|
+
content: 'partial...',
|
|
132
|
+
response_metadata: { stopReason: 'max_tokens' },
|
|
133
|
+
})
|
|
134
|
+
)
|
|
135
|
+
).not.toThrow();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('no-ops for a complete tool call (normal stop reason)', () => {
|
|
139
|
+
const message = new AIMessage({
|
|
140
|
+
content: '',
|
|
141
|
+
tool_calls: [
|
|
142
|
+
{ id: '1', name: 'create_file', args: { path: 'a', content: 'b' } },
|
|
143
|
+
],
|
|
144
|
+
response_metadata: { stopReason: 'tool_use' },
|
|
145
|
+
});
|
|
146
|
+
expect(() => assertNotTruncatedToolCall(message)).not.toThrow();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('throws when a tool call is present and the turn was truncated', () => {
|
|
150
|
+
const message = new AIMessage({
|
|
151
|
+
content: '',
|
|
152
|
+
tool_calls: [{ id: '1', name: 'create_file', args: { path: 'a' } }],
|
|
153
|
+
response_metadata: { messageStop: { stopReason: 'max_tokens' } },
|
|
154
|
+
});
|
|
155
|
+
expect(() => assertNotTruncatedToolCall(message)).toThrow(
|
|
156
|
+
OutputTruncationError
|
|
157
|
+
);
|
|
158
|
+
try {
|
|
159
|
+
assertNotTruncatedToolCall(message);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
expect(err).toBeInstanceOf(OutputTruncationError);
|
|
162
|
+
expect((err as OutputTruncationError).stopReason).toBe('max_tokens');
|
|
163
|
+
expect((err as OutputTruncationError).toolCallNames).toContain(
|
|
164
|
+
'create_file'
|
|
165
|
+
);
|
|
166
|
+
expect((err as OutputTruncationError).message).toMatch(
|
|
167
|
+
/max output tokens/i
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('throws when only incomplete tool_call_chunks survived truncation', () => {
|
|
173
|
+
const message = new AIMessageChunk({
|
|
174
|
+
content: '',
|
|
175
|
+
tool_call_chunks: [
|
|
176
|
+
{
|
|
177
|
+
name: 'create_file',
|
|
178
|
+
args: '{"path":"a"',
|
|
179
|
+
index: 0,
|
|
180
|
+
type: 'tool_call_chunk',
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
response_metadata: { messageStop: { stopReason: 'max_tokens' } },
|
|
184
|
+
});
|
|
185
|
+
expect(() => assertNotTruncatedToolCall(message)).toThrow(
|
|
186
|
+
OutputTruncationError
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('still throws for a streaming-arg provider (Anthropic)', () => {
|
|
191
|
+
const message = new AIMessage({
|
|
192
|
+
content: '',
|
|
193
|
+
tool_calls: [{ id: '1', name: 'create_file', args: { path: 'a' } }],
|
|
194
|
+
response_metadata: { stop_reason: 'max_tokens' },
|
|
195
|
+
});
|
|
196
|
+
expect(() =>
|
|
197
|
+
assertNotTruncatedToolCall(message, Providers.ANTHROPIC)
|
|
198
|
+
).toThrow(OutputTruncationError);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('throws for an Anthropic streaming tool call truncated via additional_kwargs', () => {
|
|
202
|
+
const message = new AIMessageChunk({
|
|
203
|
+
content: '',
|
|
204
|
+
tool_calls: [{ id: '1', name: 'create_file', args: { path: 'a' } }],
|
|
205
|
+
additional_kwargs: { stop_reason: 'max_tokens' },
|
|
206
|
+
response_metadata: { model_provider: 'anthropic' },
|
|
207
|
+
});
|
|
208
|
+
expect(() =>
|
|
209
|
+
assertNotTruncatedToolCall(message, Providers.ANTHROPIC)
|
|
210
|
+
).toThrow(OutputTruncationError);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('throws for an OpenAI Responses tool call truncated via incomplete_details', () => {
|
|
214
|
+
const message = new AIMessage({
|
|
215
|
+
content: '',
|
|
216
|
+
tool_calls: [{ id: '1', name: 'create_file', args: { path: 'a' } }],
|
|
217
|
+
response_metadata: {
|
|
218
|
+
status: 'incomplete',
|
|
219
|
+
incomplete_details: { reason: 'max_output_tokens' },
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
expect(() => assertNotTruncatedToolCall(message, Providers.OPENAI)).toThrow(
|
|
223
|
+
OutputTruncationError
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('does not throw for providers that deliver complete tool calls (Google/Vertex)', () => {
|
|
228
|
+
const message = new AIMessage({
|
|
229
|
+
content: '',
|
|
230
|
+
tool_calls: [
|
|
231
|
+
{ id: '1', name: 'create_file', args: { path: 'a', content: 'b' } },
|
|
232
|
+
],
|
|
233
|
+
response_metadata: { finishReason: 'MAX_TOKENS' },
|
|
234
|
+
});
|
|
235
|
+
expect(() =>
|
|
236
|
+
assertNotTruncatedToolCall(message, Providers.GOOGLE)
|
|
237
|
+
).not.toThrow();
|
|
238
|
+
expect(() =>
|
|
239
|
+
assertNotTruncatedToolCall(message, Providers.VERTEXAI)
|
|
240
|
+
).not.toThrow();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { AIMessageChunk, BaseMessage } from '@langchain/core/messages';
|
|
2
|
+
import { Providers } from '@/common';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Providers whose adapters deliver tool calls as complete, atomic objects
|
|
6
|
+
* rather than streamed argument deltas. Google/Vertex (GenAI) emit each
|
|
7
|
+
* `functionCall` whole and seal it on arrival, so a `MAX_TOKENS` finish does
|
|
8
|
+
* NOT imply the arguments were cut off — the truncation guard must skip them.
|
|
9
|
+
*/
|
|
10
|
+
const ATOMIC_TOOL_CALL_ARG_PROVIDERS = new Set<Providers>([
|
|
11
|
+
Providers.GOOGLE,
|
|
12
|
+
Providers.VERTEXAI,
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalized truncation reasons across providers. Providers disagree on the
|
|
17
|
+
* key and the value: Anthropic/Bedrock use `max_tokens`, OpenAI uses `length`,
|
|
18
|
+
* Google uses `MAX_TOKENS`.
|
|
19
|
+
*/
|
|
20
|
+
export type TruncationStopReason = 'max_tokens' | 'length';
|
|
21
|
+
|
|
22
|
+
const MAX_TOKEN_VALUES = new Set([
|
|
23
|
+
'max_tokens',
|
|
24
|
+
'max_token',
|
|
25
|
+
'maxtokens',
|
|
26
|
+
'max_output_tokens',
|
|
27
|
+
]);
|
|
28
|
+
const LENGTH_VALUES = new Set(['length']);
|
|
29
|
+
|
|
30
|
+
function normalizeStopValue(value: unknown): TruncationStopReason | null {
|
|
31
|
+
if (typeof value !== 'string') {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const normalized = value.trim().toLowerCase();
|
|
35
|
+
if (MAX_TOKEN_VALUES.has(normalized)) {
|
|
36
|
+
return 'max_tokens';
|
|
37
|
+
}
|
|
38
|
+
if (LENGTH_VALUES.has(normalized)) {
|
|
39
|
+
return 'length';
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads the truncation stop reason off a message, covering every provider
|
|
46
|
+
* shape we stream:
|
|
47
|
+
* - `response_metadata.stopReason` (Bedrock Converse, non-streaming)
|
|
48
|
+
* - `response_metadata.messageStop.stopReason` (Bedrock Converse streaming)
|
|
49
|
+
* - `response_metadata.stop_reason` (Anthropic, non-streaming)
|
|
50
|
+
* - `additional_kwargs.stop_reason` (Anthropic streaming `message_delta`)
|
|
51
|
+
* - `response_metadata.finish_reason` (OpenAI chat-completions / compatible)
|
|
52
|
+
* - `response_metadata.incomplete_details.reason` (OpenAI Responses API)
|
|
53
|
+
* - `response_metadata.finishReason` (Google)
|
|
54
|
+
*
|
|
55
|
+
* Returns the normalized reason when the model stopped because it hit the
|
|
56
|
+
* output token ceiling, otherwise `null`.
|
|
57
|
+
*/
|
|
58
|
+
export function getTruncationStopReason(
|
|
59
|
+
message:
|
|
60
|
+
| Pick<BaseMessage, 'response_metadata' | 'additional_kwargs'>
|
|
61
|
+
| undefined
|
|
62
|
+
| null
|
|
63
|
+
): TruncationStopReason | null {
|
|
64
|
+
const meta = message?.response_metadata as
|
|
65
|
+
| Record<string, unknown>
|
|
66
|
+
| undefined;
|
|
67
|
+
const additionalKwargs = message?.additional_kwargs as
|
|
68
|
+
| Record<string, unknown>
|
|
69
|
+
| undefined;
|
|
70
|
+
if (meta == null && additionalKwargs == null) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const messageStop = meta?.messageStop as { stopReason?: unknown } | undefined;
|
|
75
|
+
const incompleteDetails = meta?.incomplete_details as
|
|
76
|
+
| { reason?: unknown }
|
|
77
|
+
| undefined;
|
|
78
|
+
const candidates: unknown[] = [
|
|
79
|
+
meta?.stopReason,
|
|
80
|
+
meta?.stop_reason,
|
|
81
|
+
meta?.finish_reason,
|
|
82
|
+
meta?.finishReason,
|
|
83
|
+
messageStop?.stopReason,
|
|
84
|
+
incompleteDetails?.reason,
|
|
85
|
+
additionalKwargs?.stop_reason,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
const normalized = normalizeStopValue(candidate);
|
|
90
|
+
if (normalized != null) {
|
|
91
|
+
return normalized;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasOpenToolCall(message: AIMessageChunk | BaseMessage): boolean {
|
|
98
|
+
const m = message as AIMessageChunk & {
|
|
99
|
+
tool_calls?: unknown[];
|
|
100
|
+
tool_call_chunks?: unknown[];
|
|
101
|
+
invalid_tool_calls?: unknown[];
|
|
102
|
+
};
|
|
103
|
+
return (
|
|
104
|
+
(m.tool_calls?.length ?? 0) > 0 ||
|
|
105
|
+
(m.tool_call_chunks?.length ?? 0) > 0 ||
|
|
106
|
+
(m.invalid_tool_calls?.length ?? 0) > 0
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Error raised when a model turn is cut off by the output token limit while it
|
|
112
|
+
* was still emitting a tool call. The arguments are necessarily incomplete, so
|
|
113
|
+
* executing or re-prompting the call would loop on a malformed request. Failing
|
|
114
|
+
* fast with this surfaces an actionable message to the caller instead.
|
|
115
|
+
*/
|
|
116
|
+
export class OutputTruncationError extends Error {
|
|
117
|
+
readonly stopReason: TruncationStopReason;
|
|
118
|
+
readonly toolCallNames: string[];
|
|
119
|
+
|
|
120
|
+
constructor(stopReason: TruncationStopReason, toolCallNames: string[]) {
|
|
121
|
+
const named =
|
|
122
|
+
toolCallNames.length > 0
|
|
123
|
+
? ` (tool call: ${toolCallNames.join(', ')})`
|
|
124
|
+
: '';
|
|
125
|
+
super(
|
|
126
|
+
'The model response was truncated at the maximum output token limit before ' +
|
|
127
|
+
`the tool call arguments were complete${named}. Increase the model's max ` +
|
|
128
|
+
'output tokens, or have the model produce smaller arguments — for large ' +
|
|
129
|
+
'files, write a lean main file and move bulky content into separate files.'
|
|
130
|
+
);
|
|
131
|
+
this.name = 'OutputTruncationError';
|
|
132
|
+
this.stopReason = stopReason;
|
|
133
|
+
this.toolCallNames = toolCallNames;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function collectToolCallNames(message: AIMessageChunk | BaseMessage): string[] {
|
|
138
|
+
const m = message as AIMessageChunk & {
|
|
139
|
+
tool_calls?: Array<{ name?: string }>;
|
|
140
|
+
tool_call_chunks?: Array<{ name?: string }>;
|
|
141
|
+
invalid_tool_calls?: Array<{ name?: string }>;
|
|
142
|
+
};
|
|
143
|
+
const names = [
|
|
144
|
+
...(m.tool_calls ?? []),
|
|
145
|
+
...(m.tool_call_chunks ?? []),
|
|
146
|
+
...(m.invalid_tool_calls ?? []),
|
|
147
|
+
]
|
|
148
|
+
.map((tc) => tc.name)
|
|
149
|
+
.filter((name): name is string => typeof name === 'string' && name !== '');
|
|
150
|
+
return [...new Set(names)];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Throws {@link OutputTruncationError} when `message` was truncated by the
|
|
155
|
+
* output token limit AND still carries a tool call. For providers that stream
|
|
156
|
+
* tool arguments incrementally (Anthropic, Bedrock, OpenAI), a tool call
|
|
157
|
+
* emitted under truncation has incomplete arguments — the tool-use block is the
|
|
158
|
+
* last thing the model streams — so letting it through makes the agent loop on a
|
|
159
|
+
* malformed call. No-ops for normal completions, truncated plain-text turns, and
|
|
160
|
+
* providers that deliver complete tool calls atomically (Google/Vertex), where
|
|
161
|
+
* `MAX_TOKENS` does not imply the arguments were cut off.
|
|
162
|
+
*/
|
|
163
|
+
export function assertNotTruncatedToolCall(
|
|
164
|
+
message: AIMessageChunk | BaseMessage | undefined | null,
|
|
165
|
+
provider?: Providers
|
|
166
|
+
): void {
|
|
167
|
+
if (message == null) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (provider != null && ATOMIC_TOOL_CALL_ARG_PROVIDERS.has(provider)) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const stopReason = getTruncationStopReason(message);
|
|
174
|
+
if (stopReason == null || !hasOpenToolCall(message)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
throw new OutputTruncationError(stopReason, collectToolCallNames(message));
|
|
178
|
+
}
|