Skip to content

4.2 AgentEvent 事件清单 ​

本节你会学到

  • 什么时候用 AgentEvent(UI / 调试 / 回放)vs HostEvent(稳定的宿主合约)
  • 全部事件类型的清单:触发条件、data 负载、典型用途
  • 怎样把事件安全地序列化到 SSE / WebSocket

Agent 在运行过程中通过 transport.emit(event) 推送结构化事件。本节是全量事件参考——每个事件的触发时机、data 负载、典型用法。

在做生产审计流水线?请用 HostEvent,不是本页的 AgentEvent

本页的事件是内部 transport 事件 —— 驱动 CLI、replay、调试工具,字段和枚举值会随版本演进。它们适合做需要细节的流式 UI(THINKING 气泡、in-flight 工具视图)。只要 assistant 文本时,agent.astream() 是稳定路径(4.7)。

如果你在做生产审计 / 可观测 / SIEM 流水线,请用 4.7 嵌入式 Harness 合约 的稳定宿主表面。快速对照:

面在哪里稳定性何时使用
agentao.transport.AgentEvent(本页)Transport.emit() 推送回调内部 —— 随版本可能变CLI / 流式 UI 需要细节
agentao.host.HostEvent(4.7)agent.events() 异步 pull 迭代器稳定,schema 快照、CI 强制生产审计、计费、多租户合规
agentao.host.TextDelta / TurnOutcome(4.7)agent.astream() 异步生成器稳定(不是 HostEvent 成员)把 assistant 文本流式推给 UI

两个面互补,不是二选一 —— 大多数生产部署两者都用:Transport 给 UI,events() 给审计。它们零代码路径共享。

AgentEvent 数据结构 ​

python
@dataclass
class AgentEvent:
    type: EventType              # 枚举值
    data: Dict[str, Any] = ...   # 必须是 JSON 可序列化的

JSON 可序列化 约束意味着所有 data 字段都能直接转发到 SSE / WebSocket / JSON-RPC,无需额外处理。

事件分组 ​

TURN_BEGIN -> (用户消息到达——turn 开始;携带 user 文本)
└── TURN_START -> (LLM 调用开始;重置流式 UI)
    ├── LLM_CALL_STARTED        (调用 provider 前的元数据)
    ├── LLM_RETRY *             (可选:一次失败的调用即将重试)
    ├── THINKING *              (可选:推理分片,随流到达)
    ├── LLM_TEXT *              (用户可见的流式 chunk)
    ├── LLM_CALL_DELTA          (本次调用新增的 messages)
    ├── LLM_CALL_COMPLETED      (usage + finish reason)
    ├── TOOL_START              (工具开始)
    │   ├── TOOL_CONFIRMATION   (可选,确认弹窗镜像事件)
    │   ├── TOOL_OUTPUT *       (流式 chunk)
    │   ├── TOOL_COMPLETE       (状态 + 耗时)
    │   └── TOOL_RESULT         (最终内容 / hash / 落盘元数据)
    ├── AGENT_START / AGENT_END (sub-agent 生命周期)
    ├── ERROR                   (可选,出错时)
    └── replay-only observability events
TURN_END   -> (turn 结束;携带最终 assistant 文本 + status/error)

TURN_BEGIN / TURN_END 每个用户驱动的 turn 各发一次;TURN_START 是 turn 内每次 LLM 迭代都发(一个 turn 内可能很多次)。Replay 录制器是通过把一个 ReplayAdapter 拼接到 agent.transport 前面来接线的——由它把外层这一对翻译成录制器的 turn 写入(ReplayAdapter._mirror),并不是靠订阅。Transport.subscribe()(见 4.1)是留给其他旁路观察者的路径,且该 adapter 会把 subscribe 转发给内层 transport,所以 replay 开启时订阅照样有效。

大多数 UI 只需要处理 LLM_TEXT、THINKING、TOOL_START、TOOL_OUTPUT、TOOL_COMPLETE、TOOL_CONFIRMATION、AGENT_START、AGENT_END 和 ERROR。 其他事件主要服务于 session replay、审计、指标和调试。

