From f7f37766b40cec56a5acd719ce45d8e1ea7111f2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 23 Jun 2026 02:52:55 -0700 Subject: [PATCH] [eric] agents: extract web-MCP-need provider decision into tools/web (should_register_web_mcp) --- backend/apps/agents/agent_manager.py | 70 +++++--------------------- backend/apps/agents/tools/web.py | 47 ++++++++++++++++- backend/tests/test_web_mcp_decision.py | 48 ++++++++++++++++++ 3 files changed, 107 insertions(+), 58 deletions(-) create mode 100644 backend/tests/test_web_mcp_decision.py diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 19d97b05..3cdc3ddf 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -62,6 +62,7 @@ from backend.apps.agents.manager.streaming import thinking as thinking_mod from backend.apps.agents.manager.streaming import tool_result_hook from backend.apps.agents.manager.streaming import stop_hook as stop_hook_mod from backend.apps.agents.manager.prompt.system_prompt import compose_turn_system_prompt +from backend.apps.agents.tools.web import should_register_web_mcp from backend.apps.agents.manager.permissions import gate_hooks from backend.apps.agents.manager.view_builder_state import ( view_builder_render_retry_counts, @@ -552,63 +553,18 @@ class AgentManager: } - # The CLI's built-in WebSearch/WebFetch wraps Anthropic's - # web_search_20250305. For non-Claude primaries the CLI - # delegates execution back to Anthropic via - # ANTHROPIC_SMALL_FAST_MODEL, needs an Anthropic credential - # or it 401s. We register our DDG-backed MCP only for users - # with no Anthropic path; Anthropic's hosted search is - # higher-quality so we prefer it whenever it's reachable. + # Register the DDG-backed openswarm-web MCP only when the primary has no reliable + # native Anthropic web path (decided in tools/web.py); _m feeds the registration log + # + provider branch just below, so it stays a loop local. _m = _router_model_id if isinstance(_router_model_id, str) else "" - # When the primary is non-Claude we deliberately don't count - # OpenSwarm Pro as an Anthropic path, using the Pro pool for - # WebSearch on a GPT/Gemini session would drain it for the - # user's Claude turns. The user's GPT/Gemini subscription - # serves their non-Claude turns at zero cost to us. - _primary_is_claude = _m.startswith("cc/") or ( - isinstance(_router_model_id, str) - and not _router_model_id.startswith(("cc/", "cx/", "gc/", "ag/", "gemini/")) - and _api_type_for_session == "anthropic" + need_web_mcp = should_register_web_mcp( + model=session.model, + router_model_id=_router_model_id, + api_type=_api_type_for_session, + anthropic_api_key=getattr(global_settings, "anthropic_api_key", None), + connection_mode=getattr(global_settings, "connection_mode", "own_key"), ) - # Custom-provider sessions (Ollama Cloud, Together, Groq, etc.) - # set ANTHROPIC_BASE_URL to 9Router but 9Router has no Claude - # connection unless the user separately set up one. The CLI's - # built-in WebSearch delegates to Anthropic Haiku, which falls - # through 9Router to whichever connection serves anthropic/... - # ids, usually OpenRouter, and 401s. Force the openswarm-web - # MCP to register so WebSearch always cascades through our own - # /api/web/search (Gemini → OpenAI → DuckDuckGo). - _is_custom_session = _api_type_for_session == "custom" - # The built-in WebSearch's aux haiku call only authenticates when it - # reaches an ENTITLED Anthropic endpoint. That's true in exactly two - # cases, mirroring the direct-Anthropic env-branch built further down: - # a direct Anthropic api-route model (base_url = api.anthropic.com - # with the user's key), or OpenSwarm Pro (entitled to the managed pool - # 9Router's anthropic/* resolves to). A SUBSCRIPTION-route Claude - # model (opus-4-8, route=None) routes the haiku call through 9Router - # to the managed pool and 401s for non-Pro users, so a bare key in - # settings is NOT enough; it must be a *-api route model. Everyone - # else registers openswarm-web and cascades through /api/web/search. - from backend.apps.agents.tools.web import anthropic_web_search_is_reliable - from backend.apps.agents.providers.registry import _find_builtin_model as _fbm_web - _web_model_entry = _fbm_web(session.model) - _uses_direct_anthropic_api = ( - _web_model_entry is not None - and _web_model_entry.get("route") == "api" - and _web_model_entry.get("api") == "anthropic" - and bool(getattr(global_settings, "anthropic_api_key", None)) - ) - _has_anthropic_path = ( - not _is_custom_session - and _primary_is_claude - and anthropic_web_search_is_reliable( - uses_direct_anthropic_api=_uses_direct_anthropic_api, - is_pro=(getattr(global_settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial")), - ) - ) - - _need_web_mcp = not _has_anthropic_path - if _need_web_mcp: + if need_web_mcp: web_mcp_server_path = os.path.join( os.path.dirname(__file__), "web_mcp_server.py" ) @@ -701,7 +657,7 @@ class AgentManager: # WebSearch/WebFetch are guaranteed to fail (no Anthropic # backend). Suppress them so the model picks our MCP variants # and doesn't waste a turn on a broken tool. - if _need_web_mcp: + if need_web_mcp: effective_allowed = [t for t in effective_allowed if t not in ("WebSearch", "WebFetch")] for _bt in ("WebSearch", "WebFetch"): if _bt not in effective_disallowed: @@ -719,7 +675,7 @@ class AgentManager: # web MCP, AND (b) the user hasn't disabled the policy, matches # the same gate the MCP allowlist uses, so disabling WebSearch in # Settings still wins. - _web_tools_available = _need_web_mcp and ( + _web_tools_available = need_web_mcp and ( "mcp__openswarm-web__WebSearch" in effective_allowed or "mcp__openswarm-web__WebFetch" in effective_allowed ) diff --git a/backend/apps/agents/tools/web.py b/backend/apps/agents/tools/web.py index 82e46fcf..6e2a74a7 100644 --- a/backend/apps/agents/tools/web.py +++ b/backend/apps/agents/tools/web.py @@ -4,9 +4,10 @@ from __future__ import annotations import html import re -from typing import Any +from typing import Any, Optional import httpx +from typeguard import typechecked from backend.apps.agents.tools.base import BaseTool, ToolContext from backend.apps.agents.tools.ssrf_guard import SSRFBlocked, safe_fetch @@ -48,6 +49,50 @@ def anthropic_web_search_is_reliable(*, uses_direct_anthropic_api: bool, return bool(uses_direct_anthropic_api or is_pro) +@typechecked +def should_register_web_mcp( + *, + model: str, + router_model_id: object, + api_type: Optional[str], + anthropic_api_key: Optional[str], + connection_mode: str, +) -> bool: + """True when the agent loop must register the DDG-backed openswarm-web MCP because the + primary model has NO reliable native Anthropic web-search path. We prefer Anthropic's + hosted search (return False) whenever it's actually reachable, and cascade through our own + /api/web/search (Gemini -> OpenAI -> DuckDuckGo) otherwise. The three no-path cases: + a non-Claude primary, a custom-provider session (ANTHROPIC_BASE_URL points at 9Router with + no Claude connection), and a subscription-route Claude model on a non-Pro account (the + built-in WebSearch's aux haiku call 401s). Pro pool is deliberately NOT counted for a + non-Claude primary: spending it on WebSearch would drain the user's Claude turns.""" + from backend.apps.agents.providers.registry import _find_builtin_model as find_builtin_model + + m = router_model_id if isinstance(router_model_id, str) else "" + primary_is_claude = m.startswith("cc/") or ( + isinstance(router_model_id, str) + and not router_model_id.startswith(("cc/", "cx/", "gc/", "ag/", "gemini/")) + and api_type == "anthropic" + ) + is_custom_session = api_type == "custom" + web_model_entry = find_builtin_model(model) + uses_direct_anthropic_api = ( + web_model_entry is not None + and web_model_entry.get("route") == "api" + and web_model_entry.get("api") == "anthropic" + and bool(anthropic_api_key) + ) + has_anthropic_path = ( + not is_custom_session + and primary_is_claude + and anthropic_web_search_is_reliable( + uses_direct_anthropic_api=uses_direct_anthropic_api, + is_pro=(connection_mode in ("openswarm-pro", "free-trial")), + ) + ) + return not has_anthropic_path + + def _truncate(text: str, limit: int = _MAX_OUTPUT_BYTES) -> str: if len(text) > limit: return text[:limit] + "\n... (output truncated)" diff --git a/backend/tests/test_web_mcp_decision.py b/backend/tests/test_web_mcp_decision.py new file mode 100644 index 00000000..b5f1b40f --- /dev/null +++ b/backend/tests/test_web_mcp_decision.py @@ -0,0 +1,48 @@ +"""Unit coverage for should_register_web_mcp: the decision of whether to register the +DDG-backed openswarm-web MCP. Security/cost-relevant (it governs whether WebSearch cascades +through our own /api/web/search vs the native Anthropic path), so pin each provider case.""" + +import pytest +from unittest.mock import patch + +from backend.apps.agents.tools.web import should_register_web_mcp + + +def _call(**kw): + base = dict(model="m", router_model_id="cc/opus", api_type="anthropic", + anthropic_api_key=None, connection_mode="own_key") + base.update(kw) + with patch("backend.apps.agents.providers.registry._find_builtin_model", return_value=None): + return should_register_web_mcp(**base) + + +def test_custom_session_always_registers(): + # ANTHROPIC_BASE_URL points at 9Router with no Claude connection -> native WebSearch 401s. + assert _call(api_type="custom") is True + + +def test_non_claude_primary_registers(): + # A Gemini/GPT primary has no native Anthropic web path; Pro pool is not counted for it. + assert _call(router_model_id="gemini/flash", api_type="google") is True + + +def test_claude_pro_uses_native_path(): + # Claude primary on Pro: the managed pool entitles the built-in WebSearch, so don't register. + assert _call(router_model_id="cc/opus", api_type="anthropic", connection_mode="openswarm-pro") is False + + +def test_subscription_route_claude_non_pro_registers(): + # opus-4-8 on a non-Pro own-key account: the aux haiku call 401s through 9Router, so a bare + # key isn't enough -> fall back to openswarm-web. + assert _call(router_model_id="cc/opus", api_type="anthropic", + connection_mode="own_key", anthropic_api_key="sk-ant-xxx") is True + + +def test_direct_anthropic_api_route_uses_native_path(): + entry = {"route": "api", "api": "anthropic"} + with patch("backend.apps.agents.providers.registry._find_builtin_model", return_value=entry): + out = should_register_web_mcp( + model="claude-x", router_model_id="claude-3-5-api", api_type="anthropic", + anthropic_api_key="sk-ant-xxx", connection_mode="own_key", + ) + assert out is False