From e0f5414cda7d42477f7ba5dfc08f51d09138beac Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 5 Jul 2026 16:19:12 -0700 Subject: [PATCH] [eric] web-search: gate the browser-fallback nudge on browser-delegation availability --- backend/apps/agents/manager/run/RunOptions.py | 3 ++- .../agents/manager/run/run_options_helpers.py | 3 ++- backend/apps/agents/web_mcp_server.py | 4 +++- backend/apps/web/web.py | 7 +++++-- backend/tests/test_web_search_cascade.py | 18 +++++++++++++++++- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/backend/apps/agents/manager/run/RunOptions.py b/backend/apps/agents/manager/run/RunOptions.py index 53fe7d85..5a4b0630 100644 --- a/backend/apps/agents/manager/run/RunOptions.py +++ b/backend/apps/agents/manager/run/RunOptions.py @@ -132,7 +132,8 @@ class RunOptions(AgentManagerProtocol): connection_mode=getattr(global_settings, "connection_mode", "own_key"), ) if need_web_mcp: - register_web_mcp_server(mcp_servers, p_m) + # browser_ok gates the search-dead fallback nudge: never tell the model to call CreateBrowserAgent in a session where browser delegation is denied. + register_web_mcp_server(mcp_servers, p_m, browser_ok=bool(browser_delegation_tools)) effective_allowed, effective_disallowed = build_effective_tool_lists( session, mcp_servers, builtin_perms, need_web_mcp, diff --git a/backend/apps/agents/manager/run/run_options_helpers.py b/backend/apps/agents/manager/run/run_options_helpers.py index 74dc1b38..c88fbd81 100644 --- a/backend/apps/agents/manager/run/run_options_helpers.py +++ b/backend/apps/agents/manager/run/run_options_helpers.py @@ -92,7 +92,7 @@ def set_framework_overhead(session: AgentSession, composed_prompt: Optional[str] @typechecked -def register_web_mcp_server(mcp_servers: Dict, p_m: str) -> None: +def register_web_mcp_server(mcp_servers: Dict, p_m: str, browser_ok: bool = False) -> None: """Register the DDG-backed openswarm-web stdio MCP into the server set when the primary has no reliable native web path. The server script lives in the agents package (not here), so resolve it off that package dir, not __file__.""" @@ -115,6 +115,7 @@ def register_web_mcp_server(mcp_servers: Dict, p_m: str) -> None: "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), "OPENSWARM_AUTH_TOKEN": p_get_auth_token3(), "OPENSWARM_PRIMARY_API": p_primary_hint, + "OPENSWARM_BROWSER_OK": "1" if browser_ok else "0", }, "type": "stdio", } diff --git a/backend/apps/agents/web_mcp_server.py b/backend/apps/agents/web_mcp_server.py index 09b827d9..dada7086 100755 --- a/backend/apps/agents/web_mcp_server.py +++ b/backend/apps/agents/web_mcp_server.py @@ -14,6 +14,8 @@ FETCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/fetch" # Primary-provider hint from agent_manager; backend picks the native search tool (googleSearch/web_search_preview) so searches use the user's existing budget. PRIMARY_HINT = os.environ.get("OPENSWARM_PRIMARY_API", "") or None +# Whether this session actually has browser-delegation tools; gates the backend's "fall back to the browser" nudge. +BROWSER_OK = os.environ.get("OPENSWARM_BROWSER_OK", "0") == "1" TOOLS = [ { @@ -104,7 +106,7 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict: return {"content": [{"type": "text", "text": "Error: query is required"}], "isError": True} num = int(arguments.get("num_results", 5)) num = max(1, min(num, 10)) - body = {"query": query, "num_results": num} + body = {"query": query, "num_results": num, "browser_ok": BROWSER_OK} if PRIMARY_HINT: body["primary"] = PRIMARY_HINT r = p_post(SEARCH_URL, body, timeout=45.0) diff --git a/backend/apps/web/web.py b/backend/apps/web/web.py index c4d0fd5d..1f4712a7 100644 --- a/backend/apps/web/web.py +++ b/backend/apps/web/web.py @@ -36,6 +36,8 @@ class SearchBody(BaseModel): 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: str | None = 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): @@ -477,10 +479,11 @@ async def search(body: SearchBody) -> dict: "DuckDuckGo is rate-limiting this network and every configured provider " "errored (see details below)." ) - nudge = p_browser_fallback_nudge(body.query) + nudge = p_browser_fallback_nudge(body.query) if body.browser_ok else "" + p_results_text = f"No results for: {body.query}\n\n{tail}" + (f"\n\n{nudge}" if nudge else "") return { "query": body.query, - "results": f"No results for: {body.query}\n\n{tail}\n\n{nudge}", + "results": p_results_text, "backend": "none", "cascade_errors": errors, } diff --git a/backend/tests/test_web_search_cascade.py b/backend/tests/test_web_search_cascade.py index 23f54306..d394f31a 100644 --- a/backend/tests/test_web_search_cascade.py +++ b/backend/tests/test_web_search_cascade.py @@ -138,12 +138,28 @@ async def test_everything_fails_nudges_browser_not_retry(monkeypatch): raise RuntimeError("openai down") monkeypatch.setattr(W, "p_openai_websearch", p_openai_boom) - res = await search(SearchBody(query="sony zv-e10 price")) + res = await search(SearchBody(query="sony zv-e10 price", browser_ok=True)) assert res["backend"] == "none" assert "CreateBrowserAgent" in res["results"] assert "retry" not in res["results"].lower() +@pytest.mark.asyncio +async def test_nudge_suppressed_when_browser_denied(monkeypatch): + # A session without browser-delegation tools must never be told to call CreateBrowserAgent. + p_ddg_throttled(monkeypatch) + monkeypatch.setattr(W, "p_resolve_openai_api_key", lambda: "okey") + + async def p_openai_boom(*a, **k): + raise RuntimeError("openai down") + monkeypatch.setattr(W, "p_openai_websearch", p_openai_boom) + + res = await search(SearchBody(query="sony zv-e10 price")) + assert res["backend"] == "none" + assert "CreateBrowserAgent" not in res["results"] + assert "retry" not in res["results"].lower() + + # -------------------------------------------------------------------------- /fetch mirrors /search: local httpx + trafilatura is the fast path, grounded fetchers are the fallback for JS/paywalled pages, every attempt is bounded. -------------------------------------------------------------------------- from backend.apps.web.web import fetch, FetchBody