The Soul of cell.cpp Design

拆开一个单文件 C++ Agent —— 它从不幻想模型会听话,也从不假装自己能拦住一切

工程# cell.cpp# Agent# C++26# 系统设计# 工程哲学# 沙箱# 多代理

在上一篇《How to Design an Elegant Agent System》里,我把它提炼成了十条原则。这一篇我想换个方向:不再往上抽象,而是往下钻,看这些原则在具体的代码里是怎么落下来的。

因为我读完两遍之后,发现它所有的取舍背后是同一个判断。我先把它写出来:

它从不幻想模型会听话,也从不假装自己能拦住一切。

前半句解释了那些"多此一举"的机制 —— 两阶段调度、read-before-edit、深度 1 的子代理树、系统消息折叠。

后半句解释了那些"不够硬"的地方 —— 沙箱只检查路径、SIGSEGV 不抢救状态、文档里专门有一节承认自己与注释不一致。

而两者之间那道缝,它用同一件东西填:fail-closed,加上一条不阻塞的降级路径。

下面逐条拆。


1. 单文件是一种纪律,不是一种炫技

cell.cpp 是单一翻译单元。整个程序按命名空间切开,依赖方向被写死:

texttext
cell::async_io   合并后台文件写入器cell::plat       OS 垫片(只有它知道 OS API)cell::text       零拷贝行生成器、display_safe / console_safecell::box        沙箱 + 每个工具实现cell::net        curl 传输(只有它知道 curl)cell::sys        print/eprintln、结构化日志器、scoped_exit、动态线程池、信号处理器cell::config     提供方注册表、设置、旧式迁移cell::encrypt    Argon2id + AES-256-GCM 保险库(只有它知道 libsodium)cell::tools      Policy(Deny/Ask/Allow)、工具基类cell::llm        SSE 解析器、三种 API 风格客户端cell::chat       会话与历史cell::skills     技能发现cell::stats      使用计数器cell::teamwork   子代理调度器 

我看到这张表第一反应是:这是在用命名纪律换取链接期隔离。

把代码放进一个 .cpp,你就失去了"这个符号只在本翻译单元可见"这种编译器保证。换来的是什么?一个 grep 就能看到全貌、没有头文件循环、没有 ODR 陷阱、构建只要一行 g++ -std=c++26 -O2。

但代价是纪律必须靠人守。所以它在注释里明确写了那句话:"只有 cell::plat 了解 OS API,只有 cell::net 了解 curl,只有 cell::encrypt 了解 libsodium。"

这是一份契约,写在注释里,靠自觉执行。我认为这是诚实的选择 —— 它没有假装单文件是纯粹的优势。

我自己的判断:单文件适合规模有上限、边界清晰的程序。cell.cpp 是终端 Agent,它的规模天然有上限(一个人能维护的全部功能),所以单文件是对的。换成框架?我会坚决反对。


2. 循环:结束条件必须可枚举

代理循环画出来是这样:

texttext
user message ──▶ LLM (stream) ──▶ tool_calls? ──▶ pass 1: 只读工具,并发                    │                           pass 2: write/edit 顺序,exec 确认                    │                                  │                    │      results sanitized + wrapped ─▶ append to transcript ─▶ 下一轮                    └──▶ no tool calls ──▶ final answer ──▶ 会话持久化 

我关心的不是这张图,是它下面那句话:

没有固定轮次上限。 循环持续到模型在无工具调用的情况下回答、调用被阻止/拒绝、用户取消或发生错误。

四个结束条件,全都是可枚举的。

对比一下常见做法:"最多跑 10 轮"。那不是结束条件,那是一个放弃阈值。前者是状态机的终止态,后者是超时。区别在于:状态机的终止态可以被记录和解释,超时只能被报告。

这也解释了为什么它能做 /reload —— 因为"中断"是一个明确的状态(interrupt.json 里有 reason),而不是"不知道跑哪去了"。

