Skip to content

Commit 1160591

Browse files
committed
Add tool approval integration for Vercel AI adapter
1 parent 64a6fe4 commit 1160591

File tree

6 files changed

+395
-10
lines changed

6 files changed

+395
-10
lines changed

docs/ui/vercel-ai.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
Pydantic AI natively supports the [Vercel AI Data Stream Protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#data-stream-protocol) to receive agent run input from, and stream events to, a [Vercel AI Elements](https://ai-sdk.dev/elements) frontend.
44

5+
!!! note "AI SDK Version Compatibility"
6+
The base protocol is compatible with AI SDK v5 and later. However, [tool approval](#tool-approval) (human-in-the-loop) features require **AI SDK v6 or later**.
7+
58
## Usage
69

710
The [`VercelAIAdapter`][pydantic_ai.ui.vercel_ai.VercelAIAdapter] class is responsible for transforming agent run input received from the frontend into arguments for [`Agent.run_stream_events()`](../agents.md#running-agents), running the agent, and then transforming Pydantic AI events into Vercel AI events. The event stream transformation is handled by the [`VercelAIEventStream`][pydantic_ai.ui.vercel_ai.VercelAIEventStream] class, but you typically won't use this directly.
@@ -81,3 +84,58 @@ async def chat(request: Request) -> Response:
8184
sse_event_stream = adapter.encode_stream(event_stream)
8285
return StreamingResponse(sse_event_stream, media_type=accept)
8386
```
87+
88+
## Tool Approval
89+
90+
Pydantic AI supports [AI SDK's human-in-the-loop tool approval](https://ai-sdk.dev/cookbook/next/human-in-the-loop) workflow, allowing users to approve or deny tool executions before they run.
91+
92+
!!! warning "Requires AI SDK v6"
93+
Tool approval is an AI SDK v6 feature. The `tool-approval-request` and `tool-output-denied` stream chunks, along with the `approval` field on tool parts, are not available in AI SDK v5.
94+
95+
### How It Works
96+
97+
1. **Tool requests approval**: When an agent calls a tool with `requires_approval=True`, Pydantic AI emits a `tool-approval-request` chunk instead of executing the tool immediately.
98+
99+
2. **User decides**: The AI SDK frontend displays an approval UI. The user can approve or deny the tool execution.
100+
101+
3. **Response is sent**: The frontend sends the approval decision back to the server in a follow-up request.
102+
103+
4. **Tool executes or is denied**: If approved, the tool runs normally. If denied, Pydantic AI emits a `tool-output-denied` chunk and informs the model that the tool was rejected.
104+
105+
### Server-Side Setup
106+
107+
To enable tool approval, define your tool with `requires_approval=True` and include [`DeferredToolRequests`][pydantic_ai.tools.DeferredToolRequests] in your agent's output types:
108+
109+
```py
110+
from pydantic_ai import Agent
111+
from pydantic_ai.tools import DeferredToolRequests
112+
113+
agent: Agent[None, str | DeferredToolRequests] = Agent(
114+
'openai:gpt-5',
115+
output_type=[str, DeferredToolRequests],
116+
)
117+
118+
@agent.tool_plain(requires_approval=True)
119+
def delete_file(path: str) -> str:
120+
"""Delete a file from the filesystem."""
121+
# This won't execute until the user approves
122+
os.remove(path)
123+
return f'Deleted {path}'
124+
```
125+
126+
When processing a follow-up request with approval responses, extract and pass the deferred tool results:
127+
128+
```py
129+
@app.post('/chat')
130+
async def chat(request: Request) -> Response:
131+
adapter = await VercelAIAdapter.from_request(request, agent=agent)
132+
return adapter.streaming_response(
133+
adapter.run_stream(deferred_tool_results=adapter.deferred_tool_results)
134+
)
135+
```
136+
137+
### Client-Side Setup
138+
139+
On the frontend, use AI SDK v6's [`useChat`](https://v6.ai-sdk.dev/docs/ai-sdk-ui/use-chat) hook with the [`Confirmation`](https://ai-sdk.dev/elements/components/confirmation) component or the `addToolApprovalResponse` function to handle approval UI.
140+
141+
See the [AI SDK Human-in-the-Loop Cookbook](https://ai-sdk.dev/cookbook/next/human-in-the-loop) for complete frontend examples.

pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_adapter.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
VideoUrl,
3636
)
3737
from ...output import OutputDataT
38-
from ...tools import AgentDepsT
38+
from ...tools import AgentDepsT, DeferredToolResults, ToolApproved, ToolDenied
3939
from .. import MessagesBuilder, UIAdapter, UIEventStream
4040
from ._event_stream import VercelAIEventStream
4141
from .request_types import (
@@ -51,6 +51,7 @@
5151
SourceUrlUIPart,
5252
StepStartUIPart,
5353
TextUIPart,
54+
ToolApprovalResponded,
5455
ToolInputAvailablePart,
5556
ToolOutputAvailablePart,
5657
ToolOutputErrorPart,
@@ -87,6 +88,54 @@ def messages(self) -> list[ModelMessage]:
8788
"""Pydantic AI messages from the Vercel AI run input."""
8889
return self.load_messages(self.run_input.messages)
8990

91+
@cached_property
92+
def deferred_tool_results(self) -> DeferredToolResults | None:
93+
"""Extract deferred tool results from tool parts with approval responses.
94+
95+
When the Vercel AI SDK client responds to a tool-approval-request, it sends
96+
the approval decision in the tool part's `approval` field. This method extracts
97+
those responses and converts them to Pydantic AI's `DeferredToolResults` format.
98+
99+
Returns:
100+
DeferredToolResults if any tool parts have approval responses, None otherwise.
101+
"""
102+
return self.extract_deferred_tool_results(self.run_input.messages)
103+
104+
@classmethod
105+
def extract_deferred_tool_results(cls, messages: Sequence[UIMessage]) -> DeferredToolResults | None:
106+
"""Extract deferred tool results from UI messages.
107+
108+
Args:
109+
messages: The UI messages to scan for approval responses.
110+
111+
Returns:
112+
DeferredToolResults if any tool parts have approval responses, None otherwise.
113+
"""
114+
approvals: dict[str, bool | ToolApproved | ToolDenied] = {}
115+
116+
for msg in messages:
117+
if msg.role != 'assistant':
118+
continue
119+
120+
for part in msg.parts:
121+
if not isinstance(part, ToolUIPart | DynamicToolUIPart):
122+
continue
123+
124+
approval = part.approval
125+
if approval is None or not isinstance(approval, ToolApprovalResponded):
126+
continue
127+
128+
tool_call_id = part.tool_call_id
129+
if approval.approved:
130+
approvals[tool_call_id] = ToolApproved()
131+
else:
132+
approvals[tool_call_id] = ToolDenied(message=approval.reason or 'The tool call was denied.')
133+
134+
if not approvals:
135+
return None
136+
137+
return DeferredToolResults(approvals=approvals)
138+
90139
@classmethod
91140
def load_messages(cls, messages: Sequence[UIMessage]) -> list[ModelMessage]: # noqa: C901
92141
"""Transform Vercel AI messages into Pydantic AI messages."""

pydantic_ai_slim/pydantic_ai/ui/vercel_ai/_event_stream.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from collections.abc import AsyncIterator, Mapping
66
from dataclasses import dataclass
77
from typing import Any
8+
from uuid import uuid4
89

910
from pydantic_core import to_json
1011

@@ -25,9 +26,15 @@
2526
)
2627
from ...output import OutputDataT
2728
from ...run import AgentRunResultEvent
28-
from ...tools import AgentDepsT
29+
from ...tools import AgentDepsT, DeferredToolRequests
2930
from .. import UIEventStream
30-
from .request_types import RequestData
31+
from .request_types import (
32+
DynamicToolUIPart,
33+
RequestData,
34+
ToolApprovalResponded,
35+
ToolUIPart,
36+
UIMessage,
37+
)
3138
from .response_types import (
3239
BaseChunk,
3340
DoneChunk,
@@ -44,10 +51,12 @@
4451
TextDeltaChunk,
4552
TextEndChunk,
4653
TextStartChunk,
54+
ToolApprovalRequestChunk,
4755
ToolInputAvailableChunk,
4856
ToolInputDeltaChunk,
4957
ToolInputStartChunk,
5058
ToolOutputAvailableChunk,
59+
ToolOutputDeniedChunk,
5160
ToolOutputErrorChunk,
5261
)
5362

@@ -71,12 +80,35 @@ def _json_dumps(obj: Any) -> str:
7180
return to_json(obj).decode('utf-8')
7281

7382

83+
def _extract_denied_tool_ids(messages: list[UIMessage]) -> set[str]:
84+
"""Extract tool_call_ids that were denied from UI messages."""
85+
denied_ids: set[str] = set()
86+
for msg in messages:
87+
if msg.role != 'assistant':
88+
continue
89+
for part in msg.parts:
90+
if not isinstance(part, ToolUIPart | DynamicToolUIPart):
91+
continue
92+
approval = part.approval
93+
if isinstance(approval, ToolApprovalResponded) and not approval.approved:
94+
denied_ids.add(part.tool_call_id)
95+
return denied_ids
96+
97+
7498
@dataclass
7599
class VercelAIEventStream(UIEventStream[RequestData, BaseChunk, AgentDepsT, OutputDataT]):
76100
"""UI event stream transformer for the Vercel AI protocol."""
77101

78102
_step_started: bool = False
79103
_finish_reason: FinishReason = None
104+
_denied_tool_ids: set[str] | None = None
105+
106+
@property
107+
def denied_tool_ids(self) -> set[str]:
108+
"""Get the set of tool_call_ids that were denied by the user."""
109+
if self._denied_tool_ids is None:
110+
self._denied_tool_ids = _extract_denied_tool_ids(self.run_input.messages)
111+
return self._denied_tool_ids
80112

81113
@property
82114
def response_headers(self) -> Mapping[str, str] | None:
@@ -105,8 +137,15 @@ async def handle_run_result(self, event: AgentRunResultEvent) -> AsyncIterator[B
105137
pydantic_reason = event.result.response.finish_reason
106138
if pydantic_reason:
107139
self._finish_reason = _FINISH_REASON_MAP.get(pydantic_reason)
108-
return
109-
yield
140+
141+
# Emit tool approval requests for deferred approvals
142+
output = event.result.output
143+
if isinstance(output, DeferredToolRequests):
144+
for tool_call in output.approvals:
145+
yield ToolApprovalRequestChunk(
146+
approval_id=str(uuid4()),
147+
tool_call_id=tool_call.tool_call_id,
148+
)
110149

111150
async def on_error(self, error: Exception) -> AsyncIterator[BaseChunk]:
112151
self._finish_reason = 'error'
@@ -203,10 +242,15 @@ async def handle_file(self, part: FilePart) -> AsyncIterator[BaseChunk]:
203242

204243
async def handle_function_tool_result(self, event: FunctionToolResultEvent) -> AsyncIterator[BaseChunk]:
205244
part = event.result
206-
if isinstance(part, RetryPromptPart):
207-
yield ToolOutputErrorChunk(tool_call_id=part.tool_call_id, error_text=part.model_response())
245+
tool_call_id = part.tool_call_id
246+
247+
# Check if this tool was denied by the user
248+
if tool_call_id in self.denied_tool_ids:
249+
yield ToolOutputDeniedChunk(tool_call_id=tool_call_id)
250+
elif isinstance(part, RetryPromptPart):
251+
yield ToolOutputErrorChunk(tool_call_id=tool_call_id, error_text=part.model_response())
208252
else:
209-
yield ToolOutputAvailableChunk(tool_call_id=part.tool_call_id, output=self._tool_return_output(part))
253+
yield ToolOutputAvailableChunk(tool_call_id=tool_call_id, output=self._tool_return_output(part))
210254

211255
# ToolCallResultEvent.content may hold user parts (e.g. text, images) that Vercel AI does not currently have events for
212256

pydantic_ai_slim/pydantic_ai/ui/vercel_ai/request_types.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Vercel AI request types (UI messages).
22
33
Converted to Python from:
4-
https://github.com/vercel/ai/blob/ai%405.0.59/packages/ai/src/ui/ui-messages.ts
4+
https://github.com/vercel/ai/blob/ai%406.0.0-beta.159/packages/ai/src/ui/ui-messages.ts
5+
6+
Tool approval types (`ToolApprovalRequested`, `ToolApprovalResponded`) require AI SDK v6 or later.
57
"""
68

79
from abc import ABC
@@ -110,6 +112,30 @@ class DataUIPart(BaseUIPart):
110112
data: Any
111113

112114

115+
class ToolApprovalRequested(CamelBaseModel):
116+
"""Tool approval in requested state (awaiting user response)."""
117+
118+
id: str
119+
"""The approval request ID."""
120+
121+
122+
class ToolApprovalResponded(CamelBaseModel):
123+
"""Tool approval in responded state (user has approved or denied)."""
124+
125+
id: str
126+
"""The approval request ID."""
127+
128+
approved: bool
129+
"""Whether the user approved the tool call."""
130+
131+
reason: str | None = None
132+
"""Optional reason for the approval or denial."""
133+
134+
135+
ToolApproval = ToolApprovalRequested | ToolApprovalResponded
136+
"""Union of tool approval states."""
137+
138+
113139
# Tool part states as separate models
114140
class ToolInputStreamingPart(BaseUIPart):
115141
"""Tool part in input-streaming state."""
@@ -119,6 +145,7 @@ class ToolInputStreamingPart(BaseUIPart):
119145
state: Literal['input-streaming'] = 'input-streaming'
120146
input: Any | None = None
121147
provider_executed: bool | None = None
148+
approval: ToolApproval | None = None
122149

123150

124151
class ToolInputAvailablePart(BaseUIPart):
@@ -130,6 +157,7 @@ class ToolInputAvailablePart(BaseUIPart):
130157
input: Any | None = None
131158
provider_executed: bool | None = None
132159
call_provider_metadata: ProviderMetadata | None = None
160+
approval: ToolApproval | None = None
133161

134162

135163
class ToolOutputAvailablePart(BaseUIPart):
@@ -143,6 +171,7 @@ class ToolOutputAvailablePart(BaseUIPart):
143171
provider_executed: bool | None = None
144172
call_provider_metadata: ProviderMetadata | None = None
145173
preliminary: bool | None = None
174+
approval: ToolApproval | None = None
146175

147176

148177
class ToolOutputErrorPart(BaseUIPart):
@@ -156,6 +185,7 @@ class ToolOutputErrorPart(BaseUIPart):
156185
error_text: str
157186
provider_executed: bool | None = None
158187
call_provider_metadata: ProviderMetadata | None = None
188+
approval: ToolApproval | None = None
159189

160190

161191
ToolUIPart = ToolInputStreamingPart | ToolInputAvailablePart | ToolOutputAvailablePart | ToolOutputErrorPart
@@ -171,6 +201,7 @@ class DynamicToolInputStreamingPart(BaseUIPart):
171201
tool_call_id: str
172202
state: Literal['input-streaming'] = 'input-streaming'
173203
input: Any | None = None
204+
approval: ToolApproval | None = None
174205

175206

176207
class DynamicToolInputAvailablePart(BaseUIPart):
@@ -182,6 +213,7 @@ class DynamicToolInputAvailablePart(BaseUIPart):
182213
state: Literal['input-available'] = 'input-available'
183214
input: Any
184215
call_provider_metadata: ProviderMetadata | None = None
216+
approval: ToolApproval | None = None
185217

186218

187219
class DynamicToolOutputAvailablePart(BaseUIPart):
@@ -195,6 +227,7 @@ class DynamicToolOutputAvailablePart(BaseUIPart):
195227
output: Any
196228
call_provider_metadata: ProviderMetadata | None = None
197229
preliminary: bool | None = None
230+
approval: ToolApproval | None = None
198231

199232

200233
class DynamicToolOutputErrorPart(BaseUIPart):
@@ -207,6 +240,7 @@ class DynamicToolOutputErrorPart(BaseUIPart):
207240
input: Any
208241
error_text: str
209242
call_provider_metadata: ProviderMetadata | None = None
243+
approval: ToolApproval | None = None
210244

211245

212246
DynamicToolUIPart = (

pydantic_ai_slim/pydantic_ai/ui/vercel_ai/response_types.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Vercel AI response types (SSE chunks).
22
33
Converted to Python from:
4-
https://github.com/vercel/ai/blob/ai%405.0.59/packages/ai/src/ui-message-stream/ui-message-chunks.ts
4+
https://github.com/vercel/ai/blob/ai%406.0.0-beta.159/packages/ai/src/ui-message-stream/ui-message-chunks.ts
5+
6+
Tool approval types (`ToolApprovalRequestChunk`, `ToolOutputDeniedChunk`) require AI SDK v6 or later.
57
"""
68

79
from abc import ABC

0 commit comments

Comments
 (0)