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

912 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 实现的流式传输
- **WhatsAppCloud 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 不适用的通道(例如,使用缓冲流的 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` 内容仅引用文件名:
```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_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` 和可选的 `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` 可以指定特定 sessionnull = 查找或创建默认 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>` 技能安装)
- 路由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 查询所有未处理的消息,并将它们作为批次处理——多条消息被格式化到单个 `<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 添加不同的能力(通道、集成、行为)。代码必须结构化为:
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.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 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, -- 平台特定 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_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`** — 格式化为 `<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` + `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 的内置命令)
- 原样传递,不做 `<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](api-details.md)** — Channel adapter 接口NanoClaw + Chat SDK 桥接)、消息内容示例、宿主机投递逻辑
- **[agent-runner-details.md](agent-runner-details.md)** — AgentProvider 接口、MCP 工具、消息格式化、媒体处理、提供商实现