Skip to main content

guides/plugins.md

# 写一个 Plugin

Plugin 是 CMDC 注入业务切面的标准方式。本章给出最小可运行模板、13 hook ×
8 action 矩阵、以及 5 个完整 Plugin 范例。

---

## Plugin behaviour

实现 `CMDC.Plugin` 三个 callback 即可:

```elixir
defmodule MyApp.MyPlugin do
  @behaviour CMDC.Plugin

  @impl true
  def init(opts) do
    # opts 来自 create_agent 时传的 {MyPlugin, opts}
    {:ok, %{counter: 0, opts: opts}}
  end

  @impl true
  def priority, do: 100  # 1-1000,小的先执行;同 priority 内顺序未定

  @impl true
  def handle_event({:before_tool, name, args}, state, ctx) do
    # state 是本 plugin 自己的状态;ctx 是 CMDC.Context.t()
    {:continue, %{state | counter: state.counter + 1}}
  end

  def handle_event(_event, state, _ctx), do: {:continue, state}
end
```

挂载:

```elixir
{:ok, session} = CMDC.create_agent(
  model: "...",
  plugins: [{MyApp.MyPlugin, [my_opt: :foo]}]
)
```

---

## 13 个 hook 速查

| hook | 触发时机 |
|---|---|
| `:session_start` | Agent 会话刚启动 |
| `:session_end` | Agent 会话正常结束 |
| `{:after_turn, payload}` | 每 turn 回 idle 前(finish + abort 双路径) |
| `{:before_prompt, text}` | 用户 prompt 提交前 |
| `{:before_request, messages}` | LLM 请求前(可改 messages) |
| `{:after_response, assistant_msg}` | LLM 回复后 |
| `{:before_tool, name, args}` | 单工具执行前 |
| `{:on_tool_error, name, call_id, error, attempt}` | 工具失败、retry 前 |
| `{:after_tool, name, call_id, result}` | 单工具执行后 |
| `{:after_tool_batch, results}` | 批工具全部完成后 |
| `:before_finish` | Agent 准备返回最终结果前 |
| `{:before_compact, messages}` | 上下文压缩前 |
| `{:before_steering, text}` | `steer/2` 中段软中断入队前 |

---

## 8 种 action

| action | 元组形式 | 含义 |
|---|---|---|
| `continue` | `{:continue, state}` | 继续下一个 plugin |
| `intervene` | `{:intervene, prompt, state}` | 注入提示文本(多个 plugin 同时 intervene 时按 priority 顺序拼接) |
| `abort` | `{:abort, reason, state}` | 短路 + Agent 回 idle + emit `:agent_abort` |
| `skip` | `{:skip, state}` | 短路 Pipeline,不影响 Agent 主流程 |
| `block_tool` | `{:block_tool, reason, state}` |`:before_tool`,阻止当前工具,注入 synthetic error result |
| `replace_tool_args` | `{:replace_tool_args, new_args, state}` |`:before_tool`,覆盖参数 |
| `replace_tool_result` | `{:replace_tool_result, new_result, state}` |`:after_tool`,覆盖结果 |
| `emit` | `{:emit, {name, payload}, state}` 等 4 种形态 | 广播自定义事件(累积) |
| `switch_model` | `{:switch_model, model, state}``{:switch_model, model, state, opts}` | 运行期换模型 |

---

## Hook × Action 矩阵

`` 表示该 hook 接受该 action;`-` 表示忽略;每个 hook 都隐含支持 `:continue``:emit`
| Hook \\ Action          | continue | intervene | abort | skip | block_tool | replace_args | replace_result | emit | switch_model |
|------------------------|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| `:session_start`        || - || - | - | - | - || - |
| `:session_end`          || - | - | - | - | - | - || - |
| `{:after_turn, p}`      || - | - | - | - | - | - || - |
| `{:before_prompt, t}`   ||||| - | - | - || - |
| `{:before_request, m}`  ||||| - | - | - |||
| `{:after_response, m}`  ||||| - | - | - |||
| `{:before_tool, n, a}`  || - || - ||| - |||
| `{:on_tool_error, ...}` || - ||| - | - | - || -* |
| `{:after_tool, ...}`    |||| - | - | - ||||
| `{:after_tool_batch, r}`|||| - | - | - | - |||
| `:before_finish`        |||| - | - | - | - || - |
| `{:before_compact, m}`  || - | - || - | - | - || - |
| `{:before_steering, t}` |||| - | - | - | - || - |

