resolve-lume 容器边界切分:C 后端 + 宿主侧车 + Node 反代

作者:

在

🇬🇧 English

resolve-lume 是用 lume 技术栈复刻 resolve-studio 核心的实现——不改动原项目,只把其中的多智能体工作台用 lume + lume-core + React 重写了一遍。这套系统的容器边界不是按技术栈切的,而是按特权需求切的:能干 shell、能写盘、能拉起子进程的那一层被移出容器,剩下的两层瘦到运行时里连 Node 和 Python 都不装。接下来的三层——容器内的 C 运行时、宿主上的 sidecar 进程、容器内自写的 Node 反代——每一层的归属都能在源码里找到判据,也都能在源码注释里找到当初划错的注脚。

全景:两个容器 + 一个宿主进程

分层调用链只有一条:浏览器打 :18085 → frontend 容器的 Node 服务 → /api/* 走 host.docker.internal:3000 落到宿主 sidecar、/react/* 走 compose 内网到 backend 的 :8080。

关键论断:sidecar 不在 compose 内,它是宿主进程。docker-compose.yml 里 sidecar 那个 service 已被注释移除、只留说明块,拉起它的是 Makefile 的 sidecar-host-start,用 nohup node sidecar/launcher.js 起一个宿主机上的 Node。这一点后面第 3 层会展开——之所以必须落到宿主,不是口味问题,是它要触达的能力在容器里根本不具备。

第二层·前端容器:为什么不挂 nginx,自己写 200 行 Node 反代

镜像绕过 nginx。原因是反代这一层不只有转发,还有形状改写和契约注入,纯静态配置做不了。

sidecar 与 C 二进制 agent-httpd 的关系,在 agent.js 的文件头注释里写得直白——它只是一个网关,把请求转发给 C 二进制、再把后者的 SSE 帧改写成前端认得的格式(sidecar/agent.js:4):

sidecar/agent.js:4

// agent-httpd speaks its own SSE dialect: each frame is `data: {"t":"delta","d":"..."}`
// (no `event:` line; the type lives in the JSON `t` field). The resolve-studio
// frontend expects the OpenAI-style dialect: `event: <type>\ndata: <json>\n\n`.
// This module rewrites agent-httpd frames into that dialect on the fly, so the
// ported frontend can consume lume's runtime unchanged.

而真正指向 C 二进制后端的入口在 sidecar/agent.js:13:

const AGENT_URL = process.env.AGENT_HTTPD_URL || 'http://backend:8080'

C 二进制 agent-httpd 吐的是自己的 SSE 方言:每帧只有 data: {"t":"delta","d":"..."},没有 event: 行,类型藏在 JSON 的 t 字段里(sidecar/agent.js:4)。而移植过来的前端只认 OpenAI 风格的 event: <type>\ndata: <json>(sidecar/agent.js:6)。两边谁也不改,代价就落在中间这一层:translateFrame(sidecar/agent.js:83)逐帧重写,把 t 映射成 event: 行,前端才能原样消费 lume 的运行时。

这一层还承担了一次事故沉淀。translateFrame 里那个 emptyRunSummary(buf) 不是普通错误提示,是一份带日期的复盘:注释记录 2026-11-06 那次 8 轮、1 次 article-write + 6 次无关 hot-news 调用、零输出,用户没有任何线索判断哪个工具挂了。于是空回答时不再吐一句"工具已执行但模型未返回总结",而是把调用轨迹(hot-news ×4 这种折叠形式)、轮次预算、最后错误一起打印出来,并明确劝阻"直接点重试"。这类东西写不进 nginx 配置,只能写在反代里。

同一套思路在请求侧也有一份。sidecar/chatBody.js:2 起头就交代这份纯函数为什么单独拆出来:

sidecar/chatBody.js:2

// Pure request-shape translation for /api/chat. Kept out of server.js so the
// contract is unit-testable without starting the HTTP server (server.js has
// top-level listen() + MCP refresh side effects).

buildAgentChatBody 干的是形状转换:前端发 { messages, model, ... },agent-httpd 要的是 { message, history },于是取最后一条 user 当 message、其余进 history,任务 soul 与前端算好的 systemPrompt 依次拼上,TOOL_DISCIPLINE 永远压在最末。为什么最末?因为它优先级最高、最有权威。而这份纪律本身是事故驱动的:sidecar/chatBody.js:8 写着,一次工具调用失败就足以让免费档模型跑偏到另一个业务域、烧光全部 8 轮预算。

把它拆成纯模块、不引 server.js 的副作用,是为了能单测——这类契约是最容易在改动中悄悄破掉的东西。

第三层·宿主 sidecar:为什么它必须离开容器

sidecar 干的事——转译 SSE、注入系统提示、维护工具名单漂移检查、拉起子进程执行工具——有一半需要宿主特权。把它留在 compose 里就意味着容器要装 Node、要能 exec、要能写盘,那第一层的"瘦"就白做了。所以 docker-compose.yml 里 sidecar service 被注释掉,改由 Makefile 在宿主机 nohup node sidecar/launcher.js 拉起。

这里最值得单独拎出来的是 sidecar/consistency.js。它不是运行时逻辑,是一道启动期的断言闸门,防的是"四份名单各自漂移"。readToolChain 把四份名单从各自的源读出来——生成脚本里的 DEFAULT_ALLOW、.lume 桥里的 tool "name"、schema 文件里的工具名:

sidecar/consistency.js:79

function readToolChain() {
  const gen = fs.readFileSync(GENERATOR_FILE, 'utf8')
  const block = gen.match(/const DEFAULT_ALLOW\s*=\s*\[([\s\S]*?)\]/)
  const allow = [...block[1].matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1])
  const bridgeSrc = fs.readFileSync(BRIDGE_FILE, 'utf8')
  const bridge = [...bridgeSrc.matchAll(/^tool ['"]([^'"]+)['"]/gm)].map((m) => m[1])
  const schemaNames = JSON.parse(fs.readFileSync(SCHEMA_FILE, 'utf8')).tools.map((t) => t.name)
  return { schemaNames, allow, bridge }
}

四份名单:前端面板展示的 schema、DEFAULT_ALLOW 声明应该开放的、生成出来的 .lume 桥里 agent 真正能看见的、以及 builtin-exec.js 里真正实现了的。computeDrift 把每一对差集都算一遍,任何漂移都变成显式的启动失败,而不是一个静默消失的能力。

其中一个特判值得抄下来:capabilities 必须在 allow 与 bridge 两侧都开放,否则 agent 无法区分"没开放"和"不存在"。这正是当初那句"网关没挂载 article-write"的来源——不是工具没实现,是 agent 没法自证。

这一整套检查之所以能放在宿主而不是容器里跑,是因为它要读生成脚本、读 .lume 源文件、读实现模块——都是仓库内的文件系统动作,宿主上做最自然。容器里做要么得挂一堆卷,要么得把生成流程塞进构建期。

第一层·容器内 C 运行时:瘦到只剩二进制

backend 容器跑的是 agent-httpd 这个 C 二进制,compose 里只 expose 端口、外部访问全绑 127.0.0.1。它不认 OpenAI 的 SSE 方言(sidecar/agent.js:4),也不负责形状转换;方言的事全交给宿主 sidecar。容器里没有 Node、没有 Python,因为这一层只做运行时该做的事:接请求、跑 agent 循环、把事件按自己的 {t,d} 格式吐出去。

前端容器同理。它对外是 server-entrypoint.mjs 那个 Node 服务,/api/* 通过 host.docker.internal:3000 转给宿主 sidecar,/react/* 走 compose 内网到 backend 的 :8080。frontend/src/api.ts 里那些 fetch('/api/...') 调用(fetchTasks、createJob、streamJob 等)全部只认相对路径,从不知道对面是宿主还是容器——这是边界切对了的一个隐含好处:前端对拓扑无感。

各层契约如何合拢

回到开头那个结论:三层边界按特权需求切,不按语言切。逐层看下来,每一层都只保留它非做不可的那部分能力。

  • C 层只管运行时与它自己的 SSE 方言,不碰形状转换(sidecar/agent.js:4)。
  • sidecar 层在宿主上,负责所有需要 exec / 写盘 / 读仓库文件的事,以及形状与纪律的注入(sidecar/chatBody.js:2、sidecar/consistency.js:79)。
  • frontend 层只做反代与静态服务,把方言差异挡在容器外(sidecar/agent.js:6)。

三层之间靠两份契约咬合:一份是 SSE 帧格式({t,d} ↔ event: 行),一份是 /api/chat 的请求体形状({ messages } ↔ { message, history, system })。两份契约都各有一个纯函数承载、都能脱离 HTTP 服务单测,这正是它们能在多次事故后被稳定修好的原因。

要复现这套拓扑,按顺序跑:

make bootstrap
make build
make check

make check 会把工具链一致性检查一起跑过——四份名单漂移会直接失败并打印差集。

架构对照:resolve-lume 与 lume-a2a 的 Multi-Agent 分工

resolve-lume 与 lume-a2a 都基于 lume + lume-core + React,但解决 Multi-Agent 的两个相反层面:resolve-lume 是单节点多角色的"动手"执行器,lume-a2a 是跨节点分布式 A2A的"开口"协议层。

维度 resolve-lume lume-a2a
形态 单节点、多角色(10 个 souls:planner/evaluator/specialist + code-review/rust-review/weekly-investment/article-write 等) 跨节点分布式 A2A(默认 7 个真实 lume 节点互联)
协作范式 PSE 角色链:Planner 拆解 → Specialist 调研 → Evaluator 批判 → ToolAgent 执行 节点间协商 + 委派:task.delegate 把活派给 executor
执行边界 sidecar 是动手层:builtin-tools.json 登记 43 个业务工具,其中 24 个经 sidecar_ro.lume 暴露给 agent-httpd,真 exec/写盘在侧车 只读 25 个无参工具,动手留在 executor
编排底座 C 二进制 agent-httpd + sidecar(:3000) node.lume + a2a.lume + negotiate.lume
LLM 耦合 活体 SSE 流式 快照制:控制台读 data/self/llm-negotiation.json,不实时调模型
通信协议 内部角色通信(编排器内) A2A JSON-RPC + /peer/invoke(HMAC 签名)+ Agent Card 发现
合规设计 PSE 投资组合周度诊断,有代码 grounding(sidecar/consistency.js:79 的 readToolChain/computeDrift) 明示非投顾、只到行业级、无证券代码

最本质的一点:sidecar/business-helpers.js:363 的 runPseTask 流水线(article-write / pse-review / resume-tailor …)就跑在 resolve-lume 的 sidecar 里——它自己就是会执行的多智能体工作台。而 lume-a2a 舰队把 resolve-lume 当作第 7 个 peer,只查询它暴露的能力目录、把"动手"的活经 task.delegate 签名通道派回来;而执行器内部的工具纪律(sidecar/chatBody.js:8 记录的 2026-11-06 事故)正是这道边界如何兜住的——不让免费档模型越界去碰别的业务域。换句话说,resolve-lume 是被 lume-a2a "借手"的执行器,lume-a2a 则是让多个独立智能体互相发现、协商、派活的协议层。

lume 源码实证:peer_node.lume 与 sidecar_ro.lume

前面几层讲的都是容器边界和 Node sidecar,但 resolve-lume 之所以能被 lume-a2a 当作第 7 个 peer "借手",靠的是它自己的一份 lume 代码——peer_node.lume。它和 crm/scanner/talentlens 同型,是个静态节点:能力全部来自本地文件,与 backend 容器、宿主 sidecar 正交(它们没起时这些能力照样可用)。文件头把端点签名与隐私纪律写死(节选 peer_node.lume:1 与 peer_node.lume:17,其中本机路径已脱敏):

// peer_node.lume — resolve-lume 的 A2A peer 端点(静态节点, 不带 agent 面)
//
// 隐私纪律: builtin-tools.json 里部分工具 description 含字段名(appid/appsecret),
// 这里全量透传 description(截 120 字), 但**不暴露参数 JSON 的 values 字段**
// (只暴露 name/description), 避免把运行时的凭据路径泄露进对端 LLM 上下文。
// 用**本仓 submodule** 的 peer 库(lume-core/lib/peer.lume, 与 lume 宿主仓同哈希),
// 自包含不跨树。import 路径相对本 .lume 文件所在目录(resolve-lume/)。
import "lume-core/lib/peer.lume" as peer;

它对外暴露的能力清单 rl_static_tools(),把"只亮菜单不亮手"写得明明白白——resolve.* 是只读摘要,exec.* 才是真动手的代理(peer_node.lume:188):

func rl_static_tools() {
  return [
    { name: "resolve.skills", desc: "resolve-lume 的 PSE 可编排任务目录: 7 个技能...", "peer_callable": true },
    { name: "resolve.tools",  desc: "resolve-lume 的 sidecar 内置工具目录: 全部工具(名/描述截 120 字), 不含参数值",
      "peer_callable": true },
    { name: "exec.submit", desc: "把一段指令递给宿主 sidecar 后台 job runner, 立刻返回 job_id(异步)...真正动手在 sidecar; 本节点只做签名 proxy。",
      "peer_callable": true },
    { name: "exec.status", desc: "查一个 exec.submit 派出去的 job 当前态(running/succeeded/failed/cancelled)。",
      "peer_callable": true },
  ];
};

/peer/invoke 的分流是这道边界的硬实现:以 resolve. 开头的只走只读本地文件,以 exec. 开头的才透传给宿主 sidecar 干活,其余一律 denied——连"顺手调个别的工具"都不给(peer_node.lume:278):

post "/peer/invoke", (req) => {
  // … peer.verify 校验签名、解析 body 为 JSON(见源码 278-291)
  let tool_name = str(get(parsed.ok, "tool", ""));
  if (len(str_match("^resolve\\.", tool_name)) > 0) {   // 只读, 走本地文件
    if (tool_name == "resolve.skills") { hit = rl_skills(); }
    else if (tool_name == "resolve.tools") { hit = rl_tools(); }
    ...
  }
  if (len(str_match("^exec\\.", tool_name)) > 0) {       // 真动手, 透传宿主 sidecar
    let args = get(parsed.ok, "arguments", {});
    if (tool_name == "exec.submit") { hit = exec_submit(args); }
    else if (tool_name == "exec.status") { hit = exec_status(args); }
    ...
  }
  return { ok: false, denied: true, error: "本节点只开放 resolve.* 只读能力, 不接受其他工具调用" };
};

另一侧,lume-tools/sidecar_ro.lume 是 agent 真正能看见的工具桥——正是 consistency.js 第四份名单里"agent CAN see"的那一份。它是自动生成的,所有工具共用一个出站调用 sidecar_tool():工具名在 handler 里写死,LLM 没法把 name 传成别的工具,因此不会把请求绕回 agent-httpd 形成回环(lume-tools/sidecar_ro.lume:17):

func sidecar_tool(tool_name, args_json) {
  let base = env("SIDECAR_TOOLS_URL");
  if (len(base) == 0) { return "error: SIDECAR_TOOLS_URL not set (compose backend env)"; }
  let hdr = {"Content-Type": "application/json", "X-Sidecar-Token": env("SIDECAR_TOOLS_AUTH")};
  let body = "{\"name\":\"" + tool_name + "\",\"arguments\":" + args_json + "}";
  // allow_private: sidecar 在 127.0.0.1,lume 的 SSRF 闸门默认拒私有地址
  let r = http_post(base, {"headers": hdr, "body": body, "timeout": 600, "max_bytes": 2097152, "allow_private": true});
  ...
  if (len(outv) > 20000) { outv = substr(outv, 0, 20000) + "\n[output cut at MCP frame limit; ...]"; }
  ...
}

文章第 116 行提到的 article-write 工具,在这份桥里就是这样一行声明——注意它明确要求"直接调用、不要先读文件猜路径":

tool "article-write", "Write article? Call DIRECTLY with project=KEY; NEVER read files or guess paths first. Omit project to list candidates. ONE article per call.", {"project": "string", "publish": "bool", "style": "string", "provider": "string"}, (p) => {
  return sidecar_tool("article-write", stringify(p));
};

源码导航

  • sidecar/agent.js — agent-httpd SSE 方言到 OpenAI 风格的逐帧转译、空回答复盘
  • sidecar/chatBody.js — /api/chat 请求形状转换、工具调用纪律、发布意图判定
  • sidecar/consistency.js — 四份工具名单的漂移检查
  • sidecar/builtin-tools.json — 前端面板展示的工具 schema
  • sidecar/builtin-exec.js — 工具的真实实现与 BUILTIN_NAMES
  • deploy/gen-sidecar-tools.mjs — DEFAULT_ALLOW 与桥生成
  • lume-tools/sidecar_ro.lume — agent 可见的工具桥
  • frontend/src/api.ts — 前端对 /api/* 的全部调用面
  • frontend/server-entrypoint.mjs — 前端容器真正对外跑的服务
  • docker-compose.yml — backend / frontend 两个 service 与端口绑定

sidecar 为什么不放在 compose 里?

它要 exec、要写盘、要读仓库内文件,这些是宿主特权;docker-compose.yml 中该 service 已被注释移除,由 Makefile 在宿主拉起 sidecar/launcher.js。

agent-httpd 的 SSE 和前端要的格式差在哪?

agent-httpd 每帧只有 data: {"t":"delta","d":"..."},类型在 JSON 的 t 字段;前端要 event: <type>\ndata: <json>,由 sidecar/agent.js 的 translateFrame 重写。

consistency.js 检查的是哪几份名单?

四份——builtin-tools.json 的 schema、gen-sidecar-tools.mjs 的 DEFAULT_ALLOW、sidecar_ro.lume 的桥声明、builtin-exec.js 的真实实现。

为什么 capabilities 必须对 agent 开放?

否则 agent 无法区分”工具没开放”和”工具不存在”,历史上因此回答过”网关没挂 article-write tool”。

怎么验证工具链没漂移?

跑 make check,或以 node sidecar/consistency.js --check 触发,漂移会打印差集并以非零码退出。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号