mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] phase 1: MCP activation gate, context pill, friendly 429s, -api routes so API keys actually work
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user