# nanobot source analysis: a “minimal” personal agent that lives in chat apps

> **Subject**: [HKUDS/nanobot](https://github.com/HKUDS/nanobot) (PyPI package `nanobot-ai`)
> **Based on commit**: `6a9157f4774e26cf0eda6e8c59bed52e05395e6a` (2026-07-24, `main` branch, version `v0.2.2` “Durability Release”)
> **Analysis date**: 2026-07-24
> **Audience**: a beginner with a little programming. Each chapter follows “analogy → real source → line-by-line → design trade-offs.”
> **Note**: this lecture uses the same 12-chapter scaffold as the *Claude Code / opencode / hermes-agent / Raven / CodeWhale / goose* analyses in this directory, for side-by-side comparison. Every `file:line` is relative to `参考项目/nanobot/` and can be checked at this commit. Anything not found or uncertain is stated as such; nothing is invented.
> **A special lineage note**: another project, **Raven (EverMind-AI)**, has a Python runtime that was **forked from nanobot (`v0.1.5.post3`)**. So besides analyzing nanobot on its own, this lecture keeps marking **“this is nanobot’s original shape; after the Raven fork it became X”**, laying track for later comparison.

---

## Chapter 1 · Project overview: what it is, who builds it

### One-sentence version

**nanobot is the HKU Data Intelligence Lab (HKUDS) open-source “ultra-lightweight, fully self-owned (you can truly own) personal AI agent runtime”**: a tiny core that still ships WebUI, a dozen-plus chat platforms, tools, memory, MCP, model routing, scheduled automation, and deployment as a complete Python framework, MIT license (`LICENSE:1-3`, copyright “Xubin Ren and the nanobot contributors”). The README’s first sentence (`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.

### Positioning, by analogy

If Claude Code is a “high-end sushi counter” (polished around writing code), nanobot is more like a **home-placed “personal server + all-purpose butler”**: it lives by default on your machine or a cloud host, takes over every chat app (Telegram, WeChat, Feishu, Slack, Discord, email……), and is on call; at the same time it deliberately keeps the “brain” (the agent loop) small and readable — the README repeats “small core / readable internals” (`README.md:107`), and `.agent/design.md:5-7` makes “keep the core small, grow capability at the edge” the first design iron law:

> 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.

That is the key to the whole project: **nanobot leaves “smart” to the model and presses “structure” to a minimum** (`.agent/design.md:11-13`: “Less structure, more intelligence”).

### Who builds it

- **HKUDS**: the open-source org under the University of Hong Kong Data Intelligence Lab (Chao Huang’s group), also authors of LightRAG, MiniRAG, and other well-known projects. nanobot’s primary author is credited as **Xubin Ren** (`LICENSE:3`).
- **Product shape**: `v0.2.2` is themed “Durability Release” (`README.md:73`). Recent work concentrates on crash recovery, session/gateway reliability, and WebUI segmented records — consistent with “it should run for a long time on your machine.”

### Lineage with Raven / hermes (important)

- **nanobot is the upstream.** When Raven (EverMind-AI) forked, it pinned nanobot `v0.1.5.post3` as the Python base runtime (agent, channels, cli, providers, session, …), then stacked EverOS long-term memory, Curator lossless compaction, Sentinel proactivity, and a TUI (the last from hermes-agent) into a “self-improving harness.”
- **So this lecture has a hidden value**: many designs you see here (MessageBus, AgentLoop/AgentRunner split, UNTRUSTED fencing, SSRF protection, sub-agent results returning via the bus……) are **Raven’s factory defaults**. Whenever the Raven lecture says “we changed/added X,” you can look here to see what it changed *from*. Differences are marked **【vs Raven】** at the end of each chapter.

### Stack at a glance

| Layer | Tech | Location / evidence |
|---|---|---|
| Runtime language | Python ≥ 3.11, asyncio throughout | `AGENTS.md:69`, `pyproject.toml:22` |
| CLI framework | typer + rich + prompt-toolkit + questionary | `pyproject.toml:26-45` |
| LLM SDK | **direct `openai` + `anthropic` official SDKs** (**no LiteLLM**) | `pyproject.toml:27,45`; `nanobot/providers/` |
| Model protocol | first-party `openai_compat_provider` + dedicated anthropic/azure/bedrock/codex/copilot backends | `nanobot/providers/registry.py:52` |
| Memory storage | plain files (Markdown + JSONL) + pure-Python git (`dulwich`) | `pyproject.toml:48`; `nanobot/agent/memory.py:82` |
| Frontend | React + Vite SPA (browser WebUI), multiplexed WebSocket | `AGENTS.md:44`; `webui/` |
| MCP | official `mcp` SDK | `pyproject.toml:41` |
| Search/fetch | `ddgs` (DuckDuckGo), `readability-lxml` | `pyproject.toml:32,36` |
| Scheduling | `croniter` | `pyproject.toml:38` |

> **First key difference**: **nanobot does not depend on LiteLLM** (`nanobot/cli/models.py:3` only has a leftover comment “litellm temporarily disabled”). It maintains its own registry of ~40 providers and an OpenAI-compatible adapter. **Raven switched to LiteLLM** for unified adaptation. That is the fundamental split in “how we attach models”: nanobot chooses to **own every provider resolution path** (echoing `.agent/design.md:29` “Explicit over magical”); Raven chooses to **outsource adaptation to 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
```

---

## Chapter 2 · Architectural panorama: a layered map

### Analogy: a “shop in front, factory in back” workshop

nanobot’s architecture is a small workshop with clean roles:

- **Front counter (Channels / WebUI / CLI)**: a dozen receiving windows; every platform’s message is translated into the same “inbound slip” (`InboundMessage`);
- **Conveyor (MessageBus)**: one `inbound` queue in, one `outbound` queue out, fully decoupling front and back;
- **Shop floor lead (AgentLoop)**: takes slips off the belt, owns sessions, workspace, context assembly, outbound delivery;
- **Line worker (AgentRunner)**: actually talks to the model, runs tools, handles streaming and exceptions;
- **Warehouse (Session / Memory / Dream)**: session history, long-term memory files, nighttime “dream” consolidation.

The official architecture diagram (`docs/architecture.md:9-23`) is exactly this chain; here it is as a layered figure:

```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
```

### Main-package directory duties (one by one)

Subdirectories under `nanobot/` (against `AGENTS.md:35-51` and the actual tree):

| Directory | Duty | Key files |
|---|---|---|
| `agent/` | **Core**: turn orchestration, model loop, context, memory, tools, sub-agents | `loop.py`, `runner.py`, `context.py`, `memory.py`, `context_governance.py`, `subagent.py`, `tools/` |
| `agent/tools/` | all tool implementations + register/discover | `registry.py`, `loader.py`, `filesystem.py`, `shell.py`, `web.py`, `mcp.py`, `spawn.py`, `long_task.py` |
| `api/` | OpenAI-compatible HTTP API (`/v1/chat/completions`) | `api/server.py`, `api/runtime.py` |
| `apps/` | CLI-App attachment (hang an external CLI into a turn as a capability) | `apps/cli/service.py` |
| `audio/` | speech (transcription/TTS) | — |
| `bus/` | message bus and event types | `bus/queue.py`, `bus/events.py`, `bus/runtime_events.py` |
| `channels/` | 17 chat-platform adapter packages (self-contained, pkgutil auto-discovery) | `channels/manager.py`, `channels/<platform>/` |
| `cli/` | typer command tree, onboard wizard | `cli/commands.py` (2972 lines), `cli/onboard.py` |
| `command/` | slash-command routing and builtins | `command/builtin.py` |
| `config/` | Pydantic config schema / load / paths | `config/schema.py`, `config/loader.py`, `config/paths.py` |
| `cron/` | scheduled-job service (Dream/heartbeat also go through it) | `cron/service.py` |
| `gateway/` | gateway runtime (brings up every service) | `gateway/runtime.py` |
| `pairing/` | DM sender pairing / approval codes | `pairing/` |
| `providers/` | LLM provider registry and implementations | `providers/registry.py`, `providers/base.py`, `providers/openai_compat_provider.py`, `providers/fallback_provider.py` |
| `security/` | workspace boundary, SSRF, PTH guards | `security/network.py`, `security/workspace_access.py` |
| `session/` | session history, compaction, goal state | `session/manager.py`, `session/goal_state.py` |
| `skills/` | built-in skills (Markdown + YAML frontmatter) | `skills/<name>/SKILL.md` |
| `triggers/` | local triggers | `triggers/local_turns.py` |
| `templates/` | Jinja2 system-prompt / scenario templates | `templates/agent/*.md` |
| `utils/` | helpers, token estimate, git store | `utils/helpers.py`, `utils/gitstore.py`, `utils/prompt_templates.py` |
| `web/` | WebUI static artifacts packed into the wheel | `web/dist/` |
| `webui/` (repo root) | WebUI source (React/Vite) | `webui/` |

### Three “layering disciplines”

1. **Keep the core small; grow capability at the edge.** `loop.py`/`runner.py` are explicitly the “critical core path”; changes should be restrained (`.agent/design.md:5-7`). New capability should be a channel / tool / skill / MCP.
2. **Prefer duplication over premature abstraction.** `.agent/design.md:15-17` explicitly allows channels and providers to **duplicate** send-retry, media handling, message splitting, and forbids inventing a complex base class just to kill duplication — “each channel file should be readable on its own.” That is why every platform under `channels/` is a thick self-contained package.
3. **Explicit over magical.** Config must be declared on the Pydantic models in `config/schema.py`; provider auto-detection exists, but “every resolution path must be traceable from the factory to a concrete class” (`.agent/design.md:27-29`).

### Process / thread model

- **Single-process asyncio**: in gateway mode (`nanobot gateway`) everything runs on one event loop — config hot-reload, the agent main loop, all channels, local triggers, health endpoint, (optional) auto-open browser, all as parallel `asyncio.create_task` (`cli/commands.py:2103-2130`).
- **Frontend/backend is HTTP/WebSocket, not a child process**: the WebUI is a browser SPA, connected to the gateway via a multiplexed WebSocket protocol (`AGENTS.md:44`); WebUI static assets are served by the **WebSocket channel** (default `:8765`), health on another port (default `:18790`, `docs/architecture.md:102-105`).

### 【vs Raven】

- **Scheduling layer**: nanobot uses **two naive asyncio queues + one `asyncio.Lock` per session** (`bus/queue.py:16-18`; `loop.py:407`). Raven replaced that with a formal **Spine/Scheduler/Lane** scheduling layer. nanobot is more “small workshop”; Raven is more “logistics hub.”
- **Frontend**: nanobot is a **browser WebUI**; Raven replaced the frontend with an **Ink TUI** forked from hermes-agent (two processes + JSON-RPC). That is the most visible product difference.

---

## Chapter 3 · Startup: from command to UI

### Analogy: three ways to open the shop

nanobot has three “ways of being open”: one-shot Q&A (`nanobot agent -m`), long-running gateway (`nanobot gateway`/`serve`), browser UI (`nanobot webui`). They share one entry, but they bring up different things.

### The real call chain

**Step 1: the entry is tiny.** `nanobot/__main__.py:5-8` can be quoted in full:

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

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

`pyproject.toml:104-105` also binds the CLI script `nanobot` to `nanobot.cli.commands:app` — a typer app (`cli/commands.py:217`, `name="nanobot"`).

**Step 2: CLI command tree.** `cli/commands.py` registers a batch of commands (`@app.command()` and `add_typer`):

| Command | Role | Location |
|---|---|---|
| `nanobot onboard` | first-run config wizard (provider/model/sandbox/channel……) | `cli/commands.py:630` |
| `nanobot agent` | one-shot / interactive chat | `cli/commands.py:2204` |
| `nanobot serve` / `gateway` | long-running gateway (brings up every service) | `cli/commands.py:1323`, `:2183` |
| `nanobot webui` | start the browser UI | `cli/commands.py:1407` |
| `nanobot trigger` | local triggers | `cli/commands.py:1289` |
| `nanobot status` | runtime status | `cli/commands.py:2627` |
| `nanobot channels …` | platform status/login | `cli/commands.py:2480-2547` |
| `nanobot plugins …` | plugin enable/disable | `cli/commands.py:2547-2626` |
| `nanobot provider …` | provider login/logout | `cli/commands.py:2672-2793` |

At startup `cli/commands.py` forces `stdout/stderr` onto UTF-8 (`.agent/gotchas.md:20`) to handle emoji and multilingual input — a necessary detail for a project that “runs on chat platforms worldwide.”

**Step 3: the gateway brings up every service in one go.** `nanobot gateway`’s `run()` (`cli/commands.py:2091-2130`) is a set of parallel tasks:

```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"),
]
# 可选：健康端点、自动开浏览器
```

Note **config hot-reload** (`watch_config_file` → `agent.invalidate_runtime_config`, `loop.py:516-519`): changing `~/.nanobot/config.json` does not require a restart; runtime re-resolves provider/model catalogs. A considerate design for a “long-resident” product.

**Step 4: Dream / heartbeat become cron system jobs.** Older versions had dedicated services; now they are unified into cron (`AGENTS.md:47`). If enabled at gateway start, two system-level CronJobs are registered (`cli/commands.py:2031-2055`): `dream` (nighttime memory consolidation) and `heartbeat` (periodic check of `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
```

**Step 5: AgentLoop.run main loop.** `loop.py:1023-1122` is the heartbeat: `while self._running:` consumes inbound with a 1-second timeout; on timeout it also checks whether idle sessions should compact (`auto_compact.check_expired`). This “1-second poll doubles as background maintenance” is naive but practical.

### 【vs Raven】

- nanobot startup is **a batch of parallel asyncio tasks** (config-watcher + agent-loop + channels + triggers). Because Raven’s frontend is a separate Node process, startup adds `find_node()` + spawn TUI child + TCP-loopback handshake. nanobot has none of that — its “UI” is either a terminal REPL or a page in the browser.

---

## Chapter 4 · Input capture and routing: the journey of one sentence

### Analogy: a conveyor with a single entrance

Whether you speak from Telegram, WeChat, or the browser, your message is translated into the same inbound slip `InboundMessage` and thrown onto the same conveyor `MessageBus.inbound`. The back shop only knows slips, not where you came from — that is **decoupling**.

### The unified inbound slip: `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}"
```

**The session key is `channel:chat_id`** (unless overridden by `session_key_override`). That is the system-wide routing primary key — messages with the same `session_key` are serialized; different keys run concurrently (next chapter). The outbound slip is the symmetric `OutboundMessage` (`bus/events.py:42-59`).

The conveyor itself is extremely naive (`bus/queue.py:8-34`): two `asyncio.Queue`s, `publish_inbound` / `consume_inbound` / `publish_outbound` / `consume_outbound`. No priority, no partitions, no persistence — **“less structure, more intelligence”** is fully visible here.

### Four layers of routing

**Layer 1: the channel side.** Each platform adapter translates raw events (including images, files, voice) into `InboundMessage` and `publish_inbound`. `channels/` is 17 self-contained packages, auto-discovered by `channels/manager.py` via pkgutil (`AGENTS.md:39`).

**Layer 2: runtime control after the main loop takes a slip.** `loop.py:1053-1062`, three questions first:
1. `handle_runtime_control` — is this an internal control signal (MCP reload, image-generation reload, `bus/events.py:17-20`)? Handle in place; do not enter a session.
2. `commands.is_priority(raw)` — is this a **priority slash command** (`/stop`, `/restart`, `/status`)? If so, `_dispatch_command_inline` runs immediately — so even if a session is busy, `/stop` can punch through.
3. Whether an automation turn (cron / local trigger) needs to “defer” (`defer_if_active`), so it does not interrupt an in-flight session.

**Layer 3: “interjection” routing for a busy session.** `loop.py:1082-1109` is a neat nanobot design: if a session already has a running task (`effective_key in self._pending_queues`), a new **non-command** message does not spawn a competing task; it is stuffed into that session’s `pending_queue` (an `asyncio.Queue(maxsize=20)`), and the running turn “picks it up between iterations” and merges it into the conversation (see Chapter 6’s injection). Non-priority but dispatchable commands go through `_dispatch_command_inline` directly (`loop.py:1085-1090`).

**Layer 4: actually opening a turn.** Only at `loop.py:1112` does it `asyncio.create_task(self._dispatch(msg))`, registering the task in `_active_tasks[effective_key]` so `/stop` can cancel precisely.

```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]
```

### Session-key mapping and `unified_session`

Default `session_key = channel:chat_id`. If `unified_session` is on (`config/schema.py:149`), `_effective_session_key` (`loop.py:786-790`) maps every channel’s messages onto the same `UNIFIED_SESSION_KEY` — good for “one user, many devices,” so phone, computer, and browser talk to the same agent continuously.

### 【vs Raven】

- nanobot’s routing rules **scatter across a few ifs in the main loop** (runtime control / priority / pending queue / new task), hand-routed by `session_key` strings and a `_pending_queues` dict. Raven abstracted that into first-class `TurnRequest` + `Origin` (USER/CRON/SUBAGENT…) + `BusyPolicy` (APPEND/INJECT/INTERRUPT), adjudicated by Spine. **The same “busy-session interjection” need: nanobot solves it with a pending_queue hack; Raven solves it declaratively with BusyPolicy.**

---

## Chapter 5 · Context assembly: packing for the model

### Analogy: a “briefing packet” separated by `---`

On every request, `ContextBuilder` (`agent/context.py:54`) concatenates several blocks into one system message, blocks joined by `\n\n---\n\n` (`context.py:121`). Unlike Raven’s numbered segment builders and two-phase parallel assembly, this is **sequential string concat** — naive and direct.

### What the system prompt is made of

`build_system_prompt` (`context.py:70-121`) concatenates in order:

1. **Identity + runtime + workspace** (`_get_identity` → template `templates/agent/identity.md`). Notably: **this block has no big title like “# nanobot”**; it starts at `## Runtime` (`templates/agent/identity.md:1`), lists OS/Python version, current project workspace, agent workspace, and tells the model: `SOUL.md`/`USER.md`/`MEMORY.md` are auto-managed by Dream — **do not edit them directly** (`identity.md:9-10`).
2. **Bootstrap files** (`_load_bootstrap_files`, `context.py:57`): `BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]` — project rules + persona + user portrait. `AGENTS.md` is taken from the **project workspace**, `SOUL.md`/`USER.md` from the **agent workspace** (`context.py:162-166`); the two paths may differ. A decent detail: if `AGENTS.md`/`USER.md` content is **byte-identical to the factory template** (the user never edited it), skip loading (`context.py:174-177` + `_is_template_content`) so default templates do not waste tokens.
3. **Tool contract** (`render_template("agent/tool_contract.md")`, `context.py:88`): a long “how to use tools” (`templates/agent/tool_contract.md`) teaching the model to prefer structured tools over `exec`, change approach after failure rather than repeat, read before edit, and treat safety/boundary errors as real limits.
4. **`# Memory`**: contents of long-term `MEMORY.md` (`context.py:90-92`), also checking “is this the unedited template.”
5. **`# Active Skills`**: full text of skills marked `always` (`context.py:94-98`).
6. **Skills inventory summary** (`agent/skills_section.md`): remaining skills get only “name+description+path”; the model `read_file`s them on demand — **progressive disclosure** (`context.py:100-102`; `skills.py:111` `build_skills_summary`).
7. **`# Recent History`**: from `history.jsonl`, history since the last Dream, cap 50 entries / 8000 tokens (`context.py:104-116`; constants `_MAX_RECENT_HISTORY=50`, `_MAX_HISTORY_TOKENS=8_000` at `context.py:60-61`).
8. **`[Archived Context Summary]`**: if this session has a compacted archive summary, append it last (`context.py:118-119`).

### User messages and “runtime context”

`build_messages` (`context.py:190-241`) concatenates `[system, *history, current]`. In front of the current user message it prepends a **runtime context block** (`runtime_context_blocks`, e.g. current time, CLI-App attachments), merged via `append_runtime_context`, booked in `_meta` (`context.py:211-240`). Images are encoded as base64 data-urls into the `content` array (`context.py:243-266`).

Key discipline (`.agent/gotchas.md:30-32` “Context Pollution Persists”): **anything written into memory/history/prompt will be replayed by future LLM calls**, so timestamps, local media paths, tool-call echoes, raw fallback dumps must be sanitized before disk — `_sanitize_persisted_blocks` (`loop.py:1689-1718`) replaces base64 images with placeholder text and truncates overlong text.

### Prompt-cache engineering (a load-bearing axis)

nanobot’s prompt-cache approach is `_apply_cache_control` in `openai_compat_provider.py:553-585`: only for providers with `supports_prompt_caching=True` (Anthropic, OpenRouter, see `registry.py:191,361`) it injects Anthropic-style `cache_control: {"type": "ephemeral"}` breakpoints at **three fixed places**:

- the system message (first);
- the second-to-last message (`messages[-2]`);
- several spots in tool definitions (`_tool_cache_marker_indices`).

Combined with `ToolRegistry.get_definitions` (`tools/registry.py:86-109`) **stable sort** (built-in tools first, MCP tools after, each sorted by name, and cached), the byte prefix of the tool list stays stable across turns — critical for cache hits.

> One sentence: **nanobot’s prompt cache is the naive scheme “fixed breakpoints + stable tool sort”**; it does not have Raven’s TokenWise CacheOptimizer that adaptively moves cache positions among ≤4 breakpoints.

### 【vs Raven】

- **Assembly**: nanobot is “sequential string concat + `---` separators” (`context.py:121`); Raven is “6 Segments with owner/order/budget + two-phase parallel assembly.”
- **Memory source**: nanobot’s `# Memory` reads the `MEMORY.md` file directly; Raven stacks EverOS vector recall on top.
- **Identity header**: nanobot’s identity does not even have a top-level title name; Raven has a conspicuous `# Raven 🐦‍⬛`.

---

## Chapter 6 · The agent loop: the project’s heart

nanobot’s “heart” is **two chambers**. That is the core structure that sets it apart from most agent projects:

- **AgentLoop (`agent/loop.py`, 1987 lines)**: channel-facing orchestration, running a turn with an **explicit state machine**;
- **AgentRunner (`agent/runner.py`, 1401 lines)**: model-facing execution, running the “call model → execute tools → call model again” iteration.

`docs/architecture.md:36-54` even teaches you how to debug: **session routing / workspace / delivery problems → `loop.py`; provider calls / tools / streaming / iteration cap → `runner.py`.**

### Chamber one: AgentLoop’s turn state machine

`loop.py:105-113` defines 8 states:

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

Transitions live in an explicit table `_TRANSITIONS` (`loop.py:248-257`). The driver `_process_message` (`loop.py:1351-1403`) is a `while ctx.state is not DONE:` loop: each step calls `_state_<name>`, looks up the next state from the returned event, and records per-step timing (`StateTraceEntry`, for debug). This “event-driven state machine” is **nanobot’s original design**, splitting a turn’s lifetime into something you can actually read:

```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`): **crash recovery** first (below), then extract documents/media from the user message.
- **COMPACT** (`loop.py:1480-1483`): `auto_compact.prepare_session`, pull any existing archive summary.
- **COMMAND** (`loop.py:1485-1524`): slash commands are routed here; a hit “shortcut command” produces outbound and jumps to DONE, tagging the user message and command result with `_command` on disk (so the WebUI can see them without polluting LLM context).
- **BUILD** (`loop.py:1526-1582`): resolve this session’s runtime, trigger `consolidator.maybe_consolidate_by_tokens` against the token budget, take history, assemble `initial_messages`, and **persist the user message early** (`_persist_user_message_early`, a crash-recovery backstop).
- **RUN** (`loop.py:1584-1619`): actually call `_run_agent_loop` (→ AgentRunner).
- **SAVE** (`loop.py:1621-1662`): atomically persist newly added messages, compute latency, schedule another compact in the background, clear crash-recovery markers.
- **RESPOND** (`loop.py:1664-1687`): assemble the final `OutboundMessage`.

### Chamber two: AgentRunner’s iteration loop

`runner.py:354` is the loop body:

```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
```

**Iteration budget is as high as 200** (`config/schema.py:133` `max_tool_iterations: int = 200`) — a distinctive nanobot trait, much looser than many peers (e.g. Raven’s 40), matching its “long-running tasks” positioning.

A full iteration journey:

```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"]
```

Close reading of several nanobot-specific pieces:

**① When tools run**: `should_execute_tools` (`providers/base.py:176-179`) requires “there are tool_calls **and** finish_reason is a stop reason that allows tools.” That is, **tools always run after a complete (or stream-ended) model response**, never mid-stream. Streaming only affects token presentation, not “when we act.”

**② Mid-turn injection**: `_drain_injections` (`runner.py:213-261`) uses `injection_callback` to pull user messages accumulated in pending_queue (at most 3 per round, `_MAX_INJECTIONS_PER_TURN=3`), and repairs role alternation (two consecutive users are merged, `_append_injected_messages:124-146`). At most 5 injection cycles per turn (`_MAX_INJECTION_CYCLES=5`). This is the landing end of Chapter 4’s “busy-session interjection.”

**③ Empty-response recovery**: models sometimes emit thinking with no body. `runner.py:494-524`: retry first (`_MAX_EMPTY_RETRIES=2`); if still empty, send a **no-tools finalization request** “please give a final answer” (`_request_finalization_retry`).

**④ Truncation continuation**: when `finish_reason == "length"` and there is content, append what was produced and add a “continue” message, at most 3 times (`_MAX_LENGTH_RECOVERIES=3`, `runner.py:526-545`).

**⑤ Malformed tool-call self-heal**: a tool_call with `name=None/""` makes upstream APIs permanently reject the whole session. `_drop_malformed_tool_calls` (`runner.py:878-910`) drops them on the response side; if all are dropped, retry then degrade to a no-tools request (`runner.py:845-875`). `ContextGovernor.strip_malformed_tool_calls` (`context_governance.py:176-229`) cleans **history**, so a polluted session “heals next turn.”

**⑥ Exhausted-budget wrap-up**: after 200 rounds still not closed, `_try_finalize_after_max_iterations` (`runner.py:947-986`) does a **tools-disabled** wrap-up call using existing material for a best answer; only on failure does it fall back to template copy (`agent/max_iterations_message.md`).

**⑦ How tool errors are handled**: a noteworthy nanobot point. Default `fail_on_tool_error = True` (`config/schema.py:135`). In `_run_tool` (`runner.py:1146-1254`):
- **Recoverable safety-boundary errors** (SSRF, workspace overshoot) are classified by `_classify_violation` (`runner.py:1299-1337`) as “soft errors” — fed back to the model to change approach, with an un-bypassable boundary note (`_SSRF_BOUNDARY_NOTE:1263-1270`);
- **Ordinary tool errors** (`ToolResult.error` or an exception), when `fail_on_tool_error=True`, become a **fatal `tool_error`**, ending this turn immediately (`runner.py:446-462`).

That is: **most failures are still “feed the error back and let the model self-heal,” but a real tool failure (blocked command, crash) by default hard-ends the current turn** — stricter than Raven’s “errors are always soft strings fed back.”

### Crash recovery: nanobot’s “durability” brand

“Durability Release” is not just a slogan. nanobot **does not snapshot dialogue with a shadow git**; it writes in-flight state into `session.metadata`:

- **Before and after every tool execution**, AgentRunner via `checkpoint_callback` writes “assistant message + completed tool results + pending tool calls” into session metadata `runtime_checkpoint` (`loop.py:841-844`, `_set_runtime_checkpoint:1826-1829`; runner side `_emit_checkpoint`).
- **Early persist of the user message**: at turn start the user message is written with a `pending_user_turn` marker (`_persist_user_message_early:666-699`).
- **Restore at the next turn’s start**: `_state_restore` calls `_restore_runtime_checkpoint` (`loop.py:1853-1905`) to “materialize” interrupted in-flight messages into history; tools that were pending but unfinished get a “Task interrupted before this tool finished” placeholder result; `_restore_pending_user_turn` (`loop.py:1907-1925`) gives a “Task interrupted” assistant reply to a turn that only persisted the user message then crashed. It uses message keys for overlap dedup, avoiding double insert.
- **When cancelled by `/stop`** (`_dispatch`’s `CancelledError` branch, `loop.py:1160-1187`): already-checkpointed partial context is persisted; the user does not lose tool results that already ran.

Session files themselves are **atomic writes** (`.agent/gotchas.md:38-40`): `history.jsonl` uses “temp file + fsync + rename + directory fsync” so crashes are recoverable (`memory.py:460-483`).

### Stop and interrupt

- **Normal stop**: model no longer calls tools (completed) / empty final response (empty_final_response) / LLM error (error) / fatal tool error (tool_error) / budget exhausted (max_iterations).
- **Hard cancel**: `/stop` → priority command punches through → `_cancel_active_tasks` (`loop.py:773-784`) calls `asyncio.Task.cancel()` on every task for that session, and `cancel_by_session` cancels its sub-agents. This is a **hard cancel** (real `.cancel()` raising `CancelledError`), but with checkpoint it keeps the completed part — “hard cancel + graceful persist.”

### 【vs Raven】

- **Structure**: nanobot **explicitly separates** AgentLoop (state-machine orchestration) from AgentRunner (model loop), and makes the turn lifetime explicit with a transition table. After forking, Raven **merged both into one `main.py`** `_run_agent_loop`.
- **Iteration budget**: nanobot default **200**, Raven default **40**.
- **Crash recovery**: nanobot uses **runtime_checkpoint + pending_user_turn in session.metadata** (no extra git); Raven uses **per-turn shadow git snapshots + checkpoint.py**. A fork in the “durability” implementation path.
- **Tool errors**: nanobot default `fail_on_tool_error=True` (real errors end the turn); Raven always feeds tool errors back as soft strings.
- **Interjection**: nanobot uses pending_queue + `_drain_injections`; Raven uses `drain()` + BusyPolicy.

---

## Chapter 7 · Tools and permissions: the model’s “hands” and the tight hoop

### The uniform shape of a tool

Every tool inherits `Tool` (`agent/tools/base.py`), providing the quartet `name`/`description`/`parameters(JSON Schema)`/`execute`, plus switch attributes: `read_only`, `exclusive`, `concurrency_safe`, `_scopes` (which scene the tool is available in: core / subagent). `ToolResult.error(...)` is the unified error carrier (`tools/registry.py:15-16`).

**Discovery and registration** is `ToolLoader` (`tools/loader.py`): `pkgutil` scans modules under `agent/tools/` (skipping foundation modules `base/schema/registry/...`, `loader.py:14-17`), collecting every non-abstract, `_plugin_discoverable` `Tool` subclass; then `importlib.metadata.entry_points(group="nanobot.tools")` loads **external plugin tools** (`loader.py:62-84`). `load()` filters by `scope`, by `enabled(ctx)`, and handles name collisions (builtins win; plugin collisions are skipped with a warning, `loader.py:100-107`).

**Execution entry** `ToolRegistry.execute` (`tools/registry.py:186-200`): `prepare_call` looks up the tool (on miss, a normalized key suggests “did you mean X,” `_suggest_name:58-69`), lenient JSON parse of arguments, schema validate (`prepare_call:111-149`), then execute; every exception becomes `ToolResult.error`, and a unified crib is appended: `[Analyze the error above and try a different approach.]` (`registry.py:188`) — the dumbest way to raise the model’s error-correction rate.

### Built-in tool inventory

By module (`agent/tools/`):

| Group | Tools | Notes | File |
|---|---|---|---|
| Files | `read_file`/`write_file`/`edit_file`/`list_dir` | read/write/exact-replace edit/list dir, via workspace path resolver | `filesystem.py` (1117 lines) |
| Files | `apply_patch` | default code-edit tool, multi-file/structured/`dry_run` | `apply_patch.py` |
| Search | `grep`/`find_files` | content regex / filename find, with binary and size limits | `search.py` |
| Exec | `exec` | run shell (security focus, below), `yield_time_ms` long-task sessions | `shell.py` (923 lines) |
| Network | `web_search`/`web_fetch` | DuckDuckGo search / readability extract, SSRF-checked | `web.py` (1139 lines) |
| Comms | `message` | proactive/cross-channel send, deliver local files and generated images | `message.py` |
| Multi-agent | `spawn` | spawn a background sub-agent (Chapter 9) | `spawn.py` |
| Goals | `create_goal`/`update_goal` | sustained goal, needs explicit `/goal` authorization | `long_task.py` |
| Media | `generate_image` etc. | text-to-image (opt-in, needs key/model) | `image_generation.py` (1966 lines) |
| Scheduling | `cron` | manage cron jobs (registered only when cron service is wired) | `cron.py` |
| Session | `write_stdin`/`list_exec_sessions` | talk to long-running exec sessions | `exec_session.py` |
| Introspection | `my` (MyTool) | read/change runtime state (needs `my.enable`, `loop.py:624-628`) | `self.py` |
| CLI-App | `run_cli_app` | run a user-attached external CLI app | `cli_apps.py` |
| MCP | `mcp_<server>_<tool>` | each MCP remote tool/resource/prompt wrapped as a native tool | `mcp.py` (1435 lines) |

Tool-definition order (`registry.py:86-109`) is **builtins first, MCP after, each sorted and cached** — prompt-cache friendly.

### Permissions and safety: four pre-gates (no per-call approval)

Like Raven, **nanobot has no Claude Code-style “y/n popup on every tool call”** — it is a resident IM assistant; nobody sits there clicking confirm. Safety is four layers of pre-defense:

**Layer 1: dangerous-command regex denylist** (`shell.py:214-232`), default intercepts:

```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` beat `deny_patterns`, and **every top-level segment of a chained command must hit the allowlist** to pass (`_guard_command:760-771`, `_split_shell_segments` does quote/paren-aware splitting). Note it specially intercepts redirects/tee/cp/sed writes to `history.jsonl`/`.dream_cursor` — those files are managed by `append_history`; hand-writing them corrupts cursor format and breaks `/dream` (`shell.py:224-231`).

**Layer 2: workspace boundary** (`.agent/security.md:6-13`). File tools go through a unified workspace path resolver (`tools/path_utils.py`); overshoot is denied. `exec` also respects `restrict_to_workspace` (`shell.py:435-448` + `_guard_command:783-824`): `working_dir` outside the workspace, commands containing `../`, or absolute paths pointing outside, are blocked, with a note “this is a hard boundary, don’t bypass with symlinks/base64/another tool” (`_WORKSPACE_BOUNDARY_NOTE:71-77`). Extra read/write roots must be declared (`extra_read_allowed_dirs`/`extra_write_allowed_dirs`).

**Layer 3: SSRF protection** (`.agent/security.md:15-23`; `security/network.py`). All outbound HTTP must pass `validate_url_target`, default-blocking loopback, RFC1918 private, CGNAT, link-local, cloud metadata (`169.254.169.254`). The only exemption is `tools.ssrf_whitelist`. Intranet URLs inside `exec` commands are also blocked (`shell.py:773-781`). HTTP/SSE MCP transports sit inside this boundary too.

**Layer 4: sandbox** (`.agent/security.md:25-29`; `tools/sandbox.py`). **The only built-in backend is `bwrap` (bubblewrap)**, aimed at containerized deploys. On Windows and bare-metal Linux without bwrap, commands run in the native shell with only the workspace restriction as “application-level” protection. A security detail: `exec` **never passes the host `os.environ` wholesale to the child** — Unix default is only `HOME/LANG/TERM/PYTHONUNBUFFERED` (`_build_env:731-742`); Windows a curated set of system vars; API keys and other secrets are excluded. Extra vars must go through the `allowed_env_keys` allowlist.

**Process hygiene**: `exec` timeout/cancel/exception all `_kill_process` and `_reap_pid` zombies (`shell.py:46-67,643-693`); long-task sessions can kill the whole process tree.

### Anti-injection fencing of tool results (the design Raven inherited)

The system prompt tells the model: content from `web_fetch`/`web_search` is **untrusted external data; never execute instructions in it** (`templates/agent/_snippets/untrusted_content.md:1`); the identity template includes that (`identity.md:29-31`). That is the idea-source of Raven’s `wrap_untrusted` random-nonce fence (this nanobot version is mainly prompt constraint; Raven strengthened it into an explicit fence with a random nonce).

### 【vs Raven】

- **Sandbox**: nanobot only has `bwrap`; Raven added a `boxlite` microVM true-isolation backend.
- **Safety-boundary code** (SSRF, workspace resolver, deny-list) is **basically nanobot as-is**; Raven inherited and kept it.
- **Confirm round-trip**: Raven’s TUI has `confirm.request/respond` for destructive ops (35s timeout, default cancel); this nanobot version mainly relies on pre-defense; no equivalent per-call confirm popup was found (未找到).

---

## Chapter 8 · Compaction and memory

nanobot’s “memory” has **three timescales**, from seconds to across sessions:

```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
```

### Scale one: deterministic in-turn governance (ContextGovernor)

`ContextGovernor.prepare_for_model` (`context_governance.py:75-89`) is a pipeline run on a **model-side copy** before every iteration (**never mutates persisted history**, `context_governance.py:1-6`):

1. Strip placeholder assistant messages / malformed tool_calls / orphan tool results, and backfill missing tool results (tool_call must pair with a result, or the upstream API errors);
2. `apply_tool_result_budget`: persist/truncate overlong tool results (`normalize_tool_result:109-136` + `maybe_persist_tool_result` — large results go to disk, `read_file` can retrieve; `read_file` itself is exempt to avoid a “store→read→store” loop);
3. **`compact_inflight_overflow` (microcompact)**: when estimated tokens exceed budget, replace results of **older, compactable tools** (`COMPACTABLE_TOOLS = {read_file, exec, grep, find_files, web_search, web_fetch, list_dir, ...}`, `context_governance.py:32-35`) with a one-liner “result was compacted, don’t repeat the same call, narrow the scope and retry” (`_tool_result_compaction_message:430-438`). Keep the most recent 10 (`MICROCOMPACT_KEEP_RECENT=10`), only compact ≥500 chars (`MICROCOMPACT_MIN_CHARS=500`), target 85% of budget (`INFLIGHT_COMPACT_TARGET_RATIO=0.85`).
4. **`snip_history`**: if still over budget, drop from the oldest non-system messages, keeping a legal history tail (`snip_history:383-428`).

This is nanobot’s “band-aid”: **purely deterministic, no LLM, only the model copy**, corresponding to Raven’s `_emergency_shrink`.

### Scale two: in-session lossy summary (Consolidator)

`Consolidator.maybe_consolidate_by_tokens` (`memory.py:982-1089`): estimate session prompt tokens; once over the input budget (`context_window - max_tokens - 1024`), **loop** cutting the oldest block at a “user-turn boundary,” call the LLM to summarize (`archive`, `memory.py:922-980`, system prompt `agent/consolidator_archive.md`), append the summary into `history.jsonl`, until down to `budget * consolidation_ratio` (default 0.5, i.e. back to half the budget, `config/schema.py:157-163`). At most 5 rounds (`_MAX_CONSOLIDATION_ROUNDS=5`).

**Key: this is “lossy”** — summarized original messages **do not come back after leaving context**; the summary is re-injected as `[Archived Context Summary]` (`context.py:118-119`). If LLM summary fails, degrade to `raw_archive` (dump as-is into history.jsonl, `memory.py:655-675`) and advance the cursor; never summarize the same block twice.

`AutoCompact` (`agent/autocompact.py`) handles **idle-session compact**: after `session_ttl_minutes` (default 15, `config/schema.py:151-156`) it calls `compact_idle_session` (`memory.py:1091-1162`) on idle sessions, hard-truncating to a legal tail and archiving.

### Scale three: cross-session file memory + Dream reflection

Memory is **a pile of files a human can read and write** (`docs/architecture.md:174-179`), under `<workspace>/`:

- `SOUL.md` (persona), `USER.md` (user portrait) — agent workspace;
- `memory/MEMORY.md` (long-term facts);
- `memory/history.jsonl` (append-only event log, with incrementing cursor `cursor` and timestamps, `memory.py:253-304`).

**Dream is nanobot’s memory brand** (inside `memory.py`; `AGENTS.md:41` calls it “two-phase memory consolidation”). It is essentially a **restricted, ephemeral agent**:

1. `build_dream_prompt` (`memory.py:532-558`) concatenates “unprocessed history since last Dream” (`.dream_cursor` cursor) + **the current full text of the three memory files** (`_render_current_memory_files:560-580`, so the model edits against real files, avoiding hallucination) into a prompt (template `agent/dream.md`).
2. `build_dream_tools` (`memory.py:593-633`) gives it an **extremely shrunk toolbox**: only `read_file` + `write/edit/apply_patch` on `skills/` + precise write permission on the three memory files. It can **create skills** (`skill-creator`) and update memory, but nothing else.
3. **git provenance**: `MemoryStore` uses pure-Python `dulwich` (`GitStore`, `memory.py:82-84`) tracking only four files: `SOUL.md/USER.md/memory/MEMORY.md/.dream_cursor`. Dream commit messages are **based on the real worktree diff** (`dream_content_diff:582-591` + `build_dream_commit_message:686-702`), **not** the model’s self-report — `/dream-log` audit reflects filesystem truth. Whether the cursor advances also depends on “was there a real content change,” not LLM self-description.
4. **Scheduling**: Dream runs as a cron system job (`cli/commands.py:2031-2039`), or `/dream` manually (`command/builtin.py:399`). Old dream session files are pruned by `prune_dream_sessions`, keeping the most recent 10 (`memory.py:704-726`).

`history.jsonl` also has decent robustness: cursor allocation is locked for atomicity (`memory.py:290`), `strip_think` removes template leakage (`memory.py:287`), corrupt entries warn once and skip (`_iter_valid_entries:313-342`), legacy `HISTORY.md` migrates once (`_maybe_migrate_legacy_history:100-137`).

### Compaction philosophy, summarized

nanobot’s compaction is a **combo of “lossy summary + deterministic microcompact”**: seconds use deterministic microcompact (no semantic loss, only tool output); session scale uses LLM summary (**trade detail for volume**); across sessions Dream distills valuables into Markdown memory. It **does not have Raven’s Curator (lossless archive + citation + re-injection)** — that is Raven’s post-fork “fidelity” path.

### 【vs Raven】

- **Compaction path**: nanobot is **lossy-summary first** (Consolidator → history.jsonl, originals unrestorable); Raven added **Curator lossless archive** (verbatim disk + citations + Working State re-injection) as default, demoting Consolidator to a memory writer.
- **Long-term memory backend**: nanobot is **plain files + dulwich git**, no vector DB; Raven stacked **EverOS dual-track vector memory** (user track/agent track) + SkillForge retrieval ranking.
- **Reflection**: nanobot’s **Dream** (edit SOUL/USER/MEMORY, git provenance) is original; Raven kept similar capability but shifted center of gravity to EverOS.

---

## Chapter 9 · Sub-agents and multi-agent: doppelgängers

### Analogy: send a temp worker to the back room

When the main agent hits work that is “big and independent,” it can `spawn` a sub-agent to run in the background and stuff a report back into the main conversation. **Raven inherited this mechanism almost as-is** — so what you see here is the “factory setting.”

### Implementation: SubagentManager

The entry is the `spawn` tool (`tools/spawn.py:56-88`); the model just gives a `task` description. The real steward is `SubagentManager` (`agent/subagent.py:82`).

**Concurrency gate**: before `spawn`, check `get_running_count() >= max_concurrent_subagents` (`spawn.py:64-71`); over the limit, refuse and say “wait for one to finish.” Default `max_concurrent_subagents = 1` (`config/schema.py:134`, `ge=1`) — **default only one sub-agent**, quite conservative.

**Toolbox deliberately shrunk**: `_build_tools` (`subagent.py:197-217`) loads tools with `scope="subagent"`. Only tools whose `_scopes` contain `"subagent"` get in (e.g. `ExecTool._scopes = {"core", "subagent"}`, `shell.py:167`) — i.e. read/write/edit/list dir + exec + web. **No `message` (cannot jump the chain to the user), no `spawn` (cannot have children)** — structurally no recursive runaway. Sub-agents use a separate `ExecSessionManager`.

**How results come back**: the cleverest bit (`_announce_result:372-415`). When a sub-agent finishes it **does not send a message directly**; it constructs an `InboundMessage` with `channel="system"`, `sender_id="subagent"`, `injected_event="subagent_result"` and `subagent_task_id`, aligns the main session key via `session_key_override`, and `publish_inbound`s onto **the same message bus**:

```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)
```

Then it is routed into the main session’s pending_queue, picked up as a **mid-turn interjection** in an iteration gap (Chapters 4 and 6); the main agent wakes as if a new message arrived and retells it to the user. `loop.py` has dedicated handling for sub-agent results: persist as an assistant record, but present to the model as “new follow-up input” (`_persist_subagent_followup:1802-1824`; `_state_build:1554-1563`), deduping the same `task_id`.

**Cancel**: `cancel_by_session` (`subagent.py:456-465`) cancels every sub-agent task for that session and kills their exec sessions — invoked together with the main loop’s `_cancel_active_tasks` on `/stop`.

```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: 转述结果给用户
```

### Relation to other “non-user” turns

Besides sub-agents, nanobot has several “not user-initiated” turns, distinguished by `channel="system"` + metadata: cron triggers (`CronTurnCoordinator`), local triggers (`LocalTriggerTurnCoordinator`). When a session is busy they are deferred by `defer_if_active` (`loop.py:1064-1076`), not preempting the user.

### 【vs Raven】

- **Return path**: **almost the same** (sub-agent returns to the main session via the bus with `injected_event`), because Raven forked from here.
- **Rate limit**: nanobot only has `max_concurrent_subagents` (default **1**); Raven raised default concurrency to **4** and **added a “at most 30 per session per hour” frequency gate** — this nanobot version has **no** such frequency gate (未找到).
- **Iteration cap**: nanobot sub-agents share `max_iterations` with the main agent (default 200, `subagent.py:131-135` from AgentDefaults); Raven separately presses sub-agents to 15.

---

## Chapter 10 · Ecosystem: commands, extensions, plugins

### Slash commands

`command/builtin.py` registers a set of slash commands, split into “priority” (can punch through a busy session) and “ordinary” (`register_builtin_commands:995-1020`):

| Command | Role | Type |
|---|---|---|
| `/stop`, `/restart`, `/status` | stop / restart / status | priority (`is_priority`) |
| `/new` | clear current session (archive first) | exact |
| `/model` | view/switch model | exact |
| `/history` | view history | exact |
| `/goal` | activate a sustained goal | exact |
| `/trigger` | manage local triggers | exact |
| `/dream`, `/dream-prompt`, `/dream-log`, `/dream-restore` | manual dream / edit dream prompt / see audit / rollback | exact |
| `/evaluator-prompt` | edit evaluator prompt | exact |
| `/skill` | skill management | exact |
| `/pairing` | DM pairing code | exact |
| `/help` | help | exact |

Commands are routed in `_state_command` (`loop.py:1485-1524`); “shortcut commands” produce a result and skip BUILD/RUN (no tokens spent).

### MCP

`agent/tools/mcp.py` (1435 lines) is full MCP support: `connect_mcp_servers` (`mcp.py:833`) connects servers in `tools.mcpServers`, wrapping each remote **tool/resource/prompt** as a native Tool named `mcp_<server>_<tool>` / `mcp_<server>_resource_<name>` / `mcp_<server>_prompt_<name>` (`mcp.py:454,610,708`), registered into the same ToolRegistry — to the model they are indistinguishable from builtins. Engineering details: handling the MCP SDK anyio cancel-scope leaking `CancelledError` on timeout/failure (`mcp.py:488-489` and other `except asyncio.CancelledError`), and Windows path normalization for stdio server commands (`.agent/gotchas.md:21`). HTTP/SSE MCP endpoints sit inside the SSRF boundary (`.agent/security.md:21`).

### Plugin system

Two extension points (`docs/architecture.md:197-207`):
- **Tool plugins**: register external `Tool` subclasses via `entry_points(group="nanobot.tools")` (`loader.py:62-84`), wrapped in `_LegacyErrorPrefixTool` for error-contract compatibility (`loader.py:121-186`).
- **Channel plugins**: export a `ChannelPlugin` descriptor, a self-contained package (`docs/channel-package-guide.md`).
- CLI has `nanobot plugins list/enable/disable` (`cli/commands.py:2547-2626`).

### Skills

`agent/skills.py`: a skill is `<workspace>/skills/<name>/SKILL.md` or built-in `nanobot/skills/<name>/SKILL.md` (Markdown + YAML frontmatter). Built-in skills: `clawhub`, `cron`, `github`, `image-generation`, `memory`, `my`, `skill-creator`, `summarize`, `tmux`, `update-setup`, `weather`. Load strategy is **progressive disclosure**: `always` skills reside in full; others get a summary; the model `read_file`s on demand (`context.py:94-102`; `skills.py:111`). Workspace skills override same-named builtins (`skills.py:61-66`). External skills can be published/installed to **ClawHub** (`.agent/gotchas.md:36`; built-in `clawhub` skill).

### Messaging platforms (17)

17 self-contained packages under `nanobot/channels/`: `dingtalk`, `discord`, `email`, `feishu`, `matrix`, `mattermost`, `mochat`, `msteams`, `napcat`, `qq`, `signal`, `slack`, `telegram`, `websocket` (carries the WebUI), `wecom`, `weixin`, `whatsapp`. All auto-discovered by `channels/manager.py` via pkgutil (`AGENTS.md:39`), brought up together in gateway mode.

### External interfaces

- **Python SDK**: `nanobot/nanobot.py` (`AGENTS.md:55`).
- **OpenAI-compatible HTTP API**: `nanobot/api/server.py` exposes `/v1/chat/completions`, `/v1/models` (`AGENTS.md:45`), usable as an ordinary OpenAI endpoint from third parties.

```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】

- **Skill marketplace**: nanobot uses **ClawHub**; Raven switched to **Skill Hub** + SkillForge weighted RRF retrieval ranking.
- **Platform count**: nanobot 17; Raven about 12 (the subset at fork time).
- **Frontend interface**: nanobot provides a **browser WebUI + OpenAI-compatible API**; Raven provides **Ink TUI + TUI-RPC**.

---

## Chapter 11 · Features: auth, models, and “thinking”

### Provider lineup (~40, direct SDKs)

The `PROVIDERS` tuple in `providers/registry.py:135-715` is the **single source of truth** — adding a provider is one `ProviderSpec` + one config field (`registry.py:4-7`). Order is priority (gateways first). Coverage:

- **Gateway class** (detect by key/base, can route any model): OpenRouter (`sk-or-` prefix, prompt caching + `reasoning_effort`), OpenCode Zen/Go, Hugging Face, Skywork, AiHubMix, SiliconFlow, Novita, VolcEngine (火山, including Coding Plan), BytePlus, ModelScope.
- **Standard direct**: Anthropic (native SDK, prompt caching), OpenAI, DeepSeek, Gemini, Zhipu(GLM), DashScope(Qwen), Moonshot(Kimi), Kimi Coding (Anthropic protocol), MiniMax, Mistral (including Magistral implicit-reasoning special case), StepFun, Xiaomi MIMO, LongCat, Ant Ling, Qianfan(ERNIE).
- **OAuth class** (no API key): OpenAI Codex (built-in GPT-5.x model catalog), xAI Grok (built-in Grok 4.5 + X Search), GitHub Copilot.
- **Enterprise/local**: Azure OpenAI, AWS Bedrock (native Converse API), vLLM, Ollama, LM Studio, Atomic Chat, OpenVINO, NVIDIA NIM.
- **Auxiliary**: Groq, AssemblyAI (transcription only).

Most providers go through `openai_compat_provider.py` (1746 lines); Anthropic/Azure/Bedrock/Codex/Copilot/xAI have dedicated backends (`registry.py:50-52`). Default model is `anthropic/claude-opus-4-5`, provider `auto` (`config/schema.py:124-127`).

**Provider resolution** (`docs/architecture.md:60-66`): explicit config > registry keywords > API-key prefix / base-URL keywords > local fallback > gateway catch-all. Every path is traceable from the factory to a concrete class (`.agent/design.md:29`).

### Auth shapes

- **API key**: `env_key` (e.g. `ANTHROPIC_API_KEY`) or `apiKey` in config (supports `${VAR}` env interpolation, but **not** shell default-value syntax; missing → error and fall back to default config, `.agent/gotchas.md:7-14`).
- **OAuth**: Codex/Grok/Copilot go through `oauth-cli-kit` (`pyproject.toml:31`), `nanobot provider login` (`cli/commands.py:2752`).
- **First-run wizard**: `nanobot onboard` (`cli/onboard.py`, 1985 lines) interactively configures provider/model/sandbox/channel etc.

### Fallback and retry

`fallback_provider.py` is a transparent degrade wrapper: when the primary model returns a **degradable error and has not yet streamed content**, it tries `fallback_models` in order (`config/schema.py:132`); each fallback can use a different provider/params (`fallback_provider.py:240-307`). Errors are structured (`providers/base.py`): timeout/rate/network → retryable and degradable; arrears (`is_arrearage_response:435`) → fatal (returns dedicated “API key arrears/over-quota” copy, `runner.py:48-51,574-578`); context overflow → should compact, not switch model. `provider_retry_mode` supports `standard`/`persistent` (`config/schema.py:137`).

### Model selection and hot switch

`ModelPresetConfig` (`config/schema.py:99-116`) is a named preset of “model + generation params”; `/model` can switch (`command/builtin.py:343`); session-level preference lives in `session.metadata` (`loop.py:545-555`). Config-file changes are hot-loaded by the watcher (`loop.py:516-519`).

### “Thinking” (reasoning / thinking)

nanobot treats reasoning as a first-class citizen, and **adapts per-provider differences carefully** (the concentrated expression of its provider-layer “explicit” philosophy):

- `reasoning_effort` (low/medium/high/adaptive/none, `config/schema.py:145`) uniformly expresses “thinking effort”; `None` keeps the provider default.
- **Each provider’s thinking switch is different**; the registry distinguishes with `thinking_style` (`registry.py:85-91`): `thinking_type` (DeepSeek/火山/BytePlus/MIMO), `enable_thinking` (DashScope/ModelScope), `reasoning_split` (MiniMax); gateway side `gateway_reasoning_style="reasoning_effort"` (OpenRouter).
- **Reasoning content is separated from the body**: `reasoning_content`/`thinking_blocks` are transmitted separately (`runner.py:375-387`); `extract_reasoning`/`strip_think` peel thinking from the body; history keeps thinking fields but for some providers (Mistral) `strip_history_reasoning_content` (`registry.py:120-124`). Special cases: StepFun puts the answer in the `reasoning` field, using `reasoning_as_content=True` as body (`registry.py:98-101`); Mistral’s Magistral implicit reasoning rejects the `reasoning_effort` parameter, special-cased via `implicit_reasoning_models` (`registry.py:109-112`).

### Other features

| Feature | Location | One sentence |
|---|---|---|
| Sustained goal | `agent/tools/long_task.py`, `session/goal_state.py` | after explicit `/goal` authorization, `create_goal`/`update_goal` let the agent chase a long-term goal across turns; if the goal is still active at the end of a runner round, a “continue” hint is injected (`runner.py:171-174`, `loop.py:932-941`) |
| Dream reflection | `agent/memory.py` | see Chapter 8 |
| Heartbeat | `templates/HEARTBEAT.md` + cron | periodic check of a task list (`cli/commands.py:2044-2055`) |
| OpenAI-compatible API | `api/server.py` | treat nanobot as an OpenAI endpoint |
| Pairing/approval | `pairing/` | DM senders need a pairing code to reach the agent |
| Long-task exec sessions | `tools/exec_session.py` | `yield_time_ms` lets long commands return a pollable session_id |

### 【vs Raven】

- **Model attach**: nanobot **first-party ~40-provider registry + direct SDKs + careful thinking-difference adaptation**; Raven switched to **LiteLLM** unified adaptation. The biggest feature-layer difference.
- **Proactivity**: nanobot has cron/heartbeat/local triggers/sustained goal; Raven added a **Sentinel “attention pipeline”** on top (event-driven, comes to find you).
- **Cost**: nanobot has no independent TokenWise money-saving layer; Raven added TokenWise (usage tracking + adaptive cache + routing).

---

## Chapter 12 · Summary: design philosophy and trade-offs

First, a figure of nanobot’s three pillars — they explain almost every “unconventional” choice in the first 11 chapters:

```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
```

### Five distinctive ideas

**① Make the turn lifetime an explicit state machine.** `RESTORE→COMPACT→COMMAND→BUILD→RUN→SAVE→RESPOND→DONE` (`loop.py:105-113,248-257`) + per-step trace timing — most agents bury this in one giant function; nanobot spreads it into a readable, debuggable, extensible transition table.

**② Clean AgentLoop / AgentRunner split.** “channel-side orchestration” and “model-side loop” are two files, two duties (`docs/architecture.md:36-54`). That turns “keep the core small” from a slogan into an executable boundary: routing → loop; model loop → runner.

**③ Durability is engineering detail, not copy.** Atomic write (temp+fsync+rename+dir fsync), runtime_checkpoint into session.metadata, pending_user_turn backstop, `/stop` hard-cancel still persisting partial context — the name “Durability Release” lands on every IO.

**④ Dream: a restricted little agent that “dreams to tidy memory.”** An ultra-small toolbox edits three Markdown memory files; commit messages are keyed off the **real git diff**, not the model’s self-report (`memory.py:686-702`). That is an original design that takes “memory should be transparent, auditable, and roll-backable to the user” all the way.

**⑤ First-party provider layer + extreme difference adaptation.** No LiteLLM; own ~40 providers; even “how each vendor turns thinking on,” “which model rejects the reasoning parameter,” “which stuffs the answer into the reasoning field” are special-cased one by one (`registry.py`). Cost is maintenance volume; benefit is every path is traceable and controllable.

### Main trade-offs (the cost)

- **Ceiling of naive scheduling**: two asyncio queues + one lock per session + pending_queue interjection is simple and readable, but there is no priority/backpressure/cross-session fairness — high-concurrency multi-user scenes will strain (exactly why Raven built Spine).
- **Lossy compact drops detail**: after Consolidator summary, originals cannot be restored; early details in a long session disappear forever (Raven uses Curator lossless archive to patch this gap).
- **Provider-adaptation maintenance tax**: ~40 providers of hand special-cases is a continuous burden; model/API changes have to be followed.
- **The bet of safety-by-pre-defense**: giving up per-call approval, betting on “denylist + workspace boundary + SSRF + bwrap,” is right for unattended IM, but a denylist is best-effort by nature (`shell.py:752` comment admits it); the real backstop is still the sandbox — and the only built-in sandbox is bwrap, weak on non-Linux / no-container environments.
- **Default sub-agent concurrency is only 1**: conservatively safe, but complex parallel work needs the user to raise it explicitly.

### nanobot vs Raven at a glance

| Dimension | nanobot (upstream) | Raven (fork downstream: added/changed) |
|---|---|---|
| Language/runtime | Python + asyncio | same (Python kernel) + Node TUI |
| Model attach | first-party ~40 providers, direct openai/anthropic SDKs | switched to **LiteLLM** unified adaptation |
| Scheduling | MessageBus two queues + per-session lock + pending_queue | **Spine/Scheduler/Lane** scheduling layer + BusyPolicy |
| Core structure | **AgentLoop/AgentRunner split** + explicit state machine | merged into a single `main.py` |
| Iteration budget | default **200** | default 40 |
| Frontend | browser **WebUI** + OpenAI API | hermes-forked **Ink TUI** + TUI-RPC |
| Crash recovery | session.metadata checkpoint (no dialogue git) | **per-turn shadow git snapshot** |
| Context compact | deterministic microcompact + **lossy LLM summary** | added **Curator lossless archive** |
| Long-term memory | plain files (MEMORY/SOUL/USER) + **Dream** + dulwich git | stacked **EverOS dual-track vector memory** + SkillForge |
| Sandbox | **bwrap** only | added **boxlite microVM** |
| Sub-agents | default concurrency 1, no per-hour frequency gate | concurrency 4 + 30/hour frequency gate |
| Proactivity | cron/heartbeat/triggers/sustained goal | added **Sentinel** event proactivity |
| Money-saving layer | no independent layer | added **TokenWise** |
| prompt cache | fixed breakpoints + stable tool sort | adaptive ≤4 breakpoints (TokenWise) |
| Skill marketplace | ClawHub | Skill Hub + SkillForge |

**One-sentence close**: Claude Code answers “how to write the code well”; Raven answers “how to make the agent better the more you use it”; **nanobot answers “how to let a small enough, honest enough, crash-doesn’t-lose-things agent live long-term in every chat app you have — and still belong entirely to you.”** It polishes “small core + edge extension + durable persist + file memory” into a readable, self-owned, auditable open-source specimen — both a personal agent you can use, and the best baseline for understanding what downstream projects like Raven looked like “at the factory.”

---

## Writing notes

- **Analysis baseline**: every conclusion in this lecture is based on nanobot repo commit `6a9157f4774e26cf0eda6e8c59bed52e05395e6a` (`main` branch, 2026-07-24, version `v0.2.2`, latest commit “feat(webui): present chats as topics”). Commit obtained via `git -C 参考项目/nanobot rev-parse HEAD`.
- **Verification**: every `file:line` was opened against source with `Read`/`Grep`, relative paths rooted at `参考项目/nanobot/`. Anything that could not be confirmed from source (e.g. “confirm round-trip popup,” “sub-agent per-hour frequency gate”) is explicitly marked 未找到 in the body; no speculation.
- **Vs Raven**: based on the fork relationship recorded in the Raven analysis (Raven forked nanobot v0.1.5.post3) plus actual differences in current nanobot source; “Raven added/changed” is given as Raven-doc conclusions + nanobot-source counter-evidence. Both projects have evolved on their own; treat each repo’s latest code as authoritative.
- **Honesty**: this lecture’s subject is **nanobot itself**; it does not copy any Raven-doc conclusions. Every nanobot–Raven difference is based on nanobot’s real source and called out explicitly.

*End of lecture.*
