diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 96746f02..d4265849 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -9,9 +9,15 @@ from fastapi.responses import JSONResponse import asyncio import json import logging +import time logger = logging.getLogger(__name__) +# Soft per-session throttle so the integration suggestion can fire on any turn +# without nagging every message; only stamped when a suggestion actually emits. +MCP_SUGGEST_COOLDOWN_S = 300.0 +p_mcp_suggest_cooldown: dict[str, float] = {} + # Dedup concurrent generate-group-meta calls; collapses the 429 thundering herd by sharing one upstream Future per (session, group). _group_meta_inflight: dict[tuple[str, str], asyncio.Future] = {} @@ -74,24 +80,26 @@ async def send_message(session_id: str, body: dict): raise HTTPException(status_code=400, detail="prompt is required") # Run MCP-suggestion classifier in parallel with the agent launch; fails open. + # Fires on any turn, but a per-session cooldown keeps it from nagging every message. try: - from backend.apps.agents.core.mcp_preflight import run_preflight - from backend.apps.agents.core.ws_manager import ws_manager as _ws + last_suggested = p_mcp_suggest_cooldown.get(session_id, 0.0) + if time.monotonic() - last_suggested >= MCP_SUGGEST_COOLDOWN_S: + from backend.apps.agents.core.mcp_preflight import run_preflight - async def _emit_preflight(): - try: - result = await run_preflight(prompt, task_id=session_id) - if result.get("suggestions") or result.get("is_vague"): - await _ws.send_to_session(session_id, "agent:mcp_suggestions", { - "session_id": session_id, - "suggestions": result.get("suggestions", []), - "is_vague": bool(result.get("is_vague")), - }) - except Exception: - pass + async def _emit_preflight(): + try: + result = await run_preflight(prompt, task_id=session_id) + if result.get("suggestions"): + p_mcp_suggest_cooldown[session_id] = time.monotonic() + await ws_manager.send_to_session(session_id, "agent:mcp_suggestions", { + "session_id": session_id, + "suggestions": result.get("suggestions", []), + "is_vague": bool(result.get("is_vague")), + }) + except Exception: + pass - import asyncio as _asyncio - _asyncio.create_task(_emit_preflight()) + asyncio.create_task(_emit_preflight()) except Exception: pass diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index bd501e1f..7c8294ef 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -3,6 +3,7 @@ import os import tempfile import time import logging +from datetime import datetime, timezone from contextlib import asynccontextmanager from fastapi import HTTPException, Query, UploadFile, File from fastapi.responses import JSONResponse @@ -292,6 +293,21 @@ async def put_app_theme_override(body: AppThemeOverridePayload): return {"ok": True, "mode": current.app_template_theme_override} +class DismissMcpSuggestionPayload(BaseModel): + ids: list[str] + + +@settings.router.put("/dismiss-mcp-suggestion") +async def put_dismiss_mcp_suggestion(body: DismissMcpSuggestionPayload): + """MERGE dismissed integration suggestions; the general PUT replaces the whole object and would blank secrets.""" + current = load_settings() + now = datetime.now(timezone.utc).isoformat() + for tool_id in body.ids: + current.dismissed_mcp_suggestions[tool_id] = now + await save_settings_async(current) + return {"ok": True, "settings": current.model_dump()} + + @settings.router.get("/default-system-prompt") async def get_default_system_prompt(): return {"default_system_prompt": DEFAULT_SYSTEM_PROMPT} diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 0cff5c47..477e850f 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -6,6 +6,7 @@ import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; import TextField from '@mui/material/TextField'; import ClickAwayListener from '@mui/material/ClickAwayListener'; +import Fade from '@mui/material/Fade'; import CloseIcon from '@mui/icons-material/Close'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; @@ -17,7 +18,7 @@ import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { friendlyStatusLabel } from '@/shared/statusLabel'; -import { openSettingsModal } from '@/shared/state/settingsSlice'; +import { openSettingsModal, dismissMcpSuggestion } from '@/shared/state/settingsSlice'; import { API_BASE, getAuthToken } from '@/shared/config'; import { sendMessage as sendMessageThunk, @@ -342,6 +343,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const [preSendActivityLabel, setPreSendActivityLabel] = useState(null); const [activatingMcp, setActivatingMcp] = useState(null); const [activateError, setActivateError] = useState(null); + // Holds the last non-empty suggestions so the docked banner's exit fade renders + // them instead of going blank the instant the array is cleared. + const mcpSnapshotRef = useRef>([]); const [mode, setMode] = useState('agent'); const [model, setModel] = useState('sonnet'); @@ -1588,123 +1592,6 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }} > - {(session.mcp_suggestions && session.mcp_suggestions.length > 0) && ( - - id && dispatch(clearMcpSuggestions({ sessionId: id }))} - sx={{ - position: 'absolute', - top: 6, - right: 8, - width: 20, - height: 20, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: '1rem', - lineHeight: 1, - color: c.text.muted, - cursor: 'pointer', - borderRadius: 0.75, - '&:hover': { color: c.text.primary, bgcolor: c.bg.elevated }, - }} - > - × - - - Looks like this might need an integration - - - Activating one of these will let the agent answer in a single round-trip. - - - {session.mcp_suggestions.map((s) => ( - - - - {s.title} - - {s.reason && ( - - {s.reason} - - )} - - { - 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 straight to Actions - // so the user can finish OAuth. Nothing here - // can do it on their behalf. - navigate('/actions'); - } else if (id) { - // Activation succeeded; clear the banner so the user - // gets visual confirmation the click did something. - dispatch(clearMcpSuggestions({ sessionId: id })); - } - } catch (e: any) { - setActivateError(e?.message || 'Activation failed'); - } finally { - setActivatingMcp(null); - } - }} - sx={{ - cursor: activatingMcp === s.id ? 'wait' : 'pointer', - border: `1px solid ${c.border.medium}`, - borderRadius: 1, - px: 1.25, - py: 0.5, - bgcolor: 'transparent', - color: c.text.primary, - opacity: activatingMcp === s.id ? 0.5 : 1, - '&:hover': { bgcolor: activatingMcp ? 'transparent' : c.bg.elevated }, - flexShrink: 0, - }} - > - {activatingMcp === s.id ? 'Activating…' : 'Activate'} - - - ))} - - {activateError && ( - - {activateError} - - )} - - )} {session.context_overflow && (() => { const reason = session.context_overflow.reason; const isAuth = reason === 'openswarm_pro_auth_expired' || reason === 'anthropic_auth_invalid' || reason === 'auth_error'; @@ -2206,6 +2093,134 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose ); })()} + {(() => { + const list = session.mcp_suggestions ?? []; + if (list.length) mcpSnapshotRef.current = list; + const display = mcpSnapshotRef.current; + return ( + 0} timeout={{ enter: 200, exit: 220 }} unmountOnExit> + + { + if (!id) return; + dispatch(clearMcpSuggestions({ sessionId: id })); + dispatch(dismissMcpSuggestion(display.map((s) => s.id))); + }} + sx={{ + position: 'absolute', + top: 6, + right: 8, + width: 20, + height: 20, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '1rem', + lineHeight: 1, + color: c.text.muted, + cursor: 'pointer', + borderRadius: 0.75, + '&:hover': { color: c.text.primary, bgcolor: c.bg.elevated }, + }} + > + × + + + Looks like this might need an integration + + + Activating one of these will let the agent answer in a single round-trip. + + + {display.map((s) => ( + + + + {s.title} + + {s.reason && ( + + {s.reason} + + )} + + { + 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 straight to Actions + // so the user can finish OAuth. Nothing here + // can do it on their behalf. + navigate('/actions'); + } else if (id) { + // Activation succeeded; clear the banner so the user + // gets visual confirmation the click did something. + dispatch(clearMcpSuggestions({ sessionId: id })); + } + } catch (e: any) { + setActivateError(e?.message || 'Activation failed'); + } finally { + setActivatingMcp(null); + } + }} + sx={{ + cursor: activatingMcp === s.id ? 'wait' : 'pointer', + border: `1px solid ${c.border.medium}`, + borderRadius: 1, + px: 1.25, + py: 0.5, + bgcolor: 'transparent', + color: c.text.primary, + opacity: activatingMcp === s.id ? 0.5 : 1, + '&:hover': { bgcolor: activatingMcp ? 'transparent' : c.bg.elevated }, + flexShrink: 0, + }} + > + {activatingMcp === s.id ? 'Activating…' : 'Activate'} + + + ))} + + {activateError && ( + + {activateError} + + )} + + + ); + })()} {isStoppableSidecar ? ( ) : ( diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 90518f83..b951d7fd 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -168,6 +168,19 @@ export const resetSystemPrompt = createAsyncThunk( } ); +export const dismissMcpSuggestion = createAsyncThunk( + 'settings/dismissMcpSuggestion', + async (ids: string[]) => { + const res = await fetch(`${SETTINGS_API}/dismiss-mcp-suggestion`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }); + const data = await res.json(); + return data.settings as AppSettings; + } +); + export const browseDirectories = createAsyncThunk( 'settings/browseDirectories', async (path: string) => { @@ -298,6 +311,10 @@ const settingsSlice = createSlice({ state.data = action.payload; state.draft = null; state.draftTab = null; + }) + .addCase(dismissMcpSuggestion.fulfilled, (state, action) => { + state.latestWriteId = action.meta.requestId; + state.data = action.payload; }); }, });