Current section
Files
Jump to
Current section
Files
lib/cmdc.ex
defmodule CMDC do
@moduledoc """
CMDC — Elixir Agent Kernel 公共 API 入口。
提供从零创建 Agent 会话到收集回复的完整链路。
内部通过 `CMDC.EventBus` 实现异步解耦。
## 公开 API 矩阵
所有公开 API 同时接受 **session pid** 和 **session_id 字符串**。
字符串入参通过 `CMDC.SessionRegistry`(`:via` 注册表)反查 pid。
| 能力 | API | 入参类型 | 备注 |
|---|---|---|---|
| 创建会话 | `create_agent/1` | keyword \\| `Options.t()` | 返回 `{:ok, pid}` |
| 发送 prompt | `prompt/2` | pid \\| string | 立刻或入队 |
| 收集回复 | `collect_reply/2` | pid \\| string | 阻塞等待 |
| 中段干预 | `steer/2` | pid \\| string | 软中断 |
| 中止 | `abort/1` | pid \\| string | Agent → idle |
| 停止 | `stop/2` | pid \\| string | 关闭 Supervisor |
| 订阅事件 | `subscribe/1` | pid \\| string | 当前进程接收事件 |
| 取消订阅 | `unsubscribe/1` | pid \\| string | — |
| 取 session_id | `session_id/1` | pid \\| string | 字符串入参直接返回 |
| 取 Agent pid | `agent_pid/1` | pid \\| string | — |
| 状态快照 | `status/1` | pid \\| string | `%{state, turns, ...}` |
| HITL 批准 | `approve/3` | pid \\| string | 默认 auto_resume |
| HITL 拒绝 | `reject/3` | pid \\| string | 默认不续 turn |
| 用户回应 | `user_respond/3` | pid \\| string | 应答 AskUser 工具 |
字符串入参对应的 session 进程必须存活;不存在时 raise `ArgumentError`。
## 快速开始
# 1. 创建 Agent 会话
{:ok, session} = CMDC.create_agent(
session_id: "my-session",
model: "anthropic:claude-sonnet-4-5",
tools: [CMDC.Tool.ReadFile, CMDC.Tool.Shell],
system_prompt: "你是一个专业的代码助手。",
working_dir: "/my/project"
)
# 2. 发送 prompt(pid 或 session_id 字符串都行)
CMDC.prompt(session, "帮我列出当前目录的文件")
CMDC.prompt("my-session", "帮我列出当前目录的文件")
# 3. 等待回复(阻塞直到 Agent 回到 idle)
{:ok, reply} = CMDC.collect_reply(session, timeout: 30_000)
IO.puts(reply)
# 4. 停止会话
CMDC.stop(session)
## 流式接收
CMDC.subscribe(session)
CMDC.prompt(session, "写一首诗")
receive do
{:cmdc_event, _sid, {:message_delta, %{delta: chunk}}} -> IO.write(chunk)
{:cmdc_event, _sid, {:agent_end, _msgs, _usage}} -> :done
end
## HITL 审批
CMDC.subscribe(session)
CMDC.prompt(session, "删除所有日志文件")
receive do
{:cmdc_event, _sid, {:approval_required, %{id: ref, tool: tool, args: args}}} ->
IO.inspect({tool, args}, label: "批准工具调用?")
CMDC.approve(session, ref) # 或 CMDC.reject(session, ref)
end
## AskUser 工具回应
receive do
{:cmdc_event, sid, {:ask_user, ^sid, question, options, ref}} ->
# 通过 string session_id 也可以
CMDC.user_respond(sid, ref, "我的答案")
end
"""
alias CMDC.{Agent, Checkpoint, EventBus, SessionServer}
alias CMDC.Checkpoint.Snapshot
# ==========================================================================
# 类型
# ==========================================================================
@typedoc """
会话引用 — 既支持 SessionServer pid,也支持 session_id 字符串。
字符串入参会通过 `CMDC.SessionRegistry` 反查 pid;
当 session 不存在或已死亡时,所有公开 API 会 raise `ArgumentError`。
"""
@type session :: pid() | String.t()
@type create_opts :: keyword() | CMDC.Options.t()
# ==========================================================================
# create_agent/1
# ==========================================================================
@doc """
创建并启动一个 Agent 会话。
支持三种调用方式:
**1. keyword list(推荐)**
{:ok, session} = CMDC.create_agent(
model: "anthropic:claude-sonnet-4-5",
tools: [CMDC.Tool.Shell],
working_dir: "/project"
)
**2. `CMDC.Options.t()` struct**
opts = CMDC.Options.new!(model: "anthropic:claude-sonnet-4-5")
{:ok, session} = CMDC.create_agent(opts)
**3. Blueprint 模块**
{:ok, session} = CMDC.create_agent(blueprint: MyApp.CodingAgent)
## 必填选项
- `:model` — 模型标识符,如 `"anthropic:claude-sonnet-4-5"`(Blueprint 启动时可省略)
## 可选选项
- `:session_id` — 会话唯一标识符,默认自动生成
- `:blueprint` — Blueprint 模块或 `CMDC.Blueprint.t()` struct
- `:working_dir` — 工作目录,默认当前目录
- `:system_prompt` — 系统提示词
- `:tools` — Tool 模块列表
- `:plugins` — Plugin 列表
- `:config` — `CMDC.Config.t()` struct
- `:provider_opts` — 传给 req_llm 的选项,如 `[api_key: "sk-..."]`
- `:max_turns` — 最大轮数,默认 100
- `:user_data` — 业务上下文 map,原样写入 `ctx.user_data`,SubAgent 自动继承
- `:messages` — 历史消息列表(`[CMDC.Message.t()]`),按时间顺序传入;
Agent 启动时直接写入 `state.messages`,跳过让 LLM 重读历史的轮次,
适用于 idle 休眠唤醒 / 跨进程恢复
## 历史消息恢复
history = [
CMDC.Message.user("帮我审核这段代码"),
CMDC.Message.assistant("好的,请贴上来", [], [])
]
{:ok, session} = CMDC.create_agent(
model: "anthropic:claude-sonnet-4-5",
messages: history
)
CMDC.prompt(session, "def hello, do: :world")
# → 续接历史,不会让 LLM 重新读 history
返回 `{:ok, session_pid}` 或 `{:error, reason}`。
"""
@spec create_agent(create_opts()) :: {:ok, session()} | {:error, term()}
def create_agent(%CMDC.Options{} = opts) do
opts
|> options_to_session_opts()
|> start_session_with_registry_check()
end
def create_agent(opts) when is_list(opts) do
opts
|> ensure_session_id()
|> start_session_with_registry_check()
end
# v0.5.1:model 是 "registry:profile:model_id" 协议时,在 SessionServer.start_link
# 前**预检** Registry。命中则放行,未命中直接返 {:error, {:registry_profile_missing, name}},
# 避免 Supervisor restart 3 次后才暴露错误。Agent.init/1 内部仍保留同样的解析作为兜底。
defp start_session_with_registry_check(session_opts) do
case preflight_registry_model(session_opts) do
:ok -> SessionServer.start_link(session_opts)
{:error, _} = err -> err
end
end
defp preflight_registry_model(session_opts) do
case Keyword.get(session_opts, :model) do
"registry:" <> rest when is_binary(rest) -> resolve_registry_prefix(rest)
_ -> :ok
end
end
defp resolve_registry_prefix(rest) do
case String.split(rest, ":", parts: 2) do
[name, _model_id] when name != "" -> lookup_registry_profile(name)
_ -> {:error, {:invalid_registry_model, "registry:" <> rest}}
end
end
defp lookup_registry_profile(name) do
case CMDC.Provider.Registry.lookup(name) do
{:ok, _profile} -> :ok
{:error, :not_found} -> {:error, {:registry_profile_missing, name}}
# v0.6+ resolver_fn 失败:把错误暴露到 create_agent 调用方,避免 Supervisor restart 3 次
{:error, {:resolver_failed, _} = err} -> {:error, {:registry_resolver_failed, name, err}}
{:error, :resolver_invalid_return} -> {:error, {:registry_resolver_invalid, name}}
end
end
# ==========================================================================
# prompt/2
# ==========================================================================
@doc """
向 Agent 发送用户 prompt。
- 若 Agent 处于 idle,立即开始处理,返回 `%{queued: false}`
- 若 Agent 正忙,消息入队,处理完当前任务后自动处理,返回 `%{queued: true}`
## 示例
%{queued: false} = CMDC.prompt(session, "帮我写一个 Elixir GenServer")
"""
@spec prompt(session(), String.t()) ::
%{queued: boolean()} | {:error, :invalid_session | :not_alive}
def prompt(session, text) when is_binary(text) do
with_agent(session, &Agent.prompt(&1, text))
end
# ==========================================================================
# collect_reply/2
# ==========================================================================
@doc """
等待并收集 Agent 的最终文本回复。
订阅当前会话的 EventBus,等待 `{:agent_end, messages, _usage}` 事件,
从最后一条 assistant 消息中提取文本内容返回。
## 选项
- `:timeout` — 等待超时时间(毫秒),默认 60_000(60 秒)
## 返回
- `{:ok, text}` — 成功收到回复文本
- `{:error, :timeout}` — 超时
- `{:error, reason}` — Agent 出错或被中止
## 示例
CMDC.prompt(session, "你好")
{:ok, reply} = CMDC.collect_reply(session, timeout: 30_000)
IO.puts(reply)
"""
@spec collect_reply(session(), keyword()) ::
{:ok, String.t()} | {:error, :timeout | :invalid_session | term()}
def collect_reply(session, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 60_000)
with_sid(session, fn sid ->
EventBus.subscribe(sid)
result = await_reply(sid, timeout)
EventBus.unsubscribe(sid)
result
end)
end
# ==========================================================================
# stop/2
# ==========================================================================
@doc """
停止 Agent 会话,清理所有资源。
优雅停止 `SessionServer` 监督树(级联停止 SubAgent.Supervisor 和主 Agent)。
## 选项
- `:reason` — 停止原因,默认 `:normal`
- `:timeout` — 等待停止超时(毫秒),默认 5_000
## 示例
:ok = CMDC.stop(session)
"""
@spec stop(session(), keyword()) :: :ok | {:error, :invalid_session | :not_alive}
def stop(session, opts \\ []) do
reason = Keyword.get(opts, :reason, :normal)
timeout = Keyword.get(opts, :timeout, 5_000)
case normalize_to_pid(session) do
{:ok, pid} -> Supervisor.stop(pid, reason, timeout)
{:error, _} = err -> err
end
end
# ==========================================================================
# subscribe / unsubscribe
# ==========================================================================
@doc """
订阅当前进程接收指定 Agent 会话的所有事件。
订阅后,当前进程将收到 `{:cmdc_event, session_id, event}` 格式的消息。
常见事件:
- `{:stream_chunk, session_id, chunk}` — 流式 token
- `{:tool_start, session_id, tool_name, args}` — 工具开始
- `{:tool_end, session_id, tool_name, result}` — 工具完成
- `{:agent_end, messages, %CMDC.TokenUsage{}}` — 回复完成
- `{:approval_required, session_id, tool_name, args, ref}` — 等待审批
## 选项
- `:since :: non_neg_integer()` — 从该 index 之后的事件开始 replay。
要求 Agent 启动时已开启 ring buffer(`Options.event_buffer_size > 0`)。
用于 WebSocket / Channel 重连补帧,避免断网那一秒丢的 stream_chunk / tool_end 永远丢失。
可通过 `CMDC.EventBus.last_index/1` 获取当前最新 index 作为下次重连的 `:since`。
## 示例
CMDC.subscribe(session)
CMDC.prompt(session, "hello")
receive do
{:cmdc_event, _sid, {:agent_end, _msgs, _usage}} -> IO.puts("done")
end
# 重连补帧(需 Options.event_buffer_size > 0)
CMDC.subscribe(session, since: last_seen_index)
"""
@spec subscribe(session(), keyword()) ::
{:ok, pid()} | {:error, :invalid_session | term()}
def subscribe(session, opts \\ []) do
with_sid(session, &EventBus.subscribe(&1, opts))
end
@doc "取消当前进程对指定 Agent 会话的事件订阅。"
@spec unsubscribe(session()) :: :ok | {:error, :invalid_session}
def unsubscribe(session) do
with_sid(session, &EventBus.unsubscribe/1)
end
# ==========================================================================
# monitor / demonitor
# ==========================================================================
@doc """
登记当前进程对 Agent 的崩溃监控。
与 `Process.monitor/1` 的区别:CMDC.monitor 返回的 `reference()` 是 CMDC 自维护的,
Agent 进程退出时观察者收到**结构化 reason**:
{:cmdc_down, ref, session_id, structured_reason}
## Structured Reason 表
- `:normal` — 正常退出(supervisor shutdown / `CMDC.stop/2` 明确调用)
- `{:exception, exception}` — 未捕获异常(含 RuntimeError / ArgumentError 等)
- `:max_turns_exceeded` — 超出 `Options.max_turns`(预留)
- `:provider_timeout` — Provider.stream 超时(预留)
- `{:plugin_aborted, plugin_name, why}` — Plugin 返回 `:abort` 导致 Agent 终止
(预留;当前 plugin abort 仅将 Agent 回到 idle 不退出进程)
- 其他原子/元组 — 原样透传(未知 reason)
## 示例
ref = CMDC.monitor(session)
receive do
{:cmdc_down, ^ref, sid, :normal} ->
IO.puts("Agent \#{sid} exited normally")
{:cmdc_down, ^ref, sid, {:exception, e}} ->
IO.puts("Agent \#{sid} crashed: \#{Exception.message(e)}")
end
## 与 `Process.monitor/1` 区别
- `Process.monitor` 只给 `{:DOWN, ref, :process, pid, reason}`,reason 是 raw 的
`:shutdown` / `{:shutdown, _}` / 异常元组,需要调用方自行解析
- `CMDC.monitor` 返回 CMDC 自己的 ref,reason 已结构化,可直接做 pattern match
"""
@spec monitor(session()) :: reference() | {:error, :invalid_session | :not_alive}
def monitor(session) do
with_agent(session, &CMDC.Agent.monitor/1)
end
@doc "取消通过 `monitor/1` 登记的 CMDC 崩溃监控。"
@spec demonitor(session(), reference()) :: :ok | {:error, :invalid_session | :not_alive}
def demonitor(session, ref) when is_reference(ref) do
with_agent(session, &CMDC.Agent.demonitor(&1, ref))
end
# ==========================================================================
# abort / approve / reject
# ==========================================================================
@doc """
中止当前 Agent 运行中的任务,Agent 回到 `:idle` 状态。
## 选项
- `:reason :: term()` — 中止原因。如设置则事件为 `{:agent_abort, reason}`,
否则为裸 `:agent_abort`(向后兼容)。
- `:clear_queue :: boolean()` — 是否清空 prompt pending queue,默认 `true`;
清空时为每条被丢弃的 user prompt emit `{:prompt_dropped, text}`。
- `:kill_tools :: :all | :killable | :none` — 工具任务清理策略,默认 `:killable`:
- `:all` — 杀全部 in-flight 工具
- `:killable` — 只杀非 immune 工具(与 Steering 一致)
- `:none` — 不杀,让工具自然完成
每杀一个工具 emit `{:tool_killed, %{name, call_id, reason}}`。
## 状态行为
| 调用时状态 | `:agent_abort` 是否发出 | 工具行为(默认 `:killable`) |
|---|---|---|
| `:idle` | ✅ no-op,但仍发出便于订阅方对账 | — |
| `:running` | ✅ | cancel stream task |
| `:streaming` | ✅ | cancel stream task |
| `:executing_tools` | ✅ | 杀非 immune 工具,immune 工具继续 |
事件保证:所有状态下 `:agent_abort` 在 100ms 内到达订阅方
(BEAM 调度延迟,本地 EventBus 通常 <10ms)。多次 abort 幂等。
"""
@spec abort(session(), keyword()) :: :ok | {:error, :invalid_session | :not_alive}
def abort(session, opts \\ []) do
with_agent(session, &Agent.abort(&1, opts))
end
# ==========================================================================
# switch_model/2 — 运行期切换 LLM 模型
# ==========================================================================
@doc """
运行期切换 LLM 模型。
下一次 LLM 请求立即用新模型,不重启 Agent,**messages / tools / plugin 状态全部保留**。
## 行为
- **idle 状态**:立即更新 `state.model`,emit `{:model_switched, %{from, to}}`,
后续 `prompt/2` 用新模型
- **running / streaming**:当前请求继续用旧模型完成;`state.model` 已更新;
`{:model_switched, ...}` 立即发出;下一个 turn 用新模型
- **executing_tools**:同上,工具继续执行,下一轮 LLM 调用用新模型
- **同模型切换**(new_model == 当前 model):no-op,**不发事件**
## 触发方式
### 方式一:调用方主动切换
:ok = CMDC.switch_model(session, "openai:gpt-4o-mini")
### 方式二:Plugin Action
Plugin 在 `:on_tool_error` / `:after_response` / `:after_tool_batch` 等钩子中返回:
def handle_event({:on_tool_error, _name, _call_id, _err, _attempt}, state, _ctx) do
{:switch_model, "openai:gpt-4o-mini", state}
end
Pipeline 收集后由 Agent 自动执行(无需调用方介入)。
## 兼容性警告
- **system_prompt 兼容性**:不同 Provider 对 system prompt 的支持差异由 Provider 层处理
- **上下文窗口差异**:从 200k 模型切到 8k 模型时,调用方需自行评估是否要先压缩上下文
- **tool_calling schema 差异**:由 Provider 适配(OpenAI / Anthropic / Gemini 各自不同)
## 事件
{:model_switched, %{from: "anthropic:claude-sonnet-4-5", to: "openai:gpt-4o-mini"}}
"""
@spec switch_model(session(), CMDC.Provider.model()) ::
:ok | {:error, :invalid_session | :not_alive}
def switch_model(session, new_model) when is_binary(new_model) do
with_agent(session, &Agent.switch_model(&1, new_model))
end
@doc """
运行期切换 LLM 模型并同步替换 provider 参数(base_url / api_key / timeout 等)。
## 选项
- `:provider_opts :: keyword()` — 与 model 一并替换 provider 参数(base_url / api_key / timeout);
`nil` 或不传则保留现有 provider_opts;
典型场景:从 Anthropic 切到 OpenAI 自建网关,需同步 base_url + api_key
事件 `{:model_switched, %{from, to, provider_opts_changed?}}` 含 `provider_opts_changed?`
字段,便于订阅方判断是否需要刷新连接池。
## 示例
:ok = CMDC.switch_model(session, "openai:gpt-4o-mini",
provider_opts: [base_url: "https://api.custom.com/v1", api_key: "sk-..."])
"""
@spec switch_model(session(), CMDC.Provider.model(), keyword()) ::
:ok | {:error, :invalid_session | :not_alive}
def switch_model(session, new_model, opts)
when is_binary(new_model) and is_list(opts) do
with_agent(session, &Agent.switch_model(&1, new_model, opts))
end
# ==========================================================================
# attach_tool/2 + detach_tool/2 — 运行期挂载/卸载工具
# ==========================================================================
@doc """
运行期挂载新工具。
立即写入 `state.tools`,**下一次** LLM 请求生效(重生成 tools schema)。
In-flight 请求不受影响。
## 返回
- `:ok` — 挂载成功,emit `{:tool_attached, name}`
- `{:error, :already_attached}` — 已存在同名 tool(按 `tool.name()` 比较)
- `{:error, :invalid_tool}` — 模块未实现 `CMDC.Tool` behaviour 必需回调
## 示例
:ok = CMDC.attach_tool(session, MyApp.Tools.GitHubMCP)
# → 下一次 prompt 时 LLM 即可看到该 tool 的 schema 并调用
## 适用场景
- **MCP 热加载**:用户对话中途说"装个 GitHub MCP",无需重启 Agent
- **Skill 进化**:升级一个 Skill 不必重启会话
- **基于上下文动态扩展**:根据 LLM 当前任务挂载相关工具
"""
@spec attach_tool(session(), module()) ::
:ok | {:error, :already_attached | :invalid_tool | :invalid_session | :not_alive}
def attach_tool(session, tool_module) when is_atom(tool_module) do
with_agent(session, &Agent.attach_tool(&1, tool_module))
end
@doc """
运行期卸载工具。
立即从 `state.tools` 移除(按 `tool.name()` 字符串匹配),
**下一次** LLM 请求生效。已 in-flight 的 tool 调用**不受影响**(继续执行完)。
## 返回
- `:ok` — 卸载成功,emit `{:tool_detached, name}`
- `{:error, :not_found}` — 不存在同名 tool
## 示例
:ok = CMDC.detach_tool(session, "shell")
## 注意
若 LLM 在 detach 后仍调用该 tool(已在 streaming 中或下一轮),
Agent 会发 `{:tool_call_unknown, name, call_id}` 事件,
并在工具结果中注入 `{:error, "tool not found"}` 让 LLM 自我纠正。
"""
@spec detach_tool(session(), String.t()) ::
:ok | {:error, :not_found | :invalid_session | :not_alive}
def detach_tool(session, tool_name) when is_binary(tool_name) do
with_agent(session, &Agent.detach_tool(&1, tool_name))
end
@doc """
批量挂载工具(原子操作)。
dry-run 全部 validate 通过才一次性挂载;任何一个失败 → 全回滚。
典型场景:用户启用 MCP Server 时一次性挂上 N 个工具。
返回 `{:ok, [name, ...]}` 或 `{:error, {:validation_failed, [{module, reason}, ...]}}`,
reason: `:invalid_tool | :already_attached`。
emit 单个 `{:tool_attached, name}` × N + 汇总 `{:tools_updated, %{attached, detached: []}}`。
"""
@spec attach_tools(session(), [module()]) ::
{:ok, [String.t()]}
| {:error, {:validation_failed, [{module(), atom()}]}}
| {:error, :invalid_session | :not_alive}
def attach_tools(session, tool_modules) when is_list(tool_modules) do
with_agent(session, &Agent.attach_tools(&1, tool_modules))
end
@doc """
批量卸载工具(原子操作)。
返回 `{:ok, [name, ...]}` 或 `{:error, {:validation_failed, [{name, :not_found}, ...]}}`。
emit 单个 `{:tool_detached, name}` × N + 汇总 `{:tools_updated, %{attached: [], detached}}`。
"""
@spec detach_tools(session(), [String.t()]) ::
{:ok, [String.t()]}
| {:error, {:validation_failed, [{String.t(), atom()}]}}
| {:error, :invalid_session | :not_alive}
def detach_tools(session, tool_names) when is_list(tool_names) do
with_agent(session, &Agent.detach_tools(&1, tool_names))
end
@doc """
替换整张工具表(原子操作)。
自动 diff:保留已存在的(按 `tool.name()`)、attach 新的、detach 缺失的。
全部 validate 失败 → 全回滚。典型场景:MCP Server 重启 / 配置变更。
返回 `{:ok, %{attached: [name, ...], detached: [name, ...]}}` 或
`{:error, {:validation_failed, [{module, :invalid_tool | :duplicate_in_list}, ...]}}`。
emit 单个 `{:tool_attached/detached, name}` × N + 汇总 `{:tools_updated, %{attached, detached}}`。
"""
@spec replace_tools(session(), [module()]) ::
{:ok, %{attached: [String.t()], detached: [String.t()]}}
| {:error, {:validation_failed, [{module(), atom()}]}}
| {:error, :invalid_session | :not_alive}
def replace_tools(session, tool_modules) when is_list(tool_modules) do
with_agent(session, &Agent.replace_tools(&1, tool_modules))
end
# ==========================================================================
# steer/2 — 中段软中断
# ==========================================================================
@doc """
中段软中断(Steering):在 Agent 执行中段追加新指引,**不需要先 abort 再 prompt**。
详见 `guides/cookbook.md` 中的「中段干预(Steering)」配方。
## 行为
- `idle` 状态:等同 `prompt/2`,立刻进入新 turn
- `running` / `streaming` / `executing_tools`:入 `:steering_queue`,下个 turn 间隙合并注入
- `executing_tools` 同时杀掉 killable in-flight tool(白名单豁免见 `Options.interrupt_immune_tools`)
## 返回
- `{:ok, ref}` — 已入队(不等同已生效;生效信号通过 `:steering_applied` 事件广播)
- `{:error, :queue_full}` — Steering queue 已满(默认 3,可由 `Options.max_steering_queue` 配置)
- `{:error, :rejected}` — Plugin 拦截 `:before_steering` 返回 `:abort`
- `{:error, :invalid_text}` — 入参非合法字符串
## 示例
CMDC.subscribe(session)
CMDC.prompt(session, "搜索 Elixir gen_statem 教程,分析每个并整理成文档")
# 一段时间后用户改主意
{:ok, _ref} = CMDC.steer(session, "改成只看官方文档,不要第三方教程")
receive do
{:cmdc_event, _sid, {:steering_applied, %{count: n}}} ->
IO.puts("已注入 \#{n} 条 steering")
end
"""
@spec steer(session(), String.t()) ::
{:ok, reference()}
| {:error, :queue_full | :rejected | :invalid_text | :invalid_session | :not_alive}
def steer(session, text) when is_binary(text) and byte_size(text) > 0 do
ref = make_ref()
case with_agent(session, &Agent.steer(&1, ref, text)) do
:ok -> {:ok, ref}
{:error, _} = error -> error
end
end
def steer(_session, _text), do: {:error, :invalid_text}
# ==========================================================================
# update_plugin_opts/3 — 运行期 Plugin opts 热更(v0.6+)
# ==========================================================================
@doc """
运行期热更 Plugin opts(v0.6+)。
长会话中无需停 Agent 重启即可调整已注册 Plugin 的运行时参数。新 opts 立即入队
`state.pending_plugin_opts`,Agent 进入 `idle` 状态时统一 flush;`running` /
`streaming` / `executing_tools` 期间走 `:postpone` 推迟,到 idle 后由
gen_statem 自动重投递。
## 工作流程
1. cast 立即返回 `:ok`,不阻塞
2. 同一 plugin 多次更新 LWW 覆盖(后写覆盖前写)
3. 进入 idle 后按入队顺序 flush
4. 每条 flush 先走 `:before_plugin_opts_update` Plugin hook
(其他 Plugin 可 `:abort` / `:skip` 拦截,典型用途:SecurityGuard 校验合法性)
5. 通过 hook 后调用 plugin 的 `on_config_update/2`(若实现)或默认替换语义
6. 单条 flush 完成 emit `{:plugin_opts_updated, %{plugin, success?, error, mode}}`
## 默认替换语义(未实现 `on_config_update/2`)
- `new_opts` 是 keyword list 且老 plugin state 是 map → 自动 `Map.merge`
- 其他情况 → `new_opts` 直接替换为新 state
详见 `CMDC.Plugin` moduledoc 的「运行期热更 Plugin opts」段。
## 示例
:ok = CMDC.update_plugin_opts(session, MyApp.Plugins.CostGuard,
max_cost_usd: 10.0, soft_warning_pct: 0.8)
# 订阅结果
receive do
{:cmdc_event, _sid,
{:plugin_opts_updated, %{plugin: MyApp.Plugins.CostGuard, success?: true}}} ->
:ok
end
## 返回
- `:ok` — cast 已入队(不等于已生效;生效信号见 `:plugin_opts_updated` 事件)
- `{:error, :invalid_session | :not_alive}` — 会话不存在或 Agent 已退出
"""
@spec update_plugin_opts(session(), module(), keyword() | term()) ::
:ok | {:error, :invalid_session | :not_alive}
def update_plugin_opts(session, plugin_module, new_opts) when is_atom(plugin_module) do
with_agent(session, &Agent.update_plugin_opts(&1, plugin_module, new_opts))
end
@doc """
批准一个待审批的工具调用(HITL 审批流)。
`approval_id` 来自 `{:approval_required, approval_map}` 事件中的 `approval_map.id`。
## 选项
- `:auto_resume` — boolean,默认 `true`。Agent 处于 idle 时(被 `block_tool` 拦下后回到 idle),
审批通过后自动开新 turn 让 LLM 重试,**无需调用方再 `prompt/2`**。
设为 `false` 保持旧版"只放行不续 turn"行为。
自动续接成功时会广播 `{:agent_resumed, %{trigger: :tool_approved, approval_id: id}}`,
上层可订阅事件确认 Agent 已重新进入 running 状态。
## 示例
receive do
{:cmdc_event, _sid, {:approval_required, %{id: aid}}} ->
:ok = CMDC.approve(session, aid)
end
receive do
{:cmdc_event, _sid, {:agent_resumed, %{approval_id: ^aid}}} ->
IO.puts("Agent 已自动续 turn")
end
关闭自动续(保留旧行为,需要自行 prompt):
CMDC.approve(session, aid, auto_resume: false)
CMDC.prompt(session, "继续刚才被拦截的操作")
"""
@spec approve(session(), String.t(), keyword()) ::
:ok | {:error, :invalid_session | :not_alive}
def approve(session, approval_id, opts \\ []) when is_binary(approval_id) and is_list(opts) do
with_agent(session, &Agent.approve(&1, approval_id, opts))
end
@doc """
拒绝一个待审批的工具调用(HITL 审批流)。
## 选项
- `:auto_resume` — boolean,默认 `false`。reject 通常希望让 Agent 保持 idle 等待新 prompt,
被拒命令的 ToolMessage 已记录为 is_error,下次用户 prompt 时 LLM 自然能感知拒绝结果。
若希望 reject 后 Agent 立刻重新规划(让 LLM 走"被拒后换个方案"分支),传 `auto_resume: true`,
会广播 `{:agent_resumed, %{trigger: :tool_rejected, approval_id: id}}` 并自动续 turn。
## 示例
:ok = CMDC.reject(session, approval_id)
:ok = CMDC.reject(session, approval_id, auto_resume: true)
"""
@spec reject(session(), String.t(), keyword()) ::
:ok | {:error, :invalid_session | :not_alive}
def reject(session, approval_id, opts \\ []) when is_binary(approval_id) and is_list(opts) do
with_agent(session, &Agent.reject(&1, approval_id, opts))
end
# ==========================================================================
# user_respond/3
# ==========================================================================
@doc """
应答 `CMDC.Tool.AskUser` 工具的等待请求(HITL 输入流)。
AskUser 工具会广播 `{:ask_user, session_id, question, options, ref}` 并阻塞等待
`{:user_responded, session_id, ref, response}` 事件。本函数提供与 `approve/3` /
`reject/3` 对称的公开 API,避免上层直接调用 `EventBus.broadcast/2`。
## 参数
- `session` — pid 或 session_id 字符串
- `ref` — `:ask_user` 事件中的 `ref`(字符串)
- `response` — 任意可序列化的应答(字符串、字符串列表、map)
## 示例
receive do
{:cmdc_event, sid, {:ask_user, ^sid, _question, _options, ref}} ->
CMDC.user_respond(sid, ref, "我的回答")
end
"""
@spec user_respond(session(), String.t(), term()) ::
:ok | {:error, :invalid_session | :not_alive}
def user_respond(session, ref, response) when is_binary(ref) do
with_sid(session, fn sid ->
EventBus.broadcast(sid, {:user_responded, sid, ref, response})
# 让前端在统一的 :agent_resumed 监听点感知用户响应
EventBus.broadcast(sid, {:agent_resumed, %{trigger: :user_respond, ref: ref}})
:ok
end)
end
# ==========================================================================
# status / agent_pid / session_id
# ==========================================================================
@doc """
获取 Agent 当前状态快照。
返回:
%{
state: :idle | :running | :streaming | :executing_tools,
session_id: String.t(),
turns: non_neg_integer(),
cost_usd: float(),
uptime_ms: non_neg_integer(),
timestamp_ms: integer()
}
"""
@spec status(session()) :: map() | {:error, :invalid_session | :not_alive}
def status(session) do
with_agent(session, &Agent.status/1)
end
@doc """
获取会话的完整消息列表(按时间顺序,含 system / user / assistant / tool_result)。
适用场景:
- 切换模型前查看历史
- 持久化对话用于断点恢复
- 审计日志
"""
@spec messages(session()) :: [CMDC.Message.t()] | {:error, :invalid_session | :not_alive}
def messages(session) do
with_agent(session, &Agent.messages/1)
end
@doc "返回会话对应的主 Agent pid。"
@spec agent_pid(session()) :: pid() | nil | {:error, :invalid_session | :not_alive}
def agent_pid(session) do
with_agent(session, & &1)
end
@doc """
返回会话的 session_id 字符串。
字符串入参直接返回(不查注册表,便于纯 string 流水线)。
pid 入参时会查询 Agent 状态获取 session_id。
"""
@spec session_id(session()) :: String.t() | {:error, :invalid_session | :not_alive}
def session_id(session) when is_binary(session), do: session
def session_id(session) when is_pid(session) do
with_agent(session, &(&1 |> Agent.status() |> Map.fetch!(:session_id)))
end
def session_id(_other), do: {:error, :invalid_session}
# ==========================================================================
# checkpoint! / resume_session! — 会话快照 + 跨进程恢复(v0.5)
# ==========================================================================
@doc """
抓取当前会话的状态快照并持久化到 backend。
适用场景:BEAM 节点滚动重启、跨设备恢复、长会话定期存档、HITL 审批前留底。
返回的 `Snapshot.t()` 含 `checkpoint_id` / `saved_at`,可后续传给 `resume_session!/2`。
## 选项
- `:backend` — 覆盖默认 backend(`Application.get_env(:cmdc, :checkpoint_backend, ETS)`);
生产环境推荐用 `cmdc_memory_pg` 子库提供的 `CMDCMemoryPg.CheckpointBackend`
- `:label` — 可选标签(如 `"before_compact"`),便于回溯
- `:metadata` — 用户附加 map(如 `%{trigger: :manual, actor: "alice"}`)
## 序列化策略
快照只保留 **可重建的核心状态**:
- **保留**:`session_id` / `model` / `working_dir` / `messages`(正序化)/
`tools` / `plugins`(模块名)/ `disabled_tools` / `subagents` / `user_data` /
`prompt_mode` / `blueprint_system_prompt` / `todos` / `memory_contents` /
`dynamic_context_sections` / `steering_queue` / `token_usage` / `turn_count` / `cost_usd`
- **丢弃**:`plugin_states`(resume 后由 plugin `:session_start` hook 重建)/
`pending_tool_tasks` / `stream_task_pid` / `streaming_resp` / `monitors` /
`turn_start_marker` / 流式累积字段(`current_text` / `current_tool_calls` / 等)
这一策略避免序列化死引用 + 强制 plugin 走 `:session_start` 重建,
与 `CMDC.Checkpoint.Snapshot` 模块文档一致。
## 示例
{:ok, snap} = CMDC.checkpoint!(session, label: "after_plan_approved")
:ok = save_to_db(snap.checkpoint_id, snap)
# 配合 redact helper 写前去敏
{:ok, snap} = CMDC.checkpoint!(session)
sanitized = CMDC.Checkpoint.Snapshot.redact(snap, &MyApp.Vault.encrypt/1)
## 错误返回
- `{:error, :invalid_session}` — session_id 字符串查不到对应进程
- `{:error, :not_alive}` — pid 已退出
- `{:error, term()}` — backend `save/3` 返回的错误(如 PG 连接断)
"""
@spec checkpoint!(session(), keyword()) ::
{:ok, Snapshot.t()} | {:error, :invalid_session | :not_alive | term()}
def checkpoint!(session, opts \\ []) when is_list(opts) do
with_agent(session, fn agent_pid ->
state = Agent.get_state(agent_pid)
serializable = state_to_serializable(state)
Checkpoint.save(state.session_id, serializable, opts)
end)
end
@doc """
从 Snapshot 重建一个 Agent 会话,messages / tools / plugins / user_data 等全部还原。
## 行为
- 启动新的 `SessionServer` 监督树(session_id 默认沿用 snapshot,可通过
`:session_id` 选项覆盖以避免 Registry 冲突)
- **messages** 正序灌入(Agent.init 内部 reverse 成头新顺序)
- **plugins** 从模块名列表重建,初始 plugin_state 走每个 plugin 的 `init/1` 默认值;
plugin 通过订阅自动触发的 `:session_start` hook 恢复需要的状态
- **运行期字段**(streaming / pending tools / monitors / turn_start_marker 等)
全部由 Agent.init 重新初始化
## 选项
允许覆盖 snapshot 中的字段(典型用途:换 working_dir、换 provider_opts 接生产 LLM 等):
- `:session_id` — 默认沿用 snapshot.session_id;若同名 session 已运行需指定新 ID
- `:provider_opts` — 重连 LLM Provider 时传新 api_key / base_url
- `:hibernate_after_ms` — 新增/覆盖 hibernate 配置
- 其他任意 `CMDC.Options` 字段
返回 `{:ok, pid()}`(与 `create_agent/1` 一致),调用方可直接传给 `prompt/2` 续接对话。
## 示例
# 跨 BEAM 节点恢复
{:ok, snap} = CMDC.Checkpoint.load("sess-prod-001")
{:ok, session} = CMDC.resume_session!(snap, provider_opts: [api_key: System.get_env("API_KEY")])
CMDC.prompt(session, "继续刚才的话题")
# 同进程内 fork 一个新会话(不同 session_id)
{:ok, snap} = CMDC.checkpoint!(running_session)
{:ok, fork} = CMDC.resume_session!(snap, session_id: "sess-fork-1")
## 错误返回
- `{:error, {:already_started, pid}}` — session_id 已运行
- `{:error, :missing_model}` — snapshot.state 没有 `:model` 字段(损坏的快照)
- 其他 `create_agent/1` 可能返回的错误
"""
@spec resume_session!(Snapshot.t(), keyword()) ::
{:ok, session()} | {:error, term()}
def resume_session!(%Snapshot{} = snap, extra_opts \\ []) when is_list(extra_opts) do
case snapshot_to_start_opts(snap) do
{:ok, base_opts} ->
base_opts
|> Keyword.merge(extra_opts)
|> create_agent()
{:error, _} = err ->
err
end
end
# ==========================================================================
# 私有辅助 — Snapshot ↔ Agent.State 转换
# ==========================================================================
# 运行期字段(plugin / stream / monitor / tool task 等)在 resume 时由 Agent.init
# 重建,不进 Snapshot;与 CMDC.Checkpoint.Snapshot moduledoc 描述的"不可序列化字段策略"对齐。
@resume_dropped_state_fields [
:pending_tool_tasks,
:stream_task_pid,
:streaming_resp,
:monitors,
:turn_start_marker,
:tag_buffers,
:tool_call_arg_buffers,
:current_text,
:current_tool_calls,
:current_thinking,
:message_started,
:last_chunk_at,
:stream_errored,
:stall_count,
:plugin_states,
:exit_reason
]
defp state_to_serializable(%CMDC.Agent.State{} = state) do
# State.messages 内部头新(逆序),Snapshot / resume 用正序,
# 这样和 Options.messages / Agent.init 的输入语义一致。
ordered_messages = Enum.reverse(state.messages)
# plugins 字段在 State 中是 [{module, plugin_state}],
# resume 时只需要 [module] / [{module, opts}] 形式重新 init。
plugin_specs =
Enum.map(state.plugins, fn
{mod, _ps} when is_atom(mod) -> mod
mod when is_atom(mod) -> mod
end)
state
|> Map.from_struct()
|> Map.put(:messages, ordered_messages)
|> Map.put(:plugins, plugin_specs)
|> Map.drop(@resume_dropped_state_fields)
end
defp snapshot_to_start_opts(%Snapshot{state: state, session_id: sid}) when is_map(state) do
case Map.get(state, :model) do
nil ->
{:error, :missing_model}
model when is_binary(model) ->
{:ok,
[
session_id: sid,
model: model,
working_dir: Map.get(state, :working_dir, "."),
tools: Map.get(state, :tools, []),
plugins: Map.get(state, :plugins, []),
disabled_tools: Map.get(state, :disabled_tools, []),
subagents: Map.get(state, :subagents, []),
user_data: Map.get(state, :user_data, %{}),
messages: Map.get(state, :messages, []),
prompt_mode: Map.get(state, :prompt_mode, :full),
system_prompt: Map.get(state, :blueprint_system_prompt, "")
]
|> Enum.reject(fn {_k, v} -> v in [nil, "", []] end)
|> Keyword.put(:session_id, sid)
|> Keyword.put(:model, model)
|> Keyword.put(:messages, Map.get(state, :messages, []))}
end
end
defp snapshot_to_start_opts(%Snapshot{}), do: {:error, :invalid_snapshot}
# ==========================================================================
# 私有辅助 — session 入参归一化(pid 与 session_id 字符串都接受)
# ==========================================================================
# 所有 normalize/with_* 内部 helper 返回 `{:ok, _} | {:error, _}`
# 风格,不再 raise / exit 调用方。Facade API 通过 `with_agent/2` / `with_sid/2`
# 把内部错误透传为标准 `{:error, :invalid_session | :not_alive}`。
#
# 成功路径的返回值保持 :ok / map / Message struct 等不变;
# 失败路径才返回 `{:error, _}`——老 `case ... do :ok -> ... ; {:error, _} -> ... end`
# 模式继续工作;旧 `:ok = CMDC.xxx(invalid_sid)` 模式会 MatchError,但 invalid
# session 本来就是程序错误。
@typedoc "normalize_to_pid 返回类型。"
@type pid_lookup :: {:ok, pid()} | {:error, :invalid_session | :not_alive}
@spec normalize_to_pid(session()) :: pid_lookup()
defp normalize_to_pid(session) when is_pid(session) do
if Process.alive?(session), do: {:ok, session}, else: {:error, :not_alive}
end
defp normalize_to_pid(session_id) when is_binary(session_id) do
case SessionServer.whereis(session_id) do
nil -> {:error, :invalid_session}
pid when is_pid(pid) -> {:ok, pid}
end
end
defp normalize_to_pid(_other), do: {:error, :invalid_session}
@spec normalize_to_session_id(session()) :: {:ok, String.t()} | {:error, atom()}
defp normalize_to_session_id(session_id) when is_binary(session_id), do: {:ok, session_id}
defp normalize_to_session_id(session) when is_pid(session) do
with {:ok, pid} <- normalize_to_pid(session) do
try do
sid = pid |> SessionServer.agent() |> Agent.status() |> Map.fetch!(:session_id)
{:ok, sid}
catch
:exit, _ -> {:error, :not_alive}
end
end
end
defp normalize_to_session_id(_other), do: {:error, :invalid_session}
# 包装 Agent.* 调用:自动 normalize + 透传 invalid_session/not_alive。
# 成功 → 调 fun,返回 fun 结果(不强行包成 ok-tuple);
# 失败 → 立刻返回 `{:error, _}`。
defp with_agent(session, fun) when is_function(fun, 1) do
case normalize_to_pid(session) do
{:ok, pid} ->
try do
fun.(SessionServer.agent(pid))
catch
:exit, {:noproc, _} -> {:error, :not_alive}
:exit, {:normal, _} -> {:error, :not_alive}
end
{:error, _} = err ->
err
end
end
defp with_sid(session, fun) when is_function(fun, 1) do
case normalize_to_session_id(session) do
{:ok, sid} -> fun.(sid)
{:error, _} = err -> err
end
end
# ==========================================================================
# 私有辅助
# ==========================================================================
defp ensure_session_id(opts) do
if Keyword.has_key?(opts, :session_id) do
opts
else
Keyword.put(opts, :session_id, generate_session_id())
end
end
defp generate_session_id do
:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
end
defp options_to_session_opts(%CMDC.Options{} = opts) do
opts
|> Map.from_struct()
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Keyword.new()
|> ensure_session_id()
end
defp await_reply(session_id, timeout) do
receive do
{:cmdc_event, ^session_id, {:agent_end, messages, _token_usage}} ->
reply_text = extract_last_assistant(messages)
{:ok, reply_text}
{:cmdc_event, ^session_id, {:agent_abort, reason}} ->
{:error, {:aborted, reason}}
{:cmdc_event, ^session_id, {:error, _sid, reason}} ->
{:error, reason}
after
timeout -> {:error, :timeout}
end
end
defp extract_last_assistant(messages) do
messages
|> Enum.filter(&(&1.role == :assistant))
|> List.last()
|> case do
nil -> ""
msg -> msg.content || ""
end
end
end