Pause for external approval#
Correlate an external reply with a waiting tool. The simulated UI answers immediately; duplicate replies are rejected. No export is actually performed.
Run it#
From the repository root:
uv run python docs/_examples/approval.py
The output includes:
Response accepted; duplicate rejected
This program uses deterministic offline models and temporary storage where needed. It requires no API key and is executed by the documentation test suite.
Connect an approval UI#
The example publishes an approval request, pauses the tool, and sends a correlated reply back through the client. In a real application, replace the simulated reply with your UI’s accept or reject action.
async def main() -> None:
config = AgentRunConfig(session_id="approval-demo", request_id="request-1")
client = await create_agent(
ApprovalModel(), config=config, extensions=[ApprovalExtension(), ToolGuidelinesExtension()]
)
# stream() is annotated AsyncIterator but is an async generator, which aclosing needs.
async with aclosing(client.stream("Export my notes")) as events: # type: ignore[type-var]
async for event in events:
if event.type == AgentEventType.CUSTOM and event.name == "approval.requested":
assert event.payload is not None
print(f"Question: {event.payload['action']}?")
# A real application sends this from a UI/API callback, not automatically.
response = ExternalEvent(
name="approval.response",
payload={"tool_call_id": event.payload["tool_call_id"], "approved": True},
)
accepted = client.agent.emit_external_event(response, config=config)
assert accepted == ["ApprovalExtension"]
assert client.agent.emit_external_event(response, config=config) == []
print("Response accepted; duplicate rejected")
elif event.type == AgentEventType.RUN_COMPLETED:
assert event.message is not None
print(event.message.content)
Keep the session, request, and tool-call identifiers with the prompt you show the user. A reply for another request must not authorize this one. The example checks that a duplicate reply is rejected and does not perform an actual export.
Complete source#
1"""Round-trip an external approval through the framework's pending-event base."""
2
3import asyncio
4from collections.abc import AsyncIterator
5from contextlib import aclosing
6
7from zett_agent.agent import (
8 AgentRunConfig,
9 AgentRunContext,
10)
11from zett_agent.client import create_agent
12from zett_agent.events import (
13 AgentEvent,
14 AgentEventType,
15)
16from zett_agent.extensions.external import (
17 ExternalEvent,
18 ExternalEventExtension,
19)
20from zett_agent.extensions.tool_guidelines import ToolGuidelinesExtension
21from zett_agent.messages import (
22 AssistantMessage,
23 ToolCall,
24 ToolMessage,
25)
26from zett_agent.model import (
27 ModelEvent,
28 ModelRequest,
29 ModelResponse,
30 RetryOptions,
31)
32from zett_agent.tools.base import tool
33
34
35class ApprovalExtension(ExternalEventExtension):
36 def __init__(self) -> None:
37 super().__init__(response_event_name="approval.response", correlation_field="tool_call_id")
38
39 async def on_tool(self, context: AgentRunContext) -> None:
40 @tool
41 def request_approval(action: str) -> str:
42 """Ask the user whether a proposed action should proceed.
43
44 Args:
45 action: Human-readable proposed action; this tool does not execute it.
46
47 Snippet:
48 request_approval(action="Export notes")
49
50 Guidelines:
51 - Ask before performing an action that requires user approval.
52 """
53 response = self._take_external_event(context)
54 approved = response.payload.get("approved")
55 if not isinstance(approved, bool):
56 raise ValueError("approved must be a boolean")
57 return "approved" if approved else "rejected"
58
59 context.register_tool(request_approval)
60
61 async def before_tool(self, context: AgentRunContext, call: ToolCall) -> None:
62 if call.name != "request_approval":
63 return
64 async with self._wait_for_external_event(context, call.id):
65 await context.emit(
66 AgentEvent(
67 AgentEventType.CUSTOM,
68 session_id=context.session_id,
69 name="approval.requested",
70 payload={"tool_call_id": call.id, "action": call.arguments["action"]},
71 )
72 )
73 # Leaving the async-with body waits for the routed external response.
74
75
76class ApprovalModel:
77 retry = RetryOptions(max_retries=0)
78
79 async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
80 last = request.messages[-1]
81 if isinstance(last, ToolMessage):
82 assert last.success
83 message = AssistantMessage(content="Approval received; no export was executed in this demo.")
84 else:
85 message = AssistantMessage(
86 tool_calls=(ToolCall("approval-1", "request_approval", {"action": "Export notes"}),)
87 )
88 yield ModelEvent.completed(ModelResponse(message))
89
90
91async def main() -> None:
92 config = AgentRunConfig(session_id="approval-demo", request_id="request-1")
93 client = await create_agent(
94 ApprovalModel(), config=config, extensions=[ApprovalExtension(), ToolGuidelinesExtension()]
95 )
96 # stream() is annotated AsyncIterator but is an async generator, which aclosing needs.
97 async with aclosing(client.stream("Export my notes")) as events: # type: ignore[type-var]
98 async for event in events:
99 if event.type == AgentEventType.CUSTOM and event.name == "approval.requested":
100 assert event.payload is not None
101 print(f"Question: {event.payload['action']}?")
102 # A real application sends this from a UI/API callback, not automatically.
103 response = ExternalEvent(
104 name="approval.response",
105 payload={"tool_call_id": event.payload["tool_call_id"], "approved": True},
106 )
107 accepted = client.agent.emit_external_event(response, config=config)
108 assert accepted == ["ApprovalExtension"]
109 assert client.agent.emit_external_event(response, config=config) == []
110 print("Response accepted; duplicate rejected")
111 elif event.type == AgentEventType.RUN_COMPLETED:
112 assert event.message is not None
113 print(event.message.content)
114
115
116if __name__ == "__main__":
117 asyncio.run(main())
Next: Emit events and accept user input for response routing, or
Compose built-in extensions for AskUserExtension and PlanModeExtension.