Skip to main content

guides/upgrading.md

# 升级指南

本章只列**用户实际会被坑**的兼容边界——不是新增的可选字段,是**写错代码会报错或行为变化**的位置。
新增字段、新增可选参数、文档改写等"无影响"的变化请看 [Changelog](changelog.html)。

---

## v0.5.x → v0.6.0

**主要为加性升级**,1 处 breaking 改动(`Sandbox.Local` `virtual_mode` 默认从
`false` → `true`,提供 opt-in 回退)。其他全部向后兼容。

### v0.6.0 新增(零兼容影响)

- **`CMDC.EventBus.subscribe_group/1`** —— 多 Agent 协调,按 `group_id` 一次性聚合订阅
- **`CMDC.Options.group_id`** —— 字段默认 `nil`,不传完全兼容 v0.5
- **`CMDC.Provider.Registry.register/2` `:resolver_fn` opt** —— Vault / Cloak 懒解密注入点
- **`CMDC.Memory.entry()` `:tags :: [String.t()]`** —— 一等公民字段(默认 `[]`)
- **`CMDC.Memory.ETS` `:tags_any` / `:tags_all` filter 操作符** —— 高效 tag 检索
- **`CMDC.Memory` `c:schema_version/0`** —— 可选 callback,未实现的老 backend 默认认为是 `1`
- **`CMDC.update_plugin_opts/3`** —— Plugin 长会话内热更(idle flush only)
- **`CMDC.AuditEvent.from_event/2` 15 核心事件 + `from_telemetry/3` 12 事件**

### Memory backend 升级注意(v0.5 keyword filters 永久保留)

v0.5 时代 `Memory.search/3` 的 `filters` 接受 `[{key, value}]` 等值过滤。**v0.6
完全保留该行为**,语义恒为"AND 等值",没有引入 `{:and, [...]}` / `{:or, [...]}`
等结构化 DSL evaluator(让 backend 实现复杂度可控)。

新增的 `{:tags_any, [...]}` / `{:tags_all, [...]}` 与老 keyword filters **可同时
在同一调用混用**,按 AND 组合:

```elixir
# v0.5 写法(v0.6 完全等价)
CMDC.Memory.ETS.search(mem, "", filters: [{:user_id, "u-1"}])

# v0.6 推荐写法(结合 tags)
CMDC.Memory.ETS.search(mem, "",
  filters: [{:user_id, "u-1"}, {:tags_any, ["domain:finance"]}])
```

不需要的代码改动:

- ❌ v0.5 的 `filters: [{:user_id, "u-1"}]` 在 v0.6 **不需要改写**
- ❌ 现存 entry 没有 `:tags` 字段,v0.6 的 ETS backend 在 `search/3` 时 `tags_*`
  filter 对其视为"无 tag",不会报错

需要的代码改动:

- ✅ 自定义 Memory backend 实现需要新加 `def schema_version, do: 1`(可选,
  未实现走 `function_exported?` 回退路径)
- ✅ `cmdc_memory_pg` 集成方需运行新增 migration `mix ecto.migrate` 加 `tags`
  列 + GIN 索引(升 0.1.1 → 0.1.2 唯一变动,详见 cmdc_memory_pg CHANGELOG)

### Sandbox.Local `virtual_mode` 默认 true(**唯一 breaking**)

`CMDC.Sandbox.Local` 的所有 IO 函数(`read_file/2` / `write_file/3` / `edit_file/4` /
`list_dir/2` / `file_exists?/2` / `grep/3` / `glob/3`)v0.6 起接受 `:virtual_mode` opt,
**默认值从隐式 `false`(v0.5 老行为)翻转为 `true`**。

**`:virtual_mode true`(默认,新行为)拦截**:

- path 含 `".."`(如 `"../etc/passwd"`)
- path 以 `"~"` 开头(如 `"~/.ssh/id_rsa"`)
- 绝对路径解析后不在 `:working_dir` 内(如 `/etc/passwd`,working_dir=`/tmp`)

违反返 `{:error, "路径越界(virtual_mode 防护...)"}` 字符串。`file_exists?/2`
在越界路径上返 `false`(不泄漏外部文件系统结构)。

