@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.
@@ -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: thinkingPart.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
+ }