# 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。 ```ts // 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 主要是**挂回调钩子**。改动量小。_ ## ⑥ 错误传播链 ```js // 错误 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 持久化](sdd-process.html) 描述了行为,本页是**实现细节**。 ## ⑦ Mock Claude CLI 模式(开发期省 token) P0 阶段开发 79 工具时,不可能每次都真调 Claude(烧 token + 等响应慢)。需要一个 `MOCK_CLAUDE=1` 环境变量让 ClaudeCliProtocol 返回 stub 回复,工具流照常跑通。 ### 设计 ```ts // 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 ```js { "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": "...mock content..." } } }, { "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 替换为实际运行时值。简单但够用: ```ts 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 ```ts // 第二层实现: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。**没有新协议,只有现有接口的扩展**。 ## ⑪ 关联 - [Tool Registry](arch-tool-registry.html) · 工具层细节 - [P-8 Agent State 持久化](sdd-process.html) · 包装层为啥这么写 - [LemonBoss 映射](arch-mapping.html) · 既有 session-pool / agent-manager / scheduler 怎么 REUSE