Skip to content

Commit 70d3980

Browse files
fix(ai): use errorMode 'text' in approval continuation to preserve tool error messages (#13056)
## Problem When a tool with `needsApproval` throws an error during execution, the error message is silently lost. The model receives `{}` instead of the actual error text, causing it to hallucinate success. Closes #13048 ## Root Cause The approval continuation path in both `streamText` and `generateText` used: ```ts errorMode: output.type === 'tool-error' ? 'json' : 'none' ``` `errorMode: 'json'` routes to `createToolModelOutput` which does `toJSONValue(error)` → `JSON.stringify(new Error('msg'))` → `'{}'` because `Error` properties (`message`, `stack`) are non-enumerable. The normal multi-step flow and `to-response-messages.ts` correctly use `errorMode: 'text'` which calls `getErrorMessage(error)` to extract the message string. ## Fix Change `'json'` → `'text'` in both locations to align with the normal flow: ```ts // Before (broken): errorMode: output.type === 'tool-error' ? 'json' : 'none' // After (fixed): errorMode: output.type === 'tool-error' ? 'text' : 'none' ``` ## Tests Added test cases for both `generateText` and `streamText` that verify when an approved `needsApproval` tool throws, the model receives `{ type: 'error-text', value: 'tool execution failed' }` instead of `{ type: 'error-json', value: {} }`. --------- Co-authored-by: Dmitrii Troitskii <jsleitor@gmail.com> Co-authored-by: Aayush Kapoor <aayushkapoor34@gmail.com>
1 parent a676c69 commit 70d3980

File tree

5 files changed

+244
-2
lines changed

5 files changed

+244
-2
lines changed

.changeset/brave-gorillas-rhyme.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ai': patch
3+
---
4+
5+
fix(ai): use errorMode 'text' in approval continuation to preserve tool error messages

packages/ai/src/generate-text/generate-text.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7564,6 +7564,120 @@ describe('generateText', () => {
75647564
});
75657565
});
75667566

7567+
describe('when a call from a single tool that needs approval is approved and the tool throws', () => {
7568+
let result: GenerateTextResult<any, any>;
7569+
let prompts: LanguageModelV3Prompt[];
7570+
7571+
beforeEach(async () => {
7572+
prompts = [];
7573+
result = await generateText({
7574+
model: new MockLanguageModelV3({
7575+
doGenerate: async ({ prompt }) => {
7576+
prompts.push(prompt);
7577+
return {
7578+
...dummyResponseValues,
7579+
content: [
7580+
{
7581+
type: 'text',
7582+
text: 'Hello, world!',
7583+
},
7584+
],
7585+
finishReason: { unified: 'stop', raw: 'stop' },
7586+
};
7587+
},
7588+
}),
7589+
tools: {
7590+
tool1: tool({
7591+
inputSchema: z.object({ value: z.string() }),
7592+
execute: async (): Promise<string> => {
7593+
throw new Error('No valid token for plugin');
7594+
},
7595+
needsApproval: true,
7596+
}),
7597+
},
7598+
stopWhen: stepCountIs(3),
7599+
_internal: {
7600+
generateId: mockId({ prefix: 'id' }),
7601+
},
7602+
messages: [
7603+
{ role: 'user', content: 'test-input' },
7604+
{
7605+
role: 'assistant',
7606+
content: [
7607+
{
7608+
input: {
7609+
value: 'value',
7610+
},
7611+
providerExecuted: undefined,
7612+
providerOptions: undefined,
7613+
toolCallId: 'call-1',
7614+
toolName: 'tool1',
7615+
type: 'tool-call',
7616+
},
7617+
{
7618+
approvalId: 'id-1',
7619+
toolCallId: 'call-1',
7620+
type: 'tool-approval-request',
7621+
},
7622+
],
7623+
},
7624+
{
7625+
role: 'tool',
7626+
content: [
7627+
{
7628+
approvalId: 'id-1',
7629+
type: 'tool-approval-response',
7630+
approved: true,
7631+
},
7632+
],
7633+
},
7634+
],
7635+
});
7636+
});
7637+
7638+
it('should serialize the tool error as error-text in the continuation prompt', async () => {
7639+
expect(prompts).toEqual([
7640+
[
7641+
{
7642+
role: 'user',
7643+
content: [{ type: 'text', text: 'test-input' }],
7644+
providerOptions: undefined,
7645+
},
7646+
{
7647+
role: 'assistant',
7648+
content: [
7649+
{
7650+
type: 'tool-call',
7651+
toolCallId: 'call-1',
7652+
toolName: 'tool1',
7653+
input: { value: 'value' },
7654+
providerExecuted: undefined,
7655+
providerOptions: undefined,
7656+
},
7657+
],
7658+
providerOptions: undefined,
7659+
},
7660+
{
7661+
role: 'tool',
7662+
content: [
7663+
{
7664+
type: 'tool-result',
7665+
toolCallId: 'call-1',
7666+
toolName: 'tool1',
7667+
output: {
7668+
type: 'error-text',
7669+
value: 'No valid token for plugin',
7670+
},
7671+
providerOptions: undefined,
7672+
},
7673+
],
7674+
providerOptions: undefined,
7675+
},
7676+
],
7677+
]);
7678+
});
7679+
});
7680+
75677681
describe('when a call from a single tool that needs approval is denied', () => {
75687682
let result: GenerateTextResult<any, any>;
75697683
let prompts: LanguageModelV3Prompt[];

packages/ai/src/generate-text/generate-text.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@ export async function generateText<
595595
tool: tools?.[output.toolName],
596596
output:
597597
output.type === 'tool-result' ? output.output : output.error,
598-
errorMode: output.type === 'tool-error' ? 'json' : 'none',
598+
errorMode: output.type === 'tool-error' ? 'text' : 'none',
599599
});
600600

