[eric] phase 1: MCP activation gate, context pill, friendly 429s, -api routes so API keys actually work

This commit is contained in:
ciregenz
2026-04-28 19:01:07 -04:00
parent 24dbd999ac
commit 30d581e9ec
11 changed files with 1143 additions and 52 deletions
+382 -29
View File
@@ -70,7 +70,11 @@ _TRANSIENT_CAPACITY_PATTERNS = re.compile(
)
# Patterns that look rate-limit-ish but are actually non-transient (user quota,
# auth). Must NOT retry — upgrading or reauthing is required.
# auth, context-window tier gate). Must NOT retry — upgrading, reauthing, or
# trimming context is required. The long-context-required variant is what
# Anthropic returns when an OAuth Pro/Max account ships a request whose input
# exceeds the 200K standard tier and would need the "extra usage" tier; the
# user can't recover by waiting, so we surface it instead of looping.
_NON_TRANSIENT_PATTERNS = re.compile(
r"(?:usage\s+cap\s+exceeded"
r"|reached\s+your\s+OpenSwarm.*plan\s+limit"
@@ -78,11 +82,54 @@ _NON_TRANSIENT_PATTERNS = re.compile(
r"|subscription\s+(?:canceled|past_due)"
r"|invalid.*token"
r"|missing\s+bearer\s+token"
r"|extra\s+usage\s+is\s+required\s+for\s+long\s+context"
r"|long\s+context\s+(?:requests?\s+)?(?:requires?|not\s+(?:available|enabled))"
r"|401|403)",
re.IGNORECASE,
)
def _is_long_context_error(exc: BaseException, extra_text: str = "") -> bool:
"""True when the upstream error is the 'long context tier required' 429.
Used by the catch-all error path to emit a friendly context-overflow
event instead of a generic system-error message.
"""
combined = f"{exc!s}\n{extra_text}".strip()
if not combined:
return False
return bool(re.search(
r"extra\s+usage\s+is\s+required\s+for\s+long\s+context"
r"|long\s+context\s+(?:requests?\s+)?(?:requires?|not\s+(?:available|enabled))",
combined,
re.IGNORECASE,
))
def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
"""True when the upstream error is a 401/403 auth failure.
Used by the catch-all error path to surface a friendly "subscription
expired / reconnect" card instead of dumping the raw 401 JSON. The most
common cause: the OpenSwarm Pro bearer or 9Router OAuth token has expired
while the UI still shows the connection as 'connected'.
"""
combined = f"{exc!s}\n{extra_text}".strip()
if not combined:
return False
return bool(re.search(
r"\b(401|403)\b"
r"|invalid\s+authentication\s+credentials"
r"|invalid.*api[_\s-]?key"
r"|missing\s+bearer\s+token"
r"|unauthori[sz]ed"
r"|no\s+credentials\s+for\s+provider"
r"|provider\s+not\s+(?:configured|connected|authorized)",
combined,
re.IGNORECASE,
))
def _is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
# The Claude CLI's underlying ProcessError stringifies to a generic
# "Command failed with exit code 1 / Check stderr output for details" —
@@ -267,15 +314,39 @@ class AgentManager:
return tools, mode_def.system_prompt, mode_def.default_folder
return get_all_tool_names(), None, None
async def _build_mcp_servers(self, allowed_tools: list[str]) -> dict:
async def _build_mcp_servers(
self,
allowed_tools: list[str],
active_mcps: list[str] | None = None,
) -> dict:
"""Build the mcp_servers dict for ClaudeAgentOptions from installed MCP tools.
Filtering is two-stage:
1. allowed_tools (mode/session permission) — same as before.
2. active_mcps (per-session activation gate) — NEW. When this list is
provided (non-None), only MCP servers whose sanitized name appears
in it are forwarded to the SDK. Empty list means zero MCPs ship.
None means legacy / non-gated path (used by sessions created
before the gate existed, where active_mcps was implicit-all).
The activation gate is the dispatch-layer enforcement of the product
invariant "all MCP actions only via ToolSearch": the model can only
reach an MCP server's tools if the user has approved MCPActivate for
that server, which appends to session.active_mcps. The model cannot
bypass this by ignoring prompt instructions — the SDK simply receives
no MCP definition for unactivated servers.
Servers whose every sub-tool is denied are skipped entirely.
"""
mcp_servers: dict = {}
all_tools = load_all_tools()
mcp_tools = [t for t in all_tools if t.mcp_config and t.enabled and t.auth_status in ("configured", "connected")]
logger.info(f"[MCP-DEBUG] Building MCP servers. {len(mcp_tools)} MCP tools found, allowed_tools has {len(allowed_tools)} entries")
active_set = set(active_mcps) if active_mcps is not None else None
logger.info(
f"[MCP-DEBUG] Building MCP servers. {len(mcp_tools)} MCP tools found, "
f"allowed_tools has {len(allowed_tools)} entries, "
f"active_mcps={'<unset/all>' if active_set is None else sorted(active_set)}"
)
for tool in mcp_tools:
tool_ref = f"mcp:{tool.name}"
@@ -284,6 +355,11 @@ class AgentManager:
logger.info(f"[MCP-DEBUG] SKIPPED {tool.name}: '{tool_ref}' not in allowed_tools")
continue
server_name = _sanitize_server_name(tool.name)
if active_set is not None and server_name not in active_set:
logger.info(f"[MCP-DEBUG] GATED {server_name}: not in session.active_mcps — model must call MCPActivate first")
continue
if _is_fully_denied(tool):
logger.info(f"[MCP-DEBUG] SKIPPED {tool.name}: fully denied")
continue
@@ -302,7 +378,6 @@ class AgentManager:
config = derive_mcp_config(tool)
if config:
server_name = _sanitize_server_name(tool.name)
mcp_servers[server_name] = config
env_keys = list(config.get("env", {}).keys())
logger.info(f"[MCP-DEBUG] ADDED {server_name}: command={config.get('command')}, args={config.get('args')}, env_keys={env_keys}")
@@ -393,27 +468,31 @@ class AgentManager:
)
def _build_outputs_context(self) -> str | None:
"""Build a context block describing available Outputs the agent can render."""
import json as _json
"""One-line index of available Outputs.
Previously dumped each Output's full json.dumps(input_schema) every
turn, which grew without bound and was the dominant non-MCP source of
per-request bloat. Now we emit a compact index (name + id +
description) plus a hint that full schemas are fetched on demand by
RenderOutput. This drops typical 5-Output context from ~6KB to ~400B,
and a 30-Output context from ~30KB to ~2KB.
"""
all_outputs = load_all_outputs()
if not all_outputs:
return None
sections = []
lines = []
for out in all_outputs:
lines = [f"- **{out.name}** (id: `{out.id}`)"]
if out.description:
lines.append(f" Description: {out.description}")
schema_str = _json.dumps(out.input_schema, indent=2)
lines.append(f" Input schema:\n```json\n{schema_str}\n```")
sections.append("\n".join(lines))
desc = f"{out.description}" if out.description else ""
lines.append(f"- `{out.id}` **{out.name}**{desc}")
return (
"<available_views>\n"
"The following reusable View artifacts are available. "
"Use the RenderOutput tool to invoke one by providing its output_id "
"and the required input_data matching its schema.\n\n"
+ "\n\n".join(sections)
"The following reusable View artifacts are available. Pass the "
"output_id below to RenderOutput along with input_data that matches "
"the View's schema. RenderOutput will surface schema validation "
"errors if the input shape is wrong.\n\n"
+ "\n".join(lines)
+ "\n</available_views>"
)
@@ -485,8 +564,71 @@ class AgentManager:
browser_cards = raw.get("layout", {}).get("browser_cards", {})
return [card.get("browser_id", "") for card in browser_cards.values() if card.get("browser_id")]
def _compose_system_prompt(self, default_prompt: str | None, mode_prompt: str | None, session_prompt: str | None, connected_tools_ctx: str | None = None, outputs_ctx: str | None = None, browser_ctx: str | None = None) -> str | None:
parts = [p for p in (default_prompt, mode_prompt, session_prompt, connected_tools_ctx, outputs_ctx, browser_ctx) if p]
def _build_mcp_registry_summary(self, allowed_tools: list[str], active_mcps: list[str]) -> str | None:
"""Compact registry of installed MCP servers — one line per server.
This is the visible surface that drives the activation gate: the model
sees which servers exist and what they're for, but cannot call any
unactivated server's tools (the dispatch-layer filter in
_build_mcp_servers blocks that). To use a server, the model must call
MCPSearch (to find the right one) and then MCPActivate, which fires a
HITL prompt; on approve, the server's tools become callable next turn.
Schemas are NOT included here — that's the whole point. A 30-server
registry costs ~1KB; the previous full-schema dump cost ~30-80KB.
"""
all_tools = load_all_tools()
mcp_tools = [
t for t in all_tools
if t.mcp_config and t.enabled and t.auth_status in ("configured", "connected")
]
if not mcp_tools:
return None
active_set = set(active_mcps or [])
active_lines: list[str] = []
available_lines: list[str] = []
for tool in mcp_tools:
tool_ref = f"mcp:{tool.name}"
if tool_ref not in allowed_tools and allowed_tools != get_all_tool_names():
continue
if _is_fully_denied(tool):
continue
server_name = _sanitize_server_name(tool.name)
desc = (getattr(tool, "description", None) or "").strip()
if not desc:
# Fall back to a generic blurb keyed on the tool name so the
# model still has *some* signal to MCPSearch against.
desc = f"{tool.name} integration"
line = f"- `{server_name}` — {desc}"
if server_name in active_set:
active_lines.append(line)
else:
available_lines.append(line)
if not active_lines and not available_lines:
return None
sections = ["<mcp_servers>"]
sections.append(
"MCP servers are gated: the model cannot call any MCP tool until "
"the user approves an MCPActivate request for that server. To use "
"a server below, first call MCPSearch (to confirm the right server "
"for the task), then call MCPActivate(server_name) — the user will "
"be prompted to approve activation. After approval, the server's "
"tools (`mcp__<server>__<tool>`) become callable on the next turn."
)
if active_lines:
sections.append("\nActive (already approved this session — tools callable now):")
sections.extend(active_lines)
if available_lines:
sections.append("\nAvailable (installed but not yet activated):")
sections.extend(available_lines)
sections.append("</mcp_servers>")
return "\n".join(sections)
def _compose_system_prompt(self, default_prompt: str | None, mode_prompt: str | None, session_prompt: str | None, connected_tools_ctx: str | None = None, outputs_ctx: str | None = None, browser_ctx: str | None = None, mcp_registry_ctx: str | None = None) -> str | None:
parts = [p for p in (default_prompt, mode_prompt, session_prompt, connected_tools_ctx, mcp_registry_ctx, outputs_ctx, browser_ctx) if p]
return "\n\n".join(parts) if parts else None
async def launch_agent(self, config: AgentConfig) -> AgentSession:
@@ -1053,15 +1195,28 @@ class AgentManager:
connected_tools_ctx = None
outputs_ctx = self._build_outputs_context()
browser_ctx = self._build_browser_context(session.dashboard_id, selected_browser_ids=selected_browser_ids)
mcp_registry_ctx = self._build_mcp_registry_summary(session.allowed_tools, session.active_mcps)
global_settings = load_settings()
composed_prompt = self._compose_system_prompt(global_settings.default_system_prompt, mode_sys_prompt, session.system_prompt, connected_tools_ctx, outputs_ctx, browser_ctx)
composed_prompt = self._compose_system_prompt(
global_settings.default_system_prompt,
mode_sys_prompt,
session.system_prompt,
connected_tools_ctx,
outputs_ctx,
browser_ctx,
mcp_registry_ctx,
)
if session.mode == "view-builder":
from backend.apps.outputs.view_builder_templates import VIEW_BUILDER_SKILL
skill_block = f"<app_builder_reference>\n{VIEW_BUILDER_SKILL}\n</app_builder_reference>"
composed_prompt = f"{composed_prompt}\n\n{skill_block}" if composed_prompt else skill_block
mcp_servers = await self._build_mcp_servers(session.allowed_tools)
# Pass session.active_mcps as the activation filter. Empty list ⇒
# no MCP tools shipped to the SDK; the model must MCPSearch and
# MCPActivate first. The product invariant lives here at the
# dispatch layer (see _build_mcp_servers docstring).
mcp_servers = await self._build_mcp_servers(session.allowed_tools, session.active_mcps)
_browser_delegation_tools = ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents"]
_browser_all_denied = all(
@@ -1115,6 +1270,26 @@ class AgentManager:
"type": "stdio",
}
# Always-on meta-MCP server. Exposes MCPList / MCPSearch /
# MCPActivate so the model can discover and activate user MCPs at
# runtime. The activation gate (active_mcps filter in
# _build_mcp_servers above) ensures the model cannot reach any
# other MCP server's tools without going through this layer first.
mcp_meta_server_path = os.path.join(
os.path.dirname(__file__), "mcp_meta_server.py"
)
from backend.auth import get_auth_token as _get_auth_token3
mcp_servers["openswarm-mcp-meta"] = {
"command": sys.executable,
"args": [mcp_meta_server_path],
"env": {
"OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
"OPENSWARM_AUTH_TOKEN": _get_auth_token3(),
"OPENSWARM_PARENT_SESSION_ID": session.id,
},
"type": "stdio",
}
# -----------------------------------------------------------------
# openswarm-web MCP — DDG search + trafilatura fetch
# -----------------------------------------------------------------
@@ -1356,7 +1531,71 @@ class AgentManager:
# connection_mode is openswarm-pro.
from backend.apps.nine_router import is_running as _9r_running
resolved_is_9router = isinstance(resolved_model, str) and resolved_model.startswith(("cc/", "cx/", "gc/", "ag/", "gemini/"))
if api_type == "anthropic" and not resolved_is_9router and getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro":
# `route="api"` overrides every routing decision below: this is
# the user's pinned-API-key path. Bypasses the OpenSwarm Pro
# proxy AND 9Router by pointing the CLI directly at the
# provider's API host with the user's per-provider key. The
# picker only emits these variants when the matching key is
# set, so the env vars below are always populated when this
# branch fires.
#
# Per-provider env recipes:
# - Anthropic: ANTHROPIC_API_KEY + ANTHROPIC_BASE_URL=api.anthropic.com
# - OpenAI: OPENAI_API_KEY + OPENAI_BASE_URL=api.openai.com/v1
# - Gemini: GEMINI_API_KEY + (no base_url override; SDK default)
from backend.apps.agents.providers.registry import _find_builtin_model
_model_entry = _find_builtin_model(session.model)
_is_pinned_api_route = (
_model_entry is not None
and _model_entry.get("route") == "api"
)
_api_route_provider = (_model_entry or {}).get("api") if _is_pinned_api_route else None
if _is_pinned_api_route and _api_route_provider == "anthropic" and getattr(global_settings, "anthropic_api_key", None):
options_kwargs["env"] = {
"ANTHROPIC_API_KEY": global_settings.anthropic_api_key,
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
# Subagents + small-fast spawn fresh CLI processes that
# inherit env. Pin them so they also take the API-key
# path and don't accidentally fall back to the proxy.
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-6",
"ANTHROPIC_SMALL_FAST_MODEL": "claude-haiku-4-5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5",
}
logger.info(f"[MCP-DEBUG] Using direct Anthropic API key (route=api) for {session.model}")
elif _is_pinned_api_route and _api_route_provider == "openai" and getattr(global_settings, "openai_api_key", None):
# OpenAI direct path. The Claude CLI doesn't speak OpenAI
# natively, so we still need an Anthropic-compatible relay.
# Easiest: keep the local anthropic_proxy in front so it
# translates Claude-format requests to OpenAI's API. The
# proxy already routes by model id; for OpenAI -api models
# we set OPENAI_API_KEY in env so the proxy's OpenAI
# adapter (added implicitly via 9Router's openai-to-claude
# translator running at localhost:20128) picks it up.
options_kwargs["env"] = {
"OPENAI_API_KEY": global_settings.openai_api_key,
"OPENAI_BASE_URL": "https://api.openai.com/v1",
# Route through 9Router which knows how to translate
# Claude-format → OpenAI; with OPENAI_API_KEY set on
# the spawn env, 9Router uses the user's key directly
# rather than its subscription lane.
"ANTHROPIC_API_KEY": "9router",
"ANTHROPIC_BASE_URL": "http://localhost:20128",
}
logger.info(f"[MCP-DEBUG] Using direct OpenAI API key (route=api) for {session.model}")
elif _is_pinned_api_route and _api_route_provider == "gemini" and getattr(global_settings, "google_api_key", None):
# Google AI Studio direct path. Same translator-relay
# pattern as OpenAI. 9Router's Gemini adapter picks up
# GEMINI_API_KEY / GOOGLE_API_KEY from spawn env when set.
options_kwargs["env"] = {
"GEMINI_API_KEY": global_settings.google_api_key,
"GOOGLE_API_KEY": global_settings.google_api_key,
"ANTHROPIC_API_KEY": "9router",
"ANTHROPIC_BASE_URL": "http://localhost:20128",
}
logger.info(f"[MCP-DEBUG] Using direct Google API key (route=api) for {session.model}")
elif api_type == "anthropic" and not resolved_is_9router and getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro":
proxy_url = getattr(global_settings, "openswarm_proxy_url", None) or "https://api.openswarm.com"
bearer = getattr(global_settings, "openswarm_bearer_token", "") or ""
options_kwargs["env"] = {
@@ -1824,8 +2063,30 @@ class AgentManager:
out = usage.get("output_tokens", 0) or 0
cache_create = usage.get("cache_creation_input_tokens", 0) or 0
cache_read = usage.get("cache_read_input_tokens", 0) or 0
session.tokens["input"] = inp + cache_create + cache_read
total_input = inp + cache_create + cache_read
session.tokens["input"] = total_input
session.tokens["output"] = out
# Per-turn context-usage broadcast. Drives the UI
# status pill, the auto-compact threshold (Phase 2),
# and is the user's only honest signal that they're
# approaching the context cap. 200K is the standard-
# tier ceiling Anthropic returns the
# long-context-required 429 against; it's also the
# right denominator for OAuth Pro/Max users.
ctx_used_pct = round(total_input / 200_000.0, 4) if total_input else 0.0
cache_read_pct = round(cache_read / total_input, 4) if total_input else 0.0
try:
await ws_manager.send_to_session(session_id, "agent:context_update", {
"session_id": session_id,
"input_tokens": total_input,
"output_tokens": out,
"cache_read_tokens": cache_read,
"cache_read_pct": cache_read_pct,
"ctx_used_pct": ctx_used_pct,
"active_mcps": list(session.active_mcps),
})
except Exception:
logger.exception("Failed to emit agent:context_update")
capacity_retry_attempt = 0
while True:
@@ -1890,12 +2151,104 @@ class AgentManager:
"provider": session.provider,
"mode": session.mode,
}, session_id=session_id, dashboard_id=session.dashboard_id)
error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id)
session.messages.append(error_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": error_msg.model_dump(mode="json"),
})
# Long-context-required 429 fork: surface a friendly overflow event
# so the frontend can render an actionable card ("Switch to Chat
# mode" / "Start a fresh chat") instead of a raw error blob. The
# user can't recover by waiting — this is a tier-gate, not a rate
# limit — so the UX matters.
try:
_stderr_tail = "\n".join(_stderr_buffer[-50:])
except Exception:
_stderr_tail = ""
if _is_long_context_error(e, extra_text=_stderr_tail):
friendly_msg = (
"This conversation has grown too large for your account's "
"standard context window. Long-context requests require an "
"upgraded tier — switch to Chat mode or start a fresh chat "
"to continue."
)
error_msg = Message(role="system", content=friendly_msg, branch_id=session.active_branch_id)
session.messages.append(error_msg)
await ws_manager.send_to_session(session_id, "agent:context_overflow", {
"session_id": session_id,
"reason": "long_context_required",
"message": friendly_msg,
"input_tokens": session.tokens.get("input", 0),
"active_mcps": list(session.active_mcps),
})
_analytics("context.overflow_blocked", {
"input_tokens": session.tokens.get("input", 0),
"active_mcps_count": len(session.active_mcps),
"model": session.model,
}, session_id=session_id, dashboard_id=session.dashboard_id)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": error_msg.model_dump(mode="json"),
})
elif _is_auth_error(e, extra_text=_stderr_tail):
# Three sub-cases the user can hit, with distinct fixes:
# 1. "No credentials for provider: claude" — user picked a
# -cc route but doesn't have Claude Pro/Max connected
# via 9Router. Tell them to either connect Claude
# Pro/Max OR pick a non--cc model.
# 2. OpenSwarm Pro 401 — bearer expired. Reconnect.
# 3. Anthropic API key 401 — wrong key. Re-enter.
_model = (session.model or "").lower()
_combined = f"{e!s}\n{_stderr_tail}".lower()
if "no credentials for provider" in _combined:
friendly_msg = (
"Selected route requires Claude Pro / Max, but it's "
"not connected. Open Settings → Models and either "
"connect Claude Pro / Max, or switch the model to a "
"non-`-cc` variant (e.g. Claude Sonnet 4.6 instead "
"of Sonnet 4.6 -cc)."
)
reason = "claude_sub_not_connected"
elif (
"-cc" not in _model
and getattr(load_settings(), "connection_mode", "own_key") == "openswarm-pro"
):
friendly_msg = (
"OpenSwarm Pro authentication failed. Your subscription "
"token may have expired even though the connection still "
"shows green. Open Settings → Models and click "
"Disconnect / Reconnect on Claude Pro / Max to refresh "
"the token."
)
reason = "openswarm_pro_auth_expired"
else:
friendly_msg = (
"Anthropic authentication failed. The API key or "
"subscription token for this model is invalid. Open "
"Settings → Models and re-enter the API key, or "
"reconnect Claude Pro / Max."
)
reason = "anthropic_auth_invalid"
error_msg = Message(role="system", content=friendly_msg, branch_id=session.active_branch_id)
session.messages.append(error_msg)
await ws_manager.send_to_session(session_id, "agent:auth_error", {
"session_id": session_id,
"reason": reason,
"message": friendly_msg,
"model": session.model,
})
_analytics("auth.error", {
"reason": reason,
"model": session.model,
"provider": session.provider,
}, session_id=session_id, dashboard_id=session.dashboard_id)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": error_msg.model_dump(mode="json"),
})
else:
error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id)
session.messages.append(error_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": error_msg.model_dump(mode="json"),
})
except BaseException as e:
# Catch BaseExceptionGroup from anyio task groups (e.g. concurrent
# CLI crash + pending approval cancellation) so it doesn't escape
+51 -9
View File
@@ -353,26 +353,68 @@ async def list_models():
# groups, with the Anthropic group using the pinned "-cc" variants so a
# per-call selection actually routes through 9Router instead of the proxy.
anthropic_models = BUILTIN_MODELS.get("Anthropic", [])
adaptive = [m for m in anthropic_models if m.get("route") != "cc"]
adaptive = [m for m in anthropic_models if m.get("route") not in ("cc", "api")]
cc_variants = [m for m in anthropic_models if m.get("route") == "cc"]
api_variants = [m for m in anthropic_models if m.get("route") == "api"]
if is_openswarm_pro and has_claude_sub:
result["OpenSwarm Pro"] = _serialize(adaptive)
result["Anthropic"] = _serialize(cc_variants)
elif is_openswarm_pro:
# Anthropic surface depends on which credentials are wired:
# - is_openswarm_pro + has_claude_sub: two groups. "OpenSwarm Pro" uses
# the unsuffixed values (proxy-routed); "Anthropic" uses the -cc
# variants which 9Router routes via the user's own claude
# subscription, bypassing the Pro proxy.
# - is_openswarm_pro + has_api_key only (no claude sub): the -cc route
# would fail with "No credentials for provider: claude" because
# 9Router has no claude provider node. The API key sits dormant
# while proxy mode is active — the user must switch connection_mode
# to own_key in Settings to use the key directly. We surface a
# one-line note instead of a broken-route group.
# - is_openswarm_pro alone: only the proxy-routed group.
# - has_api_key or has_claude_sub without proxy: single Anthropic group
# using adaptive values, which fall through to 9Router and use
# whichever creds are available.
notes: list[dict] = []
if is_openswarm_pro:
# Always show the OpenSwarm Pro group. Then layer on whatever
# alternate Anthropic credentials the user has (claude-sub via cc/,
# api-key via direct). Both variants live under "Anthropic" — the
# labels disambiguate ("(Pro/Max)" vs "(API key)").
result["OpenSwarm Pro"] = _serialize(adaptive)
anth_alternates: list[dict] = []
if has_claude_sub:
anth_alternates += cc_variants
if has_api_key:
anth_alternates += api_variants
if anth_alternates:
result["Anthropic"] = _serialize(anth_alternates)
elif has_api_key or has_claude_sub:
# Pure own_key mode. The adaptive route already uses the api_key
# (or falls through to 9Router for the claude sub) — no need for
# explicit -api / -cc variants in the picker.
result["Anthropic"] = _serialize(adaptive)
# Non-Anthropic providers (OpenAI, Google, etc.)
# visibility is gated by 9Router's connected providers set.
# Non-Anthropic providers (OpenAI, Google, etc.).
# Subscription-routed models (api=codex/gemini-cli/antigravity) are
# gated by 9Router's connected providers set.
# API-key-routed models (route="api", api=openai/gemini) are gated by
# the corresponding *_api_key being set in settings — same pattern as
# the Anthropic -api variants.
has_openai_key = bool(getattr(settings, "openai_api_key", None))
has_google_key = bool(getattr(settings, "google_api_key", None))
for provider_name, models in BUILTIN_MODELS.items():
if provider_name == "Anthropic":
continue
visible = []
for m in models:
api = m.get("api", "")
if m.get("subscription_only"):
route = m.get("route")
if route == "api":
# Direct API key path. Show only when the matching key is set.
if api == "openai" and not has_openai_key:
continue
if api == "gemini" and not has_google_key:
continue
elif m.get("subscription_only"):
# Subscription path. Show only when 9Router has the lane up.
if not nine_router_up or api not in connected:
continue
visible.append({
@@ -384,7 +426,7 @@ async def list_models():
if visible:
result[provider_name] = visible
return {"models": result}
return {"models": result, "notes": notes}
@agents.router.post("/subscriptions/disconnect")
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Stdio MCP server exposing the MCP activation gate.
Tools:
- MCPList: enumerate installed MCP servers (active + available).
- MCPSearch(query): rank servers by relevance to a free-form query.
- MCPActivate(server_name): activate a server for the rest of the session.
The activation gate is the dispatch-layer enforcement of the product invariant
"all MCP actions only via ToolSearch": the model can only reach an MCP server's
tools if the user has approved MCPActivate for that server, which appends to
session.active_mcps. _build_mcp_servers in agent_manager.py intersects connected
MCPs with that list before handing them to the SDK, so unactivated servers are
literally unreachable — the gate cannot be bypassed by ignoring prompt rules.
HITL: the model's invocation of MCPActivate goes through agent_manager's pre-
tool approval hook just like any other tool call — the user is prompted to
approve activation in the standard ApprovalBar UI. No separate HITL inside this
server.
"""
import json
import os
import sys
import urllib.error
import urllib.request
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/mcp-meta"
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
TOOLS = [
{
"name": "MCPList",
"description": (
"List the MCP servers installed on this machine. Returns one entry "
"per server with its name, one-sentence purpose, and current "
"activation status (active/available). Costs almost nothing — the "
"registry is a flat list, no schemas. Use this when you want a "
"broad survey before picking a server."
),
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": False,
},
},
{
"name": "MCPSearch",
"description": (
"Find MCP servers relevant to a query. Returns the top matches "
"ranked by description match against the query (e.g. 'email', "
"'calendar', 'spreadsheet'). Use this BEFORE MCPActivate when you "
"are not sure which server to enable. The server's tools are NOT "
"callable yet — you still need MCPActivate after picking one."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Free-form description of what you need (e.g. 'send email', 'read inbox', 'post to slack').",
},
},
"required": ["query"],
"additionalProperties": False,
},
},
{
"name": "MCPActivate",
"description": (
"Request activation of an MCP server for this session. The user is "
"prompted via the standard tool-approval UI; on approve, the "
"server's tools become callable on the NEXT turn (the current turn "
"ends after this call). On deny, the server stays unavailable and "
"you should ask the user how to proceed. Always call MCPSearch or "
"MCPList first to confirm the server name — invalid names return "
"a list of valid alternatives instead of activating."
),
"inputSchema": {
"type": "object",
"properties": {
"server_name": {
"type": "string",
"description": "Sanitized server name as returned by MCPList/MCPSearch (e.g. 'gmail', 'slack', 'discord').",
},
"reason": {
"type": "string",
"description": "One-sentence explanation of why you need this server, shown to the user in the approval prompt to help them decide.",
},
},
"required": ["server_name"],
"additionalProperties": False,
},
},
]
def send_response(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def call_backend(action: str, payload: dict) -> dict:
full = {**payload, "parent_session_id": PARENT_SESSION_ID}
body = json.dumps(full).encode()
headers = {"Content-Type": "application/json"}
if BACKEND_AUTH:
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
req = urllib.request.Request(
f"{BACKEND_URL}/{action}",
data=body,
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else str(e)
return {"error": f"HTTP {e.code}: {body}"}
except Exception as e:
return {"error": str(e)}
def format_servers(servers: list[dict], heading: str = "") -> str:
if not servers:
return ""
lines = []
if heading:
lines.append(heading)
for s in servers:
name = s.get("name", "")
desc = s.get("description") or f"{name} integration"
status = s.get("status", "available")
lines.append(f"- `{name}` [{status}] — {desc}")
return "\n".join(lines)
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
if tool_name == "MCPList":
result = call_backend("list", {})
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
active = result.get("active", [])
available = result.get("available", [])
if not active and not available:
return {"content": [{"type": "text", "text": "No MCP servers are installed. The user can install one from the Tools page."}]}
parts = []
if active:
parts.append(format_servers(active, "Active (callable now):"))
if available:
parts.append(format_servers(available, "Available (call MCPActivate to enable):"))
return {"content": [{"type": "text", "text": "\n\n".join(parts)}]}
if tool_name == "MCPSearch":
query = arguments.get("query", "")
if not query:
return {"content": [{"type": "text", "text": "Error: query is required"}], "isError": True}
result = call_backend("search", {"query": query})
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
matches = result.get("matches", [])
if not matches:
return {"content": [{"type": "text", "text": f"No MCP servers matched '{query}'. Try MCPList to see everything installed, or tell the user no suitable server is connected."}]}
body = format_servers(matches, f"Top matches for '{query}':")
body += "\n\nNext step: pick one and call MCPActivate(server_name) to request activation."
return {"content": [{"type": "text", "text": body}]}
if tool_name == "MCPActivate":
server_name = arguments.get("server_name", "")
reason = arguments.get("reason", "")
if not server_name:
return {"content": [{"type": "text", "text": "Error: server_name is required"}], "isError": True}
result = call_backend("activate", {"server_name": server_name, "reason": reason})
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
if result.get("status") == "unknown_server":
available = result.get("available", [])
return {
"content": [{
"type": "text",
"text": (
f"Unknown MCP server '{server_name}'. Valid options: "
+ ", ".join(f"`{s}`" for s in available)
+ ". Call MCPList for full descriptions."
),
}],
"isError": True,
}
if result.get("status") == "already_active":
return {"content": [{"type": "text", "text": f"`{server_name}` is already active for this session — its tools should be callable now."}]}
if result.get("status") == "activated":
return {
"content": [{
"type": "text",
"text": (
f"Activated `{server_name}`. Its tools (`mcp__{server_name}__*`) "
f"will be callable on the NEXT turn. End this turn now and the user's "
f"next message will see the new tools."
),
}],
}
return {"content": [{"type": "text", "text": f"Unexpected response: {json.dumps(result)}"}], "isError": True}
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
method = msg.get("method")
id_ = msg.get("id")
params = msg.get("params", {})
if method == "initialize":
send_response(id_, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "openswarm-mcp-meta",
"version": "1.0.0",
},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
send_response(id_, {"tools": TOOLS})
elif method == "tools/call":
tool_name = params.get("name", "")
arguments = params.get("arguments", {})
try:
result = handle_tool_call(tool_name, arguments)
send_response(id_, result)
except Exception as e:
send_response(id_, error={"code": -32000, "message": str(e)})
elif method == "resources/list":
send_response(id_, {"resources": []})
elif method == "prompts/list":
send_response(id_, {"prompts": []})
elif id_ is not None:
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
if __name__ == "__main__":
main()
+9
View File
@@ -77,6 +77,15 @@ class AgentSession(BaseModel):
browser_id: Optional[str] = None
parent_session_id: Optional[str] = None
needs_fork: bool = False
# Sanitized server names (matching tools_lib._sanitize_server_name) of MCP
# servers the model has explicitly activated this session via the
# MCPActivate meta-tool. Empty by default — the gate in
# _build_mcp_servers intersects connected MCPs with this list, so no
# MCP tool is callable until the model searches for and activates a
# server. The product invariant is that this is non-bypassable: the
# filter lives at the dispatch layer (mcp_servers passed to the SDK),
# not the prompt layer.
active_mcps: list[str] = Field(default_factory=list)
# How much the model should "think" before answering. Provider-agnostic
# value that gets translated per-API in agent_manager:
# off — no thinking
+49 -7
View File
@@ -75,17 +75,21 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
# BOTH openswarm-pro active AND the 9Router `claude` subscription
# connected — so the model picker can offer a per-call choice between
# the managed OpenSwarm proxy and their own Claude subscription.
{"value": "sonnet-cc", "label": "Claude Sonnet 4.6", "context_window": 1_000_000,
{"value": "sonnet-cc", "label": "Claude Sonnet 4.6 (Pro/Max)", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "cc/claude-sonnet-4-6", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "opus-cc", "label": "Claude Opus 4.6", "context_window": 1_000_000,
{"value": "opus-cc", "label": "Claude Opus 4.6 (Pro/Max)", "context_window": 1_000_000,
"model_id": "claude-opus-4-6", "router_model_id": "cc/claude-opus-4-6", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "haiku-cc", "label": "Claude Haiku 4.5", "context_window": 200_000,
{"value": "haiku-cc", "label": "Claude Haiku 4.5 (Pro/Max)", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "sonnet-api", "label": "Claude Sonnet 4.6 (API key)", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "claude-sonnet-4-6", "api": "anthropic", "reasoning": True, "route": "api"},
{"value": "opus-api", "label": "Claude Opus 4.6 (API key)", "context_window": 1_000_000,
"model_id": "claude-opus-4-6", "router_model_id": "claude-opus-4-6", "api": "anthropic", "reasoning": True, "route": "api"},
{"value": "haiku-api", "label": "Claude Haiku 4.5 (API key)", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "claude-haiku-4-5", "api": "anthropic", "reasoning": True, "route": "api"},
],
# OpenAI: ChatGPT Plus/Pro (Codex) subscription. gpt-5.4 is the
# current flagship — combines GPT-5.3 Codex coding capabilities with
# stronger reasoning, tool use, and agentic workflows.
# See: https://developers.openai.com/codex/models
"OpenAI": [
{"value": "gpt-5.4", "label": "GPT-5.4",
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.4",
@@ -96,6 +100,20 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
{"value": "gpt-5.3-codex", "label": "GPT-5.3 Codex",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex",
"api": "codex", "subscription_only": True, "reasoning": True},
# Pinned-API-key entries: bypass 9Router and call api.openai.com
# directly with openai_api_key. Model ids match what OpenAI's API
# accepts (no cx/ prefix). Surfaced when openai_api_key is set —
# gives a metered alternative to the ChatGPT-Plus subscription
# route. Same -api suffix convention as the Anthropic mirrors.
{"value": "gpt-5.4-api", "label": "GPT-5.4 (API key)",
"context_window": 1_000_000, "router_model_id": "gpt-5.4", "model_id": "gpt-5.4",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.4-mini-api", "label": "GPT-5.4 Mini (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.4-mini", "model_id": "gpt-5.4-mini",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.3-codex-api", "label": "GPT-5.3 Codex (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex", "model_id": "gpt-5.3-codex",
"api": "openai", "reasoning": True, "route": "api"},
],
# Google: Gemini via Gemini CLI subscription. Both 3.x (thinking-
# capable) and 2.5 (stable) are offered. Gemini 3 models have
@@ -119,6 +137,23 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
{"value": "gemini-2.5-flash", "label": "Gemini 2.5 Flash",
"context_window": 1_000_000, "router_model_id": "gc/gemini-2.5-flash",
"api": "gemini-cli", "subscription_only": True},
# Pinned-API-key entries for Google AI Studio (api="gemini"). Bypass
# both 9Router (which routes via Gemini CLI/Antigravity OAuth) and
# any subscription path; call generativelanguage.googleapis.com
# directly with google_api_key. Free-tier quota is generous (~1K
# requests/day) and lives separately from the OAuth lanes.
{"value": "gemini-3-pro-api", "label": "Gemini 3 Pro (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3-pro-preview", "model_id": "gemini-3-pro-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-3-flash-api", "label": "Gemini 3 Flash (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3-flash-preview", "model_id": "gemini-3-flash-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-2.5-pro-api", "label": "Gemini 2.5 Pro (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-2.5-pro", "model_id": "gemini-2.5-pro",
"api": "gemini", "route": "api"},
{"value": "gemini-2.5-flash-api", "label": "Gemini 2.5 Flash (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-2.5-flash", "model_id": "gemini-2.5-flash",
"api": "gemini", "route": "api"},
],
}
@@ -242,6 +277,13 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
# subscription even while openswarm-pro is the default Claude route.
if entry.get("route") == "cc":
return entry.get("router_model_id", entry.get("model_id", short_name))
# route="api" is the analogue for the user's direct Anthropic API key:
# bare model_id, and agent_manager will force the spawn env to point at
# api.anthropic.com with the api_key (skipping both the Pro proxy AND
# 9Router). This is what makes "use my API key" reachable even when
# connection_mode is openswarm-pro.
if entry.get("route") == "api":
return entry.get("model_id", short_name)
if entry.get("api") == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return entry.get("model_id", short_name)
+20
View File
@@ -41,6 +41,26 @@ class ModeUpdate(BaseModel):
BUILTIN_MODES: list[Mode] = [
Mode(
id="chat",
name="Chat",
description="Lightweight conversational mode. Minimal tools, no MCPs by default — perfect for quick greetings, questions, and ideation without spinning up the full agent.",
system_prompt=(
"You are in Chat mode — a lightweight conversational assistant. Keep "
"responses short and natural. You do NOT have file-editing or shell "
"access in this mode; if the user asks for something that requires "
"real work (writing code, running commands, hitting an MCP), tell "
"them to switch to Agent mode. If the user asks something that "
"requires an MCP server (email, calendar, etc.), you can use "
"MCPSearch and MCPActivate to bring it in — those are the only "
"external capabilities available to you here."
),
tools=["AskUserQuestion", "WebFetch", "WebSearch"],
default_next_mode=None,
is_builtin=True,
icon="chat_bubble",
color="#60a5fa",
),
Mode(
id="agent",
name="Agent",
+155
View File
@@ -337,6 +337,161 @@ async def browser_agent_run(request: Request):
return JSONResponse({"results": results})
@app.post("/api/mcp-meta/{action}")
async def mcp_meta(action: str, request: Request):
"""Back the openswarm-mcp-meta stdio MCP server.
Actions:
- list: enumerate installed MCPs, separated by active vs available.
- search: rank by description match against a query.
- activate: append to session.active_mcps + flag needs_fork=True so the
next turn rebuilds options with the newly-activated server. Validates
server_name against the canonical registry; unknown names return the
valid options instead of activating (anti-hallucination).
"""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.tools_lib.tools_lib import _load_all as load_all_tools, _sanitize_server_name
body = await request.json()
parent_session_id = body.get("parent_session_id", "")
# Aliases that broaden the search corpus for common user intents. Without
# these, MCPSearch("email") fails to surface Google Workspace because
# the tool's stored description says "Gmail" not "email". Keys are
# sanitized server names; values are extra search-hint tokens appended
# to the haystack. Only generic synonyms — anything that's already in
# the description doesn't need to be listed.
_SERVER_SEARCH_ALIASES: dict[str, list[str]] = {
"google-workspace": [
"email", "inbox", "mail", "gmail", "calendar", "schedule",
"events", "drive", "docs", "sheets", "spreadsheet", "slides",
"presentation",
],
"microsoft-365": [
"email", "inbox", "mail", "outlook", "calendar", "schedule",
"onedrive", "excel", "spreadsheet", "onenote", "teams",
"sharepoint", "tasks", "contacts",
],
"discord": ["chat", "message", "messaging", "server", "guild", "voice"],
"slack": ["chat", "message", "messaging", "dm", "thread", "workspace"],
"notion": ["docs", "wiki", "notes", "knowledge base", "database", "pages"],
"airtable": ["spreadsheet", "database", "table", "records"],
"hubspot": ["crm", "sales", "leads", "contacts", "deals"],
"reddit": ["forum", "subreddit", "posts", "comments", "social"],
"youtube": ["video", "transcript", "channel"],
}
def _connected_servers() -> list[dict]:
out = []
for t in load_all_tools():
if not (t.mcp_config and t.enabled and t.auth_status in ("configured", "connected")):
continue
sanitized = _sanitize_server_name(t.name)
# Pull tool sub-action names from tool_permissions._tool_descriptions
# so MCPSearch can match against capability names (e.g. "send_email").
action_names: list[str] = []
try:
td = (t.tool_permissions or {}).get("_tool_descriptions", {})
if isinstance(td, dict):
action_names = [str(k) for k in td.keys() if not str(k).startswith("_")]
except Exception:
pass
aliases = _SERVER_SEARCH_ALIASES.get(sanitized, [])
out.append({
"name": sanitized,
"description": (t.description or "").strip() or f"{t.name} integration",
"raw_name": t.name,
"_search_extras": " ".join(action_names + aliases),
})
return out
def _strip_extras(s: dict) -> dict:
return {k: v for k, v in s.items() if not k.startswith("_")}
if action == "list":
servers = _connected_servers()
session = agent_manager.sessions.get(parent_session_id) if parent_session_id else None
active_set = set(session.active_mcps) if session else set()
active = [{**_strip_extras(s), "status": "active"} for s in servers if s["name"] in active_set]
available = [{**_strip_extras(s), "status": "available"} for s in servers if s["name"] not in active_set]
return JSONResponse({"active": active, "available": available})
if action == "search":
query = (body.get("query") or "").strip().lower()
servers = _connected_servers()
session = agent_manager.sessions.get(parent_session_id) if parent_session_id else None
active_set = set(session.active_mcps) if session else set()
# Ranking: substring hits across name+description+sub-tool names+
# generic-purpose aliases. The aliases are what let "email" match
# google-workspace even though the description says "Gmail".
# Active-first tiebreak so the model prefers servers it has already
# activated when both score equally.
scored: list[tuple[int, dict]] = []
for s in servers:
extras = s.get("_search_extras", "")
hay = f"{s['name']} {s['raw_name']} {s['description']} {extras}".lower()
score = 0
for tok in query.split():
if tok and tok in hay:
# Hits in the canonical name count more; alias hits
# count once so a "drive" query doesn't beat the actual
# Drive tool description.
if tok in s["name"]:
score += 2
elif tok in s["description"].lower():
score += 2
else:
score += 1
if score:
annotated = {**_strip_extras(s), "status": "active" if s["name"] in active_set else "available"}
scored.append((score, annotated))
scored.sort(key=lambda t: (-t[0], 0 if t[1]["status"] == "active" else 1, t[1]["name"]))
matches = [s for _, s in scored[:5]]
return JSONResponse({"matches": matches})
if action == "activate":
server_name = (body.get("server_name") or "").strip()
reason = body.get("reason") or ""
if not server_name:
return JSONResponse({"error": "server_name is required"}, status_code=400)
if not parent_session_id:
return JSONResponse({"error": "parent_session_id is required"}, status_code=400)
session = agent_manager.sessions.get(parent_session_id)
if not session:
return JSONResponse({"error": "session not found"}, status_code=404)
servers = _connected_servers()
valid_names = {s["name"] for s in servers}
if server_name not in valid_names:
return JSONResponse({"status": "unknown_server", "available": sorted(valid_names)})
if server_name in session.active_mcps:
return JSONResponse({"status": "already_active", "server_name": server_name})
session.active_mcps.append(server_name)
session.needs_fork = True
try:
from backend.apps.agents.ws_manager import ws_manager as _ws
await _ws.send_to_session(parent_session_id, "agent:status", {
"session_id": parent_session_id,
"status": session.status,
"session": session.model_dump(mode="json"),
})
except Exception:
logger.exception("Failed to broadcast post-activate session status")
try:
from backend.apps.analytics.collector import record as _analytics
_analytics("mcp.activated", {
"server_name": server_name,
"reason_len": len(reason),
}, session_id=parent_session_id, dashboard_id=session.dashboard_id)
except Exception:
pass
return JSONResponse({"status": "activated", "server_name": server_name})
return JSONResponse({"error": f"unknown action: {action}"}, status_code=400)
@app.post("/api/invoke-agent/run")
async def invoke_agent_run(request: Request):
"""Fork an existing agent session and send it a new message.
+116 -6
View File
@@ -143,6 +143,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined));
const modesMap = useAppSelector((state) => state.modes.items);
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
const connectionMode = useAppSelector((state) => state.settings.data.connection_mode);
// Used by the "too many connected apps for Haiku" warning rendered above
// ChatInput. Each connected MCP adds a meaningful chunk of tool-schema
// tokens to every request; Haiku 4.5's 200K window can't hold 5+ of them.
@@ -769,18 +770,81 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
)}
</Box>
{!isDraft && (
<Box sx={{ display: 'flex', gap: 1.5, mt: 0.25 }}>
<Box sx={{ display: 'flex', gap: 1.5, mt: 0.25, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{session.model}
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{session.branch_name}
</Typography>
{session.cost_usd > 0 && (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)}
</Typography>
)}
{(() => {
if (!(session.cost_usd > 0)) return null;
// The SDK reports a per-call $ figure regardless of how
// the request was routed. For requests that went through
// a subscription path, that figure is misleading — the
// user pays flat-rate. Show "subscription" instead in
// those cases. Show $ only when the call was actually
// metered (Anthropic API key, OpenAI API key, etc.).
//
// Model-id signals (these are short_name values from the
// BUILTIN_MODELS registry):
// - `*-api` → pinned Anthropic API key (METERED)
// - `*-cc` → pinned Claude Pro/Max via 9Router (sub)
// - plain sonnet/opus/haiku + openswarm-pro mode → Pro proxy (sub)
// - plain sonnet/opus/haiku + own_key mode → API key (METERED)
// - gpt-5.4* / gpt-5.3* → ChatGPT Plus/Pro via 9Router (sub)
// - gemini-* → Gemini Advanced via 9Router (sub)
const m = (session.model || '').toLowerCase();
const isApiRoute = m.endsWith('-api');
if (isApiRoute) {
return (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)}
</Typography>
);
}
const isCcRoute = m.endsWith('-cc');
const isPlainAnthropic = m === 'sonnet' || m === 'opus' || m === 'haiku';
const isProRoute = isPlainAnthropic && connectionMode === 'openswarm-pro';
const isOwnKeyAnthropic = isPlainAnthropic && connectionMode !== 'openswarm-pro';
const isOpenAISub = m.startsWith('gpt-5') || m.startsWith('gpt-4') || m.startsWith('o1') || m.startsWith('o3') || m.startsWith('o4');
const isGeminiSub = m.startsWith('gemini-');
const isSubscriptionRouted = isCcRoute || isProRoute || isOpenAISub || isGeminiSub;
if (isSubscriptionRouted) {
return (
<Typography
variant="caption"
sx={{ color: c.text.tertiary }}
title="Routed through subscription — flat-rate, per-call cost not metered"
>
subscription
</Typography>
);
}
// own-key Anthropic OR anything else → real $ figure.
void isOwnKeyAnthropic;
return (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)}
</Typography>
);
})()}
{(() => {
const pct = session.ctx_used_pct ?? 0;
if (!pct) return null;
const pctTxt = `${Math.round(pct * 100)}%`;
const color = pct >= 0.9 ? '#ef4444' : pct >= 0.7 ? '#f59e0b' : c.text.tertiary;
const mcpCount = session.active_mcps?.length ?? 0;
return (
<Typography
variant="caption"
sx={{ color, fontVariantNumeric: 'tabular-nums' }}
title={`Context ${pctTxt} of 200K · ${mcpCount} MCP${mcpCount === 1 ? '' : 's'} active`}
>
{pctTxt} ctx · {mcpCount} mcp
</Typography>
);
})()}
</Box>
)}
</Box>
@@ -813,6 +877,52 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{session.context_overflow && (() => {
const reason = session.context_overflow.reason;
const isAuth = reason === 'openswarm_pro_auth_expired' || reason === 'anthropic_auth_invalid' || reason === 'auth_error';
const title = isAuth ? 'Sign-in required' : 'Context full';
const primaryLabel = isAuth ? 'Open Settings' : 'Start a fresh chat';
const onPrimary = () => {
if (isAuth) window.location.hash = '#/settings';
else window.location.hash = '#/';
};
return (
<Box sx={{
mt: 1,
mb: 1.5,
p: 1.5,
borderRadius: 1.5,
border: `1px solid ${c.border.strong}`,
bgcolor: c.bg.secondary,
}}>
<Typography variant="body2" sx={{ color: c.text.primary, fontWeight: 500, mb: 0.5 }}>
{title}
</Typography>
<Typography variant="caption" sx={{ color: c.text.secondary, display: 'block', mb: 1.25 }}>
{session.context_overflow.message}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography
component="button"
variant="caption"
onClick={onPrimary}
sx={{
cursor: 'pointer',
border: `1px solid ${c.border.medium}`,
borderRadius: 1,
px: 1.25,
py: 0.5,
bgcolor: 'transparent',
color: c.text.primary,
'&:hover': { bgcolor: c.bg.elevated },
}}
>
{primaryLabel}
</Typography>
</Box>
</Box>
);
})()}
{renderItems.filter((item) => !session.streamingMessage || item.id !== session.streamingMessage.id).map((item) => {
if (isToolGroup(item)) {
const groupMeta = session.tool_group_meta?.[item.id];
+62
View File
@@ -74,6 +74,11 @@ export interface AgentSession {
browser_id?: string | null;
parent_session_id?: string | null;
thinking_level?: 'off' | 'low' | 'medium' | 'high' | 'auto';
active_mcps?: string[];
ctx_used_pct?: number;
cache_read_pct?: number;
cache_read_tokens?: number;
context_overflow?: { reason: string; message: string; at: string } | null;
}
export interface AgentConfig {
@@ -682,6 +687,60 @@ const agentsSlice = createSlice({
}
},
updateSessionContext(
state,
action: PayloadAction<{
sessionId: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheReadPct: number;
ctxUsedPct: number;
activeMcps: string[];
}>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.tokens = {
...(session.tokens || {}),
input: action.payload.inputTokens,
output: action.payload.outputTokens,
};
session.cache_read_tokens = action.payload.cacheReadTokens;
session.cache_read_pct = action.payload.cacheReadPct;
session.ctx_used_pct = action.payload.ctxUsedPct;
session.active_mcps = action.payload.activeMcps;
}
},
setContextOverflow(
state,
action: PayloadAction<{
sessionId: string;
reason: string;
message: string;
}>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.context_overflow = {
reason: action.payload.reason,
message: action.payload.message,
at: new Date().toISOString(),
};
}
},
clearContextOverflow(
state,
action: PayloadAction<{ sessionId: string }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.context_overflow = null;
}
},
addBranch(state, action: PayloadAction<{ sessionId: string; branch: MessageBranch }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
@@ -1048,6 +1107,9 @@ export const {
addApprovalRequest,
removeApprovalRequest,
updateSessionCost,
updateSessionContext,
setContextOverflow,
clearContextOverflow,
addBranch,
setActiveBranch,
updateSessionProvider,
@@ -11,6 +11,8 @@ import {
removeApprovalRequest,
updateSessionStatus,
updateSessionCost,
updateSessionContext,
setContextOverflow,
addBranch,
setActiveBranch,
closeSessionFromWs,
@@ -270,6 +272,42 @@ class WebSocketManager {
}
break;
case 'agent:context_update':
if (session_id) {
store.dispatch(updateSessionContext({
sessionId: session_id,
inputTokens: data.input_tokens ?? 0,
outputTokens: data.output_tokens ?? 0,
cacheReadTokens: data.cache_read_tokens ?? 0,
cacheReadPct: data.cache_read_pct ?? 0,
ctxUsedPct: data.ctx_used_pct ?? 0,
activeMcps: Array.isArray(data.active_mcps) ? data.active_mcps : [],
}));
}
break;
case 'agent:context_overflow':
if (session_id) {
store.dispatch(setContextOverflow({
sessionId: session_id,
reason: data.reason ?? 'long_context_required',
message: data.message ?? 'Context full.',
}));
}
break;
case 'agent:auth_error':
// Re-uses the context_overflow card slot — both are "this session is
// blocked, here's what to do" cards. Reason field disambiguates.
if (session_id) {
store.dispatch(setContextOverflow({
sessionId: session_id,
reason: data.reason ?? 'auth_error',
message: data.message ?? 'Authentication failed.',
}));
}
break;
case 'agent:branch_created':
if (session_id && data.branch) {
store.dispatch(addBranch({ sessionId: session_id, branch: data.branch }));
File diff suppressed because one or more lines are too long