**为什么这么改**:v0.5 行为对开发 / CLI / 单机 Agent 是合理的,但**Web 服务器
/ 不可信负载 / 在线托管多用户 Agent** 等场景下,LLM 一旦被 prompt injection
诱导生成 `read_file("../../../etc/passwd")` 就能拿到任意系统文件。
v0.6 默认翻转把"路径在 working_dir 内"作为安全基线,符合现代 Agent
runtime 的预期。

**v0.5 → v0.6 影响判断**:

| v0.5 代码场景 | v0.6 影响 | 推荐处理 |
|---|---|---|
| 始终用相对路径 + working_dir 内文件 | **零影响**(推荐路径)| 不动 |
| 偶尔用 `"../foo"` / `"~/cache"` | `{:error, ...}` | 1 行 opt-out(见下)/ 重设 working_dir |
| 跨多个目录访问系统文件 / CLI 工具 | `{:error, ...}` | 显式传 `virtual_mode: false` |
| 完全自管路径解析(已自验证) | `{:error, ...}` | 显式传 `virtual_mode: false`(**强烈不推荐**)|

**显式 opt-out(v0.5 兼容路径,1 行迁移)**:

```elixir
# v0.5 老代码(隐式 virtual_mode=false 行为)
CMDC.Sandbox.Local.read_file("../parent.txt", working_dir: dir)

# v0.6 行为变化:默认拦截
{:error, "路径越界(virtual_mode 防护..."}

# v0.6 显式 opt-out 回退到 v0.5 行为
CMDC.Sandbox.Local.read_file("../parent.txt",
  working_dir: dir,
  virtual_mode: false                # ← 1 行 opt-out
)

# 推荐:重设 working_dir 为公共祖先(不需要 opt-out)
CMDC.Sandbox.Local.read_file("parent.txt",
  working_dir: Path.dirname(dir)
)
```

`virtual_mode: false` 显式传入时,每个 Process 第一次会触发 `Logger.warning/1`,
提示用户考虑重设 `working_dir` 为公共祖先目录。后续调用静默。

**v0.7+ 计划**:`:virtual_mode` opt 保留至少一个 minor cycle,**不计划完全移除**
(合理跨目录访问场景永远存在,如 CLI 开发工具 / 日志读取 / 系统监控)。

**自定义 `CMDC.Sandbox` 实现**:本变化仅影响默认 `Sandbox.Local`,自定义 behaviour
实现完全不受影响。`Sandbox.Local` 内部新增的 virtual_mode 防护**不进 behaviour
contract**(不强制其他实现支持)。

---

## v0.5.x → v0.5.3(当前版本)

**全加性升级**,零代码 / 零行为破坏。所有 v0.5.x 代码不动即可跑通。

### v0.5.3 新增

- **`CMDC.Plugin.Builtin.SkillGuard`** —— Skill `allowed_tools` 白名单 enforcer Plugin

  ```elixir
  skills = CMDC.Skill.discover([{:project, "./.cmdc/skills"}])

  CMDC.create_agent(
    model: "anthropic:claude-sonnet-4-5",
    tools: [CMDC.Tool.ReadFile, CMDC.Tool.Grep, CMDC.Tool.WriteFile, CMDC.Tool.Shell],
    plugins: [
      {CMDC.Plugin.Builtin.SkillGuard,
       skills: skills,
       active: ["elixir-testing"],
       enforce_mode: :strict}  # 或 :warn
    ]
  )
  ```

  并集语义:多 active Skill 的 `allowed_tools` 取并集;任一未设白名单即不限制。
  显式 opt-in,不挂载就零行为变化。

- **3 份 observability 配方** (`docs/recipes/observability/`):
  - Langfuse OTLP / LangSmith OTel / Grafana Tempo + Loki + PromEx
  - 把 18 个 `:telemetry` 事件接到主流可观测性栈

### v0.5.2 新增

- **`CMDC.Skill.t()` 加 `:source` 字段** —— `:base | :user | :project | :team | :custom | nil`
- **`Skill.discover/1` 多源标签重载**:

  ```elixir
  Skill.discover([
    {:base,    "priv/skills"},
    {:user,    "~/.cmdc/skills"},
    {:project, "./.cmdc/skills"}
  ])
  ```

  老 `discover([dir])` 调用形式零行为变化(`:source = nil`)。

