Skip to main content

guides/tools.md

# 写一个 Tool

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

---

## Tool behaviour

实现 [`CMDC.Tool`](CMDC.Tool.html) 4 个 callback:

```elixir
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
```

挂载:

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

---

## 返回约定

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

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

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

## ctx 是什么

`ctx :: CMDC.Context.t()`
```elixir
%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`](CMDC.Context.html)
---

## Sandbox 代理模式

文件类工具不要直接调 `File.read/1` / `:os.cmd/1`**走 Sandbox 代理**
```elixir
@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)即可:

```elixir
@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](https://json-schema.org/) 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 Crash | ToolRunner 兜底 | raise → 注入合成 error result,不影响其他工具 |

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

---

## 范例 1 — HTTP API 调用

```elixir
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 多租户)

```elixir
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` 透传进去:

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

---

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

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

---

## 下一步

- [写一个 Plugin](plugins.html) — 在工具调用前后做切面拦截
- [常见配方](cookbook.html) — Tool + Plugin + Backend 的端到端组合