数据截至 (上游 commit 53ea1e8ba6fd)
容器内的 agent-runner
本章讲什么: 容器里那 5700 行 TypeScript 在干嘛。核心是一个 while 循环,但**「模型说的哪段话该发出去」这个问题占了它一半的复杂度**——本章重点讲这块。
1. 启动:它读什么、不读什么
container/agent-runner/src/index.ts:48 的 main:
① loadConfig() ← 只读 /workspace/agent/container.json
② ensureMemoryScaffold() ← 建记忆目录骨架
③ buildSystemPromptAddendum() ← 运行时生成的系统提示补充:名字 + 目的地表
④ 扫描 /workspace/extra/* ← 额外挂载目录
⑤ 组装 MCP servers(内置 nanoclaw + container.json 里配的)
⑥ createProvider(providerName) ← 从注册表拿 provider
⑦ runPollLoop(...)
环境变量只用两类: TZ,和 OneCLI 的网络变量。所有 NanoClaw 自己的配置都在 container.json 里——文件头注释明确写了这条规矩(No stdin, no stdout markers, no IPC files)。
系统提示分三处来
| 来源 | 内容 | 何时定 |
|---|---|---|
/app/CLAUDE.md(RO 挂载) | 所有 agent 共享的基底行为 | 镜像/仓库里 |
/workspace/agent/CLAUDE.md | 每次 spawn 由主机重新组装的入口(import 基底 + 各模块片段) | spawn 时 |
| 运行时 addendum | agent 名字 + 当前目的地表 | 每次容器启动 |
目的地表是活的:主机在每次唤醒时刷新 inbound.db 的 destinations 表(replaceDestinations,src/mailbox/sqlite/session-db.ts:68;表定义在 src/mailbox/sqlite/schema.ts:30),容器每次查名字都是现查(container/agent-runner/src/mailbox/sqlite/operations.ts:276)。所以管理员改了接线,不用重启容器就生效。
2. 主循环:runPollLoop
container/agent-runner/src/poll-loop.ts:78。骨架很短:
while (true):
messages = getPendingMessages() # 排除 kind='system'
if 空: sleep(1s); continue
if 全是 trigger=0: sleep(1s); continue # 只有积累上下文 → 不唤醒
markProcessing(ids)
分离出命令(/clear、/upload-trace)并就地处理
跑定时任务的预检脚本(可能把某些任务行挡掉)
prompt = formatMessagesWithCommands(剩下的)
query = provider.query({ prompt, continuation, cwd, systemContext })
await processQuery(query, ...) # ← 复杂度都在这
markCompleted(processingIds)
两个 sleep 分支的意义
第二个分支(poll-loop.ts:134)是「accumulate 契约」的容器侧执行点:一批全是 trigger=0 的上下文行不唤醒 agent,留着 pending,等下一条真的触发消息来时一起被读走。
主机侧的 countDueMessages(src/mailbox/sqlite/session-db.ts:108)用同样的条件决定要不要冷启动容器。同一条规则在两处独立实现,一处管「热容器要不要理」,一处管「冷容器要不要起」。
取消息的两阶段选择
getPendingMessages(container/agent-runner/src/db/messages-in.ts:80)有个不显然的顺序讲究:
① 查 inbound.db 所有到期 pending 行(按 seq)
② 减去 outbound.db 的 processing_ack 已认领集合 ← 必须在③之前!
③ 阶段一:trigger=1 的行,最旧优先,取满 cap
阶段二:剩余名额用最新的 trigger=0 行填
④ 合并后按 seq 排序返回
为什么②必须在③之前(注释在 :68-73):这个容器刚认领的行,在主机 sweep 同步回来之前(最多 60 秒)在 inbound.db 里仍然是 pending。先开窗口的话,一整窗都是这些已认领的行,过滤后窗口空了,而窗口之外的新行整轮都看不见。
为什么要两阶段:如果群里刷了一堆 accumulate 上下文行(比 cap 还多)且比一个到期的定时任务更新,单纯按 seq 取前 N 会把任务本身挤出去。
3. Provider 抽象
接口在 container/agent-runner/src/providers/types.ts:3:
| 成员 | 干什么 |
|---|---|
query(input) | 开一轮,返回 { push, end, events, abort } |
supportsNativeSlashCommands | SDK 自己能处理 /compact 这类命令吗 |
emitsMidTurnText | 能力声明:是否每段助手文本都会先以 text 事件流出来 |
isSessionInvalid(err) | 这个错误是不是意味着存的续接 id 废了 |
maybeRotateContinuation(id, cwd) | 续接前的体检:transcript 太大/太老就换新会话 |
onExchangeComplete(exchange) | 可选:自己不落 transcript 的 provider 用它归档对话 |
registerMemorySessionHook(hook) | 用 provider 原生的 session-start 机制注入共享记忆 |
事件流是统一的(types.ts:157):init / result / text / error / progress / activity。
activity 是硬要求:注释写「providers MUST yield this on every underlying SDK event」,因为轮询循环靠它 touch 心跳。
Claude provider 的具体化
container/agent-runner/src/providers/claude.ts:549 的 query 传给 SDK 的关键选项:
| 选项 | 值 | 为什么 |
|---|---|---|
permissionMode | 'bypassPermissions' | 权限边界是容器,不是 SDK 的确认弹窗 |
allowedTools | 白名单 + 每个 MCP server 的通配 | 见下 |
disallowedTools | SDK_DISALLOWED_TOOLS | 见下 |
resume | 存的续接 id | 跨容器重启接着聊 |
systemPrompt | { type: 'preset', preset: 'claude_code', append: instructions } | 在 Claude Code 预设上追加运行时 addendum |
hooks | PreToolUse / PostToolUse / PostToolUseFailure / PreCompact | 前三个维护 container_state(给主机看门狗用) |
SDK_DISALLOWED_TOOLS(claude.ts:89)是一份很有教学价值的清单,每一条都写了理由:
| 被禁的工具 | 理由 |
|---|---|
CronCreate/Delete/List、ScheduleWakeup | NanoClaw 有自己的持久化调度(ncl tasks) |
AskUserQuestion | SDK 返回占位符而不是真的阻塞等答案;mcp__nanoclaw__ask_user_question 才是真的 |
SendMessage | 它寻址的是 Claude Code 自己的子 agent,名字太像跨 agent 通信,模型会误用 |
EnterPlanMode / EnterWorktree 等 | 交互式 UI 亲和物,无头容器里会像卡住 |
DesignSync(~9.3KB/轮)、ReportFindings(~1.9KB/轮) | 无头环境没有接收方,纯占 token |
MCP 工具的允许模式要模仿 SDK 的名字消毒规则(mcpAllowPattern,claude.ts:129):SDK 会把服务器名里 [A-Za-z0-9_-] 之外的字符换成 _,白名单模式必须一样做,否则动态加的 MCP server 会被静默过滤掉。
速率限制事件的 正确解读
classifyRateLimitEvent(claude.ts:51)修的是一个真实 bug(#3016):SDK 的 rate_limit_event 是遥测,status 通常是 allowed(告诉你还剩多少余量)。之前把每一个都当成终止性配额错误,结果健康的对话轮里也打 rate-limit 日志、甚至直接中止。
现在的逻辑:只有 status === 'rejected' 才是真被挡了;被挡时再按 errorCode: 'credits_required' / overageDisabledReason: 'out_of_credits' 区分「真没钱了」和「窗口限流会自己恢复」。
4. 核心难题:哪段文字该发出去
4.1 契约:必须包在 <message> 里
agent 的输出被要求写成:
<internal>随便想,不会发出去</internal>
<message to="family">这句才会真的发到「family」这个目的地</message>
没包的裸文本 = 草稿纸(scratchpad),只进日志不发送。
4.2 为什么需要「中途投递门」
Claude Agent SDK 的最终 result 事件只携带最后一段助手文本。如果模型这样输出:
助手消息 1: <message to="family">晚饭好了</message>
工具调用: Bash(...)
助手消息 2: 好的,我已经通知了。
那么 result 里只有「好的,我已经通知了。」——第一条 <message> 块会彻底丢失。
所以对声明了 emitsMidTurnText 的 provider,投递门被移到流式 text 事件上:
没有中途门(旧) 有中途门(现在)
───────────────── ─────────────────
text 事件 → 不投递 text 事件 → 解析并投递 ← 唯一内容门
result → 投递 ← 唯一门 result → 不投递内容
只负责:错误结果 + 要不要 nudge
「一扇门」是硬约束:两扇门开着就会重复发送。suppressDelivery 这个选项(poll-loop.ts:737)就是在 result 侧把门关死。
4.3 跨片段拼接:unresolvedTailStart
一个 <message> 块可能被切在两个 text 事件中间,甚至标签本身被切成 <mess + age to=…。
unresolvedTailStart(poll-loop.ts:883)算出「从哪个下标开始是未决尾巴」:
输入 = 上次留下的尾巴 + 这次的 text
│
① 先把完整的 <internal>…</internal> 用等长空格盖掉(位置不变)
│ ← 完整 span 里的未闭合结构是「已定的垃圾」,不该触发缓冲
② 找候选切点(取最靠前的):
├─ 未闭合的 <internal 开标签 ← 里面引用的草稿绝不能被当成真发送
├─ 最后一个 </message> 之后的 <message 开标签
└─ 结尾处的半个标签前缀(<mess / <inter)
③ 有候选 → 返回最小的;没有 → 返回 input.length(全部已定)
已定(settled)部分立即解析投递,尾巴带到下一个事件。已定文本只消费一次,所以已投递的块不可能被重新匹配。
轮次结束时尾巴被丢弃——「一个从头到尾没闭合的块」交给 nudge 机制处理,不跨轮次带。
4.4 回声守卫:模型爱把已发的块再说一遍
实测形状(注释里叫 “SDK battery s03”):工具调用之后,模型常把已经发过的块原样再输出一遍当作最终文本。那段最终文本也会作为一个 text 事件流出来,于是门会发第二遍。
守卫的做法很干净——不在进程里存内容账本,直接查 outbound.db:
// poll-loop.ts:972 wasWrittenInSeqWindow 的条件
seq > turnStartSeq AND seq <= segStartSeq
AND kind='chat' AND platform_id=? AND channel_type=? AND content=?
窗口是 (本轮开始的 seq, 本片段开始的 seq],即「本轮由更早的片段写过的行」。
三个推论:
- 同一次扫描里出现两个一模一样的块 = 明确的双发意图,照发两遍。
- 跨轮次再说同样的话不在窗口内,正常发送。
- 查询本身失败时 fail-open 到发送(注释:守卫只是去重优化,真的 DB 坏了会在写入时大声报错)。
4.5 nudge:这一轮啥也没发出去怎么办
result 事件到达时,门会问一个问题:这一轮有没有任何用户可见的东西发出去?
// poll-loop.ts:597
turnDelivered: emitsMidTurnText ? midTurnSent > 0 || chatRowWrittenSince(turnStartSeq) : undefined
两个来源:门自己投递的计数,加上「本轮 seq 之后有没有任何 chat 行落进 outbound.db」——后者能看见 MCP send_message 的调用,那是帧内计数器看不到的。
没有 → 往 query 里 push 一段系统提示:
<system>你的回复没有被投递 —— 它没有包在 <message to="name">...</message> 里。
所有输出都必须包起来:要发的内容用 <message to="name">,草稿用 <internal>。
你的目的地: family, worker-1。请用正确的包装重发。</system>
重发的内容会走中途门流出去。unwrappedNudged 保证每轮最多 nudge 一次。
反过来的情况也处理了:如果本轮已经发过东西了,最终那段裸文本只是自我总结——不 nudge,否则会哄出一条冗余的第二条消息(注释说这是实测过的)。
4.6 任务会话是「单门」
定时任务跑出来的会话(routing.taskRun)规则不同:
| 聊天会话 | 任务会话 | |
|---|---|---|
<message to> 块 | 投递 | 不投递,只留在草稿/运行日志里 |
| 唯一投递路径 | 中途门 | 显式调 send_message 工具 |
| 最终文本 | 草稿 | 自动追加成运行日志(autoAppendTaskLog,poll-loop.ts:1116) |
理由:定时任务的「结果」天然该进日志,不该自动骚扰用户。真要通知,得显式调工具。任务会话里出现了 <message to> 块时,会 push 一段专门的 nudge(buildTaskBlockNudge,:1114),把未投递内容列给模型,并告诉它「已经记进运行日志了,别重复」。
5. 并发跟进:边跑边收新消息
轮询循环在 processQuery 里开了一个 500ms 的 setInterval(poll-loop.ts:411),在当前这一轮还在跑的时候继续收消息,收到就 query.push(prompt) 塞进活着的流。
为什么不关流再开
注释算过账:重开流要重新 spawn SDK 子进程(几秒)并重新加载 .jsonl transcript。而 Anthropic 的 prompt cache 是服务端的、5 分钟 TTL、按前缀哈希,所以流的生命周期不影响缓存命中——5 分钟内关了再开照样命中。既然如此,保持流开着纯赚。
遇到斜杠命令要中止,不是 end
// poll-loop.ts:449
if (pending.some((m) => isRunnerCommand(m))) {
endedForCommand = true;
query.abort();
return;
}
理由分两层:
/clear要重置 SDK 的 resume id,而那个 id 在sdkQuery()调用时就固定了 → 必须新开一轮。/compact、/cost这类只有在作为一轮的第一个输入时才会被 SDK 调度;中途 push 进去就变成普通文本了。
abort 而不是 end:end() 会让在飞的这一轮跑完,/clear 可能因此被一个长任务阻塞任意久。
6. 输入格式化:agent 看到的 prompt 长什么样
formatMessages(container/agent-runner/src/formatter.ts:157)拼出来的形状:
<context timezone="Asia/Tokyo" />
<message id="4" from="family" sender="Alice" time="2026-08-18 10:32">
<quoted_message from="Bob">昨天说的那个</quoted_message>
今天几点?
[image: photo.jpg — saved to /workspace/inbox/msg-123/photo.jpg]
</message>
<cross-session-context from="#Pixel room" sender="Carol" time="…">另一个会话里说的话</cross-session-context>
几个刻意的设计:
- 时区头是硬性的。注释说 v1 有,后来漏掉过,导致 agent 把任务排到错误的小时。
- 路由字段被剥掉:agent 永远看不到
platform_id、channel_type、thread_id;它只看见from="目的地名"。 id就是 seq,和edit_message、add_reaction用的编号是同一个。- 没有外层
<messages>包装(formatter.ts:186-193):早期版本有,结果 Claude Agent SDK 对这个形状会返回一个合成占位符(model: "<synthetic>",content: "No response requested.")而不去调 API。issue #2555。修法就是把包装去掉,单条消息变成 N=1 的特例。
7. MCP 工具:agent 主动能做的事
内置 MCP server 在 container/agent-runner/src/mcp-tools/:
| 工具 | 干什么 | 文件 |
|---|---|---|
send_message | 按名字发消息到目的地 | core.ts:70 |
send_file | 拷文件进 outbox 再发 | core.ts:111 |
edit_message | 按 seq 编辑已发消息 | core.ts:160 |
add_reaction | 按 seq 加表情 | core.ts:201 |
ask_user_question | 阻塞式多选提问 | interactive.ts:37 |
send_card | 发卡片 | interactive.ts:132 |
| self-mod 系 | 申请装包 / 接 MCP server | self-mod.ts |
ask_user_question 怎么阻塞
没有回调、没有 webhook:写一条带问题卡的 messages_out 行,然后轮询 inbound.db 找带对应 questionId 的响应行(findQuestionResponse,db/messages-in.ts:105)。用户在聊天软件里点按钮 → 适配器的 onAction → 主机的响应注册表 → 写进 inbound.db → 容器轮到。
双库设计让「阻塞式工具」变得很简单——它就是一次同步的轮询等待。
线程保持的小规则
resolveRouting(core.ts:49):如果 to 解析出来的 channel 就是当前会话绑定的那个,就保留会话的 thread_id(回复落回原线程);否则 thread_id = null(发去别处 = 开一段新对话)。
8. 容器里的 ncl:又一次用 DB 当传输层
container/agent-runner/src/cli/ncl.ts 是一个自包含的脚本(不 import agent-runner 任何东西)。它检测到自己在容器里(/workspace/ 下有 session DB),就切换到 DB 传输:
写一条 kind='system'、action='cli_request' 的 messages_out 行
↓ 主机的 delivery 轮询看到 system 动作 → 分发到注册的 cli_request 处理器
↓ 主机跑 dispatch(),把结果写回 inbound.db
容器轮询 inbound.db 拿到响应帧
writeRequest(cli/ncl.ts:44)用 BEGIN IMMEDIATE 先拿写锁再读 max(seq),避免和 agent-runner 自己的写并发算出同一个 seq。