# 写一个 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
```
---
## 范例 6 — v0.5 新增 `:after_turn` hook 写长期记忆
v0.3 起新增 `:after_turn` hook,每个 turn 回 idle 前触发(finish + abort 双路径),
payload 含完整结构化信息(messages_diff / token_usage_diff / duration_ms 等)。
比 `:session_end` 触发更频繁,**新 plugin 推荐用它**写审计 / 长期记忆 / 计费等:
```elixir
defmodule MyApp.TurnAuditor do
@behaviour CMDC.Plugin
@impl true
def init(opts), do: {:ok, %{db_pool: Keyword.fetch!(opts, :db_pool)}}
@impl true
def priority, do: 950 # 低优先级,业务 plugin 之后跑
@impl true
def handle_event({:after_turn, payload}, state, ctx) do
# payload 字段:
# :outcome - :finished | :aborted
# :abort_reason - term | nil
# :messages_diff - 本 turn 新增的 messages(按时间顺序)
# :token_usage_diff - %CMDC.TokenUsage{} 本 turn 增量
# :started_at_ms / :ended_at_ms / :duration_ms
Task.Supervisor.start_child(MyApp.AsyncTaskSupervisor, fn ->
MyApp.Audit.record_turn(state.db_pool, %{
session_id: ctx.session_id,
user_id: ctx.user_data[:user_id],
outcome: payload.outcome,
prompt_tokens: payload.token_usage_diff.prompt_tokens,
completion_tokens: payload.token_usage_diff.completion_tokens,
duration_ms: payload.duration_ms,
message_count: length(payload.messages_diff)
})
end)
{:continue, state}
end
def handle_event(_, state, _), do: {:continue, state}
end
```
异步走 `Task.Supervisor` 避免阻塞 Agent gen_statem callback。
内置 `AutoCheckpoint` Plugin 是这个模式的典型实现(按 turn 触发 + 异步 save + GC)。
---
## 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。
---
## 17 个内置 Plugin(v0.5 新增 1 个)
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) — 完成前自评/他评循环
- [AutoCheckpoint](CMDC.Plugin.Builtin.AutoCheckpoint.html) — **v0.5 新增** — by_turn / on_tools / on_events OR 触发 + 异步 save + max_checkpoints / ttl_seconds GC
---
## 下一步
- [写一个 Tool](tools.html) — Tool behaviour + Sandbox 代理
- [常见配方](cookbook.html) — 多 plugin 组合的端到端范例