912 lines
52 KiB
Markdown
912 lines
52 KiB
Markdown
# 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>` 技能安装)
|
||
- 路由: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.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)
|
||
);
|
||
|
||
-- 用户(消息平台身份,命名空间格式 "<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 工具、消息格式化、媒体处理、提供商实现
|