> **\\***`:on_tool_error` 在 Task retry 内部 Pipeline 触发,无完整 Agent state
> 上下文,`:switch_model` 被收集但**不应用**。要在工具失败后切模型请改用
> `:after_tool` 钩子匹配 `{:error, _}` result。

---

## 范例 1 — 危险命令拦截器(block_tool)

```elixir
defmodule MyApp.DangerousCommandGuard do
  @behaviour CMDC.Plugin

  @denied ~w(rm dd mkfs sudo curl wget)

  @impl true
  def init(_), do: {:ok, %{}}

  @impl true
  def priority, do: 50  # 早于 HumanApproval

  @impl true
  def handle_event({:before_tool, "shell", %{"command" => cmd}}, state, _ctx) do
    bin = cmd |> String.split(" ", parts: 2) |> hd() |> Path.basename()

    if bin in @denied do
      {:block_tool, "Command '#{bin}' is in the deny list.", state}
    else
      {:continue, state}
    end
  end

  def handle_event(_, state, _), do: {:continue, state}
end
```

---

## 范例 2 — Prompt 审计日志(emit + 文件落盘)

```elixir
defmodule MyApp.PromptAuditLog do
  @behaviour CMDC.Plugin

  @impl true
  def init(opts), do: {:ok, %{file: Keyword.fetch!(opts, :file)}}

  @impl true
  def priority, do: 200

  @impl true
  def handle_event({:before_prompt, text}, state, ctx) do
    line = "#{DateTime.utc_now()} #{ctx.session_id} #{inspect(text)}\n"
    File.write!(state.file, line, [:append])

    {:emit, {:prompt_audited, %{session_id: ctx.session_id, length: byte_size(text)}}, state}
  end

  def handle_event(_, state, _), do: {:continue, state}
end
```

---

## 范例 3 — 成本预算 abort(after_response)

```elixir
defmodule MyApp.BudgetGuard do
  @behaviour CMDC.Plugin

  @impl true
  def init(opts) do
    {:ok, %{max_usd: Keyword.fetch!(opts, :max_usd)}}
  end

  @impl true
  def priority, do: 300

  @impl true
  def handle_event({:after_response, _msg}, state, ctx) do
    if ctx.cost_usd > state.max_usd do
      {:abort, {:budget_exceeded, ctx.cost_usd, state.max_usd}, state}
    else
      {:continue, state}
    end
  end

  def handle_event(_, state, _), do: {:continue, state}
end
```

挂载后超预算自动 abort,订阅方收到 `{:agent_abort, {:budget_exceeded, ...}}`
---

## 范例 4 — 工具失败降级换模型

`:on_tool_error` 不能切 model,改用 `:after_tool` 匹配 `{:error, _}`
```elixir
defmodule MyApp.ToolFallbackGuard do
  @behaviour CMDC.Plugin

  @impl true
  def init(opts) do
    {:ok, %{
      fallback: Keyword.fetch!(opts, :fallback),
      threshold: Keyword.get(opts, :consecutive_failures, 3),
      counter: 0
    }}
  end

  @impl true
  def priority, do: 400

  @impl true
  def handle_event({:after_tool, _name, _id, {:error, _}}, %{counter: n} = state, _ctx) do
    new_state = %{state | counter: n + 1}

    if new_state.counter >= state.threshold do
      {:switch_model, state.fallback, %{new_state | counter: 0}}
    else
      {:continue, new_state}
    end
  end

  def handle_event({:after_tool, _, _, {:ok, _}}, state, _ctx) do
    {:continue, %{state | counter: 0}}
  end

  def handle_event(_, state, _), do: {:continue, state}
end
```

---

## 范例 5 — 敏感词拦截 `:before_request`

