[Haik]: round 1 of absracting the agents dir. Leaving this as is for now then will circle back bc hella code needs to be redone here and de slopped

This commit is contained in:
haikdc
2026-03-31 23:12:16 -07:00
parent 63af322ab7
commit a78819e1cf
30 changed files with 154 additions and 657 deletions
-132
View File
@@ -1,132 +0,0 @@
"""Mock agent and session-completed analytics.
Extracted from agent_loop.py to keep every file under 250 lines.
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime
from uuid import uuid4
from backend.apps.agents.models import AgentSession, ApprovalRequest, Message
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.analytics.collector import record as _analytics
logger = logging.getLogger(__name__)
async def stream_text(session_id: str, msg_id: str, text: str, delay: float = 0.03):
await ws_manager.emit_stream_start(session_id, msg_id, "assistant")
words = text.split(" ")
for i, word in enumerate(words):
chunk = word if i == 0 else " " + word
await ws_manager.emit_stream_delta(session_id, msg_id, chunk)
await asyncio.sleep(delay)
await ws_manager.emit_stream_end(session_id, msg_id)
async def stream_tool_input(session_id: str, msg_id: str, tool_name: str, input_json: str, delay: float = 0.02):
await ws_manager.emit_stream_start(session_id, msg_id, "tool_call", tool_name=tool_name)
chunk_size = 12
for i in range(0, len(input_json), chunk_size):
await ws_manager.emit_stream_delta(session_id, msg_id, input_json[i:i + chunk_size])
await asyncio.sleep(delay)
await ws_manager.emit_stream_end(session_id, msg_id)
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)
async def run_mock_agent(session_id: str, prompt: str, sessions: dict[str, AgentSession]):
session = 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.emit_status(session_id, "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.emit_status(session_id, "running")
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.emit_message(session_id, tool_msg)
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.emit_message(session_id, tool_result)
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 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.emit_message(session_id, asst_msg)
session.status = "completed"
session.closed_at = datetime.now()
session.cost_usd = 0.001
await ws_manager.emit_status(session_id, "completed", session)
await ws_manager.emit_cost_update(session_id, session.cost_usd)
+2 -19
View File
@@ -1,11 +1,10 @@
from backend.config.Apps import SubApp
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.agents.manager.agent_manager import agent_manager
from backend.apps.agents.models import AgentConfig, ApprovalResponse
from backend.apps.agents.browser.runner import run_browser_agents
from contextlib import asynccontextmanager
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from uuid import uuid4
import logging
logger = logging.getLogger(__name__)
@@ -169,25 +168,9 @@ async def resume_session(session_id: str):
return {"session": session.model_dump(mode="json")}
@agents.router.post("/browser/command")
async def browser_command(request: Request):
"""Proxy browser commands to the frontend via WebSocket and wait for results."""
body = await request.json()
action = body.get("action", "")
browser_id = body.get("browser_id", "")
tab_id = body.get("tab_id", "")
params = body.get("params", {})
if not action or not browser_id:
return JSONResponse({"error": "action and browser_id are required"}, status_code=400)
request_id = uuid4().hex
result = await ws_manager.send_browser_command(request_id, action, browser_id, params, tab_id=tab_id)
return JSONResponse(result)
@agents.router.post("/browser-agent/run")
async def browser_agent_run(request: Request):
"""Run one or more browser sub-agents in parallel."""
from backend.apps.agents.browser_agent import run_browser_agents
body = await request.json()
tasks = body.get("tasks", [])
if not tasks:
+2 -2
View File
@@ -7,8 +7,8 @@ import logging
from uuid import uuid4
from backend.apps.agents.models import AgentSession
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.agents.approval import request_approval
from backend.apps.agents.manager.ws_manager import ws_manager
from backend.apps.agents.execution.approval import request_approval
from backend.apps.agents.browser.schemas import ACTION_MAP
logger = logging.getLogger(__name__)
+9 -8
View File
@@ -9,9 +9,17 @@ from datetime import datetime
from uuid import uuid4
from backend.apps.agents.models import AgentSession, Message
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.agents.manager.ws_manager import ws_manager
from backend.apps.common.model_registry import resolve_model_id
from backend.apps.tools_lib.tools_lib import load_builtin_permissions
from backend.apps.agents.manager.agent_manager import agent_manager
from backend.apps.settings.settings import load_settings
from backend.apps.settings.credentials import get_anthropic_client
from backend.apps.common.llm_helpers import _resolve_model as _resolve_9r
from backend.apps.dashboards.dashboards import _load, _save
from backend.apps.dashboards.models import BrowserCardPosition, BrowserTab
from backend.apps.analytics.collector import record as _analytics
from backend.apps.agents.browser.schemas import (
BROWSER_TOOLS_SCHEMA, SYSTEM_PROMPT, MAX_TURNS,
)
@@ -28,7 +36,6 @@ async def run_browser_agent(
pre_selected: bool = False, initial_url: str | None = None,
parent_session_id: str | None = None,
) -> dict:
from backend.apps.agents.agent_manager import agent_manager
_browser_perms = load_builtin_permissions()
session_id = uuid4().hex
@@ -49,9 +56,6 @@ async def run_browser_agent(
logger.info(f"Browser agent {session_id}: navigated to {initial_url}: {nav_result.get('text', nav_result.get('error', ''))}")
api_model = resolve_model_id(model)
from backend.apps.settings.settings import load_settings
from backend.apps.settings.credentials import get_anthropic_client
from backend.apps.common.llm_helpers import _resolve_model as _resolve_9r
_settings = load_settings()
api_model = _resolve_9r(api_model, _settings)
client = get_anthropic_client(_settings)
@@ -168,8 +172,6 @@ async def run_browser_agent(
async def _create_browser_card(dashboard_id: str, url: str, parent_session_id: str | None = None) -> str:
from backend.apps.dashboards.dashboards import _load, _save
from backend.apps.dashboards.models import BrowserCardPosition, BrowserTab
dashboard = _load(dashboard_id)
browser_id = f"browser-{uuid4().hex[:8]}"
@@ -196,7 +198,6 @@ async def run_browser_agents(
pre_selected_browser_ids: list[str] | None = None,
parent_session_id: str | None = None,
) -> list[dict]:
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {
"feature": "browser_agent.launched", "task_count": len(tasks), "model": model,
}, dashboard_id=dashboard_id)
-9
View File
@@ -1,9 +0,0 @@
"""Backward-compatible shim — re-exports from the browser sub-package."""
from backend.apps.agents.browser.runner import ( # noqa: F401
run_browser_agent,
run_browser_agents,
)
from backend.apps.agents.browser.executor import ( # noqa: F401
execute_browser_tool,
)
-212
View File
@@ -1,212 +0,0 @@
"""Tool schema definitions for the browser MCP server."""
TAB_ID_PROP = {
"type": "string",
"description": "Optional tab ID within the browser card. If omitted, targets the active tab.",
}
TOOLS = [
{
"name": "BrowserScreenshot",
"description": (
"Capture a screenshot of the browser page. Returns the screenshot as a "
"base64-encoded PNG image. Use this to see what is currently displayed."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID to capture. Use the ID from the selected browser card context.",
},
"tab_id": TAB_ID_PROP,
},
"required": ["browser_id"],
},
},
{
"name": "BrowserGetText",
"description": (
"Get the visible text content of the browser page. Returns the page's "
"innerText (up to 15000 characters)."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
},
"required": ["browser_id"],
},
},
{
"name": "BrowserNavigate",
"description": "Navigate the browser to a URL.",
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"url": {
"type": "string",
"description": "The URL to navigate to.",
},
},
"required": ["browser_id", "url"],
},
},
{
"name": "BrowserClick",
"description": (
"Click an element in the browser page identified by a CSS selector."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": "CSS selector of the element to click.",
},
},
"required": ["browser_id", "selector"],
},
},
{
"name": "BrowserType",
"description": (
"Type text into an input element in the browser page. Clears the "
"existing value first, then types the new text."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": "CSS selector of the input element.",
},
"text": {
"type": "string",
"description": "The text to type.",
},
},
"required": ["browser_id", "selector", "text"],
},
},
{
"name": "BrowserEvaluate",
"description": (
"Evaluate a JavaScript expression in the browser page and return the result. "
"The expression is run via executeJavaScript on the webview."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"expression": {
"type": "string",
"description": "JavaScript expression to evaluate.",
},
},
"required": ["browser_id", "expression"],
},
},
{
"name": "BrowserGetElements",
"description": (
"Get a list of interactive elements on the page with their CSS selectors. "
"Returns clickable elements, inputs, links, and buttons with selector paths "
"you can use with BrowserClick and BrowserType. Call this BEFORE attempting "
"to click or type so you know which selectors are valid."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": (
"Optional CSS selector to scope the search "
"(e.g. 'form', '#main'). Defaults to 'body'."
),
},
},
"required": ["browser_id"],
},
},
{
"name": "BrowserScroll",
"description": (
"Scroll the page up or down. Automatically finds the correct scrollable "
"container (works on SPAs like Notion, Gmail, etc. that use nested scroll "
"containers instead of window-level scrolling). Returns scroll position info "
"including whether top/bottom has been reached."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"direction": {
"type": "string",
"enum": ["up", "down"],
"description": "Scroll direction. Defaults to 'down'.",
},
"amount": {
"type": "number",
"description": "Pixels to scroll. Defaults to 500.",
},
},
"required": ["browser_id"],
},
},
{
"name": "BrowserWait",
"description": (
"Wait for a specified duration. Useful after navigation or actions that "
"trigger page loads, animations, or async content rendering. "
"Min 100ms, max 10000ms."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"milliseconds": {
"type": "number",
"description": "Duration to wait in milliseconds. Defaults to 1000.",
},
},
"required": ["browser_id"],
},
},
]
-180
View File
@@ -1,180 +0,0 @@
#!/usr/bin/env python3
"""
Minimal stdio MCP server that exposes browser interaction tools.
Launched as a subprocess by the Claude Agent SDK. Proxies tool calls
to the OpenSwarm backend via HTTP, which bridges them to the Electron
frontend via WebSocket where the actual webview lives.
"""
import base64
import json
import sys
import os
import urllib.request
import urllib.error
from io import BytesIO
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
from browser_mcp_schemas import TOOLS # noqa: E402 (sibling script import)
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8325")
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/agents/browser/command"
def send_response(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def call_backend(action: str, browser_id: str, params: dict | None = None, tab_id: str = "") -> dict:
payload = json.dumps({
"action": action,
"browser_id": browser_id,
"tab_id": tab_id,
"params": params or {},
}).encode()
req = urllib.request.Request(
BACKEND_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else str(e)
return {"error": f"HTTP {e.code}: {body}"}
except Exception as e:
return {"error": str(e)}
MAX_IMAGE_B64_BYTES = 400_000
def compress_screenshot(b64_png: str) -> tuple[str, str] | None:
"""Resize and re-encode as JPEG to stay under the stdio buffer limit."""
if not HAS_PIL:
return None
try:
raw = base64.b64decode(b64_png)
img = Image.open(BytesIO(raw))
max_width = 1024
if img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
buf = BytesIO()
img.convert("RGB").save(buf, format="JPEG", quality=45)
return base64.b64encode(buf.getvalue()).decode(), "image/jpeg"
except Exception:
return None
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
browser_id = arguments.get("browser_id", "")
tab_id = arguments.get("tab_id", "")
if not browser_id:
return {"content": [{"type": "text", "text": "Error: browser_id is required"}], "isError": True}
action_map = {
"BrowserScreenshot": "screenshot",
"BrowserGetText": "get_text",
"BrowserNavigate": "navigate",
"BrowserClick": "click",
"BrowserType": "type",
"BrowserEvaluate": "evaluate",
"BrowserGetElements": "get_elements",
"BrowserScroll": "scroll",
"BrowserWait": "wait",
}
action = action_map.get(tool_name)
if not action:
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
params = {k: v for k, v in arguments.items() if k not in ("browser_id", "tab_id")}
result = call_backend(action, browser_id, params, tab_id=tab_id)
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
if action == "screenshot" and result.get("image"):
image_data = result["image"]
mime_type = "image/png"
if len(image_data) > MAX_IMAGE_B64_BYTES:
compressed = compress_screenshot(image_data)
if compressed:
image_data, mime_type = compressed
if len(image_data) > MAX_IMAGE_B64_BYTES:
return {
"content": [
{"type": "text", "text": (
f"Screenshot too large to return ({len(image_data)} bytes base64). "
f"URL: {result.get('url', 'unknown')}. "
"Use BrowserGetText to read the page content instead."
)},
],
}
return {
"content": [
{"type": "image", "data": image_data, "mimeType": mime_type},
{"type": "text", "text": f"Screenshot captured. URL: {result.get('url', 'unknown')}"},
],
}
text = result.get("text", result.get("data", json.dumps(result)))
return {"content": [{"type": "text", "text": str(text)}]}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
method = msg.get("method")
id_ = msg.get("id")
params = msg.get("params", {})
if method == "initialize":
send_response(id_, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "openswarm-browser",
"version": "1.0.0",
},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
send_response(id_, {"tools": TOOLS})
elif method == "tools/call":
tool_name = params.get("name", "")
arguments = params.get("arguments", {})
result = handle_tool_call(tool_name, arguments)
send_response(id_, result)
elif method == "ping":
send_response(id_, {})
elif id_ is not None:
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
if __name__ == "__main__":
main()
@@ -14,10 +14,11 @@ from datetime import datetime
from uuid import uuid4
from backend.apps.agents.models import AgentSession, Message
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.agents.approval import request_approval
from backend.apps.agents.mcp_builder import get_effective_policy
from backend.apps.agents.manager.ws_manager import ws_manager
from backend.apps.agents.execution.approval import request_approval
from backend.apps.agents.execution.mcp_builder import get_effective_policy
from backend.apps.analytics.collector import record as _analytics
import asyncio
logger = logging.getLogger(__name__)
@@ -38,7 +39,7 @@ def create_sdk_hooks(
safe_input = tool_input if isinstance(tool_input, dict) else {}
return await request_approval(session, tool_name, safe_input, track_analytics=True)
async def can_use_tool(tool_name, input_data, context):
async def can_use_tool(tool_name, input_data):
if tool_name != "AskUserQuestion":
policy = get_effective_policy(tool_name, builtin_perms)
if policy == "always_allow":
@@ -50,7 +51,7 @@ def create_sdk_hooks(
return PermissionResultAllow(updated_input=decision.get("updated_input", input_data))
return PermissionResultDeny(message=decision.get("message", "User denied this action"))
async def pre_tool_hook(input_data, tool_use_id, context):
async def pre_tool_hook(input_data, tool_use_id):
tool_name = input_data.get("tool_name", "")
hook_event = input_data.get("hook_event_name", "PreToolUse")
if tool_name and tool_name != "AskUserQuestion":
@@ -69,7 +70,7 @@ def create_sdk_hooks(
tool_start_times[tool_use_id] = time.time()
return {}
async def post_tool_hook(input_data, tool_use_id, context):
async def post_tool_hook(input_data, tool_use_id):
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)
@@ -176,6 +177,5 @@ def _build_sub_agent_session(
dashboard_id=session.dashboard_id, parent_session_id=session_id,
)
sessions[sub_session_id] = sub_session
import asyncio
asyncio.ensure_future(_broadcast_sub_session(sub_session))
return sub_session_id
@@ -13,15 +13,25 @@ import logging
from uuid import uuid4
from backend.apps.agents.models import AgentSession, Message
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.agents.session_store import save_session
from backend.apps.agents.prompt_builder import build_prompt_content
from backend.apps.agents.manager.ws_manager import ws_manager
from backend.apps.agents.manager.session_store import save_session
from backend.apps.agents.execution.prompt_builder import build_prompt_content
from backend.apps.tools_lib.tools_lib import (
_load_all as load_all_tools,
load_builtin_permissions,
)
from backend.apps.analytics.collector import record as _analytics
from backend.apps.agents.agent_mock import run_mock_agent, fire_session_completed
from backend.apps.agents.execution.agent_hooks import create_sdk_hooks
from claude_agent_sdk import (
query, ClaudeAgentOptions, AssistantMessage, ResultMessage,
)
from claude_agent_sdk.types import (
PermissionResultAllow, PermissionResultDeny,
TextBlock, ToolUseBlock, StreamEvent, SystemMessage,
)
from backend.apps.agents.execution.agent_options import build_agent_options
logger = logging.getLogger(__name__)
@@ -47,30 +57,14 @@ async def run_agent_loop(
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()
from backend.apps.agents.agent_hooks import create_sdk_hooks
can_use_tool, pre_tool_hook, post_tool_hook = create_sdk_hooks(
session, session_id, sessions, builtin_perms,
PermissionResultAllow, PermissionResultDeny,
)
from backend.apps.agents.agent_options import build_agent_options
try:
options_kwargs = await build_agent_options(
session, builtin_perms, can_use_tool, pre_tool_hook, post_tool_hook,
@@ -0,0 +1,48 @@
"""Mock agent and session-completed analytics.
Extracted from agent_loop.py to keep every file under 250 lines.
"""
from __future__ import annotations
import logging
from datetime import datetime
from backend.apps.agents.models import AgentSession
from backend.apps.analytics.collector import record as _analytics
logger = logging.getLogger(__name__)
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)
@@ -11,23 +11,33 @@ import os
import sys
from backend.apps.agents.models import AgentSession
from backend.apps.agents.prompt_builder import resolve_mode, compose_system_prompt
from backend.apps.agents.prompt_context import (
from backend.apps.agents.execution.prompt_builder import resolve_mode, compose_system_prompt
from backend.apps.agents.execution.prompt_context import (
build_connected_tools_context, build_outputs_context,
build_browser_context, get_pre_selected_browser_ids,
)
from backend.apps.agents.mcp_builder import (
from backend.apps.agents.execution.mcp_builder import (
FULL_TOOLS, build_mcp_servers, get_all_tool_names,
_get_denied_tool_names, _get_all_known_tool_names, _is_fully_denied,
)
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,
_load_all as load_all_tools
)
from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name
from backend.ports import BACKEND_DEV_PORT, NINE_ROUTER_PORT
from claude_agent_sdk.types import HookMatcher
from backend.apps.outputs.view_builder_templates import VIEW_BUILDER_SKILL
from backend.apps.common.model_registry import resolve_model_id as _resolve_mid
from backend.apps.nine_router import is_running as _9r_running
logger = logging.getLogger(__name__)
@@ -45,8 +55,6 @@ async def build_agent_options(
Requires claude_agent_sdk types to be imported by the caller; they are
passed in via the hook callables.
"""
from claude_agent_sdk import ClaudeAgentOptions
from claude_agent_sdk.types import HookMatcher
_, mode_sys_prompt, _ = resolve_mode(session.mode, get_all_tool_names)
connected_tools_ctx = build_connected_tools_context(
@@ -61,7 +69,6 @@ async def build_agent_options(
)
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
@@ -122,7 +129,6 @@ async def build_agent_options(
"include_partial_messages": True,
}
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")
@@ -132,7 +138,6 @@ async def build_agent_options(
"ANTHROPIC_BASE_URL": f"http://localhost:{NINE_ROUTER_PORT}",
}
options_kwargs["extra_args"] = {"bare": None}
from backend.apps.common.model_registry import resolve_model_id as _resolve_mid
resolved = _resolve_mid(session.model)
if not resolved.startswith("cc/"):
options_kwargs["model"] = f"cc/{resolved}"
@@ -11,7 +11,10 @@ from datetime import datetime
from uuid import uuid4
from backend.apps.agents.models import AgentSession, ApprovalRequest
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.agents.manager.ws_manager import ws_manager
from backend.apps.analytics.collector import record as _analytics
logger = logging.getLogger(__name__)
@@ -40,7 +43,6 @@ async def request_approval(
session.status = "waiting_approval"
if track_analytics:
from backend.apps.analytics.collector import record as _analytics
_analytics("approval.requested", {
"tool_name": tool_name,
"is_first_approval_in_session": len(session.pending_approvals) == 1,
@@ -63,7 +65,6 @@ async def request_approval(
)
if track_analytics:
from backend.apps.analytics.collector import record as _analytics
latency_ms = int((datetime.now() - approval_req.created_at).total_seconds() * 1000)
_analytics("approval.resolved", {
"tool_name": tool_name,
@@ -11,7 +11,9 @@ from typing import Any
from backend.apps.modes.modes import load_mode
from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name
from backend.apps.agents.prompt_context import resolve_context_paths
from backend.apps.agents.execution.prompt_context import resolve_context_paths
from backend.apps.tools_lib.models import BUILTIN_TOOLS
logger = logging.getLogger(__name__)
@@ -47,7 +49,6 @@ def resolve_forced_tools(
) -> str:
if not forced_tools:
return ""
from backend.apps.tools_lib.models import BUILTIN_TOOLS
desc_map: dict[str, str] = {t.name: t.description for t in BUILTIN_TOOLS}
tool_to_server: dict[str, str] = {}
tool_to_email: dict[str, str] = {}
@@ -13,6 +13,9 @@ import os
from backend.apps.outputs.outputs import _load_all as load_all_outputs
from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name
from backend.apps.dashboards.dashboards import _load as load_dashboard
logger = logging.getLogger(__name__)
@@ -109,7 +112,6 @@ def build_browser_context(
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
@@ -160,7 +162,6 @@ def get_pre_selected_browser_ids(dashboard_id: str | None) -> list[str]:
if not dashboard_id:
return []
try:
from backend.apps.dashboards.dashboards import _load as load_dashboard
dashboard = load_dashboard(dashboard_id)
except Exception:
return []
@@ -19,25 +19,26 @@ from typing import Optional
from uuid import uuid4
from backend.apps.agents.models import AgentConfig, AgentSession, Message
from backend.apps.agents.ws_manager import ws_manager
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 (
delete_session_file, get_history,
from backend.apps.agents.manager.ws_manager import ws_manager
from backend.apps.agents.execution.prompt_builder import resolve_mode
from backend.apps.agents.execution.mcp_builder import get_all_tool_names
from backend.apps.agents.manager.session_store import (
get_history,
reconcile_on_startup, get_browser_agent_children,
)
from backend.apps.agents.agent_loop import run_agent_loop
from backend.apps.agents.agent_manager_ops import (
from backend.apps.agents.execution.agent_loop import run_agent_loop
from backend.apps.agents.manager.agent_manager_ops import (
edit_message_op, close_session_op, resume_session_op,
duplicate_session_op, invoke_agent_op,
duplicate_session_op, invoke_agent_op
)
from backend.apps.agents.agent_manager_meta import (
from backend.apps.agents.manager.agent_manager_meta import (
generate_title_op, generate_group_meta_op,
persist_all_sessions_op, restore_all_sessions_op,
persist_all_sessions_op, restore_all_sessions_op, delete_session_op
)
from backend.apps.settings.settings import load_settings
from backend.apps.analytics.collector import record as _analytics
logger = logging.getLogger(__name__)
os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000")
@@ -201,7 +202,6 @@ class AgentManager:
await close_session_op(self.sessions, self.tasks, session_id)
async def delete_session(self, session_id: str) -> None:
from backend.apps.agents.agent_manager_ops import delete_session_op
await delete_session_op(self, session_id)
async def resume_session(self, session_id: str) -> AgentSession:
@@ -9,11 +9,14 @@ import asyncio
import logging
from backend.apps.agents.models import AgentSession, ToolGroupMeta
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.agents.session_store import (
from backend.apps.agents.manager.ws_manager import ws_manager
from backend.apps.agents.manager.session_store import (
save_session, delete_session_file, build_search_text,
)
from backend.apps.common.llm_helpers import quick_llm_call, quick_llm_json
from backend.apps.agents.manager.session_store import load_all_session_data
from backend.apps.agents.execution.agent_mock import fire_session_completed
logger = logging.getLogger(__name__)
@@ -75,7 +78,6 @@ async def generate_group_meta_op(
async def persist_all_sessions_op(sessions: dict, tasks: dict) -> None:
from backend.apps.agents.agent_mock import fire_session_completed
for session_id, session in list(sessions.items()):
if session.status in ("running", "waiting_approval"):
session.status = "stopped"
@@ -92,7 +94,6 @@ async def persist_all_sessions_op(sessions: dict, tasks: dict) -> None:
async def restore_all_sessions_op(sessions: dict) -> None:
from backend.apps.agents.session_store import load_all_session_data
for sid, data in load_all_session_data():
try:
session = AgentSession(**data)
@@ -14,12 +14,19 @@ from uuid import uuid4
from backend.apps.agents.models import (
AgentSession, Message, MessageBranch,
)
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.agents.session_store import (
from backend.apps.agents.manager.ws_manager import ws_manager
from backend.apps.agents.manager.session_store import (
save_session, load_session_data, delete_session_file,
build_search_text, copy_session_messages,
)
from backend.apps.analytics.collector import record as _analytics
from backend.apps.agents.execution.agent_loop import run_agent_loop
from backend.apps.agents.execution.agent_loop import run_agent_loop
from backend.apps.agents.execution.agent_mock import fire_session_completed
logger = logging.getLogger(__name__)
@@ -75,7 +82,6 @@ async def edit_message_op(
session.sdk_session_id = None
session.status = "running"
await ws_manager.emit_status(session_id, "running", session)
from backend.apps.agents.agent_loop import run_agent_loop
task = asyncio.create_task(run_agent_loop(
sessions, session_id, new_content,
images=target_msg.images, context_paths=target_msg.context_paths,
@@ -88,8 +94,9 @@ async def close_session_op(
sessions: dict, tasks: dict,
session_id: str,
):
from backend.apps.agents.agent_manager import agent_manager
children = [s for s in sessions.values() if s.parent_session_id == session_id and s.mode == "browser-agent"]
from backend.apps.agents.manager.agent_manager import agent_manager
# NOTE: this is a circular dependency, must be fixed soon by fixing the ai slop
for child in children:
await agent_manager.stop_agent(child.id)
task = tasks.get(session_id)
@@ -110,7 +117,6 @@ async def close_session_op(
session.pending_approvals = []
if hasattr(session, '_cancel_event'):
session._cancel_event.set()
from backend.apps.agents.agent_mock import fire_session_completed
fire_session_completed(session, sessions)
doc_data = session.model_dump(mode="json")
doc_data["search_text"] = build_search_text(session)
@@ -204,7 +210,6 @@ async def invoke_agent_op(
user_msg = Message(role="user", content=message, branch_id=fork.active_branch_id)
fork.messages.append(user_msg)
await ws_manager.emit_message(fork.id, user_msg)
from backend.apps.agents.agent_loop import run_agent_loop
await run_agent_loop(sessions, fork.id, message, fork_session=True)
last_assistant = None
for msg in reversed(fork.messages):
@@ -222,11 +227,4 @@ async def invoke_agent_op(
"forked_session_id": fork.id, "source_name": source_name,
"response": last_assistant or "No response from invoked agent.",
"cost_usd": fork.cost_usd,
}
from backend.apps.agents.agent_manager_meta import ( # noqa: F401 — re-exports
generate_title_op, generate_group_meta_op,
persist_all_sessions_op, restore_all_sessions_op,
delete_session_op,
)
}
+2 -8
View File
@@ -8,15 +8,14 @@ from __future__ import annotations
import logging
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.agents.manager.ws_manager import ws_manager
from backend.apps.agents.manager.agent_manager import agent_manager
logger = logging.getLogger(__name__)
async def handle_session_message(session_id: str, event: str, payload: dict):
"""Dispatch an incoming WebSocket message for a session."""
if event == "agent:send_message":
from backend.apps.agents.agent_manager import agent_manager
await agent_manager.send_message(
session_id,
payload.get("prompt", ""),
@@ -26,28 +25,23 @@ async def handle_session_message(session_id: str, event: str, payload: dict):
images=payload.get("images"),
)
elif event == "agent:approval_response":
from backend.apps.agents.agent_manager import agent_manager
agent_manager.handle_approval(payload.get("request_id"), {
"behavior": payload.get("behavior", "deny"),
"message": payload.get("message"),
"updated_input": payload.get("updated_input"),
})
elif event == "agent:edit_message":
from backend.apps.agents.agent_manager import agent_manager
await agent_manager.edit_message(
session_id,
payload.get("message_id", ""),
payload.get("content", ""),
)
elif event == "agent:stop":
from backend.apps.agents.agent_manager import agent_manager
await agent_manager.stop_agent(session_id)
async def handle_dashboard_message(event: str, payload: dict):
"""Dispatch an incoming WebSocket message for the dashboard."""
if event == "agent:approval_response":
from backend.apps.agents.agent_manager import agent_manager
agent_manager.handle_approval(payload.get("request_id"), {
"behavior": payload.get("behavior", "deny"),
"message": payload.get("message"),
+8 -5
View File
@@ -10,6 +10,14 @@ from backend.config.Apps import SubApp
from backend.apps.analytics.collector import init as init_collector, shutdown as shutdown_collector, record, identify
from backend.apps.analytics.usage_summary import load_all_sessions, compute_session_stats, enrich_with_nine_router
from backend.apps.agents.manager.agent_manager import agent_manager
from backend.apps.settings.settings import load_settings, _save_settings
from backend.apps.agents.manager.agent_manager import agent_manager
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
logger = logging.getLogger(__name__)
APP_VERSION = "1.0.20"
@@ -22,13 +30,11 @@ async def _heartbeat_loop():
while True:
await asyncio.sleep(60)
try:
from backend.apps.agents.agent_manager import agent_manager
props = {
"active_session_count": len(agent_manager.sessions),
}
try:
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
if _9r_running():
stats = await get_usage_stats()
if stats:
@@ -64,7 +70,6 @@ async def analytics_lifespan():
logger.info("PostHog analytics initialised")
try:
from backend.apps.settings.settings import load_settings, _save_settings
settings = load_settings()
is_first_open = settings.first_opened_at is None
@@ -132,8 +137,6 @@ analytics = SubApp("analytics", analytics_lifespan)
@analytics.router.get("/usage-summary")
async def usage_summary():
"""Compute usage stats from persisted sessions for the Settings page."""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
sessions = load_all_sessions()
for s in agent_manager.get_all_sessions():
+2 -1
View File
@@ -12,6 +12,8 @@ import platform
from uuid import uuid4
from posthog import Posthog
from backend.apps.settings.settings import load_settings, _save_settings
logger = logging.getLogger(__name__)
@@ -50,7 +52,6 @@ def _get_installation_id() -> str:
if _installation_id:
return _installation_id
try:
from backend.apps.settings.settings import load_settings, _save_settings
settings = load_settings()
iid = getattr(settings, "installation_id", None)
if not iid:
+1 -1
View File
@@ -8,7 +8,7 @@ logger = logging.getLogger(__name__)
from backend.config.Apps import MainApp
from backend.apps.health.health import health
from backend.apps.agents.agents import agents
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.agents.manager.ws_manager import ws_manager
from backend.apps.agents.ws_routes import handle_session_message, handle_dashboard_message
from backend.apps.skills.skills import skills
from backend.apps.tools_lib.tools_lib import tools_lib
+1 -2
View File
@@ -12,12 +12,11 @@
"knip": "knip"
},
"dependencies": {
"@assistant-ui/core": "^0.1.9",
"@assistant-ui/core": "^0.1.9",
"@assistant-ui/core": "^0.1.9",
"@assistant-ui/react": "^0.12.21",
"@assistant-ui/react-lexical": "^0.0.3",
"@assistant-ui/react-markdown": "^0.12.7",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
+5 -5
View File
@@ -1,13 +1,13 @@
{
"enabled": {
"max-file-lines": false,
"max-folder-items": false,
"no-nested-imports": false,
"vulture": false,
"max-folder-items": true,
"no-nested-imports": true,
"vulture": true,
"eslint": false,
"knip": true,
"endpoints": false,
"classes": false
"endpoints": true,
"classes": true
},
"rules": {
"max-file-lines": 250,