mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
Refine tool config ownership
This commit is contained in:
parent
15964fb466
commit
8a51059bdf
@ -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(
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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)
|
||||
Loading…
x
Reference in New Issue
Block a user