mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
272 lines
9 KiB
Python
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",
|
|
]
|