暂停以等待外部审批#
把外部回复与正在等待的工具关联起来。模拟的 UI 会立即作答;重复的回复会被拒绝。实际上不会执行任何导出。
运行示例#
在仓库根目录下:
uv run python docs/_examples/approval.py
输出包含:
Response accepted; duplicate rejected
这个程序使用确定性的离线模型,并在需要时使用临时存储。它不需要 API 密钥,由文档测试套件执行。
接入审批 UI#
示例会发布一个审批请求,暂停该工具,并通过客户端发回一个关联的回复。在真实的应用中,请把模拟的回复替换成你 UI 的接受或拒绝操作。
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)
在你展示给用户的提示词旁,保存会话、请求和工具调用的标识符。针对另一个请求的回复绝不能为当前请求授权。示例会检查重复的回复被拒绝,并且不会执行真正的导出。
完整源码#
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())
下一步:阅读发出事件并接收用户输入了解响应路由,或阅读组合内置扩展了解 AskUserExtension 和 PlanModeExtension。