Skip to content

Commit 3deae5b

Browse files
committed
Support integrating the hook messages with the ai-sdk ui protocol
Approvals will cause `ToolResultPart`s with `is_hook_abort` to get injected back into the last tool result.
1 parent f39810b commit 3deae5b

7 files changed

Lines changed: 143 additions & 30 deletions

File tree

examples/fastapi-vite/backend/agent.py

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@
2828
when issuing direction. Two short paragraphs at most. This is fiction."""
2929

3030

31+
@ai.tool
32+
async def get_weather(city: str) -> str:
33+
"""Get current weather for a city."""
34+
await asyncio.sleep(2)
35+
return f"Sunny, 72F in {city}" if city == "Tokyo" else f"Cloudy, 55F in {city}"
36+
37+
38+
@ai.tool
39+
async def get_population(city: str) -> int:
40+
"""Get population of a city."""
41+
await asyncio.sleep(1)
42+
return {"new york": 8_336_817, "tokyo": 13_960_000}.get(city.lower(), 1_000_000)
43+
44+
3145
@ai.tool
3246
async def talk_to_mothership(question: str) -> ai.SubAgentTool:
3347
"""Contact the mothership for important decisions."""
@@ -41,7 +55,8 @@ async def talk_to_mothership(question: str) -> ai.SubAgentTool:
4155
yield event
4256

4357

44-
TOOLS: list[ai.AgentTool] = [talk_to_mothership]
58+
TOOLS: list[ai.AgentTool] = [get_weather, get_population, talk_to_mothership]
59+
4560

4661
chat_agent = ai.agent(tools=TOOLS)
4762

@@ -56,20 +71,28 @@ async def graph(context: ai.Context) -> AsyncGenerator[ai.events.AgentEvent]:
5671
Reject buttons and sends the decision back on the next request.
5772
"""
5873
while context.keep_running():
59-
async with ai.models.stream(context=context) as s:
60-
async for event in s:
74+
async with (
75+
ai.stream(context=context) as s,
76+
ai.ToolRunner() as tr,
77+
):
78+
async for event in ai.util.merge(s, tr.events()):
6179
yield event
62-
context.add(s.message)
80+
if isinstance(event, ai.events.ToolEnd):
81+
tc = _resolve(context, event.tool_call)
82+
tr.schedule(tc)
6383

64-
tool_calls = context.resolve(s.tool_calls)
65-
if not tool_calls:
66-
continue
84+
context.add(s.message)
85+
context.add(tr.get_tool_message())
6786

68-
results = await asyncio.gather(
69-
*(_execute_with_approval(tc) for tc in tool_calls)
70-
)
71-
yield ai.tool_result(*results)
72-
context.add(ai.tool_message(*results))
87+
88+
def _resolve(
89+
context: ai.Context, tool_call: ai.messages.ToolCallPart
90+
) -> ai.ToolCallLike:
91+
tc = context.resolve(tool_call)
92+
if tc.name == "talk_to_mothership":
93+
return lambda: _execute_with_approval(tc)
94+
else:
95+
return tc
7396

7497

