Decouple tool configs from root schema

This commit is contained in:
chengyongru 2026-06-15 15:22:13 +08:00
parent 8a51059bdf
commit e015f469ad
10 changed files with 181 additions and 118 deletions

View File

@ -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")

View File

@ -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,
)

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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_<server>_<tool> 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}")

View File

@ -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

View File

@ -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"

View File

@ -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"))

View File

@ -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 = (