第 03 章:用工具调用与 Bash 构建第一个 Agent 循环
适用版本:0.3.x · 源码与发布标签:chapter-03 · 程序版本:0.3.0(2026-10-21 发布)
问 Mino:“这台电脑安装的 Go 是什么版本?”如果还没有检查过,对话历史也无法提供这个事实。本章让 Mino 有办法取得它:模型请求执行一条 Bash 命令,你批准操作,程序再把工具结果交回模型。
请使用 chapter-03 发布包或对应的源码。开始交互前,先完成准备与安装。
1. 一个问题把决定权交回你手中
在你希望运行命令的目录启动 Mino,请它用 go version 检查已安装的 Go 版本。是否请求工具由模型决定;在回答里写出命令,并不会执行它。
交互示意,节选。完整批准界面还会显示解析后的工作目录、转义后的环境、超时、输出上限和账户权限提示,此处省略这些行。版本号和模型措辞均为示例,不是实测记录。
You> 运行 go version,告诉我安装的 Go 是什么版本。
Assistant>
[Tool: bash]
Command: "go version"
Approve this operation? [y/N] y
[Tool result: completed; exit code 0]
go version go1.27.1 darwin/arm64
Assistant> 这台电脑安装的是面向 Apple Silicon Mac 的 Go 1.27.1。
You>第一次模型响应可以只有工具调用,因此空的 Assistant> 是有效情况。Mino 等你决定,执行获批命令,显示工具结果,再把结果发回去。模型随后可以回答,也可以再次请求工具。这种反复交接就是 Agent 循环。一轮问答现在涵盖原始输入、中间的工具步骤,以及最终回答。
再问一次同样的问题,在批准提示处输入 n,或者直接回车。Mino 不会执行这条命令,而是提供状态为 denied 的工具结果,让模型说明未能完成检查。拒绝执行是程序确定的行为;模型是否解释得清楚,是另一个问题。
2. Agent 循环
第 1 节从终端这一侧展示了循环。本节换到模型那一侧,跟踪同一次交互:每次请求包含什么,返回什么,以及由什么决定 Mino 是否再次请求。
2.1 从工具调用到配对的工具结果
每次请求都提供当前问题、可重放的历史、已加载的指令,以及 tools 中的 bash 定义。这个定义只有一个必填字符串字段 command,不接受其他字段。模型自己不能运行任何东西,只能返回结构化的请求。对开头的问题,响应可以包含一个 bash 工具调用,参数为 {"command":"go version"},并在 call_id 中带有标识。
命令运行或被拒绝后,Mino 发送一个使用同一 call_id 的 function_call_output。其中的 JSON 结果包含状态、采集到的输出,以及可选的退出码或截断标记。缺失或重复 ID 会使整个响应无效;在同一轮问答中再次使用之前的 ID,也会在执行本次响应中的任何调用前停止。call_id 把每个工具结果与引发它的请求配对。
下一次请求先重复原有输入,再按原顺序加入模型的完整输出项,最后附上配对的工具结果。这些输出项包括存在时的 assistant phase 和不透明的加密推理内容。Mino 保持 store: false 并请求 reasoning.encrypted_content,由自己提供上下文,而不通过 previous_response_id 连接请求。
图 03-1:每个工具调用都以配对结果的形式回到模型,再由模型决定回答还是继续请求。
窄屏上可以横向滚动图表。模型服务只看到请求和 Mino 发回的结果,不会执行本地命令。
2.2 一轮问答的上限
Mino 请求 parallel_tool_calls: false,如果仍收到一批调用,也按顺序处理。一轮问答最多允许 8 次模型请求、16 次工具调用。如果第八次响应还要求调用工具,或新一批调用将突破工具数量限制,Mino 会把尚未启动的调用记为 not_executed,并以失败状态结束本轮。没有空间继续请求模型时,程序不会先执行这批命令。
2.3 各自负责什么
Mino 面向终端的命令行界面(CLI)接入层称为 CLI Gateway,负责接收输入、显示事件和询问确认;它不打开会话文件,也不执行 Bash。Agent 发送模型请求、处理响应流并运行工具循环,再通过事件把文本、结果和提示交回 Gateway。Mino 在构造 Agent 时把会话和工具传给它,因此更换工具或交互入口时可以复用同一套循环;本版本仍然只有 CLI、一段对话和 Bash。
交互是同步的:CLI 等整轮问答结束,包括中途的批准,再接收下一个问题。如果另一个调用同时使用该会话,程序会在写入输入前返回忙碌错误,避免两轮交错。
工具分三步参与交互:描述可用操作(Definition),校验参数并准备展示字段而不执行(Prepare),再执行已准备的操作(Execute)。批准位于后两步之间。你审查的是准备阶段的产物;Gateway 只收到展示字段的副本,Agent 保留将要执行的已准备参数。这样,你批准的操作就是实际运行的操作。
3. 一次批准对应一次受限执行
准备在先。除了参数结构,Bash 还会在本地校验参数,拒绝空命令、重复或未知字段、无效文本、NUL 字符,以及超过 16 KiB 的命令。未通过校验的调用,或者请求了 Mino 没有的工具,都不会进入批准提示;模型会收到 invalid_arguments 或 unknown_tool 结果。
询问 Approve this operation? [y/N] 之前,Gateway 会显示已准备的完整命令、解析后的启动目录,以及环境,特殊字符经过转义。你批准的是这些字段所描述的操作。只有 y 或 yes 表示同意,大小写和首尾空白不影响判断。每个调用都要单独决定,管道输入不能批准命令。聊天和批准共用一条输入流,因此确认内容会被当作决定,不会成为下一个问题。
Mino 用 /bin/bash --noprofile --norc -c 运行获批命令。每次调用都从已确定的目录和最小环境开始,因此某条命令中的 cd 不会改变下一次调用的起始目录。Shell 配置文件和继承的启动变量不会参与启动,模型配置中的 API Key 也不会被放进子进程环境。具体环境见源码准备说明。
标准输出和标准错误共用 64 KiB 的采集上限。执行停止后,Mino 显示采集结果,再交给模型。非零退出码会成为失败的工具结果,让模型有机会解释失败。超过 30 秒或输出超限时,程序终止进程组;输出超限还会标记截断。Ctrl+C 取消并退出,同时停止活动进程组。已经发生的文件或网络变更不会被撤销。
这些限制约束了执行过程,但 Bash 仍使用你的账户权限,可以读取账户可访问的文件、联系可访问的服务,也可以通过命令自行切换目录或设置环境。清理覆盖的是进程组,主动脱离进程组的后代进程可能逃出这一范围。Mino 将继承输出管道的等待限制为 250 ms,不提供后台任务管理或操作系统沙箱。指令要求模型把工具输出当作不可信数据,但这条指引不能强制约束文件或网络访问。
4. 每一步先保存再继续
执行命令带来了纯文本历史没有的问题:一轮问答现在可能留下实际影响。Mino 仍使用第 02 章的那一个对话文件 ~/.mino/sessions/<session_id>.jsonl,并在进入下一步之前,把每个工具步骤记进去。
4.1 一轮问答中的保存点
下面每条记录都先写入并同步,Mino 才更新内存中的对话状态,才进入下一步。
图 03-2:每个保存点之后的步骤都依赖保存成功;被拒绝的调用只保存结果,没有开始记录。
你的输入在第一次模型请求前保存。文本片段仍到达即显示,但它们是用于显示的事件,不是已提交的对话状态。只有成功的 response.completed 事件到达后,Agent 才校验完整输出,并将其作为 model_response 保存。包含工具调用的中间响应也遵守这个顺序。参数片段或单个输出项结束,都不能启动 Bash;断开的流不会留下一个可供执行的完整响应。
取得批准后,Agent 在执行前保存 tool_start,在再次请求模型前保存 tool_result。最终响应没有工具调用时,才可以记录已完成的 turn_end。这些记录共用本轮的 turn_id,每个工具的开始与结果还携带对应的 call_id。写入或同步失败时,先前已提交的状态保持不变,聊天停止。
只在执行后保存可以减少写入,但一旦中断,就可能没有可靠记录说明命令曾经开始。Mino 选择增加这些保存边界,并在写入或同步失败时停止聊天。代价是命令本来还能运行,交互却必须中断;收益是留下了有据可查的边界,而不是让文件存储和 Bash 组成一个事务:保存了开始记录,不证明进程已创建;结果写入失败,也不证明命令失败。
4.2 重启时留在上下文之外的工具问答
重启时,Mino 恢复文件内容,不执行工具,也不请求模型。如果最后一轮停在某个工具步骤中间,Mino 会在磁盘上补齐它:没有开始记录的调用补为 not_executed,有开始记录却没有结果的调用补为 unknown,本轮以 interrupted 结束。没有任何命令被重新执行。显示启动横幅前,Mino 会输出:
An interrupted tool turn was retained but excluded from context. Commands it started may already have run; none was retried.这条提示是给你看的。模型收不到这一轮的任何内容,因为第 03 章只重放已完成的问答,与第 02 章的规则相同。运行期间失败或被取消的工具问答,例如触及第 2.2 节上限的一轮,同样保留在文件中,但不会进入后续请求。
排除这一轮,让重放规则保持简单,也不会把不完整的文本当作回答。代价是模型不知道某条命令可能已经运行过。 如果你再提一次同样的要求,模型看不到之前尝试的任何痕迹,可能请求同一条命令。这时,挡在重复影响前面的只剩你的批准。
5. 分开检查执行与重放
模型回答“Go 1.27.1”,不能证明 Bash 运行过。在 chapter-03 的仓库根目录检查执行与重放边界:
go test ./internal/agent ./internal/gateway ./internal/tools测试使用临时用户目录、工作目录和本机模拟 HTTP 服务,不需要真实 API Key,也不调用付费模型。通过时,三个包路径前都会显示 ok;模拟服务需要能够绑定本机端口。
一个契约测试注入仅用于测试的 echo 工具,无需修改 Agent 循环。它在图 03-2 的每个保存点检查磁盘记录:首次模型请求前已有输入,确认前已有完整响应,执行前已有开始记录,下次请求前已有工具结果。它的成功结果没有进程退出码,说明共用的结果格式不要求每个工具都启动进程。Bash 仍会在能够取得退出码时记录它;运行中的应用没有提供 echo 工具。
独立测试会注入同步失败,检查内存状态没有推进;还会并发提交另一个问题,检查程序在写入前拒绝忙碌期间的新一轮问答。批准测试检查默认拒绝和共享输入,协议测试检查配对 ID 与保留的响应项,不完整响应和无效调用都不得执行。Bash 测试覆盖失败退出码、环境隔离、超时、输出超限、取消和普通后台子进程清理。
Bash 集成用例在临时目录中写入、读取已知标记,检查交回模型的结果,再重新打开对话,确认追问不会执行旧命令。中断问答测试分别在模型响应之后、工具开始之后、已保存一个结果之后中止一轮,再重新打开文件。每种情况下,文件里都有补上的 not_executed 结果,只有缺少结果的开始记录才会变成 unknown,而重放出的上下文始终为空。最后这一项,正是第 4.2 节所说的局限,被做成了可重复的检查。
这些检查验证的是测试覆盖的控制流程与重放内容,不证明真实回答的质量,也不保证所有 Responses 端点都兼容,更不证明能够约束脱离进程组的进程。开头的 Go 版本交互仍取决于所用服务,模拟测试则提供可重复验证的证据。
6. 本章小结与下一步
Go 版本问题现在有了取得证据的路径:完整工具调用、你的批准、受限的 Bash 执行,以及交给模型的配对结果。每一步都在下一步开始前落盘,因此崩溃不会让 Mino 重新执行命令。
崩溃仍然藏起来的,是证据本身:文件知道某条命令可能已经运行,模型却不知道。第 04 章见 chapter-04 版本;它会把中断的工具问答连同配对结果一起重放,并在对话继续之前,请你确认结果未知的操作。