Skip to content

Commit d1ad307

Browse files
committed
Wire tool approval into AI SDK UI protocol
1 parent 208072b commit d1ad307

7 files changed

Lines changed: 192 additions & 23 deletions

File tree

src/vercel_ai_sdk/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from . import ai_gateway, ai_sdk_ui, anthropic, mcp, openai
22
from .core import telemetry
33
from .core.checkpoint import Checkpoint
4-
from .core.hooks import Hook, hook
4+
from .core.hooks import Hook, ToolApproval, hook
55
from .core.llm import LanguageModel
66

77
# Re-export core types
@@ -51,6 +51,7 @@
5151
"StreamResult",
5252
"Hook",
5353
"HookPart",
54+
"ToolApproval",
5455
"StructuredOutputPart",
5556
"Checkpoint",
5657
# Functions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from . import local, proto, tools, vercel
2-
from .agent import Agent, ToolApproval
2+
from .agent import Agent
33

4-
__all__ = ["Agent", "ToolApproval", "proto", "tools", "local", "vercel"]
4+
__all__ = ["Agent", "proto", "tools", "local", "vercel"]

src/vercel_ai_sdk/agent/agent.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,12 @@
44
import dataclasses
55
from typing import Any
66

7-
import pydantic
8-
97
import vercel_ai_sdk as ai
108

119
from . import proto
1210
from .tools import BUILTIN_TOOLS, _filesystem
1311

1412

