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

272 lines
9 KiB
Python

"""Web backend session for OpenSpace WebSearch and WebFetch tools.
OpenSpace has no backend/session abstraction for web tools; ``WebSearch`` and
``WebFetch`` are flat built-in tools. OpenSpace keeps its BackendType.WEB
provider/session boundary, but the session now only registers those two
primitive tools by default. The old Perplexity ``deep_research_agent`` path
is intentionally not part of this backend; deep research is handled by the
``deep-researcher`` subagent that composes ``web_search`` and ``web_fetch``.
"""
from __future__ import annotations
import os
from typing import Any, Iterable
from openspace.config import get_config
from openspace.config.grounding import WebConfig, WebSearchConfig
from openspace.grounding.core.session import BaseSession
from openspace.grounding.core.transport.connectors import BaseConnector
from openspace.grounding.core.types import BackendType, SessionConfig
from openspace.host_detection import load_runtime_env
from openspace.tools.web_fetch_tool import (
FETCH_TIMEOUT_SECONDS,
MAX_MARKDOWN_LENGTH,
WebFetchTool,
)
from openspace.tools.web_search_tool import (
ANTHROPIC_WEB_SEARCH_TOOL_TYPE,
AnthropicServerWebSearchProvider,
BraveSearchProvider,
DuckDuckGoHTMLSearchProvider,
MAX_SEARCH_USES,
SerpAPISearchProvider,
TavilySearchProvider,
WebSearchProvider,
WebSearchTool,
)
from openspace.utils.logging import Logger
load_runtime_env()
logger = Logger.get_logger(__name__)
class WebConnector(BaseConnector[Any]):
"""No-op lifecycle connector for local web tools.
WebSearch/WebFetch own their HTTP/API clients per call. A session-level
connector is still supplied because BaseSession expects one, but listing
tools must not require OpenRouter, Anthropic, or any other network setup.
"""
def __init__(self) -> None:
self._connected = False
async def connect(self) -> None:
self._connected = True
async def disconnect(self) -> None:
self._connected = False
@property
def is_connected(self) -> bool:
return self._connected
async def invoke(self, name: str, params: dict[str, Any]) -> Any:
raise NotImplementedError(f"WebConnector has no session RPC method: {name}")
async def request(self, *args: Any, **kwargs: Any) -> Any:
raise NotImplementedError("Web backend tools perform their own HTTP requests")
class WebSession(BaseSession):
backend_type = BackendType.WEB
def __init__(
self,
*,
session_id: str,
config: SessionConfig,
web_config: WebConfig | dict[str, Any] | None = None,
auto_connect: bool = True,
auto_initialize: bool = True,
) -> None:
super().__init__(
connector=WebConnector(),
session_id=session_id,
backend_type=BackendType.WEB,
auto_connect=auto_connect,
auto_initialize=auto_initialize,
)
self.config = config
self.web_config = _coerce_web_config(web_config)
@property
def web_connector(self) -> WebConnector:
return self.connector
async def initialize(self) -> dict[str, Any]:
"""Register OpenSpace web tools without opening a network connection."""
if not self.is_connected:
await self.connect()
if self.tools:
logger.debug("Web session %s already initialized, skipping", self.session_id)
return self._session_info()
search_cfg = self.web_config.search
fetch_cfg = self.web_config.fetch
self.tools = [
WebSearchTool(
providers=_build_search_providers(search_cfg),
allowed_domains=list(search_cfg.allowed_domains),
blocked_domains=list(search_cfg.blocked_domains),
max_searches_per_call=_positive_int(
search_cfg.max_searches_per_call,
MAX_SEARCH_USES,
),
),
WebFetchTool(
summarize_model=_resolve_env_placeholder(fetch_cfg.summarize_model),
max_content_length=_positive_int(
fetch_cfg.max_content_length,
MAX_MARKDOWN_LENGTH,
),
request_timeout=_positive_int(
fetch_cfg.request_timeout,
FETCH_TIMEOUT_SECONDS,
),
user_agent=fetch_cfg.user_agent or "OpenSpace WebFetch",
preapproved_domains=list(fetch_cfg.preapproved_domains),
),
]
logger.info(
"Initialized Web session %s with tools: %s",
self.session_id,
[tool.name for tool in self.tools],
)
return self._session_info()
def _session_info(self) -> dict[str, Any]:
search_cfg = self.web_config.search
return {
"tools": [tool.name for tool in self.tools],
"backend": BackendType.WEB.value,
"web_search": {
"server_tool_type": ANTHROPIC_WEB_SEARCH_TOOL_TYPE,
"max_searches_per_call": search_cfg.max_searches_per_call,
"providers": [
getattr(provider, "name", provider.__class__.__name__)
for provider in _build_search_providers(search_cfg)
],
},
}
def _coerce_web_config(config: WebConfig | dict[str, Any] | None) -> WebConfig:
if isinstance(config, WebConfig):
return config
if isinstance(config, dict):
return WebConfig.model_validate(config)
loaded = get_config().get_backend_config(BackendType.WEB.value)
if isinstance(loaded, WebConfig):
return loaded
if isinstance(loaded, dict):
return WebConfig.model_validate(loaded)
return WebConfig()
def _resolve_env_placeholder(value: str | None) -> str | None:
if value is None:
return None
value = str(value).strip()
if not value:
return None
if value.startswith("${") and value.endswith("}") and len(value) > 3:
return os.getenv(value[2:-1]) or None
return os.path.expandvars(value)
def _positive_int(value: Any, default: int) -> int:
try:
parsed = int(value)
return parsed if parsed > 0 else default
except Exception:
return default
def _provider_names(search_cfg: WebSearchConfig) -> list[str]:
explicit = [name.strip() for name in search_cfg.provider_order if name.strip()]
if explicit:
return _dedupe(explicit)
names: list[str] = []
api_key = _resolve_env_placeholder(search_cfg.search_api_key) or os.getenv("ANTHROPIC_API_KEY")
if api_key:
names.append("anthropic")
fallback = search_cfg.fallback_search_provider or ""
names.extend(_split_csv(fallback))
if not names:
names.append("duckduckgo")
return _dedupe(names)
def _split_csv(value: str | Iterable[str]) -> list[str]:
if isinstance(value, str):
raw_items = value.split(",")
else:
raw_items = list(value)
return [
str(item).strip()
for item in raw_items
if str(item).strip() and str(item).strip().lower() not in {"none", "disabled", "false"}
]
def _dedupe(names: Iterable[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for name in names:
normalized = name.strip().lower()
if not normalized or normalized in seen:
continue
seen.add(normalized)
result.append(normalized)
return result
def _build_search_providers(search_cfg: WebSearchConfig) -> tuple[WebSearchProvider, ...]:
max_results = _positive_int(search_cfg.max_searches_per_call, MAX_SEARCH_USES)
providers: list[WebSearchProvider] = []
for name in _provider_names(search_cfg):
try:
provider = _build_search_provider(name, search_cfg, max_results=max_results)
except ValueError as exc:
logger.warning("%s", exc)
continue
providers.append(provider)
return tuple(providers)
def _build_search_provider(
name: str,
search_cfg: WebSearchConfig,
*,
max_results: int,
) -> WebSearchProvider:
normalized = name.strip().lower()
if normalized in {"anthropic", "claude", "server"}:
return AnthropicServerWebSearchProvider(
api_key=_resolve_env_placeholder(search_cfg.search_api_key),
model=search_cfg.search_model,
base_url=_resolve_env_placeholder(search_cfg.search_base_url),
max_uses=max_results,
)
if normalized == "tavily":
return TavilySearchProvider(max_results=max_results)
if normalized == "brave":
return BraveSearchProvider(max_results=max_results)
if normalized in {"serpapi", "serp"}:
return SerpAPISearchProvider(max_results=max_results)
if normalized in {"duckduckgo", "ddg"}:
return DuckDuckGoHTMLSearchProvider(max_results=max_results)
raise ValueError(f"Unknown web search provider configured for WebSession: {name}")
__all__ = [
"WebConnector",
"WebSession",
]