From 8a51059bdf7d19a59a21572582149c2a358bcfc6 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 15 Jun 2026 14:43:04 +0800 Subject: [PATCH] Refine tool config ownership --- nanobot/agent/tools/cli_apps.py | 23 ++-- nanobot/agent/tools/filesystem.py | 8 +- nanobot/agent/tools/image_generation.py | 17 ++- nanobot/agent/tools/self.py | 8 +- nanobot/agent/tools/shell.py | 15 ++- nanobot/agent/tools/web.py | 26 ++++- nanobot/config/schema.py | 149 ++++++++++++++++++++++-- nanobot/config/tool_configs.py | 119 ------------------- 8 files changed, 219 insertions(+), 146 deletions(-) delete mode 100644 nanobot/config/tool_configs.py diff --git a/nanobot/agent/tools/cli_apps.py b/nanobot/agent/tools/cli_apps.py index 0d6376fea..e794c09d6 100644 --- a/nanobot/agent/tools/cli_apps.py +++ b/nanobot/agent/tools/cli_apps.py @@ -5,17 +5,22 @@ from __future__ import annotations from pathlib import Path from typing import Any +from pydantic import Field + from nanobot.agent.tools.base import Tool, tool_parameters -from nanobot.agent.tools.schema import ( - ArraySchema, - BooleanSchema, - IntegerSchema, - StringSchema, - tool_parameters_schema, -) -from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig -from nanobot.config.tool_configs import CliAppsToolConfig +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_base import Base + + +class CliAppsToolConfig(Base): + """CLI Apps tool configuration.""" + + enable: bool = True + install_timeout: int = Field(default=300, ge=1, le=3600) + run_timeout: int = Field(default=60, ge=1, le=600) + catalog_ttl_seconds: int = Field(default=3600, ge=60, le=86_400) @tool_parameters( diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 26e04698b..9c1854217 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -16,11 +16,17 @@ from nanobot.agent.tools.schema import ( StringSchema, tool_parameters_schema, ) -from nanobot.config.tool_configs import FileToolsConfig +from nanobot.config_base import Base from nanobot.security.workspace_access import current_tool_workspace from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime +class FileToolsConfig(Base): + """Filesystem tools configuration.""" + + enable: bool = True # built-in file tools on by default + + class _FsTool(Tool): """Shared base for filesystem tools — common init and path resolution.""" diff --git a/nanobot/agent/tools/image_generation.py b/nanobot/agent/tools/image_generation.py index 2cdaf29e3..82a09fb91 100644 --- a/nanobot/agent/tools/image_generation.py +++ b/nanobot/agent/tools/image_generation.py @@ -5,6 +5,8 @@ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING, Any +from pydantic import Field + from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.schema import ( ArraySchema, @@ -12,14 +14,14 @@ from nanobot.agent.tools.schema import ( StringSchema, tool_parameters_schema, ) +from nanobot.security.workspace_access import current_tool_workspace from nanobot.config.paths import get_media_dir -from nanobot.config.tool_configs import ImageGenerationToolConfig +from nanobot.config_base import Base from nanobot.providers.image_generation import ( ImageGenerationError, ImageGenerationProvider, get_image_gen_provider, ) -from nanobot.security.workspace_access import current_tool_workspace from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path from nanobot.utils.artifacts import ( ArtifactError, @@ -32,6 +34,17 @@ if TYPE_CHECKING: from nanobot.config.schema import ProviderConfig +class ImageGenerationToolConfig(Base): + """Image generation tool configuration.""" + enabled: bool = False + provider: str = "openrouter" + model: str = "openai/gpt-5.4-image-2" + default_aspect_ratio: str = "1:1" + default_image_size: str = "1K" + max_images_per_turn: int = Field(default=4, ge=1, le=8) + save_dir: str = "generated" + + @tool_parameters( tool_parameters_schema( prompt=StringSchema( diff --git a/nanobot/agent/tools/self.py b/nanobot/agent/tools/self.py index fa607705d..1e60b3457 100644 --- a/nanobot/agent/tools/self.py +++ b/nanobot/agent/tools/self.py @@ -10,12 +10,18 @@ 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.tool_configs import MyToolConfig +from nanobot.config_base import Base if TYPE_CHECKING: from nanobot.agent.subagent import SubagentStatus +class MyToolConfig(Base): + """Self-inspection tool configuration.""" + enable: bool = True + allow_set: bool = False + + def _has_real_attr(obj: Any, key: str) -> bool: """Check if obj has a real (explicitly set) attribute, not auto-generated by mock.""" if isinstance(obj, dict): diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 62f794ee9..808beaf09 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Any from loguru import logger +from pydantic import Field from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.context import current_request_session_key @@ -33,7 +34,7 @@ from nanobot.agent.tools.schema import ( tool_parameters_schema, ) from nanobot.config.paths import get_media_dir -from nanobot.config.tool_configs import ExecToolConfig +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 @@ -50,6 +51,18 @@ _WORKSPACE_BOUNDARY_NOTE = ( ) +class ExecToolConfig(Base): + """Shell exec tool configuration.""" + enable: bool = True + timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max. + path_prepend: str = "" + path_append: str = "" + sandbox: str = "" + allowed_env_keys: list[str] = Field(default_factory=list) + allow_patterns: list[str] = Field(default_factory=list) + deny_patterns: list[str] = Field(default_factory=list) + + @dataclass(slots=True) class _PreparedCommand: command: str diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index e66f86bc1..0d2302b51 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -12,6 +12,7 @@ from urllib.parse import quote, urljoin, urlparse import httpx from loguru import logger +from pydantic import Field from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.schema import ( @@ -20,7 +21,7 @@ from nanobot.agent.tools.schema import ( StringSchema, tool_parameters_schema, ) -from nanobot.config.tool_configs import WebFetchConfig, WebSearchConfig, WebToolsConfig +from nanobot.config_base import Base from nanobot.utils.helpers import build_image_content_blocks # Shared constants @@ -34,6 +35,29 @@ _VOLCENGINE_TIME_RANGES = {"OneDay", "OneWeek", "OneMonth", "OneYear"} _VOLCENGINE_DATE_RANGE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$") +class WebSearchConfig(Base): + """Web search configuration.""" + provider: str = "duckduckgo" + api_key: str = "" + base_url: str = "" + max_results: int = 5 + timeout: int = 30 + + +class WebFetchConfig(Base): + """Web fetch tool configuration.""" + use_jina_reader: bool = True + + +class WebToolsConfig(Base): + """Web tools configuration.""" + enable: bool = True + proxy: str | None = None + user_agent: str | None = None + search: WebSearchConfig = Field(default_factory=WebSearchConfig) + fetch: WebFetchConfig = Field(default_factory=WebFetchConfig) + + def _strip_tags(text: str) -> str: """Remove HTML tags and decode entities.""" text = re.sub(r'', '', text, flags=re.I) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index c45b4833e..81c3b56c7 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -2,25 +2,33 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal from pydantic import AliasChoices, ConfigDict, Field, model_validator from pydantic_settings import BaseSettings -import nanobot.config.tool_configs as _tool_configs from nanobot.config_base import Base from nanobot.cron.types import CronSchedule -CliAppsToolConfig = _tool_configs.CliAppsToolConfig -ExecToolConfig = _tool_configs.ExecToolConfig -FileToolsConfig = _tool_configs.FileToolsConfig -ImageGenerationToolConfig = _tool_configs.ImageGenerationToolConfig -MCPServerConfig = _tool_configs.MCPServerConfig -MyToolConfig = _tool_configs.MyToolConfig -ToolsConfig = _tool_configs.ToolsConfig -WebFetchConfig = _tool_configs.WebFetchConfig -WebSearchConfig = _tool_configs.WebSearchConfig -WebToolsConfig = _tool_configs.WebToolsConfig +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): @@ -294,6 +302,68 @@ class GatewayConfig(Base): heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig) +class MCPServerConfig(Base): + """MCP server connection configuration (stdio or HTTP).""" + + type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted + command: str = "" # Stdio: command to run (e.g. "npx") + args: list[str] = Field(default_factory=list) # Stdio: command arguments + env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars + cwd: str = "" # Stdio: working directory for MCP server runtime artifacts + url: str = "" # HTTP/SSE: endpoint URL + headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers + tool_timeout: int = 30 # seconds before a tool call is cancelled + 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. + """ + + 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"), + ) + restrict_to_workspace: bool = False # policy intent: keep tool access inside workspace when possible + webui_allow_local_service_access: bool = Field( + default=True, + validation_alias=AliasChoices( + "webuiAllowLocalServiceAccess", + "webui_allow_local_service_access", + "allowLocalPreviewAccess", + "allow_local_preview_access", + ), + ) # allow WebUI Full Access shell checks against localhost services; legacy allowLocalPreviewAccess still reads + 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) + + @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) + + class Config(BaseSettings): """Root configuration for nanobot.""" @@ -309,6 +379,17 @@ 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: @@ -496,3 +577,47 @@ 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/nanobot/config/tool_configs.py b/nanobot/config/tool_configs.py deleted file mode 100644 index ea8b7c5e2..000000000 --- a/nanobot/config/tool_configs.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Tool configuration models with no tool-runtime imports.""" - -from __future__ import annotations - -from typing import Literal - -from pydantic import AliasChoices, Field - -from nanobot.config_base import Base - - -class WebSearchConfig(Base): - """Web search configuration.""" - - provider: str = "duckduckgo" - api_key: str = "" - base_url: str = "" - max_results: int = 5 - timeout: int = 30 - - -class WebFetchConfig(Base): - """Web fetch tool configuration.""" - - use_jina_reader: bool = True - - -class WebToolsConfig(Base): - """Web tools configuration.""" - - enable: bool = True - proxy: str | None = None - user_agent: str | None = None - search: WebSearchConfig = Field(default_factory=WebSearchConfig) - fetch: WebFetchConfig = Field(default_factory=WebFetchConfig) - - -class ExecToolConfig(Base): - """Shell exec tool configuration.""" - - enable: bool = True - timeout: int = Field(default=60, ge=0) - path_prepend: str = "" - path_append: str = "" - sandbox: str = "" - allowed_env_keys: list[str] = Field(default_factory=list) - allow_patterns: list[str] = Field(default_factory=list) - deny_patterns: list[str] = Field(default_factory=list) - - -class FileToolsConfig(Base): - """Filesystem tools configuration.""" - - enable: bool = True - - -class CliAppsToolConfig(Base): - """CLI Apps tool configuration.""" - - enable: bool = True - install_timeout: int = Field(default=300, ge=1, le=3600) - run_timeout: int = Field(default=60, ge=1, le=600) - catalog_ttl_seconds: int = Field(default=3600, ge=60, le=86_400) - - -class MyToolConfig(Base): - """Self-inspection tool configuration.""" - - enable: bool = True - allow_set: bool = False - - -class ImageGenerationToolConfig(Base): - """Image generation tool configuration.""" - - enabled: bool = False - provider: str = "openrouter" - model: str = "openai/gpt-5.4-image-2" - default_aspect_ratio: str = "1:1" - default_image_size: str = "1K" - max_images_per_turn: int = Field(default=4, ge=1, le=8) - save_dir: str = "generated" - - -class MCPServerConfig(Base): - """MCP server connection configuration (stdio or HTTP).""" - - type: Literal["stdio", "sse", "streamableHttp"] | None = None - command: str = "" - args: list[str] = Field(default_factory=list) - env: dict[str, str] = Field(default_factory=dict) - cwd: str = "" - url: str = "" - headers: dict[str, str] = Field(default_factory=dict) - tool_timeout: int = 30 - enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) - - -class ToolsConfig(Base): - """Tools configuration.""" - - web: WebToolsConfig = Field(default_factory=WebToolsConfig) - exec: ExecToolConfig = Field(default_factory=ExecToolConfig) - file: FileToolsConfig = Field(default_factory=FileToolsConfig) - cli_apps: CliAppsToolConfig = Field(default_factory=CliAppsToolConfig) - my: MyToolConfig = Field(default_factory=MyToolConfig) - image_generation: ImageGenerationToolConfig = Field(default_factory=ImageGenerationToolConfig) - restrict_to_workspace: bool = False - webui_allow_local_service_access: bool = Field( - default=True, - validation_alias=AliasChoices( - "webuiAllowLocalServiceAccess", - "webui_allow_local_service_access", - "allowLocalPreviewAccess", - "allow_local_preview_access", - ), - ) - mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) - ssrf_whitelist: list[str] = Field(default_factory=list)