失败路径同样可枚举:失败的 LLM 请求每轮最多重试 5 次,间隔 5 秒;耗尽后记一个中断轮次;如果失败是上下文溢出,则压缩一次并立即重试,不消耗重试次数。

我喜欢"不消耗重试次数"这个细节。它区分了两种失败:暂时性抖动(消耗重试)和状态性错误(改变状态后重试)。把两者混在一起计数,是很多系统越跑越没劲的根源。

同样的判断也贯穿到子代理:它们没有轮次上限——结束条件与主循环一致地可枚举(无工具调用地作答、被停止、失败),而不是"跑满 N 轮就交卷";上下文增长交给自我压缩接管:子代理把自己的输出摘要成一条助手消息、续回背景之后,原始记录先归档,再发一条 compact 通知给监督者。


3. 九个工具,逐个做过必要性论证

我判断一个指令集好不好的方法,是逐个问"去掉它会怎样"。cell.cpp 的九个工具能全部过关:

工具去掉之后
ls模型无法发现目录结构,只能靠 find 猜
read无法获知文件内容 —— 后续一切无从谈起
write无法创建新文件
edit只能整文件重写,token 爆炸且容易覆盖掉没看到的部分
rg无法按内容定位,只能在文件海洋里 read
exec无法构建、测试、跑脚本 —— 编程 Agent 直接残废
find无法按名字/时间/大小定位(已合并 glob)
tw无法分解超出单上下文的任务
notice子代理无法汇报进度,监督者只能轮询

九个里最容易被质疑的是 tw 和 notice。但正是这两个把 cell.cpp 从"一个会改代码的聊天窗口"抬成了"一个有调度能力的系统"。

而 glob 被合并进 find 这件事,我在上一篇说过,这里再提一次 —— 因为它代表了一种持续的偏好:能合并就不新增。

同样的偏好出现在 edit 上:它没有为"替换""插入""追加""删除"各开一个工具,而是用一个 mode 参数把它们收进同一个工具,外加一个只读的 query 模式用于定位。少四个工具,少四次选错的机会。


4. ABI:返回值是给机器看的界面

这一节我想写得比上一篇更具体。

read 返回的每一行都带右对齐 6 列行号({:>6}: content)。为什么是 6 列、右对齐?因为右对齐让行号在视觉上形成一列,模型更容易把它解析为结构而非文本。这不是排版洁癖,这是对 tokenizer 的体谅。

rg 把命中分组为 === file === + line: content。这让模型能说"src/net.cpp 第 118 行",而不是复述上下文。

exec 结果永远以 exitcode=N 结尾 —— 成功与否不靠猜。

edit 遇到非唯一 search 会中止并报告每一个匹配及上下文,而不是随便改一个。相同替换是 no-op。如果 search 以文件没有的换行结尾,会不带它重试。

最后那句"不带它重试"让我笑了。写它的人一定被模型多写的一个 \n 折磨过无数次。这种代码只有在真实跑过几千轮之后才会长出来。

还有 rg 的两条自我保护:8M 行扫描预算、拒绝指数回溯形态((a+)+、(a | b){3}、a{2}{3})和超过 200 字符的模式。这不是防攻击,是防模型自己写出能卡死自己的正则。

工具的 ABI 不只是给模型的接口,也是给自己兜底的护栏。


5. "它从不幻想模型会听话"

这是我说的那句话的前半句。三处证据:

(a)两阶段调度。 同一条助手消息里,ls/read/rg/find 在共享工作池上并发跑;write/edit 被有意推迟到第二轮顺序跑。这样"读一定先于写完成"和"同一文件的两个编辑永不竞争"就成了调度器的性质,而不是对模型的请求。

(b)read-before-edit。 路径规范化(Windows 上小写化)后记录读取覆盖的行范围,edit 拒绝触碰任何未被覆盖的行。而日志在 /clear、/new、/session、/compact 时一律清除。

