mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[eric] phase2: fire connect offer on MCPSearch/MCPList (not just loop), 8s preflight, render below the reply
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -1526,81 +1526,6 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
{(session.mcp_suggestions && session.mcp_suggestions.length > 0) && (
|
||||
<Box sx={{ mt: 1, mb: 1, px: 0.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{session.mcp_suggestions.map((s) => (
|
||||
<Box key={s.id} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: c.text.secondary, flex: 1, minWidth: 0 }}>
|
||||
Connect{' '}
|
||||
<Box component="span" sx={{ color: c.text.primary, fontWeight: 500 }}>{s.title}</Box>
|
||||
{' '}so the agent can do this
|
||||
</Typography>
|
||||
<Typography
|
||||
component="button"
|
||||
variant="caption"
|
||||
disabled={activatingMcp === s.id}
|
||||
onClick={async () => {
|
||||
if (activatingMcp) return;
|
||||
setActivateError(null);
|
||||
setActivatingMcp(s.id);
|
||||
try {
|
||||
const headers: Record<string, string> = { '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'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{activateError && (
|
||||
<Typography variant="caption" sx={{ display: 'block', color: c.status.error }}>
|
||||
{activateError}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => id && dispatch(clearMcpSuggestions({ sessionId: id }))}
|
||||
sx={{ alignSelf: 'flex-start', color: c.text.muted, cursor: 'pointer', fontSize: '0.72rem', '&:hover': { color: c.text.secondary } }}
|
||||
>
|
||||
Dismiss
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{/* 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) && (
|
||||
<Box sx={{ mt: 1, mb: 1, px: 0.5, display: 'flex', flexDirection: 'column', gap: 0.5, overflowAnchor: 'none' }}>
|
||||
{session.mcp_suggestions.map((s) => (
|
||||
<Box key={s.id} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: c.text.secondary, flex: 1, minWidth: 0 }}>
|
||||
Connect{' '}
|
||||
<Box component="span" sx={{ color: c.text.primary, fontWeight: 500 }}>{s.title}</Box>
|
||||
{' '}so the agent can do this
|
||||
</Typography>
|
||||
<Typography
|
||||
component="button"
|
||||
variant="caption"
|
||||
disabled={activatingMcp === s.id}
|
||||
onClick={async () => {
|
||||
if (activatingMcp) return;
|
||||
setActivateError(null);
|
||||
setActivatingMcp(s.id);
|
||||
try {
|
||||
const headers: Record<string, string> = { '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'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{activateError && (
|
||||
<Typography variant="caption" sx={{ display: 'block', color: c.status.error }}>
|
||||
{activateError}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => id && dispatch(clearMcpSuggestions({ sessionId: id }))}
|
||||
sx={{ alignSelf: 'flex-start', color: c.text.muted, cursor: 'pointer', fontSize: '0.72rem', '&:hover': { color: c.text.secondary } }}
|
||||
>
|
||||
Dismiss
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{/* 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') && (
|
||||
|
||||
Reference in New Issue
Block a user