Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
c33e01e
fix(cron): scope cron job store to workspace instead of global directory
MiguelPF Mar 18, 2026
4e56481
add one-time migration for legacy global cron store
MiguelPF Mar 18, 2026
9a2b1a3
feat(telegram): add react_emoji config for incoming messages
flobo3 Mar 21, 2026
80ee272
feat(telegram): add silent_tool_hints config to disable notifications…
flobo3 Mar 20, 2026
d7373db
feat(qq): bot can send and receive images and files (#1667)
ddadaal Mar 20, 2026
2db2cc1
fix(qq): fix local file outbound and add svg as image type (#2294)
ddadaal Mar 20, 2026
e413773
fix(qq): handle file:// URI on Windows in _read_media_bytes
chengyongru Mar 23, 2026
b14d5a0
feat(whatsapp): add group_policy to control bot response behavior in …
flobo3 Mar 18, 2026
4145f3e
feat(feishu): add thread reply support for topic group messages
A11Might Mar 20, 2026
186357e
Merge branch 'main' into fix/workspace-scoped-cron-store
Re-bin Mar 24, 2026
b5c95b1
Merge PR #2204: fix(cron): scope cron state to each workspace with sa…
Re-bin Mar 24, 2026
d454386
docs(weixin): clarify source-only installation in README
Re-bin Mar 24, 2026
14763a6
fix(provider): accept canonical and alias provider names consistently
Re-bin Mar 24, 2026
a96dd8b
Merge branch 'main' into feat/channel_enhancement
Re-bin Mar 24, 2026
c00e64a
Merge PR #2386: feat(channel): enhance Telegram, QQ, Feishu, and What…
Re-bin Mar 24, 2026
69f1dcd
proposal to adopt mypy some e.g. interfaces problems
19emtuck Mar 22, 2026
d4a7194
remove some none used f string
19emtuck Mar 23, 2026
d25985b
fix(filesystem): clarify optional tool argument handling
Re-bin Mar 24, 2026
72acba5
refactor(tests): optimize unit test structure
chengyongru Mar 24, 2026
38ce054
fix(security): pin litellm and add supply chain advisory note
Re-bin Mar 24, 2026
3dfdab7
refactor: replace litellm with native openai + anthropic SDKs
Re-bin Mar 24, 2026
c3031c9
docs: update news section about litellm
Re-bin Mar 24, 2026
7b31af2
docs: update news section
Re-bin Mar 24, 2026
3a9d6ea
feat(WeXin): add route_tag property to adapt to WeChat official ilink…
xcosmosbox Mar 24, 2026
9c872c3
fix(WeiXin): resolve polling issues in WeiXin plugin
xcosmosbox Mar 24, 2026
1f5492e
fix(WeiXin): persist _context_tokens with account.json to restore con…
xcosmosbox Mar 24, 2026
48902ae
fix(WeiXin): auto-refresh expired QR code during login to improve suc…
xcosmosbox Mar 24, 2026
0dad612
chore(WeiXin): version migration and compatibility update
xcosmosbox Mar 24, 2026
0ccfcf6
fix(WeiXin): version migration
xcosmosbox Mar 24, 2026
b7df3a0
Update README with group policy clarification
Seeratul Mar 24, 2026
321214e
Update group policy explanation in README
Seeratul Mar 24, 2026
2630695
fix(provider): accept plain text OpenAI-compatible responses
Re-bin Mar 25, 2026
7b720ce
feat(OpenAICompatProvider): enhance tool call handling with provider-…
yoheinishikubo Mar 25, 2026
af84b1b
fix(Gemini): update ToolCallRequest and OpenAICompatProvider to handl…
yoheinishikubo Mar 25, 2026
b5302b6
refactor(provider): preserve extra_content verbatim for Gemini though…
Re-bin Mar 25, 2026
ef10df9
fix(providers): add max_completion_tokens for openai o1 compatibility
flobo3 Mar 25, 2026
13d6c0a
feat(config): add configurable timezone for runtime context
Re-bin Mar 25, 2026
4a7d7b8
feat(cron): inherit agent timezone for default schedules
Re-bin Mar 25, 2026
fab1469
refactor(cron): align displayed times with schedule timezone
Re-bin Mar 25, 2026
3f71014
fix(agent): use configured timezone when registering cron tool
Re-bin Mar 25, 2026
5e9fa28
feat(channel): add message send retry mechanism with exponential backoff
chengyongru Mar 25, 2026
f0f0bf0
refactor(channel): centralize retry around explicit send failures
Re-bin Mar 25, 2026
813de55
feat(provider): add Step Fun (阶跃星辰) provider support
Mar 25, 2026
33abe91
fix telegram streaming message boundaries
Re-bin Mar 26, 2026
be036e7
Merge upstream/main — litellm → openai_compat_provider, timezone support
kmbandy Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install .[dev]
- name: Install all dependencies
run: uv sync --all-extras

- name: Run tests
run: python -m pytest tests/ -v
run: uv run pytest tests/
92 changes: 81 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@

## 📢 News

> [!IMPORTANT]
> **Security note:** Due to `litellm` supply chain poisoning, **please check your Python environment ASAP** and refer to this [advisory](https://github.com/HKUDS/nanobot/discussions/2445) for details. We have fully removed the `litellm` dependency in [this commit](https://github.com/HKUDS/nanobot/commit/3dfdab7).

- **2026-03-21** 🔒 Replace `litellm` with native `openai` + `anthropic` SDKs. Please see [commit](https://github.com/HKUDS/nanobot/commit/3dfdab7).
- **2026-03-20** 🧙 Interactive setup wizard — pick your provider, model autocomplete, and you're good to go.
- **2026-03-19** 💬 Telegram gets more resilient under load; Feishu now renders code blocks properly.
- **2026-03-18** 📷 Telegram can now send media via URL. Cron schedules show human-readable details.
- **2026-03-17** ✨ Feishu formatting glow-up, Slack reacts when done, custom endpoints support extra headers, and image handling is more reliable.
- **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details.
- **2026-03-15** 🧩 DingTalk rich media, smarter built-in skills, and cleaner model compatibility.
- **2026-03-14** 💬 Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling.
Expand Down Expand Up @@ -373,6 +381,7 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso
> - `"mention"` (default) — Only respond when @mentioned
> - `"open"` — Respond to all messages
> DMs always respond when the sender is in `allowFrom`.
> - If you set group policy to open create new threads as private threads and then @ the bot into it. Otherwise the thread itself and the channel in which you spawned it will spawn a bot session.

**5. Invite the bot**
- OAuth2 → URL Generator
Expand Down Expand Up @@ -724,10 +733,14 @@ nanobot gateway

Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.

**1. Install the optional dependency**
> Weixin support is available from source checkout, but is not included in the current PyPI release yet.

**1. Install from source**

```bash
pip install nanobot-ai[weixin]
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
pip install -e ".[weixin]"
```

**2. Configure**
Expand All @@ -745,6 +758,7 @@ pip install nanobot-ai[weixin]

> - `allowFrom`: Add the sender ID you see in nanobot logs for your WeChat account. Use `["*"]` to allow all users.
> - `token`: Optional. If omitted, log in interactively and nanobot will save the token for you.
> - `routeTag`: Optional. When your upstream Weixin deployment requires request routing, nanobot will send it as the `SKRouteTag` header.
> - `stateDir`: Optional. Defaults to nanobot's runtime directory for Weixin state.
> - `pollTimeout`: Optional long-poll timeout in seconds.

Expand Down Expand Up @@ -832,10 +846,12 @@ Config file: `~/.nanobot/config.json`
> - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers.
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
> - **Step Fun Step Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.stepfun.ai/step-plan) · [Mainland China](https://platform.stepfun.com/step-plan)

| Provider | Purpose | Get API Key |
|----------|---------|-------------|
| `custom` | Any OpenAI-compatible endpoint (direct, no LiteLLM) | — |
| `custom` | Any OpenAI-compatible endpoint | — |
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) |
| `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [byteplus.com](https://www.byteplus.com) |
Expand All @@ -853,6 +869,7 @@ Config file: `~/.nanobot/config.json`
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `ollama` | LLM (local, Ollama) | — |
| `mistral` | LLM | [docs.mistral.ai](https://docs.mistral.ai/) |
| `stepfun` | LLM (Step Fun/阶跃星辰) | [platform.stepfun.com](https://platform.stepfun.com) |
| `ovms` | LLM (local, OpenVINO Model Server) | [docs.openvino.ai](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) |
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
Expand Down Expand Up @@ -936,7 +953,7 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
<details>
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>

Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Bypasses LiteLLM; model name is passed as-is.
Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Model name is passed as-is.

```json
{
Expand Down Expand Up @@ -1113,10 +1130,9 @@ Adding a new provider only takes **2 steps** — no if-elif chains to touch.
ProviderSpec(
name="myprovider", # config field name
keywords=("myprovider", "mymodel"), # model-name keywords for auto-matching
env_key="MYPROVIDER_API_KEY", # env var for LiteLLM
env_key="MYPROVIDER_API_KEY", # env var name
display_name="My Provider", # shown in `nanobot status`
litellm_prefix="myprovider", # auto-prefix: model → myprovider/model
skip_prefixes=("myprovider/",), # don't double-prefix
default_api_base="https://api.myprovider.com/v1", # OpenAI-compatible endpoint
)
```

Expand All @@ -1128,23 +1144,55 @@ class ProvidersConfig(BaseModel):
myprovider: ProviderConfig = ProviderConfig()
```

That's it! Environment variables, model prefixing, config matching, and `nanobot status` display will all work automatically.
That's it! Environment variables, model routing, config matching, and `nanobot status` display will all work automatically.

**Common `ProviderSpec` options:**

| Field | Description | Example |
|-------|-------------|---------|
| `litellm_prefix` | Auto-prefix model names for LiteLLM | `"dashscope"` → `dashscope/qwen-max` |
| `skip_prefixes` | Don't prefix if model already starts with these | `("dashscope/", "openrouter/")` |
| `default_api_base` | OpenAI-compatible base URL | `"https://api.deepseek.com"` |
| `env_extras` | Additional env vars to set | `(("ZHIPUAI_API_KEY", "{api_key}"),)` |
| `model_overrides` | Per-model parameter overrides | `(("kimi-k2.5", {"temperature": 1.0}),)` |
| `is_gateway` | Can route any model (like OpenRouter) | `True` |
| `detect_by_key_prefix` | Detect gateway by API key prefix | `"sk-or-"` |
| `detect_by_base_keyword` | Detect gateway by API base URL | `"openrouter"` |
| `strip_model_prefix` | Strip existing prefix before re-prefixing | `True` (for AiHubMix) |
| `strip_model_prefix` | Strip provider prefix before sending to gateway | `True` (for AiHubMix) |

</details>

### Channel Settings

Global settings that apply to all channels. Configure under the `channels` section in `~/.nanobot/config.json`:

```json
{
"channels": {
"sendProgress": true,
"sendToolHints": false,
"sendMaxRetries": 3,
"telegram": { ... }
}
}
```

| Setting | Default | Description |
|---------|---------|-------------|
| `sendProgress` | `true` | Stream agent's text progress to the channel |
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |

#### Retry Behavior

When a channel send operation raises an error, nanobot retries with exponential backoff:

- **Attempt 1**: Initial send
- **Attempts 2-4**: Retry delays are 1s, 2s, 4s
- **Attempts 5+**: Retry delay caps at 4s
- **Transient failures** (network hiccups, temporary API limits): Retry usually succeeds
- **Permanent failures** (invalid token, channel banned): All retries fail

> [!NOTE]
> When a channel is completely unavailable, there's no way to notify the user since we cannot reach them through that channel. Monitor logs for "Failed to send to {channel} after N attempts" to detect persistent delivery failures.

### Web Search

Expand Down Expand Up @@ -1333,6 +1381,28 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
| `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. |


### Timezone

Time is context. Context should be precise.

By default, nanobot uses `UTC` for runtime time context. If you want the agent to think in your local time, set `agents.defaults.timezone` to a valid [IANA timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones):

```json
{
"agents": {
"defaults": {
"timezone": "Asia/Shanghai"
}
}
}
```

This affects runtime time strings shown to the model, such as runtime context and heartbeat prompts. It also becomes the default timezone for cron schedules when a cron expression omits `tz`, and for one-shot `at` times when the ISO datetime has no explicit offset.

Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/London`, `Europe/Berlin`, `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Singapore`, `Australia/Sydney`.

> Need another timezone? Browse the full [IANA Time Zone Database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).

## 🧩 Multiple Instances

Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint. Optionally pass `--workspace` during `onboard` when you want to initialize or update the saved workspace for a specific instance.
Expand Down
28 changes: 28 additions & 0 deletions bridge/src/whatsapp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface InboundMessage {
content: string;
timestamp: number;
isGroup: boolean;
wasMentioned?: boolean;
media?: string[];
}

Expand All @@ -48,6 +49,31 @@ export class WhatsAppClient {
this.options = options;
}

private normalizeJid(jid: string | undefined | null): string {
return (jid || '').split(':')[0];
}

private wasMentioned(msg: any): boolean {
if (!msg?.key?.remoteJid?.endsWith('@g.us')) return false;

const candidates = [
msg?.message?.extendedTextMessage?.contextInfo?.mentionedJid,
msg?.message?.imageMessage?.contextInfo?.mentionedJid,
msg?.message?.videoMessage?.contextInfo?.mentionedJid,
msg?.message?.documentMessage?.contextInfo?.mentionedJid,
msg?.message?.audioMessage?.contextInfo?.mentionedJid,
];
const mentioned = candidates.flatMap((items) => (Array.isArray(items) ? items : []));
if (mentioned.length === 0) return false;

const selfIds = new Set(
[this.sock?.user?.id, this.sock?.user?.lid, this.sock?.user?.jid]
.map((jid) => this.normalizeJid(jid))
.filter(Boolean),
);
return mentioned.some((jid: string) => selfIds.has(this.normalizeJid(jid)));
}

async connect(): Promise<void> {
const logger = pino({ level: 'silent' });
const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
Expand Down Expand Up @@ -145,6 +171,7 @@ export class WhatsAppClient {
if (!finalContent && mediaPaths.length === 0) continue;

const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
const wasMentioned = this.wasMentioned(msg);

this.options.onMessage({
id: msg.key.id || '',
Expand All @@ -153,6 +180,7 @@ export class WhatsAppClient {
content: finalContent,
timestamp: msg.messageTimestamp as number,
isGroup,
...(isGroup ? { wasMentioned } : {}),
...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
});
}
Expand Down
15 changes: 9 additions & 6 deletions nanobot/agent/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ class ContextBuilder:
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"

def __init__(self, workspace: Path, provider: LLMProvider | None = None, model: str | None = None,
memory_max_chars: int = 8000, memory_max_tokens: int = 2000, memory_compaction_enabled: bool = True,
temperature: float = 0.1):
def __init__(self, workspace: Path, timezone: str | None = None, provider: LLMProvider | None = None,
model: str | None = None, memory_max_chars: int = 8000, memory_max_tokens: int = 2000,
memory_compaction_enabled: bool = True, temperature: float = 0.1):
self.workspace = workspace
self.timezone = timezone
self.provider = provider
self.model = model
self.memory = MemoryStore(workspace)
Expand Down Expand Up @@ -174,9 +175,11 @@ def _get_identity(self) -> str:
IMPORTANT: To send files (images, documents, audio, video) to the user, you MUST call the 'message' tool with the 'media' parameter. Do NOT use read_file to "send" a file — reading a file only shows its content to you, it does NOT deliver the file to the user. Example: message(content="Here is the file", media=["/path/to/file.png"])"""

@staticmethod
def _build_runtime_context(channel: str | None, chat_id: str | None) -> str:
def _build_runtime_context(
channel: str | None, chat_id: str | None, timezone: str | None = None,
) -> str:
"""Build untrusted runtime metadata block for injection before the user message."""
lines = [f"Current Time: {current_time_str()}"]
lines = [f"Current Time: {current_time_str(timezone)}"]
if channel and chat_id:
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines)
Expand Down Expand Up @@ -204,7 +207,7 @@ def build_messages(
current_role: str = "user",
) -> list[dict[str, Any]]:
"""Build the complete message list (sync, no memory compaction). For token estimation."""
runtime_ctx = self._build_runtime_context(channel, chat_id)
runtime_ctx = self._build_runtime_context(channel, chat_id, self.timezone)
user_content = self._build_user_content(current_message, media)

if isinstance(user_content, str):
Expand Down
28 changes: 25 additions & 3 deletions nanobot/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -1241,6 +1241,7 @@ def __init__(
memory_max_tokens: int | None = None,
memory_compaction_enabled: bool | None = None,
sysmon: bool = True,
timezone: str | None = None,
):
from nanobot.config.schema import ExecToolConfig, WebSearchConfig

Expand All @@ -1265,6 +1266,7 @@ def __init__(

self.context = ContextBuilder(
workspace=workspace,
timezone=timezone,
provider=provider,
model=self.model,
memory_max_chars=memory_max_chars or 8000,
Expand Down Expand Up @@ -1349,7 +1351,9 @@ def _register_default_tools(self) -> None:
default_model=self.nvidia_default_model or 'nvidia/llama-3.1-nemotron-ultra-253b-v1',
))
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
self.tools.register(
CronTool(self.cron_service, default_timezone=self.context.timezone or "UTC")
)

def _register_fleet_commands(self) -> None:
"""Register custom fleet slash/bang commands with the CommandRouter."""
Expand Down Expand Up @@ -1743,17 +1747,35 @@ async def _dispatch(self, msg: InboundMessage) -> None:
try:
on_stream = on_stream_end = None
if msg.metadata.get("_wants_stream"):
# Split one answer into distinct stream segments.
stream_base_id = f"{msg.session_key}:{time.time_ns()}"
stream_segment = 0

def _current_stream_id() -> str:
return f"{stream_base_id}:{stream_segment}"

async def on_stream(delta: str) -> None:
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content=delta, metadata={"_stream_delta": True},
content=delta,
metadata={
"_stream_delta": True,
"_stream_id": _current_stream_id(),
},
))

async def on_stream_end(*, resuming: bool = False) -> None:
nonlocal stream_segment
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata={"_stream_end": True, "_resuming": resuming},
content="",
metadata={
"_stream_end": True,
"_resuming": resuming,
"_stream_id": _current_stream_id(),
},
))
stream_segment += 1

response = await self._process_message(
msg, on_stream=on_stream, on_stream_end=on_stream_end,
Expand Down
Loading
Loading