清除这一手特别关键。它承认:模型的"我读过这个文件"这份信念,只在它的上下文仍然成立时才成立。 上下文一换,信念作废,日志也跟着作废。这是把模型的认知状态当成一个有作用域的变量来处理 —— 非常工程化的思路。

(c)fold_system_messages。 因为严格的服务端聊天模板(llama.cpp 的 Jinja)只接受恰好一条 system 消息且位于最前,所以每当会话状态在对话中途到达(通知、已加载技能、中断说明),所有 system 消息都会按对话记录顺序合并进唯一的前导 system 消息 —— 而且每次请求前都会再跑一次,作为最后一道防线。

注意"最后一道防线"这个措辞。它显然不信自己第一次就折对了。

三处的共同点:不相信,所以不依赖。 这不是对模型的不信任,这是对"任何概率性组件"的正确态度。


6. "它从不假装自己能拦住一切"

后半句。六层安全模型:

层机制它管什么
1沙箱模式read-only / edit-only / full-access 三档,按模式允许或拒绝工具
2路径门控 check_path.. 一律拒绝;.cell 运行时目录不可访问(除 .cell/skills/);磁盘上任何位置的知名凭据存储(~/.ssh/id_*、~/.aws/credentials、~/.git-credentials、~/.kube/config……)全部阻止
3命令门控 check_exec只检查路径遍历和敏感路径
4人工确认exec 是唯一的 Ask 工具;参数经 display_safe 打印,注入的 JSON 无法擦除提示或伪造批准
5输出加固sanitize_output + wrap_tool_output + truncate_output
6凭据保险库Argon2id + AES-256-GCM(XChaCha20-Poly1305 回退),sodium_malloc 秘密缓冲区

第 3 层是整份设计里最诚实的地方。它在文档里白纸黑字写着:

check_exec 不解析命令名、不解码编码载荷、也不按二进制名阻止网络出口 —— 它们只拒绝路径遍历和敏感路径。exec 仍可运行解释器,例如 python3 -c "exec(base64…)"。

它甚至专门开了一节"实现说明",写代码与自身帮助文本不一致的地方,以及行为故意比看上去更简单的地方。比如:--sandbox 行的帮助文本夸大了模式;exec 工具描述里"拒绝网络"的声明,其实只是"没有白名单"的意思。

我读到这里的时候,是有点respect 的。绝大多数项目会把这些东西藏起来。

威胁模型也写得清清楚楚:一个不受信任的 Agent 运行在受信任的主机内。 有了这句话,第 3 层的不作为就不是缺陷,而是在既定模型下的正确取舍 —— 因为 exec 由用户确认(或 autoallow),网络出口根本不在防护范围内。

同一判断的另一处:SIGSEGV / SIGFPE / SIGILL 故意不尝试序列化状态,恢复默认动作并终止。而 Ctrl+C 走 sigwait 专用线程,持久化代码永远不在异步信号处理器内运行。

能救的救,救不了的别添乱。不是所有死亡都值得抢救。


7. 缝怎么填:fail-closed + 可降级

既然既不幻想模型听话、也不假装能拦住一切,那中间那道缝怎么办?

看 Teamwork 的监督审查,我认为这是整份设计最漂亮的一段:

子代理改主代理授权列表上的文件时,每次 write/edit 都被暂缓,同时向主会话模型发一个短请求,只根据 edit_rules 判断这一次改动。审查携带路径、工具参数、文件当前内容以及它将产生的结果。

  • 审查缺失或失败 → 拒绝,绝不静默允许;
  • 每个子代理最多 8 次审查;
  • 拒绝不是致命的 —— 子代理收到裁决和原因作为工具结果,可修改后重试,或把它的版本写进自己的新文件;
  • 租约从不比一次运行活得更久;
  • 审查是并发的 —— 租约锁在发出审查请求之前就已释放,没有子代理排队等另一个的审查。

最后两条我要单独点出来。

"租约不比一次运行活得更久"意味着权限是作用域内的临时状态,不会跨任务泄漏成持久的隐性授权。

