arch-acp-bridge.md 14.1 KB

ACP Bridge · LoopController ↔ LemonBoss agent-manager

前面文档反复出现 agentManager.callAgent(name, input)。本页交代这一行的实际实现链路:从 LoopController 调用,到 LemonBoss 既有 agent-manager 怎么走,到 SessionPool 复用,到 Claude CLI 子进程启动,到工具调用回路。

① 现状梳理 · 既有 LemonBoss 链路

LemonBoss 已经有完整的 ACP / Master Agent / SessionPool 链路:

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

② callAgentWithPersistence 的完整实现

这是 LoopController 调任何 Agent 的唯一入口。它包住 agent-manager.callAgent 同时管 M-10/M-12/M-13。

// server/src/core/loop-controller.js · 节选
const { agentManager }   = require('./agent-manager');
const { unitRegistry }   = require('../../services/unit-registry');
const { docStore }       = require('../../services/doc-store');
const { bugTracker }     = require('../../services/bug-tracker');
const { sseBroker }      = require('../../services/sse-broker');
const { timelineLogger } = require('../../services/timeline-logger');
const { loadAllTools }   = require('../tools/tool-registry');

const toolRegistry = loadAllTools();   // 单例

async function callAgentWithPersistence(name, input) {
  const { pid, unit_id } = input;
  const call_id = genCallId();
  const startedAt = Date.now();

  // 1. 注入运行态 (M-10)
  await writeRuntimeState(pid, name, {
    agent: name, status: 'running', call_id, unit_id,
    current_step: 0, total_steps: 0,
    started_at: new Date().toISOString(),
  });

  timelineLogger.append(pid, {
    source: 'system', kind: 'agent_started',
    ref: `agent/${name}/${call_id}`,
    summary: `${name} started`,
  });
  sseBroker.emit(pid, 'agent.started', { agent: name, call_id, unit_id });

  // 2. 注入 memory + 构建 prompt
  const systemPrompt = await buildPromptWithMemory(name, pid);

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

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

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

  } catch (e) {
    // 6. 失败处理 (P-8 状态机)
    await writeRuntimeState(pid, name, {
      status: 'error',
      error: { code: e.code || 'AGENT_CRASH', message: e.message, stack: e.stack },
      updated_at: new Date().toISOString(),
    });
    timelineLogger.append(pid, {
      source: 'system', kind: 'agent_error',
      ref: `agent/${name}/${call_id}`,
      summary: e.message,
    });
    sseBroker.emit(pid, 'agent.error', { agent: name, call_id, code: e.code, message: e.message });
    throw e;
  }
}

③ agent-manager.callAgent 需要扩展的字段

LemonBoss 既有 agent-manager.callAgent 已经接受 agentName / client / deploy / workDir 等。要支持 DevLoop 需要追加 3 个字段:

新字段 类型 含义 改 agent-manager 的代码量
toolRegistry Map 本次注册的 79 个工具(来自 loadAllTools) +5 行:透传给 SessionPool → Protocol
toolCtx object 工具调用时的 ctx(pid · sseBroker · etc) +3 行:同上透传
onChunk function 流式回调(SSE 用) +2 行:传给 stream-executor

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

④ SessionPool key 设计

既有 SessionPool key = (agentName, client, deploy, workDir)。DevLoop 把它映射成:

SessionPool key 段 DevLoop 填入
agentName devloop-pm / devloop-architect / ...(与 _registry.json id 一致)
client data/companies/{cid}/
deploy {pid}
workDir project / unit 目录

这样得到的复用粒度:

  • 同 (company, project, agent) 内 → ACP session 复用(多次 callAgent 上下文延续)
  • 跨 project → 独立 session(避免 PM 把 A 项目知识带进 B 项目)
  • 同 project 不同 unit 的 Coder → 独立 session(避免 todo-be 的 prompt 污染 auth-be)

Coder/Tester 特殊:workDir 必须含 unit_id

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

⑤ 流式输出 + tool_use 中断处理

Claude CLI 输出经 stream-executor 处理。DevLoop 需要的额外行为:

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

stream-executor 在 LemonBoss 已经处理大部分,DevLoop 主要是挂回调钩子。改动量小。

⑥ 错误传播链

// 错误 4 层传播
工具 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

这个链路在 P-8 Agent State 持久化 描述了行为,本页是实现细节。

⑦ Mock Claude CLI 模式(开发期省 token)

P0 阶段开发 79 工具时,不可能每次都真调 Claude(烧 token + 等响应慢)。需要一个 MOCK_CLAUDE=1 环境变量让 ClaudeCliProtocol 返回 stub 回复,工具流照常跑通。

设计

// packages/acp-core/protocol/ClaudeCliProtocol.js · 增量改动 ~40 行
const MOCK = process.env.MOCK_CLAUDE === '1';
const mockResponsesDir = path.resolve(__dirname, '../../../system/mock-responses');

class ClaudeCliProtocol {
  async sendPrompt(input) {
    if (MOCK) return this._sendPromptMock(input);
    return this._sendPromptReal(input);
  }

