arch-acp-bridge.html 20.1 KB
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>ACP Bridge · LemonBoss 桥接 · LemonBoss PRD</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,500;9..144,700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="assets/shell.css" />
</head>
<body data-page="arch-acp-bridge.html">
<div class="shell">
  <aside id="sidebar"></aside>
  <main class="page">
    <header class="page-head">
      <div class="crumb"><span class="pill">05 · ARCHITECTURE</span><span class="sep">/</span><span>ACP Bridge</span></div>
      <h1>ACP Bridge · LoopController ↔ LemonBoss agent-manager</h1>
      <p class="deck">前面文档反复出现 <code>agentManager.callAgent(name, input)</code>。本页交代这一行的<b>实际实现链路</b>:从 LoopController 调用,到 LemonBoss 既有 agent-manager 怎么走,到 SessionPool 复用,到 Claude CLI 子进程启动,到工具调用回路。</p>
    </header>
    <article class="body">

      <section>
        <h2>① 现状梳理 · 既有 LemonBoss 链路</h2>
        <p>LemonBoss 已经有完整的 ACP / Master Agent / SessionPool 链路:</p>
        <svg viewBox="0 0 1100 280" width="100%" role="img">
          <defs><marker id="arrAB" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 Z" fill="#0E0E10"/></marker></defs>
          <g font-family="JetBrains Mono" font-size="11" fill="#0E0E10">
            <rect x="20"   y="40" width="200" height="60" rx="6" fill="#EAEAEA" stroke="#666"/>
            <text x="120" y="65" text-anchor="middle" font-weight="700">master-session.js</text>
            <text x="120" y="83" text-anchor="middle" font-size="10">既有 entry</text>

            <rect x="260"  y="40" width="200" height="60" rx="6" fill="#EAEAEA" stroke="#666"/>
            <text x="360" y="65" text-anchor="middle" font-weight="700">agent-manager.js</text>
            <text x="360" y="83" text-anchor="middle" font-size="10">callAgent facade</text>

            <rect x="500"  y="40" width="200" height="60" rx="6" fill="#EAEAEA" stroke="#666"/>
            <text x="600" y="65" text-anchor="middle" font-weight="700">session-pool.js</text>
            <text x="600" y="83" text-anchor="middle" font-size="10">key:(agent,client,deploy,workDir)</text>

            <rect x="740"  y="40" width="200" height="60" rx="6" fill="#EAEAEA" stroke="#666"/>
            <text x="840" y="65" text-anchor="middle" font-weight="700">ClaudeCliProtocol</text>
            <text x="840" y="83" text-anchor="middle" font-size="10">spawn claude</text>

            <rect x="20"  y="160" width="200" height="60" rx="6" fill="#FCE7E2" stroke="#C7341A"/>
            <text x="120" y="185" text-anchor="middle" font-weight="700">LoopController · NEW</text>
            <text x="120" y="203" text-anchor="middle" font-size="10">runProject / runUnit</text>

            <rect x="260"  y="160" width="200" height="60" rx="6" fill="#FCE7E2" stroke="#C7341A"/>
            <text x="360" y="185" text-anchor="middle" font-weight="700">callAgentWithPersistence</text>
            <text x="360" y="203" text-anchor="middle" font-size="10">P-8 包装层</text>

            <g stroke="#0E0E10" fill="none" marker-end="url(#arrAB)">
              <line x1="220" y1="70"  x2="255" y2="70"/>
              <line x1="460" y1="70"  x2="495" y2="70"/>
              <line x1="700" y1="70"  x2="735" y2="70"/>
              <line x1="120" y1="160" x2="120" y2="100"/>
              <line x1="220" y1="190" x2="255" y2="190"/>
              <line x1="360" y1="160" x2="360" y2="100"/>
            </g>
          </g>
        </svg>

        <p><b>关键 insight:</b>DevLoop 的 LoopController <b>不需要新建</b> agent-manager 等价物。它<b>复用</b>既有 agent-manager.callAgent,只在上面套一层「持久化包装」(P-8 的 callAgentWithPersistence)。</p>
      </section>

      <section>
        <h2>② callAgentWithPersistence 的完整实现</h2>
        <p>这是 LoopController 调任何 Agent 的<b>唯一入口</b>。它包住 agent-manager.callAgent 同时管 M-10/M-12/M-13。</p>

