From c33e01ee621aece07d2d1f614a261c02628fb4cf Mon Sep 17 00:00:00 2001 From: MiguelPF Date: Wed, 18 Mar 2026 10:11:01 +0100 Subject: [PATCH 1/4] fix(cron): scope cron job store to workspace instead of global directory Replace `get_cron_dir()` with `config.workspace_path / "cron"` so each workspace keeps its own `jobs.json`. This lets users run multiple nanobot instances with independent cron schedules without cross-talk. Co-Authored-By: Claude Opus 4.6 --- nanobot/cli/commands.py | 10 ++++------ tests/test_commands.py | 6 +----- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 0d4bb3d..cde1436 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -465,7 +465,6 @@ def gateway( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.config.paths import get_cron_dir from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -485,8 +484,8 @@ def gateway( provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) - # Create cron service first (callback set after agent creation) - cron_store_path = get_cron_dir() / "jobs.json" + # Create cron service with workspace-scoped store + cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) # Create agent with cron service @@ -663,7 +662,6 @@ def agent( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus - from nanobot.config.paths import get_cron_dir from nanobot.cron.service import CronService config = _load_runtime_config(config, workspace) @@ -673,8 +671,8 @@ def agent( bus = MessageBus() provider = _make_provider(config) - # Create cron service for tool usage (no callback needed for CLI unless running) - cron_store_path = get_cron_dir() / "jobs.json" + # Create cron service with workspace-scoped store + cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) if logs: diff --git a/tests/test_commands.py b/tests/test_commands.py index a820e77..fcb2f6a 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -275,10 +275,8 @@ def mock_agent_runtime(tmp_path): """Mock agent command dependencies for focused CLI tests.""" config = Config() config.agents.defaults.workspace = str(tmp_path / "default-workspace") - cron_dir = tmp_path / "data" / "cron" with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \ - patch("nanobot.config.paths.get_cron_dir", return_value=cron_dir), \ patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \ patch("nanobot.cli.commands._make_provider", return_value=object()), \ patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \ @@ -351,7 +349,6 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None: lambda path: seen.__setitem__("config_path", path), ) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron") monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) @@ -508,7 +505,6 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron") monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) @@ -524,7 +520,7 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat result = runner.invoke(app, ["gateway", "--config", str(config_file)]) assert isinstance(result.exception, _StopGateway) - assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json" + assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json" def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None: From 4e56481f0ba59ce53bfed03e01c941722fdcae20 Mon Sep 17 00:00:00 2001 From: MiguelPF Date: Wed, 18 Mar 2026 10:16:06 +0100 Subject: [PATCH 2/4] add one-time migration for legacy global cron store When upgrading, if jobs.json exists at the old global path and not yet at the workspace path, move it automatically. Prevents silent loss of existing cron jobs. Co-Authored-By: Claude Opus 4.6 --- nanobot/cli/commands.py | 18 ++++++++++++++++++ tests/test_commands.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index cde1436..17fe7b8 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -449,6 +449,18 @@ def _print_deprecated_memory_window_notice(config: Config) -> None: ) +def _migrate_cron_store(config: "Config") -> None: + """One-time migration: move legacy global cron store into the workspace.""" + from nanobot.config.paths import get_cron_dir + + legacy_path = get_cron_dir() / "jobs.json" + new_path = config.workspace_path / "cron" / "jobs.json" + if legacy_path.is_file() and not new_path.exists(): + new_path.parent.mkdir(parents=True, exist_ok=True) + import shutil + shutil.move(str(legacy_path), str(new_path)) + + # ============================================================================ # Gateway / Server # ============================================================================ @@ -484,6 +496,9 @@ def gateway( provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) + # Migrate legacy global cron store into workspace (one-time) + _migrate_cron_store(config) + # Create cron service with workspace-scoped store cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) @@ -671,6 +686,9 @@ def agent( bus = MessageBus() provider = _make_provider(config) + # Migrate legacy global cron store into workspace (one-time) + _migrate_cron_store(config) + # Create cron service with workspace-scoped store cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) diff --git a/tests/test_commands.py b/tests/test_commands.py index fcb2f6a..9875644 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -523,6 +523,47 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json" +def test_migrate_cron_store_moves_legacy_file(tmp_path: Path) -> None: + """Legacy global jobs.json is moved into the workspace on first run.""" + from nanobot.cli.commands import _migrate_cron_store + + legacy_dir = tmp_path / "global" / "cron" + legacy_dir.mkdir(parents=True) + legacy_file = legacy_dir / "jobs.json" + legacy_file.write_text('{"jobs": []}') + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "workspace") + workspace_cron = config.workspace_path / "cron" / "jobs.json" + + with patch("nanobot.config.paths.get_cron_dir", return_value=legacy_dir): + _migrate_cron_store(config) + + assert workspace_cron.exists() + assert workspace_cron.read_text() == '{"jobs": []}' + assert not legacy_file.exists() + + +def test_migrate_cron_store_skips_when_workspace_file_exists(tmp_path: Path) -> None: + """Migration does not overwrite an existing workspace cron store.""" + from nanobot.cli.commands import _migrate_cron_store + + legacy_dir = tmp_path / "global" / "cron" + legacy_dir.mkdir(parents=True) + (legacy_dir / "jobs.json").write_text('{"old": true}') + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "workspace") + workspace_cron = config.workspace_path / "cron" / "jobs.json" + workspace_cron.parent.mkdir(parents=True) + workspace_cron.write_text('{"new": true}') + + with patch("nanobot.config.paths.get_cron_dir", return_value=legacy_dir): + _migrate_cron_store(config) + + assert workspace_cron.read_text() == '{"new": true}' + + def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None: config_file = tmp_path / "instance" / "config.json" config_file.parent.mkdir(parents=True) From d454386f3266dbd9f843874192e4de280d77f7b9 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 24 Mar 2026 02:51:50 +0000 Subject: [PATCH 3/4] docs(weixin): clarify source-only installation in README --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e793282..797a5bc 100644 --- a/README.md +++ b/README.md @@ -724,10 +724,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** From 14763a6ad1721736ae0658b485a218107618972b Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 24 Mar 2026 03:03:59 +0000 Subject: [PATCH 4/4] fix(provider): accept canonical and alias provider names consistently --- nanobot/config/schema.py | 9 ++++++--- nanobot/providers/registry.py | 5 ++++- tests/test_commands.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 7d8f5c8..b31f306 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -165,12 +165,15 @@ class Config(BaseSettings): self, model: str | None = None ) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" - from nanobot.providers.registry import PROVIDERS + from nanobot.providers.registry import PROVIDERS, find_by_name forced = self.agents.defaults.provider if forced != "auto": - p = getattr(self.providers, forced, None) - return (p, forced) if p else (None, None) + spec = find_by_name(forced) + if spec: + p = getattr(self.providers, spec.name, None) + return (p, spec.name) if p else (None, None) + return None, None model_lower = (model or self.agents.defaults.model).lower() model_normalized = model_lower.replace("-", "_") diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 9cc430b..10e0fec 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -15,6 +15,8 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any +from pydantic.alias_generators import to_snake + @dataclass(frozen=True) class ProviderSpec: @@ -545,7 +547,8 @@ def find_gateway( def find_by_name(name: str) -> ProviderSpec | None: """Find a provider spec by config field name, e.g. "dashscope".""" + normalized = to_snake(name.replace("-", "_")) for spec in PROVIDERS: - if spec.name == name: + if spec.name == normalized: return spec return None diff --git a/tests/test_commands.py b/tests/test_commands.py index 68cc429..4e79fc7 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -11,7 +11,7 @@ from nanobot.cli.commands import _make_provider, app from nanobot.config.schema import Config from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import _strip_model_prefix -from nanobot.providers.registry import find_by_model +from nanobot.providers.registry import find_by_model, find_by_name runner = CliRunner() @@ -240,6 +240,34 @@ def test_config_explicit_ollama_provider_uses_default_localhost_api_base(): assert config.get_api_base() == "http://localhost:11434" +def test_config_accepts_camel_case_explicit_provider_name_for_coding_plan(): + config = Config.model_validate( + { + "agents": { + "defaults": { + "provider": "volcengineCodingPlan", + "model": "doubao-1-5-pro", + } + }, + "providers": { + "volcengineCodingPlan": { + "apiKey": "test-key", + } + }, + } + ) + + assert config.get_provider_name() == "volcengine_coding_plan" + assert config.get_api_base() == "https://ark.cn-beijing.volces.com/api/coding/v3" + + +def test_find_by_name_accepts_camel_case_and_hyphen_aliases(): + assert find_by_name("volcengineCodingPlan") is not None + assert find_by_name("volcengineCodingPlan").name == "volcengine_coding_plan" + assert find_by_name("github-copilot") is not None + assert find_by_name("github-copilot").name == "github_copilot" + + def test_config_auto_detects_ollama_from_local_api_base(): config = Config.model_validate( {