- **`CMDC.Provider.Registry.Broadcaster.PG`** 配套 handler 协议测
  + `@tag :distributed` 可选双节点 :pg 集群验收测

### v0.5.1 新增

- **`CMDC.Provider.Registry`** —— 多租户命名 Provider Profile 寻址中心

  ```elixir
  :ok = CMDC.Provider.Registry.register("tenant-A",
          provider: "anthropic",
          opts: [api_key: "sk-...", base_url: "https://litellm.a.internal"])

  CMDC.create_agent(model: "registry:tenant-A:claude-sonnet-4-5")
  # 等价于 CMDC.create_agent(model: "anthropic:claude-sonnet-4-5",
  #                          provider_opts: [api_key: "...", base_url: "..."])
  ```

  - hot-path `lookup/1` ≤ 1 µs (ETS read)
  - 跨节点同步通过 `Broadcaster` behaviour 解耦(默认 PG / 可接 Phoenix.PubSub)
  - profile name **不能含 `:`** (`register/2` 返 `{:error, {:invalid_name, :contains_colon}}`)
  - `resume_session!/2` 遇 profile 缺失返 `{:error, {:registry_profile_missing, name}}`

### v0.5 系列新增子库

- **`cmdc_eval ~> 0.1`**(v0.5.3 同窗口)—— Agent benchmark harness
  + Suite behaviour + Internal/BFCL 内置 suite + Mix.Tasks.Cmdc.Eval CLI

  ```elixir
  defp deps, do: [{:cmdc, "~> 0.5.3"}, {:cmdc_eval, "~> 0.1"}]
  ```

  ```bash
  $ mix cmdc.eval --suite=internal --model="anthropic:claude-sonnet-4-5" --report=out.jsonl
  ```

---

## v0.4.x → v0.5.0

**全加性升级**,零代码 / 零行为破坏。所有 v0.4.x 代码不动即可跑通。

### 新增公共 API(向后兼容,按需采用)

- **`CMDC.Options.hibernate_after_ms`** — 进程空闲超时自动 hibernate,单进程 heap 8KB → 1.5KB
- **`CMDC.checkpoint!/2`** + **`CMDC.resume_session!/2`** — facade API 把运行中 session 抓快照 + 跨进程恢复
- **`CMDC.Checkpoint.Snapshot.redact/2`** — backend 写前预处理 hook(接 Cloak / KMS)
- **`CMDC.Plugin.Builtin.AutoCheckpoint`** — 内置 Plugin,按 turn / on_tools / on_events 自动存档 + 自动 GC
- **`CMDC.AsyncTaskSupervisor`** — application supervision tree 新增,供 plugin 异步任务用
- **`CMDC.Telemetry`** 从 6 事件扩到 16 事件(10 个新事件覆盖 Plugin Pipeline / Compactor / Checkpoint / SubAgent / Hibernate)

### 长会话场景推荐配置

如果你的 Agent 是常驻多租户场景(>100 idle 会话同节点):

```elixir
{:ok, session} = CMDC.create_agent(
  model: "anthropic:claude-sonnet-4-5",
  hibernate_after_ms: 60_000,                              # 新增
  plugins: [
    {CMDC.Plugin.Builtin.AutoCheckpoint,                    # 新增
     backend: CMDCMemoryPg.CheckpointBackend,
     every_n_turns: 10,
     on_events: [:approval_required, :session_end]}
  ]
)
```

### 持久化场景新增子库 `cmdc_memory_pg 0.1.0`

```elixir
defp deps do
  [
    {:cmdc, "~> 0.5"},
    {:cmdc_memory_pg, "~> 0.1"}        # 可选 PG backend
  ]
end

# config/runtime.exs
config :cmdc, :checkpoint_backend, CMDCMemoryPg.CheckpointBackend
```

提供 Checkpoint + EpisodicMemory 持久化 PG 后端,与主库 `CMDC.Checkpoint.Backend` / `CMDC.Memory` behaviour 完全对接。

### 测试场景新增子库 `cmdc_test 0.1.0`

```elixir
defp deps do
  [
    {:cmdc, "~> 0.5"},
    {:cmdc_test, "~> 0.1", only: :test}
  ]
end
```

