<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Origin of Ray</title>
  
  <subtitle>一起探索互联网的秘密</subtitle>
  <link href="https://sunra.top/atom.xml" rel="self"/>
  
  <link href="https://sunra.top/"/>
  <updated>2026-08-27T01:58:06.618Z</updated>
  <id>https://sunra.top/</id>
  
  <author>
    <name>Ray Sun</name>
    
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（二十五）剧本回放评测与 Trace 尺子</title>
    <link href="https://sunra.top/posts/5a8c10/"/>
    <id>https://sunra.top/posts/5a8c10/</id>
    <published>2026-08-27T02:00:00.000Z</published>
    <updated>2026-08-27T01:58:06.618Z</updated>
    
    <content type="html"><![CDATA[<p>系列二十四把 MCP 和自家信封的 A2A 接进 Registry。之后又补了官方 <code>SendMessage</code> / Agent Card，以及把 <code>spawn_subagent</code> 挂上 Factory。阶段十二要回答另一件事：固定任务上行为有没有回退，一次 Run 慢在哪。</p><p>一到十一的 <code>go test</code> 测的是函数。评测测的是整条 ReAct：终态对不对、调了哪些工具、取消后是否成对。性能先有尺子，再谈优化。这一刀两件事一起做：剧本回放，以及在已有 Trace 上记三个数。</p><p>本文对应 <code>go-tiny-claw</code> 提交：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">8fd935d  feat: 用剧本回放评测 Agent，并在 Trace 上记下延迟尺子</span><br></pre></td></tr></table></figure><p>官方 A2A 与 <code>spawn_subagent</code> 在 <code>ee4048b</code>，不在本篇展开。</p><span id="more"></span><h2 id="一-评测不是再写一遍-engine-测试"><a class="markdownIt-Anchor" href="#一-评测不是再写一遍-engine-测试"></a> 一、评测不是再写一遍 engine 测试</h2><p><code>test/engine</code> 里已经有预算、取消、断档。每份测试自己捏一个假 Provider，证明某一个分支。评测要的是<strong>同一套入口、多条固定任务</strong>：给一句 prompt，跑完 <code>Engine.Run</code>，对历史和工作区打分。</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">任务是否完成</span><br><span class="line">是否调用正确工具</span><br><span class="line">是否越权写入</span><br><span class="line">取消后 Observation 是否成对</span><br><span class="line">Token 超了会不会停</span><br></pre></td></tr></table></figure><p>真模型贵、飘、难进 CI。第一刀只用剧本：按轮吐固定的 assistant / ToolCall，工作区是 <code>t.TempDir()</code>。</p><h2 id="二-eval-在根上只调用-internal"><a class="markdownIt-Anchor" href="#二-eval-在根上只调用-internal"></a> 二、eval 在根上，只调用 internal</h2><p>评测不是 Engine 的一部分，循环不该 <code>import eval</code>。它和 <code>cmd/claw</code> 一样，是消费方：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">eval  →  internal/engine</span><br><span class="line">     →  internal/context</span><br><span class="line">     →  internal/tools</span><br><span class="line">     →  internal/observability</span><br></pre></td></tr></table></figure><p>放根目录 <code>eval/</code>，一个包，先不分 <code>cases/</code>。<code>go test ./eval</code> 就能跑。以后要 <code>cmd/claw_eval</code>，直接 import 这个包，不必从 <code>test/</code> 里往外搬。</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">eval/script.go     剧本 Provider，可带 Delay</span><br><span class="line">eval/run.go        组 Engine / Session / Gate，调用 Run</span><br><span class="line">eval/assert.go     历史、工具名、Observation</span><br><span class="line">eval/metrics.go    从 Agent.Run 读尺子</span><br><span class="line">eval/cases_test.go 六条任务</span><br></pre></td></tr></table></figure><p><code>Run</code> 自己 <code>StartSpan(&quot;Eval&quot;)</code>，Engine 再挂上 <code>Agent.Run</code>。评测结束时父 span 还在，尺子从子 span 读，不用改 <code>Run</code> 的返回值。</p><p>默认 Registry 只有 <code>read_file</code> / <code>write_file</code> / <code>edit_file</code>，不接 MCP、不拉 Factory。审批默认 AllowOnce；要测拒绝就传入 <code>DenyHandler</code>。</p><h2 id="三-六条任务证明什么"><a class="markdownIt-Anchor" href="#三-六条任务证明什么"></a> 三、六条任务证明什么</h2><table><thead><tr><th>任务</th><th>过线条件</th></tr></thead><tbody><tr><td>只读作答</td><td>终态含文件原文，只调了 <code>read_file</code></td></tr><tr><td>唯一编辑</td><td><code>edit_file</code> 一次，磁盘内容变对</td></tr><tr><td>危险写入被拒</td><td><code>write_file</code> 被 Deny，文件不存在，Observation 写「拒绝」</td></tr><tr><td>取消半截工具</td><td>工具堵住后 cancel，该 <code>ToolCallID</code> 仍有 Observation</td></tr><tr><td>Token 预算停</td><td><code>MaxTokensPerRun</code> 触顶，<code>Generate</code> 不再打第二次</td></tr><tr><td>TTFB + 墙钟</td><td>剧本延迟 40ms、工具睡 40ms，尺子都 ≥ 30</td></tr></tbody></table><p>预算那条复用阶段九的闸：第一次调用用满额度，第二次 <code>checkRunBudget</code> 直接错。评测断言的是「停了，而且假模型只被叫过一次」，不是再实现一遍预算。</p><h2 id="四-尺子写在已有-trace-上不另打日志"><a class="markdownIt-Anchor" href="#四-尺子写在已有-trace-上不另打日志"></a> 四、尺子写在已有 Trace 上，不另打日志</h2><p>机制早就有：首 Token 超时 15 秒、Session 累计 Token、工具墙钟。缺的是<strong>数字</strong>。超时逻辑不动，只在 <code>generate</code> 里记下第一次吐字（或非流式 <code>Generate</code> 成功返回）距开始的毫秒，属性名 <code>ttfb_ms</code>。同一 span 只记一次。</p><p><code>endRunBudget</code> 会把 <code>toolElapsed</code> 清零。所以根 span 的 defer 必须先快照再清：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">prompt_tokens / completion_tokens</span><br><span class="line">tool_wall_ms</span><br><span class="line">把子 span 上第一个 ttfb_ms 抄到 Agent.Run</span><br><span class="line">然后 endRunBudget、EndSpan、写盘</span><br></pre></td></tr></table></figure><p><code>eval.MetricsFrom</code> 找到 <code>Agent.Run</code>，读这四个数。CI 只跑假 Provider。真模型的「首 Token 800ms」是夜间任务，不要进 <code>go test</code>。</p><h2 id="五-阶段十二还没做完的"><a class="markdownIt-Anchor" href="#五-阶段十二还没做完的"></a> 五、阶段十二还没做完的</h2><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">真模型评测（同一套断言，分数允许浮动）</span><br><span class="line">Trace 异步写盘</span><br><span class="line">cmd/claw_eval</span><br><span class="line">并发 Run 压测、连接复用</span><br><span class="line">部署：密钥、租户、健康检查、SLO</span><br></pre></td></tr></table></figure><p><code>TotalCostCNY</code> 仍是 0。MCP 工具热刷新也还没做。评测集以后加任务，往 <code>cases_test.go</code> 里加函数即可；框架不必跟着涨。</p><h2 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h2><p>二十四解决「手和同事从哪来」。二十五解决「怎么知道没回退，以及慢在哪」：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">eval/ 单向调用 internal，不进 Engine</span><br><span class="line">    ↓</span><br><span class="line">剧本 Provider + TempDir，六条固定任务</span><br><span class="line">    ↓</span><br><span class="line">同一条 Engine.Run，对历史和工作区打分</span><br><span class="line">    ↓</span><br><span class="line">根 span：ttfb_ms、Token、tool_wall_ms</span><br><span class="line">    ↓</span><br><span class="line">CI 只跑假模型</span><br></pre></td></tr></table></figure><p>有回放才能改循环而不瞎；有尺子才知道慢的是模型、工具还是写 Trace。部署治理另开。</p>]]></content>
    
    
    <summary type="html">阶段十二第一刀：根目录 eval 用剧本 Provider 回放固定任务；Agent.Run 根 span 记下首 Token、Token 累计和工具墙钟。不打真模型。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（二十四）MCP 当手、A2A 当同事</title>
    <link href="https://sunra.top/posts/5a8c0f/"/>
    <id>https://sunra.top/posts/5a8c0f/</id>
    <published>2026-08-26T08:45:00.000Z</published>
    <updated>2026-08-27T01:58:06.618Z</updated>
    
    <content type="html"><![CDATA[<p>系列二十三把历史和 Token 落到磁盘，断档只补 Observation。路线图下一阶段是工具生态：本地 Registry 已经能跑 <code>read_file</code> / <code>bash</code>，还缺两样外来的东西。</p><p>MCP 是远程的<strong>手</strong>：一次函数调用，拿回一段结果。A2A 是远程的<strong>同事</strong>：整件任务丢过去，对方自己想、自己调工具，回来交一份报告。模型两边都只看见本地 <code>ToolDefinition</code>，不会直接讲 JSON-RPC。</p><p>本文对应 <code>go-tiny-claw</code> 两次提交：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">d516575  feat: 将 MCP 工具和 A2A 委派接到本地 Registry</span><br><span class="line">47c256a  feat: 支持 Streamable HTTP MCP，并改用 OpenAI 环境变量</span><br></pre></td></tr></table></figure><span id="more"></span><h2 id="一-外来的手必须进同一道门"><a class="markdownIt-Anchor" href="#一-外来的手必须进同一道门"></a> 一、外来的手必须进同一道门</h2><p>路线图写得很死：外部工具及远程 Agent 必须复用同一个 Context、Approval 和 Observability，不能因为来自 MCP 就绕过本地安全边界。</p><p>所以这一刀的形状是适配，不是另起一套引擎：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">.claw/mcp.json</span><br><span class="line">    ↓ command 或 url</span><br><span class="line">MCP Server（stdio 子进程 / HTTP）</span><br><span class="line">    ↓ initialize → tools/list</span><br><span class="line">Adapter（mcp_&lt;server&gt;_&lt;tool&gt;）</span><br><span class="line">    ↓ schema.ToolDefinition + RiskLevel</span><br><span class="line">本地 Registry</span><br><span class="line">    ↓ Approval Gate</span><br><span class="line">    ↓ tools/call</span><br><span class="line">MCP Server</span><br></pre></td></tr></table></figure><p>名字加前缀，两个服务器都叫 <code>search</code> 也不会撞。风险写在配置里，不信远端自己报的安全等级；缺省当成 <code>dangerous</code>，默认要审批。</p><p>A2A 更简单：Registry 里多一个 <code>delegate_agent</code>，风险同样是危险。参数是 <code>message</code>，外加 <code>url</code> 或 <code>.claw/a2a.json</code> 里的 <code>peer</code>。模型发出去的是「请对方做完这件事」，不是远程那一侧的工具列表。</p><h2 id="二-client-只认三件事"><a class="markdownIt-Anchor" href="#二-client-只认三件事"></a> 二、Client 只认三件事</h2><p>MCP 规范很长。第一刀只做模型真正用得到的：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">initialize + notifications/initialized</span><br><span class="line">tools/list</span><br><span class="line">tools/call</span><br></pre></td></tr></table></figure><p><code>resources</code>、<code>prompts</code>、<code>sampling</code>、工具热刷新都没做。启动时列一次，挂到 Registry，进程活着就用这份。某个服务器连不上，整份 MCP 一起失败：与其 silently 少几只手，不如启动时就看见。</p><p><code>Client</code> 本身不读 stdin、不发 HTTP。它只分配 JSON-RPC id，调 <code>Transport</code>：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">SendRequest</span><br><span class="line">SendNotification</span><br><span class="line">Close</span><br></pre></td></tr></table></figure><p>取消时立刻把错误还给调用方，<code>notifications/cancelled</code> <strong>异步</strong>发出去。stdio 底下是 <code>io.Pipe</code>，同步写会堵死：读循环还卡在等响应，写通知没人读。取消测试曾经因此卡满 1 秒。</p><h2 id="三-stdio-先通http-再拆一层"><a class="markdownIt-Anchor" href="#三-stdio-先通http-再拆一层"></a> 三、stdio 先通，HTTP 再拆一层</h2><p>本地调试用 stdio：<code>command</code> + <code>args</code> + <code>env</code>，工作目录就是 Agent 的 workspace，换行分隔 JSON-RPC。子进程跟 Factory 走，<code>factory.Close()</code> 杀掉。</p><p>要接托管服务（例如只给 URL 和 Bearer 的 OTA），stdio 不够。第二刀把读写从 <code>Client</code> 里抽走，补上 Streamable HTTP：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">POST JSON-RPC</span><br><span class="line">Accept: application/json, text/event-stream</span><br><span class="line">记下响应头 MCP-Session-Id</span><br><span class="line">initialize 之后带上 MCP-Protocol-Version</span><br><span class="line">SSE 只收匹配当前 id 的 data:</span><br><span class="line">通知接受 202 或 200</span><br><span class="line">Close 时 DELETE 会话</span><br><span class="line">401 / 403 → MCP 鉴权失败</span><br></pre></td></tr></table></figure><p>协议版本写死 <code>2025-11-25</code>。GET 挂长连接、会话恢复、断线续传都没做。响应可以是一整段 JSON，也可以是 SSE；扫描到对应 id 就返回，通知事件丢掉。</p><p>配置互斥：一个服务器只能有 <code>command</code> 或 <code>url</code>，两个都写或两个都不写直接失败。HTTP 的 <code>headers</code> 支持 <code>$&#123;ENV&#125;</code>。变量不存在或值为空，加载配置失败——密钥不要写进 <code>mcp.json</code>，也不要在缺 Key 时默默连上去。</p><p>示例（密钥在环境变量里，不进仓库）：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;combos-ota&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;url&quot;</span><span class="punctuation">:</span> <span class="string">&quot;https://ota.combos.fun/mcp&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;headers&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;Authorization&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Bearer $&#123;COMBOS_OTA_MCP_KEY&#125;&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;risk&quot;</span><span class="punctuation">:</span> <span class="string">&quot;mutating&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>启动脚本要先 <code>source .env</code>。直接 <code>go run</code> 不会读 <code>.env</code>。缺了 <code>COMBOS_OTA_MCP_KEY</code>，整个 Agent 起不来，这是故意的。</p><h2 id="四-连接挂在-factory-上不挂在单次-run"><a class="markdownIt-Anchor" href="#四-连接挂在-factory-上不挂在单次-run"></a> 四、连接挂在 Factory 上，不挂在单次 Run</h2><p>MCP 子进程和 HTTP 会话都贵。Factory 懒创建一份 <code>Manager</code>，每次 <code>NewRuntime</code> 只往新 Registry 上 <code>Register</code>。REPL 多轮、WebSocket 多会话，共用同一批远端。</p><p><code>cmd/claw</code> 和 <code>cmd/claw_server</code> 都 <code>defer factory.Close()</code>。stdio 杀进程，HTTP 发 DELETE。不关就会留下 npx 子进程，或者远端会话一直占着。</p><p>Adapter 的 <code>Execute</code> 把 <code>tools/call</code> 的文本块拼起来。远端标了 <code>isError</code>，返回中文错误，走 Recovery，不伪装成功 Observation。</p><h2 id="五-a2a-用自己的信封不假装官方协议"><a class="markdownIt-Anchor" href="#五-a2a-用自己的信封不假装官方协议"></a> 五、A2A 用自己的信封，不假装官方协议</h2><p>官方 A2A 是另一套 JSON-RPC。这一刀只解决「把任务交给另一个 claw」：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">POST /a2a/message</span><br><span class="line">GET  /a2a/agent-card</span><br><span class="line">GET  /.well-known/agent-card.json</span><br></pre></td></tr></table></figure><p>响应仍是项目约定的 <code>&#123;code, message, data&#125;</code>。挂在现有 <code>:8081</code> 上，不另开端口。入站走 <code>Manager</code>，Ask 类工具一律 <code>Deny</code>：远端没有人坐在终端前点 <code>y</code>，不能让远程 Agent 改你的磁盘。</p><p>出站工具危险，要本地人批。回来的是一段报告，不是对方内部的 ToolCall 轨迹。<code>spawn_subagent</code> 仍是进程内子智能体，这一刀没有把它注册进 Factory。</p><h2 id="六-测试要证明什么"><a class="markdownIt-Anchor" href="#六-测试要证明什么"></a> 六、测试要证明什么</h2><p>MCP：</p><ol><li>stdio 能 <code>tools/list</code> / <code>tools/call</code>，注册名带 <code>mcp_</code> 前缀</li><li>取消立刻返回，不必等远端做完</li><li>没有配置文件等于没有远端，不报错</li><li>必须只写 <code>command</code> 或 <code>url</code> 之一</li><li>HTTP 能握手、列工具、调用，并带上 Session 头</li><li><code>$&#123;ENV&#125;</code> 能展开；缺变量或 command/url 同时出现则失败</li></ol><p>A2A：</p><ol><li>入站空消息拒绝</li><li>Agent Card 能 GET</li><li><code>delegate_agent</code> 能把 <code>peer</code> 解析成 URL</li><li>既没有 <code>url</code> 也没有已知 <code>peer</code> 则失败</li></ol><h2 id="七-阶段十一还没做完的"><a class="markdownIt-Anchor" href="#七-阶段十一还没做完的"></a> 七、阶段十一还没做完的</h2><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">MCP resources / prompts / sampling</span><br><span class="line">启动后刷新工具列表</span><br><span class="line">GET SSE 长连接与会话恢复</span><br><span class="line">官方 A2A JSON-RPC / 流式</span><br><span class="line">把 spawn_subagent 挂进 Factory</span><br><span class="line">某个 MCP 失败时跳过而不是整组失败</span><br></pre></td></tr></table></figure><p>阶段九留下的 <code>TotalCostCNY</code> 仍是 0。未使用的 Claude Provider 已经删掉，入口改成 <code>OPENAI_API_KEY</code> / <code>OPENAI_BASE_URL</code> / <code>OPENAI_MODEL</code>。费用记账仍不是 MCP 的前置条件。</p><p>路线图里的「工具发现和刷新」「远程超时单独治理」「工具版本兼容」都还粗。现在的发现是启动时列一次；超时复用调用方 Context；版本就是握手里那个字符串。</p><h2 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h2><p>二十三解决「进程没了如何接上」。二十四解决「手和同事从哪来」：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">模型只看见本地 ToolDefinition</span><br><span class="line">    ↓</span><br><span class="line">MCP：stdio 或 Streamable HTTP，进同一 Registry / Gate</span><br><span class="line">    ↓</span><br><span class="line">名字加前缀，缺省危险，密钥走 $&#123;ENV&#125;</span><br><span class="line">    ↓</span><br><span class="line">连接挂 Factory，Close 杀进程或 DELETE 会话</span><br><span class="line">    ↓</span><br><span class="line">A2A：自己的信封，入站 Ask 一律拒</span><br></pre></td></tr></table></figure><p>阶段十二是评测、性能和部署。MCP 先能调用、A2A 先能交差，就可以停。</p>]]></content>
    
    
    <summary type="html">阶段十一：外部工具走 MCP（stdio 与 Streamable HTTP），远程任务走 A2A。模型只看见本地 ToolDefinition，审批和取消不绕开。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（二十三）Session 落盘与断档修补</title>
    <link href="https://sunra.top/posts/5a8c0e/"/>
    <id>https://sunra.top/posts/5a8c0e/</id>
    <published>2026-08-23T07:50:00.000Z</published>
    <updated>2026-08-27T01:58:06.618Z</updated>
    
    <content type="html"><![CDATA[<p>系列二十二让一次 Run 能干净停住。阶段十要解决的是进程死了之后：同一 <code>sessionID</code> 还能否接上聊天，以及历史里半截工具调用会不会把上下文弄坏。</p><p>路线图里阶段十还写了「当前 Run 状态」「卡在审批 / 工具执行中」。那一刀没做。磁盘上看不出工具做到哪了，重跑 <code>bash</code> / <code>write_file</code> 可能重复副作用。这一篇只收两刀：能读回历史和 Token；断档只补 Observation。</p><p>本文对应 <code>go-tiny-claw</code> 两次提交：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">b961e9e  feat: 将会话历史和 Token 累计落到工作区磁盘</span><br><span class="line">626cdfd  feat: 为中断的工具调用补齐 Observation</span><br></pre></td></tr></table></figure><span id="more"></span><h2 id="一-先能接上聊天不恢复做到一半"><a class="markdownIt-Anchor" href="#一-先能接上聊天不恢复做到一半"></a> 一、先能接上聊天，不恢复「做到一半」</h2><p>Grant 早就写在 <code>.claw/grants.json</code>。Session 一直在内存里，REPL 一退就没了。阶段十的第一刀只存这些：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Session 元数据（id、workdir、时间）</span><br><span class="line">Message 历史</span><br><span class="line">Token 累计（费用字段带着，值仍是 0）</span><br></pre></td></tr></table></figure><p>不存这些：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">正在等模型</span><br><span class="line">正在等审批</span><br><span class="line">工具执行到一半</span><br></pre></td></tr></table></figure><p>「等待模型」重启后下一句会再打，没问题。「等待审批」没结果就当没点过 <code>y</code>。「工具执行中」是危险区：文件可能已经写完，Observation 还没 <code>Append</code>。</p><h2 id="二-一个会话一个文件对齐-grant-的原子写"><a class="markdownIt-Anchor" href="#二-一个会话一个文件对齐-grant-的原子写"></a> 二、一个会话一个文件，对齐 Grant 的原子写</h2><p>授权是整份 <code>grants.json</code>。会话按 ID 拆开，避免改一个会话重写全部：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">.claw/sessions/&lt;id&gt;.json</span><br></pre></td></tr></table></figure><p>文件名拒绝 <code>/</code>、<code>\</code>、<code>..</code>，避免 <code>sessionID=../../etc/passwd</code> 写到工作区外面。CLI 现在的 <code>terminal_default</code> 可以直接当文件名。</p><p>快照就是 Session 里已经有的字段，外加 <code>history</code>。<code>schema.Message</code> 自带 JSON tag，工具参数里的 <code>json.RawMessage</code> 也能过。写入用 <code>MarshalIndent</code>，嵌套 <code>arguments</code> 会被重新排版，读回来要比语义，不要比原始字节。</p><p>写盘抄 Grant：先 <code>.tmp</code>，再 <code>Rename</code>。半截写不会留下半个 JSON。文件不存在或空文件当成新会话；JSON 坏了往上抛，不要假装空会话再覆盖坏文件。</p><p><code>Append</code> / <code>RecordUsage</code> / <code>Clear</code> 在持有 <code>Session.mu</code> 时写盘。先 Unlock 再写，两个 <code>Append</code> 并发时旧快照会盖掉新的。落盘失败第一刀只打日志，不改函数签名；内存仍保留。<code>Clear</code> 本来就不清 Token，落盘后预算也不会因 <code>/clear</code> 归零。</p><p>第一次 <code>GetOrCreate</code> 不写空文件，等第一次变更再出现。<code>RunSub</code> 用的是裸 <code>NewSession(&quot;run-sub&quot;, &quot;&quot;)</code>，没有 <code>persistDir</code>，子智能体历史不进磁盘。</p><h2 id="三-新建会话必须挂-persistdirfactory-的-nil-才能走文件"><a class="markdownIt-Anchor" href="#三-新建会话必须挂-persistdirfactory-的-nil-才能走文件"></a> 三、新建会话必须挂 persistDir，Factory 的 nil 才能走文件</h2><p>只在 <code>loadSession</code> 里设 <code>persistDir</code> 不够。新建会话的 <code>Append</code> 会看到空目录，直接 return，第一份文件永远出不来：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">GetOrCreate → NewSession（没挂 persistDir）</span><br><span class="line">    ↓</span><br><span class="line">Append / RecordUsage 不写盘</span><br><span class="line">    ↓</span><br><span class="line">重启再 GetOrCreate → 文件不存在 → 又是空会话</span><br></pre></td></tr></table></figure><p><code>GetOrCreate</code> 新建时补上一行：<code>sess.persistDir = sm.persistDir</code>。<code>GlobalSessionMgr</code> 的目录仍是空串，Engine 测试不碰磁盘。</p><p><code>cmd/claw</code> 和 <code>cmd/claw_server</code> 都是 <code>NewRuntimeFactory(..., nil)</code>。以前 <code>nil</code> 会先换成 <code>GlobalSessionMgr</code>，后面的懒创建 <code>FileSessionManager</code> 是死代码。现在 <code>nil</code> 原样进去，<code>sessionManager()</code> 才走到 <code>.claw/sessions</code>。测试里显式传入 <code>GlobalSessionMgr</code> 的，仍然纯内存。</p><p><code>GetOrCreate</code> 改成返回 <code>error</code>。坏文件必须让 <code>NewRuntime</code> 失败。读盘后用调用方 <code>workDir</code> 覆盖文件里的路径：工作区搬家后，工具不要锁死在过期目录。<code>CreatedAt</code> 和 Token 仍用文件里的。</p><p><code>MaxTokens</code> 按 Session 累计。重启后预算接着算，杀进程洗不掉额度。</p><h2 id="四-断档只补-observation不重跑工具"><a class="markdownIt-Anchor" href="#四-断档只补-observation不重跑工具"></a> 四、断档只补 Observation，不重跑工具</h2><p>活着的那一轮，助手带 ToolCalls 入库之后，取消路径已经用 <code>EnsureToolObservations</code> 补「工具调用已取消」。那是当前这一轮的平行数组，不管落盘后的聊天记录。</p><p>进程死在这里：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">session.Append(助手 + ToolCalls)     // 已落盘</span><br><span class="line">    ↓</span><br><span class="line">审批 / Execute / Append(Observation)  // 还没写上</span><br></pre></td></tr></table></figure><p>下次 <code>GetWorkingMemory</code> 会把未配对的 ToolCalls 送给模型。Provider 可能直接拒；模型也可能以为工具已经跑过。</p><p><strong>工具重放</strong>是按当时的参数再 <code>Execute</code> 一次。磁盘上看起来都一样：助手有调用，后面没有结果。分不清「做完了没记账」和「还没做」：</p><table><thead><tr><th>实际情况</th><th>重放的后果</th></tr></thead><tbody><tr><td><code>write_file</code> 已经写完</td><td>再写一次，文件被覆盖</td></tr><tr><td><code>bash</code> 已经 <code>rm</code> / <code>git commit</code></td><td>再删一次、再提交一次</td></tr><tr><td>工具其实没跑到</td><td>重放碰巧是对的</td></tr></tbody></table><p>所以只插入一条「工具调用未完成（上次运行中断）」。文案不和活着的「已取消」混用，模型不应当成已经执行成功，可以自己决定要不要再调。只读的 <code>read_file</code> 重放相对安全，但收益小，模型看见未完成自己会再读。</p><p><code>RecoveryManager</code> 是工具报错提示，不管崩溃。<code>GetWorkingMemory</code> 开头丢掉的是窗口边缘的孤儿<strong>结果</strong>，不是孤儿<strong>调用</strong>。</p><h2 id="五-插入不要-append-到末尾"><a class="markdownIt-Anchor" href="#五-插入不要-append-到末尾"></a> 五、插入，不要 Append 到末尾</h2><p><code>Runtime.Start</code> 会先把新的用户那一句写进历史，再进 <code>Engine.Run</code>。如果补丁加在末尾：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">助手 [call-1]</span><br><span class="line">用户：下一句</span><br><span class="line">用户：工具未完成      ← 错，Observation 必须紧跟助手</span><br></pre></td></tr></table></figure><p><code>RepairIncompleteTools</code> 扫一遍谁已经有 <code>ToolCallID</code>，缺的插到该助手后面、已有 Observation 的后面、下一条非 Observation 的前面。同一助手缺两条，从后往前插，顺序仍是 <code>call-1</code>、<code>call-2</code>。已经成对则不动 <code>UpdatedAt</code>、不写盘。</p><p>两处调用：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GetOrCreate 读盘成功 → Repair（磁盘先合法）</span><br><span class="line">Engine.Run 进循环前 → Repair（测试直调 Run、内存命中也能盖住）</span><br></pre></td></tr></table></figure><p>因为是插入，即使 Start 已经写下「下一句」，Observation 仍会插回助手后面。<code>RunSub</code> 不用加：跟父会话时父 <code>Run</code> 已经修过；独立跑是空会话。</p><p>活着的取消仍走 <code>EnsureToolObservations</code>。两套不要合成一套。</p><h2 id="六-测试要证明什么"><a class="markdownIt-Anchor" href="#六-测试要证明什么"></a> 六、测试要证明什么</h2><p>落盘：</p><ol><li><code>Append</code> + <code>RecordUsage</code> 后换一个 <code>FileSessionManager</code>，历史、Token、费用字段、<code>CreatedAt</code> 还在</li><li>第一次 <code>GetOrCreate</code> 不写空文件</li><li>工具调用和 <code>ToolCallID</code> 能过 JSON</li><li><code>Clear</code> 后历史空、Token 还在</li><li>重载时用调用方 <code>workDir</code></li><li>空 workDir、非法 ID、坏 JSON 失败；空文件当新会话</li><li><code>GlobalSessionMgr</code> 不创建 <code>.claw/sessions</code></li><li>Factory 传 <code>nil</code>，<code>Run(&quot;你好&quot;)</code> 后新 Factory 再用同一 ID，模型请求里能看到上一句</li></ol><p>断档：</p><ol><li>两条 ToolCall 只回了一条，只补缺的那条，已有结果不改</li><li>助手后面已经是「下一句」，Observation 插在中间</li><li>已经成对，不再插入，不改 <code>UpdatedAt</code></li><li>磁盘上只有断档助手，新 Manager <code>GetOrCreate</code> 读回来已经成对</li><li><code>Engine.Run</code> 直调：发给模型的 messages 里该 <code>ToolCallID</code> 已有 Observation</li></ol><h2 id="七-阶段十还没做完的"><a class="markdownIt-Anchor" href="#七-阶段十还没做完的"></a> 七、阶段十还没做完的</h2><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">当前 Run 状态（等模型 / 等审批 / 工具执行中）</span><br><span class="line">工具重放或补偿流程</span><br><span class="line">Clear 时连 Token 一起归零</span><br><span class="line">TotalCostCNY 仍是 0（阶段九尾巴）</span><br><span class="line">Claude HTTP 还没套重试（入口没用它）</span><br></pre></td></tr></table></figure><p>路线图说「只有状态和 Observation 一致才能继续」。现在的一致是：不一致就标成未完成，让模型重想，不让运行时重做。审批卡在中间同理，不要当成用户点过 <code>y</code>。</p><p>真要重放，得先有检查点：执行前写 intent，成功后再写结果。那是另一套设计。阶段十收到「落盘 + 断档修补」就可以停。下一阶段是路线图里的工具生态和 MCP / A2A。</p><h2 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h2><p>二十二解决「这一发何时停」。二十三解决「进程没了之后如何接上，又不把工具再跑一遍」：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">.claw/sessions/&lt;id&gt;.json，tmp + Rename</span><br><span class="line">    ↓</span><br><span class="line">新建会话挂 persistDir；Factory nil 走文件</span><br><span class="line">    ↓</span><br><span class="line">重启后历史和 Token 还在，预算接着算</span><br><span class="line">    ↓</span><br><span class="line">孤儿 ToolCall 插入「未完成」，不 Execute</span><br><span class="line">    ↓</span><br><span class="line">插在助手后面，不要 Append 到新用户消息之后</span><br></pre></td></tr></table></figure><p>阶段十一再谈外部工具和远程 Agent。费用记账仍不是前置条件。</p>]]></content>
    
    
    <summary type="html">阶段十：把历史和 Token 写到 .claw/sessions，读盘后为孤儿 ToolCall 插入未完成 Observation。不重放有副作用的工具。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（二十二）预算停 Run、指数退避、首 Token 与半响应</title>
    <link href="https://sunra.top/posts/5a8c0d/"/>
    <id>https://sunra.top/posts/5a8c0d/</id>
    <published>2026-08-20T06:20:00.000Z</published>
    <updated>2026-08-27T01:58:06.618Z</updated>
    
    <content type="html"><![CDATA[<p>系列二十一让失败可分类、可重试，并且不碰工具。当时留下的清单里，有几条已经可以收口：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Token 超了不会停 Run</span><br><span class="line">CostTracker 只实现 Generate，包上去会丢掉流式</span><br><span class="line">固定 200ms 还不是指数退避</span><br><span class="line">没有单独的模型调用超时</span><br><span class="line">没有首 Token 超时</span><br><span class="line">流式半响应只是普通 error</span><br></pre></td></tr></table></figure><p>后来又补上单次 Run Token、派出次数和工具墙钟，并让 <code>RunSub</code> 共用同一本账。</p><p>本文对应 <code>go-tiny-claw</code> 这几次提交：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">3b430d6  feat: 在主循环与 RunSub 的 trace 中记录 Token 消耗</span><br><span class="line">ce7e757  feat: 在模型调用前检查 Session Token 预算</span><br><span class="line">93d3b76  feat: 为模型重试增加指数退避与单次调用超时</span><br><span class="line">9aa7f78  feat: 为流式模型调用增加首 Token 超时</span><br><span class="line">06682fe  feat: 将流式半响应标成不可重试错误</span><br><span class="line">e3a2701  feat: 增加单次 Run 的 Token 预算并与会话累计分开</span><br><span class="line">24ddcff  feat: 为生产 Engine 设置单次 Run Token 上限</span><br><span class="line">6a7421b  feat: 限制单次运行的工具墙钟时间和 Subagent 派出次数</span><br><span class="line">7e230a6  refactor: 拆分 Engine 循环并让 RunSub 共用运行预算</span><br></pre></td></tr></table></figure><span id="more"></span><h2 id="一-预算放在-engine不放在-provider-外套"><a class="markdownIt-Anchor" href="#一-预算放在-engine不放在-provider-外套"></a> 一、预算放在 Engine，不放在 Provider 外套</h2><p>二十一提过：不要再做一个只实现 <code>Generate</code> 的 <code>CostTracker</code> 挡在最外层。流式会被它吃掉。记账和停 Run 都应该读 Session 里已经有的 Usage。</p><p>最后删掉了那个装饰器。数字进 <code>Session</code>，闸门在 <code>AgentEngine</code>：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">checkBudget       每次 generate 之前，读 Session 累计</span><br><span class="line">checkRunBudget    同样在 generate 之前，读本次 Run 增量</span><br><span class="line">recordUsage       这次 generate 成功之后</span><br></pre></td></tr></table></figure><p><code>Session.TotalTokens()</code> 加锁读 <code>TotalPromptTokens + TotalCompletionTokens</code>。<code>MaxTokens &lt;= 0</code> 视为不限，单元测试保持这个默认。工厂里会话上限 <code>200000</code>。</p><p><code>MaxTokens</code> 计量的是 <strong>Session</strong>，不是单次 <code>Run</code>。<code>GetOrCreate</code> 同一个 ID，账一直加，REPL 下一发不会从零算。报错是「已超过本会话的 Token 预算」。</p><p>语义是停 <strong>下一次</strong> 模型调用，不是把已经打完的最后一句作废。第一次 Action 用满上限、没有工具要跑，应成功。第一次带了工具、累计已经顶到上限，下一轮 Action 前再拦。</p><p>Trace 只是把同一份 Usage 写到 Thinking / Action / RunSub 的 span 上，不参与停 Run。</p><h2 id="二-单次-run-派出次数-工具墙钟"><a class="markdownIt-Anchor" href="#二-单次-run-派出次数-工具墙钟"></a> 二、单次 Run、派出次数、工具墙钟</h2><p>只靠会话 Token 挡不住「这一发工具循环把账烧光」。后来加了三道按 <strong>一次 <code>Run</code></strong> 计的闸，<code>&lt;= 0</code> 都不限：</p><table><thead><tr><th>闸</th><th>工厂默认</th><th>计量</th></tr></thead><tbody><tr><td><code>MaxTokensPerRun</code></td><td>100000</td><td><code>TotalTokens() - runStart</code>，停下一枪模型</td></tr><tr><td><code>MaxSubagents</code></td><td>3</td><td><code>RunSub</code> 入口加锁计数，<code>Run()</code> 开头清零</td></tr><tr><td><code>MaxToolTime</code></td><td>2 分钟</td><td>各 Turn 工具波次的<strong>墙钟加总</strong>，不是每轮重置</td></tr></tbody></table><p>单 Turn Token 没做：没开 Thinking 时一轮就是一枪，枪前增量永远是 0。卡一次 Run 比卡一轮更合理。</p><p>工具时长不要套在整个 <code>Run</code> 的 <code>ctx</code> 上，否则模型也会被掐。<code>toolBatchContext</code> 只包 <code>Execute</code>。并行同一波只算这一波墙钟，不加总每个 goroutine。审批等待不算。bash 的 30s 仍是单次命令。2 分钟是保险丝，构建/测试多的任务会先撞它。</p><p><code>RunSub</code> 现在接同一套：主循环进行中就写进主 Session、累加主 <code>toolElapsed</code>；单独调用则用临时 Session 和自己的计时。子循环仍最多 10 轮。<code>MaxTurns</code>（主 20）管不着子循环。Retry / <code>CallTimeout</code> / 首 Token 在 Provider 上，子循环同样有效。</p><p>循环拆开之后，预算在 <code>budget.go</code>，工具波次在 <code>tools_batch.go</code>，<code>Run</code> / <code>RunSub</code> 共用 <code>runToolBatch</code>。</p><h2 id="三-退避按失败次数加倍封顶-2-秒"><a class="markdownIt-Anchor" href="#三-退避按失败次数加倍封顶-2-秒"></a> 三、退避按失败次数加倍，封顶 2 秒</h2><p><code>Backoff</code> 仍是第一档，默认 200ms。第 n 次失败后等 <code>Backoff * 2^(n-1)</code>，封顶 <code>MaxBackoff</code>（默认 2s）。<code>Backoff == 0</code> 立即重试，旧测试不用睡。</p><p><code>wait</code> 继续用 <code>Timer</code> + <code>select</code> 听父 <code>ctx</code>。Ctrl-C 不必等满退避。</p><h2 id="四-两层超时不要共用一个-ctx"><a class="markdownIt-Anchor" href="#四-两层超时不要共用一个-ctx"></a> 四、两层超时，不要共用一个 ctx</h2><p>整次尝试用 <code>CallTimeout</code>（默认 60s），包在 <code>attemptContext</code> 里传给底层。这管「这一枪从开打到结束」。</p><p>首 Token 不能写进同一个 ctx。字已经出来之后，那个 timeout 一到会把还在流的 HTTP 掐死。所以首 Token（默认 15s）是消费循环上的定时器：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">attemptCtx = CallTimeout          整次尝试</span><br><span class="line">firstToken 定时器                 通道已开、还没有任何事件</span><br><span class="line">任意事件到达（delta / completed / error） 立刻停表</span><br><span class="line">之后再慢只走 CallTimeout</span><br></pre></td></tr></table></figure><p><code>select</code> 三路：父 <code>ctx</code>、首 Token 表、<code>inner</code>。父 ctx 和定时器同时就绪时，Go 会随机选一条，所以走进 <code>case &lt;-firstToken</code> 还要再看一次 <code>ctx.Err()</code>。否则 Ctrl-C 会被写成「模型首 Token 超时」，还可能按 <code>DeadlineExceeded</code> 再打一次。</p><p>同步 <code>Generate</code> 没有「第一个字」和「整段回复」的区别，只保留 <code>CallTimeout</code>。<code>FirstTokenTimeout == 0</code> 关掉首 Token 表，测试默认这么做。</p><p>文案分开：<code>模型调用超时</code> 对整次尝试，<code>模型首 Token 超时</code> 对还没出字。两者都包着 <code>DeadlineExceeded</code>，<code>shouldRetry</code> 认它，没出字可以重试。</p><h2 id="五-吐字之后再断是半响应不是-429"><a class="markdownIt-Anchor" href="#五-吐字之后再断是半响应不是-429"></a> 五、吐字之后再断，是半响应，不是 429</h2><p>二十一已经规定：转发过 <code>text_delta</code> / <code>completed</code> 不再重试。缺的是类型。吐字后的 429 如果原样往外扔，<code>ClassifyError</code> 仍会说可重试。出字后的 <code>CallTimeout</code> 会变成 <code>canceled</code>。</p><p>所以加 <code>PartialResponseError</code>，里面留着原来的原因。<code>ClassifyError</code> <strong>先</strong>认半响应，再看取消和 HTTP：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">PartialResponseError     →  fatal，不重试</span><br><span class="line">Canceled / DeadlineExceeded  →  canceled</span><br><span class="line">HTTP 429 / 5xx / net 超时   →  retryable</span><br><span class="line">其余                     →  fatal</span><br></pre></td></tr></table></figure><p><code>wrapPartial</code> 已经是半响应就不再包一层。</p><p>只在用户已经看到字时包装：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">forwarded 且收到 StreamError     → wrapPartial（父 ctx 取消除外）</span><br><span class="line">forwarded 且通道空关             → wrapPartial(未返回最终消息)</span><br><span class="line">没出字就结束                    → 普通错误，不是半响应</span><br></pre></td></tr></table></figure><p>本仓库工具调用不单独推 delta，只出现在 <code>completed</code> 里，所以 <code>completed</code> 也算「已经转发」。父 <code>ctx</code> 取消仍返回 <code>ctx.Err()</code>，不要写成半响应。</p><h2 id="六-测试要证明什么"><a class="markdownIt-Anchor" href="#六-测试要证明什么"></a> 六、测试要证明什么</h2><p>会话 Token：</p><ol><li>第一次 generate 用满上限且还要再打，第二次不准发出去</li><li>最后一轮答完、后面没有 generate，用满上限也成功</li></ol><p>单次 Run Token：会话里已有 100，本次运行用满 10 会停下一枪；最后一枪答完不再打则成功。文案是「本次运行」，不是「本会话」。</p><p>派出次数：<code>MaxSubagents = 1</code> 时第二次 <code>RunSub</code> 失败且不再打模型；新的 <code>Run()</code> 会清零。</p><p>工具墙钟：卡住的工具听 <code>toolCtx</code>，30ms 后报「工具时间预算」，不再打下一枪；工具很快结束则跑完第二枪。</p><p><code>RunSub</code>：自己也能顶满 Run Token / 工具时间；从主循环派出时，Token 记进主 Session。</p><p>退避：200ms → 400ms，封顶 500ms；<code>Backoff = 0</code> 等待为 0。</p><p>整次超时：堵在 <code>ctx.Done()</code> 上的 <code>Generate</code>，<code>CallTimeout = 20ms</code>、两次尝试，报「模型调用超时」。父 ctx 已取消，一次都不打。</p><p>首 Token：</p><ol><li>开口 1 秒才出字，20ms 超时，重试 2 次，报「模型首 Token 超时」</li><li>10ms 出字、200ms 限额，只打一次</li><li>先吐 <code>hello</code> 再卡到 <code>CallTimeout</code>：只打一次，错误是「模型调用超时」，不是首 Token</li></ol><p>半响应：</p><ol><li>半响应包着 429 或 <code>DeadlineExceeded</code>，分类都是 fatal</li><li>先 delta 再 429：不重试，外层是 <code>PartialResponseError</code>，仍能 <code>errors.As</code> 出 429</li><li>吐字后通道直接关：半响应，不重试</li><li>上面第 3 条尾包超时，现在也是半响应，文案里还能看到「模型调用超时」</li></ol><h2 id="七-阶段九还没做完的"><a class="markdownIt-Anchor" href="#七-阶段九还没做完的"></a> 七、阶段九还没做完的</h2><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">TotalCostCNY 一直是 0，recordUsage 第三个参数没算钱</span><br><span class="line">Claude 的 HTTP 状态还没翻译（入口没用它）</span><br></pre></td></tr></table></figure><p>可靠性可以停。Token、派出次数、工具墙钟都已能停 Run。费用仍是空账，先跳过计价。</p><p>阶段十是 Session 落盘和任务恢复。它依赖「一次 Run 能干净失败」。Token 闸门已经能停，费用闸门不是前置条件。</p><h2 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h2><p>二十一解决「失败之后怎么办」。二十二补上「什么时候不准再打」和「流式失败分别是什么」：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">Session 累计 Token + 单次 Run 增量，generate 前检查</span><br><span class="line">    ↓</span><br><span class="line">派出次数卡在 RunSub 入口；工具墙钟只套 Execute</span><br><span class="line">    ↓</span><br><span class="line">RunSub 挂在主 Run 上时共用同一本账</span><br><span class="line">    ↓</span><br><span class="line">Backoff 加倍，封顶 2s</span><br><span class="line">    ↓</span><br><span class="line">CallTimeout 管整次；首 Token 只盯还没出字</span><br><span class="line">    ↓</span><br><span class="line">已吐字再断 → PartialResponseError，fatal</span><br></pre></td></tr></table></figure><p>下一阶段是 Session 落盘：先能存历史和 Token，先不做「卡在工具执行中」的崩溃恢复。</p>]]></content>
    
    
    <summary type="html">阶段九后半段：Session 与单次 Run 两道 Token 闸、Subagent 派出次数、工具墙钟预算，以及 Retry 的指数退避、两层超时和不可重试的半响应。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（二十一）Provider 重试：错误分类、吐字后不重放与 SDK 映射</title>
    <link href="https://sunra.top/posts/5a8c0c/"/>
    <id>https://sunra.top/posts/5a8c0c/</id>
    <published>2026-08-17T04:40:00.000Z</published>
    <updated>2026-08-27T01:58:06.617Z</updated>
    
    <content type="html"><![CDATA[<p>系列二十收口了阶段八。阶段九要解决的不是权限，而是模型调用太脆：一次 429 或 5xx，整次 Run 就停。工具已经执行过的 <code>write_file</code> / <code>bash</code> 不能跟着再打一遍。</p><p>本文对应 <code>go-tiny-claw</code> 两次提交：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">fa1085b  feat: 为模型调用增加可重试错误分类与重试包装</span><br><span class="line">b1557fa  feat: 将 OpenAI 错误映射为可重试类型并接入 Retry</span><br></pre></td></tr></table></figure><span id="more"></span><h2 id="一-只重试模型不重试工具"><a class="markdownIt-Anchor" href="#一-只重试模型不重试工具"></a> 一、只重试模型，不重试工具</h2><p>重试必须包在 Provider 外面，不能写进 Engine 的工具循环。</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Engine.generate</span><br><span class="line">    ↓</span><br><span class="line">RetryingProvider</span><br><span class="line">    ↓</span><br><span class="line">OpenAI / Claude</span><br></pre></td></tr></table></figure><p><code>Generate</code> / <code>GenerateStream</code> 失败且可重试，再打一次模型。这一轮已经跑过的工具 Observation 还在 Session 里，不会因为 HTTP 抖动再执行一次。</p><p>默认 3 次，间隔 200ms。间隔用 <code>Timer</code> + <code>select</code> 听 <code>ctx</code>，不用 <code>Sleep</code>：Ctrl-C 不必等满退避，定时器用 <code>Stop</code> 拆掉。</p><p>取消和截止不重试。<code>context.Canceled</code> / <code>DeadlineExceeded</code> 是 Run 要停，不是瞬时故障。</p><h2 id="二-先分类再决定重不重试"><a class="markdownIt-Anchor" href="#二-先分类再决定重不重试"></a> 二、先分类，再决定重不重试</h2><p><code>ClassifyError</code> 不认任何一家 SDK。它只看三件事：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Canceled / DeadlineExceeded  →  canceled，不重试</span><br><span class="line">HTTP 429、5xx、net 超时     →  retryable</span><br><span class="line">401、400、普通错误           →  fatal</span><br></pre></td></tr></table></figure><p>HTTP 状态收在自己的 <code>HTTPError</code> 里，<code>errors.As</code> 能穿过外层的 <code>%w</code>。这样 <code>fmt.Errorf(&quot;OpenAI/Zhipu API 请求失败: %w&quot;, httpErr)</code> 仍然能分出 429。</p><p>不要把 <code>*openai.Error</code> 写进 <code>ClassifyError</code>。Claude 以后也会失败，分类器不该依赖某一家客户端。</p><h2 id="三-流式没吐字才能重来"><a class="markdownIt-Anchor" href="#三-流式没吐字才能重来"></a> 三、流式：没吐字才能重来</h2><p><code>RetryingProvider</code> 实现了 <code>StreamingProvider</code>。只实现 <code>Generate</code> 的话，Engine 会走同步路径，终端看不到打字。底层不会流式时，先走带重试的 <code>Generate</code>，再补一条 <code>completed</code>。</p><p>流式重试多一条规则：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">还没转发 text_delta / completed</span><br><span class="line">    且错误可重试</span><br><span class="line">    → 丢掉这次失败，再开一条流</span><br><span class="line"></span><br><span class="line">已经把字发给 Reporter</span><br><span class="line">    → 不再重试，把 StreamError 原样往外传</span><br></pre></td></tr></table></figure><p>已经吐出的「你好」不能再打一遍「你好」。半截流标成失败，让 Engine 停，而不是当新请求重放。</p><p><code>generate</code> 在 <code>ctx.Done()</code> 时会立刻返回，不等生产者 goroutine。生产者自己听同一个 <code>ctx</code>：<code>wait</code>、<code>sendStreamEvent</code>、底层 <code>GenerateStream</code> 都会停，然后 <code>defer close</code>。这不是泄漏，是取消后自己收尾。</p><h2 id="四-sdk-错误要在出厂时翻译"><a class="markdownIt-Anchor" href="#四-sdk-错误要在出厂时翻译"></a> 四、SDK 错误要在出厂时翻译</h2><p>分类器认 <code>HTTPError</code>，OpenAI SDK 给的是 <code>*openai.Error</code>（带 <code>StatusCode</code>）。翻译放在 <code>openai.go</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">WrapOpenAIError</span><span class="params">(prefix <span class="type">string</span>, err <span class="type">error</span>)</span></span> <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">if</span> err == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">var</span> apiErr *openai.Error</span><br><span class="line"><span class="keyword">if</span> errors.As(err, &amp;apiErr) &#123;</span><br><span class="line">err = &amp;HTTPError&#123;StatusCode: apiErr.StatusCode, Err: err&#125;</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;%s: %w&quot;</span>, prefix, err)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>同步 <code>Completions.New</code> 和流式 <code>stream.Err()</code> 都走它。<code>buildParams</code> 失败、工具参数非法 JSON 不走：那是本地问题，再打一次请求没有意义。</p><p>不是 API 错误的超时、断连不要改成 Fatal。前缀加上之后，里面的 <code>net.Error</code> 还在，分类器仍能判成可重试。<code>Canceled</code> 包完仍是 canceled。</p><h2 id="五-接线包在-main不包进工厂"><a class="markdownIt-Anchor" href="#五-接线包在-main不包进工厂"></a> 五、接线包在 main，不包进工厂</h2><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">llmProvider := provider.NewRetryingProvider(</span><br><span class="line">provider.NewOpenAICompatibleProvider(<span class="string">&quot;glm-5-2-260617&quot;</span>),</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p><code>NewRuntimeFactory</code> 继续收任意 <code>LLMProvider</code>。测试里的 fake 不该被强制套上 Retry。也不要把 Retry 写进 <code>NewOpenAICompatibleProvider</code>：客户端和重试策略要分开。</p><p><code>ClaudeProvider</code> 这次没映射、没接线，两个入口都没用它。</p><h2 id="六-测试要证明什么"><a class="markdownIt-Anchor" href="#六-测试要证明什么"></a> 六、测试要证明什么</h2><p>分类：429 / 500 / 包装后的 429 / 网络超时可重试；401 / 400 / 普通错误不可重试；取消和截止是 canceled。</p><p>Retry：</p><ol><li><code>Generate</code> 连着两次 429，第三次成功，一共 3 次</li><li>401 只打 1 次</li><li>第一次 500 之后取消，不再打下一次</li><li>流式先 <code>StreamError(429)</code> 再成功，外层只看到成功的 delta 和 completed</li><li>先 delta 再 429，不重试，错误原样转发</li></ol><p>映射：构造带 <code>StatusCode</code> 的 <code>*openai.Error</code>，走 <code>WrapOpenAIError</code> 再 <code>ClassifyError</code>。不打真 API。失败断言不要 <code>%v</code> 打印 SDK 错误：<code>Request</code> / <code>Response</code> 为空时，SDK 的 <code>Error()</code> 会 panic。</p><h2 id="七-阶段九还没做完的"><a class="markdownIt-Anchor" href="#七-阶段九还没做完的"></a> 七、阶段九还没做完的</h2><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">CostTracker 能记账，但 main 没包，超了也不会停 Run</span><br><span class="line">它只实现了 Generate，包在最外层会丢掉流式</span><br><span class="line">单次 Run / Session 的 Token 与费用上限</span><br><span class="line">首 Token 超时</span><br><span class="line">流式半响应单独标错</span><br><span class="line">固定 200ms 还不是指数退避</span><br><span class="line">Claude 的 HTTP 状态还没翻译</span><br></pre></td></tr></table></figure><p>路线图里的工具时长、Subagent 次数可以后做。重试已经保证「只打模型」，预算停 Run 时也不该回头重放工具。</p><h2 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h2><p>阶段九第一刀是让失败可分类、可重试，并且不碰工具：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">ClassifyError 只认 HTTPError / net.Error / context</span><br><span class="line">    ↓</span><br><span class="line">Retry 只包 Generate / GenerateStream</span><br><span class="line">    ↓</span><br><span class="line">流式吐字后不重放</span><br><span class="line">    ↓</span><br><span class="line">openai.go 把 SDK 错误收成 HTTPError</span><br><span class="line">    ↓</span><br><span class="line">两个入口在工厂外面包 NewRetryingProvider</span><br></pre></td></tr></table></figure><p>下一刀是预算：超了用中文错误结束当前 Run，记账还要能跟上流式。</p>]]></content>
    
    
    <summary type="html">为 go-tiny-claw 的模型调用加上可重试错误分类和 Retry 装饰器：只重试 HTTP，已吐字的流式不再重放，并在入口把 OpenAI 错误收成 HTTPError。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（二十）最小权限收口：符号链接、bash 关进工作区与 Grant 审计</title>
    <link href="https://sunra.top/posts/5a8c0b/"/>
    <id>https://sunra.top/posts/5a8c0b/</id>
    <published>2026-08-14T06:40:00.000Z</published>
    <updated>2026-08-27T01:58:06.617Z</updated>
    
    <content type="html"><![CDATA[<p>系列十九把 Grant 收到「工作区 + 参数摘要」，文件路径做了词法沙箱，破坏性 bash 直接 Deny，授权落到 <code>.claw/grants.json</code>。当时还留下三件事：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">符号链接跟随后仍可能逃出工作区</span><br><span class="line">bash 可以 cd .. 或 cat /etc/passwd</span><br><span class="line">Grant 落盘后看不出批了哪次、何时批的</span><br></pre></td></tr></table></figure><p>本文对应 <code>go-tiny-claw</code> 三次提交：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">f90da50  feat: 在持久化 Grant 中记录审批请求与时间</span><br><span class="line">fd625bc  feat: 解析文件路径时跟随符号链接并拒绝逃出工作区</span><br><span class="line">b3473ba  feat: 拒绝试图访问工作区外路径的 bash 命令</span><br></pre></td></tr></table></figure><span id="more"></span><h2 id="一-词法沙箱挡不住符号链接"><a class="markdownIt-Anchor" href="#一-词法沙箱挡不住符号链接"></a> 一、词法沙箱挡不住符号链接</h2><p><code>ResolveWithinWorkspace</code> 上一篇只做字符串：<code>Abs</code>、<code>Clean</code>、<code>Rel</code>。<code>workDir/link.txt → /etc/passwd</code> 的相对名仍在区内，<code>read_file</code> 又是 <code>RiskSafe</code>，打开时内核会再 follow 一次。</p><p>所以要两道检查：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">词法 Rel(root, resolved)     挡住 ../outside</span><br><span class="line">    ↓</span><br><span class="line">EvalSymlinks 后再 Rel        挡住区内 link 指向区外</span><br></pre></td></tr></table></figure><p>工作区自己也可能是链接。macOS 上 <code>/var</code> 常指向 <code>/private/var</code>。比较真实落点时，两边都要用解开后的根，否则区内路径会被误判成逃逸。</p><p>目标文件还不存在时（<code>write_file</code> 先解析、再 <code>MkdirAll</code>），不能对整条路径 <code>EvalSymlinks</code>。从父目录往上走到第一个存在的祖先，再拼回后面的相对段：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">subdir/a.go 且 subdir 不存在</span><br><span class="line">    → 跟到 workDir，拼回 subdir/a.go，区内，通过</span><br><span class="line"></span><br><span class="line">out/new.go 且 out → /tmp/outside</span><br><span class="line">    → 跟到 /tmp/outside/new.go，Rel 失败</span><br></pre></td></tr></table></figure><p>返回值仍是词法路径，不是 follow 之后的真实路径。调用方写入的是用户给的那个名字；测试也比较 <code>Abs(Join(workDir, ...))</code>。已经确认目标在区内，<code>Open(resolved)</code> 再 follow 一次是安全的。</p><p>错误要拆开：真正越界报「路径超出工作区」；权限不够、符号链接环报「解析符号链接失败」。不要把 follow 失败一律收成越界。</p><p>文件工具的调用点不用改，它们已经走 <code>ResolveWithinWorkspace</code>。</p><h2 id="二-cmddir-不是沙箱"><a class="markdownIt-Anchor" href="#二-cmddir-不是沙箱"></a> 二、cmd.Dir 不是沙箱</h2><p><code>bash</code> 设了 <code>cmd.Dir = workDir</code>，只决定起始目录。<code>cat /etc/passwd</code> 和 <code>cd ..</code> 仍然能跑。</p><p>再加一道启发式：<code>CommandEscapesWorkspace</code> 看命令文本，不解析 shell。</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">strings.Fields 切 token</span><br><span class="line">绝对路径          → 拒</span><br><span class="line">任一路径段是 ..   → 拒</span><br><span class="line">cd 无参数 / 绝对路径 / ..  → 拒</span><br></pre></td></tr></table></figure><p><code>foo/bar</code>、<code>cat a.go</code>、<code>go test ./...</code> 放行。<code>./...</code> 里没有名为 <code>..</code> 的路径段。</p><p>判定放在 <code>internal/sandbox</code>，和破坏性命令同一层。<code>Policy</code> 只做映射：破坏性 <strong>或</strong> 逃逸都是 <code>PolicyDeny</code>，不要问用户「要不要允许 <code>cat /etc/passwd</code>」。<code>Execute</code> 在 <code>CommandContext</code> 之前再拒一次，换掉 Policy 也执行不了。</p><p>对外文案统一成「命令试图访问工作区外的路径」。sandbox 回答出没出界，不按绝对路径 / <code>..</code> / <code>cd</code> 分三种给用户。</p><p>这不是 OS 隔离。<code>cat $(echo /etc/passwd)</code>、加引号的绝对路径，<code>Fields</code> 拆不开。v1 只挡明文。</p><h2 id="三-grant-要能回看批了什么"><a class="markdownIt-Anchor" href="#三-grant-要能回看批了什么"></a> 三、Grant 要能回看批了什么</h2><p>匹配键仍然是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">SessionID + WorkDir + ToolName + ArgumentDigest</span><br></pre></td></tr></table></figure><p>审计字段不进钥匙。<code>Has</code> 只问「这条授权在不在」，不按 <code>RequestID</code> 查找。</p><p><code>AllowSession</code> 落盘时补上：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Grant&#123;</span><br><span class="line">    RequestID:  request.ID,</span><br><span class="line">    Decision:   decision,</span><br><span class="line">    ApprovedAt: time.Now(),</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>FileGrantStore</code> 整份 <code>Grant</code> 序列化进 <code>grants.json</code>，重新加载后这三项还在。测试里用第二次 <code>NewFileGrantStore</code> 读回，确认不是只活在内存里。</p><p>还没记的是「谁批的」。<code>AllowOnce</code> / <code>Deny</code> 也不落盘，只有会话级允许会留下记录。完整审计日志是下一档，不是改匹配键。</p><h2 id="四-测试要证明什么"><a class="markdownIt-Anchor" href="#四-测试要证明什么"></a> 四、测试要证明什么</h2><p>符号链接：</p><ol><li>指向区内文件的 link 解析成功，返回词法路径</li><li>指向区外文件的 link 失败，文案是超出工作区</li><li>父目录是区外 link、目标文件还不存在，同样失败</li><li><code>a → b → a</code> 的环报解析符号链接失败，不报越界</li></ol><p>bash：</p><ol><li><code>go test ./...</code>、<code>ls -la</code>、<code>cat a.go</code> 通过</li><li><code>cat /etc/passwd</code>、<code>cd ..</code>、<code>cd /tmp</code>、<code>ls ../../</code> 失败，文案含「工作区外」</li><li>Policy / Gate 对逃逸命令直接 Deny，不叫 Handler</li><li><code>BashTool.Execute</code> 在真正 <code>bash -c</code> 之前就返回错误</li></ol><p>Grant 审计：落盘后再 <code>New</code>，<code>RequestID</code>、<code>Decision</code>、<code>ApprovedAt</code> 还在。</p><h2 id="五-阶段八收到哪里"><a class="markdownIt-Anchor" href="#五-阶段八收到哪里"></a> 五、阶段八收到哪里</h2><p>路线图里的最小权限，现在是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">Grant 认工作区和参数摘要</span><br><span class="line">    ↓</span><br><span class="line">文件路径：词法 + 跟随符号链接</span><br><span class="line">    ↓</span><br><span class="line">破坏性 bash、逃出工作区的 bash → Deny</span><br><span class="line">    ↓</span><br><span class="line">同一工作区一份 grants.json</span><br><span class="line">    ↓</span><br><span class="line">会话级授权带上请求 ID 和批准时间</span><br></pre></td></tr></table></figure><p>还不是完整隔离：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">bash 仍是启发式，不是 bwrap / sandbox-exec</span><br><span class="line">命令替换、引号绕过挡不住</span><br><span class="line">检查和使用之间改掉 symlink（TOCTOU）</span><br><span class="line">硬链接没有「指向哪」可 follow</span><br><span class="line">AllowOnce / Deny 没有审计记录</span><br></pre></td></tr></table></figure><p>工作区内的 <code>write_file</code> 仍询问，这是产品选择。参数级风险目前覆盖的是「明显破坏、明显逃逸直接拒绝」。</p><h2 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h2><p>十九做匹配和落盘，二十补硬边界的第二刀，并让磁盘上的 Grant 能回看：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">路径先 Rel，再 follow</span><br><span class="line">    ↓</span><br><span class="line">bash 文本里的绝对路径和 .. 直接拒绝</span><br><span class="line">    ↓</span><br><span class="line">Grant 多记 RequestID / Decision / ApprovedAt</span><br></pre></td></tr></table></figure><p>阶段八可以收口。下一阶段是路线图里的 Provider 可靠性与成本预算：超时、429、重试，以及单次 Run 的 Token 上限。</p>]]></content>
    
    
    <summary type="html">为 go-tiny-claw 的路径沙箱补上符号链接跟随，用启发式规则把 bash 关进工作区，并在持久化 Grant 中记下审批请求与时间。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（十九）最小权限：Grant 收窄、工作区沙箱与授权落盘</title>
    <link href="https://sunra.top/posts/5a8c0a/"/>
    <id>https://sunra.top/posts/5a8c0a/</id>
    <published>2026-08-14T01:40:00.000Z</published>
    <updated>2026-08-27T01:58:06.617Z</updated>
    
    <content type="html"><![CDATA[<p>系列十八收口了阶段七。阶段八要解决的不是取消，而是授权太粗：一次「本会话允许 bash」，等于这个会话里任意命令都自动过；<code>read_file</code> 标成 Safe，<code>../etc/passwd</code> 也会自动执行。</p><p>本文对应 <code>go-tiny-claw</code> 四次提交：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">4604fbf  feat: 将 Grant 匹配从工具名缩小到工作区与参数摘要</span><br><span class="line">908c112  feat: 为文件工具增加工作区路径沙箱</span><br><span class="line">db7b9c6  feat: 拒绝破坏性 bash 命令，不再询问用户</span><br><span class="line">9a9c112  feat: 将会话授权持久化到工作区 Grant 文件</span><br></pre></td></tr></table></figure><span id="more"></span><h2 id="一-原来的-grant-只认工具名"><a class="markdownIt-Anchor" href="#一-原来的-grant-只认工具名"></a> 一、原来的 Grant 只认工具名</h2><p>系列十三的 <code>AllowSession</code> 存的是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">g.grants.Save(ctx, Grant&#123;</span><br><span class="line">SessionID: request.SessionID,</span><br><span class="line">ToolName:  request.ToolCall.Name,</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>查找同样只看这两项。<code>Grant</code> 里其实已经有 <code>WorkDir</code>，但 Gate 没填，Engine 构造 <code>Request</code> 时也没带工作区。</p><p>于是会出现：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">用户允许 bash(&quot;go test&quot;)</span><br><span class="line">    ↓</span><br><span class="line">本会话任意 bash 都 Has == true</span><br><span class="line">    ↓</span><br><span class="line">bash(&quot;rm -rf /&quot;) 不再询问</span><br></pre></td></tr></table></figure><p>最小权限的第一刀不是落盘，而是把匹配键改对。磁盘上如果还按工具名存，持久化只会把错误范围写死。</p><h2 id="二-匹配键加上工作区和参数摘要"><a class="markdownIt-Anchor" href="#二-匹配键加上工作区和参数摘要"></a> 二、匹配键加上工作区和参数摘要</h2><p>现在的钥匙是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">SessionID + WorkDir + ToolName + ArgumentDigest</span><br></pre></td></tr></table></figure><p>参数不能直接当 map key：LLM 可能把 <code>&#123;&quot;a&quot;:1,&quot;b&quot;:2&#125;</code> 写成 <code>&#123;&quot;b&quot;:2,&quot;a&quot;:1&#125;</code>。<code>DigestArguments</code> 先 Unmarshal 再 Marshal（Go 会对 object 的 key 排序），然后 sha256。空参数当成 <code>&#123;&#125;</code>。非法 JSON 对原始字节做 hash，避免和合法 <code>&#123;&#125;</code> 撞上。</p><p>Gate 保存时把四元组写全：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> decision == AllowSession &#123;</span><br><span class="line">err = g.grants.Save(ctx, Grant&#123;</span><br><span class="line">SessionID:      request.SessionID,</span><br><span class="line">WorkDir:        request.WorkDir,</span><br><span class="line">ToolName:       request.ToolCall.Name,</span><br><span class="line">ArgumentDigest: DigestArguments(request.ToolCall.Arguments),</span><br><span class="line">&#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>Has</code> 用请求现算摘要，不要先取出 Grant 再比。找不到键，说明不是同一条授权。</p><p>Engine 构造审批请求时补上 <code>session.WorkDir</code>。测试里如果手动 <code>Save</code> 过期 Grant，也必须带上 <code>ArgumentDigest</code>，否则测到的是「键对不上」，不是「过期删除」。</p><h2 id="三-路径沙箱是硬边界"><a class="markdownIt-Anchor" href="#三-路径沙箱是硬边界"></a> 三、路径沙箱是硬边界</h2><p><code>filepath.Join</code> 有两个坑：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Join(&quot;/work&quot;, &quot;../etc/passwd&quot;)  →  还没清掉 ..</span><br><span class="line">Join(&quot;/work&quot;, &quot;/etc/passwd&quot;)    →  丢掉 workDir</span><br></pre></td></tr></table></figure><p><code>read_file</code> 又是 <code>RiskSafe</code>，这两类请求会自动执行。审批解决不了这件事：逃逸必须在执行前被拒绝。</p><p>规则放在 <code>internal/sandbox</code>，不依赖 <code>tools</code> 或 <code>approval</code>：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">sandbox</span><br><span class="line">  ↑</span><br><span class="line">tools / approval</span><br></pre></td></tr></table></figure><p><code>ResolveWithinWorkspace</code> 的步骤是：</p><ol><li>拒绝空路径、空工作区、绝对路径</li><li>对 workDir 和 Join 结果做 <code>Abs</code> + <code>Clean</code></li><li>用 <code>filepath.Rel</code> 看相对路径是不是 <code>..</code> 开头</li></ol><p>不要用 <code>strings.HasPrefix(resolved, root)</code>：<code>/work</code> 会误放行 <code>/work-evil</code>。</p><p><code>read_file</code>、<code>write_file</code>、<code>edit_file</code> 都先走这个函数，失败直接返回，不再问用户。<code>bash</code> 这次没接：<code>cmd.Dir</code> 只是起始目录，命令里仍可以 <code>cd ..</code>。</p><h2 id="四-破坏性命令由-sandbox-判定policy-只做映射"><a class="markdownIt-Anchor" href="#四-破坏性命令由-sandbox-判定policy-只做映射"></a> 四、破坏性命令由 sandbox 判定，Policy 只做映射</h2><p>拒绝表属于「什么命令危险」，和路径逃不逃逸是一类规则。<code>Policy</code> 只回答危险了怎么办。</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">sandbox.IsDestructiveCommand(command)  ← 只收 string</span><br><span class="line">        ↑</span><br><span class="line">DefaultPolicy 解析 bash JSON，命中则 PolicyDeny</span><br><span class="line">        ↑</span><br><span class="line">Gate 把 PolicyDeny 映射成 Deny，不问 Handler</span><br></pre></td></tr></table></figure><p>匹配是启发式子串，不是完整 shell 解析。空白压扁、转小写之后查：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">rm -rf / rm -fr / mkfs / dd of=/dev/ / shutdown / reboot / 叉弹</span><br></pre></td></tr></table></figure><p><code>go test ./...</code> 仍会询问。<code>cmd=rm; $cmd -rf</code> 这种绕过挡不住，以后再收。</p><p>Grant 测试里「不同参数再问」不能再用 <code>rm -rf /</code> 当第二条命令，否则现在会直接 Deny，测不到摘要是否收窄。</p><h2 id="五-授权落到-clawgrantsjson"><a class="markdownIt-Anchor" href="#五-授权落到-clawgrantsjson"></a> 五、授权落到 .claw/grants.json</h2><p>匹配键稳定之后才落盘。文件在 <code>&#123;workDir&#125;/.claw/grants.json</code>。</p><p><code>FileGrantStore</code> 的 <code>Has</code> / <code>Save</code> / <code>Revoke</code> 和内存版同一把钥匙。写入先写 <code>.tmp</code> 再 <code>Rename</code>；落盘失败把内存里那次改动撤掉。过期条目在 <code>Has</code> 时删除并写回。</p><p>工厂不能每个 Runtime <code>New</code> 一份文件 Store：两个 Session 会互相覆盖 JSON。<code>RuntimeFactory</code> 按工作区懒加载一次，SessionID 仍在键里，会话之间不串授权。</p><p>单元测试里的 Engine 继续用 <code>MemoryGrantStore</code>。CLI / Channel 才走文件。</p><h2 id="六-测试要证明什么"><a class="markdownIt-Anchor" href="#六-测试要证明什么"></a> 六、测试要证明什么</h2><p>Grant 收窄：</p><ol><li>相同参数复用，Handler 只问一次</li><li>不同参数再问</li><li>JSON 键顺序不同视为同一 Grant</li><li>不同 WorkDir 不复用</li><li>过期 Grant 先命中再删掉</li></ol><p>路径沙箱：相对路径成功；<code>..</code>、嵌套 <code>..</code>、绝对路径、空路径、空工作区失败。</p><p>命令拒绝：破坏性命令 <code>IsDestructiveCommand == true</code>；<code>go test</code> 为 false。Gate 对 <code>rm -rf /</code> 不调用 Handler。</p><p>文件存储：重新 <code>NewFileGrantStore</code> 后相同参数仍命中；不同参数不命中；过期失效；没有文件也能创建。</p><h2 id="七-阶段八还没做完的"><a class="markdownIt-Anchor" href="#七-阶段八还没做完的"></a> 七、阶段八还没做完的</h2><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">审计：谁批的、何时、批了哪次调用</span><br><span class="line">bash 限制在工作区内（cd ..、cat /etc/passwd）</span><br><span class="line">符号链接跟随后仍可能逃出工作区</span><br><span class="line">拒绝表不是完整 shell 解析</span><br></pre></td></tr></table></figure><p>路线图里的参数级风险，目前只覆盖了「明显破坏性 bash 直接拒绝」。工作区内 <code>write_file</code> 仍询问，这是产品选择，不是漏做。</p><h2 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h2><p>阶段八先改匹配，再加硬边界，最后才落盘：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Grant 认参数摘要和工作区</span><br><span class="line">    ↓</span><br><span class="line">文件路径必须落在工作区内</span><br><span class="line">    ↓</span><br><span class="line">破坏性 bash 由 sandbox 判定，Policy 映射为 Deny</span><br><span class="line">    ↓</span><br><span class="line">同一工作区共享一份 grants.json</span><br></pre></td></tr></table></figure><p>授权持久化之前，如果钥匙还是工具名，磁盘上存的也会是错误范围。下一阶段可以补审计，或把 bash 也关进工作区。</p>]]></content>
    
    
    <summary type="html">把 go-tiny-claw 的 AllowSession 从工具名收窄到工作区与参数摘要，补上路径沙箱和破坏性 bash 拒绝，并把授权持久化到 .claw/grants.json。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（十八）事件背压：有界队列、尊重 Context 与可关闭的 EventSink</title>
    <link href="https://sunra.top/posts/5a8c09/"/>
    <id>https://sunra.top/posts/5a8c09/</id>
    <published>2026-08-13T04:00:00.000Z</published>
    <updated>2026-08-27T01:58:06.617Z</updated>
    
    <content type="html"><![CDATA[<p>系列十七修好了终端审批泄漏。阶段七还剩另一件容易在生产里爆内存的事：事件比网络快时，往哪里堆？以及堆不住时，失败能不能变成 Run 的终态。</p><p>工具并发上限限制的是「同时跑几个工具」。事件背压限制的是「事件管道别无限灌」。两者管的不是同一类资源。</p><p>本文对应 <code>go-tiny-claw</code> 两次提交：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">bb746e1  feat: 为 Channel EventSink 增加有界队列背压</span><br><span class="line">85904e7  feat: 将 Reporter 错误回传 Engine，关闭时等待 EventSink 写循环退出</span><br></pre></td></tr></table></figure><p>落地之后，Channel 侧的事件出口是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">有界 queue（默认 64）</span><br><span class="line">唯一 writer goroutine</span><br><span class="line">Publish 尊重 ctx</span><br><span class="line">Close 叫醒阻塞中的 Publish</span><br><span class="line">conn.Close 打断正在进行的 Write</span><br><span class="line">Wait 确认 loop 已退出</span><br><span class="line">Reporter 错误会停掉当前 Run</span><br></pre></td></tr></table></figure><span id="more"></span><h2 id="一-原来的-publish-直接写连接"><a class="markdownIt-Anchor" href="#一-原来的-publish-直接写连接"></a> 一、原来的 Publish 直接写连接</h2><p>改造前，路径是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Engine / Reporter</span><br><span class="line">    ↓</span><br><span class="line">JSONEventSink.Publish</span><br><span class="line">    ↓</span><br><span class="line">MessageWriter.Encode</span><br><span class="line">    ↓</span><br><span class="line">TCP / WebSocket conn</span><br></pre></td></tr></table></figure><p><code>Publish</code> 会先看一眼 <code>ctx</code>，然后同步 <code>Write</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s *JSONEventSink)</span></span> Publish(ctx context.Context, event reporter.Event) <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">if</span> err := ctx.Err(); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> s.writer.Write(event)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>慢客户端会把 <code>Write</code> 堵住。更糟的是：堵住之后再按 Ctrl-C，这次 <code>Write</code> 不一定听 <code>ctx</code>，Engine 可能一直卡在某次 <code>OnTextDelta</code> 上。</p><p>当时 <code>JSONReporter</code> 还是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">_ = r.sink.Publish(ctx, event)</span><br></pre></td></tr></table></figure><p>错误被丢掉。背压这一层先让 <code>Publish</code> 自己能阻塞、能取消、能关闭；随后再把错误从 Reporter 传回 Engine。</p><h2 id="二-背压是什么"><a class="markdownIt-Anchor" href="#二-背压是什么"></a> 二、背压是什么</h2><p>把中间换成有界队列：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Publish  ──►  queue(cap=64)  ──►  loop goroutine  ──►  Write(conn)</span><br></pre></td></tr></table></figure><table><thead><tr><th>情况</th><th>行为</th></tr></thead><tbody><tr><td>队列有空位</td><td><code>Publish</code> 立刻返回</td></tr><tr><td>队列满、任务还在</td><td><code>Publish</code> 堵住，Engine 变慢</td></tr><tr><td>队列满、用户取消</td><td><code>ctx.Done()</code> 返回，不永久卡住</td></tr><tr><td>Session 关闭</td><td>不再入队，等 Write 返回后 loop 退出</td></tr></tbody></table><p>「满了让上游慢下来」就是背压。没有界的 channel 或无限 goroutine，只是把压力变成内存。</p><h2 id="三-为什么不能-for-range-queue"><a class="markdownIt-Anchor" href="#三-为什么不能-for-range-queue"></a> 三、为什么不能 for range queue</h2><p>第一版 writer 是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> item := <span class="keyword">range</span> s.queue &#123;</span><br><span class="line">_ = s.writer.Write(item.event)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>range</code> 一个 channel 会一直等到 <strong>channel 被 close</strong>。Session 关掉如果不 <code>close(queue)</code>，这条 goroutine 会永远挂着。</p><p>但 <code>close(queue)</code> 和 <code>queue &lt;- item</code> 并发会发生 panic。Engine 的 <code>Destroy</code> 和迟到的 <code>Publish</code> 之间做不到绝对无缝。</p><p>所以关机信号不能靠 <code>queue</code>，要另开一个 <code>done</code>：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">done：广播「Sink 已关闭」，叫醒所有 select</span><br><span class="line">queue：只承载事件，Close 时不要 close 它</span><br></pre></td></tr></table></figure><p><code>loop</code> 改成：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s *JSONEventSink)</span></span> loop() &#123;</span><br><span class="line"><span class="keyword">defer</span> s.wg.Done()</span><br><span class="line"><span class="keyword">for</span> &#123;</span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> &lt;-s.done:</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line"><span class="keyword">case</span> item := &lt;-s.queue:</span><br><span class="line"><span class="keyword">if</span> item.ctx.Err() != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br><span class="line">_ = s.writer.Write(item.event)</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>Close</code> 只 <code>close(s.done)</code>。<code>select</code> 只能叫醒还在等 <code>done</code> / <code>queue</code> 的 loop。一旦已经进了 <code>Write</code>，<code>done</code> 对它就是空气。</p><h2 id="四-publish-必须同时听三件事"><a class="markdownIt-Anchor" href="#四-publish-必须同时听三件事"></a> 四、Publish 必须同时听三件事</h2><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line"><span class="keyword">return</span> ctx.Err()</span><br><span class="line"><span class="keyword">case</span> &lt;-s.done:</span><br><span class="line"><span class="keyword">return</span> ErrEventSinkClosed</span><br><span class="line"><span class="keyword">case</span> s.queue &lt;- item:</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这就是「尊重 ctx」：可阻塞的等待里，取消和关闭都要能打断入队。</p><p>这里有一个 Go 的坑。<code>Close</code> 之后立刻 <code>Publish</code>，若队列还有空位：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">&lt;-s.done        就绪</span><br><span class="line">s.queue &lt;- item 也就绪</span><br></pre></td></tr></table></figure><p>同一个 <code>select</code> 会随机挑一个。测试里曾经抽中入队，返回 <code>nil</code>，断言关闭失败。</p><p>拆成两个 <code>select</code>（先看 <code>done</code>，再阻塞）能修，但读起来绕。更直接的办法是加一个 <code>closed</code> 标记，<strong>先挡关闭后的调用</strong>，<code>done</code> 继续负责叫醒已经堵在 <code>select</code> 里的人：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s *JSONEventSink)</span></span> Publish(ctx context.Context, event reporter.Event) <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">if</span> s.closed.Load() &#123;</span><br><span class="line"><span class="keyword">return</span> ErrEventSinkClosed</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line"><span class="keyword">return</span> ctx.Err()</span><br><span class="line"><span class="keyword">case</span> &lt;-s.done:</span><br><span class="line"><span class="keyword">return</span> ErrEventSinkClosed</span><br><span class="line"><span class="keyword">case</span> s.queue &lt;- item:</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s *JSONEventSink)</span></span> Close() &#123;</span><br><span class="line">s.closeOnce.Do(<span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">s.closed.Store(<span class="literal">true</span>)</span><br><span class="line"><span class="built_in">close</span>(s.done)</span><br><span class="line">&#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>两个东西不是重复：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">closed：已经关闭了吗？（布尔，查一眼）</span><br><span class="line">done  ：正在等待的人，请醒来（channel，能广播）</span><br></pre></td></tr></table></figure><p>只有布尔值，堵在满队列上的 <code>Publish</code> 醒不过来；只有 <code>done</code> 放进同一个 <code>select</code>，关闭后的瞬时调用可能误入队。</p><h2 id="五-close-关连接-wait-不能写反"><a class="markdownIt-Anchor" href="#五-close-关连接-wait-不能写反"></a> 五、Close、关连接、Wait 不能写反</h2><p><code>Close</code> 立刻返回，只表示「通知已发出」。<code>Wait</code> 返回，才表示写事件的那条 goroutine 已经 <code>return</code>。</p><p>因此 <code>Close</code> 不能在内部 <code>Wait</code>：Sink 不拥有连接，等 loop 时 <code>Write</code> 可能还堵着，自己死锁。等待是 Session 的责任，而且必须先把 <code>Write</code> 打断。</p><p><code>ChannelSession.Close</code> 的顺序是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">Destroy Runtime     ← Cancel，Engine 停止继续生产</span><br><span class="line">events.Close()      ← closed + close(done)，Publish 立刻失败</span><br><span class="line">conn.Close()        ← 打断正在进行的 Write</span><br><span class="line">events.Wait()       ← 确认 loop 已退出</span><br></pre></td></tr></table></figure><p>两处对调会出问题：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">先 Wait 再 conn.Close</span><br><span class="line">    → loop 卡在 Write 上，Wait 永远回不来</span><br><span class="line"></span><br><span class="line">先 conn.Close 再 events.Close</span><br><span class="line">    → Publish 仍可能入队，再对已关闭的连接 Write，错误被丢掉</span><br></pre></td></tr></table></figure><p><code>done</code> 解决的是「别再入队 / 别再取下一条」。它解决不了「这一条已经交给内核的 Write」。打断 <code>Write</code> 是连接所有者的责任。</p><h2 id="六-把-publish-错误传回-engine"><a class="markdownIt-Anchor" href="#六-把-publish-错误传回-engine"></a> 六、把 Publish 错误传回 Engine</h2><p>有界队列只能让上游变慢。下游已经关掉时，还得让当前 Run 停下来，并且 Session 里的 ToolCall 仍然成对。</p><p><code>Reporter</code> / <code>StreamReporter</code> 现在返回 <code>error</code>。<code>JSONReporter</code> 不再丢掉 <code>Publish</code> 的结果：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *JSONReporter)</span></span> publish(ctx context.Context, event Event) <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">return</span> r.sink.Publish(ctx, event)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Engine 按阶段处理：</p><table><thead><tr><th>失败点</th><th>行为</th></tr></thead><tbody><tr><td><code>OnThinking</code> / 流式 <code>OnTextDelta</code></td><td>还没有带 ToolCall 的 Assistant，直接返回</td></tr><tr><td><code>OnMessage</code> 且已 <code>Append</code> 了 ToolCalls</td><td><code>EnsureToolObservations</code> 后再返回</td></tr><tr><td><code>OnToolCall</code></td><td>不 <code>Execute</code>，补取消 Observation</td></tr><tr><td><code>OnToolResult</code></td><td>工具已经跑完，仍写入真实结果，再记下 <code>reportErr</code></td></tr></tbody></table><p>工具 goroutine 里用 <code>setReportErr</code> 只保留第一个错误。<code>Wait</code> 之后先看 <code>ctx.Err()</code>，再看 <code>reportErr</code>：用户取消时返回 <code>Canceled</code>，不会被 Sink 错误盖住。</p><p><code>RunSub</code> 用同一套逻辑。它没有持久化 Session，失败时把 Observation 补进局部 <code>contextHistory</code> 再返回。</p><h2 id="七-generate-返回时必须取消流式-ctx"><a class="markdownIt-Anchor" href="#七-generate-返回时必须取消流式-ctx"></a> 七、generate 返回时必须取消流式 ctx</h2><p>只让 Engine 提前 <code>return</code> 不够。<code>GenerateStream</code> 那条 goroutine 可能还堵在往 <code>events</code> 里送。</p><p><code>OnTextDelta</code> 失败时，父 <code>ctx</code> 不一定已经被取消——Channel 正常关闭会先 <code>Destroy</code>，但 Reporter 自己失败时不会。所以 <code>generate</code> 进入时派生一个可取消的 ctx：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">ctx, cancel := context.WithCancel(ctx)</span><br><span class="line"><span class="keyword">defer</span> cancel()</span><br></pre></td></tr></table></figure><p>任何返回路径都会取消这次流。Provider 侧的 <code>sendStreamEvent</code> 已经同时听 <code>ctx.Done()</code>，HTTP stream 也把同一个 ctx 传给 SDK。这样「Run 结束」和「读模型的 goroutine 退出」才是一件事。</p><h2 id="八-测试要证明什么"><a class="markdownIt-Anchor" href="#八-测试要证明什么"></a> 八、测试要证明什么</h2><p><code>test/channel/event_sink_test.go</code>：</p><ol><li>队列有空位，<code>Publish</code> 立刻成功。</li><li>用会阻塞的 <code>io.Writer</code> 让 <code>loop</code> 卡在 <code>Write</code> 上，填满容量为 1 的队列，第三次 <code>Publish</code> 在 <code>cancel</code> 后返回 <code>context.Canceled</code>。</li><li><code>Close</code> 两次不 panic；之后 <code>Publish</code> 返回 <code>ErrEventSinkClosed</code>。</li><li><code>Write</code> 仍阻塞时 <code>Wait</code> 不返回；模拟 <code>conn.Close</code> 让 <code>Write</code> 返回后，<code>Wait</code> 结束。</li></ol><p>第二条说明背压：下游不消费时，上游必须能被 ctx 救出来。第四条说明关机契约：<code>Close</code> 叫不醒 <code>Write</code>，<code>Wait</code> 必须发生在连接被关掉之后。</p><p><code>test/engine/reporter_error_test.go</code>：</p><ol><li><code>OnMessage</code> 失败时，已入库的 ToolCall 会补 Observation，工具不会执行。</li><li><code>OnToolCall</code> 失败时同样跳过 <code>Execute</code>，并补取消 Observation。</li></ol><p><code>test/engine/stream_cancel_test.go</code>：</p><ol><li>父 ctx 取消后，流式 Provider 的 goroutine 退出。</li><li>只有 Reporter 失败、父 ctx 还活着时，<code>generate</code> 的 <code>defer cancel</code> 仍能让 Provider goroutine 退出。</li></ol><p>为了测「满」，构造函数增加了容量参数；生产路径仍走默认 64。相关测试可以在 <code>-race</code> 下通过。</p><h2 id="九-阶段七收口"><a class="markdownIt-Anchor" href="#九-阶段七收口"></a> 九、阶段七收口</h2><p>对照系列十的阶段七清单：</p><table><thead><tr><th>问题</th><th>落地</th></tr></thead><tbody><tr><td>Approval 输入 goroutine 泄漏</td><td>系列十七：stdin 归 REPL</td></tr><tr><td>取消后误写 Session</td><td><code>EnsureToolObservations</code></td></tr><tr><td>bash 子进程 / 工具级超时</td><td><code>CommandContext</code> + <code>ErrToolTimeout</code></td></tr><tr><td>统一任务状态</td><td><code>canceled</code> / <code>timed_out</code> / <code>failed</code></td></tr><tr><td>工具并发上限</td><td><code>MaxToolConcurrency</code></td></tr><tr><td>Channel 背压</td><td>有界 EventSink</td></tr><tr><td>Reporter 并发输出</td><td>Terminal 加锁；Channel 单 goroutine 写</td></tr><tr><td>优雅关闭</td><td><code>Close</code> → <code>conn.Close</code> → <code>Wait</code></td></tr><tr><td>Provider 流取消后退出</td><td><code>generate</code> 派生 ctx + 测试</td></tr><tr><td>取消、超时、泄漏测试</td><td>Session、bash、Sink、Reporter、Stream</td></tr></tbody></table><p>阶段八才是 Grant 持久化和参数级权限，不在本篇范围。</p><h2 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h2><p>事件出口要同时成立五条约束：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">队列有界</span><br><span class="line">    ↓</span><br><span class="line">Publish 同时听 ctx 和关闭</span><br><span class="line">    ↓</span><br><span class="line">Close 能叫醒等待入队的人</span><br><span class="line">    ↓</span><br><span class="line">关连接才能打断正在进行的 Write</span><br><span class="line">    ↓</span><br><span class="line">Reporter 错误变成 Run 终态，且 ToolCall 仍然成对</span><br></pre></td></tr></table></figure><p>执行侧限制并行数，输出侧限制管道深度，失败时停止当前任务而不是把错误吞掉。到这里，路线图阶段七「资源生命周期和可靠取消」可以收口；下一阶段是审批持久化与最小权限。</p>]]></content>
    
    
    <summary type="html">为 JSONEventSink 增加有界队列与关闭等待，并把 Reporter 错误传回 Engine，收口 go-tiny-claw 阶段七的资源生命周期与可靠取消。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（十七）修复终端审批泄漏并对齐 TerminalSession</title>
    <link href="https://sunra.top/posts/5a8c08/"/>
    <id>https://sunra.top/posts/5a8c08/</id>
    <published>2026-08-12T07:00:00.000Z</published>
    <updated>2026-08-27T01:58:06.617Z</updated>
    
    <content type="html"><![CDATA[<p>系列十三引入了通用 Approval Gate，系列十六已经让 Channel 侧用 <code>Approve</code> + <code>Respond</code> 完成了审批闭环。但 Terminal 这条路还停在一个危险实现上：<code>Approve</code> 内部另起 goroutine 去 <code>ReadString</code>，一旦用户在审批等待时按 Ctrl-C，调用链返回了，读 stdin 的 goroutine 却可能还活着。</p><p>本文对应 <code>go-tiny-claw</code> 提交：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">04608aa  fix: 消除终端审批在 Ctrl-C 后的 stdin 读泄漏</span><br><span class="line">7ebceab  refactor: 引入 TerminalSession 对齐 Channel 装配</span><br></pre></td></tr></table></figure><p>它补上的是路线图阶段七里被点名的缺口之一：资源生命周期与可靠取消中的「Approval 输入 Goroutine 是否泄漏」。</p><span id="more"></span><h2 id="一-问题从哪里来"><a class="markdownIt-Anchor" href="#一-问题从哪里来"></a> 一、问题从哪里来</h2><p>系列十三里的终端 Handler 大致是这样写的：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">inputCh := <span class="built_in">make</span>(<span class="keyword">chan</span> <span class="type">string</span>, <span class="number">1</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">line, _ := h.reader.ReadString(<span class="string">&#x27;\n&#x27;</span>)</span><br><span class="line">inputCh &lt;- strings.TrimSpace(line)</span><br><span class="line">&#125;()</span><br><span class="line"></span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, ctx.Err()</span><br><span class="line"><span class="keyword">case</span> input := &lt;-inputCh:</span><br><span class="line"><span class="comment">// 解析 y / a / n</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这段代码能工作，是因为它同时在等两件事：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">用户输入一行</span><br><span class="line">    或</span><br><span class="line">Context 被取消</span><br></pre></td></tr></table></figure><p>但 <code>ReadString</code> 本身不知道 Context。Ctrl-C 只会取消 <code>runCtx</code>，让 <code>select</code> 走到 <code>ctx.Done()</code> 并返回；后台那条 <code>go</code> 仍然堵在 stdin 上。</p><p>于是会出现：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">Approve 返回 context.Canceled</span><br><span class="line">    ↓</span><br><span class="line">任务结束，REPL 回到下一轮</span><br><span class="line">    ↓</span><br><span class="line">REPL 再次 ReadString</span><br><span class="line">    ↓</span><br><span class="line">泄漏的审批 goroutine 也在 ReadString</span><br><span class="line">    ↓</span><br><span class="line">两个读者抢同一个 stdin</span><br></pre></td></tr></table></figure><p>用户下一行输入可能被泄漏的 goroutine 吃掉，表现为「按了没反应」或输入错乱。更重要的是：每次取消都可能留下一个永远卡住的 goroutine。</p><p>系列十三已经写过这个缺口：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">ReadString 不能直接被 Context 取消。</span><br><span class="line">中断发生时，Handler 可以返回，</span><br><span class="line">但后台读取 Goroutine 仍可能阻塞。</span><br></pre></td></tr></table></figure><p>本篇就是把这句话真正落地。</p><h2 id="二-为什么-channel-没有这个问题"><a class="markdownIt-Anchor" href="#二-为什么-channel-没有这个问题"></a> 二、为什么 Channel 没有这个问题</h2><p><code>ChannelApprovalHandler</code> 从一开始就没有在 <code>Approve</code> 里读输入：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, ctx.Err()</span><br><span class="line"><span class="keyword">case</span> decision := &lt;-response:</span><br><span class="line"><span class="keyword">return</span> decision, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>输入来自外部协议消息。<code>ChannelSession</code> 读到 <code>MessageApprovalResponse</code> 后再调用：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">s.approval.Respond(message.RequestID, decision)</span><br></pre></td></tr></table></figure><p>取消时只需要注销 pending、结束 <code>Approve</code>，没有不可取消的阻塞 IO 挂在 Handler 内部。</p><p>所以正确模型不是「让 <code>ReadString</code> 支持 cancel」，而是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Approve：只等待 Decision / Context</span><br><span class="line">Respond：由唯一输入所有者注入 Decision</span><br><span class="line">stdin / WebSocket：只允许一个读者</span><br></pre></td></tr></table></figure><p>Terminal 应该复用同一套语义，而不是另搞一套可读但会泄漏的实现。</p><h2 id="三-把-terminalapproval-改成-approve-respond"><a class="markdownIt-Anchor" href="#三-把-terminalapproval-改成-approve-respond"></a> 三、把 TerminalApproval 改成 Approve / Respond</h2><p>终端审批从 <code>internal/approval</code> 挪到 <code>internal/cli</code>。原因和 Channel 对称：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">internal/approval</span><br><span class="line">  └── Gate / Policy / Grant / Handler 接口</span><br><span class="line"></span><br><span class="line">internal/cli</span><br><span class="line">  └── Terminal 渠道适配：审批 UI + stdin 分流</span><br><span class="line"></span><br><span class="line">internal/channel</span><br><span class="line">  └── WebSocket / TCP 渠道适配</span><br></pre></td></tr></table></figure><p>新的 <code>TerminalApprovalHandler</code> 不再持有 <code>bufio.Reader</code>，也不再 <code>go ReadString</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> TerminalApprovalHandler <span class="keyword">struct</span> &#123;</span><br><span class="line">out io.Writer</span><br><span class="line"></span><br><span class="line">mu       sync.Mutex</span><br><span class="line">response <span class="keyword">chan</span> approval.Decision</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(h *TerminalApprovalHandler)</span></span> Approve(</span><br><span class="line">ctx context.Context,</span><br><span class="line">request approval.Request,</span><br><span class="line">) (approval.Decision, <span class="type">error</span>) &#123;</span><br><span class="line">response := <span class="built_in">make</span>(<span class="keyword">chan</span> approval.Decision, <span class="number">1</span>)</span><br><span class="line"></span><br><span class="line">h.mu.Lock()</span><br><span class="line"><span class="keyword">if</span> h.response != <span class="literal">nil</span> &#123;</span><br><span class="line">h.mu.Unlock()</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, ErrApprovalAlreadyPending</span><br><span class="line">&#125;</span><br><span class="line">h.response = response</span><br><span class="line">h.mu.Unlock()</span><br><span class="line"></span><br><span class="line"><span class="keyword">defer</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">h.mu.Lock()</span><br><span class="line">h.response = <span class="literal">nil</span></span><br><span class="line">h.mu.Unlock()</span><br><span class="line">&#125;()</span><br><span class="line"></span><br><span class="line">fmt.Fprintf(h.out, <span class="string">&quot;\n需要确认执行工具: %s\n&quot;</span>, request.ToolCall.Name)</span><br><span class="line">fmt.Fprintf(h.out, <span class="string">&quot;参数: %s\n&quot;</span>, request.ToolCall.Arguments)</span><br><span class="line">fmt.Fprint(h.out, <span class="string">&quot;[y]允许本次 [a]本会话允许 [n]拒绝: &quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, ctx.Err()</span><br><span class="line"><span class="keyword">case</span> decision := &lt;-response:</span><br><span class="line"><span class="keyword">return</span> decision, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>Respond</code> 只负责往唯一槽位投递决策：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(h *TerminalApprovalHandler)</span></span> Respond(decision approval.Decision) <span class="type">error</span> &#123;</span><br><span class="line"><span class="comment">// 校验 AllowOnce / AllowSession / Deny</span></span><br><span class="line">h.mu.Lock()</span><br><span class="line">response := h.response</span><br><span class="line">h.mu.Unlock()</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> response == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> ErrApprovalRequestNotFound</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> response &lt;- decision:</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line"><span class="keyword">default</span>:</span><br><span class="line"><span class="keyword">return</span> ErrApprovalAlreadyResolved</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这里用单个 <code>response</code> 而不是 <code>map[requestID]chan</code>，是因为终端交互本身是串行的：同一时刻用户只能回答一个问题，Gate 也会串行 Check。Channel 继续用 map，是因为协议消息自带 <code>RequestID</code>，按 ID 寻址更自然。</p><p>清理责任仍然在 <code>Approve</code> 的 <code>defer</code> 上：无论成功还是取消，离开 <code>Approve</code> 都会清空 pending。<code>Respond</code> 不负责 delete。</p><h2 id="四-repl-必须成为-stdin-的唯一读者"><a class="markdownIt-Anchor" href="#四-repl-必须成为-stdin-的唯一读者"></a> 四、REPL 必须成为 stdin 的唯一读者</h2><p>若只改 Handler，却仍然在审批时另起 <code>ReadString</code>，泄漏只是换了个地方。</p><p>因此 REPL 要同时承担两件事：</p><ol><li>空闲时读下一句 Prompt</li><li>审批等待时读 <code>y/a/n</code> 并调用 <code>Respond</code></li></ol><p>这要求主循环不能在 <code>task.Wait()</code> 上堵死。旧写法是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Start</span><br><span class="line">  ↓</span><br><span class="line">Wait          ← 主循环卡在这里</span><br><span class="line">  ↓</span><br><span class="line">回到 claw&gt;</span><br></pre></td></tr></table></figure><p>任务运行期间没人读 stdin，所以旧 Handler 才不得不自己读。新写法对齐 <code>ChannelSession</code>：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Start 后立刻返回</span><br><span class="line">  ↓</span><br><span class="line">go &#123; Wait; 清理 active; 打印终态 &#125;</span><br><span class="line">  ↓</span><br><span class="line">主循环继续 ReadString</span><br></pre></td></tr></table></figure><p>分流逻辑变成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">ReadString</span><br><span class="line">  ├── HasPending()      → Respond(y/a/n)</span><br><span class="line">  ├── 有 active Task    → 提示任务进行中</span><br><span class="line">  └── 空闲              → /help、/clear 或 Start 新任务</span><br></pre></td></tr></table></figure><p>对应代码骨架：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> r.approval != <span class="literal">nil</span> &amp;&amp; r.approval.HasPending() &#123;</span><br><span class="line">decision, ok := parseApprovalDecision(prompt)</span><br><span class="line"><span class="keyword">if</span> !ok &#123;</span><br><span class="line">fmt.Fprintln(r.out, <span class="string">&quot;请输入 y / a / n&quot;</span>)</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br><span class="line">_ = r.approval.Respond(decision)</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> !r.isIdle() &#123;</span><br><span class="line">fmt.Fprintln(r.out, <span class="string">&quot;当前任务正在执行，请等待任务完成。&quot;</span>)</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>并且只在空闲时打印提示符：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> r.isIdle() &#123;</span><br><span class="line">fmt.Fprint(r.out, <span class="string">&quot;\nclaw&gt;&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这样审批提示前不会再多打一个 <code>claw&gt;</code>。</p><p>Ctrl-C 之后的语义变为：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">ctx 取消</span><br><span class="line">  ↓</span><br><span class="line">Approve 从 select 返回</span><br><span class="line">  ↓</span><br><span class="line">defer 清空 pending</span><br><span class="line">  ↓</span><br><span class="line">没有残留 ReadString goroutine</span><br><span class="line">  ↓</span><br><span class="line">REPL 仍是唯一 stdin 读者</span><br></pre></td></tr></table></figure><h2 id="五-为什么-main-里曾经要传两次-handler"><a class="markdownIt-Anchor" href="#五-为什么-main-里曾经要传两次-handler"></a> 五、为什么 main 里曾经要传两次 Handler</h2><p>Gate 需要的是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Approve(ctx, request) (Decision, <span class="type">error</span>)</span><br></pre></td></tr></table></figure><p>REPL 需要的是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">HasPending() <span class="type">bool</span></span><br><span class="line">Respond(decision) <span class="type">error</span></span><br></pre></td></tr></table></figure><p>同一个对象，两个角色。Channel 看起来「只传一次」，是因为 <code>ChannelSession</code> 在构造函数内部完成了两次注入：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">channelApproval, _ := NewChannelApprovalHandler(eventSink)</span><br><span class="line"></span><br><span class="line">bundle, _ := manager.Create(id, runtimepkg.RuntimeOptions&#123;</span><br><span class="line">ApprovalHandler: channelApproval, <span class="comment">// 给 Gate</span></span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> &amp;ChannelSession&#123;</span><br><span class="line">approval: channelApproval, <span class="comment">// 留给 Session 自己 Respond</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Terminal 之前没有对等的 Session，装配落在 <code>main</code>，所以会出现：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">ApprovalHandler: approvalHandler,          <span class="comment">// Create</span></span><br><span class="line">NewREPL(..., approvalHandler)              <span class="comment">// REPL</span></span><br></pre></td></tr></table></figure><p>这不是创建了两个 Handler，而是装配归属放错了层。</p><h2 id="六-引入-terminalsession"><a class="markdownIt-Anchor" href="#六-引入-terminalsession"></a> 六、引入 TerminalSession</h2><p>为了和 Channel 对齐，新增 <code>internal/cli/terminal_session.go</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">NewTerminalSession</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">id <span class="type">string</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">manager *runtimepkg.Manager,</span></span></span><br><span class="line"><span class="params"><span class="function">reader *bufio.Reader,</span></span></span><br><span class="line"><span class="params"><span class="function">out io.Writer,</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span> (*TerminalSession, <span class="type">error</span>) &#123;</span><br><span class="line">approvalHandler, err := NewTerminalApprovalHandler(out)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">runtimeBundle, err := manager.Create(id, runtimepkg.RuntimeOptions&#123;</span><br><span class="line">ApprovalHandler: approvalHandler,</span><br><span class="line">Reporter:        reporter.NewTerminalReporter(out),</span><br><span class="line">&#125;)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">repl := NewREPL(</span><br><span class="line">reader,</span><br><span class="line">out,</span><br><span class="line">runtimeBundle.Runtime,</span><br><span class="line">runtimeBundle.Reporter,</span><br><span class="line">approvalHandler,</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> &amp;TerminalSession&#123;</span><br><span class="line">id:       id,</span><br><span class="line">manager:  manager,</span><br><span class="line">runtime:  runtimeBundle.Runtime,</span><br><span class="line">reporter: runtimeBundle.Reporter,</span><br><span class="line">approval: approvalHandler,</span><br><span class="line">repl:     repl,</span><br><span class="line">&#125;, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>cmd/claw/main.go</code> 收成：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">session, err := cli.NewTerminalSession(</span><br><span class="line"><span class="string">&quot;terminal_default&quot;</span>,</span><br><span class="line">manager,</span><br><span class="line">bufio.NewReader(os.Stdin),</span><br><span class="line">os.Stdout,</span><br><span class="line">)</span><br><span class="line"><span class="keyword">defer</span> session.Close()</span><br><span class="line"></span><br><span class="line"><span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line"><span class="keyword">for</span> <span class="keyword">range</span> signals &#123;</span><br><span class="line">session.Interrupt()</span><br><span class="line">&#125;</span><br><span class="line">&#125;()</span><br><span class="line"></span><br><span class="line">session.Run(context.Background())</span><br></pre></td></tr></table></figure><p>当前 <code>Run</code> / <code>Interrupt</code> 先委托给已有 REPL，目标不是立刻消掉 REPL，而是先把装配边界摆正：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">claw_server  → ChannelSession</span><br><span class="line">claw         → TerminalSession</span><br></pre></td></tr></table></figure><p><code>Close</code> 使用 <code>sync.Once</code>，避免重复 <code>Destroy</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s *TerminalSession)</span></span> Close() <span class="type">error</span> &#123;</span><br><span class="line">s.closeOnce.Do(<span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">err := s.manager.Destroy(s.id)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &amp;&amp; !errors.Is(err, runtimepkg.ErrRuntimeNotFound) &#123;</span><br><span class="line">s.closeErr = err</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line">s.closeErr = <span class="literal">nil</span></span><br><span class="line">&#125;)</span><br><span class="line"><span class="keyword">return</span> s.closeErr</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>若将来在 <code>Create</code> 成功之后还有可能失败的步骤，失败路径应立刻 <code>manager.Destroy(id)</code>，防止半成品 Runtime 留在 Manager 里。</p><h2 id="七-这一步完成后的分层"><a class="markdownIt-Anchor" href="#七-这一步完成后的分层"></a> 七、这一步完成后的分层</h2><p>Terminal 渠道现在可以画成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">stdin / stdout / Ctrl-C</span><br><span class="line">        ↓</span><br><span class="line">TerminalSession</span><br><span class="line">        ├── TerminalApprovalHandler   Approve / Respond</span><br><span class="line">        ├── TerminalReporter</span><br><span class="line">        └── REPL                      唯一 ReadString</span><br><span class="line">                ↓</span><br><span class="line">Runtime / Task</span><br><span class="line">                ↓</span><br><span class="line">AgentEngine / Approval Gate</span><br></pre></td></tr></table></figure><p>和 Channel 对比：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">WebSocket / TCP</span><br><span class="line">        ↓</span><br><span class="line">ChannelSession</span><br><span class="line">        ├── ChannelApprovalHandler    Approve / Respond</span><br><span class="line">        ├── JSONReporter</span><br><span class="line">        └── MessageReader             唯一读连接</span><br><span class="line">                ↓</span><br><span class="line">Runtime / Task</span><br><span class="line">                ↓</span><br><span class="line">AgentEngine / Approval Gate</span><br></pre></td></tr></table></figure><p>两边差异只在输入源和展示方式；审批等待模型已经统一。</p><h2 id="八-还没做完的部分"><a class="markdownIt-Anchor" href="#八-还没做完的部分"></a> 八、还没做完的部分</h2><p>这次修掉的是「取消后 stdin 读泄漏」和「Terminal 装配不对齐」。阶段七到生产级还有几件事：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">取消后半截结果是否误写 Session</span><br><span class="line">工具并发上限和事件 Channel 背压</span><br><span class="line">Provider / bash 子进程退出与泄漏测试</span><br><span class="line">Race Detector 覆盖审批取消路径</span><br><span class="line">任务结束后 claw&gt; 提示符可能晚一拍刷新</span><br><span class="line">/exit 时是否应先取消 active Task</span><br></pre></td></tr></table></figure><p>再往后才是路线图阶段八及以后：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Grant 持久化与参数级最小权限</span><br><span class="line">Provider 超时、重试和预算</span><br><span class="line">Session 持久化与任务恢复</span><br><span class="line">MCP / A2A</span><br><span class="line">评测、性能和部署治理</span><br></pre></td></tr></table></figure><h2 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h2><p>终端审批泄漏的本质，不是「少写了一个 cancel」，而是把不可取消的 stdin 读塞进了本应只等待 Decision 的 <code>Approve</code>。</p><p>修复路径也很明确：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">复用 Channel 的 Approve / Respond</span><br><span class="line">    ↓</span><br><span class="line">REPL 独占 stdin 并负责分流</span><br><span class="line">    ↓</span><br><span class="line">任务异步 Wait，主循环永不因审批停读</span><br><span class="line">    ↓</span><br><span class="line">TerminalSession 收拢装配，与 ChannelSession 对称</span><br></pre></td></tr></table></figure><p>到这里，Terminal 和 Channel 在审批与会话边界上已经站在同一套模型上。下一步可以继续补阶段七的其余可靠性项，再进入权限持久化和 Session 恢复。</p>]]></content>
    
    
    <summary type="html">修复 TerminalApproval 在 Ctrl-C 后残留 ReadString goroutine 的问题，将终端审批改为 Approve/Respond 模型，并引入 TerminalSession 与 ChannelSession 对齐装配边界。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（十六）Protocol、WebSocket 与多渠道连接层</title>
    <link href="https://sunra.top/posts/5a8c07/"/>
    <id>https://sunra.top/posts/5a8c07/</id>
    <published>2026-07-23T10:00:00.000Z</published>
    <updated>2026-08-27T01:58:06.617Z</updated>
    
    <content type="html"><![CDATA[<p>上一篇我们让一个 Go 进程可以同时承载多个 TCP 对话：每个连接拥有自己的 <code>ChannelSession</code>、<code>Runtime</code> 和 <code>Session</code>，不同 Session 可以并行运行，而同一个 Runtime 内部仍然只允许一个 Task。</p><p>但系列十五结尾还留下了几个关键问题：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">TCP 客户端和 Server 之间到底传什么格式？</span><br><span class="line">浏览器为什么不能直接连接 TCP Server？</span><br><span class="line">流式文本、工具调用和审批请求如何传到外部渠道？</span><br><span class="line">一个前端页面如何同时管理多个 Session？</span><br></pre></td></tr></table></figure><p>这一篇继续实现这些能力。本文对应当前 <code>go-tiny-claw</code> 中已经落地的连接层：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">JSON Line Protocol</span><br><span class="line">Structured Event</span><br><span class="line">JSON Reporter</span><br><span class="line">ChannelSession</span><br><span class="line">WebSocket Server</span><br><span class="line">React Multi-Session Console</span><br></pre></td></tr></table></figure><span id="more"></span><h2 id="一-从-tcp-多连接到浏览器控制台"><a class="markdownIt-Anchor" href="#一-从-tcp-多连接到浏览器控制台"></a> 一、从 TCP 多连接到浏览器控制台</h2><p>系列十五中的 TCP 连接可以这样使用：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">TCP Client</span><br><span class="line">    ↓</span><br><span class="line">TCPServer</span><br><span class="line">    ↓</span><br><span class="line">ChannelSession</span><br><span class="line">    ↓</span><br><span class="line">Runtime</span><br><span class="line">    ↓</span><br><span class="line">AgentEngine</span><br></pre></td></tr></table></figure><p>TCP 是一个字节流。Server 能够读取到字节，但它并不知道这些字节代表什么业务动作。</p><p>如果客户端直接发送：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">帮我读取 README</span><br></pre></td></tr></table></figure><p>Server 无法可靠判断这是一条 Prompt，还是一次中断、审批响应或者关闭请求。</p><p>因此需要在传输层之上定义应用协议：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">TCP / WebSocket</span><br><span class="line">    ↓</span><br><span class="line">Message Protocol</span><br><span class="line">    ↓</span><br><span class="line">ChannelSession</span><br></pre></td></tr></table></figure><p>同时，浏览器还有一个限制：浏览器 JavaScript 不能直接创建原始 TCP 连接。浏览器可以使用 HTTP、WebSocket 等标准 Web 协议，但不能像 Go 客户端一样连接 <code>net.Conn</code>。</p><p>所以最终的连接层变成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">TCP Client ─────── TCP :8080 ───────┐</span><br><span class="line">                                    ├── ChannelSession ─── Runtime</span><br><span class="line">React Browser ─ WebSocket :8081 ───┘</span><br></pre></td></tr></table></figure><p>TCP 和 WebSocket 使用不同的传输适配器，但进入后面的 <code>ChannelSession</code>、<code>Runtime</code> 和 <code>AgentEngine</code>。</p><h2 id="二-先定义连接协议"><a class="markdownIt-Anchor" href="#二-先定义连接协议"></a> 二、先定义连接协议</h2><p>协议实现位于：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">internal/channel/protocol.go</span><br></pre></td></tr></table></figure><p>当前协议采用 JSON Line 形式。每条消息是一个 JSON 对象，并以换行结束。</p><h3 id="1-消息类型"><a class="markdownIt-Anchor" href="#1-消息类型"></a> 1. 消息类型</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> MessageType <span class="type">string</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> (</span><br><span class="line">MessagePrompt           MessageType = <span class="string">&quot;prompt&quot;</span></span><br><span class="line">MessageInterrupt        MessageType = <span class="string">&quot;interrupt&quot;</span></span><br><span class="line">MessageClose            MessageType = <span class="string">&quot;close&quot;</span></span><br><span class="line">MessagePing             MessageType = <span class="string">&quot;ping&quot;</span></span><br><span class="line">MessagePong             MessageType = <span class="string">&quot;pong&quot;</span></span><br><span class="line">MessageApprovalResponse MessageType = <span class="string">&quot;approval_response&quot;</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>客户端发送 Prompt：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span><span class="attr">&quot;type&quot;</span><span class="punctuation">:</span><span class="string">&quot;prompt&quot;</span><span class="punctuation">,</span><span class="attr">&quot;content&quot;</span><span class="punctuation">:</span><span class="string">&quot;请读取 README&quot;</span><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>客户端请求中断：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span><span class="attr">&quot;type&quot;</span><span class="punctuation">:</span><span class="string">&quot;interrupt&quot;</span><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>客户端响应审批：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span><span class="attr">&quot;type&quot;</span><span class="punctuation">:</span><span class="string">&quot;approval_response&quot;</span><span class="punctuation">,</span><span class="attr">&quot;request_id&quot;</span><span class="punctuation">:</span><span class="string">&quot;abc123&quot;</span><span class="punctuation">,</span><span class="attr">&quot;decision&quot;</span><span class="punctuation">:</span><span class="string">&quot;allow_once&quot;</span><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>这里的 <code>type</code> 是连接层协议类型。它和 Reporter 发出的 Agent 事件类型不是同一个概念。</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">MessageType</span><br><span class="line">  表示客户端要求 Server 做什么</span><br><span class="line"></span><br><span class="line">EventType</span><br><span class="line">  表示 Agent 当前发生了什么</span><br></pre></td></tr></table></figure><h3 id="2-message-结构"><a class="markdownIt-Anchor" href="#2-message-结构"></a> 2. Message 结构</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Message <span class="keyword">struct</span> &#123;</span><br><span class="line">Type      MessageType <span class="string">`json:&quot;type&quot;`</span></span><br><span class="line">Content   <span class="type">string</span>      <span class="string">`json:&quot;content,omitempty&quot;`</span></span><br><span class="line">RequestID <span class="type">string</span>      <span class="string">`json:&quot;request_id,omitempty&quot;`</span></span><br><span class="line">Decision  <span class="type">string</span>      <span class="string">`json:&quot;decision,omitempty&quot;`</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>目前的消息结构仍然比较小，但已经覆盖了连续对话所需的控制面：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Content   Prompt 内容</span><br><span class="line">RequestID 审批请求 ID</span><br><span class="line">Decision  审批决策</span><br></pre></td></tr></table></figure><p>未来如果需要协议版本、客户端 ID、任务 ID 和请求追踪，可以继续扩展这个结构，或者引入统一的 Envelope。</p><h3 id="3-messagereader-为什么接收-ioreader"><a class="markdownIt-Anchor" href="#3-messagereader-为什么接收-ioreader"></a> 3. MessageReader 为什么接收 io.Reader</h3><p><code>MessageReader</code> 的构造函数不再要求调用方传入 <code>*bufio.Reader</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">NewMessageReader</span><span class="params">(input io.Reader)</span></span> (*MessageReader, <span class="type">error</span>) &#123;</span><br><span class="line"><span class="keyword">if</span> input == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, errors.New(<span class="string">&quot;消息读取器输入不能为空&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> &amp;MessageReader&#123;reader: bufio.NewReader(input)&#125;, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这个边界很重要：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">外部依赖：io.Reader</span><br><span class="line">内部实现：bufio.Reader</span><br></pre></td></tr></table></figure><p>因此以下输入都可以被协议层使用：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">net.Conn</span><br><span class="line">WebSocket Adapter</span><br><span class="line">bytes.Buffer</span><br><span class="line">测试输入</span><br></pre></td></tr></table></figure><p>调用方不需要知道协议层是否使用缓冲。</p><h3 id="4-reader-负责协议校验"><a class="markdownIt-Anchor" href="#4-reader-负责协议校验"></a> 4. Reader 负责协议校验</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *MessageReader)</span></span> Read() (Message, <span class="type">error</span>) &#123;</span><br><span class="line">line, err := r.reader.ReadBytes(<span class="string">&#x27;\n&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> <span class="built_in">len</span>(line) &gt; MaxMessageSize &#123;</span><br><span class="line"><span class="keyword">return</span> Message&#123;&#125;, errors.New(<span class="string">&quot;消息长度超过限制&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> Message&#123;&#125;, err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">line = bytes.TrimSpace(line)</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> message Message</span><br><span class="line"><span class="keyword">if</span> err := json.Unmarshal(line, &amp;message); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> Message&#123;&#125;, errors.New(<span class="string">&quot;消息格式错误&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">switch</span> message.Type &#123;</span><br><span class="line"><span class="keyword">case</span> MessagePrompt:</span><br><span class="line"><span class="keyword">if</span> message.Content == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> Message&#123;&#125;, errors.New(<span class="string">&quot;提示不能为空&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">case</span> MessageApprovalResponse:</span><br><span class="line"><span class="keyword">if</span> message.RequestID == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> Message&#123;&#125;, errors.New(<span class="string">&quot;审批响应缺少请求 ID&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> message.Decision == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> Message&#123;&#125;, errors.New(<span class="string">&quot;审批响应缺少决策&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">default</span>:</span><br><span class="line"><span class="keyword">return</span> Message&#123;&#125;, errors.New(<span class="string">&quot;不支持的消息类型&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> message, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>协议层至少应该负责：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">限制单条消息大小</span><br><span class="line">校验 JSON</span><br><span class="line">校验消息类型</span><br><span class="line">校验必需字段</span><br></pre></td></tr></table></figure><p>它不应该负责启动 Agent 或执行工具。协议层只回答“收到了一条什么消息”。</p><h2 id="三-messagewriter-解决并发输出"><a class="markdownIt-Anchor" href="#三-messagewriter-解决并发输出"></a> 三、MessageWriter 解决并发输出</h2><p>Server 中一个 Session 可能同时产生多种输出：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">模型文本 Delta</span><br><span class="line">工具调用事件</span><br><span class="line">工具结果事件</span><br><span class="line">审批请求</span><br><span class="line">任务完成事件</span><br><span class="line">Ping 响应</span><br></pre></td></tr></table></figure><p>这些事件可能来自不同 Goroutine。因此不能让多个 Goroutine 直接对同一个连接调用 <code>json.Encoder</code>。</p><p>当前 Writer 使用一个互斥锁：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> MessageWriter <span class="keyword">struct</span> &#123;</span><br><span class="line">encoder *json.Encoder</span><br><span class="line">mu      sync.Mutex</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">NewMessageWriter</span><span class="params">(output io.Writer)</span></span> (*MessageWriter, <span class="type">error</span>) &#123;</span><br><span class="line"><span class="keyword">if</span> output == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, errors.New(<span class="string">&quot;消息写入器输出不能为空&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> &amp;MessageWriter&#123;encoder: json.NewEncoder(output)&#125;, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(w *MessageWriter)</span></span> Write(value any) <span class="type">error</span> &#123;</span><br><span class="line">w.mu.Lock()</span><br><span class="line"><span class="keyword">defer</span> w.mu.Unlock()</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> w.encoder.Encode(value)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这里有两个设计点。</p><p>第一，Writer 接收 <code>io.Writer</code>，不和 TCP、WebSocket 绑定。</p><p>第二，<code>Write</code> 接收 <code>any</code>，因为它既可能写入连接控制消息：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Message&#123;Type: MessagePong&#125;</span><br></pre></td></tr></table></figure><p>也可能写入 Agent 事件：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">reporter.Event&#123;Type: reporter.EventToolCall&#125;</span><br></pre></td></tr></table></figure><p>它的真实职责不是“只写 Message”，而是“串行写入 JSON 对象”。</p><p>同一个连接内，Writer 保证 JSON 字节不会交错；不同连接之间则因为底层连接不同，天然隔离。<br />同一个连接内，Writer 保证 JSON 字节不会交错；不同连接之间则因为底层连接不同，天然隔离。</p><h2 id="四-从-reporter-回调到结构化事件"><a class="markdownIt-Anchor" href="#四-从-reporter-回调到结构化事件"></a> 四、从 Reporter 回调到结构化事件</h2><p>AgentEngine 只依赖 Reporter 接口：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">AgentEngine</span><br><span class="line">    └── reporter.Reporter</span><br></pre></td></tr></table></figure><p>它不会直接调用 <code>fmt.Printf</code>，也不会直接写 TCP。</p><h3 id="1-event-定义"><a class="markdownIt-Anchor" href="#1-event-定义"></a> 1. Event 定义</h3><p>文件：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">internal/reporter/event.go</span><br></pre></td></tr></table></figure><p>当前事件类型包括：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> (</span><br><span class="line">EventThinking         EventType = <span class="string">&quot;thinking&quot;</span></span><br><span class="line">EventTextDelta       EventType = <span class="string">&quot;text_delta&quot;</span></span><br><span class="line">EventTextCompleted   EventType = <span class="string">&quot;text_completed&quot;</span></span><br><span class="line">EventToolCall        EventType = <span class="string">&quot;tool_call&quot;</span></span><br><span class="line">EventToolResult      EventType = <span class="string">&quot;tool_result&quot;</span></span><br><span class="line">EventTaskCompleted   EventType = <span class="string">&quot;task_completed&quot;</span></span><br><span class="line">EventTaskCanceled    EventType = <span class="string">&quot;task_canceled&quot;</span></span><br><span class="line">EventTaskFailed      EventType = <span class="string">&quot;task_failed&quot;</span></span><br><span class="line">EventError           EventType = <span class="string">&quot;error&quot;</span></span><br><span class="line">EventPong            EventType = <span class="string">&quot;pong&quot;</span></span><br><span class="line">EventApprovalRequest EventType = <span class="string">&quot;approval_request&quot;</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>事件结构：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Event <span class="keyword">struct</span> &#123;</span><br><span class="line">Type      EventType <span class="string">`json:&quot;type&quot;`</span></span><br><span class="line">Content   <span class="type">string</span>    <span class="string">`json:&quot;content,omitempty&quot;`</span></span><br><span class="line">ToolName  <span class="type">string</span>    <span class="string">`json:&quot;tool_name,omitempty&quot;`</span></span><br><span class="line">Result    <span class="type">string</span>    <span class="string">`json:&quot;result,omitempty&quot;`</span></span><br><span class="line">RequestID <span class="type">string</span>    <span class="string">`json:&quot;request_id,omitempty&quot;`</span></span><br><span class="line">Decision  <span class="type">string</span>    <span class="string">`json:&quot;decision,omitempty&quot;`</span></span><br><span class="line">Risk      <span class="type">string</span>    <span class="string">`json:&quot;risk,omitempty&quot;`</span></span><br><span class="line">Reason    <span class="type">string</span>    <span class="string">`json:&quot;reason,omitempty&quot;`</span></span><br><span class="line">IsError   <span class="type">bool</span>      <span class="string">`json:&quot;is_error,omitempty&quot;`</span></span><br><span class="line">Error     <span class="type">string</span>    <span class="string">`json:&quot;error,omitempty&quot;`</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="2-jsonreporter"><a class="markdownIt-Anchor" href="#2-jsonreporter"></a> 2. JSONReporter</h3><p>文件：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">internal/reporter/json_reporter.go</span><br></pre></td></tr></table></figure><p>JSONReporter 把 Engine 的回调转换成 Event：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *JSONReporter)</span></span> OnToolCall(</span><br><span class="line">ctx context.Context,</span><br><span class="line">toolName <span class="type">string</span>,</span><br><span class="line">args <span class="type">string</span>,</span><br><span class="line">) &#123;</span><br><span class="line">r.publish(ctx, Event&#123;</span><br><span class="line">Type:     EventToolCall,</span><br><span class="line">ToolName: toolName,</span><br><span class="line">Content:  args,</span><br><span class="line">&#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>最终发送给浏览器的是：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span><span class="string">&quot;tool_call&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;tool_name&quot;</span><span class="punctuation">:</span><span class="string">&quot;write_file&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;content&quot;</span><span class="punctuation">:</span><span class="string">&quot;&#123;...&#125;&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>同一个 Engine 可以继续使用 TerminalReporter：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">CLI → TerminalReporter → stdout</span><br><span class="line">WebSocket → JSONReporter → EventSink → MessageWriter</span><br></pre></td></tr></table></figure><p>Reporter 的抽象让输出渠道可以替换，而不需要修改 Agent Loop。</p><h3 id="3-eventsink"><a class="markdownIt-Anchor" href="#3-eventsink"></a> 3. EventSink</h3><p>文件：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">internal/channel/event_sink.go</span><br></pre></td></tr></table></figure><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> EventSink <span class="keyword">interface</span> &#123;</span><br><span class="line">Publish(ctx context.Context, event Event) <span class="type">error</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>JSONReporter 只依赖 EventSink：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">JSONReporter</span><br><span class="line">    ↓</span><br><span class="line">EventSink</span><br><span class="line">    ↓</span><br><span class="line">MessageWriter</span><br><span class="line">    ↓</span><br><span class="line">TCP / WebSocket</span><br></pre></td></tr></table></figure><p>这样 Reporter 不需要知道底层是字节流还是 WebSocket Frame。</p><h2 id="五-channelsession连接和-runtime-的绑定层"><a class="markdownIt-Anchor" href="#五-channelsession连接和-runtime-的绑定层"></a> 五、ChannelSession：连接和 Runtime 的绑定层</h2><p>文件：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">internal/channel/channel_session.go</span><br></pre></td></tr></table></figure><p>一个 ChannelSession 的结构是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">外部连接</span><br><span class="line">  ├── MessageReader</span><br><span class="line">  ├── MessageWriter</span><br><span class="line">  ├── JSONReporter</span><br><span class="line">  ├── ChannelApprovalHandler</span><br><span class="line">  └── Runtime</span><br></pre></td></tr></table></figure><p>构造时，所有输出共享同一个 MessageWriter：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">writer, err := NewMessageWriter(conn)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">eventSink, err := NewJSONEventSinkWithWriter(writer)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">bundle, err := manager.Create(id, runtimepkg.RuntimeOptions&#123;</span><br><span class="line">ApprovalHandler: channelApproval,</span><br><span class="line">Reporter:        reporter.NewJSONReporter(eventSink),</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>这里不能分别创建两个 Writer：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">错误做法：</span><br><span class="line">Reporter → Writer A</span><br><span class="line">Ping     → Writer B</span><br><span class="line">             ↓</span><br><span class="line">        同一个连接</span><br></pre></td></tr></table></figure><p>两个 Writer 各自持有锁，无法保护彼此，可能导致输出交错。</p><p>正确做法是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Reporter ─┐</span><br><span class="line">          ├── 同一个 MessageWriter</span><br><span class="line">Ping ─────┘</span><br></pre></td></tr></table></figure><h3 id="1-消息循环"><a class="markdownIt-Anchor" href="#1-消息循环"></a> 1. 消息循环</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s *ChannelSession)</span></span> Run(ctx context.Context) <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">for</span> &#123;</span><br><span class="line">message, err := s.reader.Read()</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">switch</span> message.Type &#123;</span><br><span class="line"><span class="keyword">case</span> MessagePrompt:</span><br><span class="line"><span class="keyword">if</span> err := s.startTask(ctx, message.Content); err != <span class="literal">nil</span> &#123;</span><br><span class="line">s.publishError(ctx, err)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">case</span> MessageInterrupt:</span><br><span class="line">s.runtime.Cancel()</span><br><span class="line"><span class="keyword">case</span> MessageApprovalResponse:</span><br><span class="line">decision := approval.Decision(message.Decision)</span><br><span class="line"><span class="keyword">if</span> err := s.approval.Respond(message.RequestID, decision); err != <span class="literal">nil</span> &#123;</span><br><span class="line">s.publishError(ctx, err)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">case</span> MessagePing:</span><br><span class="line"><span class="keyword">if</span> err := s.writer.Write(Message&#123;Type: MessagePong&#125;); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">case</span> MessageClose:</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="2-prompt-必须异步启动"><a class="markdownIt-Anchor" href="#2-prompt-必须异步启动"></a> 2. Prompt 必须异步启动</h3><p>如果 <code>MessagePrompt</code> 直接同步等待：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">task, _ := s.runtime.Start(ctx, prompt, s.reporter)</span><br><span class="line">task.Wait()</span><br></pre></td></tr></table></figure><p>那么消息循环会被阻塞。在 Agent 运行期间，下面这些消息都无法处理：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">interrupt</span><br><span class="line">approval_response</span><br><span class="line">ping</span><br><span class="line">close</span><br></pre></td></tr></table></figure><p>当前实现启动 Task 后立即返回消息循环：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">task, err := s.runtime.Start(ctx, prompt, s.reporter)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">err := task.Wait()</span><br><span class="line"><span class="comment">// 发布 task_completed、task_canceled 或 task_failed</span></span><br><span class="line">&#125;()</span><br></pre></td></tr></table></figure><p>这就是 Server 能够在 Agent 执行期间响应中断和审批的原因。<br />这就是 Server 能够在 Agent 执行期间响应中断和审批的原因。</p><h2 id="六-通道审批如何工作"><a class="markdownIt-Anchor" href="#六-通道审批如何工作"></a> 六、通道审批如何工作</h2><p>终端审批可以直接读取 stdin，但 TCP 或 WebSocket 审批不能再启动一个 Reader 去读取同一连接。</p><p>否则会变成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">ChannelSession.Reader ──┐</span><br><span class="line">                        ├── 同一个连接</span><br><span class="line">ApprovalHandler.Reader ─┘</span><br></pre></td></tr></table></figure><p>两个 Reader 会竞争输入，导致 Prompt 或审批响应被错误消费。</p><p>当前 <code>ChannelApprovalHandler</code> 使用一个 pending Map：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> ChannelApprovalHandler <span class="keyword">struct</span> &#123;</span><br><span class="line">sink reporter.EventSink</span><br><span class="line"></span><br><span class="line">mu      sync.Mutex</span><br><span class="line">pending <span class="keyword">map</span>[<span class="type">string</span>]<span class="keyword">chan</span> approval.Decision</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>审批开始时：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">Engine</span><br><span class="line">  ↓</span><br><span class="line">ApprovalGate.Check</span><br><span class="line">  ↓</span><br><span class="line">ChannelApprovalHandler.Approve</span><br><span class="line">  ↓</span><br><span class="line">发送 approval_request 事件</span><br><span class="line">  ↓</span><br><span class="line">等待 pending[requestID]</span><br></pre></td></tr></table></figure><p>前端收到：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span><span class="string">&quot;approval_request&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;request_id&quot;</span><span class="punctuation">:</span><span class="string">&quot;request-1&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;tool_name&quot;</span><span class="punctuation">:</span><span class="string">&quot;write_file&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;risk&quot;</span><span class="punctuation">:</span><span class="string">&quot;mutating&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>点击允许一次后发送：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span><span class="string">&quot;approval_response&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;request_id&quot;</span><span class="punctuation">:</span><span class="string">&quot;request-1&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;decision&quot;</span><span class="punctuation">:</span><span class="string">&quot;allow_once&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>ChannelSession 的唯一输入循环收到响应后，调用：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">s.approval.Respond(message.RequestID, decision)</span><br></pre></td></tr></table></figure><p>pending Channel 被唤醒，ApprovalGate 才会把工具调用交给 Engine 后续执行。</p><p>审批响应和 Prompt 使用同一个输入循环，这是多渠道审批能够正确工作的关键。</p><h2 id="七-为什么需要-websocket-adapter"><a class="markdownIt-Anchor" href="#七-为什么需要-websocket-adapter"></a> 七、为什么需要 WebSocket Adapter</h2><p>浏览器使用 WebSocket Frame，而当前 <code>MessageReader</code> 使用 JSON Line：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">MessageReader 期待：&#123;&quot;type&quot;:&quot;ping&quot;&#125;\n</span><br><span class="line">WebSocket 实际：一个 Text Frame</span><br></pre></td></tr></table></figure><p>因此不能直接把 <code>*websocket.Conn</code> 传给 <code>MessageReader</code>。需要一个适配器，把 Frame 转换成 Reader/Writer 认识的字节流。</p><p>文件：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">internal/server/websocket_conn.go</span><br></pre></td></tr></table></figure><p>读取时，适配器从下一条 WebSocket 消息读取内容，并补充换行：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(c *websocketConn)</span></span> Read(p []<span class="type">byte</span>) (<span class="type">int</span>, <span class="type">error</span>) &#123;</span><br><span class="line"><span class="keyword">for</span> c.readOffset &gt;= <span class="built_in">len</span>(c.readBuffer) &#123;</span><br><span class="line">messageType, reader, err := c.conn.NextReader()</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="number">0</span>, err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> messageType != websocket.TextMessage &amp;&amp; messageType != websocket.BinaryMessage &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="number">0</span>, errors.New(<span class="string">&quot;只支持文本或二进制消息&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">message, err := io.ReadAll(reader)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="number">0</span>, err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">c.readBuffer = <span class="built_in">append</span>(message[:<span class="number">0</span>:<span class="number">0</span>], message...)</span><br><span class="line">c.readBuffer = <span class="built_in">append</span>(c.readBuffer, <span class="string">&#x27;\n&#x27;</span>)</span><br><span class="line">c.readOffset = <span class="number">0</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">count := <span class="built_in">copy</span>(p, c.readBuffer[c.readOffset:])</span><br><span class="line">c.readOffset += count</span><br><span class="line"><span class="keyword">return</span> count, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>写入时，适配器把一条 JSON Line 转换成一个 Text Frame：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(c *websocketConn)</span></span> Write(p []<span class="type">byte</span>) (<span class="type">int</span>, <span class="type">error</span>) &#123;</span><br><span class="line">c.writeMu.Lock()</span><br><span class="line"><span class="keyword">defer</span> c.writeMu.Unlock()</span><br><span class="line"></span><br><span class="line">payload := bytes.TrimSpace(p)</span><br><span class="line"><span class="keyword">if</span> <span class="built_in">len</span>(payload) == <span class="number">0</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="built_in">len</span>(p), <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> err := c.conn.WriteMessage(websocket.TextMessage, payload); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="number">0</span>, err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> <span class="built_in">len</span>(p), <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>适配完成后，<code>ChannelSession</code> 不需要知道自己面对的是 TCP 还是 WebSocket：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">TCP Conn ────────────────┐</span><br><span class="line">                         ├── io.ReadWriteCloser</span><br><span class="line">WebSocket Stream Adapter ┘</span><br><span class="line">                         ↓</span><br><span class="line">                   ChannelSession</span><br></pre></td></tr></table></figure><h2 id="八-websocket-server-如何承载多个-session"><a class="markdownIt-Anchor" href="#八-websocket-server-如何承载多个-session"></a> 八、WebSocket Server 如何承载多个 Session</h2><p>文件：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">internal/server/websocket_server.go</span><br></pre></td></tr></table></figure><p>TCP Server 和 WebSocket Server 的职责类似：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">监听连接</span><br><span class="line">生成 Session ID</span><br><span class="line">创建 ChannelSession</span><br><span class="line">注册连接</span><br><span class="line">运行消息循环</span><br><span class="line">连接结束后销毁 Runtime</span><br><span class="line">服务关闭时清理所有连接</span><br></pre></td></tr></table></figure><p>WebSocket Session ID 使用独立前缀：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">ws-channel-session-1</span><br><span class="line">ws-channel-session-2</span><br></pre></td></tr></table></figure><p>TCP 使用：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">tcp-channel-session-1</span><br><span class="line">tcp-channel-session-2</span><br></pre></td></tr></table></figure><p>两个 Server 共享同一个 RuntimeManager，但每个连接仍然拥有独立 Runtime：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">RuntimeManager</span><br><span class="line">  ├── tcp-channel-session-1 → Runtime A</span><br><span class="line">  ├── tcp-channel-session-2 → Runtime B</span><br><span class="line">  ├── ws-channel-session-1  → Runtime C</span><br><span class="line">  └── ws-channel-session-2  → Runtime D</span><br></pre></td></tr></table></figure><p>启动时，当前 Server 使用两个端口：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">TCP       :8080</span><br><span class="line">WebSocket :8081/ws</span><br></pre></td></tr></table></figure><p>这里没有强行把 TCP 和 WebSocket 复用到同一个端口，因为二者的连接握手不同。生产环境可以使用反向代理统一域名，也可以使用连接复用器，但学习阶段使用两个端口更清晰。</p><h2 id="九-前端多-session-控制台"><a class="markdownIt-Anchor" href="#九-前端多-session-控制台"></a> 九、前端多 Session 控制台</h2><p>前端目录：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">web-console</span><br></pre></td></tr></table></figure><p>技术栈：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">Vite</span><br><span class="line">React</span><br><span class="line">TypeScript</span><br><span class="line">WebSocket</span><br></pre></td></tr></table></figure><h3 id="1-一个-session-一条-websocket"><a class="markdownIt-Anchor" href="#1-一个-session-一条-websocket"></a> 1. 一个 Session 一条 WebSocket</h3><p>前端维护一个 Socket Map：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> sockets = useRef&lt;<span class="title class_">Record</span>&lt;<span class="built_in">string</span>, <span class="title class_">WebSocket</span>&gt;&gt;(&#123;&#125;)</span><br></pre></td></tr></table></figure><p>创建 Session 时：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> id = <span class="string">`session-<span class="subst">$&#123;sequence.current++&#125;</span>`</span></span><br><span class="line"></span><br><span class="line"><span class="title function_">setSessions</span>(<span class="function">(<span class="params">current</span>) =&gt;</span> [...current, &#123;</span><br><span class="line">  id,</span><br><span class="line">  <span class="attr">status</span>: <span class="string">&#x27;connecting&#x27;</span>,</span><br><span class="line">  <span class="attr">items</span>: [],</span><br><span class="line">  <span class="attr">draft</span>: <span class="string">&#x27;&#x27;</span>,</span><br><span class="line">  <span class="attr">createdAt</span>: <span class="title class_">Date</span>.<span class="title function_">now</span>(),</span><br><span class="line">&#125;])</span><br><span class="line"></span><br><span class="line"><span class="title function_">setActiveID</span>(id)</span><br><span class="line"><span class="variable language_">window</span>.<span class="built_in">setTimeout</span>(<span class="function">() =&gt;</span> <span class="title function_">connectSession</span>(id), <span class="number">0</span>)</span><br></pre></td></tr></table></figure><p>每个连接的消息事件都带着自己的 Session ID 回到状态更新函数：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">socket.<span class="property">onmessage</span> = <span class="function">(<span class="params">message</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">handleEvent</span>(id, <span class="title class_">JSON</span>.<span class="title function_">parse</span>(message.<span class="property">data</span>))</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>因此连接 A 的事件只会更新 Session A 的消息列表。</p><h3 id="2-流式文本聚合"><a class="markdownIt-Anchor" href="#2-流式文本聚合"></a> 2. 流式文本聚合</h3><p>Server 会连续发送：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span><span class="attr">&quot;type&quot;</span><span class="punctuation">:</span><span class="string">&quot;text_delta&quot;</span><span class="punctuation">,</span><span class="attr">&quot;content&quot;</span><span class="punctuation">:</span><span class="string">&quot;第一段&quot;</span><span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#123;</span><span class="attr">&quot;type&quot;</span><span class="punctuation">:</span><span class="string">&quot;text_delta&quot;</span><span class="punctuation">,</span><span class="attr">&quot;content&quot;</span><span class="punctuation">:</span><span class="string">&quot;第二段&quot;</span><span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#123;</span><span class="attr">&quot;type&quot;</span><span class="punctuation">:</span><span class="string">&quot;text_completed&quot;</span><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>前端在 Session 内记录当前的 <code>streamItemId</code>：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (session.<span class="property">streamItemId</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> &#123;</span><br><span class="line">    ...session,</span><br><span class="line">    <span class="attr">items</span>: session.<span class="property">items</span>.<span class="title function_">map</span>(<span class="function">(<span class="params">item</span>) =&gt;</span> item.<span class="property">id</span> === session.<span class="property">streamItemId</span></span><br><span class="line">      ? &#123; ...item, <span class="attr">content</span>: item.<span class="property">content</span> + (event.<span class="property">content</span> ?? <span class="string">&#x27;&#x27;</span>) &#125;</span><br><span class="line">      : item),</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这样每个 Delta 都会追加到当前 Agent 消息，而不是生成很多气泡。</p><h3 id="3-审批按钮"><a class="markdownIt-Anchor" href="#3-审批按钮"></a> 3. 审批按钮</h3><p>收到 <code>approval_request</code> 后，前端生成审批卡片：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">需要审批 · write_file</span><br><span class="line">risk: mutating</span><br><span class="line"></span><br><span class="line">[允许一次] [会话允许] [拒绝]</span><br></pre></td></tr></table></figure><p>按钮最终发送：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">socket.<span class="title function_">send</span>(<span class="title class_">JSON</span>.<span class="title function_">stringify</span>(&#123;</span><br><span class="line">  <span class="attr">type</span>: <span class="string">&#x27;approval_response&#x27;</span>,</span><br><span class="line">  <span class="attr">request_id</span>: requestID,</span><br><span class="line">  decision,</span><br><span class="line">&#125;))</span><br></pre></td></tr></table></figure><p>前端不直接执行工具，也不决定工具权限。它只是把用户选择传回 Server，真正的 Policy、GrantStore 和 Gate 仍然在 Runtime 内部。</p><h3 id="4-固定底部输入框"><a class="markdownIt-Anchor" href="#4-固定底部输入框"></a> 4. 固定底部输入框</h3><p>连续对话的输入框不能随着消息增长被推到页面之外。这里不是简单地给输入框设置 <code>position: fixed</code>，而是让工作区成为一个高度受控的 Flex 容器：消息区滚动，输入区不参与滚动。</p><figure class="highlight css"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="selector-tag">html</span>,</span><br><span class="line"><span class="selector-id">#root</span> &#123;</span><br><span class="line">  <span class="attribute">height</span>: <span class="number">100%</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="selector-class">.workspace</span> &#123;</span><br><span class="line">  <span class="attribute">display</span>: flex;</span><br><span class="line">  <span class="attribute">flex</span>: <span class="number">1</span>;</span><br><span class="line">  <span class="attribute">flex-direction</span>: column;</span><br><span class="line">  <span class="attribute">min-height</span>: <span class="number">0</span>;</span><br><span class="line">  <span class="attribute">height</span>: <span class="number">100vh</span>;</span><br><span class="line">  <span class="attribute">overflow</span>: hidden;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="selector-class">.conversation</span> &#123;</span><br><span class="line">  <span class="attribute">flex</span>: <span class="number">1</span> <span class="number">1</span> auto;</span><br><span class="line">  <span class="attribute">min-height</span>: <span class="number">0</span>;</span><br><span class="line">  <span class="attribute">overflow-x</span>: hidden;</span><br><span class="line">  <span class="attribute">overflow-y</span>: auto;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="selector-class">.composer-wrap</span> &#123;</span><br><span class="line">  <span class="attribute">flex</span>: <span class="number">0</span> <span class="number">0</span> auto;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>min-height: 0</code> 很重要。Flex 子元素默认可能按照内容的最小高度撑开父容器，如果省略它，消息列表会把整个页面撑长，输入框仍然会被推走。</p><h2 id="十-一次请求的完整时序"><a class="markdownIt-Anchor" href="#十-一次请求的完整时序"></a> 十、一次请求的完整时序</h2><p>把各层连起来后，一条用户消息的生命周期如下：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">React Session A</span><br><span class="line">  -&gt; WebSocket Text Frame</span><br><span class="line">  -&gt; WebSocketServer</span><br><span class="line">  -&gt; websocketConn.Read</span><br><span class="line">  -&gt; MessageReader</span><br><span class="line">  -&gt; ChannelSession.handleMessage</span><br><span class="line">  -&gt; Runtime.Start</span><br><span class="line">  -&gt; Task goroutine</span><br><span class="line">  -&gt; AgentEngine.Run</span><br><span class="line">  -&gt; JSONReporter.OnTextDelta</span><br><span class="line">  -&gt; EventSink</span><br><span class="line">  -&gt; MessageWriter</span><br><span class="line">  -&gt; WebSocket Text Frame</span><br><span class="line">  -&gt; task_completed</span><br></pre></td></tr></table></figure><p>这里有两个容易混淆的异步边界。</p><p>第一，<code>ChannelSession</code> 的读循环不能直接同步执行 Agent。否则 Agent 调用模型、执行工具时，连接就无法继续读取 <code>interrupt</code> 或 <code>approval_response</code>。因此收到 <code>prompt</code> 后要启动任务，读循环继续工作。</p><p>第二，Agent 的输出不能直接写 WebSocket。Agent 只依赖 <code>reporter.Reporter</code>，由 <code>JSONReporter</code> 把领域事件转换成协议消息，再由 <code>MessageWriter</code> 串行写入连接。这样 Runtime 不知道当前连接是 TCP、WebSocket 还是未来的飞书。</p><p>审批的时序则是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Agent -&gt; Policy/Gate -&gt; approval_request -&gt; Client</span><br><span class="line">Client -&gt; approval_response -&gt; ChannelApprovalHandler</span><br><span class="line">ChannelApprovalHandler -&gt; pending[requestID]</span><br><span class="line">Gate &lt;- decision</span><br><span class="line">Gate -&gt; Tool.Execute</span><br><span class="line">Agent -&gt; tool_result -&gt; Client</span><br></pre></td></tr></table></figure><p><code>request_id</code> 是审批闭环的关联键。不能只依赖工具名，因为同一个 Session 中可能同时存在多个工具调用，多个连接也可能并行审批。</p><h2 id="十一-如何验证这一阶段"><a class="markdownIt-Anchor" href="#十一-如何验证这一阶段"></a> 十一、如何验证这一阶段</h2><p>先启动 Server：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">cd</span> /Users/smsun/Documents/github/go-tiny-claw</span><br><span class="line"><span class="built_in">export</span> ZHIPU_API_KEY=<span class="string">&quot;你的 API Key&quot;</span></span><br><span class="line">go run ./cmd/claw_server</span><br></pre></td></tr></table></figure><p>启动 Web 控制台：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">cd</span> /Users/smsun/Documents/github/go-tiny-claw/web-console</span><br><span class="line">npm install</span><br><span class="line">npm run dev</span><br></pre></td></tr></table></figure><p>浏览器打开 <code>http://localhost:5173</code>。开发服务器会把 WebSocket 请求转发到 <code>ws://127.0.0.1:8081/ws</code>。打开两个会话后分别发送消息，可以验证：</p><ol><li>两个会话能够同时运行任务。</li><li>会话 A 的流式事件不会显示到会话 B。</li><li>会话 A 按下中断时，只取消会话 A 的 Task。</li><li>会话 A 等待审批时，会话 B 仍然可以继续对话。</li><li>审批响应只唤醒对应的 <code>request_id</code>。</li><li>Task 结束后会收到且只收到一个终态事件。</li></ol><p>已有的 Go 测试和前端构建可以这样运行：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">cd</span> /Users/smsun/Documents/github/go-tiny-claw</span><br><span class="line">GOCACHE=/tmp/go-tiny-claw-cache go <span class="built_in">test</span> ./...</span><br><span class="line">GOCACHE=/tmp/go-tiny-claw-cache go <span class="built_in">test</span> -race ./test/channel ./test/runtime</span><br><span class="line"></span><br><span class="line"><span class="built_in">cd</span> web-console</span><br><span class="line">npm run build</span><br></pre></td></tr></table></figure><p>测试重点不是只验证“能不能返回文本”，而是验证连接边界和并发边界：协议读写、审批等待、Session 隔离、Task 终止、事件顺序和竞态安全。</p><h2 id="十二-距离生产级还缺什么"><a class="markdownIt-Anchor" href="#十二-距离生产级还缺什么"></a> 十二、距离生产级还缺什么</h2><p>这一阶段解决的是“多连接可用”和“浏览器可接入”，还不能直接当作公网生产服务。下一步至少要补齐以下能力。</p><h3 id="1-身份认证与授权"><a class="markdownIt-Anchor" href="#1-身份认证与授权"></a> 1. 身份认证与授权</h3><p>当前连接建立后还没有可靠的用户身份。生产环境需要在握手或首条消息中完成认证，并把 <code>UserID</code>、<code>TenantID</code>、<code>ProjectID</code> 绑定到 Session。Policy 和 GrantStore 也要按用户、项目或租户隔离，不能让一个连接查询到另一个用户的授权。</p><h3 id="2-tls-origin-和网络保护"><a class="markdownIt-Anchor" href="#2-tls-origin-和网络保护"></a> 2. TLS、Origin 和网络保护</h3><p>WebSocket 生产部署应使用 TLS。服务端不能永久允许任意 <code>Origin</code>，要配置允许的前端域名。同时还需要限制连接数、单条消息大小、并发 Task 数和请求频率，避免一个客户端耗尽进程资源。</p><h3 id="3-超时-心跳和连接清理"><a class="markdownIt-Anchor" href="#3-超时-心跳和连接清理"></a> 3. 超时、心跳和连接清理</h3><p>需要分别设置握手超时、读超时、写超时、模型调用超时和工具执行超时。<code>ping/pong</code> 不能只作为协议示例，还要用来发现断开的连接。连接断开后必须取消当前 Task，并从 Manager 中清理 Session，避免 goroutine 和状态泄漏。</p><h3 id="4-协议版本和错误模型"><a class="markdownIt-Anchor" href="#4-协议版本和错误模型"></a> 4. 协议版本和错误模型</h3><p>协议消息应增加版本字段，并定义稳定的错误结构，例如错误码、可重试标记、关联的 <code>request_id</code> 和 <code>task_id</code>。不能只返回一段无法被前端稳定解析的中文错误字符串。</p><h3 id="5-事件关联与恢复"><a class="markdownIt-Anchor" href="#5-事件关联与恢复"></a> 5. 事件关联与恢复</h3><p>生产事件至少需要 <code>session_id</code>、<code>task_id</code>、<code>event_id</code>、时间戳和序号。客户端重连后才能根据序号补事件，而不是只能重新发起任务。长任务还需要持久化 Task 状态、审批状态和必要的事件日志。</p><h3 id="6-持久化和水平扩展"><a class="markdownIt-Anchor" href="#6-持久化和水平扩展"></a> 6. 持久化和水平扩展</h3><p>现在 Manager、Session 和 GrantStore 主要是进程内内存实现。单进程可以支持多个 TCP/WebSocket 连接，但服务重启后状态会丢失，也无法让多个实例共同管理同一个 Session。生产化需要把 Session、Task、授权和事件分别抽象出持久化接口，必要时使用 Redis、数据库和消息总线。</p><h3 id="7-可观测性和可靠性"><a class="markdownIt-Anchor" href="#7-可观测性和可靠性"></a> 7. 可观测性和可靠性</h3><p>日志要携带结构化的 <code>session_id</code>、<code>task_id</code> 和 <code>request_id</code>，指标要覆盖连接数、活跃 Task、模型延迟、工具延迟、审批等待时间、错误率和取消率。还需要补充 TCP/WebSocket 集成测试、断线测试、重复审批测试、并发压力测试和故障恢复测试。</p><h3 id="8-前端生产能力"><a class="markdownIt-Anchor" href="#8-前端生产能力"></a> 8. 前端生产能力</h3><p>控制台还需要登录、自动重连、历史消息加载、断线提示、重复发送保护、审批超时提示和权限展示。开发环境的 Vite 代理只解决本地调试问题，生产环境应由反向代理统一提供 HTTPS、WebSocket 转发和静态资源服务。</p><h2 id="十三-这一阶段的架构总结"><a class="markdownIt-Anchor" href="#十三-这一阶段的架构总结"></a> 十三、这一阶段的架构总结</h2><p>到这里，连接层可以整理成下面的依赖关系：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">TCP / WebSocket</span><br><span class="line">      |</span><br><span class="line">      v</span><br><span class="line">MessageReader / MessageWriter</span><br><span class="line">      |</span><br><span class="line">      v</span><br><span class="line">ChannelSession / ChannelApprovalHandler</span><br><span class="line">      |</span><br><span class="line">      +--&gt; Reporter: TerminalReporter / JSONReporter</span><br><span class="line">      |</span><br><span class="line">      v</span><br><span class="line">Runtime / Session / Task / AgentEngine</span><br><span class="line">      |</span><br><span class="line">      v</span><br><span class="line">Tools / Policy / GrantStore</span><br></pre></td></tr></table></figure><p>这套分层的核心价值是：</p><ul><li>Transport 只负责连接，不负责 Agent 业务。</li><li>Protocol 只负责消息格式，不负责工具权限。</li><li>Channel 只负责把外部消息翻译成 Runtime 调用，把内部事件翻译成外部消息。</li><li>Runtime 负责 Session、Task、审批和 Agent 生命周期。</li><li>Reporter 负责不同渠道的输出表达。</li><li>Web Console 只是一个客户端，不会反向侵入核心引擎。</li></ul><p>因此，增加 WebSocket 并不是复制一套 Agent 逻辑，而是增加一个新的 Transport 和一个新的入口适配器。未来接入飞书、HTTP API、MCP 或 A2A 时，也应沿着同样的方向扩展，而不是把渠道判断散落到 Engine、Tool 和 Task 中。</p><h2 id="结语"><a class="markdownIt-Anchor" href="#结语"></a> 结语</h2><p>从最初的单次命令行调用，到现在的 Task、Runtime、Manager、TCP 多连接、JSON Protocol、结构化事件、审批闭环和 WebSocket 控制台，<code>go-tiny-claw</code> 已经从一个 Agent Demo 变成了一个具备连接层和运行时边界的 Harness 原型。</p><p>但“能连接”不等于“能生产”。后续工作应优先补齐身份、协议安全、超时和恢复，再做 MCP 工具生态与 A2A 多 Agent 协作。只有先把连接、状态、事件和授权的边界稳定下来，外部协议越多，系统才不会越混乱。</p>]]></content>
    
    
    <summary type="html">在 Task、Runtime 和 TCP 多 Session 之后，为 go-tiny-claw 增加 JSON Protocol、结构化事件、WebSocket 接入和可视化多 Session 控制台。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（十五）Task、Runtime 与多 Session 并行</title>
    <link href="https://sunra.top/posts/5a8c06/"/>
    <id>https://sunra.top/posts/5a8c06/</id>
    <published>2026-07-20T14:00:00.000Z</published>
    <updated>2026-08-27T01:58:06.617Z</updated>
    
    <content type="html"><![CDATA[<p>前一篇我们把 Reporter 和 Runtime 从 Agent Engine 中拆了出来。Engine 负责执行 Agent Loop，Runtime 负责承载一次会话，Reporter 负责向外部渠道发布事件。</p><p>但如果只停留在这一层，Runtime 仍然只是一个可以被调用的对象。一个真正可用的 Agent Harness 还需要回答三个问题：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">一次任务如何被启动、等待、取消和查询？</span><br><span class="line">一个 Session 如何避免多个任务同时修改上下文？</span><br><span class="line">一个 Go 进程如何同时承载多个独立对话？</span><br></pre></td></tr></table></figure><p>这篇文章记录今天在 go-tiny-claw 中完成的三个方向：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Task：一次 Agent Run 的生命周期</span><br><span class="line">RuntimeManager：多个 Runtime 的注册表</span><br><span class="line">Multi-Session：同一个进程承载多个独立会话</span><br></pre></td></tr></table></figure><span id="more"></span><h2 id="一-为什么-runtime-还需要-task"><a class="markdownIt-Anchor" href="#一-为什么-runtime-还需要-task"></a> 一、为什么 Runtime 还需要 Task</h2><p>最简单的 Runtime 调用方式是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">err := runtime.Run(ctx, prompt, reporter)</span><br></pre></td></tr></table></figure><p>调用者只能同步等待结果。如果需要支持任务运行时查询状态、用户按 Ctrl-C 取消任务、多个调用者等待同一个任务，以及渠道连接关闭时取消任务，就需要把一次运行显式建模为 Task。</p><p>Task 表示一次具体的 Agent 执行，而不是一个 Session：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">Session</span><br><span class="line">  └── 保存长期对话历史</span><br><span class="line"></span><br><span class="line">Runtime</span><br><span class="line">  └── 管理一个 Session 的运行边界</span><br><span class="line"></span><br><span class="line">Task</span><br><span class="line">  └── 表示一次 Prompt 的执行过程</span><br></pre></td></tr></table></figure><p>因此 Runtime 的调用方式变成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Runtime.Start</span><br><span class="line">  └── 立即返回 Task</span><br><span class="line"></span><br><span class="line">Task.Wait</span><br><span class="line">  └── 等待 Agent Engine 执行完成</span><br></pre></td></tr></table></figure><h2 id="二-task-的状态模型"><a class="markdownIt-Anchor" href="#二-task-的状态模型"></a> 二、Task 的状态模型</h2><p>当前 internal/runtime/task.go 中定义了四种状态：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> TaskStatus <span class="type">string</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> (</span><br><span class="line">TaskRunning   TaskStatus = <span class="string">&quot;running&quot;</span></span><br><span class="line">TaskCompleted TaskStatus = <span class="string">&quot;completed&quot;</span></span><br><span class="line">TaskCanceled  TaskStatus = <span class="string">&quot;canceled&quot;</span></span><br><span class="line">TaskFailed    TaskStatus = <span class="string">&quot;failed&quot;</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>Task 的核心字段是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Task <span class="keyword">struct</span> &#123;</span><br><span class="line">id     <span class="type">string</span></span><br><span class="line">done   <span class="keyword">chan</span> <span class="keyword">struct</span>&#123;&#125;</span><br><span class="line">cancel context.CancelFunc</span><br><span class="line"></span><br><span class="line">mu     sync.RWMutex</span><br><span class="line">status TaskStatus</span><br><span class="line">err    <span class="type">error</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这里的 done 不是传递错误结果的 Channel，而是一个完成通知：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">任务未完成：done 保持打开</span><br><span class="line">任务完成：close(done)</span><br></pre></td></tr></table></figure><p>多个调用者因此可以安全等待同一个任务：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">&lt;-task.Done()</span><br></pre></td></tr></table></figure><p>因为读取的是关闭事件，而不是从 Channel 中消费一个错误值。Channel 关闭后，所有等待者都可以被唤醒。</p><p>Wait 的实现是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(t *Task)</span></span> Wait() <span class="type">error</span> &#123;</span><br><span class="line">&lt;-t.done</span><br><span class="line"></span><br><span class="line">t.mu.RLock()</span><br><span class="line"><span class="keyword">defer</span> t.mu.RUnlock()</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> t.err</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>状态和错误由读写锁保护：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(t *Task)</span></span> Status() TaskStatus &#123;</span><br><span class="line">t.mu.RLock()</span><br><span class="line"><span class="keyword">defer</span> t.mu.RUnlock()</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> t.status</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(t *Task)</span></span> Err() <span class="type">error</span> &#123;</span><br><span class="line">t.mu.RLock()</span><br><span class="line"><span class="keyword">defer</span> t.mu.RUnlock()</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> t.err</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>任务结束时只允许从 running 转换一次：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(t *Task)</span></span> finish(err <span class="type">error</span>) &#123;</span><br><span class="line">t.mu.Lock()</span><br><span class="line"><span class="keyword">defer</span> t.mu.Unlock()</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> t.status != TaskRunning &#123;</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">t.err = err</span><br><span class="line"></span><br><span class="line"><span class="keyword">switch</span> &#123;</span><br><span class="line"><span class="keyword">case</span> err == <span class="literal">nil</span>:</span><br><span class="line">t.status = TaskCompleted</span><br><span class="line"><span class="keyword">case</span> errors.Is(err, context.Canceled):</span><br><span class="line">t.status = TaskCanceled</span><br><span class="line"><span class="keyword">default</span>:</span><br><span class="line">t.status = TaskFailed</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="built_in">close</span>(t.done)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这里必须先写入状态和错误，最后关闭 done。这样所有被唤醒的等待者都能读取完整状态。</p><h2 id="三-runtime-保证一个-session-内部不并发"><a class="markdownIt-Anchor" href="#三-runtime-保证一个-session-内部不并发"></a> 三、Runtime 保证一个 Session 内部不并发</h2><p>Task 解决了单次任务的生命周期，但 Runtime 仍然需要限制同一 Session 的任务数量。</p><p>Runtime 保存当前活动任务：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Runtime <span class="keyword">struct</span> &#123;</span><br><span class="line">runner  AgentRunner</span><br><span class="line">session *ctxpkg.Session</span><br><span class="line"></span><br><span class="line">mu     sync.Mutex</span><br><span class="line">active *Task</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>启动任务时先检查 active：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> r.active != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, ErrTaskRunning</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>任务结束后清理活动任务：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *Runtime)</span></span> execute(</span><br><span class="line">ctx context.Context,</span><br><span class="line">task *Task,</span><br><span class="line">rep reporter.Reporter,</span><br><span class="line">) &#123;</span><br><span class="line">err := r.runner.Run(ctx, r.session, rep)</span><br><span class="line">task.finish(err)</span><br><span class="line"></span><br><span class="line">r.mu.Lock()</span><br><span class="line"><span class="keyword">if</span> r.active == task &#123;</span><br><span class="line">r.active = <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line">r.mu.Unlock()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这产生了一个重要的并发边界：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">同一个 Runtime：同一时间只能有一个 Task</span><br><span class="line">不同 Runtime：可以并行运行不同 Task</span><br></pre></td></tr></table></figure><p>这不是为了禁止整个系统并发，而是为了保护一个 Session 的消息历史不被两个 Agent Loop 同时写入。</p><h2 id="四-为什么需要-runtimemanager"><a class="markdownIt-Anchor" href="#四-为什么需要-runtimemanager"></a> 四、为什么需要 RuntimeManager</h2><p>一个 Runtime 只能管理一个 Session。如果系统只使用一个 Runtime，就只能支持一个对话。</p><p>因此增加一个进程级 Manager：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Manager <span class="keyword">struct</span> &#123;</span><br><span class="line">mu       sync.RWMutex</span><br><span class="line">factory  *RuntimeFactory</span><br><span class="line">runtimes <span class="keyword">map</span>[<span class="type">string</span>]*Runtime</span><br><span class="line">creating <span class="keyword">map</span>[<span class="type">string</span>]<span class="keyword">struct</span>&#123;&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Manager 的职责不是运行 Agent，而是管理 Runtime 实例：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Create：创建并注册 Runtime</span><br><span class="line">Add：手动注册 Runtime</span><br><span class="line">Get：查询 Runtime</span><br><span class="line">List：列出 Session ID</span><br><span class="line">Count：查询 Runtime 数量</span><br><span class="line">Destroy：取消任务并删除 Runtime</span><br></pre></td></tr></table></figure><p>创建流程是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">连接建立</span><br><span class="line">  ↓</span><br><span class="line">Manager.Create(sessionID)</span><br><span class="line">  ↓</span><br><span class="line">RuntimeFactory 创建依赖</span><br><span class="line">  ↓</span><br><span class="line">Manager.Add(sessionID, runtime)</span><br></pre></td></tr></table></figure><p>creating 用来防止同一个 Session ID 被多个 Goroutine 同时创建：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> _, exists := m.runtimes[sessionID]; exists &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, ErrRuntimeExists</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> _, exists := m.creating[sessionID]; exists &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, ErrRuntimeCreating</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">m.creating[sessionID] = <span class="keyword">struct</span>&#123;&#125;&#123;&#125;</span><br></pre></td></tr></table></figure><p>Factory 创建结束后释放占位：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">defer</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">m.mu.Lock()</span><br><span class="line"><span class="built_in">delete</span>(m.creating, sessionID)</span><br><span class="line">m.mu.Unlock()</span><br><span class="line">&#125;()</span><br></pre></td></tr></table></figure><p>销毁 Runtime 时，Manager 先从 Map 中删除，再取消活动任务：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">delete</span>(m.runtimes, sessionID)</span><br><span class="line">m.mu.Unlock()</span><br><span class="line"></span><br><span class="line">agentRuntime.Cancel()</span><br></pre></td></tr></table></figure><p>不在持有 Manager 锁时调用 Cancel，可以避免 Manager 锁和 Runtime 锁形成嵌套等待。</p><h2 id="五-runtimefactory-解决什么问题"><a class="markdownIt-Anchor" href="#五-runtimefactory-解决什么问题"></a> 五、RuntimeFactory 解决什么问题</h2><p>如果每次建立连接时都在 Server 中手动创建 Registry、Approval、Engine、Session 和 Reporter，Server 很快会变成依赖组装代码的集合。</p><p>RuntimeFactory 统一创建每个 Runtime 所需的依赖：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">RuntimeFactory</span><br><span class="line">  ├── Session</span><br><span class="line">  ├── Tool Registry</span><br><span class="line">  ├── Approval Handler</span><br><span class="line">  ├── GrantStore</span><br><span class="line">  ├── Approval Gate</span><br><span class="line">  ├── AgentEngine</span><br><span class="line">  └── Reporter</span><br></pre></td></tr></table></figure><p>每个独立连接都应该拥有自己的 Runtime、Session、Registry、Approval Handler、GrantStore 和 Reporter。</p><p>Provider 可以由多个 Runtime 共享，但 Session、Reporter 和审批状态不能错误共享。</p><h2 id="六-一个-go-进程承载多个-session"><a class="markdownIt-Anchor" href="#六-一个-go-进程承载多个-session"></a> 六、一个 Go 进程承载多个 Session</h2><p>单机 CLI 的入口仍然是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">cmd/claw/main.go</span><br><span class="line">  └── os.Stdin + os.Stdout</span><br><span class="line">        └── 一个 REPL</span><br></pre></td></tr></table></figure><p>多连接 Server 的入口则是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">cmd/claw_server/main.go</span><br><span class="line">  └── TCPServer</span><br><span class="line">        └── Accept 循环</span><br></pre></td></tr></table></figure><p>TCP Server 每接收一个连接，就启动一个 Goroutine：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> &#123;</span><br><span class="line">conn, err := s.listener.Accept()</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">sessionID := fmt.Sprintf(</span><br><span class="line"><span class="string">&quot;tcp-channel-%d&quot;</span>,</span><br><span class="line">atomic.AddUint64(&amp;s.sequence, <span class="number">1</span>),</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">go</span> s.handleConnection(ctx, sessionID, conn)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>一个连接对应一个 ChannelSession：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> ChannelSession <span class="keyword">struct</span> &#123;</span><br><span class="line">id      <span class="type">string</span></span><br><span class="line">conn    io.ReadWriteCloser</span><br><span class="line">manager *runtimepkg.Manager</span><br><span class="line">runtime *runtimepkg.Runtime</span><br><span class="line">repl    *cli.REPL</span><br><span class="line"></span><br><span class="line">closeOnce sync.Once</span><br><span class="line">closeErr  <span class="type">error</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>它负责把网络连接和 Agent Runtime 绑定起来：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">TCP Connection</span><br><span class="line">  ├── bufio.Reader</span><br><span class="line">  ├── io.Writer</span><br><span class="line">  ├── ChannelSession</span><br><span class="line">  ├── Runtime</span><br><span class="line">  ├── Session</span><br><span class="line">  └── REPL</span><br></pre></td></tr></table></figure><p>最终一个 Go 进程中的关系是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">Go Process</span><br><span class="line">  ├── TCP Connection A</span><br><span class="line">  │     ├── Runtime A</span><br><span class="line">  │     └── Session A</span><br><span class="line">  │</span><br><span class="line">  ├── TCP Connection B</span><br><span class="line">  │     ├── Runtime B</span><br><span class="line">  │     └── Session B</span><br><span class="line">  │</span><br><span class="line">  └── TCP Connection C</span><br><span class="line">        ├── Runtime C</span><br><span class="line">        └── Session C</span><br></pre></td></tr></table></figure><p>连接 A 的任务被取消，不应该影响连接 B：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Task A.Cancel()</span><br><span class="line">  └── Runtime A</span><br><span class="line">        └── Session A</span><br><span class="line"></span><br><span class="line">Runtime B 继续运行</span><br><span class="line">Session B 历史不变</span><br></pre></td></tr></table></figure><h2 id="七-连接生命周期"><a class="markdownIt-Anchor" href="#七-连接生命周期"></a> 七、连接生命周期</h2><p>ChannelSession 的关闭必须同时处理两类资源：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Runtime</span><br><span class="line">  └── 取消当前 Task，并从 Manager 删除</span><br><span class="line"></span><br><span class="line">Network Connection</span><br><span class="line">  └── 关闭 Reader 的底层连接，解除阻塞</span><br></pre></td></tr></table></figure><p>因此 Server 退出时不能只关闭 Listener，还需要关闭所有活动 ChannelSession：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Server Shutdown</span><br><span class="line">  ├── 关闭 Listener，阻止新连接</span><br><span class="line">  ├── 遍历活动 ChannelSession</span><br><span class="line">  ├── Destroy Runtime</span><br><span class="line">  └── Close TCP Connection</span><br></pre></td></tr></table></figure><p>这也是多连接服务和一次性 CLI 最大的区别：除了任务本身，还需要管理连接资源和进程级退出。</p><h2 id="八-测试应该验证什么"><a class="markdownIt-Anchor" href="#八-测试应该验证什么"></a> 八、测试应该验证什么</h2><p>Task 和 Manager 的单元测试覆盖了：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">任务完成</span><br><span class="line">任务失败</span><br><span class="line">任务取消</span><br><span class="line">同一 Runtime 拒绝并发 Task</span><br><span class="line">多个 Runtime 并发添加</span><br><span class="line">Runtime 创建和销毁</span><br></pre></td></tr></table></figure><p>多 Session 还需要补充 Channel 和 Server 集成测试：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">ChannelSession 创建后 Manager 数量增加</span><br><span class="line">ChannelSession Close 后 Runtime 被删除</span><br><span class="line">重复 Close 不报错</span><br><span class="line">两个 TCP 客户端可以同时连接</span><br><span class="line">关闭客户端 A 不影响客户端 B</span><br><span class="line">Server 关闭后所有连接和 Runtime 都退出</span><br></pre></td></tr></table></figure><p>测试不应该调用真实 LLM Provider，而应该使用 fake Provider：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> fakeProvider <span class="keyword">struct</span>&#123;&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(p *fakeProvider)</span></span> Generate(</span><br><span class="line">ctx context.Context,</span><br><span class="line">messages []schema.Message,</span><br><span class="line">tools []schema.ToolDefinition,</span><br><span class="line">) (*schema.Message, <span class="type">error</span>) &#123;</span><br><span class="line"><span class="keyword">return</span> &amp;schema.Message&#123;&#125;, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>并发测试使用竞态检测：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GOCACHE=/tmp/go-tiny-claw-gocache go <span class="built_in">test</span> -race ./...</span><br></pre></td></tr></table></figure><h2 id="九-这一阶段之后"><a class="markdownIt-Anchor" href="#九-这一阶段之后"></a> 九、这一阶段之后</h2><p>完成 Task、RuntimeManager 和 TCP 多连接后，Agent Harness 已经从“一次性 CLI”进入“进程级多会话运行时”。</p><p>但这还不是完整的生产级网络服务，后续还需要：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">TCP 消息协议</span><br><span class="line">Prompt / Interrupt / Close / Heartbeat 消息类型</span><br><span class="line">连接认证</span><br><span class="line">TLS</span><br><span class="line">读取和写入超时</span><br><span class="line">最大消息长度</span><br><span class="line">连接数限制</span><br><span class="line">空闲连接清理</span><br><span class="line">Session 持久化</span><br><span class="line">Runtime 状态查询接口</span><br><span class="line">WebSocket Channel</span><br></pre></td></tr></table></figure><p>当前几个对象的边界是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Task：一次执行</span><br><span class="line">Session：一段长期对话</span><br><span class="line">Runtime：一个 Session 的运行时边界</span><br><span class="line">Manager：进程内多个 Runtime 的管理者</span><br><span class="line">ChannelSession：一个外部连接与 Runtime 的绑定</span><br><span class="line">TCPServer：连接接入和生命周期管理</span><br></pre></td></tr></table></figure><p>把这些对象分开之后，Agent Engine 不需要知道用户来自本地 Terminal、TCP、WebSocket 还是其他渠道，只需要接收 Context、Session 和 Reporter，完成自己的 Agent Loop。</p>]]></content>
    
    
    <summary type="html">在 Reporter 和 Runtime 抽象之后，继续为 go-tiny-claw 增加 Task 生命周期、RuntimeManager、RuntimeFactory 和 TCP 多 Session 并行能力。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（十四）抽象 Reporter 与 Runtime：把 Agent Engine 变成可复用内核</title>
    <link href="https://sunra.top/posts/5a8c05/"/>
    <id>https://sunra.top/posts/5a8c05/</id>
    <published>2026-07-19T10:00:00.000Z</published>
    <updated>2026-08-27T01:58:06.617Z</updated>
    
    <content type="html"><![CDATA[<p>前面几篇已经让 <code>go-tiny-claw</code> 具备了连续对话、流式输出、Ctrl-C 中断和工具审批能力。但如果继续把这些能力都写在 <code>main.go</code> 或 <code>REPL</code> 中，系统很快会遇到一个问题：Agent Engine 只能被 Terminal 使用。</p><p>一个真正可复用的 Agent Framework，至少需要把下面两个问题拆开：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Agent 如何把状态和事件交给外部渠道？</span><br><span class="line">一条用户任务如何被启动、等待、取消和结束？</span><br></pre></td></tr></table></figure><p>第一个问题由 <code>Reporter</code> 解决，第二个问题由 <code>Runtime</code> 解决。</p><p>本文对应当前仓库中 Reporter 和 Runtime 的抽象实现，重点不是增加一个新功能，而是把已经存在的功能重新放到正确的架构边界中。</p><span id="more"></span><h2 id="一-为什么-engine-不能直接依赖-terminal"><a class="markdownIt-Anchor" href="#一-为什么-engine-不能直接依赖-terminal"></a> 一、为什么 Engine 不能直接依赖 Terminal</h2><p>在最初的实现中，Terminal 入口大致负责下面所有事情：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">读取 stdin</span><br><span class="line">    ↓</span><br><span class="line">追加 User Message</span><br><span class="line">    ↓</span><br><span class="line">创建 Context</span><br><span class="line">    ↓</span><br><span class="line">启动 Engine.Run</span><br><span class="line">    ↓</span><br><span class="line">等待 done Channel</span><br><span class="line">    ↓</span><br><span class="line">处理 Ctrl-C</span><br><span class="line">    ↓</span><br><span class="line">把文本和工具状态打印到 stdout</span><br></pre></td></tr></table></figure><p>这种实现可以快速跑起来，但它把三种完全不同的职责混在了一起：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">输入：用户从哪里提交 Prompt</span><br><span class="line">执行：Agent 当前这一轮如何运行</span><br><span class="line">输出：模型和工具事件应该如何展示</span><br></pre></td></tr></table></figure><p>如果下一步接入 HTTP、WebSocket 或飞书，直接复制 REPL 代码会产生多个问题：</p><ol><li>每个渠道都要重新实现一遍任务生命周期。</li><li>Engine 需要知道当前输出是 Terminal 还是消息卡片。</li><li>Provider 产生的流式 Chunk 可能直接进入 <code>fmt.Printf</code>。</li><li>Ctrl-C、HTTP 取消、飞书撤回实际上都是“取消任务”，却没有统一模型。</li></ol><p>因此我们希望得到下面的依赖关系：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Terminal / HTTP / 飞书</span><br><span class="line">        ↓</span><br><span class="line">      Runtime</span><br><span class="line">        ↓</span><br><span class="line">    Agent Engine</span><br><span class="line">        ↓</span><br><span class="line"> Provider / Approval / Tool Registry</span><br></pre></td></tr></table></figure><p>渠道负责适配输入和展示，Runtime 负责一次任务的生命周期，Engine 只负责完成 Agent Loop。</p><h2 id="二-reporter-是什么"><a class="markdownIt-Anchor" href="#二-reporter-是什么"></a> 二、Reporter 是什么</h2><p>当前 Reporter 定义在 <code>internal/reporter/reporter.go</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> reporter</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> <span class="string">&quot;context&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> Reporter <span class="keyword">interface</span> &#123;</span><br><span class="line">OnThinking(ctx context.Context)</span><br><span class="line">OnToolCall(ctx context.Context, toolName <span class="type">string</span>, args <span class="type">string</span>)</span><br><span class="line">OnToolResult(ctx context.Context, toolName <span class="type">string</span>, result <span class="type">string</span>, isError <span class="type">bool</span>)</span><br><span class="line">OnMessage(ctx context.Context, content <span class="type">string</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>它表达的不是“如何打印文字”，而是 Agent 运行过程中对外发布的高层事件：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">OnThinking     开始思考</span><br><span class="line">OnToolCall     准备执行工具</span><br><span class="line">OnToolResult   工具执行完成</span><br><span class="line">OnMessage      得到一条完整回复</span><br></pre></td></tr></table></figure><p>Engine 只调用这些方法，不关心具体实现：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> rep != <span class="literal">nil</span> &#123;</span><br><span class="line">rep.OnToolCall(ctx, item.call.Name, <span class="type">string</span>(item.call.Arguments))</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这段代码既可以把事件打印到 Terminal，也可以把事件转换成 WebSocket 消息、HTTP SSE 事件或飞书卡片更新。</p><h3 id="1-reporter-不是-logger"><a class="markdownIt-Anchor" href="#1-reporter-不是-logger"></a> 1. Reporter 不是 Logger</h3><p>Logger 主要记录系统诊断信息，例如：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Provider 请求耗时 2.4 秒</span><br><span class="line">工具执行失败</span><br><span class="line">Trace 已写入文件</span><br></pre></td></tr></table></figure><p>Reporter 面向的是 Agent 使用者，表达的是用户需要看到的过程：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">正在思考</span><br><span class="line">正在调用 bash</span><br><span class="line">工具执行成功</span><br><span class="line">Agent 回复</span><br></pre></td></tr></table></figure><p>两者的生命周期和消费对象不同，不能用一个接口混合。Logger 可以写文件，Reporter 通常需要绑定当前会话和当前渠道。</p><h3 id="2-reporter-不应该读取输入"><a class="markdownIt-Anchor" href="#2-reporter-不应该读取输入"></a> 2. Reporter 不应该读取输入</h3><p>审批看起来也是用户交互，但它与 Reporter 的方向相反：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Reporter：Agent → 渠道</span><br><span class="line">Approval Handler：渠道 → Agent</span><br></pre></td></tr></table></figure><p>如果 Reporter 同时读取 stdin，就会和 REPL 的 Prompt 读取、Terminal Approval 的输入读取互相竞争。因此当前架构把审批保留在 <code>internal/approval</code>，Reporter 只负责输出。</p><h2 id="三-为什么-streamreporter-要单独定义"><a class="markdownIt-Anchor" href="#三-为什么-streamreporter-要单独定义"></a> 三、为什么 StreamReporter 要单独定义</h2><p>流式文本不是一条完整消息，而是一系列增量事件。当前源码用另一个接口表达这个能力：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> StreamReporter <span class="keyword">interface</span> &#123;</span><br><span class="line">OnTextDelta(ctx context.Context, delta <span class="type">string</span>)</span><br><span class="line">OnTextComplete(ctx context.Context)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这样设计有两个好处。</p><p>第一，原有 Reporter 实现不需要立刻支持流式输出。一个只支持完整消息的渠道仍然可以实现：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> SimpleReporter <span class="keyword">struct</span>&#123;&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *SimpleReporter)</span></span> OnMessage(ctx context.Context, content <span class="type">string</span>) &#123;</span><br><span class="line"><span class="comment">// 只在完整消息产生后展示</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>第二，Engine 可以在运行时检查渠道是否支持流式能力。当前 <code>internal/engine/stream.go</code> 中的逻辑是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">streamReporter, canStream := rep.(reporter.StreamReporter)</span><br></pre></td></tr></table></figure><p>如果 <code>canStream</code> 为 <code>true</code>，就把文本 Delta 实时交给 Reporter；否则只等待 <code>StreamCompleted</code>，再通过普通的 <code>OnMessage</code> 输出完整内容。</p><p>这是一种 Go 中常见的能力接口设计：基础接口保证最低能力，扩展接口表达可选能力。</p><h2 id="四-terminalreporter-如何实现两个接口"><a class="markdownIt-Anchor" href="#四-terminalreporter-如何实现两个接口"></a> 四、TerminalReporter 如何实现两个接口</h2><p>当前 Terminal 实现位于 <code>internal/reporter/terminal_reporter.go</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> TerminalReporter <span class="keyword">struct</span>&#123;&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">NewTerminalReporter</span><span class="params">()</span></span> *TerminalReporter &#123;</span><br><span class="line"><span class="keyword">return</span> &amp;TerminalReporter&#123;&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>它同时实现普通 Reporter 和 StreamReporter：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *TerminalReporter)</span></span> OnMessage(ctx context.Context, content <span class="type">string</span>) &#123;</span><br><span class="line"><span class="keyword">if</span> content == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line">fmt.Printf(<span class="string">&quot;\n🤖 Agent 回复:\n%s\n\n&quot;</span>, content)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *TerminalReporter)</span></span> OnTextDelta(ctx context.Context, delta <span class="type">string</span>) &#123;</span><br><span class="line">fmt.Printf(delta)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *TerminalReporter)</span></span> OnTextComplete(ctx context.Context) &#123;</span><br><span class="line">fmt.Print(<span class="string">&quot;\n\n&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>因此同一个 Terminal Reporter 可以处理两类响应：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">非流式 Provider：OnMessage</span><br><span class="line">流式 Provider：OnTextDelta + OnTextComplete</span><br></pre></td></tr></table></figure><p>Engine 的调用方不需要判断具体 Provider 是哪一种，只需要判断 Reporter 是否有流式展示能力。</p><p>不过，这个实现还有一个生产化问题：它直接使用 <code>fmt.Printf</code>，而工具调用是并发执行的。多个 goroutine 同时输出时，文本可能互相穿插。后续应该把输出对象注入 Reporter，并在 Reporter 内部增加互斥或事件队列：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> TerminalReporter <span class="keyword">struct</span> &#123;</span><br><span class="line">out io.Writer</span><br><span class="line">mu  sync.Mutex</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这样测试时也可以传入 <code>bytes.Buffer</code>，不必真的写 stdout。</p><h2 id="五-reporter-抽象如何接入-engine"><a class="markdownIt-Anchor" href="#五-reporter-抽象如何接入-engine"></a> 五、Reporter 抽象如何接入 Engine</h2><p>Reporter 抽取后，<code>AgentEngine.Run</code> 的参数不再属于 Engine 包，而是依赖独立的 <code>reporter.Reporter</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(e *AgentEngine)</span></span> Run(</span><br><span class="line">ctx context.Context,</span><br><span class="line">session *ctxpkg.Session,</span><br><span class="line">rep reporter.Reporter,</span><br><span class="line">) <span class="type">error</span></span><br></pre></td></tr></table></figure><p>Engine 在关键阶段发布事件：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> e.EnableThinking &amp;&amp; rep != <span class="literal">nil</span> &#123;</span><br><span class="line">rep.OnThinking(ctx)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> rep != <span class="literal">nil</span> &#123;</span><br><span class="line">rep.OnToolCall(ctx, item.call.Name, <span class="type">string</span>(item.call.Arguments))</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> rep != <span class="literal">nil</span> &#123;</span><br><span class="line">rep.OnToolResult(ctx, item.call.Name, displayOutput, result.IsError)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>流式文本则由 <code>generate</code> 负责消费 Provider 的 <code>StreamEvent</code>，再转发给 <code>StreamReporter</code>。因此事件链路是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">OpenAI SDK Chunk</span><br><span class="line">    ↓</span><br><span class="line">Provider StreamEvent</span><br><span class="line">    ↓</span><br><span class="line">Engine 处理和聚合</span><br><span class="line">    ↓</span><br><span class="line">Reporter 展示</span><br></pre></td></tr></table></figure><p>Provider 不知道终端存在，Reporter 也不需要知道 OpenAI SDK 的 Chunk 结构。</p><h2 id="六-runtime-是什么"><a class="markdownIt-Anchor" href="#六-runtime-是什么"></a> 六、Runtime 是什么</h2><p>Reporter 解决了“事件发给谁”，Runtime 解决的是“任务如何运行”。当前 Runtime 位于 <code>internal/runtime/runtime.go</code>，先定义了一个可以被 Engine 实现的运行接口：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> AgentRunner <span class="keyword">interface</span> &#123;</span><br><span class="line">Run(ctx context.Context, session *ctxpkg.Session, rep reporter.Reporter) <span class="type">error</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>AgentEngine</code> 天然满足这个接口，因为它已经有同样签名的 <code>Run</code> 方法。Runtime 不需要依赖 <code>*engine.AgentEngine</code> 这个具体类型，只依赖 <code>AgentRunner</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Runtime <span class="keyword">struct</span> &#123;</span><br><span class="line">runner  AgentRunner</span><br><span class="line">session *ctxpkg.Session</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>构造函数也因此很简单：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">NewRuntime</span><span class="params">(runner AgentRunner, session *ctxpkg.Session)</span></span> *Runtime &#123;</span><br><span class="line"><span class="keyword">return</span> &amp;Runtime&#123;</span><br><span class="line">runner:  runner,</span><br><span class="line">session: session,</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这里的关键不是代码量，而是依赖方向发生了变化：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">之前：REPL → AgentEngine</span><br><span class="line">现在：REPL → Runtime → AgentRunner</span><br></pre></td></tr></table></figure><p>未来可以为测试构造一个假的 AgentRunner，也可以为不同渠道创建不同 Runtime 适配层，而不需要让 REPL 了解 Engine 的细节。</p><h2 id="七-start-如何创建一条任务"><a class="markdownIt-Anchor" href="#七-start-如何创建一条任务"></a> 七、Start 如何创建一条任务</h2><p>当前 Runtime 的异步入口是 <code>Start</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *Runtime)</span></span> Start(</span><br><span class="line">parent context.Context,</span><br><span class="line">prompt <span class="type">string</span>,</span><br><span class="line">rep reporter.Reporter,</span><br><span class="line">) (*Task, <span class="type">error</span>) &#123;</span><br><span class="line">prompt = strings.TrimSpace(prompt)</span><br><span class="line"><span class="keyword">if</span> prompt == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, errors.New(<span class="string">&quot;用户提示不能为空&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">r.session.Append(schema.Message&#123;</span><br><span class="line">Role:    schema.RoleUser,</span><br><span class="line">Content: prompt,</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line">runCtx, cancel := context.WithCancel(parent)</span><br><span class="line">done := <span class="built_in">make</span>(<span class="keyword">chan</span> <span class="type">error</span>, <span class="number">1</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line"><span class="keyword">defer</span> <span class="built_in">close</span>(done)</span><br><span class="line">done &lt;- r.runner.Run(runCtx, r.session, rep)</span><br><span class="line">&#125;()</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> &amp;Task&#123;</span><br><span class="line">done:   done,</span><br><span class="line">cancel: cancel,</span><br><span class="line">&#125;, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这段代码完成了四件事。</p><h3 id="1-runtime-负责写入用户消息"><a class="markdownIt-Anchor" href="#1-runtime-负责写入用户消息"></a> 1. Runtime 负责写入用户消息</h3><p>以前是 REPL 直接调用 <code>session.Append</code>。现在 Prompt 由 Runtime 统一追加：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Terminal Prompt</span><br><span class="line">      ↓</span><br><span class="line">Runtime.Start</span><br><span class="line">      ↓</span><br><span class="line">Session.Append(UserMessage)</span><br><span class="line">      ↓</span><br><span class="line">AgentRunner.Run</span><br></pre></td></tr></table></figure><p>这样 HTTP、飞书和 Terminal 都不会各自实现一套消息写入逻辑，也不会出现同一个 Prompt 被追加两次。</p><h3 id="2-每次-start-都创建独立的-context"><a class="markdownIt-Anchor" href="#2-每次-start-都创建独立的-context"></a> 2. 每次 Start 都创建独立的 Context</h3><p>Runtime 接收渠道传入的父 Context，然后创建当前任务自己的子 Context：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">runCtx, cancel := context.WithCancel(parent)</span><br></pre></td></tr></table></figure><p>父 Context 通常代表整个进程或请求；子 Context 代表这一轮 Agent Run。取消边界因此变成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">程序退出 / HTTP 请求结束</span><br><span class="line">              ↓</span><br><span class="line">          parent cancel</span><br><span class="line">              ↓</span><br><span class="line">          runCtx cancel</span><br><span class="line"></span><br><span class="line">Terminal Ctrl-C</span><br><span class="line">              ↓</span><br><span class="line">          task.Cancel()</span><br><span class="line">              ↓</span><br><span class="line">          runCtx cancel</span><br></pre></td></tr></table></figure><p>Engine、Provider 和支持 Context 的工具都可以沿着这条链路退出。</p><h3 id="3-runtime-用-goroutine-隔离-engine"><a class="markdownIt-Anchor" href="#3-runtime-用-goroutine-隔离-engine"></a> 3. Runtime 用 Goroutine 隔离 Engine</h3><p>如果在 <code>Start</code> 中同步调用 <code>runner.Run</code>，调用方就无法及时得到一个任务句柄，也无法同时处理取消信号。因此 Runtime 把 Runner 放进 goroutine，并立即返回 <code>Task</code>。</p><h3 id="4-done-使用容量为-1-的-channel"><a class="markdownIt-Anchor" href="#4-done-使用容量为-1-的-channel"></a> 4. <code>done</code> 使用容量为 1 的 Channel</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">done := <span class="built_in">make</span>(<span class="keyword">chan</span> <span class="type">error</span>, <span class="number">1</span>)</span><br></pre></td></tr></table></figure><p>Runner 结束时把结果写入 Channel，调用方通过 <code>Task.Done()</code> 读取。容量为 1 很重要：即使调用方先响应 Ctrl-C、暂时还没有读取结果，Runner 也可以安全地把最终错误写进去，不会因为发送结果而永久阻塞。</p><h2 id="八-task-是一次运行的句柄"><a class="markdownIt-Anchor" href="#八-task-是一次运行的句柄"></a> 八、Task 是一次运行的句柄</h2><p>当前 <code>Task</code> 定义在 <code>internal/runtime/task.go</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Task <span class="keyword">struct</span> &#123;</span><br><span class="line">done   &lt;-<span class="keyword">chan</span> <span class="type">error</span></span><br><span class="line">cancel context.CancelFunc</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(t *Task)</span></span> Done() &lt;-<span class="keyword">chan</span> <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">return</span> t.done</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(t *Task)</span></span> Cancel() &#123;</span><br><span class="line">t.cancel()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>调用方不需要接触 Runtime 内部的 Context 和 goroutine，只需要操作两个方法：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">task, err := rt.Start(ctx, prompt, rep)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> err := &lt;-task.Done():</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line"><span class="keyword">case</span> &lt;-signals:</span><br><span class="line">task.Cancel()</span><br><span class="line"><span class="keyword">return</span> &lt;-task.Done()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这就是任务控制面的最小模型：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Start → 得到 Task</span><br><span class="line">Task.Done → 等待结束</span><br><span class="line">Task.Cancel → 请求取消</span><br></pre></td></tr></table></figure><p>未来还可以在 Task 上增加：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">ID()       返回任务 ID</span><br><span class="line">Status()   查看任务状态</span><br><span class="line">Err()      获取最终错误</span><br><span class="line">Wait()     阻塞等待并返回错误</span><br><span class="line">Events()   订阅结构化运行事件</span><br></pre></td></tr></table></figure><p>但第一步不应该过早设计一个复杂的任务对象。先把生命周期边界固定下来，再根据渠道需求扩展。</p><h2 id="九-同步-run-只是-start-的便捷封装"><a class="markdownIt-Anchor" href="#九-同步-run-只是-start-的便捷封装"></a> 九、同步 Run 只是 Start 的便捷封装</h2><p>Runtime 同时保留同步入口：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *Runtime)</span></span> Run(</span><br><span class="line">ctx context.Context,</span><br><span class="line">prompt <span class="type">string</span>,</span><br><span class="line">rep reporter.Reporter,</span><br><span class="line">) <span class="type">error</span> &#123;</span><br><span class="line">task, err := r.Start(ctx, prompt, rep)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> &lt;-task.Done()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这里不要再复制一套 Prompt 校验、Session 追加和 goroutine 逻辑。同步 Run 只是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Start</span><br><span class="line">  ↓</span><br><span class="line">等待 Done</span><br><span class="line">  ↓</span><br><span class="line">返回错误</span><br></pre></td></tr></table></figure><p>这样同步调用方和异步调用方共享完全相同的行为，后续修复取消或错误处理时也只需要改一个地方。</p><h2 id="十-repl-改造后只剩渠道职责"><a class="markdownIt-Anchor" href="#十-repl-改造后只剩渠道职责"></a> 十、REPL 改造后只剩渠道职责</h2><p>当前 <code>internal/cli/repl.go</code> 定义了一个更窄的 Runtime 接口：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Runtime <span class="keyword">interface</span> &#123;</span><br><span class="line">Start(parent context.Context, prompt <span class="type">string</span>, reporter reporter.Reporter) (*runtime.Task, <span class="type">error</span>)</span><br><span class="line">Clear()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>REPL 自己只保存输入、输出、Runtime 和 Reporter：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> REPL <span class="keyword">struct</span> &#123;</span><br><span class="line">reader   *bufio.Reader</span><br><span class="line">out      io.Writer</span><br><span class="line">runtime  Runtime</span><br><span class="line">reporter reporter.Reporter</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>本地命令由 REPL 处理：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">switch</span> prompt &#123;</span><br><span class="line"><span class="keyword">case</span> <span class="string">&quot;/exit&quot;</span>, <span class="string">&quot;/quit&quot;</span>:</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line"><span class="keyword">case</span> <span class="string">&quot;/clear&quot;</span>:</span><br><span class="line">r.runtime.Clear()</span><br><span class="line">fmt.Fprintln(r.out, <span class="string">&quot;会话已清空。&quot;</span>)</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line"><span class="keyword">case</span> <span class="string">&quot;/help&quot;</span>:</span><br><span class="line">printHelp(r.out)</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>普通 Prompt 则交给 Runtime：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *REPL)</span></span> runTurn(</span><br><span class="line">parent context.Context,</span><br><span class="line">prompt <span class="type">string</span>,</span><br><span class="line">signals &lt;-<span class="keyword">chan</span> os.Signal,</span><br><span class="line">) <span class="type">error</span> &#123;</span><br><span class="line">task, err := r.runtime.Start(parent, prompt, r.reporter)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> err := &lt;-task.Done():</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line"><span class="keyword">case</span> &lt;-signals:</span><br><span class="line">fmt.Fprintln(r.out, <span class="string">&quot;\n正在取消当前任务...&quot;</span>)</span><br><span class="line">task.Cancel()</span><br><span class="line"><span class="keyword">return</span> &lt;-task.Done()</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>改造后的 REPL 不再知道：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Engine 如何循环</span><br><span class="line">Provider 如何流式读取</span><br><span class="line">ToolCall 如何聚合</span><br><span class="line">Session 如何保存历史</span><br><span class="line">工具如何并发执行</span><br></pre></td></tr></table></figure><p>它只知道用户输入、命令、信号和任务句柄。这就是渠道层应该拥有的复杂度。</p><h2 id="十一-maingo-如何组装对象"><a class="markdownIt-Anchor" href="#十一-maingo-如何组装对象"></a> 十一、main.go 如何组装对象</h2><p>完成抽象后，启动入口负责依赖注入：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">eng := engine.NewAgentEngine(llmProvider, registry, gate, <span class="literal">false</span>, <span class="literal">false</span>)</span><br><span class="line">rep := reporter.NewTerminalReporter()</span><br><span class="line">sess := ctxpkg.GlobalSessionMgr.GetOrCreate(<span class="string">&quot;terminal_default&quot;</span>, workDir)</span><br><span class="line">rt := runtime.NewRuntime(eng, sess)</span><br><span class="line"></span><br><span class="line">repl := cli.NewREPL(reader, os.Stdout, rt, rep)</span><br></pre></td></tr></table></figure><p>组装关系是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">OpenAIProvider ─┐</span><br><span class="line">ToolRegistry ───┼→ AgentEngine ─→ Runtime ─→ REPL</span><br><span class="line">ApprovalGate ───┘                  ↑          ↑</span><br><span class="line">                              Session     Reporter</span><br></pre></td></tr></table></figure><p><code>main.go</code> 仍然负责组装具体实现，但不再让具体依赖渗透进 REPL。将来换成 HTTP 入口时，可以复用：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">rt := runtime.NewRuntime(engine, session)</span><br><span class="line">task, err := rt.Start(request.Context(), prompt, httpReporter)</span><br></pre></td></tr></table></figure><p>这就是 Runtime 抽象的实际价值：不是为了多创建一个 package，而是为了让不同入口复用同一条 Agent 执行链路。</p><h2 id="十二-一次完整请求的时序"><a class="markdownIt-Anchor" href="#十二-一次完整请求的时序"></a> 十二、一次完整请求的时序</h2><p>把 Reporter 和 Runtime 放在一起，一次请求的时序如下：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line">用户输入 Prompt</span><br><span class="line">      ↓</span><br><span class="line">REPL.runTurn</span><br><span class="line">      ↓</span><br><span class="line">Runtime.Start</span><br><span class="line">      ├── 校验 Prompt</span><br><span class="line">      ├── Session.Append(UserMessage)</span><br><span class="line">      ├── context.WithCancel</span><br><span class="line">      └── goroutine → AgentRunner.Run</span><br><span class="line">                              ↓</span><br><span class="line">                       Engine.generate</span><br><span class="line">                              ↓</span><br><span class="line">                       StreamEvent</span><br><span class="line">                              ↓</span><br><span class="line">                       Reporter.OnTextDelta</span><br><span class="line">                              ↓</span><br><span class="line">                       Tool / Approval</span><br><span class="line">                              ↓</span><br><span class="line">                       Reporter.OnToolResult</span><br><span class="line">                              ↓</span><br><span class="line">                       Task.Done</span><br><span class="line">                              ↓</span><br><span class="line">                       返回 claw&gt;</span><br></pre></td></tr></table></figure><p>Ctrl-C 的路径则是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">Ctrl-C</span><br><span class="line">  ↓</span><br><span class="line">REPL 收到 os.Interrupt</span><br><span class="line">  ↓</span><br><span class="line">Task.Cancel</span><br><span class="line">  ↓</span><br><span class="line">runCtx.Done</span><br><span class="line">  ├── Engine 停止消费流</span><br><span class="line">  ├── Provider 停止发送事件</span><br><span class="line">  ├── bash 子进程收到取消</span><br><span class="line">  └── Runner 返回 context.Canceled</span><br></pre></td></tr></table></figure><p>注意，Reporter 不参与取消。它只展示取消前已经产生的事件；取消控制权属于 Runtime 和 Task。</p><h2 id="十三-这次抽象解决了什么"><a class="markdownIt-Anchor" href="#十三-这次抽象解决了什么"></a> 十三、这次抽象解决了什么</h2><h3 id="1-engine-可以脱离-terminal-测试"><a class="markdownIt-Anchor" href="#1-engine-可以脱离-terminal-测试"></a> 1. Engine 可以脱离 Terminal 测试</h3><p>可以使用一个假的 Reporter 收集事件：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> RecordingReporter <span class="keyword">struct</span> &#123;</span><br><span class="line">messages []<span class="type">string</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>也可以使用假的 AgentRunner 测试 Runtime：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> FakeRunner <span class="keyword">struct</span> &#123;</span><br><span class="line">run <span class="function"><span class="keyword">func</span><span class="params">(context.Context, *ctxpkg.Session, reporter.Reporter)</span></span> <span class="type">error</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>不需要启动真实模型，也不需要读取 stdin。</p><h3 id="2-输出渠道可以替换"><a class="markdownIt-Anchor" href="#2-输出渠道可以替换"></a> 2. 输出渠道可以替换</h3><p>同一套 Engine 和 Runtime 可以连接不同 Reporter：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">TerminalReporter</span><br><span class="line">SSEReporter</span><br><span class="line">WebSocketReporter</span><br><span class="line">FeishuReporter</span><br><span class="line">RecordingReporter</span><br></pre></td></tr></table></figure><h3 id="3-任务控制统一"><a class="markdownIt-Anchor" href="#3-任务控制统一"></a> 3. 任务控制统一</h3><p>Terminal 的 Ctrl-C、HTTP 的请求取消、服务端的超时和未来的“停止任务”按钮，都可以映射到同一个操作：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">task.Cancel()</span><br></pre></td></tr></table></figure><h3 id="4-连续会话有了明确归属"><a class="markdownIt-Anchor" href="#4-连续会话有了明确归属"></a> 4. 连续会话有了明确归属</h3><p>REPL 不再直接维护消息写入规则，Runtime 负责把 Prompt 转换为 Session 中的一条 User Message，再启动一次 Agent Run。</p><h2 id="十四-当前实现还不够生产级"><a class="markdownIt-Anchor" href="#十四-当前实现还不够生产级"></a> 十四、当前实现还不够生产级</h2><p>目前的 Runtime 是一个最小可用抽象，还需要继续补齐以下边界。</p><h3 id="1-防止同一-session-并发运行"><a class="markdownIt-Anchor" href="#1-防止同一-session-并发运行"></a> 1. 防止同一 Session 并发运行</h3><p>当前 <code>Start</code> 可以被连续调用两次。两个 Runner 会同时修改同一个 Session，导致上下文顺序和 Reporter 输出不可预测。</p><p>Runtime 至少应该增加：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">activeTask</span><br><span class="line">互斥锁</span><br><span class="line">忙碌错误或排队策略</span><br><span class="line">任务结束后的清理</span><br></pre></td></tr></table></figure><h3 id="2-任务状态不能只靠-error"><a class="markdownIt-Anchor" href="#2-任务状态不能只靠-error"></a> 2. 任务状态不能只靠 error</h3><p><code>context.Canceled</code>、超时、用户拒绝和 Engine 失败应该在外部有清晰区分。后续可以定义：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> TaskStatus <span class="type">string</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> (</span><br><span class="line">TaskRunning   TaskStatus = <span class="string">&quot;running&quot;</span></span><br><span class="line">TaskCompleted TaskStatus = <span class="string">&quot;completed&quot;</span></span><br><span class="line">TaskCanceled  TaskStatus = <span class="string">&quot;canceled&quot;</span></span><br><span class="line">TaskFailed    TaskStatus = <span class="string">&quot;failed&quot;</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><h3 id="3-reporter-需要结构化事件"><a class="markdownIt-Anchor" href="#3-reporter-需要结构化事件"></a> 3. Reporter 需要结构化事件</h3><p>当前 Reporter 方法已经比 <code>fmt.Printf</code> 好很多，但仍然是多个回调。生产环境还需要统一事件模型：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Event <span class="keyword">struct</span> &#123;</span><br><span class="line">TaskID <span class="type">string</span></span><br><span class="line">Kind   <span class="type">string</span></span><br><span class="line">Text   <span class="type">string</span></span><br><span class="line">Data   any</span><br><span class="line">At     time.Time</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这样日志、UI、Trace 和远程渠道可以订阅同一份事件，而不必分别实现十几个回调。</p><h3 id="4-终端输入和审批输入需要统一协调"><a class="markdownIt-Anchor" href="#4-终端输入和审批输入需要统一协调"></a> 4. 终端输入和审批输入需要统一协调</h3><p>当前 REPL 读取 Prompt，Approval Handler 也读取 Terminal 输入。它们必须保证不会同时消费同一个 stdin。更进一步，可以由 Runtime 统一管理输入请求，并让审批变成一种挂起的任务状态。</p><h3 id="5-session-需要持久化"><a class="markdownIt-Anchor" href="#5-session-需要持久化"></a> 5. Session 需要持久化</h3><p>当前 Session 主要存在内存中。进程重启后，连续对话会丢失。生产级 Runtime 需要抽象：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">SessionStore</span><br><span class="line">TaskStore</span><br><span class="line">GrantStore</span><br><span class="line">TraceStore</span><br></pre></td></tr></table></figure><p>然后根据部署方式选择内存、SQLite、PostgreSQL 或其他存储。</p><h2 id="十五-下一步应该怎么做"><a class="markdownIt-Anchor" href="#十五-下一步应该怎么做"></a> 十五、下一步应该怎么做</h2><p>Reporter 和 Runtime 抽象完成后，下一步不是继续给 Engine 增加更多 if，而是先补齐任务控制面：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">Runtime 单任务并发控制</span><br><span class="line">        ↓</span><br><span class="line">Task 状态机和任务 ID</span><br><span class="line">        ↓</span><br><span class="line">结构化 Event 总线</span><br><span class="line">        ↓</span><br><span class="line">Session 持久化</span><br><span class="line">        ↓</span><br><span class="line">跨渠道 Runtime Adapter</span><br></pre></td></tr></table></figure><p>等这些边界稳定后，Agent Framework 才能从“一个可以运行的 Terminal Demo”逐渐变成“多个入口共享的执行平台”。</p><p>Reporter 让 Engine 的输出可以被替换，Runtime 让 Engine 的生命周期可以被控制。两者结合之后，Agent 的核心能力终于不再绑定某一个终端入口，这也是从 Demo 走向 Framework 的关键一步。</p>]]></content>
    
    
    <summary type="html">基于 go-tiny-claw 当前源码，拆解 Reporter 和 Runtime 两个边界，让 Agent Engine 从 Terminal 入口中解耦，支持连续对话、流式输出和可取消任务。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（十三）通用工具审批与 Human-in-the-loop</title>
    <link href="https://sunra.top/posts/5a8c04/"/>
    <id>https://sunra.top/posts/5a8c04/</id>
    <published>2026-07-17T10:30:00.000Z</published>
    <updated>2026-08-27T01:58:06.617Z</updated>
    
    <content type="html"><![CDATA[<p>Coding Agent 和普通聊天机器人最大的区别之一，是它可以改变真实环境。读取文件通常是低风险操作；执行任意 bash、覆盖文件、修改代码则可能带来不可逆后果。</p><p>本文源码版本对应提交：<code>8a13d9a876a86a08c2ee43171cc2a108412f18ec</code>（add approval）。</p><p>因此，工具调用不能从模型响应直接跳到 <code>registry.Execute</code>。中间需要一个可审计、可替换、可取消的 Human-in-the-loop 层。本篇实现当前仓库里的 Approval 协议，并分析它距离生产级还差哪些能力。</p><span id="more"></span><h2 id="一-为什么不把审批写进-tool-或-middleware"><a class="markdownIt-Anchor" href="#一-为什么不把审批写进-tool-或-middleware"></a> 一、为什么不把审批写进 Tool 或 Middleware</h2><p>工具本身应该关注参数解析和业务执行。<code>Registry</code> 的 Middleware 适合做确定性的拦截，例如参数校验、路径限制、命令黑名单；但“等待用户输入”属于交互流程。</p><p>当前工具注册接口是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> BaseTool <span class="keyword">interface</span> &#123;</span><br><span class="line">Name() <span class="type">string</span></span><br><span class="line">Definition() schema.ToolDefinition</span><br><span class="line">Execute(ctx context.Context, args json.RawMessage) (<span class="type">string</span>, <span class="type">error</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> MiddlewareFunc <span class="function"><span class="keyword">func</span><span class="params">(ctx context.Context, call schema.ToolCall)</span></span> (allowed <span class="type">bool</span>, rejectReason <span class="type">string</span>)</span><br></pre></td></tr></table></figure><p>如果把审批放在 Middleware 中，Registry 执行工具时才询问用户，会出现三个问题：</p><ol><li>Engine 无法在执行前统一展示所有待审批操作。</li><li>并发执行时多个 Middleware 可能同时读取 stdin。</li><li>渠道交互逻辑进入工具注册层，未来难以接 Web 或其他渠道。</li></ol><p>所以审批应该是 Engine 和 Registry 之间的独立阶段：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">模型返回完整 ToolCall</span><br><span class="line">        ↓</span><br><span class="line">Approval Gate</span><br><span class="line">        ↓</span><br><span class="line">获准的 ToolCall</span><br><span class="line">        ↓</span><br><span class="line">Registry.Execute</span><br></pre></td></tr></table></figure><h2 id="二-先定义渠道无关的协议"><a class="markdownIt-Anchor" href="#二-先定义渠道无关的协议"></a> 二、先定义渠道无关的协议</h2><p><code>internal/approval/interface.go</code> 当前定义了三类决策：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Decision <span class="type">string</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> (</span><br><span class="line">AllowOnce    Decision = <span class="string">&quot;allow_once&quot;</span></span><br><span class="line">AllowSession Decision = <span class="string">&quot;allow_session&quot;</span></span><br><span class="line">Deny         Decision = <span class="string">&quot;deny&quot;</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>它们分别表示：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">AllowOnce：只允许当前 ToolCall</span><br><span class="line">AllowSession：当前会话中继续允许该工具</span><br><span class="line">Deny：拒绝当前 ToolCall</span><br></pre></td></tr></table></figure><p>风险级别也是协议的一部分：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> RiskLevel <span class="type">string</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> (</span><br><span class="line">RiskSafe      RiskLevel = <span class="string">&quot;safe&quot;</span></span><br><span class="line">RiskMutating  RiskLevel = <span class="string">&quot;mutating&quot;</span></span><br><span class="line">RiskDangerous RiskLevel = <span class="string">&quot;dangerous&quot;</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>一次审批请求携带完整上下文：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Request <span class="keyword">struct</span> &#123;</span><br><span class="line">ID        <span class="type">string</span></span><br><span class="line">SessionID <span class="type">string</span></span><br><span class="line">ToolCall  schema.ToolCall</span><br><span class="line">Risk      RiskLevel</span><br><span class="line">Reason    <span class="type">string</span></span><br><span class="line">CreatedAt time.Time</span><br><span class="line">ExpiresAt time.Time</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> Handler <span class="keyword">interface</span> &#123;</span><br><span class="line">Approve(ctx context.Context, request Request) (Decision, <span class="type">error</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>Handler</code> 不知道 Agent 如何运行，也不需要知道 Registry。它只负责把请求交给具体渠道，并返回标准 Decision。</p><h2 id="三-risklevel-应该由工具声明"><a class="markdownIt-Anchor" href="#三-risklevel-应该由工具声明"></a> 三、RiskLevel 应该由工具声明</h2><p>当前 <code>internal/tools/interface.go</code> 通过独立接口表达工具风险：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> RiskedTool <span class="keyword">interface</span> &#123;</span><br><span class="line">RiskLevel() approval.RiskLevel</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Registry 查询工具风险时，如果工具不存在或没有实现 <code>RiskedTool</code>，默认按危险处理：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *registryImpl)</span></span> GetRiskLevel(name <span class="type">string</span>) approval.RiskLevel &#123;</span><br><span class="line">tool, exists := r.tools[name]</span><br><span class="line"><span class="keyword">if</span> !exists &#123;</span><br><span class="line"><span class="keyword">return</span> approval.RiskDangerous</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">riskedTool, ok := tool.(RiskedTool)</span><br><span class="line"><span class="keyword">if</span> !ok &#123;</span><br><span class="line"><span class="keyword">return</span> approval.RiskDangerous</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> riskedTool.RiskLevel()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这是一种 fail closed 策略：未知工具不能因为缺少风险声明而自动放行。</p><p>当前内置工具的实际风险声明是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(t *ReadFileTool)</span></span> RiskLevel() approval.RiskLevel &#123;</span><br><span class="line"><span class="keyword">return</span> approval.RiskSafe</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(t *WriteFileTool)</span></span> RiskLevel() approval.RiskLevel &#123;</span><br><span class="line"><span class="keyword">return</span> approval.RiskMutating</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(t *EditFileTool)</span></span> RiskLevel() approval.RiskLevel &#123;</span><br><span class="line"><span class="keyword">return</span> approval.RiskMutating</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(t *BashTool)</span></span> RiskLevel() approval.RiskLevel &#123;</span><br><span class="line"><span class="keyword">return</span> approval.RiskDangerous</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>风险是工具能力的粗粒度分类，不是最终授权结果。即使是 <code>RiskSafe</code>，仍然可以被 Middleware 的路径检查拒绝。</p><h2 id="四-policy-决定默认行为"><a class="markdownIt-Anchor" href="#四-policy-决定默认行为"></a> 四、Policy 决定默认行为</h2><p>Policy 不直接询问用户，它只返回策略结果：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> PolicyDecision <span class="type">string</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> (</span><br><span class="line">PolicyAutoAllow PolicyDecision = <span class="string">&quot;auto_allow&quot;</span></span><br><span class="line">PolicyAsk       PolicyDecision = <span class="string">&quot;ask&quot;</span></span><br><span class="line">PolicyDeny      PolicyDecision = <span class="string">&quot;deny&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> Policy <span class="keyword">interface</span> &#123;</span><br><span class="line">Evaluate(Request) PolicyDecision</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>当前默认策略是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> DefaultPolicy <span class="keyword">struct</span>&#123;&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(DefaultPolicy)</span></span> Evaluate(req Request) PolicyDecision &#123;</span><br><span class="line"><span class="keyword">switch</span> req.Risk &#123;</span><br><span class="line"><span class="keyword">case</span> RiskSafe:</span><br><span class="line"><span class="keyword">return</span> PolicyAutoAllow</span><br><span class="line"><span class="keyword">case</span> RiskMutating, RiskDangerous:</span><br><span class="line"><span class="keyword">return</span> PolicyAsk</span><br><span class="line"><span class="keyword">default</span>:</span><br><span class="line"><span class="keyword">return</span> PolicyDeny</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这个设计把“默认规则”和“用户本次选择”分开了。未来可以增加：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">只允许 workspace 内的写入</span><br><span class="line">只允许 go test、go fmt 等命令自动执行</span><br><span class="line">生产环境全部 Dangerous 自动拒绝</span><br><span class="line">开发环境允许某些命令进入会话授权</span><br></pre></td></tr></table></figure><p>而不用修改 Handler 或 Engine。</p><h2 id="五-grantstore-为什么需要单独抽象"><a class="markdownIt-Anchor" href="#五-grantstore-为什么需要单独抽象"></a> 五、GrantStore 为什么需要单独抽象</h2><p><code>AllowSession</code> 不是一次性结果，它需要被保存。当前接口是：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> GrantStore <span class="keyword">interface</span> &#123;</span><br><span class="line">Has(ctx context.Context, request Request) (<span class="type">bool</span>, <span class="type">error</span>)</span><br><span class="line">Save(ctx context.Context, grant Grant) <span class="type">error</span></span><br><span class="line">Revoke(ctx context.Context, grant Grant) <span class="type">error</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>GrantStore</code> 是能力接口，<code>MemoryGrantStore</code> 是一个具体实现。二者不能混为一谈：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">GrantStore：Engine/Gate 依赖的抽象</span><br><span class="line">MemoryGrantStore：开发阶段的内存实现</span><br></pre></td></tr></table></figure><p>当前内存实现用会话和工具名作为索引：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Grant <span class="keyword">struct</span> &#123;</span><br><span class="line">SessionID <span class="type">string</span></span><br><span class="line">ToolName  <span class="type">string</span></span><br><span class="line">WorkDir   <span class="type">string</span></span><br><span class="line">ExpiresAt time.Time</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> grantKey <span class="keyword">struct</span> &#123;</span><br><span class="line">sessionID <span class="type">string</span></span><br><span class="line">toolName  <span class="type">string</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> MemoryGrantStore <span class="keyword">struct</span> &#123;</span><br><span class="line">mu     sync.RWMutex</span><br><span class="line">grants <span class="keyword">map</span>[grantKey]Grant</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">NewMemoryGrantStore</span><span class="params">()</span></span> *MemoryGrantStore &#123;</span><br><span class="line"><span class="keyword">return</span> &amp;MemoryGrantStore&#123;</span><br><span class="line">grants: <span class="built_in">make</span>(<span class="keyword">map</span>[grantKey]Grant),</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>sync.RWMutex</code> 的零值可以直接使用，因此不需要显式初始化 <code>mu</code>；需要初始化的是 map，否则写入时会 panic。</p><h2 id="六-实现过期授权和并发安全"><a class="markdownIt-Anchor" href="#六-实现过期授权和并发安全"></a> 六、实现过期授权和并发安全</h2><p>当前 <code>Has</code> 会检查 Context、读取授权，并删除已经过期的 Grant：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s *MemoryGrantStore)</span></span> Has(ctx context.Context, request Request) (<span class="type">bool</span>, <span class="type">error</span>) &#123;</span><br><span class="line"><span class="keyword">if</span> err := ctx.Err(); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">false</span>, err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">key := grantKey&#123;</span><br><span class="line">sessionID: request.SessionID,</span><br><span class="line">toolName:  request.ToolCall.Name,</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">s.mu.Lock()</span><br><span class="line"><span class="keyword">defer</span> s.mu.Unlock()</span><br><span class="line"></span><br><span class="line">grant, exists := s.grants[key]</span><br><span class="line"><span class="keyword">if</span> !exists &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">false</span>, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> !grant.ExpiresAt.IsZero() &amp;&amp;</span><br><span class="line">!time.Now().Before(grant.ExpiresAt) &#123;</span><br><span class="line"><span class="built_in">delete</span>(s.grants, key)</span><br><span class="line"><span class="keyword">return</span> <span class="literal">false</span>, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> <span class="literal">true</span>, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这里使用写锁而不是读锁，是因为读取过程中可能清理过期记录。<code>Save</code> 会校验必要字段：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s *MemoryGrantStore)</span></span> Save(ctx context.Context, grant Grant) <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">if</span> err := ctx.Err(); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> grant.SessionID == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;grant session ID 不能为空&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> grant.ToolName == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;grant tool name 不能为空&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">key := grantKey&#123;</span><br><span class="line">sessionID: grant.SessionID,</span><br><span class="line">toolName:  grant.ToolName,</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">s.mu.Lock()</span><br><span class="line">s.grants[key] = grant</span><br><span class="line">s.mu.Unlock()</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="七-gate-统一编排审批流程"><a class="markdownIt-Anchor" href="#七-gate-统一编排审批流程"></a> 七、Gate 统一编排审批流程</h2><p><code>internal/approval/gate.go</code> 把 Policy、GrantStore 和 Handler 串起来：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> Gate <span class="keyword">struct</span> &#123;</span><br><span class="line">policy  Policy</span><br><span class="line">handler Handler</span><br><span class="line">grants  GrantStore</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(g *Gate)</span></span> Check(ctx context.Context, request Request) (Decision, <span class="type">error</span>) &#123;</span><br><span class="line">ploicyDecision := g.policy.Evaluate(request)</span><br><span class="line"></span><br><span class="line"><span class="keyword">switch</span> ploicyDecision &#123;</span><br><span class="line"><span class="keyword">case</span> PolicyAutoAllow:</span><br><span class="line"><span class="keyword">return</span> AllowOnce, <span class="literal">nil</span></span><br><span class="line"><span class="keyword">case</span> PolicyDeny:</span><br><span class="line"><span class="keyword">return</span> Deny, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">allowed, err := g.grants.Has(ctx, request)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, err</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> allowed &#123;</span><br><span class="line"><span class="keyword">return</span> AllowOnce, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">decision, err := g.handler.Approve(ctx, request)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> decision == AllowSession &#123;</span><br><span class="line">err = g.grants.Save(ctx, Grant&#123;</span><br><span class="line">SessionID: request.SessionID,</span><br><span class="line">ToolName:  request.ToolCall.Name,</span><br><span class="line">&#125;)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> decision, err</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>上面保留了当前源码中的 <code>ploicyDecision</code> 拼写；它不影响逻辑，但正式重构时应改为 <code>policyDecision</code>，避免误导后续维护者。</p><p>流程顺序是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">PolicyAutoAllow → AllowOnce</span><br><span class="line">PolicyDeny      → Deny</span><br><span class="line">PolicyAsk       → 查询已有 Grant</span><br><span class="line">已有 Grant      → AllowOnce</span><br><span class="line">没有 Grant      → 调用 Handler</span><br><span class="line">AllowSession    → 保存 Grant</span><br></pre></td></tr></table></figure><p>Gate 不执行工具，也不负责输出日志。这样可以对它做纯单元测试。</p><h2 id="八-engine-必须先审批再并发执行"><a class="markdownIt-Anchor" href="#八-engine-必须先审批再并发执行"></a> 八、Engine 必须先审批，再并发执行</h2><p>当前 <code>internal/engine/loop.go</code> 使用 <code>IndexedToolCall</code> 保存模型原始顺序：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> IndexedToolCall <span class="keyword">struct</span> &#123;</span><br><span class="line">index <span class="type">int</span></span><br><span class="line">call  schema.ToolCall</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>审批阶段是串行的：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line">observationMsgs := <span class="built_in">make</span>([]schema.Message, <span class="built_in">len</span>(actionResp.ToolCalls))</span><br><span class="line">approvedCalls := <span class="built_in">make</span>([]IndexedToolCall, <span class="number">0</span>, <span class="built_in">len</span>(actionResp.ToolCalls))</span><br><span class="line">toolResults := <span class="built_in">make</span>([]schema.ToolResult, <span class="built_in">len</span>(actionResp.ToolCalls))</span><br><span class="line"><span class="keyword">var</span> wg sync.WaitGroup</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> i, toolCall := <span class="keyword">range</span> actionResp.ToolCalls &#123;</span><br><span class="line">req := approval.Request&#123;</span><br><span class="line">ID:        approval.NewRequestID(),</span><br><span class="line">SessionID: session.ID,</span><br><span class="line">ToolCall:  toolCall,</span><br><span class="line">Risk:      e.registry.GetRiskLevel(toolCall.Name),</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">decision, err := e.approvalGate.Check(ctx, req)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;审批请求失败: %w&quot;</span>, err)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> decision == approval.Deny &#123;</span><br><span class="line">observationMsgs[i] = schema.Message&#123;</span><br><span class="line">Role:       schema.RoleUser,</span><br><span class="line">Content:    fmt.Sprintf(<span class="string">&quot;工具调用被拒绝: %s&quot;</span>, toolCall.Name),</span><br><span class="line">ToolCallID: toolCall.ID,</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">approvedCalls = <span class="built_in">append</span>(approvedCalls, IndexedToolCall&#123;</span><br><span class="line">index: i,</span><br><span class="line">call:  toolCall,</span><br><span class="line">&#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>只有被批准的调用才进入并发阶段：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> _, item := <span class="keyword">range</span> approvedCalls &#123;</span><br><span class="line">wg.Add(<span class="number">1</span>)</span><br><span class="line"><span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">(item IndexedToolCall)</span></span> &#123;</span><br><span class="line"><span class="keyword">defer</span> wg.Done()</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> reporter != <span class="literal">nil</span> &#123;</span><br><span class="line">reporter.OnToolCall(ctx, item.call.Name, <span class="type">string</span>(item.call.Arguments))</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">result := e.registry.Execute(ctx, item.call)</span><br><span class="line"><span class="keyword">if</span> reporter != <span class="literal">nil</span> &#123;</span><br><span class="line">displayOutput := result.Output</span><br><span class="line"><span class="keyword">if</span> <span class="built_in">len</span>(displayOutput) &gt; <span class="number">200</span> &#123;</span><br><span class="line">displayOutput = displayOutput[:<span class="number">200</span>] + <span class="string">&quot;... (已截断)&quot;</span></span><br><span class="line">&#125;</span><br><span class="line">reporter.OnToolResult(ctx, item.call.Name, displayOutput, result.IsError)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">observationMsgs[item.index] = schema.Message&#123;</span><br><span class="line">Role:       schema.RoleUser,</span><br><span class="line">Content:    result.Output,</span><br><span class="line">ToolCallID: item.call.ID,</span><br><span class="line">&#125;</span><br><span class="line">toolResults[item.index] = result</span><br><span class="line">&#125;(item)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">wg.Wait()</span><br><span class="line">session.Append(observationMsgs...)</span><br></pre></td></tr></table></figure><p>为什么不能审批和执行一起并发？因为终端 Handler 会从同一个 stdin 读取，多个 Goroutine 会互相抢输入，用户无法知道当前回答对应哪个请求。</p><p>为什么要保留下标？因为工具完成顺序是不确定的，但模型下一轮需要看到与 ToolCall 顺序对应的 Observation。</p><h2 id="九-terminal-handler-只是一个适配器"><a class="markdownIt-Anchor" href="#九-terminal-handler-只是一个适配器"></a> 九、Terminal Handler 只是一个适配器</h2><p><code>internal/approval/terminal_approval.go</code> 当前实现了最小可用的终端交互：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(h *TerminalApprovalHandler)</span></span> Approve(ctx context.Context, request Request) (Decision, <span class="type">error</span>) &#123;</span><br><span class="line">fmt.Fprintf(h.out, <span class="string">&quot;\n需要确认执行工具: %s\n&quot;</span>, request.ToolCall.Name)</span><br><span class="line">fmt.Fprintf(h.out, <span class="string">&quot;参数: %s\n&quot;</span>, request.ToolCall.Arguments)</span><br><span class="line">fmt.Fprint(h.out, <span class="string">&quot;[y]允许本次 [a]本会话允许 [n]拒绝: &quot;</span>)</span><br><span class="line"></span><br><span class="line">inputCh := <span class="built_in">make</span>(<span class="keyword">chan</span> <span class="type">string</span>, <span class="number">1</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">line, _ := h.reader.ReadString(<span class="string">&#x27;\n&#x27;</span>)</span><br><span class="line">inputCh &lt;- strings.TrimSpace(line)</span><br><span class="line">&#125;()</span><br><span class="line"></span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, ctx.Err()</span><br><span class="line"><span class="keyword">case</span> input := &lt;-inputCh:</span><br><span class="line"><span class="keyword">switch</span> strings.ToLower(input) &#123;</span><br><span class="line"><span class="keyword">case</span> <span class="string">&quot;y&quot;</span>:</span><br><span class="line"><span class="keyword">return</span> AllowOnce, <span class="literal">nil</span></span><br><span class="line"><span class="keyword">case</span> <span class="string">&quot;a&quot;</span>:</span><br><span class="line"><span class="keyword">return</span> AllowSession, <span class="literal">nil</span></span><br><span class="line"><span class="keyword">default</span>:</span><br><span class="line"><span class="keyword">return</span> Deny, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>它只实现 <code>Handler</code>，因此 Gate 不关心这是 Terminal、HTTP 还是其他渠道。</p><h2 id="十-maingo-中完成依赖组装"><a class="markdownIt-Anchor" href="#十-maingo-中完成依赖组装"></a> 十、main.go 中完成依赖组装</h2><p>当前 <code>cmd/claw/main.go</code> 使用同一个 Reader 组装审批器和 REPL：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">reader := bufio.NewReader(os.Stdin)</span><br><span class="line"></span><br><span class="line">handler := approval.NewTerminalApprovalHandler(reader, os.Stdout)</span><br><span class="line">grantStore := approval.NewMemoryGrantStore()</span><br><span class="line">gate := approval.NewGate(approval.DefaultPolicy&#123;&#125;, handler, grantStore)</span><br><span class="line"></span><br><span class="line">eng := engine.NewAgentEngine(llmProvider, registry, gate, <span class="literal">false</span>, <span class="literal">false</span>)</span><br><span class="line">reporter := engine.NewTerminalReporter()</span><br><span class="line">sess := ctxpkg.GlobalSessionMgr.GetOrCreate(<span class="string">&quot;terminal_default&quot;</span>, workDir)</span><br><span class="line"></span><br><span class="line">repl := cli.NewREPL(reader, os.Stdout, eng, sess, reporter)</span><br></pre></td></tr></table></figure><p>共享 Reader 很重要。若 REPL 和 Handler 各自对 <code>os.Stdin</code> 创建 <code>bufio.Reader</code>，缓冲区可能分别预读数据，造成输入丢失或错位。</p><h2 id="十一-当前实现的生产级缺口"><a class="markdownIt-Anchor" href="#十一-当前实现的生产级缺口"></a> 十一、当前实现的生产级缺口</h2><h3 id="1-grant-范围过宽"><a class="markdownIt-Anchor" href="#1-grant-范围过宽"></a> 1. Grant 范围过宽</h3><p>当前 key 只有 <code>SessionID + ToolName</code>。用户允许一次 <code>bash</code> 后，同一会话的其他 bash 命令也会命中授权。生产级授权至少应考虑：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">工作区 WorkDir</span><br><span class="line">工具名 ToolName</span><br><span class="line">命令前缀或参数摘要</span><br><span class="line">文件路径范围</span><br><span class="line">创建时间和过期时间</span><br></pre></td></tr></table></figure><h3 id="2-保存-grant-时没有带上过期信息"><a class="markdownIt-Anchor" href="#2-保存-grant-时没有带上过期信息"></a> 2. 保存 Grant 时没有带上过期信息</h3><p>协议定义了 <code>WorkDir</code> 和 <code>ExpiresAt</code>，但当前 Gate 保存时只填了 SessionID 和 ToolName：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">err = g.grants.Save(ctx, Grant&#123;</span><br><span class="line">SessionID: request.SessionID,</span><br><span class="line">ToolName:  request.ToolCall.Name,</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>这意味着目前没有真正的会话授权过期策略，需要补充策略配置。</p><h3 id="3-审批输入-goroutine-可能泄漏"><a class="markdownIt-Anchor" href="#3-审批输入-goroutine-可能泄漏"></a> 3. 审批输入 Goroutine 可能泄漏</h3><p><code>ReadString</code> 不能直接被 Context 取消。中断发生时，Handler 可以返回，但后台读取 Goroutine 仍可能阻塞。后续应设计统一的输入循环或可关闭的输入源。</p><h3 id="4-所有执行路径都要经过-gate"><a class="markdownIt-Anchor" href="#4-所有执行路径都要经过-gate"></a> 4. 所有执行路径都要经过 Gate</h3><p>主 Agent 的工具调用已经经过 <code>approvalGate.Check</code>。但 Subagent 使用传入的只读 Registry 直接执行工具：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">result := readOnlyRegistry.Execute(ctx, call)</span><br></pre></td></tr></table></figure><p>这是有意设计的受限路径，但生产级系统必须明确记录哪些 Registry 是只读的，避免以后新增工具时错误地把写能力挂进去。</p><h3 id="5-gate-需要防御空依赖"><a class="markdownIt-Anchor" href="#5-gate-需要防御空依赖"></a> 5. Gate 需要防御空依赖</h3><p>当前 Engine 直接调用：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">decision, err := e.approvalGate.Check(ctx, req)</span><br></pre></td></tr></table></figure><p>如果某个调用方传入 nil Gate，会发生 panic。生产级构造器应该要求 Gate 非空，或者在 Engine 初始化阶段直接返回配置错误。</p><h2 id="十二-审批测试应该验证什么"><a class="markdownIt-Anchor" href="#十二-审批测试应该验证什么"></a> 十二、审批测试应该验证什么</h2><p>建议先给 Gate 写表驱动测试：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">RiskSafe 自动返回 AllowOnce，不调用 Handler</span><br><span class="line">未知风险返回 Deny</span><br><span class="line">已有 Grant 返回 AllowOnce，不重复询问</span><br><span class="line">Handler 返回 AllowOnce，不写 Grant</span><br><span class="line">Handler 返回 AllowSession，写入 Grant</span><br><span class="line">Handler 返回 Deny，不执行工具</span><br><span class="line">Grant 过期后重新询问</span><br><span class="line">Context 取消能从 Check 返回</span><br></pre></td></tr></table></figure><p>再给 Engine 写集成测试：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">被拒绝的 ToolCall 不会调用 Registry.Execute</span><br><span class="line">批准的多个 ToolCall 可以并发执行</span><br><span class="line">Observation 按原始 ToolCall 顺序写入</span><br><span class="line">工具执行错误仍然会生成 Observation</span><br></pre></td></tr></table></figure><h2 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h2><p>审批功能的核心不是一个 <code>[y/n]</code> 提示，而是一条完整的授权链：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">RiskedTool</span><br><span class="line">    ↓</span><br><span class="line">Policy</span><br><span class="line">    ↓</span><br><span class="line">GrantStore</span><br><span class="line">    ↓</span><br><span class="line">Handler</span><br><span class="line">    ↓</span><br><span class="line">Gate Decision</span><br><span class="line">    ↓</span><br><span class="line">Approved ToolCall</span><br><span class="line">    ↓</span><br><span class="line">Registry.Execute</span><br></pre></td></tr></table></figure><p>到这里，go-tiny-claw 已经具备了一个可扩展的 Terminal Human-in-the-loop 骨架：安全工具可以自动执行，危险工具在执行前请求用户确认，会话授权可以复用，执行仍然保持并发。</p><p>下一步可以继续把工具边界从进程内 Registry 扩展到 MCP：动态发现外部工具、声明工具权限，并把审批策略应用到远程工具调用。</p>]]></content>
    
    
    <summary type="html">从 go-tiny-claw 当前源码出发，设计终端可用、渠道可替换的工具审批协议，并完成先审批后并发执行。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（十二）用 Context 和 Ctrl-C 中断当前任务</title>
    <link href="https://sunra.top/posts/5a8c03/"/>
    <id>https://sunra.top/posts/5a8c03/</id>
    <published>2026-07-17T10:00:00.000Z</published>
    <updated>2026-08-27T01:58:06.617Z</updated>
    
    <content type="html"><![CDATA[<p>连续对话只有在“当前任务可以被停止”时才真正适合 Terminal。Coding Agent 可能正在等待模型响应，也可能正在执行 <code>go test</code>、搜索大量文件或等待用户审批。如果 Ctrl-C 只能等任务自然结束，Terminal 的交互体验仍然是不完整的。</p><p>本文源码版本对应提交：<code>4a9be525a8e0f5ef24014c7c178f7ac8a8704d2b</code>（add interrupt）。</p><p>本篇只讨论中断，不讨论 Unix raw mode 或复杂 TUI。我们先理解当前实现，再指出它距离生产级取消还差哪些边界。</p><span id="more"></span><h2 id="一-ctrl-c-到底应该取消什么"><a class="markdownIt-Anchor" href="#一-ctrl-c-到底应该取消什么"></a> 一、Ctrl-C 到底应该取消什么</h2><p>用户在 Terminal 中按下 Ctrl-C 时，期望的是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">取消当前 Agent Run</span><br><span class="line">保留已经写入的 Session 历史</span><br><span class="line">回到 claw&gt; 提示符</span><br><span class="line">不退出整个程序</span><br></pre></td></tr></table></figure><p>因此中断的作用域应该是一次 <code>runTurn</code>，而不是整个 REPL。当前 <code>REPL.Run</code> 创建了一个信号 Channel：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">signals := <span class="built_in">make</span>(<span class="keyword">chan</span> os.Signal, <span class="number">1</span>)</span><br><span class="line">signal.Notify(signals, os.Interrupt)</span><br><span class="line"><span class="keyword">defer</span> signal.Stop(signals)</span><br></pre></td></tr></table></figure><p><code>os.Interrupt</code> 通常对应用户按下 Ctrl-C。<code>signal.Notify</code> 把信号交给 Go Channel，而不是让进程立即采用默认动作退出。</p><h2 id="二-runturn-是中断边界"><a class="markdownIt-Anchor" href="#二-runturn-是中断边界"></a> 二、runTurn 是中断边界</h2><p>当前 <code>internal/cli/repl.go</code> 中的 <code>runTurn</code> 是关键代码：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *REPL)</span></span> runTurn(</span><br><span class="line">parent context.Context,</span><br><span class="line">signals &lt;-<span class="keyword">chan</span> os.Signal,</span><br><span class="line">) <span class="type">error</span> &#123;</span><br><span class="line">runCtx, cancel := context.WithCancel(parent)</span><br><span class="line"><span class="keyword">defer</span> cancel()</span><br><span class="line"></span><br><span class="line">done := <span class="built_in">make</span>(<span class="keyword">chan</span> <span class="type">error</span>, <span class="number">1</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">done &lt;- r.engine.Run(</span><br><span class="line">runCtx,</span><br><span class="line">r.session,</span><br><span class="line">r.reporter,</span><br><span class="line">)</span><br><span class="line">&#125;()</span><br><span class="line"></span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> err := &lt;-done:</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line"></span><br><span class="line"><span class="keyword">case</span> &lt;-signals:</span><br><span class="line">fmt.Fprintln(r.out, <span class="string">&quot;\n正在取消当前任务...&quot;</span>)</span><br><span class="line">cancel()</span><br><span class="line"></span><br><span class="line">err := &lt;-done</span><br><span class="line"><span class="keyword">if</span> errors.Is(err, context.Canceled) &#123;</span><br><span class="line">fmt.Fprintln(r.out, <span class="string">&quot;当前任务已取消。&quot;</span>)</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这段代码可以拆成三个角色：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">runCtx：当前任务的取消信号</span><br><span class="line">done：Run 完成后返回 error 的结果通道</span><br><span class="line">select：同时等待任务结束和 Ctrl-C</span><br></pre></td></tr></table></figure><h2 id="三-为什么要启动-goroutine"><a class="markdownIt-Anchor" href="#三-为什么要启动-goroutine"></a> 三、为什么要启动 Goroutine</h2><p>如果直接在 REPL 中调用：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">err := r.engine.Run(runCtx, r.session, r.reporter)</span><br></pre></td></tr></table></figure><p>当前 Goroutine 会一直阻塞在 <code>Run</code> 中，无法同时读取 <code>signals</code>。把 <code>Run</code> 放入 Goroutine 后，当前 Goroutine 才能使用 <code>select</code> 等待两个事件：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">done &lt;- r.engine.Run(runCtx, r.session, r.reporter)</span><br><span class="line">&#125;()</span><br><span class="line"></span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> err := &lt;-done:</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line"><span class="keyword">case</span> &lt;-signals:</span><br><span class="line">cancel()</span><br><span class="line"><span class="keyword">return</span> &lt;-done</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>done</code> 使用容量为 1 的 Channel：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">done := <span class="built_in">make</span>(<span class="keyword">chan</span> <span class="type">error</span>, <span class="number">1</span>)</span><br></pre></td></tr></table></figure><p>这样即使主 Goroutine已经先收到 Ctrl-C 并开始取消，Run Goroutine 也能把最终错误写入 <code>done</code>，不会因为消费者暂时没有接收而卡住。</p><p><code>cancel()</code> 不会把 <code>context.Canceled</code> 直接写入 <code>done</code>。<code>done</code> 中的值是 <code>r.engine.Run</code> 的返回值；只有当 Run 内部的下游操作响应取消并逐层返回后，Run 才可能返回 <code>context.Canceled</code> 或包含它的包装错误。</p><h2 id="四-engine-如何感知取消"><a class="markdownIt-Anchor" href="#四-engine-如何感知取消"></a> 四、Engine 如何感知取消</h2><p>流式 Engine 在 <code>internal/engine/stream.go</code> 中同时监听 Context 和事件 Channel：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> &#123;</span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, <span class="literal">false</span>, ctx.Err()</span><br><span class="line"></span><br><span class="line"><span class="keyword">case</span> event, ok := &lt;-events:</span><br><span class="line"><span class="keyword">if</span> !ok &#123;</span><br><span class="line"><span class="keyword">if</span> finalMessage == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, <span class="literal">false</span>, fmt.Errorf(<span class="string">&quot;流式响应未返回最终消息&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> finalMessage, emittedText, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">switch</span> event.Type &#123;</span><br><span class="line"><span class="keyword">case</span> provider.StreamTextDelta:</span><br><span class="line"><span class="keyword">if</span> emitText &amp;&amp; canStream &#123;</span><br><span class="line">streamReporter.OnTextDelta(ctx, event.Text)</span><br><span class="line">emittedText = <span class="literal">true</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">case</span> provider.StreamCompleted:</span><br><span class="line">finalMessage = event.Message</span><br><span class="line"><span class="keyword">case</span> provider.StreamError:</span><br><span class="line"><span class="keyword">if</span> event.Err == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, <span class="literal">false</span>, fmt.Errorf(<span class="string">&quot;流式响应失败&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, <span class="literal">false</span>, event.Err</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>取消后，Engine 不应该继续等待 <code>StreamCompleted</code>。否则用户虽然已经按了 Ctrl-C，UI 却还要等模型流结束。</p><h2 id="五-provider-发送事件时也必须响应-context"><a class="markdownIt-Anchor" href="#五-provider-发送事件时也必须响应-context"></a> 五、Provider 发送事件时也必须响应 Context</h2><p><code>internal/provider/openai.go</code> 没有直接把事件写入 Channel，而是使用：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">sendStreamEvent</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">ctx context.Context,</span></span></span><br><span class="line"><span class="params"><span class="function">events <span class="keyword">chan</span>&lt;- StreamEvent,</span></span></span><br><span class="line"><span class="params"><span class="function">event StreamEvent,</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span> <span class="type">bool</span> &#123;</span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> events &lt;- event:</span><br><span class="line"><span class="keyword">return</span> <span class="literal">true</span></span><br><span class="line"><span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line"><span class="keyword">return</span> <span class="literal">false</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这解决的是“消费者已经退出，但生产者还在发送”的阻塞问题。流式 Goroutine 本身也使用了同一个 Context：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">stream := p.client.Chat.Completions.NewStreaming(ctx, params)</span><br><span class="line"><span class="keyword">defer</span> stream.Close()</span><br></pre></td></tr></table></figure><p>因此取消链路是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">Ctrl-C</span><br><span class="line">  ↓</span><br><span class="line">cancel()</span><br><span class="line">  ↓</span><br><span class="line">runCtx.Done()</span><br><span class="line">  ├── engine.generate 返回 context.Canceled</span><br><span class="line">  ├── Provider HTTP 流结束或关闭</span><br><span class="line">  └── sendStreamEvent 不再阻塞</span><br></pre></td></tr></table></figure><h2 id="六-工具进程如何被取消"><a class="markdownIt-Anchor" href="#六-工具进程如何被取消"></a> 六、工具进程如何被取消</h2><p>模型请求结束后，Agent 可能进入工具执行。以 <code>internal/tools/bash.go</code> 为例：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(t *BashTool)</span></span> Execute(ctx context.Context, args json.RawMessage) (<span class="type">string</span>, <span class="type">error</span>) &#123;</span><br><span class="line"><span class="keyword">var</span> input bashArgs</span><br><span class="line"><span class="keyword">if</span> err := json.Unmarshal(args, &amp;input); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, fmt.Errorf(<span class="string">&quot;参数解析失败: %w&quot;</span>, err)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">timeoutCtx, cancel := context.WithTimeout(ctx, <span class="number">30</span>*time.Second)</span><br><span class="line"><span class="keyword">defer</span> cancel()</span><br><span class="line"></span><br><span class="line">cmd := exec.CommandContext(timeoutCtx, <span class="string">&quot;bash&quot;</span>, <span class="string">&quot;-c&quot;</span>, input.Command)</span><br><span class="line">cmd.Dir = t.workDir</span><br><span class="line"></span><br><span class="line">out, err := cmd.CombinedOutput()</span><br><span class="line">outputStr := <span class="type">string</span>(out)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> timeoutCtx.Err() == context.DeadlineExceeded &#123;</span><br><span class="line"><span class="keyword">return</span> outputStr + <span class="string">&quot;\n[警告: 命令执行超时(30s)，已被系统强制终止。]&quot;</span>, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Sprintf(<span class="string">&quot;执行报错: %v\n输出:\n%s&quot;</span>, err, outputStr), <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> outputStr == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;命令执行成功，无终端输出。&quot;</span>, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> maxLen = <span class="number">8000</span></span><br><span class="line"><span class="keyword">if</span> <span class="built_in">len</span>(outputStr) &gt; maxLen &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Sprintf(<span class="string">&quot;%s\n\n...[终端输出过长，已截断至前 %d 字节]...&quot;</span>, outputStr[:maxLen], maxLen), <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> outputStr, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>exec.CommandContext</code> 会把 Context 的取消传给子进程。这里还额外叠加了 30 秒超时：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">父 Context 取消：用户 Ctrl-C 触发</span><br><span class="line">子 Context 超时：单个 bash 最多执行 30 秒</span><br></pre></td></tr></table></figure><p>二者任何一个发生，命令都不应该继续无限运行。</p><h2 id="七-为什么-errorsis-能识别-contextcanceled"><a class="markdownIt-Anchor" href="#七-为什么-errorsis-能识别-contextcanceled"></a> 七、为什么 <a href="http://errors.Is">errors.Is</a> 能识别 context.Canceled</h2><p>Engine 的错误可能被多层包装，例如：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;Action 阶段失败: %w&quot;</span>, err)</span><br></pre></td></tr></table></figure><p>所以不应该使用字符串比较：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">err.Error() == <span class="string">&quot;context canceled&quot;</span></span><br></pre></td></tr></table></figure><p>当前 REPL 使用：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> errors.Is(err, context.Canceled) &#123;</span><br><span class="line">fmt.Fprintln(r.out, <span class="string">&quot;当前任务已取消。&quot;</span>)</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>errors.Is</code> 会沿着 <code>%w</code> 包装链检查原始错误，因此即使 Run 返回的是包装后的错误，也能判断它是否由取消导致。</p><h2 id="八-当前-terminal-approval-的取消边界"><a class="markdownIt-Anchor" href="#八-当前-terminal-approval-的取消边界"></a> 八、当前 Terminal Approval 的取消边界</h2><p>审批 Handler 当前通过 Goroutine 等待输入：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">inputCh := <span class="built_in">make</span>(<span class="keyword">chan</span> <span class="type">string</span>, <span class="number">1</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">line, _ := h.reader.ReadString(<span class="string">&#x27;\n&#x27;</span>)</span><br><span class="line">inputCh &lt;- strings.TrimSpace(line)</span><br><span class="line">&#125;()</span><br><span class="line"></span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, ctx.Err()</span><br><span class="line"><span class="keyword">case</span> input := &lt;-inputCh:</span><br><span class="line"><span class="comment">// 解析 y、a、n。</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>主流程可以及时返回取消，但 <code>ReadString</code> 本身未必会因为 Context 取消而停止。这个输入 Goroutine 可能继续占用共享 Reader，后续用户输入还可能被它消费掉。</p><p>这说明“函数返回了 context.Canceled”不等于“所有 Goroutine 都已经退出”。生产级实现需要让输入层本身可取消，或者由专门的 Terminal 输入循环统一读取，再通过内部请求 Channel 分发审批答案。</p><h2 id="九-第一版实现的边界和改进方向"><a class="markdownIt-Anchor" href="#九-第一版实现的边界和改进方向"></a> 九、第一版实现的边界和改进方向</h2><p>当前实现适合学习 Context 传播，但还应该继续补齐：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">记录当前任务状态，避免取消后误显示成功</span><br><span class="line">区分用户取消、Provider 超时、工具超时和进程异常</span><br><span class="line">保证取消后的工具结果不会覆盖错误状态</span><br><span class="line">处理多个 Ctrl-C 的行为</span><br><span class="line">避免审批输入 Goroutine 泄漏</span><br><span class="line">为 Provider、Engine、Tool 分别增加取消测试</span><br></pre></td></tr></table></figure><p>还要注意一个事实：普通 <code>bufio.Reader.ReadString</code> 并不是可取消的。若要像成熟 CLI 一样处理输入，需要把 stdin 读取和 Agent 执行解耦，或者使用支持终端事件的输入层。</p><h2 id="十-验证中断功能"><a class="markdownIt-Anchor" href="#十-验证中断功能"></a> 十、验证中断功能</h2><p>至少做四个实验：</p><ol><li>在模型流式输出过程中按 Ctrl-C，确认 Run 返回且能再次看到 <code>claw&gt;</code>。</li><li>执行一个超过 30 秒的 bash 命令，确认 Ctrl-C 能停止命令。</li><li>在审批提示处按 Ctrl-C，确认当前任务退出等待。</li><li>取消后输入下一条任务，确认 Session 没有被锁死、REPL 还能继续工作。</li></ol><p>中断的本质不是监听一个信号，而是让每个可阻塞组件都遵守同一个取消协议。下一篇将把工具执行前的人类确认设计成可复用的 Approval Gate。</p>]]></content>
    
    
    <summary type="html">逐层分析 go-tiny-claw 如何监听 os.Interrupt，并通过 Context 取消模型流、工具进程和审批等待。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（十一）REPL 与流式输出</title>
    <link href="https://sunra.top/posts/5a8c02/"/>
    <id>https://sunra.top/posts/5a8c02/</id>
    <published>2026-07-17T09:30:00.000Z</published>
    <updated>2026-08-27T01:58:06.617Z</updated>
    
    <content type="html"><![CDATA[<p>上一讲把连续 Terminal 拆成了四个阶段。本篇实现第一阶段：REPL 负责持续读取用户输入，Provider 负责读取模型流，Engine 负责消费事件，Reporter 负责把文本 Delta 实时显示到终端。</p><p>本文源码版本对应后续提交：<code>fb7d7450c0fa9d4d096a06f77e609c4320a55b4f</code>（REPL）和 <code>02dd5b944952fe7a9369f7e6877c4fef4b0881c9</code>（Stream Provider）。</p><p>这里有一个重要的架构原则：流式输出是展示协议，不是工具执行协议。文本可以边生成边显示；ToolCall 必须等参数完整后才交给 Engine。</p><span id="more"></span><h2 id="一-先定义-provider-的流式协议"><a class="markdownIt-Anchor" href="#一-先定义-provider-的流式协议"></a> 一、先定义 Provider 的流式协议</h2><p><code>internal/provider/interface.go</code> 中，非流式接口仍然保留：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> LLMProvider <span class="keyword">interface</span> &#123;</span><br><span class="line">Generate(ctx context.Context, messages []schema.Message, availableTools []schema.ToolDefinition) (*schema.Message, <span class="type">error</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>流式能力通过额外接口表示，而不是修改所有 Provider 的 <code>Generate</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> StreamEventType <span class="type">string</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> (</span><br><span class="line">StreamTextDelta StreamEventType = <span class="string">&quot;text_delta&quot;</span></span><br><span class="line">StreamCompleted StreamEventType = <span class="string">&quot;completed&quot;</span></span><br><span class="line">StreamError     StreamEventType = <span class="string">&quot;error&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> StreamEvent <span class="keyword">struct</span> &#123;</span><br><span class="line">Type    StreamEventType</span><br><span class="line">Text    <span class="type">string</span></span><br><span class="line">Message *schema.Message</span><br><span class="line">Usage   *schema.Usage</span><br><span class="line">Err     <span class="type">error</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> StreamingProvider <span class="keyword">interface</span> &#123;</span><br><span class="line">LLMProvider</span><br><span class="line">GenerateStream(ctx context.Context, messages []schema.Message, tools []schema.ToolDefinition) (&lt;-<span class="keyword">chan</span> StreamEvent, <span class="type">error</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>三个事件的含义不同：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">StreamTextDelta  一小段可以立即展示的文本</span><br><span class="line">StreamCompleted  完整 Assistant Message，包含完整 ToolCall</span><br><span class="line">StreamError      Provider 或流解析失败</span><br></pre></td></tr></table></figure><p><code>StreamEvent.Message</code> 不应该在每个 Delta 中反复携带。最终消息只在 <code>StreamCompleted</code> 中出现，避免 Engine 把半成品消息写入 Session。</p><h2 id="二-openai-兼容-provider-如何生产事件"><a class="markdownIt-Anchor" href="#二-openai-兼容-provider-如何生产事件"></a> 二、OpenAI 兼容 Provider 如何生产事件</h2><p>当前实现位于 <code>internal/provider/openai.go</code>。它先复用已有的参数构造逻辑，再打开 SDK 的流式请求：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(p *OpenAIProvider)</span></span> GenerateStream(</span><br><span class="line">ctx context.Context,</span><br><span class="line">msgs []schema.Message,</span><br><span class="line">availableTools []schema.ToolDefinition,</span><br><span class="line">) (&lt;-<span class="keyword">chan</span> StreamEvent, <span class="type">error</span>) &#123;</span><br><span class="line">params, err := p.buildParams(msgs, availableTools)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">params.StreamOptions.IncludeUsage = openai.Bool(<span class="literal">true</span>)</span><br><span class="line"></span><br><span class="line">events := <span class="built_in">make</span>(<span class="keyword">chan</span> StreamEvent, <span class="number">16</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line"><span class="keyword">defer</span> <span class="built_in">close</span>(events)</span><br><span class="line"></span><br><span class="line">stream := p.client.Chat.Completions.NewStreaming(ctx, params)</span><br><span class="line"><span class="keyword">defer</span> stream.Close()</span><br><span class="line"></span><br><span class="line"><span class="keyword">var</span> content strings.Builder</span><br><span class="line">toolCalls := <span class="built_in">make</span>([]*toolCallAccumulator, <span class="number">0</span>)</span><br><span class="line"><span class="keyword">var</span> usage *schema.Usage</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> stream.Next() &#123;</span><br><span class="line">chunk := stream.Current()</span><br><span class="line"><span class="comment">// 继续处理 chunk。</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;()</span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> events, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这里的 <code>events</code> 是一个带缓冲的 Channel。Provider Goroutine 是生产者，Engine 是消费者。缓冲区大小为 16，意味着短时间内生产者可以先放入少量事件，不必每次都等待消费者。</p><p>生产事件时使用当前源码中的 <code>sendStreamEvent</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">sendStreamEvent</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">ctx context.Context,</span></span></span><br><span class="line"><span class="params"><span class="function">events <span class="keyword">chan</span>&lt;- StreamEvent,</span></span></span><br><span class="line"><span class="params"><span class="function">event StreamEvent,</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span> <span class="type">bool</span> &#123;</span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> events &lt;- event:</span><br><span class="line"><span class="keyword">return</span> <span class="literal">true</span></span><br><span class="line"><span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line"><span class="keyword">return</span> <span class="literal">false</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这比直接写 <code>events &lt;- event</code> 更安全。假如 Engine 因为 Ctrl-C 已经停止消费，Provider 不会永久阻塞在发送操作上，而是从 <code>ctx.Done()</code> 分支退出。</p><h2 id="三-文本-delta-可以立即展示"><a class="markdownIt-Anchor" href="#三-文本-delta-可以立即展示"></a> 三、文本 Delta 可以立即展示</h2><p>当前 Provider 对文本 Delta 的处理是：一边累积完整内容，一边发送当前片段。</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> delta.Content != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">content.WriteString(delta.Content)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> !sendStreamEvent(ctx, events, StreamEvent&#123;</span><br><span class="line">Type: StreamTextDelta,</span><br><span class="line">Text: delta.Content,</span><br><span class="line">&#125;) &#123;</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>content.WriteString(delta.Content)</code> 不能省略。发送给 Terminal 的 Delta 只是 UI 用的增量；后续要写入 <code>schema.Message.Content</code> 的完整答案仍然需要由 Provider 自己拼起来。</p><p>最终消息使用：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">finalMessage := &amp;schema.Message&#123;</span><br><span class="line">Role:    schema.RoleAssistant,</span><br><span class="line">Content: content.String(),</span><br><span class="line">Usage:   usage,</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>如果只把 Delta 发给 Reporter，却不在 Provider 内累计，Engine 最终就拿不到完整内容，下一轮上下文会丢失模型回答。</p><h2 id="四-toolcall-为什么不能直接流式执行"><a class="markdownIt-Anchor" href="#四-toolcall-为什么不能直接流式执行"></a> 四、ToolCall 为什么不能直接流式执行</h2><p>模型返回 ToolCall 时，名称、ID 和参数都可能分散在多个 Chunk 中。当前代码为每个 ToolCall 准备一个累加器：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> toolCallAccumulator <span class="keyword">struct</span> &#123;</span><br><span class="line">id        <span class="type">string</span></span><br><span class="line">name      <span class="type">string</span></span><br><span class="line">arguments strings.Builder</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>在每个 Delta 中，根据模型给出的 Index 找到对应累加器：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> _, deltaToolCall := <span class="keyword">range</span> delta.ToolCalls &#123;</span><br><span class="line">index := <span class="type">int</span>(deltaToolCall.Index)</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> <span class="built_in">len</span>(toolCalls) &lt;= index &#123;</span><br><span class="line">toolCalls = <span class="built_in">append</span>(toolCalls, <span class="literal">nil</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> toolCalls[index] == <span class="literal">nil</span> &#123;</span><br><span class="line">toolCalls[index] = &amp;toolCallAccumulator&#123;&#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">accumulator := toolCalls[index]</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> deltaToolCall.ID != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">accumulator.id = deltaToolCall.ID</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> deltaToolCall.Function.Name != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">accumulator.name = deltaToolCall.Function.Name</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> deltaToolCall.Function.Arguments != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">accumulator.arguments.WriteString(</span><br><span class="line">deltaToolCall.Function.Arguments,</span><br><span class="line">)</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>参数片段可能是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">&#123;&quot;com</span><br><span class="line">mand&quot;:&quot;go test</span><br><span class="line"> ./...&quot;&#125;</span><br></pre></td></tr></table></figure><p>每一段单独拿出来都不是合法 JSON，所以不能在收到第一段时调用工具。流结束后再转换为正式的 <code>schema.ToolCall</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> _, accumulator := <span class="keyword">range</span> toolCalls &#123;</span><br><span class="line"><span class="keyword">if</span> accumulator == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">arguments := accumulator.arguments.String()</span><br><span class="line"><span class="keyword">if</span> arguments == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">arguments = <span class="string">&quot;&#123;&#125;&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> !json.Valid([]<span class="type">byte</span>(arguments)) &#123;</span><br><span class="line">sendStreamEvent(ctx, events, StreamEvent&#123;</span><br><span class="line">Type: StreamError,</span><br><span class="line">Err: fmt.Errorf(</span><br><span class="line"><span class="string">&quot;工具 %s 返回非法 JSON 参数: %s&quot;</span>,</span><br><span class="line">accumulator.name,</span><br><span class="line">arguments,</span><br><span class="line">),</span><br><span class="line">&#125;)</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">finalMessage.ToolCalls = <span class="built_in">append</span>(finalMessage.ToolCalls, schema.ToolCall&#123;</span><br><span class="line">ID:        accumulator.id,</span><br><span class="line">Name:      accumulator.name,</span><br><span class="line">Arguments: json.RawMessage(arguments),</span><br><span class="line">&#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="五-结束-错误和-usage-事件"><a class="markdownIt-Anchor" href="#五-结束-错误和-usage-事件"></a> 五、结束、错误和 Usage 事件</h2><p>当前实现从流 Chunk 中提取 Usage：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> chunk.Usage.PromptTokens &gt; <span class="number">0</span> ||</span><br><span class="line">chunk.Usage.CompletionTokens &gt; <span class="number">0</span> &#123;</span><br><span class="line">usage = &amp;schema.Usage&#123;</span><br><span class="line">PromptTokens:     <span class="type">int</span>(chunk.Usage.PromptTokens),</span><br><span class="line">CompletionTokens: <span class="type">int</span>(chunk.Usage.CompletionTokens),</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>底层流结束后必须检查 <code>stream.Err()</code>：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> err := stream.Err(); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">if</span> !sendStreamEvent(ctx, events, StreamEvent&#123;</span><br><span class="line">Type: StreamError,</span><br><span class="line">Err:  fmt.Errorf(<span class="string">&quot;流式响应失败: %w&quot;</span>, err),</span><br><span class="line">&#125;) &#123;</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>没有错误时发送最终事件：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">sendStreamEvent(ctx, events, StreamEvent&#123;</span><br><span class="line">Type:    StreamCompleted,</span><br><span class="line">Message: finalMessage,</span><br><span class="line">Usage:   usage,</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>这里要区分“Channel 关闭”和“任务完成事件”：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">StreamCompleted：Provider 已经构造出最终消息</span><br><span class="line">close(events)：生产者不会再发送任何事件</span><br></pre></td></tr></table></figure><p>二者都需要。Engine 可能先收到 Completed，再收到 Channel 关闭；如果 Channel 直接关闭却没有最终消息，Engine 应该报错，而不是静默结束。</p><h2 id="六-engine-如何消费流"><a class="markdownIt-Anchor" href="#六-engine-如何消费流"></a> 六、Engine 如何消费流</h2><p><code>internal/engine/stream.go</code> 用类型断言兼容不支持流式的 Provider：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">streamProvider, ok := e.provider.(provider.StreamingProvider)</span><br><span class="line"><span class="keyword">if</span> !ok &#123;</span><br><span class="line">message, err := e.provider.Generate(ctx, messages, tools)</span><br><span class="line"><span class="keyword">return</span> message, <span class="literal">false</span>, err</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>支持流式时，Engine 监听 Context 和事件 Channel：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br></pre></td><td class="code"><pre><span class="line">events, err := streamProvider.GenerateStream(ctx, messages, tools)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, <span class="literal">false</span>, err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">streamReporter, canStream := reporter.(StreamReporter)</span><br><span class="line">emittedText := <span class="literal">false</span></span><br><span class="line"><span class="keyword">var</span> finalMessage *schema.Message</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> &#123;</span><br><span class="line"><span class="keyword">select</span> &#123;</span><br><span class="line"><span class="keyword">case</span> &lt;-ctx.Done():</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, <span class="literal">false</span>, ctx.Err()</span><br><span class="line"></span><br><span class="line"><span class="keyword">case</span> event, ok := &lt;-events:</span><br><span class="line"><span class="keyword">if</span> !ok &#123;</span><br><span class="line"><span class="keyword">if</span> finalMessage == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, <span class="literal">false</span>, fmt.Errorf(<span class="string">&quot;流式响应未返回最终消息&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> finalMessage, emittedText, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">switch</span> event.Type &#123;</span><br><span class="line"><span class="keyword">case</span> provider.StreamTextDelta:</span><br><span class="line"><span class="keyword">if</span> emitText &amp;&amp; canStream &#123;</span><br><span class="line">streamReporter.OnTextDelta(ctx, event.Text)</span><br><span class="line">emittedText = <span class="literal">true</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">case</span> provider.StreamCompleted:</span><br><span class="line">finalMessage = event.Message</span><br><span class="line"><span class="keyword">if</span> emitText &amp;&amp; canStream &#123;</span><br><span class="line">streamReporter.OnTextComplete(ctx)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">case</span> provider.StreamError:</span><br><span class="line"><span class="keyword">if</span> event.Err == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, <span class="literal">false</span>, fmt.Errorf(<span class="string">&quot;流式响应失败&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, <span class="literal">false</span>, event.Err</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>StreamReporter</code> 只关心文本展示：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> StreamReporter <span class="keyword">interface</span> &#123;</span><br><span class="line">OnTextDelta(ctx context.Context, delta <span class="type">string</span>)</span><br><span class="line">OnTextComplete(ctx context.Context)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>ToolCall 不放到这里，因为它不是“已经可以展示的普通文本”，而是 Engine 下一步状态转移的控制数据。工具调用仍然在 <code>StreamCompleted</code> 携带的最终消息里进入审批和执行。</p><h2 id="七-repl-负责把-run-串起来"><a class="markdownIt-Anchor" href="#七-repl-负责把-run-串起来"></a> 七、REPL 负责把 Run 串起来</h2><p><code>internal/cli/repl.go</code> 的主体是一个明确的输入循环：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *REPL)</span></span> Run(ctx context.Context) <span class="type">error</span> &#123;</span><br><span class="line">signals := <span class="built_in">make</span>(<span class="keyword">chan</span> os.Signal, <span class="number">1</span>)</span><br><span class="line">signal.Notify(signals, os.Interrupt)</span><br><span class="line"><span class="keyword">defer</span> signal.Stop(signals)</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> &#123;</span><br><span class="line">fmt.Fprint(r.out, <span class="string">&quot;\nclaw&gt;&quot;</span>)</span><br><span class="line"></span><br><span class="line">line, err := r.reader.ReadString(<span class="string">&#x27;\n&#x27;</span>)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">if</span> errors.Is(err, io.EOF) &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">prompt := strings.TrimSpace(line)</span><br><span class="line"><span class="keyword">if</span> prompt == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">switch</span> prompt &#123;</span><br><span class="line"><span class="keyword">case</span> <span class="string">&quot;/exit&quot;</span>, <span class="string">&quot;/quit&quot;</span>:</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line"><span class="keyword">case</span> <span class="string">&quot;/clear&quot;</span>:</span><br><span class="line">r.session.Clear()</span><br><span class="line">fmt.Fprintln(r.out, <span class="string">&quot;会话已清空。&quot;</span>)</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line"><span class="keyword">case</span> <span class="string">&quot;/help&quot;</span>:</span><br><span class="line">printHelp(r.out)</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">r.session.Append(schema.Message&#123;</span><br><span class="line">Role:    schema.RoleUser,</span><br><span class="line">Content: prompt,</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> err := r.runTurn(ctx, signals); err != <span class="literal">nil</span> &#123;</span><br><span class="line">fmt.Fprintf(r.out, <span class="string">&quot;引擎运行出错: %v\n&quot;</span>, err)</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>同一个 <code>Session</code> 被反复传入 <code>Run</code>，因此 <code>/clear</code> 清理的是上下文而不是重新创建一套 Engine。中断的 <code>runTurn</code> 细节留到下一篇讲。</p><h2 id="八-terminal-reporter-的职责"><a class="markdownIt-Anchor" href="#八-terminal-reporter-的职责"></a> 八、Terminal Reporter 的职责</h2><p>当前 <code>TerminalReporter</code> 对文本增量只做一件事：直接写到 stdout。</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *TerminalReporter)</span></span> OnTextDelta(ctx context.Context, delta <span class="type">string</span>) &#123;</span><br><span class="line">fmt.Printf(<span class="string">&quot;%s&quot;</span>, delta)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *TerminalReporter)</span></span> OnTextComplete(ctx context.Context) &#123;</span><br><span class="line">fmt.Print(<span class="string">&quot;\n\n&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>工具状态和普通消息仍然有独立回调：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *TerminalReporter)</span></span> OnToolCall(ctx context.Context, toolName <span class="type">string</span>, args <span class="type">string</span>) &#123;</span><br><span class="line">fmt.Printf(<span class="string">&quot;[🛠️ 调用工具] %s\n&quot;</span>, toolName)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *TerminalReporter)</span></span> OnToolResult(ctx context.Context, toolName <span class="type">string</span>, result <span class="type">string</span>, isError <span class="type">bool</span>) &#123;</span><br><span class="line"><span class="keyword">if</span> isError &#123;</span><br><span class="line">fmt.Printf(<span class="string">&quot;[❌ 执行失败] %s\n&quot;</span>, toolName)</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line">fmt.Printf(<span class="string">&quot;[✅ 执行成功] %s\n&quot;</span>, toolName)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Reporter 这样设计后，换成 WebSocket、飞书或测试用的内存 Reporter，不需要改 Provider 和 Engine。</p><h2 id="九-这一篇的验证清单"><a class="markdownIt-Anchor" href="#九-这一篇的验证清单"></a> 九、这一篇的验证清单</h2><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">不支持 StreamingProvider 时仍能走 Generate</span><br><span class="line">支持流式时文本 Delta 能实时显示</span><br><span class="line">多个 ToolCall 的参数能按 Index 聚合</span><br><span class="line">非法 JSON 参数会产生 StreamError</span><br><span class="line">流结束但没有 Completed 会报错</span><br><span class="line">Provider 发送事件时能响应 ctx.Done()</span><br><span class="line">Session 中保存的是完整 Assistant Message</span><br><span class="line">Reporter 不负责执行工具</span><br></pre></td></tr></table></figure><p>下一篇继续处理用户体验中最重要的控制能力：如何让 Ctrl-C 取消当前任务，而不是直接杀掉整个 Terminal 进程。</p>]]></content>
    
    
    <summary type="html">基于 go-tiny-claw 当前源码，实现连续 Terminal 的 REPL、流式事件协议、ToolCall 聚合和实时输出。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（十）从当前基线到生产级 Harness 的完整路线</title>
    <link href="https://sunra.top/posts/5a8c01/"/>
    <id>https://sunra.top/posts/5a8c01/</id>
    <published>2026-07-17T09:00:00.000Z</published>
    <updated>2026-08-27T01:58:06.617Z</updated>
    
    <content type="html"><![CDATA[<p>最终用户应该可以这样使用它：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">$ claw</span><br><span class="line"></span><br><span class="line">claw&gt; 帮我检查这个项目有哪些编译错误</span><br><span class="line">实时看到 Agent 的回复和工具调用</span><br><span class="line">执行命令或修改文件前得到明确确认</span><br><span class="line">任务很长时可以按 Ctrl-C 取消</span><br><span class="line"></span><br><span class="line">claw&gt; 把刚才发现的问题修好</span><br><span class="line">继续复用上一轮上下文</span><br><span class="line">修改完成后回到下一个 claw&gt; 提示符</span><br></pre></td></tr></table></figure><p>这不是单个功能，而是一条有依赖关系的工程路线：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">Runtime 边界</span><br><span class="line">    ↓</span><br><span class="line">连续对话</span><br><span class="line">    ↓</span><br><span class="line">流式事件</span><br><span class="line">    ↓</span><br><span class="line">全链路取消</span><br><span class="line">    ↓</span><br><span class="line">工具审批</span><br><span class="line">    ↓</span><br><span class="line">持久化、可靠性、协议、评测和部署</span><br></pre></td></tr></table></figure><p>本篇只描述从当前基线继续往前的未来计划，不重复前面已经完成的基础能力。后续系列文章再按照本文的阶段逐步实现。</p><span id="more"></span><h2 id="一-当前基线和最终目标"><a class="markdownIt-Anchor" href="#一-当前基线和最终目标"></a> 一、当前基线和最终目标</h2><p>在这个提交中，入口程序仍然是一次性任务：创建 Provider、Registry、Engine 和 Session，追加一条固定 Prompt，然后调用一次 <code>Run</code>。</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">sess.Append(schema.Message&#123;Role: schema.RoleUser, Content: prompt&#125;)</span><br><span class="line"></span><br><span class="line">err := eng.Run(context.Background(), sess, reporter)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">log.Fatalf(<span class="string">&quot;引擎崩溃: %v&quot;</span>, err)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这个入口可以验证 Agent Loop、工具并发和 Trace，但不能承载长期交互。目标不是把所有逻辑塞进 <code>main.go</code>，而是形成以下运行时分层：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">Terminal / HTTP / 其他渠道</span><br><span class="line">    ↓ 输入、展示、审批回答</span><br><span class="line">Runtime</span><br><span class="line">    ↓ Session、Run 生命周期、取消</span><br><span class="line">Agent Engine</span><br><span class="line">    ↓ Thinking / Action / Tool / Observation</span><br><span class="line">Provider Adapter</span><br><span class="line">    ↓ 同步或流式模型调用</span><br><span class="line">Approval Layer</span><br><span class="line">    ↓ Policy / Grant / Handler</span><br><span class="line">Tool Registry</span><br><span class="line">    ↓ 工具发现、风险声明、中间件和执行</span><br><span class="line">Persistence / Observability</span><br><span class="line">    ↓ Session、Grant、Trace、Usage、审计</span><br></pre></td></tr></table></figure><p>层之间必须保持单向依赖：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Terminal 改变，不修改 Engine</span><br><span class="line">Provider 改变，不修改 Approval</span><br><span class="line">Approval 渠道改变，不修改 Tool</span><br><span class="line">工具增加，不修改 REPL</span><br><span class="line">Session 存储改变，不修改 Reporter</span><br></pre></td></tr></table></figure><h2 id="二-目标运行时状态机"><a class="markdownIt-Anchor" href="#二-目标运行时状态机"></a> 二、目标运行时状态机</h2><p>一条用户任务的完整生命周期是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">Idle</span><br><span class="line">  ↓ 用户提交 Prompt</span><br><span class="line">Preparing</span><br><span class="line">  ↓ 读取 Session、压缩上下文、构造请求</span><br><span class="line">WaitingResponse</span><br><span class="line">  ↓ 模型返回完整 Assistant Message</span><br><span class="line">NoToolCall ─────────────────────────→ Completed</span><br><span class="line">  ↓ 有 ToolCall</span><br><span class="line">WaitingApproval</span><br><span class="line">  ├── Denied ────────────────────────→ Observation → Next Turn</span><br><span class="line">  └── Allowed</span><br><span class="line">          ↓</span><br><span class="line">      ExecutingTools</span><br><span class="line">          ↓</span><br><span class="line">      AppendObservation → Next Turn</span><br></pre></td></tr></table></figure><p>Ctrl-C 可以从 <code>Preparing</code>、<code>WaitingResponse</code>、<code>WaitingApproval</code> 和 <code>ExecutingTools</code> 进入 <code>Cancelled</code>：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Cancelled</span><br><span class="line">    ↓</span><br><span class="line">当前 Run 返回</span><br><span class="line">    ↓</span><br><span class="line">Session 保留一致状态</span><br><span class="line">    ↓</span><br><span class="line">Runtime 回到 Idle</span><br></pre></td></tr></table></figure><p>这要求 Runtime 和 Engine 的职责严格分离：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">Runtime：管理用户和任务生命周期</span><br><span class="line">Engine：完成一条 Agent 任务</span><br><span class="line">Provider：适配模型协议</span><br><span class="line">Approval：决定工具是否可以执行</span><br><span class="line">Registry：执行已经获准的工具</span><br></pre></td></tr></table></figure><h2 id="三-未来阶段一建立-runtime-边界"><a class="markdownIt-Anchor" href="#三-未来阶段一建立-runtime-边界"></a> 三、未来阶段一：建立 Runtime 边界</h2><p>第一步不是马上实现 REPL，而是先明确“一条任务”和“长期运行程序”的边界。</p><p>当前 Engine 的入口已经是可复用形态：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(e *AgentEngine)</span></span> Run(</span><br><span class="line">ctx context.Context,</span><br><span class="line">session *ctxpkg.Session,</span><br><span class="line">reporter Reporter,</span><br><span class="line">) <span class="type">error</span></span><br></pre></td></tr></table></figure><p>目标关系是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Runtime Loop</span><br><span class="line">    ├── 读取一条用户输入</span><br><span class="line">    ├── 写入 Session</span><br><span class="line">    ├── 创建本次 Run Context</span><br><span class="line">    ├── 调用 AgentEngine.Run</span><br><span class="line">    ├── 等待完成、失败或取消</span><br><span class="line">    └── 回到下一次输入</span><br></pre></td></tr></table></figure><p>本阶段需要完成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">把用户输入从 main.go 移出</span><br><span class="line">让 Engine 不读取 stdin</span><br><span class="line">让 Runtime 不解析模型 SDK Chunk</span><br><span class="line">让 Reporter 成为唯一展示出口</span><br><span class="line">让同一个 Session 支持多次 Run</span><br></pre></td></tr></table></figure><p>验收标准：可以在不修改 Engine 核心循环的情况下，替换 Terminal 为另一个输入渠道。</p><h2 id="四-未来阶段二连续对话-repl"><a class="markdownIt-Anchor" href="#四-未来阶段二连续对话-repl"></a> 四、未来阶段二：连续对话 REPL</h2><p>Runtime 的第一种实现是 Terminal REPL：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">打印 claw&gt;</span><br><span class="line">    ↓</span><br><span class="line">读取一行 Prompt</span><br><span class="line">    ↓</span><br><span class="line">处理本地命令</span><br><span class="line">    ↓</span><br><span class="line">session.Append(UserMessage)</span><br><span class="line">    ↓</span><br><span class="line">engine.Run(...)</span><br><span class="line">    ↓</span><br><span class="line">回到 claw&gt;</span><br></pre></td></tr></table></figure><p>计划新增 <code>internal/cli/repl.go</code>，支持：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">/help   查看帮助</span><br><span class="line">/clear  清空 Session 历史</span><br><span class="line">/exit   退出程序</span><br><span class="line">/quit   退出程序</span><br></pre></td></tr></table></figure><p>Session 必须跨轮复用：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">用户输入 A → Append(User A) → Run(session)</span><br><span class="line">用户输入 B → Append(User B) → Run(session)</span><br><span class="line">用户输入 C → Append(User C) → Run(session)</span><br></pre></td></tr></table></figure><p><code>/clear</code> 只清理对话状态，不重建 Provider、Registry 和 Engine。REPL 还要区分三类错误：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">用户退出：正常结束</span><br><span class="line">当前任务取消：回到 claw&gt;</span><br><span class="line">Engine 崩溃或配置错误：显示错误并决定是否继续</span><br></pre></td></tr></table></figure><p>该阶段对应的后续实现提交是 <code>fb7d7450c0fa9d4d096a06f77e609c4320a55b4f</code>。</p><h2 id="五-未来阶段三增加流式事件协议"><a class="markdownIt-Anchor" href="#五-未来阶段三增加流式事件协议"></a> 五、未来阶段三：增加流式事件协议</h2><p>基线的 Provider 只有完整响应接口：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> LLMProvider <span class="keyword">interface</span> &#123;</span><br><span class="line">Generate(ctx context.Context, messages []schema.Message, availableTools []schema.ToolDefinition) (*schema.Message, <span class="type">error</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>目标是在保留同步能力的同时，增加可选的流式接口：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> StreamEventType <span class="type">string</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> (</span><br><span class="line">StreamTextDelta StreamEventType = <span class="string">&quot;text_delta&quot;</span></span><br><span class="line">StreamCompleted StreamEventType = <span class="string">&quot;completed&quot;</span></span><br><span class="line">StreamError     StreamEventType = <span class="string">&quot;error&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> StreamEvent <span class="keyword">struct</span> &#123;</span><br><span class="line">Type    StreamEventType</span><br><span class="line">Text    <span class="type">string</span></span><br><span class="line">Message *schema.Message</span><br><span class="line">Usage   *schema.Usage</span><br><span class="line">Err     <span class="type">error</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> StreamingProvider <span class="keyword">interface</span> &#123;</span><br><span class="line">LLMProvider</span><br><span class="line">GenerateStream(ctx context.Context, messages []schema.Message, tools []schema.ToolDefinition) (&lt;-<span class="keyword">chan</span> StreamEvent, <span class="type">error</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>三层职责必须分开：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Provider：SDK Chunk → StreamEvent</span><br><span class="line">Engine：StreamEvent → Agent 状态变化</span><br><span class="line">Reporter：StreamEvent → Terminal 展示</span><br></pre></td></tr></table></figure><p>Channel 只负责 Provider 和 Engine 之间的事件传递，Provider 不应该直接调用 <code>fmt.Printf</code>。</p><p>该阶段对应的后续实现提交是 <code>02dd5b944952fe7a9369f7e6877c4fef4b0881c9</code>。</p><h2 id="六-未来阶段四聚合流式-toolcall"><a class="markdownIt-Anchor" href="#六-未来阶段四聚合流式-toolcall"></a> 六、未来阶段四：聚合流式 ToolCall</h2><p>文本 Delta 可以立即输出，但 ToolCall 参数通常是分片 JSON：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">&#123;&quot;com</span><br><span class="line">mand&quot;:&quot;go test</span><br><span class="line"> ./...&quot;&#125;</span><br></pre></td></tr></table></figure><p>所以不能把每个 Delta 直接交给工具执行。Provider 要按 ToolCall Index 聚合：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">ToolCall Delta</span><br><span class="line">    ├── ID</span><br><span class="line">    ├── Function Name</span><br><span class="line">    └── Function Arguments 分片</span><br><span class="line">             ↓</span><br><span class="line">      ToolCallAccumulator</span><br><span class="line">             ↓</span><br><span class="line">      JSON 校验</span><br><span class="line">             ↓</span><br><span class="line">      schema.ToolCall</span><br></pre></td></tr></table></figure><p>完成条件：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">多个 ToolCall 的参数不互相串联</span><br><span class="line">空参数可以安全转换为 &#123;&#125;</span><br><span class="line">非法 JSON 转换为 StreamError</span><br><span class="line">只有 Completed 后才进入 Engine Tool 阶段</span><br></pre></td></tr></table></figure><p>流式文本和最终上下文必须分开：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Text Delta       → 立即展示</span><br><span class="line">完整 Message     → 写入 Session</span><br><span class="line">ToolCall         → 进入审批和执行</span><br></pre></td></tr></table></figure><h2 id="七-未来阶段五实现-ctrl-c-全链路取消"><a class="markdownIt-Anchor" href="#七-未来阶段五实现-ctrl-c-全链路取消"></a> 七、未来阶段五：实现 Ctrl-C 全链路取消</h2><p>REPL 为每条任务创建独立的 <code>runCtx</code>：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">os.Interrupt</span><br><span class="line">    ↓ cancel()</span><br><span class="line">runCtx.Done()</span><br><span class="line">    ├── Runtime 停止等待当前 Run</span><br><span class="line">    ├── Engine 停止等待 StreamEvent</span><br><span class="line">    ├── Provider 关闭 HTTP Stream</span><br><span class="line">    ├── sendStreamEvent 停止阻塞</span><br><span class="line">    ├── bash 子进程被终止</span><br><span class="line">    └── Approval Handler 返回取消</span><br></pre></td></tr></table></figure><p>取消的用户语义是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">取消当前任务</span><br><span class="line">保留已确认的 Session 历史</span><br><span class="line">不执行未开始的危险工具</span><br><span class="line">回到 claw&gt;</span><br><span class="line">不退出整个进程</span><br></pre></td></tr></table></figure><p>每一个可阻塞组件都必须继续传递同一个 Context。不能在 Provider、Tool 或 Handler 内部重新使用无法取消的 <code>context.Background()</code>。</p><p>该阶段对应的后续实现提交是 <code>4a9be525a8e0f5ef24014c7c178f7ac8a8704d2b</code>。</p><h2 id="八-未来阶段六引入通用-approval-gate"><a class="markdownIt-Anchor" href="#八-未来阶段六引入通用-approval-gate"></a> 八、未来阶段六：引入通用 Approval Gate</h2><p>工具执行从：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">ToolCall → Registry.Execute</span><br></pre></td></tr></table></figure><p>改为：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">ToolCall</span><br><span class="line">    ↓ RiskLevel</span><br><span class="line">Policy</span><br><span class="line">    ↓ GrantStore</span><br><span class="line">    ↓ Handler</span><br><span class="line">Decision</span><br><span class="line">    ↓</span><br><span class="line">Registry.Execute</span><br></pre></td></tr></table></figure><p>Approval 层拆成四个职责：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">RiskLevel：工具能力的风险分类</span><br><span class="line">Policy：默认自动允许、询问或拒绝</span><br><span class="line">GrantStore：保存会话授权</span><br><span class="line">Handler：Terminal 等渠道如何询问用户</span><br></pre></td></tr></table></figure><p>需要新增：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">internal/approval/interface.go</span><br><span class="line">internal/approval/policy.go</span><br><span class="line">internal/approval/grant.go</span><br><span class="line">internal/approval/gate.go</span><br><span class="line">internal/approval/terminal_approval.go</span><br><span class="line">internal/approval/id.go</span><br></pre></td></tr></table></figure><p>执行顺序必须是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">所有 ToolCall 串行 Check</span><br><span class="line">    ├── Deny：生成拒绝 Observation</span><br><span class="line">    └── Allow：加入 approvedCalls</span><br><span class="line"></span><br><span class="line">approvedCalls 并发 Execute</span><br><span class="line">    ↓</span><br><span class="line">按原始 index 写回 Observation</span><br></pre></td></tr></table></figure><p>不能把审批放到执行 Goroutine 中，否则多个请求会同时读取同一个 Terminal 输入流。</p><p>该阶段对应的后续实现提交是 <code>8a13d9a876a86a08c2ee43171cc2a108412f18ec</code>。</p><h2 id="九-未来阶段七补齐资源生命周期和可靠取消"><a class="markdownIt-Anchor" href="#九-未来阶段七补齐资源生命周期和可靠取消"></a> 九、未来阶段七：补齐资源生命周期和可靠取消</h2><p>阶段一至六跑通后，先处理最容易在生产环境暴露的问题：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Provider Goroutine 是否全部退出</span><br><span class="line">Approval 输入 Goroutine 是否泄漏</span><br><span class="line">HTTP Stream 是否关闭</span><br><span class="line">bash 子进程是否被回收</span><br><span class="line">取消后的结果是否误写 Session</span><br><span class="line">Reporter 并发输出是否可读</span><br></pre></td></tr></table></figure><p>需要增加：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">统一任务状态</span><br><span class="line">工具级超时</span><br><span class="line">并发上限</span><br><span class="line">Channel 背压</span><br><span class="line">优雅关闭</span><br><span class="line">Race Detector</span><br><span class="line">取消、超时和泄漏测试</span><br></pre></td></tr></table></figure><p>尤其要解决 Terminal Handler 中“Context 已取消但 <code>ReadString</code> 仍阻塞”的问题。函数返回 <code>context.Canceled</code> 不代表后台输入 Goroutine 一定已经退出。</p><h2 id="十-未来阶段八审批持久化和最小权限"><a class="markdownIt-Anchor" href="#十-未来阶段八审批持久化和最小权限"></a> 十、未来阶段八：审批持久化和最小权限</h2><p>内存 Grant 只能用于单进程实验。生产级授权需要：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">文件或数据库 GrantStore</span><br><span class="line">工作区范围</span><br><span class="line">文件路径范围</span><br><span class="line">命令前缀或参数摘要</span><br><span class="line">过期时间</span><br><span class="line">撤销能力</span><br><span class="line">审批人和审批时间</span><br><span class="line">审计记录</span><br></pre></td></tr></table></figure><p>授权不能只匹配 <code>SessionID + ToolName</code>。允许一次 <code>bash</code> 不应自动等于允许该会话执行任意命令。</p><p>风险还要逐步细化到参数级：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">read_file(workspace/a.go)  低风险</span><br><span class="line">write_file(workspace/a.go) 中风险</span><br><span class="line">bash(&quot;go test ./...&quot;)      可配置风险</span><br><span class="line">bash(&quot;rm -rf ...&quot;)         高风险</span><br></pre></td></tr></table></figure><p>这一步还要建立工作区沙箱、路径规范化、命令策略和完整审计。</p><h2 id="十一-未来阶段九provider-可靠性和成本预算"><a class="markdownIt-Anchor" href="#十一-未来阶段九provider-可靠性和成本预算"></a> 十一、未来阶段九：Provider 可靠性和成本预算</h2><p>生产模型调用需要处理：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">连接超时</span><br><span class="line">首 Token 超时</span><br><span class="line">整体响应超时</span><br><span class="line">429 限流</span><br><span class="line">5xx 服务错误</span><br><span class="line">流式半响应</span><br><span class="line">模型能力不匹配</span><br><span class="line">重试和退避</span><br></pre></td></tr></table></figure><p>还要建立预算控制：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">单 Turn 最大 Token</span><br><span class="line">单 Run 最大 Token</span><br><span class="line">Session 累计费用</span><br><span class="line">工具执行时间预算</span><br><span class="line">Subagent 次数预算</span><br></pre></td></tr></table></figure><p>涉及副作用 ToolCall 时，重试必须考虑幂等性，不能因为 HTTP 重试而重复执行写文件或 bash。</p><h2 id="十二-未来阶段十session-持久化和任务恢复"><a class="markdownIt-Anchor" href="#十二-未来阶段十session-持久化和任务恢复"></a> 十二、未来阶段十：Session 持久化和任务恢复</h2><p>内存 Session 只能支持单进程运行。生产级 Session 需要保存：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Session 元数据</span><br><span class="line">Message History</span><br><span class="line">Working Memory</span><br><span class="line">当前 Run 状态</span><br><span class="line">最后一次 ToolCall</span><br><span class="line">取消或失败原因</span><br><span class="line">Usage 和成本</span><br></pre></td></tr></table></figure><p>重启恢复时要判断任务停在：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">等待模型</span><br><span class="line">等待审批</span><br><span class="line">工具执行中</span><br><span class="line">已取消</span><br><span class="line">已完成</span><br></pre></td></tr></table></figure><p>只有状态和 Observation 一致时才能继续。若进程在副作用工具执行中崩溃，必须进入人工确认、幂等重试或补偿流程，不能盲目重复执行。</p><h2 id="十三-未来阶段十一工具生态和-mcp-a2a"><a class="markdownIt-Anchor" href="#十三-未来阶段十一工具生态和-mcp-a2a"></a> 十三、未来阶段十一：工具生态和 MCP / A2A</h2><p>本地 Registry 稳定后，接入外部工具和远程 Agent：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">MCP Server</span><br><span class="line">    ↓ tools/list</span><br><span class="line">MCP Adapter</span><br><span class="line">    ↓ schema.ToolDefinition</span><br><span class="line">Tool Registry</span><br><span class="line">    ↓ Approval Gate</span><br><span class="line">    ↓ tools/call</span><br><span class="line">MCP Server</span><br></pre></td></tr></table></figure><p>需要解决：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">工具发现和刷新</span><br><span class="line">远程连接生命周期</span><br><span class="line">远程超时和取消</span><br><span class="line">远程错误映射</span><br><span class="line">工具版本兼容</span><br><span class="line">远程风险声明</span><br><span class="line">Agent-to-Agent 身份和权限</span><br></pre></td></tr></table></figure><p>外部工具及远程 Agent 必须复用同一个 Context、Approval 和 Observability 链路，不能因为工具来自 MCP 就绕过本地安全边界。</p><h2 id="十四-未来阶段十二评测-性能和部署治理"><a class="markdownIt-Anchor" href="#十四-未来阶段十二评测-性能和部署治理"></a> 十四、未来阶段十二：评测、性能和部署治理</h2><h3 id="测试与评测"><a class="markdownIt-Anchor" href="#测试与评测"></a> 测试与评测</h3><p>生产级 Harness 不能只依赖手工运行，需要：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">Provider 协议测试</span><br><span class="line">StreamEvent 顺序测试</span><br><span class="line">ToolCall 聚合测试</span><br><span class="line">REPL 和 Ctrl-C 测试</span><br><span class="line">Approval Gate 表驱动测试</span><br><span class="line">并发和 Race 测试</span><br><span class="line">Fake Provider 回放测试</span><br><span class="line">固定任务回归测试</span><br></pre></td></tr></table></figure><p>还要评估 Agent 行为：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">任务是否完成</span><br><span class="line">是否调用正确工具</span><br><span class="line">是否产生越权操作</span><br><span class="line">是否陷入 Doom Loop</span><br><span class="line">Token 和耗时是否超预算</span><br><span class="line">取消后状态是否一致</span><br></pre></td></tr></table></figure><h3 id="性能与规模化"><a class="markdownIt-Anchor" href="#性能与规模化"></a> 性能与规模化</h3><p>需要关注：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">首 Token 延迟</span><br><span class="line">完整 Run 延迟</span><br><span class="line">并发 Run 数</span><br><span class="line">Provider 连接复用</span><br><span class="line">工具执行并发池</span><br><span class="line">上下文缓存</span><br><span class="line">Trace 异步写入</span><br><span class="line">大输出截断</span><br><span class="line">资源配额</span><br></pre></td></tr></table></figure><h3 id="部署与治理"><a class="markdownIt-Anchor" href="#部署与治理"></a> 部署与治理</h3><p>最后把本地 CLI 变成团队可以依赖的服务：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Secret 管理</span><br><span class="line">用户认证和租户隔离</span><br><span class="line">工作区沙箱</span><br><span class="line">权限审计</span><br><span class="line">指标、日志、Trace</span><br><span class="line">健康检查和优雅关闭</span><br><span class="line">SLO、告警、灰度和回滚</span><br></pre></td></tr></table></figure><h2 id="十五-阶段依赖和提交规划"><a class="markdownIt-Anchor" href="#十五-阶段依赖和提交规划"></a> 十五、阶段依赖和提交规划</h2><p>未来阶段不能随意调换顺序：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">阶段一 Runtime 边界</span><br><span class="line">    ↓</span><br><span class="line">阶段二 连续 REPL</span><br><span class="line">    ↓</span><br><span class="line">阶段三/四 Stream 和 ToolCall 聚合</span><br><span class="line">    ↓</span><br><span class="line">阶段五 全链路取消</span><br><span class="line">    ↓</span><br><span class="line">阶段六 Approval Gate</span><br><span class="line">    ↓</span><br><span class="line">阶段七 资源生命周期可靠性</span><br><span class="line">    ↓</span><br><span class="line">阶段八 权限持久化</span><br><span class="line">    ↓</span><br><span class="line">阶段九 Provider 容错和预算</span><br><span class="line">    ↓</span><br><span class="line">阶段十 Session 恢复</span><br><span class="line">    ↓</span><br><span class="line">阶段十一 MCP / A2A</span><br><span class="line">    ↓</span><br><span class="line">阶段十二 评测、性能和部署</span><br></pre></td></tr></table></figure><p>代码提交可以保持小步可运行：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">47452b3  当前起点</span><br><span class="line">    ↓</span><br><span class="line">fb7d745  阶段二：REPL 和 Session 复用</span><br><span class="line">    ↓</span><br><span class="line">02dd5b9  阶段三/四：Stream 和 ToolCall 聚合</span><br><span class="line">    ↓</span><br><span class="line">4a9be52  阶段五：Ctrl-C 和 Context 取消</span><br><span class="line">    ↓</span><br><span class="line">8a13d9a  阶段六：Policy、Gate、GrantStore、Terminal Handler</span><br><span class="line">    ↓</span><br><span class="line">后续提交  阶段七至十二的生产化能力</span><br></pre></td></tr></table></figure><p>每个阶段都必须保持可运行：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">完成阶段二后：仍可非流式运行</span><br><span class="line">完成阶段三后：不支持流式的 Provider 仍可回退</span><br><span class="line">完成阶段五后：取消当前 Run，REPL 不退出</span><br><span class="line">完成阶段六后：安全工具自动执行，危险工具需确认</span><br><span class="line">完成阶段十后：进程重启可以恢复一致任务状态</span><br><span class="line">完成阶段十二后：具备部署、告警、回滚和评测能力</span><br></pre></td></tr></table></figure><h2 id="十六-最终验收标准"><a class="markdownIt-Anchor" href="#十六-最终验收标准"></a> 十六、最终验收标准</h2><h3 id="连续对话"><a class="markdownIt-Anchor" href="#连续对话"></a> 连续对话</h3><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">输入任务 A</span><br><span class="line">任务完成后仍停留在 claw&gt;</span><br><span class="line">输入任务 B</span><br><span class="line">Agent 能引用任务 A 的上下文</span><br></pre></td></tr></table></figure><h3 id="实时输出"><a class="markdownIt-Anchor" href="#实时输出"></a> 实时输出</h3><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">模型生成期间持续出现文本</span><br><span class="line">文本结束后有明确换行</span><br><span class="line">ToolCall 参数完整后才执行</span><br></pre></td></tr></table></figure><h3 id="中断"><a class="markdownIt-Anchor" href="#中断"></a> 中断</h3><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">模型流式输出时 Ctrl-C</span><br><span class="line">bash 执行时 Ctrl-C</span><br><span class="line">审批等待时 Ctrl-C</span><br><span class="line">三种情况下都返回 claw&gt;，不退出进程</span><br></pre></td></tr></table></figure><h3 id="审批"><a class="markdownIt-Anchor" href="#审批"></a> 审批</h3><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">read_file 自动允许</span><br><span class="line">write_file 询问用户</span><br><span class="line">bash 询问用户</span><br><span class="line">允许一次只影响当前调用</span><br><span class="line">允许本会话可以复用授权</span><br><span class="line">拒绝后生成 Observation，不执行工具</span><br></pre></td></tr></table></figure><h3 id="生产稳定性"><a class="markdownIt-Anchor" href="#生产稳定性"></a> 生产稳定性</h3><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Provider 错误可分类、重试或降级</span><br><span class="line">Context 取消不会泄漏 Goroutine</span><br><span class="line">工具结果顺序稳定</span><br><span class="line">Session 可以恢复</span><br><span class="line">Trace 可以回放</span><br><span class="line">权限决策可以审计</span><br><span class="line">指标和成本可以查询</span><br></pre></td></tr></table></figure><h2 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h2><p>从 <code>47452b38c047ba8e614369a099a04c2bbad90c83</code> 到生产级 Agent Harness，未来要完成的是十二个连续阶段：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">Runtime 边界</span><br><span class="line">连续 REPL</span><br><span class="line">流式事件</span><br><span class="line">ToolCall 聚合</span><br><span class="line">全链路取消</span><br><span class="line">Approval Gate</span><br><span class="line">资源生命周期可靠性</span><br><span class="line">权限持久化</span><br><span class="line">Provider 容错和预算</span><br><span class="line">Session 恢复</span><br><span class="line">MCP / A2A 生态</span><br><span class="line">评测、性能和部署治理</span><br></pre></td></tr></table></figure><p>系列十一、十二、十三会先落地其中的 REPL/Stream、中断和审批；但它们只是完整路线中的早期阶段。只有把后续的权限、恢复、评测、性能和部署工作继续完成，<code>go-tiny-claw</code> 才真正具备生产级 Agent Framework 的能力。</p>]]></content>
    
    
    <summary type="html">以 go-tiny-claw 提交 47452b38c047ba8e614369a099a04c2bbad90c83 为起点，规划连续对话、流式输出、中断、审批及生产级 Agent Harness 的完整建设路线。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>Claude Code 源码解析（五）：Reactive Compact 与 413 恢复</title>
    <link href="https://sunra.top/posts/6097476/"/>
    <id>https://sunra.top/posts/6097476/</id>
    <published>2026-07-12T06:00:00.000Z</published>
    <updated>2026-08-27T01:59:01.996Z</updated>
    
    <content type="html"><![CDATA[<p>接续<a href="/posts/claue-code-source-code-4/">第四篇</a>的 <strong>proactive 上下文预处理</strong>（budget → snip → microcompact → collapse → autocompact），本文专讲其对称另一半：<strong>reactive 恢复</strong>——当 proactive 没压住、或刻意 reactive-only 时，真实 API 返回 413 / 媒体过大后，queryLoop 如何 withhold 错误、尝试抢救、再决定 surface 或 continue。</p><blockquote><p><strong>源码说明</strong>：<code>reactiveCompact.js</code>、<code>contextCollapse/index.js</code> 等为 <code>REACTIVE_COMPACT</code> / <code>CONTEXT_COLLAPSE</code> feature-gated，外部 build 可能不存在；本文以 <strong>调用方 + 类型定义 + 共享工具</strong> 为准还原行为。</p></blockquote><span id="more"></span><h2 id="proactive-vs-reactive同一问题的两面"><a class="markdownIt-Anchor" href="#proactive-vs-reactive同一问题的两面"></a> Proactive vs Reactive：同一问题的两面</h2><table><thead><tr><th></th><th>Proactive（第四篇）</th><th>Reactive（本篇）</th></tr></thead><tbody><tr><td>触发时机</td><td>调 API <strong>之前</strong>，本地 token 估算</td><td>调 API <strong>之后</strong>，收到真实错误</td></tr><tr><td>典型手段</td><td>snip / microcompact / collapse 投影 / autocompact</td><td>collapse <strong>drain</strong> → <code>tryReactiveCompact</code></td></tr><tr><td>目标</td><td>尽量不让请求失败</td><td>请求已失败时 <strong>抢救一轮</strong></td></tr></tbody></table><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">flowchart LR</span><br><span class="line">  subgraph proactive [Proactive Phase 1]</span><br><span class="line">    P1[budget / snip / MC / collapse / autocompact]</span><br><span class="line">  end</span><br><span class="line">  subgraph api [API]</span><br><span class="line">    A[callModel]</span><br><span class="line">  end</span><br><span class="line">  subgraph reactive [Reactive 恢复]</span><br><span class="line">    R1[collapse drain]</span><br><span class="line">    R2[tryReactiveCompact]</span><br><span class="line">    R3[surface error]</span><br><span class="line">  end</span><br><span class="line">  P1 --&gt; A</span><br><span class="line">  A --&gt;|413 / media| R1</span><br><span class="line">  R1 --&gt;|仍失败| R2</span><br><span class="line">  R2 --&gt;|仍失败| R3</span><br><span class="line">  R1 --&gt;|成功| A</span><br><span class="line">  R2 --&gt;|成功| A</span><br></pre></td></tr></table></figure><p><strong>Reactive-only 模式</strong>（GrowthBook <code>tengu_cobalt_raccoon</code>）：关闭 proactive autocompact，专门等 API 413 再 compact（<code>autoCompact.ts</code> ~195–198）。手动 <code>/compact</code> 在该模式下走 <code>compactViaReactive</code>（<code>commands/compact/compact.ts</code> ~87）。</p><hr /><h2 id="413-错误如何变成一条-message"><a class="markdownIt-Anchor" href="#413-错误如何变成一条-message"></a> 413 错误如何变成一条 message</h2><p>API 抛错后，<code>getAssistantMessageFromError</code>（<code>errors.ts</code>）将其规范为带 <code>isApiErrorMessage</code> 的 assistant message。</p><h3 id="prompt-too-long413-400"><a class="markdownIt-Anchor" href="#prompt-too-long413-400"></a> Prompt too long（413 / 400）</h3><p>Vertex 常返回 413，直连 API 常返回 400，文案都含 <code>prompt is too long</code>：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// errors.ts ~560–573</span></span><br><span class="line"><span class="keyword">return</span> <span class="title function_">createAssistantAPIErrorMessage</span>(&#123;</span><br><span class="line">  <span class="attr">content</span>: <span class="variable constant_">PROMPT_TOO_LONG_ERROR_MESSAGE</span>,  <span class="comment">// 固定串 &quot;Prompt is too long&quot; — UI 匹配用</span></span><br><span class="line">  <span class="attr">error</span>: <span class="string">&#x27;invalid_request&#x27;</span>,</span><br><span class="line">  <span class="attr">errorDetails</span>: error.<span class="property">message</span>,             <span class="comment">// 原始串，如 &quot;137500 tokens &gt; 135000 maximum&quot;</span></span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>判定：<code>isPromptTooLongMessage(msg)</code> — <code>isApiErrorMessage</code> 且 content 里 text 以 <code>&quot;Prompt is too long&quot;</code> 开头。</p><p>Reactive 恢复会用 <code>getPromptTooLongTokenGap(msg)</code> 从 <code>errorDetails</code> 解析 <strong>超出多少 token</strong>，以便一次丢掉足够多的 API round 组，而不是一组一组剥（<code>errors.ts</code> ~104–117；proactive 侧同类逻辑见 <code>compact.ts</code> <code>truncateHeadForPTLRetry</code>）。</p><h3 id="媒体过大image-pdf-many-image"><a class="markdownIt-Anchor" href="#媒体过大image-pdf-many-image"></a> 媒体过大（image / PDF / many-image）</h3><p>平行路径：<code>isMediaSizeError</code> / <code>isMediaSizeErrorMessage</code>（<code>errors.ts</code> ~133–152）。<strong>content</strong> 是用户可读文案；<strong>errorDetails</strong> 存原始 API 串，供 reactive 判断是否可 strip 后重试。</p><table><thead><tr><th>错误类型</th><th>恢复手段</th></tr></thead><tbody><tr><td>413 PTL</td><td>collapse drain → reactive compact（摘要）</td></tr><tr><td>媒体过大</td><td>仅 reactive compact（<code>stripImagesFromMessages</code> 后重试）</td></tr></tbody></table><p>Media error <strong>不走 collapse drain</strong>：collapse 投影只替换 text span，不 strip image/document。</p><hr /><h2 id="流式-withhold先藏起来再决定要不要给用户看"><a class="markdownIt-Anchor" href="#流式-withhold先藏起来再决定要不要给用户看"></a> 流式 Withhold：先藏起来，再决定要不要给用户看</h2><p><code>query.ts</code> ~788–825：API 流中收到 assistant message 时，若为 <strong>可恢复错误</strong>，则 <strong>不 <code>yield</code></strong> 给 UI/SDK，但仍 <strong><code>push</code> 到 <code>assistantMessages</code></strong>，供后续恢复逻辑读取。</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> withheld = <span class="literal">false</span></span><br><span class="line"><span class="keyword">if</span> (<span class="title function_">feature</span>(<span class="string">&#x27;CONTEXT_COLLAPSE&#x27;</span>)) &#123;</span><br><span class="line">  <span class="keyword">if</span> (contextCollapse?.<span class="title function_">isWithheldPromptTooLong</span>(...)) withheld = <span class="literal">true</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> (reactiveCompact?.<span class="title function_">isWithheldPromptTooLong</span>(message)) withheld = <span class="literal">true</span></span><br><span class="line"><span class="keyword">if</span> (mediaRecoveryEnabled &amp;&amp; reactiveCompact?.<span class="title function_">isWithheldMediaSizeError</span>(message)) withheld = <span class="literal">true</span></span><br><span class="line"><span class="keyword">if</span> (<span class="title function_">isWithheldMaxOutputTokens</span>(message)) withheld = <span class="literal">true</span></span><br><span class="line"><span class="keyword">if</span> (!withheld) &#123;</span><br><span class="line">  <span class="keyword">yield</span> yieldMessage</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> (message.<span class="property">type</span> === <span class="string">&#x27;assistant&#x27;</span>) &#123;</span><br><span class="line">  assistantMessages.<span class="title function_">push</span>(message)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="为什么要-withhold"><a class="markdownIt-Anchor" href="#为什么要-withhold"></a> 为什么要 withhold</h3><ol><li><strong>SDK / desktop</strong>：看到 <code>error</code> 字段可能 <strong>直接终止 session</strong>；恢复还在跑时泄露错误，调用方已退出，抢救无人接收（<code>query.ts</code> ~166–171 注释，与 <code>max_output_tokens</code> withhold 对称）。</li><li><strong>用户体验</strong>：能恢复则不先展示失败；确认救不回来再 <code>yield lastMessage</code>。</li></ol><h3 id="mediarecoveryenabled-必须-hoist"><a class="markdownIt-Anchor" href="#mediarecoveryenabled-必须-hoist"></a> mediaRecoveryEnabled 必须 hoist</h3><p><code>mediaRecoveryEnabled</code> 在流式 <strong>前</strong> 算一次（<code>query.ts</code> ~626–627），流中与流后恢复必须用 <strong>同一值</strong>。否则可能出现「流里 withhold 了、流后认为 recovery 关闭」→ message 丢失。</p><hr /><h2 id="恢复链只在-needsfollowup-时执行"><a class="markdownIt-Anchor" href="#恢复链只在-needsfollowup-时执行"></a> 恢复链：只在 <code>!needsFollowUp</code> 时执行</h2><h3 id="needsfollowup-是什么"><a class="markdownIt-Anchor" href="#needsfollowup-是什么"></a> <code>needsFollowUp</code> 是什么</h3><p><code>needsFollowUp</code> 不是「要不要 compact」的开关，而是 <strong>这一轮 API 响应有没有留下未完成的 tool 轨迹</strong>（<code>query.ts</code> ~554–558）：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 流里每出现一个 tool_use block → needsFollowUp = true</span></span><br><span class="line"><span class="comment">// 这是 loop 退出信号：false = 本轮 agentic step 已收场（modulo stop-hook retry）</span></span><br><span class="line"><span class="keyword">let</span> needsFollowUp = <span class="literal">false</span></span><br></pre></td></tr></table></figure><p>每一轮 iteration 在流结束后 <strong>二选一</strong>：</p><table><thead><tr><th><code>needsFollowUp</code></th><th>路径</th></tr></thead><tbody><tr><td><code>true</code></td><td>执行 tool → 拼 <code>assistant + tool_results</code> → <code>continue</code>（<code>transition: next_turn</code>）</td></tr><tr><td><code>false</code></td><td><strong>收尾</strong>：413 恢复 / max_output_tokens 恢复 / stop hooks / <code>completed</code></td></tr></tbody></table><p>413 恢复写在 <code>if (!needsFollowUp)</code> 里（<code>query.ts</code> ~1062–1183），与 stop hooks、正常结束 <strong>共用同一出口</strong>。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">flowchart TD</span><br><span class="line">  A[流结束] --&gt; B&#123;needsFollowUp?&#125;</span><br><span class="line">  B --&gt;|true| T[tool 执行 → continue next_turn]</span><br><span class="line">  B --&gt;|false| C&#123;withheld 413?&#125;</span><br><span class="line">  C --&gt;|是| D[recoverFromOverflow]</span><br><span class="line">  D --&gt;|committed &gt; 0| E[continue collapse_drain_retry]</span><br><span class="line">  D --&gt;|0| F[tryReactiveCompact]</span><br><span class="line">  C --&gt;|否| G&#123;withheld media?&#125;</span><br><span class="line">  G --&gt;|是| F</span><br><span class="line">  F --&gt;|成功| H[continue reactive_compact_retry]</span><br><span class="line">  F --&gt;|失败| I[yield error return]</span><br><span class="line">  B --&gt;|false 无 413| J[stop hooks / completed]</span><br></pre></td></tr></table></figure><h3 id="为什么只有-needsfollowup-才触发恢复"><a class="markdownIt-Anchor" href="#为什么只有-needsfollowup-才触发恢复"></a> 为什么只有 <code>!needsFollowUp</code> 才触发恢复</h3><p><strong>1. 413 在实践里几乎是整请求失败</strong></p><p><code>prompt is too long</code> 是请求级拒绝：返回 <code>isApiErrorMessage</code>，<strong>通常不含</strong>有效 <code>tool_use</code>。触发恢复时 <code>needsFollowUp</code> 自然为 <code>false</code>。</p><p><strong>2. 避免在「半段 tool 轨迹」中间 compact</strong></p><p>若 <code>needsFollowUp === true</code>，history 里已有 <code>tool_use</code> 但尚未产生 <code>tool_result</code>。此时 <code>tryReactiveCompact</code> 若把 <code>state.messages</code> 换成 <code>postCompactMessages</code> 再 <code>continue</code>：</p><ul><li>可能丢掉 in-flight 的 tool 流或已部分 yield 的结果</li><li>破坏 <strong>tool_use ↔ tool_result 配对</strong>（API 硬要求）</li><li>UI 上出现「模型要了 tool → 历史突然被摘要替换」的断裂感</li></ul><p><strong>有 tool 要跟 → 先走完 tool 路径；compact 等这一轮 agentic step 闭合。</strong></p><p><strong>3. 不是永远不做 reactive，而是 defer 到合适的一轮</strong></p><p>Tool 跑完后的 <code>continue</code>（<code>query.ts</code> ~1715–1725）会：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">messages</span>: [...messagesForQuery, ...assistantMessages, ...toolResults],</span><br><span class="line"><span class="attr">hasAttemptedReactiveCompact</span>: <span class="literal">false</span>,  <span class="comment">// 新 iteration 重置</span></span><br><span class="line"><span class="attr">transition</span>: &#123; <span class="attr">reason</span>: <span class="string">&#x27;next_turn&#x27;</span> &#125;,</span><br></pre></td></tr></table></figure><p>下一轮重新走 <strong>Phase 1 预处理</strong>，再 <code>callModel</code>。若仍 413 且 <code>needsFollowUp === false</code>，reactive 恢复才会触发。</p><h3 id="好处归纳"><a class="markdownIt-Anchor" href="#好处归纳"></a> 好处归纳</h3><table><thead><tr><th>好处</th><th>说明</th></tr></thead><tbody><tr><td>保护 tool 配对</td><td>不在 <code>tool_use</code> 已发、<code>tool_result</code> 未齐时改 history</td></tr><tr><td>分支清晰</td><td>恢复 = callModel 失败；tool 路径 = callModel 成功且要了 tool</td></tr><tr><td>符合 413 形态</td><td>失败请求通常无 <code>tool_use</code></td></tr><tr><td>收尾集中</td><td>与 stop hooks、completed、max_output_tokens 同分支</td></tr><tr><td>下轮仍有机会</td><td>tool 结束 → proactive 预处理 → 仍可 reactive</td></tr></tbody></table><h3 id="时间线示例"><a class="markdownIt-Anchor" href="#时间线示例"></a> 时间线示例</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">Iteration N:   callModel → 413</span><br><span class="line">               needsFollowUp=false → tryReactiveCompact → continue</span><br><span class="line"></span><br><span class="line">Iteration N:   callModel → tool_use × 3</span><br><span class="line">               needsFollowUp=true  → 跑 tool（不 compact）</span><br><span class="line">               → continue next_turn</span><br><span class="line"></span><br><span class="line">Iteration N+1: Phase 1 预处理 → callModel → 又 413</span><br><span class="line">               needsFollowUp=false → 这次才 reactive compact</span><br></pre></td></tr></table></figure><p>长 agentic turn：<strong>先让 tool 闭环，再在无 tool 的失败点（或下一轮）压 context</strong>。</p><hr /><h2 id="collapse-drain-专节非-git-概念"><a class="markdownIt-Anchor" href="#collapse-drain-专节非-git-概念"></a> Collapse drain 专节（非 Git 概念）</h2><p>第四篇提到 context collapse 的 <strong>读时投影</strong>；本节说清楚 <strong>drain</strong> 涉及的 staged / commit 语义。</p><blockquote><p><strong>重要</strong>：下文 staged、commit、queue、drain <strong>均不是 Git 概念</strong>，与 <code>utils/git.ts</code>、<code>/commit</code> 命令无关，只是 Context Collapse 子系统借用了相近词汇。</p></blockquote><h3 id="双状态staged-vs-committed"><a class="markdownIt-Anchor" href="#双状态staged-vs-committed"></a> 双状态：Staged vs Committed</h3><table><thead><tr><th>状态</th><th>含义</th><th>持久化</th></tr></thead><tbody><tr><td><strong>Staged</strong></td><td>「这段 span 要压成摘要 S」已写好，<strong>尚未对模型视图生效</strong></td><td>内存队列 + 快照 <code>marble-origami-snapshot</code></td></tr><tr><td><strong>Committed</strong></td><td>已登记，投影时 span → <code>&lt;collapsed&gt;摘要&lt;/collapsed&gt;</code></td><td>追加 <code>marble-origami-commit</code></td></tr></tbody></table><p><strong>REPL <code>messages[]</code> 一般不删</strong>；commit 只影响 <strong>模型看到的投影视图</strong>（<code>projectView</code>）。</p><h3 id="staged-queue-里有什么"><a class="markdownIt-Anchor" href="#staged-queue-里有什么"></a> Staged queue 里有什么</h3><p><code>types/logs.ts</code> <code>ContextCollapseSnapshotEntry</code>：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">staged</span>: <span class="title class_">Array</span>&lt;&#123;</span><br><span class="line">  <span class="attr">startUuid</span>: <span class="built_in">string</span>   <span class="comment">// span 起点 message uuid</span></span><br><span class="line">  <span class="attr">endUuid</span>: <span class="built_in">string</span>     <span class="comment">// span 终点</span></span><br><span class="line">  <span class="attr">summary</span>: <span class="built_in">string</span>     <span class="comment">// ctx-agent 写好的摘要正文</span></span><br><span class="line">  <span class="attr">risk</span>: <span class="built_in">number</span></span><br><span class="line">  <span class="attr">stagedAt</span>: <span class="built_in">number</span></span><br><span class="line">&#125;&gt;</span><br></pre></td></tr></table></figure><h3 id="谁写入-staged-queue"><a class="markdownIt-Anchor" href="#谁写入-staged-queue"></a> 谁写入 staged queue</h3><p>主要是 <strong>ctx-agent</strong>（<code>querySource === 'marble_origami'</code>）在后台产出：</p><ol><li>上下文涨到约 <strong>90%</strong> → 开始 commit 流程；约 <strong>95%</strong> → 可能 blocking spawn（<code>autoCompact.ts</code> ~201–205 注释）</li><li>spawn ctx-agent，分析对话、选定 span、写好 summary</li><li>结果入 <strong>staged queue</strong>（内存）</li><li>每次 ctx-agent spawn 结束 → 快照写入 transcript（<code>types/logs.ts</code> ~274–275）</li></ol><p><code>/context</code> 可显示 <code>N staged</code>（<code>commands/context/context-noninteractive.ts</code> ~128）。</p><h3 id="积压的-collapse"><a class="markdownIt-Anchor" href="#积压的-collapse"></a> 「积压的 collapse」</h3><p>已在 staged queue 里排队、但 <strong>还没 commit</strong> 的项。ctx-agent 一次可能产出多段；正常路径未必立刻全部 commit，对话继续变长时队列会堆着多段「摘要已写好、投影未生效」的 collapse——模型视图里 <strong>原文仍在</strong>，token 未降。</p><h3 id="commit-做什么"><a class="markdownIt-Anchor" href="#commit-做什么"></a> Commit 做什么</h3><ol><li>追加 <code>marble-origami-commit</code>（span 边界 uuid + <code>summaryContent</code> 等）</li><li>之后 <code>applyCollapsesIfNeeded</code> → <code>projectView</code> 重放 commit log，模型视图里该 span 变为 <code>&lt;collapsed&gt;</code></li><li>原始 user/assistant 仍在 transcript；commit 条目 <strong>不</strong> 重复存 archived 正文（<code>types/logs.ts</code> ~240–244）</li></ol><h3 id="drain-413-时一次性-commit-全部-staged"><a class="markdownIt-Anchor" href="#drain-413-时一次性-commit-全部-staged"></a> Drain = 413 时一次性 commit 全部 staged</h3><p><code>recoverFromOverflow(messagesForQuery, querySource)</code>：</p><ul><li>把 staged queue 里 <strong>所有</strong> 待处理项 <strong>一次性 commit</strong>（排空队列）</li><li><code>drained.committed &gt; 0</code> → 更新 <code>state.messages</code>，<code>transition: 'collapse_drain_retry'</code>，<code>continue</code> 重试 API</li><li><strong>只 drain 一次</strong>：若上轮已是 <code>collapse_drain_retry</code> 仍 413 → fall through 到 reactive compact</li><li>队列空 → <code>committed === 0</code>，跳过 drain</li></ul><p>相对 reactive compact，drain <strong>便宜</strong>（摘要已写好，只 commit）；reactive compact <strong>贵</strong>（fork 摘要 agent）。</p><hr /><h2 id="tryreactivecompact最后手段"><a class="markdownIt-Anchor" href="#tryreactivecompact最后手段"></a> tryReactiveCompact：最后手段</h2><p><code>query.ts</code> ~1119–1165：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> compacted = <span class="keyword">await</span> reactiveCompact.<span class="title function_">tryReactiveCompact</span>(&#123;</span><br><span class="line">  <span class="attr">hasAttempted</span>: hasAttemptedReactiveCompact,</span><br><span class="line">  querySource,</span><br><span class="line">  <span class="attr">aborted</span>: toolUseContext.<span class="property">abortController</span>.<span class="property">signal</span>.<span class="property">aborted</span>,</span><br><span class="line">  <span class="attr">messages</span>: messagesForQuery,</span><br><span class="line">  <span class="attr">cacheSafeParams</span>: &#123;</span><br><span class="line">    systemPrompt, userContext, systemContext,</span><br><span class="line">    toolUseContext,</span><br><span class="line">    <span class="attr">forkContextMessages</span>: messagesForQuery,</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> (compacted) &#123;</span><br><span class="line">  <span class="comment">// task_budget 扣减（同 proactive autocompact）</span></span><br><span class="line">  <span class="keyword">const</span> postCompactMessages = <span class="title function_">buildPostCompactMessages</span>(compacted)</span><br><span class="line">  <span class="keyword">yield</span> ...postCompactMessages</span><br><span class="line">  state = &#123;</span><br><span class="line">    <span class="attr">messages</span>: postCompactMessages,</span><br><span class="line">    <span class="attr">hasAttemptedReactiveCompact</span>: <span class="literal">true</span>,</span><br><span class="line">    <span class="attr">autoCompactTracking</span>: <span class="literal">undefined</span>,</span><br><span class="line">    <span class="attr">transition</span>: &#123; <span class="attr">reason</span>: <span class="string">&#x27;reactive_compact_retry&#x27;</span> &#125;,</span><br><span class="line">    ...</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">continue</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><ul><li>产出与 proactive 相同：<code>buildPostCompactMessages</code> → boundary + summary + attachments</li><li>成功后 <code>autoCompactTracking</code> 置 <code>undefined</code>（当作全新 compact 后上下文）</li></ul><h3 id="hasattemptedreactivecompact每-turn-最多一次"><a class="markdownIt-Anchor" href="#hasattemptedreactivecompact每-turn-最多一次"></a> <code>hasAttemptedReactiveCompact</code>：每 turn 最多一次</h3><table><thead><tr><th>场景</th><th>行为</th></tr></thead><tbody><tr><td>首次 413，compact 成功</td><td><code>true</code> + <code>continue</code></td></tr><tr><td>compact 后仍 413 / media error</td><td>不再 compact，surface 错误</td></tr><tr><td>stop-hook blocking 后 <code>continue</code></td><td><strong>不能 reset</strong> 该 flag（<code>query.ts</code> ~1292–1297）</td></tr></tbody></table><p>否则死循环：compact → 仍太长 → error → stop hook 注入 token → 再 compact → 无限 API 调用。</p><h3 id="恢复失败不走-handlestophooks"><a class="markdownIt-Anchor" href="#恢复失败不走-handlestophooks"></a> 恢复失败：不走 <code>handleStopHooks</code></h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// query.ts ~1168–1175</span></span><br><span class="line"><span class="keyword">yield</span> lastMessage</span><br><span class="line"><span class="built_in">void</span> <span class="title function_">executeStopFailureHooks</span>(lastMessage, toolUseContext)</span><br><span class="line"><span class="keyword">return</span> &#123; <span class="attr">reason</span>: isWithheldMedia ? <span class="string">&#x27;image_error&#x27;</span> : <span class="string">&#x27;prompt_too_long&#x27;</span> &#125;</span><br></pre></td></tr></table></figure><ul><li><strong>不走</strong> <code>handleStopHooks</code>（会注入 <code>blockingErrors</code> 再 <code>continue</code>，与 413 叠加 → error → hook → retry → error）</li><li>只跑 <strong><code>StopFailure</code> hooks</strong>（<code>executeStopFailureHooks</code>），不往 history 塞继续对话的 blocking message</li></ul><hr /><h2 id="与-blocking-preempt-的配合"><a class="markdownIt-Anchor" href="#与-blocking-preempt-的配合"></a> 与 Blocking Preempt 的配合</h2><p>发 API <strong>前</strong> 的 synthetic 413（<code>blocking_limit</code>，<code>query.ts</code> ~637–647）在以下情况 <strong>skip</strong>：</p><table><thead><tr><th>条件</th><th>原因</th></tr></thead><tbody><tr><td>reactive compact enabled <strong>且</strong> <code>isAutoCompactEnabled()</code></td><td>需要 <strong>真实 API 413</strong> 触发 reactive</td></tr><tr><td>context collapse enabled <strong>且</strong> <code>isAutoCompactEnabled()</code></td><td>同上，drain 依赖真实 413</td></tr><tr><td>本轮刚 autocompact 成功</td><td>stale usage 会误判</td></tr><tr><td>snip 已 freeing token</td><td>计数需减 <code>snipTokensFreed</code></td></tr><tr><td><code>querySource === 'compact' | 'session_memory'</code></td><td>fork agent 不能在此被拦死</td></tr></tbody></table><p>用户设 <code>DISABLE_AUTO_COMPACT</code> 时 <strong>仍</strong> 可能走 blocking preempt（「不要自动 anything」优先于 reactive 路径）。</p><hr /><h2 id="三种机制的互斥关系"><a class="markdownIt-Anchor" href="#三种机制的互斥关系"></a> 三种机制的互斥关系</h2><table><thead><tr><th>机制</th><th>何时</th><th>与其它</th></tr></thead><tbody><tr><td><strong>Proactive autocompact</strong></td><td>API 前 Phase 1</td><td><code>CONTEXT_COLLAPSE</code> on → <strong>关</strong>；<code>tengu_cobalt_raccoon</code> reactive-only → <strong>关</strong></td></tr><tr><td><strong>Blocking preempt</strong></td><td>API 前硬拦</td><td>reactive/collapse + autocompact on → <strong>关</strong></td></tr><tr><td><strong>Reactive compact</strong></td><td>API 后 413/media</td><td>与 proactive <strong>互补</strong>；collapse drain 为其前置便宜步骤</td></tr></tbody></table><hr /><h2 id="reactivecompactjs-推断接口"><a class="markdownIt-Anchor" href="#reactivecompactjs-推断接口"></a> <code>reactiveCompact.js</code> 推断接口</h2><table><thead><tr><th>导出</th><th>调用处</th><th>职责</th></tr></thead><tbody><tr><td><code>isReactiveCompactEnabled()</code></td><td><code>query.ts</code></td><td>feature + experiment gate</td></tr><tr><td><code>isReactiveOnlyMode()</code></td><td><code>/compact</code></td><td><code>tengu_cobalt_raccoon</code></td></tr><tr><td><code>isWithheldPromptTooLong(msg)</code></td><td>流式 withhold</td><td>PTL + gate</td></tr><tr><td><code>isWithheldMediaSizeError(msg)</code></td><td>流式 withhold</td><td>media + gate</td></tr><tr><td><code>tryReactiveCompact(opts)</code></td><td>query 自动恢复</td><td>→ <code>CompactionResult</code></td></tr><tr><td><code>reactiveCompactOnPromptTooLong(...)</code></td><td>手动 <code>/compact</code></td><td>同上，支持 <code>customInstructions</code></td></tr></tbody></table><h3 id="共享可见逻辑"><a class="markdownIt-Anchor" href="#共享可见逻辑"></a> 共享可见逻辑</h3><table><thead><tr><th>模块</th><th>作用</th></tr></thead><tbody><tr><td><code>grouping.ts</code> <code>groupMessagesByApiRound</code></td><td>按新 assistant <code>message.id</code> 分组；reactive 从 tail 剥多轮</td></tr><tr><td><code>compact.ts</code> <code>stripImagesFromMessages</code></td><td>media 恢复：image/document → <code>[image]</code> / <code>[document]</code></td></tr><tr><td><code>compact.ts</code> <code>getPromptTooLongTokenGap</code></td><td>按 413 超出量一次 drop 多组</td></tr><tr><td><code>compact.ts</code> <code>buildPostCompactMessages</code></td><td>统一 post-compact 消息结构</td></tr><tr><td><code>postCompactCleanup.ts</code></td><td>compact 后清 module state</td></tr></tbody></table><p><code>compact.ts</code> ~238 注释：gated 的 <code>compactMessages.ts</code> 有 <strong>从 tail 剥的 retry loop</strong>；proactive 的 <code>truncateHeadForPTLRetry</code> 是从 <strong>head 丢</strong> 的 fallback。</p><h3 id="手动-compact-的-reactive-路径"><a class="markdownIt-Anchor" href="#手动-compact-的-reactive-路径"></a> 手动 <code>/compact</code> 的 reactive 路径</h3><p><code>compactViaReactive</code>（<code>commands/compact/compact.ts</code> ~139–227）：</p><ul><li>并发 <code>executePreCompactHooks</code> + <code>getCacheSharingParams</code></li><li>调 <code>reactiveCompactOnPromptTooLong</code>（内部跑 PostCompact hooks）</li><li>失败 reason：<code>too_few_groups</code> / <code>aborted</code> / <code>exhausted</code> / <code>media_unstrippable</code> 等</li></ul><hr /><h2 id="邻接max_output_tokens-恢复同一-withhold-框架"><a class="markdownIt-Anchor" href="#邻接max_output_tokens-恢复同一-withhold-框架"></a> 邻接：<code>max_output_tokens</code> 恢复（同一 withhold 框架）</h2><p>同一段 <code>!needsFollowUp</code> 内（<code>query.ts</code> ~1185–1256），与 413 <strong>共用 withhold</strong>，但 <strong>不走 compact</strong>：</p><ol><li>默认 8k cap 命中 → 升到 <code>ESCALATED_MAX_TOKENS</code>（64k）同请求重试</li><li>仍不够 → 注入 meta user message「Output token limit hit…」，最多 3 次</li><li>耗尽才 <code>yield</code> withheld error</li></ol><hr /><h2 id="六个自检问题含答案"><a class="markdownIt-Anchor" href="#六个自检问题含答案"></a> 六个自检问题（含答案）</h2><h3 id="1-为什么-413-在流式里不立刻显示"><a class="markdownIt-Anchor" href="#1-为什么-413-在流式里不立刻显示"></a> 1. 为什么 413 在流式里不立刻显示？</h3><p>可恢复错误先 withhold。SDK/desktop 见 <code>error</code> 可能终止 session；恢复成功则用户无需看到中间失败。确认救不回来再 <code>yield</code>。</p><h3 id="2-为什么先-collapse-drain再-reactive-compactmedia-为何跳过-collapse"><a class="markdownIt-Anchor" href="#2-为什么先-collapse-drain再-reactive-compactmedia-为何跳过-collapse"></a> 2. 为什么先 collapse drain，再 reactive compact？media 为何跳过 collapse？</h3><p>先便宜后昂贵：drain 用已 staged 的摘要 commit；reactive 要 fork 整段摘要。collapse 不处理 image/PDF，media 必须走 strip + reactive。</p><h3 id="3-hasattemptedreactivecompact-防什么"><a class="markdownIt-Anchor" href="#3-hasattemptedreactivecompact-防什么"></a> 3. <code>hasAttemptedReactiveCompact</code> 防什么？</h3><p>每 user turn 最多自动 reactive compact 一次；stop-hook 路径不得 reset，防 compact ↔ error ↔ hook 死循环。</p><h3 id="4-为什么-413-失败后不能走-handlestophooks"><a class="markdownIt-Anchor" href="#4-为什么-413-失败后不能走-handlestophooks"></a> 4. 为什么 413 失败后不能走 <code>handleStopHooks</code>？</h3><p>模型无有效回复；stop hook 会注入更多 token 再 <code>continue</code>，形成 error → hook → retry → error 螺旋。只跑 <code>StopFailure</code> hooks 并 return。</p><h3 id="5-proactive-autocompact-blocking-preempt-reactive-何时互斥"><a class="markdownIt-Anchor" href="#5-proactive-autocompact-blocking-preempt-reactive-何时互斥"></a> 5. proactive autocompact、blocking preempt、reactive 何时互斥？</h3><p>collapse on 或 reactive-only → proactive off；reactive/collapse + autocompact on → blocking preempt off（要真 413）；<code>DISABLE_AUTO_COMPACT</code> → blocking 仍可能拦。</p><h3 id="6-为什么-413-恢复只在-needsfollowup-时触发"><a class="markdownIt-Anchor" href="#6-为什么-413-恢复只在-needsfollowup-时触发"></a> 6. 为什么 413 恢复只在 <code>!needsFollowUp</code> 时触发？</h3><p><code>needsFollowUp</code> 表示本轮 API 是否还有未完成的 tool（已收 <code>tool_use</code>、待跑 tool）。为保 tool 配对，不在半段轨迹中 compact；413 失败通常也无 <code>tool_use</code>。Tool 跑完后的下一轮仍可触发 reactive。</p><hr /><h2 id="一次-413-恢复的数据流示例"><a class="markdownIt-Anchor" href="#一次-413-恢复的数据流示例"></a> 一次 413 恢复的数据流示例</h2><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">Proactive 预处理完成 → callModel</span><br><span class="line">  ↓ API 返回 413</span><br><span class="line">流式 withhold（用户未见错误）</span><br><span class="line">  ↓ needsFollowUp=false</span><br><span class="line">recoverFromOverflow → committed=2（两段 staged collapse 一次性 commit）</span><br><span class="line">  ↓ continue, transition=collapse_drain_retry</span><br><span class="line">callModel 仍 413</span><br><span class="line">  ↓</span><br><span class="line">tryReactiveCompact → CompactionResult</span><br><span class="line">  ↓ buildPostCompactMessages → yield boundary + summary</span><br><span class="line">  ↓ continue, hasAttemptedReactiveCompact=true</span><br><span class="line">callModel 成功 → 正常 turn 继续</span><br></pre></td></tr></table></figure><p>若 <code>tryReactiveCompact</code> 返回 null → <code>yield</code>  withheld 413 → <code>StopFailure</code> hooks → <code>return &#123; reason: 'prompt_too_long' &#125;</code>。</p><hr /><h2 id="关键文件索引"><a class="markdownIt-Anchor" href="#关键文件索引"></a> 关键文件索引</h2><table><thead><tr><th>主题</th><th>文件</th></tr></thead><tbody><tr><td>恢复编排</td><td><code>src/query.ts</code> ~621–647, ~788–825, ~1062–1183</td></tr><tr><td>PTL / media 错误</td><td><code>src/services/api/errors.ts</code></td></tr><tr><td>API round 分组</td><td><code>src/services/compact/grouping.ts</code></td></tr><tr><td>stripImages / PTL retry</td><td><code>src/services/compact/compact.ts</code></td></tr><tr><td>proactive 互斥</td><td><code>src/services/compact/autoCompact.ts</code></td></tr><tr><td>手动 reactive /compact</td><td><code>src/commands/compact/compact.ts</code></td></tr><tr><td>collapse 持久化类型</td><td><code>src/types/logs.ts</code> <code>marble-origami-*</code></td></tr><tr><td>collapse 恢复 transcript</td><td><code>src/utils/sessionRestore.ts</code></td></tr><tr><td>StopFailure hooks</td><td><code>src/utils/hooks.ts</code> <code>executeStopFailureHooks</code></td></tr><tr><td>reactive 实现（gated）</td><td><code>src/services/compact/reactiveCompact.js</code></td></tr><tr><td>collapse 实现（gated）</td><td><code>src/services/contextCollapse/index.js</code></td></tr></tbody></table><hr /><h2 id="小结"><a class="markdownIt-Anchor" href="#小结"></a> 小结</h2><ol><li><strong>Reactive 是 proactive 的对称补位</strong>：前者 API 前压 context，后者 API 失败后抢救</li><li><strong>Withhold</strong>：可恢复错误先不 yield，救不了再 surface</li><li><strong>恢复顺序</strong>：413 → collapse <strong>drain</strong>（排空 staged queue，非 Git）→ <strong>reactive compact</strong>；media 跳过 drain</li><li><strong>Staged / commit</strong>：ctx-agent 写 staged queue；commit 让投影生效；drain = 413 时批量 commit</li><li><strong><code>hasAttemptedReactiveCompact</code> + 不走 stop hooks</strong>：防死循环与 token 螺旋</li><li><strong>Blocking preempt</strong> 与 reactive/collapse 配合时需 skip synthetic 413，保留真实 API 错误作触发信号</li><li><strong><code>!needsFollowUp</code></strong>：恢复只在无待执行 tool 时触发，保护 tool 轨迹；tool 结束后的下一轮仍可 reactive</li></ol><p>下一篇可继续 <strong>compact 摘要 agent 内部流程</strong>（<code>compactConversation</code> / session memory fork），或 <strong>Stop hooks + Attachments 管道</strong>（turn 末尾第二输入源）。</p>]]></content>
    
    
    <summary type="html">接续上下文预处理：API 报错后的 reactive compact 全链路——withhold、collapse drain、tryReactiveCompact、媒体恢复，以及 staged queue / commit 语义（非 Git）。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>从零搭建 Agent Harness 系列（九）可观测性</title>
    <link href="https://sunra.top/posts/7b08c0/"/>
    <id>https://sunra.top/posts/7b08c0/</id>
    <published>2026-06-27T23:25:43.000Z</published>
    <updated>2026-08-27T01:58:06.618Z</updated>
    
    <content type="html"><![CDATA[<p>在过去的几个模块中，我们如同打造一辆超级跑车般，为 go-tiny-claw 组装了强大的 V8 引擎（Main Loop）、防抱死刹车（Safety Middleware）、甚至是能自动寻路的“副驾驶”（Subagent）。但是，如果这辆跑车没有“仪表盘（Dashboard）”，你敢把它开上真实的赛道吗？</p><p>想象一下，你把 go-tiny-claw 部署到了公司的生产环境中，团队的 10 个开发人员每天都在飞书里唤醒它去做代码 Review 和 Bug 排查。月底结算时，老板拿着一张高达几万元的 API 账单质问你：为什么这个月的大模型费用这么高？到底是哪一个任务、调了哪个工具消耗了最多的 Token？Agent 每次回复都要等 30 秒，到底是网络慢、还是它在本地执行 go test 慢、还是大模型推理慢？</p><p>如果你无法回答这些问题，你的 Agent 依然只能是一个“玩具”，老板不会批准你将其投入到日常生产，也无法成为企业级的数字资产。</p><p>我们将通过极简的代码，在 Harness 层（而非业务层）拦截大模型的返回包，精确记录 Token 消耗、金钱成本和执行耗时。</p><span id="more"></span><h2 id="成本追踪"><a class="markdownIt-Anchor" href="#成本追踪"></a> 成本追踪</h2><h3 id="成本由哪些构成"><a class="markdownIt-Anchor" href="#成本由哪些构成"></a> 成本由哪些构成</h3><p>在调用大模型 API 时，成本主要由两部分构成：</p><ol><li><p>Prompt Tokens（输入 Token）：这是大模型阅读系统提示词、对话历史和文件内容的成本。在 go-tiny-claw 中，由于上下文是在不断累加的，输入 Token 会随着对话轮数呈现出近似 O(n²) 的增长趋势。</p></li><li><p>Completion Tokens（输出 Token）：这是大模型生成回答、思考过程（Thinking Trace）和工具调用参数（JSON）的成本。通常比输入 Token 贵 3-5 倍。</p></li></ol><p>除了金钱成本，时间成本也是决定 Agent 体验的关键。</p><p>一个 Turn 的耗时 = 大模型推理耗时 + 工具在本地的物理执行耗时（如 go build）。</p><h3 id="代码实战构建-cost-tracker-中间件"><a class="markdownIt-Anchor" href="#代码实战构建-cost-tracker-中间件"></a> 代码实战：构建 Cost Tracker 中间件</h3><p>接下来，我们将用 Go 语言将这个优雅的架构变现。</p><h4 id="第-1-步扩展基础数据结构"><a class="markdownIt-Anchor" href="#第-1-步扩展基础数据结构"></a> 第 1 步：扩展基础数据结构</h4><p>大模型 API 会在返回结果中附带 Token 消耗的元数据（Metadata）。我们需要在 schema 中找个地方接住它们。打开 internal/schema/message.go：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// internal/schema/message.go</span></span><br><span class="line"><span class="keyword">package</span> schema</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> <span class="string">&quot;encoding/json&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// Usage 记录了单次大模型 API 调用的 Token 消耗</span></span><br><span class="line"><span class="keyword">type</span> Usage <span class="keyword">struct</span> &#123;</span><br><span class="line">    PromptTokens     <span class="type">int</span> <span class="string">`json:&quot;prompt_tokens&quot;`</span>     <span class="comment">// 输入的 Token 数量</span></span><br><span class="line">    CompletionTokens <span class="type">int</span> <span class="string">`json:&quot;completion_tokens&quot;`</span> <span class="comment">// 产生的 Token 数量</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Message 代表上下文中传递的单条消息</span></span><br><span class="line"><span class="keyword">type</span> Message <span class="keyword">struct</span> &#123;</span><br><span class="line">    Role       Role       <span class="string">`json:&quot;role&quot;`</span></span><br><span class="line">    Content    <span class="type">string</span>     <span class="string">`json:&quot;content&quot;`</span></span><br><span class="line">    ToolCalls  []ToolCall <span class="string">`json:&quot;tool_calls,omitempty&quot;`</span></span><br><span class="line">    ToolCallID <span class="type">string</span>     <span class="string">`json:&quot;tool_call_id,omitempty&quot;`</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">// 【新增】如果这是大模型 (Assistant) 的回复，此字段存放本次调用的 Token 消耗</span></span><br><span class="line">    Usage *Usage <span class="string">`json:&quot;usage,omitempty&quot;`</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// ... 其余定义保持不变 ...</span></span><br></pre></td></tr></table></figure><p>接着，我们需要让 Session 能够记住自己“这辈子”一共花了多少钱。打开 internal/engine/session.go，修改 Session 结构体：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// internal/engine/session.go</span></span><br><span class="line"><span class="keyword">package</span> engine</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="comment">// ... 保持原有导入 ...</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">type</span> Session <span class="keyword">struct</span> &#123;</span><br><span class="line">    ID        <span class="type">string</span></span><br><span class="line">    CreatedAt time.Time</span><br><span class="line">    UpdatedAt time.Time</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 【新增】用于统计该 Session 累计消耗的资源</span></span><br><span class="line">    TotalPromptTokens     <span class="type">int</span></span><br><span class="line">    TotalCompletionTokens <span class="type">int</span></span><br><span class="line">    TotalCostCNY          <span class="type">float64</span></span><br><span class="line"></span><br><span class="line">    history []schema.Message</span><br><span class="line">    mu      sync.RWMutex</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// RecordUsage 是一个给外部 Tracker 调用的辅助方法，用于累加账单</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s *Session)</span></span> RecordUsage(prompt <span class="type">int</span>, completion <span class="type">int</span>, cost <span class="type">float64</span>) &#123;</span><br><span class="line">    s.mu.Lock()</span><br><span class="line">    <span class="keyword">defer</span> s.mu.Unlock()</span><br><span class="line">    s.TotalPromptTokens += prompt</span><br><span class="line">    s.TotalCompletionTokens += completion</span><br><span class="line">    s.TotalCostCNY += cost</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// ... 其余方法保持不变 ...</span></span><br></pre></td></tr></table></figure><h4 id="第-2-步在-provider-适配层提取-token"><a class="markdownIt-Anchor" href="#第-2-步在-provider-适配层提取-token"></a> 第 2 步：在 Provider 适配层提取 Token</h4><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// internal/provider/openai.go</span></span><br><span class="line"><span class="keyword">package</span> provider</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="comment">// ... 保持原有导入 ...</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment">// ... NewZhipuOpenAIProvider 等保持不变 ...</span></span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(p *OpenAIProvider)</span></span> Generate(ctx context.Context, msgs []schema.Message, availableTools []schema.ToolDefinition) (*schema.Message, <span class="type">error</span>) &#123;</span><br><span class="line">    <span class="comment">// ... 前面组装请求的代码完全保持不变 ...</span></span><br><span class="line"></span><br><span class="line">    resp, err := p.client.Chat.Completions.New(ctx, params)</span><br><span class="line">    <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">nil</span>, fmt.Errorf(<span class="string">&quot;OpenAI/Zhipu API 请求失败: %w&quot;</span>, err)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    choice := resp.Choices[<span class="number">0</span>].Message</span><br><span class="line">    resultMsg := &amp;schema.Message&#123;</span><br><span class="line">        Role:    schema.RoleAssistant,</span><br><span class="line">        Content: choice.Content,</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 【新增】提取 Usage 信息</span></span><br><span class="line">    <span class="keyword">if</span> resp.Usage.PromptTokens &gt; <span class="number">0</span> || resp.Usage.CompletionTokens &gt; <span class="number">0</span> &#123;</span><br><span class="line">        resultMsg.Usage = &amp;schema.Usage&#123;</span><br><span class="line">            PromptTokens:     <span class="type">int</span>(resp.Usage.PromptTokens),</span><br><span class="line">            CompletionTokens: <span class="type">int</span>(resp.Usage.CompletionTokens),</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// ... 后面解析 ToolCalls 的代码完全保持不变 ...</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> resultMsg, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h4 id="第-3-步编写优雅的-cost-tracker-装饰器"><a class="markdownIt-Anchor" href="#第-3-步编写优雅的-cost-tracker-装饰器"></a> 第 3 步：编写优雅的 Cost Tracker 装饰器</h4><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// internal/observability/tracker.go</span></span><br><span class="line"><span class="keyword">package</span> observability</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="string">&quot;context&quot;</span></span><br><span class="line">    <span class="string">&quot;log&quot;</span></span><br><span class="line">    <span class="string">&quot;time&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="string">&quot;github.com/yourname/go-tiny-claw/internal/provider&quot;</span></span><br><span class="line">    <span class="string">&quot;github.com/yourname/go-tiny-claw/internal/schema&quot;</span></span><br><span class="line">    ctxpkg <span class="string">&quot;github.com/yourname/go-tiny-claw/internal/context&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment">// PricingModel 定义了不同大模型的计费标准 (单位: 美元/1M Tokens)</span></span><br><span class="line"><span class="comment">// 为了演示，这里硬编码了当前市面上几个主流模型的官方大致定价。</span></span><br><span class="line"><span class="keyword">var</span> PricingModel = <span class="keyword">map</span>[<span class="type">string</span>]<span class="keyword">struct</span> &#123;</span><br><span class="line">    InputPrice  <span class="type">float64</span></span><br><span class="line">    OutputPrice <span class="type">float64</span></span><br><span class="line">&#125;&#123;</span><br><span class="line">    <span class="string">&quot;glm-4.5-air&quot;</span>:              &#123;InputPrice: <span class="number">0.15</span>, OutputPrice: <span class="number">0.15</span>&#125;, <span class="comment">// 这里假定的大模型价格(每百万Token，tk)</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// CostTracker 是一个包装了真实 LLMProvider 的装饰器中间件</span></span><br><span class="line"><span class="keyword">type</span> CostTracker <span class="keyword">struct</span> &#123;</span><br><span class="line">    nextProvider provider.LLMProvider</span><br><span class="line">    modelName    <span class="type">string</span></span><br><span class="line">    session      *ctxpkg.Session <span class="comment">// 当前所属的会话 (用于累加总成本)</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// NewCostTracker 构造函数：接收一个现有的 Provider，返回一个被监控的 Provider</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">NewCostTracker</span><span class="params">(next provider.LLMProvider, modelName <span class="type">string</span>, session *ctxpkg.Session)</span></span> *CostTracker &#123;</span><br><span class="line">    <span class="keyword">return</span> &amp;CostTracker&#123;</span><br><span class="line">        nextProvider: next,</span><br><span class="line">        modelName:    modelName,</span><br><span class="line">        session:      session,</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Generate 实现了 LLMProvider 接口！这意味着它可以被无缝注入到 Main Loop 中。</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(t *CostTracker)</span></span> Generate(ctx context.Context, msgs []schema.Message, availableTools []schema.ToolDefinition) (*schema.Message, <span class="type">error</span>) &#123;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 1. 记录请求发起的时刻</span></span><br><span class="line">    startTime := time.Now()</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 2. 调用真实的底层大模型去执行耗时的网络请求</span></span><br><span class="line">    respMsg, err := t.nextProvider.Generate(ctx, msgs, availableTools)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 3. 计算耗时</span></span><br><span class="line">    latency := time.Since(startTime)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 如果报错了，只打印报错时间，不计费</span></span><br><span class="line">    <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">        log.Printf(<span class="string">&quot;[Tracker] ❌ API 调用失败，耗时: %v\n&quot;</span>, latency)</span><br><span class="line">        <span class="keyword">return</span> respMsg, err</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 4. 解析 Token 并计算成本</span></span><br><span class="line">    <span class="keyword">if</span> respMsg.Usage != <span class="literal">nil</span> &#123;</span><br><span class="line">        promptTokens := respMsg.Usage.PromptTokens</span><br><span class="line">        completionTokens := respMsg.Usage.CompletionTokens</span><br><span class="line"></span><br><span class="line">        <span class="keyword">var</span> cost <span class="type">float64</span></span><br><span class="line">        <span class="keyword">if</span> price, exists := PricingModel[t.modelName]; exists &#123;</span><br><span class="line">            <span class="comment">// 计算美元花费 = (输入Tokens * 输入单价 + 输出Tokens * 输出单价) / 1000000</span></span><br><span class="line">            cost = (<span class="type">float64</span>(promptTokens)*price.InputPrice + <span class="type">float64</span>(completionTokens)*price.OutputPrice) / <span class="number">1000000.0</span></span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 5. 打印精美的仪表盘日志</span></span><br><span class="line">        log.Printf(<span class="string">&quot;[Tracker] 📊 API 调用完成 | 耗时: %v | 输入: %d tk | 输出: %d tk | 花费: ¥%.6f\n&quot;</span>, </span><br><span class="line">            latency, promptTokens, completionTokens, cost)</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 6. 将账单累加到当前的 Session 中，供人类后续随时查询</span></span><br><span class="line">        <span class="keyword">if</span> t.session != <span class="literal">nil</span> &#123;</span><br><span class="line">            t.session.RecordUsage(promptTokens, completionTokens, cost)</span><br><span class="line">            log.Printf(<span class="string">&quot;[Tracker] 💰 当前会话 (%s) 累计花费: ¥%.6f\n&quot;</span>, t.session.ID, t.session.TotalCostCNY)</span><br><span class="line">        &#125;</span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        log.Printf(<span class="string">&quot;[Tracker] ⚠️ API 调用完成，但未返回 Usage 数据 | 耗时: %v\n&quot;</span>, latency)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> respMsg, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h4 id="第-4-步在-main-函数中像组装乐高一样串联它们"><a class="markdownIt-Anchor" href="#第-4-步在-main-函数中像组装乐高一样串联它们"></a> 第 4 步：在 Main 函数中像组装乐高一样串联它们</h4><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// cmd/claw/main.go</span></span><br><span class="line"><span class="keyword">package</span> main</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="string">&quot;context&quot;</span></span><br><span class="line">    <span class="string">&quot;log&quot;</span></span><br><span class="line">    <span class="string">&quot;os&quot;</span></span><br><span class="line"></span><br><span class="line">    ctxpkg <span class="string">&quot;github.com/yourname/go-tiny-claw/internal/context&quot;</span></span><br><span class="line">    <span class="string">&quot;github.com/yourname/go-tiny-claw/internal/engine&quot;</span></span><br><span class="line">    <span class="string">&quot;github.com/yourname/go-tiny-claw/internal/observability&quot;</span> <span class="comment">// 导入监控包</span></span><br><span class="line">    <span class="string">&quot;github.com/yourname/go-tiny-claw/internal/provider&quot;</span></span><br><span class="line">    <span class="string">&quot;github.com/yourname/go-tiny-claw/internal/schema&quot;</span></span><br><span class="line">    <span class="string">&quot;github.com/yourname/go-tiny-claw/internal/tools&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">main</span><span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="keyword">if</span> os.Getenv(<span class="string">&quot;ZHIPU_API_KEY&quot;</span>) == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">        log.Fatal(<span class="string">&quot;请先导出 ZHIPU_API_KEY 环境变量&quot;</span>)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    workDir, _ := os.Getwd()</span><br><span class="line">    modelName := <span class="string">&quot;glm-4.5-air&quot;</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">// 1. 初始化真实的底层大脑</span></span><br><span class="line">    realProvider := provider.NewZhipuOpenAIProvider(modelName)</span><br><span class="line"></span><br><span class="line">    sessionID := <span class="string">&quot;test_observability_001&quot;</span></span><br><span class="line">    sess := ctxpkg.GlobalSessionMgr.GetOrCreate(sessionID, workDir)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 2. 核心拼装：用 Tracker 将真实的大脑包裹起来</span></span><br><span class="line">    trackedProvider := observability.NewCostTracker(realProvider, modelName, sess)</span><br><span class="line"></span><br><span class="line">    registry := tools.NewRegistry()</span><br><span class="line">    registry.Register(tools.NewBashTool(workDir))</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 3. 将被包裹的 Provider 注入给 Engine (Engine 毫不知情)</span></span><br><span class="line">    eng := engine.NewAgentEngine(trackedProvider, registry, <span class="literal">false</span>, <span class="literal">false</span>)</span><br><span class="line">    reporter := engine.NewTerminalReporter()</span><br><span class="line"></span><br><span class="line">    prompt := <span class="string">`请用 bash 帮我用 date 命令查一下现在的时间。`</span></span><br><span class="line"></span><br><span class="line">    log.Println(<span class="string">&quot;\n&gt;&gt;&gt; 🚀 启动带仪表盘的可观测性测试...&quot;</span>)</span><br><span class="line">    sess.Append(schema.Message&#123;Role: schema.RoleUser, Content: prompt&#125;)</span><br><span class="line"></span><br><span class="line">    err := eng.Run(context.Background(), sess, reporter)</span><br><span class="line">    <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">        log.Fatalf(<span class="string">&quot;引擎运行崩溃: %v&quot;</span>, err)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    log.Printf(<span class="string">&quot;\n================ 财务报表 ================\n&quot;</span>)</span><br><span class="line">    log.Printf(<span class="string">&quot;会话 ID: %s\n&quot;</span>, sess.ID)</span><br><span class="line">    log.Printf(<span class="string">&quot;总消耗 Input Tokens: %d\n&quot;</span>, sess.TotalPromptTokens)</span><br><span class="line">    log.Printf(<span class="string">&quot;总消耗 Output Tokens: %d\n&quot;</span>, sess.TotalCompletionTokens)</span><br><span class="line">    log.Printf(<span class="string">&quot;总计费用 (CNY): ¥%.6f\n&quot;</span>, sess.TotalCostCNY)</span><br><span class="line">    log.Printf(<span class="string">&quot;==========================================\n&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="总结"><a class="markdownIt-Anchor" href="#总结"></a> 总结</h3><ol><li><p>算明经济账是落地的关键：在驾驭工程中，衡量一个 Agent 是否优秀，除了看它能不能把代码跑通，更要看它的 Token 效率。如果不把成本监控落到代码实处，就无法优化 System Prompt 的长度，也无从判断上下文压缩是否真的起到了省钱的作用。</p></li><li><p>装饰器模式的优雅应用：为了保持核心引擎（Main Loop）的纯粹性，我们没有在里面混入任何一行记录时间或计费的代码。我们通过实现一个包装了真实 LLMProvider 的 CostTracker，实现了功能的无缝外挂（运用了类似 AOP 面向切面编程的思想）。</p></li><li><p>长期价值的沉淀：通过将会话总账单挂载到 Session 对象上</p></li></ol><h2 id="tracing机制"><a class="markdownIt-Anchor" href="#tracing机制"></a> Tracing机制</h2><p>大模型本身是一个不可控的“黑盒（Black Box）”。如果在驾驭工程（Harness Engineering）中，我们不能提供透视这个黑盒的“X 光机”，一旦 Agent 发生智障行为，我们将陷入无法调试的境地。</p><p>我们将补齐可观测性体系（Observability）中最具技术含量的一环：链路追踪（Tracing）。我们将像微服务架构那样，用纯 Go 语言实现一套轻量级的上下文级联追踪机制，将 Agent 的每一次“思考 - 行动”完整固化为可供回放的 JSON 决策树。</p><h3 id="agent-链路追踪的本质是树tree"><a class="markdownIt-Anchor" href="#agent-链路追踪的本质是树tree"></a> Agent 链路追踪的本质是树（Tree）</h3><p>在 Agent 的驾驭工程中，Tracing 的理念是完全一致的。只不过，我们的追踪对象从网络节点变成了智能体的决策层级。一个完整的 Agent 运行周期，天然具备一棵极度工整的树状结构：</p><ol><li><p>Root Span（根跨度）：代表一次完整的 Run 任务。</p></li><li><p>Child Spans（子跨度）：代表 ReAct 循环中的每一个 Turn。</p></li><li><p>Leaf Spans（叶子节点）：代表每一个 Turn 内部的细分操作，例如 Generate（LLM 调用）、Execute（工具执行）、Compaction（内存压缩）。</p></li></ol><h3 id="代码实战"><a class="markdownIt-Anchor" href="#代码实战"></a> 代码实战</h3><p>我们将所有的追踪代码收敛在 internal/observability/trace.go 中，并在 engine 和 tools 层进行埋点。</p><h4 id="第-1-步实现-trace-数据结构与上下文传递"><a class="markdownIt-Anchor" href="#第-1-步实现-trace-数据结构与上下文传递"></a> 第 1 步：实现 Trace 数据结构与上下文传递</h4><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// internal/observability/trace.go</span></span><br><span class="line"><span class="keyword">package</span> observability</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="string">&quot;context&quot;</span></span><br><span class="line">    <span class="string">&quot;encoding/json&quot;</span></span><br><span class="line">    <span class="string">&quot;os&quot;</span></span><br><span class="line">    <span class="string">&quot;path/filepath&quot;</span></span><br><span class="line">    <span class="string">&quot;sync&quot;</span></span><br><span class="line">    <span class="string">&quot;time&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment">// traceKey 是 Context 中存放 Span 的专属 Key</span></span><br><span class="line"><span class="keyword">type</span> traceKey <span class="keyword">struct</span>&#123;&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Span 代表链路追踪中的一个时间跨度和操作节点</span></span><br><span class="line"><span class="keyword">type</span> Span <span class="keyword">struct</span> &#123;</span><br><span class="line">    Name       <span class="type">string</span>                 <span class="string">`json:&quot;name&quot;`</span></span><br><span class="line">    StartTime  time.Time              <span class="string">`json:&quot;start_time&quot;`</span></span><br><span class="line">    EndTime    time.Time              <span class="string">`json:&quot;end_time&quot;`</span></span><br><span class="line">    DurationMs <span class="type">int64</span>                  <span class="string">`json:&quot;duration_ms&quot;`</span></span><br><span class="line">    Attributes <span class="keyword">map</span>[<span class="type">string</span>]<span class="keyword">interface</span>&#123;&#125; <span class="string">`json:&quot;attributes,omitempty&quot;`</span> <span class="comment">// 存放元数据 (如消耗的 Token, 执行的命令)</span></span><br><span class="line">    Children   []*Span                <span class="string">`json:&quot;children,omitempty&quot;`</span>   <span class="comment">// 子跨度</span></span><br><span class="line"></span><br><span class="line">    mu sync.Mutex <span class="comment">// 保护 Children 的并发写入</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// StartSpan 开启一个新的追踪跨度，并将其级联到 Context 中</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">StartSpan</span><span class="params">(ctx context.Context, name <span class="type">string</span>)</span></span> (context.Context, *Span) &#123;</span><br><span class="line">    span := &amp;Span&#123;</span><br><span class="line">        Name:       name,</span><br><span class="line">        StartTime:  time.Now(),</span><br><span class="line">        Attributes: <span class="built_in">make</span>(<span class="keyword">map</span>[<span class="type">string</span>]<span class="keyword">interface</span>&#123;&#125;),</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 从 context 中尝试获取父 Span</span></span><br><span class="line">    <span class="keyword">if</span> parent, ok := ctx.Value(traceKey&#123;&#125;).(*Span); ok &#123;</span><br><span class="line">        parent.mu.Lock()</span><br><span class="line">        parent.Children = <span class="built_in">append</span>(parent.Children, span)</span><br><span class="line">        parent.mu.Unlock()</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 将当前新创建的 Span 作为最新的父节点，塞入衍生 Context 并返回</span></span><br><span class="line">    newCtx := context.WithValue(ctx, traceKey&#123;&#125;, span)</span><br><span class="line">    <span class="keyword">return</span> newCtx, span</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// EndSpan 结束跨度，计算耗时</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s *Span)</span></span> EndSpan() &#123;</span><br><span class="line">    s.EndTime = time.Now()</span><br><span class="line">    s.DurationMs = s.EndTime.Sub(s.StartTime).Milliseconds()</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// AddAttribute 为当前 Span 记录关键的元数据</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s *Span)</span></span> AddAttribute(key <span class="type">string</span>, value <span class="keyword">interface</span>&#123;&#125;) &#123;</span><br><span class="line">    s.mu.Lock()</span><br><span class="line">    <span class="keyword">defer</span> s.mu.Unlock()</span><br><span class="line">    s.Attributes[key] = value</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// ExportTraceToFile 当整个根 Span 结束时，将其序列化并保存为本地 JSON 文件</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">ExportTraceToFile</span><span class="params">(rootSpan *Span, workDir <span class="type">string</span>, sessionID <span class="type">string</span>)</span></span> <span class="type">error</span> &#123;</span><br><span class="line">    traceDir := filepath.Join(workDir, <span class="string">&quot;.claw&quot;</span>, <span class="string">&quot;traces&quot;</span>)</span><br><span class="line">    os.MkdirAll(traceDir, <span class="number">0755</span>)</span><br><span class="line"></span><br><span class="line">    filename := filepath.Join(traceDir, fmt.Sprintf(<span class="string">&quot;trace_%s_%d.json&quot;</span>, sessionID, time.Now().Unix()))</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 美化输出 JSON，便于人类和工具阅读</span></span><br><span class="line">    data, err := json.MarshalIndent(rootSpan, <span class="string">&quot;&quot;</span>, <span class="string">&quot;  &quot;</span>)</span><br><span class="line">    <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> err</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> os.WriteFile(filename, data, <span class="number">0644</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这段代码完美利用了 Go 语言 context.WithValue 的特性。我们通过每次进入新函数时调用 ctx, span := StartSpan(ctx, “Name”)，在不知不觉中构建出了一棵完整的调用树，而且完全不用担心并发安全问题。</p><h4 id="第-2-步在核心代码中埋点-instrumentation"><a class="markdownIt-Anchor" href="#第-2-步在核心代码中埋点-instrumentation"></a> 第 2 步：在核心代码中埋点 (Instrumentation)</h4><p>有了工具，接下来我们要在 Harness 的关键生命周期节点进行“埋点”。埋点在驾驭工程中是一项艺术：埋得太多，性能下降、日志噪音大；埋得太少，关键信息丢失。</p><h5 id="在-main-loop-中埋点"><a class="markdownIt-Anchor" href="#在-main-loop-中埋点"></a> 在 Main Loop 中埋点</h5><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// internal/engine/loop.go</span></span><br><span class="line"><span class="keyword">package</span> engine</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="string">&quot;context&quot;</span></span><br><span class="line">    <span class="comment">// ... 其他导入保持不变 ...</span></span><br><span class="line">    <span class="string">&quot;github.com/yourname/go-tiny-claw/internal/observability&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment">// ... AgentEngine 定义保持不变 ...</span></span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(e *AgentEngine)</span></span> Run(ctx context.Context, session *Session, reporter Reporter) <span class="type">error</span> &#123;</span><br><span class="line">    log.Printf(<span class="string">&quot;[Engine] 唤醒会话 [%s]，锁定工作区: %s (PlanMode: %v)\n&quot;</span>, session.ID, session.WorkDir, e.PlanMode)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 【埋点 1】：开启 Root Span，记录整个任务的生命周期</span></span><br><span class="line">    ctx, rootSpan := observability.StartSpan(ctx, <span class="string">&quot;Agent.Run&quot;</span>)</span><br><span class="line">    rootSpan.AddAttribute(<span class="string">&quot;SessionID&quot;</span>, session.ID)</span><br><span class="line">    rootSpan.AddAttribute(<span class="string">&quot;WorkDir&quot;</span>, session.WorkDir)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// defer 保证在引擎退出时，无论成功失败，都能结束根 Span 并导出 Trace 报告</span></span><br><span class="line">    <span class="keyword">defer</span> <span class="function"><span class="keyword">func</span><span class="params">()</span></span> &#123;</span><br><span class="line">        rootSpan.EndSpan()</span><br><span class="line">        _ = observability.ExportTraceToFile(rootSpan, session.WorkDir, session.ID)</span><br><span class="line">        log.Printf(<span class="string">&quot;📊 [Tracing] 本次任务的执行回放链路已保存至工作区的 .claw/traces 目录下\n&quot;</span>)</span><br><span class="line">    &#125;()</span><br><span class="line"></span><br><span class="line">    composer := ctxpkg.NewPromptComposer(session.WorkDir, e.PlanMode)</span><br><span class="line">    systemMsg := composer.Build()</span><br><span class="line"></span><br><span class="line">    turnCount := <span class="number">0</span></span><br><span class="line">    <span class="keyword">for</span> &#123;</span><br><span class="line">        turnCount++</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 【埋点 2】：记录单次 Turn 循环</span></span><br><span class="line">        turnCtx, turnSpan := observability.StartSpan(ctx, fmt.Sprintf(<span class="string">&quot;Turn-%d&quot;</span>, turnCount))</span><br><span class="line">        <span class="keyword">defer</span> turnSpan.EndSpan() <span class="comment">// 利用 defer，哪怕遇到了 break 或 error 也会计算耗时</span></span><br><span class="line"></span><br><span class="line">        availableTools := e.registry.GetAvailableTools()</span><br><span class="line">        workingMemory := session.GetWorkingMemory(<span class="number">20</span>)</span><br><span class="line"></span><br><span class="line">        <span class="keyword">var</span> contextHistory []schema.Message</span><br><span class="line">        contextHistory = <span class="built_in">append</span>(contextHistory, systemMsg)</span><br><span class="line">        contextHistory = <span class="built_in">append</span>(contextHistory, workingMemory...)</span><br><span class="line">        compactedContext := e.compactor.Compact(contextHistory)</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 记录发给模型的实际上下文大小，非常有助于排查幻觉</span></span><br><span class="line">        turnSpan.AddAttribute(<span class="string">&quot;context_message_count&quot;</span>, <span class="built_in">len</span>(compactedContext))</span><br><span class="line"></span><br><span class="line">        <span class="comment">// ================= Phase 1: Thinking =================</span></span><br><span class="line">        <span class="keyword">var</span> currentTurnThinkingContent <span class="type">string</span></span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> e.EnableThinking &#123;</span><br><span class="line">            <span class="keyword">if</span> reporter != <span class="literal">nil</span> &#123; reporter.OnThinking(turnCtx) &#125; <span class="comment">// 传递带有 trace 的 turnCtx</span></span><br><span class="line"></span><br><span class="line">            <span class="comment">// 【埋点 3】：记录 Thinking 调用</span></span><br><span class="line">            thinkCtx, thinkSpan := observability.StartSpan(turnCtx, <span class="string">&quot;LLM.Thinking&quot;</span>)</span><br><span class="line">            thinkResp, err := e.provider.Generate(thinkCtx, compactedContext, <span class="literal">nil</span>)</span><br><span class="line">            thinkSpan.EndSpan() <span class="comment">// 结束思考跨度</span></span><br><span class="line"></span><br><span class="line">            <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">                <span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;Thinking 阶段失败: %w&quot;</span>, err)</span><br><span class="line">            &#125;</span><br><span class="line">            <span class="keyword">if</span> thinkResp.Content != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">                currentTurnThinkingContent = thinkResp.Content</span><br><span class="line">                compactedContext = <span class="built_in">append</span>(compactedContext, *thinkResp)</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// ================= Phase 2: Action =================</span></span><br><span class="line">        <span class="comment">// 【埋点 4】：记录 Action 调用</span></span><br><span class="line">        actCtx, actSpan := observability.StartSpan(turnCtx, <span class="string">&quot;LLM.Action&quot;</span>)</span><br><span class="line">        actionResp, err := e.provider.Generate(actCtx, compactedContext, availableTools)</span><br><span class="line">        actSpan.EndSpan() <span class="comment">// 结束行动跨度</span></span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">            <span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;Action 阶段失败: %w&quot;</span>, err)</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        session.Append(*actionResp)</span><br><span class="line">        <span class="comment">// ... 输出回调逻辑不变 ...</span></span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> <span class="built_in">len</span>(actionResp.ToolCalls) == <span class="number">0</span> &#123;</span><br><span class="line">            turnSpan.EndSpan() <span class="comment">// 没有工具调用，正常结束</span></span><br><span class="line">            <span class="keyword">break</span></span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// ================= 并发执行工具 =================</span></span><br><span class="line">        observationMsgs := <span class="built_in">make</span>([]schema.Message, <span class="built_in">len</span>(actionResp.ToolCalls))</span><br><span class="line">        <span class="keyword">var</span> wg sync.WaitGroup</span><br><span class="line"></span><br><span class="line">        <span class="keyword">for</span> i, toolCall := <span class="keyword">range</span> actionResp.ToolCalls &#123;</span><br><span class="line">            wg.Add(<span class="number">1</span>)</span><br><span class="line">            <span class="keyword">go</span> <span class="function"><span class="keyword">func</span><span class="params">(idx <span class="type">int</span>, call schema.ToolCall)</span></span> &#123;</span><br><span class="line">                <span class="keyword">defer</span> wg.Done()</span><br><span class="line">                ... ...</span><br><span class="line"></span><br><span class="line">                <span class="comment">// 此时，传给 Registry 的 ctx 是带有当前 Turn 的上下文。</span></span><br><span class="line">                <span class="comment">// 并且由于是并发执行，多个工具的 Span 会平行地挂在 Turn 节点下！</span></span><br><span class="line">                result := e.registry.Execute(turnCtx, call)</span><br><span class="line"></span><br><span class="line">                <span class="comment">// ... 错误注入等不变 ...</span></span><br><span class="line"></span><br><span class="line">                observationMsgs[idx] = schema.Message&#123;</span><br><span class="line">                    Role:       schema.RoleUser,</span><br><span class="line">                    Content:    result.Output, <span class="comment">// 生产环境为了 json 不至于过大，可考虑此处不塞入全量 Output</span></span><br><span class="line">                    ToolCallID: call.ID,</span><br><span class="line">                &#125;</span><br><span class="line">            &#125;(i, toolCall)</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        wg.Wait()</span><br><span class="line">        session.Append(observationMsgs...)</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 结束本轮 Turn 的 Span</span></span><br><span class="line">        turnSpan.EndSpan()</span><br><span class="line"></span><br><span class="line">        <span class="comment">// ... System Reminder 干预逻辑不变 ...</span></span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h5 id="在-tool-registry-中埋点"><a class="markdownIt-Anchor" href="#在-tool-registry-中埋点"></a> 在 Tool Registry 中埋点</h5><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// internal/tools/registry.go (局部修改)</span></span><br><span class="line"><span class="keyword">package</span> tools</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> (</span><br><span class="line">    <span class="comment">// ... 导入保持不变 ...</span></span><br><span class="line">    <span class="string">&quot;github.com/yourname/go-tiny-claw/internal/observability&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(r *registryImpl)</span></span> Execute(ctx context.Context, call schema.ToolCall) schema.ToolResult &#123;</span><br><span class="line">    <span class="comment">// 【埋点 5】：开启工具执行的 Span</span></span><br><span class="line">    ctx, span := observability.StartSpan(ctx, <span class="string">&quot;Tool.Execute&quot;</span>)</span><br><span class="line">    span.AddAttribute(<span class="string">&quot;tool_name&quot;</span>, call.Name)</span><br><span class="line">    <span class="comment">// 将 JSON 参数存入以备调试</span></span><br><span class="line">    span.AddAttribute(<span class="string">&quot;arguments&quot;</span>, <span class="type">string</span>(call.Arguments))</span><br><span class="line"></span><br><span class="line">    <span class="keyword">defer</span> span.EndSpan() <span class="comment">// 无论成功失败，确保结束</span></span><br><span class="line"></span><br><span class="line">    tool, exists := r.tools[call.Name]</span><br><span class="line">    <span class="keyword">if</span> !exists &#123;</span><br><span class="line">        <span class="comment">// ...</span></span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">for</span> _, mw := <span class="keyword">range</span> r.middlewares &#123;</span><br><span class="line">        allowed, reason := mw(ctx, call)</span><br><span class="line">        <span class="keyword">if</span> !allowed &#123;</span><br><span class="line">            span.AddAttribute(<span class="string">&quot;intercepted&quot;</span>, <span class="literal">true</span>)</span><br><span class="line">            span.AddAttribute(<span class="string">&quot;reject_reason&quot;</span>, reason)</span><br><span class="line">            <span class="comment">// ...</span></span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    output, err := tool.Execute(ctx, call.Arguments)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">        span.AddAttribute(<span class="string">&quot;error&quot;</span>, err.Error())</span><br><span class="line">        <span class="comment">// ...</span></span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 我们甚至可以只截取输出的前 100 字符放入 Trace，防止 Trace 文件过度膨胀</span></span><br><span class="line">    span.AddAttribute(<span class="string">&quot;output_preview&quot;</span>, truncate(output, <span class="number">100</span>))</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> schema.ToolResult&#123;</span><br><span class="line">        <span class="comment">// ...</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">truncate</span><span class="params">(s <span class="type">string</span>, max <span class="type">int</span>)</span></span> <span class="type">string</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> <span class="built_in">len</span>(s) &gt; max &#123;</span><br><span class="line">        <span class="keyword">return</span> s[:max] + <span class="string">&quot;...&quot;</span></span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> s</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>]]></content>
    
    
    <summary type="html">&lt;p&gt;在过去的几个模块中，我们如同打造一辆超级跑车般，为 go-tiny-claw 组装了强大的 V8 引擎（Main Loop）、防抱死刹车（Safety Middleware）、甚至是能自动寻路的“副驾驶”（Subagent）。但是，如果这辆跑车没有“仪表盘（Dashboard）”，你敢把它开上真实的赛道吗？&lt;/p&gt;
&lt;p&gt;想象一下，你把 go-tiny-claw 部署到了公司的生产环境中，团队的 10 个开发人员每天都在飞书里唤醒它去做代码 Review 和 Bug 排查。月底结算时，老板拿着一张高达几万元的 API 账单质问你：为什么这个月的大模型费用这么高？到底是哪一个任务、调了哪个工具消耗了最多的 Token？Agent 每次回复都要等 30 秒，到底是网络慢、还是它在本地执行 go test 慢、还是大模型推理慢？&lt;/p&gt;
&lt;p&gt;如果你无法回答这些问题，你的 Agent 依然只能是一个“玩具”，老板不会批准你将其投入到日常生产，也无法成为企业级的数字资产。&lt;/p&gt;
&lt;p&gt;我们将通过极简的代码，在 Harness 层（而非业务层）拦截大模型的返回包，精确记录 Token 消耗、金钱成本和执行耗时。&lt;/p&gt;</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>Claude Code 源码解析（四）：上下文预处理全链路</title>
    <link href="https://sunra.top/posts/6a7a33a0/"/>
    <id>https://sunra.top/posts/6a7a33a0/</id>
    <published>2026-06-22T06:00:00.000Z</published>
    <updated>2026-08-27T01:58:06.620Z</updated>
    
    <content type="html"><![CDATA[<p>接续<a href="/posts/claue-code-source-code-3/">第三篇</a>对 queryLoop 各 phase 的概述，本文专讲 <strong>Phase 1：上下文预处理</strong>——从 REPL 全量 history 到 <code>messagesForQuery</code> 的完整变换链，以及 tool result budget、preview 落盘与 cache editing 三条容易混淆的子系统。</p><span id="more"></span><h2 id="设计原则阶梯式压缩"><a class="markdownIt-Anchor" href="#设计原则阶梯式压缩"></a> 设计原则：阶梯式压缩</h2><p>预处理不是「选一个策略」，而是 <strong>从轻到重、依次尝试</strong> 的流水线：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">getMessagesAfterCompactBoundary（切片 + snip 投影）</span><br><span class="line">  → applyToolResultBudget（内容替换，非 compact）</span><br><span class="line">  → snip（删 message）</span><br><span class="line">  → microcompact（清 tool 结果 / cache edit）</span><br><span class="line">  → context collapse（span 摘要，读时投影）</span><br><span class="line">  → autocompact（整段摘要，替换 messages）</span><br></pre></td></tr></table></figure><p>越往后越「破坏性」；前面步骤能把 token 压到阈值以下，后面就 <strong>no-op</strong>，尽量保留细粒度上下文。</p><p>代码入口：<code>query.ts</code> ~365–468 行，每轮 <code>while (true)</code> iteration 的 API 调用前执行。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">flowchart LR</span><br><span class="line">  A[REPL 全量 messages] --&gt; B[boundary 切片]</span><br><span class="line">  B --&gt; C[budget 替换]</span><br><span class="line">  C --&gt; D[snip]</span><br><span class="line">  D --&gt; E[microcompact]</span><br><span class="line">  E --&gt; F[collapse 投影]</span><br><span class="line">  F --&gt; G[autocompact]</span><br><span class="line">  G --&gt; H[messagesForQuery → API]</span><br></pre></td></tr></table></figure><p><strong>双视图分离</strong>：</p><table><thead><tr><th>存储</th><th>内容</th></tr></thead><tbody><tr><td>REPL <code>messages[]</code></td><td>全量 history（compact/snip 前内容仍可 UI 回看）</td></tr><tr><td><code>messagesForQuery</code></td><td>模型实际看到的子集（切片 + 投影 + 替换后）</td></tr></tbody></table><p><code>/context</code> 命令用同样变换（<code>getMessagesAfterCompactBoundary</code> + <code>projectView</code>），保证展示 token 与 API 一致。</p><hr /><h2 id="step-0getmessagesaftercompactboundary"><a class="markdownIt-Anchor" href="#step-0getmessagesaftercompactboundary"></a> Step 0：<code>getMessagesAfterCompactBoundary</code></h2><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> messagesForQuery = [...<span class="title function_">getMessagesAfterCompactBoundary</span>(messages)]</span><br></pre></td></tr></table></figure><p><strong>做什么</strong>（<code>utils/messages.ts</code>）：</p><ol><li><code>findLastCompactBoundaryIndex</code> — 从后往前找最后一个 <code>compact_boundary</code> system message</li><li><code>messages.slice(boundaryIndex)</code> — 只保留 boundary <strong>及之后</strong> 的消息</li><li>若 <code>HISTORY_SNIP</code> 开启 → <code>projectSnippedView</code> 过滤已 snip 的消息</li></ol><p>boundary 本身是 system message，发 API 时由 <code>normalizeMessagesForAPI</code> 过滤掉；其语义是「此线之前的历史已被 autocompact 摘要，不再送入模型」。</p><h3 id="snip-双视图与-idxxx-tag"><a class="markdownIt-Anchor" href="#snip-双视图与-idxxx-tag"></a> Snip 双视图与 <code>[id:xxx]</code> tag</h3><p>Snip 不是简单「删数组元素」：</p><table><thead><tr><th>层</th><th>行为</th></tr></thead><tbody><tr><td>REPL <code>messages[]</code></td><td>保留被 snip 的 message（UI 可滚动回看）</td></tr><tr><td><code>getMessagesAfterCompactBoundary</code></td><td>默认再跑 <code>projectSnippedView</code>，过滤已 snip 段</td></tr><tr><td><code>normalizeMessagesForAPI</code></td><td>给非 meta 的 user message 末尾追加 <code>[id:xxxxxx]</code>（由 uuid 派生的 6 位 base36）</td></tr></tbody></table><p><code>[id:xxx]</code> <strong>只出现在 API-bound 副本</strong>，不改 REPL 存储；供 SnipTool 引用「删哪条 message」。因此 snip 是 <strong>双视图 + 模型侧 ID 标注</strong>，不是单纯 splice。</p><hr /><h2 id="step-05applytoolresultbudget-单条-user-message-体积上限"><a class="markdownIt-Anchor" href="#step-05applytoolresultbudget-单条-user-message-体积上限"></a> Step 0.5：<code>applyToolResultBudget</code> — 单条 user message 体积上限</h2><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">messagesForQuery = <span class="keyword">await</span> <span class="title function_">applyToolResultBudget</span>(</span><br><span class="line">  messagesForQuery,</span><br><span class="line">  toolUseContext.<span class="property">contentReplacementState</span>,</span><br><span class="line">  persistReplacements ? writeToTranscript : <span class="literal">undefined</span>,</span><br><span class="line">  skipToolNames,  <span class="comment">// Read 等 maxResultSizeChars: Infinity 的工具</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><p><strong>性质</strong>：不是 compact，而是 <strong>内容替换</strong>——超大 tool_result 落盘 + 替换成短 preview。</p><h3 id="触发条件"><a class="markdownIt-Anchor" href="#触发条件"></a> 触发条件</h3><p>按 <strong>wire-level user message</strong> 聚合：同一 API user turn 里多个 tool_result 的字符数 <strong>合计</strong> 超过 budget（默认 <strong>200,000 chars</strong>，GrowthBook <code>tengu_hawthorn_window</code> 可覆盖）。</p><p><code>collectCandidatesByMessage</code> 按与 <code>normalizeMessagesForAPI</code> 相同的 merge 规则分组——并行 tool 在 state 里是 N 条 user message，上 API 时合并成一条，budget 必须在合并粒度上 enforcement。</p><p><strong>分组边界细节</strong>（<code>toolResultStorage.ts</code> 注释）：</p><ul><li>只有 <strong>assistant message</strong> 才 flush 分组；<code>progress</code> / <code>attachment</code> / <code>system(local_command)</code> 在 normalize 时会被 merge 或过滤，<strong>不能</strong>当边界</li><li>Streaming 会把同一 turn 拆成多条 assistant（同一 <code>message.id</code>）；budget 用 <code>seenAsstIds</code> 跟踪——<strong>同一 id 重现时不 flush</strong>，否则 abort 中途 parallel tools 会漏检</li><li>已是 <code>&lt;persisted-output&gt;</code> 前缀的内容（单-tool persist 或上轮 budget 产物）→ <code>isContentAlreadyCompacted</code> 跳过，不再参与候选</li><li>含 image block 的 tool_result 跳过（不能替成纯文本 preview）</li></ul><p>若 <strong>frozen 合计 alone 已超 budget</strong>，接受 overage 不再替换——microcompact / autocompact 最终会清掉。</p><h3 id="选哪些-result-替换"><a class="markdownIt-Anchor" href="#选哪些-result-替换"></a> 选哪些 result 替换</h3><p><code>selectFreshToReplace</code>：只对 <strong>fresh</strong>（首次见到的 <code>tool_use_id</code>）操作，按 size <strong>从大到小</strong> 选，直到 <code>frozenSize + remainingFresh ≤ limit</code>。</p><table><thead><tr><th>状态</th><th>行为</th></tr></thead><tbody><tr><td><code>mustReapply</code></td><td>之前 replace 过 → 从 <code>replacements</code> Map <strong>原样重放</strong>同一字符串（prompt cache 字节稳定）</td></tr><tr><td><code>frozen</code></td><td>之前 seen 但未 replace → <strong>永不再动</strong></td></tr><tr><td><code>fresh</code></td><td>本 turn 新 message → 可参与 budget 决策</td></tr></tbody></table><p>Feature gate：<code>tengu_hawthorn_steeple</code>；<code>state === undefined</code> 时整步 no-op。</p><h3 id="preview-怎么来的"><a class="markdownIt-Anchor" href="#preview-怎么来的"></a> Preview 怎么来的</h3><p><strong>不是 LLM 摘要</strong>，是 <strong>原文前 ~2000 字节</strong> + 固定 XML 模板。</p><ol><li><p><code>persistToolResult</code> 把全文写入<br /><code>~/.claude/projects/&lt;repo&gt;/&lt;sessionId&gt;/tool-results/&lt;toolUseId&gt;.txt</code>（或 <code>.json</code>）<br /><strong>不是</strong> Auto Memory 的 memdir。</p></li><li><p><code>generatePreview(contentStr, PREVIEW_SIZE_BYTES=2000)</code>：</p><ul><li>全文 ≤ 2000 字节 → preview = 全文</li><li>否则取前 2000 字节，尽量在 <strong>最后一个 <code>\n</code></strong> 处截断（且 <code>\n</code> 位置 &gt; 1000 字节）</li><li><code>hasMore = true</code> 时模板末尾加 <code>\n...\n</code></li></ul></li><li><p><code>buildLargeToolResultMessage</code> 包装：</p></li></ol><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">persisted-output</span>&gt;</span></span><br><span class="line">Output too large (1.2 MB). Full output saved to: .../tool-results/<span class="tag">&lt;<span class="name">id</span>&gt;</span>.txt</span><br><span class="line"></span><br><span class="line">Preview (first 2.0 KB):</span><br><span class="line"><span class="tag">&lt;<span class="name">原文前缀</span>&gt;</span></span><br><span class="line">...</span><br><span class="line"><span class="tag">&lt;/<span class="name">persisted-output</span>&gt;</span></span><br></pre></td></tr></table></figure><ol start="4"><li><code>replaceToolResultContents</code> 把 tool_result 的 <code>content</code> 换成上述字符串。</li></ol><p><strong>Resume</strong>：完整 replacement 字符串写入 transcript 的 <code>content-replacement</code> record；<code>reconstructContentReplacementState</code> 直接读 record，<strong>不再读盘重算</strong>，避免模板变更破坏 cache。</p><h3 id="与单-tool-阈值路径的关系"><a class="markdownIt-Anchor" href="#与单-tool-阈值路径的关系"></a> 与单-tool 阈值路径的关系</h3><table><thead><tr><th></th><th>单-tool 阈值</th><th>Message budget</th></tr></thead><tbody><tr><td>时机</td><td>tool 执行完（<code>processToolResultBlock</code>）</td><td>query 预处理</td></tr><tr><td>条件</td><td>单个 result &gt; tool 的 <code>maxResultSizeChars</code></td><td>同条 user message 合计 &gt; 200k</td></tr><tr><td>Preview 逻辑</td><td><strong>相同</strong></td><td><strong>相同</strong></td></tr></tbody></table><p>单-tool 路径先 replace；budget 是对「合并后仍过大」的兜底。</p><hr /><h2 id="step-1sniphistory_snip"><a class="markdownIt-Anchor" href="#step-1sniphistory_snip"></a> Step 1：Snip（<code>HISTORY_SNIP</code>）</h2><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> snipResult = snipModule!.<span class="title function_">snipCompactIfNeeded</span>(messagesForQuery)</span><br><span class="line">messagesForQuery = snipResult.<span class="property">messages</span></span><br><span class="line">snipTokensFreed = snipResult.<span class="property">tokensFreed</span></span><br></pre></td></tr></table></figure><p>源码：<code>services/compact/snipCompact.js</code>（feature-gated，外部 build 可能不存在）。</p><ul><li>删除整条 message 段（模型 <code>/snip</code> 或 token 超阈值自动 snip）</li><li>REPL 保留全量；<code>projectSnippedView</code> 在 Step 0 已过滤</li><li>产出 <code>snipTokensFreed</code> → 传给 autocompact（见下）</li></ul><hr /><h2 id="step-2microcompact"><a class="markdownIt-Anchor" href="#step-2microcompact"></a> Step 2：Microcompact</h2><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> microcompactResult = <span class="keyword">await</span> deps.<span class="title function_">microcompact</span>(messagesForQuery, toolUseContext, querySource)</span><br><span class="line">messagesForQuery = microcompactResult.<span class="property">messages</span></span><br><span class="line"><span class="keyword">const</span> pendingCacheEdits = microcompactResult.<span class="property">compactionInfo</span>?.<span class="property">pendingCacheEdits</span></span><br></pre></td></tr></table></figure><p>入口：<code>microcompactMessages()</code>（<code>services/compact/microCompact.ts</code>）。</p><h3 id="分支优先级"><a class="markdownIt-Anchor" href="#分支优先级"></a> 分支优先级</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">1. Time-based microcompact（距上次 assistant 超过 gap 阈值）</span><br><span class="line">      ↓ 未触发</span><br><span class="line">2. Cached microcompact（cache editing，主线程 + 支持模型）</span><br><span class="line">      ↓ 不可用</span><br><span class="line">3. Legacy 路径已移除 → return &#123; messages &#125; 不变</span><br></pre></td></tr></table></figure><h3 id="time-based-mc"><a class="markdownIt-Anchor" href="#time-based-mc"></a> Time-based MC</h3><ul><li><strong>触发</strong>：cache 已冷（gap &gt; <code>gapThresholdMinutes</code>）</li><li><strong>动作</strong>：除最近 N 个外，compactable tool 的 result content → <code>'[Old tool result content cleared]'</code></li><li><strong>直接 mutate 本地 content</strong>（cache 反正失效）</li><li>触发时 <strong>跳过</strong> cached MC</li><li>副作用：<code>resetMicrocompactState()</code>（清 module 级 cached MC 注册表，否则下轮会对已不存在的 cache entry 发 delete）；<code>notifyCacheDeletion</code> 抑制 prompt cache break 误报</li></ul><h3 id="cached-mccache-editing"><a class="markdownIt-Anchor" href="#cached-mccache-editing"></a> Cached MC（Cache Editing）</h3><p>见下文专节。要点：<strong>本地 messages 不变</strong>，删的是服务端 prompt cache 里的 tool 内容。</p><h3 id="deferred-boundary"><a class="markdownIt-Anchor" href="#deferred-boundary"></a> Deferred boundary</h3><p>cached MC 删除 tool 后，boundary message <strong>defer 到 API 响应后</strong>，用真实 <code>cache_deleted_input_tokens</code> delta yield（<code>query.ts</code> ~870）。</p><hr /><h2 id="step-3context-collapsecontext_collapse"><a class="markdownIt-Anchor" href="#step-3context-collapsecontext_collapse"></a> Step 3：Context Collapse（<code>CONTEXT_COLLAPSE</code>）</h2><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> collapseResult = <span class="keyword">await</span> contextCollapse.<span class="title function_">applyCollapsesIfNeeded</span>(...)</span><br><span class="line">messagesForQuery = collapseResult.<span class="property">messages</span></span><br></pre></td></tr></table></figure><p><strong>模型</strong>：</p><table><thead><tr><th>概念</th><th>说明</th></tr></thead><tbody><tr><td>REPL history</td><td>完整 messages，archived 段仍在</td></tr><tr><td>Collapse store</td><td>commit log（transcript 存 <code>marble-origami-commit</code>）</td></tr><tr><td><code>projectView</code></td><td>读时投影：span → <code>&lt;collapsed&gt;summary&lt;/collapsed&gt;</code></td></tr></tbody></table><ul><li><strong>不 yield</strong> 到 REPL — summary 在 store 里，不在 messages 数组</li><li>在 autocompact <strong>之前</strong>：collapse 压到阈值下 → autocompact no-op</li><li><code>shouldAutoCompact</code> 在 collapse enabled 时 <strong>return false</strong></li><li>413 恢复：<code>recoverFromOverflow</code> drain staged collapses</li></ul><hr /><h2 id="step-4autocompact"><a class="markdownIt-Anchor" href="#step-4autocompact"></a> Step 4：Autocompact</h2><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> &#123; compactionResult &#125; = <span class="keyword">await</span> deps.<span class="title function_">autocompact</span>(...)</span><br><span class="line"><span class="keyword">if</span> (compactionResult) &#123;</span><br><span class="line">  <span class="keyword">yield</span> ...<span class="title function_">buildPostCompactMessages</span>(compactionResult)</span><br><span class="line">  messagesForQuery = postCompactMessages</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="阈值autocompactts"><a class="markdownIt-Anchor" href="#阈值autocompactts"></a> 阈值（<code>autoCompact.ts</code>）</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">effectiveWindow = contextWindow(model) - reservedForSummary(~20k)</span><br><span class="line">autoCompactThreshold = effectiveWindow - 13_000</span><br><span class="line">blockingLimit = effectiveWindow - 3_000  （autocompact 关闭时的硬拦截）</span><br></pre></td></tr></table></figure><p>token 计数：<code>tokenCountWithEstimation(messages) - snipTokensFreed</code></p><h3 id="两条-compact-路径"><a class="markdownIt-Anchor" href="#两条-compact-路径"></a> 两条 compact 路径</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">flowchart TD</span><br><span class="line">  T[shouldAutoCompact] --&gt; SM[trySessionMemoryCompaction]</span><br><span class="line">  SM --&gt;|成功| OUT1[boundary + summary + messagesToKeep]</span><br><span class="line">  SM --&gt;|null| LEG[compactConversation]</span><br><span class="line">  LEG --&gt; OUT2[fork agent 生成整段摘要]</span><br></pre></td></tr></table></figure><p><strong>Session Memory Compact</strong>（实验优先）：保留 tail + session-memory 文件摘要。</p><p><strong>Legacy <code>compactConversation</code></strong>：PreCompact hooks → fork 子 agent 调 API 摘要 → 产出：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  boundaryMarker,    <span class="comment">// system compact_boundary</span></span><br><span class="line">  summaryMessages,</span><br><span class="line">  messagesToKeep?,</span><br><span class="line">  attachments,</span><br><span class="line">  hookResults,</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>buildPostCompactMessages</code> 顺序：boundary → summary → keep → attachments → hooks。</p><p><code>compact_boundary</code> 不只标记切点，还携带 metadata：</p><ul><li><code>preCompactDiscoveredTools</code> — autocompact 摘要会丢 <code>tool_reference</code>，boundary 保存 compact 前已 discover 的 deferred tool 名，供 post-compact schema 继续发送</li></ul><p>成功后 <strong>yield 并替换</strong> <code>messagesForQuery</code>；<code>runPostCompactCleanup</code> 清理 module 级 state（microcompact、collapse store、getUserContext cache、classifier approvals 等）。<strong>subagent compact 不能 reset main thread 的 module state</strong>——<code>querySource.startsWith('repl_main_thread')</code> 才做 collapse/memory 重置。</p><h3 id="不触发-跳过的情况"><a class="markdownIt-Anchor" href="#不触发-跳过的情况"></a> 不触发 / 跳过的情况</h3><ul><li><code>DISABLE_COMPACT</code> / <code>DISABLE_AUTO_COMPACT</code> / 用户关闭 autoCompact</li><li><code>querySource === 'compact' | 'session_memory'</code>（fork agent 需跑完 compact 降 token，此处 block 会 deadlock）</li><li><code>querySource === 'marble_origami'</code>（ctx-agent compact 会 reset 主线程 collapse commit log）</li><li>reactive-only（<code>REACTIVE_COMPACT</code> + <code>tengu_cobalt_raccoon</code>）/ context collapse enabled</li><li>连续失败 ≥ 3（circuit breaker）</li></ul><hr /><h2 id="各步骤对比表"><a class="markdownIt-Anchor" href="#各步骤对比表"></a> 各步骤对比表</h2><table><thead><tr><th>步骤</th><th>删 message</th><th>改 tool_result 本地 content</th><th>替换整段 history</th><th>动 REPL 存储</th><th>Prompt cache</th></tr></thead><tbody><tr><td>boundary 切片</td><td>逻辑丢弃 boundary 前</td><td>—</td><td>部分</td><td>否</td><td>boundary 后 prefix 稳定</td></tr><tr><td>tool result budget</td><td>否</td><td>替换为 preview</td><td>否</td><td>可选 persist</td><td>seenIds 冻结决策</td></tr><tr><td>snip</td><td>是（投影）</td><td>—</td><td>部分</td><td>否</td><td>变 prefix</td></tr><tr><td>time-based MC</td><td>否</td><td>清空 content</td><td>否</td><td>是</td><td>故意 invalidate</td></tr><tr><td>cached MC</td><td>否</td><td><strong>否</strong>（API cache edit）</td><td>否</td><td>否</td><td><strong>preserve prefix</strong></td></tr><tr><td>context collapse</td><td>否（投影）</td><td>—</td><td>读时投影</td><td>否</td><td>视 commit</td></tr><tr><td>autocompact</td><td>是</td><td>—</td><td><strong>完全替换</strong></td><td><strong>是</strong></td><td>新 prefix</td></tr></tbody></table><hr /><h2 id="cache-editing-专节"><a class="markdownIt-Anchor" href="#cache-editing-专节"></a> Cache Editing 专节</h2><h3 id="为什么需要"><a class="markdownIt-Anchor" href="#为什么需要"></a> 为什么需要</h3><p>Prompt caching 缓存对话前缀。直接改 message 正文 → 整段 cache 失效。Cache editing 在 <strong>不改本地 transcript</strong> 的前提下，让 API <strong>从 cache 里删掉指定 tool_result 块</strong>。</p><h3 id="与-context-management-的区别"><a class="markdownIt-Anchor" href="#与-context-management-的区别"></a> 与 Context Management 的区别</h3><table><thead><tr><th></th><th>Cache editing（Cached MC）</th><th>Context editing（API）</th></tr></thead><tbody><tr><td>配置</td><td>message 内 <code>cache_edits</code> / <code>cache_reference</code></td><td>请求体 <code>context_management.edits</code></td></tr><tr><td>Beta</td><td>cache editing beta（ant 动态 import）</td><td><code>context-management-2025-06-27</code></td></tr><tr><td>谁决定删</td><td>客户端 <code>cachedMicrocompact</code></td><td>服务端 trigger/keep</td></tr><tr><td>Claude Code</td><td>主线程 microcompact</td><td><code>getAPIContextManagement()</code>（thinking 清理等）</td></tr></tbody></table><h3 id="api-语义"><a class="markdownIt-Anchor" href="#api-语义"></a> API 语义</h3><p><strong>1. <code>cache_reference</code></strong> — 挂在 cache 前缀内、最后一个 <code>cache_control</code> <strong>之前</strong> 的 tool_result 上：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">&#123; <span class="attr">type</span>: <span class="string">&#x27;tool_result&#x27;</span>, <span class="attr">tool_use_id</span>: <span class="string">&#x27;...&#x27;</span>, <span class="attr">cache_reference</span>: <span class="string">&#x27;&lt;tool_use_id&gt;&#x27;</span>, ... &#125;</span><br></pre></td></tr></table></figure><p><strong>2. <code>cache_edits</code></strong> — 插在 user message content 里（通常在 tool_result 之后）：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  <span class="attr">type</span>: <span class="string">&#x27;cache_edits&#x27;</span>,</span><br><span class="line">  <span class="attr">edits</span>: [&#123; <span class="attr">type</span>: <span class="string">&#x27;delete&#x27;</span>, <span class="attr">cache_reference</span>: <span class="string">&#x27;&lt;tool_use_id&gt;&#x27;</span> &#125;]</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>响应 usage 含 <strong><code>cache_deleted_input_tokens</code></strong>（累积值，query 里减 baseline 得 delta）。</p><h3 id="claude-code-链路"><a class="markdownIt-Anchor" href="#claude-code-链路"></a> Claude Code 链路</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">flowchart TD</span><br><span class="line">  A[microcompactMessages] --&gt; B[cachedMicrocompactPath]</span><br><span class="line">  B --&gt; C[注册 tool_result 到 cachedMCState]</span><br><span class="line">  C --&gt; D&#123;数量 &gt; triggerThreshold?&#125;</span><br><span class="line">  D --&gt;|是| E[createCacheEditsBlock → pendingCacheEdits]</span><br><span class="line">  D --&gt;|否| F[不变]</span><br><span class="line">  E --&gt; G[claude.ts consumePendingCacheEdits]</span><br><span class="line">  G --&gt; H[addCacheBreakpoints 注入]</span><br><span class="line">  H --&gt; I[插 pinned + new cache_edits]</span><br><span class="line">  I --&gt; J[加 cache_reference 到 prefix tool_results]</span><br><span class="line">  J --&gt; K[API 请求]</span><br><span class="line">  K --&gt; L[query.ts yield microcompact boundary]</span><br></pre></td></tr></table></figure><p><strong>启用条件</strong>（<code>claude.ts</code>）：</p><ul><li><code>CACHED_MICROCOMPACT</code> feature + <code>isCachedMicrocompactEnabled()</code></li><li><code>isModelSupportedForCacheEditing(model)</code></li><li><code>getAPIProvider() === 'firstParty'</code></li><li><code>querySource.startsWith('repl_main_thread')</code>（含 outputStyle 变体）</li><li>cache editing beta header session latch</li></ul><p><strong>Pinned edits</strong>：历史 <code>cache_edits</code> 按原 user message index <strong>每轮重发</strong>，保证多轮指令一致。</p><p><strong>实现细节</strong>：</p><ul><li><code>consumePendingCacheEdits()</code> 在 <code>claude.ts</code> <strong>请求构建前只 consume 一次</strong>——<code>paramsFromContext</code> 会被 logging/retry 多次调用，不能在里面 consume</li><li>API 流式成功后 <code>markToolsSentToAPIState()</code> 更新 <code>registeredTools</code>，与 delete 决策对齐</li><li><code>querySource</code> 判定用 <code>startsWith('repl_main_thread')</code>（含 <code>outputStyle</code> 变体），不是裸字符串相等</li><li>仅 <strong>main thread</strong> 跑 cached MC——subagent 若注册 tool 会污染全局 <code>cachedMCState</code></li></ul><h3 id="api-context_management与-cached-mc-同请求可并存"><a class="markdownIt-Anchor" href="#api-context_management与-cached-mc-同请求可并存"></a> API <code>context_management</code>（与 Cached MC 同请求可并存）</h3><p><code>getAPIContextManagement()</code>（<code>apiMicrocompact.ts</code>）在 <code>claude.ts</code> 里作为请求体 <code>context_management.edits</code> 发出，与 message 内的 <code>cache_edits</code> <strong>不同层</strong>：</p><table><thead><tr><th>Strategy</th><th>触发 / 条件</th></tr></thead><tbody><tr><td><code>clear_thinking_20251015</code></td><td>有 thinking 且非 redact-thinking；idle &gt;1h（<code>thinkingClearLatched</code>）时 keep 1 turn，否则 keep all</td></tr><tr><td><code>clear_tool_uses_20250919</code></td><td>ant-only + env <code>USE_API_CLEAR_TOOL_RESULTS</code> / <code>USE_API_CLEAR_TOOL_USES</code>；服务端按 input_tokens trigger 清 tool results / tool uses</td></tr></tbody></table><p>这是 <strong>服务端</strong> 在超阈值时清 context；本地 budget/snip/microcompact 是 <strong>客户端</strong> 预处理。一次请求可能同时带 <code>cache_edits</code>（客户端指定删哪些 cache tool）和 <code>context_management</code>（服务端策略）。</p><hr /><h2 id="与-phase-2-blocking-limit"><a class="markdownIt-Anchor" href="#与-phase-2-blocking-limit"></a> 与 Phase 2 Blocking Limit</h2><p>预处理完成后、API 前还有硬拦截（<code>query.ts</code> ~637）：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (isAtBlockingLimit) &#123;</span><br><span class="line">  <span class="keyword">yield</span> <span class="title function_">createAssistantAPIErrorMessage</span>(&#123; <span class="attr">content</span>: <span class="variable constant_">PROMPT_TOO_LONG_ERROR_MESSAGE</span> &#125;)</span><br><span class="line">  <span class="keyword">return</span> &#123; <span class="attr">reason</span>: <span class="string">&#x27;blocking_limit&#x27;</span> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>仅在 <strong>autocompact 关闭</strong> 时生效。以下情况 <strong>skip</strong> synthetic 413，让真实 API 413 走 reactive/collapse 恢复链：</p><ul><li>本轮刚 autocompact 成功（<code>compactionResult</code> 存在）——<code>tokenCountWithEstimation</code> 会用 kept messages 上 <strong>stale 的 usage</strong>，误判仍超限</li><li>snip 已 freeing token → 计数减去 <code>snipTokensFreed</code>（protected-tail assistant 的 usage 看不到 snip 收益）</li><li>reactive compact enabled + autocompact allowed</li><li>context collapse enabled + autocompact allowed</li><li><code>querySource === 'compact' | 'session_memory'</code></li></ul><p>用户显式 <code>DISABLE_AUTO_COMPACT</code> 时仍走 blocking preempt（「不要自动 anything」的配置优先）。</p><hr /><h2 id="预处理之后normalizemessagesforapi-延伸"><a class="markdownIt-Anchor" href="#预处理之后normalizemessagesforapi-延伸"></a> 预处理之后：<code>normalizeMessagesForAPI</code> 延伸</h2><p>严格在 query Phase 1 <strong>之后</strong>、API 序列化时执行，但影响模型最终看到的 context：</p><ul><li>orphan thinking-only assistant 过滤、trailing thinking strip（顺序有依赖，见 <code>messages.ts</code> 注释）</li><li>attachment → meta user message，可能 merge 进上一条 user</li><li><code>[id:xxx]</code> tag 注入（snip 配套）</li><li><code>ensureToolResultPairing</code> — 修 orphan / duplicate tool_use（API 前最后一道）</li></ul><p>这些不算 Phase 1 步骤，但与预处理产物共同构成 wire payload。</p><hr /><h2 id="一次-iteration-数据流示例"><a class="markdownIt-Anchor" href="#一次-iteration-数据流示例"></a> 一次 iteration 数据流示例</h2><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">REPL: 500 条 messages</span><br><span class="line">  ↓ getMessagesAfterCompactBoundary（compact 在 #200）</span><br><span class="line">messagesForQuery: #200..#500</span><br><span class="line">  ↓ applyToolResultBudget — 80MB Read → &lt;persisted-output&gt; preview</span><br><span class="line">  ↓ snip — 删 #210-#280, tokensFreed=40k</span><br><span class="line">  ↓ cached microcompact — pendingCacheEdits=&#123;deletedToolIds:[...]&#125;，本地不变</span><br><span class="line">  ↓ context collapse — #300-#350 投影为 &lt;collapsed&gt;</span><br><span class="line">  ↓ autocompact — 仍超阈值 → yield [boundary, summary, tail]，替换为 20 条</span><br><span class="line">  ↓ callModel(prependUserContext(messagesForQuery))</span><br><span class="line">API: 压缩 history + cache_edits block + cache_reference 标注</span><br></pre></td></tr></table></figure><hr /><h2 id="关键文件索引"><a class="markdownIt-Anchor" href="#关键文件索引"></a> 关键文件索引</h2><table><thead><tr><th>主题</th><th>文件</th></tr></thead><tbody><tr><td>预处理编排</td><td><code>src/query.ts</code> ~365–468</td></tr><tr><td>boundary / snip 投影</td><td><code>src/utils/messages.ts</code></td></tr><tr><td>tool result budget / preview</td><td><code>src/utils/toolResultStorage.ts</code></td></tr><tr><td>microcompact</td><td><code>src/services/compact/microCompact.ts</code></td></tr><tr><td>autocompact 阈值</td><td><code>src/services/compact/autoCompact.ts</code></td></tr><tr><td>compact 摘要</td><td><code>src/services/compact/compact.ts</code></td></tr><tr><td>session memory compact</td><td><code>src/services/compact/sessionMemoryCompact.ts</code></td></tr><tr><td>API context_management</td><td><code>src/services/compact/apiMicrocompact.ts</code></td></tr><tr><td>cache editing 注入</td><td><code>src/services/api/claude.ts</code> <code>addCacheBreakpoints</code></td></tr><tr><td>content 插入位置</td><td><code>src/utils/contentArray.ts</code></td></tr><tr><td>collapse transcript 类型</td><td><code>src/types/logs.ts</code> <code>marble-origami-*</code></td></tr></tbody></table><hr /><h2 id="小结"><a class="markdownIt-Anchor" href="#小结"></a> 小结</h2><p>Claude Code 上下文预处理可以概括为：</p><ol><li><strong>阶梯式</strong>：budget → snip → microcompact → collapse → autocompact，前面成功则后面常 no-op</li><li><strong>双视图</strong>：REPL 全量 vs <code>messagesForQuery</code> 模型视图；snip/collapse 多靠投影；snip 另配 API 侧 <code>[id:xxx]</code></li><li><strong>Budget 状态机</strong>：fresh / frozen / mustReapply + wire-level 分组对齐 normalize；resume 靠 <code>contentReplacements</code> 重建</li><li><strong>Preview</strong>：原文前 2000 字节 + <code>&lt;persisted-output&gt;</code> 模板，落盘在 session <code>tool-results/</code></li><li><strong>Microcompact 三路径</strong>：time-based（mutate + reset cached state）→ cached MC（API cache edit）→ no-op</li><li><strong>Cache editing vs context_management</strong>：前者客户端指定删 cache tool；后者服务端策略（含 idle thinking 清理）</li><li><strong>snipTokensFreed + post-compact skip</strong>：修正 stale usage，避免 blocking preempt 误拦</li></ol><p>下一篇：<a href="/posts/claue-code-source-code-5/">第五篇</a> 专讲 <strong>reactive compact + 413 恢复</strong>（withhold、collapse drain、tryReactiveCompact）。再往后可写 <strong>compact 摘要 agent 内部流程</strong>或 <strong>Stop hooks + Attachments 管道</strong>。</p>]]></content>
    
    
    <summary type="html">深入 queryLoop Phase 1 上下文预处理：boundary 切片、tool result budget、snip、microcompact、context collapse、autocompact，以及 preview 生成与 cache editing 机制。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
  <entry>
    <title>Claude Code 源码解析（三）：queryLoop、Tool 执行与权限链</title>
    <link href="https://sunra.top/posts/c8a9d983/"/>
    <id>https://sunra.top/posts/c8a9d983/</id>
    <published>2026-06-18T06:00:00.000Z</published>
    <updated>2026-08-27T01:58:06.620Z</updated>
    
    <content type="html"><![CDATA[<p>接续<a href="/posts/claue-code-source-code-1/">第一篇</a>的主链路与<a href="/posts/claue-code-source-code-2/">第二篇</a>的 Memory 体系，本文先展开 <strong>queryLoop 各 phase 的完整流程</strong>，再深入 Tool 执行、权限链与 API 归一化。</p><span id="more"></span><h2 id="queryloop-总览"><a class="markdownIt-Anchor" href="#queryloop-总览"></a> queryLoop 总览</h2><p><code>query()</code> 是对外的 async generator；真正循环在内部的 <code>queryLoop()</code>（<code>query.ts</code> ~241 行），结构是 <strong><code>while (true)</code> + <code>state</code> 对象在 iteration 间传递</strong>，而不是简单的 <code>while (needsFollowUp)</code>。</p><p>一次用户输入可能触发 <strong>多轮 iteration</strong>（每次 tool 调用后 <code>continue</code> 递归），每轮 iteration 内部又分 <strong>预处理 → API 流式 → 收尾/Tool → 附件 → 状态更新</strong> 几个 phase。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br></pre></td><td class="code"><pre><span class="line">flowchart TB</span><br><span class="line">  ENTRY[&quot;query() 入口&lt;br/&gt;startRelevantMemoryPrefetch (整 turn 一次)&quot;]</span><br><span class="line">  LOOP[&quot;while (true) iteration&quot;]</span><br><span class="line"></span><br><span class="line">  subgraph P0[&quot;Phase 0: Iteration 初始化&quot;]</span><br><span class="line">    P0A[destructure state]</span><br><span class="line">    P0B[startSkillDiscoveryPrefetch]</span><br><span class="line">    P0C[&quot;yield stream_request_start&quot;]</span><br><span class="line">    P0D[queryTracking depth++]</span><br><span class="line">  end</span><br><span class="line"></span><br><span class="line">  subgraph P1[&quot;Phase 1: 上下文预处理&quot;]</span><br><span class="line">    P1A[getMessagesAfterCompactBoundary]</span><br><span class="line">    P1B[applyToolResultBudget]</span><br><span class="line">    P1C[snip]</span><br><span class="line">    P1D[microcompact]</span><br><span class="line">    P1E[context collapse]</span><br><span class="line">    P1F[autocompact]</span><br><span class="line">  end</span><br><span class="line"></span><br><span class="line">  subgraph P2[&quot;Phase 2: API 前 Setup&quot;]</span><br><span class="line">    P2A[StreamingToolExecutor]</span><br><span class="line">    P2B[getRuntimeMainLoopModel]</span><br><span class="line">    P2C[blocking limit 检查]</span><br><span class="line">  end</span><br><span class="line"></span><br><span class="line">  subgraph P3[&quot;Phase 3: API 流式&quot;]</span><br><span class="line">    P3A[callModel SSE]</span><br><span class="line">    P3B[yield stream_event / assistant]</span><br><span class="line">    P3C[流式 Tool 增量执行]</span><br><span class="line">    P3D&#123;needsFollowUp?&#125;</span><br><span class="line">  end</span><br><span class="line"></span><br><span class="line">  subgraph P4[&quot;Phase 4: 无 Tool 收尾&quot;]</span><br><span class="line">    P4A[413/media/max_tokens 恢复]</span><br><span class="line">    P4B[Stop hooks]</span><br><span class="line">    P4C[token budget]</span><br><span class="line">    P4D[return completed]</span><br><span class="line">  end</span><br><span class="line"></span><br><span class="line">  subgraph P5[&quot;Phase 5: Tool 执行&quot;]</span><br><span class="line">    P5A[getRemainingResults / runTools]</span><br><span class="line">    P5B[generateToolUseSummary 异步]</span><br><span class="line">  end</span><br><span class="line"></span><br><span class="line">  subgraph P6[&quot;Phase 6: 附件与 Prefetch&quot;]</span><br><span class="line">    P6A[getAttachmentMessages]</span><br><span class="line">    P6B[memory prefetch consume]</span><br><span class="line">    P6C[skill prefetch consume]</span><br><span class="line">    P6D[refreshTools]</span><br><span class="line">  end</span><br><span class="line"></span><br><span class="line">  subgraph P7[&quot;Phase 7: 递归&quot;]</span><br><span class="line">    P7A[&quot;state.messages += assistant + toolResults&quot;]</span><br><span class="line">    P7B[continue → 下一轮 iteration]</span><br><span class="line">  end</span><br><span class="line"></span><br><span class="line">  ENTRY --&gt; LOOP</span><br><span class="line">  LOOP --&gt; P0 --&gt; P1 --&gt; P2 --&gt; P3</span><br><span class="line">  P3 --&gt;|needsFollowUp=false| P4</span><br><span class="line">  P3 --&gt;|needsFollowUp=true| P5 --&gt; P6 --&gt; P7</span><br><span class="line">  P7 --&gt; LOOP</span><br><span class="line">  P4 --&gt; END[return Terminal]</span><br></pre></td></tr></table></figure><p>设置 <code>CLAUDE_CODE_PROFILE_QUERY=1</code> 可对照 <code>queryCheckpoint</code> 打点（见 <code>utils/queryProfiler.ts</code>）。</p><hr /><h2 id="queryloop-各-phase-详解"><a class="markdownIt-Anchor" href="#queryloop-各-phase-详解"></a> queryLoop 各 Phase 详解</h2><h3 id="phase-0iteration-初始化"><a class="markdownIt-Anchor" href="#phase-0iteration-初始化"></a> Phase 0：Iteration 初始化</h3><p>每次 <code>while (true)</code> 开头：</p><table><thead><tr><th>步骤</th><th>代码位置</th><th>说明</th></tr></thead><tbody><tr><td>解构 <code>state</code></td><td>~311</td><td><code>messages</code>、<code>toolUseContext</code>、<code>turnCount</code>、<code>pendingToolUseSummary</code> 等</td></tr><tr><td>Skill prefetch 启动</td><td>~331</td><td><code>startSkillDiscoveryPrefetch</code>，在模型 streaming + tool 执行期间后台跑</td></tr><tr><td><code>yield stream_request_start</code></td><td>~337</td><td>REPL spinner 切到 requesting</td></tr><tr><td><code>queryTracking.depth++</code></td><td>~347</td><td>链式追踪，每 iteration 深度 +1</td></tr></tbody></table><p><strong>整 turn 只做一次</strong>（在 <code>while</code> 外）：<code>startRelevantMemoryPrefetch</code> — relevant memory 后台检索，iteration 末尾 consume。</p><h3 id="phase-1上下文预处理api-调用前"><a class="markdownIt-Anchor" href="#phase-1上下文预处理api-调用前"></a> Phase 1：上下文预处理（API 调用前）</h3><p>顺序固定，<strong>snip → microcompact → collapse → autocompact</strong>，越往后越「重」：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">messagesForQuery = getMessagesAfterCompactBoundary(messages)</span><br><span class="line">  → applyToolResultBudget()          // tool 结果体积上限，content replacement</span><br><span class="line">  → snipCompactIfNeeded()            // HISTORY_SNIP：删整条 message</span><br><span class="line">  → microcompact()                   // 清空 tool 结果 / cache edit</span><br><span class="line">  → contextCollapse.applyCollapsesIfNeeded()  // span 摘要（不 yield，读时投影）</span><br><span class="line">  → autocompact()                    // 整段 compact，可能替换 messagesForQuery</span><br></pre></td></tr></table></figure><p>要点：</p><ul><li><strong>snipTokensFreed</strong> 传给 autocompact，因为 <code>tokenCountWithEstimation</code> 读不到 snip 释放的 token</li><li><strong>context collapse</strong> 在 autocompact 之前：若 collapse 已把 token 压到阈值以下，autocompact 成为 no-op，保留更细粒度上下文</li><li><strong>autocompact 成功</strong>：yield <code>buildPostCompactMessages()</code>，<code>messagesForQuery</code> 替换为 compact 后消息，重置 <code>autoCompactTracking</code></li></ul><h3 id="phase-2api-前-setup"><a class="markdownIt-Anchor" href="#phase-2api-前-setup"></a> Phase 2：API 前 Setup</h3><table><thead><tr><th>步骤</th><th>说明</th></tr></thead><tbody><tr><td>初始化 per-iteration 容器</td><td><code>assistantMessages[]</code>、<code>toolResults[]</code>、<code>toolUseBlocks[]</code>、<code>needsFollowUp=false</code></td></tr><tr><td><code>StreamingToolExecutor</code></td><td>feature gate <code>streamingToolExecution</code> 开启时创建</td></tr><tr><td><code>getRuntimeMainLoopModel</code></td><td>plan 模式 + 超 200k 可能换模型</td></tr><tr><td><strong>Blocking limit</strong></td><td>autocompact 关闭时的硬拦截；compact/session_memory/reactive compact 等路径跳过</td></tr></tbody></table><h3 id="phase-3api-流式"><a class="markdownIt-Anchor" href="#phase-3api-流式"></a> Phase 3：API 流式</h3><p>内层 <code>while (attemptWithFallback)</code> 包一层 <strong>模型 fallback 重试</strong>：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">for await (message of deps.callModel(&#123; messages: prependUserContext(...), ... &#125;))</span><br></pre></td></tr></table></figure><p>流式循环内对每个 <code>message</code>：</p><table><thead><tr><th>message 类型</th><th>行为</th></tr></thead><tbody><tr><td><code>stream_event</code></td><td>yield 给 REPL → <code>handleMessageFromStream</code> 更新 streaming 预览</td></tr><tr><td><code>assistant</code></td><td>push 到 <code>assistantMessages</code>；含 <code>tool_use</code> → <code>needsFollowUp=true</code></td></tr><tr><td><code>tool_use</code> block 完成</td><td><code>streamingToolExecutor.addTool()</code> + <code>getCompletedResults()</code> 增量 yield tool_result</td></tr><tr><td>streaming fallback</td><td>tombstone 旧 assistant、discard executor、清空状态、重试</td></tr><tr><td>withheld 413/media/max_tokens</td><td>暂 yield，留到 Phase 4 恢复</td></tr></tbody></table><p><strong>needsFollowUp 的语义</strong>：不依赖 API 的 <code>stop_reason === 'tool_use'</code>（不可靠），而是 <strong>流式收到 tool_use block 即置 true</strong>。</p><p>流式结束后：</p><ul><li>若有 <code>pendingCacheEdits</code> → yield microcompact boundary（用 API 回报的 <code>cache_deleted_input_tokens</code>）</li><li><code>executePostSamplingHooks</code>（fire-and-forget）</li></ul><p><strong>Abort 路径</strong>（流式刚结束）：<code>getRemainingResults()</code> 补 synthetic tool_result → cleanup → <code>return &#123; reason: 'aborted_streaming' &#125;</code></p><h3 id="phase-4无-tool-收尾needsfollowup"><a class="markdownIt-Anchor" href="#phase-4无-tool-收尾needsfollowup"></a> Phase 4：无 Tool 收尾（<code>!needsFollowUp</code>）</h3><p>模型本轮 <strong>没有 tool_use</strong>，进入收尾链：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line">flowchart TD</span><br><span class="line">  A[&quot;!needsFollowUp&quot;] --&gt; B&#123;yield pendingToolUseSummary&#125;</span><br><span class="line">  B --&gt; C&#123;withheld 413?&#125;</span><br><span class="line">  C --&gt;|是| D[collapse drain → continue]</span><br><span class="line">  C --&gt;|否| E&#123;withheld media?&#125;</span><br><span class="line">  E --&gt;|是| F[reactive compact → continue]</span><br><span class="line">  E --&gt;|否| G&#123;withheld max_output_tokens?&#125;</span><br><span class="line">  G --&gt;|是| H[escalate 64k / meta recovery → continue]</span><br><span class="line">  G --&gt;|否| I&#123;lastMessage.isApiErrorMessage?&#125;</span><br><span class="line">  I --&gt;|是| J[return completed]</span><br><span class="line">  I --&gt;|否| K[handleStopHooks]</span><br><span class="line">  K --&gt; L&#123;blockingErrors?&#125;</span><br><span class="line">  L --&gt;|是| M[inject errors → continue]</span><br><span class="line">  L --&gt;|否| N&#123;token budget continue?&#125;</span><br><span class="line">  N --&gt;|是| O[nudge meta message → continue]</span><br><span class="line">  N --&gt;|否| P[&quot;return &#123; reason: &#x27;completed&#x27; &#125;&quot;]</span><br></pre></td></tr></table></figure><p>关键 recovery <code>transition.reason</code>：</p><table><thead><tr><th>reason</th><th>触发条件</th></tr></thead><tbody><tr><td><code>collapse_drain_retry</code></td><td>withheld prompt-too-long，drain staged collapses</td></tr><tr><td><code>reactive_compact_retry</code></td><td>413/media 触发 reactive compact</td></tr><tr><td><code>max_output_tokens_escalate</code></td><td>8k cap 命中 → 单次升到 64k 重试</td></tr><tr><td><code>max_output_tokens_recovery</code></td><td>注入 meta「继续写，不要 recap」</td></tr><tr><td><code>stop_hook_blocking</code></td><td>Stop hook 返回 blocking error → 带 error 再调 API</td></tr><tr><td><code>token_budget_continuation</code></td><td>token budget 未耗尽 → meta nudge 继续</td></tr></tbody></table><p><strong>Stop hooks 不在 API error 上跑</strong>（防 death spiral：error → hook 加 token → 再 error）。</p><h3 id="phase-5tool-执行needsfollowup-true"><a class="markdownIt-Anchor" href="#phase-5tool-执行needsfollowup-true"></a> Phase 5：Tool 执行（<code>needsFollowUp === true</code>）</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">toolUpdates = streamingToolExecutor</span><br><span class="line">  ? streamingToolExecutor.getRemainingResults()</span><br><span class="line">  : runTools(toolUseBlocks, ...)</span><br></pre></td></tr></table></figure><ul><li>逐个 <code>yield update.message</code>（含 progress、tool_result、hook attachment）</li><li><code>normalizeMessagesForAPI</code> 过滤出 user 类型 push 到 <code>toolResults</code></li><li><code>update.newContext</code> 合并 tool 产生的 context 变更</li><li><code>hook_stopped_continuation</code> → <code>shouldPreventContinuation=true</code> → 后续 <code>return &#123; reason: 'hook_stopped' &#125;</code></li></ul><p>并行启动 <strong><code>generateToolUseSummary</code></strong>（Haiku），结果存 <code>nextPendingToolUseSummary</code>，<strong>下一轮 iteration Phase 4 开头 yield</strong>（在模型 streaming 的 5–30s 窗口内完成）。</p><p>Abort  mid-tools → <code>return &#123; reason: 'aborted_tools' &#125;</code></p><h3 id="phase-6附件与-prefetch"><a class="markdownIt-Anchor" href="#phase-6附件与-prefetch"></a> Phase 6：附件与 Prefetch</h3><p><strong>必须在 tool_result 全部完成之后</strong>——API 不允许 tool_result 与普通 user message 交错。</p><p>顺序：</p><ol><li><strong><code>getAttachmentMessages</code></strong> — IDE 上下文、queued command、file change 等</li><li><strong>Memory prefetch consume</strong> — <code>settledAt !== null</code> 且未消费时注入 relevant memory attachment</li><li><strong>Skill prefetch consume</strong> — 注入 skill discovery 结果</li><li><strong>Drain command queue</strong> — 已消费的 prompt/task-notification 从队列移除</li></ol><p>然后 <code>refreshTools()</code> — MCP 新连接的工具在下一轮可用。</p><h3 id="phase-7递归下一轮-iteration"><a class="markdownIt-Anchor" href="#phase-7递归下一轮-iteration"></a> Phase 7：递归（下一轮 iteration）</h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">state = &#123;</span><br><span class="line">  <span class="attr">messages</span>: [...messagesForQuery, ...assistantMessages, ...toolResults],</span><br><span class="line">  <span class="attr">toolUseContext</span>: toolUseContextWithQueryTracking,</span><br><span class="line">  <span class="attr">turnCount</span>: turnCount + <span class="number">1</span>,</span><br><span class="line">  <span class="attr">pendingToolUseSummary</span>: nextPendingToolUseSummary,</span><br><span class="line">  <span class="attr">maxOutputTokensRecoveryCount</span>: <span class="number">0</span>,</span><br><span class="line">  <span class="attr">hasAttemptedReactiveCompact</span>: <span class="literal">false</span>,</span><br><span class="line">  <span class="attr">transition</span>: &#123; <span class="attr">reason</span>: <span class="string">&#x27;next_turn&#x27;</span> &#125;,</span><br><span class="line">&#125;</span><br><span class="line"><span class="comment">// continue → 回到 Phase 0</span></span><br></pre></td></tr></table></figure><ul><li><code>maxTurns</code> 超限 → yield <code>max_turns_reached</code> attachment → return</li><li><code>queryCheckpoint('query_recursive_call')</code> 标记递归点</li></ul><h3 id="state-对象iteration-间传递什么"><a class="markdownIt-Anchor" href="#state-对象iteration-间传递什么"></a> State 对象：iteration 间传递什么</h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">type</span> <span class="title class_">State</span> = &#123;</span><br><span class="line">  <span class="attr">messages</span>: <span class="title class_">Message</span>[]                              <span class="comment">// 每轮 append assistant + toolResults</span></span><br><span class="line">  <span class="attr">toolUseContext</span>: <span class="title class_">ToolUseContext</span>                   <span class="comment">// tools/MCP/permissions 等可变上下文</span></span><br><span class="line">  <span class="attr">autoCompactTracking</span>: <span class="title class_">AutoCompactTrackingState</span>    <span class="comment">// compact 后 turn 计数</span></span><br><span class="line">  <span class="attr">maxOutputTokensRecoveryCount</span>: <span class="built_in">number</span>             <span class="comment">// max_tokens 多轮 recovery</span></span><br><span class="line">  <span class="attr">hasAttemptedReactiveCompact</span>: <span class="built_in">boolean</span>             <span class="comment">// 防 reactive compact 死循环</span></span><br><span class="line">  <span class="attr">maxOutputTokensOverride</span>: <span class="built_in">number</span> | <span class="literal">undefined</span>      <span class="comment">// 64k escalate</span></span><br><span class="line">  <span class="attr">pendingToolUseSummary</span>: <span class="title class_">Promise</span>&lt;...&gt; | <span class="literal">undefined</span>  <span class="comment">// 上轮 tool summary</span></span><br><span class="line">  <span class="attr">stopHookActive</span>: <span class="built_in">boolean</span> | <span class="literal">undefined</span>              <span class="comment">// stop hook 重入标记</span></span><br><span class="line">  <span class="attr">turnCount</span>: <span class="built_in">number</span>                                <span class="comment">// agent turn 计数（含 tool 轮）</span></span><br><span class="line">  <span class="attr">transition</span>: <span class="title class_">Continue</span> | <span class="literal">undefined</span>                 <span class="comment">// 上一轮为何 continue（测试/诊断）</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="querycheckpoint-对照表"><a class="markdownIt-Anchor" href="#querycheckpoint-对照表"></a> queryCheckpoint 对照表</h3><table><thead><tr><th>Checkpoint</th><th>Phase</th></tr></thead><tbody><tr><td><code>query_fn_entry</code></td><td>0</td></tr><tr><td><code>query_snip_start/end</code></td><td>1</td></tr><tr><td><code>query_microcompact_start/end</code></td><td>1</td></tr><tr><td><code>query_autocompact_start/end</code></td><td>1</td></tr><tr><td><code>query_setup_start/end</code></td><td>2</td></tr><tr><td><code>query_api_loop_start</code></td><td>3</td></tr><tr><td><code>query_api_streaming_start/end</code></td><td>3</td></tr><tr><td><code>query_tool_execution_start/end</code></td><td>5</td></tr><tr><td><code>query_recursive_call</code></td><td>7</td></tr></tbody></table><hr /><h2 id="tool-子系统总览"><a class="markdownIt-Anchor" href="#tool-子系统总览"></a> Tool 子系统总览</h2><p>Tool 相关逻辑主要在 <strong>Phase 3（流式增量）</strong> 和 <strong>Phase 5（批量收尾）</strong>：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">flowchart LR</span><br><span class="line">  P3[&quot;Phase 3 流式&lt;br/&gt;addTool + getCompletedResults&quot;]</span><br><span class="line">  P5[&quot;Phase 5&lt;br/&gt;getRemainingResults / runTools&quot;]</span><br><span class="line">  P3 --&gt; P5</span><br></pre></td></tr></table></figure><p>关键文件：</p><table><thead><tr><th>模块</th><th>路径</th><th>职责</th></tr></thead><tbody><tr><td>query 主循环</td><td><code>src/query.ts</code></td><td>流式消费、assistant 收集、tool 触发</td></tr><tr><td>Tool 编排</td><td><code>src/services/tools/toolOrchestration.ts</code></td><td><code>runTools</code>、batch 分区、并发/串行</td></tr><tr><td>流式 Tool 执行</td><td><code>src/services/tools/StreamingToolExecutor.ts</code></td><td>流式收到完整 tool_use 即排队执行</td></tr><tr><td>单 Tool 执行</td><td><code>src/services/tools/toolExecution.ts</code></td><td><code>runToolUse</code> → <code>checkPermissionsAndCallTool</code></td></tr><tr><td>API 流式</td><td><code>src/services/api/claude.ts</code></td><td>SSE → <code>content_block_stop</code> yield assistant</td></tr><tr><td>流式 UI</td><td><code>src/utils/messages.ts</code></td><td><code>handleMessageFromStream</code></td></tr><tr><td>权限 Hook</td><td><code>src/hooks/useCanUseTool.tsx</code></td><td>弹窗 / classifier / coordinator</td></tr><tr><td>API 归一化</td><td><code>src/utils/messages.ts</code></td><td><code>normalizeMessagesForAPI</code></td></tr></tbody></table><hr /><h2 id="一-checkpermissionsandcalltool-执行管线"><a class="markdownIt-Anchor" href="#一-checkpermissionsandcalltool-执行管线"></a> 一、<code>checkPermissionsAndCallTool</code> 执行管线</h2><p>单 Tool 执行的入口是 <code>runToolUse</code>（async generator），内部调用 <code>streamedCheckPermissionsAndCallTool</code>，最终落到 <code>checkPermissionsAndCallTool</code>（<code>toolExecution.ts</code> ~599 行）。</p><h3 id="11-完整流水线"><a class="markdownIt-Anchor" href="#11-完整流水线"></a> 1.1 完整流水线</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">runToolUse</span><br><span class="line">  └─ streamedCheckPermissionsAndCallTool   (Promise → yield 包装)</span><br><span class="line">       └─ checkPermissionsAndCallTool</span><br><span class="line">            ├─ ① Zod 校验 (tool.inputSchema.safeParse)</span><br><span class="line">            ├─ ② validateInput (各 Tool 自定义)</span><br><span class="line">            ├─ ③ Bash 预启动 classifier (与后续并行)</span><br><span class="line">            ├─ ④ 输入预处理 (strip _simulatedSedEdit, backfillObservableInput)</span><br><span class="line">            ├─ ⑤ PreToolUse hooks</span><br><span class="line">            ├─ ⑥ 权限决策 (resolveHookPermissionDecision → canUseTool)</span><br><span class="line">            ├─ ⑦ tool.call()  (允许时)</span><br><span class="line">            ├─ ⑧ PostToolUse hooks</span><br><span class="line">            └─ ⑨ addToolResult → createUserMessage(tool_result)</span><br></pre></td></tr></table></figure><h3 id="12-各阶段要点"><a class="markdownIt-Anchor" href="#12-各阶段要点"></a> 1.2 各阶段要点</h3><p><strong>① Zod 校验</strong>：<code>inputSchema.safeParse(input)</code> 失败则直接返回 <code>InputValidationError</code> 的 <code>tool_result</code>，不调用 Tool。deferred tool 还会附加 schema-not-sent hint，提示模型先 <code>ToolSearch</code>。</p><p><strong>② validateInput</strong>：各 Tool 自定义校验（路径是否存在、命令是否合法等），失败同样提前返回 error <code>tool_result</code>。</p><p><strong>③ Bash 投机优化</strong>：对 Bash 提前 <code>startSpeculativeClassifierCheck</code>，与 PreToolUse hook、权限弹窗并行，减少 auto 模式等待。</p><p><strong>④ 输入预处理</strong>：</p><ul><li>去掉模型不应提供的 <code>_simulatedSedEdit</code>（仅权限系统在用户批准后注入）</li><li><code>backfillObservableInput</code> 在浅拷贝上补全字段（如 <code>~</code> 展开为绝对路径），供 Hook/权限使用；<strong>不污染</strong>传给 <code>tool.call()</code> 的原始 input，避免 VCR hash 变化</li></ul><p><strong>⑤ PreToolUse hooks</strong>（<code>runPreToolUseHooks</code>）可能产生：</p><table><thead><tr><th>事件</th><th>效果</th></tr></thead><tbody><tr><td><code>message</code> / <code>progress</code></td><td>中间消息、进度</td></tr><tr><td><code>hookPermissionResult</code></td><td>Hook 直接给权限结论</td></tr><tr><td><code>hookUpdatedInput</code></td><td>修改 input</td></tr><tr><td><code>preventContinuation</code> / <code>stopReason</code></td><td>阻止继续</td></tr><tr><td><code>stop</code></td><td>直接返回 error tool_result</td></tr></tbody></table><p><strong>⑥ 权限</strong>：见下文「canUseTool 权限链」。</p><p><strong>⑦ tool.call()</strong>：权限通过后调用，传入 <code>toolUseId</code>、<code>userModified</code>、progress 回调等。</p><p><strong>⑧ PostToolUse hooks</strong>：MCP Tool 可能修改 output；非 MCP 先 <code>addToolResult</code>，MCP 在 Hook 后再 <code>addToolResult</code>。</p><p><strong>⑨ 产出</strong>：<code>resultingMessages: MessageUpdateLazy[]</code>，含 <code>tool_result</code> user message、attachment、hook summary 等；<code>streamedCheckPermissionsAndCallTool</code> 逐个 <code>yield</code> 给上层。</p><h3 id="13-repl-中的-wiring"><a class="markdownIt-Anchor" href="#13-repl-中的-wiring"></a> 1.3 REPL 中的 wiring</h3><p><code>canUseTool</code> 在 REPL 中由 React hook 创建（<code>REPL.tsx</code> ~2382 行）：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> canUseTool = <span class="title function_">useCanUseTool</span>(setToolUseConfirmQueue, setToolPermissionContext);</span><br></pre></td></tr></table></figure><p>经 <code>getToolUseContext</code> 注入 query 链路，最终传入 <code>checkPermissionsAndCallTool</code>。</p><hr /><h2 id="二-tool-并发无读写锁靠-isconcurrencysafe"><a class="markdownIt-Anchor" href="#二-tool-并发无读写锁靠-isconcurrencysafe"></a> 二、Tool 并发：无读写锁，靠 <code>isConcurrencySafe</code></h2><p>Claude Code <strong>没有通用读写锁，也没有 Tool 间依赖图</strong>。并发完全靠每个 Tool 声明的 <code>isConcurrencySafe(input)</code> + 批处理/队列规则。</p><h3 id="21-谁可以并发"><a class="markdownIt-Anchor" href="#21-谁可以并发"></a> 2.1 谁可以并发？</h3><p>每个 Tool 自己实现 <code>isConcurrencySafe</code>：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// FileReadTool — 固定 true</span></span><br><span class="line"><span class="title function_">isConcurrencySafe</span>(<span class="params"></span>) &#123; <span class="keyword">return</span> <span class="literal">true</span> &#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// BashTool — 只读命令可并发</span></span><br><span class="line"><span class="title function_">isConcurrencySafe</span>(<span class="params">input</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> <span class="variable language_">this</span>.<span class="property">isReadOnly</span>?.(input) ?? <span class="literal">false</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Edit / Write / 多数 MCP — 默认 false（DEFAULT_TOOL_IMPL）</span></span><br></pre></td></tr></table></figure><p>这不是 RW lock，而是 <strong>粗粒度「只读 vs 有副作用」</strong> 分类。</p><h3 id="22-非流式partitiontoolcalls-batch"><a class="markdownIt-Anchor" href="#22-非流式partitiontoolcalls-batch"></a> 2.2 非流式：<code>partitionToolCalls</code> + batch</h3><p><code>toolOrchestration.ts</code> 的 <code>partitionToolCalls</code> 按模型输出顺序分组：</p><ul><li><strong>连续 safe batch</strong> → <code>runToolsConcurrently</code>（默认最多 10，<code>CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY</code>）</li><li><strong>非 safe batch</strong> → <code>runToolsSerially</code> 逐个跑</li><li>batch 之间串行；<strong>不会</strong>把 <code>[Read, Edit, Read]</code> 里的两个 Read 跨 Edit 合并</li></ul><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 连续 safe tool 合并为一批</span></span><br><span class="line"><span class="keyword">if</span> (isConcurrencySafe &amp;&amp; acc[acc.<span class="property">length</span> - <span class="number">1</span>]?.<span class="property">isConcurrencySafe</span>) &#123;</span><br><span class="line">  acc[acc.<span class="property">length</span> - <span class="number">1</span>]!.<span class="property">blocks</span>.<span class="title function_">push</span>(toolUse)</span><br><span class="line">&#125; <span class="keyword">else</span> &#123;</span><br><span class="line">  acc.<span class="title function_">push</span>(&#123; isConcurrencySafe, <span class="attr">blocks</span>: [toolUse] &#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="23-流式streamingtoolexecutor-队列"><a class="markdownIt-Anchor" href="#23-流式streamingtoolexecutor-队列"></a> 2.3 流式：<code>StreamingToolExecutor</code> 队列</h3><p>默认开启 <code>streamingToolExecution</code> 时，queryLoop 使用 <code>StreamingToolExecutor</code>：每个 <code>content_block_stop</code> 产出的完整 <code>tool_use</code> 即 <code>addTool</code> 排队。</p><p>核心规则（<code>canExecuteTool</code>）：</p><ul><li>无 executing → 任意 tool 可启动</li><li>有 executing → 只有 <strong>当前 + 已在跑的全是 safe</strong> 才能再启动 safe tool</li><li>队首是非 safe 且前面有 executing → <strong>break</strong>，等前一个完成</li></ul><p>结果按 <strong>模型输出顺序</strong> yield（<code>getCompletedResults</code> 顺序遍历）。</p><h3 id="24-依赖如何识别"><a class="markdownIt-Anchor" href="#24-依赖如何识别"></a> 2.4 「依赖」如何识别？</h3><p><strong>不识别。</strong> 没有 DAG、没有 file_path 冲突检测、没有「B 的 input 引用 A 的 output」分析。</p><p>实际策略：</p><ol><li><strong>假设模型按依赖顺序发 tool</strong>（Read 在前，Edit 在后）</li><li><strong>Edit/Bash-write 等非 safe → 强制串行</strong></li><li><strong>Bash 出错会 cancel 兄弟 tool</strong>（<code>siblingAbortController</code>），因为命令链常有隐式依赖</li><li><strong>Read 类失败不 cancel 兄弟</strong>（彼此独立）</li></ol><hr /><h2 id="三-流式消息如何拼接"><a class="markdownIt-Anchor" href="#三-流式消息如何拼接"></a> 三、流式消息如何拼接</h2><p>分 <strong>API 层</strong>（<code>claude.ts</code>）和 <strong>UI 层</strong>（<code>messages.ts</code>）两层。</p><h3 id="31-api-层sse-逐-block-产出-assistant-message"><a class="markdownIt-Anchor" href="#31-api-层sse-逐-block-产出-assistant-message"></a> 3.1 API 层：SSE → 逐 block 产出 assistant message</h3><table><thead><tr><th>SSE 事件</th><th>作用</th></tr></thead><tbody><tr><td><code>message_start</code></td><td>保存 <code>partialMessage</code>（id、role 等）</td></tr><tr><td><code>content_block_start</code></td><td><code>contentBlocks[index] = &#123; ...block, text/input/thinking: '' &#125;</code></td></tr><tr><td><code>content_block_delta</code></td><td>追加 delta 到对应 block</td></tr><tr><td><code>content_block_stop</code></td><td><strong>yield 一条完整 assistant message</strong>（content 仅含该 block）</td></tr><tr><td><code>message_delta</code></td><td>回填最后一条 message 的 <code>usage</code>、<code>stop_reason</code></td></tr></tbody></table><p><strong>Text 拼接</strong>：<code>content_block_delta</code> 的 <code>text_delta</code> → <code>contentBlock.text += delta.text</code></p><p><strong>Tool input 拼接</strong>：<code>input_json_delta</code> → <code>contentBlock.input += delta.partial_json</code>（字符串累加，<code>content_block_stop</code> 时 parse 成 object）</p><p>要点：<strong>一个 turn 多个 block → 多条 assistant message</strong>（text、thinking、tool_use 各一条）。</p><h3 id="32-ui-层增量渲染"><a class="markdownIt-Anchor" href="#32-ui-层增量渲染"></a> 3.2 UI 层：增量渲染</h3><p><code>handleMessageFromStream</code> 处理 <code>stream_event</code>：</p><ul><li><strong>Text</strong>：<code>content_block_start</code>(text) 重置 <code>streamingText</code>；<code>text_delta</code> 追加</li><li><strong>Tool input 预览</strong>：<code>content_block_start</code>(tool_use) 加入 <code>streamingToolUses</code>；<code>input_json_delta</code> 追加 <code>unparsedToolInput</code></li><li><strong>Turn 结束</strong>：<code>message_stop</code> 清空 <code>streamingToolUses</code></li></ul><p>UI 的 <code>streamingText</code> / <code>streamingToolUses</code> 是 <strong>预览</strong>；持久化 transcript 靠 API 层 <code>content_block_stop</code> yield 的完整 message。</p><h3 id="33-queryloop-如何拼成下一轮-context"><a class="markdownIt-Anchor" href="#33-queryloop-如何拼成下一轮-context"></a> 3.3 queryLoop 如何拼成下一轮 context</h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (message.<span class="property">type</span> === <span class="string">&#x27;assistant&#x27;</span>) &#123;</span><br><span class="line">  assistantMessages.<span class="title function_">push</span>(message)</span><br><span class="line">  <span class="comment">// 每个 tool_use block 完成即 addTool</span></span><br><span class="line">  <span class="keyword">for</span> (<span class="keyword">const</span> toolBlock <span class="keyword">of</span> msgToolUseBlocks) &#123;</span><br><span class="line">    streamingToolExecutor.<span class="title function_">addTool</span>(toolBlock, message)</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>一轮 iteration 结束后：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">messagesForQuery</span><br><span class="line">  + assistantMessages[]     (可能多条：text / thinking / tool_use...)</span><br><span class="line">  + toolResults[]           (每个 tool_use 对应 user tool_result)</span><br><span class="line">→ 下一轮 callModel</span><br></pre></td></tr></table></figure><p><strong>Prompt cache 注意</strong>：queryLoop 刻意 <strong>不 mutate 原始 assistant message</strong>（只 clone 后 yield），注释写明 mutating 会破坏 prompt caching 的 byte 匹配。</p><hr /><h2 id="四-canusetool-权限链"><a class="markdownIt-Anchor" href="#四-canusetool-权限链"></a> 四、<code>canUseTool</code> 权限链</h2><h3 id="41-完整调用栈"><a class="markdownIt-Anchor" href="#41-完整调用栈"></a> 4.1 完整调用栈</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">checkPermissionsAndCallTool</span><br><span class="line">  └─ resolveHookPermissionDecision (PreToolUse hook 结果优先)</span><br><span class="line">       └─ canUseTool (useCanUseTool hook)</span><br><span class="line">            └─ hasPermissionsToUseTool (规则层，纯逻辑)</span><br><span class="line">                 └─ [behavior=ask] → 交互 / classifier / coordinator 分支</span><br></pre></td></tr></table></figure><h3 id="42-第一层resolvehookpermissiondecision"><a class="markdownIt-Anchor" href="#42-第一层resolvehookpermissiondecision"></a> 4.2 第一层：<code>resolveHookPermissionDecision</code></h3><p>PreToolUse hook 可能直接给 <code>allow</code> / <code>deny</code> / <code>ask</code>。关键约束：<strong>Hook 的 <code>allow</code> 不能绕过 settings.json 的 deny/ask 规则</strong>（inc-4788 类比）。</p><table><thead><tr><th>Hook 结果</th><th>后续</th></tr></thead><tbody><tr><td><code>allow</code> + 无 deny/ask 规则</td><td>直接允许（跳过弹窗）</td></tr><tr><td><code>allow</code> + deny 规则</td><td>deny 覆盖 hook</td></tr><tr><td><code>allow</code> + ask 规则</td><td>仍走 <code>canUseTool</code> 弹窗</td></tr><tr><td><code>allow</code> + 需交互 Tool（如 AskUserQuestion）</td><td>必须走 <code>canUseTool</code></td></tr><tr><td><code>deny</code></td><td>直接拒绝</td></tr><tr><td><code>ask</code> / 无 hook</td><td>正常走 <code>canUseTool</code>，可带 <code>forceDecision</code></td></tr></tbody></table><h3 id="43-第二层haspermissionstousetool规则引擎"><a class="markdownIt-Anchor" href="#43-第二层haspermissionstousetool规则引擎"></a> 4.3 第二层：<code>hasPermissionsToUseTool</code>（规则引擎）</h3><p>纯配置/规则判断，返回 <code>allow</code> / <code>deny</code> / <code>ask</code>，不负责 UI。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">flowchart TD</span><br><span class="line">  A[hasPermissionsToUseToolInner] --&gt; B&#123;整 Tool deny 规则?&#125;</span><br><span class="line">  B --&gt;|是| DENY[deny]</span><br><span class="line">  B --&gt;|否| C&#123;整 Tool ask 规则?&#125;</span><br><span class="line">  C --&gt;|是且非 sandbox 自动允许| ASK1[ask]</span><br><span class="line">  C --&gt;|否| D[tool.checkPermissions]</span><br><span class="line">  D --&gt; E&#123;Tool 返回 deny?&#125;</span><br><span class="line">  E --&gt;|是| DENY</span><br><span class="line">  E --&gt;|否| F&#123;requiresUserInteraction + ask?&#125;</span><br><span class="line">  F --&gt;|是| ASK2[ask]</span><br><span class="line">  F --&gt;|否| G&#123;内容级 ask / safetyCheck?&#125;</span><br><span class="line">  G --&gt;|是| ASK3[ask]</span><br><span class="line">  G --&gt;|否| H&#123;bypassPermissions 模式?&#125;</span><br><span class="line">  H --&gt;|是| ALLOW1[allow]</span><br><span class="line">  H --&gt;|否| I&#123;always-allow 规则?&#125;</span><br><span class="line">  I --&gt;|是| ALLOW2[allow]</span><br><span class="line">  I --&gt;|否| J[passthrough → ask]</span><br></pre></td></tr></table></figure><p><strong>规则来源</strong>（按优先级）：</p><ol><li><strong>整 Tool 级</strong>：<code>getDenyRuleForTool</code> / <code>getAskRuleForTool</code>（settings.json、session、cli 等）</li><li><strong>Tool 实现级</strong>：<code>tool.checkPermissions()</code>（如 Bash 子命令 <code>Bash(git *)</code>）</li><li><strong>模式级</strong>：<code>bypassPermissions</code> / plan+bypass 可用 → 直接 allow</li><li><strong>always-allow 规则</strong>：前缀匹配通过 → allow</li><li><strong>默认</strong>：<code>passthrough</code> → 转成 <code>ask</code></li></ol><p><strong>免疫 bypass 的检查</strong>（即使在 yolo/bypass 下也要弹窗）：</p><ul><li>内容级 ask 规则（<code>ruleBehavior === 'ask'</code>）</li><li><code>safetyCheck</code>（<code>.git/</code>、<code>.claude/</code> 等敏感路径）</li></ul><h3 id="44-第三层模式变换与-auto-classifier"><a class="markdownIt-Anchor" href="#44-第三层模式变换与-auto-classifier"></a> 4.4 第三层：模式变换与 auto classifier</h3><p>规则层返回 <code>ask</code> 后：</p><table><thead><tr><th>模式</th><th>行为</th></tr></thead><tbody><tr><td><code>dontAsk</code></td><td><code>ask</code> → <code>deny</code></td></tr><tr><td><code>auto</code> / plan+auto</td><td>走 <strong>auto mode classifier</strong>（side query）</td></tr><tr><td>其他</td><td>保持 <code>ask</code>，交给 UI</td></tr></tbody></table><p><strong>auto 模式 fast-path</strong>（跳过 classifier API）：</p><ol><li><strong>acceptEdits 探测</strong>：用虚拟 <code>acceptEdits</code> 模式再调 <code>checkPermissions</code>，若 allow 则直接放行</li><li><strong>safe allowlist</strong>：Read/Grep 等安全 Tool 直接 allow</li><li>否则 → <code>classifyYoloAction()</code>（YOLO classifier）</li></ol><h3 id="45-第四层usecanusetool交互ui"><a class="markdownIt-Anchor" href="#45-第四层usecanusetool交互ui"></a> 4.5 第四层：<code>useCanUseTool</code>（交互/UI）</h3><p>规则层返回后，<code>useCanUseTool</code> 把决策变成 <strong>Promise</strong>：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">flowchart TD</span><br><span class="line">  START[hasPermissionsToUseTool 结果] --&gt; ALLOW&#123;behavior=allow?&#125;</span><br><span class="line">  ALLOW --&gt;|是| RESOLVE_ALLOW[resolve buildAllow]</span><br><span class="line">  ALLOW --&gt;|否| SWITCH&#123;behavior&#125;</span><br><span class="line">  SWITCH --&gt;|deny| RESOLVE_DENY[resolve deny]</span><br><span class="line">  SWITCH --&gt;|ask| COORD&#123;coordinator / swarm?&#125;</span><br><span class="line">  COORD --&gt; SPEC&#123;Bash speculative classifier?&#125;</span><br><span class="line">  SPEC --&gt;|2s 内 high confidence| AUTO_ALLOW[跳过弹窗 allow]</span><br><span class="line">  SPEC --&gt;|timeout| DIALOG[handleInteractivePermission]</span><br><span class="line">  DIALOG --&gt; QUEUE[PermissionRequest UI]</span><br><span class="line">  QUEUE --&gt; USER[用户 Allow/Reject]</span><br><span class="line">  USER --&gt; DONE[resolve PermissionDecision]</span><br></pre></td></tr></table></figure><p><strong><code>handleInteractivePermission</code></strong> 要点：</p><ul><li>往 <code>toolUseConfirmQueue</code> 推 <code>ToolUseConfirm</code> 组件</li><li>后台并行跑 PermissionRequest hooks + Bash classifier</li><li>用户先点 vs classifier 先出 → <code>resolveOnce</code> 防重复</li><li>Bridge/Kairos channel 可远程审批</li></ul><p><strong>Bash 投机 classifier</strong> 与 <code>checkPermissionsAndCallTool</code> 里的 <code>startSpeculativeClassifierCheck</code> 配合：弹窗前最多等 2s，high confidence match → 静默 allow。</p><h3 id="46-决策如何回到-tool-执行"><a class="markdownIt-Anchor" href="#46-决策如何回到-tool-执行"></a> 4.6 决策如何回到 Tool 执行</h3><table><thead><tr><th><code>permissionDecision.behavior</code></th><th>结果</th></tr></thead><tbody><tr><td><code>allow</code></td><td><code>tool.call()</code>，可能带 <code>updatedInput</code>、<code>acceptFeedback</code>、粘贴图片</td></tr><tr><td><code>deny</code> / <code>ask</code> 被拒</td><td>error <code>tool_result</code>，不执行 Tool</td></tr></tbody></table><hr /><h2 id="五-normalizemessagesforapi-合并逻辑"><a class="markdownIt-Anchor" href="#五-normalizemessagesforapi-合并逻辑"></a> 五、<code>normalizeMessagesForAPI</code> 合并逻辑</h2><p>运行时 transcript 里 message 类型很多（progress、attachment、system、virtual…），API 只接受 <strong>user / assistant</strong> 且格式严格。<code>normalizeMessagesForAPI</code>（<code>utils/messages.ts</code> ~1989 行）是 <strong>transcript → API payload</strong> 的转换器。</p><h3 id="51-何时调用"><a class="markdownIt-Anchor" href="#51-何时调用"></a> 5.1 何时调用</h3><ul><li><strong><code>claude.ts</code></strong>：每次 <code>callModel</code> 发 API 前</li><li><strong><code>query.ts</code></strong>：tool result 写入 messages 前（局部 normalize）</li></ul><h3 id="52-整体流水线"><a class="markdownIt-Anchor" href="#52-整体流水线"></a> 5.2 整体流水线</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">flowchart LR</span><br><span class="line">  IN[原始 messages] --&gt; R1[reorderAttachmentsForAPI]</span><br><span class="line">  R1 --&gt; R2[过滤 isVirtual]</span><br><span class="line">  R2 --&gt; R3[逐条 walk + merge]</span><br><span class="line">  R3 --&gt; R4[relocateToolReferenceSiblings]</span><br><span class="line">  R4 --&gt; R5[filterOrphanedThinkingOnly]</span><br><span class="line">  R5 --&gt; R6[strip trailing thinking / whitespace]</span><br><span class="line">  R6 --&gt; R7[smooshSystemReminderSiblings]</span><br><span class="line">  R7 --&gt; R8[sanitizeErrorToolResultContent]</span><br><span class="line">  R8 --&gt; R9[appendMessageTag / validateImages]</span><br><span class="line">  R9 --&gt; OUT[API-ready messages]</span><br></pre></td></tr></table></figure><h3 id="53-核心-merge-规则"><a class="markdownIt-Anchor" href="#53-核心-merge-规则"></a> 5.3 核心 merge 规则</h3><h4 id="user-message相邻合并"><a class="markdownIt-Anchor" href="#user-message相邻合并"></a> User message：相邻合并</h4><p>连续两条 <code>user</code> → <code>mergeUserMessages</code>：</p><ul><li>拼接 <code>content[]</code> 数组</li><li>text-text 接缝处插入 <code>\n</code>（防 <code>&quot;2 + 2&quot; + &quot;3 + 3&quot;</code> → <code>&quot;2 + 23 + 3&quot;</code>）</li><li><code>hoistToolResults</code>：<strong>tool_result 块必须排在前面</strong>（API 要求）</li><li>attachment 也会 merge 进上一条 user</li></ul><h4 id="assistant-message按-messageid-合并"><a class="markdownIt-Anchor" href="#assistant-message按-messageid-合并"></a> Assistant message：按 <code>message.id</code> 合并</h4><p>流式阶段 <strong>一 block 一条 assistant</strong>；normalize 时 <strong>同 id 合并 content</strong>：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">export</span> <span class="keyword">function</span> <span class="title function_">mergeAssistantMessages</span>(<span class="params">a, b</span>): <span class="title class_">AssistantMessage</span> &#123;</span><br><span class="line">  <span class="keyword">return</span> &#123;</span><br><span class="line">    ...a,</span><br><span class="line">    <span class="attr">message</span>: &#123;</span><br><span class="line">      ...a.<span class="property">message</span>,</span><br><span class="line">      <span class="attr">content</span>: [...a.<span class="property">message</span>.<span class="property">content</span>, ...b.<span class="property">message</span>.<span class="property">content</span>],</span><br><span class="line">    &#125;,</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>向后遍历时 <strong>跳过中间的 tool_result user message</strong>，所以典型 turn：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">流式 yield:</span><br><span class="line">  assistant(id=msg_abc, [text])</span><br><span class="line">  assistant(id=msg_abc, [thinking])</span><br><span class="line">  assistant(id=msg_abc, [tool_use])</span><br><span class="line">  user(tool_result)</span><br><span class="line"></span><br><span class="line">normalize 后 API 看到:</span><br><span class="line">  assistant(id=msg_abc, [text, thinking, tool_use])</span><br><span class="line">  user(tool_result)</span><br></pre></td></tr></table></figure><h4 id="过滤掉的消息类型"><a class="markdownIt-Anchor" href="#过滤掉的消息类型"></a> 过滤掉的消息类型</h4><p>不进入 API：<code>progress</code>、普通 <code>system</code>（<code>local_command</code> 例外 → 转 user）、<code>isSyntheticApiErrorMessage</code>、<code>isVirtual</code>。</p><h4 id="tool-input-规范化"><a class="markdownIt-Anchor" href="#tool-input-规范化"></a> Tool input 规范化</h4><p>assistant 的 <code>tool_use</code> block 会调 <code>normalizeToolInputForAPI</code>；tool search 关闭时 strip <code>caller</code> 等非标准字段。</p><h3 id="54-后处理防-api-400"><a class="markdownIt-Anchor" href="#54-后处理防-api-400"></a> 5.4 后处理（防 API 400）</h3><table><thead><tr><th>步骤</th><th>目的</th></tr></thead><tbody><tr><td><code>filterOrphanedThinkingOnlyMessages</code></td><td>去掉 compaction 后孤立的 thinking-only assistant</td></tr><tr><td><code>filterTrailingThinkingFromLastAssistant</code></td><td>最后一条 assistant 不能有 trailing thinking</td></tr><tr><td><code>filterWhitespaceOnlyAssistantMessages</code></td><td>去掉纯空白 assistant</td></tr><tr><td><code>smooshSystemReminderSiblings</code></td><td>把 <code>&lt;system-reminder&gt;</code> 文本 fold 进 tool_result</td></tr><tr><td><code>sanitizeErrorToolResultContent</code></td><td>error tool_result 里不能有 image 等</td></tr><tr><td><code>validateImagesForAPI</code></td><td>图片尺寸校验</td></tr></tbody></table><h3 id="55-运行时细粒度-vs-api-粗粒度"><a class="markdownIt-Anchor" href="#55-运行时细粒度-vs-api-粗粒度"></a> 5.5 运行时细粒度 vs API 粗粒度</h3><table><thead><tr><th>阶段</th><th>assistant 表示</th></tr></thead><tbody><tr><td>流式 yield</td><td>N 条 message，各 1 个 content block</td></tr><tr><td>transcript 存储</td><td>同上（利于 prompt cache byte 匹配）</td></tr><tr><td>发 API 前 normalize</td><td>1 条 message，content 数组拼接</td></tr></tbody></table><hr /><h2 id="六-三条链路交汇"><a class="markdownIt-Anchor" href="#六-三条链路交汇"></a> 六、三条链路交汇</h2><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">                  ┌─────────────────────────────────┐</span><br><span class="line">用户输入 ────────►│ queryLoop                       │</span><br><span class="line">                  │  callModel (stream)             │</span><br><span class="line">                  │    ↓ N 条 assistant (by block)  │</span><br><span class="line">                  │  StreamingToolExecutor          │</span><br><span class="line">                  │    ↓ runToolUse                 │</span><br><span class="line">                  │      checkPermissionsAndCallTool│</span><br><span class="line">                  │        resolveHookPermission    │</span><br><span class="line">                  │          canUseTool ◄── REPL UI  │</span><br><span class="line">                  │        tool.call()              │</span><br><span class="line">                  │    ↓ tool_result user msgs      │</span><br><span class="line">                  │  messages = [...prev, asst, tr] │</span><br><span class="line">                  │    ↓ normalizeMessagesForAPI    │</span><br><span class="line">                  │  下一轮 callModel               │</span><br><span class="line">                  └─────────────────────────────────┘</span><br></pre></td></tr></table></figure><hr /><h2 id="关键文件索引"><a class="markdownIt-Anchor" href="#关键文件索引"></a> 关键文件索引</h2><table><thead><tr><th>主题</th><th>文件</th></tr></thead><tbody><tr><td>query 主循环</td><td><code>src/query.ts</code></td></tr><tr><td>Tool 编排</td><td><code>src/services/tools/toolOrchestration.ts</code></td></tr><tr><td>流式 Tool</td><td><code>src/services/tools/StreamingToolExecutor.ts</code></td></tr><tr><td>单 Tool 执行</td><td><code>src/services/tools/toolExecution.ts</code></td></tr><tr><td>Hook 权限桥接</td><td><code>src/services/tools/toolHooks.ts</code></td></tr><tr><td>canUseTool</td><td><code>src/hooks/useCanUseTool.tsx</code></td></tr><tr><td>规则引擎</td><td><code>src/utils/permissions/permissions.ts</code></td></tr><tr><td>交互弹窗</td><td><code>src/hooks/toolPermission/handlers/interactiveHandler.ts</code></td></tr><tr><td>API 流式</td><td><code>src/services/api/claude.ts</code></td></tr><tr><td>流式 UI + normalize</td><td><code>src/utils/messages.ts</code></td></tr><tr><td>Tool 接口</td><td><code>src/Tool.ts</code></td></tr></tbody></table><hr /><h2 id="小结"><a class="markdownIt-Anchor" href="#小结"></a> 小结</h2><p>Claude Code 的 queryLoop + Tool 子系统可以概括为：</p><ol><li><strong>queryLoop 七 Phase</strong>：初始化 → 预处理（snip/microcompact/collapse/autocompact）→ setup → API 流式 → 无 Tool 收尾 / Tool 执行 → 附件 prefetch → 递归 <code>continue</code></li><li><strong>needsFollowUp</strong>：流式收到 <code>tool_use</code> 即 true，不依赖 <code>stop_reason</code></li><li><strong>执行管线</strong>：Zod → validateInput → PreHook → canUseTool → call → PostHook → tool_result</li><li><strong>并发策略</strong>：无 RW lock、无依赖图；<code>isConcurrencySafe</code> + batch/队列</li><li><strong>流式拼接</strong>：delta 累加 → <code>content_block_stop</code> yield；normalize 时按 <code>message.id</code> merge</li><li><strong>权限链</strong>：Hook 不 bypass deny/ask → 规则引擎 → auto classifier / 弹窗</li></ol><p>下一篇：<a href="/posts/claue-code-source-code-4/">第四篇</a> 专讲 <strong>上下文预处理全链路</strong>（budget / snip / microcompact / collapse / autocompact / cache editing）。再往后可写 <strong>compact 摘要 agent 内部流程</strong>或 <strong>reactive compact + 413 恢复</strong>。</p>]]></content>
    
    
    <summary type="html">queryLoop 各 phase 详解，以及 Tool 执行、checkPermissionsAndCallTool、并发策略、流式拼接、canUseTool 权限链与 normalizeMessagesForAPI。</summary>
    
    
    
    <category term="Agent" scheme="https://sunra.top/categories/Agent/"/>
    
    
  </entry>
  
</feed>