单事件详解 ​

TURN_BEGIN ​

字段说明
触发时机每个用户驱动 turn 开始时一次,在任何 LLM 迭代之前
data{"user_message": "..."}
典型用法通过 Transport.subscribe() 在审计 / 观察者流中开一个新的 turn 帧。(replay 录制器是另行接线的——走 ReplayAdapter 拼接,不是这个订阅。)

与 TURN_START(每个 LLM 迭代各发一次)语义不同。TURN_BEGIN 携带用户输入,并与 TURN_END 1:1 配对。

TURN_END ​

字段说明
触发时机每个用户驱动 turn 结束时一次,在最终 assistant 回复之后(或出错 / 被取消时)
data{"final_text": "...", "status": "ok"|"error"|"cancelled", "error": None, "tool_count": 3, "incomplete_reason": None, "finish_reason_missing": False}
典型用法关闭 turn 帧;刷出 per-turn 指标 —— tool_count 是这个 turn 内所有迭代里 LLM 发起的工具调用总数,宿主无需重放每个 TOOL_START 就能掂量这一 turn 的规模

incomplete_reason 在普通 turn 上为 None。当 turn 结束但没有拿到完整的、模型撰写的答案时它会被置位,并说明原因:

取值含义
no_output模型什么都没输出。
reasoning_only只有推理内容,没有答案正文。
length_truncated输出在 token 上限处被截断——要么是工具调用的参数被截断(harness 随即中止该 turn),要么是最终答案在半句处被切断。
doom_loop模型重复同一个调用,直至检测器 halt 了该 turn。
llm_errorLLM 调用本身失败(provider 5xx / 限流 / 认证)且重试后仍失败;final_text 里是 harness 的 [LLM API error: …] 提示,不是模型答案。

这是一套单一封闭的词表:每一条 turn 结束路径都恰好提交其中一个取值,或对真实答案提交 None——不存在未被分类的结束。前两者表示 turn 正常结束但没有答案;中间两者表示 harness 停掉了一个不收敛的 turn;最后一个表示 provider 调用根本没产出一个 turn。(max_iterations 是第六种"结束但无完整答案"的方式,但它是刻意独立的一根轴——一个带自己退出码 4 和 on_max_iterations 交互的粘性 transport 标志,不是这里的取值。)

优先用它来判断,而不要去字符串匹配 final_text —— harness 会在那里替换占位符或固定话术(包括现已被分类为 llm_error 的 [LLM API error: …] 提示),而 Stop hook 还可能装饰它,所以文本并不是可靠信号。被取消的 turn 不会通过 incomplete_reason 上报(它携带 status: "cancelled")。agentao run 会把非 None 的值映射为非零退出码;其他宿主可以自行选择重试、追问或忽略。

finish_reason_missing 是另一根独立的轴,不属于上面那套词表。当这个 turn 里至少有一次 LLM 调用结束时 provider 从未说明生成为何停止(网关在自己的上游超时后关掉 SSE body,或者部分兼容的服务端根本不发这个字段),它为 True——也就是说"答案完整"这句话是 agentao 的 "stop" 兜底说的,不是 provider 说的。它不影响 incomplete_reason、is_answer,也不影响 agentao run 的退出码:不发这个字段的服务端是每次都不发,以它为门会让针对这些服务端的每个 turn 都失败。想要严格语义的宿主自己检查这个键。被取消的 turn 上会被抑制(取消本身已经解释了缺失),出错的 turn 上照常上报。

Replay 录制器靠它和 TURN_BEGIN 配对来界定一个 turn。它替代了运行时直接调用 replay adapter 的旧路径。

TURN_START ​

