# 写一个 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 的端到端组合