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(订阅数) |
⑨ 安全边界
沙箱通过三层强制:
- ACP 进程层:Claude CLI 起在 cwd 锁定的子进程,不能 cd 出去
- 工具层:Write/Edit 工具检查 writeAllowList,越界抛 ToolSandboxError
- 系统层(兜底):定期 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。没有新协议,只有现有接口的扩展。
⑪ 关联
- Tool Registry · 工具层细节
- P-8 Agent State 持久化 · 包装层为啥这么写
- LemonBoss 映射 · 既有 session-pool / agent-manager / scheduler 怎么 REUSE