English lecture · source-traced

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 sam

EN
lecture
source
file:line
no
embellishment
中文
sibling

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


Part 1

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.

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

Part 2

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:

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

Part 3

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:

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:

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

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.

Part 4

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:

@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.Queues, 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.

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.

Part 5

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_files 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 🐦‍⬛.

Part 6

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:

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:

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

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:

flowchart TD
    START["iteration in range(200)"] --> GOV["context_governor.prepare_for_model
治理模型侧副本(见第8章)"] GOV --> CALL["_request_model:流式/进度流/非流式
+ 300s wall timeout"] CALL --> MAL{"全是畸形 tool_call?"} MAL -- 是 --> RETRY["重试→退化为无工具请求
runner.py:845-875"] MAL -- 否 --> TC{"should_execute_tools?"} TC -- 是 --> EXEC["_execute_tools:批量/并发执行
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
_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.

Part 7

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:

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 (未找到).

Part 8

Chapter 8 · Compaction and memory

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

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

Part 9

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_inbounds onto the same message bus:

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.

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.

Part 10

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

Part 11

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

Part 12

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:

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


Part 13

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.

↑