OpenSpace/openspace/config/grounding.py
2026-07-17 11:43:42 +08:00

429 lines
16 KiB
Python

from typing import Dict, Optional, Any, List, Literal
try:
from pydantic import BaseModel, Field, field_validator
PYDANTIC_V2 = True
except ImportError:
from pydantic import BaseModel, Field, validator as field_validator
PYDANTIC_V2 = False
from openspace.grounding.core.types import (
SessionConfig,
SecurityPolicy,
BackendType
)
from .constants import LOG_LEVELS
class ConfigMixin:
"""Mixin to add utility methods for config access"""
def get_value(self, key: str, default=None):
"""
Safely get config value, works with both dict and Pydantic models.
Args:
key: Configuration key
default: Default value if key not found
"""
if isinstance(self, dict):
return self.get(key, default)
else:
return getattr(self, key, default)
class BackendConfig(BaseModel, ConfigMixin):
"""Base backend configuration"""
enabled: bool = Field(True, description="Whether the backend is enabled")
timeout: int = Field(30, ge=1, le=300, description="Timeout in seconds")
max_retries: int = Field(3, ge=0, le=10, description="Maximum retry attempts")
class ShellConfig(BackendConfig):
"""
Shell backend configuration
Attributes:
enabled: Whether shell backend is enabled
mode: Execution mode. Only "local" is supported for shell execution,
because the local connector owns task lifecycle and sandbox state.
timeout: Default timeout for shell operations (seconds)
max_retries: Maximum number of retry attempts for failed operations
retry_interval: Wait time between retries (seconds)
default_shell: Path to default shell executable
working_dir: Default working directory for bash scripts
env: Default environment variables for shell operations
conda_env: Conda environment name to activate before execution (optional)
"""
mode: Literal["local"] = Field("local", description="Execution mode: 'local' (in-process subprocess)")
retry_interval: float = Field(3.0, ge=0.1, le=60.0, description="Wait time between retries in seconds")
default_shell: str = Field("/bin/bash", description="Default shell path")
working_dir: Optional[str] = Field(None, description="Default working directory for bash scripts")
env: Dict[str, str] = Field(default_factory=dict, description="Default environment variables")
conda_env: Optional[str] = Field(None, description="Conda environment name to activate (e.g., 'myenv')")
use_clawwork_productivity: bool = Field(
False,
description="If True and livebench is installed, add ClawWork productivity tools (web_search, web_fetch, create_file, read, execute_code_sandbox, create_video) for fair comparison with ClawWork."
)
productivity_date: str = Field(
"default",
description="Date segment for productivity sandbox paths (e.g. 'default' or 'YYYY-MM-DD'). Used when use_clawwork_productivity is True."
)
@field_validator('default_shell')
@classmethod
def validate_shell(cls, v):
if not v or not isinstance(v, str):
raise ValueError("Shell path must be a non-empty string")
return v
@field_validator('working_dir')
@classmethod
def validate_working_dir(cls, v):
if v is not None and not isinstance(v, str):
raise ValueError("Working directory must be a string")
return v
class WebSearchConfig(BaseModel, ConfigMixin):
"""Configuration for the OpenSpace ``web_search`` tool."""
search_model: str = Field(
"claude-sonnet-4-20250514",
description="Anthropic-compatible model used for server-side web search",
)
search_api_key: Optional[str] = Field(
None,
description="API key for Anthropic-compatible server-side web search. Supports ${ENV_VAR}.",
)
search_base_url: Optional[str] = Field(
"https://api.anthropic.com/v1",
description="API base URL for Anthropic-compatible server-side web search",
)
max_searches_per_call: int = Field(
8,
ge=1,
le=20,
description="Maximum server-side searches per WebSearch call; OpenSpace default is 8",
)
allowed_domains: List[str] = Field(
default_factory=list,
description="Default domains to include when a WebSearch input omits allowed_domains",
)
blocked_domains: List[str] = Field(
default_factory=list,
description="Default domains to exclude when a WebSearch input omits blocked_domains",
)
fallback_search_provider: str = Field(
"duckduckgo",
description=(
"Comma-separated fallback providers after Anthropic server-side search. "
"Supported: tavily, brave, serpapi, duckduckgo."
),
)
provider_order: List[str] = Field(
default_factory=list,
description="Optional explicit provider order. Overrides automatic Anthropic + fallback ordering.",
)
class WebFetchConfig(BaseModel, ConfigMixin):
"""Configuration for the OpenSpace ``web_fetch`` tool."""
summarize_model: Optional[str] = Field(
None,
description="Model override for applying prompts to fetched markdown; empty uses agent/default model",
)
max_content_length: int = Field(
100_000,
ge=1_000,
le=500_000,
description="Maximum markdown characters sent to the secondary model; OpenSpace default is 100000",
)
request_timeout: int = Field(
60,
ge=1,
le=300,
description="HTTP request timeout in seconds; OpenSpace default is 60",
)
user_agent: str = Field(
"OpenSpace WebFetch",
description="User-Agent header used by WebFetch HTTP requests",
)
preapproved_domains: List[str] = Field(
default_factory=list,
description="Additional preapproved hosts or host/path prefixes that bypass WebFetch permission ask",
)
class WebConfig(BackendConfig):
"""
Web backend configuration.
The backend now mirrors OpenSpace's flat web tools: ``web_search`` and
``web_fetch``. The old Perplexity ``deep_research_agent`` is not
registered here; complex research is handled by the dedicated
deep-researcher subagent using these two primitive tools.
"""
search: WebSearchConfig = Field(default_factory=WebSearchConfig)
fetch: WebFetchConfig = Field(default_factory=WebFetchConfig)
class MCPConfig(BackendConfig):
"""MCP backend configuration"""
sandbox: bool = Field(False, description="Whether to enable sandbox")
auto_initialize: bool = Field(True, description="Whether to auto initialize")
eager_sessions: bool = Field(False, description="Whether to eagerly create sessions for all servers on initialization")
retry_interval: float = Field(2.0, ge=0.1, le=60.0, description="Wait time between retries in seconds")
servers: Dict[str, Dict[str, Any]] = Field(default_factory=dict, description="MCP servers configuration, loaded from config_mcp.json")
sse_read_timeout: float = Field(300.0, ge=1.0, le=3600.0, description="SSE read timeout in seconds for HTTP/Sandbox connectors")
class GUIConfig(BackendConfig):
"""
GUI backend configuration
Attributes:
mode: Execution mode - "local" runs GUI operations in-process,
"server" connects to a running local_server via HTTP
"""
mode: Literal["local", "server"] = Field("local", description="Execution mode: 'local' (in-process) or 'server' (HTTP local_server)")
retry_interval: float = Field(5.0, ge=0.1, le=60.0, description="Wait time between retries in seconds")
driver_type: str = Field("pyautogui", description="GUI driver type")
failsafe: bool = Field(False, description="Whether to enable pyautogui failsafe mode")
screenshot_on_error: bool = Field(True, description="Whether to capture screenshot on error")
enable_visual_analysis: bool = Field(
True,
description="Legacy switch for GUI visual analysis fallback hook",
)
visual_analysis_mode: Literal["fallback", "always", "off"] = Field(
"fallback",
description=(
"GUI visual analysis policy: fallback runs only when the main model "
"cannot consume image inputs, always forces text extraction, off disables it"
),
)
visual_analysis_timeout: float = Field(
30.0,
ge=1.0,
le=300.0,
description="Timeout for GUI visual analysis LLM calls in seconds",
)
visual_analysis_model: Optional[str] = Field(
None,
description="Dedicated model for GUI visual analysis; defaults to the agent model",
)
pkgs_prefix: str = Field(
"import pyautogui; import time; pyautogui.FAILSAFE = {failsafe}; {command}",
description="Python command prefix for pyautogui setup"
)
class ToolSearchConfig(BaseModel):
"""Tool search and ranking configuration"""
embedding_model: str = Field(
"BAAI/bge-small-en-v1.5",
description="Embedding model name for semantic search"
)
max_tools: int = Field(
20,
ge=1,
le=1000,
description="Maximum number of tools to return from search"
)
search_mode: str = Field(
"keyword",
description="Default search mode: keyword, semantic, or hybrid"
)
enable_llm_filter: bool = Field(
True,
description="Whether to use LLM for backend/server filtering"
)
llm_filter_threshold: int = Field(
50,
ge=1,
le=1000,
description="Only apply LLM filter when tool count exceeds this threshold"
)
enable_cache_persistence: bool = Field(
False,
description="Whether to persist embeddings to disk"
)
cache_dir: Optional[str] = Field(
None,
description="Directory for embedding cache. None means use default <project_root>/.openspace/embedding_cache"
)
@field_validator('search_mode')
@classmethod
def validate_search_mode(cls, v):
valid_modes = ['semantic', 'keyword', 'hybrid']
if v.lower() not in valid_modes:
raise ValueError(f"Search mode must be one of {valid_modes}, got: {v}")
return v.lower()
class ToolQualityConfig(BaseModel):
"""Tool quality tracking configuration"""
enabled: bool = Field(
True,
description="Whether to enable tool quality tracking"
)
enable_persistence: bool = Field(
True,
description="Whether to persist quality data to disk"
)
db_path: Optional[str] = Field(
None,
description="SQLite DB path for persisted tool quality data"
)
enable_quality_ranking: bool = Field(
True,
description="Whether to incorporate quality scores in tool ranking"
)
class SkillConfig(BaseModel):
"""Skill engine configuration
Controls how skills are discovered and exposed to the model.
Built-in skills (``openspace/skills/``) are always auto-discovered.
"""
enabled: bool = Field(True, description="Enable skill matching and injection")
skill_dirs: List[str] = Field(
default_factory=list,
description="Extra skill directories. Built-in openspace/skills/ is always included."
)
listing_enabled: bool = Field(
True,
description="Emit OpenSpace lightweight skill_listing attachments when Skill is available.",
)
listing_budget_context_percent: float = Field(
0.01,
ge=0.0,
le=1.0,
description="Listing budget as a fraction of context window.",
)
listing_max_description_chars: int = Field(
250,
ge=20,
le=2000,
description="Per-skill description cap in skill_listing attachments.",
)
discovery_enabled: bool = Field(
True,
description="Expose DiscoverSkills and use OpenSpace search as the discovery backend.",
)
discovery_max_results: int = Field(
5,
ge=1,
le=20,
description="Default maximum DiscoverSkills candidates.",
)
post_tool_query_builder_enabled: bool = Field(
False,
description="Use an LLM to condense recent tool output into a structured skill discovery query.",
)
post_tool_query_builder_model: Optional[str] = Field(
None,
description="Optional model override for post-tool skill discovery query building.",
)
post_tool_query_builder_max_chars: int = Field(
4000,
ge=500,
le=20000,
description="Maximum characters of recent task/tool evidence sent to the post-tool query builder.",
)
quality_enabled: bool = Field(
True,
description="Use skill quality store for discovery ranking and invocation accounting.",
)
class GroundingConfig(BaseModel):
"""
Main configuration for Grounding module.
Contains configuration for all grounding backends and grounding-level settings.
Note: Local server connection uses defaults or environment variables (LOCAL_SERVER_URL).
"""
# Backend configurations
shell: ShellConfig = Field(default_factory=ShellConfig)
web: WebConfig = Field(default_factory=WebConfig)
mcp: MCPConfig = Field(default_factory=MCPConfig)
gui: GUIConfig = Field(default_factory=GUIConfig)
meta: BackendConfig = Field(default_factory=BackendConfig)
# Grounding-level settings
tool_search: ToolSearchConfig = Field(default_factory=ToolSearchConfig)
tool_quality: ToolQualityConfig = Field(default_factory=ToolQualityConfig)
skills: SkillConfig = Field(default_factory=SkillConfig)
enabled_backends: List[Dict[str, str]] = Field(
default_factory=list,
description="List of enabled backends, each item: {'name': str, 'provider_cls': str}"
)
session_defaults: SessionConfig = Field(
default_factory=lambda: SessionConfig(
session_name="",
backend_type=BackendType.SHELL,
timeout=30,
auto_reconnect=True,
health_check_interval=30
)
)
tool_cache_ttl: int = Field(
300,
ge=1,
le=3600,
description="Tool cache time-to-live in seconds"
)
tool_cache_maxsize: int = Field(
300,
ge=1,
le=10000,
description="Maximum number of tool cache entries"
)
debug: bool = Field(False, description="Debug mode")
log_level: str = Field("INFO", description="Log level")
security_policies: Dict[str, Any] = Field(default_factory=dict)
@field_validator('log_level')
@classmethod
def validate_log_level(cls, v):
if v.upper() not in LOG_LEVELS:
raise ValueError(f"Log level must be one of {LOG_LEVELS}, got: {v}")
return v.upper()
def get_backend_config(self, backend_type: str) -> BackendConfig:
"""Get configuration for specified backend"""
name = backend_type.lower()
if not hasattr(self, name):
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
logger.warning(f"Unknown backend type: {backend_type}")
return BackendConfig()
return getattr(self, name)
def get_security_policy(self, backend_type: str) -> SecurityPolicy:
global_policy = self.security_policies.get("global", {})
backend_policy = self.security_policies.get("backend", {}).get(backend_type.lower(), {})
merged_policy = {**global_policy, **backend_policy}
return SecurityPolicy.from_dict(merged_policy)
__all__ = [
"BackendConfig",
"ShellConfig",
"WebSearchConfig",
"WebFetchConfig",
"WebConfig",
"MCPConfig",
"GUIConfig",
"ToolSearchConfig",
"ToolQualityConfig",
"SkillConfig",
"GroundingConfig",
]