15-
@ai.hook
16-
class ToolApproval(pydantic.BaseModel):
17-
granted: bool
18-
reason: str | None = None
19-
20-
2113
@dataclasses.dataclass
2214
class Agent:
2315
"""
@@ -48,7 +40,7 @@ async def _execute_tool(
4840
"""
4941
# TODO: mypy doesn't support class decorators that change the class type —
5042
# @ai.hook returns type[Hook[T]] but mypy still sees the original BaseModel.
51-
approval = await ToolApproval.create( # type: ignore[attr-defined]
43+
approval = await ai.ToolApproval.create( # type: ignore[attr-defined]
5244
f"approve_{tc.tool_call_id}",
5345
metadata={"tool_name": tc.tool_name, "tool_args": tc.tool_args},
5446
)

src/vercel_ai_sdk/ai_sdk_ui/adapter.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import Any, Literal
1212

1313
from .. import core
14+
from ..core import hooks
1415
from . import protocol, ui_message
1516

1617
# ============================================================================
@@ -69,6 +70,7 @@ def __init__(self) -> None:
6970
self.started_tool_calls: set[str] = set()
7071
self.emitted_tool_results: set[str] = set()
7172
self.pending_tool_calls: set[str] = set()
73+
self.emitted_approval_requests: set[str] = set()
7274

7375
def close_open_blocks(self) -> list[protocol.UIMessageStreamPart]:
7476
"""Close any open reasoning/text blocks, returning parts to emit."""
@@ -94,6 +96,7 @@ def reset_tool_tracking(self) -> None:
9496
self.started_tool_calls = set()
9597
self.emitted_tool_results = set()
9698
self.pending_tool_calls = set()
99+
self.emitted_approval_requests = set()
97100

98101
def begin_message(
99102
self, msg: core.messages.Message
@@ -130,6 +133,22 @@ def begin_message(
130133
return parts
131134

132135

136+
def _tool_call_id_from_approval_hook(
137+
hook_part: core.messages.HookPart,
138+
) -> str | None:
139+
"""Extract tool_call_id from a ToolApproval HookPart.
140+
141+
Returns the tool_call_id if this is a ToolApproval hook whose hook_id
142+
follows the ``approve_{tool_call_id}`` convention, otherwise None.
143+
"""
144+
if hook_part.hook_type != hooks.ToolApproval.hook_type: # type: ignore[attr-defined]
145+
return None
146+
prefix = "approve_"
147+
if hook_part.hook_id.startswith(prefix):
148+
return hook_part.hook_id[len(prefix) :]
149+
return None
150+
151+
133152
async def to_ui_message_stream(
134153
messages: AsyncIterable[core.messages.Message],
135154
) -> AsyncGenerator[protocol.UIMessageStreamPart]:
@@ -256,6 +275,33 @@ async def to_ui_message_stream(
256275
output=result,
257276
)
258277

278+
# Pass 3: Hook-based tool approvals
279+
for msg_part in msg.parts:
280+
if not isinstance(msg_part, core.messages.HookPart):
281+
continue
282+
approval_tc_id = _tool_call_id_from_approval_hook(msg_part)
283+
if approval_tc_id is None:
284+
continue
285+
286+
if msg_part.status == "pending":
287+
if approval_tc_id not in state.emitted_approval_requests:
288+
state.emitted_approval_requests.add(approval_tc_id)
289+
yield protocol.ToolApprovalRequestPart(
290+
approval_id=msg_part.hook_id,
291+
tool_call_id=approval_tc_id,
292+
)
293+
elif msg_part.status == "resolved":
294+
resolution = msg_part.resolution or {}
295+
if not resolution.get("granted", False):
296+
yield protocol.ToolOutputDeniedPart(
297+
tool_call_id=approval_tc_id,
298+
)
299+
elif msg_part.status == "cancelled":
300+
yield protocol.ToolOutputErrorPart(
301+
tool_call_id=approval_tc_id,
302+
error_text="Hook cancelled",
303+
)
304+
259305
# Final cleanup
260306
for part in state.finish_step():
261307
yield part
@@ -333,6 +379,10 @@ def to_messages(
333379
) -> list[core.messages.Message]:
334380
"""Convert AI SDK v6 UI messages to internal Message format.
335381
382+
As a side-effect, tool parts in ``approval-responded`` state trigger
383+
``ToolApproval.resolve()`` so the agent loop can resume execution
384+
without the caller needing to handle approval routing explicitly.
385+
336386
Args:
337387
ui_messages: List of UIMessage objects from the AI SDK v6 frontend.
338388
@@ -375,6 +425,20 @@ def to_messages(
375425
result=_normalize_tool_result(tp.output),
376426
)
377427
)
428+
# Side-effect: resolve ToolApproval hooks from approval
429+
# responses so the agent loop can resume execution.
430+
if (
431+
tp.state == "approval-responded"
432+
and tp.approval is not None
433+
and tp.approval.approved is not None
434+
):
435+
hooks.ToolApproval.resolve( # type: ignore[attr-defined]
436+
tp.approval.id,
437+
{
438+
"granted": tp.approval.approved,
439+
"reason": tp.approval.reason,
440+
},
441+
)
378442

379443
case (
380444
ui_message.UIStepStartPart()

src/vercel_ai_sdk/ai_sdk_ui/ui_message.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ class UIReasoningPart(pydantic.BaseModel):
3737
# Tool invocation states in AI SDK v6:
3838
# - "input-streaming": Tool arguments are being streamed
3939
# - "input-available": Tool arguments are complete, ready for execution
40-
# - "approval-requested": Tool requires user approval (TODO: approval workflow)
41-
# - "approval-responded": User has responded to approval (TODO: approval workflow)
40+
# - "approval-requested": Tool requires user approval before execution
41+
# - "approval-responded": User has responded to approval request
4242
# - "output-available": Tool has been executed, result is available
4343
# - "output-error": Tool execution failed
44-
# - "output-denied": Tool execution was denied by user (TODO: approval workflow)
44+
# - "output-denied": Tool execution was denied by user
4545
UIToolInvocationState = Literal[
4646
"input-streaming",
4747
"input-available",
@@ -78,6 +78,21 @@ class UIStepStartPart(pydantic.BaseModel):
7878
type: Literal["step-start"]
7979

8080

81+
class UIToolApproval(pydantic.BaseModel):
82+
"""Approval state on a tool part (AI SDK v6 protocol).
83+
84+
Present when a tool requires user approval before execution.
85+
``id`` matches the hook label used by the ToolApproval hook.
86+
``approved`` is None while awaiting a response, True/False after.
87+
"""
88+
89+
model_config = pydantic.ConfigDict(populate_by_name=True)
90+
91+
id: str
92+
approved: bool | None = None
93+
reason: str | None = None
94+
95+
8196
class UIToolPart(pydantic.BaseModel):
8297
"""Tool part with dynamic type pattern: tool-{toolName}.
8398
@@ -95,8 +110,7 @@ class UIToolPart(pydantic.BaseModel):
95110
input: str | dict[str, Any] | None = None # JSON string or parsed dict
96111
output: Any | None = None
97112
error_text: str | None = pydantic.Field(default=None, alias="errorText")
98-
# TODO: title, providerExecuted, preliminary fields
99-
# TODO: approval workflow (approval object)
113+
approval: UIToolApproval | None = None
100114

101115
@property
102116
def tool_name(self) -> str:

src/vercel_ai_sdk/core/hooks.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ class Hook[T: pydantic.BaseModel]:
7272
"""
7373

7474
_schema: ClassVar[type[pydantic.BaseModel]]
75-
_hook_type: ClassVar[str]
75+
hook_type: ClassVar[str]
7676

7777
@classmethod
7878
async def create(cls, label: str, metadata: dict[str, Any] | None = None) -> T:
@@ -113,7 +113,7 @@ async def create(cls, label: str, metadata: dict[str, Any] | None = None) -> T:
113113
future: asyncio.Future[dict[str, Any]] = asyncio.Future()
114114
suspension = rt_mod.HookSuspension(
115115
label=label,
116-
hook_type=cls._hook_type,
116+
hook_type=cls.hook_type,
117117
metadata=metadata or {},
118118
future=future,
119119
)
@@ -142,7 +142,7 @@ async def create(cls, label: str, metadata: dict[str, Any] | None = None) -> T:
142142
parts=[
143143
messages_.HookPart(
144144
hook_id=label,
145-
hook_type=cls._hook_type,
145+
hook_type=cls.hook_type,
146146
status="resolved",
147147
metadata=hook_metadata,
148148
resolution=resolution,
@@ -215,7 +215,7 @@ async def cancel(cls, label: str, reason: str | None = None) -> None:
215215
parts=[
216216
messages_.HookPart(
217217
hook_id=label,
218-
hook_type=cls._hook_type,
218+
hook_type=cls.hook_type,
219219
status="cancelled",
220220
metadata=hook_metadata,
221221
)
@@ -235,9 +235,22 @@ def hook[T: pydantic.BaseModel](cls: type[T]) -> type[Hook[T]]:
235235
(Hook,),
236236
{
237237
"_schema": cls,
238-
"_hook_type": cls.__name__,
238+
"hook_type": cls.__name__,
239239
"__doc__": cls.__doc__,
240240
},
241241
)
242242

243243
return hook_impl
244+
245+
246+
@hook
247+
class ToolApproval(pydantic.BaseModel):
248+
"""Prewired hook for tool call approval.
249+
250+
Used by the AI SDK UI adapter to bridge the protocol's
251+
tool-approval-request / approval-responded flow to the
252+
hook system.
253+
"""
254+
255+
granted: bool
256+
reason: str | None = None

tests/ai_sdk_ui/test_adapter.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import vercel_ai_sdk as ai
1010
from vercel_ai_sdk.ai_sdk_ui import adapter, ui_message
11-
from vercel_ai_sdk.core import messages
11+
from vercel_ai_sdk.core import hooks, messages
1212

1313
from ..conftest import MockLLM
1414

@@ -492,3 +492,88 @@ def test_ui_skips_unsupported_parts() -> None:
492492

493493
internal = adapter.to_messages([ui_msg])
494494
assert len(internal[0].parts) == 2
495+
496+
497+
# -----------------------------------------------------------------------------
498+
# Tool approval (human-in-the-loop) tests
499+
# -----------------------------------------------------------------------------
500+
501+
502+
@pytest.mark.asyncio
503+
async def test_tool_approval_hook_emits_approval_request() -> None:
504+
"""Pending ToolApproval HookPart emits tool-approval-request on the wire."""
505+
msgs = [
506+
# Tool pending (args complete, awaiting approval)
507+
messages.Message(
508+
id="msg-1",
509+
role="assistant",
510+
parts=[
511+
messages.ToolPart(
512+
tool_call_id="tc-1",
513+
tool_name="rm_rf",
514+
tool_args='{"path": "/"}',
515+
status="pending",
516+
state="done",
517+
),
518+
],
519+
),
520+
# Hook pending (approval requested)
521+
messages.Message(
522+
id="msg-1",
523+
role="assistant",
524+
parts=[
525+
messages.HookPart(
526+
hook_id="approve_tc-1",
527+
hook_type=hooks.ToolApproval.hook_type, # type: ignore[attr-defined]
528+
status="pending",
529+
metadata={"tool_name": "rm_rf", "tool_args": '{"path": "/"}'},
530+
),
531+
],
532+
),
533+
]
534+
535+
event_types = await get_event_types(msgs)
536+
assert event_types == [
537+
"start",
538+
"start-step",
539+
"tool-input-start",
540+
"tool-input-available",
541+
"tool-approval-request",
542+
"finish-step",
543+
"finish",
544+
]
545+
546+
547+
def test_approval_responded_resolves_hook() -> None:
548+
"""to_messages() resolves the ToolApproval hook for approval-responded parts."""
549+
label = "approve_tc-42"
550+
raw_messages = [
551+
{
552+
"id": "msg-1",
553+
"role": "assistant",
554+
"parts": [
555+
{
556+
"type": "tool-dangerous_action",
557+
"toolCallId": "tc-42",
558+
"state": "approval-responded",
559+
"input": '{"x": 1}',
560+
"approval": {
561+
"id": label,
562+
"approved": True,
563+
"reason": "looks safe",
564+
},
565+
}
566+
],
567+
},
568+
]
569+
570+
# Clean up any leftover state from other tests
571+
hooks._pending_resolutions.pop(label, None)
572+
573+
ui_msgs = [ui_message.UIMessage.model_validate(m) for m in raw_messages]
574+
adapter.to_messages(ui_msgs)
575+
576+
# The side-effect should have pre-registered the resolution
577+
assert label in hooks._pending_resolutions
578+
resolution = hooks._pending_resolutions.pop(label)
579+
assert resolution == {"granted": True, "reason": "looks safe"}

0 commit comments

Comments
 (0)