Files
nanoclaw/docs/zh/architecture.md
2026-05-12 13:14:17 +00:00

52 KiB
Raw Permalink Blame History

NanoClaw 架构(草案)

核心理念

每个智能体会话agent session都有一个挂载的 SQLite 数据库DB。该数据库是宿主机host与容器container之间的唯一 IO 机制。没有 IPC 文件,没有 stdin 管道。两张表:messages_in(宿主机 → agent-runnermessages_outagent-runner → 宿主机)。一切皆消息。

两级数据库

中央数据库Central DB宿主机进程内

  • 智能体组agent group、对话、路由routing
  • 将平台 ID 映射到 agent group → 会话session
  • 通道适配器channel adapter不直接访问此库——宿主机负责查找

逐会话数据库Per-session DB挂载到容器内

  • messages_in宿主机写入agent-runner 读取)
  • messages_outagent-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 负责:

  1. 接收平台事件Webhook、轮询polling、WebSocket——平台特定
  2. 过滤:决定将哪些消息转发给宿主机处理。可以是无状态的(正则触发器匹配)或有状态的(例如,"该机器人曾在此线程中被提及过吗?如果是,则转发所有后续消息"。Adapter 接收未经过滤的平台消息流并决定传递哪些消息。如何决定是实现细节——NanoClaw 不关心也不需知道。
  3. 提取并标准化两个 ID
    • Platform channel ID——标识对话WhatsApp 群组、Slack 频道、邮件线程)
    • Platform thread ID——可选的子上下文Slack 消息分支、GitHub PR 评论分支)
  4. 出站投递——将响应发送回平台

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 卡片、模态框、流式传输、表情反应、临时消息)
  • DiscordEmbeds、按钮、通过 post+edit 实现的流式传输
  • WhatsAppCloud API:仅私信、交互式回复按钮、不支持流式传输、不支持表情反应
  • GitHub/LinearMarkdown 评论、无交互元素
  • Telegram:内联键盘按钮、通过 post+edit 实现的流式传输

宿主机/桥接处理优雅降级——如果 agent 在不支持卡片的平台上发布卡片,则回退为文本。

非 Chat SDK 通道WhatsApp via Baileys、Gmail、自定义集成直接实现 NanoClaw 通道接口——无需桥接,无需 Chat SDK 类型。

容器生命周期

宿主机是一个编排器orchestrator

  1. 启动Spawn——当调用 wakeUpAgent 且该 session 不存在容器时
  2. 空闲终止Idle kill——当容器在某个超时时段内没有未处理消息时
  3. 限制Limits——MAX_CONCURRENT_CONTAINERS 限制活跃容器数量

当容器启动时agent-runner 立即开始轮询其 session DB。消息已在那里等待。

媒体处理

入站

宿主机不下载媒体。取而代之:

  • 消息包含下载 URL尽可能使用签名 URL
  • Agent-runner 在容器内下载并处理媒体
  • 对于签名 URL 不适用的通道(例如,使用缓冲流的 WhatsAppchannel adapter 下载媒体并通过容器可访问的本地 URL/服务器提供服务

原生内容块(取决于提供商):

Agent-runner 检测文件类型,并在提供商支持的情况下将支持的类型作为原生内容块传递:

类型 Claude Codex OpenCode
图片JPEG、PNG、GIF、WebP 原生图片内容块 保存到磁盘,在提示中引用 保存到磁盘,在提示中引用
PDF 原生文档内容块 保存到磁盘 保存到磁盘
音频 原生音频内容块 保存到磁盘 保存到磁盘
其他文件(代码、数据、视频、归档) 保存到磁盘 保存到磁盘 保存到磁盘

"保存到磁盘"指下载到 /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_outagent 永远不会看到这些字段)
  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()) → 唤醒 agent
  • messages_in WHERE status = 'processing' AND status_changed < (now - stale_threshold) → 过期检测,增加 tries,带退避重置为 pending
  • messages_out WHERE delivered = 0 AND (deliver_after IS NULL OR deliver_after <= now()) → 投递
  • 完成/投递带有 recurrence 的行后,插入下一次发生

活跃容器轮询(约 1 秒)检查相同的条件,但仅针对正在运行容器的 session。