提供 4 大测试 helpers:
- `MockProvider` Builder API
- `Plugin.run_hook/3` 单元测 + `Plugin.Spy` 集成路径 inject anonymous handler
- `EventCapture` + `Assertions.{assert_event_emitted, refute_event_emitted, assert_event_count}`

**无升级动作**。直接改 `mix.exs` 依赖到 `~> 0.5` 即可。

---

## v0.4.0 → v0.4.1

**纯文档 patch**,零代码 / 零行为变更。

- 主库 `mix.exs` extras 重组、内核模块加 `@moduledoc false`、清理 hex doc 内部字眼
- 1219 tests + 21 doctests 不变

**无升级动作**。

---

## v0.3.x → v0.4.0

### 新增公共模块(向后兼容,纯增量)

- `CMDC.Backend` behaviour + `Backend.{State, Filesystem, Composite}` 三个内置实现
- `CMDC.Checkpoint` + `Checkpoint.Backend` behaviour + ETS / DETS 两个内置 backend
- `CMDC.Telemetry` 标准 `:telemetry` 事件契约
- 4 个新内置 Plugin:`LargeResultOffload` / `ContentPolicy` / `EpisodicMemory` / `MemoryFlush`

旧代码不需要改任何东西就能拿到这些能力——按需挂载即可。

### Plugin Pipeline 新 action `:replace_tool_result`

仅 `:after_tool` hook 接受。让 plugin 在 raw_result 写进 message history
**之前**替换它(`LargeResultOffload` 用这个 action 把 200KB 结果换成
preview)。

无破坏性影响——旧 plugin 完全不受影响。

### `HumanApproval` 加 `:approve_always` 第三态

```elixir
# v0.3:approve(session, id) 只放当次
CMDC.approve(session, id)

# v0.4:可加 :kind 走 session-scoped 永久白名单
CMDC.approve(session, id, kind: :approve_always)
```

旧代码 `CMDC.approve(session, id)` 默认 `:approve_once`,行为不变。

### Sandbox `virtual_mode`(推荐生产开启)

`Backend.Filesystem.new/1` 支持 `:virtual_mode` 选项:

```elixir
# v0.3 行为(默认 false,0 安全保护)
backend = CMDC.Backend.Filesystem.new(root_dir: "/tmp/work")

# v0.4 推荐生产配置
backend = CMDC.Backend.Filesystem.new(
  root_dir: "/tmp/work",
  virtual_mode: true     # 拦 .. / ~ traversal + O_NOFOLLOW symlink 防护
)
```

`virtual_mode: true` 会拒绝路径逃逸 `root_dir`,**有可能**让原本依赖
绝对路径的代码失败。如果你的 Agent 之前依赖访问 `root_dir` 外的文件
(不推荐),开启前要先改造业务代码。

未来 v0.5 默认值会切换为 `true`,到时是 breaking change。

---

## v0.2.x → v0.3.0(**1 条 breaking change**)

### #1 公共 API 全部改 `{:ok, _} | {:error, _}` 返回(**breaking**)

v0.2 的 `CMDC.monitor` / `abort` / `attach_tool` / `detach_tool` /
`status` / `messages` / `agent_pid` / `steer` / `stop` / `switch_model` /
`replace_tools` / `attach_tools` / `detach_tools` 在传非法 session 时**会 raise**。

v0.3 起改为返回 `{:error, :invalid_session | :not_alive}`。

**迁移**:把所有 `CMDC.xxx(session, ...)` 改为 with 链或 case 匹配:

```elixir
# v0.2 写法(v0.3 仍能跑,但只在 session 合法时工作)
CMDC.attach_tool(session, MyTool)

# v0.3 推荐写法
case CMDC.attach_tool(session, MyTool) do
  {:ok, _name} -> :ok
  {:error, :invalid_session} -> handle_dead_session()
  {:error, {:validation_failed, failures}} -> handle_invalid_tool(failures)
end
```

成功路径的返回值(`:ok` / map / Message struct 等)保持不变,所以多数业务
代码只需要在最外层套个 `case` 即可。

### `abort/2` `:reason` 接受 string

v0.2 只接受 atom,v0.3 起接受下列 6 个标准 string 自动归一为 atom,防止
前端通过 JSON 反序列化注入任意 atom 进 BEAM atom table:

