Current section

Files

Jump to
cmdc llms-full.txt
Raw

llms-full.txt

# 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 响应中 <status> 标签内容
# {:title_generated, title} — LLM 响应中 <title> 标签内容(限 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)