# CMDC — AI Quick Reference (llm.txt)
# Elixir Agent Kernel v0.2.1 | OTP gen_statem | Hex: {:cmdc, "~> 0.2"}
# Full reference: llms-full.txt | HexDocs: https://hexdocs.pm/cmdc
## Facade API — CMDC module
# Create agent session (returns {:ok, pid} | {:error, term})
{:ok, session} = CMDC.create_agent(
model: "anthropic:claude-sonnet-4-5", # required — "provider:model_id"
tools: [CMDC.Tool.ReadFile, CMDC.Tool.Shell], # optional — Tool modules
plugins: [CMDC.Plugin.Builtin.SecurityGuard], # optional — Plugin modules or {Module, opts}
system_prompt: "You are a coding assistant.", # optional — prepended after BasePrompt
working_dir: "/my/project", # optional — default "."
provider_opts: [api_key: "sk-..."], # optional — passed to req_llm
max_turns: 50, # optional — default 100
skills_dirs: ["./skills"], # optional — SKILL.md discovery paths
memory: ["AGENTS.md"], # optional — memory file paths
sandbox: nil, # optional — Sandbox module, nil = local OS
subagents: [], # optional — [SubAgent.t()]
max_tokens: nil, # optional — nil = provider default
user_data: %{tenant_id: "t-1"}, # optional — verbatim into ctx.user_data, SubAgent inherits
messages: [] # optional — [%CMDC.Message{}], inject prior history at boot
)
# Also accepts %CMDC.Options{} struct or blueprint:
{:ok, session} = CMDC.create_agent(blueprint: MyApp.CodingAgent)
CMDC.prompt(session, "Help me refactor this module")
# => %{queued: false} (false = immediate, true = queued while busy)
{:ok, reply} = CMDC.collect_reply(session, timeout: 60_000)
# => {:ok, "Here's the refactored code..."} | {:error, :timeout} | {:error, reason}
CMDC.stop(session) # graceful shutdown
CMDC.abort(session) # abort running task → idle
CMDC.abort(session, reason: :user_cancelled) # v0.2 RFC B6: 事件 {:agent_abort, :user_cancelled}
CMDC.abort(session, kill_tools: :all) # v0.2 RFC B6: brutal_kill 全部工具(含 immune)
CMDC.abort(session, kill_tools: :none) # v0.2 RFC B6: 不杀工具,让其自然完成
CMDC.abort(session, clear_queue: false) # v0.2 RFC B6: 保留 pending prompt 队列
# 事件保证::agent_abort 100ms 内到达订阅方;
# 默认 clear_queue: true 时,每条被丢弃的 user prompt 发 {:prompt_dropped, text};
# kill_tools 模式下,每杀一个工具发 {:tool_killed, %{name, call_id, reason}}。
CMDC.status(session)
# => %{
# state: :idle | :running | :streaming | :executing_tools,
# session_id: "...",
# model: "anthropic:claude-sonnet-4-5", # v0.2 RFC C8 — 当前 LLM 模型
# turns: 3, tool_calls: 7, total_tokens: 12_345,
# cost_usd: 0.0123, # v0.2 RFC B5
# token_usage: %CMDC.TokenUsage{ # v0.2 RFC B5 — struct
# prompt_tokens: 8_000, completion_tokens: 4_345, total_tokens: 12_345,
# cost_usd: 0.0123, cached_tokens: 1_200
# },
# uptime_ms: 4_321, timestamp_ms: 1_700_000_000_000,
# pending_tools: [%{name, call_id, args, started_at_ms}, ...], # v0.2 RFC C11 + v0.3 RFC 11G #A15
# pending_approvals: [%{id, tool, args, requested_at, ...}], # v0.2 RFC C11 — 等审批工具
# queues: %{prompt_queue: 0, steering_queue: 0} # v0.2 RFC C11 — 内部队列长度
# }
CMDC.messages(session) # v0.2 — [%CMDC.Message{}] 完整对话历史
# 运行期切换 LLM 模型(v0.2 RFC C8 + v0.3 RFC 11G #A17)— 不重启 Agent,messages/tools/plugin 全保留
CMDC.switch_model(session, "openai:gpt-4o-mini")
# - idle: 立即生效,下次 prompt 用新 model
# - running/streaming/executing_tools: 当前轮跑完再切,下一轮用新 model
# - 同 model + 无 provider_opts: no-op,不发事件
# - 事件: {:model_switched, %{from: old, to: new, provider_opts_changed?: false}}
# - Plugin Action 等价: {:switch_model, "openai:gpt-4o-mini", state}
# 多个 plugin 同时 switch_model,priority 最大者(后执行)的值生效
# 同时切 provider 参数(v0.3 RFC 11G #A17 — 多 provider 容灾)
CMDC.switch_model(session, "openai:gpt-4o-mini",
provider_opts: [base_url: "https://api.fallback.com/v1", api_key: "sk-...", timeout_ms: 90_000]
)
# - 事件: {:model_switched, %{..., provider_opts_changed?: true}}
# - state.config.provider_opts 立即被替换
# - Plugin Action 4 元组形态: {:switch_model, "openai:gpt-4o-mini", state, provider_opts: [...]}
# 运行期挂载/卸载工具(v0.2 RFC C9)— 不重启 Agent,下次 LLM 请求生效
:ok = CMDC.attach_tool(session, MyApp.Tools.GitHubMCP)
# {:error, :already_attached} | {:error, :invalid_tool}
# 事件: {:tool_attached, name}
:ok = CMDC.detach_tool(session, "shell")
# {:error, :not_found}
# 事件: {:tool_detached, name}
# 注意: 已 in-flight 的 tool 调用继续跑完,不被取消;
# 若 LLM 在 detach 后仍调用该 tool,emit {:tool_call_unknown, name, call_id}
# 并自动注入 error tool_result 让 LLM 自我纠正
# EventBus ring buffer 与重连补帧(v0.2 RFC C10)— 默认关闭
{:ok, session} = CMDC.create_agent(model: "...", event_buffer_size: 100)
# 启动时设置 event_buffer_size > 0 开启 per-session 缓存(典型 50-200)
# 默认 0:关闭,无任何内存开销,行为同 v0.1
# 断线重连补帧
{:ok, _} = CMDC.subscribe(session, since: last_seen_index)
# replay last_seen_index < idx <= now 之间的所有事件
# 早于 buffer 起点的部分已被 FIFO 丢弃(无法 replay)
# 工具 API
last_idx = CMDC.EventBus.last_index(session_id) # 获取当前最新 index
size = CMDC.EventBus.buffer_size(session_id) # 当前缓存事件数
# 结构化崩溃监控(v0.2 RFC C12)— 替代 Process.monitor,拿到结构化 reason
ref = CMDC.monitor(session) # 接受 pid | session_id string
# Agent 退出时当前进程收到:
# {:cmdc_down, ref, session_id, structured_reason}
# structured_reason:
# :normal | :shutdown | {:shutdown, _} → 归一为 :normal
# %RuntimeError{} | {%RuntimeError{}, stacktrace} → {:exception, exception}
# :max_turns_exceeded | :provider_timeout | {:plugin_aborted, name, why} (v0.2+ 预留)
# 其他 → 原样透传
CMDC.demonitor(session, ref) # 取消监控
# PromptMode — SubAgent / 长会话 system prompt 降本(v0.2 Phase 10B)
{:ok, session} = CMDC.create_agent(model: "...", prompt_mode: :full)
# 可选::full(默认,主 Agent)| :task(SubAgent 默认,省 ~50%)
# :minimal(仅工具名列表,省 ~90%)| :none(完全由 system_prompt 接管)
#
# SubAgent 默认 :task,Tool.Task 派发时自动透传
CMDC.SubAgent.new!(name: "router", prompt_mode: :minimal) # 快速分类子代理
# 四模式注入矩阵(true/-)
# mode user_sp | BasePrompt | Identity | tools列表 | Skills | Memory
# :full ✓ | 完整基座 | ✓ | name+desc | ✓ | ✓
# :task ✓ | 精简(~150字节)| ✓ | name+desc | - | -
# :minimal ✓ | - | ✓ | 仅 name (no desc) | - | -
# :none ✓ | - | - | - | - | -
# MemoryFlush — 压缩前提取关键事实,持久化到 MEMORY.md(v0.2 Phase 10C)
{:ok, session} = CMDC.create_agent(
model: "...",
plugins: [
CMDC.Plugin.Builtin.MemoryLoader, # session_start 加载 MEMORY.md / AGENTS.md
CMDC.Plugin.Builtin.MemoryFlush # :before_compact 写入 MEMORY.md
],
working_dir: "/project/path"
)
# 长会话 → 自动 compact → MemoryFlush 写 working_dir/MEMORY.md → 下次会话 MemoryLoader 读回
# 可覆盖 extract_fn 用真实 LLM 提取;默认启发式规则(纯离线,便于单测)
#
# RFC D14 — MemoryFlush emit :plugin_event 统一事件(便于集成方订阅入库)
CMDC.subscribe(session)
# 收到 {:cmdc_event, sid, {:plugin_event, %{kind: :memory_flush, facts, count,
# session_id, occurred_at, file, v: 1}}}
# ModelRouter — 按业务规则在 before_request 切换模型(v0.2 Phase 10D)
{:ok, session} = CMDC.create_agent(
model: "anthropic:claude-sonnet-4-5",
user_data: %{user_tier: :free, task_complexity: :complex, token_budget: 100_000},
plugins: [
{CMDC.Plugin.Builtin.ModelRouter,
default_model: "anthropic:claude-sonnet-4-5",
rules: [
# v0.1 三条(运行时)
%{condition: {:turn_gt, 20}, model: "openai:gpt-4.1-mini"},
%{condition: {:cost_gt, 0.5}, model: "openai:gpt-4.1-mini"},
%{condition: {:tokens_gt, 50_000}, model: "openai:gpt-4.1-mini"},
# v0.2 四条新条件(业务层面)
%{condition: {:token_budget_lt, 5_000}, model: "openai:gpt-4.1-mini"},
%{condition: {:task_complexity, :complex}, model: "anthropic:claude-opus-4"},
%{condition: {:task_complexity_in, [:simple]}, model: "openai:gpt-4.1-mini"},
%{condition: {:time_of_day_in, [22..23, 0..6]}, model: "openai:gpt-4.1-mini"},
%{condition: {:user_tier, :free}, model: "openai:gpt-4.1-mini"},
%{condition: {:user_tier_in, [:enterprise]}, model: "anthropic:claude-opus-4"},
%{condition: {:user_data, :region, "eu-west"}, model: "mistral:mistral-large"},
%{condition: {:user_data, :priority, :gt, 5}, model: "anthropic:claude-opus-4"}
]
}
]
)
# 命中首条规则 → emit {:switch_model, new_model, state} action,下一次 LLM
# 请求用新模型,EventBus 广播 :model_switched(from/to/reason=:model_router)
CMDC.subscribe(session) # receive {:cmdc_event, session_id, event_tuple}
CMDC.unsubscribe(session)
CMDC.approve(session, approval_id) # HITL approve, auto_resume: true (default)
CMDC.approve(session, approval_id, auto_resume: false) # 旧版:只放行不续 turn
CMDC.reject(session, approval_id) # HITL reject, auto_resume: false (default)
CMDC.reject(session, approval_id, auto_resume: true) # 拒绝后立即续 turn 让 LLM 改方案
CMDC.user_respond(session, ref, response) # 应答 AskUser 工具(与 approve/reject 对称)
# session 入参类型 — v0.2 起所有 API 同时接受 pid 与 session_id 字符串
# @type session :: pid() | String.t()
# 字符串通过 CMDC.SessionRegistry(:via 注册)反查 pid;不存在 raise ArgumentError
CMDC.prompt("my-sid", "hello") # ✓ string 入参直接工作
CMDC.status("my-sid") # ✓ 反查 pid 后调用
CMDC.subscribe("not-yet-started") # ✓ 提前订阅合理场景,不 raise
CMDC.user_respond("my-sid", ref, "ans") # ✓ 直接 broadcast,不查注册表
SessionServer.whereis("my-sid") # => pid | nil(不抛异常)
# Steering — mid-execution soft interrupt (do NOT abort + re-prompt; use this)
{:ok, ref} = CMDC.steer(session, "改方向:只看官方文档")
# => {:ok, reference()} # queued; effect signaled by :steering_applied event
# => {:error, :queue_full} # steering_queue 满(默认 max=3)
# => {:error, :rejected} # plugin abort before_steering
# => {:error, :invalid_text} # 入参非合法字符串
# 状态行为:
# idle → 等同 prompt/2,立刻进入新 turn(仅 emit :steering_applied)
# running/streaming → 入 queue,下个 turn 间隙合并注入
# executing_tools → 入 queue,下次 collect_result 时杀掉非 immune 工具,全清空后注入
## Options — CMDC.Options
opts = CMDC.Options.new!(model: "openai:gpt-4o", tools: [CMDC.Tool.Shell])
# @enforce_keys [:model]
# Fields: model, tools, system_prompt, plugins, subagents, skills_dirs,
# memory, sandbox, response_format, provider_opts, max_turns,
# max_tokens, working_dir, max_steering_queue, interrupt_immune_tools,
# user_data, messages
# user_data: arbitrary map() (or keyword, normalized to map) → ctx.user_data
# SubAgent inherits unless its own :user_data is non-nil (then full replace).
# messages: [%CMDC.Message{}] — prior conversation history, injected directly into
# state.messages at Agent boot. Skips one full LLM "re-read" round-trip
# for idle-resume / cross-process restore. Default []. system_prompt is
# still composed by SystemPrompt independently — do NOT put :system in here.
CMDC.Options.new(keyword) # => {:ok, t} | {:error, ValidationError}
CMDC.Options.merge(opts, overrides_keyword) # => new Options
## Events — CMDC.Event (broadcast as {:cmdc_event, session_id, event})
# Lifecycle: :agent_start | {:agent_end, messages, %CMDC.TokenUsage{}} | {:agent_abort, reason} | :agent_abort
# ↑ v0.2 RFC B5: token_usage is now a struct with prompt/completion/total_tokens + cost_usd + cached_tokens
# v0.1.x compat: still a map; pattern-match on Map.get/2 if you need to support both
# Prompt: {:prompt_received, text} | {:prompt_queued, text} | {:prompt_rejected, reason}
# Stream: :message_start | {:message_delta, %{delta: text}} | {:response_complete, %Message{}}
# :thinking_start | {:thinking_delta, %{delta: text}}
# {:status_update, text} | {:title_generated, title}
# Request: {:request_start, %{model: m, messages: n}} | {:stream_error, reason}
# {:stream_stalled, elapsed_s} | {:retry, attempt, delay_ms, reason} | {:context_overflow, r}
# Tool: {:tool_calls, count}
# {:tool_execution_start, name, call_id, args}
# {:tool_execution_end, name, call_id, {:ok, text}|{:error, text}}
# {:tool_execution_metrics, name, call_id, %{started_at_ms[, ended_at_ms, duration_ms]}}
# # v0.3 RFC 11G #A15
# # start 时只含 started_at_ms,
# # end 时含完整 3 字段
# {:tool_blocked, name, call_id, reason} | {:loop_detected, %{type: atom()}}
# HITL: {:approval_required, approval_map} | {:approval_resolved, approval_map}
# approval_map: %{id, tool, args, session_id, hint, requested_at, status(resolved only)}
# {:agent_resumed, %{trigger: :tool_approved|:tool_rejected|:tool_approval_timeout, approval_id}}
# ↑ broadcast when approve/reject auto_resume kicks in (Agent was idle → re-enters running)
# Ask: {:ask_user, sid, question, options, ref} | {:user_responded, sid, ref, response}
# Plan: {:todo_change, sid, todos}
# Compact: {:compact_start, sid} | {:compact_end, sid, removed_count}
# SubAgent: {:subagent_start, sid, child_sid, desc} | {:subagent_end, sid, child_sid, result}
# {:sub_agent_event, call_id, child_sid, event}
# Plugin: {:plugin_event, name, payload}
# Steering: {:steering_received, %{ref, text, queued_at, status: :queued|:rejected_full|:rejected_by_plugin}}
# {:steering_applied, %{refs: [ref], count: pos_integer()}}
# {:tool_skipped_for_steering, %{name, call_id, reason: :killed_by_steering | :pending_dispatch}}
# Other: {:intervention, prompt} | {:stop_blocked, prompt} | {:error, sid, reason}
## EventBus — CMDC.EventBus
EventBus.subscribe(session_id) # => {:ok, pid}
EventBus.subscribe_all() # all sessions
EventBus.broadcast(session_id, event)
EventBus.unsubscribe(session_id)
## TokenUsage — CMDC.TokenUsage (v0.2 RFC B5, @derive Jason.Encoder)
# %CMDC.TokenUsage{
# prompt_tokens: non_neg_integer(), # OpenAI input / Anthropic input_tokens
# completion_tokens: non_neg_integer(), # OpenAI output / Anthropic output_tokens
# total_tokens: non_neg_integer(), # 多数 Provider 自带,缺失时 prompt+completion 补齐
# cost_usd: float() | nil, # Provider 配定价时计算;否则 nil
# cached_tokens: non_neg_integer() | nil # Anthropic cache_read_input_tokens
# }
#
TokenUsage.new(prompt_tokens: 100, completion_tokens: 50)
# %CMDC.TokenUsage{prompt_tokens: 100, completion_tokens: 50, total_tokens: 150, ...}
TokenUsage.from_raw(%{input_tokens: 80, output_tokens: 40}) # Anthropic 风格
TokenUsage.from_raw(%{prompt_token_count: 200, candidates_token_count: 100}) # Google
TokenUsage.add(usage_a, usage_b) # 多 Provider 累加
# 内部 State.token_usage 仍是扩展 map(含 :context_window / :current_context_tokens 等
# 上下文窗口元数据,不进 struct);事件 / status 边界用 from_state_token_map/3 转 struct。
## Message — CMDC.Message (@derive Jason.Encoder)
Message.system("You are helpful.")
Message.user("Hello")
Message.assistant("Hi!", [%{call_id: "c1", name: "read_file", arguments: %{"path" => "a.ex"}}])
Message.tool_result("c1", "file contents...", false) # call_id, output, is_error
# Fields: id, parent_id, role (:system|:user|:assistant|:tool_result),
# content, thinking, tool_calls, call_id, name, is_error, metadata
## Context — CMDC.Context (passed to Tool.execute/2 and Plugin.handle_event/3)
# @enforce_keys [:session_id, :working_dir, :model]
# Fields: session_id, working_dir, model, sandbox, tools, subagents,
# config, todos, memory_contents, user_data, turn, total_tokens, cost_usd,
# last_assistant_reply # v0.3 — 最后一条 assistant message 的文本,Plugin/Tool 直接读取无需翻 messages
#
# user_data — arbitrary business context map injected from Options.user_data;
# use directly in pattern match: execute(args, %Context{user_data: %{tenant_id: t}})
## Plan — CMDC.Plan (v0.3 ADP Ch6, @derive Jason.Encoder)
# Plan struct 字段:
# goal :: String.t()
# steps :: [%CMDC.Plan.Step{}]
# status :: :draft | :approved | :in_progress | :completed
# created_at, approved_at, metadata :: map()
#
# Step struct 字段:
# id :: String.t() # "step-1", "step-2" ...
# description :: String.t()
# status :: :pending | :in_progress | :completed | :failed | :skipped
# started_at, finished_at, result, notes
#
# 创建/解析
CMDC.Plan.new(goal, step_descriptions) # => %Plan{status: :draft}
CMDC.Plan.from_markdown(markdown, goal) # 解析 "- [ ] ..." checklist
# 状态迁移(railway:{:ok, plan} | {:error, :not_found})
CMDC.Plan.step_started(plan, step_id)
CMDC.Plan.step_completed(plan, step_id, result)
CMDC.Plan.step_failed(plan, step_id, reason)
CMDC.Plan.step_skipped(plan, step_id, reason)
CMDC.Plan.annotate_step(plan, step_id, notes)
CMDC.Plan.approve(plan) # status → :approved
# Replan(ADP Ch6 动态调整)
CMDC.Plan.replace_steps(plan, ["new1", "new2"]) # 全量重规划,重新编号 step-1…
CMDC.Plan.add_step(plan, "追加的步骤") # 末尾 append,自动 step-N+1
CMDC.Plan.insert_step(plan, "step-2", "新步骤") # 默认在 step-2 之后;opts before: true 在之前
CMDC.Plan.remove_step(plan, "step-3")
# 查询/渲染
CMDC.Plan.get_step(plan, step_id)
CMDC.Plan.current_step(plan) # 第一个 :pending / :in_progress 步骤
CMDC.Plan.progress(plan) # %{total, completed, failed, skipped, pending, in_progress, pct}
CMDC.Plan.all_finished?(plan) / all_completed?(plan)
CMDC.Plan.to_markdown(plan) # "## Goal\n...\n- [x] step-1 ..."
CMDC.Plan.to_prompt_section(plan) # "# Plan\n...\n# Progress\n..." 注入 system prompt
## Built-in Tools (11)
# All tools implement @behaviour CMDC.Tool
# Callbacks: name/0, description/0, parameters/0, execute/2
# Result: {:ok, String.t()} | {:error, String.t()} | {:effect, term()}
CMDC.Tool.ReadFile # read_file — path (req), offset, limit
CMDC.Tool.WriteFile # write_file — path (req), content (req)
CMDC.Tool.EditFile # edit_file — path (req), old_string (req), new_string (req)
CMDC.Tool.Shell # shell — command (req), timeout
CMDC.Tool.Grep # grep — pattern (req), path, include, context_lines, max_results
CMDC.Tool.ListDir # list_dir — path
CMDC.Tool.Glob # glob — pattern (req), path
CMDC.Tool.Task # task — description (req), subagent_type
CMDC.Tool.WriteTodos # write_todos — todos (req) [%{id, content, status}]
CMDC.Tool.AskUser # ask_user — question (req), options
CMDC.Tool.CompactConversation # compact_conversation — reason
## Built-in Plugins (8)
# All plugins implement @behaviour CMDC.Plugin
# Callbacks: init/1, priority/0, handle_event/3
# Actions (8): {:continue, state} | {:intervene, prompt, state} | {:abort, reason, state}
# {:skip, state} | {:block_tool, reason, state}
# {:replace_tool_args, new_args, state} | {:emit, {type, data}, state}
# {:switch_model, model_string, state} # v0.2 RFC C8
# {:switch_model, model_string, state, provider_opts: [...]} # v0.3 RFC 11G #A17
CMDC.Plugin.Builtin.SecurityGuard # priority 10 — blocks dangerous paths/commands
CMDC.Plugin.Builtin.EventLogger # priority 50 — JSON Lines audit log
CMDC.Plugin.Builtin.HumanApproval # priority 15 — HITL before_tool approval
CMDC.Plugin.Builtin.MemoryLoader # priority 100 — loads AGENTS.md into system prompt
CMDC.Plugin.Builtin.PatchToolCalls # priority 120 — patches dangling tool_calls
CMDC.Plugin.Builtin.PromptCache # priority 130 — Anthropic prompt caching
CMDC.Plugin.Builtin.Planning # priority 200 — v0.3 ADP Ch6: plan_first + dynamic plan injection
CMDC.Plugin.Builtin.Reflection # priority 400 — v0.3 ADP Ch4+Ch7: Producer-Reviewer 自评/SubAgent
# --- v0.3 Planning plugin ---
# {CMDC.Plugin.Builtin.Planning,
# plan_first: true, # default true — 在长 prompt 上先出 plan
# min_prompt_length: 20, # 默认 20 字符以下不触发规划
# max_plan_attempts: 2 # 解析失败兜底重试次数
# }
# Emits: {:plugin_event, :plan_generated, %CMDC.Plan{}}
# Side-effect: emit {:update_system_context, :plan, Plan.to_prompt_section(plan)}
# → Agent.state.dynamic_context_sections[:plan] 更新,下一轮 system prompt 自动携带
#
# --- v0.3 Reflection plugin(修 iteration-reset bug + Producer-Reviewer)---
# {CMDC.Plugin.Builtin.Reflection,
# reviewer_prompt: "You are a strict quality reviewer...", # 评审员人设
# reviewer_model: "qwen3-max", # 可选,SubAgent 模式下用不同模型
# reviewer_subagent: true, # true = 启独立 SubAgent (ADP 推荐)
# criteria: ["准确性", "完整性", "可读性"],
# max_reviews: 3, # 最大 review 轮数(max_iterations 别名仍兼容)
# pass_signal: "APPROVED" # 字符串或 %Regex{},通过信号
# }
# 状态机::idle ─before_finish→ :reviewing ─after_response(pass)→ :done
# │
# └─max_reviews 达到──→ :done(强制通过)
# Emits: {:plugin_event, :reflection_approved, %{critique, iteration}}
## Plugin Events (13 hooks)
# :session_start | :session_end | {:after_turn, payload} # v0.3 RFC 11G #A16
# {:before_prompt, text} | {:before_request, messages} | {:after_response, message}
# {:before_tool, name, args}
# {:on_tool_error, name, call_id, error, attempt} — tool failed, before retry
# {:after_tool, name, call_id, result} | {:after_tool_batch, results}
# :before_finish | {:before_compact, messages}
# {:before_steering, text} — CMDC.steer/2 触发;continue/intervene/abort/emit
# {:after_turn, payload} payload 字段(v0.3 RFC 11G #A16):
# outcome :: :finished | :aborted
# abort_reason :: term() | nil
# messages_diff :: [%CMDC.Message{}] — 本轮 prompt cycle 新增的消息(按时间序)
# token_usage_diff :: %CMDC.TokenUsage{} — 本轮增量
# started_at_ms / ended_at_ms / duration_ms
# 与 :session_end 的区别::session_end 无 payload,保持向后兼容;新 Plugin 建议用 :after_turn
## Dynamic System Prompt Injection (v0.3)
# Plugin 可以通过 emit 事件把文本动态注入下一轮 system prompt:
# {:emit, {:update_system_context, key, text}, state} # 注入 text
# {:emit, {:update_system_context, key, nil}, state} # 删除 key
# 对应 state field:CMDC.Agent.State.dynamic_context_sections :: %{atom() => String.t()}
# SystemPrompt 在 :full / :task / :minimal 三种模式下都会把这些段落拼进去
# (按 key 字典序;排在 BasePrompt + 用户 system_prompt + Blueprint + Skills + Memory 之后)
#
# 典型使用者:CMDC.Plugin.Builtin.Planning 每次生成/更新 plan 都 emit {:update_system_context, :plan, ...}
## Plugin Hook × Action 完整矩阵 + 5 个 Cookbook(v0.2 RFC B7)
# 详见 docs/dev/plugin-cookbook.md
# Hook \ Action | continue | intervene | abort | skip | block_tool | replace_tool_args | emit | switch_model
# :session_start | ✓ | - | ✓ | - | - | - | ✓ | -
# :session_end | ✓ | - | - | - | - | - | ✓ | -
# {:after_turn, p} | ✓ | - | - | - | - | - | ✓ | -
# :before_prompt | ✓ | ✓ | ✓ | ✓ | - | - | ✓ | -
# :before_request | ✓ | ✓ | ✓ | ✓ | - | - | ✓ | ✓
# :after_response | ✓ | ✓ | ✓ | ✓ | - | - | ✓ | ✓
# :before_tool | ✓ | - | ✓ | - | ✓ | ✓ | ✓ | ✓
# :on_tool_error | ✓ | - | ✓ | ✓ | - | - | ✓ | -*
# :after_tool | ✓ | ✓ | ✓ | - | - | - | ✓ | ✓
# :after_tool_batch | ✓ | ✓ | ✓ | - | - | - | ✓ | ✓
# :before_finish | ✓ | ✓ | ✓ | - | - | - | ✓ | -
# :before_compact | ✓ | - | - | ✓ | - | - | ✓ | -
# :before_steering | ✓ | ✓ | ✓ | - | - | - | ✓ | -
# *: :on_tool_error 在 Task 内 retry 循环触发,无 Agent state 上下文,switch_model 被忽略;
# 若需基于工具失败切 model,请在 :after_tool 钩子内基于 result 是 {:error, _} 决定。
#
# 13 hooks × 8 actions 完整矩阵 + emit 形态详解 + 累积/覆盖语义见
# `CMDC.Plugin` 模块 @moduledoc(v0.3 RFC 11G #D27),及 docs/dev/plugin-cookbook.md。
#
# 5 Cookbook 场景:
# 1. DangerousCommandGuard — :before_tool + block_tool 拦截 rm -rf
# 2. PromptAuditLog — :before_prompt + emit 审计所有 user prompt
# 3. BudgetGuard — :after_response + abort 控制成本上限
# 4. ToolFallbackGuard — :on_tool_error + emit 工具降级触发
# 5. SteeringContentGuard — :before_steering + abort 拦截 prompt injection
## Tool Retry (via state.config)
# :tool_max_retries — max retry attempts on tool failure (default 0 = no retry)
# :tool_retry_delay_ms — delay between retries in ms (default 500)
# on_tool_error plugin hook: {:continue, state} = retry, {:abort, _, state} or {:skip, state} = give up
## Blueprint — CMDC.Blueprint
# @callback build(keyword()) :: CMDC.Options.t()
defmodule MyAgent do
use CMDC.Blueprint
@impl true
def build(opts) do
%CMDC.Options{
model: opts[:model] || "anthropic:claude-sonnet-4-5",
tools: [CMDC.Tool.ReadFile, CMDC.Tool.Shell],
system_prompt: "Expert Elixir developer."
}
end
end
{:ok, s} = CMDC.create_agent(blueprint: MyAgent, provider_opts: [api_key: "sk-..."])
## Blueprint.Base — built-in base blueprint (SecurityGuard + EventLogger + PatchToolCalls)
opts = CMDC.Blueprint.Base.build(model: "anthropic:claude-sonnet-4-5", tools: [...])
## SubAgent — CMDC.SubAgent
sa = CMDC.SubAgent.new!(name: "coder", model: "anthropic:claude-opus-4-5",
tools: [CMDC.Tool.ReadFile, CMDC.Tool.EditFile])
# @enforce_keys [:name]
# Fields: name, description, system_prompt, model, tools, plugins, skills_dirs
# nil = inherit from parent agent
## Skill — CMDC.Skill
skills = CMDC.Skill.discover(["./skills"]) # => [%Skill{}]
{:ok, loaded} = CMDC.Skill.load(skill) # reads SKILL.md content
CMDC.Skill.to_prompt_snippet(skills) # => prompt section string
## Sandbox — CMDC.Sandbox (behaviour)
# @callback read_file(path, opts) :: {:ok, String.t()} | {:error, String.t()}
# @callback write_file(path, content, opts) :: :ok | {:error, String.t()}
# @callback edit_file(path, old_str, new_str, opts) :: {:ok, count} | {:error, reason}
# @callback list_dir(path, opts) :: {:ok, [dir_entry]} | {:error, String.t()}
# @callback file_exists?(path, opts) :: boolean()
# @callback grep(pattern, path, opts) :: {:ok, [grep_match]} | {:error, String.t()}
# @callback glob(pattern, path, opts) :: {:ok, [glob_match]} | {:error, String.t()}
# @callback execute(command, opts) :: {:ok, String.t()} | {:error, String.t()}
# Default: CMDC.Sandbox.Local (direct OS execution)
## Provider — CMDC.Provider (req_llm wrapper)
Provider.stream(model, messages, tools, opts) # => {:ok, %{bridge_pid: pid}}
Provider.convert_messages(messages) # => [ReqLLM.Message.t()]
Provider.convert_tools(tool_modules) # => [map()]
## Config — CMDC.Config
config = CMDC.Config.new!(provider_opts: [api_key: "sk-..."])
# Fields: data_dir, default_model, default_tools, provider, provider_opts, providers
## Agent Internals (L2 — usually not called directly)
Agent.prompt(agent_pid, text) # direct gen_statem call
Agent.status(agent_pid) # => %{state: :idle|:running|:streaming|:executing_tools, ...}
Agent.get_state(agent_pid) # => State.t()
# States: :idle → :running → :streaming → :executing_tools → :idle (loop)
## Compactor — CMDC.Agent.Compactor
# Auto-triggers when context exceeds token threshold
# Compactor.maybe_compact(state) => {:compacted, state} | {:skip, state}
# Compactor.force_compact(state) => {:compacted, state} | {:skip, state}
# ArgTruncator: auto-truncates large write_file/edit_file/execute args
## MCP Integration
# Discover MCP servers from mcp.json:
servers = CMDC.MCP.Config.discover(working_dir)
modules = CMDC.MCP.Bridge.discover_tool_modules(servers)
# MCP tools are dynamically wrapped as CMDC.Tool modules
## Session Lifecycle
# CMDC.create_agent → SessionServer.start_link → Supervisor tree:
# SessionServer (Supervisor)
# ├── Agent (gen_statem)
# └── SubAgent.Supervisor (DynamicSupervisor)
# └── child Agent processes (isolated, :temporary)
## Key Patterns
# Streaming events:
CMDC.subscribe(session)
CMDC.prompt(session, "hello")
receive do
{:cmdc_event, sid, {:message_delta, %{delta: chunk}}} -> IO.write(chunk)
{:cmdc_event, _sid, {:agent_end, _msgs, _usage}} -> :done
end
# HITL approval flow (auto-resumes by default — no sleep + re-prompt hack needed):
receive do
{:cmdc_event, _sid, {:approval_required, %{id: ref, tool: tool, args: args}}} ->
:ok = CMDC.approve(session, ref)
end
# 上层若需等待 Agent 重新跑起来再做下一步:
receive do
{:cmdc_event, _sid, {:agent_resumed, %{approval_id: ^ref}}} -> :resumed
end
# 关闭自动续 turn(保留旧 v0.1 行为,需要自己 prompt 续接):
:ok = CMDC.approve(session, ref, auto_resume: false)
:ok = CMDC.prompt(session, "继续刚才被拦截的操作")
# Steering flow (mid-execution direction change):
CMDC.subscribe(session)
CMDC.prompt(session, "搜索 Elixir gen_statem 教程")
# 一段时间后改变主意,无需 abort,直接追加新指引
{:ok, _ref} = CMDC.steer(session, "改成只看官方 hexdocs,不要第三方教程")
receive do
{:cmdc_event, _sid, {:steering_applied, %{count: n}}} ->
IO.puts("已注入 #{n} 条 steering 指引,agent 重新规划中")
end
# Business context passthrough (multi-tenant / operator id / data scope):
{:ok, session} = CMDC.create_agent(
model: "anthropic:claude-sonnet-4-5",
tools: [MyApp.Tools.FetchOrders],
user_data: %{tenant_id: "acme-1", operator_id: "u-9", data_scope: :restricted}
)
defmodule MyApp.Tools.FetchOrders do
@behaviour CMDC.Tool
def execute(_args, %CMDC.Context{user_data: %{tenant_id: t, data_scope: scope}}) do
rows = Repo.all(from o in Order, where: o.tenant_id == ^t and o.scope == ^scope)
{:ok, Jason.encode!(rows)}
end
end
# SubAgent inherits parent user_data; SubAgent.new!(name: "x", user_data: %{tenant_id: "other"})
# fully replaces (does NOT merge).
# Restore prior conversation history (idle resume / cross-process restore):
history = [
CMDC.Message.user("帮我审核这段代码"),
CMDC.Message.assistant("好的,请贴上来", [], []),
CMDC.Message.user("def hello, do: :world")
]
{:ok, session} = CMDC.create_agent(
model: "anthropic:claude-sonnet-4-5",
system_prompt: "你是一名严谨的 Elixir 审计员。",
messages: history # ← 直接进 state.messages,下次 prompt 自然续接
)
CMDC.prompt(session, "继续:这段代码的复杂度如何?")
# LLM 看到的 messages: [system, user, assistant, user, user] —— 不需要让 LLM 重读 history
# system_prompt 由 SystemPrompt.build/1 拼接,messages 列表里**不要**再放 :system 消息
## Steering Options (CMDC.Options)
# :max_steering_queue — Steering queue 最大长度(默认 3,溢出 → :queue_full)
# :interrupt_immune_tools — Steering 触发时不会被 brutal_kill 的 tool 名白名单
# 默认 ~w(write_file edit_file shell git_commit notebook_edit ask_user)