Skip to content

Commit d23121f

Browse files
vercel-ai-sdk[bot]patrikdevlinfelixarntz
authored
Backport: chore(ai): add optional ChatRequestOptions to addToolApprovalResponse and addToolOutput (#13107)
This is an automated backport of #11048 to the release-v6.0 branch. FYI @patrikdevlin Co-authored-by: patrik <17474633+patrikdevlin@users.noreply.github.com> Co-authored-by: Felix Arntz <felix.arntz@vercel.com>
1 parent 55a2acf commit d23121f

File tree

7 files changed

+341
-18
lines changed

7 files changed

+341
-18
lines changed

.changeset/curvy-doors-shake.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+
chore(ai): add optional ChatRequestOptions to `addToolApprovalResponse` and `addToolOutput`
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { anthropic } from '@ai-sdk/anthropic';
2+
import { ToolLoopAgent, dynamicTool, createAgentUIStreamResponse } from 'ai';
3+
import { z } from 'zod';
4+
5+
function randomWeather() {
6+
const weatherOptions = ['sunny', 'cloudy', 'rainy', 'windy'];
7+
return weatherOptions[Math.floor(Math.random() * weatherOptions.length)];
8+
}
9+
10+
const weatherTool = dynamicTool({
11+
description: 'Get the weather in a location',
12+
inputSchema: z.object({ city: z.string() }),
13+
needsApproval: true,
14+
async *execute() {
15+
yield { state: 'loading' as const };
16+
await new Promise(resolve => setTimeout(resolve, 2000));
17+
yield {
18+
state: 'ready' as const,
19+
temperature: 72,
20+
weather: randomWeather(),
21+
};
22+
},
23+
});
24+
25+
const defaultInstructions =
26+
'You are a helpful weather assistant. ' +
27+
'When a tool execution is not approved by the user, do not retry it. ' +
28+
'Just say that the tool execution was not approved.';
29+
30+
export async function POST(request: Request) {
31+
const body = await request.json();
32+
33+
const systemInstruction: string | undefined = body.systemInstruction;
34+
35+
const agent = new ToolLoopAgent({
36+
model: anthropic('claude-sonnet-4-6'),
37+
instructions: systemInstruction ?? defaultInstructions,
38+
tools: { weather: weatherTool },
39+
});
40+
41+
return createAgentUIStreamResponse({
42+
agent,
43+
uiMessages: body.messages,
44+
});
45+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use client';
2+
3+
import ChatInput from '@/components/chat-input';
4+
import DynamicToolWithApprovalView from '@/components/tool/dynamic-tool-with-approval-view';
5+
import { useChat } from '@ai-sdk/react';
6+
import {
7+
ChatRequestOptions,
8+
DefaultChatTransport,
9+
lastAssistantMessageIsCompleteWithApprovalResponses,
10+
} from 'ai';
11+
import { useState } from 'react';
12+
13+
export default function TestToolApprovalOptions() {
14+
const [systemInstruction, setSystemInstruction] = useState('');
15+
16+
const requestOptions: ChatRequestOptions = {
17+
body: { systemInstruction: systemInstruction || undefined },
18+
};
19+
20+
const { status, sendMessage, messages, addToolApprovalResponse } = useChat({
21+
transport: new DefaultChatTransport({
22+
api: '/api/chat/tool-approval-options',
23+
}),
24+
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
25+
});
26+
27+
return (
28+
<div className="flex flex-col py-24 mx-auto w-full max-w-md stretch">
29+
<h1 className="mb-4 text-xl font-bold">
30+
Tool Approval with Options Test
31+
</h1>
32+
33+
<label className="mb-4">
34+
<span className="block mb-1 text-sm font-medium">
35+
System Instruction (optional):
36+
</span>
37+
<input
38+
className="w-full p-2 border border-gray-300 rounded"
39+
placeholder="e.g. Always respond in French."
40+
value={systemInstruction}
41+
onChange={e => setSystemInstruction(e.target.value)}
42+
/>
43+
</label>
44+
45+
{messages.map(message => (
46+
<div key={message.id} className="whitespace-pre-wrap">
47+
{message.role === 'user' ? 'User: ' : 'AI: '}
48+
{message.parts.map((part, index) => {
49+
switch (part.type) {
50+
case 'text':
51+
return <div key={index}>{part.text}</div>;
52+
case 'dynamic-tool':
53+
return (
54+
<DynamicToolWithApprovalView
55+
key={index}
56+
invocation={part}
57+
addToolApprovalResponse={addToolApprovalResponse}
58+
requestOptions={requestOptions}
59+
/>
60+
);
61+
}
62+
})}
63+
</div>
64+
))}
65+
66+
<ChatInput
67+
status={status}
68+
onSubmit={text => sendMessage({ text }, requestOptions)}
69+
/>
70+
</div>
71+
);
72+
}

examples/ai-e2e-next/components/tool/dynamic-tool-with-approval-view.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
import type { ChatAddToolApproveResponseFunction, DynamicToolUIPart } from 'ai';
1+
import type {
2+
ChatAddToolApproveResponseFunction,
3+
ChatRequestOptions,
4+
DynamicToolUIPart,
5+
} from 'ai';
26

37
export default function WeatherWithApprovalView({
48
invocation,
59
addToolApprovalResponse,
10+
requestOptions,
611
}: {
712
invocation: DynamicToolUIPart;
813
addToolApprovalResponse: ChatAddToolApproveResponseFunction;
14+
requestOptions?: ChatRequestOptions;
915
}) {
1016
switch (invocation.state) {
1117
case 'approval-requested':
@@ -26,6 +32,7 @@ export default function WeatherWithApprovalView({
2632
addToolApprovalResponse({
2733
id: invocation.approval.id,
2834
approved: true,
35+
options: requestOptions,
2936
})
3037
}
3138
>
@@ -37,6 +44,7 @@ export default function WeatherWithApprovalView({
3744
addToolApprovalResponse({
3845
id: invocation.approval.id,
3946
approved: false,
47+
options: requestOptions,
4048
})
4149
}
4250
>

packages/ai/src/ui/chat.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2427,6 +2427,163 @@ describe('Chat', () => {
24272427
});
24282428
});
24292429

2430+
describe('addToolOutput options forwarding', () => {
2431+
it('should forward options to makeRequest when auto-sending', async () => {
2432+
server.urls['http://localhost:3000/api/chat'].response = [
2433+
{
2434+
type: 'stream-chunks',
2435+
chunks: [
2436+
formatChunk({ type: 'start' }),
2437+
formatChunk({ type: 'start-step' }),
2438+
formatChunk({
2439+
type: 'tool-input-available',
2440+
dynamic: true,
2441+
toolCallId: 'tool-call-0',
2442+
toolName: 'test-tool',
2443+
input: { testArg: 'test-value' },
2444+
}),
2445+
formatChunk({ type: 'finish-step' }),
2446+
formatChunk({ type: 'finish' }),
2447+
],
2448+
},
2449+
{
2450+
type: 'stream-chunks',
2451+
chunks: [
2452+
formatChunk({ type: 'start' }),
2453+
formatChunk({ type: 'start-step' }),
2454+
formatChunk({ type: 'finish-step' }),
2455+
formatChunk({ type: 'finish' }),
2456+
],
2457+
},
2458+
];
2459+
2460+
let callCount = 0;
2461+
const onFinishPromise = createResolvablePromise<void>();
2462+
2463+
const chat = new TestChat({
2464+
id: '123',
2465+
generateId: mockId(),
2466+
transport: new DefaultChatTransport({
2467+
api: 'http://localhost:3000/api/chat',
2468+
}),
2469+
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
2470+
onFinish: () => {
2471+
callCount++;
2472+
if (callCount === 2) {
2473+
onFinishPromise.resolve();
2474+
}
2475+
},
2476+
});
2477+
2478+
await chat.sendMessage({
2479+
text: 'Hello, world!',
2480+
});
2481+
2482+
await chat.addToolOutput({
2483+
state: 'output-available',
2484+
tool: 'test-tool',
2485+
toolCallId: 'tool-call-0',
2486+
output: 'test-output',
2487+
options: {
2488+
headers: { 'x-custom': 'test-value' },
2489+
body: { extra: 'data' },
2490+
},
2491+
});
2492+
2493+
await onFinishPromise.promise;
2494+
2495+
expect(server.calls.length).toBe(2);
2496+
expect(server.calls[1].requestHeaders['x-custom']).toBe('test-value');
2497+
expect(await server.calls[1].requestBodyJson).toMatchObject({
2498+
extra: 'data',
2499+
});
2500+
});
2501+
});
2502+
2503+
describe('addToolApprovalResponse options forwarding', () => {
2504+
it('should forward options to makeRequest when auto-sending', async () => {
2505+
server.urls['http://localhost:3000/api/chat'].response = [
2506+
{
2507+
type: 'stream-chunks',
2508+
chunks: [
2509+
formatChunk({ type: 'start' }),
2510+
formatChunk({ type: 'start-step' }),
2511+
formatChunk({
2512+
type: 'tool-output-available',
2513+
toolCallId: 'call-1',
2514+
output: { temperature: 72, weather: 'sunny' },
2515+
}),
2516+
formatChunk({ type: 'text-start', id: 'txt-1' }),
2517+
formatChunk({
2518+
type: 'text-delta',
2519+
id: 'txt-1',
2520+
delta: 'The weather in Tokyo is sunny.',
2521+
}),
2522+
formatChunk({ type: 'text-end', id: 'txt-1' }),
2523+
formatChunk({ type: 'finish-step' }),
2524+
formatChunk({
2525+
type: 'finish',
2526+
finishReason: 'stop',
2527+
}),
2528+
],
2529+
},
2530+
];
2531+
2532+
const onFinishPromise = createResolvablePromise<void>();
2533+
2534+
const chat = new TestChat({
2535+
id: '123',
2536+
generateId: mockId({ prefix: 'newid' }),
2537+
transport: new DefaultChatTransport({
2538+
api: 'http://localhost:3000/api/chat',
2539+
}),
2540+
messages: [
2541+
{
2542+
id: 'id-0',
2543+
role: 'user',
2544+
parts: [{ text: 'What is the weather in Tokyo?', type: 'text' }],
2545+
},
2546+
{
2547+
id: 'id-1',
2548+
role: 'assistant',
2549+
parts: [
2550+
{ type: 'step-start' },
2551+
{
2552+
type: 'tool-weather',
2553+
toolCallId: 'call-1',
2554+
state: 'approval-requested',
2555+
input: { city: 'Tokyo' },
2556+
approval: { id: 'approval-1' },
2557+
},
2558+
],
2559+
},
2560+
],
2561+
sendAutomaticallyWhen:
2562+
lastAssistantMessageIsCompleteWithApprovalResponses,
2563+
onFinish: () => {
2564+
onFinishPromise.resolve();
2565+
},
2566+
});
2567+
2568+
await chat.addToolApprovalResponse({
2569+
id: 'approval-1',
2570+
approved: true,
2571+
options: {
2572+
headers: { 'x-custom': 'test-value' },
2573+
body: { extra: 'data' },
2574+
},
2575+
});
2576+
2577+
await onFinishPromise.promise;
2578+
2579+
expect(server.calls.length).toBe(1);
2580+
expect(server.calls[0].requestHeaders['x-custom']).toBe('test-value');
2581+
expect(await server.calls[0].requestBodyJson).toMatchObject({
2582+
extra: 'data',
2583+
});
2584+
});
2585+
});
2586+
24302587
describe('addToolApprovalResponse', () => {
24312588
describe('approved', () => {
24322589
let chat: TestChat;

0 commit comments

Comments
 (0)