From 3a221d74cfb9c3dcb830f85c345309cf88d62cc9 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sat, 13 Jun 2026 01:48:45 +0800 Subject: [PATCH] Break tool config schema import cycle --- nanobot/agent/tools/cli_apps.py | 2 +- nanobot/agent/tools/image_generation.py | 2 +- nanobot/agent/tools/self.py | 2 +- nanobot/agent/tools/shell.py | 2 +- nanobot/agent/tools/web.py | 2 +- nanobot/config/schema.py | 14 ++----- nanobot/config_base.py | 15 +++++++ tests/config/test_tool_config_boundaries.py | 38 ++++++++++++++++++ tests/tools/test_tool_loader.py | 44 --------------------- 9 files changed, 62 insertions(+), 59 deletions(-) create mode 100644 nanobot/config_base.py create mode 100644 tests/config/test_tool_config_boundaries.py diff --git a/nanobot/agent/tools/cli_apps.py b/nanobot/agent/tools/cli_apps.py index 9bee1a34..e794c09d 100644 --- a/nanobot/agent/tools/cli_apps.py +++ b/nanobot/agent/tools/cli_apps.py @@ -11,7 +11,7 @@ from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema from nanobot.security.workspace_access import current_tool_workspace from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig -from nanobot.config.schema import Base +from nanobot.config_base import Base class CliAppsToolConfig(Base): diff --git a/nanobot/agent/tools/image_generation.py b/nanobot/agent/tools/image_generation.py index 4471f999..82a09fb9 100644 --- a/nanobot/agent/tools/image_generation.py +++ b/nanobot/agent/tools/image_generation.py @@ -16,7 +16,7 @@ from nanobot.agent.tools.schema import ( ) from nanobot.security.workspace_access import current_tool_workspace from nanobot.config.paths import get_media_dir -from nanobot.config.schema import Base +from nanobot.config_base import Base from nanobot.providers.image_generation import ( ImageGenerationError, ImageGenerationProvider, diff --git a/nanobot/agent/tools/self.py b/nanobot/agent/tools/self.py index f12f83b3..1e60b345 100644 --- a/nanobot/agent/tools/self.py +++ b/nanobot/agent/tools/self.py @@ -10,7 +10,7 @@ from loguru import logger from nanobot.agent.tools.base import Tool from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.runtime_state import RuntimeState -from nanobot.config.schema import Base +from nanobot.config_base import Base if TYPE_CHECKING: from nanobot.agent.subagent import SubagentStatus diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index b4960e8e..808beaf0 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -34,7 +34,7 @@ from nanobot.agent.tools.schema import ( tool_parameters_schema, ) from nanobot.config.paths import get_media_dir -from nanobot.config.schema import Base +from nanobot.config_base import Base from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace from nanobot.security.workspace_policy import is_path_within diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 0b26441d..0d2302b5 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -21,7 +21,7 @@ from nanobot.agent.tools.schema import ( StringSchema, tool_parameters_schema, ) -from nanobot.config.schema import Base +from nanobot.config_base import Base from nanobot.utils.helpers import build_image_content_blocks # Shared constants diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 4e2a4599..e57220f7 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -4,10 +4,10 @@ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING, Any, Literal -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator -from pydantic.alias_generators import to_camel +from pydantic import AliasChoices, ConfigDict, Field, model_validator from pydantic_settings import BaseSettings +from nanobot.config_base import Base from nanobot.cron.types import CronSchedule if TYPE_CHECKING: @@ -18,12 +18,6 @@ if TYPE_CHECKING: from nanobot.agent.tools.web import WebToolsConfig -class Base(BaseModel): - """Base model that accepts both camelCase and snake_case keys.""" - - model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) - - class ChannelsConfig(Base): """Configuration for chat channels. @@ -320,8 +314,8 @@ class ToolsConfig(Base): """Tools configuration. Field types for tool-specific sub-configs are resolved via model_rebuild() - at the bottom of this file to avoid circular imports (tool modules import - Base from schema.py). + at the bottom of this file so tool config classes can stay next to their + tool implementations. """ web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig")) diff --git a/nanobot/config_base.py b/nanobot/config_base.py new file mode 100644 index 00000000..1972a7d3 --- /dev/null +++ b/nanobot/config_base.py @@ -0,0 +1,15 @@ +"""Shared Pydantic base model for configuration DTOs. + +This module intentionally lives outside the ``nanobot.config`` package so +runtime modules can define local config DTOs without importing the full root +configuration schema. +""" + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class Base(BaseModel): + """Base model that accepts both camelCase and snake_case keys.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/tests/config/test_tool_config_boundaries.py b/tests/config/test_tool_config_boundaries.py new file mode 100644 index 00000000..7de583f3 --- /dev/null +++ b/tests/config/test_tool_config_boundaries.py @@ -0,0 +1,38 @@ +import ast +import subprocess +import sys +from pathlib import Path + + +def test_config_base_import_does_not_load_config_schema(): + code = """ +import sys +from nanobot.config_base import Base +print("nanobot.config.schema" in sys.modules) +""" + result = subprocess.run( + [sys.executable, "-c", code], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout.strip() == "False" + + +def test_builtin_tool_configs_do_not_depend_on_config_schema_base(): + repo = Path(__file__).resolve().parents[2] + tool_paths = sorted((repo / "nanobot/agent/tools").glob("*.py")) + + violations = [] + for path in tool_paths: + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom): + continue + if node.module != "nanobot.config.schema": + continue + if any(alias.name == "Base" for alias in node.names): + violations.append(str(path.relative_to(repo))) + + assert violations == [] diff --git a/tests/tools/test_tool_loader.py b/tests/tools/test_tool_loader.py index 7c6cd872..c7ad9d69 100644 --- a/tests/tools/test_tool_loader.py +++ b/tests/tools/test_tool_loader.py @@ -352,50 +352,6 @@ def test_mcp_wrappers_not_discoverable(): assert MCPPromptWrapper._plugin_discoverable is False -# --- Task 8: Config round-trip tests --- - - -def test_config_round_trip(): - """Verify config serialization is unchanged after moving config classes.""" - from nanobot.config.schema import Config - - config_dict = { - "tools": { - "web": {"enable": True, "search": {"provider": "brave", "api_key": "test"}}, - "exec": {"enable": False, "timeout": 120, "pathPrepend": "/venv/bin"}, - "my": {"allowSet": True}, - "imageGeneration": {"enabled": True, "provider": "openrouter"}, - } - } - config = Config.model_validate(config_dict) - dumped = config.model_dump(mode="json", by_alias=True) - - assert dumped["tools"]["my"]["allowSet"] is True - assert dumped["tools"]["imageGeneration"]["enabled"] is True - assert dumped["tools"]["exec"]["pathPrepend"] == "/venv/bin" - assert config.tools.exec.enable is False - assert config.tools.exec.timeout == 120 - assert config.tools.exec.path_prepend == "/venv/bin" - assert config.tools.web.search.provider == "brave" - - -def test_config_defaults(): - """Verify default values match the original hardcoded schema.""" - from nanobot.config.schema import Config - - config = Config.model_validate({}) - assert config.tools.exec.enable is True - assert config.tools.exec.timeout == 60 - assert config.tools.exec.path_prepend == "" - assert config.tools.web.enable is True - assert config.tools.web.search.provider == "duckduckgo" - assert config.tools.my.enable is True - assert config.tools.my.allow_set is False - assert config.tools.image_generation.enabled is False - assert config.tools.cli_apps.enable is True - assert config.tools.restrict_to_workspace is False - - # --- Task 10: Integration test ---