```
"user_cancelled" / "timeout" / "shutdown" /
"budget_exceeded" / "permission_denied" / "provider_error"
```

其他 string 一律归并为 `:unknown` 并 `Logger.warning`。Atom 入参保持原样
透传。

### `pending_tools` 加 `started_at_ms` 字段

`CMDC.status/1` 返回的 `pending_tools` 列表每项新增 `started_at_ms` 字段
(`System.system_time(:millisecond)`)。如果你之前对 pending_tools 做了
strict map match `%{name: _, call_id: _, args: _}`,会失败:

```elixir
# v0.2 strict match
%{name: name, call_id: id, args: args} = tool

# v0.3 兼容写法
%{name: name, call_id: id, args: args, started_at_ms: _} = tool
# 或更稳妥:
name = tool.name
```

### Plugin emit 自动注入 user_data

emit 出来的 `{:plugin_event, name, payload}` 当 payload 是 map 时,Pipeline
会自动 merge `state.user_data` 到 `:user_data` 字段。如果你的订阅方做了
strict map match 不期待 `:user_data`,要么改 match,要么在 plugin 给
payload 加 `:_no_user_data` opt out。

### `:after_turn` 新 hook

新增 `{:after_turn, payload}` Plugin hook,每 turn 回 idle 前触发(finish
+ abort 双路径)。比 `:session_end` 触发更频繁,**新 plugin 推荐用它**写
审计 / 长期记忆 / 计费等。

旧 plugin 不受影响。

### `attach_tools / detach_tools / replace_tools` 批量原子 API

新增三个批量 API。dry-run + 全回滚语义:任一失败全部不动。**单次的
`attach_tool/2` / `detach_tool/2` 行为完全不变**,可继续用。

### EventBus replay 加 `:types` 白名单

`subscribe/2` 加 `:types` 选项:

```elixir
# v0.3 起:只 replay stream / agent_end 类事件
{:ok, _} = CMDC.subscribe(session, since: 100, types: [:message_delta, :agent_end])
```

`:since` 一直存在;`:types` 是新增可选项。

---

## v0.1.x → v0.2.0

### `{:agent_end, messages, token_usage}` 第三参数改 struct

v0.1.x 的 `token_usage` 是 plain map(`%{prompt_tokens, completion_tokens, ...}`),
v0.2 起统一为 `%CMDC.TokenUsage{}` struct:

```elixir
# v0.1.x 兼容写法
%{total_tokens: tt} = usage

# v0.2 推荐写法(也兼容 v0.1 字段名)
%CMDC.TokenUsage{total_tokens: tt, cost_usd: cost} = usage
```

字段名保留 `prompt_tokens / completion_tokens / total_tokens`(OpenAI 行业
事实标准),同时归一化 Anthropic 风格的 `input_tokens` / `output_tokens`。

### Steering 软中断(新功能)

新增 `CMDC.steer/2` 公开 API + 3 个新事件 `:steering_received` /
`:steering_applied` / `:tool_skipped_for_steering`。

### SubAgent `prompt_mode` 默认改 `:task`

v0.1 子代理用完整 BasePrompt(同主 Agent)。v0.2 起 SubAgent 默认 `prompt_mode:
:task`(精简),节省 30-50% system prompt token。

如果你的子代理依赖完整 BasePrompt 行为:

```elixir
%CMDC.SubAgent{
  name: "...",
  prompt_mode: :full  # 显式指定回 v0.1 行为
}
```

### MemoryFlush Plugin(新功能)

新增 `CMDC.Plugin.Builtin.MemoryFlush`,在压缩前把关键事实持久化到
`MEMORY.md`,下次会话由 `MemoryLoader` 自动加载回 system prompt——解决
长会话失忆问题。

### Ring Buffer + replay(新功能)

`Options.event_buffer_size > 0` 启用 per-session 事件 ring buffer,
`subscribe(session, since: idx)` 重连补帧。默认 `0` = 关闭,零内存开销。

---

## 完整变更摘要

各版本完整 changelog 见仓库 [CHANGELOG.md](https://github.com/tupleyun/cmdc/blob/main/CHANGELOG.md)。