Tool 是 LLM 视角的「函数」。本章覆盖 Tool behaviour、Sandbox 代理模式、参数 schema、错误处理约定,以及 3 个完整 Tool 范例。


Tool behaviour

实现 CMDC.Tool 4 个 callback:

defmodule MyApp.MyTool do
  @behaviour CMDC.Tool

  @impl true
  def name, do: "my_tool"

  @impl true
  def description, do: "做点什么的工具"

  @impl true
  def parameters do
    %{
      "type" => "object",
      "properties" => %{
        "query" => %{"type" => "string", "description" => "查询字符串"},
        "limit" => %{"type" => "integer", "default" => 10}
      },
      "required" => ["query"]
    }
  end

  @impl true
  def execute(args, ctx) do
    query = Map.fetch!(args, "query")
    limit = Map.get(args, "limit", 10)

    case do_search(query, limit) do
      {:ok, results} -> {:ok, format(results)}
      {:error, reason} -> {:error, "search failed: #{reason}"}
    end
  end
end

挂载:

{:ok, session} = CMDC.create_agent(
  model: "...",
  tools: [MyApp.MyTool]
)

返回约定

execute/2 必须返回三种之一:

返回含义注入到对话
{:ok, text}成功,text 给 LLMtool_result 消息(is_error: false
{:error, text}失败,text 给 LLM 自我纠正tool_result 消息(is_error: true
{:effect, term}仅副作用,无文本回写不注入对话(如 WriteTodosctx.todos

惯例

  • text 限制在 2-4KB;超大结果用 LargeResultOffload Plugin 自动落盘
  • error text 写人类可读的失败原因,让 LLM 能自己修
  • 不要在 execute 里 raise——除非你确实想让 ToolRunner 回退到 retry 机制 (详见 Options.tool_max_retries

ctx 是什么

ctx :: CMDC.Context.t()

%CMDC.Context{
  session_id: "ar-123",
  working_dir: "/tmp/work",
  model: "anthropic:claude-sonnet-4-5",
  sandbox: CMDC.Sandbox.Local,
  backend: nil,
  todos: [...],
  memory_contents: %{},
  user_data: %{tenant_id: "...", ...},  # 来自 Options 透传
  turn: 3,
  total_tokens: 1234,
  cost_usd: 0.0123,
  ...
}

几个高频字段

  • ctx.working_dir — 工具操作的根目录
  • ctx.user_data — 业务自定义数据,从 create_agent(opts ++ user_data: ...) 透传
  • ctx.session_id — 当前会话 ID(多租户隔离 key)
  • ctx.sandbox — 文件操作代理(见下节)

完整字段见 CMDC.Context


Sandbox 代理模式

文件类工具不要直接调 File.read/1 / :os.cmd/1走 Sandbox 代理

@impl true
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

为什么

  1. 单元测试可以注入 mock Sandbox,不依赖真实文件系统
  2. 生产可切到 Sandbox.Docker / Sandbox.Modal 等远端实现
  3. virtual_mode: true 时 Sandbox 会校验路径不能逃逸 working_dir,防 traversal

Backend 的关系:未来版本 CMDC.Sandbox extends CMDC.Backend,目前两个 behaviour 并存。新 Tool 直接读 ctx.backend(如果非 nil)即可:

@impl true
def execute(args, %{backend: backend} = ctx) when not is_nil(backend) do
  case backend.read(args["path"], offset: 0, limit: nil) do
    %CMDC.Backend.Results.ReadResult{error: nil, content: content} ->
      {:ok, content}
    %CMDC.Backend.Results.ReadResult{error: reason} ->
      {:error, to_string(reason)}
  end
end

参数 schema

parameters/0 返回标准 JSON Schema map。 Provider 层(req_llm)会自动转换为各家 LLM 的 tool schema 格式。

惯例

  • "type": "object" 在最外层
  • 每个属性都填 "description",LLM 看得到
  • "required" 数组列必填字段
  • 默认值用 "default" 字段(LLM 可能忽略,所以你的 execute 也要做默认)

错误处理三层

谁负责例子
L1 参数校验Tool 自己{:error, "missing 'path' argument"}
L2 业务失败Tool 自己{:error, "file not found: /etc/secret"}
L3 CrashToolRunner 兜底raise → 注入合成 error result,不影响其他工具

L1 / L2 写人类可读的英文(LLM 大多数训练语料是英文,自我纠正更稳);L3 不 建议主动用,除非你想让 Options.tool_max_retries 重试。


范例 1 — HTTP API 调用

defmodule MyApp.WeatherTool do
  @behaviour CMDC.Tool

  @impl true
  def name, do: "get_weather"

  @impl true
  def description, do: "获取指定城市的当前天气"

  @impl true
  def parameters do
    %{
      "type" => "object",
      "properties" => %{
        "city" => %{"type" => "string", "description" => "城市英文名,如 'shanghai'"}
      },
      "required" => ["city"]
    }
  end

  @impl true
  def execute(%{"city" => city}, _ctx) do
    case Req.get!("https://wttr.in/#{city}?format=3") do
      %{status: 200, body: body} -> {:ok, String.trim(body)}
      %{status: code} -> {:error, "weather API returned #{code}"}
    end
  rescue
    e -> {:error, "weather lookup failed: #{Exception.message(e)}"}
  end
end

范例 2 — 数据库查询(带 user_data 多租户)

defmodule MyApp.QueryDB do
  @behaviour CMDC.Tool

  @impl true
  def name, do: "query_db"

  @impl true
  def description, do: "在用户的数据库分片上执行只读 SQL"

  @impl true
  def parameters do
    %{
      "type" => "object",
      "properties" => %{
        "sql" => %{"type" => "string", "description" => "只读 SELECT 语句"}
      },
      "required" => ["sql"]
    }
  end

  @impl true
  def execute(%{"sql" => sql}, %{user_data: %{tenant_id: tid}} = _ctx) do
    if String.starts_with?(String.trim(sql), "SELECT ") do
      MyApp.Repo.with_tenant(tid, fn ->
        case MyApp.Repo.query(sql) do
          {:ok, %{columns: cols, rows: rows}} ->
            {:ok, format_table(cols, rows)}
          {:error, %{message: msg}} ->
            {:error, "SQL error: #{msg}"}
        end
      end)
    else
      {:error, "only SELECT statements allowed"}
    end
  end

  def execute(_, _), do: {:error, "missing tenant_id in user_data"}

  defp format_table(cols, rows) do
    [Enum.join(cols, " | ") | Enum.map(rows, &Enum.join(&1, " | "))]
    |> Enum.join("\n")
  end
end

挂载时把 tenant_id 透传进去:

{:ok, session} = CMDC.create_agent(
  model: "...",
  tools: [MyApp.QueryDB],
  user_data: %{tenant_id: "acme-corp"}
)

范例 3 — 副作用工具(写 todos)

defmodule MyApp.AddTodo do
  @behaviour CMDC.Tool

  @impl true
  def name, do: "add_todo"

  @impl true
  def description, do: "向用户的 todo 列表追加一项"

  @impl true
  def parameters do
    %{
      "type" => "object",
      "properties" => %{
        "title" => %{"type" => "string"},
        "due" => %{"type" => "string", "description" => "ISO 8601 deadline,可选"}
      },
      "required" => ["title"]
    }
  end

  @impl true
  def execute(%{"title" => title} = args, ctx) do
    todo = %{
      id: Ecto.UUID.generate(),
      title: title,
      due: Map.get(args, "due"),
      created_at: DateTime.utc_now()
    }

    new_todos = ctx.todos ++ [todo]

    {:effect, {:update_context, :todos, new_todos}}
  end
end

{:effect, ...} 不会写 tool_result 进对话历史,但 Agent 会按 effect 类型 执行副作用(这里更新 ctx.todos),并 emit {:todo_change, sid, todos} 事件。


11 个内置 Tool

Tool用途走 Sandbox?
ReadFile读文件(offset / limit 分页)
WriteFile写文件(覆盖)
EditFile字符串替换
Shell执行命令(大输出自动落临时文件)
Grep正则 + glob 搜索
Glob模式匹配文件名
ListDir列目录
Task派发子代理
WriteTodos更新 ctx.todos
AskUser主动向用户提问
CompactConversation手动触发上下文压缩

下一步