<pre class="code shape"><span class="c">// server/src/core/loop-controller.js · 节选</span>
<span class="k">const</span> <span class="p">{</span> agentManager <span class="p">}</span>   = <span class="n">require</span>(<span class="s">'./agent-manager'</span>);
<span class="k">const</span> <span class="p">{</span> unitRegistry <span class="p">}</span>   = <span class="n">require</span>(<span class="s">'../../services/unit-registry'</span>);
<span class="k">const</span> <span class="p">{</span> docStore <span class="p">}</span>       = <span class="n">require</span>(<span class="s">'../../services/doc-store'</span>);
<span class="k">const</span> <span class="p">{</span> bugTracker <span class="p">}</span>     = <span class="n">require</span>(<span class="s">'../../services/bug-tracker'</span>);
<span class="k">const</span> <span class="p">{</span> sseBroker <span class="p">}</span>      = <span class="n">require</span>(<span class="s">'../../services/sse-broker'</span>);
<span class="k">const</span> <span class="p">{</span> timelineLogger <span class="p">}</span> = <span class="n">require</span>(<span class="s">'../../services/timeline-logger'</span>);
<span class="k">const</span> <span class="p">{</span> loadAllTools <span class="p">}</span>   = <span class="n">require</span>(<span class="s">'../tools/tool-registry'</span>);

<span class="k">const</span> toolRegistry = <span class="n">loadAllTools</span>();   <span class="c">// 单例</span>

