@librechat/agents 3.2.54 → 3.2.56
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/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/cjs/stream.cjs +7 -2
- package/dist/cjs/stream.cjs.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/esm/stream.mjs +7 -2
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/types/llm/truncation.d.ts +45 -0
- package/dist/types/types/tools.d.ts +10 -0
- package/package.json +1 -1
- package/src/__tests__/stream.eagerEventExecution.test.ts +100 -0
- 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
- package/src/stream.ts +19 -1
- package/src/types/tools.ts +10 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AIMessageChunk, BaseMessage } from '@langchain/core/messages';
|
|
2
|
+
import { Providers } from '@/common';
|
|
3
|
+
/**
|
|
4
|
+
* Normalized truncation reasons across providers. Providers disagree on the
|
|
5
|
+
* key and the value: Anthropic/Bedrock use `max_tokens`, OpenAI uses `length`,
|
|
6
|
+
* Google uses `MAX_TOKENS`.
|
|
7
|
+
*/
|
|
8
|
+
export type TruncationStopReason = 'max_tokens' | 'length';
|
|
9
|
+
/**
|
|
10
|
+
* Reads the truncation stop reason off a message, covering every provider
|
|
11
|
+
* shape we stream:
|
|
12
|
+
* - `response_metadata.stopReason` (Bedrock Converse, non-streaming)
|
|
13
|
+
* - `response_metadata.messageStop.stopReason` (Bedrock Converse streaming)
|
|
14
|
+
* - `response_metadata.stop_reason` (Anthropic, non-streaming)
|
|
15
|
+
* - `additional_kwargs.stop_reason` (Anthropic streaming `message_delta`)
|
|
16
|
+
* - `response_metadata.finish_reason` (OpenAI chat-completions / compatible)
|
|
17
|
+
* - `response_metadata.incomplete_details.reason` (OpenAI Responses API)
|
|
18
|
+
* - `response_metadata.finishReason` (Google)
|
|
19
|
+
*
|
|
20
|
+
* Returns the normalized reason when the model stopped because it hit the
|
|
21
|
+
* output token ceiling, otherwise `null`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getTruncationStopReason(message: Pick<BaseMessage, 'response_metadata' | 'additional_kwargs'> | undefined | null): TruncationStopReason | null;
|
|
24
|
+
/**
|
|
25
|
+
* Error raised when a model turn is cut off by the output token limit while it
|
|
26
|
+
* was still emitting a tool call. The arguments are necessarily incomplete, so
|
|
27
|
+
* executing or re-prompting the call would loop on a malformed request. Failing
|
|
28
|
+
* fast with this surfaces an actionable message to the caller instead.
|
|
29
|
+
*/
|
|
30
|
+
export declare class OutputTruncationError extends Error {
|
|
31
|
+
readonly stopReason: TruncationStopReason;
|
|
32
|
+
readonly toolCallNames: string[];
|
|
33
|
+
constructor(stopReason: TruncationStopReason, toolCallNames: string[]);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Throws {@link OutputTruncationError} when `message` was truncated by the
|
|
37
|
+
* output token limit AND still carries a tool call. For providers that stream
|
|
38
|
+
* tool arguments incrementally (Anthropic, Bedrock, OpenAI), a tool call
|
|
39
|
+
* emitted under truncation has incomplete arguments — the tool-use block is the
|
|
40
|
+
* last thing the model streams — so letting it through makes the agent loop on a
|
|
41
|
+
* malformed call. No-ops for normal completions, truncated plain-text turns, and
|
|
42
|
+
* providers that deliver complete tool calls atomically (Google/Vertex), where
|
|
43
|
+
* `MAX_TOKENS` does not imply the arguments were cut off.
|
|
44
|
+
*/
|
|
45
|
+
export declare function assertNotTruncatedToolCall(message: AIMessageChunk | BaseMessage | undefined | null, provider?: Providers): void;
|
|
@@ -31,6 +31,16 @@ export type EagerEventToolExecutionConfig = {
|
|
|
31
31
|
* ToolMessages so provider message ordering is preserved.
|
|
32
32
|
*/
|
|
33
33
|
enabled?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Tool names that must never be started eagerly. Eager execution
|
|
36
|
+
* speculates on tool args before the model turn commits, so
|
|
37
|
+
* side-effecting tools (e.g. file writes) should opt out: a
|
|
38
|
+
* speculative write can land even if the turn is superseded, and a
|
|
39
|
+
* later arg revision would otherwise trip the "changed after eager
|
|
40
|
+
* execution" guard. Excluded calls fall through to normal ToolNode
|
|
41
|
+
* execution with final args.
|
|
42
|
+
*/
|
|
43
|
+
excludeToolNames?: string[];
|
|
34
44
|
};
|
|
35
45
|
export type EagerEventToolExecutionOutcome = {
|
|
36
46
|
results: ToolExecuteResult[];
|
package/package.json
CHANGED
|
@@ -4726,4 +4726,104 @@ describe('ChatModelStreamHandler eager event tool execution', () => {
|
|
|
4726
4726
|
expect(toolExecuteCalls).toHaveLength(0);
|
|
4727
4727
|
expect(graph.eagerEventToolExecutions.size).toBe(0);
|
|
4728
4728
|
});
|
|
4729
|
+
|
|
4730
|
+
it('does not prestart tools listed in excludeToolNames', async () => {
|
|
4731
|
+
const graph = createGraph({
|
|
4732
|
+
eagerEventToolExecution: {
|
|
4733
|
+
enabled: true,
|
|
4734
|
+
excludeToolNames: ['create_file'],
|
|
4735
|
+
},
|
|
4736
|
+
});
|
|
4737
|
+
const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
|
|
4738
|
+
jest
|
|
4739
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
4740
|
+
.mockImplementation(async (event, data): Promise<void> => {
|
|
4741
|
+
if (event !== GraphEvents.ON_TOOL_EXECUTE) {
|
|
4742
|
+
return;
|
|
4743
|
+
}
|
|
4744
|
+
const batch = data as t.ToolExecuteBatchRequest;
|
|
4745
|
+
toolExecuteCalls.push(batch);
|
|
4746
|
+
batch.resolve([
|
|
4747
|
+
{ toolCallId: 'call_file', status: 'success', content: 'ok' },
|
|
4748
|
+
]);
|
|
4749
|
+
});
|
|
4750
|
+
|
|
4751
|
+
await new ChatModelStreamHandler().handle(
|
|
4752
|
+
GraphEvents.CHAT_MODEL_STREAM,
|
|
4753
|
+
{
|
|
4754
|
+
chunk: {
|
|
4755
|
+
content: '',
|
|
4756
|
+
tool_calls: [
|
|
4757
|
+
{
|
|
4758
|
+
id: 'call_file',
|
|
4759
|
+
name: 'create_file',
|
|
4760
|
+
args: { path: '/mnt/data/x.py', content: 'print(1)' },
|
|
4761
|
+
},
|
|
4762
|
+
],
|
|
4763
|
+
response_metadata: finalToolCallResponseMetadata,
|
|
4764
|
+
} as unknown as t.StreamChunk,
|
|
4765
|
+
},
|
|
4766
|
+
{ langgraph_node: 'agent' },
|
|
4767
|
+
graph
|
|
4768
|
+
);
|
|
4769
|
+
|
|
4770
|
+
// Excluded: no eager execution started; the call falls through to ToolNode.
|
|
4771
|
+
expect(toolExecuteCalls).toHaveLength(0);
|
|
4772
|
+
expect(graph.eagerEventToolExecutions.has('call_file')).toBe(false);
|
|
4773
|
+
});
|
|
4774
|
+
|
|
4775
|
+
it('keeps the direct-tool batch guard when an excluded tool is also direct', async () => {
|
|
4776
|
+
// edit_file is both a direct graph tool AND excluded from eager. A mixed
|
|
4777
|
+
// batch with a direct tool must suppress eager for the whole batch, so the
|
|
4778
|
+
// sibling event tool must NOT prestart — excluding edit_file must not hide
|
|
4779
|
+
// it from the batch-level direct-tool guard.
|
|
4780
|
+
const graph = createGraph({
|
|
4781
|
+
eagerEventToolExecution: {
|
|
4782
|
+
enabled: true,
|
|
4783
|
+
excludeToolNames: ['edit_file'],
|
|
4784
|
+
},
|
|
4785
|
+
getAgentContext: jest.fn(() => ({
|
|
4786
|
+
provider: Providers.ANTHROPIC,
|
|
4787
|
+
reasoningKey: 'reasoning',
|
|
4788
|
+
toolDefinitions: [{ name: 'weather' }, { name: 'edit_file' }],
|
|
4789
|
+
graphTools: [{ name: 'edit_file' }],
|
|
4790
|
+
agentId: 'agent_1',
|
|
4791
|
+
})) as unknown as StandardGraph['getAgentContext'],
|
|
4792
|
+
});
|
|
4793
|
+
const toolExecuteCalls: t.ToolExecuteBatchRequest[] = [];
|
|
4794
|
+
jest
|
|
4795
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
4796
|
+
.mockImplementation(async (event, data): Promise<void> => {
|
|
4797
|
+
if (event !== GraphEvents.ON_TOOL_EXECUTE) {
|
|
4798
|
+
return;
|
|
4799
|
+
}
|
|
4800
|
+
toolExecuteCalls.push(data as t.ToolExecuteBatchRequest);
|
|
4801
|
+
(data as t.ToolExecuteBatchRequest).resolve([]);
|
|
4802
|
+
});
|
|
4803
|
+
|
|
4804
|
+
await new ChatModelStreamHandler().handle(
|
|
4805
|
+
GraphEvents.CHAT_MODEL_STREAM,
|
|
4806
|
+
{
|
|
4807
|
+
chunk: {
|
|
4808
|
+
content: '',
|
|
4809
|
+
tool_calls: [
|
|
4810
|
+
{ id: 'call_weather', name: 'weather', args: { city: 'NYC' } },
|
|
4811
|
+
{
|
|
4812
|
+
id: 'call_edit',
|
|
4813
|
+
name: 'edit_file',
|
|
4814
|
+
args: { path: '/mnt/data/x.py' },
|
|
4815
|
+
},
|
|
4816
|
+
],
|
|
4817
|
+
response_metadata: finalToolCallResponseMetadata,
|
|
4818
|
+
} as unknown as t.StreamChunk,
|
|
4819
|
+
},
|
|
4820
|
+
{ langgraph_node: 'agent' },
|
|
4821
|
+
graph
|
|
4822
|
+
);
|
|
4823
|
+
|
|
4824
|
+
// Direct tool in batch suppresses eager for the whole batch.
|
|
4825
|
+
expect(toolExecuteCalls).toHaveLength(0);
|
|
4826
|
+
expect(graph.eagerEventToolExecutions.has('call_weather')).toBe(false);
|
|
4827
|
+
expect(graph.eagerEventToolExecutions.has('call_edit')).toBe(false);
|
|
4828
|
+
});
|
|
4729
4829
|
});
|
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
|
+
}
|