[eric] phase2: fire connect offer on MCPSearch/MCPList (not just loop), 8s preflight, render below the reply

This commit is contained in:
ciregenz
2026-06-17 00:30:42 -07:00
parent fc6d53b492
commit 8c93d42226
4 changed files with 143 additions and 78 deletions
+24
View File
@@ -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(
+5 -3
View File
@@ -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:
+36
View File
@@ -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"] == []
+78 -75
View File
@@ -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') && (