# nanobot 源码分析：一只住在聊天软件里的"极简"个人 Agent

> **分析对象**：[HKUDS/nanobot](https://github.com/HKUDS/nanobot)（PyPI 包名 `nanobot-ai`）
> **基于 commit**：`6a9157f4774e26cf0eda6e8c59bed52e05395e6a`（2026-07-24，`main` 分支，版本 `v0.2.2` "Durability Release"）
> **分析日期**：2026-07-24
> **读者设定**：有一点编程基础的初学者。每章尽量遵循"比喻 → 真实源码 → 逐行解释 → 设计取舍"的节奏。
> **说明**：本文与同目录下《Claude Code / opencode / hermes-agent / Raven / CodeWhale / goose 源码分析》采用同一套 12 章结构，方便横向对比。文中所有 `文件:行号` 均相对 `参考项目/nanobot/` 目录，可在该 commit 下直接核对；凡是找不到 / 不确定的功能，都会明确写出，绝不编造。
> **一个特别的血缘背景**：另一个项目 **Raven（EverMind-AI）** 的 Python 运行时正是 **fork 自 nanobot（v0.1.5.post3）**。所以本文除了独立分析 nanobot 本身，还会不断标注"**这是 nanobot 的原始形态，Raven fork 后改成了什么**"，为后续横向对比埋线。

---

## 第 1 章 项目概览：它是什么，谁在做

### 一句话版本

**nanobot 是港大数据智能实验室（HKUDS）开源的"超轻量、可完全自持（you can truly own）的个人 AI Agent 运行时"**：一个核心极小、却把 WebUI、十几个聊天平台、工具、记忆、MCP、模型路由、定时自动化、部署全都配齐的 Python 框架，MIT 协议（`LICENSE:1-3`，版权归 "Xubin Ren and the nanobot contributors"）。README 第一句的原话是（`README.md:37`）：

> 🐈 **nanobot** is an open-source, ultra-lightweight personal AI agent you can truly own. It keeps the agent core small and readable while giving you the practical pieces for real long-running work.

### 用比喻理解定位

如果说 Claude Code 是"精装寿司店"（围绕写代码打磨到极致），那 nanobot 更像一台**可以摆在自己家里的"个人服务器 + 全能管家"**：它默认长在你的电脑或一台云主机上，接管你所有的聊天软件（Telegram、微信、飞书、Slack、Discord、邮件……），随叫随到；同时又刻意把"大脑"（agent 循环）做得很小、很好读——README 反复强调 "small core / readable internals"（`README.md:107`），`.agent/design.md:5-7` 更是把"核心保持小、能力在边缘扩展"立为第一条设计铁律：

> New capabilities should be added via `channels/`, `tools/`, skills, or MCP servers. The files `agent/loop.py` and `agent/runner.py` form the critical core path; changes there should be minimal and justified.

这就是理解全项目的钥匙：**nanobot 把"聪明"留给模型，把"结构"压到最少**（`.agent/design.md:11-13`："Less structure, more intelligence"）。

### 谁在做

- **HKUDS**：香港大学 Data Intelligence Lab（数据智能实验室，Chao Huang 教授团队）旗下的开源组织，也是 LightRAG、MiniRAG 等一批知名开源项目的作者。nanobot 的主要作者署名为 **Xubin Ren**（`LICENSE:3`）。
- **产品形态**：`v0.2.2` 的主题词叫 "Durability Release"（耐久性版本，`README.md:73`），最近更新集中在崩溃恢复、会话/网关可靠性、WebUI 段式记录——这和它"要在你机器上长期跑着"的定位高度一致。

### 与 Raven / hermes 的血缘关系（非常重要）

- **nanobot 是"上游"**。Raven（EverMind-AI）在 fork 时锁定的是 nanobot 的 `v0.1.5.post3`，把它当作 Python 基础运行时（agent、channels、cli、providers、session 等目录），然后在上面叠加了 EverOS 长期记忆、Curator 无损压缩、Sentinel 主动性、TUI 界面（后者来自 hermes-agent）等一整套"自进化底座"。
- **所以本文有一个隐藏价值**：你在这里看到的很多设计（MessageBus、AgentLoop/AgentRunner 分层、UNTRUSTED 围栏、SSRF 防护、subagent 经消息总线回报……）都是 **Raven 的"出厂设置"**。凡是 Raven 文档里提到的"我们改了/加了"，对照本文就能看清它改自什么。这些差异点我会在每章末尾用 **【vs Raven】** 小节标出。

### 技术栈速览

| 层 | 技术 | 位置 / 证据 |
|---|---|---|
| 运行时语言 | Python ≥ 3.11，全程 asyncio | `AGENTS.md:69`、`pyproject.toml:22` |
| CLI 框架 | typer + rich + prompt-toolkit + questionary | `pyproject.toml:26-45` |
| LLM SDK | **直接用 `openai` + `anthropic` 官方 SDK**（**不用 LiteLLM**） | `pyproject.toml:27,45`；`nanobot/providers/` |
| 模型协议 | 自研 `openai_compat_provider` + 专用 anthropic/azure/bedrock/codex/copilot 后端 | `nanobot/providers/registry.py:52` |
| 记忆存储 | 纯文件（Markdown + JSONL）+ 纯 Python git（`dulwich`） | `pyproject.toml:48`；`nanobot/agent/memory.py:82` |
| 前端 | React + Vite SPA（浏览器 WebUI），走 WebSocket 多路复用 | `AGENTS.md:44`；`webui/` |
| MCP | 官方 `mcp` SDK | `pyproject.toml:41` |
| 搜索/抓取 | `ddgs`（DuckDuckGo）、`readability-lxml` | `pyproject.toml:32,36` |
| 定时 | `croniter` | `pyproject.toml:38` |

> **第一个关键差异**：**nanobot 不依赖 LiteLLM**（`nanobot/cli/models.py:3` 里只留了一句"litellm 暂时禁用"的注释）。它自己维护了一个 ~40 家 provider 的注册表和一套 OpenAI 兼容适配层。而 **Raven 换用了 LiteLLM** 做统一适配。这是两者在"模型接入哲学"上的根本分野：nanobot 选择**自己掌握每一条 provider 解析路径**（呼应 `.agent/design.md:29` 的 "Explicit over magical"），Raven 选择**把适配外包给 LiteLLM**。

```mermaid
flowchart LR
    subgraph N["nanobot（本项目）"]
        N1[WebUI / CLI / 17 个聊天平台] --> N2[MessageBus 两条异步队列]
        N2 --> N3[AgentLoop 编排]
        N3 --> N4[AgentRunner 模型循环]
        N4 --> N5[工具: 文件/exec/web/MCP/cron/spawn]
        N3 --> N6[文件记忆 + Dream 反思]
        N4 --> N7["自研 provider 层（~40 家，直连 SDK）"]
    end
    style N fill:#e8f5e9
```

---

## 第 2 章 全景架构：一张分层地图

### 比喻：一家"前店后厂"的小作坊

nanobot 的架构像一家分工清爽的小作坊：

- **门市（Channels / WebUI / CLI）**：十几个收货窗口，把不同平台的消息都翻译成同一张"入库单"（`InboundMessage`）；
- **传送带（MessageBus）**：一条 `inbound` 队列进、一条 `outbound` 队列出，把门市和后厂彻底解耦；
- **车间主任（AgentLoop）**：从传送带取单，管会话、管工作区、拼上下文、发货；
- **流水线工人（AgentRunner）**：真正和模型对话、执行工具、处理流式和异常；
- **仓库（Session / Memory / Dream）**：会话历史、长期记忆文件、夜间"做梦"整理。

官方架构图（`docs/architecture.md:9-23`）画的正是这条主链，我把它翻译成分层图：

```mermaid
flowchart TB
    subgraph FE["门市层"]
        WUI["webui/<br/>React/Vite SPA"]
        CLI["nanobot/cli/<br/>typer 命令行"]
        CH["nanobot/channels/<br/>17 个平台适配包"]
    end
    subgraph BUS["传送带层"]
        Q["nanobot/bus/queue.py<br/>MessageBus：inbound + outbound 两条 asyncio.Queue"]
    end
    subgraph CORE["核心层（'保持小'）"]
        LOOP["nanobot/agent/loop.py<br/>AgentLoop：状态机编排 turn"]
        RUN["nanobot/agent/runner.py<br/>AgentRunner：model/tool 循环"]
        CTX["nanobot/agent/context.py<br/>ContextBuilder：拼系统提示词"]
        GOV["nanobot/agent/context_governance.py<br/>ContextGovernor：模型侧消息治理/微压缩"]
    end
    subgraph STATE["仓库层"]
        SESS["nanobot/session/manager.py<br/>会话 JSONL + 自动压缩"]
        MEM["nanobot/agent/memory.py<br/>MEMORY.md/history.jsonl + Dream"]
        SKILL["nanobot/agent/skills.py<br/>SKILL.md 技能"]
    end
    subgraph EXT["外部世界"]
        PROV["nanobot/providers/<br/>~40 家 LLM（直连 SDK）"]
        MCP["MCP 服务器"]
        SUB["nanobot/agent/subagent.py<br/>后台子代理"]
    end
    WUI & CLI & CH --> Q
    Q -->|InboundMessage| LOOP
    LOOP --> CTX --> GOV
    LOOP --> RUN --> PROV
    RUN --> SUB
    RUN -->|工具| MCP
    LOOP <--> SESS & MEM & SKILL
    LOOP -->|OutboundMessage| Q --> CH & WUI & CLI
```

### 主包目录职责（逐一标注）

`nanobot/` 下的子目录分工（对照 `AGENTS.md:35-51` 与实际代码）：

| 目录 | 职责 | 关键文件 |
|---|---|---|
| `agent/` | **核心**：turn 编排、模型循环、上下文、记忆、工具、子代理 | `loop.py`、`runner.py`、`context.py`、`memory.py`、`context_governance.py`、`subagent.py`、`tools/` |
| `agent/tools/` | 所有工具实现 + 注册/发现 | `registry.py`、`loader.py`、`filesystem.py`、`shell.py`、`web.py`、`mcp.py`、`spawn.py`、`long_task.py` |
| `api/` | OpenAI 兼容 HTTP API（`/v1/chat/completions`） | `api/server.py`、`api/runtime.py` |
| `apps/` | CLI-App 附件机制（把外部 CLI 当能力挂进 turn） | `apps/cli/service.py` |
| `audio/` | 语音（转写/TTS）相关 | — |
| `bus/` | 消息总线与事件类型 | `bus/queue.py`、`bus/events.py`、`bus/runtime_events.py` |
| `channels/` | 17 个聊天平台适配包（自包含，pkgutil 自动发现） | `channels/manager.py`、`channels/<平台>/` |
| `cli/` | typer 命令树、onboard 向导 | `cli/commands.py`（2972 行）、`cli/onboard.py` |
| `command/` | 斜杠命令路由与内置命令 | `command/builtin.py` |
| `config/` | Pydantic 配置 schema / 加载 / 路径 | `config/schema.py`、`config/loader.py`、`config/paths.py` |
| `cron/` | 定时任务服务（Dream/heartbeat 也走它） | `cron/service.py` |
| `gateway/` | 网关运行时（拉起所有服务） | `gateway/runtime.py` |
| `pairing/` | DM 发送者配对/审批码 | `pairing/` |
| `providers/` | LLM provider 注册表与实现 | `providers/registry.py`、`providers/base.py`、`providers/openai_compat_provider.py`、`providers/fallback_provider.py` |
| `security/` | 工作区边界、SSRF、PTH 守卫 | `security/network.py`、`security/workspace_access.py` |
| `session/` | 会话历史、压缩、目标状态 | `session/manager.py`、`session/goal_state.py` |
| `skills/` | 内置技能（Markdown + YAML frontmatter） | `skills/<名>/SKILL.md` |
| `triggers/` | 本地触发器 | `triggers/local_turns.py` |
| `templates/` | Jinja2 系统提示词/场景模板 | `templates/agent/*.md` |
| `utils/` | 助手函数、token 估算、git 存储 | `utils/helpers.py`、`utils/gitstore.py`、`utils/prompt_templates.py` |
| `web/` | 打包进 wheel 的 WebUI 静态产物 | `web/dist/` |
| `webui/`（仓库根） | WebUI 源码（React/Vite） | `webui/` |

### 三个"分层纪律"

1. **核心保持小，能力从边缘长出来**。`loop.py`/`runner.py` 被明确列为"关键核心路径"，改动要克制（`.agent/design.md:5-7`）。新能力应做成 channel / tool / skill / MCP。
2. **宁可重复，不要过早抽象**。`.agent/design.md:15-17` 特别允许 channels 与 providers 之间**重复**发送重试、媒体处理、消息切分等逻辑，禁止为了消除重复而造复杂基类——"每个 channel 文件应该自己读得懂"。这解释了为什么 `channels/` 下每个平台都是一个厚厚的自包含包。
3. **显式优于魔法**。配置必须在 `config/schema.py` 的 Pydantic 模型里显式声明；provider 自动探测存在，但"每一条解析路径都必须能从工厂追溯到具体类"（`.agent/design.md:27-29`）。

### 进程 / 线程模型

- **单进程 asyncio**：网关模式下（`nanobot gateway`）所有东西跑在一个事件循环里——config 热加载、agent 主循环、所有 channels、本地触发器、健康端点、（可选）自动开浏览器，都是并列的 `asyncio.create_task`（`cli/commands.py:2103-2130`）。
- **前后端是 HTTP/WebSocket，不是子进程**：WebUI 是浏览器里的 SPA，通过 WebSocket 多路复用协议连到网关（`AGENTS.md:44`）；WebUI 静态资源由 **WebSocket channel** 提供（默认 `:8765`），健康端点在另一个端口（默认 `:18790`，`docs/architecture.md:102-105`）。

### 【vs Raven】

- **调度层**：nanobot 用**朴素的两条 asyncio 队列 + 每会话一把 `asyncio.Lock`**（`bus/queue.py:16-18`；`loop.py:407`）。Raven 把它换成了正式的 **Spine/Scheduler/Lane** 调度层。nanobot 更"小作坊"，Raven 更"物流中心"。
- **前端**：nanobot 是**浏览器 WebUI**；Raven 把前端换成了 fork 自 hermes-agent 的 **终端 Ink TUI**（双进程 + JSON-RPC）。这是两者最直观的产品差异。

---

## 第 3 章 启动流程：从命令到界面

### 比喻：开门营业的三种姿势

nanobot 有三种"营业方式"：一次性问答（`nanobot agent -m`）、长期网关（`nanobot gateway`/`serve`）、浏览器界面（`nanobot webui`）。它们共享同一个入口，但拉起的东西不同。

### 真实调用链

**第 1 步：入口极短**。`nanobot/__main__.py:5-8` 全文可引用：

```python
from nanobot.cli.commands import app

if __name__ == "__main__":
    app()
```

`pyproject.toml:104-105` 也把命令行脚本 `nanobot` 直接绑到 `nanobot.cli.commands:app`——这是一个 typer 应用（`cli/commands.py:217` 处 `name="nanobot"`）。

**第 2 步：CLI 命令树**。`cli/commands.py` 注册了一批命令（`@app.command()` 与 `add_typer`）：

| 命令 | 作用 | 位置 |
|---|---|---|
| `nanobot onboard` | 首次配置向导（provider/模型/沙箱/channel……） | `cli/commands.py:630` |
| `nanobot agent` | 一次性/交互式对话 | `cli/commands.py:2204` |
| `nanobot serve` / `gateway` | 长期网关（拉起全部服务） | `cli/commands.py:1323`、`:2183` |
| `nanobot webui` | 启动浏览器界面 | `cli/commands.py:1407` |
| `nanobot trigger` | 本地触发器 | `cli/commands.py:1289` |
| `nanobot status` | 运行状态 | `cli/commands.py:2627` |
| `nanobot channels …` | 平台状态/登录 | `cli/commands.py:2480-2547` |
| `nanobot plugins …` | 插件启用/禁用 | `cli/commands.py:2547-2626` |
| `nanobot provider …` | provider 登录/登出 | `cli/commands.py:2672-2793` |

启动时 `cli/commands.py` 会强制把 `stdout/stderr` 切到 UTF-8（`.agent/gotchas.md:20`），以处理 emoji 和多语种输入——一个"要在全球聊天平台跑"的项目的必要细节。

**第 3 步：网关一把拉起所有服务**。`nanobot gateway` 的 `run()`（`cli/commands.py:2091-2130`）用一组并列 task 组成运行时：

```python
tasks = [
    asyncio.create_task(watch_config_file(..., agent.invalidate_runtime_config), name="nanobot-config-watcher"),
    asyncio.create_task(agent.run(),          name="nanobot-agent-loop"),
    asyncio.create_task(channels.start_all(), name="nanobot-channels"),
    asyncio.create_task(run_local_trigger_queue(...), name="nanobot-local-triggers"),
]
# 可选：健康端点、自动开浏览器
```

注意 **config 热加载**（`watch_config_file` → `agent.invalidate_runtime_config`，`loop.py:516-519`）：改 `~/.nanobot/config.json` 不必重启，运行时会重新解析 provider/模型目录。这是"长期驻留"产品的贴心设计。

**第 4 步：Dream / heartbeat 变成 cron 系统任务**。老版本有专门的服务，现在统一收进 cron（`AGENTS.md:47`）。网关启动时若开启，会注册两个系统级 CronJob（`cli/commands.py:2031-2055`）：`dream`（夜间记忆整理）与 `heartbeat`（周期性检查 `HEARTBEAT.md`）。

```mermaid
sequenceDiagram
    participant U as 用户
    participant CLI as typer app
    participant GW as gateway.run()
    participant AL as AgentLoop
    participant CH as ChannelManager
    U->>CLI: nanobot gateway
    CLI->>GW: 解析 config / 构建 provider / AgentLoop
    GW->>GW: cron.register_system_job(dream, heartbeat)
    GW->>AL: create_task(agent.run())
    GW->>CH: create_task(channels.start_all())
    GW->>GW: create_task(watch_config_file) 热加载
    AL->>AL: await bus.consume_inbound() 主循环
    CH->>AL: 平台消息 → bus.publish_inbound
```

**第 5 步：AgentLoop.run 主循环**。`loop.py:1023-1122` 是心跳所在：`while self._running:` 里以 1 秒超时不断 `consume_inbound()`；超时的时候顺便检查空闲会话是否该压缩（`auto_compact.check_expired`）。这个"1 秒轮询兼做后台维护"的写法很朴素但很实用。

### 【vs Raven】

- nanobot 的启动是**一批并列 asyncio task**（config-watcher + agent-loop + channels + triggers）。Raven 因为前端是独立 Node 进程，启动流程多了一步 `find_node()` + spawn TUI 子进程 + TCP-loopback 握手。nanobot 没有这一层——它的"界面"要么是终端里的 REPL，要么是浏览器打开一个页面。

---

## 第 4 章 输入捕获与分流：一句话的旅程

### 比喻：一条只有一个入口的传送带

不管你从 Telegram、微信还是浏览器说话，你的消息都被翻译成同一张入库单 `InboundMessage`，扔上同一条传送带 `MessageBus.inbound`。后厂只认单子，不认你从哪来——这就是**解耦**。

### 统一的入库单：`InboundMessage`

`bus/events.py:23-39`：

```python
@dataclass
class InboundMessage:
    channel: str        # telegram / discord / slack / webui ...
    sender_id: str      # 发送者
    chat_id: str        # 会话/群标识
    content: str        # 文本
    media: list[str]    # 媒体路径
    metadata: dict      # 平台特定数据
    session_key_override: str | None = None

    @property
    def session_key(self) -> str:
        return self.session_key_override or f"{self.channel}:{self.chat_id}"
```

**会话键就是 `channel:chat_id`**（除非被 `session_key_override` 覆盖）。这是全系统的路由主键——同一个 `session_key` 的消息串行处理，不同键并发（下一章细说）。出库单是对称的 `OutboundMessage`（`bus/events.py:42-59`）。

传送带本体极其朴素（`bus/queue.py:8-34`）：就是两条 `asyncio.Queue`，`publish_inbound` / `consume_inbound` / `publish_outbound` / `consume_outbound`。没有优先级、没有分区、没有持久化——**"less structure, more intelligence"** 在这里体现得淋漓尽致。

### 四层分流

**第 1 层：channel 侧**。各平台适配器把原始事件（含图片、文件、语音）翻译成 `InboundMessage` 并 `publish_inbound`。`channels/` 是 17 个自包含包，由 `channels/manager.py` 用 pkgutil 扫描自动发现（`AGENTS.md:39`）。

**第 2 层：主循环取单后的运行时控制**。`loop.py:1053-1062`，取到消息先问三件事：
1. `handle_runtime_control` —— 是不是内部控制信号（如 MCP 重载、图像生成重载，`bus/events.py:17-20`）？是就地处理，不进会话。
2. `commands.is_priority(raw)` —— 是不是**优先斜杠命令**（`/stop`、`/restart`、`/status`）？是就 `_dispatch_command_inline` 立即执行——这样即使一个会话正忙，`/stop` 也能穿透过去。
3. 自动化 turn（cron / 本地触发器）是否需要"延后"（`defer_if_active`），避免打断正在进行的会话。

**第 3 层：忙碌会话的"插话"路由**。`loop.py:1082-1109` 是 nanobot 一个精巧设计：如果某会话已经有一个 task 在跑（`effective_key in self._pending_queues`），新来的**非命令**消息不会另起一个竞争 task，而是被塞进该会话的 `pending_queue`（一条 `asyncio.Queue(maxsize=20)`），交给正在跑的 turn 在迭代间隙"顺手捞起来"合并进对话（见第 6 章的注入机制）。非优先但可分派的命令则走 `_dispatch_command_inline` 直接执行（`loop.py:1085-1090`）。

**第 4 层：真正开一个 turn**。`loop.py:1112` 才 `asyncio.create_task(self._dispatch(msg))`，把这个 task 登记进 `_active_tasks[effective_key]`，以便 `/stop` 能精确取消。

```mermaid
flowchart TD
    IN[channel: InboundMessage] --> BUS[MessageBus.inbound 队列]
    BUS --> C1{运行时控制信号?}
    C1 -- 是 --> RC[就地处理 MCP/图像重载]
    C1 -- 否 --> C2{优先命令 /stop//restart//status?}
    C2 -- 是 --> PRI[穿透执行, 可打断忙碌会话]
    C2 -- 否 --> C3{该会话已有 task 在跑?}
    C3 -- 是, 且是普通文本 --> PQ[塞进 pending_queue 等待插话注入]
    C3 -- 是, 且是可分派命令 --> INL[直接分派命令]
    C3 -- 否 --> T[create_task _dispatch → 一个新 turn]
```

### 会话键映射与 `unified_session`

默认 `session_key = channel:chat_id`。但如果开了 `unified_session`（`config/schema.py:149`），`_effective_session_key`（`loop.py:786-790`）会把所有 channel 的消息映射到同一个 `UNIFIED_SESSION_KEY`——这适合"单用户多设备"，让你在手机、电脑、浏览器上跟同一个 agent 连续对话。

### 【vs Raven】

- nanobot 的分流规则**散落在主循环的几个 if 里**（runtime control / priority / pending queue / new task），靠 `session_key` 字符串和 `_pending_queues` 字典手工路由。Raven 把它抽象成了 `TurnRequest` + `Origin`（USER/CRON/SUBAGENT…）+ `BusyPolicy`（APPEND/INJECT/INTERRUPT）的一等公民模型，由 Spine 统一裁决。**同样的"忙碌会话插话"需求，nanobot 用 pending_queue 土办法解决，Raven 用 BusyPolicy 声明式解决。**

---

## 第 5 章 上下文组装：给模型打包

### 比喻：一份用 `---` 分隔的"工作简报"

每次请求，`ContextBuilder`（`agent/context.py:54`）把若干块拼成一条 system 消息，块与块之间用 `\n\n---\n\n` 分隔（`context.py:121`）。它不像 Raven 那样有编号的段构建器和两阶段并行组装——就是**顺序拼字符串**，朴素直接。

### 系统提示词的组成

`build_system_prompt`（`context.py:70-121`）按顺序拼接：

1. **身份 + 运行时 + 工作区**（`_get_identity` → 模板 `templates/agent/identity.md`）。有意思的是：**这一段没有 "# nanobot" 这样的大标题**，直接从 `## Runtime` 开始（`templates/agent/identity.md:1`），列出操作系统/Python 版本、当前项目工作区、agent 工作区，并明确告诉模型：`SOUL.md`/`USER.md`/`MEMORY.md` 由 Dream 自动管理、**不要直接编辑**（`identity.md:9-10`）。
2. **Bootstrap 文件**（`_load_bootstrap_files`，`context.py:57`）：`BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]`——项目守则 + 人格 + 用户画像。其中 `AGENTS.md` 取**项目工作区**的，`SOUL.md`/`USER.md` 取 **agent 工作区**的（`context.py:162-166`），二者可能是不同路径。一个体面细节：如果 `AGENTS.md`/`USER.md` 内容与出厂模板**逐字相同**（用户没改过），就跳过不加载（`context.py:174-177` + `_is_template_content`），避免拿默认模板浪费 token。
3. **工具契约**（`render_template("agent/tool_contract.md")`，`context.py:88`）：一份很长的"工具使用守则"（`templates/agent/tool_contract.md`），教模型优先用结构化工具而非 `exec`、失败后换招而非重复、改前先读、把安全/边界错误当真实限制。
4. **`# Memory`**：长期记忆 `MEMORY.md` 的内容（`context.py:90-92`），同样会检测"是不是没改过的模板"。
5. **`# Active Skills`**：标了 `always` 的常驻技能全文（`context.py:94-98`）。
6. **技能清单摘要**（`agent/skills_section.md`）：其余技能只给"名字+描述+路径"的摘要，让模型按需用 `read_file` 展开——**渐进式披露**（`context.py:100-102`；`skills.py:111` 的 `build_skills_summary`）。
7. **`# Recent History`**：从 `history.jsonl` 里取"上次 Dream 之后"的近期历史，上限 50 条、8000 token（`context.py:104-116`；常量 `_MAX_RECENT_HISTORY=50`、`_MAX_HISTORY_TOKENS=8_000` 在 `context.py:60-61`）。
8. **`[Archived Context Summary]`**：如果本会话有被压缩归档的摘要，附在最后（`context.py:118-119`）。

### 用户消息与"运行时上下文"

`build_messages`（`context.py:190-241`）把 `[system, *history, current]` 拼起来。当前用户消息前会拼上一块**运行时上下文块**（`runtime_context_blocks`，如当前时间、CLI-App 附件等），通过 `append_runtime_context` 合并，并在 `_meta` 里记账（`context.py:211-240`）。图片则被编码成 base64 data-url 塞进 `content` 数组（`context.py:243-266`）。

关键纪律（`.agent/gotchas.md:30-32` "Context Pollution Persists"）：**任何写进记忆/历史/prompt 的东西都会被未来的 LLM 调用回放**，所以时间戳、本地媒体路径、工具调用回显、原始 fallback dump 都必须先净化再落盘——落盘时 `_sanitize_persisted_blocks`（`loop.py:1689-1718`）会把 base64 图片替换成占位符文本，把超长文本截断。

### Prompt Cache 工程（重点轴）

nanobot 的 prompt 缓存做法在 `openai_compat_provider.py:553-585` 的 `_apply_cache_control`：只对 `supports_prompt_caching=True` 的 provider（Anthropic、OpenRouter，见 `registry.py:191,361`）注入 Anthropic 风格的 `cache_control: {"type": "ephemeral"}` 断点，位置是**固定三处**：

- system 消息（第一条）；
- 倒数第二条消息（`messages[-2]`）；
- 工具定义里的若干处（`_tool_cache_marker_indices`）。

再配合 `ToolRegistry.get_definitions`（`tools/registry.py:86-109`）的**稳定排序**（内置工具排前、MCP 工具排后，各自按名排序，并缓存），保证工具清单的字节前缀在多轮之间稳定——这对缓存命中至关重要。

> 一句话：**nanobot 的 prompt cache 是"固定断点 + 稳定工具排序"的朴素方案**；它没有 Raven 那种自适应地在 ≤4 个断点间挪动缓存位置的 TokenWise CacheOptimizer。

### 【vs Raven】

- **组装方式**：nanobot 是"顺序拼字符串 + `---` 分隔"（`context.py:121`）；Raven 是"6 个有 owner/order/预算的 Segment + 两阶段并行组装"。
- **记忆来源**：nanobot 的 `# Memory` 直接读 `MEMORY.md` 文件；Raven 在此叠加了 EverOS 向量召回。
- **身份头**：nanobot 的 identity 甚至没有一级标题名字；Raven 有醒目的 `# Raven 🐦‍⬛`。

---

## 第 6 章 Agent 主循环：项目的心脏

nanobot 的"心脏"分成**两个腔室**，这是它区别于大多数 agent 项目的核心结构：

- **AgentLoop（`agent/loop.py`，1987 行）**：面向 channel 的编排层，用一个**显式状态机**跑完一个 turn；
- **AgentRunner（`agent/runner.py`，1401 行）**：面向模型的执行层，跑"调模型 → 执行工具 → 再调模型"的迭代循环。

`docs/architecture.md:36-54` 特意教你怎么 debug：**会话路由/工作区/发货的问题看 `loop.py`；provider 调用/工具/流式/迭代上限的问题看 `runner.py`。**

### 腔室一：AgentLoop 的 turn 状态机

`loop.py:105-113` 定义了 8 个状态：

```python
class TurnState(Enum):
    RESTORE; COMPACT; COMMAND; BUILD; RUN; SAVE; RESPOND; DONE
```

状态转移写在一张显式的表 `_TRANSITIONS`（`loop.py:248-257`），驱动器 `_process_message`（`loop.py:1351-1403`）就是个 `while ctx.state is not DONE:` 循环，每步调 `_state_<name>` 处理器、拿它返回的 event 去查下一状态、并记录每步耗时（`StateTraceEntry`，用于 debug）。这条"事件驱动状态机"是 **nanobot 的原创设计**，把一个 turn 的生命周期拆得清清楚楚：

```mermaid
flowchart LR
    R[RESTORE<br/>崩溃恢复+抽取文档] --> C[COMPACT<br/>准备会话/待压缩摘要]
    C --> CMD{COMMAND<br/>是斜杠命令?}
    CMD -- shortcut --> DONE[DONE]
    CMD -- dispatch --> B[BUILD<br/>拼上下文+按需压缩]
    B --> RUN[RUN<br/>调 AgentRunner]
    RUN --> S[SAVE<br/>原子落盘+调度压缩]
    S --> RESP[RESPOND<br/>组装 OutboundMessage]
    RESP --> DONE
```

- **RESTORE**（`loop.py:1440-1468`）：先做**崩溃恢复**（下文详述），再抽取用户消息里的文档/媒体。
- **COMPACT**（`loop.py:1480-1483`）：调 `auto_compact.prepare_session`，拿出可能存在的归档摘要。
- **COMMAND**（`loop.py:1485-1524`）：斜杠命令在这里被路由；命中的"快捷命令"直接产出 outbound 并跳到 DONE，同时把用户消息和命令结果打上 `_command` 标记落盘（这样 WebUI 能看到、但不污染 LLM 上下文）。
- **BUILD**（`loop.py:1526-1582`）：解析本会话的 runtime、按 token 预算触发 `consolidator.maybe_consolidate_by_tokens`、取历史、组装 `initial_messages`、并**提前落盘用户消息**（`_persist_user_message_early`，为崩溃恢复兜底）。
- **RUN**（`loop.py:1584-1619`）：真正调 `_run_agent_loop`（→ AgentRunner）。
- **SAVE**（`loop.py:1621-1662`）：原子落盘本轮新增消息、计算延迟、后台再调度一次压缩、清掉崩溃恢复标记。
- **RESPOND**（`loop.py:1664-1687`）：组装最终 `OutboundMessage`。

### 腔室二：AgentRunner 的迭代循环

`runner.py:354` 是循环本体：

```python
for iteration in range(spec.max_iterations):   # 默认 200（config/schema.py:133）
    messages_for_model = self.context_governor.prepare_for_model(...)  # 模型侧治理副本
    ...
    response = await self._request_model(spec, messages_for_model, hook, context)
    if response.should_execute_tools:
        ... 执行工具, 结果 append 成 tool 消息, continue
    ... 否则处理最终文本 / 空响应 / 截断 / 错误 → break
```

**迭代预算高达 200**（`config/schema.py:133` `max_tool_iterations: int = 200`）——这是 nanobot 一个鲜明特征，比很多同类项目（如 Raven 的 40）宽松得多，配合它"长时间任务"的定位。

一轮迭代的完整旅程：

```mermaid
flowchart TD
    START["iteration in range(200)"] --> GOV["context_governor.prepare_for_model<br/>治理模型侧副本(见第8章)"]
    GOV --> CALL["_request_model：流式/进度流/非流式<br/>+ 300s wall timeout"]
    CALL --> MAL{"全是畸形 tool_call?"}
    MAL -- 是 --> RETRY["重试→退化为无工具请求<br/>runner.py:845-875"]
    MAL -- 否 --> TC{"should_execute_tools?"}
    TC -- 是 --> EXEC["_execute_tools：批量/并发执行<br/>runner.py:1095"]
    EXEC --> APPEND["结果 normalize 后 append 为 tool 消息"]
    APPEND --> FATAL{"致命工具错误?"}
    FATAL -- 是 --> BRK1["stop_reason=tool_error, break"]
    FATAL -- 否 --> DRAIN["drain 插话注入 → continue"]
    TC -- 否 --> EMPTY{"内容为空?"}
    EMPTY -- 是且重试<2 --> RETRY2["重试; 达上限→无工具 finalize"]
    EMPTY -- 否 --> LEN{"finish_reason=length?"}
    LEN -- 是且<3次 --> CONT["append + 续写提示 → continue"]
    LEN -- 否 --> ERR{"finish_reason=error?"}
    ERR -- 是 --> BRK2["arrears/通用错误, break"]
    ERR -- 否 --> FINAL["final_content=文本, break"]
    START -.预算耗尽.-> FIN["无工具 finalize<br/>_try_finalize_after_max_iterations"]
```

逐段精读几个 nanobot 的独到之处：

**① 何时执行工具**：`should_execute_tools`（`providers/base.py:176-179`）要求"有 tool_calls **且** finish_reason 是允许调工具的停止原因"。也就是说，**工具永远在一次完整（或流式结束）的模型响应之后才执行**，绝不边流边执行。流式只影响 token 呈现，不影响"何时动手"。

**② 中途插话（injection）**：`_drain_injections`（`runner.py:213-261`）通过 `injection_callback` 把 pending_queue 里攒下的用户消息捞出来（每轮最多 3 条，`_MAX_INJECTIONS_PER_TURN=3`），并做角色交替修复（连续两条 user 会被合并，`_append_injected_messages:124-146`）。整轮最多注入 5 个周期（`_MAX_INJECTION_CYCLES=5`）。这就是第 4 章"忙碌会话插话"的落地端。

**③ 空响应恢复**：模型偶尔只吐思考不吐正文。`runner.py:494-524`：先重试（`_MAX_EMPTY_RETRIES=2`），还不行就发一次"请给出最终答案"的**无工具 finalization 请求**（`_request_finalization_retry`）。

**④ 截断续写**：`finish_reason == "length"` 且有内容时，把已产出的内容 append 进去、再补一条"继续"消息，最多续 3 次（`_MAX_LENGTH_RECOVERIES=3`，`runner.py:526-545`）。

**⑤ 畸形工具调用自愈**：模型给出 `name=None/""` 的 tool_call 会让上游 API 永久拒绝整个会话。`_drop_malformed_tool_calls`（`runner.py:878-910`）在响应侧丢弃它们；若全被丢弃就先重试、再退化为无工具请求（`runner.py:845-875`）。`ContextGovernor.strip_malformed_tool_calls`（`context_governance.py:176-229`）则在**历史侧**清理，让被污染的会话"下一轮自愈"。

**⑥ 预算耗尽的收尾**：跑满 200 轮仍未收口时，`_try_finalize_after_max_iterations`（`runner.py:947-986`）做一次**禁用工具**的收尾调用，用已有材料给最佳答案；失败才回退到模板文案（`agent/max_iterations_message.md`）。

**⑦ 工具错误如何处理**：这是 nanobot 一个值得注意的点。默认 `fail_on_tool_error = True`（`config/schema.py:135`）。在 `_run_tool`（`runner.py:1146-1254`）里：
- **可恢复的安全边界错误**（SSRF、工作区越界）被 `_classify_violation`（`runner.py:1299-1337`）识别为"软错误"——原样喂回模型让它换招，附带一段不可绕过的边界说明（`_SSRF_BOUNDARY_NOTE:1263-1270`）；
- **普通工具错误**（返回 `ToolResult.error` 或抛异常）在 `fail_on_tool_error=True` 时会变成**致命 `tool_error`**，直接结束这一 turn（`runner.py:446-462`）。

也就是说，**大部分失败仍是"喂回错误让模型自愈"，但真正的工具失败（被拦截的命令、崩溃）默认会硬性终结当前 turn**——比 Raven"错误一律软字符串喂回"更严格。

### 崩溃恢复：nanobot 的"耐久性"招牌

"Durability Release" 名不虚传。nanobot **不用影子 git 快照对话**，而是把在途状态写进 `session.metadata`：

- **每轮工具执行前后**，AgentRunner 通过 `checkpoint_callback` 把"assistant 消息 + 已完成工具结果 + 待执行工具调用"写进会话元数据 `runtime_checkpoint`（`loop.py:841-844`、`_set_runtime_checkpoint:1826-1829`；runner 侧 `_emit_checkpoint`）。
- **提前落盘用户消息**：turn 一开始就把用户消息落盘并打上 `pending_user_turn` 标记（`_persist_user_message_early:666-699`）。
- **下一轮开头恢复**：`_state_restore` 调 `_restore_runtime_checkpoint`（`loop.py:1853-1905`）把中断的在途消息"物化"进历史，待执行但没跑完的工具补一条"Task interrupted before this tool finished"占位结果；`_restore_pending_user_turn`（`loop.py:1907-1925`）则给"只落了用户消息就崩了"的 turn 补一条"Task interrupted"assistant 回复。它用消息 key 做重叠去重，避免重复插入。
- **被 `/stop` 取消时**（`_dispatch` 的 `CancelledError` 分支，`loop.py:1160-1187`）：会把已 checkpoint 的部分上下文落盘，用户不丢已跑出来的工具结果。

会话文件本身是**原子写**（`.agent/gotchas.md:38-40`）：`history.jsonl` 用"临时文件 + fsync + rename + 目录 fsync"保证崩溃可恢复（`memory.py:460-483`）。

### 终止与中断机制

- **正常终止**：模型不再调工具（completed）/ 空最终响应（empty_final_response）/ LLM 错误（error）/ 工具致命错误（tool_error）/ 预算耗尽（max_iterations）。
- **硬取消**：`/stop` → 优先命令穿透 → `_cancel_active_tasks`（`loop.py:773-784`）对该会话的所有 task 调 `asyncio.Task.cancel()`，并 `cancel_by_session` 取消其子代理。这是**硬取消**（真的 `.cancel()` 抛 `CancelledError`），但配合 checkpoint 把已完成部分保住——可以理解为"硬取消 + 优雅落盘"。

### 【vs Raven】

- **结构**：nanobot **显式分离** AgentLoop（状态机编排）与 AgentRunner（模型循环），且用一张状态转移表把 turn 生命周期显式化。Raven fork 后把两者**合并进一个 `main.py`** 的 `_run_agent_loop`。
- **迭代预算**：nanobot 默认 **200**，Raven 默认 **40**。
- **崩溃恢复**：nanobot 用 **session.metadata 里的 runtime_checkpoint + pending_user_turn**（无额外 git）；Raven 用**每轮影子 git 快照 + checkpoint.py**。这是"耐久性"实现路线的分叉。
- **工具错误**：nanobot 默认 `fail_on_tool_error=True`（真错误终结 turn）；Raven 把工具错误一律当软字符串喂回模型。
- **插话**：nanobot 用 pending_queue + `_drain_injections`；Raven 用 `drain()` + BusyPolicy。

---

## 第 7 章 工具系统与权限：模型的"手"和"紧箍咒"

### 工具的统一长相

所有工具继承 `Tool`（`agent/tools/base.py`），提供 `name`/`description`/`parameters(JSON Schema)`/`execute` 四件套，外加若干开关属性：`read_only`、`exclusive`、`concurrency_safe`、`_scopes`（决定该工具在 core / subagent 哪个场景可用）。`ToolResult.error(...)` 是统一的错误载体（`tools/registry.py:15-16`）。

**发现与注册**靠 `ToolLoader`（`tools/loader.py`）：`pkgutil` 扫描 `agent/tools/` 下的模块（跳过 `base/schema/registry/...` 等基础模块，`loader.py:14-17`），把所有非抽象、`_plugin_discoverable` 的 `Tool` 子类收集起来；再通过 `importlib.metadata.entry_points(group="nanobot.tools")` 加载**外部插件工具**（`loader.py:62-84`）。`load()` 时按 `scope` 过滤、按 `enabled(ctx)` 过滤、处理命名冲突（内置优先，插件冲突则跳过并告警，`loader.py:100-107`）。

**执行入口** `ToolRegistry.execute`（`tools/registry.py:186-200`）：`prepare_call` 先做工具查找（找不到会用规范化 key 给"你是不是想调 X"的建议，`_suggest_name:58-69`）、参数 JSON 宽容解析、schema 校验（`prepare_call:111-149`），然后执行，所有异常兜成 `ToolResult.error`，并统一追加 `[Analyze the error above and try a different approach.]` 的"小抄"（`registry.py:188`）——用最笨的方式提升模型纠错率。

### 内置工具清单

按模块归类（`agent/tools/`）：

| 分组 | 工具 | 说明 | 文件 |
|---|---|---|---|
| 文件 | `read_file`/`write_file`/`edit_file`/`list_dir` | 读/写/精确替换编辑/列目录，走工作区路径解析器 | `filesystem.py`（1117 行） |
| 文件 | `apply_patch` | 默认代码编辑工具，支持多文件/结构化/`dry_run` | `apply_patch.py` |
| 搜索 | `grep`/`find_files` | 内容正则搜索 / 文件名查找，带二进制与大小限制 | `search.py` |
| 执行 | `exec` | 执行 shell（安全重点，见下），支持 `yield_time_ms` 长任务会话 | `shell.py`（923 行） |
| 网络 | `web_search`/`web_fetch` | DuckDuckGo 搜索 / readability 抓正文，走 SSRF 校验 | `web.py`（1139 行） |
| 沟通 | `message` | 主动/跨渠道发消息、投递本地文件与生成图 | `message.py` |
| 多代理 | `spawn` | 派生后台子代理（第 9 章） | `spawn.py` |
| 目标 | `create_goal`/`update_goal` | 持续目标（sustained goal），需显式 `/goal` 授权 | `long_task.py` |
| 媒体 | `generate_image` 等 | 文生图（opt-in，需配 key/model） | `image_generation.py`（1966 行） |
| 调度 | `cron` | 管理定时任务（仅当 cron 服务接线时注册） | `cron.py` |
| 会话 | `write_stdin`/`list_exec_sessions` | 与长时 exec 会话交互 | `exec_session.py` |
| 自省 | `my`（MyTool） | 读/改运行时状态（需 `my.enable`，`loop.py:624-628`） | `self.py` |
| CLI-App | `run_cli_app` | 运行用户附加的外部 CLI 应用 | `cli_apps.py` |
| MCP | `mcp_<server>_<tool>` | 每个 MCP 远程工具/资源/提示包装成原生工具 | `mcp.py`（1435 行） |

工具定义排序（`registry.py:86-109`）**内置在前、MCP 在后，各自排序并缓存**——对 prompt cache 友好。

### 权限与安全：四道前置防线（无逐次审批）

和 Raven 一样，**nanobot 没有 Claude Code 式"每次工具调用弹窗 y/n"的逐次审批**——它是常驻 IM 助手，没人守在旁边按确认。安全靠四层前置防御：

**第 1 层：危险命令正则黑名单**（`shell.py:214-232`），默认拦截：

```python
r"\brm\s+-[rf]{1,2}\b",              # rm -rf
r"(?:^|[;&|]\s*)format(?!=)\b",      # format
r"\b(mkfs|diskpart)\b",              # 磁盘
r"\bdd\s+if=",                       # dd
r">\s*/dev/sd",                      # 写磁盘
r"\b(shutdown|reboot|poweroff)\b",   # 关机
r":\(\)\s*\{.*\};\s*:",              # fork 炸弹
# 特别地：禁止直接写 nanobot 的内部状态文件
r">>?\s*\S*(?:history\.jsonl|\.dream_cursor)", ...
```

`allow_patterns` 优先于 `deny_patterns`，且要求**链式命令的每个 top-level 段都命中白名单**才算放行（`_guard_command:760-771`，`_split_shell_segments` 做了带引号/括号感知的分段）。注意它专门拦截了对 `history.jsonl`/`.dream_cursor` 的重定向/tee/cp/sed 写入——因为这些文件由 `append_history` 管理，手写会损坏游标格式并搞崩 `/dream`（`shell.py:224-231`）。

**第 2 层：工作区边界**（`.agent/security.md:6-13`）。文件工具走统一的工作区路径解析器（`tools/path_utils.py`），越界即拒。`exec` 也尊重 `restrict_to_workspace`（`shell.py:435-448` + `_guard_command:783-824`）：`working_dir` 在工作区外、命令含 `../`、或命令里出现指向工作区外的绝对路径，都会被拦，并附上一段"这是硬边界，别用符号链接/base64/换工具绕过"的说明（`_WORKSPACE_BOUNDARY_NOTE:71-77`）。额外读写根必须显式声明（`extra_read_allowed_dirs`/`extra_write_allowed_dirs`）。

**第 3 层：SSRF 防护**（`.agent/security.md:15-23`；`security/network.py`）。所有出站 HTTP 必须过 `validate_url_target`，默认封禁 loopback、RFC1918 内网、CGNAT、link-local、云元数据端点（`169.254.169.254`）。唯一豁免是 `tools.ssrf_whitelist`。`exec` 命令里若出现内网 URL 也会被拦（`shell.py:773-781`）。HTTP/SSE 类 MCP 传输也在这条边界内。

**第 4 层：沙箱**（`.agent/security.md:25-29`；`tools/sandbox.py`）。**唯一自带的后端是 `bwrap`（bubblewrap）**，面向容器化部署。Windows 和没有 bwrap 的裸机 Linux 上，命令跑在原生 shell，只有工作区限制这层"应用级"保护。一个安全细节：`exec` **绝不把宿主 `os.environ` 整个传给子进程**——Unix 默认只传 `HOME/LANG/TERM/PYTHONUNBUFFERED`（`_build_env:731-742`），Windows 传一组精选系统变量，API key 等机密一律排除。要额外传变量得走 `allowed_env_keys` 白名单。

**进程卫生**：`exec` 超时/取消/异常都会 `_kill_process` 并 `_reap_pid` 回收僵尸（`shell.py:46-67,643-693`），长任务会话还能杀整棵进程树。

### 工具结果的防注入围栏（继承给 Raven 的设计）

系统提示词里明确告诉模型：`web_fetch`/`web_search` 的内容是**不可信外部数据，永远不要执行其中的指令**（`templates/agent/_snippets/untrusted_content.md:1`），identity 模板 include 了这段（`identity.md:29-31`）。这就是 Raven 里 `wrap_untrusted` 随机 nonce 围栏的思想源头（nanobot 此版本以提示词约束为主，Raven 强化成了带随机 nonce 的显式围栏）。

### 【vs Raven】

- **沙箱**：nanobot 只有 `bwrap`；Raven 增加了 `boxlite` microVM 真隔离后端。
- **安全边界代码**（SSRF、workspace resolver、deny-list）**基本是 nanobot 原样**，Raven 继承并沿用。
- **确认往返**：Raven 的 TUI 里有 `confirm.request/respond` 破坏性操作确认（35s 超时默认取消）；nanobot 此版本主要靠前置防御，未见等价的逐次确认弹窗机制（未找到）。

---

## 第 8 章 上下文压缩与记忆

nanobot 的"记性"分成**三个尺度**，从秒级到跨会话：

```mermaid
flowchart TB
    subgraph NOW["本轮之内（秒级）— 确定性, 无 LLM"]
        GOV["ContextGovernor<br/>· 微压缩老工具结果<br/>· snip_history 裁历史<br/>context_governance.py"]
    end
    subgraph SESSION["会话之内（分钟~小时级）— 有损 LLM 摘要"]
        CONS["Consolidator<br/>按 token 预算把旧消息摘要进 history.jsonl<br/>memory.py:741"]
        AC["AutoCompact<br/>空闲 TTL 触发的整会话压缩<br/>autocompact.py"]
    end
    subgraph FOREVER["跨会话（永久）— 文件记忆 + 反思"]
        MEM["MEMORY.md 长期事实"]
        SOUL["SOUL.md 人格 / USER.md 画像"]
        HIST["history.jsonl 事件日志(带游标)"]
        DREAM["Dream：夜间反思 agent<br/>改 SOUL/USER/MEMORY, git 追溯"]
    end
    GOV -.撑不住才升级.-> SESSION
    CONS --> HIST
    HIST -->|read_recent_history| CTX["# Recent History 段"]
    DREAM -->|编辑| MEM & SOUL
```

### 尺度一：本轮内的确定性治理（ContextGovernor）

`ContextGovernor.prepare_for_model`（`context_governance.py:75-89`）是每轮迭代前对**模型侧副本**做的一条流水线（**绝不改持久化历史**，`context_governance.py:1-6`）：

1. 剥离占位 assistant 消息 / 畸形 tool_call / 孤儿 tool 结果，并回填缺失的 tool 结果（保证 tool_call 与结果配对，否则上游 API 报错）；
2. `apply_tool_result_budget`：把超长工具结果落盘/截断（`normalize_tool_result:109-136` + `maybe_persist_tool_result`——大结果写盘，`read_file` 可回捞，`read_file` 本身豁免以防"存→读→存"死循环）；
3. **`compact_inflight_overflow`（微压缩）**：当估算 token 超预算时，把**较老的、可压缩工具**（`COMPACTABLE_TOOLS = {read_file, exec, grep, find_files, web_search, web_fetch, list_dir, ...}`，`context_governance.py:32-35`）的结果替换成一句"结果已被压缩，别重复原样调用，请缩小范围重试"（`_tool_result_compaction_message:430-438`）。保留最近 10 条（`MICROCOMPACT_KEEP_RECENT=10`），只压 ≥500 字符的（`MICROCOMPACT_MIN_CHARS=500`），目标压到预算的 85%（`INFLIGHT_COMPACT_TARGET_RATIO=0.85`）。
4. **`snip_history`**：仍超预算时，从最旧的非 system 消息开始丢，保留合法的历史尾巴（`snip_history:383-428`）。

这是 nanobot 的"创可贴"：**纯确定性、不调 LLM、只动模型副本**，对应 Raven 的 `_emergency_shrink`。

### 尺度二：会话内的有损摘要（Consolidator）

`Consolidator.maybe_consolidate_by_tokens`（`memory.py:982-1089`）：估算会话 prompt token，一旦超过输入预算（`context_window - max_tokens - 1024`），就**循环**地在"用户 turn 边界"上切出最旧的一块，调 LLM 摘要（`archive`，`memory.py:922-980`，用 `agent/consolidator_archive.md` 系统提示），把摘要 append 进 `history.jsonl`，直到降到 `budget * consolidation_ratio`（默认 0.5，即压回预算一半，`config/schema.py:157-163`）。最多 5 轮（`_MAX_CONSOLIDATION_ROUNDS=5`）。

**关键：这是"有损"的**——被摘要的原始消息**离开上下文后不再回来**，摘要以 `[Archived Context Summary]` 注回（`context.py:118-119`）。若 LLM 摘要失败，退化为 `raw_archive`（原样 dump 进 history.jsonl，`memory.py:655-675`）并推进游标，绝不重复摘要同一块。

`AutoCompact`（`agent/autocompact.py`）负责**空闲会话压缩**：`session_ttl_minutes`（默认 15，`config/schema.py:151-156`）后对闲置会话调 `compact_idle_session`（`memory.py:1091-1162`），硬截断到合法尾巴并归档。

### 尺度三：跨会话的文件记忆 + Dream 反思

记忆是**一堆人能直接读写的文件**（`docs/architecture.md:174-179`），存在 `<workspace>/`：

- `SOUL.md`（人格）、`USER.md`（用户画像）——agent 工作区；
- `memory/MEMORY.md`（长期事实）；
- `memory/history.jsonl`（append-only 事件日志，带自增游标 `cursor` 与时间戳，`memory.py:253-304`）。

**Dream 是 nanobot 的记忆招牌**（`memory.py` 内，`AGENTS.md:41` 称之为 "two-phase memory consolidation"）。它本质是一个**受限的、临时的（ephemeral）agent**：

1. `build_dream_prompt`（`memory.py:532-558`）把"上次 Dream 之后的未处理历史"（`.dream_cursor` 游标控制）+ **三个记忆文件的当前全文**（`_render_current_memory_files:560-580`，让模型对着真实文件编辑，避免幻觉）拼成提示（模板 `agent/dream.md`）。
2. `build_dream_tools`（`memory.py:593-633`）给它一个**极度缩水的工具箱**：只有 `read_file` + 对 `skills/` 的 `write/edit/apply_patch` + 对三个记忆文件的精确写权限。它可以**创建技能**（`skill-creator`）和更新记忆，但碰不了别的。
3. **git 追溯**：`MemoryStore` 用纯 Python 的 `dulwich`（`GitStore`，`memory.py:82-84`）只跟踪 `SOUL.md/USER.md/memory/MEMORY.md/.dream_cursor` 四个文件。Dream 的提交信息**基于真实工作树 diff**（`dream_content_diff:582-591` + `build_dream_commit_message:686-702`），而**不是模型的自我报告**——`/dream-log` 审计记录反映文件系统的真相。游标是否推进也以"是否真有内容改动"为准，不信 LLM 自述。
4. **调度**：Dream 作为 cron 系统任务运行（`cli/commands.py:2031-2039`），也可 `/dream` 手动触发（`command/builtin.py:399`）。旧 dream 会话文件会被 `prune_dream_sessions` 修剪，只留最近 10 个（`memory.py:704-726`）。

`history.jsonl` 还做了体面的健壮性处理：游标分配加锁保证原子（`memory.py:290`）、`strip_think` 去除模板泄漏（`memory.py:287`）、损坏条目一次性告警并跳过（`_iter_valid_entries:313-342`）、legacy `HISTORY.md` 一次性迁移（`_maybe_migrate_legacy_history:100-137`）。

### 压缩哲学总结

nanobot 的压缩是**"有损摘要 + 确定性微压缩"的组合拳**：秒级用确定性微压缩（不丢语义、只压工具输出），会话级用 LLM 摘要（**丢细节、换体积**），跨会话靠 Dream 把有价值的东西提炼进 Markdown 记忆。它**没有 Raven 的 Curator（无损归档+引用+回注）**——那是 Raven fork 后新增的"保真派"路线。

### 【vs Raven】

- **压缩路线**：nanobot 是**有损摘要为主**（Consolidator → history.jsonl，原文不可还原）；Raven 新增 **Curator 无损归档**（原文逐字落盘 + 引用 + Working State 回注）作为默认，把 Consolidator 降级为记忆写手。
- **长期记忆后端**：nanobot 是**纯文件 + dulwich git**，无向量库；Raven 叠加了 **EverOS 双轨向量记忆**（用户轨/agent 轨）+ SkillForge 检索排序。
- **反思机制**：nanobot 的 **Dream**（改 SOUL/USER/MEMORY，git 追溯）是原创；Raven 保留了类似能力但重心转向 EverOS。

---

## 第 9 章 子 agent 与多 agent：分身术

### 比喻：派个临时工去后台干活

主 agent 遇到"又大又独立"的活，可以 `spawn` 一个子代理去后台跑，干完把报告塞回主对话。这个机制 **Raven 几乎原样继承**——所以这里看到的是"出厂设置"。

### 实现：SubagentManager

入口是 `spawn` 工具（`tools/spawn.py:56-88`），模型给 `task` 描述即可。真正的管家是 `SubagentManager`（`agent/subagent.py:82`）。

**并发闸**：`spawn` 前检查 `get_running_count() >= max_concurrent_subagents`（`spawn.py:64-71`），超限直接拒绝并提示"等一个跑完再派"。默认 `max_concurrent_subagents = 1`（`config/schema.py:134`，`ge=1`）——**默认只允许一个子代理**，相当保守。

**工具箱刻意缩水**：`_build_tools`（`subagent.py:197-217`）用 `scope="subagent"` 加载工具。只有声明了 `_scopes` 含 `"subagent"` 的工具才进得来（如 `ExecTool._scopes = {"core", "subagent"}`，`shell.py:167`）——即读/写/编辑/列目录 + exec + web。**没有 `message`（不能越级找用户）、没有 `spawn`（不能再生孩子）**，从结构上杜绝递归失控。子代理用独立的 `ExecSessionManager`。

**结果如何回来**：这是最妙的一环（`_announce_result:372-415`）。子代理完成后**不直接发消息**，而是构造一个 `channel="system"`、`sender_id="subagent"`、带 `injected_event="subagent_result"` 与 `subagent_task_id` 的 `InboundMessage`，用 `session_key_override` 对齐主会话键，`publish_inbound` 回**同一条消息总线**：

```python
msg = InboundMessage(
    channel="system", sender_id="subagent",
    chat_id=f"{origin['channel']}:{origin['chat_id']}",
    content=announce_content,               # 用 agent/subagent_announce.md 渲染
    session_key_override=override,
    metadata={"injected_event": "subagent_result", "subagent_task_id": task_id},
)
await self.bus.publish_inbound(msg)
```

于是它被路由进主会话的 pending_queue，作为**中途插话**被主 agent 在迭代间隙捞起（第 4、6 章），主 agent 像收到一条新消息一样被唤醒并转述给用户。`loop.py` 里对 subagent 结果有专门处理：作为 assistant 记录持久化、但以"新的 follow-up 输入"呈现给模型（`_persist_subagent_followup:1802-1824`；`_state_build:1554-1563`），并去重同一 `task_id`。

**取消**：`cancel_by_session`（`subagent.py:456-465`）取消该会话所有子代理 task 并终止其 exec 会话——`/stop` 时被主 loop 的 `_cancel_active_tasks` 连带调用。

```mermaid
sequenceDiagram
    participant M as 主 Agent(AgentRunner)
    participant SM as SubagentManager
    participant SA as 子代理(缩水工具箱)
    participant BUS as MessageBus
    M->>SM: spawn(task)
    SM->>SM: 并发闸: running < max_concurrent?
    SM->>SA: create_task 后台跑(独立 runner)
    SM-->>M: "已启动, 完成会通知"
    Note over SA: 独立循环, 无 message/spawn
    SA->>SM: 完成(final_content)
    SM->>BUS: publish_inbound(system/subagent, injected_event)
    BUS->>M: 路由进 pending_queue → 中途插话
    M-->>M: 转述结果给用户
```

### 与其他"多身份"turn 的关系

除了 subagent，nanobot 还有几类"非用户发起"的 turn，都通过 `channel="system"` + metadata 区分：cron 触发（`CronTurnCoordinator`）、本地触发器（`LocalTriggerTurnCoordinator`）。它们在忙碌会话时会被 `defer_if_active` 延后（`loop.py:1064-1076`），不抢占用户。

### 【vs Raven】

- **回报机制**：**几乎相同**（子代理经消息总线以 `injected_event` 回主会话），因为 Raven 就是从这里 fork 的。
- **限流**：nanobot 只有 `max_concurrent_subagents`（默认 **1**）；Raven 把默认并发提到 **4**，并**新增了"每会话每小时最多 30 个"的频率闸**——nanobot 此版本**未见**该频率闸（未找到）。
- **迭代上限**：nanobot 子代理与主 agent 共用 `max_iterations`（默认 200，`subagent.py:131-135` 从 AgentDefaults 取）；Raven 把子代理单独压到 15。

---

## 第 10 章 生态：命令、扩展与插件

### 斜杠命令

`command/builtin.py` 注册了一套斜杠命令，分"优先"（可穿透忙碌会话）与"普通"（`register_builtin_commands:995-1020`）：

| 命令 | 作用 | 类型 |
|---|---|---|
| `/stop`、`/restart`、`/status` | 停止 / 重启 / 状态 | 优先（`is_priority`） |
| `/new` | 清空当前会话（先归档） | exact |
| `/model` | 查看/切换模型 | exact |
| `/history` | 查看历史 | exact |
| `/goal` | 激活持续目标（sustained goal） | exact |
| `/trigger` | 管理本地触发器 | exact |
| `/dream`、`/dream-prompt`、`/dream-log`、`/dream-restore` | 手动做梦 / 改梦提示 / 看审计 / 回滚 | exact |
| `/evaluator-prompt` | 改评估器提示 | exact |
| `/skill` | 技能管理 | exact |
| `/pairing` | DM 配对码 | exact |
| `/help` | 帮助 | exact |

命令在 `_state_command`（`loop.py:1485-1524`）路由；"快捷命令"直接产出结果并跳过 BUILD/RUN（不花 token）。

### MCP

`agent/tools/mcp.py`（1435 行）实现完整 MCP 支持：`connect_mcp_servers`（`mcp.py:833`）连接 `tools.mcpServers` 配置的服务器，把每个远程 **工具/资源/提示**分别包装成 `mcp_<server>_<tool>` / `mcp_<server>_resource_<name>` / `mcp_<server>_prompt_<name>` 的原生 Tool（`mcp.py:454,610,708`），注册进同一个 ToolRegistry——对模型而言与内置工具无异。工程细节：处理 MCP SDK 的 anyio cancel scope 在超时/失败时泄漏 `CancelledError` 的坑（`mcp.py:488-489` 等多处 `except asyncio.CancelledError`），并对 stdio server 命令做 Windows 路径规范化（`.agent/gotchas.md:21`）。HTTP/SSE MCP 端点受 SSRF 边界约束（`.agent/security.md:21`）。

### 插件系统

两类扩展点（`docs/architecture.md:197-207`）：
- **工具插件**：通过 `entry_points(group="nanobot.tools")` 注册外部 `Tool` 子类（`loader.py:62-84`），会被 `_LegacyErrorPrefixTool` 包一层做错误契约兼容（`loader.py:121-186`）。
- **channel 插件**：导出 `ChannelPlugin` 描述符，一个自包含包（`docs/channel-package-guide.md`）。
- CLI 有 `nanobot plugins list/enable/disable`（`cli/commands.py:2547-2626`）。

### 技能（Skills）

`agent/skills.py`：技能是 `<workspace>/skills/<名>/SKILL.md` 或内置 `nanobot/skills/<名>/SKILL.md`（Markdown + YAML frontmatter）。内置技能有：`clawhub`、`cron`、`github`、`image-generation`、`memory`、`my`、`skill-creator`、`summarize`、`tmux`、`update-setup`、`weather`。加载策略是**渐进式披露**：`always` 技能全文常驻，其余只给摘要，模型按需 `read_file` 展开（`context.py:94-102`；`skills.py:111`）。工作区技能覆盖同名内置技能（`skills.py:61-66`）。外部技能可发布/安装到 **ClawHub**（`.agent/gotchas.md:36`；内置 `clawhub` 技能）。

### 消息平台（17 个）

`nanobot/channels/` 下 17 个自包含包：`dingtalk`、`discord`、`email`、`feishu`、`matrix`、`mattermost`、`mochat`、`msteams`、`napcat`、`qq`、`signal`、`slack`、`telegram`、`websocket`（承载 WebUI）、`wecom`、`weixin`、`whatsapp`。都由 `channels/manager.py` pkgutil 自动发现（`AGENTS.md:39`），网关模式统一拉起。

### 对外接口

- **Python SDK**：`nanobot/nanobot.py`（`AGENTS.md:55`）。
- **OpenAI 兼容 HTTP API**：`nanobot/api/server.py` 暴露 `/v1/chat/completions`、`/v1/models`（`AGENTS.md:45`），可当普通 OpenAI 端点接入第三方。

```mermaid
flowchart LR
    subgraph CORE["nanobot 内核"]
        REG[ToolRegistry]
        SK[SkillsLoader]
    end
    MCP[MCP 服务器群] -->|mcp_*_* 工具/资源/提示| REG
    EP["entry_points('nanobot.tools') 插件"] --> REG
    CLAW[ClawHub 技能市场] --> SK
    LOCAL[workspace/skills/*] --> SK
    BUILTIN[内置 skills/*] --> SK
    IM[17 个平台] -->|BaseChannel| MGR[ChannelManager]
    MGR --> BUS[MessageBus]
    API[OpenAI 兼容 API] --> BUS
    SDK[Python SDK] --> LOOP[AgentLoop]
```

### 【vs Raven】

- **技能市场**：nanobot 用 **ClawHub**；Raven 换成了 **Skill Hub** + SkillForge 加权 RRF 检索排序。
- **平台数量**：nanobot 17 个；Raven 约 12 个（fork 时的子集）。
- **前端接口**：nanobot 提供**浏览器 WebUI + OpenAI 兼容 API**；Raven 提供 **Ink TUI + TUI-RPC**。

---

## 第 11 章 功能特性：认证、模型与"思考"

### Provider 阵容（~40 家，直连 SDK）

`providers/registry.py:135-715` 的 `PROVIDERS` 元组是**单一事实来源**——加一家 provider 只需加一个 `ProviderSpec` + 一个 config 字段（`registry.py:4-7`）。顺序即优先级（网关优先）。涵盖：

- **网关类**（按 key/base 探测，能路由任意模型）：OpenRouter（`sk-or-` 前缀，支持 prompt caching + `reasoning_effort`）、OpenCode Zen/Go、Hugging Face、Skywork、AiHubMix、SiliconFlow、Novita、VolcEngine（火山，含 Coding Plan）、BytePlus、ModelScope。
- **标准直连**：Anthropic（原生 SDK，支持 prompt caching）、OpenAI、DeepSeek、Gemini、Zhipu(GLM)、DashScope(Qwen)、Moonshot(Kimi)、Kimi Coding（Anthropic 协议）、MiniMax、Mistral（含 Magistral 隐式推理特判）、StepFun、Xiaomi MIMO、LongCat、Ant Ling、Qianfan(ERNIE)。
- **OAuth 类**（无 API key）：OpenAI Codex（内置 GPT-5.x 模型目录）、xAI Grok（内置 Grok 4.5 + X Search）、GitHub Copilot。
- **企业/本地**：Azure OpenAI、AWS Bedrock（原生 Converse API）、vLLM、Ollama、LM Studio、Atomic Chat、OpenVINO、NVIDIA NIM。
- **辅助**：Groq、AssemblyAI（仅转写）。

大部分 provider 走 `openai_compat_provider.py`（1746 行），Anthropic/Azure/Bedrock/Codex/Copilot/xAI 有专用后端（`registry.py:50-52`）。默认模型是 `anthropic/claude-opus-4-5`，provider `auto`（`config/schema.py:124-127`）。

**Provider 解析**（`docs/architecture.md:60-66`）：显式配置 > 注册表关键词 > API key 前缀 / base URL 关键词 > 本地 fallback > 网关兜底。每条路径都能从工厂追溯到具体类（`.agent/design.md:29`）。

### 认证形态

- **API key**：`env_key`（如 `ANTHROPIC_API_KEY`）或 config 里的 `apiKey`（支持 `${VAR}` 环境变量引用，但**不是 shell 默认值语法**，缺失即报错回退默认配置，`.agent/gotchas.md:7-14`）。
- **OAuth**：Codex/Grok/Copilot 走 `oauth-cli-kit`（`pyproject.toml:31`），`nanobot provider login`（`cli/commands.py:2752`）。
- **首配向导**：`nanobot onboard`（`cli/onboard.py`，1985 行）交互式配置 provider/模型/沙箱/channel 等。

### Fallback 与重试

`fallback_provider.py` 是一个透明降级包装器：主模型返回**可降级错误且尚未流出内容**时，依次尝试 `fallback_models`（`config/schema.py:132`），每个 fallback 可用不同 provider/参数（`fallback_provider.py:240-307`）。错误被结构化分类（`providers/base.py`）：超时/速率/网络 → 可重试可降级；欠费（arrearage）→ 致命（`is_arrearage_response:435`，返回专门的"API key 欠费/超额"文案，`runner.py:48-51,574-578`）；上下文溢出 → 应压缩而非换模型。`provider_retry_mode` 支持 `standard`/`persistent`（`config/schema.py:137`）。

### 模型选择与热切换

`ModelPresetConfig`（`config/schema.py:99-116`）是"模型 + 生成参数"的命名预设，`/model` 可切换（`command/builtin.py:343`），会话级偏好存在 `session.metadata`（`loop.py:545-555`）。config 文件改动被 watcher 热加载（`loop.py:516-519`）。

### "思考"（reasoning / thinking）

nanobot 把推理当一等公民，且**按 provider 差异细致适配**（这是它 provider 层"显式"哲学的集中体现）：

- `reasoning_effort`（low/medium/high/adaptive/none，`config/schema.py:145`）统一表达"思考努力"，`None` 保留 provider 默认。
- **每家 provider 的思考开关不同**，注册表用 `thinking_style` 字段区分（`registry.py:85-91`）：`thinking_type`（DeepSeek/火山/BytePlus/MIMO）、`enable_thinking`（DashScope/ModelScope）、`reasoning_split`（MiniMax）；网关侧 `gateway_reasoning_style="reasoning_effort"`（OpenRouter）。
- **推理内容与正文分离**：`reasoning_content`/`thinking_blocks` 单独传输（`runner.py:375-387`），`extract_reasoning`/`strip_think` 负责把思考从正文剥出；历史里保留思考字段但对某些 provider（Mistral）会 `strip_history_reasoning_content`（`registry.py:120-124`）。特殊情况：StepFun 把答案放在 `reasoning` 字段，用 `reasoning_as_content=True` 当正文（`registry.py:98-101`）；Mistral 的 Magistral 隐式推理、拒绝 `reasoning_effort` 参数，用 `implicit_reasoning_models` 特判（`registry.py:109-112`）。

### 其他特色

| 特性 | 位置 | 一句话 |
|---|---|---|
| 持续目标（sustained goal） | `agent/tools/long_task.py`、`session/goal_state.py` | `/goal` 显式授权后，`create_goal`/`update_goal` 让 agent 跨多轮追一个长期目标；runner 每轮结束若目标仍活跃就注入"继续"提示（`runner.py:171-174`、`loop.py:932-941`） |
| Dream 反思 | `agent/memory.py` | 见第 8 章 |
| Heartbeat | `templates/HEARTBEAT.md` + cron | 周期性检查任务清单（`cli/commands.py:2044-2055`） |
| OpenAI 兼容 API | `api/server.py` | 把 nanobot 当 OpenAI 端点 |
| 配对/审批 | `pairing/` | DM 发送者需配对码才能触达 agent |
| 长任务 exec 会话 | `tools/exec_session.py` | `yield_time_ms` 让长命令返回可轮询的 session_id |

### 【vs Raven】

- **模型接入**：nanobot **自研 ~40 家 provider 注册表 + 直连 SDK + 细致的 thinking 差异适配**；Raven 换用 **LiteLLM** 统一适配。这是最大的功能层差异。
- **主动性**：nanobot 有 cron/heartbeat/本地触发器/sustained goal；Raven 在此之上新增了 **Sentinel"注意力流水线"**（事件驱动主动找你）。
- **成本**：nanobot 未见独立的 TokenWise 省钱层；Raven 新增了 TokenWise（用量追踪 + 自适应缓存 + 路由）。

---

## 第 12 章 总结：设计哲学与取舍

先用一张图复盘 nanobot 的三根支柱——它们解释了前 11 章几乎所有"非常规"选择：

```mermaid
flowchart TB
    GOAL["目标：一个你能完全自持、<br/>长期驻留在聊天软件里的个人 Agent"]
    GOAL --> P1["支柱1 核心极小<br/>loop/runner 分层 + MessageBus<br/>能力从边缘长出<br/>→ 第2/6章"]
    GOAL --> P2["支柱2 耐久可恢复<br/>原子落盘 + metadata checkpoint<br/>崩溃/取消不丢上下文<br/>→ 第6章"]
    GOAL --> P3["支柱3 聊天原生 + 自持<br/>17 平台 + WebUI + 文件记忆 + Dream<br/>不依赖任何托管平台<br/>→ 第4/8/10章"]
    P1 & P2 & P3 --> BASE["地基：显式优于魔法<br/>+ 宁可重复不过早抽象<br/>+ 自研 provider 层(不靠 LiteLLM)<br/>→ 第1/7/11章"]
    style GOAL fill:#e8f5e9
    style BASE fill:#eceff1
```

### 五个独特想法

**① 把 turn 生命周期做成一张显式状态机**。`RESTORE→COMPACT→COMMAND→BUILD→RUN→SAVE→RESPOND→DONE`（`loop.py:105-113,248-257`）+ 每步 trace 耗时——大多数 agent 把这套逻辑埋在一个大函数里，nanobot 把它摊成一张可读、可调试、可扩展的转移表。

**② AgentLoop / AgentRunner 的干净分层**。"channel 侧编排" 与 "model 侧循环" 分成两个文件、两个职责（`docs/architecture.md:36-54`）。这让"核心保持小"从口号变成可执行的边界：改路由看 loop，改模型循环看 runner。

**③ 耐久性不是宣传语而是工程细节**。原子写（temp+fsync+rename+dir fsync）、runtime_checkpoint 写进 session.metadata、pending_user_turn 兜底、`/stop` 硬取消后仍落盘部分上下文——"Durability Release" 的名字落到了每一处 IO。

**④ Dream：一个会"做梦整理记忆"的受限小 agent**。用极简工具箱改三个 Markdown 记忆文件，提交信息以**真实 git diff** 为准而非模型自述（`memory.py:686-702`）。这是把"记忆应对用户透明、可审计、可回滚"贯彻到底的原创设计。

**⑤ 自研 provider 层 + 极致的差异适配**。不靠 LiteLLM，自己维护 ~40 家 provider，连"每家怎么开思考模式""哪个模型拒绝 reasoning 参数""哪个把答案塞在 reasoning 字段"都逐一特判（`registry.py`）。代价是维护量大，收益是每条路径都可追溯、可控。

### 主要取舍（代价）

- **朴素调度的天花板**：两条 asyncio 队列 + 每会话一把锁 + pending_queue 插话，简单好懂，但没有优先级/背压/跨会话公平——高并发多用户场景会吃力（这正是 Raven 造 Spine 的动机）。
- **有损压缩会丢细节**：Consolidator 摘要后原文不可还原；长会话里早期细节会永久消失（Raven 用 Curator 无损归档来补这个缺口）。
- **provider 适配的维护税**：~40 家 provider 的手工特判是持续的负担，模型/API 一变就要跟。
- **安全靠前置防御的赌注**：放弃逐次审批、押注"黑名单 + 工作区边界 + SSRF + bwrap"，对无人值守的 IM 场景是对的，但黑名单本质是 best-effort（`shell.py:752` 注释自认），真正兜底还得靠沙箱——而唯一自带沙箱是 bwrap，非 Linux/无容器环境保护偏弱。
- **默认子代理并发只有 1**：保守安全，但复杂并行任务需要用户显式调高。

### nanobot vs Raven 一图流

| 维度 | nanobot（上游） | Raven（fork 下游新增/改动） |
|---|---|---|
| 语言/运行时 | Python + asyncio | 同（Python 内核）+ Node TUI |
| 模型接入 | 自研 ~40 家 provider，直连 openai/anthropic SDK | 换用 **LiteLLM** 统一适配 |
| 调度 | MessageBus 两队列 + 每会话锁 + pending_queue | **Spine/Scheduler/Lane** 调度层 + BusyPolicy |
| 核心结构 | **AgentLoop/AgentRunner 分离** + 显式状态机 | 合并进单个 `main.py` |
| 迭代预算 | 默认 **200** | 默认 40 |
| 前端 | 浏览器 **WebUI** + OpenAI API | fork hermes 的 **Ink TUI** + TUI-RPC |
| 崩溃恢复 | session.metadata checkpoint（无对话 git） | **每轮影子 git 快照** |
| 上下文压缩 | 确定性微压缩 + **有损 LLM 摘要** | 新增 **Curator 无损归档** |
| 长期记忆 | 纯文件（MEMORY/SOUL/USER）+ **Dream** + dulwich git | 叠加 **EverOS 双轨向量记忆** + SkillForge |
| 沙箱 | 仅 **bwrap** | 新增 **boxlite microVM** |
| 子代理 | 默认并发 1，无每小时频率闸 | 并发 4 + 每小时 30 个频率闸 |
| 主动性 | cron/heartbeat/触发器/sustained goal | 新增 **Sentinel** 事件主动性 |
| 省钱层 | 无独立层 | 新增 **TokenWise** |
| prompt cache | 固定断点 + 稳定工具排序 | 自适应 ≤4 断点（TokenWise） |
| 技能市场 | ClawHub | Skill Hub + SkillForge |

**一句话收束**：Claude Code 回答"怎么把代码写好"，Raven 回答"怎么让 agent 越用越好"，而 **nanobot 回答的是"怎么让一个足够小、足够诚实、崩了也不丢东西的 agent，长期住在你所有聊天软件里、还完全归你所有"**。它把"小核心 + 边缘扩展 + 耐久落盘 + 文件记忆"打磨成一个可读、可自持、可审计的开源样本——既是一个能直接用的个人 agent，也是理解 Raven 等下游项目"出厂时长什么样"的最佳底本。

---

## 写作说明

- **分析基准**：本文全部结论基于 nanobot 仓库 commit `6a9157f4774e26cf0eda6e8c59bed52e05395e6a`（`main` 分支，2026-07-24，版本 `v0.2.2`，最新提交 "feat(webui): present chats as topics"）。commit 用 `git -C 参考项目/nanobot rev-parse HEAD` 获取。
- **核对方式**：所有 `文件:行号` 均通过 `Read`/`Grep` 实际打开源码逐一核对，相对路径以 `参考项目/nanobot/` 为根。凡无法从源码确证之处（如"确认往返弹窗""子代理每小时频率闸"），已在正文明确标注"未找到"，未做臆测。
- **与 Raven 的对照**：基于 Raven 分析文档记载的 fork 关系（Raven fork 自 nanobot v0.1.5.post3）与 nanobot 当前源码的实际差异归纳，"Raven 新增/改动"部分以 Raven 文档结论 + nanobot 源码反证的方式给出；两项目版本已各自演进，具体以各自仓库最新代码为准。
- **诚实声明**：本文分析对象是 **nanobot 本身**，未照搬 Raven 文档的任何结论；凡 nanobot 与 Raven 不同之处均以 nanobot 真实源码为准并显式指出。

*全文完。*
