Skip to main content

Suna:最接近 Manus 的开源实现

一句话:前面四家拆的都是「Agent 怎么想」,这一家值得拆的是「Agent 跑在哪」——沙箱这层的工程量,比 Agent 循环本身大得多。

一、拿的是哪份代码

git clone --depth 1 --filter=blob:none --sparse https://github.com/kortix-ai/suna.git
仓库kortix-ai/suna
Star20,115
语言TypeScript(Bun 运行时)
拉取时间2026-08-19

仓库是个 pnpm monorepo,apps/ 下有 11 个应用:

apps/
├── api ← 控制面:账号、计费、权限、连接器
├── kortix-sandbox-agent-server ← 沙箱内的 Agent 监工,本篇重点
├── sandbox ← 沙箱镜像
├── llm-gateway ← 模型网关
├── web / mobile / desktop-electron / cli ← 各端
├── voice-agent
├── kortix-app-runtime
└── whitelabel-demo

这个目录结构本身就是结论:一个「Manus 类」产品,Agent 逻辑只占其中一个 app,剩下十个都是把它变成一个能卖的服务所需要的东西。

二、最大的发现:它不自己写 Agent 循环

apps/kortix-sandbox-agent-server/package.json 里的一句描述:

apps/kortix-sandbox-agent-server/package.json
{
"name": "@kortix/sandbox-agent-server",
"description": "Sandbox-side OpenCode REST supervisor and Kortix API surface.",
...
}

"OpenCode REST supervisor" —— 它是 OpenCode 的监工。

源码目录里满眼都是 opencode:

src/
├── opencode.ts ← 进程管理
├── opencode-config-deps.ts ← 配置依赖
├── opencode-events.ts ← 事件流转
├── opencode-turn-state.ts ← 轮次状态
├── opencode-fork-root.ts ← 温启动 fork 的根会话处理
├── opencode-audit-relay.ts ← 审计中继
├── managed-opencode-env.ts ← 环境管理
├── llm-proxy.ts ← 凭证注入代理
├── egress-shim/ ← 出网管控
├── runaway-turn-guard.ts ← 失控防护
└── routes/ ← abort / files / find / git / pty / port-proxy / web-proxy ...

所以 Suna 的架构是这样的:

它把「Agent 怎么想」外包给了一个成熟的开源 Agent,自己只做「Agent 跑在哪、跑多久、能访问什么、烧了多少钱」。

这个选择很值得琢磨。对照 Kimi CLI 自己写了 52,049 行DeerFlow 在 LangGraph 上糊了 40 个中间件——Suna 是第三条路:Agent 循环直接用别人的,力气全花在运行时。

子 Agent 的部分也就顺理成章地继承自 OpenCode:

src/opencode-events.ts:43(注释)
// subagent (Task tool) child sessions — so the handler is responsible for ...

子 Agent 在这里是 OpenCode 的 Task 工具产生的 child session,Suna 只负责观测和管控它们。

三、温启动:为什么值得花这么大力气

opencode-fork-root.ts 开头的注释,把一整套冷启动优化讲清楚了:

src/opencode-fork-root.ts:1-19
/**
* Warm-fork opencode-root de-collision (pure logic, separated from main.ts so it
* is unit-testable — main.ts self-executes on import).
*
* A warm sandbox is CoW-forked from a snapshot that booted opencode, created ONE
* root session, and pinned it (OPENCODE_SESSION_PIN_PATH) so forks resume warm
* without paying opencode's first-session project init. The catch: every fork
* inherits the SAME pinned root id from that one snapshot.
*
* The client keys ALL session state — messages, parts, status — purely by
* opencode session id (it assumes ids are unique per sandbox). So if forks adopt
* the shared seed root, every session resolves the same id and their chats bleed
* into one another: "switch sessions, see the same thread everywhere".
*
* Fix: the seed records the baked id in a marker file that is captured into the
* snapshot. A fork reads it to rotate onto its OWN fresh root EXACTLY ONCE,
* instead of reusing the shared seed root, then retires the marker so later
* daemon restarts reuse the fork's own root via the normal idempotent path.
*/

拆开看这里发生了什么:

  1. 先起一个沙箱,把 OpenCode 启动、创建一个 root session、pin 住,然后打快照
  2. 用户来了,从快照 CoW fork 一个沙箱出来 —— 省掉 OpenCode 的首次项目初始化
  3. 但是:每个 fork 都继承了同一个 pinned root session id
  4. 客户端完全按 session id 索引状态,于是所有会话串台 —— 「切换会话,到处看到同一个对话」
  5. 修法:快照里埋一个 marker 文件记下 seed 的 id,fork 起来后发现自己的 root 就是那个 seed id,就换成一个全新的,然后把 marker 退休

这个 bug 是「性能优化引入正确性问题」的教科书案例。 而且它只在温启动路径上出现,冷启动完全正常——最难查的那种。

llm-proxy.ts 里还有配套的第二个优化:

src/llm-proxy.ts:3-23(注释节选)
// Localhost credential-injecting reverse proxy (the warm-fork "no restart on
// restore" mechanism).
//
// WHY: a stateful warm-fork session attach used to KILL + respawn
// opencode purely to swap in the per-session tokens (LLM gateway key + connector
// token) — re-paying ~8s of opencode init that the snapshot already baked.
// opencode reads its config (provider.options.apiKey, mcp.environment) only at
// spawn, so swapping a token forced a config rebuild + restart.
//
// Fix: make those credentials SESSION-INDEPENDENT in the baked config. The config
// points the relevant baseURL/api-url at THIS localhost proxy with a fixed
// placeholder Bearer; the proxy holds the real per-session token in memory and
// rewrites the Authorization header on the way upstream. On restore the daemon just
// calls setToken() — opencode is never restarted.

问题:OpenCode 只在启动时读配置,换一个用户的 API key 就得重启,重启一次 8 秒

解法:配置里写死指向 localhost 代理和一个占位 Bearer,真正的 token 在代理进程内存里,出网时改写 Authorization 头。换用户只要 setToken(),OpenCode 全程不重启。

8 秒这个数字很关键。一个「点一下就开始干活」的产品,冷启动 8 秒是致命的。这条优化的价值不在架构优雅,在用户点下按钮之后的那个瞬间。

四、出网管控:每个沙箱一个临时 CA

egress-shim/ 目录做的是在沙箱内劫持出网流量。要 MITM HTTPS 就得有 CA,于是每个沙箱签发一个临时 CA。

测试文件开头的这段注释,是我在整个专题里见过最好的「踩坑记录」:

src/egress-shim/ca.test.ts:1-9
/**
* The certificate extensions, pinned because a missing one is invisible until a
* specific client rejects the chain in a real guest.
*
* Found the hard way: without the key-identifier pair, `curl` accepted the
* certificate and `python3 -m requests` refused it with
* `CERTIFICATE_VERIFY_FAILED ... Missing Authority Key Identifier`. Measured in
* a real Daytona sandbox — every local test passed while Python was broken for
* every agent that would have used it.
*/

curl 能过,Python requests 过不去。 本地测试全绿,真沙箱里所有用 Python 的 Agent 全挂。

这段注释本身就是最好的论据:沙箱这层的坑,跟 Agent 一点关系都没有,但会让整个 Agent 产品不可用。

五、失控防护:一条完整的线上事故记录

runaway-turn-guard.ts 的顶部注释,是本专题里最有价值的一段文字。它把一次线上事故从现象到根因到修复完整写下来了:

