mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
343 lines
17 KiB
Python
343 lines
17 KiB
Python
"""Web search + fetch sub-app.
|
|
|
|
Thin HTTP wrappers around the keyless search rungs and `WebFetchTool` from
|
|
`backend.apps.agents.tools`. Exists so the in-process MCP server
|
|
(`backend.apps.agents.web_mcp_server`) can proxy tool calls to the backend
|
|
instead of re-implementing scraping + trafilatura extraction in the MCP
|
|
process.
|
|
|
|
Mounted at `/api/web`.
|
|
"""
|
|
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any, Dict, List, Optional
|
|
from uuid import uuid4
|
|
|
|
from fastapi import HTTPException
|
|
from pydantic import BaseModel, Field
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.web.cascade import CascadeTier, run_cascade
|
|
from backend.apps.web.grounded import (
|
|
format_grounded_as_fetch,
|
|
format_grounded_as_search_results,
|
|
gemini_grounded_call,
|
|
gemini_grounded_via_9router,
|
|
openai_urlfetch,
|
|
openai_websearch,
|
|
openai_websearch_via_9router,
|
|
refresh_9r_connected,
|
|
resolve_gemini_api_key,
|
|
resolve_openai_api_key,
|
|
)
|
|
from backend.config.Apps import SubApp
|
|
|
|
|
|
@asynccontextmanager
|
|
async def web_lifespan():
|
|
yield
|
|
|
|
|
|
web = SubApp("web", web_lifespan)
|
|
|
|
|
|
# --------------------------------------------------------------------------- Request models ---------------------------------------------------------------------------
|
|
|
|
|
|
class SearchBody(BaseModel):
|
|
query: str = Field(..., description="The search query.")
|
|
num_results: int = Field(5, ge=1, le=10, description="Max results to return.")
|
|
# Hint from the MCP server about which primary provider the session is using. Lets us route to that provider's native search tool (Gemini googleSearch, OpenAI web_search_preview) when available, costs come out of the user's existing primary budget.
|
|
primary: Optional[str] = Field(None, description="Primary provider hint: 'gemini' | 'openai' | 'anthropic' | None")
|
|
# Set by the openswarm-web shim from OPENSWARM_BROWSER_OK; the browser-fallback nudge must never fire in a session without browser-delegation tools.
|
|
browser_ok: bool = Field(False, description="Whether this session has browser-delegation tools available.")
|
|
|
|
|
|
class FetchBody(BaseModel):
|
|
url: str = Field(..., description="The URL to fetch.")
|
|
prompt: Optional[str] = Field(None, description="Optional context hint.")
|
|
primary: Optional[str] = Field(None, description="Primary provider hint.")
|
|
|
|
|
|
# --------------------------------------------------------------------------- Budgets ---------------------------------------------------------------------------
|
|
|
|
# Whole-cascade wall clock. Nothing below can push an endpoint past this, and the MCP shim waits LONGER (see web_mcp_server) so our honest "here is why every backend failed" answer beats a client-side abort.
|
|
SEARCH_BUDGET_SECONDS = 60.0
|
|
FETCH_BUDGET_SECONDS = 60.0
|
|
|
|
KEYLESS_TIER_SECONDS = 8.0 # a search frontend answers in ~1s; >8s is a hang
|
|
BROWSER_TIER_SECONDS = 12.0 # the main-bridge send has its own per-action timeout
|
|
GROUNDED_TIER_SECONDS = 45.0 # grounded native search legitimately takes 30-42s
|
|
LOCAL_FETCH_TIER_SECONDS = 15.0 # normal pages return in <2s
|
|
ARCHIVE_TIER_SECONDS = 10.0 # the Wayback redirect path answered in 0.5-3s
|
|
|
|
|
|
# --------------------------------------------------------------------------- Helpers ---------------------------------------------------------------------------
|
|
|
|
|
|
# Drive the packaged app's offscreen Chromium (main-process hidden window) for a fetch/search. Returns the bridge result dict, or None when no Electron main bridge is connected (dev/headless/backend-only) so the cascade just skips this tier. This is the real "browser reachable" gate, OPENSWARM_BROWSER_OK is effectively always "1" and not trustworthy for this.
|
|
@typechecked
|
|
async def p_browser_bridge(action: str, params: Dict) -> Optional[Dict]:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
if ws_manager.main_connection is None:
|
|
return None
|
|
res = await ws_manager.send_main_command(uuid4().hex, action, params)
|
|
if not res or res.get("error"):
|
|
return None
|
|
return res
|
|
|
|
|
|
# When every search backend fails, point the model at the in-product browser (always-on CreateBrowserAgent tool) instead of telling it to "wait and retry", which it can't do and just relays as a dead end. A real Chromium carries a real browser fingerprint, which is what the challenge is actually keyed on.
|
|
@typechecked
|
|
def p_browser_fallback_nudge(query: str) -> str:
|
|
return (
|
|
"Don't stop here: fall back to the in-product browser, which renders real pages and "
|
|
"isn't subject to this block. Call CreateBrowserAgent with a task like: "
|
|
f'"Search the web for: {query}. Report the top results with their titles and URLs, '
|
|
'plus a direct answer if you find one."'
|
|
)
|
|
|
|
|
|
@typechecked
|
|
def p_grounded_tiers(kind: str, primary: Optional[str], runners: Dict[str, Any]) -> List[CascadeTier]:
|
|
"""Order the four grounded backends, promoting the session's own primary."""
|
|
names = ["gemini_native", "gemini_subscription", "openai_native", "openai_subscription"]
|
|
if (primary or "").lower() == "openai":
|
|
names = names[2:] + names[:2]
|
|
return [
|
|
CascadeTier(name=f"{kind}:{n}", run=runners[n], budget=GROUNDED_TIER_SECONDS)
|
|
for n in names
|
|
]
|
|
|
|
|
|
# --------------------------------------------------------------------------- Endpoints ---------------------------------------------------------------------------
|
|
|
|
|
|
@web.router.post("/search")
|
|
@typechecked
|
|
async def search(body: SearchBody) -> Dict:
|
|
"""Web search, primary-aware.
|
|
|
|
Free keyless rungs lead (they answer at human speed and cost nothing),
|
|
then the packaged app's real browser, then the provider-grounded backends
|
|
of whichever provider the user already pays for."""
|
|
gemini_key = resolve_gemini_api_key()
|
|
openai_key = resolve_openai_api_key()
|
|
|
|
async def try_keyless() -> Optional[Dict]:
|
|
# DuckDuckGo (html then lite); sub-second when it isn't challenged. None on a real no-hits so the chain falls through.
|
|
from backend.apps.agents.tools.web import DDGRateLimited, WebSearchTool
|
|
try:
|
|
text = await WebSearchTool.search_ddg(body.query, body.num_results)
|
|
except DDGRateLimited:
|
|
# Surface the challenge as a recorded error (not a silent None) so the caller can see WHY we fell through to a slower backend.
|
|
raise RuntimeError("DuckDuckGo served its bot challenge (HTTP 202)") from None
|
|
if not text:
|
|
return None
|
|
return {"query": body.query, "results": text, "backend": "ddg"}
|
|
|
|
async def try_startpage() -> Optional[Dict]:
|
|
# Second independent engine (Google's index), so DuckDuckGo's bot challenge is no longer a single point of failure for keyless users.
|
|
from backend.apps.agents.tools.search.search_startpage import search_startpage
|
|
answer = await search_startpage(body.query, body.num_results)
|
|
# Raise rather than return None: a refusal is the engine failing, and the breaker must count it so a closed Startpage stops costing its budget too.
|
|
if answer.refused:
|
|
raise RuntimeError("Startpage answered with a challenge instead of results")
|
|
if not answer.results:
|
|
return None
|
|
return {"query": body.query, "results": answer.results, "backend": "startpage"}
|
|
|
|
async def try_browser_search() -> Optional[Dict]:
|
|
# Packaged-app tier: a real Chromium's fingerprint isn't subject to the headless-client challenge, and it can scrape Google/Bing directly. Skipped (None) when no Electron main bridge is connected.
|
|
res = await p_browser_bridge("browser_search", {"query": body.query, "num_results": body.num_results})
|
|
if not res or not res.get("results"):
|
|
return None
|
|
return {"query": body.query, "results": res["results"], "backend": f"browser_{res.get('engine', 'search')}"}
|
|
|
|
async def try_gemini() -> Optional[Dict]:
|
|
if not gemini_key:
|
|
return None
|
|
grounded = await gemini_grounded_call(
|
|
gemini_key,
|
|
f"Search the web for: {body.query}\n\nReturn a concise summary of what you found. Cite sources.",
|
|
use_url_context=False,
|
|
)
|
|
return {"query": body.query, "results": format_grounded_as_search_results(grounded, body.query),
|
|
"backend": "gemini_native"}
|
|
|
|
async def try_openai() -> Optional[Dict]:
|
|
if not openai_key:
|
|
return None
|
|
grounded = await openai_websearch(openai_key, body.query)
|
|
return {"query": body.query, "results": format_grounded_as_search_results(grounded, body.query),
|
|
"backend": "openai_native"}
|
|
|
|
async def try_gemini_subscription() -> Optional[Dict]:
|
|
grounded = await gemini_grounded_via_9router(
|
|
f"Search the web for: {body.query}\n\nReturn a concise summary of what you found. Cite sources.",
|
|
False,
|
|
)
|
|
if not grounded.get("text"):
|
|
return None
|
|
return {"query": body.query, "results": format_grounded_as_search_results(grounded, body.query),
|
|
"backend": "gemini_subscription"}
|
|
|
|
async def try_openai_subscription() -> Optional[Dict]:
|
|
grounded = await openai_websearch_via_9router(body.query)
|
|
if not grounded.get("text"):
|
|
return None
|
|
return {"query": body.query, "results": format_grounded_as_search_results(grounded, body.query),
|
|
"backend": "openai_subscription"}
|
|
|
|
tiers = [
|
|
CascadeTier(name="ddg", run=try_keyless, budget=KEYLESS_TIER_SECONDS, breaker=True),
|
|
CascadeTier(name="startpage", run=try_startpage, budget=KEYLESS_TIER_SECONDS, breaker=True),
|
|
CascadeTier(name="browser_search", run=try_browser_search, budget=BROWSER_TIER_SECONDS),
|
|
] + p_grounded_tiers("search", body.primary, {
|
|
"gemini_native": try_gemini,
|
|
"gemini_subscription": try_gemini_subscription,
|
|
"openai_native": try_openai,
|
|
"openai_subscription": try_openai_subscription,
|
|
})
|
|
|
|
outcome = await run_cascade(tiers, SEARCH_BUDGET_SECONDS)
|
|
if outcome.result is not None:
|
|
if outcome.errors:
|
|
outcome.result["cascade_errors"] = outcome.errors
|
|
return outcome.result
|
|
|
|
# Everything failed. Be honest about why instead of an empty "no results".
|
|
connected = await refresh_9r_connected()
|
|
has_subscription = bool(connected & {"codex", "antigravity", "gemini-cli"})
|
|
if not (gemini_key or openai_key or has_subscription):
|
|
tail = (
|
|
"Every free search frontend refused this request and no paid search backend "
|
|
"is configured. Connect Codex / Antigravity / Gemini CLI in Settings, or add "
|
|
"an OpenAI / Gemini API key, for reliable search."
|
|
)
|
|
else:
|
|
tail = (
|
|
"Every free search frontend refused this request and every configured "
|
|
"provider errored (see details below)."
|
|
)
|
|
nudge = p_browser_fallback_nudge(body.query) if body.browser_ok else ""
|
|
results_text = f"No results for: {body.query}\n\n{tail}" + (f"\n\n{nudge}" if nudge else "")
|
|
return {
|
|
"query": body.query,
|
|
"results": results_text,
|
|
"backend": "none",
|
|
"cascade_errors": outcome.errors,
|
|
}
|
|
|
|
|
|
@web.router.post("/fetch")
|
|
@typechecked
|
|
async def fetch(body: FetchBody) -> Dict:
|
|
"""Fetch a URL, primary-aware. Mirrors the /search cascade."""
|
|
# Belt-and-suspenders: even though we delegate to remote Gemini/OpenAI fetchers (which can't reach private IPs), validating the URL here means a private/metadata URL gets a 4xx instead of being silently forwarded.
|
|
from backend.apps.agents.tools.ssrf_guard import DomainUnreachable, SSRFBlocked, assert_safe_url
|
|
try:
|
|
await assert_safe_url(body.url)
|
|
except DomainUnreachable:
|
|
# A domain that no longer resolves is the archive's whole reason for existing, so let the cascade run instead of 400ing here.
|
|
pass
|
|
except SSRFBlocked as exc:
|
|
raise HTTPException(status_code=400, detail=f"Refused: {exc}")
|
|
gemini_key = resolve_gemini_api_key()
|
|
openai_key = resolve_openai_api_key()
|
|
|
|
# Remembered so a thin/errored local read is still returned as the last resort if every other tier also fails (never worse than before).
|
|
local_text: Optional[str] = None
|
|
|
|
async def try_local() -> Optional[Dict]:
|
|
# Fast path: direct httpx + trafilatura, and it returns the page's ACTUAL text (the grounded fetchers summarize, which is slower and loses detail). Thin/errored reads (JS walls, paywalls, HTTP errors) fall through.
|
|
nonlocal local_text
|
|
from backend.apps.agents.tools.web import WebFetchTool
|
|
page = await WebFetchTool.fetch_page(body.url, body.prompt)
|
|
local_text = page.text
|
|
if page.kind == "error":
|
|
return None
|
|
# A PNG or a scanned PDF has no text for ANY tier to find, so spending a paid fetcher on it buys nothing.
|
|
if page.kind in ("binary", "pdf_unreadable"):
|
|
return {"url": body.url, "content": page.text, "backend": "local"}
|
|
body_text = page.text.split("\n\n", 1)[-1]
|
|
if len(body_text.strip()) < 200:
|
|
return None
|
|
return {"url": body.url, "content": page.text, "backend": "local"}
|
|
|
|
async def try_browser_fetch() -> Optional[Dict]:
|
|
# Packaged-app tier: renders the page in a real offscreen Chromium and returns its visible text, so JS-only / SPA / soft-paywall pages that give httpx nothing actually resolve. Shares the user's browser cookies, so pages they're logged into fetch authed.
|
|
res = await p_browser_bridge("browser_fetch", {"url": body.url})
|
|
if not res or not res.get("text"):
|
|
return None
|
|
return {"url": body.url, "content": f"Contents of {body.url}:\n\n{res['text']}", "backend": "browser"}
|
|
|
|
async def try_wayback() -> Optional[Dict]:
|
|
# A dead link or a hard bot wall is exactly what the archive is for, and it returns the page's REAL text where a grounded fetcher can only summarise a page it also can't read.
|
|
from backend.apps.agents.tools.fetch.wayback import fetch_wayback
|
|
text = await fetch_wayback(body.url)
|
|
if not text:
|
|
return None
|
|
return {"url": body.url, "content": text, "backend": "wayback"}
|
|
|
|
async def try_gemini() -> Optional[Dict]:
|
|
if not gemini_key:
|
|
return None
|
|
prompt_bits = [f"Fetch and summarize this URL: {body.url}"]
|
|
if body.prompt:
|
|
prompt_bits.append(f"Focus on: {body.prompt}")
|
|
grounded = await gemini_grounded_call(gemini_key, "\n".join(prompt_bits), use_url_context=True)
|
|
return {"url": body.url, "content": format_grounded_as_fetch(grounded, body.url),
|
|
"backend": "gemini_native"}
|
|
|
|
async def try_openai() -> Optional[Dict]:
|
|
if not openai_key:
|
|
return None
|
|
grounded = await openai_urlfetch(openai_key, body.url, body.prompt)
|
|
return {"url": body.url, "content": format_grounded_as_fetch(grounded, body.url),
|
|
"backend": "openai_native"}
|
|
|
|
async def try_gemini_subscription() -> Optional[Dict]:
|
|
prompt_bits = [f"Fetch and summarize this URL: {body.url}"]
|
|
if body.prompt:
|
|
prompt_bits.append(f"Focus on: {body.prompt}")
|
|
grounded = await gemini_grounded_via_9router("\n".join(prompt_bits), True)
|
|
if not grounded.get("text"):
|
|
return None
|
|
return {"url": body.url, "content": format_grounded_as_fetch(grounded, body.url),
|
|
"backend": "gemini_subscription"}
|
|
|
|
async def try_openai_subscription() -> Optional[Dict]:
|
|
# Codex's web_search is general; URL fetch via search query works adequately for our use.
|
|
prompt = f"Fetch this URL and summarize: {body.url}"
|
|
if body.prompt:
|
|
prompt += f"\nFocus on: {body.prompt}"
|
|
grounded = await openai_websearch_via_9router(prompt)
|
|
if not grounded.get("text"):
|
|
return None
|
|
return {"url": body.url, "content": format_grounded_as_fetch(grounded, body.url),
|
|
"backend": "openai_subscription"}
|
|
|
|
tiers = [
|
|
CascadeTier(name="local", run=try_local, budget=LOCAL_FETCH_TIER_SECONDS),
|
|
CascadeTier(name="browser", run=try_browser_fetch, budget=BROWSER_TIER_SECONDS),
|
|
CascadeTier(name="wayback", run=try_wayback, budget=ARCHIVE_TIER_SECONDS),
|
|
] + p_grounded_tiers("fetch", body.primary, {
|
|
"gemini_native": try_gemini,
|
|
"gemini_subscription": try_gemini_subscription,
|
|
"openai_native": try_openai,
|
|
"openai_subscription": try_openai_subscription,
|
|
})
|
|
|
|
outcome = await run_cascade(tiers, FETCH_BUDGET_SECONDS)
|
|
if outcome.result is not None:
|
|
if outcome.errors:
|
|
outcome.result["cascade_errors"] = outcome.errors
|
|
return outcome.result
|
|
|
|
# Every tier failed; hand back whatever the local read got (even an error string is useful signal) rather than nothing.
|
|
if local_text is not None:
|
|
return {"url": body.url, "content": local_text, "backend": "local",
|
|
**({"cascade_errors": outcome.errors} if outcome.errors else {})}
|
|
raise HTTPException(status_code=502, detail=f"Fetch failed for {body.url}: " + "; ".join(outcome.errors)[:400])
|