From c4753da8f54fe77093d4ce7dcba32ded04df2606 Mon Sep 17 00:00:00 2001 From: wang Date: Wed, 13 May 2026 03:40:13 +0000 Subject: [PATCH] docs: add detailed answers for 21 learning roadmap questions --- docs/answers/01-global-architecture.md | 139 ++++++++++++++ docs/answers/02-routing-and-sessions.md | 173 +++++++++++++++++ docs/answers/03-permissions-and-security.md | 166 +++++++++++++++++ docs/answers/04-container-lifecycle.md | 142 ++++++++++++++ .../answers/05-delivery-and-system-actions.md | 175 ++++++++++++++++++ docs/answers/06-data-model.md | 167 +++++++++++++++++ docs/answers/07-provider-and-mcp.md | 170 +++++++++++++++++ docs/answers/08-channel-adapters.md | 158 ++++++++++++++++ docs/answers/README.md | 16 ++ docs/learning-roadmap.md | 2 + 10 files changed, 1308 insertions(+) create mode 100644 docs/answers/01-global-architecture.md create mode 100644 docs/answers/02-routing-and-sessions.md create mode 100644 docs/answers/03-permissions-and-security.md create mode 100644 docs/answers/04-container-lifecycle.md create mode 100644 docs/answers/05-delivery-and-system-actions.md create mode 100644 docs/answers/06-data-model.md create mode 100644 docs/answers/07-provider-and-mcp.md create mode 100644 docs/answers/08-channel-adapters.md create mode 100644 docs/answers/README.md diff --git a/docs/answers/01-global-architecture.md b/docs/answers/01-global-architecture.md new file mode 100644 index 0000000..367c8ed --- /dev/null +++ b/docs/answers/01-global-architecture.md @@ -0,0 +1,139 @@ +# Q1-Q3: 全局架构 + +--- + +## Q1: 一条用户消息从 Slack 发出,到 agent 回复出现在聊天框里,完整路径是什么? + +### 答案 + +一条消息的完整生命周期横跨四个阶段、两个进程、三个数据库。 + +**阶段一:入站路由(Host / Node)** + +1. Slack channel adapter 收到消息事件,调用 `routeInbound(event)`(`src/router.ts:158`) +2. 应用线程策略(非线程适配器折叠线程,line 166) +3. `getMessagingGroupWithAgentCount()`(`src/db/messaging-groups.ts:53`)通过一次 JOIN 查询中央库 `v2.db`,同时获得 messaging group 行和 wired agent 数量。自动创建规则:只有 @mention 时才自动创建 messaging group(`router.ts:184-201`),普通消息静默丢弃 +4. 如果 agent 数量为 0:记录到 `unregistered_senders` 表,reason 为 `no_agent_wired`(`router.ts:210-246`,写入 `src/db/dropped-messages.ts:16`) +5. Sender resolver hook 执行:upsert 用户行到中央库 `users` 表(`router.ts:252`) +6. `getMessagingGroupAgents()` 从中央库取出所有 wired agent 行,按 `priority DESC` 排序(`messaging-groups.ts:193-196`) +7. 对每个 agent 独立评估:`evaluateEngage()` 检查 trigger 模式(`router.ts:364`),access gate 检查权限(line 283),通过则执行 `deliverToAgent()`(line 287) +8. `resolveSession()`(`session-manager.ts:92`)在中央库 `sessions` 表查找/创建 session;必要时创建目录结构并初始化 `inbound.db` + `outbound.db` +9. `writeSessionMessage()`(`session-manager.ts:193`)写消息到 `inbound.db` → `messages_in` 表,使用**偶数 seq**;每次 open-write-close +10. `wakeContainer()`(`container-runner.ts:85`)检查已运行容器、去重、spawn Docker 容器 + +**阶段二:容器处理(Container / Bun)** + +11. `container/agent-runner/src/poll-loop.ts:53` — `runPollLoop()`: + - `getPendingMessages()`(`messages-in.ts:65`)只读打开 `inbound.db`,读 `status='pending'` 行 + - `markProcessing()`(line 101)写 `processing_ack` 到 `outbound.db` + - 格式化消息,调 `provider.query()`(line 170) + - 流式过程中每 500ms poll 后续消息,调用 `provider.push()` + - `dispatchResultText()` 解析 `` XML,调 `sendToDestination()` → `writeMessageOut()` 写 `outbound.db` → `messages_out`,使用**奇数 seq** + - `markCompleted()` 写 `outbound.db` → `processing_ack` + - `touchHeartbeat()` 更新 `.heartbeat` 文件的 mtime + +**阶段三:出站投递(Host / Node)** + +12. `src/delivery.ts` 两层轮询: + - Active poll(1s):`pollActive()` 仅扫描正在运行容器的 session + - Sweep poll(60s):`pollSweep()` 扫描所有 active session + - `inflightDeliveries` Set 防止二者竞态(line 50-51) +13. `drainSession()`:只读打开 `outbound.db`,读写打开 `inbound.db` + - `getDueOutboundMessages()` 读 `outbound.db` → `messages_out` + - 与 `inbound.db` → `delivered` 表做去重比对 + - `deliverMessage()` 调 `deliveryAdapter.deliver()` 发到 Slack + - `markDelivered()` 写 `inbound.db` → `delivered` 表 + - 清理 `outbox/` 目录 + +### 每个阶段涉及的数据库 + +| 阶段 | DB | 表 | Writer | Reader | +|------|-----|-----|--------|--------| +| 路由查找 | `v2.db` | `messaging_groups`, `messaging_group_agents` | Host | Host | +| 发送者解析 | `v2.db` | `users` | Host | Host | +| Session 解析 | `v2.db` | `sessions` | Host | Host | +| 写入站消息 | `inbound.db` | `messages_in` | Host | — | +| Container poll | `inbound.db` | `messages_in` | — | Container (RO) | +| 声明处理中 | `outbound.db` | `processing_ack` | Container | — | +| 写回复 | `outbound.db` | `messages_out` | Container | — | +| 读回复 | `outbound.db` | `messages_out` | — | Host (RO) | +| 跟踪投递 | `inbound.db` | `delivered` | Host | — | +| 丢弃记录 | `v2.db` | `unregistered_senders` | Host | — | + +### 涉及进程 + +- **Node host 进程**:单进程,运行 `src/index.ts`,同时跑 router + delivery + host-sweep +- **Docker 容器**:每个活跃 session 一个独立容器,运行 `bun run /app/src/index.ts` + +### 边界情况 + +- **Messaging group 不存在**:仅 @mention 时自动创建,普通聊天静默返回 +- **频道被 owner 拒绝**(`mg.denied_at` 已设置):静默丢弃 +- **没有 agent 响应(engage)**:记录到 `unregistered_senders`,reason 为 `no_agent_engaged` +- **投递失败**:重试最多 3 次,然后标记永久失败 +- **`accumulate` 模式**:不 engage 但 `ignored_message_policy='accumulate'` 的消息写入 `trigger=0`,静默存为上下文 + +--- + +## Q2: 为什么 Host 用 Node,Container 用 Bun?两套运行时之间的"协议"是什么? + +### 答案 + +**为什么 Host 用 Node:** + +- Baileys(WhatsApp adapter)依赖 `libsignal-node` 原生绑定和一个久经考验的 WebSocket/HTTP 栈。Bun 的 Node-API 兼容性已有改善,但风险仍然太高 +- Host 负责所有平台级网络 I/O,Node 的生态系统在这个领域最成熟 + +**为什么 Container 用 Bun:** + +1. `bun:sqlite` 是内建的——不需要每次重建镜像时编译 `better-sqlite3` 原生模块 +2. TypeScript 源码直接运行——镜像构建和容器启动时不需要 `tsc` 编译步骤 +3. `bun install` 比 `npm install` 快 5-10 倍 + +**两套运行时的"协议"——SQLite,不是 IPC:** + +Host 和 Container **从不共享代码或模块**(各有自己的包树:`pnpm-lock.yaml` vs `container/agent-runner/bun.lock`)。它们之间的"协议"是两个 SQLite 文件: + +``` +Host ──写──▶ inbound.db ──读──▶ Container (Bun, 只读) +Host ◀──读── outbound.db ◀──写── Container (Bun) +``` + +没有 HTTP、没有 Unix socket、没有 stdin pipe。Container 的 `poll-loop.ts` 以 `{readonly: true}` 打开 `inbound.db`(`container/agent-runner/src/db/connection.ts:53`)并轮询新行。Host 以 `{readonly: true}` 打开 `outbound.db`(`src/db/session-db.ts:30`)并轮询新行。 + +Host 在 spawn 容器时把 session 文件夹(`inbound.db`、`outbound.db`、`.heartbeat`)挂载到 `/workspace`,agent group 文件夹挂载到 `/workspace/agent`(`src/container-runner.ts:267-271`)。 + +--- + +## Q3: `inbound.db` 和 `outbound.db` 为什么各只能有一个 writer?`journal_mode=DELETE` 为什么是必须的?seq 奇偶分配的规则是怎样的? + +### 答案 + +**为什么每文件只有一个 writer:** + +`src/session-manager.ts:7-11` 声明了三条跨 mount 不变式: + +1. `journal_mode=DELETE` — WAL 模式的 `-shm` 是内存映射的,VirtioFS 不传播 mmap 一致性。Container 会卡在第一次读到的快照上,永远看不到新消息 +2. Host 每次 open-write-close ——关闭连接会使 Container 的页缓存失效;长连接会冻结在第一次读取时的视图 +3. 每文件一个 writer —— DELETE 模式的 journal 文件解链/重建在跨 mount 边界上不是原子的,并发 writer 会损坏数据库 + +**`journal_mode=DELETE` 为什么负载关键:** + +`container/agent-runner/src/db/connection.ts:12-18` 说明:WAL 的 `-shm` 内存映射不会被 VirtioFS 从 host 传播到 guest,所以 WAL 模式的 `inbound.db` 会让 container reader 卡在早期快照上,永远看不到新的 host 消息。 + +Host 和 Container 两边都在每次打开 DB 时设置 `PRAGMA journal_mode = DELETE`(`session-db.ts:15,23,38` / `connection.ts:78`)。Container 还额外设置 `mmap_size = 0`(`connection.ts:55`)禁用内存映射 I/O,确保每次读都走内核无缓冲路径。 + +**Host open-write-close 每次操作:** + +`src/session-manager.ts:189-192` 明确说明不要在多次调用间复用长连接——每次打开、写入、关闭使 SQLite 页缓存失效,Container 才能看到最新写入。Container 对 `getPendingMessages()` 也使用 `openInboundDb()`(每次新连接),但用长期单例 `getInboundDb()` 读 host 只在 spawn 时写入一次的表(destinations、session_routing)。 + +**seq 奇偶分配规则:** + +- **Host(偶数):** `src/db/session-db.ts:89` — `nextEvenSeq()` 只读 `messages_in` 的 `MAX(seq)`,输出:0→2, 1→2, 2→4, 3→4, 4→6... +- **Container(奇数):** `container/agent-runner/src/db/messages-out.ts:45-56` — 读 `messages_in` 和 `messages_out` 两者的 `MAX(seq)`,取较大者,向上取奇数:0→1, 1→3, 2→3, 3→5... + +**为什么奇偶分配如此关键:** seq 是 agent 面对的消息 ID。当 agent 调用 `edit_message(seq=5)` 时,`getMessageIdBySeq()`(`messages-out.ts:90`)用奇偶性做快速路由: +- **奇数 → `messages_out`**(container 发出的) +- **偶数 → `messages_in`**(user/host 发出的) + +奇偶性单字段即可区分消息归属,无需 JOIN 查询。 diff --git a/docs/answers/02-routing-and-sessions.md b/docs/answers/02-routing-and-sessions.md new file mode 100644 index 0000000..c901596 --- /dev/null +++ b/docs/answers/02-routing-and-sessions.md @@ -0,0 +1,173 @@ +# Q4-Q6: 路由与会话 + +--- + +## Q4: 一个 messaging group 怎么决定路由到哪个 agent group?没匹配上怎么办? + +### 答案 + +路由发生在 `src/router.ts:158` 的 `routeInbound()` 函数中,分多步决策: + +**第一步:快速截断** — `getMessagingGroupWithAgentCount(channelType, platformId)`(line 176)一次数据库读就能判断频道是否已知、是否有 agent。如果没有匹配的 messaging group 且没有被 @mention,直接静默返回(line 211)。 + +**第二步:记录无法投递的消息** — 如果 agentCount 为 0 但被 @mention 了(或频道已通过审批但没 agent): +- 如果 `mg.denied_at` 被设置(owner 拒绝了这个频道)→ 静默丢弃(line 212-217) +- 否则 → 记录到 `unregistered_senders` 表,reason 为 `no_agent_wired`(line 221) +- 可选触发 `channelRequestGate` 升级给 owner(line 231-238) + +**第三步:Fan-out** — 如果 agentCount > 0,`getMessagingGroupAgents()`(line 256)取出所有 wired agent 行,按 `priority DESC` 排序。 + +**第四步:逐个评估 engage 条件**(`evaluateEngage()`,line 364): + +| 模式 | 行为 | +|------|------| +| `pattern` | 对消息文本做正则匹配;`'.'` 匹配所有消息 | +| `mention` | bot 必须被 @mention | +| `mention-sticky` | @mention **或者** 此 (agent, mg, thread) 组合已存在活跃 session(line 384-390) | + +**第五步:access gate + senderScope gate**(line 283-284)做模块级策略拒绝。 + +**第六步:交付决策:** +- **engages + 通过 gate** → `deliverToAgent()` 且 `wake=true` +- **不 engage 但 `ignored_message_policy='accumulate'`** → `deliverToAgent()` 且 `wake=false`,写入 `trigger=0` 作为静默上下文 +- **都不满足** → 丢弃,记录 `no_agent_engaged` + +### `unregistered_senders` 表的作用 + +尽管模块名叫 `dropped-messages.ts`,实际表名是 `unregistered_senders`(`src/db/dropped-messages.ts:16-38`)。它是核心审计基础设施: +- 记录结构性丢弃(no agent wired / no engagement)和策略拒绝(access gate 拒绝) +- 用 `ON CONFLICT DO UPDATE` 做 per-channel 聚合,统计 `message_count`、更新 `last_seen` +- 可通过 `ncl dropped-messages list` 只读查看 +- `reason` 字段记录丢弃原因 + +### 边界情况 + +- **多个 agent wired 到同一频道**:每个 agent 独立评估,一个频道消息可以唤醒多个 agent 容器 +- **mention-sticky + 线程平台**:`adapter.subscribe()` 被调用一次(line 306),订阅线程以便后续消息不需重新 @mention +- **pattern 模式的正则错误**:`evaluateEngage()` 捕获正则错误并 fail-open(line 378),让 admin 看到 agent 响应并可以修复 +- **access gate 拒绝 + accumulate**:消息不累积(line 310-316),这是安全决策——静默存储越权消息会破坏 gate 的目的 + +--- + +## Q5: Session 什么时候创建?三种隔离模式的复用逻辑有什么不同? + +### 答案 + +Session **懒创建**——只在第一条匹配 (agent group, messaging group, thread) 的消息到达时创建。创建由 `deliverToAgent()` 调用 `resolveSession()` 触发(`src/router.ts:415`)。 + +三种模式实现于 `src/session-manager.ts:92-133`: + +### `agent-shared`(完全不隔离) + +```sql +SELECT * FROM sessions WHERE agent_group_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1 +``` +(`src/db/sessions.ts:56-59`) + +- 完全忽略 `messagingGroupId` 和 `threadId`,所有 wired 的 messaging group 共用一个 session +- GitHub PR 评论和 Slack 消息出现在同一个对话中 +- 如果没有 session,创建一个且 `messaging_group_id = null` + +### `shared`(per-channel) + +```sql +SELECT * FROM sessions WHERE agent_group_id = ? AND messaging_group_id = ? AND thread_id IS NULL AND status = 'active' +``` +(`src/db/sessions.ts:36-53`) + +- 折叠所有线程——同一 messaging group 内使用一个 session +- 每个 messaging group 有自己的 session,但共享 agent group workspace + +### `per-thread`(最严格隔离) + +```sql +SELECT * FROM sessions WHERE agent_group_id = ? AND messaging_group_id = ? AND thread_id = ? AND status = 'active' +``` + +- 每个 Slack thread / Discord thread 一个独立 session +- 支持线程的 adapter(Slack、Discord)会把 `session_mode: 'shared'` 的 wiring 强制重写为 `per-thread`(`router.ts:410-413`),除非 wiring 明确使用 `agent-shared` +- DM 除外(`mg.is_group === 0`),不做强制重写 + +### Session 创建的副作用 + +首次创建时(`created: true`,line 128-131): +1. `createSession()` 写中央库 `sessions` 表 +2. `initSessionFolder()`(line 136-143)在 `data/v2-sessions///` 下创建目录,初始化 `inbound.db` + `outbound.db` 的 schema + +### 边界情况 + +- **Fan-out 到多个 agent**:每个 agent 有自己的 session。`resolveSession()` 按 `agent_group_id` 限定查询范围 +- **容器重启后 session 复用**:session 在中央库持久存在。同一 (agent, mg, thread) 的新消息触发 `resolveSession()`,找到已有 session 返回 `created: false` + +--- + +## Q6: 容器 idle 后被 kill,用户再发消息怎么被唤醒?`on_wake` 为什么不会被旧容器偷走? + +### 答案 + +**唤醒路径有两个:** + +1. **路由时立即唤醒:** `wakeContainer(session)` 在 `src/router.ts:478` 被调用(仅对 `wake=true` 的消息)→ `container-runner.ts:85`: + - 检查 `activeContainers.has(session.id)` — 已在运行则直接返回 + - 检查 `wakePromises.get(session.id)` — 去重并发唤醒(line 90-94) + - 调用 `spawnContainer()`,重新构建所有 mount、组合 CLAUDE.md、生成 container.json,spawn Docker 容器 + +2. **Host sweep 兜底唤醒:** `src/host-sweep.ts:180-186`: + ``` + dueCount = countDueMessages(inDb) + if dueCount > 0 && !isContainerRunning(session.id) → wakeContainer(session) + ``` + 处理 `wakeContainer()` 返回 `false`(瞬时失败)的情况——消息保持 pending,下次 60s sweep 周期重试。 + +### `on_wake` 防竞态机制 + +**第一步:`on_wake` 列** — `docs/db-session.md:51`:`messages_in` 表有 `on_wake INTEGER NOT NULL DEFAULT 0` 列。当 `restartAgentGroupContainers()` 被调用且带有 `wakeMessage` 时(`container-restart.ts:28-41`),写入 `onWake: 1`。 + +**第二步:Container 端的 `isFirstPoll` 过滤器** — `container/agent-runner/src/db/messages-in.ts:65-97`: +```sql +AND (on_wake = 0 OR ?1 = 1) +``` +- `isFirstPoll` 仅在容器第一次 poll 时为 `true`(`poll-loop.ts:71-74`) +- `isFirstPoll = true` 时:过滤条件变为 `on_wake = 0 OR true` → 所有 pending 消息都可见 +- `isFirstPoll = false` 时:过滤条件变为 `on_wake = 0 OR false` → `on_wake=1` 的行不可见 +- **正在被 kill 的旧容器已过第一轮 poll**,即使它撑到下一轮 poll,也看不到 `on_wake=1` 的消息 + +### `killContainer` 的 `onExit` 回调机制 + +`src/container-runner.ts:193-207`: +```typescript +export function killContainer(sessionId, reason, onExit?) { + const entry = activeContainers.get(sessionId); + if (onExit) entry.process.once('close', onExit); // 先注册回调 + stopContainer(entry.containerName); // 再发送 kill 信号 +} +``` + +`container-restart.ts:47-51` 中的 `onExit` 回调: +```typescript +() => { + const s = getSession(session.id); + if (s) wakeContainer(s); +} +``` + +这个设计保证: +1. 旧容器**完全退出**(进程终止,mount 释放)后才触发新容器 spawn +2. 新容器的 `clearStaleProcessingAcks()`(`connection.ts:175`)清掉旧容器遗留的 `processing` 声明 +3. 新容器第一轮 poll 时 `isFirstPoll=true`,捡起 `on_wake=1` 消息 + +### 容器从 idle 到 kill 到重新唤醒的生命周期 + +Host sweep 在 `host-sweep.ts:147-211` 中判断容器健康: + +1. **天花板检查**(line 99-105):heartbeat mtime 年龄 > max(30min, Bash 超时) → kill +2. **per-claim 卡住检查**(line 107-115):每个 `processing_ack` 行,如果 claim 时间 > max(60s, Bash 超时) 且从此以后 heartbeat mtime 没有前进 → kill +3. **崩溃容器清理**(line 199-201):容器已死但 `processing_ack` 仍有遗留 → `resetStuckProcessingRows()` 用指数退避(基数 5s × 2^tries,最多 5 次)重新调度消息 +4. **孤儿 claim 清理**(line 319):kill 后 `deleteOrphanProcessingClaims()` 删掉 `outbound.db` 中的 processing 行,防止 sweep 立即 kill 新 spawn 的容器 + +### 边界情况 + +- **新容器还没有 heartbeat**:天花板检查在 `heartbeatMtimeMs === 0` 时跳过(line 92-93) +- **`wakePromises` 去重**:spawn 过程中来多条消息,只 spawn 一个容器 +- **Spawn 前清 heartbeat 文件**:`container-runner.ts:155` `fs.rmSync(heartbeatPath(...), {force: true})` 清除孤儿 heartbeat +- **Bash 自定义超时**:天花板和 claim-stuck 容忍度都扩展到 `max(DEFAULT, declaredTimeoutMs)`,确保长时间 Bash 工具不被误杀 diff --git a/docs/answers/03-permissions-and-security.md b/docs/answers/03-permissions-and-security.md new file mode 100644 index 0000000..f412c49 --- /dev/null +++ b/docs/answers/03-permissions-and-security.md @@ -0,0 +1,166 @@ +# Q7-Q9: 权限与安全 + +--- + +## Q7: 用户身份怎么确定?owner / admin / member 三级权限检查在哪里完成? + +### 答案 + +**用户身份确定(两步):** + +`src/modules/permissions/index.ts:67-103` — `extractAndUpsertUser` 函数: + +1. **解析原始 handle:** 从入站消息 JSON payload 的三处位置按优先级查找:`senderId`、`sender`、`author.userId`(chat-sdk-bridge 的嵌套格式)。三处都没有返回 `null` +2. **命名空间标准化:** 如果原始 handle 已含 `:` 前缀(如 `slack:U0ABC`),直接使用;否则前缀 `channelType:` → 全局唯一 `userId` +3. **Upsert:** `users` 表中不存在则创建(`db/users.ts:13-22`),`kind` 记录 channel 类型,`display_name` 从消息中提取 + +这个 resolver 通过 `setSenderResolver(extractAndUpsertUser)` 注册到 router(`index.ts:171`),在 agent 解析**之前**执行(`router.ts:252`),确保即使后续被拒绝访问,`users` 行也已存在。 + +**三级权限表:** + +| 表 | 用途 | 关键字段 | +|-----|------|---------| +| `users` | 用户身份 | `id`(命名空间化的userId),`kind`,`display_name` | +| `user_roles` | 特权角色 | `user_id`,`role`(owner/admin),`agent_group_id`(NULL=全局) | +| `agent_group_members` | 非特权成员 | `user_id`,`agent_group_id` | + +**`canAccessAgentGroup` 完整逻辑:** + +`src/modules/permissions/access.ts:21-28` — 五步短路求值: + +```typescript +function canAccessAgentGroup(userId: string, agentGroupId: string): AccessDecision { + if (!getUser(userId)) return { allowed: false, reason: 'unknown_user' }; // Gate 0 + if (isOwner(userId)) return { allowed: true, reason: 'owner' }; // Gate 1 + if (isGlobalAdmin(userId)) return { allowed: true, reason: 'global_admin' };// Gate 2 + if (isAdminOfAgentGroup(userId, agentGroupId)) return { allowed: true, reason: 'admin_of_group' };// Gate 3 + if (isMember(userId, agentGroupId)) return { allowed: true, reason: 'member' };// Gate 4 + return { allowed: false, reason: 'not_member' }; +} +``` + +**查库函数**(`db/user-roles.ts`): +- `isOwner`:`role='owner' AND agent_group_id IS NULL`(line 36-41)—— owner 必须全局 +- `isGlobalAdmin`:`role='admin' AND agent_group_id IS NULL`(line 43-48) +- `isAdminOfAgentGroup`:`role='admin' AND agent_group_id=`(line 50-55) + +**`isMember` 的隐式 admin 语义**(`db/agent-group-members.ts:28-36`): +先检查 `isOwner || isGlobalAdmin || isAdminOfAgentGroup` 再查 `agent_group_members` 表——owner 和 admin **自动成为 member**,不需要额外插入行。 + +--- + +## Q8: 陌生人在群里 @bot,系统怎么决定忽略、审批、还是直接响应? + +### 答案 + +**`unknown_sender_policy` 三态**(`src/types.ts:31`,默认 `'strict'`): + +| 值 | 行为 | +|-----|------| +| `public` | 完全跳过访问检查。`router.ts` access gate 在 `index.ts:175` 直接返回 `{ allowed: true }` | +| `strict` | 非 member 的消息静默丢弃,记录到 `unregistered_senders`,不发送通知 | +| `request_approval` | 非 member 的消息被丢弃 + 发起一轮审批流程:DM 一个 admin/owner,展示 Approve/Deny 卡片 | + +注意:即使 `unknown_sender_policy='public'`,`sender_scope='known'` 仍可作为更严格的叠加层(`router.ts:284` `senderScopeGate`)。 + +**完整决策链路**(`src/router.ts` `routeInbound`): + +1. Message interceptor 先检查拦截器 hook 是否消费了该消息 +2. Messaging group 解析 → sender resolver → agent fan-out +3. **Access gate**(line 283):就是 `canAccessAgentGroup` —— 此处 `unknown_sender_policy` 生效 +4. **Sender scope gate**(line 284):per-wiring 的 `sender_scope='known'` 额外检查 +5. 如果 access gate 拒绝 → `handleUnknownSender` + +### sender-approval 完整流程 + +**Phase 1: 触发与去重**(`permissions/index.ts:113-169`): +- 记录 `dropped_message`,reason=`unknown_sender_request_approval` +- 调用 `requestSenderApproval` +- **去重门控**(`sender-approval.ts:59-65`):检查 `pending_sender_approvals` 表中的 `UNIQUE(messaging_group_id, sender_identity)` —— 同一个人已有 pending 卡片时,后续消息静默丢弃 + +**Phase 2: 选择审批者**(`sender-approval.ts:67-87`): +- `pickApprover(agentGroupId)` → scoped admins → global admins → owners(`primitive.ts:76-93`) +- `pickApprovalDelivery`(`primitive.ts:103-119`):遍历审批者,调用 `ensureUserDm` 找到可 DM 的人,优先同 channel 类型 + +**Phase 3: 创建 pending + 发送卡片**: +- 标题和内容:`"New sender / wants to talk to your agent. Allow?"` +- `ask_question` 卡片,通过 `deliveryAdapter.deliver` 发到审批者 DM + +**Phase 4: 审批响应**(`index.ts:225-283`): + +**APPROVE:** +1. **授权验证**(line 234-244):检查点击者是不是 `approver_user_id` **或**有 admin 权限——防止随机用户通过转发卡片自我授权 +2. `addMember` 添加到 `agent_group_members`(line 249-254) +3. **先删除 pending 行**(line 264),**再**重新调用 `routeInbound(event)`(line 268)——此时重试经过 access gate,user 已是 member + +**DENY:** +- 仅删除 `pending_sender_approvals` 行 +- **不创建拒收列表**——同一发送者的新消息会重新触发新审批卡片 + +### 失败模式 + +- 没有 owner/admin → 消息永久丢弃 +- 没有任何审批者可达(无 DM channel)→ 同上 +- 没有 delivery adapter → pending 行仍创建(可手动查看),但卡片不发送 +- 卡片发送异常 → error log,消息已丢弃 + +--- + +## Q9: Agent 在容器里能用 `ncl` 命令吗?能查其他 agent group 的数据吗?`cli_scope` 的三个值在哪里被检查? + +### 答案 + +**`cli_scope` 的三个值**(`container_configs` 表,迁移 015 添加,默认 `'group'`): + +| 值 | 含义 | +|-----|------| +| `disabled` | Agent 完全不知道 ncl 存在;host 拒绝所有 CLI 请求 | +| `group`(默认) | Agent 只能操作自己的 group(4个资源),args 被自动绑定到自己 group;无法查看其他 group 数据 | +| `global` | 无限制。仅通过 `init-first-agent` 脚本为 owner 的 agent group 设置 | + +### 四层防御 + +**防御层 1:CLAUDE.md 层面** — `src/claude-md-compose.ts:82-90` +`cli_scope === 'disabled'` 时,`cli.instructions.md` 从 CLAUDE.md 的 fragment 列表中排除。Agent 学不到 ncl 命令的存在。 + +**防御层 2:Host dispatch 核心强制** — `src/cli/dispatch.ts` + +- **disabled 检查**(line 46-48):所有 CLI 请求立即拒绝 +- **资源白名单**(line 51-55):`group` 只允许 `groups`、`sessions`、`destinations`、`members` 四种资源 +- **参数强制绑定**(line 60-72):检查 `agent_group_id`、`group`、`--id` 参数,不匹配调用者的 group 则拒绝 +- **特权升级阻止**(line 74-77):拒绝任何包含 `cli_scope` 或 `cli-scope` 参数的命令 +- **自动填充**(line 81-90):主动填充 `agent_group_id`、`group`、`--id` 为调用者的 group ID——agent 不需要也不能指定 +- **sessions-get 防 existence oracle**(line 95-100):如果传入 session UUID 属于其他 group,返回 "not found" 而非 "forbidden" +- **Post-handler 过滤**(line 150-173):对 `list` 返回的 rows 按 `scopeField` 再次过滤,丢弃不属于调用者 group 的行 + +各资源的 `scopeField` 映射(`src/cli/resources/`): +- `groups` → `'id'`(`groups.ts:41`) +- `sessions` → `'agent_group_id'`(`sessions.ts:10`) +- `destinations` → `'agent_group_id'`(`destinations.ts:11`) +- `members` → `'agent_group_id'`(`members.ts:11`) + +**防御层 3:Container 端** — `container/agent-runner/src/cli/ncl.ts` +Container 内的 ncl **不做任何权限检查**——它是纯粹的 DB transport 客户端。将 CLI 请求写入 `outbound.db`,从 `inbound.db` poll 响应。权限检查全在 host 端。Container 无法绕过,因为它只能访问 session DB,不能直接连中央库或 socket server。 + +**防御层 4:Command Gate(斜杠命令)** — `src/command-gate.ts:23-63` + +独立于 `cli_scope`,在消息写入 `messages_in` 之前运行(`router.ts:430-448`): +- **Filtered commands**(`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/remote-control`)→ 静默丢弃永不进容器 +- **Admin commands**(`/clear`, `/compact`, `/context`, `/cost`, `/files`)→ 仅 owner / admin 可通过。否则直接写 `messages_out` 返回权限拒绝 +- **降级安全**(line 51):`user_roles` 表不存在 → 所有 admin 命令放行 + +### 跨层防御矩阵总结 + +| 防御层 | disabled | group | global | +|--------|----------|-------|--------| +| CLAUDE.md 隐藏 ncl | ✅ 排除 | ❌ 包含 | ❌ 包含 | +| Host dispatch 拒绝所有 | ✅ 拒绝 | ❌ | ❌ | +| 资源白名单 | N/A | ✅ 4种 | ❌ 无限制 | +| 参数强制绑定 | N/A | ✅ 自动填+拒绝跨界 | ❌ | +| `cli_scope` 自我修改阻止 | N/A | ✅ 拒绝 | ❌ | +| Post-handler rows 过滤 | N/A | ✅ scopeField | ❌ | +| Sessions oracle 防护 | N/A | ✅ fail-closed | ❌ | +| Command gate 斜杠命令 | ✅ | ✅ | ✅ | +| Approval gating(非host) | N/A | ✅ | ✅ | + +**关键 gotcha:** group-scoped agent 可以通过 `ncl groups config get` 读到自己的完整 config(包括 `cli_scope` 字段),所以知道自己在 `group` 范围内。但由于 `cli_scope` 参数被第 74-77 行硬阻止,不能修改自己的 scope。 diff --git a/docs/answers/04-container-lifecycle.md b/docs/answers/04-container-lifecycle.md new file mode 100644 index 0000000..94e4a16 --- /dev/null +++ b/docs/answers/04-container-lifecycle.md @@ -0,0 +1,142 @@ +# Q10-Q12: 容器生命周期 + +--- + +## Q10: 启动 agent 容器时 mount 了哪些东西? + +### 答案 + +Container mount 由 `src/container-runner.ts:242-335` 的 `buildMounts()` 构建。容器内文件系统结构: + +| 容器路径 | 宿主机来源 | 权限 | 用途 | +|----------|-----------|------|------| +| `/workspace/` | `data/v2-sessions///` | RW | Session 目录:`inbound.db`、`outbound.db`、`.heartbeat`、`outbox/`、`inbox/` | +| `/workspace/agent/` | `groups//` | RW | Per-group 工作文件 + `CLAUDE.local.md` | +| `/workspace/agent/container.json` | `groups//container.json` | **RO** | 嵌套 RO 覆盖——agent 可读不能改(line 276-278) | +| `/workspace/agent/CLAUDE.md` | `groups//CLAUDE.md` | **RO** | 组合的 CLAUDE.md,spawn 时重新生成(line 287-290) | +| `/workspace/agent/.claude-fragments/` | `groups//.claude-fragments/` | **RO** | per-skill/per-MCP 指令片段 | +| `/workspace/global/` | `groups/global/` | **RO** | 共享全局记忆 | +| `/app/CLAUDE.md` | `container/CLAUDE.md` | **RO** | 共享基础 CLAUDE.md,通过 `.claude-shared.md` symlink 导入 | +| `/home/node/.claude/` | `data/v2-sessions//.claude-shared/` | RW | Claude SDK 状态、`settings.json`、skill symlinks | +| `/app/src/` | `container/agent-runner/src/` | **RO** | 共享 agent-runner TypeScript 源码 | +| `/app/skills/` | `container/skills/` | **RO** | 共享容器技能 | +| 额外 | `containerConfig.additionalMounts` | → | Provider-contributed mounts(line 330) | + +### 调用链 + +1. `spawnContainer()`(line 108)→ `buildMounts()`(line 134) +2. Pre-mount 初始化: + - `initGroupFilesystem(agentGroup)`(line 253):幂等创建 `groups//`、`CLAUDE.local.md`、`.claude-shared/` 目录和 DB 行 + - `syncSkillSymlinks()`(line 257):根据 `container.json` 的 `skills` 选择,在 `.claude-shared/skills/` 下创建 symlink + - `composeGroupClaudeMd(agentGroup)`(line 261):重新生成组合 CLAUDE.md +3. Mount 按顺序组装(line 267-333) +4. 所有 volume mount 进入 `buildContainerArgs()`(line 447-453) + +### 边界情况 + +- `CLAUDE.md` 和 `.claude-fragments/` 是嵌套 RO mount,叠加在 RW group 目录上——agent 只能写 `CLAUDE.local.md` +- `container.json` 单独 RO mount 防止 agent 修改自己的配置 +- Skill symlinks 指向容器内路径(`/app/skills/`),在宿主机上是悬空符号链接,容器内有效 + +--- + +## Q11: Agent 的 system prompt 是怎么拼出来的? + +### 答案 + +Agent 的 system prompt 由三部分拼成:**(A)** 共享基础 `CLAUDE.md`,**(B)** per-skill/per-MCP 指令片段,**(C)** 运行时 addendum(身份 + destinations)。 + +### Host 端组合(spawn 时) + +`src/claude-md-compose.ts:43-136` → `composeGroupClaudeMd()`: + +1. **共享基础 symlink**(line 49-50):`groups//.claude-shared.md` → `/app/CLAUDE.md`(21 行通用 agent 指令:交流风格、workspace、memory、conversation history) + +2. **Fragment 发现**(line 58-107): + - **Skill fragments**(line 66-76):`container/skills/` 下任何有 `instructions.md` 的技能 + - **内置模块 fragments**(line 83-96):`container/agent-runner/src/mcp-tools/` 下的 `.instructions.md`。**`cli.instructions.md` 在 `cli_scope='disabled'` 时被跳过** + - **MCP server fragments**(line 100-107):`container.json` 中外部 MCP server 的 `instructions` 字段,生成内联 fragment 文件 + +3. **Fragment 协调**(line 110-122):删除不再需要的过期 fragment,创建/更新 symlink + +4. **组合入口**(line 125-130):写出 `groups//CLAUDE.md`,只含 import 指令: + ``` + @./.claude-shared.md + @./.claude-fragments/skill-onecli-gateway.md + @./.claude-fragments/skill-welcome.md + @./.claude-fragments/module-cli.md + ``` + Claude Code 跟随 `@` import 解决所有 fragment + +5. **Per-group 记忆**(line 132-135):确保 `CLAUDE.local.md` 存在——这是唯一可写的 CLAUDE.md 文件 + +### Container 端运行时 addendum + +`container/agent-runner/src/destinations.ts:82-92` → `buildSystemPromptAddendum()`: + +- **身份**(line 85-87):如果设置了 `assistantName`:`"You are "` + 自我介绍和签名指引 +- **Destination map**(line 94-130):从 `inbound.db` 的 `destinations` 表读取,生成 "Sending messages" 部分 + +### 各部分贡献 + +| 来源 | 内容 | Agent 可修改? | +|------|------|---------------| +| `container/CLAUDE.md`(共享基础) | 通用 agent 行为 | 否(RO mount) | +| Skill `instructions.md` | Per-skill 指引 | 否(RO) | +| MCP tool `.instructions.md` | 如何使用内置工具 | 否(RO) | +| MCP server `instructions` | 外部 MCP server 指引 | 仅 admin(DB中) | +| `CLAUDE.local.md` | Per-group 记忆 | **是**(唯一可写) | +| `buildSystemPromptAddendum()` | 身份 + destination map | 生成时自动 | + +--- + +## Q12: 容器心跳怎么检测?进程活着但 poll loop 卡死了怎么发现? + +### 答案 + +心跳是**文件 touch 机制**,不是 DB 写入,避免跨 mount DB 写入争用。 + +### Container 端:心跳 touch + +- **Path**:`/workspace/.heartbeat`(`container/agent-runner/src/db/connection.ts:25`) +- **`touchHeartbeat()`**(`connection.ts:156-168`):用 `fs.utimesSync()` 更新文件 mtime。失败时回退 `fs.writeFileSync()` +- **触发时机**:`poll-loop.ts:361`,在 `for await (const event of query.events)` 循环中——每个 SDK event(`init`、`result`、`error`、`progress`)都触发。意味着**仅 agent 活跃流式回复时更新**,poll 轮空间歇不更新 + +### Host 端:卡住检测 + +Host sweep 每 60s 运行(`host-sweep.ts:61`),对每个有运行容器的 session 调用 `enforceRunningContainerSla()`(line 192 → line 228)。 + +**两种检测层级**(`decideStuckAction()`,line 82-118): + +**1. 绝对天花板**(line 91-105):heartbeat mtime 年龄 > `max(30 min, 当前Bash超时)` → `kill-ceiling` +- `ABSOLUTE_CEILING_MS = 30 * 60 * 1000` +- 扩展 `declaredBashMs`:从 `outbound.db` 读 `container_state`,如果当前工具是 Bash 且有 `tool_declared_timeout_ms`,天花板扩大到该值 +- **关键守护**(line 92-98):`heartbeatMtimeMs === 0`(刚 spawn,还没有 SDK activity)→ 跳过 + +**2. Claim-stuck per-message**(line 107-115):对每个 `processing_ack` 中的 `processing` 行,如果 `(claim_age > tolerance)` 且 `(heartbeat_mtime <= status_changed)` → `kill-claim` +- `CLAIM_STUCK_MS = 60s`:claim 一条消息后 60s 内没有任何 heartbeat → poll loop 卡住 +- 条件 `heartbeat_mtime <= claimedAt` 恰好检测 "claim 了消息后没有任何生命迹象" + +**3. 容器不在运行**(line 199-201):`!isContainerRunning` → `resetStuckProcessingRows()` 用指数退避(基数 5s × 2^tries,最多 5 次)重调度消息 + +### 处理器卡在 poll loop 的完整场景 + +``` +Container 进程存活,poll loop 卡住: + poll-loop.ts:101 → markProcessing(ids) → DB: status='processing', status_changed=NOW + poll-loop.ts:174 → config.provider.query(...) → 启动,但 SDK 挂起 + [没有 heartbeat touch,因为没有 event 触发] + ... + Host sweep(60s后): + → getProcessingClaims(outDb) → 发现 claim,status_changed 很旧 + → heartbeatMtimeMs() → 返回旧 mtime(挂起前最后一次 event 的) + → decideStuckAction(): claimAge > 60s, heartbeat_mtime <= claimedAt + → action: 'kill-claim' → killContainer() + resetStuckProcessingRows() +``` + +### 边界情况 + +- **新 spawn 宽容期**:heartbeat 文件不存在时跳过天花板检查,但 claim-stuck 检查仍然处理 "claim 了消息但在门口卡住" +- **每次 spawn 清 heartbeat**(`container-runner.ts:155`):防止旧容器的过期 mtime 立即触发 kill +- **孤儿 claim 清理**(line 319):kill 后 `deleteOrphanProcessingClaims()` 清掉 `outbound.db` 中的 processing 行,防止 sweep 立即 kill 新容器 +- **Bash 自定义超时**:扩展容忍度,确保长运行 Bash 不被误杀 diff --git a/docs/answers/05-delivery-and-system-actions.md b/docs/answers/05-delivery-and-system-actions.md new file mode 100644 index 0000000..f284454 --- /dev/null +++ b/docs/answers/05-delivery-and-system-actions.md @@ -0,0 +1,175 @@ +# Q13-Q15: 出站投递与系统动作 + +--- + +## Q13: Agent 回复消息后,delivery.ts 怎么知道用哪个 channel adapter 发送?重试和失败怎么处理? + +### 答案 + +Delivery 系统使用**两层轮询**:active poll(每 1s)扫描有运行容器的 session,sweep poll(每 60s)扫描所有 active session。从 `outbound.db`(container-owned)读取,在 `inbound.db` 的 `delivered` 表中跟踪投递状态。Channel adapter 是 boot 时设置的单个全局 `ChannelDeliveryAdapter`。 + +### Adapter 的选择 + +- `messages_out` 的每行带有 `channel_type` 和 `platform_id` 字段(container 的 `writeMessageOut()` 填入) +- `delivery.ts:356-363`:`deliveryAdapter.deliver(channelType, platformId, threadId, kind, content, files)` 被调用。Adapter 收到 `channelType` + `platformId`,负责路由到正确的平台 +- Adapter 通过 `setDeliveryAdapter()` 设置一次(line 95),是一个包装了所有 channel adapter 的 `ChannelDeliveryAdapter` + +### 完整投递流程 + +**1. Poll 触发:** `pollActive()`(1s,line 121-133)→ `getRunningSessions()`;`pollSweep()`(60s,line 136-149)→ `getActiveSessions()` + +**2. 防竞态**(line 151-162):`inflightDeliveries` 是 `Set` — 如果 active poll 和 sweep poll 竞态同个 session,第二个调用者跳过,防止重复投递 + +**3. Drain session**(`drainSession()`,line 164-232): +- 只读打开 `outbound.db`,读写打开 `inbound.db` +- `getDueOutboundMessages(outDb)` 读 `messages_out`(`deliver_after <= now`) +- `getDeliveredIds(inDb)` 做去重比对(line 183) +- 对每条未投递消息调用 `deliverMessage()`(line 192) + +**4. 消息路由**(`deliverMessage()`,line 234-375): +- **System actions**(line 255-258):`msg.kind === 'system'` → `handleSystemAction()` → 查找 `actionHandlers` Map。模块通过 `registerDeliveryAction()` 注册处理器 +- **Agent-to-agent**(line 264-271):`msg.channel_type === 'agent'` → `routeAgentMessage()` +- **Channel delivery**(line 289-375): + - **权限检查**(line 289-311):验证源 agent 是否有权发到目标 channel——要么目标是自己 session 的 origin(`session.messaging_group_id` 匹配),要么 `agent_destinations` 中有显式行 + - **Pending question 跟踪**(line 317-340):`ask_question` 类型创建 `pending_questions` 行 + - **文件附件**(line 348-354):从 session 的 `outbox//` 读文件 + - **Adapter call**(line 356-363):实际发送 + - **清理**(line 372):`clearOutbox()` 删除 outbox 目录 + +### 重试和失败处理 + +- 投递尝试在 `deliveryAttempts` Map 中以消息 ID 为 key 在内存中跟踪(进程重启时重置,给失败消息全新机会) +- 在 `MAX_DELIVERY_ATTEMPTS = 3` 次失败后,标记为 permanent failed(line 206-225) +- 重试是惰性的:消息留在 `messages_out` 表中未投递,下次 poll 迭代重新捡起 + +--- + +## Q14: Agent 发起 `install_packages` 或 `add_mcp_server` 的完整审批-执行链路是什么? + +### 答案 + +自我修改采用 **fire-and-forget** 模式 + admin 审批门控。Agent 调 MCP tool,tool 写 system message 到 `outbound.db`。Host delivery loop 拾起、验证/净化请求、排队审批,admin 批准后应用修改、重建镜像(如需要)、kill 容器、写 `on_wake` 消息给新容器。 + +### 完整链路(逐步) + +**Phase 1: Agent 请求(container 端)** + +1. Agent 调 `install_packages` 或 `add_mcp_server` MCP tool(`container/agent-runner/src/mcp-tools/self-mod.ts`) + - `install_packages`(line 53-78):用正则验证包名(`APT_RE`、`NPM_RE`,最多 20 个),写 `kind: 'system'` + `action: 'install_packages'` 的 outbound message + - `add_mcp_server`(line 97-117):验证 name 和 command 存在,写 `action: 'add_mcp_server'` + +2. 写 `outbound.db`:用 `writeMessageOut()`,写入**奇数 seq** + +**Phase 2: Host delivery 拾起** + +3. `delivery.ts` → `deliverMessage()` 看到 `kind === 'system'` → `handleSystemAction()` +4. `handleSystemAction()`(line 410-425):查 `actionHandlers` Map。self-mod 模块注册了 handler: + - `handleInstallPackages`(`self-mod/request.ts:20-64`) + - `handleAddMcpServer`(`self-mod/request.ts:66-91`) + +**Phase 3: 请求验证 + 审批排队** + +5. **Host 端验证**(深度防御第二层):对 package 名称再次验证(同一正则),失败时调 `notifyAgent()` 告知 agent,**不创建审批** + +6. **审批请求**(`approvals/primitive.ts:164-220`): + - `pickApprover(session.agent_group_id)` → scoped admins → global admins → owners + - `pickApprovalDelivery`:找到可 DM 的审批者,优先同 channel 类型 + - 用 `deliveryAdapter.deliver()` 发 `ask_question` 卡片到 admin DM + - 创建 `pending_approvals` 行,包含 `action`、`payload`(JSON)、`approval_id`、`session_id` + +**Phase 4: Admin 响应** + +7. `approvals/response-handler.ts:24-43` → `handleApprovalsResponse()`: + - **Reject**(line 72-77):调 `notify()` 告知 agent + - **Approve**(line 80-105):查 `getApprovalHandler(approval.action)` 注册的 handler,传入 `{ session, payload, userId, notify }` + +**Phase 5: 应用修改** + +8. **`install_packages` handler** — `self-mod/apply.ts:22-83`: + - 去重后追加新 apt/npm 包到 DB 中已有列表(line 37-49) + - `buildAgentGroupImage()`(line 57):构建 per-agent-group Docker 镜像(`container-runner.ts:468-515`),用 `docker build -t nanoclaw-agent:` 拉 900s 超时 + - 写 `on_wake: 1` 消息告知 agent + - **Kill 容器 with `onExit` callback**(line 72-75):`killContainer(sessionId, 'rebuild applied', () => { wakeContainer(s) })` —— 保证旧容器退出后新容器才 spawn + - 重建失败(line 77-82):通知 admin,不 kill 容器 + +9. **`add_mcp_server` handler** — `self-mod/apply.ts:85-125`: + - 添加 MCP server 到 DB 的 `mcp_servers` JSON(line 99-105) + - 写 `on_wake: 1` 消息(line 107-120) + - Kill 容器 with `onExit` → `wakeContainer` callback(line 121-124) + - **不需要重建镜像** —— Bun 直接运行 TS,纯 MCP wiring 变动不需要 Dоcker 构建 + +**Phase 6: 新容器启动** + +10. `onExit` callback 触发 → `wakeContainer()` → `spawnContainer()`: + - 从 DB 物化新 `container.json`(line 127) + - `composeGroupClaudeMd()` 重新生成 CLAUDE.md(line 261) + - `clearStaleProcessingAcks()` 清掉旧 processing ack(`connection.ts:175-177`) + - `getPendingMessages(isFirstPoll=true)` 捡起 `on_wake: 1` 消息 —— 仅第一轮 poll 可见 + +### 为什么 `on_wake` 是防竞态的 + +`messages_in` 表 `on_wake` 列 + `getPendingMessages()` 中 `isFirstPoll` 门控:第一轮 poll 包含 `on_wake = 1` 行,后续轮排除(它们已 `completed`)。结合 `killContainer` 的 `onExit` callback,旧容器绝无可能先于新容器偷走 on_wake 消息。 + +--- + +## Q15: 定时任务(cron)怎么实现? + +### 答案 + +定时任务实现为 **`messages_in` 表中 `kind='task'` 的行**,piggyback 在核心 schema 上,没有专用表。Agent 通过 MCP tool 创建任务,host 把它们写入 `inbound.db`,recurrence 由 host sweep hook 驱动:克隆已完成的周期性任务为新 pending 行。 + +### 创建任务 + +1. Agent 通过 `schedule_task` MCP tool(`container/agent-runner/src/mcp-tools/scheduling.ts`)写 `kind: 'system'` + `action: 'schedule_task'` 的 outbound message,包含 `taskId`、`prompt`、`processAfter`(首次运行 ISO 时间戳)、可选 `recurrence`(cron 表达式) + +2. Host delivery 拾起 → `handleSystemAction()` → 注册的 `action: 'schedule_task'` handler(`scheduling/actions.ts:19-40`): + - 调 `insertTask()`(`scheduling/db.ts:17-36`):插入 `messages_in` 行,`kind = 'task'`、`status = 'pending'`、`process_after = <首次运行时间>`、`recurrence = `、`series_id = ` + - 内容存储为 JSON `{ prompt, script }` + +3. Agent 也可以创建非周期性调度消息:`schedule_message` 工具同理,但 `kind` 匹配原始消息类型 + +### 触发:Host sweep + `countDueMessages` + +4. Host sweep 唤醒容器(`host-sweep.ts:180-186`): + - `countDueMessages(inDb)` 计数 `status = 'pending' AND process_after <= now` 的行 + - `dueCount > 0 && !isContainerRunning` → `wakeContainer(session)` + +5. Container 处理任务:`getPendingMessages()` 读 pending 行,包括 task 行,格式化后给 provider + +### Recurrence:host sweep + `handleRecurrence` + +6. Recurrence fanout(`scheduling/recurrence.ts:21-53`),每 60s sweep 周期调用(`host-sweep.ts:205-206`): + - `getCompletedRecurring(inDb)`(`scheduling/db.ts:122-126`):找 `status = 'completed' AND recurrence IS NOT NULL` 的行 + - 对每个完成的行: + - 在用户时区(非 UTC)解析 cron 表达式 + - 计算 `nextRun = interval.next().toISOString()` + - `insertRecurrence()`(`scheduling/db.ts:128-149`):复制原行,设置 `process_after = nextRun`、`status = 'pending'` + - `clearRecurrence()`(line 151-153):设原行 `recurrence = NULL`,防止下次周期重复克隆 + +### 其他任务生命周期操作 + +7. **Cancel/Pause/Resume**(`actions.ts:42-70`): + - `cancelTask()`(line 38-42):通过 `id OR series_id` 匹配,设所有 `pending`/`paused` 行为 `completed` + - `pauseTask()`(line 44-48)、`resumeTask()`(line 50-54):同理 + - 用 `series_id` 匹配意味着 agent 可以引用任意一次执行取消整个系列 + +### Host sweep + recurrence 协作顺序 + +``` +Host sweep(每 60s): + Step 1: syncProcessingAcks() + Step 2: countDueMessages() → 如果到期 + 容器不在运行 → wakeContainer() + Step 3: enforceRunningContainerSla() ← heartbeat/claim-stuck 检查 + Step 4: resetStuckProcessingRows() ← 崩溃容器清理 + Step 5: handleRecurrence() ← 扫描已完成周期性任务,克隆下次执行 +``` + +**关键顺序:** Step 2(唤醒到期消息)在 Step 4(崩溃容器清理)之前运行,确保新容器有机会在启动时清自己的孤儿 `processing_ack`。 + +### 边界情况 + +- **没有专用表**:任务是 `messages_in` 行——核心 `messages_in` schema 中 `kind` 字段足以区分 +- **`series_id`**:周期性任务的每次执行共享同一 `series_id`。Cancel/pause/resume 用 `id OR series_id` 匹配,影响整个系列 +- **时区**:Cron 表达式以用户配置的 `TIMEZONE` 解析(来自 `.env`),非 UTC +- **Pre-task scripts gating**:Task 行可带 `script` 字段。`applyPreTaskScripts()` hook(`poll-loop.ts:149,323`)先跑 script。如果返回 `wakeAgent: false`,任务标记完成但不唤醒 agent——实现 "仅用户活跃时运行" 等条件 +- **Agent 不能写 inbound.db**:Container 把 task 写成 `kind: 'system'` 的 outbound message;host 的 delivery action handler 才是实际插入 `messages_in` 的组件——保持单 writer 不变式 diff --git a/docs/answers/06-data-model.md b/docs/answers/06-data-model.md new file mode 100644 index 0000000..8a65a03 --- /dev/null +++ b/docs/answers/06-data-model.md @@ -0,0 +1,167 @@ +# Q16-Q17: 数据模型 + +--- + +## Q16: 中央库 `data/v2.db` 有哪些表?它们之间的外键关系是怎样的? + +### 答案 + +中央库有约 20 张表,横跨多次迁移。以下按逻辑分类: + +### 核心实体表(迁移 001) + +| # | 表 | 用途 | 关键 FK 关系 | +|---|-----|------|-------------| +| 1 | `agent_groups` | Agent 工作区(folder、skills、CLAUDE.md) | 根实体——无 FK 出 | +| 2 | `messaging_groups` | 一个平台聊天/频道/DM | 根实体;`UNIQUE(channel_type, platform_id)` | +| 3 | `messaging_group_agents` | 多对多 wiring:agent↔channel | `→ messaging_groups(id)`, `→ agent_groups(id)`;`UNIQUE(messaging_group_id, agent_group_id)` | +| 4 | `users` | 平台用户身份(`:`) | 根实体 | +| 5 | `user_roles` | 特权授予(owner/admin) | `→ users(id)` (x2: user + granted_by), `→ agent_groups(id)`;`PK (user_id, role, agent_group_id)` | +| 6 | `agent_group_members` | 非特权访问门 | `→ users(id)` (x2), `→ agent_groups(id)`;`PK (user_id, agent_group_id)` | +| 7 | `user_dms` | 冷 DM 缓存 | `→ users(id)`, `→ messaging_groups(id)`;`PK (user_id, channel_type)` | +| 8 | `sessions` | Session 生命周期元数据 | `→ agent_groups(id)`, `→ messaging_groups(id)` | +| 9 | `pending_questions` | 交互式问题状态 | `→ sessions(id)` | + +### 后续迁移添加的表 + +| # | 表 | 迁移 | 说明 | +|---|-----|------|------| +| 10 | `chat_sdk_kv` | 002 | Chat SDK 不透明状态 | +| 11 | `chat_sdk_subscriptions` | 002 | Chat SDK 线程订阅 | +| 12 | `chat_sdk_locks` | 002 | Chat SDK 分布式锁 | +| 13 | `chat_sdk_lists` | 002 | Chat SDK 列表状态 | +| 14 | `pending_approvals` | module-003 | `→ sessions(id)`, `→ agent_groups(id)` | +| 15 | `agent_destinations` | module-004 | `→ agent_groups(id)`;`PK (agent_group_id, local_name)` | +| 16 | `unregistered_senders` | 008 | 审计跟踪;`PK (channel_type, platform_id)` | +| 17 | `container_configs` | 014 | `→ agent_groups(id) ON DELETE CASCADE` | +| 18 | `pending_sender_approvals` | 011 | `→ messaging_groups(id)`, `→ agent_groups(id)`;`UNIQUE(messaging_group_id, sender_identity)` | +| 19 | `pending_channel_approvals` | 012 | `→ messaging_groups(id)`, `→ agent_groups(id)` | +| 20 | `schema_version` | 内建 | 迁移分类账——无 FK | + +### 已删除的表 + +- `pending_credentials` — 在迁移 009 中删除(已废弃) + +### 列级变更 + +- `agent_groups.denied_at` — 迁移 012 添加 +- `messaging_group_agents` 的 4 列(`engage_mode`、`engage_pattern`、`sender_scope`、`ignored_message_policy`)— 迁移 010 添加,替换旧的 `trigger_rules` + `response_scope` +- `container_configs.cli_scope` — 迁移 015 添加 + +### ER 关系图 + +``` +agent_groups ──────────────────────────────────────────────────────────────────────────┐ + │ │ + ├── messaging_group_agents ──→ messaging_groups ◄── user_dms │ + │ (UNIQUE pair) │ + │ │ + ├── sessions ──→ messaging_groups │ + │ └── pending_questions │ + │ │ + ├── user_roles (privilege: owner/admin, scoped or global) ──→ users (user_id, granted_by) + │ │ + ├── agent_group_members (unprivileged access gate) ──→ users (user_id, added_by) │ + │ │ + ├── agent_destinations (ACL + name→target routing map) │ + │ │ + ├── container_configs (1:1, ON DELETE CASCADE) │ + │ │ + ├── pending_approvals │ + ├── pending_sender_approvals │ + └── pending_channel_approvals │ + +users ────────────────────────────────────────────────────────────────────────────────┐ + ├── user_roles (user_id, granted_by 都自引用 users) │ + ├── agent_group_members │ + └── user_dms │ + +schema_version, chat_sdk_*, unregistered_senders — 无 FK;独立叶子表 +``` + +### 关键不变式 + +- **Owner 必须全局:** `role='owner'` 意味着 `user_roles` 中 `agent_group_id IS NULL`。`grantRole()` 中强制 +- **Admin 隐含 membership:** agent group A 的 admin 自动是 member——不需要 `agent_group_members` 行 +- **`agent_destinations` 双重角色:** 既是路由表也是 ACL。无行 = 未授权发送。源是中央库,spawn 时投影写入容器内 `inbound.db` +- **Chat SDK 表是不透明的**:NanoClaw 代码很少直接操作它们——由 `state-sqlite.ts` 持有 +- **Session DB 是分离的**:`inbound.db` / `outbound.db` 在 `data/v2-sessions//` 下,用 `CREATE TABLE IF NOT EXISTS` 做前向兼容 + +--- + +## Q17: DB 迁移怎么组织?我要加一张表或一个字段该改哪些文件? + +### 答案 + +### 迁移文件组织 + +每个迁移是 `src/db/migrations/` 下的一个文件,导出 `Migration` 对象: + +```typescript +// src/db/migrations/index.ts:18-22 +interface Migration { + version: number; // 在 barrel 数组中的排序提示 + name: string; // UNIQUE name — schema_version 中的去重 key + up: (db: Database) => void; // 在事务中运行 +} +``` + +### 迁移注册顺序(`migrations/index.ts:24-38`) + +``` +001-initial.ts → 核心表 +002-chat-sdk-state.ts → Chat SDK 表 +module-approvals-pending-approvals.ts → pending_approvals +module-agent-to-agent-destinations.ts → agent_destinations +module-approvals-title-options.ts → ALTER pending_approvals +008-dropped-messages.ts → unregistered_senders +009-drop-pending-credentials.ts → DROP pending_credentials +010-engage-modes.ts → ALTER messaging_group_agents +011-pending-sender-approvals.ts +012-channel-registration.ts +013-approval-render-metadata.ts +014-container-configs.ts +015-cli-scope.ts +``` + +005 和 006 编号空着——早前重编号的。 + +### 注册机制(`runMigrations`,`index.ts:40-77`) + +1. 如果不存在则创建 `schema_version` 表。唯一性在 `name`,不在 `version` +2. 读 `SELECT name FROM schema_version` 到 `Set` —— **去重 key 是 `name`**,不是 `version`。这允许模块迁移使用任意版本号 +3. `migrations.filter(m => !applied.has(m.name))` 得到待执行列表 +4. 在 `db.transaction()` 中运行每个待执行迁移: + - 调 `m.up(db)` 执行 DDL + - 计算 `next = MAX(version) + 1` + - 插入 `schema_version` + +### 加一张表 + +1. 创建 `src/db/migrations/016-your-table.ts`: + ```typescript + import type Database from 'better-sqlite3'; + import type { Migration } from './index.js'; + export const migration016: Migration = { + version: 16, + name: 'your-table', // 必须在所有迁移中全局唯一 + up(db: Database) { + db.exec(`CREATE TABLE IF NOT EXISTS your_table (...)`); + }, + }; + ``` + +2. 在 `src/db/migrations/index.ts` 中: + - 添加 `import { migration016 } from './016-your-table.js';` + - 追加到 `migrations` 数组 + +### 加一个字段 + +同理,写一个 ALTER TABLE 的迁移。复杂变更(回填)做 JS 行级更新。**幂等守护模式**(迁移 012,line 29-32):ALTER TABLE ADD COLUMN 前先 `PRAGMA table_info()` 检查列是否已存在。 + +### 边界情况 / Gotchas + +- **DROP TABLE 中的 FK 完整性:** 迁移事务中 DROP TABLE 会触发 SQLite FK 完整性检查。不能 toggle `PRAGMA foreign_keys`。优先 ALTER TABLE ADD COLUMN +- **模块迁移用任意版本:** `module-` 前缀文件的 `name` 与文件名不同——`name` 保持稳定,防止重命名后重新运行 +- **Session DB 迁移是独立的:** Session DB schema(`INBOUND_SCHEMA`、`OUTBOUND_SCHEMA`)用 `CREATE TABLE IF NOT EXISTS`,新列通过惰性迁移 helper(`migrateDeliveredTable()` 等)落地,不跟踪 `schema_version` +- **Container 端也有自己的迁移**:`container/agent-runner/src/db/connection.ts:86-110` 有惰性 session DB 迁移(添加 `on_wake` 列、`delivered` 表列等) diff --git a/docs/answers/07-provider-and-mcp.md b/docs/answers/07-provider-and-mcp.md new file mode 100644 index 0000000..7518cc2 --- /dev/null +++ b/docs/answers/07-provider-and-mcp.md @@ -0,0 +1,170 @@ +# Q18-Q19: Provider 与 MCP + +--- + +## Q18: Claude Agent SDK、OpenCode、Ollama 三个 provider 怎么抽象成统一接口?切换 provider 改什么? + +### 答案 + +### 统一接口:`AgentProvider` + +定义在 `container/agent-runner/src/providers/types.ts:1-92`: + +```typescript +interface AgentProvider { + readonly supportsNativeSlashCommands: boolean; // SDK 是否原生处理 /commands? + query(input: QueryInput): AgentQuery; // 开始一轮对话 + isSessionInvalid(err: unknown): boolean; // 检测过期的延续语境错误 +} +``` + +- `QueryInput`(line 40-60):携带 `prompt`、`continuation`(不透明 session token)、`cwd`、`systemContext`。Provider 自己决定 "continuation" 的含义 +- `AgentQuery`(line 68-80):流式句柄,`push(message)` 推送后续输入,`events: AsyncIterable` 流式输出,`abort()` 强制停止 +- `ProviderEvent`(line 82-92):判别联合类型 `init | result | error | progress | activity` +- `ProviderOptions`(line 23-38):`assistantName`、`mcpServers`、`env`、`additionalDirectories`、`model`、`effort` + +### 当前 Providers + +**ClaudeProvider**(`claude.ts:253-360`): +- 包装 `@anthropic-ai/claude-agent-sdk` 的 `query()` 函数 +- 用自定义 `MessageStream` async generator(line 80-112)把后续消息推入 SDK 流式输入 +- 把原始 SDK 事件翻译成 `ProviderEvent` 联合类型(`translateEvents()`,line 318-346) +- Hooks:PreToolUse(记录 in-flight tool + 屏蔽不允许的 SDK builtins)、PostToolUse(清除 in-flight)、PreCompact(转录归档) +- 在 line 360 注册:`registerProvider('claude', (opts) => new ClaudeProvider(opts))` + +**MockProvider**(`mock.ts:8-76`): +- 测试用——从 `responseFactory` 返回预设回复 + +**OpenCode:** 从 `providers` 分支通过 `/add-opencode` skill 安装,实现 `AgentProvider` 接口 + +**Ollama:** 尚未实现。会实现 `AgentProvider` 直接调用 Ollama API + +### factory.ts 的选择逻辑 + +`container/agent-runner/src/providers/factory.ts`(全部 13 行): + +```typescript +export function createProvider(name: string, options: ProviderOptions = {}): AgentProvider { + return getProviderFactory(name)(options); +} +``` + +这是对自注册 registry(`provider-registry.ts`)的薄分发。Registry 是 `Map`,每个 provider 模块在模块作用域调用 `registerProvider(name, factory)`。 + +选择逻辑: +1. 调用者传入 provider name 字符串(来自 `container_configs.provider` 列) +2. `getProviderFactory(name)` 查 Map——未找到抛错:`"Unknown provider: ${name}"` +3. 调用 factory 函数并传入 options + +### 注册流程 + +1. 每个 provider 文件在顶层调用 `registerProvider()`(如 `claude.ts:360`) +2. Barrel `container/agent-runner/src/providers/index.ts` 导入所有 provider 模块做副作用: + ```typescript + import './claude.js'; + import './mock.js'; + ``` +3. 运行时 agent-runner poll loop 从 `container_configs` 解析 provider name → `createProvider(name, options)` → 获取 `AgentProvider` → `provider.query(input)` + +### 切换 Provider + +修改 agent group 的 `container_configs.provider` 列: +```bash +ncl groups config update --id --provider opencode +``` +写入中央库 → 下次容器 spawn 物化到 `container.json` → agent-runner 调用 `createProvider('opencode', ...)` + +### Host 端 Provider 注册 + +对于需要 host 端 setup 的 provider(额外 mounts、env 传递),有独立的 registry:`src/providers/provider-container-registry.ts:43-54`。Provider 注册 `ProviderContainerConfigFn`,container-runner 在 spawn 时调用它来合并额外的 mounts/env。目前仅 `claude` 内建——用默认容器,无需额外注册。非默认 provider(如 OpenCode)会在这里注册。 + +--- + +## Q19: 容器里的 MCP server 怎么启动?内置工具和外部 MCP server 有什么不同? + +### 答案 + +### MCP Server Bootstrap + +入口是 `container/agent-runner/src/mcp-tools/index.ts:1-22`: + +``` +1. 导入链:index.ts → 导入 core.ts, scheduling.ts, interactive.ts, + agents.ts, self-mod.ts 以便它们的副作用 registerTools([...]) 调用生效 +2. 所有导入解决后,调用 startMcpServer() +3. 如果 server 崩溃,process.exit(1) +``` + +`startMcpServer()`(`server.ts:35-54`): +1. 创建 MCP `Server` 实例,name=`'nanoclaw'`,version=`'2.0.0'` +2. 注册两个请求 handler: + - `ListToolsRequestSchema` → 返回 `allTools.map(t => t.tool)`(line 38-40) + - `CallToolRequestSchema` → 查 `toolMap.get(name)`,调 `tool.handler(args)`(line 42-48) +3. 创建 `StdioServerTransport` 并连接(line 51-52)——Claude SDK 通过 stdio 发现 MCP server +4. 记录所有注册的 tool 名称 + +### Tool 自注册模式 + +每个 tool 模块在模块作用域调用 `registerTools([...])`。`registerTools()`(`server.ts:24-33`):将每个 `McpToolDefinition` 推入两个结构:`allTools[]`(ListTools 用)和 `toolMap`(name→definition,CallTool 用)。重复的名称警告但不报错。 + +`McpToolDefinition` 类型(`types.ts:1-6`): +```typescript +interface McpToolDefinition { + tool: Tool; // MCP SDK Tool schema (name, description, inputSchema) + handler: (args) => Promise; +} +``` + +### 内置 Tool vs 外部 MCP Server + +**内置(in-tree)工具** — 定义在 `container/agent-runner/src/mcp-tools/`: + +| 模块 | 工具 | 机制 | +|------|------|------| +| `core.ts`(line 95-263) | `send_message`、`send_file`、`edit_message`、`add_reaction` | 写 `outbound.db` 的 `messages_out` 表;通过 local destinations map 解析目标 | +| `scheduling.ts` | `schedule_task` | 持久调度,`process_after` / `recurrence` 字段 | +| `interactive.ts` | `ask_user_question` | 写中央库 `pending_questions`,host poll 并发送卡片 | +| `agents.ts` | `create_agent` | 通过 system actions spawn 子 agent 容器 | +| `self-mod.ts` | `install_packages`、`add_mcp_server` | 写 `pending_approvals` 表;host 处理审批和容器重建 | + +这些工具以 JavaScript 函数形式**运行在容器进程内部**。与 host 通信通过写 DB 表(`messages_out`、`pending_approvals` 等)——不是 IPC。 + +**外部 MCP server** — per-agent-group 配置在 `container_configs.mcp_servers`(JSON 字符串,默认 `'{}'`): +```json +{ + "server-name": { + "command": "npx", + "args": ["-y", "@some/mcp-server"], + "env": { "API_KEY": "..." } + } +} +``` + +Claude provider 把 `this.mcpServers` 直接传给 SDK 的 `mcpServers` 选项(`claude.ts:306`)。SDK 以子进程方式 **spawn 它们**,通过 stdio 自动发现它们的工具。Tool allowlist(`claude.ts:66-68`)派生 MCP patterns: +```typescript +function mcpAllowPattern(serverName: string): string { + return `mcp__${serverName.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`; +} +``` + +**关键区别:** 内置工具写 session DB;外部 MCP server 是由 SDK 管理的 stdio 子进程,通过 MCP 协议通信。内置工具能直接访问所有内部 DB 表和 destination/resolution 系统;外部 MCP 工具只能看到 provider SDK 通过 MCP 协议暴露的内容。 + +### MCP Tools ↔ Provider 交互 + +交互是间接的: + +1. Provider 启动 → 传 `mcpServers` config 给 SDK → SDK spawn 外部 MCP 进程并发现它们的 tool schema +2. **NanoClaw MCP server**(stdio)在 provider 之前由 `index.ts` 启动——SDK 连接它并发现内置 tool schema +3. Agent(Claude 模型)决定调用 tool → SDK 路由到内置或外部 MCP server → 结果返回模型 +4. Provider hooks(`PreToolUse`、`PostToolUse`)对**所有** tool 调用运行,不管来源(`claude.ts:160-189`)——用于为 host sweep 的卡住容忍度逻辑跟踪 tool-in-flight 状态 + +Provider 永远不会直接"调用"MCP tool。模型调用。Provider 只设置环境(MCP server configs、tool allowlists、hooks)。 + +### 不允许的 SDK Builtins + +`claude.ts:25-35` 定义 `SDK_DISALLOWED_TOOLS` —— Claude Code SDK builtins 被屏蔽,因为 NanoClaw 有等价实现或它们不适合无头模型: +- `CronCreate/CronDelete/CronList/ScheduleWakeup` → 被 `mcp__nanoclaw__schedule_task` 替代 +- `AskUserQuestion` → 被 `mcp__nanoclaw__ask_user_question` 替代 +- `EnterPlanMode/ExitPlanMode/EnterWorktree/ExitWorktree` → Claude Code UI 功能;在无头容器中挂起 + +如果 disallowed tool 意外通过 allowlist 过滤器,`preToolUseHook`(line 160-169)在调用时拦截它——深度防御。 diff --git a/docs/answers/08-channel-adapters.md b/docs/answers/08-channel-adapters.md new file mode 100644 index 0000000..d75f6a2 --- /dev/null +++ b/docs/answers/08-channel-adapters.md @@ -0,0 +1,158 @@ +# Q20-Q21: Channel 适配器 + +--- + +## Q20: 如果要加一个新的 channel(比如钉钉),需要实现什么接口、改哪些文件? + +### 答案 + +### `ChannelAdapter` 接口(完整合约) + +定义在 `src/channels/adapter.ts:111-167`: + +**必需属性:** + +| 属性 | 类型 | 说明 | +|------|------|------| +| `name` | `string` | 适配器显示名称 | +| `channelType` | `string` | 唯一类型标识(如 `'dingtalk'`) | +| `supportsThreads` | `boolean` | `true`=平台以线程为主要对话单元,`false`=频道本身就是对话 | + +**必需方法:** + +| 方法 | 签名 | 说明 | +|------|------|------| +| `setup(config)` | `(config: ChannelSetup) => Promise` | 初始化连接,注册事件 handler | +| `teardown()` | `() => Promise` | 优雅关闭 | +| `isConnected()` | `() => boolean` | 连接状态 | +| `deliver(platformId, threadId, message)` | `(string, string\|null, OutboundMessage) => Promise` | 发送出站消息;如有则返回平台消息 ID | + +**可选方法:** + +| 方法 | 何时实现 | 用途 | +|------|---------|------| +| `setTyping?(platformId, threadId)` | 有 typing 指示器的聊天平台 | 显示 "bot is typing..." | +| `syncConversations?()` | 有频道发现的平台 | 列出所有可访问的对话 | +| `resolveChannelName?(platformId)` | 显示需要 | 频道 ID 的人类可读名称 | +| `subscribe?(platformId, threadId)` | 有线程订阅的平台 | 订阅 bot 到线程;幂等——调用两次是 no-op | +| `openDM?(userHandle)` | 需要区分 user ID 和 DM channel ID 的平台 | 打开或获取 DM 频道,返回 DM 的 `platform_id`。仅 Discord、Slack、Teams、Webex、gChat 需要。Telegram、WhatsApp、iMessage 等跳过——user handle 就是 DM chat ID | + +### `ChannelSetup` 接口(适配器拿到的回调) + +定义在 `adapter.ts:9-26`: + +```typescript +interface ChannelSetup { + onInbound(platformId, threadId, message: InboundMessage): void | Promise; // 入站消息 + onInboundEvent(event: InboundEvent): void; // CLI/admin 传输 + onMetadata(platformId, name?, isGroup?): void; // 频道元数据 + onAction(questionId, selectedOption, userId): void; // 按钮点击 +} +``` + +### 两种实现模式 + +**模式 1:原生 Adapter** — 直接实现 `ChannelAdapter`,处理平台原始协议(HTTP API、WebSocket 等)。示例:WhatsApp (Baileys)、Signal、iMessage。 + +**模式 2:Chat SDK Bridge** — 用 `createChatSdkBridge(config)`(`chat-sdk-bridge.ts:122`)把已有 Chat SDK `Adapter` 包装成 `ChannelAdapter`。用于 Discord、Slack、Telegram、Teams、GitHub、Linear、Webex、Matrix、Google Chat。 + +### 加新 Channel 要创建/修改的文件 + +1. **创建适配器模块:** + - 如果用 Chat SDK:`src/channels/dingtalk.ts` 调 `createChatSdkBridge({...})` + - 如果用原生:`src/channels/dingtalk.ts` 直接实现 `ChannelAdapter` + +2. **自注册:** 调 `registerChannelAdapter('dingtalk', { factory, containerConfig? })` + +3. **接入导入:** 在 `src/channels/index.ts` 添加 `import './dingtalk.js';`(启动时加载所有适配器的 barrel) + +4. **Container config(可选):** 如果适配器需要在 agent 容器内额外 mount 或 env var: + ```typescript + registerChannelAdapter('dingtalk', { + factory: () => createDingTalkAdapter(), + containerConfig: { + mounts: [{ hostPath: '/path/to/certs', containerPath: '/certs', readonly: true }], + env: { DINGTALK_APP_KEY: process.env.DINGTALK_APP_KEY! }, + }, + }); + ``` + +5. **Skill 打包:** 按 `CONTRIBUTING.md` 规范写成 channel install skill: + - `skills/add-dingtalk/SKILL.md` — 面向用户的指引 + - Skill 从 `channels` 分支复制 `src/channels/dingtalk.ts`,追加 import 到 barrel,`pnpm install ` + +### 关键边界情况 + +| 情况 | 如何处理 | +|------|---------| +| 缺少凭据 | Factory 返回 `null` → `initChannelAdapters` 跳过并 warn(`channel-registry.ts:57-59`) | +| Setup 时网络错误 | 指数退避重试:[2s, 5s, 10s](`channel-registry.ts:10,68-87`) | +| 重复注册 | `registerChannelAdapter`(line 26):`registry.set()` 静默覆盖 | +| `isMention` flag 传播 | 知道平台 mention 语义的适配器设置 `InboundMessage.isMention`——router 用它替代正则 name-matching | + +--- + +## Q21: Chat SDK bridge 是什么?为什么 Discord/Slack/Telegram 等共用它? + +### 答案 + +### 什么是 Chat SDK Bridge + +Chat SDK bridge(`src/channels/chat-sdk-bridge.ts`,680 行)是一个**通用适配器 shim**,把任何 [Chat SDK](https://github.com/nicepkg/chat) `Adapter` 实例包装成 NanoClaw 兼容的 `ChannelAdapter`。它一次性处理平台无关的 concern,让每个具体 channel 模块只需提供平台特定配置。 + +核心工厂函数 `createChatSdkBridge(config)`(line 122),返回完整实现所有必需和可选方法的 `ChannelAdapter` 对象。 + +### Bridge 集中化的 10 项 concern + +1. **四种 dispatch 路径**(line 213-266)——Chat SDK 区分四种入站消息,bridge 把它们映射为正确的 `isMention` flag: + - `onSubscribedMessage` → `onInbound(channelId, threadId, message, isMention=message.isMention)` + - `onNewMention` → `onInbound(channelId, threadId, message, isMention=true)` + - `onDirectMessage` → `onInbound(channelId, threadId, message, isMention=true, isGroup=false)` + - `onNewMessage(/[\s\S]*/)` → `onInbound(channelId, threadId, message, isMention=false, isGroup=true)` + +2. **附件下载**(line 138-163)——序列化消息到 JSON 前先下载附件为 base64,使其在 `inbound.db` content 列中存活 + +3. **Reply context 提取**(line 164-169)——平台特定 hook(`extractReplyContext`) + +4. **Sender 字段归一化**(line 173-180)——把 Chat SDK 的嵌套 `author` 对象投影为 router 需要的扁平 `senderId`/`sender`/`senderName` + +5. **卡片渲染**(line 387-470)——把 `ask_question` 和 `send_card` MCP tool payload 渲染为带按钮的 Card。处理按钮编码(整数 index vs 全值,适配 Telegram 64 字节 callback_data 限制) + +6. **文本拆分**(line 480-497,`splitForLimit` line 106-120)——按 paragraph→line→space→char 边界拆分消息,适配 `maxTextLength`(Discord 2000,Telegram 4096) + +7. **Gateway listener**(line 303-359)——对支持 Gateway 的适配器(Discord),启动本地 HTTP server 接收转发事件,指数退避重启(上限 1h) + +8. **Webhook 注册**(line 362)——对非 gateway 适配器(Slack、Teams、GitHub),注册到共享 webhook server + +9. **`openDM` 委托**(line 534-549)——直接委托 `adapter.openDM()` 而非 `chat.openDM()` + +10. **Transform hook**(line 124)——`transformOutboundText` 允许 per-platform 文本清理 + +### 原生 Adapter vs Chat SDK Bridge + +| 方面 | Chat SDK Bridge | 原生 Adapter | +|------|----------------|--------------| +| **平台协议** | 由 Chat SDK 的 `Adapter` 处理 | 直接处理(如 Baileys 处理 WhatsApp WebSocket) | +| **消息解析** | Chat SDK 归一化为 `Message` 类型;bridge 序列化为 JSON | Adapter 解析原始平台 payload,直接构造 `InboundMessage` | +| **Dispatch** | Bridge 映射 4 个 SDK dispatch 路径到 `onInbound` | Adapter 从自己的 event handler 直接调用 `onInbound` | +| **卡片/按钮渲染** | Bridge 用 Chat SDK 的 `Card`/`Button`/`Actions` 组件渲染 | Adapter 必须实现平台特定的交互式消息渲染 | +| **Gateway/Webhook** | Bridge 处理 Gateway listener 生命周期 | Adapter 自己处理连接生命周期 | +| **openDM** | 委托 `adapter.openDM()` | Adapter 通过平台 API 直接实现 | +| **Channel ID 编码** | `adapter.channelIdFromThreadId()` | Adapter 用自定义 ID 方案 | +| **示例** | Discord、Slack、Telegram、Teams、GitHub、Linear、Webex、Matrix、Google Chat、Resend | WhatsApp (Baileys)、Signal、iMessage | + +### Bridge 处理的边界情况 + +| 情况 | 位置 | +|------|------| +| Gateway crash → IP block 保护 | Line 314-357:指数退避上限 1h,5min 健康运行后重置计数器 | +| Telegram 64 字节 callback_data 限制 | Line 400-408:把 button value 编码为整数 index | +| Discord interaction 更新 | Line 607-668:通过 Discord REST API 更新卡片,再 dispatch 到 `onAction` | +| 序列化时附件数据丢失 | Line 138-163:调用 `toJSON()` **前**先下载附件数据为 base64 | +| 媒体 only 空文本消息 | Line 263:`onNewMessage(/[\s\S]*/)` 匹配所有消息包括空文本 | +| `chat.openDM` 对非标准 user ID 抛错 | Line 534-549:直接委托 `adapter.openDM()` | +| Raw message 字段过大 | Line 183:`serialized.raw = undefined` 省 DB 空间 | + +### `user-dm.ts` 兜底 + +对于没有 `openDM` 支持的 channel(Telegram、WhatsApp、iMessage、email、Matrix),`src/user-dm.ts` 直接把 user handle 当 DM platform_id——user 本身就是 DM chat。Bridge 仅当底层 `adapter.openDM` 存在时才附着 `openDM`(line 544)。 diff --git a/docs/answers/README.md b/docs/answers/README.md new file mode 100644 index 0000000..26d8372 --- /dev/null +++ b/docs/answers/README.md @@ -0,0 +1,16 @@ +# 源码学习路线图 — 问题答案 + +> 对应 [`docs/learning-roadmap.md`](../learning-roadmap.md) 中的 21 个引导性问题。按子系统分组,每个问题附带完整的调用链、关键代码位置和边界情况分析。 + +## 目录 + +| 文件 | 覆盖问题 | 内容 | +|------|----------|------| +| [01-global-architecture.md](01-global-architecture.md) | Q1, Q2, Q3 | 消息完整链路、Node/Bun 运行时分离、seq 奇偶规则、跨 mount 不变式 | +| [02-routing-and-sessions.md](02-routing-and-sessions.md) | Q4, Q5, Q6 | 路由决策、三种隔离模式、`on_wake` 防竞态机制 | +| [03-permissions-and-security.md](03-permissions-and-security.md) | Q7, Q8, Q9 | 三级权限检查、陌生人审批、`cli_scope` 四层防御 | +| [04-container-lifecycle.md](04-container-lifecycle.md) | Q10, Q11, Q12 | Mount 架构、system prompt 组合、心跳检测与卡住判定 | +| [05-delivery-and-system-actions.md](05-delivery-and-system-actions.md) | Q13, Q14, Q15 | 投递重试、自我修改审批链路、定时任务 cron | +| [06-data-model.md](06-data-model.md) | Q16, Q17 | 中央库 ER 图、迁移系统注册机制 | +| [07-provider-and-mcp.md](07-provider-and-mcp.md) | Q18, Q19 | Provider 工厂模式、内置 MCP vs 外部 MCP server | +| [08-channel-adapters.md](08-channel-adapters.md) | Q20, Q21 | `ChannelAdapter` 接口、Chat SDK bridge 架构 | diff --git a/docs/learning-roadmap.md b/docs/learning-roadmap.md index 7b53f08..7148511 100644 --- a/docs/learning-roadmap.md +++ b/docs/learning-roadmap.md @@ -1,5 +1,7 @@ # NanoClaw 源码学习路线图 +> **配套答案:** 路线图中 21 个问题的详细答案见 [`docs/answers/`](answers/) 目录。 + ## 前置知识 阅读源码前,确保理解以下核心概念: