跳到主要内容

工具系统

这一章讲:LLM 怎么「请求」一个工具、插件怎么把这些请求排成队串行执行、审批怎么作为一道关卡插进来、以及一个工具长什么样。

1. 核心直觉:工具是「队列里的状态机」

LLM 一次回话可能要调好几个工具(读 3 个文件、再改 1 个)。CodeCompanion 不并发跑它们,而是推进一个队列,一个一个来。每个工具走同一套生命周期:

setup ──▶ 要审批吗?
│是

弹审批框 ──拒绝──▶ rejected 处理,跳下一个
│同意

Runner 跑 cmds ──▶ success / error 处理


on_exit ──▶ setup_next_tool(下一个)

为什么串行?因为工具会改文件、跑命令,顺序和原子性比并发吞吐重要得多——尤其当一个工具的输出是下一个的输入时。

2. 一个「工具」长什么样

工具是一张普通 Lua 表,约定几个字段。看最简单的 read_file(tools/builtin/read_file.lua:120):

return {
name = "read_file",
cmds = { function(self, args, input) ... end }, -- 真正干活的函数(可多个,串成链)
schema = { type = "function", ["function"] = { ... } }, -- 发给 LLM 的工具定义
handlers = { on_exit = ... }, -- 生命周期钩子
output = { -- 把结果回写到聊天/审批
cmd_string = ..., prompt = ..., success = ..., error = ..., rejected = ...,
},
}

四块各司其职:

字段干什么
cmds真正的执行逻辑;返回 { status, data },可异步(调 output_cb)
schemaJSON Schema 工具定义,注入 LLM 请求,告诉模型「有这个工具、参数长这样」
handlerssetup / prompt_condition / on_exit 等生命周期钩子
output把成功/失败/拒绝的结果格式化、回写聊天 buffer 和消息历史

3. 工具怎么进入 LLM 的视野:ToolRegistry

LLM 不会凭空知道有哪些工具——你得在请求里带上工具的 schema。ToolRegistry(tool_registry.lua)就管这件事。

当用户在消息里写 @{insert_edit_into_file} 或加了一个工具组,add_single_tool(tool_registry.lua:122)会:

  1. 解析工具(self.chat.tools.resolve)。
  2. 把工具的 system_prompt 作为隐藏系统消息加进历史(add_system_prompt)。
  3. 把工具的 schema 存进 self.schemas[id](add_schema,tool_registry.lua:93)。

回到 01 章:submit 拼 payload 时,tools = { self.tool_registry.schemas }(chat/init.lua:1376)——正是这里存的 schema 被发给了 LLM。

工具组(groups) 是个便利封装:config.lua:114agent 组把 read_file/grep_search/insert_edit_into_file/run_command 等一次性打包,还能 collapse_tools(在 UI 里折叠成一个标签)、ignore_system_prompt(用组自己的系统提示替换默认的)。

4. 执行流程:Orchestrator + Runner

4.1 入队

Tools:execute(tools/init.lua:265)拿到 LLM 的工具调用列表,逐个 _resolve_and_prepare_tool(解析名字、vim.json.decode 参数),成功的 push 进 orchestrator.queue,然后 orchestrator:setup_next_tool() 启动。

一个细节:_resolve_and_prepare_tool(tools/init.lua:124)会 vim.deepcopy(resolved_tool),注释直白写着「避免污染原始工具定义,否则有灾难性副作用」——因为工具表是模块级单例,跨多次调用共享。

4.2 审批关卡

Orchestrator:setup_next_tool(orchestrator.lua:258)是核心。它 pop 出下一个工具,跑 handlers.setup,然后在真正执行前判断要不要审批(orchestrator.lua:278):

-- 示意,非源码:审批的三层判定
if not already_approved(tool) then
local need = tool.opts.require_approval_before -- 配置:是否要审批
if type(need) == "function" then need = need(tool) end -- 可以是动态函数
if need and type(need) ~= "boolean" then
need = handlers.prompt_condition() -- 工具自己再判一次(如"改 buffer 不审、改文件审")
end
if need then ask_user(...) else execute_tool() end
end

