Skip to content

Commit 52fb5d1

Browse files
committed
Emit tool-output-denied chunk and document v6 requirements
1 parent 43b8232 commit 52fb5d1

File tree

5 files changed

+179
-9
lines changed

5 files changed

+179
-9
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/_event_stream.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@
2828
from ...run import AgentRunResultEvent
2929
from ...tools import AgentDepsT, DeferredToolRequests
3030
from .. import UIEventStream
31-
from .request_types import RequestData
31+
from .request_types import (
32+
DynamicToolUIPart,
33+
RequestData,
34+
ToolApprovalResponded,
35+
ToolUIPart,
36+
UIMessage,
37+
)
3238
from .response_types import (
3339
BaseChunk,
3440
DoneChunk,
@@ -50,6 +56,7 @@
5056
ToolInputDeltaChunk,
5157
ToolInputStartChunk,
5258
ToolOutputAvailableChunk,
59+
ToolOutputDeniedChunk,
5360
ToolOutputErrorChunk,
5461
)
5562

@@ -73,12 +80,35 @@ def _json_dumps(obj: Any) -> str:
7380
return to_json(obj).decode('utf-8')
7481

7582

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+
7698
@dataclass
7799
class VercelAIEventStream(UIEventStream[RequestData, BaseChunk, AgentDepsT, OutputDataT]):
78100
"""UI event stream transformer for the Vercel AI protocol."""
79101

80102
_step_started: bool = False
81103
_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
82112

83113
@property
84114
def response_headers(self) -> Mapping[str, str] | None:
@@ -212,10 +242,15 @@ async def handle_file(self, part: FilePart) -> AsyncIterator[BaseChunk]:
212242

213243
async def handle_function_tool_result(self, event: FunctionToolResultEvent) -> AsyncIterator[BaseChunk]:
214244
part = event.result
215-
if isinstance(part, RetryPromptPart):
216-
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())
217252
else:
218-
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))
219254

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

pydantic_ai_slim/pydantic_ai/ui/vercel_ai/request_types.py

Lines changed: 3 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

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

tests/test_vercel_ai.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1749,9 +1749,7 @@ def test_extract_deferred_tool_results_denied():
17491749
tool_name='delete_file',
17501750
tool_call_id='delete_1',
17511751
input={'path': 'test.txt'},
1752-
approval=ToolApprovalResponded(
1753-
id='approval-123', approved=False, reason='User rejected deletion'
1754-
),
1752+
approval=ToolApprovalResponded(id='approval-123', approved=False, reason='User rejected deletion'),
17551753
),
17561754
],
17571755
),
@@ -1779,6 +1777,74 @@ def test_extract_deferred_tool_results_no_approvals():
17791777
assert result is None
17801778

17811779

