Engineering Conventions
Code Style
Language & Version
Python ≥ 3.11 — uses
X | Yunion syntax,matchstatements,StrEnum, etc.Line length: 100 characters (
pyproject.toml: [tool.ruff] line-length = 100)Target version: py311
Linting & Formatting
nanobot uses ruff for linting with the following rule sets:
Code |
Rules |
|---|---|
E |
pycodestyle errors |
F |
Pyflakes |
I |
isort (import sorting) |
N |
pep8-naming |
W |
pycodestyle warnings |
Ignored: E501 (line length) — the 100-char limit is a guideline, not enforced.
Run linter:
ruff check nanobot/
Import Organization
Imports follow this order (enforced by ruff’s I rules):
# 1. Future annotations (always first when used)
from __future__ import annotations
# 2. Standard library
import asyncio
import json
from pathlib import Path
from typing import Any, TYPE_CHECKING
# 3. Third-party libraries
from loguru import logger
from pydantic import BaseModel
# 4. Local imports
from nanobot.bus.events import InboundMessage
from nanobot.agent.tools.base import Tool
TYPE_CHECKING guard: Heavy imports used only for type hints go inside if TYPE_CHECKING: blocks to avoid circular imports and speed up module loading.
Type Annotations
All public function signatures must have type annotations
Use
str | Nonesyntax (notOptional[str])Use
list[str](notList[str])Use
dict[str, Any](notDict[str, Any])
Naming Conventions
Element |
Convention |
Example |
|---|---|---|
Modules |
|
|
Classes |
|
|
Functions / Methods |
|
|
Private methods |
|
|
Constants |
|
|
Config fields (Python) |
|
|
Config fields (JSON) |
|
|
Dataclass fields |
|
|
Test functions |
|
|
Configuration Naming
The Pydantic Base model uses alias_generator=to_camel so that:
Python code uses
snake_case:config.providers.openrouter.api_keyJSON config accepts both:
"apiKey"or"api_key"
This dual-naming is intentional — it maintains Python idiom in code while being compatible with Claude Desktop / Cursor config formats that use camelCase.
Architecture Patterns
Abstract Base Classes
Core abstractions use Python ABCs:
LLMProvider— abstractchat()andget_default_model()BaseChannel— abstractstart(),stop(),send()Tool— abstractname,description,parameters,execute()
Registry Pattern
The provider system uses a declarative registry (ProviderSpec entries in a tuple) instead of if-elif chains. Adding a new provider means adding data, not modifying control flow.
Message Bus (Pub/Sub)
Channels and the agent loop are decoupled through asyncio.Queue. This means:
Channels don’t know about the agent
The agent doesn’t know about channels
All coordination happens through
InboundMessage/OutboundMessagedataclasses
Lazy Imports
Channel SDKs are imported lazily inside ChannelManager._init_channels() — only when the channel is enabled. This avoids loading heavy SDKs (telegram, discord, slack, etc.) for channels the user hasn’t configured.
Append-Only Sessions
Session messages are never modified or deleted. Consolidation advances the last_consolidated pointer but leaves old messages intact. This design:
Preserves LLM prompt cache efficiency (prefixes don’t change)
Simplifies persistence (append to JSONL)
Avoids data loss from failed consolidation
Error Handling
Tool Execution
The ToolRegistry.execute() method wraps all tool calls in try/except:
Validation errors → return error string with
[Analyze the error above and try a different approach.]hintExecution errors → return error string with the same hint
The LLM receives error strings as tool results and can self-correct
Provider Errors
Provider exceptions (API errors, rate limits, timeouts) are:
Logged via
loguruPropagated to the agent loop
Communicated to the user as error messages
Channel Errors
Channel connection failures are:
Logged as warnings
The channel is marked as unavailable
Other channels continue to operate independently
Logging
Library: loguru (structured logging with colors)
Default output: stderr
Log format: timestamp, level, module, message
CLI flag:
--logsshows logs alongside chat output in interactive modeBest practice: Use
logger.info()for key events,logger.warning()for recoverable issues,logger.exception()for unexpected failures
Testing
Framework
pytest + pytest-asyncio (mode:
auto)Tests in
tests/directoryTest files:
test_*.py
Patterns
Unit tests: Test individual functions/methods with no external dependencies
Mocking: Use
DummyProvider,SampleTooletc. — lightweight test doubles defined in test filesAsync tests: Decorated with
@pytest.mark.asyncio, usetmp_pathfixture for temp filesFixtures:
tmp_path(pytest built-in) for isolated filesystem tests
Running Tests
# Run all tests
pytest -s tests/
# Run a specific test file
pytest -s tests/test_tool_validation.py
# Run with verbose output
pytest -v tests/
# Run tests matching a pattern
pytest -k "test_heartbeat" tests/
Git & Contribution
Branch Strategy
PRs welcome against the main branch
The codebase is intentionally small and readable
Commit Style
Descriptive commit messages
No enforced commit message format
Code Size Budget
The project maintains a ~4,000 line code size target for core agent code. The script core_agent_lines.sh verifies this.
Dependency Management
Adding Dependencies
Add to
pyproject.tomlunder[project.dependencies]Pin major version ranges:
"typer>=0.20.0,<1.0.0"Optional dependencies go in
[project.optional-dependencies]
Current Dependency Groups
Group |
Purpose |
Example Packages |
|---|---|---|
Core |
Always installed |
typer, litellm, pydantic, httpx, loguru, rich |
Channels |
Chat platform SDKs |
python-telegram-bot, slack-sdk, qq-botpy |
Matrix |
Optional Matrix support |
matrix-nio, mistune, nh3 |
Dev |
Development tools |
pytest, pytest-asyncio, ruff |
Skills Convention
Structure
Each skill lives in a directory with a SKILL.md file:
skills/
└── my-skill/
└── SKILL.md
Frontmatter
Skills use YAML frontmatter for metadata:
---
name: weather
description: Query weather information for any location
metadata: '{"nanobot": {"requires": {"bins": ["curl"]}, "always": false}}'
---
Priority
Workspace skills (~/.nanobot/workspace/skills/) override built-in skills (nanobot/skills/) when names collide.