跳到主要内容

数据截至 (上游 commit 53ea1e8ba6fd)

扩展机制:模块、技能、定时任务

本章讲什么: NanoClaw 的主干里一个具体渠道适配器都没有。这不是没做完,是刻意的:主干发的是注册表和基础设施,功能由技能按需装进来。本章讲这套机制怎么实现,以及定时任务这个最有代表性的模块。


1. 核心思路:import 即注册

所有扩展点都是同一个形状:

src/xxx/index.ts (barrel) ──import for side effects──▶ 各模块顶层调 registerXxx()


src/index.ts 在启动时 import 这个 barrel

src/index.ts 里有三个这样的 barrel(:44:48:52):渠道、模块、CLI 命令。

装一个新渠道 = 拷文件进来 + 在 barrel 里加一行 import。 卸载就是把那行注释掉。

一个真实的循环依赖处理

src/index.ts:22-26 的注释解释了为什么响应处理器注册表被拆到 src/response-registry.ts:index.ts import 模块 barrel 是为了副作用,而模块在顶层就调 registerResponseHandler——如果那个数组住在 index.ts 里,会撞上 TDZ 错误。

这是个通用教训:副作用式注册 + barrel 导入,注册表本身必须住在依赖图的叶子上。


2. 扩展点清单

扩展点注册函数定义在不装时的行为
渠道适配器registerChannelAdaptersrc/channels/channel-registry.ts:26没有渠道可收发
provider(容器侧)registerProvidercontainer/agent-runner/src/providers/provider-registry.ts只有 claude
provider 的宿主机侧贡献provider-container-registry.tssrc/providers/无额外挂载/环境变量
主机模块生命周期onHostStart / onHostShutdownsrc/host-lifecycle.ts:22
消息拦截器registerMessageInterceptorsrc/router.ts:127
会话创建钩子registerSessionCreatedHooksrc/router.ts:187
投递动作(system 消息)registerDeliveryActionsrc/delivery.ts:556记 “Unknown system action”
投递后钩子registerPostDeliveryHooksrc/delivery.ts:504
投递批预览registerDeliveryBatchPreviewsrc/delivery.ts:550
审批处理器registerApprovalHandlersrc/modules/approvals/primitive.ts:78批准后没人接
响应处理器(卡片点击)registerResponseHandlersrc/response-registry.ts记 “Unclaimed response”
MCP 工具 / 工具扩展registerTools / extendToolcontainer/agent-runner/src/mcp-tools/server.ts:31 / :78少几个工具

核心怎么在「模块不在」时活下去

三种手法,都能在源码里看到:

手法例子
钩子为 null = 默认放行accessGate 没注册 → 全放行(src/router.ts:395)
hasTable() 守卫没有 agent_destinations 表 → 跳过 a2a 路由(src/delivery.ts:331)
动态 import + try/catchMODULE-HOOK: 标记块内的 await import(...)(src/host-sweep.ts:148-155)

那些 // MODULE-HOOK:xxx:start / :end 注释标记不是装饰——技能安装时会往这些标记块里填内容,模块搬走时清空。


3. 技能:同一份文档,两个读者

3.1 问题

「装一个 Slack 渠道」这件事需要:从 channels 分支拉代码、拷到标准路径、往 barrel 追加 import、装一个钉死版本的 npm 包、构建、然后问用户要 token 并写进 .env、最后跑几条 ncl 建接线。

两种做法各有毛病:

  • 写成脚本 → 出错了没法灵活处理,而且人读不懂它在干嘛。
  • 写成给 agent 的散文 → 每次执行结果不一样,setup 向导没法用。

3.2 NanoClaw 的解法:nc: 指令栅栏

一份 SKILL.md 里,info-string 以 nc: 开头的代码栅栏是承重指令,其它所有栅栏和散文都是「人类地板」,解析器忽略。

语法(scripts/skill-directives.ts 文件头的完整说明):

nc:<指令> <参数>... [key:value]...
<正文行>

八种指令里最有代表性的几个:

指令干什么幂等语义
copy从某个分支拷文件覆盖
append往某个文件(可指定 marker)追加行已存在则跳过
dep装钉死版本的依赖重装是 no-op
run跑 shell,带 effect: 分类(build/test/wire/restart/check…)可重跑
prompt只获取值并绑定成变量,不使用它已满足则跳过

