mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
Each removal verified by checking actual production callers (frontend,
electron, internal HTTP, MCP-server subprocesses) — not just test
references. Symbols whose only callers were tests are removed along
with those tests.
Production removals (~390 LOC):
- backend/main.py
- websocket_session: drop `agent:edit_message` WS branch. Frontend
only ever uses HTTP `POST /api/agents/sessions/{id}/edit_message`
(frontend/src/shared/state/agentsSlice.ts); nothing on the wire
sends a WS message of this type.
- backend/apps/agents/agent_manager.py
- AgentManager._build_connected_tools_context (~80 LOC): zero call
sites in production; the connected-tools system-prompt context is
built inline in _compose_system_prompt now.
- AgentManager._approx_tokens / _summarize_message_block: pure
helpers whose only callers were tests. The compaction path uses
LLM-driven _maybe_compact instead.
- backend/apps/agents/browser_agent.py
- clear_browser_history: only used by tests. _browser_history is
pruned via the size cap inline.
- MODEL_MAP constant: never read.
- backend/apps/agents/mcp_preflight.py
- DISCOVERY_SCAFFOLDING (~25-line system-prompt block): defined but
never appended anywhere. The header comment described an intended
use that the codebase no longer has.
- backend/apps/agents/providers/registry.py
- thinking_params_for, _is_9router_available, OPENROUTER_BASE_URL,
get_context_window: zero callers in production. Thinking-params
routing is done by the provider classes directly; 9Router presence
is detected at request time; context-window numbers are stamped
onto sessions from BUILTIN_MODELS at launch.
- backend/apps/agents/tools/{base,web}.py
- BaseTool.get_schema (abstract) + WebSearchTool/WebFetchTool
overrides: production code in backend/apps/web/web.py instantiates
these tools and only calls .execute(); the JSON-schema lives in
the HTTP wrapper, not on the tool class.
- backend/apps/outputs/outputs.py
- _resolve_model + MODEL_MAP: tests-only.
- load_output: docstring claimed it was a public helper for "other
modules" but no module imported it.
- backend/apps/service/client.py
- set_user_id, the _user_id module global, and the dead cache short-
circuit in _get_user_id: setter was tests-only. _get_user_id now
reads user_email directly from settings on every call.
- backend/apps/settings/credentials.py
- get_provider_credentials: zero callers. The sibling get_agent_sdk_env
is kept (it has the explicit "Legacy helpers" keep-comment).
Test updates:
- test_agent_manager_unit.py: drop _approx_tokens / _summarize_message_block
cases (5 tests), update module docstring index.
- test_browser_agent_unit.py: drop clear_browser_history cases (2 tests)
and the unused _Boom helper class in the repr-fallback test.
- test_outputs_unit.py: drop _resolve_model / load_output cases
(4 tests), update docstring + import list.
- test_v2_invariants.py: drop get_context_window tests + get_schema
assertions on web tools (kept name + BaseTool inheritance checks).
- test_service.py: rewrite the 4 set_user_id-driven tests to drive
user_id through settings.user_email instead, so _get_user_id's live
envelope-stamping path stays covered.
Verification:
- ruff --select F401,F811,F841 backend/ → clean.
- pytest backend/tests/ → 1167 passed, 1 deselected (pre-existing
sandbox git test, unrelated). No tests dropped silently — every
deletion is paired with the corresponding test removal/rewrite.
- Dead-code scan re-run: dead WS events 1→0, Tier-2 high-confidence
14→11 (residue is SDK-callback `context` params + Pydantic `cls`
validators — both false positives vulture can't see through),
vulture total 165→145.
Total diff: -565 / +34 LOC across 15 files.
Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
7.3 KiB
Python
196 lines
7.3 KiB
Python
"""Web tools: WebSearch and WebFetch."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
import re
|
||
|
||
import httpx
|
||
|
||
from backend.apps.agents.tools.base import BaseTool, ToolContext
|
||
|
||
_HTTP_TIMEOUT = 30 # seconds
|
||
_MAX_OUTPUT_BYTES = 250 * 1024 # ~250 KB — covers ~95% of articles/wikis/docs
|
||
_USER_AGENT = (
|
||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||
)
|
||
|
||
|
||
def _truncate(text: str, limit: int = _MAX_OUTPUT_BYTES) -> str:
|
||
if len(text) > limit:
|
||
return text[:limit] + "\n... (output truncated)"
|
||
return text
|
||
|
||
|
||
def _strip_html(raw_html: str) -> str:
|
||
"""Naive but effective HTML → plain-text conversion."""
|
||
# Remove script/style blocks
|
||
text = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", raw_html, flags=re.DOTALL | re.IGNORECASE)
|
||
# Remove HTML tags
|
||
text = re.sub(r"<[^>]+>", " ", text)
|
||
# Decode HTML entities
|
||
text = html.unescape(text)
|
||
# Collapse whitespace
|
||
text = re.sub(r"[ \t]+", " ", text)
|
||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||
return text.strip()
|
||
|
||
|
||
# ───────────────────────────────────────────────────────────────────────────
|
||
# WebSearchTool
|
||
# ───────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class WebSearchTool(BaseTool):
|
||
name = "WebSearch"
|
||
description = (
|
||
"Search the web using DuckDuckGo and return titles, URLs, and "
|
||
"snippets for the top results."
|
||
)
|
||
|
||
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
||
query: str = input_data["query"]
|
||
num_results: int = input_data.get("num_results", 5)
|
||
|
||
try:
|
||
results = await self._search_ddg(query, num_results)
|
||
if not results:
|
||
return [{"type": "text", "text": f"No search results found for: {query}"}]
|
||
return [{"type": "text", "text": results}]
|
||
except Exception as exc:
|
||
return [{"type": "text", "text": f"Web search error: {exc}"}]
|
||
|
||
@staticmethod
|
||
async def _search_ddg(query: str, num_results: int) -> str:
|
||
"""Query DuckDuckGo HTML endpoint and parse results."""
|
||
async with httpx.AsyncClient(
|
||
timeout=_HTTP_TIMEOUT,
|
||
follow_redirects=True,
|
||
headers={"User-Agent": _USER_AGENT},
|
||
) as client:
|
||
resp = await client.post(
|
||
"https://html.duckduckgo.com/html/",
|
||
data={"q": query},
|
||
)
|
||
resp.raise_for_status()
|
||
|
||
body = resp.text
|
||
|
||
# Parse result blocks – DuckDuckGo wraps each result in
|
||
# <div class="result ..."> ... </div>
|
||
result_blocks = re.findall(
|
||
r'<div[^>]*class="[^"]*result[^"]*"[^>]*>(.*?)</div>\s*(?=<div[^>]*class="[^"]*result|$)',
|
||
body,
|
||
flags=re.DOTALL,
|
||
)
|
||
|
||
entries: list[str] = []
|
||
for block in result_blocks:
|
||
if len(entries) >= num_results:
|
||
break
|
||
|
||
# Title + URL — handle both class-before-href and href-before-class
|
||
link_match = re.search(
|
||
r'<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
|
||
block,
|
||
flags=re.DOTALL,
|
||
)
|
||
if not link_match:
|
||
# Try reversed attribute order
|
||
link_match = re.search(
|
||
r'<a[^>]*href="([^"]*)"[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)</a>',
|
||
block,
|
||
flags=re.DOTALL,
|
||
)
|
||
if not link_match:
|
||
continue
|
||
|
||
raw_url = html.unescape(link_match.group(1))
|
||
title = _strip_html(link_match.group(2)).strip()
|
||
|
||
# Snippet
|
||
snippet_match = re.search(
|
||
r'<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>',
|
||
block,
|
||
flags=re.DOTALL,
|
||
)
|
||
snippet = _strip_html(snippet_match.group(1)).strip() if snippet_match else ""
|
||
|
||
# DuckDuckGo wraps URLs through a redirect; try to extract the real URL
|
||
real_url_match = re.search(r"uddg=([^&]+)", raw_url)
|
||
if real_url_match:
|
||
from urllib.parse import unquote
|
||
url = unquote(real_url_match.group(1))
|
||
else:
|
||
url = raw_url
|
||
|
||
entry = f"[{len(entries) + 1}] {title}\n {url}"
|
||
if snippet:
|
||
entry += f"\n {snippet}"
|
||
entries.append(entry)
|
||
|
||
return "\n\n".join(entries)
|
||
|
||
|
||
# ───────────────────────────────────────────────────────────────────────────
|
||
# WebFetchTool
|
||
# ───────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class WebFetchTool(BaseTool):
|
||
name = "WebFetch"
|
||
description = (
|
||
"Fetch the contents of a URL and return the extracted text. "
|
||
"HTML is stripped to plain text. Output capped at ~250 KB."
|
||
)
|
||
|
||
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
|
||
url: str = input_data["url"]
|
||
prompt: str | None = input_data.get("prompt")
|
||
|
||
try:
|
||
async with httpx.AsyncClient(
|
||
timeout=_HTTP_TIMEOUT,
|
||
follow_redirects=True,
|
||
headers={"User-Agent": _USER_AGENT},
|
||
) as client:
|
||
resp = await client.get(url)
|
||
resp.raise_for_status()
|
||
except httpx.HTTPStatusError as exc:
|
||
return [{"type": "text", "text": f"HTTP error {exc.response.status_code} fetching {url}"}]
|
||
except Exception as exc:
|
||
return [{"type": "text", "text": f"Error fetching {url}: {exc}"}]
|
||
|
||
content_type = resp.headers.get("content-type", "")
|
||
is_html = "html" in content_type or resp.text.strip().startswith("<!")
|
||
|
||
if is_html:
|
||
# Prefer trafilatura for article/main-content extraction — strips
|
||
# nav, footer, ads, sidebars and returns the primary text. Falls
|
||
# back to regex HTML-strip if trafilatura can't extract (rare
|
||
# pages: pure apps, login walls, heavily JS-rendered content).
|
||
text: str | None = None
|
||
try:
|
||
import trafilatura # type: ignore
|
||
text = trafilatura.extract(
|
||
resp.text,
|
||
include_comments=False,
|
||
include_tables=True,
|
||
favor_precision=True,
|
||
)
|
||
except Exception:
|
||
text = None
|
||
if not text:
|
||
text = _strip_html(resp.text)
|
||
else:
|
||
text = resp.text
|
||
|
||
text = _truncate(text)
|
||
|
||
header = f"Contents of {url}:"
|
||
if prompt:
|
||
header += f"\n(Looking for: {prompt})"
|
||
|
||
return [{"type": "text", "text": f"{header}\n\n{text}"}]
|