diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index a1e7c869..eea02675 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -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", { diff --git a/backend/apps/agents/browser_agent.py b/backend/apps/agents/browser_agent.py index 643ad242..223d6ef0 100644 --- a/backend/apps/agents/browser_agent.py +++ b/backend/apps/agents/browser_agent.py @@ -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) diff --git a/backend/main.py b/backend/main.py index 9f539480..1be36e02 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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})