Files
openswarm/backend/apps/agents/mcp_preflight.py
T
Arnav NavalandCursor 64dfd50d12 [arnav] remove dead code identified by audit
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>
2026-05-06 18:49:30 -05:00

278 lines
11 KiB
Python

"""Pre-flight MCP suggestion classifier.
Runs before a new agent launches. Given the user's initial prompt, decides:
1. Is this prompt vague or information-gathering (is_vague) — used to
conditionally inject the discovery scaffolding into the system prompt.
2. Does it suggest a not-yet-connected MCP that would dramatically
improve the outcome — surfaced to the user as a one-click
"Connect X" modal before the agent runs.
Only the curated shortlist of MCPs we ship and have vetted is considered.
The full community MCP registry is NOT mined for suggestions — flaky/
unvetted entries would make the "magic" moment feel broken.
Provider-agnostic: calls whatever cheap-tier aux model the user has wired
via `resolve_aux_model` (Haiku / GPT-5.4-mini / Gemini-2.5-flash / etc.).
If no provider is connected, fails open (no suggestions, no scaffolding).
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from typing import Any
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.settings.settings import load_settings
from backend.apps.tools_lib.tools_lib import _load_all as load_all_tools
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Curated MCP shortlist. These `id` values MUST match the exact `name` field
# on ToolDefinition entries that OpenSwarm ships as defaults (see
# `backend/data/tools/*.json` — one file per tool, `name` is the canonical
# key used everywhere else in the app). Mismatches would cause the
# enabled/disabled filter to no-op and the frontend modal to render nothing.
#
# Keep in sync with the Custom Action Sets list in Settings → Tools.
# ---------------------------------------------------------------------------
CuratedEntry = dict[str, Any]
CURATED_SHORTLIST: list[CuratedEntry] = [
{
"id": "Google Workspace",
"title": "Google Workspace",
"description": "Gmail, Calendar, Drive, Docs, Sheets, Slides — for reading/sending email, checking the user's schedule, and pulling context from their documents.",
},
{
"id": "Microsoft 365",
"title": "Microsoft 365",
"description": "Outlook email, Calendar, OneDrive, Teams, Excel, OneNote — Microsoft-stack equivalent of Google Workspace.",
},
{
"id": "Slack",
"title": "Slack",
"description": "Search channels and DMs, read history, send messages in the user's Slack workspace.",
},
{
"id": "Discord",
"title": "Discord",
"description": "Read messages, send messages, manage channels, interact with Discord servers via the OpenSwarm bot.",
},
{
"id": "Notion",
"title": "Notion",
"description": "Search and update the user's Notion pages, databases, and wikis.",
},
{
"id": "HubSpot",
"title": "HubSpot",
"description": "CRM contacts, deals, companies, tickets — when the user's task involves their customer relationships.",
},
{
"id": "Airtable",
"title": "Airtable",
"description": "Read and write records, manage bases, tables, and fields in the user's Airtable.",
},
{
"id": "Reddit",
"title": "Reddit",
"description": "Browse subreddits, search posts, analyze users — when the task involves public Reddit content.",
},
{
"id": "YouTube",
"title": "YouTube",
"description": "Video transcripts, details, comments, channel stats, search — when the task involves YouTube content.",
},
]
# ---------------------------------------------------------------------------
# Local skip filter — short-circuits the LLM call for obviously-local prompts
# where no MCP could add value. Saves ~200ms + ~$0.0001 per launch.
# ---------------------------------------------------------------------------
_PATH_LIKE = re.compile(r"^[./~]|/[\w\-]+/|\.[a-zA-Z]{1,5}\b")
_SHELL_PREFIX = re.compile(r"^\s*[\$!/]")
def _is_obviously_local(prompt: str) -> bool:
"""Heuristic: does this prompt obviously not need any MCP?
Returns True for:
- very short prompts (< 8 chars, likely greetings or acknowledgments)
- shell-command-ish prompts ("! ls", "$ git status", "/clear")
- prompts that are essentially a single file path reference
On True we skip preflight entirely; the agent launches with no
scaffolding and no suggestion modal.
"""
s = prompt.strip()
if len(s) < 8:
return True
if _SHELL_PREFIX.match(s):
return True
# Single-token path-ish prompt (e.g. "./src/foo.ts")
if " " not in s and _PATH_LIKE.search(s):
return True
return False
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
async def run_preflight(prompt: str, timeout_s: float = 2.0) -> dict:
"""Classify the user's prompt and return suggestions + vagueness flag.
Always returns a dict of shape:
{"is_vague": bool, "suggestions": [Suggestion, ...]}
Never raises: any failure (no provider, aux model timeout, bad JSON)
fails open — returns is_vague=False and empty suggestions.
"""
default: dict[str, Any] = {"is_vague": False, "suggestions": []}
if not prompt or not prompt.strip():
return default
if _is_obviously_local(prompt):
return default
try:
settings = load_settings()
available = _build_available_shortlist(settings)
if not available:
# Everything in the shortlist is either enabled, dismissed, or
# out-of-scope for this user. We still run the classifier (for
# is_vague) but with an empty candidate list — the model will
# only fill in is_vague and return no suggestions.
pass
result = await asyncio.wait_for(
_call_classifier(settings, prompt, available),
timeout=timeout_s,
)
# Re-validate suggestion ids against the curated shortlist so a
# hallucinated id can't reach the frontend.
valid_ids = {e["id"] for e in CURATED_SHORTLIST}
result["suggestions"] = [
_decorate(s, available) for s in result.get("suggestions", [])
if isinstance(s, dict) and s.get("id") in valid_ids
]
# Drop anything that ended up with no matching available entry
# (e.g. already enabled by the user between preflight and now).
result["suggestions"] = [s for s in result["suggestions"] if s is not None]
result["is_vague"] = bool(result.get("is_vague"))
# Suppress suggestions on concrete prompts. False-positives here
# are worse than missed positives — interrupting a user who typed
# "refactor foo.ts" to ask about GitHub MCP would feel broken.
# Vague/info-gathering prompts are where suggestions help; concrete
# tasks should just launch.
if not result["is_vague"]:
result["suggestions"] = []
return result
except asyncio.TimeoutError:
logger.info("preflight: classifier timed out, failing open")
return default
except Exception as e:
logger.info(f"preflight: classifier failed ({type(e).__name__}: {e}); failing open")
return default
def _build_available_shortlist(settings) -> list[CuratedEntry]:
"""Curated entries that are NOT currently enabled and NOT dismissed."""
try:
enabled_names = {t.name for t in load_all_tools() if getattr(t, "enabled", False)}
except Exception:
enabled_names = set()
dismissed = set((getattr(settings, "dismissed_mcp_suggestions", {}) or {}).keys())
return [
entry for entry in CURATED_SHORTLIST
if entry["id"] not in enabled_names and entry["id"] not in dismissed
]
def _decorate(llm_suggestion: dict, available: list[CuratedEntry]) -> dict | None:
"""Expand an LLM-returned {id, reason} into the full frontend shape."""
entry = next((e for e in available if e["id"] == llm_suggestion["id"]), None)
if entry is None:
return None
return {
"id": entry["id"],
"title": entry["title"],
"description": entry["description"],
"reason": (llm_suggestion.get("reason") or "").strip()[:200],
}
async def _call_classifier(settings, prompt: str, available: list[CuratedEntry]) -> dict:
"""One aux-model call, returns validated JSON {is_vague, suggestions}."""
aux_model, _base = await resolve_aux_model(settings, preferred_tier="haiku")
client = get_anthropic_client_for_model(settings, aux_model)
catalog_lines = "\n".join(
f"- id: {e['id']} | {e['title']}{e['description']}"
for e in available
) or "- (no candidate services available for this user)"
system = (
"You classify a single user request to help a downstream agent. "
"Output MUST be strict JSON matching this schema:\n"
" {\"is_vague\": boolean, \"suggestions\": [{\"id\": string, \"reason\": string}]}\n\n"
"Field definitions:\n"
"- is_vague: true if the request is underspecified or would benefit "
"from grounding in the user's data before answering (e.g. \"write me "
"an email\", \"summarize my meeting\", \"what's on my schedule\"). "
"false for concrete self-contained tasks (\"fix this bug\", \"refactor "
"foo.ts\", \"what's 2+2\", \"list files in ./src\").\n"
"- suggestions: up to 2 CANDIDATE SERVICE ids (from the catalog "
"below) whose connection would dramatically improve the outcome. "
"Only include a service if the request clearly implies it. If no "
"service clearly fits, return an empty array. Never invent ids.\n"
"- reason: one short sentence (<20 words) explaining WHY this "
"service fits this request.\n\n"
"Return ONLY the JSON. No prose, no markdown fences, no explanation."
)
user_turn = (
"Candidate services (may be empty):\n"
f"{catalog_lines}\n\n"
"User request:\n"
f"<request>\n{prompt}\n</request>"
)
resp = await client.messages.create(
model=aux_model,
max_tokens=300,
system=system,
messages=[{"role": "user", "content": user_turn}],
)
# Extract text content. Handle both string and content-block shapes.
text = ""
if isinstance(resp.content, list):
for block in resp.content:
t = getattr(block, "text", None)
if t:
text += t
else:
text = str(resp.content)
text = text.strip()
# Strip any accidental code fences
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```\s*$", "", text)
data = json.loads(text)
if not isinstance(data, dict):
raise ValueError("classifier did not return an object")
if not isinstance(data.get("suggestions", []), list):
data["suggestions"] = []
return data