diff --git a/README.md b/README.md index 73ad2cfb..f20bfdbb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Open Swarm + Open Swarm

Open Swarm

@@ -22,7 +22,7 @@

- Open Swarm Dashboard + Open Swarm Dashboard


diff --git a/backend/apps/agents/agent_loop.py b/backend/apps/agents/agent_loop.py index 92410408..007216c4 100644 --- a/backend/apps/agents/agent_loop.py +++ b/backend/apps/agents/agent_loop.py @@ -1,331 +1,706 @@ -"""Owned agent loop — replaces claude_agent_sdk's query() function. +"""Main agent loop — extracted from AgentManager._run_agent_loop. -Generalizes the pattern from browser_agent.py (lines 243-334) into a -provider-agnostic, streaming, HITL-aware tool-use loop. +Handles the Claude Agent SDK query loop, approval hooks, streaming, +mock-agent fallback, and session-completed analytics. """ from __future__ import annotations +import asyncio import json import logging +import os +import sys import time -from typing import Any, Callable, Awaitable +from datetime import datetime from uuid import uuid4 -from backend.apps.agents.providers.base import ( - BaseProvider, ContentBlock, ModelResponse, ProviderMessage, - StreamEvent, ToolCall, ToolSchema, +from backend.apps.agents.models import AgentSession, ApprovalRequest, Message +from backend.apps.agents.ws_manager import ws_manager +from backend.apps.agents.prompt_builder import ( + resolve_mode, compose_system_prompt, build_connected_tools_context, + build_outputs_context, build_browser_context, build_prompt_content, + get_pre_selected_browser_ids, ) +from backend.apps.agents.mcp_builder import ( + FULL_TOOLS, build_mcp_servers, get_effective_policy, get_all_tool_names, + _get_denied_tool_names, _get_all_known_tool_names, _is_fully_denied, +) +from backend.apps.agents.session_store import save_session +from backend.apps.settings.settings import load_settings +from backend.apps.tools_lib.tools_lib import ( + _load_all as load_all_tools, + load_builtin_permissions, +) +from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name +from backend.apps.analytics.collector import record as _analytics logger = logging.getLogger(__name__) -# Type aliases for callbacks -ToolExecutor = Callable[[str, dict], Awaitable[list[dict]]] -# hitl_handler(tool_name, tool_input) -> (approved, updated_input_or_None) -HITLHandler = Callable[[str, dict], Awaitable[tuple[bool, dict | None]]] -# ws_emitter(event_type, data) -> None -WSEmitter = Callable[[str, dict], Awaitable[None]] + +# --------------------------------------------------------------------------- +# Streaming helpers +# --------------------------------------------------------------------------- + +async def stream_text(session_id: str, msg_id: str, text: str, delay: float = 0.03): + await ws_manager.send_to_session(session_id, "agent:stream_start", { + "session_id": session_id, "message_id": msg_id, "role": "assistant", + }) + words = text.split(" ") + for i, word in enumerate(words): + chunk = word if i == 0 else " " + word + await ws_manager.send_to_session(session_id, "agent:stream_delta", { + "session_id": session_id, "message_id": msg_id, "delta": chunk, + }) + await asyncio.sleep(delay) + await ws_manager.send_to_session(session_id, "agent:stream_end", { + "session_id": session_id, "message_id": msg_id, + }) -class AgentLoop: - """Provider-agnostic agent loop with streaming and HITL support. +async def stream_tool_input(session_id: str, msg_id: str, tool_name: str, input_json: str, delay: float = 0.02): + await ws_manager.send_to_session(session_id, "agent:stream_start", { + "session_id": session_id, "message_id": msg_id, "role": "tool_call", "tool_name": tool_name, + }) + chunk_size = 12 + for i in range(0, len(input_json), chunk_size): + await ws_manager.send_to_session(session_id, "agent:stream_delta", { + "session_id": session_id, "message_id": msg_id, "delta": input_json[i:i + chunk_size], + }) + await asyncio.sleep(delay) + await ws_manager.send_to_session(session_id, "agent:stream_end", { + "session_id": session_id, "message_id": msg_id, + }) - The loop: - 1. Sends user message to the model - 2. Streams the response (emitting WebSocket events) - 3. If the model requests tool use: - a. For each tool call: check HITL permission → execute → collect result - b. Append tool results → go to step 2 - 4. If the model stops (end_turn/max_tokens): done - """ - def __init__( - self, - session_id: str, - provider: BaseProvider, - model: str, - system_prompt: str | None, - tools: list[ToolSchema], - tool_executor: ToolExecutor, - hitl_handler: HITLHandler, - ws_emitter: WSEmitter, - max_turns: int | None = None, - cwd: str | None = None, - ): - self.session_id = session_id - self.provider = provider - self.model = model - self.system_prompt = system_prompt - self.tools = tools - self.tool_executor = tool_executor - self.hitl_handler = hitl_handler - self.ws_emitter = ws_emitter - self.max_turns = max_turns - self.cwd = cwd +# --------------------------------------------------------------------------- +# Analytics helper +# --------------------------------------------------------------------------- - # Conversation history in provider-agnostic format - self.messages: list[ProviderMessage] = [] +def fire_session_completed(session: AgentSession, sessions_dict: dict[str, AgentSession]): + duration = 0.0 + if session.created_at: + end = session.closed_at or datetime.now() + duration = (end - session.created_at).total_seconds() + tool_names = [ + m.content.get("tool", "") for m in session.messages + if m.role == "tool_call" and isinstance(m.content, dict) + ] + user_messages = [ + (m.content if isinstance(m.content, str) else str(m.content))[:200] + for m in session.messages if m.role == "user" + ] + _analytics("session.completed", { + "model": session.model, + "provider": getattr(session, "provider", "anthropic"), + "mode": session.mode, + "cost_usd": session.cost_usd, + "message_count": len([m for m in session.messages if m.role in ("user", "assistant")]), + "duration_seconds": round(duration, 1), + "status": session.status, + "tool_count": len(tool_names), + "tools_list": list(set(tool_names)), + "session_title": session.name, + "first_user_message": user_messages[0] if user_messages else "", + "input_tokens": session.tokens.get("input", 0), + "output_tokens": session.tokens.get("output", 0), + "is_sub_agent": session.parent_session_id is not None, + "parent_session_id": session.parent_session_id, + "sub_agent_count": len([s for s in sessions_dict.values() if s.parent_session_id == session.id]), + "branch_count": len(session.branches), + }, session_id=session.id, dashboard_id=session.dashboard_id) - # Token tracking - self.total_input_tokens = 0 - self.total_output_tokens = 0 - async def run(self, user_content: Any) -> None: - """Run the agent loop for a single user turn.""" - # Append user message - user_msg = self.provider.format_user_message(user_content) - self.messages.append(user_msg) +# --------------------------------------------------------------------------- +# Mock agent +# --------------------------------------------------------------------------- - turn = 0 - while True: - if self.max_turns and turn >= self.max_turns: - logger.info(f"Agent {self.session_id}: max turns ({self.max_turns}) reached") - break - turn += 1 +async def run_mock_agent(session_id: str, prompt: str, sessions: dict[str, AgentSession]): + session = sessions.get(session_id) + if not session: + return - # Stream the model response and collect it - response = await self._stream_and_collect() + await asyncio.sleep(1) - # Track usage - self.total_input_tokens += response.usage.get("input_tokens", 0) - self.total_output_tokens += response.usage.get("output_tokens", 0) + request_id = uuid4().hex + approval_req = ApprovalRequest( + id=request_id, session_id=session_id, tool_name="Bash", + tool_input={"command": f"echo 'Processing: {prompt}'", "description": "Echo the user prompt"}, + ) + session.pending_approvals.append(approval_req) + session.status = "waiting_approval" + await ws_manager.send_to_session(session_id, "agent:status", { + "session_id": session_id, "status": "waiting_approval", + }) - # Append assistant message to conversation history - assistant_msg = self.provider.format_assistant_message(response) - self.messages.append(assistant_msg) + decision = await ws_manager.send_approval_request( + session_id, request_id, "Bash", + {"command": f"echo 'Processing: {prompt}'", "description": "Echo the user prompt"}, + ) - # If no tool use, we're done - if response.stop_reason != "tool_use": - break + session.pending_approvals = [a for a in session.pending_approvals if a.id != request_id] + session.status = "running" + await ws_manager.send_to_session(session_id, "agent:status", { + "session_id": session_id, "status": "running", + }) - # Execute tools - tool_results = await self._execute_tools(response) - if not tool_results: - break + tool_input_content = {"tool": "Bash", "input": {"command": f"echo 'Processing: {prompt}'"}, "approved": decision.get("behavior") == "allow"} + tool_msg_id = uuid4().hex + await stream_tool_input(session_id, tool_msg_id, "Bash", json.dumps(tool_input_content["input"], indent=2)) + tool_msg = Message(id=tool_msg_id, role="tool_call", content=tool_input_content, branch_id=session.active_branch_id) + session.messages.append(tool_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, "message": tool_msg.model_dump(mode="json"), + }) - # Append tool results - self.messages.append(ProviderMessage(role="tool_result", content=tool_results)) + await asyncio.sleep(1) - async def _stream_and_collect(self) -> ModelResponse: - """Stream model output, emit WebSocket events, collect full response.""" - collected_content: list[ContentBlock] = [] - collected_usage: dict[str, int] = {} - stop_reason = "end_turn" + if decision.get("behavior") == "allow": + tool_result = Message(role="tool_result", content=f"Processing: {prompt}", branch_id=session.active_branch_id) + session.messages.append(tool_result) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, "message": tool_result.model_dump(mode="json"), + }) - # Track streaming state for WS emissions - stream_text_msg_id: str | None = None - stream_tool_msg_ids: dict[int, str] = {} # block index -> msg_id - block_index_map: dict[int, str] = {} # block index -> msg_id + await asyncio.sleep(1) - # Buffers for collecting content - text_buffers: dict[int, str] = {} - json_buffers: dict[int, str] = {} - tool_names: dict[int, str] = {} - tool_ids: dict[int, str] = {} - block_types: dict[int, str] = {} + asst_text = ( + f"I've processed your request: \"{prompt}\"\n\n" + "This is a mock response because `claude-agent-sdk` is not installed. " + "Install it with `pip install claude-agent-sdk` to use real Claude Code instances.\n\n" + f"The agent was configured with:\n- Model: {session.model}\n- Mode: {session.mode}" + ) + asst_msg_id = uuid4().hex + await stream_text(session_id, asst_msg_id, asst_text) - async for event in self.provider.stream_message( - model=self.model, - system=self.system_prompt, - messages=self.messages, - tools=self.tools, - ): - if event.type == "content_block_start": - if event.block_type == "text": - if stream_text_msg_id is None: - stream_text_msg_id = uuid4().hex - await self.ws_emitter("agent:stream_start", { - "message_id": stream_text_msg_id, - "role": "assistant", - }) - block_index_map[event.index] = stream_text_msg_id - block_types[event.index] = "text" - text_buffers[event.index] = "" + asst_msg = Message(id=asst_msg_id, role="assistant", content=asst_text, branch_id=session.active_branch_id) + session.messages.append(asst_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, "message": asst_msg.model_dump(mode="json"), + }) - elif event.block_type == "tool_use": - tool_msg_id = uuid4().hex - stream_tool_msg_ids[event.index] = tool_msg_id - block_index_map[event.index] = tool_msg_id - block_types[event.index] = "tool_use" - tool_names[event.index] = event.tool_name - tool_ids[event.index] = event.tool_id - json_buffers[event.index] = "" + session.status = "completed" + session.closed_at = datetime.now() + session.cost_usd = 0.001 + await ws_manager.send_to_session(session_id, "agent:status", { + "session_id": session_id, "status": "completed", + "session": session.model_dump(mode="json"), + }) + await ws_manager.send_to_session(session_id, "agent:cost_update", { + "session_id": session_id, "cost_usd": session.cost_usd, + }) - await self.ws_emitter("agent:stream_start", { - "message_id": tool_msg_id, - "role": "tool_call", - "tool_name": event.tool_name, - }) - elif event.type == "content_block_delta": - msg_id = block_index_map.get(event.index) - if not msg_id: +# --------------------------------------------------------------------------- +# Main agent loop +# --------------------------------------------------------------------------- + +async def run_agent_loop( + sessions: dict[str, AgentSession], + session_id: str, + prompt: str, + images: list | None = None, + context_paths: list | None = None, + forced_tools: list[str] | None = None, + attached_skills: list | None = None, + fork_session: bool = False, + selected_browser_ids: list[str] | None = None, +): + """Run the Claude Agent SDK query loop for a session.""" + session = sessions.get(session_id) + if not session: + return + + prompt_content = build_prompt_content( + prompt, images, context_paths, forced_tools, attached_skills, + load_all_tools_fn=load_all_tools, + ) + + try: + from claude_agent_sdk import ( + query, ClaudeAgentOptions, AssistantMessage, ResultMessage, + ) + from claude_agent_sdk.types import ( + HookMatcher, PermissionResultAllow, PermissionResultDeny, + TextBlock, ToolUseBlock, StreamEvent, + SystemMessage, + ) + except ImportError: + logger.warning("claude_agent_sdk not installed, running in mock mode") + await run_mock_agent(session_id, prompt, sessions) + return + + session.status = "running" + _builtin_perms = load_builtin_permissions() + + async def _request_user_approval(tool_name: str, tool_input) -> dict: + safe_input = tool_input if isinstance(tool_input, dict) else {} + request_id = uuid4().hex + approval_req = ApprovalRequest( + id=request_id, session_id=session_id, tool_name=tool_name, tool_input=safe_input, + ) + session.pending_approvals.append(approval_req) + session.status = "waiting_approval" + + _analytics("approval.requested", { + "tool_name": tool_name, + "is_first_approval_in_session": len(session.pending_approvals) == 1, + "model": session.model, + }, session_id=session_id, dashboard_id=session.dashboard_id) + + await ws_manager.send_to_session(session_id, "agent:status", { + "session_id": session_id, "status": "waiting_approval", + }) + + decision = await ws_manager.send_approval_request( + session_id, request_id, tool_name, safe_input, + ) + + approval_latency_ms = int((datetime.now() - approval_req.created_at).total_seconds() * 1000) + _analytics("approval.resolved", { + "tool_name": tool_name, + "decision": decision.get("behavior", "unknown"), + "latency_ms": approval_latency_ms, + "input_was_modified": decision.get("updated_input") is not None, + "model": session.model, + }, session_id=session_id, dashboard_id=session.dashboard_id) + + session.pending_approvals = [a for a in session.pending_approvals if a.id != request_id] + session.status = "running" + await ws_manager.send_to_session(session_id, "agent:status", { + "session_id": session_id, "status": "running", + }) + return decision + + async def can_use_tool(tool_name, input_data, context): + if tool_name != "AskUserQuestion": + policy = get_effective_policy(tool_name, _builtin_perms) + if policy == "always_allow": + return PermissionResultAllow(updated_input=input_data) + if policy == "deny": + return PermissionResultDeny(message="Tool denied by permission policy") + decision = await _request_user_approval(tool_name, input_data) + if decision.get("behavior") == "allow": + return PermissionResultAllow(updated_input=decision.get("updated_input", input_data)) + return PermissionResultDeny(message=decision.get("message", "User denied this action")) + + tool_start_times: dict[str, float] = {} + + async def pre_tool_hook(input_data, tool_use_id, context): + tool_name = input_data.get("tool_name", "") + hook_event = input_data.get("hook_event_name", "PreToolUse") + if tool_name and tool_name != "AskUserQuestion": + policy = get_effective_policy(tool_name, _builtin_perms) + if policy == "deny": + return {"hookSpecificOutput": {"hookEventName": hook_event, "permissionDecision": "deny", "permissionDecisionReason": "Tool denied by permission policy"}} + if policy == "ask": + tool_input = input_data.get("tool_input", {}) + decision = await _request_user_approval(tool_name, tool_input) + if decision.get("behavior") == "allow": + if tool_use_id: + tool_start_times[tool_use_id] = time.time() + return {"hookSpecificOutput": {"hookEventName": hook_event, "permissionDecision": "allow"}} + return {"hookSpecificOutput": {"hookEventName": hook_event, "permissionDecision": "deny", "permissionDecisionReason": decision.get("message", "User denied this action")}} + if tool_use_id: + tool_start_times[tool_use_id] = time.time() + return {} + + async def post_tool_hook(input_data, tool_use_id, context): + import re as _re_tool + elapsed_ms = None + if tool_use_id and tool_use_id in tool_start_times: + elapsed_ms = int((time.time() - tool_start_times.pop(tool_use_id)) * 1000) + + raw_response = input_data.get("tool_response", "") + + hook_tool_name_early = input_data.get("tool_name", "") + if hook_tool_name_early: + _is_mcp = "__" in hook_tool_name_early + _mcp_server = "" + _tool_short = hook_tool_name_early + if _is_mcp: + _mcp_match = _re_tool.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", hook_tool_name_early) + if _mcp_match: + _mcp_server = _mcp_match.group(1) + _tool_short = _mcp_match.group(2) + _analytics("tool.executed", { + "tool_name": hook_tool_name_early, "tool_short_name": _tool_short, + "tool_type": "mcp" if _is_mcp else "builtin", "mcp_server": _mcp_server, + "duration_ms": elapsed_ms, + "success": not (isinstance(raw_response, str) and raw_response.startswith("Error")), + "model": session.model, "provider": session.provider, + }, session_id=session_id, dashboard_id=session.dashboard_id) + + if isinstance(raw_response, list) and raw_response: + text_parts = [b.get("text", "") for b in raw_response if isinstance(b, dict) and b.get("type") == "text"] + if text_parts: + raw_response = "\n".join(text_parts) if len(text_parts) > 1 else text_parts[0] + + if isinstance(raw_response, str): + content = raw_response + else: + try: + content = json.dumps(raw_response, indent=2, default=str) + except Exception: + content = str(raw_response) + + result_payload: dict = {"text": content} + hook_tool_name = input_data.get("tool_name", "") + if hook_tool_name: + result_payload["tool_name"] = hook_tool_name + if elapsed_ms is not None: + result_payload["elapsed_ms"] = elapsed_ms + + if hook_tool_name == "Agent": + tool_input = input_data.get("tool_input", {}) + agent_prompt = tool_input.get("prompt", tool_input.get("task", "")) + sub_text = content + sub_cost = 0.0 + sub_tokens: dict = {"input": 0, "output": 0} + sub_model = session.model + if isinstance(raw_response, dict): + blocks = raw_response.get("content") + if isinstance(blocks, list): + parts = [b.get("text", "") for b in blocks if isinstance(b, dict) and b.get("type") == "text"] + if parts: + sub_text = "\n".join(parts) if len(parts) > 1 else parts[0] + elif isinstance(raw_response.get("text"), str): + sub_text = raw_response["text"] + usage = raw_response.get("usage", {}) + if isinstance(usage, dict): + sub_tokens["input"] = usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0) + sub_tokens["output"] = usage.get("output_tokens", 0) + if raw_response.get("model"): + sub_model = raw_response["model"] + + sub_session_id = uuid4().hex + sub_name = agent_prompt[:50] if agent_prompt else "Sub-agent" + sub_session = AgentSession( + id=sub_session_id, name=sub_name, status="completed", model=sub_model, + mode="sub-agent", cwd=session.cwd, created_at=datetime.now(), + cost_usd=sub_cost, tokens=sub_tokens, + messages=[ + Message(role="user", content=agent_prompt, branch_id="main"), + Message(role="assistant", content=sub_text, branch_id="main"), + ], + dashboard_id=session.dashboard_id, parent_session_id=session_id, + ) + sessions[sub_session_id] = sub_session + await ws_manager.broadcast_global("agent:status", { + "session_id": sub_session_id, "status": sub_session.status, + "session": sub_session.model_dump(mode="json"), + }) + result_payload["sub_session_id"] = sub_session_id + + result_msg = Message(role="tool_result", content=result_payload, branch_id=session.active_branch_id) + session.messages.append(result_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, "message": result_msg.model_dump(mode="json"), + }) + return {"continue_": True} + + try: + _, mode_sys_prompt, _ = resolve_mode(session.mode, get_all_tool_names) + connected_tools_ctx = build_connected_tools_context( + session.allowed_tools, load_all_tools, get_all_tool_names, _is_fully_denied, _get_denied_tool_names, + ) + outputs_ctx = build_outputs_context() + browser_ctx = build_browser_context(session.dashboard_id, selected_browser_ids=selected_browser_ids) + global_settings = load_settings() + composed_prompt = compose_system_prompt( + global_settings.default_system_prompt, mode_sys_prompt, session.system_prompt, + connected_tools_ctx, outputs_ctx, browser_ctx, + ) + + if session.mode == "view-builder": + from backend.apps.outputs.view_builder_templates import VIEW_BUILDER_SKILL + skill_block = f"\n{VIEW_BUILDER_SKILL}\n" + composed_prompt = f"{composed_prompt}\n\n{skill_block}" if composed_prompt else skill_block + + mcp_servers = await build_mcp_servers(session.allowed_tools) + + _browser_delegation_tools = ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents"] + _browser_all_denied = all(_builtin_perms.get(t, "always_allow") == "deny" for t in _browser_delegation_tools) + + if not _browser_all_denied: + browser_agent_server_path = os.path.join(os.path.dirname(__file__), "browser_agent_mcp_server.py") + backend_port = os.environ.get("OPENSWARM_PORT", "8324") + pre_selected_bids = get_pre_selected_browser_ids(session.dashboard_id) + mcp_servers["openswarm-browser-agent"] = { + "command": sys.executable, + "args": [browser_agent_server_path], + "env": { + "OPENSWARM_PORT": backend_port, + "OPENSWARM_AGENT_MODEL": session.model, + "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", + "OPENSWARM_PRE_SELECTED_BROWSER_IDS": ",".join(pre_selected_bids), + "OPENSWARM_PARENT_SESSION_ID": session.id, + }, + "type": "stdio", + } + + _invoke_agent_tools = ["InvokeAgent"] + _invoke_all_denied = all(_builtin_perms.get(t, "always_allow") == "deny" for t in _invoke_agent_tools) + + if not _invoke_all_denied: + invoke_agent_server_path = os.path.join(os.path.dirname(__file__), "invoke_agent_mcp_server.py") + backend_port = os.environ.get("OPENSWARM_PORT", "8324") + mcp_servers["openswarm-invoke-agent"] = { + "command": sys.executable, + "args": [invoke_agent_server_path], + "env": { + "OPENSWARM_PORT": backend_port, + "OPENSWARM_PARENT_SESSION_ID": session.id, + "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", + }, + "type": "stdio", + } + + effective_allowed = [ + t for t in session.allowed_tools + if t in FULL_TOOLS and _builtin_perms.get(t, "always_allow") == "always_allow" + ] + effective_disallowed = [ + t for t in FULL_TOOLS + if _builtin_perms.get(t, "always_allow") == "deny" + ] + + if mcp_servers: + all_tools_list = load_all_tools() + for name in mcp_servers: + if name == "openswarm-browser-agent": + for bt in _browser_delegation_tools: + policy = _builtin_perms.get(bt, "always_allow") + if policy == "always_allow": + effective_allowed.append(f"mcp__openswarm-browser-agent__{bt}") + elif policy == "deny": + effective_disallowed.append(f"mcp__openswarm-browser-agent__{bt}") continue + if name == "openswarm-invoke-agent": + for it in _invoke_agent_tools: + policy = _builtin_perms.get(it, "always_allow") + if policy == "always_allow": + effective_allowed.append(f"mcp__openswarm-invoke-agent__{it}") + elif policy == "deny": + effective_disallowed.append(f"mcp__openswarm-invoke-agent__{it}") + continue + tool_def = next( + (t for t in all_tools_list if t.mcp_config and t.enabled and _sanitize_server_name(t.name) == name), + None, + ) + if tool_def: + denied = _get_denied_tool_names(tool_def) + known = _get_all_known_tool_names(tool_def) + for tn in known - denied: + policy = tool_def.tool_permissions.get(tn, "ask") + if policy == "always_allow": + effective_allowed.append(f"mcp__{name}__{tn}") + for tn in denied: + effective_disallowed.append(f"mcp__{name}__{tn}") + else: + effective_allowed.append(f"mcp__{name}__*") - if event.delta_type == "text_delta": - text_buffers.setdefault(event.index, "") - text_buffers[event.index] += event.text - await self.ws_emitter("agent:stream_delta", { - "message_id": msg_id, - "delta": event.text, - }) + google_allowed = [t for t in effective_allowed if "google-workspace" in t] + reddit_allowed = [t for t in effective_allowed if "reddit" in t] + builtin_allowed = [t for t in effective_allowed if not t.startswith("mcp__")] + logger.info(f"[MCP-DEBUG] effective_allowed: {len(effective_allowed)} total " + f"(builtins={len(builtin_allowed)}, google={len(google_allowed)}, reddit={len(reddit_allowed)})") + if effective_disallowed: + logger.info(f"[MCP-DEBUG] effective_disallowed: {effective_disallowed}") - elif event.delta_type == "input_json_delta": - json_buffers.setdefault(event.index, "") - json_buffers[event.index] += event.text - await self.ws_emitter("agent:stream_delta", { - "message_id": msg_id, - "delta": event.text, - }) + options_kwargs: dict = { + "model": session.model, + "max_buffer_size": 5 * 1024 * 1024, + "permission_mode": "default", + "can_use_tool": can_use_tool, + "hooks": { + "PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])], + "PostToolUse": [HookMatcher(matcher=None, hooks=[post_tool_hook])], + }, + "allowed_tools": effective_allowed, + "disallowed_tools": effective_disallowed, + "include_partial_messages": True, + } - elif event.type == "content_block_stop": - msg_id = block_index_map.get(event.index) - bt = block_types.get(event.index, "") + from backend.apps.nine_router import is_running as _9r_running + if global_settings.anthropic_api_key: + options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key} + logger.info("[MCP-DEBUG] Using direct API key") + elif _9r_running(): + options_kwargs["env"] = { + "ANTHROPIC_API_KEY": "9router", + "ANTHROPIC_BASE_URL": "http://localhost:20128", + } + options_kwargs["extra_args"] = {"bare": None} + logger.info("[MCP-DEBUG] Using 9Router (bare mode)") + else: + raise ValueError("No AI provider configured. Set an API key or connect a subscription.") - if bt == "text": - collected_content.append( - ContentBlock(type="text", text=text_buffers.get(event.index, "")) + if mcp_servers: + options_kwargs["mcp_servers"] = mcp_servers + mcp_json_len = len(json.dumps({"mcpServers": mcp_servers})) + logger.info(f"[MCP-DEBUG] mcp_servers passed to SDK: {list(mcp_servers.keys())}, JSON length={mcp_json_len}") + if composed_prompt: + options_kwargs["system_prompt"] = composed_prompt + if session.max_turns: + options_kwargs["max_turns"] = session.max_turns + if session.cwd: + options_kwargs["cwd"] = session.cwd + if session.sdk_session_id: + options_kwargs["resume"] = session.sdk_session_id + if fork_session: + options_kwargs["fork_session"] = True + + logger.info(f"[MCP-DEBUG] Creating ClaudeAgentOptions with model={session.model}") + options = ClaudeAgentOptions(**options_kwargs) + logger.info("[MCP-DEBUG] ClaudeAgentOptions created. Starting query...") + + async def prompt_stream(): + yield {"type": "user", "message": {"role": "user", "content": prompt_content}} + + stream_text_msg_id = None + stream_tool_msg_ids_ordered: list[str] = [] + stream_block_index_map: dict[int, str] = {} + _turn_number = 0 + _first_event = True + + async for message in query(prompt=prompt_stream(), options=options): + if _first_event: + logger.info(f"[MCP-DEBUG] First event received: {type(message).__name__}") + _first_event = False + + if isinstance(message, SystemMessage): + raw = message.__dict__ if hasattr(message, '__dict__') else str(message) + logger.info(f"[MCP-DEBUG] SystemMessage: {raw}") + + if isinstance(message, StreamEvent): + event = message.event + event_type = event.get("type") + + if event_type == "content_block_start": + block = event.get("content_block", {}) + index = event.get("index") + block_type = block.get("type") + if block_type == "text": + if stream_text_msg_id is None: + stream_text_msg_id = uuid4().hex + await ws_manager.send_to_session(session_id, "agent:stream_start", { + "session_id": session_id, "message_id": stream_text_msg_id, "role": "assistant", + }) + stream_block_index_map[index] = stream_text_msg_id + elif block_type == "tool_use": + tool_msg_id = uuid4().hex + stream_tool_msg_ids_ordered.append(tool_msg_id) + stream_block_index_map[index] = tool_msg_id + await ws_manager.send_to_session(session_id, "agent:stream_start", { + "session_id": session_id, "message_id": tool_msg_id, + "role": "tool_call", "tool_name": block.get("name", ""), + }) + + elif event_type == "content_block_delta": + index = event.get("index") + delta = event.get("delta", {}) + delta_type = delta.get("type") + msg_id = stream_block_index_map.get(index) + if msg_id and delta_type == "text_delta": + await ws_manager.send_to_session(session_id, "agent:stream_delta", { + "session_id": session_id, "message_id": msg_id, "delta": delta.get("text", ""), + }) + elif msg_id and delta_type == "input_json_delta": + await ws_manager.send_to_session(session_id, "agent:stream_delta", { + "session_id": session_id, "message_id": msg_id, "delta": delta.get("partial_json", ""), + }) + + elif event_type == "content_block_stop": + index = event.get("index") + msg_id = stream_block_index_map.get(index) + if msg_id and msg_id != stream_text_msg_id: + await ws_manager.send_to_session(session_id, "agent:stream_end", { + "session_id": session_id, "message_id": msg_id, + }) + + elif event_type == "message_stop": + if stream_text_msg_id: + await ws_manager.send_to_session(session_id, "agent:stream_end", { + "session_id": session_id, "message_id": stream_text_msg_id, + }) + + elif isinstance(message, AssistantMessage): + content_parts = [] + tool_uses = [] + for block in message.content: + if isinstance(block, TextBlock): + content_parts.append(block.text) + elif isinstance(block, ToolUseBlock): + tool_uses.append({"id": block.id, "tool": block.name, "input": block.input}) + + if content_parts: + asst_msg = Message( + id=stream_text_msg_id or uuid4().hex, + role="assistant", content="\n".join(content_parts), + branch_id=session.active_branch_id, ) - elif bt == "tool_use": - try: - tool_input = json.loads(json_buffers.get(event.index, "{}")) - except json.JSONDecodeError: - tool_input = {} - collected_content.append(ContentBlock( - type="tool_use", - tool_call=ToolCall( - id=tool_ids.get(event.index, uuid4().hex), - name=tool_names.get(event.index, ""), - input=tool_input, - ), - )) - - # Send stream_end for tool blocks (text block ends at message_stop) - if msg_id and bt == "tool_use": - await self.ws_emitter("agent:stream_end", { - "message_id": msg_id, + session.messages.append(asst_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, "message": asst_msg.model_dump(mode="json"), }) - elif event.type == "usage": - # Accumulate token usage from provider stream - for k, v in event.usage.items(): - collected_usage[k] = collected_usage.get(k, 0) + v - - elif event.type == "message_stop": - # Check if any tool calls means stop_reason is tool_use - has_tool_use = any(b.type == "tool_use" for b in collected_content) - if has_tool_use: - stop_reason = "tool_use" - - # End text stream - if stream_text_msg_id: - await self.ws_emitter("agent:stream_end", { - "message_id": stream_text_msg_id, + for i, tu in enumerate(tool_uses): + mid = stream_tool_msg_ids_ordered[i] if i < len(stream_tool_msg_ids_ordered) else uuid4().hex + tool_msg = Message(id=mid, role="tool_call", content=tu, branch_id=session.active_branch_id) + session.messages.append(tool_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, "message": tool_msg.model_dump(mode="json"), }) - # Build and emit the collected messages - await self._emit_collected_messages( - collected_content, stream_text_msg_id, stream_tool_msg_ids, - ) + _turn_number += 1 + _analytics("turn.completed", { + "turn_number": _turn_number, "tool_calls_in_turn": len(tool_uses), "model": session.model, + }, session_id=session_id, dashboard_id=session.dashboard_id) - return ModelResponse( - content=collected_content, - stop_reason=stop_reason, - usage=collected_usage, - ) + stream_text_msg_id = None + stream_tool_msg_ids_ordered = [] + stream_block_index_map = {} - async def _emit_collected_messages( - self, - content: list[ContentBlock], - text_msg_id: str | None, - tool_msg_ids: dict[int, str], - ) -> None: - """Emit finalized agent:message events for the collected response.""" - from backend.apps.agents.models import Message + elif isinstance(message, ResultMessage): + session.sdk_session_id = getattr(message, "session_id", None) + cost = getattr(message, "total_cost_usd", None) + if cost is not None: + session.cost_usd = cost + await ws_manager.send_to_session(session_id, "agent:cost_update", { + "session_id": session_id, "cost_usd": session.cost_usd, + }) + usage = getattr(message, "usage", None) or {} + if isinstance(usage, dict): + inp = usage.get("input_tokens", 0) or 0 + 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 + session.tokens["output"] = out - # Emit text message - text_parts = [b.text for b in content if b.type == "text" and b.text] - if text_parts: - msg = Message( - id=text_msg_id or uuid4().hex, - role="assistant", - content="\n".join(text_parts), - ) - await self.ws_emitter("agent:message", { - "message": msg.model_dump(mode="json"), + session.status = "completed" + except asyncio.CancelledError: + session.status = "stopped" + except Exception as e: + logger.exception(f"Agent {session_id} error: {e}") + session.status = "error" + _analytics("session.error", { + "error_type": type(e).__name__, "error_message": str(e)[:500], + "model": session.model, "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"), + }) + finally: + if session_id in sessions: + await ws_manager.send_to_session(session_id, "agent:status", { + "session_id": session_id, "status": session.status, + "session": session.model_dump(mode="json"), }) - - # Emit tool call messages - tool_blocks = [b for b in content if b.type == "tool_use" and b.tool_call] - tool_id_list = sorted(tool_msg_ids.items(), key=lambda x: x[0]) - for i, block in enumerate(tool_blocks): - tc = block.tool_call - msg_id = tool_id_list[i][1] if i < len(tool_id_list) else uuid4().hex - msg = Message( - id=msg_id, - role="tool_call", - content={ - "id": tc.id, - "tool": tc.name, - "input": tc.input, - }, - ) - await self.ws_emitter("agent:message", { - "message": msg.model_dump(mode="json"), - }) - - async def _execute_tools(self, response: ModelResponse) -> list[dict]: - """Execute all tool calls from a response, respecting HITL permissions. - - Returns a list of tool result dicts formatted for the provider. - """ - from backend.apps.agents.models import Message - - results = [] - for block in response.content: - if block.type != "tool_use" or not block.tool_call: - continue - - tc = block.tool_call - start_time = time.time() - - # HITL permission check - approved, updated_input = await self.hitl_handler(tc.name, tc.input) - - if not approved: - result_content = [{"type": "text", "text": "Tool use was denied by the user."}] - else: - tool_input = updated_input if updated_input else tc.input - try: - result_content = await self.tool_executor(tc.name, tool_input) - except Exception as e: - logger.warning(f"Tool execution error: {tc.name}: {e}") - result_content = [{"type": "text", "text": f"Error executing {tc.name}: {e}"}] - - elapsed_ms = int((time.time() - start_time) * 1000) - - # Emit tool result to frontend - result_text = "" - for block_item in result_content: - if isinstance(block_item, dict) and block_item.get("type") == "text": - result_text = block_item.get("text", "") - break - - result_msg = Message( - role="tool_result", - content={ - "text": result_text[:15000] if result_text else "Done.", - "tool_name": tc.name, - "elapsed_ms": elapsed_ms, - }, - ) - await self.ws_emitter("agent:message", { - "message": result_msg.model_dump(mode="json"), - }) - - # Format for provider - results.append( - self.provider.format_tool_result(tc.id, result_content) - ) - - return results + try: + save_session(session_id, session.model_dump(mode="json")) + except Exception as e: + logger.warning(f"Failed to snapshot session {session_id}: {e}") diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index dc045645..aca46b49 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1,29 +1,38 @@ +"""Thin coordinator for agent sessions. + +Heavy logic lives in sibling modules: +- prompt_builder – system-prompt composition & context injection +- mcp_builder – MCP server construction & tool-policy helpers +- session_store – on-disk persistence, history, message copying +- agent_loop – the SDK query loop, streaming, mock agent +""" + +from __future__ import annotations + import asyncio -import json import logging import os -import sys -import time from datetime import datetime -from uuid import uuid4 from typing import Optional +from uuid import uuid4 from backend.apps.agents.models import ( AgentConfig, AgentSession, Message, MessageBranch, ApprovalRequest, ToolGroupMeta, ) from backend.apps.agents.ws_manager import ws_manager -from backend.apps.modes.modes import load_mode -from backend.apps.outputs.outputs import _load_all as load_all_outputs -from backend.apps.settings.settings import load_settings -from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name -from backend.apps.tools_lib.tools_lib import ( - _load_all as load_all_tools, - derive_mcp_config, - load_builtin_permissions, - refresh_google_token, +from backend.apps.agents.prompt_builder import resolve_mode +from backend.apps.agents.mcp_builder import get_all_tool_names +from backend.apps.agents.session_store import ( + save_session, load_session_data, delete_session_file, + load_all_session_data, build_search_text, get_history, + reconcile_on_startup, get_browser_agent_children, + copy_session_messages, ) -from backend.config.paths import SESSIONS_DIR -from backend.apps.common.json_store import SessionStore +from backend.apps.agents.agent_loop import ( + run_agent_loop, fire_session_completed, +) +from backend.apps.settings.settings import load_settings +from backend.apps.common.llm_helpers import quick_llm_call, quick_llm_json from backend.apps.analytics.collector import record as _analytics logger = logging.getLogger(__name__) @@ -31,1230 +40,57 @@ logger = logging.getLogger(__name__) os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000") -_session_store = SessionStore(SESSIONS_DIR) - -_save_session = _session_store.save -_load_session_data = _session_store.load -_delete_session_file = _session_store.delete -_load_all_session_data = _session_store.load_all - -FULL_TOOLS = [ - "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", - "WebSearch", "WebFetch", "NotebookEdit", "TodoWrite", - "EnterPlanMode", "ExitPlanMode", "EnterWorktree", - "TaskOutput", "TaskStop", - "CronCreate", "CronList", "CronDelete", - "RenderOutput", - "InvokeAgent", - "Agent", -] - -def _get_denied_tool_names(tool) -> set[str]: - """Return the set of MCP sub-tool names whose permission is 'deny'.""" - return { - key for key, value in tool.tool_permissions.items() - if not key.startswith("_") and value == "deny" - } - - -def _get_all_known_tool_names(tool) -> set[str]: - """Return all known sub-tool names for an MCP tool (from _tool_descriptions).""" - return set(tool.tool_permissions.get("_tool_descriptions", {}).keys()) - - -def _is_fully_denied(tool) -> bool: - """True when every known sub-tool on this MCP server is set to 'deny'.""" - known = _get_all_known_tool_names(tool) - if not known: - return False - return known <= _get_denied_tool_names(tool) - - -def get_all_tool_names() -> list[str]: - """FULL_TOOLS + installed MCP tool identifiers (mcp:). - - Builtin tools set to 'deny' and MCP servers whose every sub-tool - is denied are excluded. - """ - builtin_perms = load_builtin_permissions() - builtin_tools = [ - t for t in FULL_TOOLS - if builtin_perms.get(t, "always_allow") != "deny" - ] - mcp_names = [ - f"mcp:{t.name}" - for t in load_all_tools() - if t.mcp_config - and t.enabled - and t.auth_status in ("configured", "connected") - and not _is_fully_denied(t) - ] - return builtin_tools + mcp_names - - class AgentManager: def __init__(self): self.sessions: dict[str, AgentSession] = {} self.tasks: dict[str, asyncio.Task] = {} - - def _resolve_mode(self, mode_id: str) -> tuple[list[str], str | None, str | None]: - """Return (tools, system_prompt, default_folder) resolved from the mode store.""" - mode_def = load_mode(mode_id) - if mode_def: - tools = mode_def.tools if mode_def.tools is not None else get_all_tool_names() - 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: - """Build the mcp_servers dict for ClaudeAgentOptions from installed MCP tools. - - 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") - - 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(): - if not any(tool_ref == at for at in allowed_tools): - logger.info(f"[MCP-DEBUG] SKIPPED {tool.name}: '{tool_ref}' not in allowed_tools") - continue - - if _is_fully_denied(tool): - logger.info(f"[MCP-DEBUG] SKIPPED {tool.name}: fully denied") - continue - - if tool.auth_type == "oauth2" and tool.auth_status == "connected": - refreshed = await refresh_google_token(tool) - logger.info(f"[MCP-DEBUG] {tool.name} token refresh: {'OK' if refreshed else 'FAILED'}") - - 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}") - else: - logger.warning(f"[MCP-DEBUG] {tool.name}: derive_mcp_config returned None") - - logger.info(f"[MCP-DEBUG] Final mcp_servers: {list(mcp_servers.keys())}") - return mcp_servers - - def _build_connected_tools_context(self, allowed_tools: list[str]) -> str | None: - """Build a context block describing connected MCP tools and their accounts. - - Tools set to 'deny' and fully-denied servers are excluded. - """ - 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")] - - sections = [] - 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) - denied = _get_denied_tool_names(tool) - tool_descs = { - k: v for k, v in tool.tool_permissions.get("_tool_descriptions", {}).items() - if k not in denied - } - if not tool_descs: - continue - - lines = [f"MCP Server: {server_name}"] - lines.append(f" Status: {tool.auth_status}") - - if tool.connected_account_email: - lines.append(f" Connected account: {tool.connected_account_email}") - lines.append( - f" IMPORTANT: When calling tools from this server that require an email " - f"parameter (e.g. user_google_email, user_email), always use " - f"\"{tool.connected_account_email}\" automatically — do NOT ask the user." - ) - - tool_names = list(tool_descs.keys()) - if tool_names: - lines.append(f" Available tools ({len(tool_names)}): {', '.join(tool_names)}") - - sections.append("\n".join(lines)) - - # Add awareness of tools that are installed but not yet connected - not_connected = [ - t for t in all_tools - if t.mcp_config and t.enabled - and t.auth_type in ("oauth2", "env_vars") - and t.auth_status != "connected" - ] - if not_connected: - nc_lines = [ - "Tools installed but not yet connected (user needs to authorize in Settings → Tools):" - ] - for t in not_connected: - nc_lines.append(f" - {t.name}") - sections.append("\n".join(nc_lines)) - - if not sections: - return None - return ( - "\n" - "The following MCP tool servers are connected and available. " - "Use them directly when relevant to the user's request.\n\n" - + "\n\n".join(sections) - + "\n" - ) - - def _build_outputs_context(self) -> str | None: - """Build a context block describing available Outputs the agent can render.""" - import json as _json - all_outputs = load_all_outputs() - if not all_outputs: - return None - - sections = [] - 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)) - - return ( - "\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) - + "\n" - ) - - def _build_browser_context(self, dashboard_id: str | None, selected_browser_ids: list[str] | None = None) -> str | None: - """Build a context block listing browser cards and delegation instructions. - - Only browser cards explicitly selected by the user are included. - If none are selected, no browser card details are exposed. - """ - if not dashboard_id: - return None - try: - from backend.apps.dashboards.dashboards import _load as load_dashboard - dashboard = load_dashboard(dashboard_id) - except Exception: - return None - raw = dashboard.model_dump(mode="json") - browser_cards = raw.get("layout", {}).get("browser_cards", {}) - - lines = [ - "", - "You have access to browser automation through the CreateBrowserAgent, BrowserAgent, and BrowserAgents tools.", - "", - "- **CreateBrowserAgent(task, url?)**: Create a new browser card and run a task on it. " - "Use this when you need a fresh browser. Optionally provide a starting URL.", - "- **BrowserAgent(browser_id, task)**: Delegate a task to an existing browser card. " - "The browser agent will autonomously navigate, click, type, and interact with the page, then return a summary and screenshot.", - "- **BrowserAgents(tasks)**: Run multiple browser tasks in parallel on existing browser cards. " - "Each task requires a browser_id.", - "", - "You do NOT have direct access to low-level browser tools (click, type, screenshot, etc.). " - "Instead, describe what you want accomplished and the browser agent will handle the details.", - ] - - if browser_cards and selected_browser_ids: - visible_cards = [ - card for card in browser_cards.values() - if card.get("browser_id", "") in selected_browser_ids - ] - if visible_cards: - lines.append("") - lines.append("The user selected these browser cards for you to work with:") - for card in visible_cards: - bid = card.get("browser_id", "") - tabs = card.get("tabs", []) - active_tab_id = card.get("activeTabId", "") - active_tab = next((t for t in tabs if t.get("id") == active_tab_id), None) - url = (active_tab or {}).get("url", card.get("url", "")) - title = (active_tab or {}).get("title", "") - lines.append(f"- browser_id: \"{bid}\"") - if title: - lines.append(f" Title: {title}") - if url: - lines.append(f" URL: {url}") - - lines.append("") - return "\n".join(lines) - - def _get_pre_selected_browser_ids(self, dashboard_id: str | None) -> list[str]: - """Return browser_ids of all browser cards currently on the dashboard.""" - if not dashboard_id: - return [] - try: - from backend.apps.dashboards.dashboards import _load as load_dashboard - dashboard = load_dashboard(dashboard_id) - except Exception: - return [] - raw = dashboard.model_dump(mode="json") - 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] - return "\n\n".join(parts) if parts else None + # ------------------------------------------------------------------ + # Session lifecycle + # ------------------------------------------------------------------ async def launch_agent(self, config: AgentConfig) -> AgentSession: session_id = uuid4().hex - - mode_tools, _, mode_folder = self._resolve_mode(config.mode) - tools = mode_tools - + mode_tools, _, mode_folder = resolve_mode(config.mode, get_all_tool_names) global_settings = load_settings() effective_cwd = ( - config.target_directory - or mode_folder - or global_settings.default_folder - or os.path.expanduser("~") + config.target_directory or mode_folder + or global_settings.default_folder or os.path.expanduser("~") ) - if config.mode in ("view-builder", "skill-builder") and not config.target_directory: effective_cwd = os.path.join(effective_cwd, session_id) - os.makedirs(effective_cwd, exist_ok=True) session = AgentSession( - id=session_id, - name=config.name, + id=session_id, name=config.name, provider=getattr(config, "provider", "anthropic"), - model=config.model, - mode=config.mode, - system_prompt=config.system_prompt, - allowed_tools=tools, - max_turns=config.max_turns, - cwd=effective_cwd, + model=config.model, mode=config.mode, + system_prompt=config.system_prompt, allowed_tools=mode_tools, + max_turns=config.max_turns, cwd=effective_cwd, dashboard_id=config.dashboard_id, ) self.sessions[session_id] = session - _analytics("session.started", { - "model": session.model, - "provider": session.provider, - "mode": session.mode, - "tool_count": len(tools), + "model": session.model, "provider": session.provider, + "mode": session.mode, "tool_count": len(mode_tools), }, session_id=session_id, dashboard_id=config.dashboard_id) - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "running", + "session_id": session_id, "status": "running", "session": session.model_dump(mode="json"), }) - return session - def _resolve_context_paths(self, context_paths: list | None) -> str: - """Read file contents / directory trees for attached context paths.""" - if not context_paths: - return "" - sections = [] - for cp in context_paths: - path = cp.get("path", "") - cp_type = cp.get("type", "file") - if not path or not os.path.exists(path): - sections.append(f"[Context: {path} — not found]") - continue - if cp_type == "file" and os.path.isfile(path): - try: - with open(path, "r", errors="replace") as f: - content = f.read(512_000) # ~500KB cap per file - sections.append( - f"\n{content}\n" - ) - except Exception as e: - sections.append(f"[Context: {path} — error reading: {e}]") - elif cp_type == "directory" and os.path.isdir(path): - tree_lines = self._build_dir_tree(path, max_depth=4) - sections.append( - f"\n{chr(10).join(tree_lines)}\n" - ) - else: - sections.append(f"[Context: {path} — type mismatch]") - return "\n\n".join(sections) - - def _build_dir_tree(self, root: str, max_depth: int = 4, prefix: str = "") -> list[str]: - """Build a recursive directory tree listing.""" - lines = [] - try: - entries = sorted(os.listdir(root)) - except PermissionError: - return [f"{prefix}[permission denied]"] - dirs = [e for e in entries if not e.startswith(".") and os.path.isdir(os.path.join(root, e))] - files = [e for e in entries if not e.startswith(".") and os.path.isfile(os.path.join(root, e))] - for f in files: - lines.append(f"{prefix}{f}") - for d in dirs: - lines.append(f"{prefix}{d}/") - if max_depth > 1: - sub = self._build_dir_tree(os.path.join(root, d), max_depth - 1, prefix + " ") - lines.extend(sub) - return lines - - def _resolve_forced_tools(self, forced_tools: list[str] | None) -> str: - """Build a context block describing explicitly requested tools.""" - if not forced_tools: - return "" - from backend.apps.tools_lib.models import BUILTIN_TOOLS - desc_map: dict[str, str] = {t.name: t.description for t in BUILTIN_TOOLS} - tool_to_server: dict[str, str] = {} - tool_to_email: dict[str, str] = {} - for t in load_all_tools(): - if not t.enabled or not t.tool_permissions: - continue - tool_descs = t.tool_permissions.get("_tool_descriptions", {}) - server_name = _sanitize_server_name(t.name) - for tn, td in tool_descs.items(): - desc_map[tn] = td - tool_to_server[tn] = server_name - if t.connected_account_email: - tool_to_email[tn] = t.connected_account_email - - lines = [] - for name in forced_tools: - desc = desc_map.get(name, "") - line = f"- {name}: {desc}" if desc else f"- {name}" - server = tool_to_server.get(name) - if server: - line += f"\n (MCP server: {server})" - email = tool_to_email.get(name) - if email: - line += f"\n (connected account: {email} — use this for any email parameter)" - lines.append(line) - - return ( - "\n" - "The user explicitly requested these tools be used. " - "Prioritize using them to address the user's request.\n" - + "\n".join(lines) - + "\n" - ) - - def _resolve_attached_skills(self, attached_skills: list | None) -> str: - """Build a context block injecting attached skill content into the prompt.""" - if not attached_skills: - return "" - sections = [] - for skill in attached_skills: - name = skill.get("name", "Unknown") - content = skill.get("content", "") - if content: - sections.append(f"[Using skill: {name}]\n\n{content}") - return "\n\n".join(sections) - - def _build_prompt_content(self, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None): - """Build message content with optional image blocks, context, and forced tools for the Claude API.""" - context_text = self._resolve_context_paths(context_paths) - forced_tools_text = self._resolve_forced_tools(forced_tools) - skills_text = self._resolve_attached_skills(attached_skills) - - parts = [p for p in (forced_tools_text, context_text, skills_text, prompt) if p] - full_prompt = "\n\n".join(parts) - - if not images: - return full_prompt - content = [{"type": "text", "text": full_prompt}] - for img in images: - content.append({ - "type": "image", - "source": { - "type": "base64", - "media_type": img.get("media_type", "image/png"), - "data": img["data"], - }, - }) - return content - - async def _run_agent_loop(self, session_id: str, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, fork_session: bool = False, selected_browser_ids: list[str] | None = None): - """Run the Claude Agent SDK query loop for a session.""" - session = self.sessions.get(session_id) - if not session: - return - - prompt_content = self._build_prompt_content(prompt, images, context_paths, forced_tools, attached_skills) - - try: - from claude_agent_sdk import ( - query, ClaudeAgentOptions, AssistantMessage, ResultMessage, - ) - from claude_agent_sdk.types import ( - HookMatcher, PermissionResultAllow, PermissionResultDeny, - TextBlock, ToolUseBlock, StreamEvent, - SystemMessage, - ) - except ImportError: - logger.warning("claude_agent_sdk not installed, running in mock mode") - await self._run_mock_agent(session_id, prompt) - return - - session.status = "running" - - _builtin_perms = load_builtin_permissions() - - def _get_effective_policy(tool_name: str) -> str: - """Return 'always_allow', 'deny', or 'ask' for any tool.""" - if tool_name in _builtin_perms: - return _builtin_perms[tool_name] - - import re as _re - - bm = _re.match(r"mcp__openswarm-browser-agent__(.+)", tool_name) - if bm: - return _builtin_perms.get(bm.group(1), "always_allow") - - im = _re.match(r"mcp__openswarm-invoke-agent__(.+)", tool_name) - if im: - return _builtin_perms.get(im.group(1), "always_allow") - - m = _re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name) - if m: - server_slug, mcp_tool_name = m.group(1), m.group(2) - for t in load_all_tools(): - if not t.mcp_config or not t.enabled: - continue - if _sanitize_server_name(t.name) == server_slug: - return t.tool_permissions.get(mcp_tool_name, "ask") - return "always_allow" - - async def _request_user_approval(tool_name: str, tool_input) -> dict: - """Send an approval request via WebSocket and wait for the user's decision.""" - safe_input = tool_input if isinstance(tool_input, dict) else {} - request_id = uuid4().hex - approval_req = ApprovalRequest( - id=request_id, - session_id=session_id, - tool_name=tool_name, - tool_input=safe_input, - ) - session.pending_approvals.append(approval_req) - session.status = "waiting_approval" - - _analytics("approval.requested", { - "tool_name": tool_name, - "is_first_approval_in_session": len(session.pending_approvals) == 1, - "model": session.model, - }, session_id=session_id, dashboard_id=session.dashboard_id) - - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "waiting_approval", - }) - - decision = await ws_manager.send_approval_request( - session_id, request_id, tool_name, safe_input - ) - - approval_latency_ms = int((datetime.now() - approval_req.created_at).total_seconds() * 1000) - _analytics("approval.resolved", { - "tool_name": tool_name, - "decision": decision.get("behavior", "unknown"), - "latency_ms": approval_latency_ms, - "input_was_modified": decision.get("updated_input") is not None, - "model": session.model, - }, session_id=session_id, dashboard_id=session.dashboard_id) - - session.pending_approvals = [ - a for a in session.pending_approvals if a.id != request_id - ] - session.status = "running" - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "running", - }) - return decision - - async def can_use_tool(tool_name, input_data, context): - if tool_name != "AskUserQuestion": - policy = _get_effective_policy(tool_name) - if policy == "always_allow": - return PermissionResultAllow(updated_input=input_data) - if policy == "deny": - return PermissionResultDeny(message="Tool denied by permission policy") - - decision = await _request_user_approval(tool_name, input_data) - if decision.get("behavior") == "allow": - return PermissionResultAllow( - updated_input=decision.get("updated_input", input_data) - ) - return PermissionResultDeny( - message=decision.get("message", "User denied this action") - ) - - tool_start_times: dict[str, float] = {} - - async def pre_tool_hook(input_data, tool_use_id, context): - tool_name = input_data.get("tool_name", "") - hook_event = input_data.get("hook_event_name", "PreToolUse") - - if tool_name and tool_name != "AskUserQuestion": - policy = _get_effective_policy(tool_name) - - if policy == "deny": - return { - "hookSpecificOutput": { - "hookEventName": hook_event, - "permissionDecision": "deny", - "permissionDecisionReason": "Tool denied by permission policy", - } - } - - if policy == "ask": - tool_input = input_data.get("tool_input", {}) - decision = await _request_user_approval(tool_name, tool_input) - - if decision.get("behavior") == "allow": - if tool_use_id: - tool_start_times[tool_use_id] = time.time() - return { - "hookSpecificOutput": { - "hookEventName": hook_event, - "permissionDecision": "allow", - } - } - return { - "hookSpecificOutput": { - "hookEventName": hook_event, - "permissionDecision": "deny", - "permissionDecisionReason": decision.get("message", "User denied this action"), - } - } - - if tool_use_id: - tool_start_times[tool_use_id] = time.time() - return {} - - async def post_tool_hook(input_data, tool_use_id, context): - elapsed_ms = None - if tool_use_id and tool_use_id in tool_start_times: - elapsed_ms = int((time.time() - tool_start_times.pop(tool_use_id)) * 1000) - - raw_response = input_data.get("tool_response", "") - - # Track individual tool execution - hook_tool_name_early = input_data.get("tool_name", "") - if hook_tool_name_early: - import re as _re_tool - _is_mcp = "__" in hook_tool_name_early - _mcp_server = "" - _tool_short = hook_tool_name_early - if _is_mcp: - _mcp_match = _re_tool.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", hook_tool_name_early) - if _mcp_match: - _mcp_server = _mcp_match.group(1) - _tool_short = _mcp_match.group(2) - _analytics("tool.executed", { - "tool_name": hook_tool_name_early, - "tool_short_name": _tool_short, - "tool_type": "mcp" if _is_mcp else "builtin", - "mcp_server": _mcp_server, - "duration_ms": elapsed_ms, - "success": not (isinstance(raw_response, str) and raw_response.startswith("Error")), - "model": session.model, - "provider": session.provider, - }, session_id=session_id, dashboard_id=session.dashboard_id) - - if isinstance(raw_response, list) and raw_response: - text_parts = [ - block.get("text", "") - for block in raw_response - if isinstance(block, dict) and block.get("type") == "text" - ] - if text_parts: - raw_response = "\n".join(text_parts) if len(text_parts) > 1 else text_parts[0] - - if isinstance(raw_response, str): - content = raw_response - else: - try: - import json as _json - content = _json.dumps(raw_response, indent=2, default=str) - except Exception: - content = str(raw_response) - - result_payload = {"text": content} - hook_tool_name = input_data.get("tool_name", "") - if hook_tool_name: - result_payload["tool_name"] = hook_tool_name - if elapsed_ms is not None: - result_payload["elapsed_ms"] = elapsed_ms - - if hook_tool_name == "Agent": - tool_input = input_data.get("tool_input", {}) - agent_prompt = tool_input.get("prompt", tool_input.get("task", "")) - - sub_text = content - sub_cost = 0.0 - sub_tokens = {"input": 0, "output": 0} - sub_model = session.model - if isinstance(raw_response, dict): - blocks = raw_response.get("content") - if isinstance(blocks, list): - parts = [ - b.get("text", "") - for b in blocks - if isinstance(b, dict) and b.get("type") == "text" - ] - if parts: - sub_text = "\n".join(parts) if len(parts) > 1 else parts[0] - elif isinstance(raw_response.get("text"), str): - sub_text = raw_response["text"] - usage = raw_response.get("usage", {}) - if isinstance(usage, dict): - sub_tokens["input"] = usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0) - sub_tokens["output"] = usage.get("output_tokens", 0) - if raw_response.get("model"): - sub_model = raw_response["model"] - - sub_session_id = uuid4().hex - sub_name = agent_prompt[:50] if agent_prompt else "Sub-agent" - sub_session = AgentSession( - id=sub_session_id, - name=sub_name, - status="completed", - model=sub_model, - mode="sub-agent", - cwd=session.cwd, - created_at=datetime.now(), - cost_usd=sub_cost, - tokens=sub_tokens, - messages=[ - Message(role="user", content=agent_prompt, branch_id="main"), - Message(role="assistant", content=sub_text, branch_id="main"), - ], - dashboard_id=session.dashboard_id, - parent_session_id=session_id, - ) - self.sessions[sub_session_id] = sub_session - await ws_manager.broadcast_global("agent:status", { - "session_id": sub_session_id, - "status": sub_session.status, - "session": sub_session.model_dump(mode="json"), - }) - result_payload["sub_session_id"] = sub_session_id - - result_msg = Message(role="tool_result", content=result_payload, branch_id=session.active_branch_id) - session.messages.append(result_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": result_msg.model_dump(mode="json"), - }) - return {"continue_": True} - - try: - _, mode_sys_prompt, _ = self._resolve_mode(session.mode) - connected_tools_ctx = self._build_connected_tools_context(session.allowed_tools) - outputs_ctx = self._build_outputs_context() - browser_ctx = self._build_browser_context(session.dashboard_id, selected_browser_ids=selected_browser_ids) - 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) - - if session.mode == "view-builder": - from backend.apps.outputs.view_builder_templates import VIEW_BUILDER_SKILL - skill_block = f"\n{VIEW_BUILDER_SKILL}\n" - 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) - - _browser_delegation_tools = ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents"] - _browser_all_denied = all( - _builtin_perms.get(t, "always_allow") == "deny" - for t in _browser_delegation_tools - ) - - if not _browser_all_denied: - browser_agent_server_path = os.path.join( - os.path.dirname(__file__), "browser_agent_mcp_server.py" - ) - backend_port = os.environ.get("OPENSWARM_PORT", "8324") - pre_selected_bids = self._get_pre_selected_browser_ids(session.dashboard_id) - mcp_servers["openswarm-browser-agent"] = { - "command": sys.executable, - "args": [browser_agent_server_path], - "env": { - "OPENSWARM_PORT": backend_port, - "OPENSWARM_AGENT_MODEL": session.model, - "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", - "OPENSWARM_PRE_SELECTED_BROWSER_IDS": ",".join(pre_selected_bids), - "OPENSWARM_PARENT_SESSION_ID": session.id, - }, - "type": "stdio", - } - - _invoke_agent_tools = ["InvokeAgent"] - _invoke_all_denied = all( - _builtin_perms.get(t, "always_allow") == "deny" - for t in _invoke_agent_tools - ) - - if not _invoke_all_denied: - invoke_agent_server_path = os.path.join( - os.path.dirname(__file__), "invoke_agent_mcp_server.py" - ) - backend_port = os.environ.get("OPENSWARM_PORT", "8324") - mcp_servers["openswarm-invoke-agent"] = { - "command": sys.executable, - "args": [invoke_agent_server_path], - "env": { - "OPENSWARM_PORT": backend_port, - "OPENSWARM_PARENT_SESSION_ID": session.id, - "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", - }, - "type": "stdio", - } - - effective_allowed = [ - t for t in session.allowed_tools - if t in FULL_TOOLS and _builtin_perms.get(t, "always_allow") == "always_allow" - ] - - effective_disallowed = [ - t for t in FULL_TOOLS - if _builtin_perms.get(t, "always_allow") == "deny" - ] - - if mcp_servers: - all_tools_list = load_all_tools() - for name in mcp_servers: - if name == "openswarm-browser-agent": - for bt in _browser_delegation_tools: - policy = _builtin_perms.get(bt, "always_allow") - if policy == "always_allow": - effective_allowed.append(f"mcp__openswarm-browser-agent__{bt}") - elif policy == "deny": - effective_disallowed.append(f"mcp__openswarm-browser-agent__{bt}") - continue - - if name == "openswarm-invoke-agent": - for it in _invoke_agent_tools: - policy = _builtin_perms.get(it, "always_allow") - if policy == "always_allow": - effective_allowed.append(f"mcp__openswarm-invoke-agent__{it}") - elif policy == "deny": - effective_disallowed.append(f"mcp__openswarm-invoke-agent__{it}") - continue - - tool_def = next( - (t for t in all_tools_list - if t.mcp_config and t.enabled and _sanitize_server_name(t.name) == name), - None, - ) - if tool_def: - denied = _get_denied_tool_names(tool_def) - known = _get_all_known_tool_names(tool_def) - for tn in known - denied: - policy = tool_def.tool_permissions.get(tn, "ask") - if policy == "always_allow": - effective_allowed.append(f"mcp__{name}__{tn}") - for tn in denied: - effective_disallowed.append(f"mcp__{name}__{tn}") - else: - effective_allowed.append(f"mcp__{name}__*") - - # Log effective tool lists - google_allowed = [t for t in effective_allowed if "google-workspace" in t] - reddit_allowed = [t for t in effective_allowed if "reddit" in t] - builtin_allowed = [t for t in effective_allowed if not t.startswith("mcp__")] - logger.info(f"[MCP-DEBUG] effective_allowed: {len(effective_allowed)} total " - f"(builtins={len(builtin_allowed)}, google={len(google_allowed)}, reddit={len(reddit_allowed)})") - if effective_disallowed: - logger.info(f"[MCP-DEBUG] effective_disallowed: {effective_disallowed}") - - options_kwargs = { - "model": session.model, - "max_buffer_size": 5 * 1024 * 1024, - "permission_mode": "default", - "can_use_tool": can_use_tool, - "hooks": { - "PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])], - "PostToolUse": [HookMatcher(matcher=None, hooks=[post_tool_hook])], - }, - "allowed_tools": effective_allowed, - "disallowed_tools": effective_disallowed, - "include_partial_messages": True, - } - # Priority: API key → 9Router subscription - from backend.apps.nine_router import is_running as _9r_running - if global_settings.anthropic_api_key: - options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key} - logger.info("[MCP-DEBUG] Using direct API key") - elif _9r_running(): - options_kwargs["env"] = { - "ANTHROPIC_API_KEY": "9router", - "ANTHROPIC_BASE_URL": "http://localhost:20128", - } - # --bare skips CLI's own OAuth/keychain auth, uses only ANTHROPIC_API_KEY - options_kwargs["extra_args"] = {"bare": None} - logger.info("[MCP-DEBUG] Using 9Router (bare mode)") - else: - raise ValueError("No AI provider configured. Set an API key or connect a subscription.") - if mcp_servers: - options_kwargs["mcp_servers"] = mcp_servers - mcp_json_len = len(json.dumps({"mcpServers": mcp_servers})) - logger.info(f"[MCP-DEBUG] mcp_servers passed to SDK: {list(mcp_servers.keys())}, JSON length={mcp_json_len}") - if composed_prompt: - options_kwargs["system_prompt"] = composed_prompt - if session.max_turns: - options_kwargs["max_turns"] = session.max_turns - - if session.cwd: - options_kwargs["cwd"] = session.cwd - - if session.sdk_session_id: - options_kwargs["resume"] = session.sdk_session_id - if fork_session: - options_kwargs["fork_session"] = True - - logger.info(f"[MCP-DEBUG] Creating ClaudeAgentOptions with model={session.model}") - options = ClaudeAgentOptions(**options_kwargs) - logger.info(f"[MCP-DEBUG] ClaudeAgentOptions created. Starting query...") - - async def prompt_stream(): - yield { - "type": "user", - "message": {"role": "user", "content": prompt_content}, - } - - stream_text_msg_id = None - stream_tool_msg_ids_ordered = [] - stream_block_index_map = {} - _turn_number = 0 - _first_event = True - - async for message in query( - prompt=prompt_stream(), - options=options, - ): - if _first_event: - logger.info(f"[MCP-DEBUG] First event received: {type(message).__name__}") - _first_event = False - - # Log system messages (MCP server status, errors, etc.) - if isinstance(message, SystemMessage): - raw = message.__dict__ if hasattr(message, '__dict__') else str(message) - logger.info(f"[MCP-DEBUG] SystemMessage: {raw}") - - if isinstance(message, StreamEvent): - event = message.event - event_type = event.get("type") - - if event_type == "content_block_start": - block = event.get("content_block", {}) - index = event.get("index") - block_type = block.get("type") - - if block_type == "text": - if stream_text_msg_id is None: - stream_text_msg_id = uuid4().hex - await ws_manager.send_to_session(session_id, "agent:stream_start", { - "session_id": session_id, - "message_id": stream_text_msg_id, - "role": "assistant", - }) - stream_block_index_map[index] = stream_text_msg_id - - elif block_type == "tool_use": - tool_msg_id = uuid4().hex - stream_tool_msg_ids_ordered.append(tool_msg_id) - stream_block_index_map[index] = tool_msg_id - await ws_manager.send_to_session(session_id, "agent:stream_start", { - "session_id": session_id, - "message_id": tool_msg_id, - "role": "tool_call", - "tool_name": block.get("name", ""), - }) - - elif event_type == "content_block_delta": - index = event.get("index") - delta = event.get("delta", {}) - delta_type = delta.get("type") - msg_id = stream_block_index_map.get(index) - - if msg_id and delta_type == "text_delta": - await ws_manager.send_to_session(session_id, "agent:stream_delta", { - "session_id": session_id, - "message_id": msg_id, - "delta": delta.get("text", ""), - }) - elif msg_id and delta_type == "input_json_delta": - await ws_manager.send_to_session(session_id, "agent:stream_delta", { - "session_id": session_id, - "message_id": msg_id, - "delta": delta.get("partial_json", ""), - }) - - elif event_type == "content_block_stop": - index = event.get("index") - msg_id = stream_block_index_map.get(index) - if msg_id and msg_id != stream_text_msg_id: - await ws_manager.send_to_session(session_id, "agent:stream_end", { - "session_id": session_id, - "message_id": msg_id, - }) - - elif event_type == "message_stop": - if stream_text_msg_id: - await ws_manager.send_to_session(session_id, "agent:stream_end", { - "session_id": session_id, - "message_id": stream_text_msg_id, - }) - - elif isinstance(message, AssistantMessage): - content_parts = [] - tool_uses = [] - for block in message.content: - if isinstance(block, TextBlock): - content_parts.append(block.text) - elif isinstance(block, ToolUseBlock): - tool_uses.append({ - "id": block.id, - "tool": block.name, - "input": block.input, - }) - - if content_parts: - asst_msg = Message( - id=stream_text_msg_id or uuid4().hex, - role="assistant", - content="\n".join(content_parts), - branch_id=session.active_branch_id, - ) - session.messages.append(asst_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": asst_msg.model_dump(mode="json"), - }) - - for i, tu in enumerate(tool_uses): - msg_id = stream_tool_msg_ids_ordered[i] if i < len(stream_tool_msg_ids_ordered) else uuid4().hex - tool_msg = Message(id=msg_id, role="tool_call", content=tu, branch_id=session.active_branch_id) - session.messages.append(tool_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": tool_msg.model_dump(mode="json"), - }) - - _turn_number += 1 - _analytics("turn.completed", { - "turn_number": _turn_number, - "tool_calls_in_turn": len(tool_uses), - "model": session.model, - }, session_id=session_id, dashboard_id=session.dashboard_id) - - stream_text_msg_id = None - stream_tool_msg_ids_ordered = [] - stream_block_index_map = {} - - elif isinstance(message, ResultMessage): - session.sdk_session_id = getattr(message, "session_id", None) - cost = getattr(message, "total_cost_usd", None) - if cost is not None: - session.cost_usd = cost - await ws_manager.send_to_session(session_id, "agent:cost_update", { - "session_id": session_id, - "cost_usd": session.cost_usd, - }) - # Extract token usage from ResultMessage - usage = getattr(message, "usage", None) or {} - if isinstance(usage, dict): - inp = usage.get("input_tokens", 0) or 0 - 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 - session.tokens["output"] = out - - session.status = "completed" - except asyncio.CancelledError: - session.status = "stopped" - except Exception as e: - logger.exception(f"Agent {session_id} error: {e}") - session.status = "error" - _analytics("session.error", { - "error_type": type(e).__name__, - "error_message": str(e)[:500], - "model": session.model, - "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"), - }) - finally: - if session_id in self.sessions: - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": session.status, - "session": session.model_dump(mode="json"), - }) - try: - _save_session(session_id, session.model_dump(mode="json")) - except Exception as e: - logger.warning(f"Failed to snapshot session {session_id}: {e}") - - async def _stream_text(self, session_id: str, msg_id: str, text: str, delay: float = 0.03): - """Emit stream_start, word-by-word deltas, and stream_end for a text message.""" - await ws_manager.send_to_session(session_id, "agent:stream_start", { - "session_id": session_id, - "message_id": msg_id, - "role": "assistant", - }) - words = text.split(" ") - for i, word in enumerate(words): - chunk = word if i == 0 else " " + word - await ws_manager.send_to_session(session_id, "agent:stream_delta", { - "session_id": session_id, - "message_id": msg_id, - "delta": chunk, - }) - await asyncio.sleep(delay) - await ws_manager.send_to_session(session_id, "agent:stream_end", { - "session_id": session_id, - "message_id": msg_id, - }) - - async def _stream_tool_input(self, session_id: str, msg_id: str, tool_name: str, input_json: str, delay: float = 0.02): - """Emit stream_start, chunked deltas, and stream_end for a tool_call input.""" - await ws_manager.send_to_session(session_id, "agent:stream_start", { - "session_id": session_id, - "message_id": msg_id, - "role": "tool_call", - "tool_name": tool_name, - }) - chunk_size = 12 - for i in range(0, len(input_json), chunk_size): - await ws_manager.send_to_session(session_id, "agent:stream_delta", { - "session_id": session_id, - "message_id": msg_id, - "delta": input_json[i:i + chunk_size], - }) - await asyncio.sleep(delay) - await ws_manager.send_to_session(session_id, "agent:stream_end", { - "session_id": session_id, - "message_id": msg_id, - }) - - async def _run_mock_agent(self, session_id: str, prompt: str): - """Mock agent loop for development without claude_agent_sdk installed.""" - session = self.sessions.get(session_id) - if not session: - return - - await asyncio.sleep(1) - - request_id = uuid4().hex - approval_req = ApprovalRequest( - id=request_id, - session_id=session_id, - tool_name="Bash", - tool_input={"command": f"echo 'Processing: {prompt}'", "description": "Echo the user prompt"}, - ) - session.pending_approvals.append(approval_req) - session.status = "waiting_approval" - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "waiting_approval", - }) - - decision = await ws_manager.send_approval_request( - session_id, request_id, "Bash", - {"command": f"echo 'Processing: {prompt}'", "description": "Echo the user prompt"} - ) - - session.pending_approvals = [a for a in session.pending_approvals if a.id != request_id] - session.status = "running" - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "running", - }) - - import json as _json - tool_input_content = {"tool": "Bash", "input": {"command": f"echo 'Processing: {prompt}'"}, "approved": decision.get("behavior") == "allow"} - tool_msg_id = uuid4().hex - await self._stream_tool_input( - session_id, tool_msg_id, "Bash", - _json.dumps(tool_input_content["input"], indent=2), - ) - tool_msg = Message(id=tool_msg_id, role="tool_call", content=tool_input_content, branch_id=session.active_branch_id) - session.messages.append(tool_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": tool_msg.model_dump(mode="json"), - }) - - await asyncio.sleep(1) - - if decision.get("behavior") == "allow": - tool_result = Message(role="tool_result", content=f"Processing: {prompt}", branch_id=session.active_branch_id) - session.messages.append(tool_result) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": tool_result.model_dump(mode="json"), - }) - - await asyncio.sleep(1) - - asst_text = ( - f"I've processed your request: \"{prompt}\"\n\n" - "This is a mock response because `claude-agent-sdk` is not installed. " - "Install it with `pip install claude-agent-sdk` to use real Claude Code instances.\n\n" - f"The agent was configured with:\n- Model: {session.model}\n- Mode: {session.mode}" - ) - asst_msg_id = uuid4().hex - await self._stream_text(session_id, asst_msg_id, asst_text) - - asst_msg = Message(id=asst_msg_id, role="assistant", content=asst_text, branch_id=session.active_branch_id) - session.messages.append(asst_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": asst_msg.model_dump(mode="json"), - }) - - session.status = "completed" - session.closed_at = datetime.now() - session.cost_usd = 0.001 - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "completed", - "session": session.model_dump(mode="json"), - }) - await ws_manager.send_to_session(session_id, "agent:cost_update", { - "session_id": session_id, - "cost_usd": session.cost_usd, - }) - async def send_message( - self, - session_id: str, - prompt: str, - mode: str | None = None, - model: str | None = None, - provider: str | None = None, - images: list | None = None, - context_paths: list | None = None, - forced_tools: list[str] | None = None, - attached_skills: list | None = None, - hidden: bool = False, + self, session_id: str, prompt: str, + mode: str | None = None, model: str | None = None, + provider: str | None = None, images: list | None = None, + context_paths: list | None = None, forced_tools: list[str] | None = None, + attached_skills: list | None = None, hidden: bool = False, selected_browser_ids: list[str] | None = None, ): - """Send a follow-up message to an existing session.""" session = self.sessions.get(session_id) if not session: raise ValueError(f"Session {session_id} not found") - existing = self.tasks.get(session_id) if existing and not existing.done(): return @@ -1262,90 +98,66 @@ class AgentManager: session_changed = False if model and model != session.model: _analytics("model.switched", { - "from_model": session.model, - "to_model": model, - "from_provider": session.provider, - "to_provider": provider or session.provider, + "from_model": session.model, "to_model": model, + "from_provider": session.provider, "to_provider": provider or session.provider, "message_number": len([m for m in session.messages if m.role == "user"]), "cost_so_far": session.cost_usd, }, session_id=session_id, dashboard_id=session.dashboard_id) session.model = model session_changed = True if mode and mode != session.mode: - _analytics("feature.used", { - "feature": "mode.switched", - "from_mode": session.mode, - "to_mode": mode, - }, session_id=session_id, dashboard_id=session.dashboard_id) + _analytics("feature.used", {"feature": "mode.switched", "from_mode": session.mode, "to_mode": mode}, session_id=session_id, dashboard_id=session.dashboard_id) session.mode = mode - mode_tools, _, _ = self._resolve_mode(mode) + mode_tools, _, _ = resolve_mode(mode, get_all_tool_names) session.allowed_tools = mode_tools session_changed = True if session_changed: await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": session.status, + "session_id": session_id, "status": session.status, "session": session.model_dump(mode="json"), }) skill_meta = [{"id": s["id"], "name": s["name"]} for s in (attached_skills or [])] or None image_meta = [{"data": img["data"], "media_type": img.get("media_type", "image/png")} for img in (images or [])] or None user_msg = Message( - role="user", - content=prompt, - branch_id=session.active_branch_id, - context_paths=context_paths if context_paths else None, - attached_skills=skill_meta, - forced_tools=forced_tools if forced_tools else None, - images=image_meta, - hidden=hidden, + role="user", content=prompt, branch_id=session.active_branch_id, + context_paths=context_paths or None, attached_skills=skill_meta, + forced_tools=forced_tools or None, images=image_meta, hidden=hidden, ) session.messages.append(user_msg) await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": user_msg.model_dump(mode="json"), + "session_id": session_id, "message": user_msg.model_dump(mode="json"), }) - - # Track context attachment patterns if context_paths or attached_skills or images or forced_tools: _analytics("context.attached", { "file_count": len([c for c in (context_paths or []) if c.get("type") == "file"]), "directory_count": len([c for c in (context_paths or []) if c.get("type") == "directory"]), - "skill_count": len(attached_skills or []), - "image_count": len(images or []), + "skill_count": len(attached_skills or []), "image_count": len(images or []), "has_forced_tools": bool(forced_tools), }, session_id=session_id, dashboard_id=session.dashboard_id) - - # Track skill usage for skill in (attached_skills or []): - _analytics("feature.used", { - "feature": "skill.used", - "skill_name": skill.get("name", ""), - }, session_id=session_id, dashboard_id=session.dashboard_id) - - # Track first message sophistication - is_first_message = sum(1 for m in session.messages if m.role == "user") == 1 - if is_first_message: + _analytics("feature.used", {"feature": "skill.used", "skill_name": skill.get("name", "")}, session_id=session_id, dashboard_id=session.dashboard_id) + is_first = sum(1 for m in session.messages if m.role == "user") == 1 + if is_first: _analytics("session.first_message", { - "message_length": len(prompt), - "has_code_block": "```" in prompt, + "message_length": len(prompt), "has_code_block": "```" in prompt, "has_url": "http://" in prompt or "https://" in prompt, - "model": session.model, - "mode": session.mode, + "model": session.model, "mode": session.mode, }, session_id=session_id, dashboard_id=session.dashboard_id) session.status = "running" await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "running", + "session_id": session_id, "status": "running", "session": session.model_dump(mode="json"), }) - - task = asyncio.create_task(self._run_agent_loop(session_id, prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids)) + task = asyncio.create_task(run_agent_loop( + self.sessions, session_id, prompt, images=images, + context_paths=context_paths, forced_tools=forced_tools, + attached_skills=attached_skills, selected_browser_ids=selected_browser_ids, + )) self.tasks[session_id] = task async def stop_agent(self, session_id: str): - """Stop a running agent and all its browser-agent children.""" task = self.tasks.get(session_id) if task and not task.done(): task.cancel() @@ -1353,42 +165,31 @@ class AgentManager: await task except asyncio.CancelledError: pass - session = self.sessions.get(session_id) if session: for req in list(session.pending_approvals): ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Agent stopped"}) session.pending_approvals = [] - if hasattr(session, '_cancel_event'): session._cancel_event.set() - session.status = "stopped" if not session.closed_at: session.closed_at = datetime.now() await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "stopped", + "session_id": session_id, "status": "stopped", "session": session.model_dump(mode="json"), }) - - children = [ - s for s in self.sessions.values() - if s.parent_session_id == session_id and s.mode == "browser-agent" - ] + children = [s for s in self.sessions.values() if s.parent_session_id == session_id and s.mode == "browser-agent"] for child in children: await self.stop_agent(child.id) def handle_approval(self, request_id: str, decision: dict): - """Resolve a pending HITL approval.""" ws_manager.resolve_approval(request_id, decision) async def edit_message(self, session_id: str, message_id: str, new_content: str): - """Edit a prior user message, creating a new branch (fork).""" session = self.sessions.get(session_id) if not session: raise ValueError(f"Session {session_id} not found") - existing = self.tasks.get(session_id) if existing and not existing.done(): existing.cancel() @@ -1397,37 +198,23 @@ class AgentManager: except asyncio.CancelledError: pass - target_msg = None - for i, msg in enumerate(session.messages): - if msg.id == message_id: - target_msg = msg - break - + target_msg = next((m for m in session.messages if m.id == message_id), None) if not target_msg or target_msg.role != "user": raise ValueError("Can only edit user messages") fork_point_id = message_id fork_parent_branch = target_msg.branch_id - msg_branch = session.branches.get(target_msg.branch_id) if msg_branch and msg_branch.fork_point_message_id: - branch_user_msgs = [ - m for m in session.messages - if m.branch_id == target_msg.branch_id and m.role == "user" - ] + branch_user_msgs = [m for m in session.messages if m.branch_id == target_msg.branch_id and m.role == "user"] if branch_user_msgs and branch_user_msgs[0].id == message_id: fork_point_id = msg_branch.fork_point_message_id fork_parent_branch = msg_branch.parent_branch_id or "main" new_branch_id = uuid4().hex - new_branch = MessageBranch( - id=new_branch_id, - parent_branch_id=fork_parent_branch, - fork_point_message_id=fork_point_id, - ) + new_branch = MessageBranch(id=new_branch_id, parent_branch_id=fork_parent_branch, fork_point_message_id=fork_point_id) session.branches[new_branch_id] = new_branch session.active_branch_id = new_branch_id - _analytics("feature.used", { "feature": "message.branched", "branch_depth": len([b for b in session.branches.values() if b.parent_branch_id]), @@ -1436,41 +223,21 @@ class AgentManager: }, session_id=session_id, dashboard_id=session.dashboard_id) edited_msg = Message( - role="user", - content=new_content, - branch_id=new_branch_id, - parent_id=target_msg.parent_id, - images=target_msg.images, - context_paths=target_msg.context_paths, - forced_tools=target_msg.forced_tools, + role="user", content=new_content, branch_id=new_branch_id, + parent_id=target_msg.parent_id, images=target_msg.images, + context_paths=target_msg.context_paths, forced_tools=target_msg.forced_tools, attached_skills=target_msg.attached_skills, ) session.messages.append(edited_msg) - - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": edited_msg.model_dump(mode="json"), - }) - await ws_manager.send_to_session(session_id, "agent:branch_created", { - "session_id": session_id, - "branch": new_branch.model_dump(mode="json"), - "active_branch_id": new_branch_id, - }) - + await ws_manager.send_to_session(session_id, "agent:message", {"session_id": session_id, "message": edited_msg.model_dump(mode="json")}) + await ws_manager.send_to_session(session_id, "agent:branch_created", {"session_id": session_id, "branch": new_branch.model_dump(mode="json"), "active_branch_id": new_branch_id}) session.sdk_session_id = None session.status = "running" - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "running", - "session": session.model_dump(mode="json"), - }) - - task = asyncio.create_task(self._run_agent_loop( - session_id, new_content, - images=target_msg.images, - context_paths=target_msg.context_paths, - forced_tools=target_msg.forced_tools, - attached_skills=target_msg.attached_skills, + await ws_manager.send_to_session(session_id, "agent:status", {"session_id": session_id, "status": "running", "session": session.model_dump(mode="json")}) + task = asyncio.create_task(run_agent_loop( + self.sessions, session_id, new_content, + images=target_msg.images, context_paths=target_msg.context_paths, + forced_tools=target_msg.forced_tools, attached_skills=target_msg.attached_skills, )) self.tasks[session_id] = task @@ -1481,99 +248,50 @@ class AgentManager: if branch_id not in session.branches: raise ValueError(f"Branch {branch_id} not found") session.active_branch_id = branch_id - await ws_manager.send_to_session(session_id, "agent:branch_switched", { - "session_id": session_id, - "active_branch_id": branch_id, - }) + await ws_manager.send_to_session(session_id, "agent:branch_switched", {"session_id": session_id, "active_branch_id": branch_id}) + + # ------------------------------------------------------------------ + # LLM-powered metadata + # ------------------------------------------------------------------ async def generate_title(self, session_id: str, first_prompt: str) -> str: - """Use a cheap LLM call to generate a short chat title from the first user message.""" session = self.sessions.get(session_id) if not session: raise ValueError(f"Session {session_id} not found") - title = first_prompt[:40].strip() try: - from backend.apps.settings.credentials import get_anthropic_client - global_settings = load_settings() - client = get_anthropic_client(global_settings) - resp = await client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=30, - system="Generate a concise 3-6 word title for a chat that starts with this message. Return only the title, nothing else.", - messages=[{"role": "user", "content": first_prompt}], + title = await quick_llm_call( + "Generate a concise 3-6 word title for a chat that starts with this message. Return only the title, nothing else.", + first_prompt, max_tokens=30, ) - generated = resp.content[0].text.strip().strip('"\'') - if generated: - title = generated + title = title.strip('"\'') or first_prompt[:40].strip() except Exception as e: logger.warning(f"Title generation failed, using fallback: {e}") - session.name = title - await ws_manager.send_to_session(session_id, "agent:name_updated", { - "session_id": session_id, - "name": title, - }) + await ws_manager.send_to_session(session_id, "agent:name_updated", {"session_id": session_id, "name": title}) return title - async def generate_group_meta( - self, - session_id: str, - group_id: str, - tool_calls: list[dict], - results_summary: list[str] | None = None, - is_refinement: bool = False, - ) -> dict: - """Use a cheap LLM call to generate a name + SVG icon for a tool group.""" + async def generate_group_meta(self, session_id: str, group_id: str, tool_calls: list[dict], results_summary: list[str] | None = None, is_refinement: bool = False) -> dict: session = self.sessions.get(session_id) if not session: raise ValueError(f"Session {session_id} not found") - fallback_name = tool_calls[0].get("tool", "Tool calls") if tool_calls else "Tool calls" fallback_name = fallback_name.split("__")[-1].replace("_", " ").title() if "__" in fallback_name else fallback_name - - name = fallback_name - svg = "" - + name, svg = fallback_name, "" try: - import json as _json - from backend.apps.settings.credentials import get_anthropic_client - global_settings = load_settings() - client = get_anthropic_client(global_settings) - - tool_desc = "\n".join( - f"- {tc.get('tool', '?')}: {tc.get('input_summary', '')}" for tc in tool_calls - ) + tool_desc = "\n".join(f"- {tc.get('tool', '?')}: {tc.get('input_summary', '')}" for tc in tool_calls) user_content = f"Tool actions:\n{tool_desc}" if results_summary: - user_content += f"\n\nResults:\n" + "\n".join(f"- {r}" for r in results_summary) - + user_content += "\n\nResults:\n" + "\n".join(f"- {r}" for r in results_summary) system = ( "Generate a concise 2-5 word name and a minimal SVG icon for a group of tool actions.\n\n" "Return ONLY valid JSON: {\"name\": \"...\", \"svg\": \"...\"}\n\n" - "Name rules:\n" - "- 2-5 words, title case, describes the action (e.g. \"Email Inbox Search\", \"Reading Project Files\")\n\n" - "SVG rules:\n" - "- 24x24 viewBox\n" - "- Use currentColor for all stroke/fill values\n" - "- Simple geometric shapes only (line, circle, rect, path, polyline)\n" - "- No text elements, no embedded images, no gradients, no filters\n" - "- Minimal: 1-3 shapes, stroke-width=\"1.5\", fill=\"none\" unless intentional\n" - "- Return ONLY the inner SVG elements (no outer tag)\n" + "Name rules:\n- 2-5 words, title case, describes the action\n\n" + "SVG rules:\n- 24x24 viewBox\n- Use currentColor for all stroke/fill\n" + "- Simple geometric shapes only\n- No text, no images, no gradients\n" "- Max 400 characters for the svg string" ) - - resp = await client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=300, - system=system, - messages=[{"role": "user", "content": user_content}], - ) - - raw = resp.content[0].text.strip() - if raw.startswith("```"): - raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip() - parsed = _json.loads(raw) + parsed = await quick_llm_json(system, user_content) if parsed.get("name"): name = parsed["name"].strip().strip("\"'") if parsed.get("svg"): @@ -1583,88 +301,32 @@ class AgentManager: meta = ToolGroupMeta(id=group_id, name=name, svg=svg, is_refined=is_refinement) session.tool_group_meta[group_id] = meta - await ws_manager.send_to_session(session_id, "agent:group_meta_updated", { - "session_id": session_id, - "group_id": group_id, - "name": name, - "svg": svg, - "is_refined": is_refinement, + "session_id": session_id, "group_id": group_id, + "name": name, "svg": svg, "is_refined": is_refinement, }) - return {"name": name, "svg": svg, "is_refined": is_refinement} + # ------------------------------------------------------------------ + # Session management + # ------------------------------------------------------------------ + async def update_session(self, session_id: str, **fields): - """Update mutable session fields (system_prompt, name).""" session = self.sessions.get(session_id) if not session: raise ValueError(f"Session {session_id} not found") - - allowed = {"system_prompt", "name"} for key, value in fields.items(): - if key in allowed: + if key in {"system_prompt", "name"}: setattr(session, key, value) - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": session.status, + "session_id": session_id, "status": session.status, "session": session.model_dump(mode="json"), }) - @staticmethod - def _build_search_text(session: AgentSession, max_len: int = 5000) -> str: - """Build a search-indexing string from the session name and message content.""" - parts = [session.name or ""] - for msg in session.messages: - if msg.role in ("user", "assistant") and isinstance(msg.content, str): - parts.append(msg.content) - text = " ".join(parts) - return text[:max_len] - - def _fire_session_completed(self, session: AgentSession): - """Fire the session.completed analytics event exactly once when a session ends.""" - duration = 0.0 - if session.created_at: - end = session.closed_at or datetime.now() - duration = (end - session.created_at).total_seconds() - tool_names = [ - m.content.get("tool", "") for m in session.messages - if m.role == "tool_call" and isinstance(m.content, dict) - ] - user_messages = [ - (m.content if isinstance(m.content, str) else str(m.content))[:200] - for m in session.messages if m.role == "user" - ] - _analytics("session.completed", { - "model": session.model, - "provider": getattr(session, "provider", "anthropic"), - "mode": session.mode, - "cost_usd": session.cost_usd, - "message_count": len([m for m in session.messages if m.role in ("user", "assistant")]), - "duration_seconds": round(duration, 1), - "status": session.status, - "tool_count": len(tool_names), - "tools_list": list(set(tool_names)), - "session_title": session.name, - "first_user_message": user_messages[0] if user_messages else "", - "input_tokens": session.tokens.get("input", 0), - "output_tokens": session.tokens.get("output", 0), - "is_sub_agent": session.parent_session_id is not None, - "parent_session_id": session.parent_session_id, - "sub_agent_count": len([s for s in self.sessions.values() if s.parent_session_id == session.id]), - "branch_count": len(session.branches), - }, session_id=session.id, dashboard_id=session.dashboard_id) - async def close_session(self, session_id: str) -> None: - """Close a session: pause the agent if running, persist to JSON file, - and remove from in-memory state. Also stops browser-agent children.""" - children = [ - s for s in self.sessions.values() - if s.parent_session_id == session_id and s.mode == "browser-agent" - ] + children = [s for s in self.sessions.values() if s.parent_session_id == session_id and s.mode == "browser-agent"] for child in children: await self.stop_agent(child.id) - task = self.tasks.get(session_id) if task and not task.done(): task.cancel() @@ -1672,55 +334,36 @@ class AgentManager: await task except asyncio.CancelledError: pass - session = self.sessions.get(session_id) if not session: raise ValueError(f"Session {session_id} not found") - if session.status in ("running", "waiting_approval"): session.status = "stopped" session.closed_at = datetime.now() - for req in list(session.pending_approvals): ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Session closed"}) session.pending_approvals = [] - if hasattr(session, '_cancel_event'): session._cancel_event.set() - - self._fire_session_completed(session) - + fire_session_completed(session, self.sessions) doc_data = session.model_dump(mode="json") - doc_data["search_text"] = self._build_search_text(session) - - _save_session(session_id, doc_data) - + doc_data["search_text"] = build_search_text(session) + save_session(session_id, doc_data) await ws_manager.send_to_session(session_id, "agent:closed", { - "session_id": session_id, - "status": session.status, - "name": session.name, - "model": session.model, - "mode": session.mode, + "session_id": session_id, "status": session.status, + "name": session.name, "model": session.model, "mode": session.mode, "created_at": session.created_at.isoformat() if session.created_at else None, "closed_at": session.closed_at.isoformat() if session.closed_at else None, - "cost_usd": session.cost_usd, - "dashboard_id": session.dashboard_id, + "cost_usd": session.cost_usd, "dashboard_id": session.dashboard_id, }) - self.sessions.pop(session_id, None) self.tasks.pop(session_id, None) logger.info(f"Session {session_id} closed and persisted") async def delete_session(self, session_id: str) -> None: - """Permanently delete a session: remove from memory and JSON file. - Also stops browser-agent children first.""" - children = [ - s for s in self.sessions.values() - if s.parent_session_id == session_id and s.mode == "browser-agent" - ] + children = [s for s in self.sessions.values() if s.parent_session_id == session_id and s.mode == "browser-agent"] for child in children: await self.stop_agent(child.id) - task = self.tasks.get(session_id) if task and not task.done(): task.cancel() @@ -1728,300 +371,95 @@ class AgentManager: await task except asyncio.CancelledError: pass - self.sessions.pop(session_id, None) self.tasks.pop(session_id, None) - - _delete_session_file(session_id) + delete_session_file(session_id) logger.info(f"Session {session_id} permanently deleted") async def resume_session(self, session_id: str) -> AgentSession: - """Restore a closed session from JSON file back into active memory.""" if session_id in self.sessions: return self.sessions[session_id] - - data = _load_session_data(session_id) + data = load_session_data(session_id) if data is None: raise ValueError(f"Session {session_id} not found in history") - session = AgentSession(**data) - - hours_since_closed = 0 + hours_since = 0 if data.get("closed_at"): try: closed = datetime.fromisoformat(data["closed_at"][:19]) - hours_since_closed = round((datetime.now() - closed).total_seconds() / 3600, 1) + hours_since = round((datetime.now() - closed).total_seconds() / 3600, 1) except Exception: pass _analytics("session.resumed", { - "hours_since_closed": hours_since_closed, + "hours_since_closed": hours_since, "original_message_count": len(data.get("messages", [])), - "original_cost_usd": data.get("cost_usd", 0), - "model": session.model, + "original_cost_usd": data.get("cost_usd", 0), "model": session.model, }, session_id=session_id, dashboard_id=session.dashboard_id) - session.closed_at = None self.sessions[session_id] = session - - _delete_session_file(session_id) - + delete_session_file(session_id) await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": session.status, + "session_id": session_id, "status": session.status, "session": session.model_dump(mode="json"), }) - logger.info(f"Session {session_id} resumed from history") return session - def get_history( - self, - q: str = "", - limit: int = 20, - offset: int = 0, - dashboard_id: str | None = None, - ) -> dict: - """Return paginated, optionally filtered summaries of closed sessions.""" - all_data = _load_all_session_data() - all_data.sort(key=lambda pair: pair[1].get("closed_at") or "", reverse=True) - - q_lower = q.strip().lower() - history = [] - for sid, data in all_data: - if dashboard_id and data.get("dashboard_id") != dashboard_id: - continue - if q_lower: - name = (data.get("name") or "").lower() - search_text = (data.get("search_text") or "").lower() - if q_lower not in name and q_lower not in search_text: - continue - history.append({ - "id": data.get("id", sid), - "name": data.get("name", "Untitled"), - "status": data.get("status", "stopped"), - "model": data.get("model", "sonnet"), - "mode": data.get("mode", "agent"), - "created_at": data.get("created_at"), - "closed_at": data.get("closed_at"), - "cost_usd": data.get("cost_usd", 0), - "dashboard_id": data.get("dashboard_id"), - }) - - total = len(history) - page = history[offset : offset + limit] - return { - "sessions": page, - "total": total, - "has_more": offset + limit < total, - } - - async def reconcile_on_startup(self) -> None: - """Mark any stale running sessions as stopped.""" - for sid, data in _load_all_session_data(): - if data.get("status") in ("running", "waiting_approval"): - data["status"] = "stopped" - _save_session(sid, data) - logger.info(f"Marked stale session {sid} as stopped") - - async def persist_all_sessions(self) -> None: - """Flush every in-memory session to JSON files (for graceful shutdown).""" - for session_id, session in list(self.sessions.items()): - if session.status in ("running", "waiting_approval"): - session.status = "stopped" - for req in list(session.pending_approvals): - ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Server shutting down"}) - session.pending_approvals = [] - self._fire_session_completed(session) - doc_data = session.model_dump(mode="json") - doc_data["search_text"] = self._build_search_text(session) - _save_session(session_id, doc_data) - logger.info(f"Persisted session {session_id} on shutdown") - self.sessions.clear() - self.tasks.clear() - - async def restore_all_sessions(self) -> None: - """On startup, reload all persisted sessions from JSON files back into memory. - - Only sessions without closed_at are restored (they were active at - shutdown). Sessions with closed_at were explicitly closed by the user - and stay on disk so the history endpoint can still serve them. - """ - for sid, data in _load_all_session_data(): - try: - session = AgentSession(**data) - except Exception as e: - logger.warning(f"Skipping corrupt session file {sid}: {e}") - continue - if session.closed_at is not None: - continue - if session.status in ("running", "waiting_approval"): - session.status = "stopped" - session.pending_approvals = [] - self.sessions[session.id] = session - _delete_session_file(sid) - logger.info(f"Restored session {session.id}") - async def duplicate_session(self, session_id: str, dashboard_id: str | None = None, up_to_message_id: str | None = None) -> AgentSession: - """Create an independent copy of a session with the same chat history.""" source = self.sessions.get(session_id) if not source: - data = _load_session_data(session_id) + data = load_session_data(session_id) if data is None: raise ValueError(f"Session {session_id} not found") source = AgentSession(**data) - - source_messages = list(source.messages) - if up_to_message_id: - cut_idx = next( - (i for i, m in enumerate(source_messages) if m.id == up_to_message_id), - None, - ) - if cut_idx is not None: - source_messages = source_messages[: cut_idx + 1] - - old_to_new_msg: dict[str, str] = {} - new_messages: list[Message] = [] - for msg in source_messages: - new_id = uuid4().hex - old_to_new_msg[msg.id] = new_id - new_messages.append(Message( - id=new_id, - role=msg.role, - content=msg.content, - timestamp=msg.timestamp, - branch_id=msg.branch_id, - parent_id=old_to_new_msg.get(msg.parent_id) if msg.parent_id else None, - context_paths=msg.context_paths, - attached_skills=msg.attached_skills, - forced_tools=msg.forced_tools, - images=msg.images, - )) - - new_branches: dict[str, MessageBranch] = {} - for bid, branch in source.branches.items(): - new_branches[bid] = MessageBranch( - id=bid, - parent_branch_id=branch.parent_branch_id, - fork_point_message_id=old_to_new_msg.get(branch.fork_point_message_id) if branch.fork_point_message_id else None, - created_at=branch.created_at, - ) - + new_messages, new_branches, _ = copy_session_messages(source, up_to_message_id) new_session = AgentSession( - id=uuid4().hex, - name=f"{source.name} (copy)", - status="stopped", - model=source.model, - mode=source.mode, - system_prompt=source.system_prompt, - allowed_tools=list(source.allowed_tools), - max_turns=source.max_turns, - cwd=source.cwd, - created_at=datetime.now(), - messages=new_messages, - branches=new_branches, - active_branch_id=source.active_branch_id, + id=uuid4().hex, name=f"{source.name} (copy)", status="stopped", + model=source.model, mode=source.mode, system_prompt=source.system_prompt, + allowed_tools=list(source.allowed_tools), max_turns=source.max_turns, + cwd=source.cwd, created_at=datetime.now(), messages=new_messages, + branches=new_branches, active_branch_id=source.active_branch_id, tool_group_meta=dict(source.tool_group_meta), dashboard_id=dashboard_id or source.dashboard_id, ) - self.sessions[new_session.id] = new_session - await ws_manager.send_to_session(new_session.id, "agent:status", { - "session_id": new_session.id, - "status": new_session.status, + "session_id": new_session.id, "status": new_session.status, "session": new_session.model_dump(mode="json"), }) - return new_session - async def invoke_agent( - self, - source_session_id: str, - message: str, - parent_session_id: str | None = None, - dashboard_id: str | None = None, - ) -> dict: - """Fork an existing session and send it a new message, returning the result.""" + async def invoke_agent(self, source_session_id: str, message: str, parent_session_id: str | None = None, dashboard_id: str | None = None) -> dict: source = self.sessions.get(source_session_id) if not source: - data = _load_session_data(source_session_id) + data = load_session_data(source_session_id) if data is None: raise ValueError(f"Session {source_session_id} not found") source = AgentSession(**data) - source_name = source.name - - old_to_new_msg: dict[str, str] = {} - new_messages: list[Message] = [] - for msg in source.messages: - new_id = uuid4().hex - old_to_new_msg[msg.id] = new_id - new_messages.append(Message( - id=new_id, - role=msg.role, - content=msg.content, - timestamp=msg.timestamp, - branch_id=msg.branch_id, - parent_id=old_to_new_msg.get(msg.parent_id) if msg.parent_id else None, - context_paths=msg.context_paths, - attached_skills=msg.attached_skills, - forced_tools=msg.forced_tools, - images=msg.images, - )) - - new_branches: dict[str, MessageBranch] = {} - for bid, branch in source.branches.items(): - new_branches[bid] = MessageBranch( - id=bid, - parent_branch_id=branch.parent_branch_id, - fork_point_message_id=( - old_to_new_msg.get(branch.fork_point_message_id) - if branch.fork_point_message_id else None - ), - created_at=branch.created_at, - ) - + new_messages, new_branches, _ = copy_session_messages(source) fork = AgentSession( - id=uuid4().hex, - name=f"{source_name} (invoked)", - status="running", - model=source.model, - mode="invoked-agent", - sdk_session_id=source.sdk_session_id, - system_prompt=source.system_prompt, - allowed_tools=list(source.allowed_tools), - max_turns=source.max_turns or 25, - cwd=source.cwd, - created_at=datetime.now(), - messages=new_messages, - branches=new_branches, + id=uuid4().hex, name=f"{source_name} (invoked)", status="running", + model=source.model, mode="invoked-agent", sdk_session_id=source.sdk_session_id, + system_prompt=source.system_prompt, allowed_tools=list(source.allowed_tools), + max_turns=source.max_turns or 25, cwd=source.cwd, created_at=datetime.now(), + messages=new_messages, branches=new_branches, active_branch_id=source.active_branch_id, tool_group_meta=dict(source.tool_group_meta), dashboard_id=dashboard_id or source.dashboard_id, parent_session_id=parent_session_id, ) - self.sessions[fork.id] = fork - await ws_manager.broadcast_global("agent:status", { - "session_id": fork.id, - "status": fork.status, + "session_id": fork.id, "status": fork.status, "session": fork.model_dump(mode="json"), }) - - user_msg = Message( - role="user", - content=message, - branch_id=fork.active_branch_id, - ) + user_msg = Message(role="user", content=message, branch_id=fork.active_branch_id) fork.messages.append(user_msg) await ws_manager.send_to_session(fork.id, "agent:message", { - "session_id": fork.id, - "message": user_msg.model_dump(mode="json"), + "session_id": fork.id, "message": user_msg.model_dump(mode="json"), }) - - await self._run_agent_loop(fork.id, message, fork_session=True) - + await run_agent_loop(self.sessions, fork.id, message, fork_session=True) last_assistant = None for msg in reversed(fork.messages): if msg.role == "assistant": @@ -2034,14 +472,16 @@ class AgentManager: else: last_assistant = str(content) break - return { - "forked_session_id": fork.id, - "source_name": source_name, + "forked_session_id": fork.id, "source_name": source_name, "response": last_assistant or "No response from invoked agent.", "cost_usd": fork.cost_usd, } + # ------------------------------------------------------------------ + # Queries + # ------------------------------------------------------------------ + def get_all_sessions(self, dashboard_id: str | None = None) -> list[AgentSession]: if dashboard_id: return [s for s in self.sessions.values() if s.dashboard_id == dashboard_id] @@ -2050,22 +490,49 @@ class AgentManager: def get_session(self, session_id: str) -> Optional[AgentSession]: return self.sessions.get(session_id) - def get_browser_agent_children(self, parent_session_id: str) -> list[dict]: - """Return browser-agent sessions for a parent, from memory or disk.""" - results: list[dict] = [] - seen: set[str] = set() + # ------------------------------------------------------------------ + # Delegated helpers (kept as methods for API compatibility) + # ------------------------------------------------------------------ - for s in self.sessions.values(): - if s.mode == "browser-agent" and s.parent_session_id == parent_session_id: - results.append(s.model_dump(mode="json")) - seen.add(s.id) + def get_history(self, q: str = "", limit: int = 20, offset: int = 0, dashboard_id: str | None = None) -> dict: + return get_history(q=q, limit=limit, offset=offset, dashboard_id=dashboard_id) - for sid, data in _load_all_session_data(): - if sid in seen: + async def reconcile_on_startup(self) -> None: + return await reconcile_on_startup() + + async def persist_all_sessions(self) -> None: + for session_id, session in list(self.sessions.items()): + if session.status in ("running", "waiting_approval"): + session.status = "stopped" + for req in list(session.pending_approvals): + ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Server shutting down"}) + session.pending_approvals = [] + fire_session_completed(session, self.sessions) + doc_data = session.model_dump(mode="json") + doc_data["search_text"] = build_search_text(session) + save_session(session_id, doc_data) + logger.info(f"Persisted session {session_id} on shutdown") + self.sessions.clear() + self.tasks.clear() + + async def restore_all_sessions(self) -> None: + for sid, data in load_all_session_data(): + try: + session = AgentSession(**data) + except Exception as e: + logger.warning(f"Skipping corrupt session file {sid}: {e}") continue - if data.get("mode") == "browser-agent" and data.get("parent_session_id") == parent_session_id: - results.append(data) + if session.closed_at is not None: + continue + if session.status in ("running", "waiting_approval"): + session.status = "stopped" + session.pending_approvals = [] + self.sessions[session.id] = session + delete_session_file(sid) + logger.info(f"Restored session {session.id}") + + def get_browser_agent_children(self, parent_session_id: str) -> list[dict]: + return get_browser_agent_children(self.sessions, parent_session_id) - return results agent_manager = AgentManager() diff --git a/backend/apps/agents/browser/__init__.py b/backend/apps/agents/browser/__init__.py new file mode 100644 index 00000000..d1274208 --- /dev/null +++ b/backend/apps/agents/browser/__init__.py @@ -0,0 +1,3 @@ +"""Browser sub-agent package.""" + +from backend.apps.agents.browser.runner import run_browser_agent, run_browser_agents diff --git a/backend/apps/agents/browser/executor.py b/backend/apps/agents/browser/executor.py new file mode 100644 index 00000000..519cd125 --- /dev/null +++ b/backend/apps/agents/browser/executor.py @@ -0,0 +1,71 @@ +"""Browser tool execution and approval helpers.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from uuid import uuid4 + +from backend.apps.agents.models import AgentSession, ApprovalRequest +from backend.apps.agents.ws_manager import ws_manager +from backend.apps.agents.browser.schemas import ACTION_MAP + +logger = logging.getLogger(__name__) + + +async def execute_browser_tool( + tool_name: str, tool_input: dict, browser_id: str, tab_id: str = "", +) -> dict: + action = ACTION_MAP.get(tool_name) + if not action: + return {"error": f"Unknown browser tool: {tool_name}"} + params = {k: v for k, v in tool_input.items()} + request_id = uuid4().hex + result = await ws_manager.send_browser_command( + request_id, action, browser_id, params, tab_id=tab_id, + ) + return result + + +def _format_tool_result(result: dict, tool_name: str) -> list[dict]: + if "error" in result: + return [{"type": "text", "text": f"Error: {result['error']}"}] + if tool_name == "BrowserScreenshot" and result.get("image"): + return [ + { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": result["image"]}, + }, + {"type": "text", "text": f"Screenshot captured. URL: {result.get('url', 'unknown')}"}, + ] + text = result.get("text", json.dumps(result)) + return [{"type": "text", "text": str(text)}] + + +async def _request_browser_approval( + session: AgentSession, tool_name: str, tool_input: dict, +) -> dict: + request_id = uuid4().hex + approval_req = ApprovalRequest( + id=request_id, session_id=session.id, + tool_name=tool_name, tool_input=tool_input, + ) + session.pending_approvals.append(approval_req) + session.status = "waiting_approval" + await ws_manager.send_to_session(session.id, "agent:status", { + "session_id": session.id, "status": "waiting_approval", + }) + try: + decision = await asyncio.wait_for( + ws_manager.send_approval_request(session.id, request_id, tool_name, tool_input), + timeout=300.0, + ) + except asyncio.TimeoutError: + decision = {"behavior": "deny", "message": "Approval timed out"} + session.pending_approvals = [a for a in session.pending_approvals if a.id != request_id] + session.status = "running" + await ws_manager.send_to_session(session.id, "agent:status", { + "session_id": session.id, "status": "running", + }) + return decision diff --git a/backend/apps/agents/browser/runner.py b/backend/apps/agents/browser/runner.py new file mode 100644 index 00000000..57942e42 --- /dev/null +++ b/backend/apps/agents/browser/runner.py @@ -0,0 +1,234 @@ +"""Browser agent runner — run_browser_agent and run_browser_agents.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from datetime import datetime +from uuid import uuid4 + +from backend.apps.agents.models import AgentSession, Message +from backend.apps.agents.ws_manager import ws_manager +from backend.apps.common.model_registry import resolve_model_id +from backend.apps.tools_lib.tools_lib import load_builtin_permissions +from backend.apps.agents.browser.schemas import ( + BROWSER_TOOLS_SCHEMA, SYSTEM_PROMPT, MAX_TURNS, +) +from backend.apps.agents.browser.executor import ( + execute_browser_tool, _format_tool_result, _request_browser_approval, +) + +logger = logging.getLogger(__name__) + + +async def run_browser_agent( + task: str, browser_id: str, model: str, + dashboard_id: str | None = None, tab_id: str = "", + pre_selected: bool = False, initial_url: str | None = None, + parent_session_id: str | None = None, +) -> dict: + from backend.apps.agents.agent_manager import agent_manager + + _browser_perms = load_builtin_permissions() + session_id = uuid4().hex + cancel_event = asyncio.Event() + session = AgentSession( + id=session_id, name="Browser Agent", model=model, + mode="browser-agent", status="running", dashboard_id=dashboard_id, + browser_id=browser_id, system_prompt=SYSTEM_PROMPT, + parent_session_id=parent_session_id, + ) + session._cancel_event = cancel_event + agent_manager.sessions[session_id] = session + + await ws_manager.send_to_session(session_id, "agent:status", { + "session_id": session_id, "status": "running", + "session": session.model_dump(mode="json"), + }) + + if initial_url: + nav_result = await execute_browser_tool("BrowserNavigate", {"url": initial_url}, browser_id, tab_id) + logger.info(f"Browser agent {session_id}: navigated to {initial_url}: {nav_result.get('text', nav_result.get('error', ''))}") + + api_model = resolve_model_id(model) + from backend.apps.settings.settings import load_settings + from backend.apps.settings.credentials import get_anthropic_client + client = get_anthropic_client(load_settings()) + + messages: list[dict] = [{"role": "user", "content": task}] + action_log: list[dict] = [] + final_screenshot: str | None = None + + user_msg = Message(role="user", content=task) + session.messages.append(user_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, "message": user_msg.model_dump(mode="json"), + }) + + try: + for turn in range(MAX_TURNS): + if cancel_event.is_set(): + break + 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 = [] + 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(role="assistant", content="\n".join(text_parts)) + session.messages.append(asst_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, "message": asst_msg.model_dump(mode="json"), + }) + for tu in tool_uses: + tool_msg = Message(role="tool_call", content={"id": tu.id, "tool": tu.name, "input": tu.input}) + session.messages.append(tool_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, "message": tool_msg.model_dump(mode="json"), + }) + + messages.append({"role": "assistant", "content": assistant_content}) + if response.stop_reason != "tool_use": + break + + tool_results = [] + cancelled = False + for tu in tool_uses: + if cancel_event.is_set(): + cancelled = True + break + policy = _browser_perms.get(tu.name, "always_allow") + if policy == "deny": + denied_text = f"Tool {tu.name} is denied by permission policy." + tool_results.append({"type": "tool_result", "tool_use_id": tu.id, "content": [{"type": "text", "text": denied_text}]}) + result_msg = Message(role="tool_result", content={"text": denied_text, "tool_name": tu.name, "elapsed_ms": 0}) + session.messages.append(result_msg) + await ws_manager.send_to_session(session_id, "agent:message", {"session_id": session_id, "message": result_msg.model_dump(mode="json")}) + continue + if policy == "ask": + decision = await _request_browser_approval(session, tu.name, tu.input) + if decision.get("behavior") == "deny": + denied_text = decision.get("message") or f"Tool {tu.name} denied by user." + tool_results.append({"type": "tool_result", "tool_use_id": tu.id, "content": [{"type": "text", "text": denied_text}]}) + result_msg = Message(role="tool_result", content={"text": denied_text, "tool_name": tu.name, "elapsed_ms": 0}) + session.messages.append(result_msg) + await ws_manager.send_to_session(session_id, "agent:message", {"session_id": session_id, "message": result_msg.model_dump(mode="json")}) + continue + + start = time.time() + result = await execute_browser_tool(tu.name, tu.input, browser_id, tab_id) + elapsed_ms = int((time.time() - start) * 1000) + action_log.append({"tool": tu.name, "input": tu.input, "result_summary": result.get("text", result.get("error", ""))[:200], "elapsed_ms": elapsed_ms}) + if tu.name == "BrowserScreenshot" and result.get("image"): + final_screenshot = result["image"] + content_blocks = _format_tool_result(result, tu.name) + tool_results.append({"type": "tool_result", "tool_use_id": tu.id, "content": content_blocks}) + result_text = result.get("text", result.get("error", "")) + result_msg = Message(role="tool_result", content={"text": result_text, "tool_name": tu.name, "elapsed_ms": elapsed_ms}) + session.messages.append(result_msg) + await ws_manager.send_to_session(session_id, "agent:message", {"session_id": session_id, "message": result_msg.model_dump(mode="json")}) + + messages.append({"role": "user", "content": tool_results}) + if cancelled: + break + + if cancel_event.is_set(): + session.status = "stopped" + await ws_manager.send_to_session(session_id, "agent:status", {"session_id": session_id, "status": "stopped", "session": session.model_dump(mode="json")}) + return {"session_id": session_id, "browser_id": browser_id, "summary": "Agent was stopped.", "action_log": action_log, "final_screenshot": final_screenshot} + + summary_parts = text_parts if text_parts else ["Task completed."] + summary = "\n".join(summary_parts) + + if not final_screenshot: + try: + ss_result = await execute_browser_tool("BrowserScreenshot", {}, browser_id, tab_id) + if ss_result.get("image"): + final_screenshot = ss_result["image"] + except Exception: + pass + + session.status = "completed" + await ws_manager.send_to_session(session_id, "agent:status", {"session_id": session_id, "status": "completed", "session": session.model_dump(mode="json")}) + return {"session_id": session_id, "browser_id": browser_id, "summary": summary, "action_log": action_log, "final_screenshot": final_screenshot} + + except Exception as e: + logger.exception(f"Browser agent {session_id} error: {e}") + session.status = "error" + error_msg = Message(role="system", content=f"Error: {str(e)}") + 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")}) + await ws_manager.send_to_session(session_id, "agent:status", {"session_id": session_id, "status": "error", "session": session.model_dump(mode="json")}) + return {"session_id": session_id, "browser_id": browser_id, "summary": f"Error: {str(e)}", "action_log": action_log, "final_screenshot": None} + + +async def _create_browser_card(dashboard_id: str, url: str, parent_session_id: str | None = None) -> str: + from backend.apps.dashboards.dashboards import _load, _save + from backend.apps.dashboards.models import BrowserCardPosition, BrowserTab + + dashboard = _load(dashboard_id) + browser_id = f"browser-{uuid4().hex[:8]}" + tab_id = f"tab-{uuid4().hex[:8]}" + tab = BrowserTab(id=tab_id, url=url or "https://www.google.com", title="") + card = BrowserCardPosition( + browser_id=browser_id, url=url or "https://www.google.com", + tabs=[tab], activeTabId=tab_id, x=40, y=100, width=1280, height=800, + ) + dashboard.layout.browser_cards[browser_id] = card + dashboard.updated_at = datetime.now() + _save(dashboard) + await ws_manager.broadcast_global("dashboard:browser_card_added", { + "dashboard_id": dashboard_id, + "browser_card": card.model_dump(mode="json"), + "parent_session_id": parent_session_id or "", + }) + return browser_id + + +async def run_browser_agents( + tasks: list[dict], model: str, + dashboard_id: str | None = None, + pre_selected_browser_ids: list[str] | None = None, + parent_session_id: str | None = None, +) -> list[dict]: + from backend.apps.analytics.collector import record as _analytics + _analytics("feature.used", { + "feature": "browser_agent.launched", "task_count": len(tasks), "model": model, + }, dashboard_id=dashboard_id) + + pre_selected = set(pre_selected_browser_ids or []) + + async def _run_one(task_def: dict) -> dict: + browser_id = task_def.get("browser_id", "") + task_text = task_def.get("task", "") + url = task_def.get("url", "") + if not browser_id and dashboard_id: + browser_id = await _create_browser_card(dashboard_id, url, parent_session_id) + await asyncio.sleep(2.0) + is_pre_selected = browser_id in pre_selected + return await run_browser_agent( + task=task_text, browser_id=browser_id, model=model, + dashboard_id=dashboard_id, 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, + ) + + results = await asyncio.gather(*[_run_one(t) for t in tasks], return_exceptions=True) + final = [] + for r in results: + if isinstance(r, Exception): + final.append({"summary": f"Error: {str(r)}", "action_log": [], "final_screenshot": None}) + else: + final.append(r) + return final diff --git a/backend/apps/agents/browser/schemas.py b/backend/apps/agents/browser/schemas.py new file mode 100644 index 00000000..a68b0f22 --- /dev/null +++ b/backend/apps/agents/browser/schemas.py @@ -0,0 +1,137 @@ +"""Browser tool schemas, constants, and system prompt.""" + +BROWSER_TOOLS_SCHEMA = [ + { + "name": "BrowserScreenshot", + "description": ( + "Capture a screenshot of the browser page. Returns the screenshot as a " + "base64-encoded PNG image. Use this to see what is currently displayed." + ), + "input_schema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "BrowserGetText", + "description": "Get the visible text content of the browser page. Returns up to 15000 characters.", + "input_schema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "BrowserNavigate", + "description": "Navigate the browser to a URL.", + "input_schema": { + "type": "object", + "properties": {"url": {"type": "string", "description": "The URL to navigate to."}}, + "required": ["url"], + }, + }, + { + "name": "BrowserClick", + "description": "Click an element identified by a CSS selector. Use BrowserGetElements first to discover valid selectors.", + "input_schema": { + "type": "object", + "properties": {"selector": {"type": "string", "description": "CSS selector of the element to click."}}, + "required": ["selector"], + }, + }, + { + "name": "BrowserType", + "description": "Type text into an input element. Clears existing value first.", + "input_schema": { + "type": "object", + "properties": { + "selector": {"type": "string", "description": "CSS selector of the input element."}, + "text": {"type": "string", "description": "The text to type."}, + }, + "required": ["selector", "text"], + }, + }, + { + "name": "BrowserEvaluate", + "description": "Evaluate a JavaScript expression in the browser page and return the result.", + "input_schema": { + "type": "object", + "properties": {"expression": {"type": "string", "description": "JavaScript expression to evaluate."}}, + "required": ["expression"], + }, + }, + { + "name": "BrowserGetElements", + "description": ( + "Get a list of interactive elements on the page with CSS selectors. " + "Call this BEFORE clicking or typing so you know which selectors are valid." + ), + "input_schema": { + "type": "object", + "properties": { + "selector": { + "type": "string", + "description": "Optional CSS selector to scope the search (e.g. 'form', '#main'). Defaults to 'body'.", + }, + }, + "required": [], + }, + }, + { + "name": "BrowserScroll", + "description": ( + "Scroll the page up or down. Automatically finds the correct scrollable " + "container. Returns scroll position info including whether top/bottom has been reached." + ), + "input_schema": { + "type": "object", + "properties": { + "direction": {"type": "string", "enum": ["up", "down"], "description": "Scroll direction. Defaults to 'down'."}, + "amount": {"type": "number", "description": "Pixels to scroll. Defaults to 500."}, + }, + "required": [], + }, + }, + { + "name": "BrowserWait", + "description": ( + "Wait for a specified duration. Useful after navigation or actions that " + "trigger page loads. Min 100ms, max 10000ms." + ), + "input_schema": { + "type": "object", + "properties": {"milliseconds": {"type": "number", "description": "Duration to wait in milliseconds. Defaults to 1000."}}, + "required": [], + }, + }, +] + +ACTION_MAP = { + "BrowserScreenshot": "screenshot", + "BrowserGetText": "get_text", + "BrowserNavigate": "navigate", + "BrowserClick": "click", + "BrowserType": "type", + "BrowserEvaluate": "evaluate", + "BrowserGetElements": "get_elements", + "BrowserScroll": "scroll", + "BrowserWait": "wait", +} + +SYSTEM_PROMPT = ( + "You are a browser automation agent. You control a single browser tab and " + "execute the task you are given.\n\n" + "Strategy:\n" + "1. Start by taking a screenshot to understand the page.\n" + "2. After navigation, use BrowserWait (1-3 seconds) to let the page finish loading.\n" + "3. Use BrowserScroll to scroll through pages — do NOT use BrowserEvaluate with " + "window.scrollBy() as many sites use nested scroll containers that BrowserScroll " + "handles automatically.\n" + "4. Use BrowserGetElements BEFORE clicking or typing to discover valid CSS selectors.\n" + "5. After performing actions, take a screenshot to verify the result.\n" + "6. If an action fails, try alternative selectors or approaches.\n" + "7. When the task is complete, provide a clear summary of what you accomplished.\n\n" + "Important notes:\n" + "- BrowserGetText returns up to 15000 chars of visible text — use it to read page content.\n" + "- BrowserScroll returns position info including atTop/atBottom — use this to know when " + "you've reached the end of the page.\n" + "- For complex SPAs (Notion, Gmail, etc.), prefer BrowserScroll over BrowserEvaluate for scrolling.\n" + "- Avoid looping: if scrolling shows no new content (scrolled 0px), you're at the boundary.\n\n" + "You have access ONLY to browser tools. Do not ask the user questions — " + "complete the task autonomously to the best of your ability." +) + +MAX_TURNS = 25 diff --git a/backend/apps/agents/browser_agent.py b/backend/apps/agents/browser_agent.py index f6d9582a..1d8c04f7 100644 --- a/backend/apps/agents/browser_agent.py +++ b/backend/apps/agents/browser_agent.py @@ -1,627 +1,9 @@ -""" -Browser sub-agent runner. +"""Backward-compatible shim — re-exports from the browser sub-package.""" -Provides a lightweight Anthropic API tool-use loop that drives browser -interactions directly through ws_manager (no MCP subprocess needed). -Sub-agents appear as visible AgentSession cards on the dashboard. -""" - -import asyncio -import json -import logging -import time -from datetime import datetime -from uuid import uuid4 - -import anthropic - -from backend.apps.agents.models import AgentSession, ApprovalRequest, Message -from backend.apps.agents.ws_manager import ws_manager -from backend.apps.common.model_registry import resolve_model_id -from backend.apps.tools_lib.tools_lib import load_builtin_permissions - -logger = logging.getLogger(__name__) - -BROWSER_TOOLS_SCHEMA = [ - { - "name": "BrowserScreenshot", - "description": ( - "Capture a screenshot of the browser page. Returns the screenshot as a " - "base64-encoded PNG image. Use this to see what is currently displayed." - ), - "input_schema": { - "type": "object", - "properties": {}, - "required": [], - }, - }, - { - "name": "BrowserGetText", - "description": ( - "Get the visible text content of the browser page. Returns up to 15000 characters." - ), - "input_schema": { - "type": "object", - "properties": {}, - "required": [], - }, - }, - { - "name": "BrowserNavigate", - "description": "Navigate the browser to a URL.", - "input_schema": { - "type": "object", - "properties": { - "url": {"type": "string", "description": "The URL to navigate to."}, - }, - "required": ["url"], - }, - }, - { - "name": "BrowserClick", - "description": "Click an element identified by a CSS selector. Use BrowserGetElements first to discover valid selectors.", - "input_schema": { - "type": "object", - "properties": { - "selector": {"type": "string", "description": "CSS selector of the element to click."}, - }, - "required": ["selector"], - }, - }, - { - "name": "BrowserType", - "description": "Type text into an input element. Clears existing value first.", - "input_schema": { - "type": "object", - "properties": { - "selector": {"type": "string", "description": "CSS selector of the input element."}, - "text": {"type": "string", "description": "The text to type."}, - }, - "required": ["selector", "text"], - }, - }, - { - "name": "BrowserEvaluate", - "description": "Evaluate a JavaScript expression in the browser page and return the result.", - "input_schema": { - "type": "object", - "properties": { - "expression": {"type": "string", "description": "JavaScript expression to evaluate."}, - }, - "required": ["expression"], - }, - }, - { - "name": "BrowserGetElements", - "description": ( - "Get a list of interactive elements on the page with CSS selectors. " - "Call this BEFORE clicking or typing so you know which selectors are valid." - ), - "input_schema": { - "type": "object", - "properties": { - "selector": { - "type": "string", - "description": "Optional CSS selector to scope the search (e.g. 'form', '#main'). Defaults to 'body'.", - }, - }, - "required": [], - }, - }, - { - "name": "BrowserScroll", - "description": ( - "Scroll the page up or down. Automatically finds the correct scrollable " - "container (works on SPAs like Notion, Gmail, etc. that use nested scroll " - "containers instead of window-level scrolling). Returns scroll position info " - "including whether top/bottom has been reached." - ), - "input_schema": { - "type": "object", - "properties": { - "direction": { - "type": "string", - "enum": ["up", "down"], - "description": "Scroll direction. Defaults to 'down'.", - }, - "amount": { - "type": "number", - "description": "Pixels to scroll. Defaults to 500.", - }, - }, - "required": [], - }, - }, - { - "name": "BrowserWait", - "description": ( - "Wait for a specified duration. Useful after navigation or actions that " - "trigger page loads, animations, or async content rendering. " - "Min 100ms, max 10000ms." - ), - "input_schema": { - "type": "object", - "properties": { - "milliseconds": { - "type": "number", - "description": "Duration to wait in milliseconds. Defaults to 1000.", - }, - }, - "required": [], - }, - }, -] - -ACTION_MAP = { - "BrowserScreenshot": "screenshot", - "BrowserGetText": "get_text", - "BrowserNavigate": "navigate", - "BrowserClick": "click", - "BrowserType": "type", - "BrowserEvaluate": "evaluate", - "BrowserGetElements": "get_elements", - "BrowserScroll": "scroll", - "BrowserWait": "wait", -} - -SYSTEM_PROMPT = ( - "You are a browser automation agent. You control a single browser tab and " - "execute the task you are given.\n\n" - "Strategy:\n" - "1. Start by taking a screenshot to understand the page.\n" - "2. After navigation, use BrowserWait (1-3 seconds) to let the page finish loading.\n" - "3. Use BrowserScroll to scroll through pages — do NOT use BrowserEvaluate with " - "window.scrollBy() as many sites use nested scroll containers that BrowserScroll " - "handles automatically.\n" - "4. Use BrowserGetElements BEFORE clicking or typing to discover valid CSS selectors.\n" - "5. After performing actions, take a screenshot to verify the result.\n" - "6. If an action fails, try alternative selectors or approaches.\n" - "7. When the task is complete, provide a clear summary of what you accomplished.\n\n" - "Important notes:\n" - "- BrowserGetText returns up to 15000 chars of visible text — use it to read page content.\n" - "- BrowserScroll returns position info including atTop/atBottom — use this to know when " - "you've reached the end of the page.\n" - "- For complex SPAs (Notion, Gmail, etc.), prefer BrowserScroll over BrowserEvaluate for scrolling.\n" - "- Avoid looping: if scrolling shows no new content (scrolled 0px), you're at the boundary.\n\n" - "You have access ONLY to browser tools. Do not ask the user questions — " - "complete the task autonomously to the best of your ability." +from backend.apps.agents.browser.runner import ( # noqa: F401 + run_browser_agent, + run_browser_agents, +) +from backend.apps.agents.browser.executor import ( # noqa: F401 + execute_browser_tool, ) - -MAX_TURNS = 25 - - -async def execute_browser_tool( - tool_name: str, tool_input: dict, browser_id: str, tab_id: str = "", -) -> dict: - """Execute a browser tool via ws_manager directly (no MCP/HTTP round-trip).""" - action = ACTION_MAP.get(tool_name) - if not action: - return {"error": f"Unknown browser tool: {tool_name}"} - - params = {k: v for k, v in tool_input.items()} - request_id = uuid4().hex - result = await ws_manager.send_browser_command( - request_id, action, browser_id, params, tab_id=tab_id, - ) - return result - - -def _format_tool_result(result: dict, tool_name: str) -> list[dict]: - """Convert a browser command result dict into Anthropic API content blocks.""" - if "error" in result: - return [{"type": "text", "text": f"Error: {result['error']}"}] - - if tool_name == "BrowserScreenshot" and result.get("image"): - blocks = [ - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": result["image"], - }, - }, - {"type": "text", "text": f"Screenshot captured. URL: {result.get('url', 'unknown')}"}, - ] - return blocks - - text = result.get("text", json.dumps(result)) - return [{"type": "text", "text": str(text)}] - - -async def _request_browser_approval( - session: AgentSession, tool_name: str, tool_input: dict, -) -> dict: - """Send an approval request for a browser sub-agent tool and wait for the decision.""" - request_id = uuid4().hex - approval_req = ApprovalRequest( - id=request_id, - session_id=session.id, - tool_name=tool_name, - tool_input=tool_input, - ) - session.pending_approvals.append(approval_req) - session.status = "waiting_approval" - - await ws_manager.send_to_session(session.id, "agent:status", { - "session_id": session.id, - "status": "waiting_approval", - }) - - try: - decision = await asyncio.wait_for( - ws_manager.send_approval_request( - session.id, request_id, tool_name, tool_input, - ), - timeout=300.0, - ) - except asyncio.TimeoutError: - decision = {"behavior": "deny", "message": "Approval timed out"} - - session.pending_approvals = [ - a for a in session.pending_approvals if a.id != request_id - ] - session.status = "running" - await ws_manager.send_to_session(session.id, "agent:status", { - "session_id": session.id, - "status": "running", - }) - return decision - - -async def run_browser_agent( - task: str, - browser_id: str, - model: str, - dashboard_id: str | None = None, - tab_id: str = "", - pre_selected: bool = False, - initial_url: str | None = None, - parent_session_id: str | None = None, -) -> dict: - """Run a browser sub-agent loop for a single browser card. - - Creates a visible AgentSession, streams progress via WebSocket, - and returns the full action log + summary + final screenshot. - """ - from backend.apps.agents.agent_manager import agent_manager - - _browser_perms = load_builtin_permissions() - - session_id = uuid4().hex - cancel_event = asyncio.Event() - session = AgentSession( - id=session_id, - name=f"Browser Agent", - model=model, - mode="browser-agent", - status="running", - dashboard_id=dashboard_id, - browser_id=browser_id, - system_prompt=SYSTEM_PROMPT, - parent_session_id=parent_session_id, - ) - session._cancel_event = cancel_event - agent_manager.sessions[session_id] = session - - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "running", - "session": session.model_dump(mode="json"), - }) - - if initial_url: - nav_result = await execute_browser_tool( - "BrowserNavigate", {"url": initial_url}, browser_id, tab_id, - ) - logger.info(f"Browser agent {session_id}: navigated to {initial_url}: {nav_result.get('text', nav_result.get('error', ''))}") - - api_model = resolve_model_id(model) - from backend.apps.settings.settings import load_settings - from backend.apps.settings.credentials import get_anthropic_client - client = get_anthropic_client(load_settings()) - - messages: list[dict] = [{"role": "user", "content": task}] - action_log: list[dict] = [] - final_screenshot: str | None = None - - user_msg = Message(role="user", content=task) - session.messages.append(user_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": user_msg.model_dump(mode="json"), - }) - - try: - for turn in range(MAX_TURNS): - if cancel_event.is_set(): - break - - 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 = [] - - 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( - role="assistant", - content="\n".join(text_parts), - ) - session.messages.append(asst_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": asst_msg.model_dump(mode="json"), - }) - - for tu in tool_uses: - tool_msg = Message( - role="tool_call", - content={"id": tu.id, "tool": tu.name, "input": tu.input}, - ) - session.messages.append(tool_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": tool_msg.model_dump(mode="json"), - }) - - messages.append({"role": "assistant", "content": assistant_content}) - - if response.stop_reason != "tool_use": - break - - tool_results = [] - cancelled = False - for tu in tool_uses: - if cancel_event.is_set(): - cancelled = True - break - - policy = _browser_perms.get(tu.name, "always_allow") - - if policy == "deny": - denied_text = f"Tool {tu.name} is denied by permission policy." - tool_results.append({ - "type": "tool_result", - "tool_use_id": tu.id, - "content": [{"type": "text", "text": denied_text}], - }) - result_msg = Message( - role="tool_result", - content={"text": denied_text, "tool_name": tu.name, "elapsed_ms": 0}, - ) - session.messages.append(result_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": result_msg.model_dump(mode="json"), - }) - continue - - if policy == "ask": - decision = await _request_browser_approval( - session, tu.name, tu.input, - ) - if decision.get("behavior") == "deny": - denied_text = decision.get("message") or f"Tool {tu.name} denied by user." - tool_results.append({ - "type": "tool_result", - "tool_use_id": tu.id, - "content": [{"type": "text", "text": denied_text}], - }) - result_msg = Message( - role="tool_result", - content={"text": denied_text, "tool_name": tu.name, "elapsed_ms": 0}, - ) - session.messages.append(result_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": result_msg.model_dump(mode="json"), - }) - continue - - start = time.time() - result = await execute_browser_tool( - tu.name, tu.input, browser_id, tab_id, - ) - elapsed_ms = int((time.time() - start) * 1000) - - action_log.append({ - "tool": tu.name, - "input": tu.input, - "result_summary": result.get("text", result.get("error", ""))[:200], - "elapsed_ms": elapsed_ms, - }) - - if tu.name == "BrowserScreenshot" and result.get("image"): - final_screenshot = result["image"] - - content_blocks = _format_tool_result(result, tu.name) - tool_results.append({ - "type": "tool_result", - "tool_use_id": tu.id, - "content": content_blocks, - }) - - result_text = result.get("text", result.get("error", "")) - result_msg = Message( - role="tool_result", - content={"text": result_text, "tool_name": tu.name, "elapsed_ms": elapsed_ms}, - ) - session.messages.append(result_msg) - await ws_manager.send_to_session(session_id, "agent:message", { - "session_id": session_id, - "message": result_msg.model_dump(mode="json"), - }) - - messages.append({"role": "user", "content": tool_results}) - - if cancelled: - break - - if cancel_event.is_set(): - session.status = "stopped" - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "stopped", - "session": session.model_dump(mode="json"), - }) - return { - "session_id": session_id, - "browser_id": browser_id, - "summary": "Agent was stopped.", - "action_log": action_log, - "final_screenshot": final_screenshot, - } - - summary_parts = text_parts if text_parts else ["Task completed."] - summary = "\n".join(summary_parts) - - if not final_screenshot: - try: - ss_result = await execute_browser_tool( - "BrowserScreenshot", {}, browser_id, tab_id, - ) - if ss_result.get("image"): - final_screenshot = ss_result["image"] - except Exception: - pass - - session.status = "completed" - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "completed", - "session": session.model_dump(mode="json"), - }) - - return { - "session_id": session_id, - "browser_id": browser_id, - "summary": summary, - "action_log": action_log, - "final_screenshot": final_screenshot, - } - - except Exception as e: - logger.exception(f"Browser agent {session_id} error: {e}") - session.status = "error" - error_msg = Message(role="system", content=f"Error: {str(e)}") - 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"), - }) - await ws_manager.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": "error", - "session": session.model_dump(mode="json"), - }) - - return { - "session_id": session_id, - "browser_id": browser_id, - "summary": f"Error: {str(e)}", - "action_log": action_log, - "final_screenshot": None, - } - - -async def _create_browser_card(dashboard_id: str, url: str, parent_session_id: str | None = None) -> str: - """Create a new browser card on the dashboard and return its browser_id.""" - from backend.apps.dashboards.dashboards import _load, _save - from backend.apps.dashboards.models import BrowserCardPosition, BrowserTab - - dashboard = _load(dashboard_id) - browser_id = f"browser-{uuid4().hex[:8]}" - tab_id = f"tab-{uuid4().hex[:8]}" - tab = BrowserTab(id=tab_id, url=url or "https://www.google.com", title="") - card = BrowserCardPosition( - browser_id=browser_id, - url=url or "https://www.google.com", - tabs=[tab], - activeTabId=tab_id, - x=40, - y=100, - width=1280, - height=800, - ) - dashboard.layout.browser_cards[browser_id] = card - dashboard.updated_at = datetime.now() - _save(dashboard) - - await ws_manager.broadcast_global("dashboard:browser_card_added", { - "dashboard_id": dashboard_id, - "browser_card": card.model_dump(mode="json"), - "parent_session_id": parent_session_id or "", - }) - return browser_id - - -async def run_browser_agents( - tasks: list[dict], - model: str, - dashboard_id: str | None = None, - pre_selected_browser_ids: list[str] | None = None, - parent_session_id: str | None = None, -) -> list[dict]: - """Run multiple browser sub-agents in parallel. - - Each task dict has: { browser_id (optional), task, url (optional) } - Returns a list of result dicts, one per task. - """ - from backend.apps.analytics.collector import record as _analytics - _analytics("feature.used", { - "feature": "browser_agent.launched", - "task_count": len(tasks), - "model": model, - }, dashboard_id=dashboard_id) - - pre_selected = set(pre_selected_browser_ids or []) - - async def _run_one(task_def: dict) -> dict: - browser_id = task_def.get("browser_id", "") - task_text = task_def.get("task", "") - url = task_def.get("url", "") - - if not browser_id and dashboard_id: - browser_id = await _create_browser_card(dashboard_id, url, parent_session_id) - await asyncio.sleep(2.0) - - is_pre_selected = browser_id in pre_selected - return await run_browser_agent( - task=task_text, - browser_id=browser_id, - model=model, - dashboard_id=dashboard_id, - 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, - ) - - results = await asyncio.gather(*[_run_one(t) for t in tasks], return_exceptions=True) - - final = [] - for r in results: - if isinstance(r, Exception): - final.append({"summary": f"Error: {str(r)}", "action_log": [], "final_screenshot": None}) - else: - final.append(r) - return final diff --git a/backend/apps/agents/mcp_builder.py b/backend/apps/agents/mcp_builder.py new file mode 100644 index 00000000..0bc8dfa6 --- /dev/null +++ b/backend/apps/agents/mcp_builder.py @@ -0,0 +1,135 @@ +"""MCP server building, tool-policy resolution, and tool-name helpers. + +Extracted from AgentManager to keep each module focused on a single concern. +""" + +from __future__ import annotations + +import logging +import re as _re + +from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name +from backend.apps.tools_lib.tools_lib import ( + _load_all as load_all_tools, + derive_mcp_config, + load_builtin_permissions, + refresh_google_token, +) + +logger = logging.getLogger(__name__) + +FULL_TOOLS = [ + "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", + "WebSearch", "WebFetch", "NotebookEdit", "TodoWrite", + "EnterPlanMode", "ExitPlanMode", "EnterWorktree", + "TaskOutput", "TaskStop", + "CronCreate", "CronList", "CronDelete", + "RenderOutput", + "InvokeAgent", + "Agent", +] + + +def _get_denied_tool_names(tool) -> set[str]: + """Return the set of MCP sub-tool names whose permission is 'deny'.""" + return { + key for key, value in tool.tool_permissions.items() + if not key.startswith("_") and value == "deny" + } + + +def _get_all_known_tool_names(tool) -> set[str]: + """Return all known sub-tool names for an MCP tool.""" + return set(tool.tool_permissions.get("_tool_descriptions", {}).keys()) + + +def _is_fully_denied(tool) -> bool: + """True when every known sub-tool on this MCP server is set to 'deny'.""" + known = _get_all_known_tool_names(tool) + if not known: + return False + return known <= _get_denied_tool_names(tool) + + +def get_all_tool_names() -> list[str]: + """FULL_TOOLS + installed MCP tool identifiers (mcp:). + + Builtin tools set to 'deny' and MCP servers whose every sub-tool + is denied are excluded. + """ + builtin_perms = load_builtin_permissions() + builtin_tools = [ + t for t in FULL_TOOLS + if builtin_perms.get(t, "always_allow") != "deny" + ] + mcp_names = [ + f"mcp:{t.name}" + for t in load_all_tools() + if t.mcp_config + and t.enabled + and t.auth_status in ("configured", "connected") + and not _is_fully_denied(t) + ] + return builtin_tools + mcp_names + + +async def build_mcp_servers(allowed_tools: list[str]) -> dict: + """Build the mcp_servers dict for ClaudeAgentOptions from installed MCP tools.""" + 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") + + 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(): + if not any(tool_ref == at for at in allowed_tools): + logger.info(f"[MCP-DEBUG] SKIPPED {tool.name}: '{tool_ref}' not in allowed_tools") + continue + + if _is_fully_denied(tool): + logger.info(f"[MCP-DEBUG] SKIPPED {tool.name}: fully denied") + continue + + if tool.auth_type == "oauth2" and tool.auth_status == "connected": + refreshed = await refresh_google_token(tool) + logger.info(f"[MCP-DEBUG] {tool.name} token refresh: {'OK' if refreshed else 'FAILED'}") + + 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}") + else: + logger.warning(f"[MCP-DEBUG] {tool.name}: derive_mcp_config returned None") + + logger.info(f"[MCP-DEBUG] Final mcp_servers: {list(mcp_servers.keys())}") + return mcp_servers + + +def get_effective_policy( + tool_name: str, + builtin_perms: dict[str, str], +) -> str: + """Return 'always_allow', 'deny', or 'ask' for any tool.""" + if tool_name in builtin_perms: + return builtin_perms[tool_name] + + bm = _re.match(r"mcp__openswarm-browser-agent__(.+)", tool_name) + if bm: + return builtin_perms.get(bm.group(1), "always_allow") + + im = _re.match(r"mcp__openswarm-invoke-agent__(.+)", tool_name) + if im: + return builtin_perms.get(im.group(1), "always_allow") + + m = _re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name) + if m: + server_slug, mcp_tool_name = m.group(1), m.group(2) + for t in load_all_tools(): + if not t.mcp_config or not t.enabled: + continue + if _sanitize_server_name(t.name) == server_slug: + return t.tool_permissions.get(mcp_tool_name, "ask") + return "always_allow" diff --git a/backend/apps/agents/prompt_builder.py b/backend/apps/agents/prompt_builder.py new file mode 100644 index 00000000..9806c116 --- /dev/null +++ b/backend/apps/agents/prompt_builder.py @@ -0,0 +1,324 @@ +"""Prompt-building helpers extracted from AgentManager. + +All functions are stateless — they accept data as parameters instead of +relying on ``self``. +""" + +from __future__ import annotations + +import json as _json +import logging +import os +from typing import Any + +from backend.apps.agents.models import AgentSession +from backend.apps.modes.modes import load_mode +from backend.apps.outputs.outputs import _load_all as load_all_outputs +from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name + +logger = logging.getLogger(__name__) + + +def resolve_mode( + mode_id: str, + get_all_tool_names_fn, +) -> tuple[list[str], str | None, str | None]: + """Return (tools, system_prompt, default_folder) from the mode store.""" + mode_def = load_mode(mode_id) + if mode_def: + tools = mode_def.tools if mode_def.tools is not None else get_all_tool_names_fn() + return tools, mode_def.system_prompt, mode_def.default_folder + return get_all_tool_names_fn(), None, None + + +def compose_system_prompt( + 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] + return "\n\n".join(parts) if parts else None + + +def build_connected_tools_context( + allowed_tools: list[str], + load_all_tools_fn, + get_all_tool_names_fn, + is_fully_denied_fn, + get_denied_tool_names_fn, +) -> str | None: + all_tools = load_all_tools_fn() + mcp_tools = [t for t in all_tools if t.mcp_config and t.enabled and t.auth_status in ("configured", "connected")] + + sections: 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_fn(): + continue + if is_fully_denied_fn(tool): + continue + + server_name = _sanitize_server_name(tool.name) + denied = get_denied_tool_names_fn(tool) + tool_descs = { + k: v for k, v in tool.tool_permissions.get("_tool_descriptions", {}).items() + if k not in denied + } + if not tool_descs: + continue + + lines = [f"MCP Server: {server_name}"] + lines.append(f" Status: {tool.auth_status}") + if tool.connected_account_email: + lines.append(f" Connected account: {tool.connected_account_email}") + lines.append( + f" IMPORTANT: When calling tools from this server that require an email " + f"parameter (e.g. user_google_email, user_email), always use " + f"\"{tool.connected_account_email}\" automatically — do NOT ask the user." + ) + tool_names = list(tool_descs.keys()) + if tool_names: + lines.append(f" Available tools ({len(tool_names)}): {', '.join(tool_names)}") + sections.append("\n".join(lines)) + + not_connected = [ + t for t in all_tools + if t.mcp_config and t.enabled + and t.auth_type in ("oauth2", "env_vars") + and t.auth_status != "connected" + ] + if not_connected: + nc_lines = ["Tools installed but not yet connected (user needs to authorize in Settings → Tools):"] + for t in not_connected: + nc_lines.append(f" - {t.name}") + sections.append("\n".join(nc_lines)) + + if not sections: + return None + return ( + "\n" + "The following MCP tool servers are connected and available. " + "Use them directly when relevant to the user's request.\n\n" + + "\n\n".join(sections) + + "\n" + ) + + +def build_outputs_context() -> str | None: + all_outputs = load_all_outputs() + if not all_outputs: + return None + sections: list[str] = [] + 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)) + return ( + "\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) + + "\n" + ) + + +def build_browser_context( + dashboard_id: str | None, + selected_browser_ids: list[str] | None = None, +) -> str | None: + if not dashboard_id: + return None + try: + from backend.apps.dashboards.dashboards import _load as load_dashboard + dashboard = load_dashboard(dashboard_id) + except Exception: + return None + raw = dashboard.model_dump(mode="json") + browser_cards = raw.get("layout", {}).get("browser_cards", {}) + + lines = [ + "", + "You have access to browser automation through the CreateBrowserAgent, BrowserAgent, and BrowserAgents tools.", + "", + "- **CreateBrowserAgent(task, url?)**: Create a new browser card and run a task on it. " + "Use this when you need a fresh browser. Optionally provide a starting URL.", + "- **BrowserAgent(browser_id, task)**: Delegate a task to an existing browser card. " + "The browser agent will autonomously navigate, click, type, and interact with the page, then return a summary and screenshot.", + "- **BrowserAgents(tasks)**: Run multiple browser tasks in parallel on existing browser cards. " + "Each task requires a browser_id.", + "", + "You do NOT have direct access to low-level browser tools (click, type, screenshot, etc.). " + "Instead, describe what you want accomplished and the browser agent will handle the details.", + ] + + if browser_cards and selected_browser_ids: + visible_cards = [ + card for card in browser_cards.values() + if card.get("browser_id", "") in selected_browser_ids + ] + if visible_cards: + lines.append("") + lines.append("The user selected these browser cards for you to work with:") + for card in visible_cards: + bid = card.get("browser_id", "") + tabs = card.get("tabs", []) + active_tab_id = card.get("activeTabId", "") + active_tab = next((t for t in tabs if t.get("id") == active_tab_id), None) + url = (active_tab or {}).get("url", card.get("url", "")) + title = (active_tab or {}).get("title", "") + lines.append(f"- browser_id: \"{bid}\"") + if title: + lines.append(f" Title: {title}") + if url: + lines.append(f" URL: {url}") + + lines.append("") + return "\n".join(lines) + + +def get_pre_selected_browser_ids(dashboard_id: str | None) -> list[str]: + if not dashboard_id: + return [] + try: + from backend.apps.dashboards.dashboards import _load as load_dashboard + dashboard = load_dashboard(dashboard_id) + except Exception: + return [] + raw = dashboard.model_dump(mode="json") + 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 resolve_context_paths(context_paths: list | None) -> str: + if not context_paths: + return "" + sections: list[str] = [] + for cp in context_paths: + path = cp.get("path", "") + cp_type = cp.get("type", "file") + if not path or not os.path.exists(path): + sections.append(f"[Context: {path} — not found]") + continue + if cp_type == "file" and os.path.isfile(path): + try: + with open(path, "r", errors="replace") as f: + content = f.read(512_000) + sections.append(f"\n{content}\n") + except Exception as e: + sections.append(f"[Context: {path} — error reading: {e}]") + elif cp_type == "directory" and os.path.isdir(path): + tree_lines = build_dir_tree(path, max_depth=4) + sections.append(f"\n{chr(10).join(tree_lines)}\n") + else: + sections.append(f"[Context: {path} — type mismatch]") + return "\n\n".join(sections) + + +def build_dir_tree(root: str, max_depth: int = 4, prefix: str = "") -> list[str]: + lines: list[str] = [] + try: + entries = sorted(os.listdir(root)) + except PermissionError: + return [f"{prefix}[permission denied]"] + dirs = [e for e in entries if not e.startswith(".") and os.path.isdir(os.path.join(root, e))] + files = [e for e in entries if not e.startswith(".") and os.path.isfile(os.path.join(root, e))] + for f in files: + lines.append(f"{prefix}{f}") + for d in dirs: + lines.append(f"{prefix}{d}/") + if max_depth > 1: + sub = build_dir_tree(os.path.join(root, d), max_depth - 1, prefix + " ") + lines.extend(sub) + return lines + + +def resolve_forced_tools( + forced_tools: list[str] | None, + load_all_tools_fn, +) -> str: + if not forced_tools: + return "" + from backend.apps.tools_lib.models import BUILTIN_TOOLS + desc_map: dict[str, str] = {t.name: t.description for t in BUILTIN_TOOLS} + tool_to_server: dict[str, str] = {} + tool_to_email: dict[str, str] = {} + for t in load_all_tools_fn(): + if not t.enabled or not t.tool_permissions: + continue + tool_descs = t.tool_permissions.get("_tool_descriptions", {}) + server_name = _sanitize_server_name(t.name) + for tn, td in tool_descs.items(): + desc_map[tn] = td + tool_to_server[tn] = server_name + if t.connected_account_email: + tool_to_email[tn] = t.connected_account_email + + lines: list[str] = [] + for name in forced_tools: + desc = desc_map.get(name, "") + line = f"- {name}: {desc}" if desc else f"- {name}" + server = tool_to_server.get(name) + if server: + line += f"\n (MCP server: {server})" + email = tool_to_email.get(name) + if email: + line += f"\n (connected account: {email} — use this for any email parameter)" + lines.append(line) + + return ( + "\n" + "The user explicitly requested these tools be used. " + "Prioritize using them to address the user's request.\n" + + "\n".join(lines) + + "\n" + ) + + +def resolve_attached_skills(attached_skills: list | None) -> str: + if not attached_skills: + return "" + sections: list[str] = [] + for skill in attached_skills: + name = skill.get("name", "Unknown") + content = skill.get("content", "") + if content: + sections.append(f"[Using skill: {name}]\n\n{content}") + return "\n\n".join(sections) + + +def build_prompt_content( + prompt: str, + images: list | None = None, + context_paths: list | None = None, + forced_tools: list[str] | None = None, + attached_skills: list | None = None, + load_all_tools_fn=None, +): + context_text = resolve_context_paths(context_paths) + forced_tools_text = resolve_forced_tools(forced_tools, load_all_tools_fn) + skills_text = resolve_attached_skills(attached_skills) + + parts = [p for p in (forced_tools_text, context_text, skills_text, prompt) if p] + full_prompt = "\n\n".join(parts) + + if not images: + return full_prompt + content: list[dict[str, Any]] = [{"type": "text", "text": full_prompt}] + for img in images: + content.append({ + "type": "image", + "source": { + "type": "base64", + "media_type": img.get("media_type", "image/png"), + "data": img["data"], + }, + }) + return content diff --git a/backend/apps/agents/session_store.py b/backend/apps/agents/session_store.py new file mode 100644 index 00000000..5ce5f5b2 --- /dev/null +++ b/backend/apps/agents/session_store.py @@ -0,0 +1,156 @@ +"""Session persistence, history queries, and message-copying helpers. + +Uses ``SessionStore`` from ``backend.apps.common.json_store`` for on-disk +JSON CRUD and exposes higher-level helpers consumed by ``AgentManager``. +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from uuid import uuid4 + +from backend.apps.agents.models import AgentSession, Message, MessageBranch +from backend.apps.common.json_store import SessionStore +from backend.config.paths import SESSIONS_DIR + +logger = logging.getLogger(__name__) + +_session_store = SessionStore(SESSIONS_DIR) + +save_session = _session_store.save +load_session_data = _session_store.load +delete_session_file = _session_store.delete +load_all_session_data = _session_store.load_all + + +def build_search_text(session: AgentSession, max_len: int = 5000) -> str: + """Build a search-indexing string from session name and message content.""" + parts = [session.name or ""] + for msg in session.messages: + if msg.role in ("user", "assistant") and isinstance(msg.content, str): + parts.append(msg.content) + text = " ".join(parts) + return text[:max_len] + + +def get_history( + q: str = "", + limit: int = 20, + offset: int = 0, + dashboard_id: str | None = None, +) -> dict: + """Return paginated, optionally filtered summaries of closed sessions.""" + all_data = load_all_session_data() + all_data.sort(key=lambda pair: pair[1].get("closed_at") or "", reverse=True) + + q_lower = q.strip().lower() + history: list[dict] = [] + for sid, data in all_data: + if dashboard_id and data.get("dashboard_id") != dashboard_id: + continue + if q_lower: + name = (data.get("name") or "").lower() + search_text = (data.get("search_text") or "").lower() + if q_lower not in name and q_lower not in search_text: + continue + history.append({ + "id": data.get("id", sid), + "name": data.get("name", "Untitled"), + "status": data.get("status", "stopped"), + "model": data.get("model", "sonnet"), + "mode": data.get("mode", "agent"), + "created_at": data.get("created_at"), + "closed_at": data.get("closed_at"), + "cost_usd": data.get("cost_usd", 0), + "dashboard_id": data.get("dashboard_id"), + }) + + total = len(history) + page = history[offset : offset + limit] + return { + "sessions": page, + "total": total, + "has_more": offset + limit < total, + } + + +async def reconcile_on_startup() -> None: + """Mark any stale running sessions as stopped.""" + for sid, data in load_all_session_data(): + if data.get("status") in ("running", "waiting_approval"): + data["status"] = "stopped" + save_session(sid, data) + logger.info(f"Marked stale session {sid} as stopped") + + +def get_browser_agent_children( + sessions: dict[str, AgentSession], + parent_session_id: str, +) -> list[dict]: + """Return browser-agent sessions for a parent, from memory or disk.""" + results: list[dict] = [] + seen: set[str] = set() + + for s in sessions.values(): + if s.mode == "browser-agent" and s.parent_session_id == parent_session_id: + results.append(s.model_dump(mode="json")) + seen.add(s.id) + + for sid, data in load_all_session_data(): + if sid in seen: + continue + if data.get("mode") == "browser-agent" and data.get("parent_session_id") == parent_session_id: + results.append(data) + + return results + + +def copy_session_messages( + source: AgentSession, + up_to_message_id: str | None = None, +) -> tuple[list[Message], dict[str, MessageBranch], dict[str, str]]: + """Deep-copy messages and branches from *source*, returning new IDs. + + Returns ``(new_messages, new_branches, old_to_new_msg_id_map)``. + """ + source_messages = list(source.messages) + if up_to_message_id: + cut_idx = next( + (i for i, m in enumerate(source_messages) if m.id == up_to_message_id), + None, + ) + if cut_idx is not None: + source_messages = source_messages[: cut_idx + 1] + + old_to_new: dict[str, str] = {} + new_messages: list[Message] = [] + for msg in source_messages: + new_id = uuid4().hex + old_to_new[msg.id] = new_id + new_messages.append(Message( + id=new_id, + role=msg.role, + content=msg.content, + timestamp=msg.timestamp, + branch_id=msg.branch_id, + parent_id=old_to_new.get(msg.parent_id) if msg.parent_id else None, + context_paths=msg.context_paths, + attached_skills=msg.attached_skills, + forced_tools=msg.forced_tools, + images=msg.images, + )) + + new_branches: dict[str, MessageBranch] = {} + for bid, branch in source.branches.items(): + new_branches[bid] = MessageBranch( + id=bid, + parent_branch_id=branch.parent_branch_id, + fork_point_message_id=( + old_to_new.get(branch.fork_point_message_id) + if branch.fork_point_message_id else None + ), + created_at=branch.created_at, + ) + + return new_messages, new_branches, old_to_new diff --git a/backend/apps/outputs/ai_generation.py b/backend/apps/outputs/ai_generation.py new file mode 100644 index 00000000..884a660b --- /dev/null +++ b/backend/apps/outputs/ai_generation.py @@ -0,0 +1,223 @@ +"""AI-powered endpoints: vibe-code, auto-run, auto-run-agent.""" + +from __future__ import annotations + +import json +import logging + +from backend.apps.outputs.helpers import _validate_against_schema +from backend.apps.outputs.executor import execute_backend_code +from backend.apps.common.model_registry import resolve_model_id as _resolve_model +from backend.apps.outputs.models import ( + VibeCodeRequest, AutoRunRequest, AutoRunAgentRequest, +) + +logger = logging.getLogger(__name__) + + +def _get_anthropic_client(): + from backend.apps.settings.credentials import get_anthropic_client + from backend.apps.settings.settings import load_settings + return get_anthropic_client(load_settings()) + + +VIBE_CODE_SYSTEM_PROMPT = """\ +You are an expert at building self-contained HTML/JS/CSS applications that run in an iframe. + +The user will describe what they want, and you will generate: +1. **frontend_code**: A complete HTML document. React 18 is available via esm.sh CDN. + - Use: + - Input data is at window.OUTPUT_INPUT (object), backend result at window.OUTPUT_BACKEND_RESULT. +2. **input_schema**: A JSON Schema object defining the structured input. +3. **backend_code** (optional): Python code where input_data is a global dict and result is a global dict to assign to. +4. **name**: A short name for the view. +5. **description**: A one-sentence description. +6. **message**: A brief explanation of what you did/changed. + +Return ONLY valid JSON with these keys. No markdown fences, no extra text.\ +""" + + +async def vibe_code(body: VibeCodeRequest): + from backend.apps.analytics.collector import record as _analytics + _analytics("feature.used", {"feature": "vibe_code.used"}) + try: + import anthropic + except ImportError: + return { + "message": "anthropic SDK not installed. Install with: pip install anthropic", + "frontend_code": body.current_frontend_code, + "backend_code": body.current_backend_code, + "input_schema": body.current_schema, + } + + context_parts = [] + if body.current_frontend_code: + context_parts.append(f"Current frontend code:\n```html\n{body.current_frontend_code}\n```") + if body.current_backend_code: + context_parts.append(f"Current backend code:\n```python\n{body.current_backend_code}\n```") + if body.current_schema: + context_parts.append(f"Current input schema:\n```json\n{body.current_schema}\n```") + if body.name: + context_parts.append(f"Current name: {body.name}") + if body.description: + context_parts.append(f"Current description: {body.description}") + + user_message = body.prompt + if context_parts: + user_message = "\n\n".join(context_parts) + "\n\nUser request: " + body.prompt + + client = _get_anthropic_client() + try: + resp = await client.messages.create( + model="claude-sonnet-4-20250514", max_tokens=8000, + system=VIBE_CODE_SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_message}], + ) + raw = resp.content[0].text.strip() + if raw.startswith("```"): + raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:] + if raw.endswith("```"): + raw = raw[:-3] + result = json.loads(raw) + return { + "message": result.get("message", "View updated."), + "frontend_code": result.get("frontend_code", body.current_frontend_code), + "backend_code": result.get("backend_code", body.current_backend_code), + "input_schema": result.get("input_schema", body.current_schema), + "name": result.get("name", body.name), + "description": result.get("description", body.description), + } + except json.JSONDecodeError: + return { + "message": "I generated code but couldn't parse the response. Please try again.", + "frontend_code": body.current_frontend_code, + "backend_code": body.current_backend_code, + "input_schema": body.current_schema, + } + except Exception as e: + logger.exception("Vibe code generation failed") + return { + "message": f"Error: {str(e)}", + "frontend_code": body.current_frontend_code, + "backend_code": body.current_backend_code, + "input_schema": body.current_schema, + } + + +AUTO_RUN_SYSTEM_PROMPT = """\ +You generate structured JSON data matching a given schema. +The user provides a prompt describing what data to generate and a JSON Schema. +Return ONLY valid JSON that conforms to the schema. No markdown fences, no extra text, no explanation. +Every required field must be present. Use realistic, meaningful data.\ +""" + + +async def auto_run_output(body: AutoRunRequest): + try: + import anthropic + except ImportError: + return {"error": "anthropic SDK not installed", "input_data": None, "backend_result": None} + + schema_str = json.dumps(body.input_schema, indent=2) + user_message = f"Schema:\n```json\n{schema_str}\n```\n\nGenerate data for: {body.prompt}" + + api_model = _resolve_model(body.model) + client = _get_anthropic_client() + try: + resp = await client.messages.create( + model=api_model, max_tokens=4000, + system=AUTO_RUN_SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_message}], + ) + raw = resp.content[0].text.strip() + if raw.startswith("```"): + raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:] + if raw.endswith("```"): + raw = raw[:-3] + input_data = json.loads(raw) + + validation_err = _validate_against_schema(input_data, body.input_schema) + if validation_err: + return {"input_data": input_data, "backend_result": None, "error": validation_err} + + backend_result = None + stdout_text = None + stderr_text = None + error = None + if body.backend_code: + try: + exec_result = await execute_backend_code(body.backend_code, input_data) + backend_result = exec_result.result + stdout_text = exec_result.stdout + stderr_text = exec_result.stderr + except Exception as e: + error = str(e) + + return {"input_data": input_data, "backend_result": backend_result, "stdout": stdout_text, "stderr": stderr_text, "error": error} + except json.JSONDecodeError: + return {"error": "Failed to parse generated data as JSON", "input_data": None, "backend_result": None} + except Exception as e: + logger.exception("Auto-run failed") + return {"error": str(e), "input_data": None, "backend_result": None} + + +AUTO_RUN_AGENT_SYSTEM_PROMPT = """\ +You are a data-gathering agent. Your job is to use the available tools to collect \ +real data, then render it into a structured View. + +You have access to MCP tools (e.g. Gmail, calendar, etc.) that let you fetch live data. \ +Use them as needed to fulfil the user's request. + +When you have gathered enough data, call the **RenderOutput** tool with: +- `output_id`: `{output_id}` +- `input_data`: a JSON object conforming to this schema: +```json +{schema} +``` + +Do NOT fabricate data. Use the tools to get real information, then structure it to match \ +the schema above. If a tool call fails, report the error clearly.\ +""" + + +async def auto_run_agent(body: AutoRunAgentRequest): + from backend.apps.agents.agent_manager import agent_manager + from backend.apps.agents.mcp_builder import FULL_TOOLS + from backend.apps.agents.models import AgentConfig + from backend.apps.outputs.outputs import _load + + output = _load(body.output_id) + schema_str = json.dumps(body.input_schema or output.input_schema, indent=2) + + system_prompt = AUTO_RUN_AGENT_SYSTEM_PROMPT.format( + output_id=body.output_id, schema=schema_str, + ) + + allowed_tools = list(FULL_TOOLS) + for tool_name in body.forced_tools: + if tool_name not in allowed_tools: + allowed_tools.append(tool_name) + + config = AgentConfig( + name=f"AutoRun: {output.name}", model=body.model, + mode="agent", system_prompt=system_prompt, + allowed_tools=allowed_tools, max_turns=20, + ) + + session = await agent_manager.launch_agent(config) + await agent_manager.send_message( + session.id, body.prompt, + context_paths=body.context_paths if body.context_paths else None, + forced_tools=body.forced_tools if body.forced_tools else None, + ) + return {"session_id": session.id} + + +async def cleanup_auto_run_agent(session_id: str): + from backend.apps.agents.agent_manager import agent_manager + try: + await agent_manager.delete_session(session_id) + except Exception as e: + logger.warning(f"Auto-run agent cleanup failed for {session_id}: {e}") + return {"ok": True} diff --git a/backend/apps/outputs/helpers.py b/backend/apps/outputs/helpers.py new file mode 100644 index 00000000..4378af6d --- /dev/null +++ b/backend/apps/outputs/helpers.py @@ -0,0 +1,72 @@ +"""Pure helpers for data injection, validation, and directory walking.""" + +from __future__ import annotations + +import base64 +import json +import os + +from jsonschema import validate as schema_validate, ValidationError as SchemaValidationError + + +def _validate_against_schema(data: dict, schema: dict) -> str | None: + """Validate *data* against *schema*. Return an error string or None.""" + try: + schema_validate(instance=data, schema=schema) + return None + except SchemaValidationError as exc: + path = " -> ".join(str(p) for p in exc.absolute_path) if exc.absolute_path else "(root)" + return f"Schema validation failed at {path}: {exc.message}" + + +def _build_data_injection(input_json: str, result_json: str) -> str: + return ( + "" + ) + + +def _inject_data_into_html(html: str, input_json: str = "{}", result_json: str = "null") -> str: + injection = _build_data_injection(input_json, result_json) + if "" in html: + return html.replace("", f"{injection}\n", 1) + if " tuple[str, str]: + try: + decoded = json.loads(base64.b64decode(d)) + input_json = json.dumps(decoded.get("i", {})) + result_json = json.dumps(decoded.get("r", None)) + return input_json, result_json + except Exception: + return "{}", "null" + + +def _walk_directory(folder: str) -> dict[str, str]: + files: dict[str, str] = {} + if not os.path.isdir(folder): + return files + for root, _dirs, filenames in os.walk(folder): + for fname in filenames: + full_path = os.path.join(root, fname) + rel_path = os.path.relpath(full_path, folder) + try: + with open(full_path) as f: + files[rel_path] = f.read() + except Exception: + pass + return files diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 2f7c9955..47a904e3 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -1,87 +1,34 @@ +"""Outputs SubApp — CRUD, workspace management, and file serving. + +AI-generation endpoints live in ``ai_generation.py``; pure helpers in ``helpers.py``. +""" + +from __future__ import annotations + import json -import os -import logging import mimetypes -import base64 +import os from datetime import datetime from contextlib import asynccontextmanager -from fastapi import HTTPException, Query + +from fastapi import HTTPException from fastapi.responses import Response -from jsonschema import validate as schema_validate, ValidationError as SchemaValidationError + from backend.config.Apps import SubApp from backend.apps.common.json_store import JsonStore from backend.apps.outputs.models import ( Output, OutputCreate, OutputUpdate, OutputExecute, OutputExecuteResult, - VibeCodeRequest, AutoRunRequest, AutoRunConfig, AutoRunAgentRequest, - WorkspaceSeedRequest, + AutoRunConfig, WorkspaceSeedRequest, ) from backend.apps.outputs.executor import execute_backend_code from backend.apps.outputs.view_builder_templates import VIEW_BUILDER_SKILL, VIEW_TEMPLATE_FILES -from backend.apps.common.model_registry import resolve_model_id as _resolve_model -from backend.apps.settings.settings import load_settings - -logger = logging.getLogger(__name__) - - -def _get_anthropic_client(): - """Create an AsyncAnthropic client using the API key from app settings.""" - from backend.apps.settings.credentials import get_anthropic_client - settings = load_settings() - return get_anthropic_client(settings) - - -def _validate_against_schema(data: dict, schema: dict) -> str | None: - """Validate *data* against *schema*. Return an error string or None.""" - try: - schema_validate(instance=data, schema=schema) - return None - except SchemaValidationError as exc: - path = " -> ".join(str(p) for p in exc.absolute_path) if exc.absolute_path else "(root)" - return f"Schema validation failed at {path}: {exc.message}" - +from backend.apps.outputs.helpers import ( + _validate_against_schema, _inject_data_into_html, _decode_data_param, _walk_directory, +) +from backend.apps.outputs import ai_generation from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR -def _build_data_injection(input_json: str, result_json: str) -> str: - """Build a

