Refine tool config ownership

This commit is contained in:
chengyongru 2026-06-15 14:43:04 +08:00
parent 15964fb466
commit 8a51059bdf
8 changed files with 219 additions and 146 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'<script[\s\S]*?</script>', '', text, flags=re.I)

View File

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

View File

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