字段说明
触发时机turn 内每次 LLM 迭代前(同一个 turn 可能发很多次);用户拒绝确认后也会发一次,用来重置显示
data{} 空对象;拒绝后的那次重置是 {"display_reset": true},它不开始新的 LLM 迭代,不要把它算作一轮
典型用法重置 UI 显示、切换 spinner 到 "Thinking…"
python
if event.type == EventType.TURN_START:
    ui.spinner.text = "Thinking..."
    ui.reset_streaming_buffer()

THINKING ​

字段说明
触发时机LLM 返回 reasoning/thought 内容时(如 o1、Claude thinking),工具调用轮和最终回答都会触发。推理是流式的:每个事件是一个已清洗的分片,按提供方发送的顺序到达,所以推理会先于它后面的文本。(宿主自带的 llm_client= 若从不调用 on_reasoning_chunk,整段推理会在调用结束后发一次,也就在正文之后。)把分片依次追加即可;在一次成功的尝试内,它们拼起来等于历史里保存的推理(唯一的罕见例外:交错思考时,被文本块隔开的旗帜 emoji 在分片里会丢掉 tag 字符)。分片发出后不撤回:重试后(LLM_RETRY,UI 可以把它当作重置信号;推理已经显示时,参数修正和非流式回退也会发一个,delay_s 为 0),下一次尝试的推理接在失败尝试的推理之后。正文从不作为 THINKING 发出:工具调用轮的正文只以 LLM_TEXT 发出
data{"text": "Let me think..."}
典型用法渲染到折叠的 "思考过程" 面板
python
if event.type == EventType.THINKING:
    ui.thinking_panel.append(event.data["text"])

LLM_TEXT ​

字段说明
触发时机LLM 流式返回正文 chunk
data{"chunk": "Sure, I can help"}
典型用法逐 chunk 追加到用户可见的回复区域
python
if event.type == EventType.LLM_TEXT:
    ui.response_area.append(event.data["chunk"])

⚠️ chunk 可能是几个字母、半个词,也可能是一整段——只保证顺序,不保证切分粒度。

要通过稳定合约拿同样的文本,用 agent.astream() 产出的 TextDelta(4.7)。

TOOL_START ​

字段说明
触发时机准备执行一个工具
data{"tool": "run_shell_command", "args": {...}, "call_id": "uuid"}
典型用法在 UI 插入 "Running tool X..." 卡片;记录 call_id 做关联

call_id 是这次调用的唯一键,后续 TOOL_OUTPUT、TOOL_COMPLETE 和 TOOL_RESULT 会带同一个 id,方便在 UI 里把流式输出挂到正确的卡片上。

TOOL_CONFIRMATION ​

字段说明
触发时机即将调用 confirm_tool() 问用户前
data{"tool": "run_shell_command", "args": {...}, "call_id": "uuid"}——call_id 与该调用的 TOOL_START / TOOL_COMPLETE 相同
典型用法可选的 "镜像" 事件——给纯只读的观察者一次看到"要弹确认框了"的机会

通常你不需要处理这个事件——真正的确认逻辑走 confirm_tool() 方法调用。TOOL_CONFIRMATION 更多用于审计/日志"事件流完整性",以及关联:ACP transport 据它先打开该调用的 tool_call,让权限请求指向 client 已经见过的调用。

TOOL_OUTPUT ​

字段说明
触发时机工具执行过程中产生流式输出
data{"tool": "...", "chunk": "...", "call_id": "uuid"}
典型用法把 chunk 追加到对应工具卡片

典型流式工具:run_shell_command(命令 stdout/stderr 边产生边 emit)、长耗时 web_fetch、自定义的"分页抓取"工具。

TOOL_COMPLETE ​

字段说明
触发时机工具执行结束(成功/失败/取消)。工具运行中被 Ctrl+C 打断的调用也会收到一个:它会先以 cancelled 结束,中断再传到这一轮
data{"tool": "...", "call_id": "uuid", "status": "ok"|"error"|"cancelled", "duration_ms": 123, "error": None}
典型用法关闭工具卡片 spinner,根据 status 变色;记录执行时长
python
if event.type == EventType.TOOL_COMPLETE:
    d = event.data
    ui.close_tool_card(d["call_id"],
                       status=d["status"],
                       duration=d["duration_ms"])

