数据截至 (上游 commit e839e559ac61)
工具体系:注册表、Tool 抽象、执行器与内置工具
30 秒导读: 模型只会「说话」,不会真的改文件、跑命令。这一章讲 Trae Agent 怎么给模型装上手脚:每个能力被封装成一个
Tool子类,它能把自己翻译成模型能看懂的 JSON 描述;一张注册表tools_registry按名字登记所有工具;ToolExecutor负责把模型说出来的「调用哪个工具、传什么参数」精确落到真实工具上并执行。读完你能讲清:工具如何定义、如何被模型看见、如何被执行,以及内置的几把工具各自干什么。
本章属于 Trae Agent 系列的第 2 章。上一章 01-agent-loop.md 讲主循环怎么把「提示 → 工具调用 → 结果 → 再提示」转起来;本章聚焦其中「工具」这一环。工具产物如何被翻译进不同厂商的请求里,见 03-llm-providers.md;CKG 工具的内部建图细节见 04-ckg.md;危险工具如何被关进 Docker 见 05-docker-execution.md。
1. 这是什么(零基础也能懂)
一句话定义: 「工具」是一段被包装成「模型可调用」形式的真实能力——比如「改文件」「跑 bash」「查 JSON」。
它解决什么问题? 大语言模型本身只能输出文本。你让它「把 config.json 里的端口改成 8080」,它只会说「我应该把端口改成 8080」,但不会真的动那个文件。要让它真正干活,得给它一组可以调用的工具,并约定一套「模型怎么表达要调用哪个工具、传什么参数」的协议。这一层就是本章的主题。
它长什么样(一次工具调用的直觉):
模型输出(它"想"调用一个工具):
调用 str_replace_based_edit_tool
参数 { command: "str_replace",
path: "/repo/config.json",
old_str: "\"port\": 3000",
new_str: "\"port\": 8080" }
│
▼
Trae Agent 收下这段"意图",找到对应工具、真的执行 →
文件被改,返回一段"改动后的片段"给模型看。
一句话类比: 把 Tool 想成一件带说明书的电器——get_description() / get_parameters() 是贴在机身上给模型读的说明书,execute() 是按下开关后真正发生的事;ToolExecutor 则是那个「听模型报菜名、去把对应电器打开」的操作员。
2. 顶层全景(工具体系怎么转)
三个主角,各司其职:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
Tool(抽象基类) | 定义每个工具的统一形状:名字 / 描述 / 参数 / 执行,并把自己翻译成模型看的 JSON | trae_agent/tools/base.py:74 |
tools_registry(注册表) | 一张「工具名 → 工具类」的字典,谁存在、叫什么由它说了算 | trae_agent/tools/__init__.py:27 |
ToolExecutor(执行器) | 拿到模型给的调用,按名字找到工具、执行、收集结果;支持串行/并行 | trae_agent/tools/base.py:182 |
主线走一遍(高层,不进代码):
① 建工具 ② 给模型看 ③ 模型点单 ④ 执行器落地
tools_registry[name]() 每个 tool.json_definition() 模型回一个 ToolCall ToolExecutor 按"归一化名字"
→ 一堆 Tool 实例 → 拼进 LLM 请求(见 03 章) (name + arguments) 找到 tool → tool.execute()
→ ToolResult 回给模型
其中 ② 的「翻译成 JSON」是 Tool.get_input_schema() 的活;④ 的「按名字找到工具」是 ToolExecutor._normalize_name 的活。下面逐个拆开。
主循环里在 ③④ 之间做「串行还是并行」的选择,不在执行器内部,而在 base_agent.py 的 _tool_call_handler 里按配置决定(见 §5.3)。
3. 核心机制:Tool 抽象基类
3.1 它要解决的小问题
想让 N 种能力都能「被模型看见、被统一调用」,就得给它们规定同一个形状。Tool 就是这个形状——一个抽象基类,强制每个工具回答四个问题:
| 抽象方法 | 回答的问题 | 位置 |
|---|---|---|
get_name() | 我叫什么 | base.py:101 |
get_description() | 我干什么(给模型读的自然语言说明书) | base.py:106 |
get_parameters() | 我要哪些参数(每个参数的名字/类型/是否必填/枚举) | base.py:111 |
execute(arguments) | 真正执行,返回 ToolExecResult | base.py:116 |
这四个都是 @abstractmethod(base.py:100-118),不实现就没法实例化。名字、描述、参数还包了一层 @cached_property(base.py:84-94),第一次取值后缓存,避免每次都重算。
3.2 教学示例:一个最小的 Tool 子类
这段演示「实现一个工具最少要写什么」。重点看:四个抽象方法各回答一件事,execute 返回 ToolExecResult。
# 示意,非源码
from trae_agent.tools.base import Tool, ToolParameter, ToolExecResult
class EchoTool(Tool):
def get_name(self): # 我叫 echo
return "echo"
def get_description(self): # 给模型读的说明书
return "把传入的 text 原样返回。"
def get_parameters(self): # 只要一个必填字符串参数
return [ToolParameter(name="text", type="string",
description="要回显的文本", required=True)]
async def execute(self, arguments): # 真正干活
return ToolExecResult(output=str(arguments.get("text", "")))
# 重点看:你只写"这四件事",基类负责把它翻译成模型能看懂的 JSON。
3.3 关键细节:get_input_schema() 怎么把工具翻译给模型
模型请求里工具长这样:{name, description, parameters},由 json_definition()(base.py:120)拼出,其中 parameters 交给 get_input_schema()(base.py:127)生成一份标准 JSON Schema。
普通情况很直白:遍历 self.parameters,每个参数生成 {type, description},必填的追加进 required 列表,有 enum / items 就补上。真正微妙的是OpenAI 的 strict 模式,get_input_schema 为它做了三处特殊处理(base.py:144-173):
| 处理 | 为什么 | 代码 |
|---|---|---|
所有参数都进 required | strict 模式要求每个属性都在 required 里 | base.py:145-146 |
可选参数把类型标成 nullable(如 "string" → ["string","null"]) | 既满足「必须 required」又表达「其实可以不给」——用 null 顶替 | base.py:147-151 |
顶层和嵌套 object 都加 additionalProperties: false | strict 模式禁止 schema 外的额外字段 | base.py:162-163、base.py:172-173 |
非 OpenAI 供应商则走普通分支:只有 param.required 为真才进 required(base.py:152-153)。是不是 OpenAI,由构造时传入的 model_provider 决定——同一个工具类,面向不同厂商会生成不同形状的 schema。这就是为什么工具实例化时都带 model_provider=provider(见 §4)。
顺带一提 close()(base.py:177):默认空实现,但持有资源的工具(如 bash 的子进程)会覆盖它, 在任务收尾时释放资源。源码里特意 return None 而不是 pass,是为绕过 Ruff 的 B027 检查(注释见 base.py:179)。
4. 核心机制:注册表与默认工具集
4.1 tools_registry——名字到类的字典
所有内置工具在一处登记(tools/__init__.py:27-34):
tools_registry: dict[str, type[Tool]] = {
"bash": BashTool,
"str_replace_based_edit_tool": TextEditorTool,
"json_edit_tool": JSONEditTool,
"sequentialthinking": SequentialThinkingTool,
"task_done": TaskDoneTool,
"ckg": CKGTool,
}
注意键是字符串名字、值是类本身(不是实例)。这样配置文件里只需写工具名,运行时再 tools_registry[name](model_provider=...) 实例化。base_agent.py:42-45 就这么做:按 agent_config.tools 里的名字逐个查表实例化,并统一把当前 model_provider 传进去(呼应 §3.3)。
4.2 TraeAgentToolNames——不配置时的默认集
若任务没指定工具,TraeAgent 用一份默认清单(agent/trae_agent.py:21-27):
| 默认工具名 | 注册表里对应的类 |
|---|---|
str_replace_based_edit_tool | TextEditorTool |
sequentialthinking | SequentialThinkingTool |
json_edit_tool | JSONEditTool |
task_done | TaskDoneTool |
bash | BashTool |
new_task 里:当 tool_names is None and len(self._tools) == 0 时,回落到 TraeAgentToolNames,再查表实例化(trae_agent.py:112-119)。注意这份默认集不含 ckg——CKG 是可选加挂的能力,注册表里有,但不进默认清单。
5. 核心机制:ToolExecutor
ToolExecutor(base.py:182)是「把模型说的调用落到真实工具」的操作员。构造时接一组 Tool 实例(base.py:185)。它做三件事:名字容错匹配、执行单个调用、按串行/并行批量执行。
5.1 _normalize_name——模糊匹配,容忍名字写法
模型不总是把工具名一字不差地复述回来——大小写、下划线常有出入。执行器的对策:把名字全转小写、去掉所有下划线再比(base.py:195-197):
def _normalize_name(self, name: str) -> str:
return name.lower().replace("_", "")
于是 str_replace_based_edit_tool、STR_REPLACE_BASED_EDIT_TOOL、strreplacebasededittool 都归一化成同一个键。tools 属性(base.py:199-203)正是用归一化后的名字建索引:{self._normalize_name(tool.name): tool for tool in self._tools},且惰性缓存进 _tool_map,只建一次。
怎么读这条降级链: 从模型给的原始名字出发,归一化后能匹配就命中,否则报「工具不存在」并列出可用工具名:
模型给的 name
│ _normalize_name(): 转小写 + 去下划线
▼
"strreplacebasededittool"
│ 在 self.tools(同样归一化过的键)里查
├── 命中 → 拿到 Tool 实例,执行
└── 未命中 → ToolResult(success=False,
error="Tool '...' not found. Available tools: [...]")
5.2 execute_tool_call——执行单个调用
execute_tool_call(base.py:205)是核心:
- 归一化名字,不在表里就返回失败的
ToolResult,错误信息里附上所有可用工具名(base.py:207-215)。 - 命中则
await tool.execute(tool_call.arguments),把返回的ToolExecResult包成ToolResult;success由error_code == 0决定(base.py:220-228)。 - 整段包在 try/except 里——工具内部抛异常不会掀翻整个循环,而是转成一个
success=False的ToolResult(base.py:229-236)。
这层「异常 → 失败结果」的兜底很关键:模型给的参数可能是错的,工具执行可能失败,但这些都应作为「一条反馈」回给模型让它自我纠正,而不是让 agent 崩掉。
5.3 并行 vs 串行——由配置决定,在主循环处选择
执行器提供两种批量执行(base.py:238-244):
| 方法 | 做法 | 位置 |
|---|---|---|
parallel_tool_call | asyncio.gather(*[...]) 一起跑,所有调用并发 | base.py:238-240 |
sequential_tool_call | 列表推导逐个 await,一个跑完才跑下一个 | base.py:242-244 |
选哪个不在执行器里决定,而在主循环的 _tool_call_handler(agent/base_agent.py:314)里按 self._model_config.parallel_tool_calls 分支(base_agent.py:331-334):
# base_agent.py:331-334
if self._model_config.parallel_tool_calls:
tool_results = await self._tool_caller.parallel_tool_call(tool_calls)
else:
tool_results = await self._tool_caller.sequential_tool_call(tool_calls)
该开关来自配置(utils/config.py:40 的 parallel_tool_calls 字段;旧配置默认 False,见 legacy_config.py:117)。配置分层细节见 06-observability.md。
5.4 close_tools——收尾释放资源
任务结束时,close_tools(base.py:189-193)对每个工具 await tool.close() 并 asyncio.gather 一起等完。默认 close() 是空的(§3.3),但 bash 这类持有子进程的工具会在这里真正关掉进程。
6. 内置工具逐个看
先一句话过一遍,再挑几把细讲。
| 工具类 | 注册名 | 一句话职责 |
|---|---|---|
TextEditorTool | str_replace_based_edit_tool | 看 / 建 / 精确替换 / 插入文件内容——编码 agent 的主力手 |
BashTool | bash | 在一个持久的 bash 会话里跑命令 |
JSONEditTool | json_edit_tool | 用 JSONPath 表达式精确改 JSON 文件 |
SequentialThinkingTool | sequentialthinking | 让模型把复杂问题拆成可修正的思考步骤(纯记录,不动外部世界) |
TaskDoneTool | task_done | 模型用它宣布「任务完成」,是主循环的收尾信号 |
CKGTool | ckg | 查代码知识图谱(按函数/类/方法名搜),内部见 04-ckg.md |
6.1 TextEditorTool(重点)——精确、唯一的字符串替换
这是整套工具里工程含量最高的一把,edit_tool.py。它有四个子命令(edit_tool.py:18-23):view / create / str_replace / insert,由 command 参数分派(edit_tool.py:117-130 的 match)。
四个子命令一句话:
| 子命令 | 干什么 | 实现 |
|---|---|---|
view | 文件则 cat -n 风格带行号显示(支持 view_range);目录则 find 列到 2 层深 | edit_tool.py:154(_view) |
create | 新建文件——路径已存在则拒绝,不覆盖 | edit_tool.py:322(_create_handler) |
str_replace | 把 old_str 精确替换成 new_str | edit_tool.py:197(str_replace) |
insert | 在指定行之后插入新内容 | edit_tool.py:238(_insert) |
最巧的是 str_replace 的「精确且唯一」匹配(edit_tool.py:197-236)。它不做模糊匹配,而是要求 old_str 在文件里恰好出现一次:
- 读文件和
old_str都先.expandtabs()(edit_tool.py:200-202),把 tab 展开成空格再比,避免「看着一样、tab/空格不同」导致匹配不上。 - 数出现次数(
file_content.count(old_str),edit_tool.py:205):0 次报「没出现,未替换」(edit_tool.py:206-209);多于 1 次则报错并列出所有命中的行号,要求模型补足上下文让它唯一(edit_tool.py:210-215)。 - 只有恰好 1 次才替换。
替换后回显一段「SNIPPET」上下文(edit_tool.py:223-232):算出替换发生在第几行,取前后各 SNIPPET_LINES=4 行(edit_tool.py:24),用 _make_output 带行号打印出来给模型确认。这让模型「看见」自己改动的效果,便于自查。
下面示意这个「唯一性」保护为什么重 要:
# 示意,非源码
文件里有两处 "port": 3000
模型说:把 "port": 3000 → 8080
│ count(old_str) == 2
▼
拒绝!报错:"Multiple occurrences ... in lines [4, 19]. Please ensure it is unique"
→ 逼模型带上足够上下文(如 "\"db\": { \"port\": 3000 }")让目标唯一
前置校验 validate_path(edit_tool.py:134-152)在执行前挡下常见错误:路径必须是绝对路径(否则提示补 /);非 create 命令要求路径存在;create 命令要求路径不存在(不覆盖);目录只能用 view。
6.2 BashTool 与持久 session
BashTool(bash_tool.py:162)把命令送进一个长期存活的 bash 子进程 _BashSession(bash_tool.py:19),而非每条命令起一个新 shell。所以 cd、导出的环境变量在多次调用间保留——描述里也明说「State is persistent across command calls」(bash_tool.py:185)。
实现的巧点是用哨兵字符串探测命令结束:每条命令后追加 echo <哨兵>,哨兵里嵌了退出码占位;读输出直到看见哨兵,再从哨兵解析出 error code(bash_tool.py:108-141)。这样无需为每条命令重开进程,也能拿到退出码。restart 参数可重启会话,close()(bash_tool.py:240-247)覆盖基类,任务收尾时真的停掉子进程。持久 session 的更多细节这里点到为止。
6.3 SequentialThinkingTool——把「想」也变成一次调用
这把工具不碰外部世界,它只是给模型一个结构化的思考载体:每次调用记录一个 ThoughtData(sequential_thinking_tool.py:19-29),字段含 thought / thought_number / total_thoughts / next_thought_needed,还能标 is_revision(修正前面某步)、branch_from_thought(分支)。执行时把这次思考追加进 thought_history(sequential_thinking_tool.py:156),并做基本校验(_validate_thought_data,sequential_thinking_tool.py:163)。作用是让模型把复杂问题显式拆成 可回溯、可修正的步骤,而不是一口气闷头输出。
6.4 TaskDoneTool——收尾信号
最简单的一把(task_done_tool.py):无参数(get_parameters 返回 [],task_done_tool.py:28-29),execute 永远返回 "Task done."(task_done_tool.py:32-33)。它的价值不在执行结果,而在语义:模型调用它就等于宣布任务完成,主循环据此结束(见 01-agent-loop.md)。描述里还特意叮嘱「验证之前不能调用它」(task_done_tool.py:25)。
6.5 JSONEditTool——JSONPath 精确改 JSON
json_edit_tool.py 用 jsonpath_ng 库,以 JSONPath 表达式定位 JSON 里的目标节点做四种操作(json_edit_tool.py:56-90):view / set / add / remove。JSONPath 例如 $.users[0].name、$.config.database.host、$.items[*].price(描述见 json_edit_tool.py:33-53)。相比用 str_replace 硬改 JSON 文本,它按结构定位,更不容易改坏格式;file_path 强制绝对路径(json_edit_tool.py:104-108)。
6.6 CKGTool——查代码知识图谱(一句话带过)
ckg_tool.py 提供三个搜索命令(ckg_tool.py:11):search_function / search_class / search_class_method,按标识符在代码知识图谱里查函数/类/方法,可选是否打印函数体(print_body)。它的 CKG 用 tree-sitter 建图、SQLite 存储、带快照缓存——内部实现见 04-ckg.md,本章不展开。
7. 巧妙之处(可带走的技术)
- 同一个工具类,面向不同厂商生成不同 schema。
get_input_schema里用model_provider分支,为 OpenAI strict 模式把可选参数标 nullable、加additionalProperties: false,而其它供应商走普通 required 逻辑(base.py:144-173)。把「厂商差异」收在工具翻译层,上游无感。 - 名字容错匹配。
_normalize_name用「小写 + 去下划线」抹平模型复述工具名时的写法漂移,让str_replace_based_edit_tool这类长名字有很强容错(base.py:195-197)。 - 编辑靠「唯一性」而非「模糊匹配」。 str_replace 要求
old_str恰好出现一次,多于一次就报错并列出行号,逼模型补上下文——用「拒绝改错」换「不会改错地方」(edit_tool.py:205-215)。 - 异常降级为反馈。 工具执行的任何异常都被
execute_tool_call兜成success=False的ToolResult回给模型自纠,而非中断 agent(base.py:229-236)。 - 持久 bash + 哨兵探测退出。 一个长活 shell 保留状态,靠追加
echo <哨兵>解析退出码,免去每命令重开进程(bash_tool.py:108-141)。
8. 边界与局限(诚实)
- str_replace 不做模糊匹配。
old_str必须逐字符(tab 展开后)精确出现且唯一,写错一个空格就替换失败——这是刻意的安全取舍,不是 bug(edit_tool.py:206-215)。 create不覆盖已存在文件。 描述里甚至提示「若已存在请先删再建」(edit_tool.py:46),validate_path会硬拒(edit_tool.py:144-147)。undo_edit已被移除。 源码显式注明本版本不实现撤销(edit_tool.py:276),改错了只能再改回去。- 默认工具集不含 CKG。
TraeAgentToolNames里没有ckg(trae_agent.py:21-27),需要它得显式配置。 - 并行/串行是全局开关。 由
parallel_tool_calls一个配置决定,不能按单个调用细分(base_agent.py:331-334)。