diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6908dc4e5..531e60549 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -217,6 +217,7 @@ class AgentLoop: runtime_events: RuntimeEventBus | None = None, runtime_model_publisher: Callable[[str, str | None], None] | None = None, ): + from nanobot.agent.tools.config import tool_config_by_key from nanobot.config.schema import ToolsConfig _tc = tools_config or ToolsConfig() @@ -254,8 +255,8 @@ class AgentLoop: else defaults.tool_hint_max_length ) self.tools_config = _tc - self.web_config = _tc.web - self.exec_config = _tc.exec + self.web_config = tool_config_by_key(_tc, "web") + self.exec_config = tool_config_by_key(_tc, "exec") self._image_generation_provider_configs = dict(image_generation_provider_configs or {}) if ( image_generation_provider_config is not None @@ -439,6 +440,7 @@ class AgentLoop: def _register_default_tools(self) -> None: """Register the default set of tools via plugin loader.""" + from nanobot.agent.tools.config import tool_config from nanobot.agent.tools.context import ToolContext from nanobot.agent.tools.loader import ToolLoader @@ -459,9 +461,10 @@ class AgentLoop: registered = loader.load(ctx, self.tools) # MyTool needs runtime state reference — manual registration - if self.tools_config.my.enable: + my_config = tool_config(self.tools_config, MyTool) + if my_config.enable: self.tools.register( - MyTool(runtime_state=self, modify_allowed=self.tools_config.my.allow_set) + MyTool(runtime_state=self, modify_allowed=my_config.allow_set) ) registered.append("my") diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index d93f8419e..9c02f6082 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -115,10 +115,12 @@ class SubagentManager: def _subagent_tools_config(self) -> ToolsConfig: """Build a ToolsConfig scoped for subagent use.""" + from nanobot.agent.tools.config import tool_config_by_key + return ToolsConfig( - exec=self.tools_config.exec, - web=self.tools_config.web, - file=self.tools_config.file, + exec=tool_config_by_key(self.tools_config, "exec"), + web=tool_config_by_key(self.tools_config, "web"), + file=tool_config_by_key(self.tools_config, "file"), restrict_to_workspace=self.restrict_to_workspace, ) diff --git a/nanobot/agent/tools/config.py b/nanobot/agent/tools/config.py new file mode 100644 index 000000000..6311c6b12 --- /dev/null +++ b/nanobot/agent/tools/config.py @@ -0,0 +1,88 @@ +"""Tool-owned configuration parsing helpers.""" + +from __future__ import annotations + +from typing import Any + +from loguru import logger +from pydantic import BaseModel + +_CONFIG_CLASSES_BY_KEY: dict[str, type[BaseModel]] | None = None + + +def _extra_values(config: BaseModel) -> dict[str, Any]: + return getattr(config, "__pydantic_extra__", None) or {} + + +def _set_extra_value(config: BaseModel, key: str, value: Any) -> None: + setattr(config, key, value) + + +def _config_classes_by_key() -> dict[str, type[BaseModel]]: + global _CONFIG_CLASSES_BY_KEY + if _CONFIG_CLASSES_BY_KEY is not None: + return _CONFIG_CLASSES_BY_KEY + + from nanobot.agent.tools.loader import ToolLoader + + classes: dict[str, type[BaseModel]] = {} + for tool_cls in ToolLoader().discover_config_classes(): + key = getattr(tool_cls, "config_key", "") + config_cls = tool_cls.config_cls() + if not key or config_cls is None: + continue + previous = classes.get(key) + if previous is not None and previous is not config_cls: + logger.warning( + "Tool config key collision for %s: %s replaces %s", + key, + config_cls.__name__, + previous.__name__, + ) + classes[key] = config_cls + _CONFIG_CLASSES_BY_KEY = classes + return classes + + +def _materialize_config(config: BaseModel, key: str, config_cls: type[BaseModel]) -> BaseModel: + raw = _extra_values(config).get(key, None) + if isinstance(raw, config_cls): + return raw + if raw is None: + parsed = config_cls() + elif isinstance(raw, BaseModel): + parsed = config_cls.model_validate(raw.model_dump(mode="python")) + else: + parsed = config_cls.model_validate(raw) + _set_extra_value(config, key, parsed) + return parsed + + +def tool_config(config: Any, tool_cls: type[Any]) -> Any: + """Return the parsed config section for one tool class.""" + key = getattr(tool_cls, "config_key", "") + config_cls = tool_cls.config_cls() + if not key or config_cls is None: + return None + if not isinstance(config, BaseModel): + return getattr(config, key) + return _materialize_config(config, key, config_cls) + + +def tool_config_by_key(config: Any, key: str) -> Any: + """Return the parsed config section for a tool config key.""" + if not isinstance(config, BaseModel): + return getattr(config, key) + config_cls = _config_classes_by_key().get(key) + if config_cls is None: + raise KeyError(key) + return _materialize_config(config, key, config_cls) + + +def materialize_tool_configs(config: Any) -> Any: + """Parse all discoverable tool config sections on a ToolsConfig object.""" + if not isinstance(config, BaseModel): + return config + for key, config_cls in _config_classes_by_key().items(): + _materialize_config(config, key, config_cls) + return config diff --git a/nanobot/agent/tools/loader.py b/nanobot/agent/tools/loader.py index 85086c16a..6bcfbf183 100644 --- a/nanobot/agent/tools/loader.py +++ b/nanobot/agent/tools/loader.py @@ -28,10 +28,15 @@ class ToolLoader: self._plugins: dict[str, type[Tool]] | None = None def discover(self) -> list[type[Tool]]: + """Discover concrete tools that should be registered automatically.""" if self._test_classes is not None: return list(self._test_classes) if self._discovered is not None: return self._discovered + self._discovered = self._discover_package_tools(include_non_discoverable=False) + return self._discovered + + def _discover_package_tools(self, *, include_non_discoverable: bool) -> list[type[Tool]]: seen: set[int] = set() results: list[type[Tool]] = [] for _importer, module_name, _ispkg in pkgutil.iter_modules(self._package.__path__): @@ -50,15 +55,23 @@ class ToolLoader: and attr is not Tool and not attr_name.startswith("_") and not getattr(attr, "__abstractmethods__", None) - and getattr(attr, "_plugin_discoverable", True) + and (include_non_discoverable or getattr(attr, "_plugin_discoverable", True)) and id(attr) not in seen ): seen.add(id(attr)) results.append(attr) results.sort(key=lambda cls: cls.__name__) - self._discovered = results return results + def discover_config_classes(self) -> list[type[Tool]]: + """Discover tool classes that declare owned config models.""" + classes = self._discover_package_tools(include_non_discoverable=True) + classes.extend(self._discover_plugins().values()) + return [ + cls for cls in classes + if getattr(cls, "config_key", "") and cls.config_cls() is not None + ] + def _discover_plugins(self) -> dict[str, type[Tool]]: """Discover external tool plugins registered via entry_points.""" if self._plugins is not None: @@ -84,6 +97,8 @@ class ToolLoader: return plugins def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]: + from nanobot.agent.tools.config import tool_config + registered: list[str] = [] builtin_names: set[str] = set() sources = [(self.discover(), False), (self._discover_plugins().values(), True)] @@ -93,6 +108,7 @@ class ToolLoader: try: if scope not in getattr(tool_cls, "_scopes", {"core"}): continue + tool_config(ctx.config, tool_cls) if not tool_cls.enabled(ctx): continue tool = tool_cls.create(ctx) diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 697b57bf4..5b8148043 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -50,6 +50,12 @@ def load_config(config_path: Path | None = None) -> Config: except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e: raise ValueError(f"Failed to load config from {path}: {e}") from e + from nanobot.agent.tools.config import materialize_tool_configs + + try: + materialize_tool_configs(config.tools) + except (ValueError, pydantic.ValidationError) as e: + raise ValueError(f"Failed to load config from {path}: {e}") from e _apply_ssrf_whitelist(config) return config @@ -72,6 +78,9 @@ def save_config(config: Config, config_path: Path | None = None) -> None: path = config_path or get_config_path() path.parent.mkdir(parents=True, exist_ok=True) + from nanobot.agent.tools.config import materialize_tool_configs + + materialize_tool_configs(config.tools) data = config.model_dump(mode="json", by_alias=True) with open(path, "w", encoding="utf-8") as f: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 81c3b56c7..9daa6cdf4 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -2,7 +2,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal from pydantic import AliasChoices, ConfigDict, Field, model_validator from pydantic_settings import BaseSettings @@ -10,26 +10,6 @@ from pydantic_settings import BaseSettings from nanobot.config_base import Base from nanobot.cron.types import CronSchedule -if TYPE_CHECKING: - from nanobot.agent.tools.cli_apps import CliAppsToolConfig - from nanobot.agent.tools.filesystem import FileToolsConfig - from nanobot.agent.tools.image_generation import ImageGenerationToolConfig - from nanobot.agent.tools.self import MyToolConfig - from nanobot.agent.tools.shell import ExecToolConfig - from nanobot.agent.tools.web import WebToolsConfig - -_TOOL_CONFIG_REF_NAMES = frozenset({ - "CliAppsToolConfig", - "ExecToolConfig", - "FileToolsConfig", - "ImageGenerationToolConfig", - "MyToolConfig", - "WebFetchConfig", - "WebSearchConfig", - "WebToolsConfig", -}) -_tool_config_refs_ready = False - class ChannelsConfig(Base): """Configuration for chat channels. @@ -316,29 +296,16 @@ class MCPServerConfig(Base): enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp__ names; ["*"] = all tools; [] = no tools -def _lazy_default(module_path: str, class_name: str) -> Any: - """Deferred import helper for ToolsConfig default factories.""" - import importlib - module = importlib.import_module(module_path) - return getattr(module, class_name)() - - class ToolsConfig(Base): """Tools configuration. - Field types for tool-specific sub-configs are resolved via model_rebuild() - at the bottom of this file so tool config classes can stay next to their - tool implementations. + Concrete tool sub-configs are stored as extra fields and parsed by the + owning tool module when tools are loaded. This keeps the root schema from + importing or naming concrete tool configuration classes. """ - web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig")) - exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig")) - file: FileToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.filesystem", "FileToolsConfig")) - cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig")) - my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig")) - image_generation: ImageGenerationToolConfig = Field( - default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"), - ) + model_config = ConfigDict(extra="allow") + restrict_to_workspace: bool = False # policy intent: keep tool access inside workspace when possible webui_allow_local_service_access: bool = Field( default=True, @@ -352,16 +319,18 @@ class ToolsConfig(Base): mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale) - def __init__(self, **values: Any) -> None: - if not type(self).__pydantic_complete__: - _resolve_tool_config_refs() - super().__init__(**values) + def __getattr__(self, name: str) -> Any: + try: + return super().__getattr__(name) + except AttributeError as exc: + if name.startswith("_"): + raise + from nanobot.agent.tools.config import tool_config_by_key - @classmethod - def model_validate(cls, obj: Any, *args: Any, **kwargs: Any) -> "ToolsConfig": - if not cls.__pydantic_complete__: - _resolve_tool_config_refs() - return super().model_validate(obj, *args, **kwargs) + try: + return tool_config_by_key(self, name) + except KeyError: + raise exc from None class Config(BaseSettings): @@ -379,17 +348,6 @@ class Config(BaseSettings): validation_alias=AliasChoices("modelPresets", "model_presets"), ) - def __init__(self, **values: Any) -> None: - if not type(self).__pydantic_complete__: - _resolve_tool_config_refs() - super().__init__(**values) - - @classmethod - def model_validate(cls, obj: Any, *args: Any, **kwargs: Any) -> "Config": - if not cls.__pydantic_complete__: - _resolve_tool_config_refs() - return super().model_validate(obj, *args, **kwargs) - @model_validator(mode="after") def _validate_model_preset(self) -> "Config": if "default" in self.model_presets: @@ -577,47 +535,3 @@ class Config(BaseSettings): return None model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__") - - -def _resolve_tool_config_refs() -> None: - """Resolve forward references in ToolsConfig by importing tool config classes. - - Must be called after all modules are loaded (breaks circular imports). - Re-exports the classes into this module's namespace so existing imports - like ``from nanobot.config.schema import ExecToolConfig`` continue to work. - """ - global _tool_config_refs_ready - if _tool_config_refs_ready: - return - - import sys - - from nanobot.agent.tools.cli_apps import CliAppsToolConfig - from nanobot.agent.tools.filesystem import FileToolsConfig - from nanobot.agent.tools.image_generation import ImageGenerationToolConfig - from nanobot.agent.tools.self import MyToolConfig - from nanobot.agent.tools.shell import ExecToolConfig - from nanobot.agent.tools.web import WebFetchConfig, WebSearchConfig, WebToolsConfig - - # Re-export into this module's namespace - mod = sys.modules[__name__] - mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined] - mod.FileToolsConfig = FileToolsConfig # type: ignore[attr-defined] - mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined] - mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined] - mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined] - mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined] - mod.MyToolConfig = MyToolConfig # type: ignore[attr-defined] - mod.ImageGenerationToolConfig = ImageGenerationToolConfig # type: ignore[attr-defined] - - ToolsConfig.model_rebuild() - Config.model_rebuild() - _tool_config_refs_ready = True - - -def __getattr__(name: str) -> Any: - if name in _TOOL_CONFIG_REF_NAMES: - _resolve_tool_config_refs() - if name in globals(): - return globals()[name] - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tests/agent/test_loop_image_generation_media.py b/tests/agent/test_loop_image_generation_media.py index cfcc3b2cd..dc974da9a 100644 --- a/tests/agent/test_loop_image_generation_media.py +++ b/tests/agent/test_loop_image_generation_media.py @@ -7,10 +7,11 @@ from unittest.mock import AsyncMock, MagicMock import pytest from nanobot.agent.loop import AgentLoop +from nanobot.agent.tools.image_generation import ImageGenerationToolConfig from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus from nanobot.config.loader import set_config_path -from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig, ToolsConfig +from nanobot.config.schema import ProviderConfig, ToolsConfig from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.image_generation import GeneratedImageResponse diff --git a/tests/agent/test_workspace_scope.py b/tests/agent/test_workspace_scope.py index 9b2cff25e..8c01d6b59 100644 --- a/tests/agent/test_workspace_scope.py +++ b/tests/agent/test_workspace_scope.py @@ -7,10 +7,16 @@ import pytest from nanobot.agent.tools.cli_apps import CliAppsTool from nanobot.agent.tools.filesystem import ReadFileTool -from nanobot.agent.tools.image_generation import ImageGenerationError, ImageGenerationTool +from nanobot.agent.tools.image_generation import ( + ImageGenerationError, + ImageGenerationTool, + ImageGenerationToolConfig, +) from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.spawn import SpawnTool +from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig +from nanobot.config.schema import ProviderConfig from nanobot.security.workspace_access import ( WORKSPACE_SCOPE_METADATA_KEY, WorkspaceScopeError, @@ -20,8 +26,6 @@ from nanobot.security.workspace_access import ( validate_workspace_scope_payload, workspace_scope_from_metadata, ) -from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig -from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig PNG_BYTES = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" diff --git a/tests/config/test_tool_config_boundaries.py b/tests/config/test_tool_config_boundaries.py index 7de583f3b..6d3eebe00 100644 --- a/tests/config/test_tool_config_boundaries.py +++ b/tests/config/test_tool_config_boundaries.py @@ -20,6 +20,32 @@ print("nanobot.config.schema" in sys.modules) assert result.stdout.strip() == "False" +def test_config_schema_import_does_not_load_builtin_tool_modules(): + code = """ +import sys +import nanobot.config.schema +print(any( + name in sys.modules + for name in ( + "nanobot.agent.tools.cli_apps", + "nanobot.agent.tools.filesystem", + "nanobot.agent.tools.image_generation", + "nanobot.agent.tools.self", + "nanobot.agent.tools.shell", + "nanobot.agent.tools.web", + ) +)) +""" + 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")) diff --git a/tests/tools/test_image_generation_tool.py b/tests/tools/test_image_generation_tool.py index d2ee28388..a2c588cdc 100644 --- a/tests/tools/test_image_generation_tool.py +++ b/tests/tools/test_image_generation_tool.py @@ -6,9 +6,9 @@ from typing import Any import pytest -from nanobot.agent.tools.image_generation import ImageGenerationTool +from nanobot.agent.tools.image_generation import ImageGenerationTool, ImageGenerationToolConfig from nanobot.config.loader import set_config_path -from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig +from nanobot.config.schema import ProviderConfig from nanobot.providers.image_generation import GeneratedImageResponse PNG_BYTES = (