Skip to main content

guides/agent-loop.md

# Agent 状态机与事件

CMDC Agent 是一个 `:gen_statem`,4 个状态、7 大类事件。看完本章你能精确预测
任何 API 在任何状态下的行为。

---

## 4 个状态

| 状态 | 含义 | 主要外部 API 行为 |
|---|---|---|
| `:idle` | 空闲,等下一条 prompt | `prompt/2` 立即开新 turn;`steer/3` 退化为 prompt |
| `:running` | 已发出 LLM 请求,等首个 chunk | `prompt/2` 入队;`steer/3` 入 steering queue |
| `:streaming` | 正在接收 LLM 流式输出 | 同上 |
| `:executing_tools` | LLM 已停,工具批次执行中 | 同上 + 当前批次结束才合并 steering |

---

## 状态转换图

```
                         ┌─────────────────────────────┐
                         │                             │
                         ▼                             │
   ┌──────────┐  prompt/2   ┌──────────┐  stream chunk │
   │   idle   │────────────▶│ running  │───────────┐   │
   └──────────┘             └──────────┘           ▼   │
        ▲                         │           ┌──────────┐
        │ agent_end /             │ stream    │streaming │
        │ abort                   │ done      └──────────┘
        │                         ▼                │
        │                   ┌────────────────┐     │ has tool_calls
        │                   │  finalize +    │◀────┘
        │                   │  decide next   │
        │                   └────────────────┘
        │                         │ has tool_calls
        │                         ▼
        │                  ┌──────────────────┐
        └──────────────────│ executing_tools  │
            batch done     └──────────────────┘
            +next turn
```

---

## 4 状态接收行为

### `prompt/2`

| 状态 | 行为 |
|---|---|
| `:idle` | 立即开新 turn,转 `:running` |
| 其他 |`pending_messages` 队列;本轮结束后自动消费下一条 |

### `steer/3`(中段干预)

| 状态 | 行为 |
|---|---|
| `:idle` | 退化为 `prompt/2` |
| `:running` | 入 steering queue,下一 turn 间隙合并注入 |
| `:streaming` | 同上 |
| `:executing_tools` | 同上 + 当前批次内 killable 工具立即被 brutal_kill |

队列满(默认 3)时返回 `{:error, :queue_full}`,发出 `:steering_received`
事件 status=`:rejected_full`。Plugin 可在 `{:before_steering, text}` hook
返回 `:abort` 拒绝某条 steering。

### `abort/2`

