数据截至 (上游 commit 1a36a37b1a96)
工具系统:模型怎么「动手」
上一章讲了循环怎么转,本章讲循环里那一步「跑工具」的内部:工具长什么样、怎么被分发执行、危险操作怎么经过用户确认、执行怎么被取消。
1. 一个工具长什么样
avante 的工具就是一个 Lua 表,带「名字 + 描述 + 参数 schema + 返回 schema + 实现函数」。看 str_replace 工具(llm_tools/str_replace.lua)的骨架:
-- 示意,基于 str_replace.lua:6-55 的真实结构
M.name = "str_replace" -- 工具名(模型按这个名字调用)
M.description = "...replace a specific string in a file..." -- 给模型看的说明
M.enabled = function() -- 何时启用(按 config 动态开关)
return Config.mode == "agentic" and not Config.behaviour.enable_fastapply
end
M.param = { type = "table", fields = { -- 参数 schema(转成各家 API 的 tool schema)
{ name = "path", type = "string", description = "..." },
{ name = "old_str", type = "string", description = "must match exactly..." },
{ name = "new_str", type = "string", description = "..." },
}}
M.returns = { ... } -- 返回 schema
function M.func(input, opts) ... end -- 实现
描述、参数 schema 会被各 provider 转成该家 API 的工具定义(如 Claude 的 self:transform_tool,providers/claude.lua:632)发给模型;模型回的 tool_use.input 就是按这个 schema 来的 JSON。
2. 工具注册表 M._tools
所有内置工具列在一个数组里(llm_tools/init.lua:732 起 M._tools)。一组真实的内置工具(按注册顺序节选):
| 工具 | 干什么 | 来源 |
|---|---|---|
dispatch_agent | 派生子 agent 跑独立子任务 | llm_tools/dispatch_agent.lua(init.lua:715) |
glob / grep / ls | 找文件 / 搜内容 / 列目录 | init.lua:716、852、851 |
view | 读文件(可带行范围) | init.lua:919 |
str_replace / write_to_file / insert | 改文件 / 整写 / 插入 | init.lua:918、920、921 |
edit_file | fast-apply 编辑(走 Morph 模型) | init.lua:1198 |
bash | 跑 shell 命令 | init.lua:1196 |
write_todos / read_todos | 维护任务清单 | init.lua:854-855 |
think | 让模型显式「思考」一步 | init.lua:1194 |
get_diagnostics | 取 LSP 诊断 | init.lua:1195 |
attempt_completion | 宣告任务完成(收尾) | init.lua:1197 |
rag_search / git_diff / git_commit / run_python / web_search / fetch | 内联定义在 init.lua 里 | init.lua:718、785 等 |
M.get_tools(user_input, history_messages)(init.lua:682)负责把内置工具 + 用户自定义工具合并,并按两条规则过滤:被 Config.disabled_tools 显式禁的剔除;有 enabled 函数的按其返回值决定(init.lua:689-697)。
直觉:工具表是「能力菜单」,
get_tools按当前模式/配置给模型端上一份裁剪过的菜单——比如关了 fast-apply 时给str_replace,开了就给edit_file。
3. 自定义工具
用户可在 setup() 里塞 custom_tools(init.lua:683),每个工具给名字、param、returns 和一个 func——可以跑 shell、脚本或纯 Lua。文件头部的 doc 注释给了完整示例(init.lua:32-64):一个 run_go_tests 工具,func 里直接 vim.system({ "go", "test", ... })。这让 avante 的能力可被项目级扩展。
4. 分发执行:process_tool_use
循环里跑工具的统一入口是 M.process_tool_use(tools, tool_use, opts)(init.lua:1331)。它做四件事:
(1) 先查取消(init.lua:1335):如果用户已取消,立刻回 CANCEL_TOKEN,不执行。
(2) 按名找实现(init.lua:1344-1356):对 Claude 原生编辑工具 str_replace_editor / str_replace_based_edit_tool 有特判,其余从工具表里按 name 找,取 tool.func(或 M[tool.name] 这种内联实现)。
(3) 起一个取消轮询定时器(init.lua:1361-1380):每 100ms 检查 Helpers.is_cancelled,一旦置位就停掉工具、回 CANCEL_TOKEN。这让长时间工具(如 bash)能被中途打断。
(4) 调用实现并归一结果(init.lua:1411-1442):
-- init.lua:1411 起(节选):同步 or 异步,统一由 handle_result 归一
local result, err = func(input_json, {
session_ctx = opts.session_ctx or {},
on_log = function(log) ... end, -- 工具日志回流 UI
on_complete = function(result, err) -- 异步工具走这里
result, err = handle_result(result, err)
on_complete(result, err)
end,
streaming = opts.streaming,
tool_use_id = opts.tool_use_id,
})
-- result 和 err 都是 nil → 说明工具走异步(将通过 on_complete 返回)
if result == nil and err == nil and on_complete then return end
return handle_result(result, err)
同步/异步双模式是这里的关键:工具可以直接 return result, err(同步),也可以两个都返回 nil 然后稍后调 opts.on_complete(异步)。改文件类工具就是异步的——因为要等用户在确认框里点「接受」。handle_result(init.lua:1384)统一把结果转成字符串、把非字符串结果 vim.json.encode、并打日志。
5. 权限确认:Helpers.confirm
危险操作(写文件、删除、移动、git_commit、跑 python……)在执行前都过 Helpers.confirm(message, callback, ...)(llm_tools/helpers.lua:55)。它的批准优先级链:
Helpers.confirm(...)
│
├─ session_ctx.always_yes? → 直接 callback(true) (本会话已选过 "全部允许")
├─ behaviour.auto_approve == true? → 直接 callback(true) (配置:全自动批准)
├─ auto_approve 是数组且含本工具名? → 直接 callback(true) (配置:按工具名白名单)
├─ confirmation_ui_style==inline? → 渲染内联按钮,点 allow_always 时置 always_yes
└─ 否则 → 弹 Confirm 浮窗(yes / all / no)
看真实代码(helpers.lua:57-75):
-- helpers.lua:57 起(节选):三道「免确认」捷径
if session_ctx and session_ctx.always_yes then callback(true); return end
local auto_approve = Config.behaviour.auto_approve_tool_permissions
if auto_approve == true then callback(true); return end
if type(auto_approve) == "table" and vim.tbl_contains(auto_approve, tool_name) then
callback(true); return
end
注意默认配置里 auto_approve_tool_permissions = true(config.lua:843)——开箱即用是「全自动批准」,符合「像 CLI agent 一样一路跑」的体感;想要每步确认的用户可改成 false 或工具名数组。点「all / allow_always」会把 session_ctx.always_yes = true(helpers.lua:80、103),本次会话后续都不再问。
6. 访问边界:has_permission_to_access
所有碰文件的工具先过 Helpers.has_permission_to_access(abs_path)(helpers.lua:148)。规则:
- 路径必须是绝对路径,且必须落在项目根或 Neovim 配置目录之内(
helpers.lua:149-156)——防止 AI 乱碰项目外的文件。 - 默认还会拒绝
.gitignore忽略的文件(is_ignored,helpers.lua:131,优先用git check-ignore,失败再回退到自己解析.gitignore),除非配allow_access_to_git_ignored_files(helpers.lua:155)。
这是一道「沙箱」边界:工具能力很强(能跑 bash、改文件),但能碰的范围被钉死在项目内 + 非 ignore 文件。
7. 取消机制
取消是一个全局软开关 LLMToolHelpers.is_cancelled。用户触发取消时 M.cancel_inflight_request(llm.lua:2207)把它置 true、关掉确认浮窗、发 AvanteLLMEscape autocmd。正在跑的工具靠 process_tool_use 里那个 100ms 轮询定时器(init.lua:1361)感知到并提前退出,回 CANCEL_TOKEN;_stream 的 handle_tool_result 认出这个 token 就优雅终止整条流(llm.lua:1884-1895)。
8. 边界与坑
- 工具串行执行(见 02 章),只读工具也不并行。
- 默认全自动批准:对不熟悉的用户,AI 可在项目内自由改文件/跑 bash 而不弹窗——这是体验与安全的取舍,改
auto_approve_tool_permissions可收紧。 - 取消是协作式的(工具需在轮询点之间让出),不是硬 kill;纯阻塞的同步工具理论上要等它自己返回。
9. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 工具注册表 | lua/avante/llm_tools/init.lua | M._tools |
| 取菜单(过滤 disabled / enabled) | lua/avante/llm_tools/init.lua | M.get_tools、M.get_tool_names |
| 分发执行 + 取消轮询 + 同异步归一 | lua/avante/llm_tools/init.lua | M.process_tool_use、handle_result |
| 权限确认 + 自动批准链 | lua/avante/llm_tools/helpers.lua | M.confirm |
| 文件访问沙箱边界 | lua/avante/llm_tools/helpers.lua | M.has_permission_to_access、M.is_ignored |
| 取消全局开关 | lua/avante/llm.lua | M.cancel_inflight_request |
| 改文件工具示例 | lua/avante/llm_tools/str_replace.lua | M.func |