diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 30f91c4c..7d414cc6 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -898,6 +898,30 @@ class AgentManager: else: _ts_loop["n"] = 0 + # MCPSearch is the agent saying "I need an integration I don't have" (e.g. "no email + # connected"). Don't make the user read a wall of options: fire the same curated connect + # card the launch preflight uses, keyed to their original request. Non-blocking (the search + # proceeds) and once per run; covers the common path the ToolSearch-loop branch misses + # because a capable model does one MCPSearch instead of thrashing. Suggest-only as ever. + if (tool_name.endswith("MCPSearch") or tool_name.endswith("MCPList")) and not _mcp_offer_sent["done"]: + _mcp_offer_sent["done"] = True + + async def _offer_from_prompt(): + try: + from backend.apps.agents.core.mcp_preflight import run_preflight + result = await run_preflight(prompt, task_id=session_id, require_vague=False) + offers = result.get("suggestions", []) + if offers: + await ws_manager.send_to_session(session_id, "agent:mcp_suggestions", { + "session_id": session_id, + "suggestions": offers, + "is_vague": False, + }) + except Exception: + logger.debug("MCPSearch-triggered connect offer skipped", exc_info=True) + + asyncio.create_task(_offer_from_prompt()) + if tool_name and tool_name != "AskUserQuestion": tool_input = input_data.get("tool_input", {}) policy, sensitive_pattern = _maybe_override_policy( diff --git a/backend/apps/agents/core/mcp_preflight.py b/backend/apps/agents/core/mcp_preflight.py index cf5fbe16..73641f2c 100644 --- a/backend/apps/agents/core/mcp_preflight.py +++ b/backend/apps/agents/core/mcp_preflight.py @@ -86,8 +86,10 @@ def _is_obviously_local(prompt: str) -> bool: return False -async def run_preflight(prompt: str, timeout_s: float = 2.0, task_id: str | None = None) -> dict: - """Classify the prompt and return {is_vague, suggestions}; never raises.""" +async def run_preflight(prompt: str, timeout_s: float = 8.0, task_id: str | None = None, require_vague: bool = True) -> dict: + """Classify the prompt and return {is_vague, suggestions}; never raises. require_vague=False + keeps suggestions even on a concrete prompt: used when the agent already proved it needs an + integration (it called MCPSearch), so the "don't interrupt concrete tasks" guard no longer applies.""" default: dict[str, Any] = {"is_vague": False, "suggestions": []} if not prompt or not prompt.strip(): @@ -113,7 +115,7 @@ async def run_preflight(prompt: str, timeout_s: float = 2.0, task_id: str | None result["suggestions"] = [s for s in result["suggestions"] if s is not None] result["is_vague"] = bool(result.get("is_vague")) # Suppress on concrete prompts; false-positives feel broken (interrupting "refactor foo.ts" to suggest GitHub MCP). - if not result["is_vague"]: + if require_vague and not result["is_vague"]: result["suggestions"] = [] return result except asyncio.TimeoutError: diff --git a/backend/tests/test_mcp_offer.py b/backend/tests/test_mcp_offer.py index 47684463..6c58cc8c 100644 --- a/backend/tests/test_mcp_offer.py +++ b/backend/tests/test_mcp_offer.py @@ -6,12 +6,14 @@ anything that could widen the MCP surface on its own. These tests make a bad off loudly instead of shipping a silent gate bypass. """ +import asyncio from types import SimpleNamespace import backend.apps.agents.core.mcp_preflight as pf from backend.apps.agents.core.mcp_preflight import ( CURATED_SHORTLIST, offer_for_gated_server, + run_preflight, ) VETTED = {e["id"] for e in CURATED_SHORTLIST} @@ -65,3 +67,37 @@ def test_offer_carries_no_activate_capability(monkeypatch): o = offer_for_gated_server(entry["id"], s) assert o is not None assert set(o.keys()) == OFFER_SHAPE, f"offer for {entry['id']} grew an unexpected field" + + +# --- require_vague: the MCPSearch path keeps suggestions on a concrete prompt ---------------- + +def _stub_classifier(is_vague, ids): + async def _fake(settings, prompt, available, task_id=None): + return {"is_vague": is_vague, "suggestions": [{"id": i, "reason": "fits"} for i in ids]} + return _fake + + +def test_preflight_default_suppresses_suggestions_on_concrete_prompt(monkeypatch): + # Launch path: a concrete (non-vague) prompt must NOT interrupt with a card. + monkeypatch.setattr(pf, "load_all_tools", lambda: []) + monkeypatch.setattr(pf, "_call_classifier", _stub_classifier(False, ["Google Workspace"])) + out = asyncio.run(run_preflight("refactor foo.ts to use the new client", timeout_s=5)) + assert out["suggestions"] == [] + + +def test_preflight_require_vague_false_keeps_suggestions(monkeypatch): + # MCPSearch path: the agent already proved it needs an integration, so keep the suggestion + # even though the prompt is concrete (is_vague False). + monkeypatch.setattr(pf, "load_all_tools", lambda: []) + monkeypatch.setattr(pf, "_call_classifier", _stub_classifier(False, ["Google Workspace"])) + out = asyncio.run(run_preflight("check my unread emails", timeout_s=5, require_vague=False)) + assert [s["id"] for s in out["suggestions"]] == ["Google Workspace"] + assert set(out["suggestions"][0].keys()) == OFFER_SHAPE + + +def test_preflight_require_vague_false_still_drops_hallucinated_ids(monkeypatch): + # require_vague=False must NOT loosen the vetted-id revalidation: a made-up id is still dropped. + monkeypatch.setattr(pf, "load_all_tools", lambda: []) + monkeypatch.setattr(pf, "_call_classifier", _stub_classifier(False, ["TotallyFakeServer"])) + out = asyncio.run(run_preflight("do the thing", timeout_s=5, require_vague=False)) + assert out["suggestions"] == [] diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 9afbf587..e9d9e3c5 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1526,81 +1526,6 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }} > - {(session.mcp_suggestions && session.mcp_suggestions.length > 0) && ( - - {session.mcp_suggestions.map((s) => ( - - - Connect{' '} - {s.title} - {' '}so the agent can do this - - { - if (activatingMcp) return; - setActivateError(null); - setActivatingMcp(s.id); - try { - const headers: Record = { 'Content-Type': 'application/json' }; - const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); - if (tok) headers['Authorization'] = `Bearer ${tok}`; - const r = await fetch(`${API_BASE}/mcp-meta/activate`, { - method: 'POST', - headers, - body: JSON.stringify({ - server_name: s.id.toLowerCase().replace(/\s+/g, '-'), - reason: s.reason || 'preflight suggestion', - parent_session_id: session.id, - }), - }); - const body = await r.json().catch(() => ({} as any)); - if (!r.ok) { - setActivateError(`Activation failed (${r.status})`); - } else if (body?.status === 'unknown_server') { - // Not yet connected; jump to Actions so the user can finish OAuth. - navigate('/actions'); - } else if (id) { - dispatch(clearMcpSuggestions({ sessionId: id })); - } - } catch (e: any) { - setActivateError(e?.message || 'Activation failed'); - } finally { - setActivatingMcp(null); - } - }} - sx={{ - border: 'none', - background: 'none', - p: 0, - color: c.accent.primary, - cursor: activatingMcp === s.id ? 'wait' : 'pointer', - opacity: activatingMcp === s.id ? 0.5 : 1, - '&:hover': { textDecoration: activatingMcp ? 'none' : 'underline' }, - flexShrink: 0, - }} - > - {activatingMcp === s.id ? 'Connecting…' : 'Connect'} - - - ))} - {activateError && ( - - {activateError} - - )} - id && dispatch(clearMcpSuggestions({ sessionId: id }))} - sx={{ alignSelf: 'flex-start', color: c.text.muted, cursor: 'pointer', fontSize: '0.72rem', '&:hover': { color: c.text.secondary } }} - > - Dismiss - - - )} {session.context_overflow && (() => { const reason = session.context_overflow.reason; const isAuth = reason === 'openswarm_pro_auth_expired' || reason === 'anthropic_auth_invalid' || reason === 'auth_error'; @@ -1777,6 +1702,84 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose /> )} + {/* Connect offer sits BELOW the latest reply (where the eye is), not at the top of the + transcript where the auto-scroll-to-bottom buries it. Suggest-only; activation is the + user's click through the gated MCPActivate endpoint. */} + {(session.mcp_suggestions && session.mcp_suggestions.length > 0) && ( + + {session.mcp_suggestions.map((s) => ( + + + Connect{' '} + {s.title} + {' '}so the agent can do this + + { + if (activatingMcp) return; + setActivateError(null); + setActivatingMcp(s.id); + try { + const headers: Record = { 'Content-Type': 'application/json' }; + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + if (tok) headers['Authorization'] = `Bearer ${tok}`; + const r = await fetch(`${API_BASE}/mcp-meta/activate`, { + method: 'POST', + headers, + body: JSON.stringify({ + server_name: s.id.toLowerCase().replace(/\s+/g, '-'), + reason: s.reason || 'preflight suggestion', + parent_session_id: session.id, + }), + }); + const body = await r.json().catch(() => ({} as any)); + if (!r.ok) { + setActivateError(`Activation failed (${r.status})`); + } else if (body?.status === 'unknown_server') { + // Not yet connected; jump to Actions so the user can finish OAuth. + navigate('/actions'); + } else if (id) { + dispatch(clearMcpSuggestions({ sessionId: id })); + } + } catch (e: any) { + setActivateError(e?.message || 'Activation failed'); + } finally { + setActivatingMcp(null); + } + }} + sx={{ + border: 'none', + background: 'none', + p: 0, + color: c.accent.primary, + cursor: activatingMcp === s.id ? 'wait' : 'pointer', + opacity: activatingMcp === s.id ? 0.5 : 1, + '&:hover': { textDecoration: activatingMcp ? 'none' : 'underline' }, + flexShrink: 0, + }} + > + {activatingMcp === s.id ? 'Connecting…' : 'Connect'} + + + ))} + {activateError && ( + + {activateError} + + )} + id && dispatch(clearMcpSuggestions({ sessionId: id }))} + sx={{ alignSelf: 'flex-start', color: c.text.muted, cursor: 'pointer', fontSize: '0.72rem', '&:hover': { color: c.text.secondary } }} + > + Dismiss + + + )} {/* First-run welcome chips: sit UNDER the streamed greeting, appear once it finishes, vanish the moment the user answers. The greeting itself is a real assistant bubble. */} {session.is_welcome_draft && isDraft && welcomeGreetingDone && !session.messages.some((m) => m.role === 'user') && (