<span class="k">async function</span> <span class="n">callAgentWithPersistence</span>(name, input) <span class="p">{</span>
  <span class="k">const</span> <span class="p">{</span> pid, unit_id <span class="p">}</span> = input;
  <span class="k">const</span> call_id = <span class="n">genCallId</span>();
  <span class="k">const</span> startedAt = <span class="n">Date.now</span>();

  <span class="c">// 1. 注入运行态 (M-10)</span>
  <span class="k">await</span> <span class="n">writeRuntimeState</span>(pid, name, <span class="p">{</span>
    agent: name, status: <span class="s">'running'</span>, call_id, unit_id,
    current_step: <span class="v">0</span>, total_steps: <span class="v">0</span>,
    started_at: <span class="k">new</span> <span class="n">Date</span>().<span class="n">toISOString</span>(),
  <span class="p">}</span>);

  timelineLogger.<span class="n">append</span>(pid, <span class="p">{</span>
    source: <span class="s">'system'</span>, kind: <span class="s">'agent_started'</span>,
    ref: <span class="s">`agent/</span><span class="v">${name}</span><span class="s">/</span><span class="v">${call_id}</span><span class="s">`</span>,
    summary: <span class="s">`</span><span class="v">${name}</span><span class="s"> started`</span>,
  <span class="p">}</span>);
  sseBroker.<span class="n">emit</span>(pid, <span class="s">'agent.started'</span>, <span class="p">{</span> agent: name, call_id, unit_id <span class="p">}</span>);

  <span class="c">// 2. 注入 memory + 构建 prompt</span>
  <span class="k">const</span> systemPrompt = <span class="k">await</span> <span class="n">buildPromptWithMemory</span>(name, pid);

  <span class="c">// 3. 构建 ctx (传给所有工具的运行时上下文)</span>
  <span class="k">const</span> ctx = <span class="p">{</span>
    pid, agent: name, unit_id, call_id,
    loopController: module.<span class="n">exports</span>,
    docStore, unitRegistry, bugTracker, timelineLogger, sseBroker,
    companyDir: <span class="n">companyDirOf</span>(pid),
    projectDir: <span class="n">projectsDir</span>(pid),
    unitDir: unit_id ? <span class="n">unitDirOf</span>(pid, unit_id) : <span class="v">null</span>,
  <span class="p">}</span>;

  <span class="c">// 4. 调既有 agent-manager (这是关键的"桥"那一行)</span>
  <span class="k">let</span> result;
  <span class="k">try</span> <span class="p">{</span>
    result = <span class="k">await</span> agentManager.<span class="n">callAgent</span>(<span class="p">{</span>
      agentName:    <span class="s">`devloop-</span><span class="v">${name}</span><span class="s">`</span>,    <span class="c">// 与 _registry.json 中 id 对齐</span>
      client:       <span class="n">companyDirOf</span>(pid),  <span class="c">// SessionPool key 之一</span>
      deploy:       pid,                  <span class="c">// 另一个 key</span>
      workDir:      ctx.unitDir || ctx.projectDir,
      systemPrompt,
      input,                              <span class="c">// 透传 PMInput / ArchitectInput / etc</span>
      toolPolicy:   <span class="n">getToolPolicy</span>(name),   <span class="c">// 从 _registry.json 读</span>
      toolRegistry, toolCtx: ctx,         <span class="c">// 关键:工具上下文注入</span>
      timeoutMs:    <span class="n">getTimeout</span>(name),       <span class="c">// PM 10min,Coder 5min, etc</span>
      onChunk: (chunk) =&gt; <span class="p">{</span>             <span class="c">// 流式回调</span>
        sseBroker.<span class="n">emit</span>(pid, <span class="s">'agent.stream'</span>, <span class="p">{</span>
          agent: name, unit_id, chunk
        <span class="p">}</span>);
      <span class="p">}</span>,
    <span class="p">}</span>);

    <span class="c">// 5. 成功收尾</span>
    <span class="k">await</span> <span class="n">writeRuntimeState</span>(pid, name, <span class="p">{</span>
      status: <span class="s">'done'</span>,
      last_output_ref: <span class="k">await</span> <span class="n">snapshot</span>(result, pid, call_id),
      updated_at: <span class="k">new</span> <span class="n">Date</span>().<span class="n">toISOString</span>(),
    <span class="p">}</span>);
    timelineLogger.<span class="n">append</span>(pid, <span class="p">{</span>
      source: name, kind: <span class="s">'agent_done'</span>,
      ref: <span class="s">`agent/</span><span class="v">${name}</span><span class="s">/</span><span class="v">${call_id}</span><span class="s">`</span>,
      summary: <span class="s">`</span><span class="v">${name}</span><span class="s"> done in </span><span class="v">${Date.now() - startedAt}</span><span class="s">ms`</span>,
      payload: <span class="p">{</span> tokens: result.tokens_used <span class="p">}</span>,
    <span class="p">}</span>);
    sseBroker.<span class="n">emit</span>(pid, <span class="s">'agent.done'</span>, <span class="p">{</span> agent: name, call_id, ...result <span class="p">}</span>);
    <span class="k">return</span> result;

  <span class="p">}</span> <span class="k">catch</span> (e) <span class="p">{</span>
    <span class="c">// 6. 失败处理 (P-8 状态机)</span>
    <span class="k">await</span> <span class="n">writeRuntimeState</span>(pid, name, <span class="p">{</span>
      status: <span class="s">'error'</span>,
      error: <span class="p">{</span> code: e.code || <span class="s">'AGENT_CRASH'</span>, message: e.message, stack: e.stack <span class="p">}</span>,
      updated_at: <span class="k">new</span> <span class="n">Date</span>().<span class="n">toISOString</span>(),
    <span class="p">}</span>);
    timelineLogger.<span class="n">append</span>(pid, <span class="p">{</span>
      source: <span class="s">'system'</span>, kind: <span class="s">'agent_error'</span>,
      ref: <span class="s">`agent/</span><span class="v">${name}</span><span class="s">/</span><span class="v">${call_id}</span><span class="s">`</span>,
      summary: e.message,
    <span class="p">}</span>);
    sseBroker.<span class="n">emit</span>(pid, <span class="s">'agent.error'</span>, <span class="p">{</span> agent: name, call_id, code: e.code, message: e.message <span class="p">}</span>);
    <span class="k">throw</span> e;
  <span class="p">}</span>
<span class="p">}</span></pre>
      </section>

      <section>
        <h2>③ agent-manager.callAgent 需要扩展的字段</h2>
        <p>LemonBoss 既有 <code>agent-manager.callAgent</code> 已经接受 agentName / client / deploy / workDir 等。要支持 DevLoop 需要<b>追加 3 个字段</b>:</p>

        <table>
          <thead><tr><th>新字段</th><th>类型</th><th>含义</th><th>改 agent-manager 的代码量</th></tr></thead>
          <tbody>
            <tr><td><code>toolRegistry</code></td><td>Map&lt;string, ToolDef&gt;</td><td>本次注册的 79 个工具(来自 loadAllTools)</td><td>+5 行:透传给 SessionPool → Protocol</td></tr>
            <tr><td><code>toolCtx</code></td><td>object</td><td>工具调用时的 ctx(pid · sseBroker · etc)</td><td>+3 行:同上透传</td></tr>
            <tr><td><code>onChunk</code></td><td>function</td><td>流式回调(SSE 用)</td><td>+2 行:传给 stream-executor</td></tr>
          </tbody>
        </table>

        <p>合计 ~10 行修改 <code>server/src/core/agent-manager.js</code>。<b>不改 agent-manager 的 public API</b>,所有新字段都是可选;不传时行为同既有。</p>
      </section>

      <section>
        <h2>④ SessionPool key 设计</h2>
        <p>既有 SessionPool key = <code>(agentName, client, deploy, workDir)</code>。DevLoop 把它映射成:</p>

        <table class="dense">
          <thead><tr><th>SessionPool key 段</th><th>DevLoop 填入</th></tr></thead>
          <tbody>
            <tr><td>agentName</td><td><code>devloop-pm</code> / <code>devloop-architect</code> / ...(与 _registry.json id 一致)</td></tr>
            <tr><td>client</td><td><code>data/companies/{cid}/</code></td></tr>
            <tr><td>deploy</td><td><code>{pid}</code></td></tr>
            <tr><td>workDir</td><td>project / unit 目录</td></tr>
          </tbody>
        </table>

        <p>这样得到的复用粒度:</p>
        <ul>
          <li>同 <code>(company, project, agent)</code> 内 → ACP session 复用(多次 callAgent 上下文延续)</li>
          <li>跨 project → 独立 session(避免 PM 把 A 项目知识带进 B 项目)</li>
          <li>同 project 不同 unit 的 Coder → 独立 session(避免 todo-be 的 prompt 污染 auth-be)</li>
        </ul>

        <h3>Coder/Tester 特殊:workDir 必须含 unit_id</h3>
        <p>Coder/Tester 的 workDir 是 <code>units/{unit_id}/</code>。SessionPool key 因此天然按 unit 隔离。<b>意外效果</b>:retry 同 unit 时<b>复用 session</b>(上下文连续),跨 unit 时不复用(互不干扰)。这正是想要的。</p>
      </section>

      <section>
        <h2>⑤ 流式输出 + tool_use 中断处理</h2>
        <p>Claude CLI 输出经 stream-executor 处理。DevLoop 需要的额外行为:</p>

        <table class="dense">
          <thead><tr><th>事件</th><th>原 stream-executor 行为</th><th>DevLoop 加什么</th></tr></thead>
          <tbody>
            <tr><td>text chunk</td><td>累积到 result.content</td><td>+ onChunk 回调 → SSE agent.stream</td></tr>
            <tr><td>tool_use 请求</td><td>路由到工具,执行后回 tool_result</td><td>+ 工具内部可调 sseBroker(如 update_todo_item → SSE agent.step)</td></tr>
            <tr><td>tool 失败</td><td>把错误打回给 Claude</td><td>+ 标准化 ToolValidationError / ToolSandboxError,给 Claude 明确格式</td></tr>
            <tr><td>stop</td><td>返回 final result</td><td>+ 自动写 timeline.agent_done</td></tr>
          </tbody>
        </table>

        <p class="sig">stream-executor 在 LemonBoss 已经处理大部分,DevLoop 主要是<b>挂回调钩子</b>。改动量小。</p>
      </section>

      <section>
        <h2>⑥ 错误传播链</h2>
        <pre class="code"><span class="c">// 错误 4 层传播</span>
