[eric] lazy MCP connections + browser agent 9Router support

Performance fix:
- MCP servers no longer spawned upfront for every message
- Tool schemas loaded from cached metadata (instant, no subprocess)
- MCP servers only connected when their tools are first called (lazy)
- Browser agent MCP server still connects upfront (lightweight)
- Reduces startup from 30+ seconds to <1 second for simple messages

Browser agent 9Router support:
- Browser agent now works through 9Router when no API key set
- Uses OpenAI client format when base_url provided
- Model ID mapping for 9Router (cc/ prefix)
- Passes auth_token and base_url through run_browser_agents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-03-21 21:57:26 -07:00
co-authored by Claude Opus 4.6
parent 0a907e4bac
commit 36d79fd1d3
3 changed files with 162 additions and 34 deletions
+50 -5
View File
@@ -649,14 +649,46 @@ class AgentManager:
"type": "stdio",
}
# Connect MCP servers and collect tool schemas
# Lazy MCP: build tool schemas from saved metadata (no subprocess spawn)
# Actual MCP servers are only connected when a tool is first called
mcp_manager = MCPClientManager()
await mcp_manager.__aenter__()
mcp_tool_schemas = []
for server_name, server_config in mcp_servers.items():
tools = await mcp_manager.connect(server_name, server_config)
mcp_tool_schemas.extend(tools)
from backend.apps.agents.providers.base import ToolSchema
mcp_tool_schemas: list[ToolSchema] = []
_pending_mcp_servers: dict[str, dict] = dict(mcp_servers) # servers not yet connected
_connected_servers: set[str] = set()
# Build tool schemas from saved tool_permissions metadata (instant, no subprocess)
for tool in load_all_tools():
if not tool.mcp_config or not tool.enabled:
continue
server_name = _sanitize_server_name(tool.name)
if server_name not in _pending_mcp_servers and server_name not in mcp_servers:
continue
tool_descs = tool.tool_permissions.get("_tool_descriptions", {})
denied = _get_denied_tool_names(tool)
for tn, desc in tool_descs.items():
if tn not in denied:
mcp_tool_schemas.append(ToolSchema(
name=f"mcp__{server_name}__{tn}",
description=desc,
input_schema={"type": "object", "properties": {}},
))
# Always add browser agent tools (they're lightweight)
browser_agent_name = "openswarm-browser-agent"
if browser_agent_name in mcp_servers:
try:
ba_tools = await asyncio.wait_for(
mcp_manager.connect(browser_agent_name, mcp_servers[browser_agent_name]),
timeout=10.0,
)
mcp_tool_schemas.extend(ba_tools)
_connected_servers.add(browser_agent_name)
except Exception as e:
logger.warning(f"Browser agent MCP connection failed: {e}")
# Collect builtin + MCP tool schemas
from backend.apps.agents.tools.registry import get_all_tool_schemas
@@ -699,6 +731,19 @@ class AgentManager:
parsed = mcp_manager.parse_mcp_tool_name(tool_name)
if parsed:
server_name, bare_name = parsed
# Lazy connect: if this server hasn't been connected yet, connect now
if server_name not in _connected_servers and server_name in _pending_mcp_servers:
try:
await asyncio.wait_for(
mcp_manager.connect(server_name, _pending_mcp_servers[server_name]),
timeout=15.0,
)
_connected_servers.add(server_name)
except Exception as e:
logger.warning(f"Lazy MCP connect failed for {server_name}: {e}")
return [{"type": "text", "text": f"Failed to connect to {server_name}: {e}"}]
result = await mcp_manager.call_tool(server_name, bare_name, tool_input)
elapsed_ms = int((time.time() - t0) * 1000)
_analytics("tool.called", {
+94 -26
View File
@@ -278,6 +278,8 @@ async def run_browser_agent(
pre_selected: bool = False,
initial_url: str | None = None,
parent_session_id: str | None = None,
auth_token: str | None = None,
base_url: str | None = None,
) -> dict:
"""Run a browser sub-agent loop for a single browser card.
@@ -315,7 +317,17 @@ async def run_browser_agent(
logger.info(f"Browser agent {session_id}: navigated to {initial_url}: {nav_result.get('text', nav_result.get('error', ''))}")
api_model = MODEL_MAP.get(model, model)
client = anthropic.AsyncAnthropic(api_key=api_key)
# Use OpenAI client for 9Router, Anthropic client for direct API
_use_openai_client = base_url is not None
if _use_openai_client:
from openai import AsyncOpenAI
# Map to 9Router model IDs
_9r_map = {"sonnet": "cc/claude-sonnet-4-6", "opus": "cc/claude-opus-4-6", "haiku": "cc/claude-haiku-4-5-20251001"}
api_model = _9r_map.get(model, f"cc/{api_model}" if not api_model.startswith("cc/") else api_model)
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
else:
client = anthropic.AsyncAnthropic(api_key=api_key)
messages: list[dict] = [{"role": "user", "content": task}]
action_log: list[dict] = []
@@ -330,30 +342,66 @@ async def run_browser_agent(
try:
for turn in range(MAX_TURNS):
response = await client.messages.create(
model=api_model,
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=BROWSER_TOOLS_SCHEMA,
messages=messages,
)
if _use_openai_client:
# OpenAI-compatible format (9Router)
import json as _json
oai_tools = [{"type": "function", "function": {"name": t["name"], "description": t["description"], "parameters": t["input_schema"]}} for t in BROWSER_TOOLS_SCHEMA]
oai_messages = [{"role": "system", "content": SYSTEM_PROMPT}] + messages
resp = await client.chat.completions.create(model=api_model, max_tokens=4096, tools=oai_tools, messages=oai_messages)
choice = resp.choices[0]
assistant_content = []
text_parts = []
tool_uses = []
assistant_content = []
text_parts = []
tool_uses = []
for block in response.content:
if block.type == "text":
text_parts.append(block.text)
assistant_content.append({"type": "text", "text": block.text})
elif block.type == "tool_use":
tool_uses.append(block)
assistant_content.append({
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input,
})
if choice.message.content:
text_parts.append(choice.message.content)
assistant_content.append({"type": "text", "text": choice.message.content})
if choice.message.tool_calls:
for tc in choice.message.tool_calls:
try:
inp = _json.loads(tc.function.arguments)
except Exception:
inp = {}
# Create a simple object with .id, .name, .input
class _TC:
pass
tool_obj = _TC()
tool_obj.id = tc.id
tool_obj.name = tc.function.name
tool_obj.input = inp
tool_uses.append(tool_obj)
assistant_content.append({"type": "tool_use", "id": tc.id, "name": tc.function.name, "input": inp})
stop_reason = "tool_use" if choice.message.tool_calls else "end_turn"
else:
# Anthropic format (direct API)
response = await client.messages.create(
model=api_model,
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=BROWSER_TOOLS_SCHEMA,
messages=messages,
)
assistant_content = []
text_parts = []
tool_uses = []
stop_reason = response.stop_reason
for block in response.content:
if block.type == "text":
text_parts.append(block.text)
assistant_content.append({"type": "text", "text": block.text})
elif block.type == "tool_use":
tool_uses.append(block)
assistant_content.append({
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input,
})
if text_parts:
asst_msg = Message(
@@ -377,9 +425,16 @@ async def run_browser_agent(
"message": tool_msg.model_dump(mode="json"),
})
messages.append({"role": "assistant", "content": assistant_content})
if _use_openai_client:
# OpenAI format: assistant message with tool_calls
asst_api_msg: dict = {"role": "assistant", "content": choice.message.content}
if choice.message.tool_calls:
asst_api_msg["tool_calls"] = [{"id": tc.id, "type": "function", "function": {"name": tc.function.name, "arguments": tc.function.arguments}} for tc in (choice.message.tool_calls or [])]
messages.append(asst_api_msg)
else:
messages.append({"role": "assistant", "content": assistant_content})
if response.stop_reason != "tool_use":
if stop_reason != "tool_use":
break
tool_results = []
@@ -460,7 +515,16 @@ async def run_browser_agent(
"message": result_msg.model_dump(mode="json"),
})
messages.append({"role": "user", "content": tool_results})
if _use_openai_client:
# OpenAI format: each tool result is a separate message
for tr in tool_results:
text_content = ""
for block in (tr.get("content") or []):
if isinstance(block, dict) and block.get("type") == "text":
text_content += block.get("text", "")
messages.append({"role": "tool", "tool_call_id": tr["tool_use_id"], "content": text_content or "Done."})
else:
messages.append({"role": "user", "content": tool_results})
summary_parts = text_parts if text_parts else ["Task completed."]
summary = "\n".join(summary_parts)
@@ -563,6 +627,8 @@ async def run_browser_agents(
dashboard_id: str | None = None,
pre_selected_browser_ids: list[str] | None = None,
parent_session_id: str | None = None,
auth_token: str | None = None,
base_url: str | None = None,
) -> list[dict]:
"""Run multiple browser sub-agents in parallel.
@@ -590,6 +656,8 @@ async def run_browser_agents(
pre_selected=is_pre_selected,
initial_url=url if url and browser_id not in pre_selected else None,
parent_session_id=parent_session_id,
auth_token=auth_token,
base_url=base_url,
)
results = await asyncio.gather(*[_run_one(t) for t in tasks], return_exceptions=True)
+18 -3
View File
@@ -145,16 +145,31 @@ async def browser_agent_run(request: Request):
return JSONResponse({"error": "tasks array is required"}, status_code=400)
settings = load_settings()
if not settings.anthropic_api_key:
return JSONResponse({"error": "Anthropic API key not configured"}, status_code=400)
# Determine API credentials — check API key, then 9Router
api_key = settings.anthropic_api_key
auth_token = None
base_url = None
if not api_key:
# Try 9Router
from backend.apps.nine_router import is_running as _9r_running
if _9r_running():
api_key = "9router"
base_url = "http://localhost:20128/v1"
auth_token = None
else:
return JSONResponse({"error": "No AI provider configured. Set an API key or connect a subscription."}, status_code=400)
results = await run_browser_agents(
tasks=tasks,
model=model,
api_key=settings.anthropic_api_key,
api_key=api_key,
dashboard_id=dashboard_id or None,
pre_selected_browser_ids=pre_selected_browser_ids,
parent_session_id=parent_session_id or None,
auth_token=auth_token,
base_url=base_url,
)
return JSONResponse({"results": results})