```elixir
defmodule MyApp.SensitiveContentGuard do
  @behaviour CMDC.Plugin

  @impl true
  def init(opts) do
    {:ok, %{words: MapSet.new(Keyword.fetch!(opts, :words))}}
  end

  @impl true
  def priority, do: 100

  @impl true
  def handle_event({:before_request, messages}, state, _ctx) do
    text = messages |> Enum.map(& &1.content) |> Enum.join(" ") |> String.downcase()

    hit = Enum.find(state.words, fn w -> String.contains?(text, w) end)

    if hit do
      {:abort, {:sensitive_word_detected, hit}, state}
    else
      {:continue, state}
    end
  end

  def handle_event(_, state, _), do: {:continue, state}
end
```

---

## Action 失败/嵌套语义

- **`:abort`** 短路:Pipeline 立即停止,后续 plugin 不再执行;Agent 直接回
  idle + 广播 `{:agent_abort, reason}` + 触发 `:after_turn`(outcome=`:aborted`)。
- **`:block_tool`** 短路(仅 `:before_tool`):当前工具不执行,注入
  synthetic error tool_result;同批其他工具继续按调度。
- **`:skip`** 短路:跳过所有后续 plugin,但不影响 Agent 主流程(按"无 action"
  继续推进)。
- **`:intervene`** 累积式:多个 plugin 同时 intervene 时按 priority 顺序
  拼接(`\n\n` 分隔),最终一次性注入;不短路。
- **`:replace_tool_args` / `:replace_tool_result` / `:switch_model`** 覆盖式:
  多个 plugin 同时返回时取最后执行(priority 最大)的值;不短路。
- **`:emit`** 累积式:所有 plugin 的 emit 事件按时序追加,Pipeline 结束后
  Agent 统一 broadcast;不短路。

---

## emit 自动注入 user_data

emit 出来的 `{:plugin_event, name, payload}` 事件,当 payload 是 map 时
Pipeline 会自动 merge `state.user_data``:user_data` 字段(除非 payload
`:_no_user_data`)。这让 plugin 不需要每次手动传 tenant_id / user_id。

---

## 16 个内置 Plugin

CMDC 提供 16 个开箱即用的 Plugin(按职能分两组):

**安全与控制**
- [SecurityGuard](CMDC.Plugin.Builtin.SecurityGuard.html) — 路径 / 命令安全防护
- [HumanApproval](CMDC.Plugin.Builtin.HumanApproval.html) — HITL 审批,含 `approve_always` 白名单
- [ContentPolicy](CMDC.Plugin.Builtin.ContentPolicy.html) — LLM-as-Judge 内容安全
- [OutputFilter](CMDC.Plugin.Builtin.OutputFilter.html) — 输出端敏感词
- [PatchToolCalls](CMDC.Plugin.Builtin.PatchToolCalls.html) — 悬空工具调用修复
- [EventLogger](CMDC.Plugin.Builtin.EventLogger.html) — 事件日志

**优化与记忆**
- [MemoryLoader](CMDC.Plugin.Builtin.MemoryLoader.html) — 加载 `AGENTS.md` 注入 system prompt
- [MemoryFlush](CMDC.Plugin.Builtin.MemoryFlush.html) — 压缩前持久化关键事实
- [EpisodicMemory](CMDC.Plugin.Builtin.EpisodicMemory.html) — 成功对话作 few-shot 复用
- [LargeResultOffload](CMDC.Plugin.Builtin.LargeResultOffload.html) — 200KB+ 结果落盘
- [PromptCache](CMDC.Plugin.Builtin.PromptCache.html) — Anthropic prompt caching
- [ModelRouter](CMDC.Plugin.Builtin.ModelRouter.html) — 按规则路由模型
- [CostGuard](CMDC.Plugin.Builtin.CostGuard.html) — 成本预算守护
- [Recovery](CMDC.Plugin.Builtin.Recovery.html) — 失败恢复策略
- [Planning](CMDC.Plugin.Builtin.Planning.html) — 强制先规划后执行
- [Reflection](CMDC.Plugin.Builtin.Reflection.html) — 完成前自评/他评循环

---

## 下一步

- [写一个 Tool](tools.html) — Tool behaviour + Sandbox 代理
- [常见配方](cookbook.html) — 多 plugin 组合的端到端范例