# 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)