| 状态 | 默认行为(`kill_tools: :killable`|
|---|---|
| `:idle` | no-op,但仍 emit `:agent_abort` 便于订阅方对账 |
| `:running` / `:streaming` | cancel stream task |
| `:executing_tools` | 杀非 immune 工具,immune 工具继续;同时 cancel stream |

无论何种状态都保证 `:agent_abort` 事件 100ms 内到达订阅方。

### `approve/3` / `reject/3`

`HumanApproval` Plugin 在 `:before_tool` 拦下危险工具,把 Agent 暂时退到
`:idle` 等审批。`approve/reject` 在 idle 状态下:

- `:auto_resume` 默认 `true`(approve)/ `false`(reject)
- auto_resume 后 Agent emit `{:agent_resumed, %{trigger: ...}}`,重新进
  `:running` 让 LLM 重试

### `switch_model/2 / 3`

| 状态 | 行为 |
|---|---|
| `:idle` | 立即切;下一次 `prompt/2` 用新模型;emit `:model_switched` |
| `:running / :streaming / :executing_tools` | state.model 立即更新 + emit;本轮跑完,下一轮才用新模型 |

切到同一模型 = no-op,不发事件。messages / tools / plugin_states 全保留。

---

## 完整事件清单

事件外层格式:`{:cmdc_event, session_id, event}`。下面只列 event 部分。

### 会话生命周期

- `:agent_start` — Agent 开始处理 prompt
- `{:agent_end, messages, %CMDC.TokenUsage{}}` — 本轮完成
- `{:agent_abort, reason}` / `:agent_abort` — 中止(reason 为 nil 时发裸 atom)
- `{:agent_resumed, %{trigger}}` — 因外部信号续命;`trigger`  `:tool_approved | :tool_rejected | :tool_approval_timeout |
  :user_respond | :steering`
- `{:prompt_received, text}` — 收到用户 prompt
- `{:prompt_queued, text}` — 忙碌中入队
- `{:prompt_dropped, text}``abort(:clear_queue)` 丢弃的 prompt
- `{:prompt_rejected, reason}` — Plugin 拒绝

### 流式响应

- `:message_start`
- `{:message_delta, %{delta: text}}`
- `{:response_complete, %CMDC.Message{}}`
- `:thinking_start`
- `{:thinking_delta, %{delta}}`
- `{:status_update, text}` / `{:title_generated, title}`  `<status>...</status>` / `<title>...</title>` 内联 XML 标签提取

### Provider / 请求

- `{:request_start, %{model, messages}}`
- `{:stream_error, reason}` / `{:stream_stalled, elapsed_s}`
- `{:retry, attempt, delay_ms, reason}`
- `{:context_overflow, reason}`

### 工具执行

- `{:tool_calls, count}` — LLM 请求了 N 个工具
- `{:tool_execution_start, name, call_id, args}`
- `{:tool_execution_end, name, call_id, result}` — result 为 `{:ok, _} | {:error, _}`
- `{:tool_execution_metrics, name, call_id, %{started_at_ms, ended_at_ms, duration_ms}}` — 自动埋点
- `{:tool_blocked, name, call_id, reason}` — Plugin block_tool
- `{:tool_killed, %{name, call_id, reason}}` — abort 杀掉的
- `{:tool_skipped_for_steering, %{name, call_id, reason}}` — Steering 跳过
- `{:tool_attached, name}` / `{:tool_detached, name}`  `attach_tool/2` / `detach_tool/2` 调用结果
- `{:tools_updated, %{attached, detached}}` — 批量原子操作汇总
- `{:tool_call_unknown, name, call_id}` — LLM 引用了已 detach 的工具
- `{:loop_detected, %{type, ...}}` — 内建循环检测;type 为
  `:repeat_pattern | :file_loop_warn | :file_loop_abort`

### 人机交互

- `{:approval_required, approval_map}` — 等待审批
- `{:approval_resolved, approval_map}` — 已决定(含 `:status`  `:approved | :rejected | :timeout`- `{:tool_approved_always, %{tool, command_family}}``approve_always` 白名单写入
- `{:ask_user, sid, question, options, ref}` — Agent 主动提问
- `{:user_responded, sid, ref, response}` — 用户已答

### 中段干预

- `{:steering_received, %{ref, text, queued_at, status}}``steer/2` 调用结果
- `{:steering_applied, %{refs, count}}` — queue 已合并到下一 turn
- `{:tool_skipped_for_steering, ...}` — 工具因 steering 取消

### 上下文压缩

- `{:compact_start, sid}` / `{:compact_end, sid, removed_count}`
- `{:before_compact, messages}` — Plugin hook 同名

### 子代理

- `{:subagent_start, sid, child_sid, description}`
- `{:subagent_end, sid, child_sid, result}`
- `{:sub_agent_event, call_id, child_sid, event}` — 子代理内部事件透传

### 模型 / 计划 / 通知

- `{:model_switched, %{from, to, provider_opts_changed?}}`
- `{:todo_change, sid, todos}``WriteTodos` 工具更新
- `{:plan_generated, %CMDC.Plan{}}``Planning` Plugin 解析
- `{:memory_flushed, %{facts, count, sid}}``MemoryFlush` 写入
- `{:large_result_offloaded, %{tool, call_id, path, bytes}}`
- `{:content_policy_violated, %{summary, triggered_policies}}`
- `{:plugin_event, name, payload}` — 自定义事件(payload map 自动注入 user_data)
- `{:intervention, prompt}` — Agent 注入干预 prompt

---

## 订阅模板

```elixir
defmodule MyApp.AgentObserver do
  use GenServer

  def start_link(session) do
    GenServer.start_link(__MODULE__, session)
  end

  def init(session) do
    CMDC.subscribe(session)
    CMDC.monitor(session)
    {:ok, %{session: session, deltas: []}}
  end

  def handle_info({:cmdc_event, _sid, {:message_delta, %{delta: t}}}, st) do
    IO.write(t)
    {:noreply, %{st | deltas: [t | st.deltas]}}
  end

  def handle_info({:cmdc_event, _sid, {:tool_execution_start, name, _, _}}, st) do
    IO.puts("\n  [tool] #{name} ...")
    {:noreply, st}
  end

  def handle_info({:cmdc_event, _sid, {:agent_end, _msgs, usage}}, st) do
    IO.puts("\n[done] tokens=#{usage.total_tokens} cost=#{usage.cost_usd}")
    {:noreply, st}
  end

  def handle_info({:cmdc_down, _ref, _sid, reason}, st) do
    IO.puts("[crashed] #{inspect(reason)}")
    {:stop, :normal, st}
  end

  def handle_info(_, st), do: {:noreply, st}
end
```

---

## 重连补帧(断网恢复)

启用 ring buffer:

```elixir
{:ok, session} = CMDC.create_agent(
  model: "...",
  event_buffer_size: 200  # 默认 0 = 关闭
)
```

订阅时给 `:since`
```elixir
last_index = MyApp.LastSeen.read(session_id)
{:ok, _} = CMDC.subscribe(session, since: last_index, types: [:message_delta, :agent_end])
```

可选 `:types` 白名单只 replay 关心的事件,省网络流量。

---

## 下一步

- [写一个 Plugin](plugins.html) — 在 13 个 hook 拦截 + 收集事件
- [常见配方](cookbook.html) — 流式 UI / HITL / Checkpoint 等组合范例