Invalid OAuth state

", status_code=400) + + tool = _get_store().load(tool_id) + provider = _resolve_oauth_provider(tool) + + client_id = os.environ.get(provider.client_id_env, "") + client_secret = os.environ.get(provider.client_secret_env, "") + _port = os.environ.get("OPENSWARM_PORT", "8324") + redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback" + + token_data: dict[str, str] = { + "code": code, "redirect_uri": redirect_uri, "grant_type": "authorization_code", + } + headers: dict[str, str] = {} + + if provider.token_auth_method == "basic": + creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + headers["Authorization"] = f"Basic {creds}" + elif provider.token_auth_method == "basic_json": + creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + headers["Authorization"] = f"Basic {creds}" + headers["Content-Type"] = "application/json" + else: + token_data["client_id"] = client_id + token_data["client_secret"] = client_secret + + if (tool.oauth_provider or "google") == "github": + headers["Accept"] = "application/json" + + code_verifier = _pending_pkce.pop(state, None) + if code_verifier: + token_data["code_verifier"] = code_verifier + + async with httpx.AsyncClient(timeout=15.0) as client: + if provider.token_auth_method == "basic_json": + resp = await client.post(provider.token_url, json=token_data, headers=headers) + else: + resp = await client.post(provider.token_url, data=token_data, headers=headers) + + if resp.status_code != 200: + logger.warning(f"OAuth token exchange failed: {resp.text}") + return HTMLResponse(f"

Token exchange failed

{resp.text}
", status_code=400) + + tokens = resp.json() + + access_token = tokens.get("access_token", "") + if provider.token_response_path and not access_token: + obj = tokens + for part in provider.token_response_path.split("."): + obj = obj.get(part, {}) if isinstance(obj, dict) else "" + if isinstance(obj, str) and obj: + access_token = obj + + tool.oauth_tokens = { + "access_token": access_token, + "refresh_token": tokens.get("refresh_token", ""), + "token_expiry": time.time() + tokens.get("expires_in", 3600), + } + + for response_path, env_var in provider.extra_token_fields.items(): + obj_val: Any = tokens + for part in response_path.split("."): + obj_val = obj_val.get(part, "") if isinstance(obj_val, dict) else "" + if obj_val: + tool.oauth_tokens[env_var] = str(obj_val) + + tool.auth_status = "connected" + + if access_token and provider.userinfo_url: + try: + async with httpx.AsyncClient(timeout=10.0) as info_client: + info_resp = await info_client.get( + provider.userinfo_url, + headers={"Authorization": f"Bearer {access_token}"}, + ) + if info_resp.status_code == 200: + tool.connected_account_email = info_resp.json().get(provider.userinfo_field) + except Exception as e: + logger.warning(f"Failed to fetch userinfo for {tool.oauth_provider or 'google'}: {e}") + + if (tool.oauth_provider or "google") == "notion" and not tool.connected_account_email: + workspace_name = tokens.get("workspace_name") + if workspace_name: + tool.connected_account_email = workspace_name + + _get_store().save(tool) + + return HTMLResponse(""" + +

Connected successfully!

+

You can close this window.

+ + + """) + + +async def oauth_disconnect(tool_id: str): + tool = _get_store().load(tool_id) + access_token = tool.oauth_tokens.get("access_token") + + if access_token: + provider = _resolve_oauth_provider(tool) + revoke_url = provider.revoke_url or "https://oauth2.googleapis.com/revoke" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + await client.post( + revoke_url, params={"token": access_token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + except Exception as e: + logger.warning(f"Failed to revoke token for tool {tool.id}: {e}") + + tool.oauth_tokens = {} + tool.auth_status = "configured" + tool.connected_account_email = None + _get_store().save(tool) + return {"ok": True, "tool": tool.model_dump()} + + +async def oauth_start(tool_id: str): + tool = _get_store().load(tool_id) + provider = _resolve_oauth_provider(tool) + + client_id = os.environ.get(provider.client_id_env, "") + if not client_id: + raise HTTPException(status_code=400, detail=f"{provider.client_id_env} not set in backend .env") + + _port = os.environ.get("OPENSWARM_PORT", "8324") + redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback" + provider_key = tool.oauth_provider or "google" + state = f"{provider_key}:{tool_id}" + + _pending_oauth[state] = tool_id + + params = { + "client_id": client_id, "redirect_uri": redirect_uri, + "response_type": "code", "state": state, + **provider.extra_auth_params, + } + if provider.scopes: + params["scope"] = " ".join(provider.scopes) + + if provider.pkce_required: + code_verifier = secrets.token_urlsafe(64) + code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode()).digest() + ).rstrip(b"=").decode() + params["code_challenge"] = code_challenge + params["code_challenge_method"] = "S256" + _pending_pkce[state] = code_verifier + + auth_url = f"{provider.auth_url}?{urlencode(params)}" + return {"auth_url": auth_url} + + +async def refresh_oauth_token(tool) -> Optional[str]: + """Refresh an expired OAuth token. Returns the fresh access_token or None.""" + if tool.auth_type != "oauth2": + return None + refresh_token = tool.oauth_tokens.get("refresh_token") + if not refresh_token: + return None + expiry = tool.oauth_tokens.get("token_expiry", 0) + if time.time() < expiry - 60: + return tool.oauth_tokens.get("access_token") + + provider = _resolve_oauth_provider(tool) + client_id = os.environ.get(provider.client_id_env, "") + client_secret = os.environ.get(provider.client_secret_env, "") + if not client_id or not client_secret: + return None + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(provider.token_url, data={ + "client_id": client_id, "client_secret": client_secret, + "refresh_token": refresh_token, "grant_type": "refresh_token", + }) + if resp.status_code == 200: + data = resp.json() + new_token = data["access_token"] + tool.oauth_tokens["access_token"] = new_token + tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 3600) + + if not tool.connected_account_email and provider.userinfo_url: + try: + async with httpx.AsyncClient(timeout=10.0) as info_client: + info_resp = await info_client.get( + provider.userinfo_url, + headers={"Authorization": f"Bearer {new_token}"}, + ) + if info_resp.status_code == 200: + tool.connected_account_email = info_resp.json().get(provider.userinfo_field) + except Exception: + pass + + _get_store().save(tool) + return new_token + except Exception as e: + logger.warning(f"OAuth token refresh failed for tool {tool.id}: {e}") + return None + + +refresh_google_token = refresh_oauth_token diff --git a/backend/apps/tools_lib/oauth_providers.py b/backend/apps/tools_lib/oauth_providers.py new file mode 100644 index 00000000..37b25608 --- /dev/null +++ b/backend/apps/tools_lib/oauth_providers.py @@ -0,0 +1,166 @@ +"""OAuth provider definitions — pure data, no route handlers.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field + +from fastapi import HTTPException + +# Default OAuth credentials (public client IDs safe to embed per vendor docs). +_DEFAULT_GOOGLE_CLIENT_ID = "6741219524-8vpt07arcc5rvkdb4j1b6v9g53469ugq.apps.googleusercontent.com" +_DEFAULT_GOOGLE_CLIENT_SECRET = "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" +os.environ.setdefault("GOOGLE_OAUTH_CLIENT_ID", _DEFAULT_GOOGLE_CLIENT_ID) +os.environ.setdefault("GOOGLE_OAUTH_CLIENT_SECRET", _DEFAULT_GOOGLE_CLIENT_SECRET) +os.environ.setdefault("GITHUB_OAUTH_CLIENT_ID", "Ov23liDcwNJaKMjXY2jI") +os.environ.setdefault("GITHUB_OAUTH_CLIENT_SECRET", "b25fe39409896aad3fd5155f032e9868440002f8") +os.environ.setdefault("SLACK_CLIENT_ID", "10795695056323.10799999254534") +os.environ.setdefault("SLACK_CLIENT_SECRET", "d3a85a286bb0205157d7e4963502a91d") +os.environ.setdefault("FIGMA_CLIENT_ID", "q6WduT7UuPaO6lM88v6ddN") +os.environ.setdefault("FIGMA_CLIENT_SECRET", "dhNZdbEuyEWC15cKLwWpqTclyOSplD") +os.environ.setdefault("AIRTABLE_CLIENT_ID", "0699038b-a3a4-46b2-8fa6-690eb76fadfa") +os.environ.setdefault("AIRTABLE_CLIENT_SECRET", "187fa83c8bab8ebcd11b8f226d75e7a1f14a8174ac0494463c1a53e66a3036d0") +os.environ.setdefault("HUBSPOT_CLIENT_ID", "6f4a1d4c-6a2f-4336-9b65-2cd84e218ff6") +os.environ.setdefault("HUBSPOT_CLIENT_SECRET", "5747b5de-0800-4c35-a2da-e0655ee7ea37") + +GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" +GOOGLE_SCOPES = [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/contacts.readonly", +] + + +@dataclass +class OAuthProvider: + auth_url: str + token_url: str + scopes: list[str] + userinfo_url: str | None + userinfo_field: str + client_id_env: str + client_secret_env: str + token_env_mapping: dict[str, str] + extra_auth_params: dict[str, str] = field(default_factory=dict) + revoke_url: str | None = None + token_response_path: str | None = None + token_auth_method: str = "form" + pkce_required: bool = False + env_value_transform: str | None = None + extra_token_fields: dict[str, str] = field(default_factory=dict) + + +OAUTH_PROVIDERS: dict[str, OAuthProvider] = { + "google": OAuthProvider( + auth_url=GOOGLE_AUTH_URL, token_url=GOOGLE_TOKEN_URL, + scopes=GOOGLE_SCOPES, userinfo_url=GOOGLE_USERINFO_URL, + userinfo_field="email", client_id_env="GOOGLE_OAUTH_CLIENT_ID", + client_secret_env="GOOGLE_OAUTH_CLIENT_SECRET", + token_env_mapping={ + "access_token": "OAUTH_ACCESS_TOKEN", + "refresh_token": "GOOGLE_WORKSPACE_REFRESH_TOKEN", + "_client_id": "GOOGLE_WORKSPACE_CLIENT_ID", + "_client_secret": "GOOGLE_WORKSPACE_CLIENT_SECRET", + }, + extra_auth_params={"access_type": "offline", "prompt": "consent"}, + ), + "github": OAuthProvider( + auth_url="https://github.com/login/oauth/authorize", + token_url="https://github.com/login/oauth/access_token", + scopes=["repo", "read:user", "user:email"], + userinfo_url="https://api.github.com/user", userinfo_field="login", + client_id_env="GITHUB_OAUTH_CLIENT_ID", client_secret_env="GITHUB_OAUTH_CLIENT_SECRET", + token_env_mapping={"access_token": "GITHUB_PERSONAL_ACCESS_TOKEN"}, + ), + "slack": OAuthProvider( + auth_url="https://slack.com/oauth/v2/authorize", + token_url="https://slack.com/api/oauth.v2.access", + scopes=[ + "channels:read", "channels:history", "chat:write", + "groups:read", "groups:history", "im:read", "im:history", + "mpim:read", "mpim:history", "users:read", "users:read.email", + "team:read", "reactions:read", "reactions:write", + "files:read", "files:write", + ], + userinfo_url="https://slack.com/api/auth.test", userinfo_field="user", + client_id_env="SLACK_CLIENT_ID", client_secret_env="SLACK_CLIENT_SECRET", + token_env_mapping={"access_token": "SLACK_BOT_TOKEN"}, + extra_token_fields={"team.id": "SLACK_TEAM_ID"}, + ), + "notion": OAuthProvider( + auth_url="https://api.notion.com/v1/oauth/authorize", + token_url="https://api.notion.com/v1/oauth/token", + scopes=[], userinfo_url=None, userinfo_field="owner", + client_id_env="NOTION_OAUTH_CLIENT_ID", client_secret_env="NOTION_OAUTH_CLIENT_SECRET", + token_env_mapping={"access_token": "OPENAPI_MCP_HEADERS"}, + extra_auth_params={"owner": "user"}, + token_auth_method="basic_json", env_value_transform="notion_headers", + ), + "spotify": OAuthProvider( + auth_url="https://accounts.spotify.com/authorize", + token_url="https://accounts.spotify.com/api/token", + scopes=[ + "user-read-playback-state", "user-modify-playback-state", + "user-read-currently-playing", "playlist-read-private", + "playlist-modify-public", "playlist-modify-private", + "user-library-read", "user-library-modify", + "user-read-recently-played", "user-top-read", + ], + userinfo_url="https://api.spotify.com/v1/me", userinfo_field="display_name", + client_id_env="SPOTIFY_CLIENT_ID", client_secret_env="SPOTIFY_CLIENT_SECRET", + token_env_mapping={ + "access_token": "SPOTIFY_ACCESS_TOKEN", + "refresh_token": "SPOTIFY_REFRESH_TOKEN", + "_client_id": "SPOTIFY_CLIENT_ID", + "_client_secret": "SPOTIFY_CLIENT_SECRET", + }, + token_auth_method="basic", + ), + "figma": OAuthProvider( + auth_url="https://www.figma.com/oauth", + token_url="https://api.figma.com/v1/oauth/token", + scopes=["current_user:read", "file_content:read", "file_metadata:read", "file_comments:read", "file_comments:write", "file_versions:read", "file_variables:read"], + userinfo_url="https://api.figma.com/v1/me", userinfo_field="email", + client_id_env="FIGMA_CLIENT_ID", client_secret_env="FIGMA_CLIENT_SECRET", + token_env_mapping={"access_token": "FIGMA_API_KEY"}, + ), + "airtable": OAuthProvider( + auth_url="https://airtable.com/oauth2/v1/authorize", + token_url="https://airtable.com/oauth2/v1/token", + scopes=[ + "data.records:read", "data.records:write", + "data.recordComments:read", "data.recordComments:write", + "schema.bases:read", "schema.bases:write", + "user.email:read", "webhook:manage", + ], + userinfo_url="https://api.airtable.com/v0/meta/whoami", userinfo_field="email", + client_id_env="AIRTABLE_CLIENT_ID", client_secret_env="AIRTABLE_CLIENT_SECRET", + token_env_mapping={"access_token": "AIRTABLE_API_KEY"}, + pkce_required=True, token_auth_method="basic", + ), + "hubspot": OAuthProvider( + auth_url="https://mcp-na2.hubspot.com/oauth/authorize/user", + token_url="https://api.hubapi.com/oauth/v1/token", + scopes=[], userinfo_url=None, userinfo_field="user", + client_id_env="HUBSPOT_CLIENT_ID", client_secret_env="HUBSPOT_CLIENT_SECRET", + token_env_mapping={ + "access_token": "PRIVATE_APP_ACCESS_TOKEN", + "refresh_token": "HUBSPOT_REFRESH_TOKEN", + }, + pkce_required=True, + ), +} + + +def _resolve_oauth_provider(tool) -> OAuthProvider: + """Resolve the OAuth provider for a tool, defaulting to Google.""" + key = tool.oauth_provider or "google" + provider = OAUTH_PROVIDERS.get(key) + if not provider: + raise HTTPException(status_code=400, detail=f"Unknown OAuth provider: {key}") + return provider diff --git a/backend/apps/tools_lib/routes.py b/backend/apps/tools_lib/routes.py new file mode 100644 index 00000000..771447f5 --- /dev/null +++ b/backend/apps/tools_lib/routes.py @@ -0,0 +1,84 @@ +"""Tool CRUD endpoints and builtin permission management.""" + +from __future__ import annotations + +import json +import os + +from fastapi import HTTPException + +from backend.apps.common.json_store import JsonStore +from backend.apps.tools_lib.models import ToolDefinition, ToolCreate, ToolUpdate, BUILTIN_TOOLS +from backend.config.paths import TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH + +_store = JsonStore(ToolDefinition, DATA_DIR, not_found_detail="Tool not found") + +_load_all = _store.load_all +_save = _store.save +_load = _store.load + + +def load_builtin_permissions() -> dict[str, str]: + if not os.path.exists(BUILTIN_PERMS_PATH): + return {} + with open(BUILTIN_PERMS_PATH) as f: + return json.load(f) + + +def save_builtin_permissions(perms: dict[str, str]): + os.makedirs(os.path.dirname(BUILTIN_PERMS_PATH), exist_ok=True) + with open(BUILTIN_PERMS_PATH, "w") as f: + json.dump(perms, f, indent=2) + + +async def list_builtin_tools(): + return {"tools": [t.model_dump() for t in BUILTIN_TOOLS]} + + +async def get_builtin_permissions(): + return {"permissions": load_builtin_permissions()} + + +async def update_builtin_permissions(body: dict): + valid_tools = {t.name for t in BUILTIN_TOOLS} + valid_policies = {"always_allow", "ask", "deny"} + perms = load_builtin_permissions() + for name, policy in body.get("permissions", {}).items(): + if name in valid_tools and policy in valid_policies: + perms[name] = policy + save_builtin_permissions(perms) + return {"permissions": perms} + + +async def list_tools(): + return {"tools": [t.model_dump() for t in _load_all()]} + + +async def get_tool(tool_id: str): + return _load(tool_id).model_dump() + + +async def create_tool(body: ToolCreate): + tool = ToolDefinition( + name=body.name, description=body.description, + command=body.command, mcp_config=body.mcp_config, + credentials=body.credentials, auth_type=body.auth_type, + auth_status=body.auth_status, oauth_provider=body.oauth_provider, + ) + _save(tool) + return {"ok": True, "tool": tool.model_dump()} + + +async def update_tool(tool_id: str, body: ToolUpdate): + tool = _load(tool_id) + for k, v in body.model_dump(exclude_none=True).items(): + setattr(tool, k, v) + _save(tool) + return {"ok": True, "tool": tool.model_dump()} + + +async def delete_tool(tool_id: str): + path = os.path.join(DATA_DIR, f"{tool_id}.json") + if os.path.exists(path): + os.remove(path) + return {"ok": True} diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index c1fe9288..6bf90bf6 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -1,1112 +1,16 @@ -import asyncio -import base64 -import hashlib -import json -import os -import re -import logging -import secrets -import shutil -import time -from contextlib import asynccontextmanager -from dataclasses import dataclass, field -from typing import Any, Optional -from urllib.parse import urlencode - -import httpx -from dotenv import load_dotenv -from fastapi import HTTPException, Query -from fastapi.responses import HTMLResponse -from backend.config.Apps import SubApp -from backend.apps.common.json_store import JsonStore -from backend.apps.tools_lib.models import ToolDefinition, ToolCreate, ToolUpdate, BUILTIN_TOOLS - -logger = logging.getLogger(__name__) - -# Default Google OAuth credentials for the OpenSwarm project. -# These are public credentials for a desktop/web OAuth client (safe to embed per Google's docs). -# Users can override via GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET env vars. -_DEFAULT_GOOGLE_CLIENT_ID = "6741219524-8vpt07arcc5rvkdb4j1b6v9g53469ugq.apps.googleusercontent.com" -_DEFAULT_GOOGLE_CLIENT_SECRET = "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" -os.environ.setdefault("GOOGLE_OAUTH_CLIENT_ID", _DEFAULT_GOOGLE_CLIENT_ID) -os.environ.setdefault("GOOGLE_OAUTH_CLIENT_SECRET", _DEFAULT_GOOGLE_CLIENT_SECRET) - -# Default GitHub OAuth credentials for the OpenSwarm project. -os.environ.setdefault("GITHUB_OAUTH_CLIENT_ID", "Ov23liDcwNJaKMjXY2jI") -os.environ.setdefault("GITHUB_OAUTH_CLIENT_SECRET", "b25fe39409896aad3fd5155f032e9868440002f8") - -# Default Slack OAuth credentials (requires HTTPS redirect — not yet functional) -os.environ.setdefault("SLACK_CLIENT_ID", "10795695056323.10799999254534") -os.environ.setdefault("SLACK_CLIENT_SECRET", "d3a85a286bb0205157d7e4963502a91d") - -# Default Figma OAuth credentials for the OpenSwarm project. -os.environ.setdefault("FIGMA_CLIENT_ID", "q6WduT7UuPaO6lM88v6ddN") -os.environ.setdefault("FIGMA_CLIENT_SECRET", "dhNZdbEuyEWC15cKLwWpqTclyOSplD") - -# Default Airtable OAuth credentials for the OpenSwarm project. -os.environ.setdefault("AIRTABLE_CLIENT_ID", "0699038b-a3a4-46b2-8fa6-690eb76fadfa") -os.environ.setdefault("AIRTABLE_CLIENT_SECRET", "187fa83c8bab8ebcd11b8f226d75e7a1f14a8174ac0494463c1a53e66a3036d0") - -# Default HubSpot MCP Auth App credentials for the OpenSwarm project. -os.environ.setdefault("HUBSPOT_CLIENT_ID", "6f4a1d4c-6a2f-4336-9b65-2cd84e218ff6") -os.environ.setdefault("HUBSPOT_CLIENT_SECRET", "5747b5de-0800-4c35-a2da-e0655ee7ea37") - -from backend.config.paths import BACKEND_DIR, DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH - -load_dotenv(os.path.join(BACKEND_DIR, ".env")) -if os.environ.get("OPENSWARM_PACKAGED") == "1": - load_dotenv(os.path.join(os.path.dirname(DATA_ROOT), ".env"), override=True) - - -@asynccontextmanager -async def tools_lib_lifespan(): - os.makedirs(DATA_DIR, exist_ok=True) - yield - - -tools_lib = SubApp("tools", tools_lib_lifespan) - -GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" -GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" -GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" -GOOGLE_SCOPES = [ - "openid", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/gmail.modify", - "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/contacts.readonly", -] - - -# --------------------------------------------------------------------------- -# Multi-provider OAuth registry -# --------------------------------------------------------------------------- - -@dataclass -class OAuthProvider: - auth_url: str - token_url: str - scopes: list[str] - userinfo_url: str | None - userinfo_field: str # JSON field for display name/email - client_id_env: str - client_secret_env: str - token_env_mapping: dict[str, str] # oauth_tokens key -> MCP env var name - extra_auth_params: dict[str, str] = field(default_factory=dict) - revoke_url: str | None = None - # For providers where token response nests the access_token differently - token_response_path: str | None = None # e.g. "authed_user.access_token" for Slack - # Token exchange auth method: "form" (default), "basic" (Basic Auth header), "basic_json" (Basic Auth + JSON body) - token_auth_method: str = "form" - # Whether PKCE is required - pkce_required: bool = False - # Custom transform for env var value (e.g., wrapping token in JSON for Notion) - env_value_transform: str | None = None # e.g., "notion_headers" - # Extra token response fields to extract (e.g., Slack team_id) - extra_token_fields: dict[str, str] = field(default_factory=dict) # response_path -> env_var - - -OAUTH_PROVIDERS: dict[str, OAuthProvider] = { - "google": OAuthProvider( - auth_url=GOOGLE_AUTH_URL, - token_url=GOOGLE_TOKEN_URL, - scopes=GOOGLE_SCOPES, - userinfo_url=GOOGLE_USERINFO_URL, - userinfo_field="email", - client_id_env="GOOGLE_OAUTH_CLIENT_ID", - client_secret_env="GOOGLE_OAUTH_CLIENT_SECRET", - token_env_mapping={ - "access_token": "OAUTH_ACCESS_TOKEN", - "refresh_token": "GOOGLE_WORKSPACE_REFRESH_TOKEN", - "_client_id": "GOOGLE_WORKSPACE_CLIENT_ID", - "_client_secret": "GOOGLE_WORKSPACE_CLIENT_SECRET", - }, - extra_auth_params={"access_type": "offline", "prompt": "consent"}, - ), - "github": OAuthProvider( - auth_url="https://github.com/login/oauth/authorize", - token_url="https://github.com/login/oauth/access_token", - scopes=["repo", "read:user", "user:email"], - userinfo_url="https://api.github.com/user", - userinfo_field="login", - client_id_env="GITHUB_OAUTH_CLIENT_ID", - client_secret_env="GITHUB_OAUTH_CLIENT_SECRET", - token_env_mapping={ - "access_token": "GITHUB_PERSONAL_ACCESS_TOKEN", - }, - ), - "slack": OAuthProvider( - auth_url="https://slack.com/oauth/v2/authorize", - token_url="https://slack.com/api/oauth.v2.access", - scopes=[ - "channels:read", "channels:history", "chat:write", - "groups:read", "groups:history", "im:read", "im:history", - "mpim:read", "mpim:history", "users:read", "users:read.email", - "team:read", "reactions:read", "reactions:write", - "files:read", "files:write", - ], - userinfo_url="https://slack.com/api/auth.test", - userinfo_field="user", - client_id_env="SLACK_CLIENT_ID", - client_secret_env="SLACK_CLIENT_SECRET", - token_env_mapping={ - "access_token": "SLACK_BOT_TOKEN", - }, - extra_token_fields={"team.id": "SLACK_TEAM_ID"}, - ), - "notion": OAuthProvider( - auth_url="https://api.notion.com/v1/oauth/authorize", - token_url="https://api.notion.com/v1/oauth/token", - scopes=[], # Notion doesn't use scopes in the auth URL - userinfo_url=None, - userinfo_field="owner", - client_id_env="NOTION_OAUTH_CLIENT_ID", - client_secret_env="NOTION_OAUTH_CLIENT_SECRET", - token_env_mapping={ - "access_token": "OPENAPI_MCP_HEADERS", - }, - extra_auth_params={"owner": "user"}, - token_auth_method="basic_json", - env_value_transform="notion_headers", - ), - "spotify": OAuthProvider( - auth_url="https://accounts.spotify.com/authorize", - token_url="https://accounts.spotify.com/api/token", - scopes=[ - "user-read-playback-state", "user-modify-playback-state", - "user-read-currently-playing", "playlist-read-private", - "playlist-modify-public", "playlist-modify-private", - "user-library-read", "user-library-modify", - "user-read-recently-played", "user-top-read", - ], - userinfo_url="https://api.spotify.com/v1/me", - userinfo_field="display_name", - client_id_env="SPOTIFY_CLIENT_ID", - client_secret_env="SPOTIFY_CLIENT_SECRET", - token_env_mapping={ - "access_token": "SPOTIFY_ACCESS_TOKEN", - "refresh_token": "SPOTIFY_REFRESH_TOKEN", - "_client_id": "SPOTIFY_CLIENT_ID", - "_client_secret": "SPOTIFY_CLIENT_SECRET", - }, - token_auth_method="basic", - ), - "figma": OAuthProvider( - auth_url="https://www.figma.com/oauth", - token_url="https://api.figma.com/v1/oauth/token", - scopes=["current_user:read", "file_content:read", "file_metadata:read", "file_comments:read", "file_comments:write", "file_versions:read", "file_variables:read"], - userinfo_url="https://api.figma.com/v1/me", - userinfo_field="email", - client_id_env="FIGMA_CLIENT_ID", - client_secret_env="FIGMA_CLIENT_SECRET", - token_env_mapping={ - "access_token": "FIGMA_API_KEY", - }, - ), - "airtable": OAuthProvider( - auth_url="https://airtable.com/oauth2/v1/authorize", - token_url="https://airtable.com/oauth2/v1/token", - scopes=[ - "data.records:read", "data.records:write", - "data.recordComments:read", "data.recordComments:write", - "schema.bases:read", "schema.bases:write", - "user.email:read", "webhook:manage", - ], - userinfo_url="https://api.airtable.com/v0/meta/whoami", - userinfo_field="email", - client_id_env="AIRTABLE_CLIENT_ID", - client_secret_env="AIRTABLE_CLIENT_SECRET", - token_env_mapping={ - "access_token": "AIRTABLE_API_KEY", - }, - pkce_required=True, - token_auth_method="basic", - ), - "hubspot": OAuthProvider( - auth_url="https://mcp-na2.hubspot.com/oauth/authorize/user", - token_url="https://api.hubapi.com/oauth/v1/token", - scopes=[], # MCP Auth Apps have preconfigured scopes - userinfo_url=None, # HubSpot userinfo requires token-in-path, handle separately - userinfo_field="user", - client_id_env="HUBSPOT_CLIENT_ID", - client_secret_env="HUBSPOT_CLIENT_SECRET", - token_env_mapping={ - "access_token": "PRIVATE_APP_ACCESS_TOKEN", - "refresh_token": "HUBSPOT_REFRESH_TOKEN", - }, - pkce_required=True, - ), -} - - -def _resolve_oauth_provider(tool: ToolDefinition) -> OAuthProvider: - """Resolve the OAuth provider for a tool, defaulting to Google for backward compat.""" - key = tool.oauth_provider or "google" - provider = OAUTH_PROVIDERS.get(key) - if not provider: - raise HTTPException(status_code=400, detail=f"Unknown OAuth provider: {key}") - return provider - - -_pending_oauth: dict[str, str] = {} -_pending_pkce: dict[str, str] = {} # state -> code_verifier (for PKCE flows) - - -_store = JsonStore(ToolDefinition, DATA_DIR, not_found_detail="Tool not found") - -_load_all = _store.load_all -_save = _store.save -_load = _store.load - - -@tools_lib.router.get("/builtin") -async def list_builtin_tools(): - return {"tools": [t.model_dump() for t in BUILTIN_TOOLS]} - - -def load_builtin_permissions() -> dict[str, str]: - if not os.path.exists(BUILTIN_PERMS_PATH): - return {} - with open(BUILTIN_PERMS_PATH) as f: - return json.load(f) - - -def save_builtin_permissions(perms: dict[str, str]): - os.makedirs(os.path.dirname(BUILTIN_PERMS_PATH), exist_ok=True) - with open(BUILTIN_PERMS_PATH, "w") as f: - json.dump(perms, f, indent=2) - - -@tools_lib.router.get("/builtin/permissions") -async def get_builtin_permissions(): - return {"permissions": load_builtin_permissions()} - - -@tools_lib.router.put("/builtin/permissions") -async def update_builtin_permissions(body: dict): - valid_tools = {t.name for t in BUILTIN_TOOLS} - valid_policies = {"always_allow", "ask", "deny"} - perms = load_builtin_permissions() - for name, policy in body.get("permissions", {}).items(): - if name in valid_tools and policy in valid_policies: - perms[name] = policy - save_builtin_permissions(perms) - return {"permissions": perms} - - -@tools_lib.router.get("/list") -async def list_tools(): - return {"tools": [t.model_dump() for t in _load_all()]} - - -@tools_lib.router.get("/oauth/callback") -async def oauth_callback(code: str = Query(...), state: str = Query("")): - # Backward compat: old state was just tool_id, new state is "provider:tool_id" - tool_id = _pending_oauth.pop(state, None) - if not tool_id: - # Try legacy format (state = tool_id directly) - tool_id = _pending_oauth.pop(state.split(":")[-1] if ":" in state else state, None) - if not tool_id: - return HTMLResponse("

Invalid OAuth state

", status_code=400) - - tool = _load(tool_id) - provider = _resolve_oauth_provider(tool) - - client_id = os.environ.get(provider.client_id_env, "") - client_secret = os.environ.get(provider.client_secret_env, "") - _port = os.environ.get("OPENSWARM_PORT", "8324") - redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback" - - # Build token exchange request based on provider's auth method - token_data: dict[str, str] = { - "code": code, - "redirect_uri": redirect_uri, - "grant_type": "authorization_code", - } - headers: dict[str, str] = {} - - if provider.token_auth_method == "basic": - # Spotify, etc: Basic Auth header, credentials in form body - creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() - headers["Authorization"] = f"Basic {creds}" - elif provider.token_auth_method == "basic_json": - # Notion: Basic Auth header, JSON body - creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() - headers["Authorization"] = f"Basic {creds}" - headers["Content-Type"] = "application/json" - else: - # Default: credentials as form data fields - token_data["client_id"] = client_id - token_data["client_secret"] = client_secret - - # GitHub requires Accept: application/json - if (tool.oauth_provider or "google") == "github": - headers["Accept"] = "application/json" - - # PKCE: include code_verifier if we stored one - code_verifier = _pending_pkce.pop(state, None) - if code_verifier: - token_data["code_verifier"] = code_verifier - - async with httpx.AsyncClient(timeout=15.0) as client: - if provider.token_auth_method == "basic_json": - resp = await client.post(provider.token_url, json=token_data, headers=headers) - else: - resp = await client.post(provider.token_url, data=token_data, headers=headers) - - if resp.status_code != 200: - logger.warning(f"OAuth token exchange failed: {resp.text}") - return HTMLResponse(f"

Token exchange failed

{resp.text}
", status_code=400) - - tokens = resp.json() - - # Extract access_token, handling nested responses (e.g., Slack) - access_token = tokens.get("access_token", "") - if provider.token_response_path and not access_token: - # Walk nested path like "authed_user.access_token" - obj = tokens - for part in provider.token_response_path.split("."): - obj = obj.get(part, {}) if isinstance(obj, dict) else "" - if isinstance(obj, str) and obj: - access_token = obj - - tool.oauth_tokens = { - "access_token": access_token, - "refresh_token": tokens.get("refresh_token", ""), - "token_expiry": time.time() + tokens.get("expires_in", 3600), - } - - # Extract extra fields (e.g., Slack team_id) - for response_path, env_var in provider.extra_token_fields.items(): - obj: Any = tokens - for part in response_path.split("."): - obj = obj.get(part, "") if isinstance(obj, dict) else "" - if obj: - tool.oauth_tokens[env_var] = str(obj) - - tool.auth_status = "connected" - - if access_token and provider.userinfo_url: - try: - async with httpx.AsyncClient(timeout=10.0) as info_client: - info_resp = await info_client.get( - provider.userinfo_url, - headers={"Authorization": f"Bearer {access_token}"}, - ) - if info_resp.status_code == 200: - tool.connected_account_email = info_resp.json().get(provider.userinfo_field) - except Exception as e: - logger.warning(f"Failed to fetch userinfo for {tool.oauth_provider or 'google'}: {e}") - - # Notion: extract workspace name from token response - if (tool.oauth_provider or "google") == "notion" and not tool.connected_account_email: - workspace_name = tokens.get("workspace_name") - if workspace_name: - tool.connected_account_email = workspace_name - - _save(tool) - - return HTMLResponse(""" - -

Connected successfully!

-

You can close this window.

- - - """) - - -@tools_lib.router.get("/{tool_id}") -async def get_tool(tool_id: str): - return _load(tool_id).model_dump() - - -@tools_lib.router.post("/create") -async def create_tool(body: ToolCreate): - tool = ToolDefinition( - name=body.name, - description=body.description, - command=body.command, - mcp_config=body.mcp_config, - credentials=body.credentials, - auth_type=body.auth_type, - auth_status=body.auth_status, - oauth_provider=body.oauth_provider, - ) - _save(tool) - return {"ok": True, "tool": tool.model_dump()} - - -@tools_lib.router.put("/{tool_id}") -async def update_tool(tool_id: str, body: ToolUpdate): - tool = _load(tool_id) - for k, v in body.model_dump(exclude_none=True).items(): - setattr(tool, k, v) - - _save(tool) - return {"ok": True, "tool": tool.model_dump()} - - -@tools_lib.router.delete("/{tool_id}") -async def delete_tool(tool_id: str): - path = os.path.join(DATA_DIR, f"{tool_id}.json") - if os.path.exists(path): - os.remove(path) - return {"ok": True} - - -# --------------------------------------------------------------------------- -# MCP config derivation -# --------------------------------------------------------------------------- - -from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name - - -def _extra_bin_dirs() -> list[str]: - """Well-known user-local bin directories that may not be on PATH in packaged apps.""" - home = os.path.expanduser("~") - # Bundled uv-bin (ships uvx for non-dev users) - _backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - dirs = [ - os.path.join(_backend, "uv-bin"), - os.path.join(home, ".bun", "bin"), - os.path.join(home, ".cargo", "bin"), - os.path.join(home, ".local", "bin"), - os.path.join(home, ".volta", "bin"), - "/opt/homebrew/bin", - "/usr/local/bin", - ] - # nvm: pick the newest installed node version - nvm_node = os.path.join(home, ".nvm", "versions", "node") - try: - if os.path.isdir(nvm_node): - versions = sorted(os.listdir(nvm_node), reverse=True) - if versions: - dirs.insert(0, os.path.join(nvm_node, versions[0], "bin")) - except OSError: - pass - # fnm - fnm_bin = os.path.join(home, "Library", "Application Support", "fnm", "aliases", "default", "bin") - if os.path.isdir(fnm_bin): - dirs.insert(0, fnm_bin) - return dirs - - -def _resolve_command(command: str) -> str | None: - """Find a command on PATH, falling back to common user-local bin directories - and bundled binaries (uv-bin for uvx/uv).""" - found = shutil.which(command) - if found: - return found - for d in _extra_bin_dirs(): - candidate = os.path.join(d, command) - if os.path.isfile(candidate) and os.access(candidate, os.X_OK): - return candidate - # Check bundled uv-bin directory (ships uv/uvx for non-dev users) - _backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" - if _is_packaged: - # In packaged app: /backend/uv-bin/ - candidate = os.path.join(_backend, "uv-bin", command) - if os.path.isfile(candidate) and os.access(candidate, os.X_OK): - return candidate - else: - # In dev: backend/uv-bin/ - candidate = os.path.join(_backend, "uv-bin", command) - if os.path.isfile(candidate) and os.access(candidate, os.X_OK): - return candidate - return None - - -def _augmented_path() -> str: - """Return PATH with extra bin dirs prepended (for child process environments).""" - extra = [d for d in _extra_bin_dirs() if os.path.isdir(d)] - current = os.environ.get("PATH", "") - seen: set[str] = set() - parts: list[str] = [] - for p in extra + current.split(os.pathsep): - if p and p not in seen: - seen.add(p) - parts.append(p) - return os.pathsep.join(parts) - - -def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]: - """Build the claude_agent_sdk mcp_servers config entry for a tool. - - Returns None if the tool cannot be configured (e.g. missing data). - """ - if not tool.mcp_config: - return None - - config: dict = dict(tool.mcp_config) - - if tool.credentials: - if config.get("type") in ("http", "sse"): - headers = config.setdefault("headers", {}) - for key, val in tool.credentials.items(): - if key.lower() in ("authorization", "api_key", "api-key"): - headers.setdefault("Authorization", f"Bearer {val}") - else: - env = config.setdefault("env", {}) - env.update(tool.credentials) - - if tool.auth_type == "oauth2" and tool.oauth_tokens.get("access_token"): - if config.get("type") in ("http", "sse"): - headers = config.setdefault("headers", {}) - headers["Authorization"] = f"Bearer {tool.oauth_tokens['access_token']}" - else: - env = config.setdefault("env", {}) - provider_key = tool.oauth_provider or "google" - provider = OAUTH_PROVIDERS.get(provider_key) - if provider: - for token_field, env_var in provider.token_env_mapping.items(): - if token_field.startswith("_client_id"): - val = os.environ.get(provider.client_id_env, "") - elif token_field.startswith("_client_secret"): - val = os.environ.get(provider.client_secret_env, "") - else: - val = tool.oauth_tokens.get(token_field, "") - if val: - # Apply value transforms (e.g., Notion needs JSON headers) - if provider.env_value_transform == "notion_headers" and token_field == "access_token": - val = json.dumps({ - "Authorization": f"Bearer {val}", - "Notion-Version": "2022-06-28", - }) - env[env_var] = val - # Inject extra token fields (e.g., Slack SLACK_TEAM_ID) - for _, env_var in provider.extra_token_fields.items(): - val = tool.oauth_tokens.get(env_var, "") - if val: - env[env_var] = val - # Figma: inject token as CLI arg (it doesn't read env vars) - if provider_key == "figma" and tool.oauth_tokens.get("access_token"): - args = config.get("args", []) - if "--figma-api-key" not in args: - config["args"] = args + ["--figma-api-key", tool.oauth_tokens["access_token"]] - else: - # Fallback: inject generic access token - env["OAUTH_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"] - - if config.get("type") == "stdio": - if config.get("command"): - resolved = _resolve_command(config["command"]) - if resolved: - config["command"] = resolved - else: - logger.warning(f"Command '{config['command']}' not found on PATH or bundled directories") - env = config.setdefault("env", {}) - env.setdefault("PATH", _augmented_path()) - env.setdefault("PYTHONPATH", "") - # Point uv/uvx at our bundled Python — avoids macOS CLT popup on fresh Macs - # and avoids downloading Python at runtime - _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" - if _is_packaged: - _resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - _bundled_python = os.path.join(_resources, "python-env", "bin", "python3") - if os.path.exists(_bundled_python): - env.setdefault("UV_PYTHON", _bundled_python) - else: - _backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - _venv_python = os.path.join(_backend, ".venv", "bin", "python3") - if os.path.exists(_venv_python): - env.setdefault("UV_PYTHON", _venv_python) - - return config - - -_READ_PREFIXES = ("get", "list", "read", "search", "fetch", "find", "query", "count", "check", "describe", "show", "download", "browse", "analy", "explain") -_WRITE_PREFIXES = ("create", "write", "delete", "update", "send", "remove", "modify", "add", "set", "put", "post", "patch", "insert", "move", "copy", "rename", "archive", "trash", "publish", "approve", "reject") - - -_SERVICE_RULES: list[tuple[list[str], str, str]] = [ - # (keywords, service_name, group) - # Google Workspace - (["gmail"], "Gmail", "Google"), - (["drive"], "Drive", "Google"), - (["calendar", "event", "freebusy"], "Calendar", "Google"), - (["spreadsheet", "sheet"], "Sheets", "Google"), - (["doc", "paragraph", "table"], "Docs", "Google"), - (["chat", "space", "reaction", "message"], "Chat", "Google"), - (["form", "publish_settings"], "Forms", "Google"), - (["presentation", "slide", "page"], "Slides", "Google"), - (["task_list", "task"], "Tasks", "Google"), - (["contact"], "Contacts", "Google"), - (["script", "deployment", "version", "trigger"], "Apps Script", "Google"), - (["search_custom", "search_engine"], "Search", "Google"), - # Reddit - (["subreddit"], "Subreddits", "Reddit"), - (["search_reddit"], "Search", "Reddit"), - (["post_detail"], "Posts", "Reddit"), - (["user_analysis"], "Users", "Reddit"), - (["reddit_explain"], "Reference", "Reddit"), - # Sequential Thinking - (["sequentialthinking", "thinking"], "Thinking", "Sequential Thinking"), - # Memory (knowledge graph) - (["create_entities", "create_relations", "add_observations", "delete_entities", - "delete_observations", "delete_relations", "read_graph", "search_nodes", - "open_nodes"], "Knowledge Graph", "Memory"), - # Filesystem - (["read_file", "read_multiple_files", "write_file", "edit_file", - "create_directory", "list_directory", "directory_tree", "move_file", - "search_files", "get_file_info", "list_allowed_directories"], "Files", "Filesystem"), - # Playwright - (["browser_navigate", "browser_screenshot", "browser_click", "browser_fill", - "browser_select", "browser_hover", "browser_evaluate", "browser_console", - "browser_tab", "browser_close", "browser_resize", "browser_snapshot", - "browser_wait", "browser_pdf", "browser_drag"], "Browser", "Playwright"), - # Git - (["git_status", "git_diff", "git_diff_unstaged", "git_diff_staged", - "git_commit", "git_log", "git_add", "git_reset", "git_show", - "git_create_branch", "git_checkout", "git_list_branches", "git_init", - "git_clone"], "Repository", "Git"), - # YouTube Transcripts - (["get_transcript"], "Transcripts", "YouTube"), - # Desktop Commander - (["execute_command", "read_output", "force_terminate", "list_sessions", - "list_processes", "kill_process", "block_command", "unblock_command", - "read_file", "write_file", "search_code", "list_directory", - "get_file_info", "edit_block"], "System", "Desktop Commander"), - # GitHub - (["repository", "issue", "pull_request", "commit", "branch", "fork", "star", - "create_issue", "list_issues", "get_issue", "create_pull_request", - "list_commits", "search_repositories", "create_repository", - "get_file_contents", "push_files", "create_branch", - "search_code", "search_issues"], "Repository", "GitHub"), - # Slack - (["channel", "slack_message", "thread", "reply", "workspace", - "list_channels", "post_message", "reply_to_thread", "search_messages", - "get_channel_history", "get_thread_replies", "get_users", - "get_user_profile"], "Messaging", "Slack"), - # Notion - (["notion_page", "database", "block", "create_page", "update_page", - "search_pages", "get_page", "get_database", "query_database", - "create_database", "append_block_children"], "Pages", "Notion"), - # Spotify - (["play", "pause", "skip", "playlist", "track", "album", "artist", - "search_tracks", "get_playlist", "get_currently_playing", - "add_to_playlist", "create_playlist", "get_recommendations", - "get_top_items"], "Music", "Spotify"), - # Figma - (["figma", "design", "component", "style", "node", - "get_file", "get_file_nodes", "get_image", "get_comments", - "get_team_projects", "get_project_files"], "Design", "Figma"), - # Airtable - (["airtable", "base", "record", "field", "view", - "list_records", "get_record", "create_record", "update_record", - "delete_record", "list_bases", "get_base_schema"], "Data", "Airtable"), - # HubSpot - (["hubspot", "contact", "deal", "company", "ticket", "pipeline", - "crm", "engagement", "association"], "CRM", "HubSpot"), - # Discord - (["discord", "guild", "server", "channel_message", "send_message", - "get_messages", "get_guilds", "get_channels", "add_reaction"], "Messaging", "Discord"), - # Twitter / X (TweetSave) - (["tweetsave", "get_tweet", "get_thread", "to_blog", "batch", - "extract_media"], "Tweets", "Twitter"), - # Shopify Dev - (["shopify", "introspect", "graphql", "search_dev_docs", "liquid", - "polaris", "admin_api", "storefront_api"], "Developer", "Shopify"), - # Zoom - (["zoom", "meeting", "recording", "participant", "webinar", - "create_meeting", "list_meetings", "get_meeting", "delete_meeting", - "update_meeting"], "Meetings", "Zoom"), - # Microsoft 365 - (["outlook", "onedrive", "ms365", "microsoft", "mail_folder", - "email", "calendar_event", "contact", "drive_item"], "Mail & Files", "Microsoft 365"), -] - - -def _categorize_tool(name: str) -> str: - lower = name.lower().replace("_", " ").replace("-", " ").strip() - for word in lower.split(): - for prefix in _READ_PREFIXES: - if word.startswith(prefix): - return "read" - for prefix in _WRITE_PREFIXES: - if word.startswith(prefix): - return "write" - return "write" - - -def _extract_service(name: str) -> tuple[str, str]: - """Extract the service and group from a tool name (e.g. 'search_gmail_messages' -> ('Gmail', 'Google')).""" - lower = name.lower() - for keywords, display, group in _SERVICE_RULES: - for kw in keywords: - if kw in lower: - return display, group - return "Other", "" - - -from backend.apps.common.mcp_utils import parse_sse_json as _parse_sse_json - - -async def _discover_mcp_tools_http(url: str, headers: dict | None = None) -> list[dict]: - """Connect to a Streamable HTTP MCP server and call tools/list via JSON-RPC POST.""" - h = { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - **(headers or {}), - } - async with httpx.AsyncClient(timeout=30.0) as client: - init_resp = await client.post(url, headers=h, json={ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2025-03-26", "capabilities": {}, - "clientInfo": {"name": "self-swarm", "version": "0.1.0"}}, - }) - if init_resp.status_code not in (200, 201): - raise HTTPException(status_code=502, detail=f"MCP initialize failed: {init_resp.status_code}") - - session_id = init_resp.headers.get("mcp-session-id", "") - if session_id: - h["mcp-session-id"] = session_id - - await client.post(url, headers=h, json={ - "jsonrpc": "2.0", "method": "notifications/initialized", - }) - - list_resp = await client.post(url, headers=h, json={ - "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}, - }) - if list_resp.status_code not in (200, 201): - raise HTTPException(status_code=502, detail=f"MCP tools/list failed: {list_resp.status_code}") - - ct = list_resp.headers.get("content-type", "") - if "text/event-stream" in ct: - data = _parse_sse_json(list_resp.text) - else: - data = list_resp.json() - - if not data: - raise HTTPException(status_code=502, detail="Empty response from MCP server") - - tools_list = data.get("result", {}).get("tools", []) - return [{"name": t.get("name", ""), "description": t.get("description", ""), "inputSchema": t.get("inputSchema")} for t in tools_list] - - -async def _discover_mcp_tools_sse(url: str, headers: dict | None = None) -> list[dict]: - """Connect to a legacy SSE MCP server (GET event-stream + POST messages) and call tools/list.""" - from mcp.client.sse import sse_client - from mcp import ClientSession - from mcp.types import Implementation - - try: - async with sse_client( - url=url, - headers=headers, - timeout=30, - sse_read_timeout=30, - ) as (read_stream, write_stream): - async with ClientSession( - read_stream, - write_stream, - client_info=Implementation(name="self-swarm", version="0.1.0"), - ) as session: - await session.initialize() - result = await session.list_tools() - return [{"name": t.name, "description": t.description or "", "inputSchema": t.inputSchema if t.inputSchema else None} for t in result.tools] - except BaseExceptionGroup as eg: - first = eg.exceptions[0] if eg.exceptions else eg - raise HTTPException(status_code=502, detail=f"SSE discovery failed: {first}") from first - - -async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None, env: dict | None = None) -> list[dict]: - """Spawn a stdio MCP server process and call tools/list via JSON-RPC over stdin/stdout.""" - cmd_path = _resolve_command(command) - if not cmd_path: - raise HTTPException(status_code=400, detail=f"Command '{command}' not found on PATH or common install locations") - - proc_env = {**os.environ, **(env or {}), "PATH": _augmented_path()} - proc_env.pop("PYTHONPATH", None) - - proc = await asyncio.create_subprocess_exec( - cmd_path, *(args or []), - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=proc_env, - limit=1024 * 1024, # 1MB buffer for large MCP responses (e.g., MS365 with 87 tools) - ) - - async def _send(msg: dict) -> None: - line = json.dumps(msg) + "\n" - proc.stdin.write(line.encode()) - await proc.stdin.drain() - - async def _recv() -> dict: - """Read JSON-RPC responses, skipping notification lines (no 'id' field).""" - while True: - line = await asyncio.wait_for(proc.stdout.readline(), timeout=30.0) - if not line: - stderr_out = "" - try: - stderr_out = (await asyncio.wait_for(proc.stderr.read(4096), timeout=2.0)).decode(errors="replace") - except (asyncio.TimeoutError, Exception): - pass - raise HTTPException( - status_code=502, - detail=f"MCP stdio process exited unexpectedly{': ' + stderr_out if stderr_out else ''}", - ) - stripped = line.decode(errors="replace").strip() - if not stripped: - continue - try: - data = json.loads(stripped) - except json.JSONDecodeError: - continue - if "id" in data: - return data - - try: - await _send({ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": {"name": "self-swarm", "version": "0.1.0"}, - }, - }) - await _recv() - - await _send({"jsonrpc": "2.0", "method": "notifications/initialized"}) - - await _send({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) - data = await _recv() - - tools_list = data.get("result", {}).get("tools", []) - return [{"name": t.get("name", ""), "description": t.get("description", ""), "inputSchema": t.get("inputSchema")} for t in tools_list] - - except HTTPException: - raise - except asyncio.TimeoutError: - raise HTTPException(status_code=504, detail="MCP stdio server timed out during discovery") - finally: - try: - proc.stdin.close() - except Exception: - pass - try: - proc.terminate() - await asyncio.wait_for(proc.wait(), timeout=5.0) - except Exception: - try: - proc.kill() - except Exception: - pass - - -@tools_lib.router.post("/{tool_id}/discover") -async def discover_tools(tool_id: str): - tool = _load(tool_id) - - if tool.auth_type == "oauth2" and tool.auth_status == "connected": - refreshed = await refresh_google_token(tool) - if not refreshed and tool.oauth_tokens.get("access_token"): - expiry = tool.oauth_tokens.get("token_expiry", 0) - if time.time() >= expiry - 60: - client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "") - if not client_id: - raise HTTPException( - status_code=400, - detail="OAuth token expired and GOOGLE_OAUTH_CLIENT_ID is not set. " - "In the packaged app, create ~/.openswarm.env or " - "~/Library/Application Support/OpenSwarm/.env with your Google OAuth credentials.", - ) - raise HTTPException( - status_code=502, - detail="OAuth token expired and refresh failed. Try reconnecting Google.", - ) - - config = derive_mcp_config(tool) - if not config: - raise HTTPException(status_code=400, detail="Cannot derive MCP config for tool") - - transport = config.get("type", "") - - try: - if transport == "stdio": - command = config.get("command", "") - if not command: - raise HTTPException(status_code=400, detail="stdio transport requires a 'command' in MCP config") - raw_tools = await _discover_mcp_tools_stdio( - command=command, - args=config.get("args"), - env=config.get("env"), - ) - elif transport in ("http", "sse") or config.get("url"): - url = config.get("url", "") - if not url: - raise HTTPException(status_code=400, detail="HTTP/SSE transport requires a 'url' in MCP config") - if transport == "sse": - raw_tools = await _discover_mcp_tools_sse(url, config.get("headers")) - else: - try: - raw_tools = await _discover_mcp_tools_http(url, config.get("headers")) - except HTTPException: - logger.info(f"Streamable HTTP failed for {tool.name}, retrying with SSE transport") - raw_tools = await _discover_mcp_tools_sse(url, config.get("headers")) - else: - raise HTTPException(status_code=400, detail=f"Unsupported MCP transport type: '{transport}'. Use 'stdio', 'http', or 'sse'.") - except HTTPException: - raise - except Exception as e: - msg = str(e).strip() - if not msg: - msg = type(e).__name__ - logger.warning(f"MCP tool discovery failed for {tool.name}: {msg}", exc_info=True) - raise HTTPException(status_code=502, detail=f"Discovery failed: {msg}") - - services: dict[str, dict[str, list[str]]] = {} - service_groups: dict[str, list[str]] = {} - permissions: dict[str, Any] = {} - - for t in raw_tools: - name = t["name"] - cat = _categorize_tool(name) - svc, group = _extract_service(name) - if svc not in services: - services[svc] = {"read": [], "write": []} - services[svc][cat].append(name) - permissions[name] = tool.tool_permissions.get(name, "ask") - if group: - service_groups.setdefault(group, []) - if svc not in service_groups[group]: - service_groups[group].append(svc) - - all_read = [n for s in services.values() for n in s["read"]] - all_write = [n for s in services.values() for n in s["write"]] - permissions["_categories"] = {"read": all_read, "write": all_write} - permissions["_services"] = services - permissions["_service_groups"] = service_groups - permissions["_tool_descriptions"] = {t["name"]: t["description"] for t in raw_tools} - permissions["_tool_schemas"] = {t["name"]: t.get("inputSchema") for t in raw_tools if t.get("inputSchema")} - - tool.tool_permissions = permissions - _save(tool) - - return {"ok": True, "tool": tool.model_dump()} - - -@tools_lib.router.post("/{tool_id}/oauth/disconnect") -async def oauth_disconnect(tool_id: str): - """Clear OAuth tokens and reset auth status so the user can reconnect with a different account.""" - tool = _load(tool_id) - access_token = tool.oauth_tokens.get("access_token") - - if access_token: - provider = _resolve_oauth_provider(tool) - revoke_url = provider.revoke_url or "https://oauth2.googleapis.com/revoke" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - await client.post( - revoke_url, - params={"token": access_token}, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - except Exception as e: - logger.warning(f"Failed to revoke token for tool {tool.id}: {e}") - - tool.oauth_tokens = {} - tool.auth_status = "configured" - tool.connected_account_email = None - _save(tool) - return {"ok": True, "tool": tool.model_dump()} - - -@tools_lib.router.post("/{tool_id}/oauth/start") -async def oauth_start(tool_id: str): - tool = _load(tool_id) - provider = _resolve_oauth_provider(tool) - - client_id = os.environ.get(provider.client_id_env, "") - if not client_id: - raise HTTPException(status_code=400, detail=f"{provider.client_id_env} not set in backend .env") - - _port = os.environ.get("OPENSWARM_PORT", "8324") - redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback" - provider_key = tool.oauth_provider or "google" - state = f"{provider_key}:{tool_id}" - - _pending_oauth[state] = tool_id - - params = { - "client_id": client_id, - "redirect_uri": redirect_uri, - "response_type": "code", - "state": state, - **provider.extra_auth_params, - } - if provider.scopes: - params["scope"] = " ".join(provider.scopes) - - # PKCE support (required by Airtable, etc.) - if provider.pkce_required: - code_verifier = secrets.token_urlsafe(64) - code_challenge = base64.urlsafe_b64encode( - hashlib.sha256(code_verifier.encode()).digest() - ).rstrip(b"=").decode() - params["code_challenge"] = code_challenge - params["code_challenge_method"] = "S256" - _pending_pkce[state] = code_verifier - - auth_url = f"{provider.auth_url}?{urlencode(params)}" - return {"auth_url": auth_url} - - -async def refresh_oauth_token(tool: ToolDefinition) -> Optional[str]: - """Refresh an expired OAuth token. Returns the fresh access_token or None.""" - if tool.auth_type != "oauth2": - return None - refresh_token = tool.oauth_tokens.get("refresh_token") - if not refresh_token: - return None - expiry = tool.oauth_tokens.get("token_expiry", 0) - if time.time() < expiry - 60: - return tool.oauth_tokens.get("access_token") - - provider = _resolve_oauth_provider(tool) - client_id = os.environ.get(provider.client_id_env, "") - client_secret = os.environ.get(provider.client_secret_env, "") - if not client_id or not client_secret: - return None - - try: - async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.post(provider.token_url, data={ - "client_id": client_id, - "client_secret": client_secret, - "refresh_token": refresh_token, - "grant_type": "refresh_token", - }) - if resp.status_code == 200: - data = resp.json() - new_token = data["access_token"] - tool.oauth_tokens["access_token"] = new_token - tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 3600) - - if not tool.connected_account_email and provider.userinfo_url: - try: - async with httpx.AsyncClient(timeout=10.0) as info_client: - info_resp = await info_client.get( - provider.userinfo_url, - headers={"Authorization": f"Bearer {new_token}"}, - ) - if info_resp.status_code == 200: - tool.connected_account_email = info_resp.json().get(provider.userinfo_field) - except Exception: - pass - - _save(tool) - return new_token - except Exception as e: - logger.warning(f"OAuth token refresh failed for tool {tool.id}: {e}") - return None - - -# Backward-compatible alias -refresh_google_token = refresh_oauth_token +"""Backward-compatible shim — re-exports symbols that external modules import. + +Preserves ``from backend.apps.tools_lib.tools_lib import ...`` used by +agent_manager.py, browser_agent.py, and main.py. +""" + +from backend.apps.tools_lib import tools_lib # noqa: F401 — SubApp +from backend.apps.tools_lib.routes import ( # noqa: F401 + _load_all, _save, _load, + load_builtin_permissions, save_builtin_permissions, +) +from backend.apps.tools_lib.mcp_config import derive_mcp_config # noqa: F401 +from backend.apps.tools_lib.oauth import ( # noqa: F401 + refresh_oauth_token, refresh_google_token, +) +from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name # noqa: F401 diff --git a/assets/icon.png b/readme_assets/icon.png similarity index 100% rename from assets/icon.png rename to readme_assets/icon.png diff --git a/assets/screenshot.png b/readme_assets/screenshot.png similarity index 100% rename from assets/screenshot.png rename to readme_assets/screenshot.png