TOOL_RESULT ​

字段说明
触发时机工具最终结果可用后
data{"tool": "...", "call_id": "uuid", "content": "...", "content_hash": "sha256:...", "original_chars": 123, "saved_to_disk": false, "disk_path": null, "status": "ok"|"error"|"cancelled", "duration_ms": 123, "error": None}
典型用法不依赖流式 chunk,持久化或检查工具最终输出

普通 UI spinner 优先看 TOOL_COMPLETE。TOOL_RESULT 更适合 replay、审计、结果 hash 和大输出场景。

LLM_CALL_STARTED / LLM_CALL_COMPLETED ​

字段说明
触发时机每次 provider 调用前后
data调用前元数据;调用后的 usage / finish 元数据。LLM_CALL_COMPLETED 携带 duration_ms、model_latency_ms(duration_ms 的稳定别名,命名更贴合意图)、first_token_ms(首 token 时延,毫秒;当本次调用没有流式文本——例如纯工具调用响应、或首个 delta 之前就失败——为 null)、first_reasoning_ms(到第一段流式推理的同类等待时间;本次调用没有流式推理时为 null,包括推理只在调用结束后整段交出的宿主 llm_client=。在非流式路径上(Gemini 绕行、不支持流式的端点),整段推理在调用结束时才交给宿主,所以它接近 duration_ms。它和 first_token_ms 一样从调用开始计时,重试时不重置,所以流在推理中途断开后,它记的是失败那次尝试的第一段。推理模型先输出推理,所以在这类模型上 first_token_ms 是等回答的时间,first_reasoning_ms 才是等第一段输出的时间)、prompt_tokens、completion_tokens、finish_reason,错误路径上还有 status / error_class / error_message / streamed(streamed 表示是否已有回答正文发给宿主;即使为 false,推理分片也可能已经流出)。自 0.5.1 起还有 cache_read_tokens / cache_creation_tokens:prompt_tokens 之中 provider 按缓存价格计费的那部分(Anthropic 线路取 cache_read_input_tokens / cache_creation_input_tokens;Chat Completions 取 prompt_tokens_details.cached_tokens / .cache_write_tokens,openai-responses 取 input_tokens_details 下的同名两项 —— 写入计数只在 SDK 给出时才有,即 openai 3.x 起)。自 0.5.15 起还有 reasoning_tokens:completion_tokens 之中用于思考的部分(Chat Completions 取 completion_tokens_details.reasoning_tokens,openai-responses 取 output_tokens_details.reasoning_tokens,anthropic-messages 上恒为 0,因为它的 output_tokens 已含思考)。响应不带 usage 时为 None,provider 没给出时为 0
典型用法指标、成本统计、调试模型行为 —— first_token_ms(推理模型上用 first_reasoning_ms)与 model_latency_ms 把排队/TTFT 与总生成时间区分开

LLM_RETRY ​

字段说明
触发时机provider 调用以可重试错误失败、客户端即将等待后再试 —— 在退避等待之前发出。只在尚未显示正文时发生:已经流出文本的调用不会重试,所以它不会与 LLM_TEXT 交错。推理可能已经流出;自 #548 起,推理已显示后,参数修正或非流式回退重新发送请求时也会发一个,delay_s 为 0,reason 为 parameter_repair / streaming_unsupported
data{"retry": 1, "max_retries": 4, "delay_s": 1.62, "reason": "status=503"} —— retry 从 1 起算(首次请求不算重试);reason 是 status=<code> 或异常类名(RemoteProtocolError、ReadTimeout、StreamEndedEarlyError 等),从不含 provider 的报错原文
典型用法告诉用户屏幕为什么停住了 —— CLI 打印 ⟳ Reconnecting… 1/4。自 0.5.4 起

