Yusuf Gürdoğan 23bd76046a
feat(community): add Sofya web search provider (#5239)
* feat(community): add Sofya web search provider

Add a community provider backed by Sofya (https://sofya.co). Its search
endpoint returns the content of the result pages, not only their snippets,
and its fetch endpoint returns a page as markdown. Both are plain JSON over
HTTP, so this needs no extra Python package (uses httpx, already a
dependency).

Changes:
- backend/packages/harness/deerflow/community/sofya/__init__.py
- backend/packages/harness/deerflow/community/sofya/tools.py
  Implements web_search_tool and web_fetch_tool using httpx.
  API key is read from the config.yaml `api_key` field or the SOFYA_API_KEY
  env var. Follows the same interface and output shape as the existing
  ddg_search and serper providers, including the max_results parameter with
  config override and the structured "No results found" error.
- backend/tests/test_sofya_tools.py
  Unit tests covering API key resolution, config overrides, result mapping,
  time range, HTTP errors, empty results, and fetch failures.
- config.example.yaml: add commented-out Sofya web_search and web_fetch
  examples alongside the other providers
- .env.example: add SOFYA_API_KEY placeholder
- backend/docs/CONFIGURATION.md: list Sofya under web_search, web_fetch and
  the environment variables

* fix(sofya): honor caller max_results, validate search_depth, join time_range contract test

- Caller-supplied max_results now wins; config is used only when the
  argument is omitted, matching GroundRoute.
- search_depth is clamped to basic/snippets; an unsupported value logs a
  warning and falls back to basic.
- Sofya added to the shared time_range schema contract test.

* fix(sofya): cap per-result content so a search stays inline

An unbounded search payload (up to 20 read pages) crossed the tool output
budget middleware's externalize_min_chars threshold, which replaces the
result list with a file reference. Cap each result's content at
contents_max_characters (default 2000, 0 disables), matching Exa's config
key. Five capped results stay under the 12000 char threshold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TZhyPNCX2GYyBPkvTgJV5

* fix(sofya): list Sofya in the recency contract, coerce non-string content

_clip subscripted its input, so a non-string content or description from
the API raised TypeError instead of degrading. Coerce to text first, the
way _sofya_post and _response_results guard the shapes around it. Also add
Sofya to the Web Search Recency section in backend/AGENTS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TZhyPNCX2GYyBPkvTgJV5

* fix(sofya): coerce web_fetch content, list sofya in the tools guide, add changelog

web_fetch sliced its content the same way web_search did before the last
push: a truthy non-string from the API passed the falsiness guard and then
raised TypeError. Reuse _clip, keeping the `or ""` so empty content still
reports "No content found".

Also add sofya to the community provider inventory in
packages/harness/deerflow/tools/AGENTS.md and an [Unreleased] changelog entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TZhyPNCX2GYyBPkvTgJV5

* docs(zh): add the missing InfoQuest and Firecrawl web_fetch tabs

The ZH web_fetch tab list named five providers where EN names seven. Both
tabs mirror their EN counterparts, so the two locales list the same
web_fetch providers again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TZhyPNCX2GYyBPkvTgJV5

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-08 10:50:03 +08:00

224 lines
8.7 KiB
Python

"""
Web search and fetch tools powered by Sofya.
Sofya is a hosted web API for agents. Search returns the content of the result
pages, not only their snippets, and fetch returns a single page as clean
markdown. An API key is required. Sign up at https://sofya.co to get one.
"""
import json
import logging
import os
import httpx
from langchain.tools import tool
from deerflow.community.search_time_range import SearchTimeRange
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
_SOFYA_BASE_URL = "https://sofya.co/v1"
_SOFYA_SEARCH_PATH = "/search"
_SOFYA_FETCH_PATH = "/fetch"
_SOFYA_MAX_RESULTS = 20
_SOFYA_TIMEOUT = 60
_SOFYA_FETCH_MAX_CHARS = 4096
_DEFAULT_SEARCH_DEPTH = "basic"
_DEFAULT_CONTENTS_MAX_CHARACTERS = 2000
_SEARCH_DEPTHS = ("basic", "snippets")
_api_key_warned: set[str] = set()
def _get_api_key(tool_name: str) -> str | None:
config = get_app_config().get_tool_config(tool_name)
if config is not None:
api_key = config.model_extra.get("api_key")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
env_key = os.getenv("SOFYA_API_KEY")
if isinstance(env_key, str) and env_key.strip():
return env_key.strip()
return None
def _coerce_max_results(value: object, default: int = 5, max_allowed: int = _SOFYA_MAX_RESULTS) -> int:
"""Coerce config/parameter input into a bounded positive result count."""
try:
count = int(value) # type: ignore[call-overload]
except (TypeError, ValueError):
return default
if count <= 0:
return default
return min(count, max_allowed)
def _coerce_content_limit(value: object, default: int = _DEFAULT_CONTENTS_MAX_CHARACTERS) -> int:
"""Coerce the per-result content limit. 0 means no limit; anything invalid falls back to the default."""
try:
limit = int(value) # type: ignore[call-overload]
except (TypeError, ValueError):
return default
return limit if limit >= 0 else default
def _resolve_search_depth(value: object) -> str:
"""Return a supported search depth, falling back to the default with a warning."""
if value is None:
return _DEFAULT_SEARCH_DEPTH
depth = str(value).strip().lower()
if depth in _SEARCH_DEPTHS:
return depth
logger.warning("Ignoring unsupported Sofya search_depth %r; using %r (supported: %s)", value, _DEFAULT_SEARCH_DEPTH, ", ".join(_SEARCH_DEPTHS))
return _DEFAULT_SEARCH_DEPTH
def _missing_key_message(tool_name: str) -> str:
if tool_name not in _api_key_warned:
_api_key_warned.add(tool_name)
logger.warning("Sofya API key is not set for '%s'. Set SOFYA_API_KEY in your environment or provide api_key in config.yaml. Sign up at https://sofya.co", tool_name)
return "SOFYA_API_KEY is not configured"
def _sofya_post(path: str, api_key: str, payload: dict) -> tuple[dict | None, str | None]:
"""Send a POST request to a Sofya endpoint.
Returns a ``(data, error)`` tuple: on success ``data`` is the parsed JSON
object and ``error`` is ``None``; on failure ``data`` is ``None`` and
``error`` is a message the caller can hand back to the model.
"""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
try:
with httpx.Client(timeout=_SOFYA_TIMEOUT) as client:
response = client.post(f"{_SOFYA_BASE_URL}{path}", headers=headers, json=payload)
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as e:
logger.error("Sofya API returned HTTP %s: %s", e.response.status_code, (e.response.text or "")[:500])
return None, f"Sofya API error: HTTP {e.response.status_code}"
except Exception as e:
logger.error("Sofya request failed: %s: %s", type(e).__name__, str(e)[:500])
return None, str(e)[:500]
if not isinstance(data, dict):
logger.error("Sofya returned an unexpected payload type: %s", type(data).__name__)
return None, "Sofya returned an unexpected response format"
return data, None
def _clip(value: object, limit: int) -> str:
"""Coerce a result field to text and truncate it. A limit of 0 means no truncation."""
text = value if isinstance(value, str) else str(value)
return text if limit <= 0 else text[:limit]
def _response_results(data: dict) -> list[dict] | None:
"""Return the result dicts of a Sofya response, or None if malformed."""
results = data.get("results")
if results is None:
return []
if not isinstance(results, list):
logger.error("Sofya returned an unexpected 'results' payload type: %s", type(results).__name__)
return None
return [item for item in results if isinstance(item, dict)]
@tool("web_search", parse_docstring=True)
def web_search_tool(query: str, max_results: int | None = None, time_range: SearchTimeRange | None = None) -> str:
"""Search the web for information. Use this tool to find current information, news, articles, and facts from the internet.
Args:
query: Search keywords describing what you want to find. Be specific for better results.
max_results: Maximum number of results to return. If omitted, uses the configured value (default 5). Capped at 20.
time_range: Optional relative publication/update window. Use only when the request requires recent results.
"""
config = get_app_config().get_tool_config("web_search")
config_extra = (config.model_extra or {}) if config is not None else {}
# Honor the caller-supplied max_results; fall back to config only when omitted.
if max_results is None:
max_results = config_extra.get("max_results")
max_results = _coerce_max_results(max_results)
search_depth = _resolve_search_depth(config_extra.get("search_depth"))
content_limit = _coerce_content_limit(config_extra.get("contents_max_characters"))
api_key = _get_api_key("web_search")
if not api_key:
return json.dumps({"error": _missing_key_message("web_search"), "query": query}, ensure_ascii=False)
payload: dict[str, object] = {
"query": query,
"max_results": max_results,
"search_depth": search_depth,
}
if time_range is not None:
payload["freshness"] = time_range
data, error = _sofya_post(_SOFYA_SEARCH_PATH, api_key, payload)
if error is not None:
return json.dumps({"error": error, "query": query}, ensure_ascii=False)
results = _response_results(data)
if results is None:
return json.dumps({"error": "Sofya returned an unexpected response format", "query": query}, ensure_ascii=False)
if not results:
return json.dumps({"error": "No results found", "query": query}, ensure_ascii=False)
normalized_results = [
{
"title": r.get("title", ""),
"url": r.get("url", ""),
# Page content when the result was read, the search snippet otherwise.
# Capped so a normal search stays inline rather than being written to disk.
"content": _clip(r.get("content") or r.get("description") or "", content_limit),
}
for r in results[:max_results]
]
output = {
"query": query,
"total_results": len(normalized_results),
"results": normalized_results,
}
return json.dumps(output, indent=2, ensure_ascii=False)
@tool("web_fetch", parse_docstring=True)
def web_fetch_tool(url: str) -> str:
"""Fetch the contents of a web page at a given URL.
Only fetch EXACT URLs that have been provided directly by the user or have been returned in results from the web_search and web_fetch tools.
This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls.
Do NOT add www. to URLs that do NOT have them.
URLs must include the schema: https://example.com is a valid URL while example.com is an invalid URL.
Args:
url: The URL to fetch the contents of.
"""
api_key = _get_api_key("web_fetch")
if not api_key:
return f"Error: {_missing_key_message('web_fetch')}"
data, error = _sofya_post(_SOFYA_FETCH_PATH, api_key, {"urls": [url]})
if error is not None:
return f"Error: {error}"
results = _response_results(data)
if results is None:
return "Error: Sofya returned an unexpected response format"
if not results:
return "Error: No results found"
result = results[0]
if not result.get("success", True):
return f"Error: {result.get('error') or 'Failed to fetch the URL'}"
content = _clip(result.get("content") or "", _SOFYA_FETCH_MAX_CHARS)
if not content:
return "Error: No content found"
title = result.get("title") or "Untitled"
return f"# {title}\n\n{content}"