审批框给四个选项(orchestrator.lua:305):

选项行为
always accept记住这个工具/命令,以后不再问(Approvals:always)
accept只批这一次
reject拒绝,问一句拒绝理由,继续下一个工具
cancel取消,清空整个队列(cancel_pending_tools)

这个设计把「人类在回路」做成了一道非阻塞关卡:拒绝单个工具不会中断整批,取消才会清队列。

4.3 Runner:跑命令链

批准后 execute_toolRunner(runtime/runner.lua)。一个工具的 cmds 是个函数链,Runner 一个个跑,前一个的输出作为后一个的 input(runner.lua:43go_to_next_tool)。

关键是它对同步和异步的统一处理(runner.lua:104):

-- 示意,非源码
local output = tool_cmd(tools, action, { input, output_cb = output_handler })
if output ~= nil then
output_handler(output) -- 同步工具:直接拿到返回值,立刻收尾
end
-- 异步工具:返回 nil,自己在回调里调 output_cb(... 见 cmd_to_func_tool)

同步工具直接 return { status, data };异步工具(如跑 shell 命令)返回 nil,在自己的回调里调 output_cb。Runner 用一个 tool_finished 布尔保证 output_cb 只生效一次,避免重复收尾。

4.4 cmd 工具 → func 工具的转换

有些工具的 cmds 是 shell 命令字符串而非函数。cmd_to_func_tool(orchestrator.lua:49)把它们包成函数。真正跑命令的脏活委托给同文件的辅助函数 execute_shell_command(orchestrator.lua:33):用 vim.system 跑命令,Windows 走单独的 cmd.exe /Q /K 分支(orchestrator.lua:34,PR #2186)。命令回来后,cmd_to_func_tool 在自己的回调里按退出码分 success/error,并顺手剥掉 ANSI 颜色码(strip_ansi,orchestrator.lua:15)再交给 output_cb

5. 巧妙之处

工具失败也回报给 LLM,而不是静默崩。 当某个工具名找不到,_handle_tool_error(tools/init.lua:64)不会让整批挂掉,而是给 LLM 回一条「X 工具不存在,可用的工具是 …」——让模型自我纠正。JSON 参数解析失败同理(tools/init.lua:137),回一句「你调 X 工具时参数写错了」。把错误当成对话的一部分,是 agent 鲁棒性的关键。

use_handlers_once:连续同名工具只跑一次 setup。 runner.lua:60 检查队列里下一个是否同名工具且带此选项,是的话复用当前的 handler 上下文直接跑——给「连续 10 次同一个命令」省掉重复初始化。

6. 边界与局限

  • 工具串行执行,没有并发;一个慢工具会卡住后面的。
  • 审批状态按 buffer 维度记(Approvals),换一个聊天 buffer 要重新批。
  • delete_filerun_command 默认 allowed_in_yolo_mode = false(config.lua:209:282)——即便开了「全自动批准」也不放行破坏性操作。

7. 横向对比

和把工具执行做成「无状态函数表」的轻量 agent 框架相比,CodeCompanion 把工具执行做成有队列、有审批、有生命周期钩子的状态机,代价是 orchestrator 这一层的复杂度,换来的是「人在回路 + 多工具有序 + 失败可恢复」。这与编码 agent 普遍的「propose → human review → apply」范式一致(参见 03 章的 diff 确认)。

8. 代码地图

主题文件符号
工具协调…/chat/tools/init.luaTools:executeTools.resolveTools:_resolve_and_prepare_tool
失败回报 LLM…/chat/tools/init.luaTools:_handle_tool_error
队列状态机…/chat/tools/orchestrator.luaOrchestrator:setup_next_toolOrchestrator:execute_toolcmd_to_func_toolexecute_shell_command
命令链执行…/chat/tools/runtime/runner.luaRunner:run_toolRunner:go_to_next_tool
schema 注入…/chat/tool_registry.luaToolRegistry:add_single_toolToolRegistry:add_group
一个样板工具…/chat/tools/builtin/read_file.lua(整文件)
工具组定义lua/codecompanion/config.luatools.groups.agent(约 :114)