编写你的第一个扩展#

目标:把/note extensions实现为请求级能力。模型会收到重写后的指令,调用 note 工具,并发出可供 UI 显示的进度事件。前置条件:阅读定义并注册工具和请求生命周期。无需模型 API 凭据。

什么时候该用扩展#

在需要动态工具、上下文准备、自定义输入命令、审批或持久化钩子时使用它。不要在这里放文本渲染回调,除非它必须参与生命周期决策;只是用于展示时,AgentEventDispatcher 更简单。

第 1 步:在 on_tool 中注册工具#

    async def on_tool(self, context: AgentRunContext) -> None:
        @tool
        def lookup_note(topic: str) -> str:
            """Look up a small demonstration note.

            Args:
                topic: Exact note topic to look up.

            Snippet:
                lookup_note(topic="extensions")

            Guidelines:
                - Use when a user requests a project note.
            """
            return {"extensions": "Extensions customize lifecycle hooks."}.get(topic, "No note found.")

        context.register_tool(lookup_note)

context.register_tool 会拒绝重名,并为本次请求复制可变的模式元数据。通过 context 注册可以避免把工具泄漏到其他会话。注册的处理函数在需要请求本地信息时可以闭包捕获 context;不要把该处理函数保存到全局单例上。

所有 on_tool 钩子都在任何 on_state 钩子之前结束,因此提示词扩展即使更早注册在列表中,也能看到完整的工具注册表。

第 2 步:注入指令,只转换新输入#

    async def on_state(self, context: AgentRunContext) -> None:
        context.add_message(SystemMessage(content="Use the note tool for project facts."), index=0)
    async def on_message(self, context: AgentRunContext) -> None:
        message = context.input_message
        if message is not None and message.text.startswith("/note "):
            attributes = deepcopy(message.attributes)
            attributes["original_content"] = deepcopy(message.content)
            context.input_message = UserMessage(
                content=f"Look up this note: {message.text.removeprefix('/note ')}",
                attributes=attributes,
            )
        # The Agent appends input after all on_message hooks. Do not append here.
        self.active[context] = 0

on_state 是扩展恢复历史记录或添加系统指令的地方。on_message 在恢复之后、追加之前看到 context.input_message。示例把原始输入保存在属性中,用模型能理解的指令替换命令,并创建一个请求本地计数器。

不要在这里追加用户消息。运行时会等所有 on_message 钩子结束后,把最终转换过的输入恰好追加一次。这一点对内存中的历史记录和只追加的持久化都很重要。

第 3 步:发出 UI 进度事件#

    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]},
            )
        )

该钩子是一个普通的异步函数。它在每次模型调用之前运行,因此一次工具往返会产生两个进度事件。请使用带命名空间的自定义名称,例如 note.model_step,以及适合你的 UI 传输方式的少量载荷。await context.emit(...) 会保持钩子顺序;CUSTOM 不会改变 Agent 阶段。

第 4 步:清理每一条终止路径#

    async def on_success(self, context: AgentRunContext, result: AssistantMessage) -> None:
        self.active.pop(context, None)

下面完整的类还额外处理 on_error 和 RunCancelledEvent。只在 on_success 中清理,会在用户停止生成时泄漏状态。context 就是请求的身份标识,因此两个会话可以安全地共存于同一个字典中。关于归属和幂等清理规则,请阅读状态、并发与清理。

第 5 步:与历史记录和提示词 guidelines 组合#

async def main() -> None:
    extension = NoteExtension()
    client = await create_agent(
        NoteModel(),
        extensions=[InMemoryMessageAccumulator(), extension, ToolGuidelinesExtension()],
        event_dispatcher=NoteEvents(),
    )
    reply = await client.run("/note extensions")
    assert not extension.active
    print(reply.content)
    print("Request state cleared")

NoteExtension 的优先级是 50,排在默认优先级 100 的扩展之前。钩子仍然按阶段执行,而不是按扩展逐个执行。显式给出的扩展列表会替换默认值,因此这个列表里出现了记忆和工具 guidelines。

运行并检查结果#

uv run python docs/_examples/custom_extension.py

预期输出:

Model step: 1
Model step: 2
Extensions customize lifecycle hooks.
Request state cleared

预设脚本的 NoteModel 会先检查转换后的输入、保留下来的属性和注入的指令,然后再请求真正注册的工具。最后的断言验证请求本地状态已被清除。要使用 LLM,只需把 NoteModel 换成模型适配器;扩展和 UI 回调保持不变。

完整实现#

