# CMDC — AI Complete Reference (llms-full.txt) # Elixir Agent Kernel v0.1.0 | OTP gen_statem | Hex: {:cmdc, "~> 0.1"} # Quick reference: llm.txt | HexDocs: https://hexdocs.pm/cmdc # # This file contains everything an AI needs to correctly use, extend, # and integrate with CMDC. Read this file to understand the full API, # event protocol, Plugin/Tool development patterns, and known pitfalls. ################################################################################ # 1. ARCHITECTURE OVERVIEW ################################################################################ # CMDC is an Elixir Agent Kernel library built on OTP gen_statem. # It provides a complete agent loop: prompt → LLM stream → tool execution → reply. # # Module dependency layers (only downward dependencies allowed): # # L0 Foundation Options / Config / Context / Message / Event / EventBus / SubAgent # L1 Abstractions Blueprint / Plugin / Tool / Sandbox / Skill / Provider # L2 Agent Kernel Agent (gen_statem) + State + Stream + ToolRunner # + BasePrompt + SystemPrompt + Compactor + ArgTruncator # L3 Integration SessionServer + MCP.* + Memory.ETS + SubAgent.Supervisor + Gateway # L4 Facade CMDC (public API entry point) # # Process tree per session: # # SessionServer (Supervisor) # ├── Agent (gen_statem) — 4-state loop: idle → running → streaming → executing_tools # └── SubAgent.Supervisor (DynamicSupervisor) # └── child Agent processes (isolated, :temporary restart strategy) ################################################################################ # 2. PUBLIC API — CMDC MODULE ################################################################################ # All operations target a session pid (CMDC.SessionServer process). # Events are decoupled via CMDC.EventBus (Registry-based PubSub). # --- Types --- # @type session :: pid() # @type create_opts :: keyword() | CMDC.Options.t() # --- create_agent/1 --- # @spec create_agent(create_opts()) :: {:ok, session()} | {:error, term()} # # Three invocation styles: # # Style 1: keyword list (recommended) {:ok, session} = CMDC.create_agent( model: "anthropic:claude-sonnet-4-5", # required — "provider:model_id" tools: [CMDC.Tool.ReadFile, CMDC.Tool.Shell], # optional, default [] plugins: [CMDC.Plugin.Builtin.SecurityGuard], # optional, default [] system_prompt: "You are a coding assistant.", # optional, nil = BasePrompt only working_dir: "/my/project", # optional, default "." provider_opts: [api_key: "sk-..."], # optional, passed to req_llm max_turns: 50, # optional, default 100 max_tokens: nil, # optional, nil = provider decides skills_dirs: ["./skills"], # optional, SKILL.md discovery memory: ["AGENTS.md"], # optional, memory file paths sandbox: nil, # optional, Sandbox module or nil subagents: [], # optional, [SubAgent.t()] response_format: nil, # optional, JSON Schema map or nil session_id: "my-session-123" # optional, auto-generated if omitted ) # Style 2: Options struct opts = CMDC.Options.new!(model: "openai:gpt-4o", tools: [CMDC.Tool.Shell]) {:ok, session} = CMDC.create_agent(opts) # Style 3: Blueprint {:ok, session} = CMDC.create_agent(blueprint: MyApp.CodingAgent) # --- prompt/2 --- # @spec prompt(session(), String.t()) :: %{queued: boolean()} # queued: false = immediate processing, true = Agent is busy, message enqueued %{queued: false} = CMDC.prompt(session, "Refactor this module") # --- collect_reply/2 --- # @spec collect_reply(session(), keyword()) :: {:ok, String.t()} | {:error, :timeout} | {:error, term()} # Blocks until Agent returns to idle. Subscribes to EventBus internally. {:ok, reply} = CMDC.collect_reply(session, timeout: 60_000) # --- stop/2 --- # @spec stop(session(), keyword()) :: :ok # Options: :reason (default :normal), :timeout (default 5_000) CMDC.stop(session) # --- abort/1 --- # @spec abort(session()) :: :ok CMDC.abort(session) # --- subscribe/1, unsubscribe/1 --- # @spec subscribe(session()) :: {:ok, pid()} | {:error, term()} # @spec unsubscribe(session()) :: :ok # After subscribe, caller receives {:cmdc_event, session_id, event_tuple} CMDC.subscribe(session) CMDC.unsubscribe(session) # --- approve/2, reject/2 --- # @spec approve(session(), String.t()) :: :ok # @spec reject(session(), String.t()) :: :ok # approval_id comes from {:approval_required, _sid, _tool, _args, approval_id} event CMDC.approve(session, approval_id) CMDC.reject(session, approval_id) # --- status/1 --- # @spec status(session()) :: map() # Returns: %{state: :idle|:running|:streaming|:executing_tools, session_id: String.t(), # turns: non_neg_integer(), cost_usd: float(), uptime_ms: ..., timestamp_ms: ...} CMDC.status(session) # --- agent_pid/1 --- # @spec agent_pid(session()) :: pid() | nil CMDC.agent_pid(session) # --- session_id/1 --- # @spec session_id(session()) :: String.t() CMDC.session_id(session) ################################################################################ # 3. OPTIONS — CMDC.Options ################################################################################ # Typed struct with NimbleOptions runtime validation. # @enforce_keys [:model] # # @type plugin_spec :: module() | {module(), keyword()} # @type t :: %CMDC.Options{ # model: String.t(), # required — "provider:model_id" # tools: [module()], # default [] # system_prompt: String.t() | nil, # default nil # plugins: [plugin_spec()], # default [] # subagents: [map()], # default [] # skills_dirs: [String.t()], # default [] # memory: [String.t()], # default [] # sandbox: module() | nil, # default nil # response_format: map() | nil, # default nil # provider_opts: keyword(), # default [] # max_turns: pos_integer(), # default 100 # max_tokens: pos_integer() | nil, # default nil # working_dir: String.t() # default "." # } # Construction: opts = CMDC.Options.new!(model: "anthropic:claude-sonnet-4-5", tools: [CMDC.Tool.Shell]) {:ok, opts} = CMDC.Options.new(model: "openai:gpt-4o") CMDC.Options.schema() # => NimbleOptions.t() CMDC.Options.merge(opts, max_turns: 20) # => new Options ################################################################################ # 4. EVENT PROTOCOL ################################################################################ # All events broadcast via CMDC.EventBus as: # {:cmdc_event, session_id :: String.t(), event :: CMDC.Event.t()} # # CMDC.Event.valid?/1 accepts atoms and tuples; CMDC.Event.all_types/0 returns all type atoms. # --- Session / Lifecycle --- # :agent_start — Agent 开始处理本轮 prompt # {:agent_end, messages, token_usage} — 本轮完成,回到 idle;token_usage: %{prompt_tokens, completion_tokens, total_tokens} # {:agent_abort, reason} | :agent_abort — Agent 被中止(含/不含 reason) # {:prompt_received, text} — 收到用户 prompt # {:prompt_queued, text} — Agent 忙,prompt 已入队 # {:prompt_rejected, reason} — prompt 被 Plugin 拒绝 # --- Streaming --- # :message_start — LLM 开始生成文本(首个 content chunk 前) # {:message_delta, %{delta: text}} — 流式 token 片段 # {:response_complete, %CMDC.Message{}} — 本轮响应完整结束(含 tool_calls 已解析) # :thinking_start — 思考链开始(部分模型) # {:thinking_delta, %{delta: text}} — 思考链 token 片段 # {:status_update, text} — LLM 响应中 标签内容 # {:title_generated, title} — LLM 响应中 标签内容(限 60 字符) # --- Provider / Request --- # {:request_start, %{model: m, messages: count}} — LLM 请求即将发起 # {:stream_error, reason} — 流式响应出错 # {:stream_stalled, elapsed_s} — 流式疑似卡住(超 stall 阈值) # {:retry, attempt, delay_ms, reason} — 即将重试 Provider 请求 # {:context_overflow, reason} — 上下文长度超限,尝试压缩 # --- Tool Execution --- # {:tool_calls, count} — 本轮 LLM 请求了 count 个工具 # {:tool_execution_start, name, call_id, args} — 工具开始执行 # {:tool_execution_end, name, call_id, result} — 工具完成,result: {:ok, text} | {:error, text} # {:tool_blocked, name, call_id, reason} — 工具被 Plugin block_tool 拦截 # {:loop_detected, %{type: type, ...}} — 循环检测触发 # type: :repeat_pattern | :file_loop_warn | :file_loop_abort # --- HITL (Human-in-the-loop) --- # {:approval_required, approval_map} — 等待人类审批 # {:approval_resolved, approval_map} — 审批已决定 # approval_map: %{ # id: String.t(), — 审批 ID,传给 CMDC.approve/reject # tool: String.t(), — 被拦截的工具名 # args: map(), — 工具参数 # session_id: String.t(), # hint: String.t(), — 用户提示文本 # requested_at: integer(), — 毫秒时间戳 # status: :approved | :rejected | :timeout # 仅 approval_resolved 有此字段 # } # --- Agent 提问 --- # {:ask_user, session_id, question, options, ref} — Agent 主动提问 # {:user_responded, session_id, ref, response} — 用户回答 # --- Planning --- # {:todo_change, session_id, todos :: [map()]} # --- Context Compaction --- # {:compact_start, session_id} # {:compact_end, session_id, removed_count :: non_neg_integer()} # --- SubAgent --- # {:subagent_start, session_id, child_session_id, description} # {:subagent_end, session_id, child_session_id, result :: {:ok, text} | {:error, reason}} # {:sub_agent_event, call_id, child_session_id, event} — 子代理内部事件透传 # --- Plugin 自定义事件 --- # {:plugin_event, name :: atom(), payload :: term()} # --- Agent 干预 --- # {:intervention, prompt} — 循环检测/before_finish 注入干预 # {:stop_blocked, prompt} — abort 被 Plugin 拦截,注入继续 prompt # --- Error --- # {:error, session_id, reason} # --- EventBus API --- # CMDC.EventBus.subscribe(session_id) # => {:ok, pid} # CMDC.EventBus.subscribe_all() # subscribe to all sessions # CMDC.EventBus.broadcast(session_id, event) # CMDC.EventBus.unsubscribe(session_id) # CMDC.EventBus.unsubscribe_all() # --- Consuming events pattern --- CMDC.subscribe(session) CMDC.prompt(session, "hello") loop = fn loop_fn -> receive do {:cmdc_event, sid, {:message_delta, %{delta: chunk}}} when is_binary(chunk) -> IO.write(chunk) loop_fn.(loop_fn) {:cmdc_event, sid, {:response_complete, _message}} -> loop_fn.(loop_fn) {:cmdc_event, sid, {:tool_execution_start, name, _call_id, _args}} -> IO.puts("[tool] #{name} started") loop_fn.(loop_fn) {:cmdc_event, sid, {:tool_execution_end, name, _call_id, _result}} -> IO.puts("[tool] #{name} done") loop_fn.(loop_fn) {:cmdc_event, _sid, {:agent_end, _messages, _usage}} -> :done {:cmdc_event, _sid, {:approval_required, %{id: ref, tool: tool, args: args}}} -> IO.inspect({tool, args}, label: "Approve?") CMDC.approve(session, ref) loop_fn.(loop_fn) after 60_000 -> {:error, :timeout} end end loop.(loop) ################################################################################ # 5. MESSAGE — CMDC.Message ################################################################################ # @derive Jason.Encoder # @enforce_keys [:id, :role] # # @type role :: :system | :user | :assistant | :tool_result # @type tool_call :: %{call_id: String.t(), name: String.t(), arguments: map()} # @type t :: %CMDC.Message{ # id: String.t(), # parent_id: String.t() | nil, # role: role(), # content: String.t() | nil, # thinking: String.t() | nil, # tool_calls: [tool_call()] | nil, # call_id: String.t() | nil, # name: String.t() | nil, # is_error: boolean(), # default false # metadata: map() | nil # } # Factory functions: Message.system("You are helpful.") Message.user("Hello") Message.assistant("Sure!", [%{call_id: "c1", name: "read_file", arguments: %{"path" => "a.ex"}}]) Message.assistant("Plain text response.") # no tool_calls Message.tool_result("c1", "file contents here", false) # (call_id, output, is_error) Message.to_map(msg) # => map for serialization ################################################################################ # 6. CONTEXT — CMDC.Context ################################################################################ # Passed to Tool.execute/2 and Plugin.handle_event/3 as execution context. # @enforce_keys [:session_id, :working_dir, :model] # # @type t :: %CMDC.Context{ # session_id: String.t(), # working_dir: String.t(), # model: String.t(), # sandbox: module() | nil, # tools: [module()], # default [] # subagents: [map()], # default [] # config: CMDC.Config.t() | nil, # todos: [map()], # default [] # memory_contents: %{String.t() => String.t()}, # default %{} # turn: non_neg_integer(), # default 0 # total_tokens: non_neg_integer(), # default 0 # cost_usd: float() # default 0.0 # } # Construction (usually automatic): ctx = CMDC.Context.from_state(agent_state) ctx = CMDC.Context.from_options(options, session_id) ################################################################################ # 7. TOOL DEVELOPMENT ################################################################################ # All tools implement @behaviour CMDC.Tool. # # Required callbacks: # @callback name() :: String.t() # @callback description() :: String.t() # @callback parameters() :: map() # JSON Schema object # @callback execute(map(), CMDC.Context.t()) :: result() # # Optional callbacks: # @callback description(tool_context()) :: String.t() # context-aware description # @callback meta(map()) :: String.t() # short summary for logs # # @type result :: {:ok, String.t()} | {:error, String.t()} | {:effect, term()} # @type tool_context :: %{optional(:working_dir) => String.t(), optional(:shell) => atom()} # --- Example: Custom Tool --- defmodule MyApp.Tool.HttpGet do @moduledoc "Fetch a URL and return the response body." @behaviour CMDC.Tool @impl true def name, do: "http_get" @impl true def description, do: "Fetch content from a URL via HTTP GET." @impl true def parameters do %{ "type" => "object", "properties" => %{ "url" => %{"type" => "string", "description" => "The URL to fetch"} }, "required" => ["url"] } end @impl true def execute(%{"url" => url}, _ctx) do case Req.get(url) do {:ok, %{status: 200, body: body}} -> {:ok, body} {:ok, %{status: status}} -> {:error, "HTTP #{status}"} {:error, reason} -> {:error, inspect(reason)} end end end # --- Sandbox proxy pattern (for file/exec tools) --- # If ctx.sandbox is set, delegate to sandbox; otherwise use Sandbox.Local: def execute(%{"path" => path}, %{sandbox: sandbox} = ctx) when not is_nil(sandbox) do sandbox.read_file(path, working_dir: ctx.working_dir) end def execute(%{"path" => path}, ctx) do CMDC.Sandbox.Local.read_file(path, working_dir: ctx.working_dir) end # --- Tool utility functions --- CMDC.Tool.to_schema([CMDC.Tool.ReadFile, CMDC.Tool.Shell]) # => [%{name, description, parameters}] CMDC.Tool.find([CMDC.Tool.ReadFile], "read_file") # => {:ok, CMDC.Tool.ReadFile} CMDC.Tool.description(CMDC.Tool.Shell, %{working_dir: "/p"}) # context-aware description # --- Built-in Tool Reference --- # # CMDC.Tool.ReadFile # name: "read_file" # params: path (required), offset (integer), limit (integer) # Returns file contents with line numbers. Supports pagination. # # CMDC.Tool.WriteFile # name: "write_file" # params: path (required), content (required) # Creates parent directories automatically. # # CMDC.Tool.EditFile # name: "edit_file" # params: path (required), old_string (required), new_string (required) # Exact string replacement. old_string must appear exactly once. # # CMDC.Tool.Shell # name: "shell" (varies by OS: "bash", "zsh", "powershell") # params: command (required), timeout (integer, default 30000ms) # Large output auto-saved to temp file, returns file path. # @type shell :: :sh | :bash | :zsh | :cmd | :powershell # # CMDC.Tool.Grep # name: "grep" # params: pattern (required), path, include (glob filter), context_lines, max_results # Regex search with line numbers. # # CMDC.Tool.ListDir # name: "list_dir" # params: path (optional, defaults to working_dir) # Returns files/dirs with type and size. # # CMDC.Tool.Glob # name: "glob" # params: pattern (required), path (optional) # File pattern matching with wildcards. # # CMDC.Tool.Task # name: "task" # params: description (required), subagent_type (optional) # Spawns a child Agent under SubAgent.Supervisor. # Child runs in isolation; last assistant reply returned as tool result. # Broadcasts :subagent_start/:subagent_end events. # # CMDC.Tool.WriteTodos # name: "write_todos" # params: todos (required) — array of %{id, content, status} # status enum: "pending" | "in_progress" | "completed" | "cancelled" # Updates Context.todos, broadcasts :todo_change event. # # CMDC.Tool.AskUser # name: "ask_user" # params: question (required), options (optional array of %{id, label}) # Broadcasts :ask_user event, waits for :user_responded. # # CMDC.Tool.CompactConversation # name: "compact_conversation" # params: reason (optional) # Triggers immediate context compaction via Compactor. ################################################################################ # 8. PLUGIN DEVELOPMENT ################################################################################ # All plugins implement @behaviour CMDC.Plugin. # # Required callbacks: # @callback init(keyword()) :: {:ok, plugin_state()} | {:error, term()} # @callback priority() :: non_neg_integer() # lower = runs first (0-999) # @callback handle_event(event(), plugin_state(), CMDC.Context.t()) :: action() # # Optional callbacks: # @callback on_session_end(plugin_state(), CMDC.Context.t()) :: :ok # @callback describe() :: String.t() # --- Plugin Events (11 hooks) --- # :session_start — Agent session started # :session_end — Agent session ending # {:before_prompt, text} — before user prompt is processed # {:before_request, [Message.t()]} — before messages sent to LLM # {:after_response, Message.t()} — after LLM response received # {:before_tool, name, args} — before tool execution # {:on_tool_error, name, call_id, error, attempt} — tool failed, before retry # {:after_tool, name, call_id, result} — after tool execution # {:after_tool_batch, results} — after all tools in batch complete # :before_finish — before Agent returns to idle # {:before_compact, [Message.t()]} — before context compaction # --- Plugin Actions (7 types) --- # {:continue, state} — pass through, no modification # {:intervene, prompt :: String.t(), state} — inject extra prompt into conversation # {:abort, reason :: String.t(), state} — abort the entire agent run # {:skip, state} — skip current operation # {:block_tool, reason :: String.t(), state} — block the tool call (before_tool only) # {:replace_tool_args, new_args :: map(), state} — replace tool arguments (before_tool only) # {:emit, {type :: atom(), data :: term()}, state} — emit a custom event # --- Plugin.Pipeline execution --- # Plugins sorted by priority (ascending). Pipeline runs each plugin in order. # Short-circuit actions (abort, block_tool, skip) halt the pipeline immediately. # The halted_by module and halt_reason are recorded in the result. # # Pipeline.run(plugin_entries, event, ctx) # => {:ok, %{action: atom, plugin_states: map, interventions: list, # emitted_events: list, replaced_args: map | nil, # halted_by: module | nil, halt_reason: String.t | nil}} # --- Example: Custom Plugin --- defmodule MyApp.Plugins.RateLimit do @moduledoc "Rate-limit tool calls to max N per minute." @behaviour CMDC.Plugin @impl true def init(opts) do {:ok, %{ max_per_minute: Keyword.get(opts, :max_per_minute, 30), calls: [], window_ms: 60_000 }} end @impl true def priority, do: 20 @impl true def handle_event({:before_tool, _name, _args}, state, _ctx) do now = System.monotonic_time(:millisecond) recent = Enum.filter(state.calls, &(now - &1 < state.window_ms)) if length(recent) >= state.max_per_minute do {:block_tool, "Rate limit exceeded (#{state.max_per_minute}/min)", state} else {:continue, %{state | calls: [now | recent]}} end end def handle_event(_event, state, _ctx), do: {:continue, state} end # Usage: CMDC.create_agent( model: "anthropic:claude-sonnet-4-5", plugins: [{MyApp.Plugins.RateLimit, max_per_minute: 10}] ) # --- Built-in Plugin Reference --- # # CMDC.Plugin.Builtin.SecurityGuard (priority 10) # Hooks: before_tool # Blocks dangerous file paths and shell commands via configurable blacklists. # Default blacklists cover /etc/passwd, rm -rf /, sudo, etc. # Config: blacklisted_paths, blacklisted_commands, allowed_dirs # # CMDC.Plugin.Builtin.HumanApproval (priority 15) # Hooks: before_prompt, before_tool # Emits {:approval_required, ...} event and waits for approve/reject. # Config: tools (list of tool names requiring approval) # Also handles extended events: {:tool_approved, id}, {:tool_rejected, id} # # CMDC.Plugin.Builtin.EventLogger (priority 50) # Hooks: session_start, session_end, before_tool, after_tool, after_tool_batch, # before_request, after_response, before_finish # Writes JSON Lines log file per session. # Config: log_dir (default: config.logs_dir) # # CMDC.Plugin.Builtin.MemoryLoader (priority 100) # Hooks: session_start, after_tool # Loads AGENTS.md files at session start → injects <agent_memory> into prompt. # Auto-reloads when write_file/edit_file modifies a memory file. # Config: auto_reload (default true) # # CMDC.Plugin.Builtin.PatchToolCalls (priority 120) # Hooks: before_request # Detects assistant messages with tool_calls that lack corresponding tool_result # messages, and synthesizes placeholder responses to prevent LLM errors. # # CMDC.Plugin.Builtin.PromptCache (priority 130) # Hooks: before_request # Adds cache_control markers for Anthropic prompt caching. # Automatically skipped for non-Anthropic models. # --- Plugin.Registry --- # Manages plugin registration, deduplication, and priority ordering. # CMDC.Plugin.Registry.from_specs([SecurityGuard, {HumanApproval, tools: ["shell"]}]) # => %Registry{entries: [{SecurityGuard, state}, {HumanApproval, state}]} ################################################################################ # 9. BLUEPRINT — CMDC.Blueprint ################################################################################ # Blueprint = declarative, reusable Agent configuration. # @callback build(keyword()) :: CMDC.Options.t() # # Struct fields mirror Options: name, model, system_prompt, tools, plugins, # subagents, skills_dirs, memory, sandbox, provider_opts, max_turns, # max_tokens, working_dir # @enforce_keys [:name, :model] # --- Blueprint via behaviour --- defmodule MyApp.CodingAgent do use CMDC.Blueprint @impl true def build(opts) do %CMDC.Options{ model: opts[:model] || "anthropic:claude-sonnet-4-5", system_prompt: "You are an expert Elixir developer.", tools: [CMDC.Tool.ReadFile, CMDC.Tool.WriteFile, CMDC.Tool.EditFile, CMDC.Tool.Shell], plugins: [CMDC.Plugin.Builtin.SecurityGuard], skills_dirs: ["priv/skills"], max_turns: 50 } end end {:ok, session} = CMDC.create_agent( blueprint: MyApp.CodingAgent, provider_opts: [api_key: System.get_env("ANTHROPIC_API_KEY")] ) # --- Blueprint via struct pipeline --- blueprint = %CMDC.Blueprint{name: "coder", model: "anthropic:claude-sonnet-4-5"} blueprint = CMDC.Blueprint.add_tools(blueprint, [CMDC.Tool.ReadFile, CMDC.Tool.Shell]) blueprint = CMDC.Blueprint.add_plugins(blueprint, [CMDC.Plugin.Builtin.SecurityGuard]) opts = CMDC.Blueprint.to_options(blueprint, provider_opts: [api_key: "sk-..."]) {:ok, session} = CMDC.create_agent(opts) # Struct manipulation: # Blueprint.override(bp, fields) # Blueprint.add_tools(bp, tools) # Blueprint.add_plugins(bp, plugins) # Blueprint.add_skills_dirs(bp, dirs) # Blueprint.add_memory(bp, paths) # Blueprint.add_subagents(bp, subagents) # Blueprint.to_options(bp, overrides \\ []) # --- Blueprint.Base (built-in) --- # Default plugins: SecurityGuard + EventLogger + PatchToolCalls opts = CMDC.Blueprint.Base.build( model: "anthropic:claude-sonnet-4-5", tools: [CMDC.Tool.ReadFile, CMDC.Tool.Shell], extra_plugins: [CMDC.Plugin.Builtin.HumanApproval] ) CMDC.Blueprint.Base.default_plugins() # => [SecurityGuard, EventLogger, PatchToolCalls] ################################################################################ # 10. SUBAGENT — CMDC.SubAgent ################################################################################ # Declarative specification for child agents spawned by Tool.Task. # @enforce_keys [:name] # # @type t :: %CMDC.SubAgent{ # name: String.t(), # required # description: String.t() | nil, # nil fields inherit from parent # system_prompt: String.t() | nil, # model: String.t() | nil, # tools: [module()] | nil, # plugins: [plugin_spec()] | nil, # skills_dirs: [String.t()] | nil # } sa = CMDC.SubAgent.new!(name: "researcher", description: "Specialized in web research", model: "openai:gpt-4o", tools: [CMDC.Tool.Shell, CMDC.Tool.ReadFile]) # SubAgent context isolation: # INHERITS: working_dir, sandbox, model (if nil), tools (if nil) # DOES NOT INHERIT: messages, todos, memory_contents, tool_call_hashes # INPUT: single user message (task description) # OUTPUT: last assistant reply text → returned as ToolMessage to parent # SubAgent.to_options(subagent, parent_options) → merged Options # Child Agent runs under SubAgent.Supervisor as :temporary process. # Crash does NOT affect parent Agent. ################################################################################ # 11. SANDBOX — CMDC.Sandbox ################################################################################ # File/command execution abstraction layer. # @behaviour with 8 callbacks: # # @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, new, opts):: {:ok, count} | {:error, :not_found | :not_unique | String.t()} # @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()} # # Types: # @type dir_entry :: %{name: String.t(), type: :file | :directory, size: non_neg_integer() | nil} # @type grep_match :: %{file: String.t(), line: non_neg_integer(), content: String.t()} # @type glob_match :: %{path: String.t(), type: :file | :directory} # # Common opts keys: :working_dir, :timeout, :offset, :limit # Default implementation: CMDC.Sandbox.Local (direct OS execution) ################################################################################ # 12. SKILL — CMDC.Skill ################################################################################ # Skills = SKILL.md files with YAML frontmatter metadata. # Auto-discovered from skills_dirs, injected into system prompt. # # @type t :: %CMDC.Skill{ # name: String.t(), # description: String.t(), # path: String.t(), # allowed_tools: [String.t()] | nil, # content: String.t() | nil # } # # SKILL.md format: # --- # name: my-skill # description: Does something useful # allowed_tools: [read_file, shell] # optional # --- # Skill content here... skills = CMDC.Skill.discover(["./skills", "~/.cmdc/skills"]) {:ok, skill} = CMDC.Skill.load(skill) # reads file content CMDC.Skill.to_prompt_snippet(skills) # generates prompt section CMDC.Skill.find(skills, "my-skill") # => %Skill{} | nil ################################################################################ # 13. PROVIDER — CMDC.Provider ################################################################################ # Thin wrapper around req_llm for LLM API calls. # NOT a behaviour — direct function module. # # @type model :: String.t() | map() # # Provider.stream(model, messages, tools, opts) # => {:ok, %{bridge_pid: pid}} | {:error, term()} # Starts a StreamBridge process that converts ReqLLM.StreamResponse # into gen_statem messages: # {:cmdc_stream_chunk, chunk} # :cmdc_stream_done # {:cmdc_stream_error, reason} # # Provider.convert_messages(messages, provider \\ :openai) # => [ReqLLM.Message.t()] # # Provider.convert_tools(tool_modules) # => [map()] — JSON Schema tool definitions ################################################################################ # 14. CONFIG — CMDC.Config ################################################################################ # @type provider_entry :: {module(), keyword()} # @type t :: %CMDC.Config{ # data_dir: String.t(), # default ~/.cmdc # default_model: String.t(), # default "anthropic:claude-sonnet-4-5" # default_tools: [module()], # default [] # provider: module() | nil, # provider_opts: keyword(), # default [] # providers: [provider_entry()] # default [] # } config = CMDC.Config.new!(provider_opts: [api_key: "sk-..."]) CMDC.Config.resolve_provider(config, "anthropic:claude-sonnet-4-5") CMDC.Config.data_dir(config) # => "~/.cmdc" CMDC.Config.sessions_dir(config) # => "~/.cmdc/sessions" CMDC.Config.logs_dir(config) # => "~/.cmdc/logs" CMDC.Config.ensure_dirs!(config) # creates data/sessions/logs dirs ################################################################################ # 15. MCP INTEGRATION ################################################################################ # Model Context Protocol support via anubis_mcp. # MCP tools are dynamically wrapped as CMDC.Tool behaviour modules. # Discovery: servers = CMDC.MCP.Config.discover(working_dir) # Searches: .cursor/mcp.json, .vscode/mcp.json, mcp.json, etc. modules = CMDC.MCP.Bridge.discover_tool_modules(servers) # Each MCP tool → dynamically generated module implementing CMDC.Tool # Client API: CMDC.MCP.Client.await_ready("server-name", 5_000) CMDC.MCP.Client.server_list_tools("server-name") CMDC.MCP.Client.server_call_tool("server-name", "tool_name", %{"arg" => "val"}) CMDC.MCP.Client.server_list_resources("server-name") CMDC.MCP.Client.server_read_resource("server-name", "resource://uri") # Supervisor: CMDC.MCP.Supervisor.start_link(opts) CMDC.MCP.Supervisor.running_count(supervisor) CMDC.MCP.Supervisor.running_clients(supervisor) ################################################################################ # 16. AGENT INTERNALS ################################################################################ # --- Agent gen_statem states --- # :idle → waiting for prompt # :running → processing prompt, preparing LLM request # :streaming → receiving LLM stream response # :executing_tools → running tool calls in parallel # # State transitions: # idle → running (prompt/2) → streaming (Provider.stream) → idle (no tools) # → executing_tools (has tools) → running (next turn) # --- Agent.State fields (notable) --- # session_id, model, working_dir, status, messages (reversed), # plugins, plugin_states, tools, disabled_tools, # pending_tool_tasks, tool_results, current_text, current_tool_calls, # current_thinking, token_usage, turn_count, tool_call_count, cost_usd, # tool_call_hashes, sandbox, subagents, todos, memory_contents, # retry_count, max_retries (3), overflow_detected, pending_messages # --- ToolRunner --- # Executes tool calls in parallel via Task.async. # Built-in loop detection: tracks hash of recent tool call sequences. # Three detection paths: # 1. Exact duplicate hash in last N calls # 2. Repeating pattern detection (min_repeat_pattern) # 3. Consecutive failure threshold (max_consecutive_failures) # # Tool retry: on {:error, _}, retries up to :tool_max_retries times (default 0). # Before each retry, runs {:on_tool_error, name, call_id, error, attempt} pipeline. # Plugin can return {:continue, state} to allow retry, {:abort, _, state} or {:skip, state} to give up. # Retry happens inside Task.async — does NOT block other concurrent tools. # Config keys (in state.config): :tool_max_retries (default 0), :tool_retry_delay_ms (default 500) # # ToolRunner.execute_batch(tool_calls, state) # => {:next_state, :executing_tools, state} — tools dispatched # => {:next_turn, state} — all results collected, continue # => {:abort, state} — loop detected or error # --- Compactor --- # Auto-triggered when context exceeds token threshold. # Strategy: usage-based (primary) + character estimation (fallback, chars/4). # Compactor.maybe_compact(state) => {:compacted, state} | {:skip, state} # Compactor.force_compact(state) => {:compacted, state} | {:skip, state} # Compactor.estimate_tokens(state) => non_neg_integer() # # ArgTruncator: pre-truncates large tool arguments (write_file, edit_file, execute) # before sending to LLM. Prevents context overflow from tool results. # ArgTruncator.truncate(state) => {state, changed?} # --- SystemPrompt assembly order --- # 1. User system_prompt (blueprint_system_prompt) # 2. BasePrompt (core behavior + tool usage guidelines) # 3. Blueprint identity (name + purpose) # 4. Skills section (discovered SKILL.md metadata) # 5. Memory section (<agent_memory> tags from loaded files) # --- Emitter --- # Emitter.broadcast(state, event) — broadcasts via EventBus # Emitter.recent(session_id, limit \\ 50) — recent events from ETS ring buffer # Emitter.clear(session_id) — clear event buffer ################################################################################ # 17. MEMORY — CMDC.Memory.ETS ################################################################################ # ETS-based key-value memory store. # @type store :: atom() # # {:ok, store} = CMDC.Memory.ETS.new(:my_memory) # CMDC.Memory.ETS.store(store, "key1", %{text: "important fact"}) # {:ok, results} = CMDC.Memory.ETS.search(store, "important") # {:ok, all} = CMDC.Memory.ETS.list(store) # CMDC.Memory.ETS.delete(store, "key1") ################################################################################ # 18. GATEWAY — CMDC.Gateway ################################################################################ # Outbound event reporting contract. # @callback report_event(session_id, event) :: :ok # @callback report_agent_state(session_id, state_info) :: :ok # # Default: CMDC.Gateway.Local — broadcasts via EventBus. ################################################################################ # 19. SESSION SERVER ################################################################################ # Per-session Supervisor tree. # SessionServer.start_link(opts) — starts Supervisor with Agent + SubAgent.Supervisor # SessionServer.agent(session_server) — returns Agent pid # SessionServer.sub_agent_supervisor(session_server) — returns SubAgent.Supervisor pid # SessionServer.prompt(session_server, text) — delegates to Agent.prompt/2 ################################################################################ # 20. KNOWN PITFALLS & GOTCHAS ################################################################################ # 1. TOKEN COUNTING # Usage data is ONLY available in the last stream chunk (stream_done). # Do NOT attempt to accumulate tokens during streaming. # Fallback: character count / 4 (rough estimate). # 2. SANDBOX PROXY PATTERN # All file/exec tools MUST check ctx.sandbox: # when not is_nil(sandbox) → sandbox.read_file(...) # else → CMDC.Sandbox.Local.read_file(...) # SecurityGuard handles policy; Sandbox handles execution. # Avoid double-validating paths in both layers. # 3. SUBAGENT ISOLATION # Child inherits: working_dir, sandbox, model (if nil), tools (if nil) # Child does NOT inherit: messages, todos, memory_contents, tool_call_hashes # Child crash → {:error, ...} returned to parent ToolRunner (no restart, :temporary) # 4. MEMORY PLUGIN WRITE-BACK # MemoryLoader only LOADS and INJECTS memory into prompts. # Writing back is done by the Agent using edit_file tool naturally. # Do NOT auto-write-back in the plugin layer. # 5. EVENTBUS IS THE ONLY EXTERNAL CONTRACT # Never use send(pid, msg) to bypass EventBus. # All external communication goes through Emitter.emit → EventBus.broadcast. # 6. PLUGIN PRIORITY ORDERING # Lower number = runs first. # SecurityGuard (10) → HumanApproval (15) → EventLogger (50) # → MemoryLoader (100) → PatchToolCalls (120) → PromptCache (130) # Custom plugins: use 0-9 for "must run first", 200+ for "run after builtins". # 7. MESSAGE LIST IS REVERSED # Agent.State.messages stores messages in reverse chronological order. # Use Enum.reverse(state.messages) when reading conversation history. # 8. NIMBLEOPTIONS VALIDATION # CMDC.Options.new!/1 raises on invalid input. # CMDC.Options.new/1 returns {:ok, t} | {:error, ValidationError}. # The error message is human-readable and includes field name + expected type. # 9. COLLECT_REPLY SUBSCRIBES INTERNALLY # collect_reply/2 subscribes to EventBus and unsubscribes after. # If you're already subscribed, you may receive duplicate events. # Best practice: use EITHER subscribe+receive OR collect_reply, not both. # 10. BLUEPRINT VS OPTIONS PRECEDENCE # When using blueprint: module, the blueprint's build/1 output provides # base options. Keyword args passed to create_agent override blueprint values. # Blueprint is evaluated first, then overrides are merged. # 11. STREAMING MESSAGE PROTOCOL # Agent gen_statem receives: # {:cmdc_stream_chunk, chunk} — from StreamBridge # :cmdc_stream_done — from StreamBridge # {:cmdc_stream_error, reason} — from StreamBridge # These are internal; external consumers use EventBus events. # 12. TOOL RESULT SIZE # Shell tool auto-saves large output (>10KB) to temp file. # ArgTruncator truncates large write_file/edit_file args in history # to prevent context overflow. # 13. MAX_TURNS PROTECTION # When turn_count reaches max_turns, Agent auto-stops with # {:error, :max_turns_reached}. Default is 100. # Set max_turns: nil in Options for unlimited (use with caution). # 14. PROMPT QUEUEING # If Agent is busy (streaming/executing_tools), prompt/2 queues the message. # Returns %{queued: true}. Message processed after current task completes. ################################################################################ # 21. COMPLETE EXAMPLE — Full Agent Integration ################################################################################ # --- Setup --- {:ok, session} = CMDC.create_agent( model: "anthropic:claude-sonnet-4-5", tools: [ CMDC.Tool.ReadFile, CMDC.Tool.WriteFile, CMDC.Tool.EditFile, CMDC.Tool.Shell, CMDC.Tool.Grep, CMDC.Tool.Glob ], plugins: [ CMDC.Plugin.Builtin.SecurityGuard, CMDC.Plugin.Builtin.EventLogger, {CMDC.Plugin.Builtin.HumanApproval, tools: ["shell"]}, CMDC.Plugin.Builtin.MemoryLoader, CMDC.Plugin.Builtin.PatchToolCalls ], system_prompt: "You are a senior Elixir developer. Follow OTP conventions.", working_dir: "/path/to/project", skills_dirs: ["./skills"], memory: ["AGENTS.md"], provider_opts: [api_key: System.get_env("ANTHROPIC_API_KEY")], max_turns: 50 ) # --- Subscribe for events --- CMDC.subscribe(session) # --- Send prompt --- CMDC.prompt(session, "Refactor the User module to use typed structs") # --- Event loop --- defmodule EventLoop do def run(session) do receive do {:cmdc_event, sid, {:message_delta, %{delta: chunk}}} when is_binary(chunk) -> IO.write(chunk) run(session) {:cmdc_event, sid, {:tool_execution_start, name, _call_id, _args}} -> IO.puts("\n⚙ #{name}") run(session) {:cmdc_event, sid, {:approval_required, ^sid, tool, args, ref}} -> IO.puts("\n🔒 Approve #{tool}? #{inspect(args)}") CMDC.approve(session, ref) run(session) {:cmdc_event, _sid, {:agent_end, _msgs, usage}} -> IO.puts("\n✓ Done. Tokens: #{inspect(usage)}") {:cmdc_event, sid, {:error, ^sid, reason}} -> IO.puts("\n✗ Error: #{inspect(reason)}") after 120_000 -> IO.puts("Timeout") end end end EventLoop.run(session) # --- Cleanup --- CMDC.stop(session)