跳转至

钩子与会话命令

钩子

Hooks 是一组 HookRegistry,注册在 agent.hooks.<name> 上:

from xun import Agent, HookArgs

def log_tool_call(args: HookArgs.BeforeToolCallArgs) -> None:
    for call in args.tool_calls:
        print(call.function.name, call.function.arguments)

agent = Agent()
agent.hooks.before_tool_call.add(log_tool_call)

注册表行为:

方法 语义
add(fn) 持久回调,按注册顺序执行
add_once(fn) 下一次 invoke 之后自动移除
invoke(args) 依次调用全部回调,然后清掉一次性回调

回调返回值被丢弃,要改行为请就地修改参数对象。回调同样被 except_safe 包裹:普通异常被吞成 Result.Err(不会打断执行),只有 KeyboardInterrupt / CancelledError 会上抛。

钩子清单

钩子 参数(主要字段) 时机 / 可改动内容
run_start agent 由空闲转入运行时(嵌套作用域不重复触发)
run_end agent 真正触发过 run_start 的最外层作用域退出时,无论成功、报错还是取消
before_execution agent、schema、max_iterations、context_value、takeover_result 执行循环开始前,字段都可改;takeover_result 可整体接管循环,见下
before_execution_step agent 每轮模型调用前;自动压缩器挂在这里
after_execution_step agent 工具结果已写回会话、下一轮模型调用之前
before_tool_call agent、tool_calls(列表) 工具执行前,可增删改工具调用
after_tool_call agent、tool_results((id, Result) 列表) 本轮有工具调用时,在全部工具执行完、写回会话前;改 tool_results 只影响写进会话的内容,结果的显示事件已先行发出
model_text_delta agent、model_call_id、content 文本增量落地前,可改写 content
model_reasoning_delta 同上 推理增量落地前
before_display_info agent、message agent.info() 输出前,可改写 message
before_display_warning 同上 agent.warning() 输出前
before_display_error 同上 agent.error() 输出前
after_initialize agent initialize() 最后一步(扩展、模型探测、显示绑定都已完成)
before_finalize agent finalize() 开始处,适合释放资源
before_command agent、command、arguments(列表) 命令解析成功之后、执行之前,arguments 可就地改;未知命令不触发
after_command 同上 命令执行结束(含被处理的错误)

接管执行循环

before_execution 的参数带一个 takeover_result(默认 None)。钩子把它设成字符串,本次 execute() 即被钩子接管:

def canned(args: HookArgs.BeforeExecutionArgs) -> None:
    if cached := args.agent.state.get("cached_answer"):
        args.takeover_result = cached          # 直接作为 execute() 的结果

agent.hooks.before_execution.add(canned)

效果是跳过整个循环:不发模型调用、不再触发循环内的钩子与事件、不写入会话历史。该字符串就是 execute() 的返回值;若 execute(schema=...) 给了 schema,它仍会按 schema 校验,校验不过则整次运行失败。适合结果缓存、离线回放与测试替身。

常见用法:把工具调用镜像到日志、给输出加前缀、在 before_execution 里统一改 max_iterations 或命中缓存、在 before_finalize 里关闭外部资源(浏览器运行时就是这么清理的)。

会话命令

命令用于人机交互入口(终端 REPL、Web 界面的 / 输入框)。Agent.execute_command() 是统一入口:先 shlex.split 解析参数、触发 before_command,再执行,最后 after_command。

from xun import Agent, Command, CommandRegistry

def wordcount(agent: Agent, args: list[str]) -> None:
    """Count words in the given text."""
    agent.info(f"{len(' '.join(args).split())} words")

agent.command.register(Command("wordcount", wordcount))
agent.command.register(Command.from_function(wordcount))     # 名称取函数名
agent.execute_command("wordcount", "one two three")

注册规则:

要点 说明
处理函数入参 1 个参数 → handler(agent);2 个 → handler(agent, arguments);否则 TypeError
描述 默认取 docstring 首行;多行 docstring 会作为 -h 的长帮助
子命令组 把 CommandRegistry 作为 handler 传入即成为命令组,cmd sub -h 自动可用
帮助 参数恰好是 -h 或 --help 时显示帮助(cmd sub extra -h 会把 -h 当普通参数交给命令);help 为虚拟命令,始终可用
错误 命令内异常被捕获为 agent.error(...),KeyboardInterrupt / CancelledError 上抛
转义 输入以 / 开头即视为命令;\/ 开头表示普通消息

内置命令清单及说明见 xun · 终端会话,策略子命令组(policy ...)由 default_tools=True 时注册。

钩子不进子智能体,命令会被复制

Agent.inherit() 不继承 hooks,但会复制 command;子智能体在 initialize() 时会重放扩展,从而重新注册钩子。

会话历史的落盘

/save、/load 背后是 Store:

from xun.store import Store

store = Store()                       # 默认根目录 get_home_dir()
target = store.next_history_store()   # .../conversation/000007.conversation
latest = store.latest_history_store()
one = store.get_history_store(3)        # int 会补零:.../conversation/000003.conversation
two = store.get_history_store("000003") # 字符串按原样拼接,必须与目录名一致

/load 的编号要补零

/load 把参数当字符串处理,所以 /load 000003 能命中,/load 3 会报 History 3 not found.;拿不准编号时用 /load latest。

实际文件为 {XUN_HOME}/conversation/{NNNNNN}.conversation/conversation.json,键包括 conversation_id、time、tokens_used、messages、compacted_toolcalls、compaction。