发出事件并接收用户输入#

向 UI 发送进度#

当工作需要可见的进度时,请在生命周期钩子里使用 context.emit。这里的负载包含第一个扩展教程中的模型步骤计数器:

    async def before_model(self, context: AgentRunContext, request: ModelRequest) -> None:
        self.active[context] += 1
        await context.emit(
            AgentEvent(
                AgentEventType.CUSTOM,
                session_id=context.session_id,
                name="note.model_step",
                payload={"step": self.active[context]},
            )
        )

应用程序通过 on_custom_event收到这个确切的事件。请选择一个稳定的带命名空间的名称,并把负载键的说明当作应用协议来记录。如果 UI 会通过 SSE 或 WebSocket 转发这些值,请使用与 JSON 兼容的值。

观察内部状态而不展示它#

class UsageObserver(AgentExtension):
    """Retain immutable observations for a demo; use a bounded sink in production."""

    def __init__(self) -> None:
        self.records: list[tuple[str, MessageAppendedEvent]] = []

    async def on_event(self, context: AgentRunContext, event: ExtensionEvent) -> None:
        if isinstance(event, MessageAppendedEvent) and event.usage is not None:
            self.records.append((context.session_id, event))

MessageAppendedEvent 记录消息的时序和模型用量。订阅者不必通过观察文本在 UI 中到达来推断时序。不要把缓存读取重复计为输入 token,也不要把推理计为额外的输出 token。运行观察用量与耗时查看完整示例。

要发布自定义的内部通知,请定义一个小小的 ExtensionEvent 子类,然后调用 await context.publish(event)。每个扩展的 on_event 都会按优先级顺序收到它。做出反应时请使用不同的事件类型,不要递归地再次发布同一个事件。内部事件不会自动流式传输。

传入 target="ExtensionName"可以只把事件投递给注册名匹配的那个扩展。目标缺失或有歧义会抛出 ValueError;省略 target则保持按顺序广播的行为。

暂停工具以等待外部审批#

这种行为请继承 ExternalEventExtension。它负责锁、待处理的 Future、路由、重复拒绝、对事件循环安全的唤醒以及清理。你的子类负责工具模式、对外发出的事件,以及校验收到的应答。

class ApprovalExtension(ExternalEventExtension):
    def __init__(self) -> None:
        super().__init__(response_event_name="approval.response", correlation_field="tool_call_id")

    async def on_tool(self, context: AgentRunContext) -> None:
        @tool
        def request_approval(action: str) -> str:
            """Ask the user whether a proposed action should proceed.

            Args:
                action: Human-readable proposed action; this tool does not execute it.

            Snippet:
                request_approval(action="Export notes")

            Guidelines:
                - Ask before performing an action that requires user approval.
            """
            response = self._take_external_event(context)
            approved = response.payload.get("approved")
            if not isinstance(approved, bool):
                raise ValueError("approved must be a boolean")
            return "approved" if approved else "rejected"

        context.register_tool(request_approval)

    async def before_tool(self, context: AgentRunContext, call: ToolCall) -> None:
        if call.name != "request_approval":
            return
        async with self._wait_for_external_event(context, call.id):
            await context.emit(
                AgentEvent(
                    AgentEventType.CUSTOM,
                    session_id=context.session_id,
                    name="approval.requested",
                    payload={"tool_call_id": call.id, "action": call.arguments["action"]},
                )
            )

等待在事件发出之前就已经注册好,所以 UI 立即回复也是安全的。async-with 退出时会等待响应,它不会阻塞事件循环。只有回复被暂存之后,真正的工具才会执行并消费它。

通过 Agent 返回应答#

应用程序用配置来路由这份回复,与业务负载分开:

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)

在这个离线演示里,应答会被自动接受。在真实应用中,请先展示问题,等用户真正做出选择之后,再发出响应。工具本身不会执行所提议的操作。

接受与校验#

  • emit_external_event 的返回值是接受该事件的扩展名列表。

  • 重复的应答、会话不匹配、提供的请求 ID 不匹配、路由已过期或响应名称不对,都会被基类拒绝。

  • 接受只表示投递成功;工具仍会把 approved校验为 bool。

  • RunCancelledEvent 会取消待处理的等待;on_error 会用该错误把它们唤醒。

  • 对一个已被接受的应答调用两次,不会让工具执行两次。

运行说明和预期输出参见暂停以等待外部审批。受保护的子类辅助方法契约会列在ExternalEventExtension的 API 页面里,因为扩展作者需要它们,尽管它们的名字以下划线开头。

internal continuation 与 user steering#

当扩展需要一次有边界的后续模型步骤时,请发布 InternalMessageEvent(AgentMessage(content=...))。GoalExtension 用它来在评审之后请求更多工作。不要直接修改循环队列。紧急的 user steering 是另一条内置通道,优先级高于内部输入。这两种机制都不会中断正在接收模型响应的 HTTP 请求。