Agent-runner 创建调度的方式是写入带有 process_after 和可选的 recurrencemessages_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 透传。包含 authortextformattedmdast ASTattachmentsisMentionlinksmetadata

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 方法投递。

带有用户交互的卡片(例如,"向用户提问"

  1. Agent 调用 ask_user_question 工具,附带问题 + 选项
  2. Agent-runner 将问询卡片写入 messages_out
  3. 宿主机通过 adapter 以交互式卡片形式投递例如Slack Block Kit 按钮)
  4. 用户点击选项
  5. 平台将事件发送回 adapter → 宿主机将响应写入 messages_in
  6. 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.tssrc/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 可以指定特定 sessionnull = 查找或创建默认 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 将路由字段(kindplatform_idchannel_typethread_id)从 messages_in 行复制到 messages_out。响应发送回原始来源。

宿主机验证: 投递前,宿主机检查此 agent group 是否被允许向目标发送消息。Agent-runner 复制路由;宿主机验证。

多目标模式(定制): Agent 可能需要发送到与来源不同的通道例如Webhook 触发 Slack 通知)。这通过自定义代码支持,而非内置于核心:

  1. 向 session DB 添加 destinations 表,将逻辑名称映射到路由字段
  2. 在设置 session 时由宿主机填充
  3. 修改 agent 提示以列出可用的目标
  4. Agent 通过名称选择目标agent-runner 解析为路由字段
  5. 宿主机照常验证

这被记录为一种模式,而非内置功能。

核心属性

  • 通过文件系统挂载实现的容器隔离isolation
  • 凭证代理OneCLI
  • 每个 agent group 的工作空间文件夹、CLAUDE.md、skills
  • 基于轮询(非事件驱动)
  • 容器启动时对每个 agent group 的 agent-runner 进行重新编译agent 可以修改其自身源代码,请求重建/重启,更改在销毁后仍然保留)
  • 宿主机 ↔ 容器 IO 通过挂载的 session DBmessages_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 文件夹挂载到 /workspaceagent 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_inagent-runner 写入 messages_out。两者同时访问同一 SQLite 文件。WAL 模式处理此问题——SQLite 允许多个并发读取者,且双方写入不同的表,因此写入争用极小。宿主机在创建 session DB 时启用 WAL 模式。

Session 管理: 宿主机管理。宿主机创建 session 文件夹并挂载。容器只能看到自己的 session 文件夹。

Session 创建(无竞态条件):

  1. 消息到达,宿主机在中央数据库中检查是否有匹配此群组 + 线程的 session
  2. Session 不存在 → 宿主机原子性地在中央数据库中创建 session 行,创建 session 文件夹,创建 session DB写入消息
  3. 在容器启动前有更多消息到达 → 宿主机找到现有 session写入同一 session DB
  4. 容器启动挂载文件夹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 或已过时)。
  • processingAgent-runner 在拾取消息时设置此状态。status_changed 设为当前时间。防止其他轮询重复拾取同一消息。
  • completedAgent-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

输出已发送保护:如果某批次已有 deliveredmessages_out 行,则不重试(防止向用户发送重复消息)。

宿主机轮询

两个层级:

  • 活跃容器(约 1 秒):轮询 session DB 以获取待投递的新 messages_out
  • 所有 session约 60 秒):巡检所有 session DB查找到期的 process_after / deliver_after 时间戳,处理循环

灵活性模型