下载 custom_extension.py。

  1"""An extension with tools, input rewriting, UI events, and request cleanup."""
  2
  3import asyncio
  4from collections.abc import AsyncIterator
  5from copy import deepcopy
  6
  7from zett_agent.agent import AgentRunContext
  8from zett_agent.client import create_agent
  9from zett_agent.dispatcher import AgentEventDispatcher
 10from zett_agent.events import (
 11    AgentEvent,
 12    AgentEventType,
 13)
 14from zett_agent.extensions.base import AgentExtension
 15from zett_agent.extensions.events import (
 16    ExtensionEvent,
 17    RunCancelledEvent,
 18)
 19from zett_agent.extensions.memory import InMemoryMessageAccumulator
 20from zett_agent.extensions.tool_guidelines import ToolGuidelinesExtension
 21from zett_agent.messages import (
 22    AssistantMessage,
 23    SystemMessage,
 24    ToolCall,
 25    ToolMessage,
 26    UserMessage,
 27)
 28from zett_agent.model import (
 29    ModelEvent,
 30    ModelRequest,
 31    ModelResponse,
 32    RetryOptions,
 33)
 34from zett_agent.tools.base import tool
 35
 36
 37class NoteExtension(AgentExtension):
 38    """Expose a request-bound note tool and translate an explicit /note command."""
 39
 40    priority = 50
 41
 42    def __init__(self) -> None:
 43        self.active: dict[AgentRunContext, int] = {}
 44
 45    async def on_tool(self, context: AgentRunContext) -> None:
 46        @tool
 47        def lookup_note(topic: str) -> str:
 48            """Look up a small demonstration note.
 49
 50            Args:
 51                topic: Exact note topic to look up.
 52
 53            Snippet:
 54                lookup_note(topic="extensions")
 55
 56            Guidelines:
 57                - Use when a user requests a project note.
 58            """
 59            return {"extensions": "Extensions customize lifecycle hooks."}.get(topic, "No note found.")
 60
 61        context.register_tool(lookup_note)
 62
 63    async def on_state(self, context: AgentRunContext) -> None:
 64        context.add_message(SystemMessage(content="Use the note tool for project facts."), index=0)
 65
 66    async def on_message(self, context: AgentRunContext) -> None:
 67        message = context.input_message
 68        if message is not None and message.text.startswith("/note "):
 69            attributes = deepcopy(message.attributes)
 70            attributes["original_content"] = deepcopy(message.content)
 71            context.input_message = UserMessage(
 72                content=f"Look up this note: {message.text.removeprefix('/note ')}",
 73                attributes=attributes,
 74            )
 75        # The Agent appends input after all on_message hooks. Do not append here.
 76        self.active[context] = 0
 77
 78    async def before_model(self, context: AgentRunContext, request: ModelRequest) -> None:
 79        self.active[context] += 1
 80        await context.emit(
 81            AgentEvent(
 82                AgentEventType.CUSTOM,
 83                session_id=context.session_id,
 84                name="note.model_step",
 85                payload={"step": self.active[context]},
 86            )
 87        )
 88
 89    async def on_success(self, context: AgentRunContext, result: AssistantMessage) -> None:
 90        self.active.pop(context, None)
 91
 92    async def on_error(self, context: AgentRunContext, error: Exception) -> None:
 93        self.active.pop(context, None)
 94
 95    async def on_event(self, context: AgentRunContext, event: ExtensionEvent) -> None:
 96        if isinstance(event, RunCancelledEvent):
 97            self.active.pop(context, None)
 98
 99
100class NoteModel:
101    retry = RetryOptions(max_retries=0)
102
103    async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
104        last = request.messages[-1]
105        if isinstance(last, ToolMessage):
106            message = AssistantMessage(content="Extensions customize lifecycle hooks.")
107        else:
108            assert isinstance(last, UserMessage)
109            assert last.text == "Look up this note: extensions"
110            assert last.attributes["original_content"] == "/note extensions"
111            assert any(isinstance(item, SystemMessage) and "note tool" in item.content for item in request.messages)
112            message = AssistantMessage(tool_calls=(ToolCall("note-1", "lookup_note", {"topic": "extensions"}),))
113        yield ModelEvent.completed(ModelResponse(message))
114
115
116class NoteEvents(AgentEventDispatcher):
117    async def on_custom_event(self, event: AgentEvent) -> None:
118        if event.name == "note.model_step" and event.payload is not None:
119            print(f"Model step: {event.payload['step']}")
120
121
122async def main() -> None:
123    extension = NoteExtension()
124    client = await create_agent(
125        NoteModel(),
126        extensions=[InMemoryMessageAccumulator(), extension, ToolGuidelinesExtension()],
127        event_dispatcher=NoteEvents(),
128    )
129    reply = await client.run("/note extensions")
130    assert not extension.active
131    print(reply.content)
132    print("Request state cleared")
133
134
135if __name__ == "__main__":
136    asyncio.run(main())

下一步:生命周期钩子参考、发出事件并接收用户输入、状态、并发与清理。

相关 API:AgentExtension、AgentRunContext、register_tool()、register_server_tool()和AgentEvent。