05 · ARCHITECTURE/ACP Bridge

ACP Bridge · LoopController ↔ LemonBoss agent-manager

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

① 现状梳理 · 既有 LemonBoss 链路

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

master-session.js 既有 entry agent-manager.js callAgent facade session-pool.js key:(agent,client,deploy,workDir) ClaudeCliProtocol spawn claude LoopController · NEW runProject / runUnit callAgentWithPersistence P-8 包装层

关键 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 的代码量
toolRegistryMap<string, ToolDef>本次注册的 79 个工具(来自 loadAllTools)+5 行:透传给 SessionPool → Protocol
toolCtxobject工具调用时的 ctx(pid · sseBroker · etc)+3 行:同上透传
onChunkfunction流式回调(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 填入
agentNamedevloop-pm / devloop-architect / ...(与 _registry.json id 一致)
clientdata/companies/{cid}/
deploy{pid}
workDirproject / 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 持久化 描述了行为,本页是实现细节。

⑦ 性能考量

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

⑩ 关联