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_events: RuntimeEventBus | None = None,
runtime_model_publisher: Callable[[str, str | None], None] | 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 from nanobot.config.schema import ToolsConfig
_tc = tools_config or ToolsConfig() _tc = tools_config or ToolsConfig()
@ -254,8 +255,8 @@ class AgentLoop:
else defaults.tool_hint_max_length else defaults.tool_hint_max_length
) )
self.tools_config = _tc self.tools_config = _tc
self.web_config = _tc.web self.web_config = tool_config_by_key(_tc, "web")
self.exec_config = _tc.exec self.exec_config = tool_config_by_key(_tc, "exec")
self._image_generation_provider_configs = dict(image_generation_provider_configs or {}) self._image_generation_provider_configs = dict(image_generation_provider_configs or {})
if ( if (
image_generation_provider_config is not None image_generation_provider_config is not None
@ -439,6 +440,7 @@ class AgentLoop:
def _register_default_tools(self) -> None: def _register_default_tools(self) -> None:
"""Register the default set of tools via plugin loader.""" """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.context import ToolContext
from nanobot.agent.tools.loader import ToolLoader from nanobot.agent.tools.loader import ToolLoader
@ -459,9 +461,10 @@ class AgentLoop:
registered = loader.load(ctx, self.tools) registered = loader.load(ctx, self.tools)
# MyTool needs runtime state reference — manual registration # 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( 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") registered.append("my")

View File

@ -115,10 +115,12 @@ class SubagentManager:
def _subagent_tools_config(self) -> ToolsConfig: def _subagent_tools_config(self) -> ToolsConfig:
"""Build a ToolsConfig scoped for subagent use.""" """Build a ToolsConfig scoped for subagent use."""
from nanobot.agent.tools.config import tool_config_by_key
return ToolsConfig( return ToolsConfig(
exec=self.tools_config.exec, exec=tool_config_by_key(self.tools_config, "exec"),
web=self.tools_config.web, web=tool_config_by_key(self.tools_config, "web"),
file=self.tools_config.file, file=tool_config_by_key(self.tools_config, "file"),
restrict_to_workspace=self.restrict_to_workspace, 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 self._plugins: dict[str, type[Tool]] | None = None
def discover(self) -> list[type[Tool]]: def discover(self) -> list[type[Tool]]:
"""Discover concrete tools that should be registered automatically."""
if self._test_classes is not None: if self._test_classes is not None:
return list(self._test_classes) return list(self._test_classes)
if self._discovered is not None: if self._discovered is not None:
return self._discovered 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() seen: set[int] = set()
results: list[type[Tool]] = [] results: list[type[Tool]] = []
for _importer, module_name, _ispkg in pkgutil.iter_modules(self._package.__path__): for _importer, module_name, _ispkg in pkgutil.iter_modules(self._package.__path__):
@ -50,15 +55,23 @@ class ToolLoader:
and attr is not Tool and attr is not Tool
and not attr_name.startswith("_") and not attr_name.startswith("_")
and not getattr(attr, "__abstractmethods__", None) 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 and id(attr) not in seen
): ):
seen.add(id(attr)) seen.add(id(attr))
results.append(attr) results.append(attr)
results.sort(key=lambda cls: cls.__name__) results.sort(key=lambda cls: cls.__name__)
self._discovered = results
return 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]]: def _discover_plugins(self) -> dict[str, type[Tool]]:
"""Discover external tool plugins registered via entry_points.""" """Discover external tool plugins registered via entry_points."""
if self._plugins is not None: if self._plugins is not None:
@ -84,6 +97,8 @@ class ToolLoader:
return plugins return plugins
def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]: def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
from nanobot.agent.tools.config import tool_config
registered: list[str] = [] registered: list[str] = []
builtin_names: set[str] = set() builtin_names: set[str] = set()
sources = [(self.discover(), False), (self._discover_plugins().values(), True)] sources = [(self.discover(), False), (self._discover_plugins().values(), True)]
@ -93,6 +108,7 @@ class ToolLoader:
try: try:
if scope not in getattr(tool_cls, "_scopes", {"core"}): if scope not in getattr(tool_cls, "_scopes", {"core"}):
continue continue
tool_config(ctx.config, tool_cls)
if not tool_cls.enabled(ctx): if not tool_cls.enabled(ctx):
continue continue
tool = tool_cls.create(ctx) 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: except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e:
raise ValueError(f"Failed to load config from {path}: {e}") from 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) _apply_ssrf_whitelist(config)
return 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 = config_path or get_config_path()
path.parent.mkdir(parents=True, exist_ok=True) 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) data = config.model_dump(mode="json", by_alias=True)
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path 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 import AliasChoices, ConfigDict, Field, model_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@ -10,26 +10,6 @@ from pydantic_settings import BaseSettings
from nanobot.config_base import Base from nanobot.config_base import Base
from nanobot.cron.types import CronSchedule 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): class ChannelsConfig(Base):
"""Configuration for chat channels. """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 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): class ToolsConfig(Base):
"""Tools configuration. """Tools configuration.
Field types for tool-specific sub-configs are resolved via model_rebuild() Concrete tool sub-configs are stored as extra fields and parsed by the
at the bottom of this file so tool config classes can stay next to their owning tool module when tools are loaded. This keeps the root schema from
tool implementations. importing or naming concrete tool configuration classes.
""" """
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig")) model_config = ConfigDict(extra="allow")
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"),
)
restrict_to_workspace: bool = False # policy intent: keep tool access inside workspace when possible restrict_to_workspace: bool = False # policy intent: keep tool access inside workspace when possible
webui_allow_local_service_access: bool = Field( webui_allow_local_service_access: bool = Field(
default=True, default=True,
@ -352,16 +319,18 @@ class ToolsConfig(Base):
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) 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) 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: def __getattr__(self, name: str) -> Any:
if not type(self).__pydantic_complete__: try:
_resolve_tool_config_refs() return super().__getattr__(name)
super().__init__(**values) except AttributeError as exc:
if name.startswith("_"):
raise
from nanobot.agent.tools.config import tool_config_by_key
@classmethod try:
def model_validate(cls, obj: Any, *args: Any, **kwargs: Any) -> "ToolsConfig": return tool_config_by_key(self, name)
if not cls.__pydantic_complete__: except KeyError:
_resolve_tool_config_refs() raise exc from None
return super().model_validate(obj, *args, **kwargs)
class Config(BaseSettings): class Config(BaseSettings):
@ -379,17 +348,6 @@ class Config(BaseSettings):
validation_alias=AliasChoices("modelPresets", "model_presets"), 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") @model_validator(mode="after")
def _validate_model_preset(self) -> "Config": def _validate_model_preset(self) -> "Config":
if "default" in self.model_presets: if "default" in self.model_presets:
@ -577,47 +535,3 @@ class Config(BaseSettings):
return None return None
model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__") 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 import pytest
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.config.loader import set_config_path 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.base import LLMResponse, ToolCallRequest
from nanobot.providers.image_generation import GeneratedImageResponse 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.cli_apps import CliAppsTool
from nanobot.agent.tools.filesystem import ReadFileTool 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.message import MessageTool
from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import SpawnTool 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 ( from nanobot.security.workspace_access import (
WORKSPACE_SCOPE_METADATA_KEY, WORKSPACE_SCOPE_METADATA_KEY,
WorkspaceScopeError, WorkspaceScopeError,
@ -20,8 +26,6 @@ from nanobot.security.workspace_access import (
validate_workspace_scope_payload, validate_workspace_scope_payload,
workspace_scope_from_metadata, workspace_scope_from_metadata,
) )
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig
from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig
PNG_BYTES = ( PNG_BYTES = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" 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" 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(): def test_builtin_tool_configs_do_not_depend_on_config_schema_base():
repo = Path(__file__).resolve().parents[2] repo = Path(__file__).resolve().parents[2]
tool_paths = sorted((repo / "nanobot/agent/tools").glob("*.py")) tool_paths = sorted((repo / "nanobot/agent/tools").glob("*.py"))

View File

@ -6,9 +6,9 @@ from typing import Any
import pytest 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.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 from nanobot.providers.image_generation import GeneratedImageResponse
PNG_BYTES = ( PNG_BYTES = (