重试按次数限制(共 5 次尝试,即 4 次重试),不按墙钟预算。没有 Retry-After 时,等待从 7.5 秒起翻倍——7.5、15、30、60 秒,各自再加至多 30% 的抖动,但都不超过 60 秒;Retry-After 同样按 60 秒封顶。重试范围:408/409/425/429/5xx/529(表示额度耗尽的 429 除外)、响应到达前的连接或超时失败,以及自 0.5.4 起的读取响应体期间连接断开,和 anthropic-messages / openai-responses 上在终止事件之前就结束的流。

LLM_CALL_DELTA ​

字段说明
触发时机一次 LLM 调用向历史新增 messages 后
data相比上次调用新增的 messages
典型用法用较紧凑的方式做 session replay

LLM_CALL_IO ​

字段说明
触发时机仅 deep capture 开启时
data该次 LLM 调用的完整 prompt / tool payload
典型用法离线调试;按敏感内容处理

ERROR ​

字段说明
触发时机运行时捕获到异常(LLM 报错、网络错误、MCP 断开…)
data{"message": "...", "detail": "..."}
典型用法弹 toast、写日志;不必停止会话——Agent 会自行决定是否继续
python
if event.type == EventType.ERROR:
    logger.error(event.data["message"], extra=event.data)
    ui.toast(event.data["message"])

AGENT_START ​

字段说明
触发时机Agent 启动一个 sub-agent(如 codebase-investigator、Explore 等)
data{"agent": "codebase-investigator", "task": "...", "max_turns": 15}
典型用法在 UI 里开一个"子任务"折叠面板

AGENT_END ​

字段说明
触发时机sub-agent 完成
data{"agent": "...", "state": "completed"|"...", "turns": 3, "tool_calls": 5, "tokens": 1200, "duration_ms": 8000, "error": None}。未知的计数不发送:在运行前就抛出异常的 sub-agent 不带 turns / tool_calls / tokens / duration_ms,请用 .get() 读取
典型用法折叠子任务面板、显示汇总 (3 轮/5 次工具/8s)

Replay 可观测性事件 ​

这些事件主要用于 session replay 和运行审计。大多数交互式 UI 可以忽略。

事件典型 payload / 用途
ASK_USER_REQUESTED / ASK_USER_ANSWERED记录 ask_user() 的问题和回答
BACKGROUND_NOTIFICATION_INJECTED后台通知被注入当前 turn
CONTEXT_COMPRESSED发生上下文压缩
SESSION_SUMMARY_WRITTEN会话摘要已写入
SKILL_ACTIVATED / SKILL_DEACTIVATEDSkill 生命周期
MEMORY_WRITE / MEMORY_DELETE / MEMORY_CLEAREDMemory 变更
MODEL_CHANGED运行时模型切换
PERMISSION_MODE_CHANGED / READONLY_MODE_CHANGED运行时安全模式变化。前者带 previous / current(模式字符串)和 cause——"cli"、"cli-allow-all"、"cli-plan-implement"、"cli-resume"、"acp"、"run" 或 "host",点名是哪条入口切的——且只在预设真的变了时才发。后者的 previous / current 是布尔:它报的是 ToolRunner.readonly_mode 这一个开关(只读有两个),不是生效姿态。两者都由 agent.set_permission_mode(...) 发出;裸调 permission_engine.set_mode(...) 两个都不发;Agentao(permission_mode=...) / build_from_environment(permission_mode=...) 设定的起始模式也两个都不发——起始状态不是一次切换。READONLY_MODE_CHANGED 也会单独由直接调用的 ToolRunner.set_readonly_mode(...) 触发——子代理就是这样继承这个开关的。
PLUGIN_HOOK_FIRED插件 hook 已运行。data["hook_name"] 取值为 UserPromptSubmit / PreToolUse / PostToolUse* / Stop / PreCompact / SessionStart 之一。其中两个看着别扭,但都是有意的:PostToolUse* 是两个事件共用的一个名字,因为一批调用里会同时出现 PostToolUse 和 PostToolUseFailure 的结果,提示跨整批聚合,按单名分支永远匹配不上;而 SessionStart 只在 full 压缩之后出现(source="compact")—— 真正会话边界上的 SessionStart / SessionEnd 在本事件上什么都不发,而是把结果返回给派发它的界面。各 hook_name 携带不同的字段——例如 Stop 带 turn_end_reason ∈ {"final_response", "max_iterations", "doom_loop", "hook_stop"}、at_max_iter、added_context_count 与 suppress_output;PreCompact 带 compaction_type ∈ {"microcompact", "full", "minimal_history"} 与 trigger="auto"。user_notices 是宿主要渲染的那个字段:一串 hook 讲给用户而非模型听的字符串 —— systemMessage、exit 2 的 stderr、一次性的 profile 字段诊断 —— 对这里列出的每一个事件来说,它都是唯一去路。所有 emit 都带 outcome 和一个规则数,但规则数是两个不同的字段:matched_rule_count(PreToolUse / Stop / PreCompact —— 被选派的规则数,这三个在它为 0 时完全不发出事件)或 rule_count(UserPromptSubmit / PostToolUse* / 压缩后的 SessionStart —— 已配置的规则数,因为这三个在发出的位置上手里并没有匹配数)。Stop 的 outcome ∈ {"allow", "block", "continue", "continue_at_max_iter", "reentry_capped"},反映该退出点的 chat-loop 判决(continue 与 continue_at_max_iter 用来区分是哪一个退出点接受了 force_continue;reentry_capped 表示循环拒绝再次重入)。PreCompact 的 outcome 仍恒为 "allow"(仅观察)。规则作者视角的完整指南见 §5.7 插件 Hooks。

