mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 19:27:45 +02:00
[eric] phase2: wire in-task MCP connect offer into the ToolSearch loop-breaker (suggest-only, slug-matched)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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": ""}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user