# 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 负责: 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 文档](https://chat-sdk.dev/docs/adapters)): - **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): 1. **启动(Spawn)**——当调用 wakeUpAgent 且该 session 不存在容器时 2. **空闲终止(Idle kill)**——当容器在某个超时时段内没有未处理消息时 3. **限制(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) | 原生图片内容块 | 保存到磁盘,在提示中引用 | 保存到磁盘,在提示中引用 | | 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` 内容仅引用文件名: ```json { "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](agent-runner-details.md) 了解工具接口。 ### 消息去重 去重是 channel adapter 的职责。Chat SDK 内部处理此问题。原生 adapter 根据需要追踪平台消息 ID。宿主机不进行去重——如果 adapter 转发消息,宿主机就写入。 ## Session DB 模式 两张表。内容使用 JSON blob——无模式,格式因 `kind` 而异。 ```sql -- 宿主机写入,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())` → 唤醒 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` 和可选的 `recurrence` 的 `messages_in`(发给自身)或 `messages_out`(提醒/通知)。 ### messages_in 各 kind 的内容格式 **`chat`** — 简洁的 NanoClaw 格式。任何通道都可以生成此格式。 ```json { "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`** — 计划任务触发。 ```json { "prompt": "审查开放 PR", "script": "scripts/review.sh" } ``` **`webhook`** — 原始 Webhook 负载。 ```json { "source": "github", "event": "pull_request", "payload": { ... } } ``` **`system`** — 宿主机操作结果(对 agent 请求的系统操作的响应)。 ```json { "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)` 投递。 ```json { "text": "LGTM,正在合并" } ``` **`chat-sdk`** — Chat SDK `AdapterPostableMessage`。桥接 adapter 通过 `thread.post()` 投递。可以是 Markdown、卡片或原始格式——adapter 处理平台转换。 ```json { "markdown": "## 审查\n**LGTM**", "attachments": [...] } ``` ```json { "card": { "type": "card", "title": "审查", "children": [...] }, "fallbackText": "..." } ``` **`task`** — 任务结果。宿主机记录日志并可选择通知。 ```json { "result": "已审查 3 个 PR", "status": "success" } ``` **`webhook`** — Webhook 响应。宿主机发送 HTTP 响应或通知。 ```json { "response": { "status": 200, "body": { ... } } } ``` **`system`** — 宿主机操作请求(注册群组、重置 session 等)。宿主机读取、验证权限、执行、将结果作为 `system` 类型的 `messages_in` 行写回。 ```json { "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.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` 内容中的操作:** ```json // 普通消息(默认) { "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。 ```json // 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 通知)。这通过自定义代码支持,而非内置于核心: 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 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 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 创建(无竞态条件):** 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 查询所有未处理的消息,并将它们作为批次处理——多条消息被格式化到单个 `` 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 添加不同的能力(通道、集成、行为)。代码必须结构化为: 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.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 收集它们: ```typescript // 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: ```typescript // 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 字符。** 大多数语句能在一行内写完而不牺牲可读性。 **简洁日志。** 一个薄封装使每个日志调用保持在一行: ```typescript 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`: 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 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 中。 ```sql -- 智能体工作区:文件夹、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) ); -- 用户(消息平台身份,命名空间格式 ":") 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_id` → `session_id` 当 Chat SDK `ActionEvent`(按钮点击)到达时: 1. 桥接从事件中提取 `actionId` 2. 宿主机通过 `question_id`(从 actionId 推导——桥接维护映射关系)查找 `pending_questions` 3. 宿主机找到目标 session,写入包含 `questionId` + `selectedOption` 的 `messages_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_id`、`channel_type`、`thread_id`)。Agent 永远不会看到路由信息——它只看到内容。 - **`chat`** — 格式化为 `` XML 块 - **`chat-sdk`** — 从序列化消息中提取 text、author、attachments;格式化为 `` 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` + `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](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 的内置命令) - 原样传递,不做 `` 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-` 技能安装) - 通过提供商特定机制恢复 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](api-details.md)** — Channel adapter 接口(NanoClaw + Chat SDK 桥接)、消息内容示例、宿主机投递逻辑 - **[agent-runner-details.md](agent-runner-details.md)** — AgentProvider 接口、MCP 工具、消息格式化、媒体处理、提供商实现