Files
openswarm/backend/apps/agents/agent_manager.py
T
2026-03-25 13:35:23 -07:00

1904 lines
79 KiB
Python

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 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.tools_lib.tools_lib import (
_load_all as load_all_tools,
_sanitize_server_name,
derive_mcp_config,
load_builtin_permissions,
refresh_google_token,
)
from backend.config.paths import SESSIONS_DIR
from backend.apps.analytics.collector import record as _analytics
logger = logging.getLogger(__name__)
os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000")
def _save_session(session_id: str, doc_data: dict):
os.makedirs(SESSIONS_DIR, exist_ok=True)
with open(os.path.join(SESSIONS_DIR, f"{session_id}.json"), "w") as f:
json.dump(doc_data, f, indent=2)
def _load_session_data(session_id: str) -> dict | None:
path = os.path.join(SESSIONS_DIR, f"{session_id}.json")
if not os.path.exists(path):
return None
with open(path) as f:
return json.load(f)
def _delete_session_file(session_id: str):
path = os.path.join(SESSIONS_DIR, f"{session_id}.json")
if os.path.exists(path):
os.remove(path)
def _load_all_session_data() -> list[tuple[str, dict]]:
results = []
if not os.path.exists(SESSIONS_DIR):
return results
for fname in os.listdir(SESSIONS_DIR):
if fname.endswith(".json"):
with open(os.path.join(SESSIONS_DIR, fname)) as f:
results.append((fname[:-5], json.load(f)))
return results
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:<tool_name>).
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")]
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):
continue
if _is_fully_denied(tool):
continue
if tool.auth_type == "oauth2" and tool.auth_status == "connected":
await refresh_google_token(tool)
config = derive_mcp_config(tool)
if config:
server_name = _sanitize_server_name(tool.name)
mcp_servers[server_name] = config
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[:15])}")
if len(tool_names) > 15:
lines.append(f" ... and {len(tool_names) - 15} more")
sections.append("\n".join(lines))
if not sections:
return None
return (
"<connected_mcp_tools>\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</connected_mcp_tools>"
)
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 (
"<available_views>\n"
"The following reusable View artifacts are available. "
"Use the RenderOutput tool to invoke one by providing its output_id "
"and the required input_data matching its schema.\n\n"
+ "\n\n".join(sections)
+ "\n</available_views>"
)
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 = [
"<browser_agent_instructions>",
"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("</browser_agent_instructions>")
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
async def launch_agent(self, config: AgentConfig) -> AgentSession:
session_id = uuid4().hex
mode_tools, _, mode_folder = self._resolve_mode(config.mode)
tools = mode_tools
global_settings = load_settings()
effective_cwd = (
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,
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,
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),
}, 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": 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"<context_file path=\"{path}\">\n{content}\n</context_file>"
)
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"<context_directory path=\"{path}\">\n{chr(10).join(tree_lines)}\n</context_directory>"
)
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 (
"<forced_tools>\n"
"The user explicitly requested these tools be used. "
"Prioritize using them to address the user's request.\n"
+ "\n".join(lines)
+ "\n</forced_tools>"
)
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,
)
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"
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
)
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", "")
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"<app_builder_reference>\n{VIEW_BUILDER_SKILL}\n</app_builder_reference>"
composed_prompt = f"{composed_prompt}\n\n{skill_block}" if composed_prompt else skill_block
mcp_servers = await self._build_mcp_servers(session.allowed_tools)
_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}__*")
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,
}
if global_settings.anthropic_api_key:
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
else:
# Try 9Router as fallback for subscription access
from backend.apps.nine_router import is_running as _9r_running
if _9r_running():
options_kwargs["env"] = {
"ANTHROPIC_BASE_URL": "http://localhost:20128",
}
else:
raise ValueError("No AI provider configured. Set an API key or connect a subscription.")
if mcp_servers:
options_kwargs["mcp_servers"] = mcp_servers
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
options = ClaudeAgentOptions(**options_kwargs)
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 = {}
async for message in query(
prompt=prompt_stream(),
options=options,
):
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"),
})
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,
})
session.status = "completed"
except asyncio.CancelledError:
session.status = "stopped"
except Exception as e:
logger.exception(f"Agent {session_id} error: {e}")
session.status = "error"
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:
# Analytics
duration = (datetime.now() - 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 "",
}, session_id=session_id, dashboard_id=session.dashboard_id)
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.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,
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
session_changed = False
if model and model != session.model:
session.model = model
session_changed = True
if mode and mode != session.mode:
session.mode = mode
mode_tools, _, _ = self._resolve_mode(mode)
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": 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,
)
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.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, 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()
try:
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"
await ws_manager.send_to_session(session_id, "agent:status", {
"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"
]
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()
try:
await existing
except asyncio.CancelledError:
pass
target_msg = None
for i, msg in enumerate(session.messages):
if msg.id == message_id:
target_msg = msg
break
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"
]
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,
)
session.branches[new_branch_id] = new_branch
session.active_branch_id = new_branch_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,
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,
})
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,
))
self.tasks[session_id] = task
async def switch_branch(self, session_id: str, branch_id: str):
session = self.sessions.get(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
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,
})
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}],
)
generated = resp.content[0].text.strip().strip('"\'')
if generated:
title = generated
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,
})
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."""
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 = ""
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
)
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)
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 <svg> tag)\n"
"- Max 400 characters for the svg string"
)
resp = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=300,
system=system,
messages=[{"role": "user", "content": user_content}],
)
raw = resp.content[0].text.strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
parsed = _json.loads(raw)
if parsed.get("name"):
name = parsed["name"].strip().strip("\"'")
if parsed.get("svg"):
svg = parsed["svg"].strip()
except Exception as e:
logger.warning(f"Group meta generation failed, using fallback: {e}")
meta = ToolGroupMeta(id=group_id, name=name, svg=svg, is_refined=is_refinement)
session.tool_group_meta[group_id] = meta
await ws_manager.send_to_session(session_id, "agent:group_meta_updated", {
"session_id": session_id,
"group_id": group_id,
"name": name,
"svg": svg,
"is_refined": is_refinement,
})
return {"name": name, "svg": svg, "is_refined": is_refinement}
async def update_session(self, session_id: str, **fields):
"""Update mutable session fields (system_prompt, name)."""
session = self.sessions.get(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
allowed = {"system_prompt", "name"}
for key, value in fields.items():
if key in allowed:
setattr(session, key, value)
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": session.status,
"session": session.model_dump(mode="json"),
})
@staticmethod
def _build_search_text(session: AgentSession, max_len: int = 5000) -> str:
"""Build a search-indexing string from the session name and message content."""
parts = [session.name or ""]
for msg in session.messages:
if msg.role in ("user", "assistant") and isinstance(msg.content, str):
parts.append(msg.content)
text = " ".join(parts)
return text[:max_len]
async def close_session(self, session_id: str) -> None:
"""Close a session: pause the agent if running, persist to JSON file,
and remove from in-memory state. Also stops browser-agent children."""
children = [
s for s in self.sessions.values()
if s.parent_session_id == session_id and s.mode == "browser-agent"
]
for child in children:
await self.stop_agent(child.id)
task = self.tasks.get(session_id)
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
session = self.sessions.get(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
if session.status in ("running", "waiting_approval"):
session.status = "stopped"
session.closed_at = datetime.now()
for req in list(session.pending_approvals):
ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Session closed"})
session.pending_approvals = []
if hasattr(session, '_cancel_event'):
session._cancel_event.set()
doc_data = session.model_dump(mode="json")
doc_data["search_text"] = self._build_search_text(session)
_save_session(session_id, doc_data)
await ws_manager.send_to_session(session_id, "agent:closed", {
"session_id": session_id,
"status": session.status,
"name": session.name,
"model": session.model,
"mode": session.mode,
"created_at": session.created_at.isoformat() if session.created_at else None,
"closed_at": session.closed_at.isoformat() if session.closed_at else None,
"cost_usd": session.cost_usd,
"dashboard_id": session.dashboard_id,
})
self.sessions.pop(session_id, None)
self.tasks.pop(session_id, None)
logger.info(f"Session {session_id} closed and persisted")
async def delete_session(self, session_id: str) -> None:
"""Permanently delete a session: remove from memory and JSON file.
Also stops browser-agent children first."""
children = [
s for s in self.sessions.values()
if s.parent_session_id == session_id and s.mode == "browser-agent"
]
for child in children:
await self.stop_agent(child.id)
task = self.tasks.get(session_id)
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self.sessions.pop(session_id, None)
self.tasks.pop(session_id, None)
_delete_session_file(session_id)
logger.info(f"Session {session_id} permanently deleted")
async def resume_session(self, session_id: str) -> AgentSession:
"""Restore a closed session from JSON file back into active memory."""
if session_id in self.sessions:
return self.sessions[session_id]
data = _load_session_data(session_id)
if data is None:
raise ValueError(f"Session {session_id} not found in history")
session = AgentSession(**data)
session.closed_at = None
self.sessions[session_id] = session
_delete_session_file(session_id)
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": session.status,
"session": session.model_dump(mode="json"),
})
logger.info(f"Session {session_id} resumed from history")
return session
def get_history(
self,
q: str = "",
limit: int = 20,
offset: int = 0,
dashboard_id: str | None = None,
) -> dict:
"""Return paginated, optionally filtered summaries of closed sessions."""
all_data = _load_all_session_data()
all_data.sort(key=lambda pair: pair[1].get("closed_at") or "", reverse=True)
q_lower = q.strip().lower()
history = []
for sid, data in all_data:
if dashboard_id and data.get("dashboard_id") != dashboard_id:
continue
if q_lower:
name = (data.get("name") or "").lower()
search_text = (data.get("search_text") or "").lower()
if q_lower not in name and q_lower not in search_text:
continue
history.append({
"id": data.get("id", sid),
"name": data.get("name", "Untitled"),
"status": data.get("status", "stopped"),
"model": data.get("model", "sonnet"),
"mode": data.get("mode", "agent"),
"created_at": data.get("created_at"),
"closed_at": data.get("closed_at"),
"cost_usd": data.get("cost_usd", 0),
"dashboard_id": data.get("dashboard_id"),
})
total = len(history)
page = history[offset : offset + limit]
return {
"sessions": page,
"total": total,
"has_more": offset + limit < total,
}
async def reconcile_on_startup(self) -> None:
"""Mark any stale running sessions as stopped."""
for sid, data in _load_all_session_data():
if data.get("status") in ("running", "waiting_approval"):
data["status"] = "stopped"
_save_session(sid, data)
logger.info(f"Marked stale session {sid} as stopped")
async def persist_all_sessions(self) -> None:
"""Flush every in-memory session to JSON files (for graceful shutdown)."""
for session_id, session in list(self.sessions.items()):
if session.status in ("running", "waiting_approval"):
session.status = "stopped"
for req in list(session.pending_approvals):
ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Server shutting down"})
session.pending_approvals = []
doc_data = session.model_dump(mode="json")
doc_data["search_text"] = self._build_search_text(session)
_save_session(session_id, doc_data)
logger.info(f"Persisted session {session_id} on shutdown")
self.sessions.clear()
self.tasks.clear()
async def restore_all_sessions(self) -> None:
"""On startup, reload all persisted sessions from JSON files back into memory.
Only sessions without closed_at are restored (they were active at
shutdown). Sessions with closed_at were explicitly closed by the user
and stay on disk so the history endpoint can still serve them.
"""
for sid, data in _load_all_session_data():
try:
session = AgentSession(**data)
except Exception as e:
logger.warning(f"Skipping corrupt session file {sid}: {e}")
continue
if session.closed_at is not None:
continue
if session.status in ("running", "waiting_approval"):
session.status = "stopped"
session.pending_approvals = []
self.sessions[session.id] = session
_delete_session_file(sid)
logger.info(f"Restored session {session.id}")
async def duplicate_session(self, session_id: str, dashboard_id: str | None = None, up_to_message_id: str | None = None) -> AgentSession:
"""Create an independent copy of a session with the same chat history."""
source = self.sessions.get(session_id)
if not source:
data = _load_session_data(session_id)
if data is None:
raise ValueError(f"Session {session_id} not found")
source = AgentSession(**data)
source_messages = list(source.messages)
if up_to_message_id:
cut_idx = next(
(i for i, m in enumerate(source_messages) if m.id == up_to_message_id),
None,
)
if cut_idx is not None:
source_messages = source_messages[: cut_idx + 1]
old_to_new_msg: dict[str, str] = {}
new_messages: list[Message] = []
for msg in source_messages:
new_id = uuid4().hex
old_to_new_msg[msg.id] = new_id
new_messages.append(Message(
id=new_id,
role=msg.role,
content=msg.content,
timestamp=msg.timestamp,
branch_id=msg.branch_id,
parent_id=old_to_new_msg.get(msg.parent_id) if msg.parent_id else None,
context_paths=msg.context_paths,
attached_skills=msg.attached_skills,
forced_tools=msg.forced_tools,
images=msg.images,
))
new_branches: dict[str, MessageBranch] = {}
for bid, branch in source.branches.items():
new_branches[bid] = MessageBranch(
id=bid,
parent_branch_id=branch.parent_branch_id,
fork_point_message_id=old_to_new_msg.get(branch.fork_point_message_id) if branch.fork_point_message_id else None,
created_at=branch.created_at,
)
new_session = AgentSession(
id=uuid4().hex,
name=f"{source.name} (copy)",
status="stopped",
model=source.model,
mode=source.mode,
system_prompt=source.system_prompt,
allowed_tools=list(source.allowed_tools),
max_turns=source.max_turns,
cwd=source.cwd,
created_at=datetime.now(),
messages=new_messages,
branches=new_branches,
active_branch_id=source.active_branch_id,
tool_group_meta=dict(source.tool_group_meta),
dashboard_id=dashboard_id or source.dashboard_id,
)
self.sessions[new_session.id] = new_session
await ws_manager.send_to_session(new_session.id, "agent:status", {
"session_id": new_session.id,
"status": new_session.status,
"session": new_session.model_dump(mode="json"),
})
return new_session
async def invoke_agent(
self,
source_session_id: str,
message: str,
parent_session_id: str | None = None,
dashboard_id: str | None = None,
) -> dict:
"""Fork an existing session and send it a new message, returning the result."""
source = self.sessions.get(source_session_id)
if not source:
data = _load_session_data(source_session_id)
if data is None:
raise ValueError(f"Session {source_session_id} not found")
source = AgentSession(**data)
source_name = source.name
old_to_new_msg: dict[str, str] = {}
new_messages: list[Message] = []
for msg in source.messages:
new_id = uuid4().hex
old_to_new_msg[msg.id] = new_id
new_messages.append(Message(
id=new_id,
role=msg.role,
content=msg.content,
timestamp=msg.timestamp,
branch_id=msg.branch_id,
parent_id=old_to_new_msg.get(msg.parent_id) if msg.parent_id else None,
context_paths=msg.context_paths,
attached_skills=msg.attached_skills,
forced_tools=msg.forced_tools,
images=msg.images,
))
new_branches: dict[str, MessageBranch] = {}
for bid, branch in source.branches.items():
new_branches[bid] = MessageBranch(
id=bid,
parent_branch_id=branch.parent_branch_id,
fork_point_message_id=(
old_to_new_msg.get(branch.fork_point_message_id)
if branch.fork_point_message_id else None
),
created_at=branch.created_at,
)
fork = AgentSession(
id=uuid4().hex,
name=f"{source_name} (invoked)",
status="running",
model=source.model,
mode="invoked-agent",
sdk_session_id=source.sdk_session_id,
system_prompt=source.system_prompt,
allowed_tools=list(source.allowed_tools),
max_turns=source.max_turns or 25,
cwd=source.cwd,
created_at=datetime.now(),
messages=new_messages,
branches=new_branches,
active_branch_id=source.active_branch_id,
tool_group_meta=dict(source.tool_group_meta),
dashboard_id=dashboard_id or source.dashboard_id,
parent_session_id=parent_session_id,
)
self.sessions[fork.id] = fork
await ws_manager.broadcast_global("agent:status", {
"session_id": fork.id,
"status": fork.status,
"session": fork.model_dump(mode="json"),
})
user_msg = Message(
role="user",
content=message,
branch_id=fork.active_branch_id,
)
fork.messages.append(user_msg)
await ws_manager.send_to_session(fork.id, "agent:message", {
"session_id": fork.id,
"message": user_msg.model_dump(mode="json"),
})
await self._run_agent_loop(fork.id, message, fork_session=True)
last_assistant = None
for msg in reversed(fork.messages):
if msg.role == "assistant":
content = msg.content
if isinstance(content, str):
last_assistant = content
elif isinstance(content, list):
texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
last_assistant = "\n".join(texts)
else:
last_assistant = str(content)
break
return {
"forked_session_id": fork.id,
"source_name": source_name,
"response": last_assistant or "No response from invoked agent.",
"cost_usd": fork.cost_usd,
}
def get_all_sessions(self, dashboard_id: str | None = None) -> list[AgentSession]:
if dashboard_id:
return [s for s in self.sessions.values() if s.dashboard_id == dashboard_id]
return list(self.sessions.values())
def get_session(self, session_id: str) -> Optional[AgentSession]:
return self.sessions.get(session_id)
def get_browser_agent_children(self, parent_session_id: str) -> list[dict]:
"""Return browser-agent sessions for a parent, from memory or disk."""
results: list[dict] = []
seen: set[str] = set()
for s in self.sessions.values():
if s.mode == "browser-agent" and s.parent_session_id == parent_session_id:
results.append(s.model_dump(mode="json"))
seen.add(s.id)
for sid, data in _load_all_session_data():
if sid in seen:
continue
if data.get("mode") == "browser-agent" and data.get("parent_session_id") == parent_session_id:
results.append(data)
return results
agent_manager = AgentManager()