  async _sendPromptMock(input) {
    // 1. 解析 agent name 从 system prompt 或 input.agent
    const agent = this.agentName;             // 启动时存的
    const mode  = input.mode || 'default';

    // 2. 读 mock 响应文件
    const p = path.join(mockResponsesDir, agent, `${mode}.json`);
    if (!fs.existsSync(p)) {
      return this._fallbackMock(agent, input);
    }

    const mock = JSON.parse(fs.readFileSync(p, 'utf8'));

    // 3. 按 mock 描述的 tool_use 序列,逐个回调工具
    //    模拟 Claude 自己调工具的行为
    for (const step of mock.steps) {
      if (step.tool_use) {
        const result = await this._dispatchTool(step.tool_use.name, step.tool_use.input);
        // 工具回执理论上影响下一步,简化版直接忽略 result
        // (高级版可让 mock 描述含 tool_result 处理逻辑)
      }
      if (step.delay_ms) await new Promise(r => setTimeout(r, step.delay_ms));
      if (step.text) this._emitChunk(step.text);
    }

    return mock.final_result;
  }

  _fallbackMock(agent, input) {
    // 没有专门 mock 文件时,返回最小合规结果
    return {
      ok: true,
      duration_ms: 100,
      tokens_used: 0,
      content: '[mock fallback response]',
    };
  }
}

Mock 响应文件结构

system/mock-responses/                  # 由 P0.A2 创建
├── pm/
│   ├── init.json                       # PM init mode 的 stub
│   ├── incremental.json
│   └── resume.json
├── architect/
│   ├── generate.json
│   ├── patch.json
│   └── resume.json
├── coder/
│   ├── implement.json
│   └── retry.json
├── tester/
│   ├── test.json
│   └── retry.json
├── integrator/
│   ├── run.json
│   └── retry.json
└── bug-triager/
    └── triage.json

Mock 响应文件示例 · pm/init.json

{
  "description": "PM init mock: 假装解析需求并写出 prd/ 11 章节",
  "steps": [
    { "text": "读取上下文...\n" },
    { "tool_use": {
        "name": "write_todolist",
        "input": { "items": [
          { "id": "step-001", "description": "解析需求" },
          { "id": "step-002", "description": "bootstrap_prd_site" },
          { "id": "step-003", "description": "写 index.html" }
        ] }
    } },
    { "tool_use": { "name": "bootstrap_prd_site", "input": { "pid": "$pid" } } },
    { "tool_use": { "name": "write_chapter",
        "input": { "pid": "$pid", "section": "index", "page": "index",
                   "html": "<!doctype html>...mock content...</html>" }
    } },
    { "tool_use": { "name": "echo_diff",
        "input": { "summary": "创建 mock PRD 站点" } } }
  ],
  "final_result": {
    "ok": true,
    "mode": "init",
    "prd_rev": 1,
    "files_written": ["prd/index.html"],
    "change_summary": "[mock] PM init",
    "duration_ms": 100,
    "tokens_used": 0
  }
}

什么时候用 mock vs 真

场景 用什么 原因
开发新工具,验证落盘 / SSE 正确 mock 不烧 token;快速迭代
调试 LoopController 主循环 mock 同上
跑 phase0-verify CI mock CI 不能依赖真 Claude
验证 Agent system prompt 质量 真 mock 不能反映 prompt 修改
P1 验收(30s 内 prd/ 落盘) 真 真 token 消耗 + 真 prompt 检验
P3 BugTriager 归因准确率测试 真 核心检验项

$pid / $unit_id 占位符

mock JSON 里写 "$pid" 时,dispatch 前 protocol 替换为实际运行时值。简单但够用:

function interpolate(input, ctx) {
  const json = JSON.stringify(input);
  const resolved = json.replace(/"\$(\w+)"/g, (_, key) =>
    JSON.stringify(ctx[key] || '__missing_' + key)
  );
  return JSON.parse(resolved);
}

关键好处:有了 mock 模式,工具实现(P0.A)+ 集成调试(P0.H)+ 验证脚本(P0.I/J)全程零 token 消耗。等所有工具就位 + system prompt 都提取了,再切到 MOCK_CLAUDE=0 跑一遍真 PM 验证。

⑧ 性能考量

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

⑨ 安全边界

沙箱通过三层强制:

  1. ACP 进程层:Claude CLI 起在 cwd 锁定的子进程,不能 cd 出去
  2. 工具层:Write/Edit 工具检查 writeAllowList,越界抛 ToolSandboxError
  3. 系统层(兜底):定期 scanSandbox 扫描 mtime 在 call 周期内变化的文件,跨边界写直接拒收 result
// 第二层实现:Write 工具的 sandbox check
module.exports.Write = {
  name: 'Write', ...
  async execute({ path: target, content }, ctx) {
    const abs = path.resolve(ctx.workDir, target);
    const allow = getAllowList(ctx.agent, ctx);   // 来自 _registry.json
    if (!withinAllow(abs, allow))
      throw new ToolSandboxError(target, ctx.agent);
    fs.writeFileSync(abs, content);
    return { ok: true, path: abs };
  }
};

⑩ 一行总结

桥的全貌: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。没有新协议,只有现有接口的扩展。

⑪ 关联