Subject: HKUDS/nanobot (PyPI package
nanobot-ai) Based on commit:6a9157f4774e26cf0eda6e8c59bed52e05395e6a(2026-07-24,mainbranch, versionv0.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. Everyfile:lineis 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 filesagent/loop.pyandagent/runner.pyform 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.2is 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.post3as 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:3only 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
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
inboundqueue in, oneoutboundqueue 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”
- Keep the core small; grow capability at the edge.
loop.py/runner.pyare explicitly the “critical core path”; changes should be restrained (.agent/design.md:5-7). New capability should be a channel / tool / skill / MCP. - Prefer duplication over premature abstraction.
.agent/design.md:15-17explicitly 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 underchannels/is a thick self-contained package. - 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 parallelasyncio.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.Lockper 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:
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.
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_keystrings and a_pending_queuesdict. Raven abstracted that into first-classTurnRequest+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:
- Identity + runtime + workspace (
_get_identity→ templatetemplates/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.mdare auto-managed by Dream — do not edit them directly (identity.md:9-10). - Bootstrap files (
_load_bootstrap_files,context.py:57):BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]— project rules + persona + user portrait.AGENTS.mdis taken from the project workspace,SOUL.md/USER.mdfrom the agent workspace (context.py:162-166); the two paths may differ. A decent detail: ifAGENTS.md/USER.mdcontent 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. - 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 overexec, change approach after failure rather than repeat, read before edit, and treat safety/boundary errors as real limits. # Memory: contents of long-termMEMORY.md(context.py:90-92), also checking “is this the unedited template.”# Active Skills: full text of skills markedalways(context.py:94-98).- Skills inventory summary (
agent/skills_section.md): remaining skills get only “name+description+path”; the modelread_files them on demand — progressive disclosure (context.py:100-102;skills.py:111build_skills_summary). # Recent History: fromhistory.jsonl, history since the last Dream, cap 50 entries / 8000 tokens (context.py:104-116; constants_MAX_RECENT_HISTORY=50,_MAX_HISTORY_TOKENS=8_000atcontext.py:60-61).[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
# Memoryreads theMEMORY.mdfile 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:
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_commandon disk (so the WebUI can see them without polluting LLM context). - BUILD (
loop.py:1526-1582): resolve this session’s runtime, triggerconsolidator.maybe_consolidate_by_tokensagainst the token budget, take history, assembleinitial_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 finalOutboundMessage.
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_callbackwrites “assistant message + completed tool results + pending tool calls” into session metadataruntime_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_turnmarker (_persist_user_message_early:666-699). - Restore at the next turn’s start:
_state_restorecalls_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’sCancelledErrorbranch,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) callsasyncio.Task.cancel()on every task for that session, andcancel_by_sessioncancels its sub-agents. This is a hard cancel (real.cancel()raisingCancelledError), 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 usesdrain()+ 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:
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 aboxlitemicroVM 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/respondfor 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:
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):
- 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);
apply_tool_result_budget: persist/truncate overlong tool results (normalize_tool_result:109-136+maybe_persist_tool_result— large results go to disk,read_filecan retrieve;read_fileitself is exempt to avoid a “store→read→store” loop);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).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 cursorcursorand 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:
build_dream_prompt(memory.py:532-558) concatenates “unprocessed history since last Dream” (.dream_cursorcursor) + 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 (templateagent/dream.md).build_dream_tools(memory.py:593-633) gives it an extremely shrunk toolbox: onlyread_file+write/edit/apply_patchonskills/+ precise write permission on the three memory files. It can create skills (skill-creator) and update memory, but nothing else.- git provenance:
MemoryStoreuses pure-Pythondulwich(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-logaudit reflects filesystem truth. Whether the cursor advances also depends on “was there a real content change,” not LLM self-description. - Scheduling: Dream runs as a cron system job (
cli/commands.py:2031-2039), or/dreammanually (command/builtin.py:399). Old dream session files are pruned byprune_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_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_iterationswith the main agent (default 200,subagent.py:131-135from 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_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.pyexposes/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.
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) orapiKeyin 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”;Nonekeeps 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 sidegateway_reasoning_style="reasoning_effort"(OpenRouter). - Reasoning content is separated from the body:
reasoning_content/thinking_blocksare transmitted separately (runner.py:375-387);extract_reasoning/strip_thinkpeel 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 thereasoningfield, usingreasoning_as_content=Trueas body (registry.py:98-101); Mistral’s Magistral implicit reasoning rejects thereasoning_effortparameter, special-cased viaimplicit_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:
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:752comment 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(mainbranch, 2026-07-24, versionv0.2.2, latest commit “feat(webui): present chats as topics”). Commit obtained viagit -C 参考项目/nanobot rev-parse HEAD. - Verification: every
file:linewas opened against source withRead/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.