prompt 和使用它的指令是分开的,变量用 {{name}} 引用。这样「问人要东西」和「拿这东西干嘛」解耦了。

3.3 这个设计的性质

两个读者,一份文档。 agent 执行散文,工具执行指令;工具处理不了的东西,降级成旁边那段散文。

消费者有三个(docs/skill-engine-seam.md 提到的三种):setup 向导用 clack 渲染引擎事件、流水线批量跑、agent 中继。同一份 SKILL.md,三种执行方式,结果一致。

还有一些细节体现了这是被真实场景打磨过的:

  • capture:<var>=<dot-path> —— 一次 API 调用用 jq 式路径同时绑定多个变量。
  • validate:<re> —— 在绑定时校验,不管值来自交互回答还是批量 inputs
  • effect:check —— 把正文当前置条件谓词跑:不改任何东西,非零退出就降级给 agent,并阻断后面那些危险副作用(重启、配对、建接线)。
  • effect:step —— 长时间的、需要操作员参与的步骤(配对码、扫码),通过流式 exec 把 === NANOCLAW SETUP: … === 状态块实时渲染给操作员。

4. 定时任务:一个完整模块的解剖

定时任务是最能说明「模块怎么嵌进核心」的例子,因为它同时用到了会话、双库、sweep 和容器侧钩子。

4.1 任务住在哪

每个任务系列有自己独立的会话,线程 id 是 system:tasks:<seriesId>(taskThreadId,src/db/sessions.ts:94)。这个会话没有 messaging_group_id

任务本身是 inbound.db 里 kind='task' 的行,带 process_afterrecurrence(cron 表达式)。

4.2 一次运行的完整链路

① sweep 发现 countDueMessages > 0 且没容器 → wakeContainer
② 容器轮询读到 kind='task' 行
③ 有 script 的先跑预检脚本(applyPreTaskScripts)
├─ wakeAgent=false → markScriptSkipped('completed'),不叫 agent
├─ 脚本崩了 → markScriptSkipped('error') → 同步成 failed
└─ wakeAgent=true → 脚本输出作为 scriptOutput 拼进 prompt
④ agent 干活。最终文本自动变成运行日志(单门规则,见第 4 章)
⑤ sweep 的 handleRecurrence:completed 且还有 recurrence 的行
→ 按用户时区解析 cron 算下一次
→ 插一条新 pending 行(series_id 沿用)
→ 清掉原行的 recurrence(避免下 tick 再克隆一遍)
⑥ sweep 的 GC:该系列没有活任务行且没容器 → 会话置为 closed

4.3 预检脚本闸门:省 token 的核心机制

# 一个预检脚本长这样(示意,非源码)
# 最后一行必须是 JSON,带 wakeAgent 布尔
count=$(gh pr list --state open --json number | jq length)
if [ "$count" -gt 0 ]; then
echo "{\"wakeAgent\": true, \"data\": {\"open\": $count}}"
else
echo '{"wakeAgent": false}'
fi

runScript(container/agent-runner/src/scheduling/task-script.ts:19)的约束:30 秒超时、1MB 输出上限、只解析最后一行为 JSON、必须有 wakeAgent 布尔。任何不符合都返回 null(视为失败)。

为什么这个机制重要: 「每 10 分钟检查一次有没有新 PR」如果每次都唤醒 agent,一天 144 次模型调用,绝大多数是「没事做」。有了脚本闸门,只有真有 PR 时才付模型的钱。

跟进消息也要跑脚本(poll-loop.ts:465-474):一个在 agent 已经忙着的时候到期的 cron 任务,如果跳过脚本闸门就永远会唤醒 agent,闸门形同虚设。

4.4 频率限制:一段会说人话的拒绝

MAX_DAILY_FIRES = 4(src/modules/scheduling/create.ts:9)。超过这个频率的 cron 会被拒绝,并返回一段教学式的警告(RECURRENCE_LIMIT_WARNING):

这个任务没有被排上。频繁运行的任务会消耗用户的订阅额度或不必要地烧 token,可能导致账号被封。请改用你自己写的预检脚本……

然后给出逃生舱:--dangerously-override-recurrence-limit,并明确要求「当且仅当你已经跟用户确认过他们理解并且这就是他们想要的」。

这段值得学: 对 agent 的限制不该只回一个错误码,应该解释为什么、给出正确做法、留一个需要显式确认的出口

4.5 坏脚本的退避

scriptBackoffMinutes(src/modules/scheduling/recurrence.ts:31):