601601
toolContent.push({

packages/ai/src/generate-text/stream-text.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20705,6 +20705,129 @@ describe('streamText', () => {
2070520705
});
2070620706
});
2070720707

20708+
describe('when a call from a single tool that needs approval is approved and the tool throws', () => {
20709+
let result: StreamTextResult<any, any>;
20710+
let prompts: LanguageModelV3Prompt[];
20711+
20712+
beforeEach(async () => {
20713+
prompts = [];
20714+
result = streamText({
20715+
model: new MockLanguageModelV3({
20716+
doStream: async ({ prompt }) => {
20717+
prompts.push(prompt);
20718+
return {
20719+
stream: convertArrayToReadableStream([
20720+
{ type: 'stream-start', warnings: [] },
20721+
{ type: 'text-start', id: '1' },
20722+
{
20723+
type: 'text-delta',
20724+
id: '1',
20725+
delta: 'Hello, world!',
20726+
},
20727+
{ type: 'text-end', id: '1' },
20728+
{
20729+
type: 'finish',
20730+
finishReason: { unified: 'stop', raw: 'stop' },
20731+
usage: testUsage,
20732+
},
20733+
]),
20734+
};
20735+
},
20736+
}),
20737+
tools: {
20738+
tool1: tool({
20739+
inputSchema: z.object({ value: z.string() }),
20740+
execute: async (): Promise<string> => {
20741+
throw new Error('No valid token for plugin');
20742+
},
20743+
needsApproval: true,
20744+
}),
20745+
},
20746+
stopWhen: stepCountIs(3),
20747+
_internal: {
20748+
generateId: mockId({ prefix: 'id' }),
20749+
},
20750+
messages: [
20751+
{ role: 'user', content: 'test-input' },
20752+
{
20753+
role: 'assistant',
20754+
content: [
20755+
{
20756+
input: {
20757+
value: 'value',
20758+
},
20759+
providerExecuted: undefined,
20760+
providerOptions: undefined,
20761+
toolCallId: 'call-1',
20762+
toolName: 'tool1',
20763+
type: 'tool-call',
20764+
},
20765+
{
20766+
approvalId: 'id-1',
20767+
toolCallId: 'call-1',
20768+
type: 'tool-approval-request',
20769+
},
20770+
],
20771+
},
20772+
{
20773+
role: 'tool',
20774+
content: [
20775+
{
20776+
approvalId: 'id-1',
20777+
type: 'tool-approval-response',
20778+
approved: true,
20779+
},
20780+
],
20781+
},
20782+
],
20783+
});
20784+
20785+
await result.consumeStream();
20786+
});
20787+
20788+
it('should serialize the tool error as error-text in the continuation prompt', async () => {
20789+
expect(prompts).toEqual([
20790+
[
20791+
{
20792+
role: 'user',
20793+
content: [{ type: 'text', text: 'test-input' }],
20794+
providerOptions: undefined,
20795+
},
20796+
{
20797+
role: 'assistant',
20798+
content: [
20799+
{
20800+
type: 'tool-call',
20801+
toolCallId: 'call-1',
20802+
toolName: 'tool1',
20803+
input: { value: 'value' },
20804+
providerExecuted: undefined,
20805+
providerOptions: undefined,
20806+
},
20807+
],
20808+
providerOptions: undefined,
20809+
},
20810+
{
20811+
role: 'tool',
20812+
content: [
20813+
{
20814+
type: 'tool-result',
20815+
toolCallId: 'call-1',
20816+
toolName: 'tool1',
20817+
output: {
20818+
type: 'error-text',
20819+
value: 'No valid token for plugin',
20820+
},
20821+
providerOptions: undefined,
20822+
},
20823+
],
20824+
providerOptions: undefined,
20825+
},
20826+
],
20827+
]);
20828+
});
20829+
});
20830+
2070820831
describe('when a call from a single tool with preliminary results that needs approval is approved', () => {
2070920832
let result: StreamTextResult<any, any>;
2071020833
let prompts: LanguageModelV3Prompt[];

packages/ai/src/generate-text/stream-text.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1451,7 +1451,7 @@ class DefaultStreamTextResult<TOOLS extends ToolSet, OUTPUT extends Output>
14511451
output.type === 'tool-result'
14521452
? output.output
14531453
: output.error,
1454-
errorMode: output.type === 'tool-error' ? 'json' : 'none',
1454+
errorMode: output.type === 'tool-error' ? 'text' : 'none',
14551455
}),
14561456
});
14571457
}

0 commit comments

Comments
 (0)