7598
async def _execute_with_approval(tc: ai.ToolCall) -> ai.events.ToolCallResult:
@@ -78,12 +101,15 @@ async def _execute_with_approval(tc: ai.ToolCall) -> ai.events.ToolCallResult:
78101
Creates a ToolApproval hook that suspends execution until the
79102
frontend responds with an approve/reject decision.
80103
"""
81-
approval = await ai.hook(
82-
f"approve_{tc.id}",
83-
payload=ai.tools.ToolApproval,
84-
metadata={"tool_name": tc.name, "tool_kwargs": tc.kwargs},
85-
interrupt_loop=True,
86-
)
104+
try:
105+
approval = await ai.hook(
106+
f"approve_{tc.id}",
107+
payload=ai.tools.ToolApproval,
108+
metadata={"tool_name": tc.name, "tool_kwargs": tc.kwargs},
109+
interrupt_loop=True,
110+
)
111+
except ai.agents.hooks.HookAbortError as e:
112+
return ai.pending_tool_result(e.hook, tool_call_id=tc.id, tool_name=tc.name)
87113

88114
if approval.granted:
89115
return await tc()

examples/fastapi-vite/backend/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class ChatRequest(pydantic.BaseModel):
5858
async def chat(request: ChatRequest) -> fastapi.responses.StreamingResponse:
5959
"""Handle chat requests and stream responses."""
6060
messages, approvals = ai.agents.ui.ai_sdk.to_messages(request.messages)
61+
6162
# Pre-register hook resolutions so the agent loop's hooks find them
6263
# immediately on the resume turn.
6364
ai.agents.ui.ai_sdk.apply_approvals(approvals)

examples/fastapi-vite/e2e-test/e2e-test.mjs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
// End-to-end smoke test for the chat + tool-approval flow.
22
//
3-
// Drives the Vite frontend in a headless browser, sends a message that
4-
// triggers the `talk_to_mothership` tool, approves the request, and
5-
// asserts the tool result is rendered.
3+
// Drives the Vite frontend in a headless browser, sends a compound
4+
// question that triggers both the (non-gated) `get_weather` tool and
5+
// the (gated) `talk_to_mothership` tool, approves the mothership
6+
// request, and asserts both tool results render alongside a final
7+
// assistant reply.
68
//
7-
// Prereqs:
8-
// - backend running on :8000 (cd backend && uv run --frozen --with-editable ~/src/py-ai/ fastapi dev main.py)
9-
// - frontend running on :5173 (cd frontend && pnpm dev)
10-
// - install deps: npm install && npx playwright install chromium
9+
// Use ./run.sh to launch backend + frontend and run this script.
1110
//
1211
// Run from this directory: node e2e-test.mjs
1312

1413
import { chromium } from "playwright";
1514

1615
const FRONTEND = process.env.FRONTEND_URL ?? "http://localhost:5173/";
17-
const PROMPT = "Ask the mothership when robots will take over";
16+
const PROMPT = [
17+
"Two things:",
18+
"1. What is the current weather in Tokyo?",
19+
"2. Ask the mothership when the robot uprising begins",
20+
].join("\n");
1821

1922
const browser = await chromium.launch();
2023
const ctx = await browser.newContext();
@@ -127,6 +130,17 @@ if (transcript.includes("Awaiting Approval")) {
127130
process.exit(1);
128131
}
129132

133+
// Both tools must have run — the non-gated get_weather alongside
134+
// the gated talk_to_mothership we just approved.
135+
if (!transcript.includes("get_weather")) {
136+
console.error("FAIL: get_weather tool not rendered in transcript");
137+
process.exit(1);
138+
}
139+
if (!transcript.includes("talk_to_mothership")) {
140+
console.error("FAIL: talk_to_mothership tool not rendered in transcript");
141+
process.exit(1);
142+
}
143+
130144
// The agent must produce a final assistant text bubble after the tool.
131145
// `.is-assistant` is the class set by the Message component for
132146
// `from="assistant"` text parts (tool parts don't carry it), so the last
@@ -145,6 +159,13 @@ if (finalReply.length < 20) {
145159
);
146160
process.exit(1);
147161
}
162+
// The compound answer should reference both halves of the question.
163+
if (!finalReply.toLowerCase().includes("tokyo")) {
164+
console.error(
165+
`FAIL: final reply doesn't mention Tokyo: ${JSON.stringify(finalReply)}`
166+
);
167+
process.exit(1);
168+
}
148169

149170
console.log("PASS");
150171
await browser.close();

examples/fastapi-vite/e2e-test/run.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ ROOT=$(cd "$HERE/.." && pwd)
1515
LOGS=$(mktemp -d)
1616
echo "Logs: $LOGS"
1717

18-
BACKEND_PORT=${BACKEND_PORT:-8000}
19-
FRONTEND_PORT=${FRONTEND_PORT:-5173}
18+
BACKEND_PORT=${BACKEND_PORT:-8765}
19+
FRONTEND_PORT=${FRONTEND_PORT:-5765}
2020
export BACKEND_URL="http://localhost:$BACKEND_PORT"
2121
export FRONTEND_URL="http://localhost:$FRONTEND_PORT/"
2222

src/ai/agents/ui/ai_sdk/_parts.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ def merge_tool_results(
7373
for part in tool_parts:
7474
if not isinstance(part, messages_.ToolResultPart):
7575
continue
76+
# Hook-abort placeholders are internal: the corresponding
77+
# HookPart(pending) carries the user-visible state via
78+
# merge_approval_signals.
79+
if part.is_hook_abort:
80+
continue
7681
idx_opt = tool_index.get(part.tool_call_id)
7782
if idx_opt is None:
7883
continue

src/ai/agents/ui/ai_sdk/inbound.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ class ApprovalResponse(NamedTuple):
108108
hook_id: str
109109
granted: bool
110110
reason: str | None
111+
tool_call_id: str
111112

112113

113114
def extract_approvals(
@@ -132,6 +133,7 @@ def extract_approvals(
132133
hook_id=part.approval.id,
133134
granted=part.approval.approved,
134135
reason=part.approval.reason,
136+
tool_call_id=part.tool_call_id,
135137
)
136138
)
137139
return approvals
@@ -203,8 +205,10 @@ def to_messages(
203205
204206
Pure: normalizes stale tool states, extracts approval responses,
205207
parses UIMessages into an ``ai.messages.Message`` list (split at
206-
tool boundaries), and drops the internal tombstones for approval
207-
responses.
208+
tool boundaries), drops the internal tombstones for approval
209+
responses, and patches the trailing tool message with
210+
``is_hook_abort`` placeholders for tool calls whose approval was
211+
just responded to but never recorded a real tool result.
208212
209213
Returns ``(messages, approvals)``. The caller can pre-register
210214
resolutions via :func:`apply_approvals` before calling
@@ -213,9 +217,61 @@ def to_messages(
213217
normalized = _normalize_ui_messages(ui_messages)
214218
approvals = extract_approvals(normalized)
215219
messages = [m for m in _parse(normalized) if not _is_approval_response(m)]
220+
_patch_pending_hook_aborts(messages, approvals)
216221
return messages, approvals
217222

218223

224+
def _patch_pending_hook_aborts(
225+
messages: list[messages_.Message],
226+
approvals: list[ApprovalResponse],
227+
) -> None:
228+
"""Inject ``is_hook_abort=True`` placeholders for tool calls whose
229+
approval was responded to but whose tool result is still missing.
230+
231+
This deals with the case where a prior run emitted multiple tool
232+
calls, some of which succeeded and some of which aborted on an
233+
approval hook.
234+
235+
In that case, there will be an assistant message with multiple
236+
tool calls, a tool result with fewer results (some are missing),
237+
and then also some hook approvals.
238+
239+
This synthesizes `ToolResultPart`s with `is_hook_abort=True` in
240+
order to be able to feed things back into the agent protocol.
241+
"""
242+
if len(messages) < 2:
243+
return
244+
245+
tool_msg = messages[-1]
246+
assistant_msg = messages[-2]
247+
if tool_msg.role != "tool" or assistant_msg.role != "assistant":
248+
return
249+
if not assistant_msg.tool_calls:
250+
return
251+
252+
hooks = {a.tool_call_id: a for a in approvals}
253+
completed_ids = {r.tool_call_id for r in tool_msg.tool_results}
254+
255+
new_parts: list[messages_.Part] = list(tool_msg.parts)
256+
for tc in assistant_msg.tool_calls:
257+
if tc.tool_call_id in completed_ids:
258+
continue
259+
if not (hook := hooks.get(tc.tool_call_id)):
260+
continue
261+
new_parts.append(
262+
messages_.ToolResultPart(
263+
tool_call_id=tc.tool_call_id,
264+
tool_name=tc.tool_name,
265+
result=f"Pending on hook '{hook.hook_id}'",
266+
is_error=True,
267+
is_hook_abort=True,
268+
)
269+
)
270+
271+
if len(new_parts) != len(tool_msg.parts):
272+
messages[-1] = tool_msg.model_copy(update={"parts": new_parts})
273+
274+
219275
def _is_approval_response(msg: messages_.Message) -> bool:
220276
"""Internal message that records a resolved tool-approval hook."""
221277
if msg.role != "internal" or len(msg.parts) != 1:

src/ai/agents/ui/ai_sdk/outbound/_state.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,10 @@ def on_tool_result(
205205
for part in event.results:
206206
if part.tool_call_id in self.emitted_tool_results:
207207
continue
208+
# Hook-abort placeholders are internal bookkeeping: the
209+
# corresponding HookPart(pending) drives the UI state.
210+
if part.is_hook_abort:
211+
continue
208212
self.emitted_tool_results.add(part.tool_call_id)
209213
if part.is_error:
210214
out.append(

0 commit comments

Comments
 (0)