mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
Decouple tool configs from root schema
This commit is contained in:
parent
8a51059bdf
commit
e015f469ad
@ -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")
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
88
nanobot/agent/tools/config.py
Normal file
88
nanobot/agent/tools/config.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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 = (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user