[eric] phase2: wire in-task MCP connect offer into the ToolSearch loop-breaker (suggest-only, slug-matched)

This commit is contained in:
ciregenz
2026-06-16 22:34:28 -07:00
parent b8dc125da7
commit 78c0971e88
3 changed files with 40 additions and 10 deletions
+24 -4
View File
@@ -847,6 +847,9 @@ class AgentManager:
# Counts ToolSearch calls in a row (no other tool between them). A run
# of these with empty results is the "looping on ToolSearch" wedge.
_ts_loop = {"n": 0}
# One mid-run connect offer per session: a stuck agent fires the loop-breaker repeatedly,
# but the user should see the "connect this MCP" card once, not on every retry.
_mcp_offer_sent = {"done": False}
async def pre_tool_hook(input_data, tool_use_id, context):
tool_name = input_data.get("tool_name", "")
@@ -862,12 +865,29 @@ class AgentManager:
if tool_name == "ToolSearch":
_ts_loop["n"] += 1
if _ts_loop["n"] >= TOOLSEARCH_LOOP_THRESHOLD:
_reason = toolsearch_loop_redirect(
_ts_loop["n"],
self._gated_mcp_server_names(session.allowed_tools, session.active_mcps),
)
_gated = self._gated_mcp_server_names(session.allowed_tools, session.active_mcps)
_reason = toolsearch_loop_redirect(_ts_loop["n"], _gated)
if _reason:
logger.info(f"[MCP-DEBUG] ToolSearch loop-breaker fired for {session_id} (n={_ts_loop['n']})")
# 2B-MCP: also surface a one-click connect offer to the USER for the vetted
# gated servers the agent keeps reaching for. Suggest-only: this just shows a
# card on the same channel the preflight uses; activation still requires
# MCPActivate + the dispatch gate, so it opens no side channel. Once per run,
# fail-open (an offer hiccup must never block the agent).
if not _mcp_offer_sent["done"]:
try:
from backend.apps.agents.core.mcp_preflight import offer_for_gated_server
_s = load_settings()
_offers = [o for o in (offer_for_gated_server(n, _s) for n in _gated) if o]
if _offers:
_mcp_offer_sent["done"] = True
await ws_manager.send_to_session(session_id, "agent:mcp_suggestions", {
"session_id": session_id,
"suggestions": _offers,
"is_vague": False,
})
except Exception:
logger.debug("mid-run MCP connect offer skipped", exc_info=True)
return {
"hookSpecificOutput": {
"hookEventName": hook_event,
+8 -1
View File
@@ -12,6 +12,7 @@ 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
from backend.apps.tools_lib.mcp_config import _sanitize_server_name
logger = logging.getLogger(__name__)
@@ -146,7 +147,13 @@ def offer_for_gated_server(server_name: str, settings) -> CuratedEntry | None:
server is vetted AND inactive AND not dismissed, reusing the same filter as the preflight."""
if not server_name or not isinstance(server_name, str):
return None
entry = next((e for e in _build_available_shortlist(settings) if e["id"] == server_name), None)
# The hot-path hands us a sanitized slug ("google-workspace"); curated ids are display names
# ("Google Workspace"). Match on the slug of both sides so neither form is a load-bearing string.
slug = _sanitize_server_name(server_name)
entry = next(
(e for e in _build_available_shortlist(settings) if _sanitize_server_name(e["id"]) == slug),
None,
)
if entry is None:
return None
return {"id": entry["id"], "title": entry["title"], "description": entry["description"], "reason": ""}
+8 -5
View File
@@ -22,13 +22,16 @@ def _settings(dismissed=None):
return SimpleNamespace(dismissed_mcp_suggestions=dismissed or {})
def test_offer_only_returns_vetted_inactive(monkeypatch):
def test_offer_resolves_both_display_name_and_hotpath_slug(monkeypatch):
# The hot-path passes a sanitized slug ("google-workspace"); the curated id is a display
# name ("Google Workspace"). Both must resolve, so the wiring isn't a load-bearing string.
monkeypatch.setattr(pf, "load_all_tools", lambda: []) # nothing enabled
s = _settings()
o = offer_for_gated_server("Google Workspace", s)
assert o is not None
assert o["id"] == "Google Workspace"
assert o["id"] in VETTED
for name in ("Google Workspace", "google-workspace"):
o = offer_for_gated_server(name, s)
assert o is not None, f"{name!r} should resolve to the vetted entry"
assert o["id"] == "Google Workspace"
assert o["id"] in VETTED
def test_offer_rejects_unvetted_and_empty(monkeypatch):