Current section
Files
Jump to
Current section
Files
lib/cmdc/event.ex
defmodule CMDC.Event do
@moduledoc """
Agent 运行时结构化事件。
`CMDC.EventBus` 广播的事件格式为 `{:cmdc_event, session_id, CMDC.Event.t()}`。
外部消费者(TUI / Phoenix / Bot)通过订阅 Registry 接收这些事件。
## 完整事件类型
### 会话生命周期
- `:agent_start` — Agent 开始处理 prompt
- `{:agent_end, messages, token_usage}` — 本轮全部完成,`token_usage` 为 map
- `{:agent_abort, reason}` / `:agent_abort` — Agent 被中止
- `{:prompt_received, text}` — 收到用户 prompt
### 流式响应
- `:message_start` — LLM 开始生成文本(首个 content chunk 前)
- `{:message_delta, %{delta: text}}` — LLM 流式 token 片段
- `{:response_complete, %CMDC.Message{}}` — 本轮 LLM 响应完整结束(含工具调用解析)
- `:thinking_start` — LLM 开始思考链(部分模型)
- `{:thinking_delta, %{delta: text}}` — 思考链 token 片段
- `{:status_update, text}` — LLM 响应中的 `<status>` XML 标签内容
- `{:title_generated, title}` — LLM 响应中的 `<title>` XML 标签内容(限 60 字符)
### Provider / 请求
- `{:request_start, %{model: model, messages: count}}` — LLM 请求即将发起
- `{:stream_error, reason}` — 流式响应出错
- `{:stream_stalled, elapsed_s}` — 流式响应疑似卡住(超过 stall 阈值未收到 chunk)
- `{:retry, attempt, delay_ms, reason}` — 即将重试
- `{:context_overflow, reason}` — 上下文长度超限
### 工具执行
- `{:tool_calls, count}` — 本轮 LLM 请求了 `count` 个工具调用
- `{:tool_execution_start, name, call_id, args}` — 工具开始执行
- `{:tool_execution_end, name, call_id, result}` — 工具执行完成,result 为 `{:ok, text}` | `{:error, text}`
- `{:tool_blocked, name, call_id, reason}` — 工具被 Plugin 拦截(block_tool)
- `{:loop_detected, %{type: type, ...}}` — 内建循环检测触发,type 为 `:repeat_pattern` | `:file_loop_warn` | `:file_loop_abort`
### 人机交互(HITL)
- `{:approval_required, approval_map}` — 等待人类审批,approval_map 含 `id/tool/args/session_id/hint/requested_at`
- `{:approval_resolved, approval_map}` — 审批已决定,approval_map 含 `status: :approved | :rejected | :timeout`
### Agent 提问
> 注:AskUser 工具通过 EventBus 广播以下事件。
- `{:ask_user, session_id, question, options, ref}` — Agent 主动向用户提问
- `{:user_responded, session_id, ref, response}` — 用户已回答
### 规划
- `{:todo_change, session_id, todos}` — Todo 列表变更,todos 为 `[map()]`
### 上下文压缩
- `{:compact_start, session_id}` — 上下文压缩开始
- `{:compact_end, session_id, removed_count}` — 上下文压缩完成
### 子代理
- `{:subagent_start, session_id, child_session_id, description}` — 子代理启动
- `{:subagent_end, session_id, child_session_id, result}` — 子代理结束,result 为 `{:ok, text}` | `{:error, reason}`
- `{:sub_agent_event, call_id, child_session_id, event}` — 子代理内部事件透传
### Plugin 自定义事件
- `{:plugin_event, name, payload}` — Plugin 通过 `emit` action 发出的自定义事件
### Agent 内部干预
- `{:intervention, prompt}` — Agent 检测到循环/异常,注入干预 prompt
- `{:stop_blocked, prompt}` — abort 被 Plugin 拦截,注入继续 prompt
- `{:prompt_rejected, reason}` — prompt 被 Plugin 拒绝
- `{:prompt_queued, text}` — Agent 忙碌,prompt 已入队
### 错误
- `{:error, session_id, reason}` — 运行时错误
## 注意
- 实际广播格式为 `{:cmdc_event, session_id, event}`,消费者需匹配外层元组
- `agent_end` / `agent_abort` 中不含 session_id(session_id 在外层元组)
- `approval_required` / `approval_resolved` 的 payload 是 approval map,不是位置参数
- `tool_execution_start/end` 含 `call_id`(区别于旧文档中的 `tool_start/end`)
"""
@type session_id :: String.t()
@type ref :: String.t()
@type approval_map :: %{
id: String.t(),
tool: String.t(),
args: map(),
session_id: String.t(),
hint: String.t(),
requested_at: integer()
}
@type token_usage :: %{
prompt_tokens: non_neg_integer(),
completion_tokens: non_neg_integer(),
total_tokens: non_neg_integer()
}
@type t ::
:agent_start
| {:agent_end, messages :: [CMDC.Message.t()], token_usage()}
| {:agent_abort, reason :: term()}
| :agent_abort
| {:prompt_received, text :: String.t()}
| {:prompt_rejected, reason :: term()}
| {:prompt_queued, text :: String.t()}
| :message_start
| {:message_delta, %{delta: String.t()}}
| {:response_complete, CMDC.Message.t()}
| :thinking_start
| {:thinking_delta, %{delta: String.t()}}
| {:status_update, text :: String.t()}
| {:title_generated, title :: String.t()}
| {:request_start, %{model: term(), messages: non_neg_integer()}}
| {:stream_error, reason :: term()}
| {:stream_stalled, elapsed_s :: non_neg_integer()}
| {:retry, attempt :: pos_integer(), delay_ms :: non_neg_integer(), reason :: term()}
| {:context_overflow, reason :: term()}
| {:tool_calls, count :: non_neg_integer()}
| {:tool_execution_start, name :: String.t(), call_id :: String.t(), args :: map()}
| {:tool_execution_end, name :: String.t(), call_id :: String.t(),
result :: {:ok, String.t()} | {:error, String.t()}}
| {:tool_blocked, name :: String.t(), call_id :: String.t(), reason :: String.t()}
| {:loop_detected, %{type: atom()}}
| {:approval_required, approval_map()}
| {:approval_resolved, approval_map()}
| {:ask_user, session_id(), question :: String.t(), options :: [map()], ref()}
| {:user_responded, session_id(), ref(), response :: term()}
| {:todo_change, session_id(), todos :: [map()]}
| {:compact_start, session_id()}
| {:compact_end, session_id(), removed_count :: non_neg_integer()}
| {:subagent_start, session_id(), child_session_id :: String.t(),
description :: String.t()}
| {:subagent_end, session_id(), child_session_id :: String.t(),
result :: {:ok, String.t()} | {:error, term()}}
| {:sub_agent_event, call_id :: String.t(), child_session_id :: String.t(), event :: t()}
| {:plugin_event, name :: atom(), payload :: term()}
| {:intervention, prompt :: String.t()}
| {:stop_blocked, prompt :: String.t()}
| {:error, session_id(), reason :: term()}
@all_types [
:agent_start,
:agent_end,
:agent_abort,
:prompt_received,
:prompt_rejected,
:prompt_queued,
:message_start,
:message_delta,
:response_complete,
:thinking_start,
:thinking_delta,
:status_update,
:title_generated,
:request_start,
:stream_error,
:stream_stalled,
:retry,
:context_overflow,
:tool_calls,
:tool_execution_start,
:tool_execution_end,
:tool_blocked,
:loop_detected,
:approval_required,
:approval_resolved,
:ask_user,
:user_responded,
:todo_change,
:compact_start,
:compact_end,
:subagent_start,
:subagent_end,
:sub_agent_event,
:plugin_event,
:intervention,
:stop_blocked,
:error
]
# ==========================================================================
# Public API
# ==========================================================================
@doc "返回所有已知事件类型原子标识列表。"
@spec all_types() :: [atom()]
def all_types, do: @all_types
@doc """
判断给定元组或原子是否为合法的 CMDC 事件。
iex> CMDC.Event.valid?({:message_delta, %{delta: "hello"}})
true
iex> CMDC.Event.valid?(:agent_start)
true
iex> CMDC.Event.valid?({:unknown, "sid"})
false
"""
@spec valid?(term()) :: boolean()
def valid?(event) when is_tuple(event), do: elem(event, 0) in @all_types
def valid?(event) when is_atom(event), do: event in @all_types
def valid?(_), do: false
@doc """
从事件中提取 session_id。
- 对于含 session_id 字段的事件(如 `compact_start`、`todo_change`),返回第二个元素
- 对于不含 session_id 的事件(如 `agent_start`、`message_delta`),返回 `nil`
> TUI 消费者通常从外层 `{:cmdc_event, session_id, event}` 元组读取 session_id,
> 无需调用此函数。
"""
@spec session_id(t()) :: session_id() | nil
def session_id(event) when is_tuple(event) and tuple_size(event) >= 2 do
case elem(event, 1) do
sid when is_binary(sid) -> sid
_ -> nil
end
end
def session_id(_), do: nil
end