跳到主要内容

数据截至 (上游 commit 53ea1e8ba6fd)

容器内的 agent-runner

本章讲什么: 容器里那 5700 行 TypeScript 在干嘛。核心是一个 while 循环,但**「模型说的哪段话该发出去」这个问题占了它一半的复杂度**——本章重点讲这块。


1. 启动:它读什么、不读什么

container/agent-runner/src/index.ts:48main:

① 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 时
运行时 addendumagent 名字 + 当前目的地表每次容器启动

目的地表是活的:主机在每次唤醒时刷新 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 }
supportsNativeSlashCommandsSDK 自己能处理 /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:549query 传给 SDK 的关键选项:

选项为什么
permissionMode'bypassPermissions'权限边界是容器,不是 SDK 的确认弹窗
allowedTools白名单 + 每个 MCP server 的通配见下
disallowedToolsSDK_DISALLOWED_TOOLS见下
resume存的续接 id跨容器重启接着聊
systemPrompt{ type: 'preset', preset: 'claude_code', append: instructions }在 Claude Code 预设上追加运行时 addendum
hooksPreToolUse / PostToolUse / PostToolUseFailure / PreCompact前三个维护 container_state(给主机看门狗用)

SDK_DISALLOWED_TOOLS(claude.ts:89)是一份很有教学价值的清单,每一条都写了理由:

被禁的工具理由
CronCreate/Delete/ListScheduleWakeupNanoClaw 有自己的持久化调度(ncl tasks)
AskUserQuestionSDK 返回占位符而不是真的阻塞等答案;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_idchannel_typethread_id;它只看见 from="目的地名"
  • id 就是 seq,和 edit_messageadd_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 serverself-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。


9. 代码地图(导航索引)

主题文件路径符号名
容器入口container/agent-runner/src/index.tsmain
主轮询循环container/agent-runner/src/poll-loop.tsrunPollLoop
一轮的事件处理container/agent-runner/src/poll-loop.tsprocessQuery
中途投递门container/agent-runner/src/poll-loop.tsdeliverMidTurnBlocks
跨片段拼接切点container/agent-runner/src/poll-loop.tsunresolvedTailStart
回声守卫container/agent-runner/src/poll-loop.tswasWrittenInSeqWindow
result 侧分发与 nudge 决策container/agent-runner/src/poll-loop.tsdispatchResultText
任务运行日志自动追加container/agent-runner/src/poll-loop.tsautoAppendTaskLog
两阶段取消息container/agent-runner/src/db/messages-in.tsgetPendingMessages
出站写入(奇数 seq)container/agent-runner/src/db/messages-out.tswriteMessageOut
prompt 拼装container/agent-runner/src/formatter.tsformatMessages
路由上下文提取container/agent-runner/src/formatter.tsextractRouting
命令分类container/agent-runner/src/formatter.tscategorizeMessage / isRunnerCommand
provider 接口container/agent-runner/src/providers/types.tsAgentProvider / ProviderEvent
Claude providercontainer/agent-runner/src/providers/claude.tsClaudeProvider
SDK 工具黑白名单container/agent-runner/src/providers/claude.tsSDK_DISALLOWED_TOOLS / TOOL_ALLOWLIST
速率事件分类container/agent-runner/src/providers/claude.tsclassifyRateLimitEvent
发消息工具container/agent-runner/src/mcp-tools/core.tssendMessage
阻塞式提问container/agent-runner/src/mcp-tools/interactive.tsaskUserQuestion
容器内 CLI 传输container/agent-runner/src/cli/ncl.tswriteRequest