diff --git a/README.md b/README.md
index 73ad2cfb..f20bfdbb 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
Open Swarm
@@ -22,7 +22,7 @@
-
+
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