该架构对代码修改灵活,但并非所有场景都可配置。高级设置(如以下 PR Factory使用自定义路由逻辑和宿主机侧钩子——而非数据库配置列。

用于技能定制的代码结构

NanoClaw 通过技能skills进行定制——合并到用户安装中的分支。不同的 skills 添加不同的能力(通道、集成、行为)。代码必须结构化为:

  1. 不同定制互不冲突。 添加 Slack 和添加 Telegram 不应产生合并冲突。添加新的 MCP 工具不应与添加通道冲突。每种定制类型应有自己的文件。

  2. 核心功能块在单独的文件中。 通道注册、消息格式化、MCP 工具、路由逻辑、容器管理——各自独立的文件。更改消息格式化方式的 skill 不会触及处理容器启动的文件。

  3. 入口文件index保持精简。 它将各部分连接起来(初始化 DB、启动 adapter、启动轮询循环但不包含业务逻辑。所有逻辑驻留在目的特定的模块中skills 可以独立修改。

  4. 不要过度拆分。 简单的更改(例如,添加新的消息类型)不应需要在 5 个文件中编辑。将相关逻辑分组在一起。目标是每个 skill 的核心更改只触及 1-2 个文件。

  5. 注册模式优先于 switch 语句。 通道、MCP 工具和提供商应使用注册/插件模式。Skill 通过添加文件和注册调用来添加通道——而不是在中央 switch 语句中与其他每个通道一起编辑。

实际示例: 通过 skill 添加新通道应需要:

  • 一个新文件channel adapter 或 Chat SDK 配置)
  • 在 barrel 文件(channels/index.ts)中添加一行以导入自注册模块
  • 对路由、格式化、投递或容器代码零更改

冲突热点与解决方案

对 33 个 skill 分支的分析显示以下文件导致最多的合并冲突:

热点 冲突原因 解决方案
src/index.ts2000 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.ts750 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.dbWAL 模式
  messages-in.ts         ← 读取待处理、更新状态
  messages-out.ts        ← 写入结果、outbox 查询
  index.ts               ← barrel

基础架构必须原生支持的功能

以下是构建块。它们都不需要特殊抽象——它们自然产生于逐 session DB、宿主机管理的路由和 kind: 'system'messages_out

  1. 同一通道上基于内容路由的多个 agent group。 同一线程中的不同消息可以基于内容路由到不同的 agent group例如@提及路由到监督者普通消息路由到工作者。Channel adapter 的路由逻辑——自定义代码——决定。

  2. 来自共享 agent group 的每线程 session。 多个 session 共享同一个 agent group文件系统、skills、CLAUDE.md但每个获得自己的 session DB。工作者池的标准用法。

  3. Session 重置和重放。 为同一线程创建新 session。将旧消息标记为未处理以便轮询重新拾取。旧输出在平台例如 Discord 线程)中仍然可见以供比较。这是 agent 可以请求的操作——而非自动。

  4. 跨 session 读取访问。 某些 agent 可以查询其他 session 的数据。不同的访问级别:管理者查看 messages_in/messages_out审查内容。监督者查看完整内部信息agent 日志、工具调用、调试追踪)。这只是文件系统/DB 访问——挂载或查询正确的路径。

  5. 上下文复制到新 session。 当监督者在工作者线程中被调用时,创建包含相关消息副本的新 session。自定义宿主机侧代码处理此问题。

  6. 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 DBmessages_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,     -- 平台特定 IDJID、频道 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 行时:

  1. 宿主机通过 channel adapter 投递卡片
  2. 宿主机写入 pending_questions 行,映射 question_idsession_id

当 Chat SDK ActionEvent(按钮点击)到达时:

  1. 桥接从事件中提取 actionId
  2. 宿主机通过 question_id(从 actionId 推导——桥接维护映射关系)查找 pending_questions
  3. 宿主机找到目标 session写入包含 questionId + selectedOptionmessages_in
  4. 宿主机删除 pending_questions
  5. 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 在没有待处理工作时退出

轮询循环

  1. 查询 messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())
  2. 如果找到行:设置每行 status = 'processing'status_changed = now()
  3. 将消息批处理为单个提示(剥离路由字段,按 kind 格式化)
  4. 推送到 Claude SDK 的 MessageStream
  5. 处理 agent 输出 → 写入 messages_out
  6. 将已处理消息设置为 status = 'completed'
  7. 回到步骤 1。如果未找到消息短暂休眠并重新轮询容器在空闲超时前保持预热

按 Kind 的消息格式化

Agent-runner 在格式化前剥离路由字段(platform_idchannel_typethread_id。Agent 永远不会看到路由信息——它只看到内容。

  • chat — 格式化为 <messages> XML 块
  • chat-sdk — 从序列化消息中提取 text、author、attachments格式化为 <messages> XML
  • task — 格式化为 [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 + recurrencemessages_in 行(发给自身)。或带有 deliver_aftermessages_out 用于出站提醒。
list_tasks 查询 messages_in WHERE recurrence IS NOT NULL
pause_task / resume_task / cancel_task 修改 messages_in 行(更新状态、清除/设置 recurrence
register_agent_group 写入 messages_outkind: '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_rolesowner / 全局 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 行到达。内容包含 sendersenderId(例如,"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 工具、消息格式化、媒体处理、提供商实现