1780+
async def test_tool_output_denied_chunk_emission():
1781+
"""Test that ToolOutputDeniedChunk is emitted when a tool call is denied."""
1782+
from pydantic_ai.tools import DeferredToolRequests
1783+
1784+
from pydantic_ai.ui.vercel_ai.request_types import (
1785+
DynamicToolInputAvailablePart,
1786+
ToolApprovalResponded,
1787+
)
1788+
1789+
async def stream_function(
1790+
messages: list[ModelMessage], agent_info: AgentInfo
1791+
) -> AsyncIterator[DeltaToolCalls | str]:
1792+
# Model acknowledges the denial
1793+
yield 'The file deletion was cancelled as requested.'
1794+
1795+
agent: Agent[None, str | DeferredToolRequests] = Agent(
1796+
model=FunctionModel(stream_function=stream_function), output_type=[str, DeferredToolRequests]
1797+
)
1798+
1799+
@agent.tool_plain(requires_approval=True)
1800+
def delete_file(path: str) -> str:
1801+
return f'Deleted {path}'
1802+
1803+
# Simulate a follow-up request where the user denied the tool
1804+
request = SubmitMessage(
1805+
id='foo',
1806+
messages=[
1807+
UIMessage(
1808+
id='user-1',
1809+
role='user',
1810+
parts=[TextUIPart(text='Delete test.txt')],
1811+
),
1812+
UIMessage(
1813+
id='assistant-1',
1814+
role='assistant',
1815+
parts=[
1816+
DynamicToolInputAvailablePart(
1817+
tool_name='delete_file',
1818+
tool_call_id='delete_1',
1819+
input={'path': 'test.txt'},
1820+
approval=ToolApprovalResponded(
1821+
id='approval-123',
1822+
approved=False,
1823+
reason='User cancelled the deletion',
1824+
),
1825+
),
1826+
],
1827+
),
1828+
],
1829+
)
1830+
1831+
adapter = VercelAIAdapter(agent, request)
1832+
events: list[str | dict[str, Any]] = [
1833+
'[DONE]' if '[DONE]' in event else json.loads(event.removeprefix('data: '))
1834+
async for event in adapter.encode_stream(
1835+
adapter.run_stream(deferred_tool_results=adapter.deferred_tool_results)
1836+
)
1837+
]
1838+
1839+
# Verify tool-output-denied chunk is emitted
1840+
denied_event: dict[str, Any] | None = next(
1841+
(e for e in events if isinstance(e, dict) and e.get('type') == 'tool-output-denied'),
1842+
None,
1843+
)
1844+
assert denied_event is not None
1845+
assert denied_event['toolCallId'] == 'delete_1'
1846+
1847+
17821848
@pytest.mark.skipif(not starlette_import_successful, reason='Starlette is not installed')
17831849
async def test_adapter_dispatch_request():
17841850
agent = Agent(model=TestModel())
@@ -2217,6 +2283,7 @@ async def test_adapter_dump_messages_with_tools():
22172283
'output': '{"results":["result1","result2"]}',
22182284
'call_provider_metadata': None,
22192285
'preliminary': None,
2286+
'approval': None,
22202287
},
22212288
],
22222289
},
@@ -2279,6 +2346,7 @@ async def test_adapter_dump_messages_with_builtin_tools():
22792346
'provider_executed': True,
22802347
'call_provider_metadata': {'pydantic_ai': {'provider_name': 'openai'}},
22812348
'preliminary': None,
2349+
'approval': None,
22822350
}
22832351
],
22842352
},
@@ -2325,6 +2393,7 @@ async def test_adapter_dump_messages_with_builtin_tool_without_return():
23252393
'input': '{"query":"orphan query"}',
23262394
'provider_executed': True,
23272395
'call_provider_metadata': {'pydantic_ai': {'provider_name': 'openai'}},
2396+
'approval': None,
23282397
}
23292398
],
23302399
},
@@ -2491,6 +2560,7 @@ async def test_adapter_dump_messages_with_retry():
24912560
Fix the errors and try again.\
24922561
""",
24932562
'call_provider_metadata': None,
2563+
'approval': None,
24942564
}
24952565
],
24962566
},
@@ -2622,6 +2692,7 @@ async def test_adapter_dump_messages_text_with_interruption():
26222692
'provider_executed': True,
26232693
'call_provider_metadata': {'pydantic_ai': {'provider_name': 'test'}},
26242694
'preliminary': None,
2695+
'approval': None,
26252696
},
26262697
{
26272698
'type': 'text',
@@ -2740,6 +2811,7 @@ async def test_adapter_dump_messages_tool_call_without_return():
27402811
'state': 'input-available',
27412812
'input': '{"city":"New York"}',
27422813
'call_provider_metadata': None,
2814+
'approval': None,
27432815
}
27442816
],
27452817
}
@@ -2774,6 +2846,7 @@ async def test_adapter_dump_messages_assistant_starts_with_tool():
27742846
'state': 'input-available',
27752847
'input': '{}',
27762848
'call_provider_metadata': None,
2849+
'approval': None,
27772850
},
27782851
{
27792852
'type': 'text',

0 commit comments

Comments
 (0)