工具 execute throws ToolValidationError
        ↓
ToolRegistry 包装成 tool_result { is_error: true, content: "..." }
        ↓
Claude CLI 收到错,继续 reasoning → 可能重试或换路径
        ↓
若多次失败 / 超 budget → Claude stop → result.ok = false
        ↓
callAgentWithPersistence catch → writeRuntimeState status=error
        ↓
LoopController 决定 retry / block / 升 L3</pre>

        <p>这个链路在 <a href="sdd-process.html">P-8 Agent State 持久化</a> 描述了行为,本页是<b>实现细节</b>。</p>
      </section>

      <section>
        <h2>⑦ 性能考量</h2>
        <table>
          <thead><tr><th>项</th><th>关注点</th><th>数字 / 缓解</th></tr></thead>
          <tbody>
            <tr><td>Claude CLI 冷启动</td><td>每个 ACP session 第一次 ~1.5s</td><td>SessionPool 复用让大部分调用是热的</td></tr>
            <tr><td>tool_use 往返延迟</td><td>每次工具调用 ~10-30ms(stdio JSON-RPC)</td><td>合理;高频工具(update_todo_item)不要每行都调</td></tr>
            <tr><td>system prompt 长度</td><td>PM 主体 ~4KB + memory ~3KB = 7KB</td><td>OK,Claude 200K context 完全够</td></tr>
            <tr><td>并发 session 数</td><td>每个 ACP session 占 ~100MB RAM + 1 个 Claude CLI 进程</td><td>单机建议 ≤ 10 并发;项目内并行 ≤ 5</td></tr>
            <tr><td>磁盘 I/O</td><td>工具大量小文件写(agent-state/ + timeline.ndjson)</td><td>同步写 fsync,无 cache;项目目录有 ~100 文件就够大了</td></tr>
            <tr><td>SSE fan-out</td><td>1 项目 1 个广播</td><td>broker 内部 Set,O(订阅数)</td></tr>
          </tbody>
        </table>
      </section>

      <section>
        <h2>⑧ 安全边界</h2>
        <p>沙箱通过三层强制:</p>
        <ol>
          <li><b>ACP 进程层:</b>Claude CLI 起在 cwd 锁定的子进程,不能 cd 出去</li>
          <li><b>工具层:</b>Write/Edit 工具检查 writeAllowList,越界抛 ToolSandboxError</li>
          <li><b>系统层(兜底):</b>定期 scanSandbox 扫描 mtime 在 call 周期内变化的文件,跨边界写直接拒收 result</li>
        </ol>

        <pre class="code shape"><span class="c">// 第二层实现:Write 工具的 sandbox check</span>
