resolve-lume Architecture: C Backend + Host Sidecar + Node Reverse Proxy

🇨🇳 中文版

resolve-lume is a reimplementation of the core of resolve-studio on the lume stack—the original project is left untouched, and only its multi-agent workbench is rewritten with lume + lume-core + React. The container boundaries in this system are not split by tech stack, but by privilege requirements: the layer that can run a shell, write to disk, and spawn child processes is moved out of the container, and the remaining two layers are slimmed down so much that neither Node nor Python is even installed in the runtime. The following three layers—the in-container C runtime, the sidecar process on the host, and the self-written Node reverse proxy inside the container—each layer's ownership can be found as criteria in the source code, and each can also find footnotes in the source comments about where the line was originally drawn wrong.

Overview: Two Containers + One Host Process

There is only one layered call chain: the browser hits :18085 → the Node service in the frontend container → /api/* goes through host.docker.internal:3000 to land on the host sidecar, /react/* goes through the compose internal network to the backend's :8080.

Key assertion: the sidecar is not inside compose; it is a host process. In docker-compose.yml, the sidecar service has been commented out and removed, leaving only an explanatory block; what starts it is the Makefile's sidecar-host-start, which uses nohup node sidecar/launcher.js to start a Node on the host machine. This will be expanded in layer 3 below—the reason it must land on the host is not a matter of taste, but that the capabilities it needs to reach simply do not exist inside the container.

Layer 2 · Frontend Container: Why Not Mount nginx and Instead Write a 200-Line Node Reverse Proxy Yourself

The image bypasses nginx. The reason is that this reverse proxy layer does not just forward; it also does shape rewriting and contract injection, which pure static configuration cannot do.

The relationship between the sidecar and the C binary agent-httpd is stated plainly in the file-header comment of agent.js—it is merely a gateway that forwards requests to the C binary and rewrites the latter's SSE frames into a format the frontend understands (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.

And the entry that actually points at the C binary backend is at sidecar/agent.js:13:

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

The C binary agent-httpd emits its own SSE dialect: each frame has only data: {"t":"delta","d":"..."}, with no event: line, and the type is hidden in the JSON's t field (sidecar/agent.js:4). But the ported frontend only recognizes the OpenAI-style event: <type>\ndata: <json> (sidecar/agent.js:6). Neither side changes, so the cost falls on this middle layer: translateFrame (sidecar/agent.js:83) rewrites frame by frame, mapping t into an event: line, so the frontend can consume lume's runtime as-is.

This layer also carries the sediment of an incident. The emptyRunSummary(buf) inside translateFrame is not an ordinary error message; it is a dated retrospective: the comment records the 2026-11-06 incident of 8 rounds, 1 article-write plus 6 unrelated hot-news calls, zero output, and the user had no clue to determine which tool had failed. So on an empty answer it no longer emits "the tool executed but the model returned no summary," but instead prints the call trace (in collapsed form like hot-news ×4), the round budget, and the last error together, and explicitly discourages "just click retry." This kind of thing cannot be written into nginx configuration; it can only be written in the reverse proxy.

The same idea also exists on the request side. sidecar/chatBody.js:2 explains at the top why this pure function was split out separately:

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 does shape conversion: the frontend sends { messages, model, ... }, while agent-httpd wants { message, history }, so it takes the last user message as message, puts the rest into history, appends the task soul and the frontend-computed systemPrompt in order, and always presses TOOL_DISCIPLINE at the very end. Why at the very end? Because it has the highest priority and the most authority. And this discipline itself is incident-driven: sidecar/chatBody.js:8 says that a single tool call failure is enough to make the free-tier model drift into another business domain and burn through the entire 8-round budget.

Splitting it into a pure module that does not import the side effects of server.js is for unit testing—these contracts are the things most likely to be quietly broken during changes.

Layer 3 · Host Sidecar: Why It Must Leave the Container

What the sidecar does—translating SSE, injecting system prompts, maintaining tool-list drift checks, and spawning child processes to execute tools—half of it requires host privileges. Keeping it in compose would mean the container has to install Node, be able to exec, and be able to write to disk, which would make the "slimming" of layer 1 pointless. So the sidecar service in docker-compose.yml is commented out and instead started by the Makefile on the host machine with nohup node sidecar/launcher.js.

What most deserves to be singled out here is sidecar/consistency.js. It is not runtime logic but an assertion gate at startup, guarding against "the four lists each drifting on their own." readToolChain reads the four lists out of their respective sources—DEFAULT_ALLOW from the generator script, tool "name" from the .lume bridge, and tool names from the schema file:

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 }
}

Four lists: the schemas shown in the frontend panel, the DEFAULT_ALLOW declaring what should be exposed, what the agent can actually see in the generated .lume bridge, and what is actually implemented in builtin-exec.js. computeDrift computes every pairwise difference, and any drift becomes an explicit startup failure rather than a silently disappearing capability.

One special case is worth copying down: capabilities must be exposed on both the allow and bridge sides, otherwise the agent cannot distinguish "not exposed" from "does not exist." This is exactly the source of that earlier line "the gateway has no article-write tool mounted"—it was not that the tool was not implemented, but that the agent could not prove it to itself.

The reason this whole set of checks can run on the host rather than in the container is that it needs to read generation scripts, read .lume source files, and read implementation modules—all filesystem actions inside the repository, most natural to do on the host. Doing it in the container would require either mounting a bunch of volumes or stuffing the generation flow into the build phase.

Layer 1 · In-Container C Runtime: Slimmed Down to Just the Binary

The backend container runs the agent-httpd C binary; in compose it only exposes the port, and all external access is bound to 127.0.0.1. It does not recognize OpenAI's SSE dialect (sidecar/agent.js:4), nor is it responsible for shape conversion; dialect matters are all handed to the host sidecar. There is no Node and no Python in the container, because this layer only does what a runtime should do: receive requests, run the agent loop, and emit events in its own {t,d} format.

The same goes for the frontend container. Externally it is the Node service server-entrypoint.mjs; /api/* is forwarded to the host sidecar via host.docker.internal:3000, and /react/* goes through the compose internal network to the backend's :8080. Those fetch('/api/...') calls in frontend/src/api.ts (fetchTasks, createJob, streamJob, etc.) all only recognize relative paths and never know whether the other side is the host or a container—this is an implicit benefit of cutting the boundaries correctly: the frontend is agnostic to topology.

How the Contracts of Each Layer Come Together

Back to the conclusion at the beginning: the three-layer boundaries are split by privilege requirements, not by language. Looking layer by layer, each layer retains only the capabilities it absolutely must have.

  • The C layer only manages the runtime and its own SSE dialect, and does not touch shape conversion (sidecar/agent.js:4).
  • The sidecar layer is on the host, responsible for everything that requires exec / disk writes / reading repository files, as well as the injection of shape and discipline (sidecar/chatBody.js:2, sidecar/consistency.js:79).
  • The frontend layer only does reverse proxying and static serving, keeping dialect differences outside the container (sidecar/agent.js:6).

The three layers mesh through two contracts: one is the SSE frame format ({t,d} ↔ event: line), and the other is the request body shape of /api/chat ({ messages } ↔ { message, history, system }). Both contracts are each carried by a pure function and can be unit-tested independently of the HTTP service, which is exactly why they could be stably fixed after multiple incidents.

To reproduce this topology, run in order:

make bootstrap
make build
make check

make check will run the toolchain consistency check as well—drift in the four lists will fail directly and print the differences.

Architecture Comparison: resolve-lume vs lume-a2a on Multi-Agent Design

resolve-lume and lume-a2a are both built on lume + lume-core + React, but they solve two opposite sides of Multi-Agent: resolve-lume is a single-node, multi-role "hands-on" executor, while lume-a2a is a cross-node distributed A2A "mouth-only" protocol layer.

Dimension resolve-lume lume-a2a
Form Single node, multi-role (10 souls: planner/evaluator/specialist + code-review/rust-review/weekly-investment/article-write …) Cross-node distributed A2A (default 7 real lume nodes)
Collaboration PSE role chain: Planner decomposes → Specialist researches → Evaluator critiques → ToolAgent executes Inter-node negotiation + delegation: hands work to the executor
Execution boundary sidecar is the hands layer: builtin-tools.json registers 43 business tools, of which 24 are exposed to agent-httpd via sidecar_ro.lume; real exec / disk-write happens in the sidecar 25 parameterless read-only tools; hands stay on the executor
Orchestration base C binary agent-httpd + sidecar (:3000) node.lume + a2a.lume + negotiate.lume
LLM coupling Live SSE streaming Snapshot-based: the console reads the negotiation result JSON, no live model call
Protocol In-process role messaging (inside the orchestrator) A2A JSON-RPC + /peer/invoke (HMAC-signed) + Agent Card discovery
Compliance PSE weekly portfolio diagnosis, code-grounded (sidecar/consistency.js:79's readToolChain/computeDrift) Explicitly non-advisory, industry-level only, no security codes

The most essential point: sidecar/business-helpers.js:363's runPseTask pipeline (article-write / pse-review / resume-tailor …) runs inside resolve-lume's sidecar — it is itself a hands-on multi-agent workbench. The lume-a2a fleet treats resolve-lume as peer #7: it only queries the capability catalog resolve-lume exposes and delegates the "hands-on" work back via task.delegate; and the executor's internal tool discipline (sidecar/chatBody.js:8, which records the 2026-11-06 incident) is exactly how this boundary held—stopping the free-tier model from wandering into other business domains. In other words, resolve-lume is the executor whose "hands" lume-a2a borrows, while lume-a2a is the protocol layer that lets independent agents discover, negotiate, and delegate.

lume Source Evidence: peer_node.lume and sidecar_ro.lume

The layers above are all about container boundaries and the Node sidecar, but what lets resolve-lume be "borrowed" by lume-a2a as its 7th peer is its own lume file — peer_node.lume. It is the same shape as crm/scanner/talentlens: a static node whose capabilities come entirely from local files and are orthogonal to the backend container and host sidecar (these capabilities work even when those are down). The file header pins the endpoint signing and privacy discipline hard (excerpt from peer_node.lume:1 and peer_node.lume:17; one local workspace path redacted). (The lume source below keeps its original Chinese comments, as it is the real file.)

// 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;

The capability list it exposes, rl_static_tools(), states "show the menu but never the hands" in black and white — resolve.* is a read-only summary, exec.* is the real hands-on proxy (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 },
  ];
};

The routing in /peer/invoke is the hard implementation of this boundary: names starting with resolve. go to read-only local files, names starting with exec. are forwarded to the host sidecar to actually work, everything else is denied — it won't even "casually call some other tool" (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.* 只读能力, 不接受其他工具调用" };
};

On the other side, lume-tools/sidecar_ro.lume is the tool bridge the agent can actually see — exactly the "agent CAN see" list that is the fourth of the four lists in consistency.js. It is auto-generated; all tools share one outbound call sidecar_tool(): the tool name is hard-coded in the handler, so the LLM cannot pass the name as a different tool, and therefore cannot loop the request back to 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; ...]"; }
  ...
}

The article-write tool mentioned in line 116 of the article is just one line of declaration in this bridge — note that it explicitly requires "call directly, do not read files or guess paths first":

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));
};

Source Code Navigation

  • sidecar/agent.js — frame-by-frame translation from the agent-httpd SSE dialect to OpenAI style, empty-answer retrospective
  • sidecar/chatBody.js — /api/chat request shape conversion, tool call discipline, release intent determination
  • sidecar/consistency.js — drift check for the four tool lists
  • sidecar/builtin-tools.json — tool schemas shown in the frontend panel
  • sidecar/builtin-exec.js — the real implementations of tools and BUILTIN_NAMES
  • deploy/gen-sidecar-tools.mjs — DEFAULT_ALLOW and bridge generation
  • lume-tools/sidecar_ro.lume — the tool bridge visible to the agent
  • frontend/src/api.ts — the entire call surface of the frontend to /api/*
  • frontend/server-entrypoint.mjs — the service the frontend container actually runs externally
  • docker-compose.yml — the backend / frontend services and port bindings

Why isn't the sidecar placed in compose?

It needs to exec, write to disk, and read files inside the repository, which are host privileges; that service in docker-compose.yml has been commented out and removed, and the Makefile starts sidecar/launcher.js on the host.

What is the difference between agent-httpd's SSE and the format the frontend wants?

Each frame of agent-httpd has only data: {"t":"delta","d":"..."}, with the type in the JSON’s t field; the frontend wants event: <type>\ndata: <json>, which is rewritten by translateFrame in sidecar/agent.js.

Which lists does consistency.js check?

Four—the schemas in builtin-tools.json, the DEFAULT_ALLOW in gen-sidecar-tools.mjs, the bridge declarations in sidecar_ro.lume, and the real implementations in builtin-exec.js.

Why must capabilities be exposed to the agent?

Otherwise the agent cannot distinguish “the tool is not exposed” from “the tool does not exist,” and historically it therefore answered “the gateway has no article-write tool mounted.”

How do you verify the toolchain has not drifted?

Run make check, or trigger it with node sidecar/consistency.js --check; drift will print the differences and exit with a non-zero code.

Comments

Leave a reply

Your email address will not be published. Required fields are marked *

AI Engineering Practices & Open Source Projects

Shop Web Chat Nsbp About Privacy

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