src/runaway-turn-guard.ts:3-26(注释节选)
// A turn — in ANY session, root or spawned child — that completes successfully
// (`session.idle`, no error) while answering the SAME parent user message it
// already answered on the PREVIOUS completion is a runaway: something re-triggered
// generation against a STANDING prompt instead of recognizing it as already
// answered — observed live 2026-08-18 (session `749045da`) as OpenCode replying
// the same one-word answer back-to-back, indefinitely, until manually aborted at
// 44 messages / $0.18. The likely trigger was a caller-supplied `messageID` that
// did not conform to OpenCode's own sortable-clock id format, breaking its
// "has this prompt already been answered" ordering check ...
//
// ... each repeat is a full clean success — no error, no timeout — so
// `turn-auto-resume.ts` (which watches `session.error`) does not apply and
// nothing else stops it. Left unguarded this burns real tokens/cost with no ceiling.
//
// Per opencode SESSION, children included: the 2026-08-18 Essentia incident
// (session `5d9e298a`) was a spawned child looping this way while
// `relayTurnEndToApi` filtered non-root sessions out before this guard ever saw
// a repeat — the abort must target the session that is looping.
//
// MAX_CONSECUTIVE_REPEATS=3 mirrors `turn-auto-resume.ts`'s MAX_ATTEMPTS_PER_WINDOW:
// a small number of repeats could in principle be legitimate ...; a 4th identical
// repeat of the SAME standing prompt is not.

把这段拆开:

要素内容
现象OpenCode 对同一条用户消息反复给出同样的一个词的回答,无限循环
发现时间2026-08-18(这份代码拉下来的前一天
止损方式人工中止,止损时已经 44 条消息 / $0.18
根因推测调用方传的 messageID 不符合 OpenCode 的可排序时钟 id 格式,破坏了「这条 prompt 是否已回答过」的判定
为什么没被现有防护抓到每一轮都是干净的成功——没有 error、没有 timeout,所以监听 session.error 的自动恢复逻辑完全不触发
第二起事故同日另一起,循环的是一个子会话,而上报逻辑在这个守卫看到重复之前就把非 root 会话过滤掉了
修复按 session 粒度(含子会话)计数,同一 parent message 连续重复 3 次就中止

三个教训,每一条都很硬:

① 最危险的失控是「一直成功」的那种。 报错会被重试逻辑接住,超时会被超时逻辑接住,但「每次都干净地成功、只是在做同一件事」什么都接不住DeerFlow 的 loop_detection_middleware 解决的是同一类问题。

② 子 Agent 的监控必须和主 Agent 一视同仁。 第二起事故的直接原因就是上报链路把非 root 会话过滤掉了——主 Agent 的防护做得再好,子 Agent 是个盲区,钱一样烧。

③ 阈值要有依据。 MAX_CONSECUTIVE_REPEATS = 3 明确对齐了另一个模块的 MAX_ATTEMPTS_PER_WINDOW,并且解释了为什么 3 次可能合法、第 4 次一定不合法。这比拍脑袋写个数字强太多。

顺带一提,$0.18 这个损失金额不值一提——但它是被人工中止的。没人盯着的话,上限是无穷。写这个守卫的人显然想明白了这一点。

六、五维打分

对照 01 的五个维度

维度Suna 的答案
D1 隔离单位一个沙箱(CoW fork 的容器),粒度仅次于 Manus 的 VM
D2 通信拓扑继承 OpenCode 的 Task 子会话模型,星型
D3 结果回收由 OpenCode 负责,Suna 只做事件中继和审计
D4 递归深度由 OpenCode 决定;Suna 侧的防护显式覆盖 spawned child
D5 生命周期温启动 CoW fork、凭证热替换、失控自动中止

一句话总结:Suna 是「Agent 逻辑外包、运行时自建」路线的样本。它证明了一件事——做一个 Manus 类产品,难点不在 Agent 循环,在沙箱、冷启动、出网和成本刹车。

七、这篇最该带走的东西

如果你要做一个「给用户一台电脑」型的 Agent 产品:

  1. 冷启动是产品问题不是工程问题。8 秒的初始化必须想办法绕过,CoW 快照 + 凭证代理是一条已被验证的路。
  2. 性能优化会引入正确性问题。共享 pinned root 导致会话串台,这类 bug 只在优化路径上出现。
  3. 沙箱出网管控要真机验证。本地测试全绿、真沙箱里 Python 全挂,这种事只能在真环境里发现。
  4. 给「一直成功的死循环」单独做防护,并且务必覆盖子 Agent
  5. Agent 循环可以不自己写。OpenCode、OpenHands 这类成熟实现拿来用,把力气留给别人替你做不了的那部分。

八、参考