枚举值的字符串形态 ​

EventType 是 str 的子类,所以你可以:

python
>>> EventType.LLM_TEXT
<EventType.LLM_TEXT: 'llm_text'>
>>> str(EventType.LLM_TEXT)
'llm_text'
>>> EventType.LLM_TEXT == "llm_text"
True

方便直接把 event.type 序列化到 SSE/WebSocket 字段:

python
json.dumps({"type": event.type, "data": event.data})
# → {"type": "llm_text", "data": {"chunk": "..."}}

事件过滤器样板 ​

嵌入场景里你往往只关心几个关键事件:

python
TEXT_EVENTS = {EventType.LLM_TEXT, EventType.TOOL_OUTPUT}
CONTROL_EVENTS = {EventType.TOOL_START, EventType.TOOL_COMPLETE,
                  EventType.AGENT_START, EventType.AGENT_END}

def on_event(event):
    if event.type in TEXT_EVENTS:
        stream_to_ui(event.data.get("chunk", ""))
    elif event.type in CONTROL_EVENTS:
        update_structural_ui(event)
    elif event.type == EventType.ERROR:
        log_and_toast(event)
    # 其他事件忽略

事件到 JSON 的完整转换 ​

用于 SSE / WebSocket / 消息队列:

python
def event_to_json(event: AgentEvent) -> str:
    return json.dumps({
        "type": event.type.value,   # "llm_text" / "tool_start" / ...
        "data": event.data,
        "ts": time.time(),
    })

反向(从 JSON 重建 AgentEvent,多用于测试 replay):

python
from agentao.transport import AgentEvent, EventType

def event_from_json(j: str) -> AgentEvent:
    obj = json.loads(j)
    return AgentEvent(type=EventType(obj["type"]), data=obj["data"])

TL;DR ​

  • AgentEvent 是内部接口——字段和 EventType 取值在版本间可能变。需要稳定宿主面(审计 / 可观测)的请用 HostEvent,见 4.7 嵌入式 Harness 合约。
  • 最常处理的几种类型:LLM_TEXT(流式文本)、TOOL_START / TOOL_COMPLETE、THINKING、ERROR。
  • 对未知类型保持防御——版本演进会新增。永远要有 default 分支。
  • 序列化用 event.type.value + event.data(已经 JSON 安全)——不要 pickle。

→ 下一节:4.3 SdkTransport 快速桥接