跳到主要内容

工具系统:模型怎么「动手」

上一章讲了循环怎么转,本章讲循环里那一步「跑工具」的内部:工具长什么样、怎么被分发执行、危险操作怎么经过用户确认、执行怎么被取消。

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:607)发给模型;模型回的 tool_use.input 就是按这个 schema 来的 JSON。

2. 工具注册表 M._tools

所有内置工具列在一个数组里(llm_tools/init.lua:714M._tools)。一组真实的内置工具(按注册顺序节选):

工具干什么来源
dispatch_agent派生子 agent 跑独立子任务llm_tools/dispatch_agent.lua(init.lua:715)
glob / grep / ls找文件 / 搜内容 / 列目录init.lua:716852851
view读文件(可带行范围)init.lua:919
str_replace / write_to_file / insert改文件 / 整写 / 插入init.lua:918920921
edit_filefast-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.luainit.lua:718785

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:849)——开箱即用是「全自动批准」,符合「像 CLI agent 一样一路跑」的体感;想要每步确认的用户可改成 false 或工具名数组。点「all / allow_always」会把 session_ctx.always_yes = true(helpers.lua:80103),本次会话后续都不再问。

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:2195)把它置 true、关掉确认浮窗、发 AvanteLLMEscape autocmd。正在跑的工具靠 process_tool_use 里那个 100ms 轮询定时器(init.lua:1361)感知到并提前退出,回 CANCEL_TOKEN;_streamhandle_tool_result 认出这个 token 就优雅终止整条流(llm.lua:1872-1883)。

8. 边界与坑

  • 工具串行执行(见 02 章),只读工具也不并行。
  • 默认全自动批准:对不熟悉的用户,AI 可在项目内自由改文件/跑 bash 而不弹窗——这是体验与安全的取舍,改 auto_approve_tool_permissions 可收紧。
  • 取消是协作式的(工具需在轮询点之间让出),不是硬 kill;纯阻塞的同步工具理论上要等它自己返回。

9. 代码地图

主题文件符号
工具注册表lua/avante/llm_tools/init.luaM._tools
取菜单(过滤 disabled / enabled)lua/avante/llm_tools/init.luaM.get_toolsM.get_tool_names
分发执行 + 取消轮询 + 同异步归一lua/avante/llm_tools/init.luaM.process_tool_usehandle_result
权限确认 + 自动批准链lua/avante/llm_tools/helpers.luaM.confirm
文件访问沙箱边界lua/avante/llm_tools/helpers.luaM.has_permission_to_accessM.is_ignored
取消全局开关lua/avante/llm.luaM.cancel_inflight_request
改文件工具示例lua/avante/llm_tools/str_replace.luaM.func