"锁在发出审查请求之前就已释放"意味着它把互斥精确限定在真正需要互斥的那一段。绝大多数系统会一直持锁到审查返回,然后所有子代理排队 —— cell.cpp 没有。

而"拒绝不是致命的"这句,是让整个机制能跑起来的关键。子代理永远可以 write 创建新文件 —— 文件被创建的那一刻它就属于该子代理,之后可自由重写。

于是被拒绝的尝试自然流向"我另建一个文件",而不是"我报错退出"。

默认拒绝,但拒绝之后必须有一条通向结果的替代路径。

这句话我在上一篇也说过,因为我认为它是所有安全设计的通用解。只做 fail-closed 的系统是脆的;只做降级的系统是松的。两个一起做,才既硬又不断。


8. 状态不丢:从 token 边界到原子 rename

中断这一块我愿意称之为教科书级。

截断到 token 边界。 Esc 或最终失败时,模型已产出的内容原样保留,截断到最后一个完整到达的 token。因为 SSE 只应用完整流事件,累积文本永远停在 token 边界 —— 不补全、不填充。

不发明结果。 仍在发出的工具调用被取消 —— 参数通常是截断的 JSON,从未被批准或执行,也绝不为它发明结果。部分助手消息存储时不带其 tool_calls,保证对每种 API 风格都合法。

"不为半截的工具调用发明结果"这一条是分水岭。太多实现在这里塞一个假返回,然后整条 transcript 就脏了,后面所有轮次都在一个谎言上继续推理。

原因进对话。 追加一条系统消息说明原因(Esc,或错误及尝试次数)—— 它是对话的一部分,不只是控制台输出。所以模型能看到上一轮为何而停。

状态可持久。 interrupt.json 记在会话文件夹里,重启后仍在;由下一条用户消息(含 /ins)或达到自然结束的 /reload 清除。/reload 按现状发送对话记录,不添加用户消息,正好从停下的地方续。

落盘是原子的。 所有会话/配置/索引写入走后台合并写线程,每次都是:写 <name>.tmp → flush 到存储设备 → rename 覆盖目标。崩溃永远不会留下一个被下次启动静默读成空的截断文件。读取型命令(/save、/sessions、/compact、退出)会先 flush() 作为持久性屏障。

从头到尾一条链:不丢、不假、可解释、可续、原子。 我认为这五个词是任何长跑系统的及格线。


9. 零 token 的等待:把协调成本压到 0

这是我最喜欢的一处,因为它解决了一个别人甚至没意识到的问题。

监督者要等子代理汇报进度。naive 做法是轮询:每 N 秒问一次模型"子代理好了吗"。每问一次就烧一轮 token。

cell.cpp 的 notice 是一个零 token 的消息总线:

  • 任何写入者(REPL 用户、本进程的子代理、共享运行时根目录的另一个 cell 进程)向目标会话的 notices/pending/ 投放一个 JSON 文件(原子写入,仅 UTF-8,剥离控制/ANSI 字节,16 KB 正文上限,500 个待处理上限);
  • /notices wait 在条件变量上阻塞,并周期性重新扫描文件夹(约 0.7 秒)—— 空闲时不发出任何 LLM 请求;
  • 进程内发送立即唤醒等待者;跨进程发送约 1 秒内到达;
  • 待处理通知在每次 LLM 轮次前自动排空,所以监督者永远不必轮询;
  • 已送达通知归档到 seen.jsonl(一条可以用 /notices seen 查询、清理、归档的审计轨迹),且在排空锁下删除文件,每个排空会话恰好送达一次;
  • 寻址是句柄式的:精确 id、唯一前缀、16 位工作区键、工作区路径、current,或 reply —— 直接回信给"最新一条发给我的通知"的发送者,连 id 都不用知道;/notices sessions 列出所有并发会话(包括刚打开、尚未落盘的),每条发送都会记录发送者,收到的人用 session=reply 回信 —— 对话双向路由。