连续失败次数: 1 2 3 4 5 6+
退避(分钟): 2 4 8 16 32 60(封顶)
连续 8 次失败 → 整个系列自动 paused,并由主机往运行日志写一行说明

注意区分:故意的 wakeAgent=false 是正常完成,永远不退避;只有脚本报错才算失败。失败次数是从历史行推导出来的(trailingFailedRuns),不存计数器。


5. 跨会话上下文:一个「知道自己边界」的模块

src/modules/cross-session-context/ 解决的问题:同一个 agent 在多个线程里被人说话,它在 A 线程里应该知道 B 线程里刚发生了什么吗?

受众规则

模块头部注释(src/modules/cross-session-context/fan.ts:14-25)给出了一条可证明安全的规则:

一条消息扇进它实际出现过的那个对话的兄弟会话——入站看它到达的 messaging group,出站看它被投递到的 messaging group。同一个 messaging group 按定义就是同一批受众,所以每一次扇出都可证明是受众安全的,不需要任何成员关系知识。

被明确排除的:agent 组的其它对话(room→DM 已废弃)、任务会话(没有 messaging group)。跨对话的感知是拉取式的:ncl sessions history

回声行的性质

扇出写进去的行是 trigger=0channel_type='session-echo',并且:

  • 永远不唤醒容器。
  • 永远不提供回复路由(extractRouting 跳过 echo 行,formatter.ts:131)。
  • 永远不被当成命令(一条被拷过来的 /clear 绝不能在这里执行,formatter.ts:54)。
  • 永远不被再次扇出(循环守卫)。
  • 渲染成 <cross-session-context> 块,和真正的消息在视觉上分开。
  • 有 TTL:sweep 每 tick 剪枝(每会话保留最新 N 条 + 硬性年龄上限),否则 inbound.db 会无限增长。

6. 自我修改:目前只有一档

agent 能申请两件事:

动作效果
install_packages改容器配置的 apt/npm 列表 → 重建镜像 → 杀容器 → 写 on_wake 消息 → 重生
add_mcp_server改容器配置 → 杀容器 → 写 on_wake 消息 → 重生

都要一次管理员审批。处理器在 src/modules/self-mod/apply.ts:34 / :93

on_wake 这一列的作用

messages_in.on_wake = 1 的行只在容器的第一次轮询时才被取到(getPendingMessages(isFirstPoll),container/agent-runner/src/mailbox/sqlite/operations.ts:33)。

防的是什么: 正在 SIGTERM 宽限期里的将死容器可能把这条「你被重启了,接着干」的消息偷走,然后自己死掉——消息就丢了。加上这一列,只有全新的容器能拿到它。

配套的还有 killContainer(sessionId, reason, onExit) 的回调参数(src/container-runner.ts:349):在进程真的退出之后才触发重生,保证旧容器一定已经没了。

第二档(直接改源码的草稿/激活流程)在代码里是计划中、尚未实现。


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

主题文件路径符号名
三个 barrel 的导入点src/index.tsmain(顶部 import 段)
模块 barrelsrc/modules/index.ts顶层 import
主机模块生命周期src/host-lifecycle.tsonHostStart / startHostModules
渠道注册表src/channels/channel-registry.tsregisterChannelAdapter / initChannelAdapters
投递适配器桥src/channels/channel-registry.tscreateChannelDeliveryAdapter
MCP 工具注册/扩展container/agent-runner/src/mcp-tools/server.tsregisterTools / extendTool
出站 payload 透传(工具扩展用)container/agent-runner/src/db/messages-out.tswithOutboundPassthrough
nc: 指令语法与解析scripts/skill-directives.ts文件头 doc comment
指令执行引擎scripts/skill-apply.ts
任务创建与频率限制src/modules/scheduling/create.tsmakeTaskId / enforceRecurrenceLimit
重复任务重排src/modules/scheduling/recurrence.tshandleRecurrence / scriptBackoffMinutes
预检脚本container/agent-runner/src/scheduling/task-script.tsrunScript / applyPreTaskScripts
任务会话线程命名src/db/sessions.tstaskThreadId / isTaskThread
跨会话扇出src/modules/cross-session-context/fan.tsfanInboundMessage / fanOutboundMessage
自我修改处理器src/modules/self-mod/apply.tsapplyInstallPackages / applyAddMcpServer
容器重启src/container-restart.ts