module.<span class="n">exports</span>.Write = <span class="p">{</span>
  name: <span class="s">'Write'</span>, ...
  <span class="k">async</span> <span class="n">execute</span>(<span class="p">{</span> path: target, content <span class="p">}</span>, ctx) <span class="p">{</span>
    <span class="k">const</span> abs = path.<span class="n">resolve</span>(ctx.workDir, target);
    <span class="k">const</span> allow = <span class="n">getAllowList</span>(ctx.agent, ctx);   <span class="c">// 来自 _registry.json</span>
    <span class="k">if</span> (!<span class="n">withinAllow</span>(abs, allow))
      <span class="k">throw new</span> <span class="n">ToolSandboxError</span>(target, ctx.agent);
    fs.<span class="n">writeFileSync</span>(abs, content);
    <span class="k">return</span> <span class="p">{</span> ok: <span class="v">true</span>, path: abs <span class="p">}</span>;
  <span class="p">}</span>
<span class="p">}</span>;</pre>
      </section>

      <section>
        <h2>⑨ 一行总结</h2>
        <div class="callout ok">
          <b>桥的全貌:</b>LoopController.callAgent (P-8 包装) → agent-manager.callAgent (透传 toolRegistry/toolCtx) → SessionPool (key 含 pid) → ClaudeCliProtocol (spawn claude + --tools) → Claude CLI (tool_use API) → ToolRegistry.execute (业务工具) → 写文件 / 发 SSE → 回到 Claude。<b>没有新协议,只有现有接口的扩展</b>。
        </div>
      </section>

      <section>
        <h2>⑩ 关联</h2>
        <ul>
          <li><a href="arch-tool-registry.html">Tool Registry</a> · 工具层细节</li>
          <li><a href="sdd-process.html">P-8 Agent State 持久化</a> · 包装层为啥这么写</li>
          <li><a href="arch-mapping.html">LemonBoss 映射</a> · 既有 session-pool / agent-manager / scheduler 怎么 REUSE</li>
        </ul>
      </section>

    </article>
    <nav class="prev-next"></nav>
    <footer class="site">
      <div><b>Section</b>05 · Architecture</div>
      <div><b>Page</b>arch-acp-bridge.html</div>
      <div><b>Spec Rev</b>v2 · 2026-05-13</div>
    </footer>
  </main>
</div>
<script src="assets/shell.js"></script>
</body>
</html>