于是监督者可以在 /notices wait 里空转,被每次阶段/worker 转换唤醒 —— 全程零 LLM 往返。

而且进度通知是自动的:任务开始/完成/失败、阶段开始/结束、门控暂停、停止、每个 worker 开始/结束(带状态和摘要片段),全部以 from=teamwork/<job-id>[/<worker>] 发出。

我认为这一处体现了一种很稀缺的成本意识:不只是优化 token,而是先问"这一步真的需要模型参与吗"。

等待不需要。所以等待是零 token 的。


10. 自测与 transcript:让系统能自我证明

--selftest 在隔离的 .cell-selftest/ 目录上跑数百条断言,跑完删除。覆盖:

  • 三种模式下的高风险和沙箱决策;
  • 路径 / 遍历 / 符号链接 / 凭据阻止;
  • 注入清理器(大小写、\r、零宽、全角、西里尔/希腊、重音、跨行拆分、标点连接、释义、超大输出);
  • wrap_tool_output 防伪造;
  • read / write / edit 全部五种模式、歧义、部分读取覆盖、no-op;
  • rg / find / ls 语义和守卫;
  • exec 超时和退出码;
  • 16 路并发只读工具调用;
  • Teamwork 任务规范化、存储往返、历史和报告查询;
  • 增量 SSE 解析、线程池记账、日志轮转、保险库往返、配置迁移、会话分组、中断轮次、行编辑器(按键解码、UTF-8 光标步进、历史去重与上限、移动/删除/yank/转置语义)、技能发现、使用统计。

"跨行拆分"这个测试项说明:注入清理器那套"6 行窗口拼接"不是随手写的,是有测试盯着它的。

而 /export 把 transcript 变成可以发给别人看的东西:自包含 HTML,无脚本无外部资源,明暗自适应,左侧粘性执行链与右侧消息 1:1 链接,工具调用/结果/推理渲染为折叠区;Teamwork 任务有可点击的任务-阶段-子代理列表;工作流任务生成带门控菱形节点的流程图子页面;页面还是手机友好的——视图标签变成拇指可达的底栏,侧栏折叠为点击打开的抽屉(依旧零脚本)。

这两件事合起来是一句话:一个 Agent 系统必须能证明自己做过什么。


灵魂小结

回到开头那句话:

它从不幻想模型会听话,也从不假装自己能拦住一切。

我把这一路读下来看到的东西,归纳成五条,作为我对 cell.cpp 的总结:

  1. 约束优先。 九个工具能合并就合并(glob→find,五种 edit 模式→一个 mode);子代理层级硬编码为深度 1 —— 子代理永远不能调用 tw,所以永远生不出孙代理。这不是配置,是硬编码。

  2. 机制 > 提示词。 两阶段调度、read-before-edit、系统消息折叠、不存在的 provider 直接报错而非静默回退。凡是能由代码保证的不变量,绝不写成对模型的请求。

  3. fail-closed + 可降级。 审查缺失或失败一律拒绝;但子代理永远能创建新文件,被拒的尝试自然流向替代路径。默认拒绝让系统硬,降级路径让系统不断。

  4. 状态不丢。 截断到 token 边界、不发明结果、原因进对话、原子 rename、SIGSEGV 不抢救。不丢、不假、可解释、可续、原子。

  5. 诚实。 专门开一节写自己与注释不一致的地方;明说沙箱只管路径;明说 exec 能跑 python3 -c "exec(base64…)"。先声明威胁模型,再在这个模型下把能做的做到位。

第 5 条是我最想留下的。

我看过太多项目,把安全做成一句 slogan,把能力吹成一层包装。cell.cpp 反过来 —— 它先告诉你笼子的边界在哪,再告诉你笼子里它能保证什么。

把不确定性关进确定性的笼子,然后老老实实画出那个笼子。

这就是我认为的 cell.cpp 的设计灵魂。


上一篇:How to Design an Elegant Agent System

评论