52 KiB
NanoClaw 架构(草案)
核心理念
每个智能体会话(agent session)都有一个挂载的 SQLite 数据库(DB)。该数据库是宿主机(host)与容器(container)之间的唯一 IO 机制。没有 IPC 文件,没有 stdin 管道。两张表:messages_in(宿主机 → agent-runner)和 messages_out(agent-runner → 宿主机)。一切皆消息。
两级数据库
中央数据库(Central DB,宿主机进程内):
- 智能体组(agent group)、对话、路由(routing)表
- 将平台 ID 映射到 agent group → 会话(session)
- 通道适配器(channel adapter)不直接访问此库——宿主机负责查找
逐会话数据库(Per-session DB,挂载到容器内):
messages_in(宿主机写入,agent-runner 读取)messages_out(agent-runner 写入,宿主机读取)- 一切皆消息:聊天、任务、Webhook、系统操作、智能体间通信——均使用这两张表
- 每个 session 一个 DB,而非每个 agent group 一个
Agent Group 与 Session
一个 agent group 拥有自己的文件系统——文件夹、CLAUDE.md、技能(skills)、容器配置。多个 session 可以共享同一个 agent group(相同的文件系统、相同的 skills),但每个 session 都有自己独立的 DB,挂载在已知路径上。每个 session = 一个独立的容器,使用相同 agent group 的文件系统但拥有不同的 session DB。
消息流程
平台事件
→ 通道适配器(Channel Adapter,触发器检查、ID 提取)
→ 返回:{ platformChannelId, platformThreadId, triggered }
→ 宿主机将 platformChannelId + platformThreadId 映射到 agent group + session
→ 宿主机将消息写入 session 的 DB
→ 宿主机调用 wakeUpAgent(session)
→ 容器启动(或已在运行)
→ Agent-runner 轮询其 session DB,发现新消息
→ Agent-runner 调用 Claude 进行处理
→ Agent-runner 将响应写入 session DB
→ 宿主机轮询活跃 session DB 以获取响应
→ 宿主机读取响应,查找对话,通过 channel adapter 进行投递(delivery)
通道适配器(Channel Adapter)
Channel adapter 负责:
- 接收平台事件(Webhook、轮询(polling)、WebSocket——平台特定)
- 过滤:决定将哪些消息转发给宿主机处理。可以是无状态的(正则触发器匹配)或有状态的(例如,"该机器人曾在此线程中被提及过吗?如果是,则转发所有后续消息")。Adapter 接收未经过滤的平台消息流,并决定传递哪些消息。如何决定是实现细节——NanoClaw 不关心也不需知道。
- 提取并标准化两个 ID:
- Platform channel ID——标识对话(WhatsApp 群组、Slack 频道、邮件线程)
- Platform thread ID——可选的子上下文(Slack 消息分支、GitHub PR 评论分支)
- 出站投递——将响应发送回平台
Channel adapter 不知道 agent group ID 或 session ID。它返回平台级别的标识符。宿主机将这些映射到实体模型。
两级 ID 方案(channel ID + thread ID)提供了灵活性:
- 希望每个 Slack 消息分支都是独立的 session?返回唯一的 thread ID。
- 希望 Slack 频道中的所有消息共享一个 session?返回相同的 thread ID(或 null)。
- 这是按通道配置的,而非全局配置。
Channel Adapter 配置
Adapter 是无状态的——它们在设置时从宿主机接收配置,而非直接从 DB 读取。
存在于代码中的(按通道类型,运行时不变):
- 自动注册(auto-registration)行为(启用/禁用,如何工作)
- 发送者允许列表规则
- 允许列表中的发送者是否可以自动注册群组
- 平台特定的连接和消息处理
这些是在设置 channel adapter 时做出的决策。更改它们 = 修改代码。
存在于 DB 中的(按群组,因组而异):
- 由哪个 agent group 处理
- 触发/过滤规则(正则、仅 @提及、排除某些发送者等)
- 响应范围(响应所有消息 vs 仅已触发/允许列表中的消息)
- Session 模式(共享 vs 每线程独立)
宿主机从 DB 读取每个群组的配置,并在设置时将其传递给 adapter。如果配置在运行时发生更改(管理员智能体注册新群组、更改触发器),宿主机调用 adapter 的更新方法。
自动注册(Auto-Registration)
当 adapter 转发来自未知群组的消息时,宿主机需要决定是否创建该群组及其 session。
Adapter 控制是否转发未知消息——基于其代码级别的 auto-registration 规则(发送者允许列表、群组添加检测等)。如果 adapter 转发它,宿主机则创建群组 + session。
已知群组的 session 创建:
- 共享 session 模式:宿主机查找现有 session,如果是第一条消息则创建一个
- 每线程独立 session 模式:宿主机通过 threadId 查找。如果此线程不存在 session,则使用相同的 agent group 自动创建一个
代码级别的规则是通道特定的:
- WhatsApp:如果允许列表中的号码将机器人添加到群组 → 自动注册。如果未知号码私信 → 取决于 adapter 的配置。
- 邮件:如果发送者已知 → 自动注册线程。如果未知 → 丢弃。
- Slack:如果某人在新频道中 @提及机器人 → adapter 根据其规则决定是否转发。
没有 channel_configs 表——通道类型级别的行为内置于 adapter 代码中。
Chat SDK 集成
Chat SDK Adapter 按通道封装:
- 每个 Chat SDK adapter 拥有自己的 Chat 实例
- 并发模式按通道配置(聊天使用并发,任务使用队列,Webhook 使用防抖)
- 一个桥接(bridge)封装 Chat 实例 + adapter,以符合 NanoClaw 的标准通道接口
- Chat SDK 处理:Webhook 解析、去重、消息历史、平台 API 调用、富内容投递
- NanoClaw 处理:路由、智能体生命周期、session 管理
Chat SDK 的订阅模型:
Chat SDK 拥有自己的线程级订阅概念(与 NanoClaw 的通道级注册不同):
onNewMention/onNewMessage(regex)——首次联系时触发(例如,在 Slack 消息分支中的 @提及)thread.subscribe()——选择接收该线程中的所有未来消息onSubscribedMessage——在已订阅线程中的所有消息触发
这是子通道粒度。NanoClaw 在通道级别注册("监听此 Discord 频道")。Chat SDK 在线程级别订阅("追踪此特定 Slack 消息分支")。桥接允许 Chat SDK 内部管理自己的订阅——NanoClaw 不干预或复制此行为。
平台能力差异:
各 adapter 的能力差异显著(参见 Chat SDK adapter 文档):
- Slack:完整富内容(Block Kit 卡片、模态框、流式传输、表情反应、临时消息)
- Discord:Embeds、按钮、通过 post+edit 实现的流式传输
- WhatsApp(Cloud API):仅私信、交互式回复按钮、不支持流式传输、不支持表情反应
- GitHub/Linear:Markdown 评论、无交互元素
- Telegram:内联键盘按钮、通过 post+edit 实现的流式传输
宿主机/桥接处理优雅降级——如果 agent 在不支持卡片的平台上发布卡片,则回退为文本。
非 Chat SDK 通道(WhatsApp via Baileys、Gmail、自定义集成)直接实现 NanoClaw 通道接口——无需桥接,无需 Chat SDK 类型。
容器生命周期
宿主机是一个编排器(orchestrator):
- 启动(Spawn)——当调用 wakeUpAgent 且该 session 不存在容器时
- 空闲终止(Idle kill)——当容器在某个超时时段内没有未处理消息时
- 限制(Limits)——MAX_CONCURRENT_CONTAINERS 限制活跃容器数量
当容器启动时,agent-runner 立即开始轮询其 session DB。消息已在那里等待。
媒体处理
入站
宿主机不下载媒体。取而代之:
- 消息包含下载 URL(尽可能使用签名 URL)
- Agent-runner 在容器内下载并处理媒体
- 对于签名 URL 不适用的通道(例如,使用缓冲流的 WhatsApp),channel adapter 下载媒体并通过容器可访问的本地 URL/服务器提供服务
原生内容块(取决于提供商):
Agent-runner 检测文件类型,并在提供商支持的情况下将支持的类型作为原生内容块传递:
| 类型 | Claude | Codex | OpenCode |
|---|---|---|---|
| 图片(JPEG、PNG、GIF、WebP) | 原生图片内容块 | 保存到磁盘,在提示中引用 | 保存到磁盘,在提示中引用 |
| 原生文档内容块 | 保存到磁盘 | 保存到磁盘 | |
| 音频 | 原生音频内容块 | 保存到磁盘 | 保存到磁盘 |
| 其他文件(代码、数据、视频、归档) | 保存到磁盘 | 保存到磁盘 | 保存到磁盘 |
"保存到磁盘"指下载到 /workspace/downloads/{messageId}/,并在提示文本中作为可用文件路径引用。Agent 可以使用工具(Read、Bash)来访问它。
Agent-runner 根据提供商不同构建提示。对于 Claude,它构造包含图片/文档块的多部分 MessageParam 内容。对于 Codex/OpenCode,所有内容都是带有文件路径引用的文本。
出站
出站文件投递是基于工具的。Agent 使用文件路径调用工具(例如 send_file)。Agent-runner 将文件移动到发件箱并写入 messages_out 行。
/workspace/
outbox/
{message_id}/ ← 每个 messages_out 行一个目录
chart.png
report.pdf
messages_out 内容仅引用文件名:
{ "text": "这是图表", "files": ["chart.png", "report.pdf"] }
DB 中没有路径——约定即是契约。宿主机从挂载的 session 文件夹中的 outbox/{message_id}/ 读取文件,并通过 adapter 进行投递(Chat SDK 使用 FileUpload 附带缓冲区数据,或原生通道使用平台特定上传)。宿主机在成功投递后清理 outbox 目录。
出站文件使用专用的 send_file MCP 工具(与 send_message 分离)。参见 agent-runner-details.md 了解工具接口。
消息去重
去重是 channel adapter 的职责。Chat SDK 内部处理此问题。原生 adapter 根据需要追踪平台消息 ID。宿主机不进行去重——如果 adapter 转发消息,宿主机就写入。
Session DB 模式
两张表。内容使用 JSON blob——无模式,格式因 kind 而异。
-- 宿主机写入,agent-runner 读取
CREATE TABLE messages_in (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- 'pending' | 'processing' | 'completed' | 'failed'
status_changed TEXT, -- 最后状态变更的 ISO 时间戳
process_after TEXT, -- ISO 时间戳。NULL = 立即处理。
recurrence TEXT, -- cron 表达式。NULL = 一次性。
tries INTEGER DEFAULT 0, -- 处理尝试次数
-- 路由(agent-runner 复制到 messages_out;agent 永远不会看到这些字段)
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
-- 负载(结构取决于 kind)
content TEXT NOT NULL -- JSON blob
);
-- Agent-runner 写入,宿主机读取
CREATE TABLE messages_out (
id TEXT PRIMARY KEY,
in_reply_to TEXT, -- 引用 messages_in.id(可选)
timestamp TEXT NOT NULL,
delivered INTEGER DEFAULT 0,
deliver_after TEXT, -- ISO 时间戳。NULL = 立即投递。
recurrence TEXT, -- cron 表达式。NULL = 一次性。
-- 路由(默认:由 agent-runner 从 messages_in 复制)
kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
-- 负载(格式匹配 kind)
content TEXT NOT NULL -- JSON blob
);
调度(Scheduling)
一次性任务和循环(recurrence)任务使用相同的表——没有独立的调度器。
一次性: process_after(入站)或 deliver_after(出站),且 recurrence = NULL。
循环: 同上,外加 recurrence cron 表达式。宿主机将行标记为已处理/已投递后,如果设置了 recurrence,则插入新行,其中 process_after/deliver_after 推进到下一个 cron 时间点。下次时间从计划时间(而非墙上时间)计算,以防止漂移。
宿主机巡检(Host sweep,所有 session DB 约每 60 秒一次):
messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())→ 唤醒 agentmessages_in WHERE status = 'processing' AND status_changed < (now - stale_threshold)→ 过期检测,增加tries,带退避重置为pendingmessages_out WHERE delivered = 0 AND (deliver_after IS NULL OR deliver_after <= now())→ 投递- 完成/投递带有
recurrence的行后,插入下一次发生
活跃容器轮询(约 1 秒)检查相同的条件,但仅针对正在运行容器的 session。
Agent-runner 创建调度的方式是写入带有 process_after 和可选的 recurrence 的 messages_in(发给自身)或 messages_out(提醒/通知)。
messages_in 各 kind 的内容格式
chat — 简洁的 NanoClaw 格式。任何通道都可以生成此格式。
{
"sender": "John",
"senderId": "user123",
"text": "请检查这个 PR",
"attachments": [{ "type": "image", "url": "https://signed-url..." }],
"isFromMe": false
}
chat-sdk — 完整的 Chat SDK SerializedMessage,从桥接 adapter 透传。包含 author、text、formatted(mdast AST)、attachments、isMention、links、metadata。
task — 计划任务触发。
{ "prompt": "审查开放 PR", "script": "scripts/review.sh" }
webhook — 原始 Webhook 负载。
{ "source": "github", "event": "pull_request", "payload": { ... } }
system — 宿主机操作结果(对 agent 请求的系统操作的响应)。
{ "action": "register_group", "status": "success", "result": { "agent_group_id": "ag-456" } }
messages_out 各 kind 的内容格式
输出 kind 决定格式和投递 adapter。默认:agent-runner 从正在响应的 messages_in 行复制 kind 和路由字段。
chat — 简洁的 NanoClaw 格式。NanoClaw 通道通过 sendMessage(text) 投递。
{ "text": "LGTM,正在合并" }
chat-sdk — Chat SDK AdapterPostableMessage。桥接 adapter 通过 thread.post() 投递。可以是 Markdown、卡片或原始格式——adapter 处理平台转换。
{ "markdown": "## 审查\n**LGTM**", "attachments": [...] }
{ "card": { "type": "card", "title": "审查", "children": [...] }, "fallbackText": "..." }
task — 任务结果。宿主机记录日志并可选择通知。
{ "result": "已审查 3 个 PR", "status": "success" }
webhook — Webhook 响应。宿主机发送 HTTP 响应或通知。
{ "response": { "status": 200, "body": { ... } } }
system — 宿主机操作请求(注册群组、重置 session 等)。宿主机读取、验证权限、执行、将结果作为 system 类型的 messages_in 行写回。
{ "action": "reset_session", "payload": { "session_id": "sess-123" } }
交互式操作(卡片、表情反应、编辑)
所有交互式操作通过 messages_in/messages_out 流转——DB 是容器唯一的 IO 边界。Agent 使用 MCP 工具;agent-runner 将工具调用转换为结构化的 messages_out 行;宿主机通过适当的 adapter 方法投递。
带有用户交互的卡片(例如,"向用户提问"):
- Agent 调用
ask_user_question工具,附带问题 + 选项 - Agent-runner 将问询卡片写入
messages_out - 宿主机通过 adapter 以交互式卡片形式投递(例如,Slack Block Kit 按钮)
- 用户点击选项
- 平台将事件发送回 adapter → 宿主机将响应写入
messages_in - Agent-runner 读取
messages_in,匹配到挂起的工具调用,将选择结果作为工具结果返回给 agent
Agent-runner 在等待 messages_in 中的用户响应期间保持工具调用打开。往返路径:agent → messages_out → 宿主机 → 平台 → 用户点击 → 平台 → 宿主机 → messages_in → agent-runner → agent。
审批(Approvals):
两种模式,均在宿主机级别处理:
- 隐式:Agent 调用需要审批的工具。宿主机拦截,向管理员发送审批卡片,等待响应,然后执行或拒绝。Agent 不知道审批步骤。
- 显式:Agent 通过工具显式请求审批。Agent-runner 将审批请求写入
messages_out。与"向用户提问"流程相同——响应通过messages_in返回。
两种情况下,审批和操作执行均在宿主机侧进行,而非 agent 侧。
审批路由: 权限是用户级别的概念。user_roles 记录 owner(仅全局——第一个配对的用户成为所有者)和 admin(全局或限定于特定 agent_group_id)。当操作需要审批时,pickApprover(agentGroupId) 按顺序返回候选者:该 agent group 的限定管理员 → 全局管理员 → 所有者(去重)。然后 pickApprovalDelivery 取第一个可通过 ensureUserDm 联系到的候选者(采用同通道类型优先策略,因此 Discord 审批请求优先选择使用 Discord 的审批者)。审批卡片发送到审批者的 DM 消息组,而非发起对话。对于需要 DM 解析的通道(Discord/Slack/…),投递通过 Chat SDK 的 openDM 解析;对于可直接寻址的通道(Telegram/WhatsApp/…),直接使用用户句柄。映射缓存在 user_dms 中以供后续请求使用。参见 src/access.ts、src/user-dm.ts。
编辑已发送消息:
Agent 调用 edit_message 工具,附带消息 ID 和新内容。Agent-runner 将编辑操作写入 messages_out。宿主机调用 adapter.editMessage()。Agent 上下文中的消息包含整数 ID,因此 agent 可以引用它们。
表情反应(Reactions):
Agent 调用 add_reaction 工具,附带消息 ID 和 emoji。Agent-runner 将反应操作写入 messages_out。宿主机调用 adapter.addReaction()。
messages_out 内容中的操作:
// 普通消息(默认)
{ "text": "LGTM" }
// 交互式卡片
{ "operation": "ask_question", "title": "部署", "question": "批准部署?", "options": ["是", "否", "推迟"] }
// 编辑现有消息
{ "operation": "edit", "messageId": "3", "text": "更新:LGTM,有少量意见" }
// 表情反应
{ "operation": "reaction", "messageId": "5", "emoji": "thumbs_up" }
宿主机读取 operation 字段(如果存在)并调用相应的 adapter 方法。没有 operation 字段 = 普通消息投递。平台能力各异——宿主机/桥接处理优雅降级(例如,在不支持表情反应的平台上添加表情 → 跳过或作为文本发送)。
智能体间通信(Agent-to-Agent Communication)
向另一个 agent 发送消息使用与通道投递相同的路由字段。Agent-runner 设置 channel_type: 'agent',platform_id 设为目标 agent group ID。可选地,thread_id 可以指定特定 session(null = 查找或创建默认 session)。
从发送 agent 的角度看,这与发送到 Slack 或 WhatsApp 是相同的机制——只是带有不同路由信息的 messages_out 行。宿主机读取后,检查此 agent group 是否有权向目标发送消息,解析目标 session,并将 messages_in 行写入该 session 的 DB。
// messages_out 路由字段
{ "kind": "chat", "channel_type": "agent", "platform_id": "pr-worker", "thread_id": null }
// messages_out 内容
{ "text": "重置你的 session 并重新审查", "sender": "监督者", "senderId": "agent:pr-admin" }
接收 agent 得到的是普通的聊天消息。除非相关内容需要,否则它不需要知道来源是另一个 agent。
路由
默认行为: Agent-runner 将路由字段(kind、platform_id、channel_type、thread_id)从 messages_in 行复制到 messages_out。响应发送回原始来源。
宿主机验证: 投递前,宿主机检查此 agent group 是否被允许向目标发送消息。Agent-runner 复制路由;宿主机验证。
多目标模式(定制): Agent 可能需要发送到与来源不同的通道(例如,Webhook 触发 Slack 通知)。这通过自定义代码支持,而非内置于核心:
- 向 session DB 添加
destinations表,将逻辑名称映射到路由字段 - 在设置 session 时由宿主机填充
- 修改 agent 提示以列出可用的目标
- Agent 通过名称选择目标;agent-runner 解析为路由字段
- 宿主机照常验证
这被记录为一种模式,而非内置功能。
核心属性
- 通过文件系统挂载实现的容器隔离(isolation)
- 凭证代理(OneCLI)
- 每个 agent group 的工作空间(文件夹、CLAUDE.md、skills)
- 基于轮询(非事件驱动)
- 容器启动时对每个 agent group 的 agent-runner 进行重新编译(agent 可以修改其自身源代码,请求重建/重启,更改在销毁后仍然保留)
- 宿主机 ↔ 容器 IO 通过挂载的 session DB(
messages_in/messages_out)——无 stdin 管道,无 IPC 文件 - Agent 命令是
kind: 'system'的messages_out行 - 通过
messages_out上的目标 agent 路由支持 agent-to-agent 通信 - 调度使用相同消息表上的
process_after/deliver_after+recurrence - 媒体通过签名 URL,在容器内下载
- Channel adapter 使用 Chat SDK 桥接 + 标准接口(主干代码仅包含桥接/注册表;平台 adapter 通过
/add-<channel>技能安装) - 路由:channel adapter 提取 ID,宿主机映射到实体
- 并发:Chat SDK 按通道 + 容器限制
- Session 范围:逐 session DB,每个 agent group 多个 session
设计决策
Session DB 位置: 不在 agent group 文件夹中。独立目录(例如,sessions/{session_id}/)。每个 session 拥有自己的文件夹,包含 session.db 和 Claude SDK 的 .claude/ 目录。Session 身份即是文件夹——无需追踪 Claude SDK session ID。
容器挂载结构:
/workspace/ ← 挂载:session 文件夹(读写)
.claude/ ← Claude SDK session 数据(自动创建)
session.db ← session SQLite DB
outbox/ ← agent-runner 在此写入出站文件
agent/ ← 挂载:agent group 文件夹(嵌套,读写)
CLAUDE.md ← agent 指令
skills/ ← agent 技能
... 工作文件
两个目录挂载:session 文件夹挂载到 /workspace,agent group 文件夹挂载到 /workspace/agent/。Agent-runner 进入 /workspace/agent/ 来运行 agent。Claude SDK 将 .claude/ 写入 /workspace/.claude/(工作空间的根目录)。Session DB 位于 /workspace/session.db。
这在 Docker(嵌套 bind mount)和 Apple Container(仅目录挂载——不支持文件级挂载,但支持嵌套目录挂载)上均可工作。
Session DB 并发访问: 宿主机写入 messages_in,agent-runner 写入 messages_out。两者同时访问同一 SQLite 文件。WAL 模式处理此问题——SQLite 允许多个并发读取者,且双方写入不同的表,因此写入争用极小。宿主机在创建 session DB 时启用 WAL 模式。
Session 管理: 宿主机管理。宿主机创建 session 文件夹并挂载。容器只能看到自己的 session 文件夹。
Session 创建(无竞态条件):
- 消息到达,宿主机在中央数据库中检查是否有匹配此群组 + 线程的 session
- Session 不存在 → 宿主机原子性地在中央数据库中创建 session 行,创建 session 文件夹,创建 session DB,写入消息
- 在容器启动前有更多消息到达 → 宿主机找到现有 session,写入同一 session DB
- 容器启动,挂载文件夹,agent-runner 发现等待中的消息
中央数据库中的 session 行创建是序列化点。无需协调 Claude SDK session ID——当 agent 运行时,SDK 自行发现其在 .claude/ 中的 session 数据。
系统操作: Agent 使用 MCP 工具(注册群组、重置 session、计划任务等)。Agent-runner 处理这些工具调用,并写入结构化的、确定性的 kind: 'system' 的 messages_out 行。这不是自然语言——它是宿主机确定性处理的编程式、结构化负载。宿主机验证权限、执行,并将结果作为 system 类型的 messages_in 行写回。
容器生命周期: 无预热池。容器按需启动(wakeUpAgent),并在空闲时由宿主机从外部终止。现有空闲检测 + 终止机制得以沿用。
运行行为
输出投递
NanoClaw 不向用户流式传输 token。Claude Agent SDK 的 query() 生成完整结果。Agent-runner 将每个结果的一条完整消息写入 messages_out。宿主机将完整消息投递到通道。
消息编辑是作为显式操作支持的(agent 调用 edit_message 工具),而非作为流式传输机制。
输入指示器:当容器对某个 session 处于活跃状态时,宿主机设置输入状态,当容器退出或 messages_out 中出现响应时清除。
消息批处理
当多条消息在容器停机期间到达时,它们以 handled = 0 行的形式累积在 messages_in 中。当容器唤醒时,agent-runner 查询所有未处理的消息,并将它们作为批次处理——多条消息被格式化到单个 <messages> XML 块中。
消息生命周期
pending → processing → completed
→ failed(达到最大重试次数后)
- pending:宿主机写入。待拾取(如果
process_after为 null 或已过时)。 - processing:Agent-runner 在拾取消息时设置此状态。
status_changed设为当前时间。防止其他轮询重复拾取同一消息。 - completed:Agent-runner 在成功处理后设置此状态。
- failed:耗尽最大重试次数后设置。
过期检测(Stale detection):如果消息处于 processing 状态但 status_changed 太久远(例如 > 10 分钟),宿主机假定容器崩溃。它将消息重置为 pending,递增 tries,并设置带指数退避的 process_after。
错误处理与重试
重试使用带有指数退避的 process_after。每次重试递增 tries 并推迟 process_after:
- 尝试 1:立即
- 尝试 2:+5 秒
- 尝试 3:+10 秒
- 尝试 4:+20 秒
- 尝试 5:+40 秒
- 达到最大重试次数后:状态设为
failed
由宿主机计算此信息——而非 agent-runner。当宿主机检测到过期的 processing 消息或容器异常退出时,递增 tries,计算下一个 process_after,并将状态重置为 pending。
输出已发送保护:如果某批次已有 delivered 的 messages_out 行,则不重试(防止向用户发送重复消息)。
宿主机轮询
两个层级:
- 活跃容器(约 1 秒):轮询 session DB 以获取待投递的新
messages_out行 - 所有 session(约 60 秒):巡检所有 session DB,查找到期的
process_after/deliver_after时间戳,处理循环
灵活性模型
该架构对代码修改灵活,但并非所有场景都可配置。高级设置(如以下 PR Factory)使用自定义路由逻辑和宿主机侧钩子——而非数据库配置列。
用于技能定制的代码结构
NanoClaw 通过技能(skills)进行定制——合并到用户安装中的分支。不同的 skills 添加不同的能力(通道、集成、行为)。代码必须结构化为:
-
不同定制互不冲突。 添加 Slack 和添加 Telegram 不应产生合并冲突。添加新的 MCP 工具不应与添加通道冲突。每种定制类型应有自己的文件。
-
核心功能块在单独的文件中。 通道注册、消息格式化、MCP 工具、路由逻辑、容器管理——各自独立的文件。更改消息格式化方式的 skill 不会触及处理容器启动的文件。
-
入口文件(index)保持精简。 它将各部分连接起来(初始化 DB、启动 adapter、启动轮询循环),但不包含业务逻辑。所有逻辑驻留在目的特定的模块中,skills 可以独立修改。
-
不要过度拆分。 简单的更改(例如,添加新的消息类型)不应需要在 5 个文件中编辑。将相关逻辑分组在一起。目标是每个 skill 的核心更改只触及 1-2 个文件。
-
注册模式优先于 switch 语句。 通道、MCP 工具和提供商应使用注册/插件模式。Skill 通过添加文件和注册调用来添加通道——而不是在中央 switch 语句中与其他每个通道一起编辑。
实际示例: 通过 skill 添加新通道应需要:
- 一个新文件(channel adapter 或 Chat SDK 配置)
- 在 barrel 文件(
channels/index.ts)中添加一行以导入自注册模块 - 对路由、格式化、投递或容器代码零更改
冲突热点与解决方案
对 33 个 skill 分支的分析显示以下文件导致最多的合并冲突:
| 热点 | 冲突原因 | 解决方案 |
|---|---|---|
src/index.ts(2000 LOC) |
每个 skill 都修补主循环、导入、初始化逻辑 | 精简的入口文件,连接各模块。逻辑驻留在目的特定文件中(router、delivery、session-manager、host-sweep)。 |
src/config.ts |
每个 skill 都向中央文件添加环境变量 | 配置在使用的模块内声明。每个模块读取自己的 env var。没有每个 skill 都编辑的中央配置注册表。 |
src/container-runner.ts |
通道 skills 添加挂载、env var、凭证设置 | 声明式挂载注册。通道在自己的文件中声明挂载。Container runner 从注册表中读取,而非硬编码列表。 |
src/db.ts(750 LOC) |
模式、迁移和所有 CRUD 在一个文件中 | 按实体拆分。编号迁移。Skills 添加迁移文件 + 编辑一个实体文件。 |
container/agent-runner/src/index.ts |
Agent 协议、IPC 处理、格式化全在一个文件中 | 拆分为 poll-loop、formatter、providers/、mcp-tools/。Session DB 替代 IPC。 |
src/ipc.ts |
每个 MCP 工具添加都修补一个文件 | mcp-tools/ 目录配合 barrel。Skills 添加工具文件 + barrel 行。 |
src/channels/index.ts |
每个通道在同一位置添加 import 行 | 带每个通道注释槽的 barrel 文件(当前模式有效,保留)。 |
挂载注册模式: 与其让每个通道 skill 编辑 buildVolumeMounts(),通道声明挂载,container runner 收集它们:
// channels/gmail.ts
registerChannel('gmail', {
factory: createGmailAdapter,
mounts: [
{ hostPath: '~/.gmail-mcp', containerPath: '/home/node/.gmail-mcp', readonly: false }
],
env: ['GMAIL_OAUTH_TOKEN'],
});
Container runner 从通道注册表读取注册的挂载——无需编辑 container-runner.ts。
配置模式: Skills 不修补 config.ts 或 .env.example。Skill 特定的 env var 在 skill 的 SKILL.md 中记录——设置过程读取这些指令。每个模块直接读取自己的 env var:
// channels/discord.ts
const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN;
// channels/gmail.ts
const GMAIL_CREDS = process.env.GMAIL_CREDENTIALS_PATH;
共享配置(DATA_DIR、TIMEZONE、MAX_CONCURRENT_CONTAINERS)保留在 config.ts 中。通道/skill 特定配置保留在使用的模块中。
代码风格
行宽:120 字符。 大多数语句能在一行内写完而不牺牲可读性。
简洁日志。 一个薄封装使每个日志调用保持在一行:
log.info('IPC 消息已发送', { chatJid, sourceGroup });
log.warn('未授权的 IPC 尝试', { chatJid });
log.error('处理错误', { file, err });
DB 文件结构
DB 层按实体拆分,而非保持在一个整体文件中:
src/db/
connection.ts ← 单例、初始化、WAL 模式
schema.ts ← CREATE TABLE 语句(当前状态,供参考)
migrations/
index.ts ← 运行器:检查版本,应用待执行的迁移
001-initial.ts ← 初始模式
002-pending-questions.ts ← 示例:添加 pending_questions 表
... ← skills 追加新的编号文件
agent-groups.ts ← agent_groups 的 CRUD
messaging-groups.ts ← messaging_groups + messaging_group_agents 的 CRUD
sessions.ts ← sessions + pending_questions 的 CRUD
index.ts ← barrel:重新导出所有内容
原则:
- 按实体拆分,而非按层。 每个实体文件有自己的 CRUD 函数(约 50-100 行)。向 messaging_groups 添加列的 skill 只编辑
messaging-groups.ts——不触及 sessions 或 agent groups。 - Schema 作为当前状态 + 迁移作为历史。
schema.ts记录 DB 现在的样子(阅读它以理解模式)。迁移是只追加的编号文件,描述我们如何达到当前状态。 - 无内联 ALTER TABLE。 带有
schema_version表的迁移运行器替代了try { ALTER TABLE } catch { /* 已存在 */ }代码块。启动时,检查当前版本并按顺序应用待执行的迁移。每个迁移是一个函数:(db: Database) => void。 - Skills 添加迁移。 需要新增列的 skill 添加一个新的编号迁移文件。只要编号不冲突(为 skill 分支使用时间戳或足够大的编号),就不会与其他 skills 的迁移冲突。
Agent-runner session DB 使用相同的模式但更轻量——无需迁移,因为 session DB 由宿主机全新创建:
container/agent-runner/src/db/
connection.ts ← 在固定路径打开 session.db,WAL 模式
messages-in.ts ← 读取待处理、更新状态
messages-out.ts ← 写入结果、outbox 查询
index.ts ← barrel
基础架构必须原生支持的功能
以下是构建块。它们都不需要特殊抽象——它们自然产生于逐 session DB、宿主机管理的路由和 kind: 'system' 的 messages_out:
-
同一通道上基于内容路由的多个 agent group。 同一线程中的不同消息可以基于内容路由到不同的 agent group(例如,@提及路由到监督者,普通消息路由到工作者)。Channel adapter 的路由逻辑——自定义代码——决定。
-
来自共享 agent group 的每线程 session。 多个 session 共享同一个 agent group(文件系统、skills、CLAUDE.md),但每个获得自己的 session DB。工作者池的标准用法。
-
Session 重置和重放。 为同一线程创建新 session。将旧消息标记为未处理,以便轮询重新拾取。旧输出在平台(例如 Discord 线程)中仍然可见以供比较。这是 agent 可以请求的操作——而非自动。
-
跨 session 读取访问。 某些 agent 可以查询其他 session 的数据。不同的访问级别:管理者查看
messages_in/messages_out(审查内容)。监督者查看完整内部信息(agent 日志、工具调用、调试追踪)。这只是文件系统/DB 访问——挂载或查询正确的路径。 -
上下文复制到新 session。 当监督者在工作者线程中被调用时,创建包含相关消息副本的新 session。自定义宿主机侧代码处理此问题。
-
Agent 发起的宿主机操作。 Agent 使用 MCP 工具(重置 session、更新 skills 等)。Agent-runner 处理工具调用并写入结构化的
system类型的messages_out行。宿主机读取并在权限检查后执行。Agent 可以请求,但宿主机决定。
示例:PR Factory
三个 agent group,一个 Discord 频道(PR Factory),外加一个管理员通道:
| 角色 | Agent Group | 所在位置 | Session 模型 |
|---|---|---|---|
| 工作者 | pr-worker | PR Factory 线程 | 每个线程一个 session(每个 PR) |
| 管理者 | pr-manager | PR Factory 频道 | 单个 session,跨工作者 session 查询 |
| 监督者 | pr-admin | 管理员通道 + PR Factory(被 @标记时) | 管理员通道中的主 session;在工作者线程中被调用时创建每线程 session |
工作者流程: GitHub PR → Discord 线程 → 工作者 agent 审查(分类、审查、测试计划)。每个线程从共享的 pr-worker group 获得一个 session。
反馈流程: 用户在工作线程中 @标记监督者 → 自定义路由将其发送到监督者,附带包含该线程消息(已复制)的新 session。监督者将反馈收集到文件系统。工作者看不到监督者消息。
迭代流程: 用户在管理员通道中与监督者讨论反馈 → 监督者建议 skill 更改(以带 diff 的富卡片显示)→ 用户批准 → 监督者通过宿主机操作应用更改 → 监督者请求 session 重置 + 重放 → 工作者使用更新后的 skills 在相同线程但全新的 session 中重新审查相同 PR → 用户并排比较审查结果。
管理者流程: 用户在 PR Factory 主频道(而非线程内)与管理者交谈。管理者可以搜索所有工作者 session DB(messages_in/messages_out),以回答如"今天有多少 PR?"或"哪些话题在趋势中?"等问题。可以请求操作(关闭 PR、重新打开)。
自定义代码 vs 基础架构:
| 能力 | 基础架构 | 自定义代码(PR Factory) |
|---|---|---|
| 每线程 session | ✓ platformThreadId → session | |
| 跨 session 共享 agent group | ✓ 多个 session,一个 group | |
| 写入消息到 session DB | ✓ 标准流程 | |
| @提及路由到不同 agent | ✓ Channel adapter 路由逻辑 | |
| 上下文复制到监督者 session | ✓ 监督者调用时的宿主机侧钩子 | |
| Session 重置 + 重放 | ✓ 原语(新 session、标记未处理) | ✓ 监督者操作触发 |
| Skill 更新 | ✓ 文件系统写入 | ✓ 监督者操作应用更改 |
| 跨 session 查询 | ✓ DB/文件系统访问 | ✓ 管理者的工具知道在哪里查找 |
| 富卡片输出 | ✓ messages_out 中的结构化输出 |
中央数据库模式
中央数据库处理路由和实体管理。所有内容和执行状态驻留在逐 session DB 中。
-- 智能体工作区:文件夹、skills、CLAUDE.md、容器配置
CREATE TABLE agent_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
agent_provider TEXT, -- session 的默认值(null = 系统默认值)
container_config TEXT, -- JSON: { additionalMounts, timeout }
created_at TEXT NOT NULL
);
-- 平台群组/频道(WhatsApp 群组、Slack 频道、Discord 频道、邮件线程等)
CREATE TABLE messaging_groups (
id TEXT PRIMARY KEY,
channel_type TEXT NOT NULL, -- 'whatsapp'、'slack'、'discord'、'telegram'、'email'
platform_id TEXT NOT NULL, -- 平台特定 ID(JID、频道 ID 等)
name TEXT,
is_group INTEGER DEFAULT 0,
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict', -- 'strict' | 'request_approval' | 'public'
created_at TEXT NOT NULL,
UNIQUE(channel_type, platform_id)
);
-- 用户(消息平台身份,命名空间格式 "<channel_type>:<handle>")
CREATE TABLE users (
id TEXT PRIMARY KEY, -- 例如 'telegram:123456', 'discord:1470...'
kind TEXT NOT NULL, -- 镜像 channel_type 前缀
display_name TEXT,
created_at TEXT NOT NULL
);
-- 角色(owner 仅全局;admin 可以是全局或限定于某个 agent_group)
CREATE TABLE user_roles (
user_id TEXT NOT NULL REFERENCES users(id),
role TEXT NOT NULL, -- 'owner' | 'admin'
agent_group_id TEXT REFERENCES agent_groups(id), -- NULL 表示全局
granted_by TEXT,
granted_at TEXT NOT NULL,
PRIMARY KEY (user_id, role, agent_group_id)
);
-- owner 行必须使 agent_group_id = NULL(在 db/user-roles.ts 中强制)
-- 成员关系(显式非特权访问;admin/owner 隐含成员关系)
CREATE TABLE agent_group_members (
user_id TEXT NOT NULL REFERENCES users(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
added_by TEXT,
added_at TEXT NOT NULL,
PRIMARY KEY (user_id, agent_group_id)
);
-- DM 解析缓存(避免每次重新解析冷 DM)
CREATE TABLE user_dms (
user_id TEXT NOT NULL REFERENCES users(id),
channel_type TEXT NOT NULL,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
resolved_at TEXT NOT NULL,
PRIMARY KEY (user_id, channel_type)
);
-- 哪些 agent group 处理哪些 messaging group,使用什么规则
CREATE TABLE messaging_group_agents (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
trigger_rules TEXT, -- JSON: { pattern, mentionOnly, excludeSenders, includeSenders }
response_scope TEXT DEFAULT 'all', -- 'all' | 'triggered' | 'allowlisted'
session_mode TEXT DEFAULT 'shared', -- 'shared' | 'per-thread'
priority INTEGER DEFAULT 0, -- 更高 = 当多个 agent 匹配时优先检查
created_at TEXT NOT NULL,
UNIQUE(messaging_group_id, agent_group_id)
);
-- Session:一个文件夹 = 一个 session = 运行时的一个容器
-- 文件夹路径推导:sessions/{agent_group_id}/{session_id}/
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
messaging_group_id TEXT REFERENCES messaging_groups(id), -- 内部/派生 session 为 null
thread_id TEXT, -- 平台线程 ID(共享 session 模式为 null)
agent_provider TEXT, -- 逐 session 覆盖(null = 继承自 agent_group)
status TEXT DEFAULT 'active', -- 'active' | 'closed'
container_status TEXT DEFAULT 'stopped', -- 'running' | 'idle' | 'stopped'
last_active TEXT, -- 最后消息活动时间戳
created_at TEXT NOT NULL
);
CREATE INDEX idx_sessions_agent_group ON sessions(agent_group_id);
CREATE INDEX idx_sessions_lookup ON sessions(messaging_group_id, thread_id);
-- 挂起的交互式问题(等待用户响应的卡片)
-- 宿主机在投递问题卡片时写入,收到响应时删除
CREATE TABLE pending_questions (
question_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
message_out_id TEXT NOT NULL, -- 发送卡片的 messages_out 行
platform_id TEXT, -- 卡片投递到的位置
channel_type TEXT,
thread_id TEXT,
created_at TEXT NOT NULL
);
挂起问题流程
当宿主机投递带有 operation: 'ask_question' 的 messages_out 行时:
- 宿主机通过 channel adapter 投递卡片
- 宿主机写入
pending_questions行,映射question_id→session_id
当 Chat SDK ActionEvent(按钮点击)到达时:
- 桥接从事件中提取
actionId - 宿主机通过
question_id(从 actionId 推导——桥接维护映射关系)查找pending_questions - 宿主机找到目标 session,写入包含
questionId+selectedOption的messages_in行 - 宿主机删除
pending_questions行 - Agent-runner 拾取
messages_in行,匹配到挂起的工具调用,返回选择结果
这避免了扫描 session DB。中央数据库是路由查找——与消息路由相同的模式。
同样用于宿主机生成的审批卡片:当宿主机向管理员 DM 发送审批请求时,写入 pending_questions 行。管理员的响应被路由回发起 session。
容器生命周期状态
stopped → running → idle → stopped
↗
idle → running(预热期间收到新消息)
- stopped:无容器。每 60 秒巡检到期定时消息。
- running:活跃处理中。每 1 秒轮询
messages_out。 - idle:处理完毕,容器仍预热中(最多 30 分钟超时)。每 1 秒轮询以便快速拾取新消息。
- 达到空闲超时 → 宿主机终止容器 → stopped。
Agent-Runner 架构
Agent-runner 是容器内的进程。它在 session DB 和 Claude SDK 之间进行中介——轮询工作、为 agent 格式化消息、将工具调用转换为 DB 行,以及管理 agent 生命周期。
IO 模型
所有 IO 通过 session DB 进行。无 stdin、无 stdout 标记、无 IPC 文件。
- 初始输入和后续:轮询
messages_in - 输出:写入
messages_out行 - MCP 工具:写入 DB 行(无 IPC 文件)
- 关闭:宿主机在空闲超时时终止容器,或 agent-runner 在没有待处理工作时退出
轮询循环
- 查询
messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now()) - 如果找到行:设置每行
status = 'processing'、status_changed = now() - 将消息批处理为单个提示(剥离路由字段,按 kind 格式化)
- 推送到 Claude SDK 的 MessageStream
- 处理 agent 输出 → 写入
messages_out行 - 将已处理消息设置为
status = 'completed' - 回到步骤 1。如果未找到消息,短暂休眠并重新轮询(容器在空闲超时前保持预热)
按 Kind 的消息格式化
Agent-runner 在格式化前剥离路由字段(platform_id、channel_type、thread_id)。Agent 永远不会看到路由信息——它只看到内容。
chat— 格式化为<messages>XML 块chat-sdk— 从序列化消息中提取 text、author、attachments;格式化为<messages>XMLtask— 格式化为[SCHEDULED TASK]前缀 + 提示。如果存在,先运行预脚本。webhook— 格式化为[WEBHOOK: source/event]+ JSON 负载system— 宿主机操作结果(例如,"register_group 成功")。格式化为系统上下文,而非聊天消息。
混合批次(例如,一条聊天消息 + 一个系统结果均待处理)使用明确的分隔符合并为单个提示。
MCP 工具
MCP 工具直接写入 session DB。
核心工具:
| 工具 | 功能 |
|---|---|
send_message |
写入 messages_out 行,kind: 'chat' |
send_file |
将文件移动到 outbox/{msg_id}/,写入带文件名的 messages_out |
schedule_task |
写入带有 process_after + recurrence 的 messages_in 行(发给自身)。或带有 deliver_after 的 messages_out 用于出站提醒。 |
list_tasks |
查询 messages_in WHERE recurrence IS NOT NULL |
pause_task / resume_task / cancel_task |
修改 messages_in 行(更新状态、清除/设置 recurrence) |
register_agent_group |
写入 messages_out,kind: 'system',action: 'register_agent_group' |
新增工具:
| 工具 | 功能 |
|---|---|
ask_user_question |
写入带有问询卡片的 messages_out。保持工具调用打开,轮询 messages_in 查找匹配 questionId 的响应。将选择作为工具结果返回。 |
edit_message |
写入带有 operation: 'edit' 的 messages_out |
add_reaction |
写入带有 operation: 'reaction' 的 messages_out |
send_to_agent |
写入带有 channel_type: 'agent'、platform_id: '{target}' 的 messages_out |
send_card |
写入带有卡片结构的 messages_out |
参见 agent-runner-details.md 了解完整的 MCP 工具参数定义。
卡片
Agent 发起(出站): 基于工具。Agent 调用 ask_user_question(带有选项的交互式卡片)或 send_card(结构化卡片)。Agent-runner 将卡片结构写入 messages_out。宿主机/adapter 处理平台特定渲染(Slack Block Kit、Discord embeds、Telegram 内联键盘、文本回退)。
宿主机发起(审批卡片): 当操作需要审批时,宿主机生成标准化审批卡片并发送到管理员 DM。这些不是 agent 发起的——agent 不知道审批步骤。卡片格式是固定的(操作描述 + 批准/拒绝按钮)。
入站(卡片响应): 不是卡片——它是 messages_in 行,内容中包含 questionId + selectedOption。Agent-runner 匹配到挂起的 ask_user_question 工具调用,并将选择作为工具结果返回。
命令
以 / 开头的消息会与三个列表进行匹配检查:
白名单命令(透传给 agent):
- Agent 提供商原生处理的标准斜杠命令(例如,Claude 的内置命令)
- 原样传递,不做
<messages>XML 包装
管理员专用命令(需要管理员发送者):
/remote-control— 远程控制 session/clear— 清除 session 上下文/compact— 强制上下文压缩- 如果由非管理员用户发送,命令被拒绝并返回错误消息。不转发给 agent。
过滤命令(完全丢弃):
- 在 NanoClaw 上下文中无意义或可能导致问题的命令
- 静默丢弃——无错误,不转发
命令列表硬编码在 agent-runner 中。管理员验证在消息到达容器之前由宿主机侧完成:src/command-gate.ts 查询 user_roles(owner / 全局 admin / 此 agent group 的限定 admin),并据此放行消息、丢弃或路由到其他地方。容器没有管理员身份的概念——无 env var,无 DB 查询,无逐消息检查。
循环任务(Recurring Tasks)
Agent-runner 像处理其他 messages_in 行一样处理循环任务消息。在 agent-runner 将循环消息标记为 completed 后,宿主机处理插入下一次发生(新的 messages_in 行,process_after 推进到下一个 cron 时间点)。Agent-runner 不管理循环——它只处理找到的消息。
预脚本:如果 task 消息有 script 字段,先运行它。如果 wakeAgent = false,标记为 completed 而不调用 Claude。
智能体间消息(Agent-to-Agent Messaging)
出站: Agent 调用 send_to_agent 工具 → agent-runner 写入 messages_out,其中 channel_type: 'agent',platform_id = 目标 agent group ID。宿主机验证权限并写入目标 session 的 messages_in。
入站: 来自其他 agent 的消息以普通 chat 类型的 messages_in 行到达。内容包含 sender 和 senderId(例如,"senderId": "agent:pr-admin")。无特殊格式化——agent 将其视为聊天消息。
Agent-Runner 属性
- AgentProvider 接口封装 SDK 特定查询逻辑(主干代码包含
claude提供商;其他提供商如 OpenCode 通过/add-<provider>技能安装) - 通过提供商特定机制恢复 session
- 从 CLAUDE.md 文件加载系统提示
- PreCompact 钩子用于对话归档(Claude 提供商)
- task 类型消息的脚本执行
待解决问题
- 审批路由——宿主机如何找到管理员的 DM 对话?如果没有 DM 通道怎么办?审批列表是否可按 agent group 或全局配置?
- MCP 服务器生命周期——MCP 服务器进程是在同一容器中的多次查询之间持续存在,还是每次都重新启动?
- 容器启动配置——在启动时除了 env var 之外,还会向容器传递什么配置(如果有)?Session DB 在固定的挂载路径上。系统提示来自 CLAUDE.md。提供商名称来自 env。还有什么?
- 挂起问题时的空闲检测——当
ask_user_question等待响应时,容器不应被视为空闲。还需要检测 agent 是否仍在工作(活跃的工具调用、子 agent),并在即使最近没有写入messages_out的情况下避免终止容器。
相关文档
- api-details.md — Channel adapter 接口(NanoClaw + Chat SDK 桥接)、消息内容示例、宿主机投递逻辑
- agent-runner-details.md — AgentProvider 接口、MCP 工具、消息格式化、媒体处理、提供商实现