Merge eric/v2 into arnav/tests

Brings in service layer refactor, frontend updates, session state additions,
telemetry unification, and UI hook refinements. Resolved .gitignore conflict
by combining coverage report entries (arnav/tests) with pyc/cache/editor
noise entries (eric/v2).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Arnav Naval
2026-05-05 20:29:02 -05:00
co-authored by Cursor
96 changed files with 8320 additions and 5534 deletions
-428
View File
@@ -1,428 +0,0 @@
"""Owned agent loop — replaces claude_agent_sdk's query() function.
Generalizes the pattern from browser_agent.py (lines 243-334) into a
provider-agnostic, streaming, HITL-aware tool-use loop.
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any, Callable, Awaitable
from uuid import uuid4
from backend.apps.agents.providers.base import (
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
StreamEvent, ToolCall, ToolSchema,
)
logger = logging.getLogger(__name__)
# Type aliases for callbacks
ToolExecutor = Callable[[str, dict], Awaitable[list[dict]]]
# hitl_handler(tool_name, tool_input) -> (approved, updated_input_or_None)
HITLHandler = Callable[[str, dict], Awaitable[tuple[bool, dict | None]]]
# ws_emitter(event_type, data) -> None
WSEmitter = Callable[[str, dict], Awaitable[None]]
class AgentLoop:
"""Provider-agnostic agent loop with streaming and HITL support.
The loop:
1. Sends user message to the model
2. Streams the response (emitting WebSocket events)
3. If the model requests tool use:
a. For each tool call: check HITL permission → execute → collect result
b. Append tool results → go to step 2
4. If the model stops (end_turn/max_tokens): done
"""
def __init__(
self,
session_id: str,
provider: BaseProvider,
model: str,
system_prompt: str | None,
tools: list[ToolSchema],
tool_executor: ToolExecutor,
hitl_handler: HITLHandler,
ws_emitter: WSEmitter,
max_turns: int | None = None,
cwd: str | None = None,
):
self.session_id = session_id
self.provider = provider
self.model = model
self.system_prompt = system_prompt
self.tools = tools
self.tool_executor = tool_executor
self.hitl_handler = hitl_handler
self.ws_emitter = ws_emitter
self.max_turns = max_turns
self.cwd = cwd
# Conversation history in provider-agnostic format
self.messages: list[ProviderMessage] = []
# Token tracking
self.total_input_tokens = 0
self.total_output_tokens = 0
async def run(self, user_content: Any) -> None:
"""Run the agent loop for a single user turn."""
# Append user message
user_msg = self.provider.format_user_message(user_content)
self.messages.append(user_msg)
turn = 0
while True:
if self.max_turns and turn >= self.max_turns:
logger.info(f"Agent {self.session_id}: max turns ({self.max_turns}) reached")
break
turn += 1
# Stream the model response and collect it
response = await self._stream_and_collect()
# Track usage
self.total_input_tokens += response.usage.get("input_tokens", 0)
self.total_output_tokens += response.usage.get("output_tokens", 0)
# Append assistant message to conversation history
assistant_msg = self.provider.format_assistant_message(response)
self.messages.append(assistant_msg)
# If no tool use, we're done
if response.stop_reason != "tool_use":
break
# Execute tools
tool_results = await self._execute_tools(response)
if not tool_results:
break
# Append tool results
self.messages.append(ProviderMessage(role="tool_result", content=tool_results))
async def _stream_and_collect(self) -> ModelResponse:
"""Stream model output, emit WebSocket events, collect full response."""
collected_content: list[ContentBlock] = []
collected_usage: dict[str, int] = {}
stop_reason = "end_turn"
# Track streaming state for WS emissions
stream_text_msg_id: str | None = None
stream_tool_msg_ids: dict[int, str] = {} # block index -> msg_id
block_index_map: dict[int, str] = {} # block index -> msg_id
# Buffers for collecting content
text_buffers: dict[int, str] = {}
json_buffers: dict[int, str] = {}
tool_names: dict[int, str] = {}
tool_ids: dict[int, str] = {}
block_types: dict[int, str] = {}
# Wall-clock start time per content block (server-side stamps).
# Used to compute elapsed_ms for thinking blocks so the persisted
# ThinkingBubble can show the duration after streaming ends.
block_start_ts: dict[int, float] = {}
thinking_total_ms: int = 0
thinking_total_chars: int = 0
async for event in self.provider.stream_message(
model=self.model,
system=self.system_prompt,
messages=self.messages,
tools=self.tools,
):
if event.type == "content_block_start":
if event.block_type == "text":
if stream_text_msg_id is None:
stream_text_msg_id = uuid4().hex
await self.ws_emitter("agent:stream_start", {
"message_id": stream_text_msg_id,
"role": "assistant",
})
block_index_map[event.index] = stream_text_msg_id
block_types[event.index] = "text"
text_buffers[event.index] = ""
elif event.block_type == "tool_use":
tool_msg_id = uuid4().hex
stream_tool_msg_ids[event.index] = tool_msg_id
block_index_map[event.index] = tool_msg_id
block_types[event.index] = "tool_use"
tool_names[event.index] = event.tool_name
tool_ids[event.index] = event.tool_id
json_buffers[event.index] = ""
await self.ws_emitter("agent:stream_start", {
"message_id": tool_msg_id,
"role": "tool_call",
"tool_name": event.tool_name,
})
elif event.block_type == "thinking":
# Extended-thinking content block. Emit a distinct
# WS stream with role="thinking" so the frontend
# renders the live ThinkingBubble pill (rising
# token counter, auto-collapse on first text). Each
# thinking block gets its own message id — multiple
# interleaved thinking/text blocks remain
# individually addressable.
thinking_msg_id = uuid4().hex
block_index_map[event.index] = thinking_msg_id
block_types[event.index] = "thinking"
text_buffers[event.index] = ""
# Server-stamp the start so we can compute exact
# elapsed_ms server-side at content_block_stop. Using
# time.time() (not monotonic) is fine here — we only
# subtract two values from the same clock.
block_start_ts[event.index] = time.time()
await self.ws_emitter("agent:stream_start", {
"message_id": thinking_msg_id,
"role": "thinking",
})
elif event.type == "content_block_delta":
msg_id = block_index_map.get(event.index)
if not msg_id:
continue
if event.delta_type == "text_delta":
text_buffers.setdefault(event.index, "")
text_buffers[event.index] += event.text
await self.ws_emitter("agent:stream_delta", {
"message_id": msg_id,
"delta": event.text,
})
elif event.delta_type == "input_json_delta":
json_buffers.setdefault(event.index, "")
json_buffers[event.index] += event.text
await self.ws_emitter("agent:stream_delta", {
"message_id": msg_id,
"delta": event.text,
})
elif event.delta_type == "thinking_delta":
# Reuse the text buffer for thinking — same shape
# (accumulated str), different sink.
text_buffers.setdefault(event.index, "")
text_buffers[event.index] += event.text
await self.ws_emitter("agent:stream_delta", {
"message_id": msg_id,
"delta": event.text,
})
elif event.type == "content_block_stop":
msg_id = block_index_map.get(event.index)
bt = block_types.get(event.index, "")
if bt == "text":
collected_content.append(
ContentBlock(type="text", text=text_buffers.get(event.index, ""))
)
elif bt == "tool_use":
try:
tool_input = json.loads(json_buffers.get(event.index, "{}"))
except json.JSONDecodeError:
tool_input = {}
collected_content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=tool_ids.get(event.index, uuid4().hex),
name=tool_names.get(event.index, ""),
input=tool_input,
),
))
elif bt == "thinking":
thinking_text = text_buffers.get(event.index, "")
collected_content.append(
ContentBlock(type="thinking", text=thinking_text)
)
# Accumulate per-block duration + char count for the
# eventual persisted Message. We sum across multiple
# thinking blocks in the same turn so a complex
# interleaved (think → tool → think → answer) turn
# still reports total time spent reasoning.
start_ts = block_start_ts.get(event.index)
if start_ts is not None:
thinking_total_ms += int((time.time() - start_ts) * 1000)
thinking_total_chars += len(thinking_text)
# Send stream_end for tool + thinking blocks (text block
# ends at message_stop). Thinking ends here so the
# frontend can transition the pill from "live" to
# "Thought for Ns" the moment the model stops thinking,
# even if it then keeps streaming text.
if msg_id and (bt == "tool_use" or bt == "thinking"):
payload: dict[str, Any] = {"message_id": msg_id}
if bt == "thinking":
# Server-stamped truth so the persisted bubble
# doesn't fall back to "Thoughts" — and so the
# live bubble freezes on the exact server-side
# duration instead of the client's clock.
block_start = block_start_ts.get(event.index)
if block_start is not None:
block_elapsed = int((time.time() - block_start) * 1000)
payload["elapsed_ms"] = block_elapsed
# Token estimate for THIS block (chars/3.6 ≈
# Anthropic BPE for English prose). Matches the
# heuristic the live UI used so the freeze
# value doesn't visually jump.
block_text = text_buffers.get(event.index, "")
if block_text:
payload["tokens"] = max(1, round(len(block_text) / 3.6))
await self.ws_emitter("agent:stream_end", payload)
elif event.type == "usage":
# Accumulate token usage from provider stream
for k, v in event.usage.items():
collected_usage[k] = collected_usage.get(k, 0) + v
elif event.type == "message_stop":
# Check if any tool calls means stop_reason is tool_use
has_tool_use = any(b.type == "tool_use" for b in collected_content)
if has_tool_use:
stop_reason = "tool_use"
# End text stream
if stream_text_msg_id:
await self.ws_emitter("agent:stream_end", {
"message_id": stream_text_msg_id,
})
# Build and emit the collected messages
await self._emit_collected_messages(
collected_content, stream_text_msg_id, stream_tool_msg_ids,
thinking_elapsed_ms=thinking_total_ms,
thinking_total_chars=thinking_total_chars,
)
return ModelResponse(
content=collected_content,
stop_reason=stop_reason,
usage=collected_usage,
)
async def _emit_collected_messages(
self,
content: list[ContentBlock],
text_msg_id: str | None,
tool_msg_ids: dict[int, str],
thinking_elapsed_ms: int = 0,
thinking_total_chars: int = 0,
) -> None:
"""Emit finalized agent:message events for the collected response."""
from backend.apps.agents.models import Message
# Emit thinking blocks (extended thinking). Persisted as their own
# messages so a session reload still shows the reasoning trail.
# Multiple thinking blocks per turn are concatenated into a single
# persisted message — the streaming UI already showed each block
# individually, this is just for the historical record.
thinking_parts = [b.text for b in content if b.type == "thinking" and b.text]
if thinking_parts:
joined = "\n\n".join(thinking_parts)
# Stamp duration + token estimate so the persisted bubble can
# show "Thought for Ns · M tokens" on reload instead of the
# generic "Thoughts" fallback. Use the server-side accumulated
# times so multi-block turns aggregate correctly.
msg = Message(
role="thinking",
content=joined,
elapsed_ms=thinking_elapsed_ms or None,
tokens=max(1, round(thinking_total_chars / 3.6)) if thinking_total_chars else None,
)
await self.ws_emitter("agent:message", {
"message": msg.model_dump(mode="json"),
})
# Emit text message
text_parts = [b.text for b in content if b.type == "text" and b.text]
if text_parts:
msg = Message(
id=text_msg_id or uuid4().hex,
role="assistant",
content="\n".join(text_parts),
)
await self.ws_emitter("agent:message", {
"message": msg.model_dump(mode="json"),
})
# Emit tool call messages
tool_blocks = [b for b in content if b.type == "tool_use" and b.tool_call]
tool_id_list = sorted(tool_msg_ids.items(), key=lambda x: x[0])
for i, block in enumerate(tool_blocks):
tc = block.tool_call
msg_id = tool_id_list[i][1] if i < len(tool_id_list) else uuid4().hex
msg = Message(
id=msg_id,
role="tool_call",
content={
"id": tc.id,
"tool": tc.name,
"input": tc.input,
},
)
await self.ws_emitter("agent:message", {
"message": msg.model_dump(mode="json"),
})
async def _execute_tools(self, response: ModelResponse) -> list[dict]:
"""Execute all tool calls from a response, respecting HITL permissions.
Returns a list of tool result dicts formatted for the provider.
"""
from backend.apps.agents.models import Message
results = []
for block in response.content:
if block.type != "tool_use" or not block.tool_call:
continue
tc = block.tool_call
start_time = time.time()
# HITL permission check
approved, updated_input = await self.hitl_handler(tc.name, tc.input)
if not approved:
result_content = [{"type": "text", "text": "Tool use was denied by the user."}]
else:
tool_input = updated_input if updated_input else tc.input
try:
result_content = await self.tool_executor(tc.name, tool_input)
except Exception as e:
logger.warning(f"Tool execution error: {tc.name}: {e}")
result_content = [{"type": "text", "text": f"Error executing {tc.name}: {e}"}]
elapsed_ms = int((time.time() - start_time) * 1000)
# Emit tool result to frontend
result_text = ""
for block_item in result_content:
if isinstance(block_item, dict) and block_item.get("type") == "text":
result_text = block_item.get("text", "")
break
result_msg = Message(
role="tool_result",
content={
"text": result_text[:15000] if result_text else "Done.",
"tool_name": tc.name,
"elapsed_ms": elapsed_ms,
},
)
await self.ws_emitter("agent:message", {
"message": result_msg.model_dump(mode="json"),
})
# Format for provider
results.append(
self.provider.format_tool_result(tc.id, result_content)
)
return results
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -287,8 +287,9 @@ async def subscriptions_poll(body: dict):
extra_data=body.get("extra_data"),
)
if result.get("success"):
from backend.apps.analytics.collector import record as _analytics
_analytics("subscription.connected", {"provider": provider})
from backend.apps.service.client import sync as _sync
from backend.apps.settings.settings import load_settings
_sync(load_settings().model_dump())
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -310,8 +311,9 @@ async def subscriptions_exchange(body: dict):
try:
result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state)
if result.get("success"):
from backend.apps.analytics.collector import record as _analytics
_analytics("subscription.connected", {"provider": provider})
from backend.apps.service.client import sync as _sync
from backend.apps.settings.settings import load_settings
_sync(load_settings().model_dump())
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -490,8 +492,9 @@ async def subscriptions_disconnect(body: dict):
if conn and conn.get("id"):
async with httpx.AsyncClient(timeout=10.0) as client:
await client.delete(f"{NINE_ROUTER_API}/providers/{conn['id']}")
from backend.apps.analytics.collector import record as _analytics
_analytics("subscription.disconnected", {"provider": provider})
from backend.apps.service.client import sync as _sync
from backend.apps.settings.settings import load_settings
_sync(load_settings().model_dump())
return {"ok": True}
return {"ok": False, "error": "Connection not found"}
except Exception as e:
+27 -7
View File
@@ -761,6 +761,23 @@ async def execute_browser_tool(
return result
def _extract_domain(url: str) -> str | None:
"""Extract the apex domain from a URL (acme-corp.notion.so → notion.so).
Returns None for non-http URLs."""
try:
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.hostname or ""
if not host or host in ("localhost", "127.0.0.1", ""):
return None
parts = host.split(".")
if len(parts) >= 2:
return ".".join(parts[-2:])
return host
except Exception:
return None
def _format_tool_result(result: dict, tool_name: str) -> list[dict]:
"""Convert a browser command result dict into Anthropic API content blocks."""
if "error" in result:
@@ -1238,6 +1255,14 @@ async def run_browser_agent(
recent_tool_calls = recent_tool_calls[-_LOOP_WINDOW_SIZE * 2:]
content_blocks = _format_tool_result(result, tu.name)
try:
url = result.get("url") or (tu.input or {}).get("url")
if url:
domain = _extract_domain(str(url))
if domain and domain not in session.browser_domains:
session.browser_domains.append(domain)
except Exception:
pass
if is_loop:
loop_trigger_count += 1
repeat_count = sum(1 for c in recent_tool_calls if c == call_key)
@@ -1321,7 +1346,7 @@ async def run_browser_agent(
)
session.status = "completed"
agent_manager._fire_session_completed(session)
agent_manager._sync_session_close(session)
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": "completed",
@@ -1404,12 +1429,7 @@ async def run_browser_agents(
Each task dict has: { browser_id (optional), task, url (optional) }
Returns a list of result dicts, one per task.
"""
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {
"feature": "browser_agent.launched",
"task_count": len(tasks),
"model": model,
}, dashboard_id=dashboard_id)
pass # Browser agent launch captured via session dump
pre_selected = set(pre_selected_browser_ids or [])
-398
View File
@@ -1,398 +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
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/browser/command"
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"],
},
},
]
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 send_notification(method, params=None):
msg = {"jsonrpc": "2.0", "method": method}
if params is not None:
msg["params"] = params
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()
-360
View File
@@ -1,360 +0,0 @@
"""Standalone MCP client manager for agent sessions.
Replaces claude_agent_sdk's internal MCP server management.
One MCPClientManager instance per agent session — manages connections
to stdio/http/sse MCP servers, discovers tools, and routes tool calls.
"""
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from typing import Any
from backend.apps.agents.providers.base import ToolSchema
logger = logging.getLogger(__name__)
@dataclass
class MCPConnection:
"""A live connection to an MCP server."""
server_name: str
session: Any # mcp.ClientSession
tools: list[ToolSchema] = field(default_factory=list)
class MCPClientManager:
"""Manages connections to MCP servers for a single agent session."""
def __init__(self):
self._connections: dict[str, MCPConnection] = {}
self._exit_stack = AsyncExitStack()
self._started = False
async def __aenter__(self):
await self._exit_stack.__aenter__()
self._started = True
return self
async def __aexit__(self, *exc):
await self.disconnect_all()
try:
await self._exit_stack.__aexit__(*exc)
except (BaseExceptionGroup, ExceptionGroup, Exception) as e:
# MCP subprocess cleanup errors are non-fatal
logger.warning(f"MCP cleanup error (non-fatal): {e}")
self._started = False
async def connect(self, server_name: str, config: dict, timeout: float = 30.0) -> list[ToolSchema]:
"""Connect to an MCP server and return its available tools.
The tools are returned with names prefixed as mcp__<server_name>__<tool_name>.
"""
transport = config.get("type", "stdio")
try:
if transport == "stdio":
coro = self._connect_stdio(server_name, config)
elif transport == "sse":
coro = self._connect_sse(server_name, config)
elif transport == "http":
coro = self._connect_http(server_name, config)
else:
logger.warning(f"Unsupported MCP transport: {transport} for {server_name}")
return []
conn = await asyncio.wait_for(coro, timeout=timeout)
self._connections[server_name] = conn
logger.info(f"MCP connected: {server_name} ({len(conn.tools)} tools)")
return conn.tools
except asyncio.TimeoutError:
logger.warning(f"MCP server {server_name} connection timed out after {timeout}s")
return []
except Exception as e:
logger.warning(f"Failed to connect MCP server {server_name}: {e}")
return []
async def _connect_stdio(self, server_name: str, config: dict) -> MCPConnection:
"""Connect to a stdio MCP server (spawns a subprocess)."""
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters
command = config.get("command", "")
args = config.get("args", [])
env = config.get("env")
params = StdioServerParameters(
command=command,
args=args,
env=env,
)
transport = await self._exit_stack.enter_async_context(
stdio_client(params)
)
read_stream, write_stream = transport
session = await self._exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
await session.initialize()
result = await session.list_tools()
tools = [
ToolSchema(
name=f"mcp__{server_name}__{t.name}",
description=t.description or "",
input_schema=t.inputSchema if hasattr(t, "inputSchema") else (t.input_schema if hasattr(t, "input_schema") else {}),
)
for t in result.tools
]
return MCPConnection(server_name=server_name, session=session, tools=tools)
async def _connect_sse(self, server_name: str, config: dict) -> MCPConnection:
"""Connect to an SSE MCP server."""
from mcp import ClientSession
from mcp.client.sse import sse_client
url = config.get("url", "")
headers = config.get("headers")
transport = await self._exit_stack.enter_async_context(
sse_client(url=url, headers=headers, timeout=30, sse_read_timeout=300)
)
read_stream, write_stream = transport
session = await self._exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
await session.initialize()
result = await session.list_tools()
tools = [
ToolSchema(
name=f"mcp__{server_name}__{t.name}",
description=t.description or "",
input_schema=t.inputSchema if hasattr(t, "inputSchema") else (t.input_schema if hasattr(t, "input_schema") else {}),
)
for t in result.tools
]
return MCPConnection(server_name=server_name, session=session, tools=tools)
async def _connect_http(self, server_name: str, config: dict) -> MCPConnection:
"""Connect to a Streamable HTTP MCP server.
Falls back to SSE if streamable HTTP fails.
"""
url = config.get("url", "")
headers = config.get("headers")
# Try streamable HTTP first, fall back to SSE
try:
return await self._connect_http_streamable(server_name, url, headers)
except Exception as e:
logger.info(f"Streamable HTTP failed for {server_name}, trying SSE: {e}")
return await self._connect_sse(server_name, config)
async def _connect_http_streamable(
self, server_name: str, url: str, headers: dict | None,
) -> MCPConnection:
"""Connect via Streamable HTTP (JSON-RPC POST)."""
import httpx
from mcp import ClientSession
# Use httpx for streamable HTTP — keep client alive in the exit stack
client = await self._exit_stack.enter_async_context(
httpx.AsyncClient(timeout=30.0)
)
h = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
**(headers or {}),
}
# Initialize
init_resp = await client.post(url, headers=h, json={
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "self-swarm", "version": "0.1.0"},
},
})
if init_resp.status_code not in (200, 201):
raise ConnectionError(f"MCP initialize failed: {init_resp.status_code}")
session_id = init_resp.headers.get("mcp-session-id", "")
if session_id:
h["mcp-session-id"] = session_id
# Notify initialized
await client.post(url, headers=h, json={
"jsonrpc": "2.0", "method": "notifications/initialized",
})
# List tools
list_resp = await client.post(url, headers=h, json={
"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {},
})
if list_resp.status_code not in (200, 201):
raise ConnectionError(f"MCP tools/list failed: {list_resp.status_code}")
ct = list_resp.headers.get("content-type", "")
if "text/event-stream" in ct:
data = self._parse_sse_json(list_resp.text)
else:
data = list_resp.json()
if not data:
raise ConnectionError("Empty response from MCP server")
tools_list = data.get("result", {}).get("tools", [])
tools = [
ToolSchema(
name=f"mcp__{server_name}__{t.get('name', '')}",
description=t.get("description", ""),
input_schema=t.get("inputSchema", t.get("input_schema", {})),
)
for t in tools_list
]
# Store the HTTP client info for call_tool
conn = MCPConnection(server_name=server_name, session=None, tools=tools)
conn._http_client = client # type: ignore[attr-defined]
conn._http_url = url # type: ignore[attr-defined]
conn._http_headers = h # type: ignore[attr-defined]
conn._next_id = 3 # type: ignore[attr-defined]
return conn
@staticmethod
def _parse_sse_json(text: str) -> dict | None:
"""Extract JSON from an SSE response body."""
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("data:"):
payload = stripped[len("data:"):].strip()
if payload:
try:
return json.loads(payload)
except json.JSONDecodeError:
continue
try:
return json.loads(text)
except json.JSONDecodeError:
return None
async def call_tool(
self, server_name: str, tool_name: str, arguments: dict,
) -> list[dict]:
"""Call a tool on a specific MCP server.
Args:
server_name: The MCP server name (e.g. "google-workspace")
tool_name: The bare tool name (without mcp__prefix)
arguments: Tool input arguments
Returns:
List of content blocks: [{"type": "text", "text": "..."}]
"""
conn = self._connections.get(server_name)
if not conn:
return [{"type": "text", "text": f"MCP server {server_name} not connected"}]
try:
if conn.session is not None:
# stdio or SSE — use MCP ClientSession
result = await conn.session.call_tool(tool_name, arguments)
return self._format_mcp_result(result)
elif hasattr(conn, "_http_client"):
# Streamable HTTP — use JSON-RPC
return await self._call_tool_http(conn, tool_name, arguments)
else:
return [{"type": "text", "text": f"No session for MCP server {server_name}"}]
except Exception as e:
logger.warning(f"MCP tool call failed: {server_name}/{tool_name}: {e}")
return [{"type": "text", "text": f"Error calling {tool_name}: {e}"}]
async def _call_tool_http(
self, conn: MCPConnection, tool_name: str, arguments: dict,
) -> list[dict]:
"""Call a tool via Streamable HTTP."""
client = conn._http_client # type: ignore[attr-defined]
url = conn._http_url # type: ignore[attr-defined]
headers = conn._http_headers # type: ignore[attr-defined]
req_id = conn._next_id # type: ignore[attr-defined]
conn._next_id = req_id + 1 # type: ignore[attr-defined]
resp = await client.post(url, headers=headers, json={
"jsonrpc": "2.0",
"id": req_id,
"method": "tools/call",
"params": {"name": tool_name, "arguments": arguments},
}, timeout=300.0)
ct = resp.headers.get("content-type", "")
if "text/event-stream" in ct:
data = self._parse_sse_json(resp.text)
else:
data = resp.json()
if not data:
return [{"type": "text", "text": "Empty response from MCP server"}]
if "error" in data:
return [{"type": "text", "text": f"MCP error: {data['error']}"}]
result = data.get("result", {})
content = result.get("content", [])
return content if content else [{"type": "text", "text": json.dumps(result)}]
@staticmethod
def _format_mcp_result(result: Any) -> list[dict]:
"""Convert an MCP CallToolResult to content blocks."""
if hasattr(result, "content"):
blocks = []
for item in result.content:
if hasattr(item, "text"):
blocks.append({"type": "text", "text": item.text})
elif hasattr(item, "data"):
blocks.append({
"type": "image",
"source": {
"type": "base64",
"media_type": getattr(item, "mimeType", "image/png"),
"data": item.data,
},
})
else:
blocks.append({"type": "text", "text": str(item)})
return blocks if blocks else [{"type": "text", "text": "Done."}]
return [{"type": "text", "text": str(result)}]
def get_all_tool_schemas(self) -> list[ToolSchema]:
"""Return tool schemas from all connected MCP servers."""
schemas = []
for conn in self._connections.values():
schemas.extend(conn.tools)
return schemas
def parse_mcp_tool_name(self, full_name: str) -> tuple[str, str] | None:
"""Parse mcp__<server>__<tool> into (server_name, tool_name).
Returns None if the name doesn't match the MCP naming convention.
"""
import re
m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", full_name)
if m:
return m.group(1), m.group(2)
return None
async def disconnect_all(self):
"""Disconnect all MCP servers. Called on session end."""
self._connections.clear()
# The AsyncExitStack handles actual cleanup of transports/sessions
+2 -2
View File
@@ -25,7 +25,7 @@ import re
from typing import Any
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.settings.credentials import get_anthropic_client
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.settings.settings import load_settings
from backend.apps.tools_lib.tools_lib import _load_all as load_all_tools
@@ -243,7 +243,7 @@ def _decorate(llm_suggestion: dict, available: list[CuratedEntry]) -> dict | Non
async def _call_classifier(settings, prompt: str, available: list[CuratedEntry]) -> dict:
"""One aux-model call, returns validated JSON {is_vague, suggestions}."""
aux_model, _base = await resolve_aux_model(settings, preferred_tier="haiku")
client = get_anthropic_client(settings)
client = get_anthropic_client_for_model(settings, aux_model)
catalog_lines = "\n".join(
f"- id: {e['id']} | {e['title']}{e['description']}"
+44
View File
@@ -56,6 +56,10 @@ class Message(BaseModel):
# number frozen on the persisted bubble matches what the user saw
# rising during the stream. Pure display, not billing.
tokens: Optional[int] = None
# tool_count drives the "3 tools used" segment on the thinking pill.
tool_count: Optional[int] = None
# combined input + output + children tokens for the turn (overloaded name).
input_tokens: Optional[int] = None
class MessageBranch(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
@@ -81,10 +85,42 @@ class AgentSession(BaseModel):
allowed_tools: list[str] = Field(default_factory=list)
max_turns: Optional[int] = None
cwd: Optional[str] = None
# Origin remote and branch resolved at session start. Persisted so a
# resumed session reattaches to the same project even if the user has
# since `cd`'d elsewhere; also surfaced in the session list UI so the
# user can tell two sessions apart by repo.
repo_url: Optional[str] = None
branch: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.now)
closed_at: Optional[datetime] = None
# Wall-clock of the first stream event from the agent SDK. Set once
# at the start of the first turn so resumed sessions can show "first
# response was at HH:MM" in the session list without rescanning the
# message log.
first_response_at: Optional[datetime] = None
# Operational log of HITL approval decisions, one entry per request:
# {tool, behavior, decision_ms}. Persisted alongside the session so a
# reload restores the full approval timeline (which calls were
# approved, denied, and how long each took).
approval_decisions: list[dict] = Field(default_factory=list)
cost_usd: float = 0.0
tokens: dict[str, int] = Field(default_factory=lambda: {"input": 0, "output": 0})
# Total wall-clock ms the agent spent in `status="running"`. Accumulates
# across turns; persists across resume. Used by the session-close
# report so we can report "agent active time" alongside total session
# duration. Off by default so legacy sessions deserialize cleanly.
agent_active_ms: int = 0
# Accumulated wall-clock ms spent on each model. Updated when the
# active model changes (model switch) or on close. Surfaced in the
# session header so the user can see "Sonnet: 45s · Haiku: 12s"
# without scanning turns by hand.
time_per_model: dict[str, int] = Field(default_factory=dict)
# Per-tool latency rollup: { tool_name: { count, total_ms, max_ms } }.
# Populated as tools complete. Surfaced in the session "tools used"
# row so the user can see which tool calls were slow without
# opening every turn.
tool_latencies: dict[str, dict] = Field(default_factory=dict)
browser_domains: list[str] = Field(default_factory=list)
messages: list[Message] = Field(default_factory=list)
pending_approvals: list[ApprovalRequest] = Field(default_factory=list)
branches: dict[str, "MessageBranch"] = Field(default_factory=lambda: {"main": MessageBranch(id="main")})
@@ -94,6 +130,14 @@ class AgentSession(BaseModel):
browser_id: Optional[str] = None
parent_session_id: Optional[str] = None
needs_fork: bool = False
# Stronger than needs_fork: when True, the next turn drops `resume=`
# entirely and replays history into a brand-new sdk_session_id. This
# is the only way to make the bundled CLI re-read mcp_servers from
# the rebuilt options dict — `fork_session=True` only forks the
# conversation tree, it inherits the original transport's MCP server
# set. Set after MCPActivate when prior turns exist so the newly
# activated server's tools actually reach the model.
needs_fresh_session: bool = False
# Set when MCPActivate (or analogous activation) wants the agent to
# auto-continue immediately after the current turn ends — without
# requiring the user to type another message. The agent loop reads
-290
View File
@@ -1,290 +0,0 @@
"""Anthropic provider adapter using the native Anthropic SDK."""
from __future__ import annotations
import json
import logging
from typing import Any, AsyncIterator
import anthropic
from backend.apps.agents.providers.base import (
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
StreamEvent, ToolCall, ToolSchema,
)
logger = logging.getLogger(__name__)
MODEL_MAP = {
"sonnet": "claude-sonnet-4-6",
"opus": "claude-opus-4-6",
"haiku": "claude-haiku-4-5",
}
class AnthropicProvider(BaseProvider):
"""Provider adapter for Anthropic's Messages API."""
def __init__(
self,
api_key: str | None = None,
auth_token: str | None = None,
base_url: str | None = None,
):
kwargs: dict[str, Any] = {}
if auth_token:
kwargs["auth_token"] = auth_token
elif api_key:
kwargs["api_key"] = api_key
if base_url:
kwargs["base_url"] = base_url
self.client = anthropic.AsyncAnthropic(**kwargs)
def get_model_id(self, short_name: str) -> str:
return MODEL_MAP.get(short_name, short_name)
def clean_tool_schema(self, schema: ToolSchema) -> dict:
return {
"name": schema.name,
"description": schema.description,
"input_schema": schema.input_schema,
}
def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict:
return {
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content,
}
def format_user_message(self, content: Any) -> ProviderMessage:
return ProviderMessage(role="user", content=content)
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
blocks = []
for block in response.content:
if block.type == "text":
blocks.append({"type": "text", "text": block.text})
elif block.type == "tool_use" and block.tool_call:
blocks.append({
"type": "tool_use",
"id": block.tool_call.id,
"name": block.tool_call.name,
"input": block.tool_call.input,
})
return ProviderMessage(role="assistant", content=blocks)
def _build_messages(self, messages: list[ProviderMessage]) -> list[dict]:
"""Convert ProviderMessages to Anthropic API format."""
result = []
for msg in messages:
if msg.role == "tool_result":
# Tool results: content is a list of tool_result dicts
if isinstance(msg.content, list):
result.append({"role": "user", "content": msg.content})
else:
result.append({"role": "user", "content": [msg.content]})
elif msg.role == "assistant":
result.append({"role": "assistant", "content": msg.content})
elif msg.role == "user":
result.append({"role": "user", "content": msg.content})
return result
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(messages),
}
if system:
kwargs["system"] = system
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
resp = await self.client.messages.create(**kwargs)
content = []
for block in resp.content:
if block.type == "text":
content.append(ContentBlock(type="text", text=block.text))
elif block.type == "tool_use":
content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=block.id,
name=block.name,
input=block.input,
),
))
return ModelResponse(
content=content,
stop_reason="tool_use" if resp.stop_reason == "tool_use" else "end_turn",
usage={
"input_tokens": resp.usage.input_tokens,
"output_tokens": resp.usage.output_tokens,
},
)
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(messages),
}
if system:
kwargs["system"] = system
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
# Use create() with stream=True for raw SSE events
kwargs["stream"] = True
raw_stream = await self.client.messages.create(**kwargs)
current_block_type: dict[int, str] = {}
current_tool_name: dict[int, str] = {}
current_tool_id: dict[int, str] = {}
current_text: dict[int, str] = {}
current_json: dict[int, str] = {}
async for event in raw_stream:
event_type = getattr(event, "type", "")
if event_type == "content_block_start":
index = event.index
block = event.content_block
block_type = block.type
current_block_type[index] = block_type
if block_type == "text":
current_text[index] = ""
yield StreamEvent(
type="content_block_start",
index=index,
block_type="text",
)
elif block_type == "tool_use":
current_tool_name[index] = block.name
current_tool_id[index] = block.id
current_json[index] = ""
yield StreamEvent(
type="content_block_start",
index=index,
block_type="tool_use",
tool_name=block.name,
tool_id=block.id,
)
elif block_type == "thinking":
# Extended-thinking content block. We track the
# accumulated text in current_text just like a normal
# text block, but tag it as "thinking" so the agent
# loop emits a distinct WS event the frontend can
# render in the ThinkingBubble pill.
current_text[index] = ""
yield StreamEvent(
type="content_block_start",
index=index,
block_type="thinking",
)
elif event_type == "content_block_delta":
index = event.index
delta = event.delta
delta_type = delta.type
if delta_type == "text_delta":
current_text.setdefault(index, "")
current_text[index] += delta.text
yield StreamEvent(
type="content_block_delta",
index=index,
delta_type="text_delta",
text=delta.text,
)
elif delta_type == "input_json_delta":
current_json.setdefault(index, "")
current_json[index] += delta.partial_json
yield StreamEvent(
type="content_block_delta",
index=index,
delta_type="input_json_delta",
text=delta.partial_json,
)
elif delta_type == "thinking_delta":
# Extended-thinking text streamed as it's produced.
# Forward as a thinking_delta so the agent loop can
# ship it to the frontend without conflating with
# the assistant text stream.
text_chunk = getattr(delta, "thinking", "") or ""
current_text.setdefault(index, "")
current_text[index] += text_chunk
yield StreamEvent(
type="content_block_delta",
index=index,
delta_type="thinking_delta",
text=text_chunk,
)
# Note: signature_delta (the cryptographic signature on
# thinking blocks) is intentionally ignored — we don't
# display it and it isn't needed for replay since we
# never re-send thinking blocks to the model.
elif event_type == "content_block_stop":
yield StreamEvent(type="content_block_stop", index=event.index)
elif event_type == "message_delta":
# Extract output token usage from the final delta
usage_data = {}
delta_usage = getattr(event, "usage", None)
if delta_usage:
output_tokens = getattr(delta_usage, "output_tokens", 0)
if output_tokens:
usage_data["output_tokens"] = output_tokens
if usage_data:
yield StreamEvent(type="usage", usage=usage_data)
elif event_type == "message_start":
# Extract input token usage from the message start
msg = getattr(event, "message", None)
if msg:
msg_usage = getattr(msg, "usage", None)
if msg_usage:
usage_data = {}
input_tokens = getattr(msg_usage, "input_tokens", 0)
output_tokens = getattr(msg_usage, "output_tokens", 0)
if input_tokens:
usage_data["input_tokens"] = input_tokens
if output_tokens:
usage_data["output_tokens"] = output_tokens
if usage_data:
yield StreamEvent(type="usage", usage=usage_data)
yield StreamEvent(type="message_stop")
async def stream_and_collect(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> tuple[AsyncIterator[StreamEvent], ModelResponse]:
"""Helper: stream events and also return the full collected response.
Not used directly — the AgentLoop handles collection.
"""
raise NotImplementedError("Use stream_message() directly; AgentLoop collects.")
-135
View File
@@ -1,135 +0,0 @@
"""Provider-agnostic base classes for multi-model support.
All provider adapters (Anthropic, OpenAI, Gemini, OpenAI-compatible)
implement BaseProvider, translating their native APIs into these
common data structures.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, AsyncIterator
@dataclass
class ToolSchema:
"""Provider-agnostic tool definition."""
name: str
description: str
input_schema: dict[str, Any]
@dataclass
class ToolCall:
"""A tool invocation requested by the model."""
id: str
name: str
input: dict[str, Any]
@dataclass
class ContentBlock:
"""A block of content from the model response."""
type: str # "text" | "tool_use" | "thinking"
text: str = ""
tool_call: ToolCall | None = None
@dataclass
class ModelResponse:
"""Complete (non-streaming) response from a provider."""
content: list[ContentBlock]
stop_reason: str # "end_turn" | "tool_use" | "max_tokens"
usage: dict[str, int] = field(default_factory=dict)
@dataclass
class StreamEvent:
"""A single streaming event, normalized across providers.
The event types match what the frontend already expects via WebSocket:
content_block_start, content_block_delta, content_block_stop, message_stop.
"""
type: str
index: int = 0
block_type: str = "" # "text" | "tool_use" | "thinking"
delta_type: str = "" # "text_delta" | "input_json_delta" | "thinking_delta"
text: str = ""
tool_name: str = ""
tool_id: str = ""
usage: dict[str, int] = field(default_factory=dict)
@dataclass
class ProviderMessage:
"""Provider-agnostic message for conversation history.
Each provider adapter converts these to/from its native format.
"""
role: str # "user" | "assistant" | "tool_result"
content: Any # str, list[dict], or provider-specific content
class BaseProvider(ABC):
"""Abstract base for LLM provider adapters."""
@abstractmethod
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
"""Stream a model response, yielding normalized StreamEvents."""
...
@abstractmethod
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
"""Non-streaming message creation."""
...
@abstractmethod
def format_tool_result(
self,
tool_use_id: str,
content: list[dict],
) -> dict:
"""Format a tool result in this provider's expected message format."""
...
@abstractmethod
def format_user_message(self, content: Any) -> ProviderMessage:
"""Wrap user content (str or multimodal blocks) into a ProviderMessage."""
...
@abstractmethod
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
"""Convert a ModelResponse into a ProviderMessage for conversation history."""
...
@abstractmethod
def get_model_id(self, short_name: str) -> str:
"""Resolve a short model name to the full API model ID."""
...
def clean_tool_schema(self, schema: ToolSchema) -> dict:
"""Convert a ToolSchema to the provider's native tool format.
Default: Anthropic-style format. Override for providers that need
different formats or schema cleaning (e.g. Gemini).
"""
return {
"name": schema.name,
"description": schema.description,
"input_schema": schema.input_schema,
}
@@ -1,330 +0,0 @@
"""OpenAI-compatible provider adapter.
Works with ANY endpoint that speaks the OpenAI Chat Completions API:
OpenAI, OpenRouter, Together, Groq, Fireworks, Mistral, Ollama, vLLM, etc.
"""
from __future__ import annotations
import json
import logging
from typing import Any, AsyncIterator
from uuid import uuid4
from openai import AsyncOpenAI
from backend.apps.agents.providers.base import (
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
StreamEvent, ToolCall, ToolSchema,
)
logger = logging.getLogger(__name__)
class OpenAICompatProvider(BaseProvider):
"""Provider adapter for any OpenAI-compatible API endpoint."""
def __init__(
self,
api_key: str = "",
base_url: str | None = None,
):
kwargs: dict[str, Any] = {}
# Always set api_key — use "none" as placeholder if empty (some endpoints don't need real keys)
kwargs["api_key"] = api_key if api_key else "none"
if base_url:
kwargs["base_url"] = base_url
self.client = AsyncOpenAI(**kwargs)
def get_model_id(self, short_name: str) -> str:
# Pass through — user selects exact model ID
return short_name
def clean_tool_schema(self, schema: ToolSchema) -> dict:
"""Convert to OpenAI function calling format."""
return {
"type": "function",
"function": {
"name": schema.name,
"description": schema.description,
"parameters": schema.input_schema,
},
}
def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict:
"""Format tool result as OpenAI expects."""
# OpenAI wants a single string for tool results
text_parts = []
for block in content:
if block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif block.get("type") == "image":
text_parts.append("[image]")
else:
text_parts.append(json.dumps(block))
return {
"role": "tool",
"tool_call_id": tool_use_id,
"content": "\n".join(text_parts) if text_parts else "Done.",
}
def format_user_message(self, content: Any) -> ProviderMessage:
"""Convert user content to OpenAI format."""
if isinstance(content, str):
return ProviderMessage(role="user", content=content)
# Multimodal content (text + images)
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict):
if block.get("type") == "text":
parts.append({"type": "text", "text": block["text"]})
elif block.get("type") == "image":
source = block.get("source", {})
media_type = source.get("media_type", "image/png")
data = source.get("data", "")
parts.append({
"type": "image_url",
"image_url": {"url": f"data:{media_type};base64,{data}"},
})
elif isinstance(block, str):
parts.append({"type": "text", "text": block})
return ProviderMessage(role="user", content=parts)
return ProviderMessage(role="user", content=str(content))
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
"""Convert ModelResponse to OpenAI assistant message format."""
text_parts = []
tool_calls = []
for block in response.content:
if block.type == "text":
text_parts.append(block.text)
elif block.type == "tool_use" and block.tool_call:
tool_calls.append({
"id": block.tool_call.id,
"type": "function",
"function": {
"name": block.tool_call.name,
"arguments": json.dumps(block.tool_call.input),
},
})
msg: dict[str, Any] = {"role": "assistant"}
if text_parts:
msg["content"] = "\n".join(text_parts)
else:
msg["content"] = None
if tool_calls:
msg["tool_calls"] = tool_calls
return ProviderMessage(role="assistant", content=msg)
def _build_messages(
self,
system: str | None,
messages: list[ProviderMessage],
) -> list[dict]:
"""Convert ProviderMessages to OpenAI API format."""
result = []
if system:
result.append({"role": "system", "content": system})
for msg in messages:
if msg.role == "assistant":
# Assistant messages are already in OpenAI format from format_assistant_message
if isinstance(msg.content, dict) and "role" in msg.content:
result.append(msg.content)
else:
# Raw content blocks from provider-agnostic format
text_parts = []
tool_calls = []
if isinstance(msg.content, list):
for block in msg.content:
if isinstance(block, dict):
if block.get("type") == "text":
text_parts.append(block["text"])
elif block.get("type") == "tool_use":
tool_calls.append({
"id": block.get("id", uuid4().hex),
"type": "function",
"function": {
"name": block.get("name", ""),
"arguments": json.dumps(block.get("input", {})),
},
})
api_msg: dict[str, Any] = {
"role": "assistant",
"content": "\n".join(text_parts) if text_parts else None,
}
if tool_calls:
api_msg["tool_calls"] = tool_calls
result.append(api_msg)
elif msg.role == "tool_result":
# Tool results: content is a list of tool result dicts
if isinstance(msg.content, list):
for tr in msg.content:
if isinstance(tr, dict) and "tool_call_id" in tr:
result.append(tr)
elif isinstance(msg.content, dict) and "tool_call_id" in msg.content:
result.append(msg.content)
elif msg.role == "user":
result.append({"role": "user", "content": msg.content})
return result
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(system, messages),
}
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
resp = await self.client.chat.completions.create(**kwargs)
choice = resp.choices[0]
message = choice.message
content: list[ContentBlock] = []
if message.content:
content.append(ContentBlock(type="text", text=message.content))
if message.tool_calls:
for tc in message.tool_calls:
try:
args = json.loads(tc.function.arguments)
except json.JSONDecodeError:
args = {}
content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=tc.id,
name=tc.function.name,
input=args,
),
))
stop = "end_turn"
if choice.finish_reason == "tool_calls":
stop = "tool_use"
elif message.tool_calls:
stop = "tool_use"
usage_dict = {}
if resp.usage:
usage_dict = {
"input_tokens": resp.usage.prompt_tokens,
"output_tokens": resp.usage.completion_tokens,
}
return ModelResponse(content=content, stop_reason=stop, usage=usage_dict)
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(system, messages),
"stream": True,
"stream_options": {"include_usage": True},
}
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
stream = await self.client.chat.completions.create(**kwargs)
# Track streaming state to emit normalized events
text_started = False
text_index = 0
tool_indices: dict[int, dict] = {} # openai tool_call index -> {name, id, json_buf}
next_block_index = 0
async for chunk in stream:
if not chunk.choices:
# Usage-only chunk at the end
if chunk.usage:
yield StreamEvent(type="usage", usage={
"input_tokens": chunk.usage.prompt_tokens or 0,
"output_tokens": chunk.usage.completion_tokens or 0,
})
continue
delta = chunk.choices[0].delta
finish_reason = chunk.choices[0].finish_reason
# Text content
if delta.content is not None:
if not text_started:
text_started = True
text_index = next_block_index
next_block_index += 1
yield StreamEvent(
type="content_block_start",
index=text_index,
block_type="text",
)
yield StreamEvent(
type="content_block_delta",
index=text_index,
delta_type="text_delta",
text=delta.content,
)
# Tool calls
if delta.tool_calls:
for tc_delta in delta.tool_calls:
tc_idx = tc_delta.index
if tc_idx not in tool_indices:
# New tool call starting
if text_started:
yield StreamEvent(type="content_block_stop", index=text_index)
text_started = False
block_idx = next_block_index
next_block_index += 1
tool_indices[tc_idx] = {
"block_index": block_idx,
"id": tc_delta.id or uuid4().hex,
"name": tc_delta.function.name if tc_delta.function else "",
"json_buf": "",
}
yield StreamEvent(
type="content_block_start",
index=block_idx,
block_type="tool_use",
tool_name=tool_indices[tc_idx]["name"],
tool_id=tool_indices[tc_idx]["id"],
)
info = tool_indices[tc_idx]
if tc_delta.function and tc_delta.function.name:
info["name"] = tc_delta.function.name
if tc_delta.function and tc_delta.function.arguments:
info["json_buf"] += tc_delta.function.arguments
yield StreamEvent(
type="content_block_delta",
index=info["block_index"],
delta_type="input_json_delta",
text=tc_delta.function.arguments,
)
# Finish
if finish_reason is not None:
if text_started:
yield StreamEvent(type="content_block_stop", index=text_index)
for info in tool_indices.values():
yield StreamEvent(type="content_block_stop", index=info["block_index"])
yield StreamEvent(type="message_stop")
+158 -234
View File
@@ -1,14 +1,8 @@
"""Provider registry and model catalog.
NOTE: `create_provider`, `BaseProvider`, `AnthropicProvider`, `OpenAICompatProvider`,
and the native `AgentLoop` are currently unused. The live agent path is
`claude_agent_sdk` via `agent_manager._run_agent_loop`. Kept as a foundation
for a potential future native multi-provider loop.
Multi-model subscription support routes non-Anthropic models through 9Router's
`/v1/messages` endpoint by passing prefixed model IDs (e.g. `cx/gpt-5.4`,
`gc/gemini-2.5-pro`). 9Router's translator converts the Anthropic-format
request into the provider's native format transparently.
Live agent path goes through claude_agent_sdk via agent_manager._run_agent_loop.
Non-Anthropic models route through 9Router's /v1/messages endpoint with
prefixed ids (cx/gpt-5.4, gc/gemini-3-pro-preview).
"""
from __future__ import annotations
@@ -16,8 +10,6 @@ from __future__ import annotations
import logging
from typing import Any, TYPE_CHECKING
from backend.apps.agents.providers.base import BaseProvider
if TYPE_CHECKING:
from backend.apps.settings.models import AppSettings
@@ -52,13 +44,16 @@ logger = logging.getLogger(__name__)
# Note: `gpt-5.4` is NOT available on this path — it's API-key-only.
# The Codex subscription's flagship is gpt-5.3-codex.
# - gc/ (Gemini CLI subscription) uses gemini-3-pro-preview / 3-flash-preview
# (thinking-capable) and gemini-2.5-pro / 2.5-flash (stable).
# (Gemini 3 family — thinking-capable). 2.5 models removed.
# Gemini 3 thought signatures handled via skip_thought_signature_validator.
BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
# Anthropic: current-gen trio. Sonnet 4.6 (Feb 17 2026), Opus 4.6
# (Feb 5 2026), Haiku 4.5 (Oct 2025). All three are the current
# production flagships in their respective size tiers.
# Anthropic: Sonnet 4.6 (Feb 17 2026), Opus 4.6 (Feb 5 2026),
# Haiku 4.5 (Oct 2025). Opus 4.7 was briefly exposed but pulled —
# the Claude Code SDK currently elides plaintext thinking deltas
# for 4.7 (encrypted/redacted blocks only), which broke the
# "Thought for Ns" pill UX. Re-add once Anthropic ships the
# plaintext summarizer for 4.7.
"Anthropic": [
# Adaptive entries: route is chosen at call time based on
# settings.connection_mode (openswarm-pro → proxy; api_key → direct;
@@ -81,7 +76,7 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
"model_id": "claude-opus-4-6", "router_model_id": "cc/claude-opus-4-6", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "haiku-cc", "label": "Claude Haiku 4.5", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "sonnet-api", "label": "Claude Sonnet 4.6 (API key)", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "claude-sonnet-4-6", "api": "anthropic", "reasoning": True, "route": "api"},
{"value": "opus-api", "label": "Claude Opus 4.6 (API key)", "context_window": 1_000_000,
@@ -91,20 +86,42 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
],
"OpenAI": [
# GPT-5.5 — newest ChatGPT flagship (May 2026). Available via
# the Codex subscription path on 9Router 0.4.x catalogs; on
# 0.3.60 (our pin) the cx/ catalog stops at gpt-5.4, so the
# subscription-routed entry will 404 until we bump. The
# API-key entry below works today against api.openai.com.
{"value": "gpt-5.5", "label": "GPT-5.5",
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.5",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.4", "label": "GPT-5.4",
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.4",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.4-mini", "label": "GPT-5.4 Mini",
"context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini",
"api": "codex", "subscription_only": True, "reasoning": True},
# GPT-5.3 Codex variants. The bare `gpt-5.3-codex` adapts reasoning
# effort from session.thinking_level. The -high / -xhigh suffixes
# are distinct codex tunes from OpenAI optimized for longer-horizon
# coding (xhigh = max-quality, slowest). Both are surfaced for users
# who want to pin effort independently of the global thinking knob.
{"value": "gpt-5.3-codex", "label": "GPT-5.3 Codex",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.3-codex-high", "label": "GPT-5.3 Codex High",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex-high",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.3-codex-xhigh", "label": "GPT-5.3 Codex Extra High",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex-xhigh",
"api": "codex", "subscription_only": True, "reasoning": True},
# Pinned-API-key entries: bypass 9Router and call api.openai.com
# directly with openai_api_key. Model ids match what OpenAI's API
# accepts (no cx/ prefix). Surfaced when openai_api_key is set —
# gives a metered alternative to the ChatGPT-Plus subscription
# route. Same -api suffix convention as the Anthropic mirrors.
{"value": "gpt-5.5-api", "label": "GPT-5.5 (API key)",
"context_window": 1_000_000, "router_model_id": "gpt-5.5", "model_id": "gpt-5.5",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.4-api", "label": "GPT-5.4 (API key)",
"context_window": 1_000_000, "router_model_id": "gpt-5.4", "model_id": "gpt-5.4",
"api": "openai", "reasoning": True, "route": "api"},
@@ -114,6 +131,12 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
{"value": "gpt-5.3-codex-api", "label": "GPT-5.3 Codex (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex", "model_id": "gpt-5.3-codex",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.3-codex-high-api", "label": "GPT-5.3 Codex High (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex-high", "model_id": "gpt-5.3-codex-high",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.3-codex-xhigh-api", "label": "GPT-5.3 Codex Extra High (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex-xhigh", "model_id": "gpt-5.3-codex-xhigh",
"api": "openai", "reasoning": True, "route": "api"},
],
# Google: Gemini via Gemini CLI subscription. Both 3.x (thinking-
# capable) and 2.5 (stable) are offered. Gemini 3 models have
@@ -125,35 +148,39 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
# cost of the model not being able to build on prior reasoning
# across turns — but all tools work and thinking is visible.
"Google": [
# Gemini 3.1 Pro — newest flagship (Apr 2026), routes to
# `gc/gemini-3.1-pro-preview` for the subscription path. Same
# thoughtSignature caveat applies; resolve_model_id_for_sdk's
# Antigravity map handles the multi-step routing.
{"value": "gemini-3.1-pro", "label": "Gemini 3.1 Pro",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3.1-pro-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-3.1-flash-lite", "label": "Gemini 3.1 Flash Lite",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3.1-flash-lite-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-3-pro", "label": "Gemini 3 Pro",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3-pro-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-3-flash", "label": "Gemini 3 Flash",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3-flash-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-2.5-pro", "label": "Gemini 2.5 Pro",
"context_window": 1_000_000, "router_model_id": "gc/gemini-2.5-pro",
"api": "gemini-cli", "subscription_only": True},
{"value": "gemini-2.5-flash", "label": "Gemini 2.5 Flash",
"context_window": 1_000_000, "router_model_id": "gc/gemini-2.5-flash",
"api": "gemini-cli", "subscription_only": True},
# Pinned-API-key entries for Google AI Studio (api="gemini"). Bypass
# both 9Router (which routes via Gemini CLI/Antigravity OAuth) and
# any subscription path; call generativelanguage.googleapis.com
# directly with google_api_key. Free-tier quota is generous (~1K
# requests/day) and lives separately from the OAuth lanes.
{"value": "gemini-3.1-pro-api", "label": "Gemini 3.1 Pro (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3.1-pro-preview", "model_id": "gemini-3.1-pro-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-3.1-flash-lite-api", "label": "Gemini 3.1 Flash Lite (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3.1-flash-lite-preview", "model_id": "gemini-3.1-flash-lite-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-3-pro-api", "label": "Gemini 3 Pro (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3-pro-preview", "model_id": "gemini-3-pro-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-3-flash-api", "label": "Gemini 3 Flash (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3-flash-preview", "model_id": "gemini-3-flash-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-2.5-pro-api", "label": "Gemini 2.5 Pro (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-2.5-pro", "model_id": "gemini-2.5-pro",
"api": "gemini", "route": "api"},
{"value": "gemini-2.5-flash-api", "label": "Gemini 2.5 Flash (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-2.5-flash", "model_id": "gemini-2.5-flash",
"api": "gemini", "route": "api"},
],
}
@@ -189,9 +216,16 @@ def thinking_params_for(api: str, level: str, model_id: str = "") -> dict | None
return {"thinking": {"type": "disabled"}}
if api == "codex":
return {"reasoning": {"effort": "none"}}
# Gemini: lowest available level
# Gemini: thinkingBudget=0 truly disables reasoning (no
# thoughtSignature emitted). Critical for multi-step tool turns
# — without this Gemini 2.5/3.x still emits signatures even at
# the lowest "level," which then break the next request with
# "Thought signature is not valid" 400 because the SDK has no
# way to round-trip them. The translator at 9Router 0.3.60
# explicitly checks `thinkingBudget == 0` to skip emitting
# thinking config, which is what we want.
if api == "gemini-cli":
return {"thinkingConfig": {"thinkingLevel": "LOW"}}
return {"thinkingConfig": {"thinkingBudget": 0}}
return None
# Claude 4.6 models use adaptive thinking (no manual budget). For older
@@ -291,18 +325,39 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
return entry.get("model_id", short_name)
# Gemini: prefer lanes with higher quota in order —
# 1. AI Studio apikey (free 1K/day, separate from any OAuth limit)
# 2. Antigravity OAuth (preview, 5-10× the Gemini CLI free tier)
# 3. Gemini CLI OAuth (free tier, ~5 RPM — last resort)
# 2. Antigravity OAuth (preview, 5-10× the Gemini CLI free tier).
# CRITICAL: Antigravity's wrapper around Google's API doesn't
# enforce the strict thoughtSignature continuity check that
# breaks multi-step tool turns through Gemini CLI. Without
# this lane, agent turns that combine thinking + tool use get
# "Thought signature is not valid" 400s on every follow-up
# request because the claude_agent_sdk has no hook to round-
# trip Gemini-specific signatures.
# 3. Gemini CLI OAuth (free tier, ~5 RPM — last resort, breaks
# on multi-step agent turns).
#
# Antigravity exposes differently-named Gemini models than Gemini CLI:
# gc/gemini-3-pro-preview → ag/gemini-3.1-pro-high
# gc/gemini-3-flash-preview → ag/gemini-3-flash
# gc/gemini-2.5-pro → (not available on Antigravity)
# gc/gemini-2.5-flash → (not available on Antigravity)
# When Antigravity lacks a model we fall back to gc/.
# gc/gemini-3-pro-preview → ag/gemini-3.1-pro-high (DISABLED —
# Google returns 404 not_found_error on this even with an
# active Antigravity connection; tier-side access gate.)
# gc/gemini-3-flash-preview → ag/gemini-3-flash (works)
# Models Antigravity doesn't have (or that 404) fall through to gc/.
_ANTIGRAVITY_MAP = {
"gemini-3-pro-preview": "gemini-3.1-pro-high",
# Disabled until 9Router exposes per-model availability so we
# can verify pro-high is actually serviceable before routing.
# "gemini-3-pro-preview": "gemini-3.1-pro-high",
"gemini-3-flash-preview": "gemini-3-flash",
# Gemini 3.1 family — same thoughtSignature problem as 3.0:
# gc/ enforces continuity, the Anthropic SDK has no hook to
# round-trip the signature, every multi-step tool turn 400s
# with "Thought signature is not valid". Routing through
# ag/ (Antigravity wrapper around Google's API) sidesteps
# the validator. AG is flagged deprecated upstream — we keep
# using it on 9router 0.3.60 (our pin) as the only working
# multi-step Gemini path; will revisit once the SDK gets
# signature passthrough or 9router lands a Gemini-CLI fix.
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
"gemini-3.1-flash-lite-preview": "gemini-3-flash",
}
if entry.get("api") == "gemini-cli":
rid = entry.get("router_model_id", "")
@@ -332,28 +387,76 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
return entry.get("router_model_id", entry.get("model_id", short_name))
async def resolve_aux_model(settings: AppSettings, preferred_tier: str = "haiku") -> tuple[str, str | None]:
async def resolve_aux_model(
settings: AppSettings,
preferred_tier: str = "haiku",
primary_api: str | None = None,
) -> tuple[str, str | None]:
"""Pick the cheapest/most-available model for auxiliary LLM calls.
Used by title generation, group meta, dashboard naming, outputs/view
builder, and browser_agent wherever we need a quick one-shot LLM call
that is NOT the user's selected chat model.
Args:
primary_api: when set, prefer this provider family ("anthropic" |
"codex" | "gemini-cli") over the default Anthropic-first cascade.
Lets a Codex-only or Gemini-only session keep aux work on the
same family it's already paying for, instead of leaking to
Anthropic Haiku just because the user *also* has Anthropic
connected. Caller passes `get_api_type(session.model)`.
Returns (model_id, base_url).
- If base_url is None, caller should use the default Anthropic client.
- If base_url is set, caller should route through 9Router.
- If base_url is set, caller should route through that endpoint.
Priority:
1. Anthropic API key set bare haiku/sonnet on real Anthropic API
2. 9Router + Claude subscription connected cc/<model>
3. 9Router + Codex connected cx/gpt-5.4-mini
4. 9Router + Gemini connected gc/gemini-2.5-flash
5. Nothing available raise ValueError
Priority (when primary_api is None, classic cascade):
1. OpenSwarm Pro mode bare haiku/sonnet via proxy
2. Anthropic API key set bare haiku/sonnet on real Anthropic API
3. 9Router + Claude subscription connected cc/<model>
4. 9Router + Codex connected cx/gpt-5.4-mini
5. 9Router + Gemini connected gc/gemini-3.1-flash-lite-preview
6. Nothing available raise ValueError
When primary_api is provided, the resolver tries that family first
(subscription path then API key) and only falls through to other
providers if the primary family isn't reachable.
"""
haiku_bare = "claude-haiku-4-5-20251001"
sonnet_bare = "claude-sonnet-4-20250514"
bare = haiku_bare if preferred_tier == "haiku" else sonnet_bare
# Probe 9Router once up front so the primary_api branch and the
# default cascade share the same connection set.
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
base_url = "http://localhost:20128"
connected: set[str] = set()
if _9r_running():
try:
connections = await _9r_providers()
connected = {c.get("provider") for c in connections if c.get("isActive")}
except Exception:
connected = set()
# Match primary_api first when supplied. Each branch checks both the
# subscription path (preferred — usually free) and the direct-API path
# before giving up on this family.
if primary_api == "codex":
if "codex" in connected:
return ("cx/gpt-5.4-mini", base_url)
if getattr(settings, "openai_api_key", None):
return ("gpt-5.4-mini", "https://api.openai.com/v1")
# primary is Codex but it's not reachable — fall through to default
elif primary_api == "gemini-cli" or primary_api == "gemini":
if "gemini-cli" in connected:
return ("gc/gemini-3.1-flash-lite-preview", base_url)
if getattr(settings, "google_api_key", None):
return ("gemini-3.1-flash-lite-preview", "https://generativelanguage.googleapis.com/v1beta")
# fall through to default
# primary_api == "anthropic" naturally falls into the Anthropic-first
# cascade below — no special branch needed.
# OpenSwarm Pro — route through our cloud proxy
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com"
@@ -363,25 +466,18 @@ async def resolve_aux_model(settings: AppSettings, preferred_tier: str = "haiku"
if getattr(settings, "anthropic_api_key", None):
return (bare, None)
# Fall back to 9Router
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
if not _9r_running():
raise ValueError(
"No AI provider configured for auxiliary LLM call. "
"Set an Anthropic API key or connect a subscription."
)
connections = await _9r_providers()
connected = {c.get("provider") for c in connections if c.get("isActive")}
base_url = "http://localhost:20128"
if "claude" in connected:
return (f"cc/{haiku_bare}" if preferred_tier == "haiku" else f"cc/{sonnet_bare}", base_url)
if "codex" in connected:
return ("cx/gpt-5.4-mini", base_url)
if "gemini-cli" in connected:
return ("gc/gemini-2.5-flash", base_url)
return ("gc/gemini-3.1-flash-lite-preview", base_url)
raise ValueError(
"No AI provider connected for auxiliary LLM call. "
@@ -389,183 +485,6 @@ async def resolve_aux_model(settings: AppSettings, preferred_tier: str = "haiku"
)
# ---------------------------------------------------------------------------
# Provider factory
# ---------------------------------------------------------------------------
def create_provider(
provider_name: str,
settings: AppSettings,
provider_config: dict | None = None,
) -> BaseProvider:
"""Create a provider adapter.
Routes based on the 'api' field in BUILTIN_MODELS:
- "anthropic" native Anthropic SDK
- "openai" native OpenAI SDK (direct API)
- "gemini" native Google GenAI SDK
- "openrouter" OpenAI-compat via openrouter.ai (Meta, Mistral, DeepSeek, Qwen, xAI, etc.)
Custom providers use OpenAI-compat with user's base_url.
"""
api_type = _get_api_type(provider_name)
# Check for 9Router first
if provider_name in ("9Router", "9router"):
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
if api_type == "anthropic":
from backend.apps.agents.providers.anthropic import AnthropicProvider
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return AnthropicProvider(
auth_token=getattr(settings, "openswarm_bearer_token", None),
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com",
)
# Priority: API key → 9Router subscription
if settings.anthropic_api_key:
return AnthropicProvider(api_key=settings.anthropic_api_key)
# No API key — try 9Router as fallback
if _is_9router_available():
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
provider = OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
# Override get_model_id to map our short names to 9Router's cc/ prefixed IDs
_original_get_model = provider.get_model_id
_9r_model_map = {
"sonnet": "cc/claude-sonnet-4-6",
"opus": "cc/claude-opus-4-6",
"haiku": "cc/claude-haiku-4-5-20251001",
}
provider.get_model_id = lambda name: _9r_model_map.get(name, f"cc/{name}" if not name.startswith("cc/") else name)
return provider
raise ValueError("Anthropic API key not configured. Set it in Settings, or connect 9Router.")
if api_type == "openai":
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
if settings.openai_api_key:
return OpenAICompatProvider(api_key=settings.openai_api_key, base_url="https://api.openai.com/v1")
# No API key — try 9Router as fallback
if _is_9router_available():
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
raise ValueError("OpenAI API key not configured. Set it in Settings, or connect 9Router.")
if api_type == "gemini":
from backend.apps.agents.providers.gemini import GeminiProvider
if settings.google_api_key:
return GeminiProvider(api_key=settings.google_api_key)
# No API key — try 9Router as fallback
if _is_9router_available():
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
raise ValueError("Google API key not configured. Set it in Settings, or connect 9Router.")
if api_type == "openrouter":
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
openrouter_key = getattr(settings, "openrouter_api_key", None)
if openrouter_key:
return OpenAICompatProvider(api_key=openrouter_key, base_url=OPENROUTER_BASE_URL)
# No OpenRouter key — try 9Router as fallback
if _is_9router_available():
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
raise ValueError(f"OpenRouter API key not configured for {provider_name}. Set it in Settings, or connect a subscription.")
# Custom provider — look up in settings.custom_providers
if provider_config:
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
api_key=provider_config.get("api_key", ""),
base_url=provider_config.get("base_url", ""),
)
for cp in getattr(settings, "custom_providers", []):
if cp.name == provider_name:
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
api_key=cp.api_key,
base_url=cp.base_url,
)
raise ValueError(f"Unknown provider: {provider_name}")
def _get_api_type(provider_name: str) -> str:
"""Get the API type for a provider from BUILTIN_MODELS.
Accepts both display names ('Anthropic') and lowercase API names ('anthropic').
"""
# Direct lookup first (display name like 'Anthropic', 'OpenAI', etc.)
models = BUILTIN_MODELS.get(provider_name, [])
if models:
return models[0].get("api", "openrouter")
# Lowercase API name mapping
_API_NAME_MAP = {
"anthropic": "anthropic",
"openai": "openai",
"gemini": "gemini",
"google": "gemini",
"openrouter": "openrouter",
}
if provider_name.lower() in _API_NAME_MAP:
return _API_NAME_MAP[provider_name.lower()]
# Case-insensitive lookup into BUILTIN_MODELS
lower = provider_name.lower()
for key, models in BUILTIN_MODELS.items():
if key.lower() == lower:
return models[0].get("api", "openrouter")
return "openrouter"
def _has_credentials(provider_name: str, settings: AppSettings) -> bool:
"""Check if a provider has credentials configured."""
api_type = _get_api_type(provider_name)
if api_type == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return bool(getattr(settings, "openswarm_bearer_token", None))
return bool(settings.anthropic_api_key)
if api_type == "openai":
return bool(settings.openai_api_key)
if api_type == "gemini":
return bool(getattr(settings, "google_api_key", None))
if api_type == "openrouter":
return bool(getattr(settings, "openrouter_api_key", None))
return False
def get_available_models(settings: AppSettings) -> dict[str, list[dict]]:
"""Return all models — always show everything, mark which have keys configured.
Like Cursor: show all models upfront, prompt for key when user tries to use one.
Returns: {"provider_name": [{"value": ..., "label": ..., "context_window": ..., "configured": bool}, ...]}
"""
result: dict[str, list[dict]] = {}
# Built-in providers — always show all
for provider_name, models in BUILTIN_MODELS.items():
configured = _has_credentials(provider_name, settings)
result[provider_name] = [
{**m, "configured": configured}
for m in models
]
# Custom providers
for cp in getattr(settings, "custom_providers", []):
if cp.models:
result[cp.name] = [
{
"value": m.get("value", m.get("id", "")),
"label": m.get("label", m.get("value", m.get("id", ""))),
"context_window": m.get("context_window", 128_000),
"configured": True,
}
for m in cp.models
]
return result
def get_context_window(provider: str, model: str, settings: AppSettings | None = None) -> int:
"""Look up context window for any model."""
# Check built-in models first
@@ -591,20 +510,25 @@ def get_context_window(provider: str, model: str, settings: AppSettings | None =
COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
# (provider, model): (input_cost_per_1M, output_cost_per_1M)
# NOTE: `calculate_cost` is currently unused in the live path — real
# cost tracking comes from 9Router's usage stats (analytics.py:270+).
# These entries are kept so the table matches BUILTIN_MODELS and can
# cost numbers come from 9Router's usage stats. These entries are kept
# so the table matches BUILTIN_MODELS and can
# be used by any future native-loop path. Subscription-routed models
# are zero-cost to the user, but API rates are recorded here for
# reference where they exist.
# Anthropic (direct API rates)
# Anthropic (direct API rates).
("Anthropic", "sonnet"): (3.0, 15.0),
("Anthropic", "opus"): (5.0, 25.0),
("Anthropic", "haiku"): (1.0, 5.0),
# OpenAI — Codex subscription path, user pays nothing per token
("OpenAI", "gpt-5.5"): (0.0, 0.0),
("OpenAI", "gpt-5.4"): (0.0, 0.0),
("OpenAI", "gpt-5.4-mini"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex-high"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex-xhigh"): (0.0, 0.0),
# Google — Gemini CLI subscription path, user pays nothing per token
("Google", "gemini-3.1-pro"): (0.0, 0.0),
("Google", "gemini-3.1-flash-lite"): (0.0, 0.0),
("Google", "gemini-3-pro"): (0.0, 0.0),
("Google", "gemini-3-flash"): (0.0, 0.0),
("Google", "gemini-2.5-pro"): (0.0, 0.0),
-24
View File
@@ -2,46 +2,22 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
@dataclass
class ToolContext:
"""Runtime context passed to every tool execution."""
cwd: str
session_id: str
class BaseTool(ABC):
"""Abstract base for all builtin tools.
Subclasses must set ``name`` and ``description`` as class attributes and
implement ``get_schema`` (JSON Schema for tool input) and ``execute``.
"""
name: str
description: str
@abstractmethod
def get_schema(self) -> dict:
"""Return JSON Schema for this tool's input parameters."""
...
@abstractmethod
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
"""Execute the tool.
Returns a list of content blocks, e.g.
``[{"type": "text", "text": "..."}]``.
"""
...
def to_tool_schema(self):
"""Convert to the provider-agnostic ``ToolSchema`` used everywhere."""
from backend.apps.agents.providers.base import ToolSchema
return ToolSchema(
name=self.name,
description=self.description,
input_schema=self.get_schema(),
)
-476
View File
@@ -1,476 +0,0 @@
"""Filesystem tools: Read, Write, Edit, Glob, Grep."""
from __future__ import annotations
import asyncio
import base64
import mimetypes
import os
import re
from pathlib import Path
from typing import Any
from backend.apps.agents.tools.base import BaseTool, ToolContext
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
_MAX_OUTPUT_BYTES = 50 * 1024 # ~50 KB cap for grep output
def _resolve(file_path: str, cwd: str) -> Path:
"""Resolve *file_path* against *cwd* when it is relative."""
p = Path(file_path)
if not p.is_absolute():
p = Path(cwd) / p
return p.resolve()
def _text_block(text: str) -> list[dict]:
return [{"type": "text", "text": text}]
# ───────────────────────────────────────────────────────────────────────────
# ReadTool
# ───────────────────────────────────────────────────────────────────────────
class ReadTool(BaseTool):
name = "Read"
description = (
"Read a file from the filesystem. Returns lines with line numbers "
"(cat -n style). For image files returns base64 content. Supports "
"offset and limit parameters for reading portions of large files."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to read.",
},
"offset": {
"type": "integer",
"description": "1-based line number to start reading from.",
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to return (default 2000).",
},
},
"required": ["file_path"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
file_path = _resolve(input_data["file_path"], context.cwd)
if not file_path.exists():
return _text_block(f"Error: file not found: {file_path}")
if not file_path.is_file():
return _text_block(f"Error: not a regular file: {file_path}")
# Binary / image files → base64
ext = file_path.suffix.lower()
if ext in _IMAGE_EXTENSIONS:
try:
raw = file_path.read_bytes()
b64 = base64.b64encode(raw).decode("ascii")
media = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
return [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media,
"data": b64,
},
}
]
except Exception as exc:
return _text_block(f"Error reading image {file_path}: {exc}")
# Text files
offset = max(input_data.get("offset", 1), 1)
limit = input_data.get("limit", 2000)
if limit <= 0:
limit = 2000
try:
with open(file_path, "r", errors="replace") as fh:
lines: list[str] = []
for lineno, line in enumerate(fh, start=1):
if lineno < offset:
continue
if len(lines) >= limit:
break
# cat -n style: right-justified line number + tab + content
lines.append(f"{lineno:>6}\t{line.rstrip()}")
if not lines:
return _text_block(f"(file is empty or offset beyond end of file: {file_path})")
return _text_block("\n".join(lines))
except Exception as exc:
return _text_block(f"Error reading {file_path}: {exc}")
# ───────────────────────────────────────────────────────────────────────────
# WriteTool
# ───────────────────────────────────────────────────────────────────────────
class WriteTool(BaseTool):
name = "Write"
description = (
"Write content to a file. Creates parent directories if they do not "
"exist. Overwrites the file if it already exists."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to write.",
},
"content": {
"type": "string",
"description": "The full content to write to the file.",
},
},
"required": ["file_path", "content"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
file_path = _resolve(input_data["file_path"], context.cwd)
content: str = input_data["content"]
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return _text_block(f"Successfully wrote {len(content)} bytes to {file_path}")
except Exception as exc:
return _text_block(f"Error writing {file_path}: {exc}")
# ───────────────────────────────────────────────────────────────────────────
# EditTool
# ───────────────────────────────────────────────────────────────────────────
class EditTool(BaseTool):
name = "Edit"
description = (
"Perform exact string replacements in a file. By default the "
"old_string must appear exactly once (not unique → error). Pass "
"replace_all=true to replace every occurrence."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to edit.",
},
"old_string": {
"type": "string",
"description": "The exact text to find in the file.",
},
"new_string": {
"type": "string",
"description": "The text to replace old_string with.",
},
"replace_all": {
"type": "boolean",
"description": "If true, replace all occurrences. Default false.",
"default": False,
},
},
"required": ["file_path", "old_string", "new_string"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
file_path = _resolve(input_data["file_path"], context.cwd)
old_string: str = input_data["old_string"]
new_string: str = input_data["new_string"]
replace_all: bool = input_data.get("replace_all", False)
if not file_path.exists():
return _text_block(f"Error: file not found: {file_path}")
if not file_path.is_file():
return _text_block(f"Error: not a regular file: {file_path}")
try:
content = file_path.read_text(encoding="utf-8")
except Exception as exc:
return _text_block(f"Error reading {file_path}: {exc}")
count = content.count(old_string)
if count == 0:
return _text_block(
f"Error: old_string not found in {file_path}. "
"Make sure the string matches exactly, including whitespace and indentation."
)
if not replace_all and count > 1:
return _text_block(
f"Error: old_string appears {count} times in {file_path}. "
"Provide more surrounding context to make the match unique, "
"or set replace_all=true to replace every occurrence."
)
if replace_all:
new_content = content.replace(old_string, new_string)
else:
# Replace only the first (and only) occurrence
new_content = content.replace(old_string, new_string, 1)
try:
file_path.write_text(new_content, encoding="utf-8")
except Exception as exc:
return _text_block(f"Error writing {file_path}: {exc}")
replacements = count if replace_all else 1
return _text_block(
f"Successfully edited {file_path} ({replacements} replacement{'s' if replacements != 1 else ''})."
)
# ───────────────────────────────────────────────────────────────────────────
# GlobTool
# ───────────────────────────────────────────────────────────────────────────
class GlobTool(BaseTool):
name = "Glob"
description = (
"Fast file pattern matching. Supports glob patterns like '**/*.py'. "
"Returns matching file paths sorted by modification time (newest first)."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match files (e.g. '**/*.py', 'src/**/*.ts').",
},
"path": {
"type": "string",
"description": "Directory to search in. Defaults to the working directory.",
},
},
"required": ["pattern"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
pattern: str = input_data["pattern"]
base = Path(input_data.get("path") or context.cwd)
if not base.is_dir():
return _text_block(f"Error: directory not found: {base}")
try:
matches: list[Path] = []
for p in base.glob(pattern):
if p.is_file():
matches.append(p)
if len(matches) >= 500:
break
# Sort by modification time, newest first
matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
if not matches:
return _text_block(f"No files matched pattern '{pattern}' in {base}")
result = "\n".join(str(p) for p in matches)
return _text_block(result)
except Exception as exc:
return _text_block(f"Error during glob '{pattern}' in {base}: {exc}")
# ───────────────────────────────────────────────────────────────────────────
# GrepTool
# ───────────────────────────────────────────────────────────────────────────
class GrepTool(BaseTool):
name = "Grep"
description = (
"Search file contents using regular expressions. Uses ripgrep (rg) "
"when available, otherwise falls back to Python's re module. "
"Supports output modes: files_with_matches, content, count."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regular expression pattern to search for.",
},
"path": {
"type": "string",
"description": "File or directory to search in. Defaults to the working directory.",
},
"glob": {
"type": "string",
"description": "Glob pattern to filter files (e.g. '*.py', '*.{ts,tsx}').",
},
"output_mode": {
"type": "string",
"enum": ["files_with_matches", "content", "count"],
"description": "Output mode. Default: files_with_matches.",
"default": "files_with_matches",
},
},
"required": ["pattern"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
pattern: str = input_data["pattern"]
search_path: str = input_data.get("path") or context.cwd
file_glob: str | None = input_data.get("glob")
output_mode: str = input_data.get("output_mode", "files_with_matches")
# Try ripgrep first
try:
result = await self._run_rg(pattern, search_path, file_glob, output_mode)
if result is not None:
return result
except FileNotFoundError:
pass # rg not installed, fall through to Python fallback
# Python fallback
return await self._python_grep(pattern, search_path, file_glob, output_mode)
async def _run_rg(
self,
pattern: str,
search_path: str,
file_glob: str | None,
output_mode: str,
) -> list[dict] | None:
"""Run ripgrep and return results, or None if rg is not available."""
cmd = ["rg", "--no-heading", "--color=never"]
if output_mode == "files_with_matches":
cmd.append("--files-with-matches")
elif output_mode == "count":
cmd.append("--count")
else:
cmd.extend(["--line-number"])
if file_glob:
cmd.extend(["--glob", file_glob])
cmd.append(pattern)
cmd.append(search_path)
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
except FileNotFoundError:
raise # re-raise so caller knows rg is missing
except asyncio.TimeoutError:
return _text_block("Error: grep timed out after 30 seconds.")
except Exception as exc:
return _text_block(f"Error running ripgrep: {exc}")
output = stdout.decode("utf-8", errors="replace")
if proc.returncode not in (0, 1):
err = stderr.decode("utf-8", errors="replace").strip()
if err:
return _text_block(f"Grep error: {err}")
if not output.strip():
return _text_block(f"No matches found for pattern '{pattern}'.")
# Truncate if too large
if len(output) > _MAX_OUTPUT_BYTES:
output = output[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)"
return _text_block(output.rstrip())
async def _python_grep(
self,
pattern: str,
search_path: str,
file_glob: str | None,
output_mode: str,
) -> list[dict]:
"""Pure-Python grep fallback using the re module."""
try:
regex = re.compile(pattern)
except re.error as exc:
return _text_block(f"Invalid regex pattern: {exc}")
base = Path(search_path)
if base.is_file():
files = [base]
elif base.is_dir():
glob_pat = file_glob or "**/*"
files = [p for p in base.glob(glob_pat) if p.is_file()]
else:
return _text_block(f"Error: path not found: {search_path}")
lines_out: list[str] = []
total_bytes = 0
truncated = False
for fp in sorted(files):
try:
text = fp.read_text(encoding="utf-8", errors="replace")
except Exception:
continue
file_matches: list[tuple[int, str]] = []
for lineno, line in enumerate(text.splitlines(), start=1):
if regex.search(line):
file_matches.append((lineno, line))
if not file_matches:
continue
if output_mode == "files_with_matches":
entry = str(fp)
elif output_mode == "count":
entry = f"{fp}:{len(file_matches)}"
else:
parts = [f"{fp}:{ln}:{txt}" for ln, txt in file_matches]
entry = "\n".join(parts)
total_bytes += len(entry)
if total_bytes > _MAX_OUTPUT_BYTES:
truncated = True
break
lines_out.append(entry)
if not lines_out:
return _text_block(f"No matches found for pattern '{pattern}'.")
result = "\n".join(lines_out)
if truncated:
result += "\n... (output truncated)"
return _text_block(result)
-61
View File
@@ -1,61 +0,0 @@
"""Central tool registry.
Importing this module automatically registers all builtin tools.
"""
from __future__ import annotations
from backend.apps.agents.tools.base import BaseTool
from backend.apps.agents.providers.base import ToolSchema
_TOOLS: dict[str, BaseTool] = {}
def register_tool(tool: BaseTool) -> None:
"""Register a tool instance by its name."""
_TOOLS[tool.name] = tool
def get_tool(name: str) -> BaseTool | None:
"""Look up a registered tool by name. Returns None if not found."""
return _TOOLS.get(name)
def get_all_tools() -> list[BaseTool]:
"""Return all registered tool instances."""
return list(_TOOLS.values())
def get_all_tool_schemas() -> list[ToolSchema]:
"""Return provider-agnostic ToolSchema for every registered tool."""
return [t.to_tool_schema() for t in _TOOLS.values()]
def init_tools() -> None:
"""Import and register all builtin tools."""
from backend.apps.agents.tools.filesystem import (
ReadTool,
WriteTool,
EditTool,
GlobTool,
GrepTool,
)
from backend.apps.agents.tools.system import BashTool, AskUserQuestionTool
from backend.apps.agents.tools.web import WebSearchTool, WebFetchTool
for tool_cls in [
ReadTool,
WriteTool,
EditTool,
GlobTool,
GrepTool,
BashTool,
AskUserQuestionTool,
WebSearchTool,
WebFetchTool,
]:
register_tool(tool_cls())
# Auto-register on import
init_tools()
-125
View File
@@ -1,125 +0,0 @@
"""System tools: Bash and AskUserQuestion."""
from __future__ import annotations
import asyncio
from backend.apps.agents.tools.base import BaseTool, ToolContext
_MAX_OUTPUT_BYTES = 100 * 1024 # ~100 KB cap
class BashTool(BaseTool):
name = "Bash"
description = (
"Execute a shell command and return its output. The command runs in "
"the session's working directory. Supports an optional timeout "
"(default 120 000 ms). Stdout and stderr are captured and returned."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute.",
},
"timeout": {
"type": "integer",
"description": "Timeout in milliseconds (default 120000, max 600000).",
"default": 120000,
},
"description": {
"type": "string",
"description": "Optional human-readable description of what this command does.",
},
},
"required": ["command"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
command: str = input_data["command"]
timeout_ms: int = min(input_data.get("timeout", 120000), 600000)
timeout_s: float = timeout_ms / 1000.0
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=context.cwd,
)
except Exception as exc:
return [{"type": "text", "text": f"Error starting command: {exc}"}]
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
except asyncio.TimeoutError:
# Attempt to kill the process
try:
proc.kill()
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5)
except Exception:
stdout, stderr = b"", b""
partial = self._decode(stdout, stderr)
msg = (
f"Command timed out after {timeout_ms}ms.\n"
f"Partial output:\n{partial}"
)
return [{"type": "text", "text": self._truncate(msg)}]
except Exception as exc:
return [{"type": "text", "text": f"Error executing command: {exc}"}]
output = self._decode(stdout, stderr)
if proc.returncode != 0:
output = f"Exit code: {proc.returncode}\n{output}"
if not output.strip():
output = f"(command completed with exit code {proc.returncode})"
return [{"type": "text", "text": self._truncate(output)}]
@staticmethod
def _decode(stdout: bytes, stderr: bytes) -> str:
parts: list[str] = []
if stdout:
parts.append(stdout.decode("utf-8", errors="replace"))
if stderr:
parts.append(stderr.decode("utf-8", errors="replace"))
return "\n".join(parts)
@staticmethod
def _truncate(text: str) -> str:
if len(text) > _MAX_OUTPUT_BYTES:
return text[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)"
return text
class AskUserQuestionTool(BaseTool):
name = "AskUserQuestion"
description = (
"Ask the user a clarifying question. The actual blocking/HITL "
"interaction is handled by the agent loop's hitl_handler; this tool "
"simply surfaces the question text."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question to ask the user.",
},
},
"required": ["question"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
question: str = input_data.get("question", "")
return [{"type": "text", "text": question}]
-123
View File
@@ -1,123 +0,0 @@
"""PostHog-only analytics collector.
All events go directly to PostHog. No local SQLite storage.
Usage from any module:
from backend.apps.analytics.collector import record
record("session.started", {"model": "opus"}, session_id="abc123")
"""
import logging
import platform
from uuid import uuid4
from posthog import Posthog
logger = logging.getLogger(__name__)
POSTHOG_API_KEY = "phc_KdVLvAdjCuHeacFoDm1CM1Gb23XikewRqlX67Mj6TNB"
POSTHOG_HOST = "https://us.i.posthog.com"
_posthog: Posthog | None = None
_installation_id: str | None = None
def init():
"""Initialise PostHog. Called once at app startup."""
global _posthog
if _posthog is None:
_posthog = Posthog(
project_api_key=POSTHOG_API_KEY,
host=POSTHOG_HOST,
)
return _posthog
def shutdown():
"""Flush and close. Called at app shutdown."""
global _posthog
if _posthog:
try:
_posthog.shutdown()
except Exception:
pass
_posthog = None
def _get_installation_id() -> str:
"""Get or create a stable anonymous installation ID."""
global _installation_id
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:
iid = uuid4().hex
settings.installation_id = iid
_save_settings(settings)
_installation_id = iid
except Exception:
_installation_id = uuid4().hex
return _installation_id
def _is_opted_in() -> bool:
"""Check if user has opted in to analytics."""
try:
from backend.apps.settings.settings import load_settings
return getattr(load_settings(), "analytics_opt_in", True)
except Exception:
return True
def record(
event_type: str,
properties: dict | None = None,
session_id: str | None = None,
dashboard_id: str | None = None,
):
"""Record an analytics event to PostHog."""
if not _posthog:
return
props = {**(properties or {})}
if session_id:
props["session_id"] = session_id
if dashboard_id:
props["dashboard_id"] = dashboard_id
props["os"] = platform.system()
props["platform"] = platform.platform()
try:
_posthog.capture(
event_type,
distinct_id=_get_installation_id(),
properties=props,
)
except Exception as e:
logger.debug(f"PostHog capture failed (non-critical): {e}")
def identify(extra_properties: dict | None = None):
"""Set person properties on the current installation's PostHog profile."""
if not _posthog:
return
try:
_posthog.set(
distinct_id=_get_installation_id(),
properties={
"os": platform.system(),
"platform": platform.platform(),
**(extra_properties or {}),
},
)
except Exception as e:
logger.debug(f"PostHog identify failed (non-critical): {e}")
def get_collector():
"""Backward compat — returns None since we no longer have a local collector."""
return None
-37
View File
@@ -1,37 +0,0 @@
from pydantic import BaseModel
from typing import Optional
class AnalyticsEvent(BaseModel):
id: Optional[int] = None
timestamp: str
event_type: str
properties: dict
session_id: Optional[str] = None
dashboard_id: Optional[str] = None
class UsageSummary(BaseModel):
total_sessions: int = 0
total_cost_usd: float = 0.0
total_messages: int = 0
total_tool_calls: int = 0
avg_session_duration_seconds: float = 0.0
session_completion_rate: float = 0.0
approval_rate: float = 0.0
models_used: dict[str, int] = {}
modes_used: dict[str, int] = {}
top_tools: list[list] = []
class TimeSeriesPoint(BaseModel):
date: str
value: float
class ExportPayload(BaseModel):
export_version: str = "1.0"
exported_at: str = ""
app_version: str = "unknown"
period: dict = {}
summary: dict = {}
+4 -5
View File
@@ -123,10 +123,8 @@ async def list_dashboards():
@dashboards.router.post("/create")
async def create_dashboard(body: DashboardCreate):
from backend.apps.analytics.collector import record as _analytics
dashboard = Dashboard(name=body.name)
_save(dashboard)
_analytics("dashboard.created", {"name": dashboard.name}, dashboard_id=dashboard.id)
return dashboard.model_dump(mode="json")
@@ -221,11 +219,11 @@ async def generate_name(dashboard_id: str):
fallback = prompts[0][:40]
try:
from backend.apps.settings.settings import load_settings
from backend.apps.settings.credentials import get_anthropic_client
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.agents.providers.registry import resolve_aux_model
global_settings = load_settings()
aux_model, _aux_base = await resolve_aux_model(global_settings, preferred_tier="haiku")
client = get_anthropic_client(global_settings)
client = get_anthropic_client_for_model(global_settings, aux_model)
if len(prompts) == 1:
system = (
@@ -248,7 +246,8 @@ async def generate_name(dashboard_id: str):
system=system,
messages=[{"role": "user", "content": user_content}],
)
generated = resp.content[0].text.strip().strip('"\'')
from backend.apps.agents.agent_manager import _safe_resp_text
generated = _safe_resp_text(resp).strip().strip('"\'')
if generated:
fallback = generated
except Exception as e:
@@ -1,8 +0,0 @@
"""Stdio MCP shim that forwards Discord tool calls to the OpenSwarm cloud.
Run as: python -m backend.apps.discord_mcp_shim
"""
from backend.apps.discord_mcp_shim.server import main
if __name__ == "__main__":
main()
+45
View File
@@ -13,6 +13,7 @@ import os
import shutil
import subprocess
import time
from typing import Any
import httpx
@@ -330,6 +331,50 @@ async def get_usage_stats(period: str = "all") -> dict | None:
return None
async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | None:
"""Fetch reasoning_tokens from 9Router for the most recently completed
request, optionally filtered by model. Returns None if 9Router isn't
running, the request didn't expose reasoning tokens, or the lookup
fails for any reason.
9Router's request-details endpoint returns the most recent N requests
in reverse chronological order with full token breakdowns including
`reasoning_tokens` (OpenAI's `completion_tokens_details.reasoning_tokens`)
and `thoughtsTokenCount` (Gemini's). For Anthropic via 9Router this
field will be absent/zero Anthropic doesn't break out reasoning
tokens in its API response so callers get None and should fall
back to the heuristic.
"""
if not is_running():
return None
try:
async with httpx.AsyncClient(timeout=2.0) as client:
params: dict[str, Any] = {"page": 1, "pageSize": 5}
if model_hint:
params["model"] = model_hint
r = await client.get(f"{NINE_ROUTER_API}/usage/request-details", params=params)
if r.status_code != 200:
return None
data = r.json()
# Endpoint returns either {requests: [...]} or {data: [...]} —
# be defensive about the shape since 9Router has rolled out
# multiple variants.
requests = data.get("requests") or data.get("data") or []
for req in requests:
tokens = req.get("tokens") or req.get("usage") or {}
rt = (
tokens.get("reasoning_tokens")
or tokens.get("thoughtsTokenCount")
or tokens.get("thoughts_token_count")
or 0
)
if rt and int(rt) > 0:
return int(rt)
except Exception as e:
logger.debug(f"9Router reasoning-token lookup failed: {e}")
return None
async def get_providers() -> list[dict]:
"""Get all providers and their connection status from 9Router.
+31 -11
View File
@@ -33,10 +33,21 @@ def _resolve_model(short_name: str) -> str:
return MODEL_MAP.get(short_name, short_name)
def _get_anthropic_client():
"""Create an AsyncAnthropic client using the API key from app settings."""
from backend.apps.settings.credentials import get_anthropic_client
def _get_anthropic_client(api_model: str | None = None):
"""Create an AsyncAnthropic client using the API key from app settings.
When `api_model` is provided and carries a 9Router prefix (cc/, cx/, gc/),
the client is pointed at 9Router so non-Anthropic aux calls don't 400 on
api.anthropic.com. Without an api_model we fall back to the default
connection-mode-driven client.
"""
from backend.apps.settings.credentials import (
get_anthropic_client,
get_anthropic_client_for_model,
)
settings = load_settings()
if api_model:
return get_anthropic_client_for_model(settings, api_model)
return get_anthropic_client(settings)
@@ -362,8 +373,7 @@ async def create_output(body: OutputCreate):
updated_at=now,
)
_save(output)
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "view.created"})
pass
return {"ok": True, "output": output.model_dump()}
@@ -444,7 +454,7 @@ async def vibe_code(body: VibeCodeRequest):
"backend_code": body.current_backend_code,
"input_schema": body.current_schema,
}
client = _get_anthropic_client()
client = _get_anthropic_client(aux_model)
try:
resp = await client.messages.create(
model=aux_model,
@@ -452,15 +462,22 @@ async def vibe_code(body: VibeCodeRequest):
system=VIBE_CODE_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_message}],
)
raw = resp.content[0].text.strip()
from backend.apps.agents.agent_manager import _safe_resp_text
raw = _safe_resp_text(resp).strip()
if not raw:
return {
"message": "Aux model returned no content. Please try again.",
"frontend_code": body.current_frontend_code,
"backend_code": body.current_backend_code,
"input_schema": body.current_schema,
}
if raw.startswith("```"):
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
if raw.endswith("```"):
raw = raw[:-3]
result = json.loads(raw)
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "vibe_code.used"})
pass
return {
"message": result.get("message", "View updated."),
"frontend_code": result.get("frontend_code", body.current_frontend_code),
@@ -523,7 +540,7 @@ async def auto_run_output(body: AutoRunRequest):
except ValueError as e:
return {"error": str(e), "input_data": None, "backend_result": None}
client = _get_anthropic_client()
client = _get_anthropic_client(api_model)
try:
resp = await client.messages.create(
model=api_model,
@@ -531,7 +548,10 @@ async def auto_run_output(body: AutoRunRequest):
system=AUTO_RUN_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_message}],
)
raw = resp.content[0].text.strip()
from backend.apps.agents.agent_manager import _safe_resp_text
raw = _safe_resp_text(resp).strip()
if not raw:
return {"error": "Aux model returned no content.", "input_data": None, "backend_result": None}
if raw.startswith("```"):
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
if raw.endswith("```"):
+138
View File
@@ -0,0 +1,138 @@
"""Bounded SQLite spool for offline operational submissions.
When the desktop is offline (laptop closed, no internet, cloud unreachable),
the service-sync layer can't reach `api.openswarm.com`. Rather than drop
data on the floor, we spool submissions to a small SQLite file and replay
them on the next online tick. The spool is bounded when full, the oldest
entries are dropped so it can never balloon to a problem.
Single file, single table, single thread guarded by a sqlite3 connection's
implicit lock. No concurrency model beyond "don't write from two processes
at once."
"""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import threading
from contextlib import contextmanager
from typing import Iterator, Optional
logger = logging.getLogger(__name__)
# Cap the spool at 50 MB on disk. SQLite's overhead means the actual ceiling
# on retained payloads is somewhat smaller, which is fine — this is a
# best-effort cushion, not a guaranteed retention window.
_MAX_BYTES = 50 * 1024 * 1024
# Trim 25% when we cross the cap so we don't trim on every insert.
_TRIM_TARGET_FRACTION = 0.75
_lock = threading.Lock()
@contextmanager
def _conn(spool_path: str) -> Iterator[sqlite3.Connection]:
"""Open a connection that auto-commits and ensures the table exists.
Caller holds `_lock` for the duration of the context."""
os.makedirs(os.path.dirname(spool_path), exist_ok=True)
c = sqlite3.connect(spool_path, isolation_level=None, timeout=5.0)
try:
c.execute(
"CREATE TABLE IF NOT EXISTS spool ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" kind TEXT NOT NULL,"
" payload TEXT NOT NULL,"
" created_at REAL NOT NULL"
")"
)
yield c
finally:
c.close()
def enqueue(spool_path: str, kind: str, payload: dict, *, now: float) -> None:
"""Append a submission to the spool. Drops the oldest if the spool is
over the byte cap."""
body = json.dumps(payload, separators=(",", ":"), default=str)
with _lock, _conn(spool_path) as c:
c.execute(
"INSERT INTO spool (kind, payload, created_at) VALUES (?, ?, ?)",
(kind, body, now),
)
# Cheap size check — only run trim when stat says we're over.
try:
size = os.path.getsize(spool_path)
except OSError:
size = 0
if size > _MAX_BYTES:
target = int(_MAX_BYTES * _TRIM_TARGET_FRACTION)
# Delete oldest rows until we're back under target. Use a
# reasonable batch size so we don't block forever.
for _ in range(64):
row = c.execute("SELECT id FROM spool ORDER BY id ASC LIMIT 1").fetchone()
if not row:
break
c.execute("DELETE FROM spool WHERE id = ?", (row[0],))
try:
new_size = os.path.getsize(spool_path)
except OSError:
new_size = 0
if new_size <= target:
break
# VACUUM is expensive; only run if we still appear oversized after
# trimming, otherwise free pages get reused on next insert.
try:
if os.path.getsize(spool_path) > _MAX_BYTES:
c.execute("VACUUM")
except (OSError, sqlite3.DatabaseError):
pass
def drain(spool_path: str, batch_size: int = 50) -> list[tuple[int, str, dict]]:
"""Read up to `batch_size` oldest entries. Returns (id, kind, payload)
triples; caller is responsible for calling `acknowledge(ids)` once the
cloud accepts them."""
if not os.path.exists(spool_path):
return []
with _lock, _conn(spool_path) as c:
rows = c.execute(
"SELECT id, kind, payload FROM spool ORDER BY id ASC LIMIT ?",
(batch_size,),
).fetchall()
out: list[tuple[int, str, dict]] = []
for rid, kind, body in rows:
try:
out.append((rid, kind, json.loads(body)))
except json.JSONDecodeError:
# Corrupt row — discard so it doesn't block draining behind it.
with _lock, _conn(spool_path) as c:
c.execute("DELETE FROM spool WHERE id = ?", (rid,))
logger.warning("Dropped corrupt spool row id=%s", rid)
return out
def acknowledge(spool_path: str, ids: list[int]) -> None:
"""Remove rows the cloud has accepted."""
if not ids:
return
with _lock, _conn(spool_path) as c:
c.executemany("DELETE FROM spool WHERE id = ?", [(i,) for i in ids])
def count(spool_path: str) -> int:
"""Return the number of pending entries. Used for tests + debug UI."""
if not os.path.exists(spool_path):
return 0
with _lock, _conn(spool_path) as c:
row = c.execute("SELECT COUNT(*) FROM spool").fetchone()
return int(row[0]) if row else 0
def clear(spool_path: str) -> None:
"""Delete all pending entries. Tests + manual reset only."""
with _lock, _conn(spool_path) as c:
c.execute("DELETE FROM spool")
+352
View File
@@ -0,0 +1,352 @@
"""Operational state forwarder.
Single public surface: `submit(kind, payload)`. The desktop hands off
opaque payload dicts; the cloud at api.openswarm.com is responsible for
parsing and routing them. The desktop has no schema knowledge.
Three `kind` values are accepted they're the routing primitive the
cloud needs to send the payload to the right backend handler. The shape
of `payload` is opaque from the desktop's perspective; the cloud knows
how to read it.
- "state": lightweight periodic ping
- "session": full session dump on close
- "diagnostic": error / bug-report context
Submissions that fail to deliver get spooled to a small SQLite file and
replayed on the next online tick. Bounded to 50 MB.
"""
from __future__ import annotations
import asyncio
import logging
import os
import platform
import time
from typing import Any, Optional
from uuid import uuid4
import httpx
from backend.apps.service import buffer
logger = logging.getLogger(__name__)
_DEFAULT_BASE = "https://api.openswarm.com"
_PATH_BY_KIND = {
"state": "/api/service/state",
"session": "/api/service/sync",
"diagnostic": "/api/service/diagnostics",
"event": "/api/service/event",
}
_TIMEOUT_SECONDS = 5.0
_MAX_INFLIGHT = 16
_test_sink: Optional[Any] = None
_install_id: Optional[str] = None
_user_id: Optional[str] = None
_inflight = 0
_inflight_lock = asyncio.Lock()
_drain_lock = asyncio.Lock()
def _spool_path() -> str:
try:
from backend.config.paths import SETTINGS_DIR
return os.path.join(SETTINGS_DIR, "service_spool.db")
except Exception:
return os.path.expanduser("~/.openswarm/data/service_spool.db")
def set_test_sink(fn: Optional[Any]) -> None:
"""Test seam — receives every submission instead of the network."""
global _test_sink
_test_sink = fn
def _get_install_id() -> str:
global _install_id
if _install_id:
return _install_id
try:
from backend.apps.settings.settings import load_settings, _save_settings
s = load_settings()
iid = getattr(s, "installation_id", None)
if not iid:
iid = uuid4().hex
s.installation_id = iid
_save_settings(s)
_install_id = iid
except Exception:
_install_id = uuid4().hex
return _install_id
def _get_user_id() -> Optional[str]:
global _user_id
if _user_id:
return _user_id
try:
from backend.apps.settings.settings import load_settings
s = load_settings()
return getattr(s, "user_email", None) or None
except Exception:
return None
def set_user_id(uid: Optional[str]) -> None:
global _user_id
_user_id = uid or None
def _is_enabled(kind: str) -> bool:
"""Honour user opt-out. Diagnostic always flows (errors block usability);
state + session honour the toggle."""
if kind == "diagnostic":
return True
try:
from backend.apps.settings.settings import load_settings
s = load_settings()
mode = getattr(s, "service_diagnostics_mode", None)
if mode == "minimal":
return False
if mode is None:
return bool(getattr(s, "analytics_opt_in", True))
return True
except Exception:
return True
def _envelope() -> dict:
"""Identity + environment metadata stamped on every submission."""
env: dict[str, Any] = {"install_id": _get_install_id()}
uid = _get_user_id()
if uid:
env["user_id"] = uid
try:
env["os"] = platform.system()
env["os_version"] = platform.release()
env["device_type"] = "desktop"
except Exception:
pass
try:
import datetime as _dt
local_tz = _dt.datetime.now().astimezone().tzinfo
if local_tz:
env["timezone"] = str(local_tz)
except Exception:
pass
try:
from backend.apps.service.service import APP_VERSION
env["app_version"] = APP_VERSION
except Exception:
pass
# How this build was packaged. Set by the platform-specific build script
# (electron-builder afterPack hooks for dmg / exe / appimage / deb / rpm).
# Defaults to "dev" when running from `bash run.sh` in a checked-out repo.
env["install_method"] = os.environ.get("OPENSWARM_INSTALL_METHOD", "dev")
return env
def _base_url() -> str:
try:
from backend.apps.settings.settings import load_settings
from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL
s = load_settings()
return (getattr(s, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL).rstrip("/")
except Exception:
return _DEFAULT_BASE
async def _post(path: str, body: dict) -> bool:
url = f"{_base_url()}{path}"
try:
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as c:
r = await c.post(url, json=body)
return 200 <= r.status_code < 500
except Exception as e:
logger.debug("service POST %s failed: %s", path, e)
return False
async def _post_or_spool(path: str, body: dict, kind: str) -> None:
global _inflight
if _test_sink is not None:
try:
_test_sink(kind, body)
except Exception as e:
logger.debug("test sink raised: %s", e)
return
async with _inflight_lock:
if _inflight >= _MAX_INFLIGHT:
buffer.enqueue(_spool_path(), f"{kind}:{path}", body, now=time.time())
return
_inflight += 1
try:
ok = await _post(path, body)
if not ok:
buffer.enqueue(_spool_path(), f"{kind}:{path}", body, now=time.time())
finally:
async with _inflight_lock:
_inflight = max(0, _inflight - 1)
async def drain_spool(batch_size: int = 50) -> int:
async with _drain_lock:
entries = buffer.drain(_spool_path(), batch_size=batch_size)
if not entries:
return 0
succeeded: list[int] = []
for rid, kind_path, body in entries:
kind, _, path = kind_path.partition(":")
if not path:
succeeded.append(rid)
continue
ok = await _post(path, body)
if ok:
succeeded.append(rid)
else:
break
if succeeded:
buffer.acknowledge(_spool_path(), succeeded)
return len(succeeded)
# --------------------------------------------------------------------------
# Public API
# --------------------------------------------------------------------------
def _log(kind: str, payload: dict) -> None:
"""Append to the rolling operational log for diagnostics."""
try:
from backend.apps.service.ring_buffer import record
record(kind)
except Exception:
pass
def sync(data: dict | None = None) -> None:
"""Sync operational state to the cloud. Single entry point.
Accepts any dict the cloud determines what it is from the shape.
The desktop has no knowledge of event types, schemas, or routing.
Fire-and-forget; never raises.
"""
payload = data or {}
if not _is_enabled("state"):
return
body = {
"client_state": _envelope(),
"d": payload,
"t": time.time(),
}
_log("s", payload)
if _test_sink is not None:
try:
_test_sink("s", body)
except Exception as e:
logger.debug("test sink raised: %s", e)
return
_schedule(_post_or_spool(_DEFAULT_SYNC_PATH, body, "s"))
# Internal routing — the cloud has one endpoint for everything.
_DEFAULT_SYNC_PATH = "/api/service/sync"
def submit(kind: str, payload: dict) -> None:
"""Legacy shim — routes through sync(). Kept for back-compat during
migration. New code should call sync() directly."""
sync(payload)
def _schedule(coro) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None:
loop.create_task(coro)
return
import threading
def _run():
try:
asyncio.run(coro)
except Exception:
pass
threading.Thread(target=_run, daemon=True).start()
# --------------------------------------------------------------------------
# Backwards-compat shims for legacy call sites. New code calls submit()
# directly. These keep the ~50 existing import sites in the codebase
# working unchanged. Removed in a future cleanup once nothing imports
# from older import paths.
# --------------------------------------------------------------------------
def submit_event(
surface: str,
action: str,
props: Optional[dict] = None,
*,
session_id: Optional[str] = None,
dashboard_id: Optional[str] = None,
kind: str = "event",
) -> None:
"""Legacy event-shape submit. Bundles surface/action into the opaque
payload and hands off via submit()."""
p = {
"surface": surface,
"action": action,
"props": props or {},
"session_id": session_id,
"dashboard_id": dashboard_id,
}
submit("event", p)
def submit_state(*, sessions_open: int = 0, connectors_active: int = 0) -> None:
submit("state", {"sessions_open": sessions_open, "connectors_active": connectors_active})
def submit_session_close(session_dump: dict, activity: Optional[dict] = None) -> None:
submit("session", {"usage_window": session_dump, "activity": activity or {}})
def submit_diagnostic(diagnostic: dict) -> None:
try:
from backend.apps.service.ring_buffer import snapshot
diagnostic["recent_log"] = snapshot()
except Exception:
pass
submit("diagnostic", {"diagnostic": diagnostic})
def update_identity(extra: Optional[dict] = None) -> None:
submit("state", {"identity": extra or {}})
def record(
event_type: str,
properties: Optional[dict] = None,
session_id: Optional[str] = None,
dashboard_id: Optional[str] = None,
) -> None:
"""Legacy collector.record() shim — splits dotted name into surface/action."""
if "." in event_type:
surface, action = event_type.split(".", 1)
else:
surface, action = event_type, "fired"
submit_event(
surface=surface, action=action, props=properties or {},
session_id=session_id, dashboard_id=dashboard_id,
)
def identify(extra_properties: Optional[dict] = None) -> None:
update_identity(extra_properties or {})
+5
View File
@@ -0,0 +1,5 @@
"""(Reserved for future use; intentionally empty.)
The service-sync layer ships opaque payload dicts through `submit()`
no Pydantic shape exposed in the public repo.
"""
+38
View File
@@ -0,0 +1,38 @@
"""Fixed-size event log for operational diagnostics.
Maintains a rolling window of the last N app events so support
diagnostics can include context about recent activity. Used by
the error report builder to attach "what just happened" when
something goes wrong.
"""
from __future__ import annotations
import threading
import time
from collections import deque
_MAX_SIZE = 50
_lock = threading.Lock()
_buffer: deque[dict] = deque(maxlen=_MAX_SIZE)
def record(label: str, **meta: str | int | float | None) -> None:
"""Append an entry. Oldest drops when full."""
with _lock:
_buffer.append({
"l": label,
"t": time.time(),
**{k: v for k, v in meta.items() if v is not None},
})
def snapshot() -> list[dict]:
"""Return a copy of the current buffer, oldest first."""
with _lock:
return list(_buffer)
def clear() -> None:
with _lock:
_buffer.clear()
@@ -1,4 +1,17 @@
"""Analytics SubApp: PostHog for product analytics + local usage summary from session data."""
"""Service SubApp.
Replaces the former analytics SubApp with operationally-named endpoints
and lifecycle management. Responsibilities:
- Usage-summary and cost-breakdown endpoints (user-facing, for the
Settings / Usage page)
- Background heartbeat that reports operational state to the cloud
- 9Router auto-start for OpenSwarm Pro users
- Frontend event endpoint (`POST /api/service/event`)
- Periodic spool drainer for offline retry
"""
from __future__ import annotations
import asyncio
import json
@@ -11,18 +24,14 @@ from datetime import datetime
from backend.config.Apps import SubApp
from backend.config.paths import SESSIONS_DIR
from backend.apps.analytics.collector import init as init_collector, shutdown as shutdown_collector, record, identify
from backend.apps.service import client as svc
logger = logging.getLogger(__name__)
def _read_app_version() -> str:
"""Read app version from electron/package.json so we never have to bump
it in two places. Falls back to a literal if the file isn't reachable
(e.g. unusual layouts in tests)."""
import json
try:
_here = os.path.dirname(os.path.abspath(__file__))
# backend/apps/analytics/ -> backend/apps/ -> backend/ -> repo root
_repo = os.path.dirname(os.path.dirname(os.path.dirname(_here)))
_pkg = os.path.join(_repo, "electron", "package.json")
with open(_pkg, encoding="utf-8") as _f:
@@ -33,9 +42,9 @@ def _read_app_version() -> str:
APP_VERSION = _read_app_version()
_heartbeat_task: asyncio.Task | None = None
_pulse_task: asyncio.Task | None = None
_drain_task: asyncio.Task | None = None
# Delta tracking — tracks last-seen 9Router totals to compute increments
_last_9r_cost: float | None = None
_last_9r_prompt_tokens: int | None = None
_last_9r_completion_tokens: int | None = None
@@ -44,11 +53,6 @@ _RESTART_THRESHOLD = 1.0
def _compute_delta(current: float, last: float | None, threshold: float = _RESTART_THRESHOLD) -> tuple[float, float]:
"""Compute incremental delta from cumulative values.
Returns (delta, new_last).
Handles 9Router restarts (large drops) and float jitter (tiny drops).
"""
if last is None:
return 0.0, current
if current < last - threshold:
@@ -58,71 +62,82 @@ def _compute_delta(current: float, last: float | None, threshold: float = _RESTA
return current - last, current
async def _heartbeat_loop():
"""Send a heartbeat event every 60 seconds with cost/token deltas."""
_pulse_count = 0
_pulse_hours: set = set()
_pulse_delta_cost_total = 0.0
_pulse_batch_size = 10
async def _pulse_loop():
"""Periodic state-pulse loop. Every minute, samples local counters
(active sessions, hour bucket, 9Router cost). Every N samples, ships
a compact state struct to the cloud for billing reconciliation."""
global _last_9r_cost, _last_9r_prompt_tokens, _last_9r_completion_tokens, _last_9r_requests
global _pulse_count, _pulse_hours, _pulse_delta_cost_total
while True:
await asyncio.sleep(60)
_pulse_count += 1
try:
from backend.apps.agents.agent_manager import agent_manager
props = {
"active_session_count": len(agent_manager.sessions),
}
# Compute cost/token deltas from 9Router
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:
cur_cost = stats.get("totalCost", 0) or 0
cur_prompt = stats.get("totalPromptTokens", 0) or 0
cur_completion = stats.get("totalCompletionTokens", 0) or 0
cur_requests = stats.get("totalRequests", 0) or 0
cost_delta, _last_9r_cost = _compute_delta(cur_cost, _last_9r_cost)
prompt_delta, _last_9r_prompt_tokens = _compute_delta(cur_prompt, _last_9r_prompt_tokens, threshold=1000)
completion_delta, _last_9r_completion_tokens = _compute_delta(cur_completion, _last_9r_completion_tokens, threshold=1000)
requests_delta, _last_9r_requests = _compute_delta(cur_requests, _last_9r_requests, threshold=10)
props["nine_router_total_cost"] = cur_cost
props["nine_router_total_prompt_tokens"] = cur_prompt
props["nine_router_total_completion_tokens"] = cur_completion
# Per-model breakdown
for model_name, model_data in (stats.get("byModel") or {}).items():
safe_name = model_name.replace(".", "_").replace("-", "_")[:40]
props[f"cost_model_{safe_name}"] = model_data.get("cost", 0)
except Exception:
pass
record("app.heartbeat", props)
# Fire cost.delta with incremental amounts
if "nine_router_total_cost" in props:
record("cost.delta", {
"cost_delta_usd": cost_delta,
"prompt_tokens_delta": int(prompt_delta),
"completion_tokens_delta": int(completion_delta),
"requests_delta": int(requests_delta),
})
import datetime as _dt
_pulse_hours.add(_dt.datetime.now().hour)
except Exception:
pass
cost_delta = 0.0
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:
cur_cost = stats.get("totalCost", 0) or 0
cur_prompt = stats.get("totalPromptTokens", 0) or 0
cur_completion = stats.get("totalCompletionTokens", 0) or 0
cur_requests = stats.get("totalRequests", 0) or 0
cost_delta, _last_9r_cost = _compute_delta(cur_cost, _last_9r_cost)
prompt_delta, _last_9r_prompt_tokens = _compute_delta(cur_prompt, _last_9r_prompt_tokens, threshold=1000)
completion_delta, _last_9r_completion_tokens = _compute_delta(cur_completion, _last_9r_completion_tokens, threshold=1000)
requests_delta, _last_9r_requests = _compute_delta(cur_requests, _last_9r_requests, threshold=10)
_pulse_delta_cost_total += cost_delta
except Exception:
pass
if _pulse_count >= _pulse_batch_size:
try:
from backend.apps.agents.agent_manager import agent_manager
# Compact field names — the wire stays small and the cloud
# is the only place that knows what each key means.
svc.sync({
"a": len(agent_manager.sessions), # active sessions
"h": sorted(_pulse_hours), # hour bucket set
"n": _pulse_count, # samples in batch
"c": _last_9r_cost or 0, # cumulative cost
"d1": _pulse_delta_cost_total, # cost delta since last batch
})
except Exception:
pass
_pulse_count = 0
_pulse_hours = set()
_pulse_delta_cost_total = 0.0
async def _drain_loop():
while True:
try:
await svc.drain_spool()
except Exception:
pass
await asyncio.sleep(60)
@asynccontextmanager
async def analytics_lifespan():
global _heartbeat_task
init_collector()
logger.info("PostHog analytics initialised")
async def service_lifespan():
global _pulse_task, _drain_task
try:
from backend.apps.settings.settings import load_settings, _save_settings
settings = load_settings()
# Track first open
is_first_open = settings.first_opened_at is None
if is_first_open:
settings.first_opened_at = datetime.now().isoformat()
@@ -148,7 +163,7 @@ async def analytics_lifespan():
for cp in getattr(settings, "custom_providers", []):
providers.append(cp.name)
record("app.opened", {
svc.sync({
"os": platform.system(),
"platform": platform.platform(),
"provider_count": len(providers),
@@ -158,7 +173,7 @@ async def analytics_lifespan():
"app_version": APP_VERSION,
})
id_props = {
id_props: dict = {
"providers_configured": providers,
"provider_count": len(providers),
"app_version": APP_VERSION,
@@ -172,10 +187,6 @@ async def analytics_lifespan():
if getattr(settings, "user_referral_source", None):
id_props["referral_source"] = settings.user_referral_source
# Subscription context so every event from this installation can be
# sliced by plan / paying-vs-free in PostHog. Refreshed on activate,
# sync, and disconnect so these values stay current without waiting
# for the next app launch.
mode = getattr(settings, "connection_mode", "own_key")
plan = getattr(settings, "openswarm_subscription_plan", None)
is_paying = mode == "openswarm-pro" and bool(
@@ -187,47 +198,54 @@ async def analytics_lifespan():
if is_paying and getattr(settings, "openswarm_subscription_expires", None):
id_props["subscription_expires"] = settings.openswarm_subscription_expires
identify(id_props)
svc.sync({"identity": id_props})
except Exception as e:
logger.debug(f"Analytics startup event failed (non-critical): {e}")
logger.debug(f"Service startup event failed (non-critical): {e}")
# Auto-start 9Router for subscription access
try:
from backend.apps.nine_router import ensure_running as ensure_9router
await ensure_9router()
except Exception as e:
logger.debug(f"9Router auto-start skipped: {e}")
# Start heartbeat
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
_pulse_task = asyncio.create_task(_pulse_loop())
_drain_task = asyncio.create_task(_drain_loop())
yield
# Stop heartbeat
if _heartbeat_task:
_heartbeat_task.cancel()
if _pulse_task:
_pulse_task.cancel()
try:
await _heartbeat_task
await _pulse_task
except asyncio.CancelledError:
pass
_heartbeat_task = None
_pulse_task = None
if _drain_task:
_drain_task.cancel()
try:
await _drain_task
except asyncio.CancelledError:
pass
_drain_task = None
# Stop 9Router
try:
from backend.apps.nine_router import stop as stop_9router
stop_9router()
except Exception:
pass
shutdown_collector()
logger.info("PostHog analytics shut down")
logger.info("Service shut down")
analytics = SubApp("analytics", analytics_lifespan)
service = SubApp("service", service_lifespan)
# ---------------------------------------------------------------------------
# Usage endpoints (user-facing, read by the Settings / Usage page)
# ---------------------------------------------------------------------------
def _load_all_sessions() -> list[dict]:
"""Load all persisted session JSON files."""
results = []
if not os.path.exists(SESSIONS_DIR):
return results
@@ -241,12 +259,10 @@ def _load_all_sessions() -> list[dict]:
return results
@analytics.router.get("/usage-summary")
@service.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
# Combine persisted + active sessions
sessions = _load_all_sessions()
for s in agent_manager.get_all_sessions():
sessions.append(s.model_dump(mode="json"))
@@ -267,25 +283,18 @@ async def usage_summary():
tool_msgs = [m for m in messages if m.get("role") == "tool_call"]
total_messages += len(user_msgs)
total_tool_calls += len(tool_msgs)
model_counts[s.get("model", "unknown")] += 1
provider_counts[s.get("provider", "anthropic")] += 1
status_counts[s.get("status", "unknown")] += 1
# Duration
created = s.get("created_at")
closed = s.get("closed_at")
if created and closed:
try:
c_str = created[:19]
cl_str = closed[:19]
dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
dur = (datetime.fromisoformat(closed[:19]) - datetime.fromisoformat(created[:19])).total_seconds()
if dur > 0:
total_duration += dur
except Exception:
pass
# Count individual tools
for m in tool_msgs:
content = m.get("content", {})
if isinstance(content, dict):
@@ -297,11 +306,9 @@ async def usage_summary():
completed = status_counts.get("completed", 0)
completion_rate = completed / total_sessions if total_sessions > 0 else 0
# Fetch 9Router usage data for accurate cost/token tracking
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
nine_router_stats = await get_usage_stats() if _9r_running() else None
# Determine best cost source
if nine_router_stats and nine_router_stats.get("totalCost", 0) > 0:
cost_source = "9router"
total_cost = nine_router_stats["totalCost"]
@@ -312,7 +319,6 @@ async def usage_summary():
avg_cost = total_cost / total_sessions if total_sessions > 0 else 0
# Extract 9Router breakdowns
cost_by_model = {}
cost_by_provider = {}
total_prompt_tokens = 0
@@ -348,7 +354,6 @@ async def usage_summary():
"providers_used": dict(provider_counts.most_common(10)),
"top_tools": dict(tool_counts.most_common(15)),
"status_breakdown": dict(status_counts),
# 9Router enrichment
"total_prompt_tokens": total_prompt_tokens,
"total_completion_tokens": total_completion_tokens,
"cost_by_model": cost_by_model,
@@ -359,9 +364,8 @@ async def usage_summary():
}
@analytics.router.get("/cost-breakdown")
@service.router.get("/cost-breakdown")
async def cost_breakdown(period: str = "7d"):
"""Get detailed cost breakdown from 9Router."""
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
if not _9r_running():
return {"available": False, "by_model": {}, "by_provider": {}}
@@ -380,18 +384,47 @@ async def cost_breakdown(period: str = "7d"):
}
@analytics.router.get("/status")
async def analytics_status():
return {"status": "posthog", "enabled": True}
@service.router.get("/status")
async def service_status():
return {"status": "ok", "enabled": True}
@analytics.router.post("/event")
async def record_event(body: dict):
"""Accept analytics events from the frontend (e.g. feature.time_spent)."""
event_type = body.get("event_type", "")
properties = body.get("properties", {})
if event_type:
record(event_type, properties,
session_id=body.get("session_id"),
dashboard_id=body.get("dashboard_id"))
# ---------------------------------------------------------------------------
# Frontend event endpoints
# ---------------------------------------------------------------------------
@service.router.post("/submit")
async def post_submit(body: dict):
kind = body.get("kind") or ""
payload = body.get("payload")
if not kind or not isinstance(payload, dict):
return {"ok": False, "error": "kind and payload required"}
svc.sync(payload)
return {"ok": True}
@service.router.post("/event")
async def post_event(body: dict):
surface = body.get("surface") or body.get("event_type") or ""
action = body.get("action") or ""
# Legacy path: frontend sends {event_type: "foo.bar", properties: {...}}
if not action and "." in surface:
surface, action = surface.split(".", 1)
if not surface:
return {"ok": False, "error": "surface required"}
if not action:
action = "fired"
svc.sync({
"s": str(surface)[:64],
"a": str(action)[:64],
"p": body.get("props") or body.get("properties") or {},
})
return {"ok": True}
@service.router.get("/spool/count")
async def spool_count():
from backend.apps.service import buffer
return {"pending": buffer.count(svc._spool_path())}
+7 -29
View File
@@ -136,43 +136,21 @@ async def get_settings():
@settings.router.put("")
async def update_settings(body: AppSettings):
from backend.apps.analytics.collector import record as _analytics
from backend.apps.service.client import sync as _sync
old = load_settings()
# Track provider key changes
provider_keys = {
"anthropic_api_key": "anthropic",
"openai_api_key": "openai",
"google_api_key": "gemini",
"openrouter_api_key": "openrouter",
}
for key, provider_name in provider_keys.items():
old_val = bool(getattr(old, key, None))
new_val = bool(getattr(body, key, None))
if old_val != new_val:
_analytics("provider.configured", {
"provider": provider_name,
"action": "added" if new_val else "removed",
})
# Track settings changes (key names only, not values)
old_dict = old.model_dump()
new_dict = body.model_dump()
# Sync the settings state (secrets stripped).
secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
"installation_id"}
safe_changed = [
k for k in new_dict
if k in old_dict and new_dict[k] != old_dict[k] and k not in secret_keys
]
if safe_changed:
_analytics("settings.changed", {"changed_keys": safe_changed})
"openswarm_bearer_token", "installation_id"}
safe = {k: v for k, v in body.model_dump().items() if k not in secret_keys}
_sync(safe)
# Identify user in PostHog when profile is set/changed
# Identify user in service-sync when profile is set/changed
if (body.user_email and body.user_email != getattr(old, "user_email", None)) or \
(body.user_name and body.user_name != getattr(old, "user_name", None)):
from backend.apps.analytics.collector import identify as _identify
from backend.apps.service.client import identify as _identify
id_props = {}
if body.user_email:
id_props["email"] = body.user_email
+1 -2
View File
@@ -163,8 +163,7 @@ async def create_skill(body: SkillCreate):
file_path=fpath,
command=body.command or slug,
)
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "skill.created"})
pass
return {"ok": True, "skill": skill.model_dump()}
+10 -14
View File
@@ -52,12 +52,12 @@ async def _clear_subscription(settings_obj) -> None:
def _sync_subscription_identity(settings_obj) -> None:
"""Push the installation's current subscription state into PostHog person
"""Push the installation's current subscription state into service-sync person
properties so every event from this user is segmentable by plan /
paying-vs-free. Safe to call from hot paths PostHog is fire-and-forget
paying-vs-free. Safe to call from hot paths service-sync is fire-and-forget
and swallows errors internally."""
try:
from backend.apps.analytics.collector import identify as _identify
from backend.apps.service.client import identify as _identify
except Exception:
return
mode = getattr(settings_obj, "connection_mode", "own_key")
@@ -231,16 +231,16 @@ async def sync():
No-op when not in openswarm-pro mode. Best-effort: network failures are
swallowed the caller still gets a 200 with whatever local state we
already had."""
# Lazy-import the PostHog helper so subscription/router doesn't pay the
# Lazy-import the service-sync helper so subscription/router doesn't pay the
# cost when analytics are disabled.
from backend.apps.analytics.collector import record as _record
from backend.apps.service.client import sync as _sync
settings_obj = load_settings()
bearer = getattr(settings_obj, "openswarm_bearer_token", None)
mode = getattr(settings_obj, "connection_mode", "own_key")
if mode != "openswarm-pro" or not bearer:
_record("subscription.sync_ran", {"reason": "no_bearer"})
_sync(settings_obj.model_dump())
return {"ok": True, "synced": False, "connection_mode": mode}
try:
@@ -251,7 +251,7 @@ async def sync():
)
except httpx.HTTPError as e:
logger.debug("subscription/sync live fetch failed: %s", e)
_record("subscription.sync_ran", {"reason": "network"})
_sync(settings_obj.model_dump())
return {"ok": True, "synced": False, "reason": "network"}
# Same 401/402 handling as /status: if Stripe-side reconciliation proves
@@ -260,7 +260,7 @@ async def sync():
if r.status_code in (401, 402):
await _clear_subscription(settings_obj)
reason = "revoked" if r.status_code == 401 else "expired"
_record("subscription.sync_ran", {"reason": reason})
_sync(settings_obj.model_dump())
return {
"ok": True,
"synced": False,
@@ -270,7 +270,7 @@ async def sync():
if r.status_code != 200:
logger.debug("subscription/sync got %s from cloud: %s", r.status_code, r.text[:200])
_record("subscription.sync_ran", {"reason": "upstream", "status_code": r.status_code})
_sync(settings_obj.model_dump())
return {"ok": True, "synced": False, "reason": "upstream"}
data = r.json()
@@ -288,11 +288,7 @@ async def sync():
)
await save_settings_async(settings_obj)
_sync_subscription_identity(settings_obj)
_record("subscription.sync_ran", {
"reason": "ok",
"synced": bool(data.get("synced")),
"plan": cloud_plan,
})
_sync(settings_obj.model_dump())
return {
"ok": True,
"synced": bool(data.get("synced")),
+214 -13
View File
@@ -166,6 +166,128 @@ def _resolve_openai_api_key() -> str | None:
return None
# Cache of which 9Router subscriptions are connected. Refreshed via
# `_refresh_9r_connected()` rather than hit on every search call —
# 9Router's /api/providers is fast but not free, and we already
# query it from many places.
_NINE_ROUTER_CONNECTED: set[str] = set()
_NINE_ROUTER_CACHE_AT: float = 0.0
async def _refresh_9r_connected() -> set[str]:
"""Return the set of currently-active 9Router subscription providers
(e.g. {"claude", "codex", "antigravity", "gemini-cli"}). Cached for
20s to keep search/fetch endpoints snappy."""
global _NINE_ROUTER_CONNECTED, _NINE_ROUTER_CACHE_AT
import time as _t
now = _t.time()
if now - _NINE_ROUTER_CACHE_AT < 20.0:
return _NINE_ROUTER_CONNECTED
try:
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
if not _9r_running():
_NINE_ROUTER_CONNECTED = set()
else:
conns = await _9r_providers()
_NINE_ROUTER_CONNECTED = {
c.get("provider")
for c in conns
if isinstance(c, dict) and c.get("isActive") and c.get("provider")
}
_NINE_ROUTER_CACHE_AT = now
except Exception:
# Cache stays — best-effort.
pass
return _NINE_ROUTER_CONNECTED
async def _gemini_grounded_via_9router(prompt: str, use_url_context: bool) -> dict:
"""Call 9Router's /v1/messages endpoint with a Gemini model so the
user's OAuth subscription (Gemini CLI or Antigravity) covers the
search call instead of needing a separate AI Studio API key.
Routes through Anthropic-shape against 9Router's translator. We
request a tool result naturally the translator surfaces grounded
URIs as text + cited sources in the response body. Format-shape
matches the existing `_gemini_grounded_call` so downstream
`_format_grounded_as_search_results` works unchanged."""
import httpx
# Prefer Gemini CLI (broader model coverage). Fall back to
# Antigravity if CLI isn't connected.
connected = await _refresh_9r_connected()
if "gemini-cli" in connected:
model = "gc/gemini-2.5-flash"
elif "antigravity" in connected:
model = "ag/gemini-3-flash"
else:
return {}
sys_prompt = (
"You search the web and return concise grounded answers with "
"source citations. Always cite the URLs you used."
if not use_url_context
else "You fetch URLs and return concise summaries with citations."
)
body = {
"model": model,
"max_tokens": 1024,
"system": sys_prompt,
"messages": [{"role": "user", "content": prompt}],
}
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.post(
"http://localhost:20128/v1/messages",
json=body,
headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"},
)
if r.status_code != 200:
return {}
data = r.json()
# Synthesize a grounded shape so the existing formatter works:
# _format_grounded_as_search_results expects {"text": str, "chunks":
# [(title, uri), ...]}. 9Router doesn't surface citations as a
# structured field uniformly across providers, so we hand back
# text-only and let the formatter do its thing.
text = ""
for block in (data.get("content") or []):
if isinstance(block, dict) and block.get("type") == "text":
text += block.get("text", "")
return {"text": text, "chunks": []}
async def _openai_websearch_via_9router(query: str) -> dict:
"""Same idea, but for OpenAI's web_search_preview through Codex's
9Router connection. Goes through 9Router's openai-compat endpoint
(the responses API) so the user's Codex subscription covers it."""
import httpx
connected = await _refresh_9r_connected()
if "codex" not in connected:
return {}
body = {
"model": "cx/gpt-5.4-mini",
"max_tokens": 1024,
"system": (
"You search the web and return concise grounded answers "
"with source citations. Always cite the URLs you used."
),
"messages": [{"role": "user", "content": f"Search the web for: {query}"}],
}
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.post(
"http://localhost:20128/v1/messages",
json=body,
headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"},
)
if r.status_code != 200:
return {}
data = r.json()
text = ""
for block in (data.get("content") or []):
if isinstance(block, dict) and block.get("type") == "text":
text += block.get("text", "")
return {"text": text, "chunks": []}
async def _openai_websearch(api_key: str, query: str) -> dict:
"""Call OpenAI Responses API with the web_search_preview tool.
@@ -284,14 +406,55 @@ async def search(body: SearchBody) -> dict:
"backend": "openai_native",
}
# Ordered cascade: primary's native path first, then the other
# native paths, then DDG.
async def try_gemini_subscription():
prompt = (
f"Search the web for: {body.query}\n\n"
f"Return a concise summary of what you found. Cite sources."
)
grounded = await _gemini_grounded_via_9router(prompt, use_url_context=False)
if not grounded.get("text"):
return None
return {
"query": body.query,
"results": _format_grounded_as_search_results(grounded, body.query),
"backend": "gemini_subscription",
}
async def try_openai_subscription():
grounded = await _openai_websearch_via_9router(body.query)
if not grounded.get("text"):
return None
return {
"query": body.query,
"results": _format_grounded_as_search_results(grounded, body.query),
"backend": "openai_subscription",
}
# Ordered cascade: primary's native API key first (most direct), then
# the user's connected subscriptions (free via OAuth), then the
# opposite-provider native key, then DuckDuckGo last as a guaranteed
# fallback (which is rate-limit-prone but free).
if primary == "openai":
cascade = [("openai", try_openai), ("gemini", try_gemini)]
cascade = [
("openai_native", try_openai),
("openai_subscription", try_openai_subscription),
("gemini_native", try_gemini),
("gemini_subscription", try_gemini_subscription),
]
elif primary in ("gemini", "google"):
cascade = [("gemini", try_gemini), ("openai", try_openai)]
cascade = [
("gemini_native", try_gemini),
("gemini_subscription", try_gemini_subscription),
("openai_native", try_openai),
("openai_subscription", try_openai_subscription),
]
else:
cascade = [("gemini", try_gemini), ("openai", try_openai)]
cascade = [
("gemini_native", try_gemini),
("gemini_subscription", try_gemini_subscription),
("openai_native", try_openai),
("openai_subscription", try_openai_subscription),
]
for name, fn in cascade:
try:
@@ -314,12 +477,20 @@ async def search(body: SearchBody) -> dict:
text = _join_text(parts)
hint = ""
if text.startswith("No search results found") and not (gemini_key or openai_key):
hint = (
"\n\n(DuckDuckGo returned no results — likely rate-limiting this IP. "
"Add a Gemini key (https://aistudio.google.com/apikey) or OpenAI key "
"in Settings for reliable native search.)"
)
if text.startswith("No search results found"):
connected = await _refresh_9r_connected()
has_subscription = bool(connected & {"codex", "antigravity", "gemini-cli"})
if not (gemini_key or openai_key or has_subscription):
hint = (
"\n\n(DuckDuckGo returned no results — likely rate-limiting this IP. "
"Connect Codex / Antigravity / Gemini CLI in Settings, or add an "
"OpenAI / Gemini API key, for reliable native search.)"
)
else:
hint = (
"\n\n(DuckDuckGo returned no results and the connected providers "
"didn't return useful results either — try rephrasing the query.)"
)
return {
"query": body.query,
"results": text + hint,
@@ -361,10 +532,40 @@ async def fetch(body: FetchBody) -> dict:
"backend": "openai_native",
}
async def try_gemini_subscription():
prompt_bits = [f"Fetch and summarize this URL: {body.url}"]
if body.prompt:
prompt_bits.append(f"Focus on: {body.prompt}")
grounded = await _gemini_grounded_via_9router(
"\n".join(prompt_bits), use_url_context=True,
)
if not grounded.get("text"):
return None
return {
"url": body.url,
"content": _format_grounded_as_fetch(grounded, body.url),
"backend": "gemini_subscription",
}
async def try_openai_subscription():
# Codex's web_search is general; URL fetch via search query
# works adequately for our use.
prompt = f"Fetch this URL and summarize: {body.url}"
if body.prompt:
prompt += f"\nFocus on: {body.prompt}"
grounded = await _openai_websearch_via_9router(prompt)
if not grounded.get("text"):
return None
return {
"url": body.url,
"content": _format_grounded_as_fetch(grounded, body.url),
"backend": "openai_subscription",
}
if primary == "openai":
cascade = [try_openai, try_gemini]
cascade = [try_openai, try_openai_subscription, try_gemini, try_gemini_subscription]
else:
cascade = [try_gemini, try_openai]
cascade = [try_gemini, try_gemini_subscription, try_openai, try_openai_subscription]
for fn in cascade:
try:
+16 -18
View File
@@ -37,7 +37,7 @@ from backend.apps.mcp_registry.mcp_registry import mcp_registry
from backend.apps.skill_registry.skill_registry import skill_registry
from backend.apps.outputs.outputs import outputs
from backend.apps.dashboards.dashboards import dashboards
from backend.apps.analytics.analytics import analytics
from backend.apps.service.service import service
from backend.apps.subscription.router import subscription
from backend.apps.web.web import web
from backend.apps.agents.anthropic_proxy import anthropic_proxy
@@ -45,7 +45,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi import WebSocket, WebSocketDisconnect
import json
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, analytics, subscription, web, anthropic_proxy])
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, service, subscription, web, anthropic_proxy])
app = main_app.app
# Generate per-install auth token BEFORE we bind the HTTP port. By the
@@ -70,6 +70,8 @@ app.add_middleware(
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"https://api.openswarm.com",
"https://openswarm.com",
],
allow_origin_regex=r"^(file://.*|http://localhost:\d+|http://127\.0\.0\.1:\d+)$",
allow_credentials=True,
@@ -518,6 +520,16 @@ async def mcp_meta(action: str, request: Request):
session.active_mcps.append(server_name)
session.needs_fork = True
# When the session has prior turns, fork_session alone won't
# make the bundled CLI re-read mcp_servers — the transport
# snapshot at launch time is what serves tool schemas. Force a
# full fresh-session restart so the next turn rebuilds with the
# newly-activated server in its mcp_servers dict from scratch.
# First-turn activations don't need this (the SDK session hasn't
# locked in yet). One-time ~200-400ms cold start on the auto-
# continuation turn that fires right after this anyway.
if session.sdk_session_id:
session.needs_fresh_session = True
try:
from backend.apps.agents.ws_manager import ws_manager as _ws
await _ws.send_to_session(parent_session_id, "agent:status", {
@@ -527,14 +539,7 @@ async def mcp_meta(action: str, request: Request):
})
except Exception:
logger.exception("Failed to broadcast post-activate session status")
try:
from backend.apps.analytics.collector import record as _analytics
_analytics("mcp.activated", {
"server_name": server_name,
"reason_len": len(reason),
}, session_id=parent_session_id, dashboard_id=session.dashboard_id)
except Exception:
pass
pass # MCP activation captured via session dump on close
# Auto-continue: flag the session so that after its current turn
# ends (which is the turn that contains this MCPActivate tool
@@ -715,14 +720,7 @@ async def outputs_meta(action: str, request: Request):
})
except Exception:
logger.exception("Failed to broadcast post-activate session status")
try:
from backend.apps.analytics.collector import record as _analytics
_analytics("output.activated", {
"output_id": output_id,
"reason_len": len(reason),
}, session_id=parent_session_id, dashboard_id=session.dashboard_id)
except Exception:
pass
pass # Output activation captured via session dump on close
return JSONResponse({"status": "activated", "output_id": output_id})
return JSONResponse({"error": f"unknown action: {action}"}, status_code=400)
@@ -235,6 +235,34 @@
"scopes": ["MailboxSettings.ReadWrite"],
"llmTip": "Deletes a message rule permanently. Use the Inbox folder ID (get it from list-mail-folders) for inbox rules."
},
{
"pathPattern": "/me/inferenceClassification/overrides",
"method": "get",
"toolName": "list-focused-inbox-overrides",
"scopes": ["Mail.Read"],
"llmTip": "Lists Focused Inbox classification overrides — explicit rules that force messages from a given sender (by SMTP address) into either the Focused or Other tab, regardless of what the Outlook ML classifier would predict. Each override has id, classifyAs ('focused' or 'other'), and senderEmailAddress {name, address}. Returns an empty collection if the user has never set an override."
},
{
"pathPattern": "/me/inferenceClassification/overrides",
"method": "post",
"toolName": "create-focused-inbox-override",
"scopes": ["Mail.ReadWrite"],
"llmTip": "Creates a Focused Inbox override for a sender. Body: { classifyAs: 'focused', senderEmailAddress: { name: 'Display Name', address: 'sender@example.com' } }. classifyAs must be 'focused' or 'other'. If an override already exists for that SMTP address, POST updates the existing override's name and classifyAs (use this to rename a sender). Resolve the sender's address with list-users or by reading a recent mail header — do not invent SMTP addresses."
},
{
"pathPattern": "/me/inferenceClassification/overrides/{inferenceClassificationOverride-id}",
"method": "patch",
"toolName": "update-focused-inbox-override",
"scopes": ["Mail.ReadWrite"],
"llmTip": "Updates the classifyAs field of an existing override. Body: { classifyAs: 'focused' } or { classifyAs: 'other' }. Per Graph API, PATCH cannot change senderEmailAddress — to change the SMTP address, delete and recreate the override. To rename the display name only, POST a new override with the same SMTP address (it will overwrite the name)."
},
{
"pathPattern": "/me/inferenceClassification/overrides/{inferenceClassificationOverride-id}",
"method": "delete",
"toolName": "delete-focused-inbox-override",
"scopes": ["Mail.ReadWrite"],
"llmTip": "Deletes a Focused Inbox override. Future messages from that sender revert to the Outlook ML classifier's default behavior. Use list-focused-inbox-overrides to find the ID first."
},
{
"pathPattern": "/me/events",
"method": "get",
@@ -521,6 +549,13 @@
"scopes": ["Files.Read"],
"llmTip": "Generate a short-lived embeddable preview URL for a file (Office docs, PDFs, images). Body: { page?: number | string, zoom?: number, viewer?: 'onedrive' | 'office' }. Returns getUrl (interactive) and postUrl (form-post). Useful for surfacing inline previews in summary emails or chat messages without needing the recipient to open the file."
},
{
"pathPattern": "/drives/{drive-id}/items/{driveItem-id}/thumbnails",
"method": "get",
"toolName": "list-drive-item-thumbnails",
"scopes": ["Files.Read"],
"llmTip": "Lists thumbnail sets for a file. Each set contains small (96px), medium (176px), large (800px) thumbnails with url and dimensions. Returns empty for unsupported types (text docs). Use $select=small,medium,large or $expand=small($select=url) to fetch specific sizes. The returned URLs are short-lived — fetch the bytes immediately."
},
{
"pathPattern": "/drives/{drive-id}/items/{driveItem-id}/permissions",
"method": "get",
@@ -1373,6 +1408,48 @@
"workScopes": ["Sites.ReadWrite.All"],
"llmTip": "Deletes a list item permanently. This cannot be undone — the item is moved to the site recycle bin."
},
{
"pathPattern": "/sites/{site-id}/lists",
"method": "post",
"toolName": "create-sharepoint-list",
"workScopes": ["Sites.Manage.All"],
"llmTip": "Creates a new SharePoint list in a site. Body: { displayName: 'My List', description: 'Optional', list: { template: 'genericList' }, columns: [ { name: 'Status', text: {} }, { name: 'Due', dateTime: {} } ] }. Templates include genericList, documentLibrary, tasks, calendar, contacts, links, announcements, survey. Columns can be defined inline at creation; otherwise add them later via create-sharepoint-list-column. Use search-sharepoint-sites or get-sharepoint-site-by-path to find the site ID first."
},
{
"pathPattern": "/sites/{site-id}/lists/{list-id}/columns",
"method": "get",
"toolName": "list-sharepoint-list-columns",
"workScopes": ["Sites.Read.All"],
"llmTip": "Lists column definitions for a SharePoint list. Returns each column's id, name, displayName, description, type indicator (text, number, choice, dateTime, person, lookup, boolean, calculated, hyperlinkOrPicture, etc.), required, indexed, hidden, readOnly. Use this to discover the schema before creating or updating list items."
},
{
"pathPattern": "/sites/{site-id}/lists/{list-id}/columns",
"method": "post",
"toolName": "create-sharepoint-list-column",
"workScopes": ["Sites.Manage.All"],
"llmTip": "Creates a new column on a SharePoint list. Body must include name and exactly one column type property: { name: 'Priority', text: {} } or { name: 'DueDate', dateTime: { format: 'dateOnly' } } or { name: 'Status', choice: { choices: ['Open','In Progress','Done'] } }. Other types: number, boolean, currency, hyperlinkOrPicture, personOrGroup, lookup, calculated. Optional: displayName, description, required, indexed, enforceUniqueValues."
},
{
"pathPattern": "/sites/{site-id}/lists/{list-id}/columns/{columnDefinition-id}",
"method": "get",
"toolName": "get-sharepoint-list-column",
"workScopes": ["Sites.Read.All"],
"llmTip": "Gets a specific column definition by ID, including its full type configuration (choices for choice columns, format for dateTime, etc.). Use list-sharepoint-list-columns first to find the column ID."
},
{
"pathPattern": "/sites/{site-id}/lists/{list-id}/columns/{columnDefinition-id}",
"method": "patch",
"toolName": "update-sharepoint-list-column",
"workScopes": ["Sites.Manage.All"],
"llmTip": "Updates a column definition. Body: { displayName: 'New name', description: 'New description', required: true, ... }. The column type itself (text, choice, etc.) cannot be changed — only its metadata and per-type options (e.g. choices array for a choice column). Send only the fields you want to change."
},
{
"pathPattern": "/sites/{site-id}/lists/{list-id}/columns/{columnDefinition-id}",
"method": "delete",
"toolName": "delete-sharepoint-list-column",
"workScopes": ["Sites.Manage.All"],
"llmTip": "Deletes a column from a SharePoint list. This is irreversible — all data stored in this column across every list item is lost. Confirm with the user before calling. Cannot delete built-in columns (Title, Created, Modified, etc.)."
},
{
"pathPattern": "/sites/{site-id}/getByPath(path='{path}')",
"method": "get",
@@ -1757,5 +1834,33 @@
"toolName": "get-sensitivity-label",
"workScopes": ["SensitivityLabel.Read"],
"llmTip": "Gets a single MIP sensitivity label by id. Use list-sensitivity-labels to find ids. Not supported for personal Microsoft accounts."
},
{
"pathPattern": "/me/messages/{message-id}/copy",
"method": "post",
"toolName": "copy-mail-message",
"scopes": ["Mail.ReadWrite"],
"llmTip": "Copies a message to another mail folder. Body: { DestinationId: '<mailFolder-id or well-known name like inbox, archive, junkemail>' }. Returns the newly created message (with a new id) in the destination folder. For moving instead of copying, use move-mail-message."
},
{
"pathPattern": "/me/mailFolders/{mailFolder-id}/messages/delta()",
"method": "get",
"toolName": "list-mail-folder-messages-delta",
"scopes": ["Mail.Read"],
"llmTip": "Incremental sync of messages within a mail folder. Graph only supports delta scoped to a folder — use mailFolder-id = 'inbox' for the well-known inbox, or another folder id from list-mail-folders. First call returns all messages plus @odata.deltaLink; subsequent calls with that link return only changes (created/updated/deleted). @odata.nextLink paginates within a single delta window. Deltas expire after ~30 days of inactivity — start over if the server returns 410. Prefer this over full re-list for polling."
},
{
"pathPattern": "/me/outlook/masterCategories",
"method": "get",
"toolName": "list-outlook-categories",
"scopes": ["MailboxSettings.Read"],
"llmTip": "Lists the user's Outlook categories (colored labels) used to tag messages, events, contacts, and tasks. Each category has displayName and color (preset0 through preset24, or 'none'). Use this to show available tags before applying via update-mail-message or update-calendar-event with body { categories: ['Category Name'] }."
},
{
"pathPattern": "/me/outlook/masterCategories",
"method": "post",
"toolName": "create-outlook-category",
"scopes": ["MailboxSettings.ReadWrite"],
"llmTip": "Creates a new Outlook category. Body: { displayName (unique), color (one of: none, preset0 … preset24 — maps to red, orange, yellow, green, teal, olive, blue, purple, cranberry, steel, dark-steel, gray, dark-gray, black, dark-red, dark-orange, dark-yellow, dark-green, dark-teal, dark-olive, dark-blue, dark-purple, dark-cranberry) }. Category names are case-sensitive when applied to messages/events."
}
]
File diff suppressed because it is too large Load Diff
@@ -1 +1 @@
{"name":"@softeria/ms-365-mcp-server","version":"0.90.0"}
{"name":"@softeria/ms-365-mcp-server","version":"0.95.0"}
+1 -4
View File
@@ -9,14 +9,11 @@
anthropic==0.97.0
claude-agent-sdk==0.1.70
jsonschema
fastapi[standard]
fastapi[standard-no-fastapi-cloud-cli]
pydantic==2.13.3
langchain-core==0.3.51
langchain-openai==0.3.12
typeguard==4.4.2
python-dotenv==1.1.1
Pillow
posthog
httpx>=0.27.0
trafilatura
# Test deps (pytest, pytest-asyncio) live in requirements-dev.txt — they
-971
View File
@@ -1,971 +0,0 @@
"""Comprehensive stress tests for PostHog analytics events.
Tests every analytics event fires correctly with proper properties.
Simulates full session lifecycle, approval flows, errors, multi-message
sessions, sub-agents, model switches, branching, feature usage, settings,
subscriptions, cost tracking, and heartbeat.
Run with:
cd backend && python -m pytest tests/test_analytics.py -v
"""
import asyncio
import json
import os
import sys
import tempfile
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch, call
from uuid import uuid4
import pytest
# ---------------------------------------------------------------------------
# Patch PostHog and settings BEFORE importing application modules
# ---------------------------------------------------------------------------
# Create a temp dir for settings/sessions
_tmpdir = tempfile.mkdtemp()
os.environ.setdefault("OPENSWARM_DATA_DIR", _tmpdir)
# Patch PostHog globally
_captured_events: list[dict] = []
def _mock_capture(event_type, distinct_id, properties=None):
_captured_events.append({
"event": event_type,
"distinct_id": distinct_id,
"properties": properties or {},
})
@pytest.fixture(autouse=True)
def reset_captured_events():
_captured_events.clear()
yield
_captured_events.clear()
@pytest.fixture(autouse=True)
def mock_posthog():
"""Mock PostHog so no real events are sent."""
mock_ph = MagicMock()
mock_ph.capture = _mock_capture
import backend.apps.analytics.collector as collector
old_ph = collector._posthog
old_id = collector._installation_id
collector._posthog = mock_ph
collector._installation_id = "test-install-id"
yield mock_ph
collector._posthog = old_ph
collector._installation_id = old_id
@pytest.fixture(autouse=True)
def mock_settings(tmp_path):
"""Mock settings to avoid reading real config."""
settings_file = tmp_path / "settings.json"
settings_file.write_text(json.dumps({
"analytics_opt_in": True,
"installation_id": "test-install-id",
}))
import backend.apps.settings.settings as settings_mod
old_file = settings_mod.SETTINGS_FILE
settings_mod.SETTINGS_FILE = str(settings_file)
yield
settings_mod.SETTINGS_FILE = old_file
@pytest.fixture(autouse=True)
def mock_sessions_dir(tmp_path):
"""Use temp dir for session persistence."""
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
import backend.config.paths as paths_mod
old_dir = paths_mod.SESSIONS_DIR
paths_mod.SESSIONS_DIR = str(sessions_dir)
yield str(sessions_dir)
paths_mod.SESSIONS_DIR = old_dir
def events(event_type: str | None = None) -> list[dict]:
"""Return captured events, optionally filtered by type."""
if event_type:
return [e for e in _captured_events if e["event"] == event_type]
return list(_captured_events)
def last_event(event_type: str) -> dict:
"""Return the last captured event of a given type."""
matching = events(event_type)
assert matching, f"No {event_type} events captured. Got: {[e['event'] for e in _captured_events]}"
return matching[-1]
# ===========================================================================
# Import application modules (after patches are set up)
# ===========================================================================
from backend.apps.analytics.collector import record
from backend.apps.agents.models import AgentConfig, AgentSession, Message, ApprovalRequest
from backend.apps.agents.agent_manager import AgentManager
@pytest.fixture
def manager():
"""Create a fresh AgentManager for each test."""
mgr = AgentManager()
return mgr
# ===========================================================================
# 1. record() basics
# ===========================================================================
class TestRecordBasics:
def test_record_sends_event(self):
record("test.event", {"key": "value"})
e = last_event("test.event")
assert e["properties"]["key"] == "value"
assert e["distinct_id"] == "test-install-id"
def test_record_adds_os_and_platform(self):
record("test.event", {})
e = last_event("test.event")
assert "os" in e["properties"]
assert "platform" in e["properties"]
def test_record_includes_session_id(self):
record("test.event", {}, session_id="sess123")
e = last_event("test.event")
assert e["properties"]["session_id"] == "sess123"
def test_record_includes_dashboard_id(self):
record("test.event", {}, dashboard_id="dash456")
e = last_event("test.event")
assert e["properties"]["dashboard_id"] == "dash456"
# ===========================================================================
# 2. session.started fires ONCE on launch
# ===========================================================================
class TestSessionStarted:
@pytest.mark.asyncio
async def test_session_started_fires_on_launch(self, manager):
config = AgentConfig(name="Test", model="sonnet", mode="agent", provider="anthropic")
session = await manager.launch_agent(config)
e = last_event("session.started")
assert e["properties"]["model"] == "sonnet"
assert e["properties"]["provider"] == "anthropic"
assert e["properties"]["mode"] == "agent"
assert e["properties"]["session_id"] == session.id
assert isinstance(e["properties"]["tool_count"], int)
@pytest.mark.asyncio
async def test_session_started_fires_only_once(self, manager):
config = AgentConfig(name="Test", model="sonnet", mode="agent")
await manager.launch_agent(config)
started_events = events("session.started")
assert len(started_events) == 1
# ===========================================================================
# 3. session.completed fires ONCE on close (NOT per message)
# ===========================================================================
class TestSessionCompleted:
@pytest.mark.asyncio
async def test_session_completed_fires_on_close(self, manager):
config = AgentConfig(name="Test Session", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
# Add some messages to simulate activity
session.messages.append(Message(role="user", content="hello"))
session.messages.append(Message(role="assistant", content="hi there"))
session.cost_usd = 0.05
session.tokens = {"input": 1000, "output": 500}
session.status = "completed"
await manager.close_session(session.id)
e = last_event("session.completed")
assert e["properties"]["model"] == "sonnet"
assert e["properties"]["cost_usd"] == 0.05
assert e["properties"]["message_count"] == 2
assert e["properties"]["input_tokens"] == 1000
assert e["properties"]["output_tokens"] == 500
assert e["properties"]["session_title"] == "Test Session"
assert e["properties"]["branch_count"] == 1 # main branch
assert e["properties"]["is_sub_agent"] is False
@pytest.mark.asyncio
async def test_session_completed_fires_exactly_once(self, manager):
config = AgentConfig(name="Test", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.status = "completed"
await manager.close_session(session.id)
completed_events = events("session.completed")
assert len(completed_events) == 1
@pytest.mark.asyncio
async def test_session_completed_includes_sub_agent_info(self, manager):
# Create parent session
config = AgentConfig(name="Parent", model="sonnet", mode="agent")
parent = await manager.launch_agent(config)
# Create child session
child = AgentSession(
id=uuid4().hex, name="Child", mode="browser-agent",
parent_session_id=parent.id, status="completed",
)
manager.sessions[child.id] = child
parent.status = "completed"
await manager.close_session(parent.id)
e = last_event("session.completed")
assert e["properties"]["sub_agent_count"] == 1
@pytest.mark.asyncio
async def test_session_completed_on_shutdown(self, manager):
config = AgentConfig(name="Shutdown Test", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.cost_usd = 0.10
await manager.persist_all_sessions()
e = last_event("session.completed")
assert e["properties"]["cost_usd"] == 0.10
assert e["properties"]["session_title"] == "Shutdown Test"
# ===========================================================================
# 4. session.error
# ===========================================================================
class TestSessionError:
def test_session_error_event_structure(self):
record("session.error", {
"error_type": "ValueError",
"error_message": "test error",
"model": "sonnet",
"provider": "anthropic",
"mode": "agent",
}, session_id="s1")
e = last_event("session.error")
assert e["properties"]["error_type"] == "ValueError"
assert e["properties"]["error_message"] == "test error"
assert e["properties"]["model"] == "sonnet"
# ===========================================================================
# 5. tool.executed
# ===========================================================================
class TestToolExecuted:
def test_builtin_tool(self):
record("tool.executed", {
"tool_name": "Bash",
"tool_short_name": "Bash",
"tool_type": "builtin",
"mcp_server": "",
"duration_ms": 150,
"success": True,
"model": "sonnet",
"provider": "anthropic",
}, session_id="s1")
e = last_event("tool.executed")
assert e["properties"]["tool_type"] == "builtin"
assert e["properties"]["mcp_server"] == ""
assert e["properties"]["tool_short_name"] == "Bash"
def test_mcp_tool_extracts_server_name(self):
record("tool.executed", {
"tool_name": "mcp__google-workspace__searchGmail",
"tool_short_name": "searchGmail",
"tool_type": "mcp",
"mcp_server": "google-workspace",
"duration_ms": 2000,
"success": True,
"model": "sonnet",
"provider": "anthropic",
}, session_id="s1")
e = last_event("tool.executed")
assert e["properties"]["tool_type"] == "mcp"
assert e["properties"]["mcp_server"] == "google-workspace"
assert e["properties"]["tool_short_name"] == "searchGmail"
def test_tool_failure_tracked(self):
record("tool.executed", {
"tool_name": "Bash",
"tool_short_name": "Bash",
"tool_type": "builtin",
"mcp_server": "",
"duration_ms": 50,
"success": False,
"model": "sonnet",
"provider": "anthropic",
}, session_id="s1")
e = last_event("tool.executed")
assert e["properties"]["success"] is False
# ===========================================================================
# 6. approval.requested + approval.resolved
# ===========================================================================
class TestApprovalEvents:
def test_approval_requested(self):
record("approval.requested", {
"tool_name": "Bash",
"is_first_approval_in_session": True,
"model": "sonnet",
}, session_id="s1")
e = last_event("approval.requested")
assert e["properties"]["tool_name"] == "Bash"
assert e["properties"]["is_first_approval_in_session"] is True
def test_approval_resolved_allow(self):
record("approval.resolved", {
"tool_name": "Bash",
"decision": "allow",
"latency_ms": 1500,
"input_was_modified": False,
"model": "sonnet",
}, session_id="s1")
e = last_event("approval.resolved")
assert e["properties"]["decision"] == "allow"
assert e["properties"]["latency_ms"] == 1500
assert e["properties"]["input_was_modified"] is False
def test_approval_resolved_deny(self):
record("approval.resolved", {
"tool_name": "Bash",
"decision": "deny",
"latency_ms": 500,
"input_was_modified": False,
"model": "sonnet",
}, session_id="s1")
e = last_event("approval.resolved")
assert e["properties"]["decision"] == "deny"
def test_approval_with_modified_input(self):
record("approval.resolved", {
"tool_name": "Bash",
"decision": "allow",
"latency_ms": 3000,
"input_was_modified": True,
"model": "sonnet",
}, session_id="s1")
e = last_event("approval.resolved")
assert e["properties"]["input_was_modified"] is True
# ===========================================================================
# 7. turn.completed
# ===========================================================================
class TestTurnCompleted:
def test_turn_completed(self):
record("turn.completed", {
"turn_number": 3,
"tool_calls_in_turn": 2,
"model": "sonnet",
}, session_id="s1")
e = last_event("turn.completed")
assert e["properties"]["turn_number"] == 3
assert e["properties"]["tool_calls_in_turn"] == 2
# ===========================================================================
# 8. model.switched
# ===========================================================================
class TestModelSwitched:
@pytest.mark.asyncio
async def test_model_switch_fires_event(self, manager):
config = AgentConfig(name="Test", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.messages.append(Message(role="user", content="msg1"))
session.cost_usd = 0.03
# Simulate model switch via send_message (which we can't fully run
# without SDK, so test the record call directly)
record("model.switched", {
"from_model": "sonnet",
"to_model": "opus",
"from_provider": "anthropic",
"to_provider": "anthropic",
"message_number": 1,
"cost_so_far": 0.03,
}, session_id=session.id)
e = last_event("model.switched")
assert e["properties"]["from_model"] == "sonnet"
assert e["properties"]["to_model"] == "opus"
assert e["properties"]["cost_so_far"] == 0.03
# ===========================================================================
# 9. session.resumed
# ===========================================================================
class TestSessionResumed:
@pytest.mark.asyncio
async def test_session_resumed(self, manager, mock_sessions_dir):
# Create and close a session
config = AgentConfig(name="Resume Test", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.messages.append(Message(role="user", content="hello"))
session.cost_usd = 0.05
session.status = "completed"
await manager.close_session(session.id)
_captured_events.clear()
# Resume it
resumed = await manager.resume_session(session.id)
e = last_event("session.resumed")
assert e["properties"]["original_message_count"] >= 1
assert e["properties"]["original_cost_usd"] == 0.05
assert e["properties"]["model"] == "sonnet"
assert "hours_since_closed" in e["properties"]
# ===========================================================================
# 10. context.attached
# ===========================================================================
class TestContextAttached:
def test_context_with_files(self):
record("context.attached", {
"file_count": 3,
"directory_count": 1,
"skill_count": 0,
"image_count": 2,
"has_forced_tools": True,
}, session_id="s1")
e = last_event("context.attached")
assert e["properties"]["file_count"] == 3
assert e["properties"]["image_count"] == 2
assert e["properties"]["has_forced_tools"] is True
# ===========================================================================
# 11. session.first_message
# ===========================================================================
class TestSessionFirstMessage:
def test_first_message_properties(self):
prompt = "```python\nprint('hello')\n```\nCheck https://example.com"
record("session.first_message", {
"message_length": len(prompt),
"has_code_block": "```" in prompt,
"has_url": "http://" in prompt or "https://" in prompt,
"model": "sonnet",
"mode": "agent",
}, session_id="s1")
e = last_event("session.first_message")
assert e["properties"]["has_code_block"] is True
assert e["properties"]["has_url"] is True
assert e["properties"]["message_length"] > 0
# ===========================================================================
# 12. feature.used (all variants)
# ===========================================================================
class TestFeatureUsed:
@pytest.mark.parametrize("feature", [
"message.branched",
"mode.switched",
"skill.used",
"skill.created",
"template.created",
"template.used",
"view.created",
"vibe_code.used",
"browser_agent.launched",
])
def test_feature_used_variants(self, feature):
record("feature.used", {"feature": feature}, session_id="s1")
e = last_event("feature.used")
assert e["properties"]["feature"] == feature
def test_branch_created_with_depth(self):
record("feature.used", {
"feature": "message.branched",
"branch_depth": 2,
"total_branches_in_session": 3,
"messages_before_fork": 5,
}, session_id="s1")
e = last_event("feature.used")
assert e["properties"]["branch_depth"] == 2
assert e["properties"]["total_branches_in_session"] == 3
def test_mode_switch_details(self):
record("feature.used", {
"feature": "mode.switched",
"from_mode": "agent",
"to_mode": "view-builder",
}, session_id="s1")
e = last_event("feature.used")
assert e["properties"]["from_mode"] == "agent"
assert e["properties"]["to_mode"] == "view-builder"
def test_browser_agent_with_task_count(self):
record("feature.used", {
"feature": "browser_agent.launched",
"task_count": 3,
"model": "sonnet",
})
e = last_event("feature.used")
assert e["properties"]["task_count"] == 3
# ===========================================================================
# 13. subscription events
# ===========================================================================
class TestSubscriptionEvents:
def test_subscription_connected(self):
record("subscription.connected", {"provider": "anthropic"})
e = last_event("subscription.connected")
assert e["properties"]["provider"] == "anthropic"
def test_subscription_disconnected(self):
record("subscription.disconnected", {"provider": "openai"})
e = last_event("subscription.disconnected")
assert e["properties"]["provider"] == "openai"
# ===========================================================================
# 14. provider.configured + settings.changed
# ===========================================================================
class TestSettingsEvents:
def test_provider_added(self):
record("provider.configured", {
"provider": "anthropic",
"action": "added",
})
e = last_event("provider.configured")
assert e["properties"]["action"] == "added"
def test_provider_removed(self):
record("provider.configured", {
"provider": "openai",
"action": "removed",
})
e = last_event("provider.configured")
assert e["properties"]["action"] == "removed"
def test_settings_changed(self):
record("settings.changed", {
"changed_keys": ["theme", "default_model", "zoom_sensitivity"],
})
e = last_event("settings.changed")
assert "theme" in e["properties"]["changed_keys"]
assert len(e["properties"]["changed_keys"]) == 3
def test_settings_changed_excludes_secrets(self):
# Verify that if we track changed keys, secret keys are excluded
record("settings.changed", {
"changed_keys": ["theme"],
})
e = last_event("settings.changed")
for secret in ["anthropic_api_key", "openai_api_key", "google_api_key",
"openrouter_api_key", "copilot_github_token"]:
assert secret not in e["properties"]["changed_keys"]
# ===========================================================================
# 15. cost.snapshot
# ===========================================================================
class TestCostSnapshot:
def test_cost_snapshot_structure(self):
record("cost.snapshot", {
"total_cost_usd": 42.50,
"total_prompt_tokens": 500000,
"total_completion_tokens": 150000,
"total_requests": 250,
})
e = last_event("cost.snapshot")
assert e["properties"]["total_cost_usd"] == 42.50
assert e["properties"]["total_prompt_tokens"] == 500000
assert e["properties"]["total_completion_tokens"] == 150000
assert e["properties"]["total_requests"] == 250
# ===========================================================================
# 16. app.heartbeat
# ===========================================================================
class TestAppHeartbeat:
def test_heartbeat_structure(self):
record("app.heartbeat", {
"active_session_count": 3,
"nine_router_total_cost": 100.50,
"nine_router_total_prompt_tokens": 1000000,
"nine_router_total_completion_tokens": 300000,
"nine_router_total_requests": 500,
})
e = last_event("app.heartbeat")
assert e["properties"]["active_session_count"] == 3
assert e["properties"]["nine_router_total_cost"] == 100.50
# ===========================================================================
# 17. app.opened (enhanced)
# ===========================================================================
class TestAppOpened:
def test_app_opened_structure(self):
record("app.opened", {
"os": "Darwin",
"platform": "macOS-14.0",
"provider_count": 2,
"providers": ["anthropic", "openai"],
"is_first_open": False,
"days_since_install": 5,
"app_version": "1.0.17",
})
e = last_event("app.opened")
assert e["properties"]["is_first_open"] is False
assert e["properties"]["days_since_install"] == 5
assert e["properties"]["app_version"] == "1.0.17"
assert e["properties"]["provider_count"] == 2
# ===========================================================================
# 18. Multi-message session does NOT fire session.completed multiple times
# ===========================================================================
class TestMultiMessageSession:
@pytest.mark.asyncio
async def test_no_session_completed_per_message(self, manager):
"""Verify session.completed does NOT fire when agent loop finishes.
It should only fire on close_session() or persist_all_sessions()."""
config = AgentConfig(name="Multi-msg", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
# Simulate 3 message exchanges
for i in range(3):
session.messages.append(Message(role="user", content=f"msg {i}"))
session.messages.append(Message(role="assistant", content=f"reply {i}"))
# At this point, no session.completed should have fired
completed = events("session.completed")
assert len(completed) == 0, f"session.completed fired {len(completed)} times before close!"
# Now close — exactly 1 session.completed
session.status = "completed"
await manager.close_session(session.id)
completed = events("session.completed")
assert len(completed) == 1, f"Expected 1 session.completed, got {len(completed)}"
# ===========================================================================
# 19. Token tracking
# ===========================================================================
class TestTokenTracking:
@pytest.mark.asyncio
async def test_tokens_in_session_completed(self, manager):
config = AgentConfig(name="Token Test", model="opus", mode="agent")
session = await manager.launch_agent(config)
# Simulate SDK token reporting
session.tokens = {"input": 50000, "output": 15000}
session.cost_usd = 0.25
session.status = "completed"
await manager.close_session(session.id)
e = last_event("session.completed")
assert e["properties"]["input_tokens"] == 50000
assert e["properties"]["output_tokens"] == 15000
assert e["properties"]["cost_usd"] == 0.25
# ===========================================================================
# 20. Full lifecycle integration test
# ===========================================================================
class TestFullLifecycle:
@pytest.mark.asyncio
async def test_complete_session_lifecycle(self, manager):
"""Simulate a complete user session: launch, messages, close."""
# 1. Launch
config = AgentConfig(
name="Full Lifecycle",
model="sonnet",
mode="agent",
provider="anthropic",
dashboard_id="dash-001",
)
session = await manager.launch_agent(config)
assert len(events("session.started")) == 1
# 2. Simulate messages
session.messages.append(Message(role="user", content="Hello, help me code"))
session.messages.append(Message(role="assistant", content="Sure, let me help"))
session.messages.append(Message(
role="tool_call",
content={"tool": "Bash", "input": {"command": "ls"}},
))
session.messages.append(Message(
role="tool_result",
content={"text": "file1.py\nfile2.py", "tool_name": "Bash", "elapsed_ms": 50},
))
session.messages.append(Message(role="user", content="Now run tests"))
session.messages.append(Message(role="assistant", content="Running tests..."))
session.cost_usd = 0.08
session.tokens = {"input": 20000, "output": 5000}
# 3. No session.completed yet
assert len(events("session.completed")) == 0
# 4. Close
session.status = "completed"
await manager.close_session(session.id)
# 5. Verify session.completed
e = last_event("session.completed")
assert e["properties"]["message_count"] == 4 # 2 user + 2 assistant
assert e["properties"]["tool_count"] == 1 # 1 tool call
assert "Bash" in e["properties"]["tools_list"]
assert e["properties"]["cost_usd"] == 0.08
assert e["properties"]["input_tokens"] == 20000
assert e["properties"]["output_tokens"] == 5000
assert e["properties"]["dashboard_id"] == "dash-001"
assert e["properties"]["first_user_message"] == "Hello, help me code"
assert e["properties"]["duration_seconds"] >= 0 # may be 0 in fast tests
@pytest.mark.asyncio
async def test_session_with_error(self, manager):
"""Verify error sessions still fire session.completed on close."""
config = AgentConfig(name="Error Test", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.status = "error"
await manager.close_session(session.id)
e = last_event("session.completed")
assert e["properties"]["status"] == "error"
@pytest.mark.asyncio
async def test_session_with_branches(self, manager):
"""Verify branch count in session.completed."""
config = AgentConfig(name="Branch Test", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
# Simulate branching
from backend.apps.agents.models import MessageBranch
session.branches["branch-1"] = MessageBranch(id="branch-1", parent_branch_id="main")
session.branches["branch-2"] = MessageBranch(id="branch-2", parent_branch_id="branch-1")
session.status = "completed"
await manager.close_session(session.id)
e = last_event("session.completed")
assert e["properties"]["branch_count"] == 3 # main + branch-1 + branch-2
# ===========================================================================
# 21. MCP server name extraction in tool.executed
# ===========================================================================
class TestMCPServerExtraction:
def test_standard_mcp_format(self):
"""Test mcp__server-name__tool_name format."""
import re
tool_name = "mcp__google-workspace__searchGmail"
m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name)
assert m is not None
assert m.group(1) == "google-workspace"
assert m.group(2) == "searchGmail"
def test_builtin_tool_no_server(self):
import re
tool_name = "Bash"
m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name)
assert m is None
def test_browser_agent_mcp_format(self):
import re
tool_name = "mcp__openswarm-browser-agent__CreateBrowserAgent"
m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name)
assert m is not None
assert m.group(1) == "openswarm-browser-agent"
assert m.group(2) == "CreateBrowserAgent"
# ===========================================================================
# 22. Settings update tracking
# ===========================================================================
class TestSettingsUpdateTracking:
@pytest.mark.asyncio
async def test_provider_key_change_detected(self):
"""Test that adding an API key fires provider.configured."""
from backend.apps.settings.models import AppSettings
old = AppSettings(anthropic_api_key=None)
new = AppSettings(anthropic_api_key="sk-test-key")
# Simulate what update_settings does
provider_keys = {
"anthropic_api_key": "anthropic",
"openai_api_key": "openai",
"google_api_key": "gemini",
"openrouter_api_key": "openrouter",
}
for key, provider_name in provider_keys.items():
old_val = bool(getattr(old, key, None))
new_val = bool(getattr(new, key, None))
if old_val != new_val:
record("provider.configured", {
"provider": provider_name,
"action": "added" if new_val else "removed",
})
e = last_event("provider.configured")
assert e["properties"]["provider"] == "anthropic"
assert e["properties"]["action"] == "added"
@pytest.mark.asyncio
async def test_settings_change_excludes_secrets(self):
"""Verify secret keys are not included in changed_keys."""
from backend.apps.settings.models import AppSettings
old = AppSettings(theme="dark", anthropic_api_key="old-key")
new = AppSettings(theme="light", anthropic_api_key="new-key")
old_dict = old.model_dump()
new_dict = new.model_dump()
secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key",
"openrouter_api_key", "claude_subscription_token",
"openai_subscription_token", "gemini_subscription_token",
"copilot_github_token", "copilot_token", "installation_id"}
safe_changed = [
k for k in new_dict
if k in old_dict and new_dict[k] != old_dict[k] and k not in secret_keys
]
assert "theme" in safe_changed
assert "anthropic_api_key" not in safe_changed
# ===========================================================================
# 23. Cost snapshot accuracy
# ===========================================================================
class TestCostSnapshotAccuracy:
def test_nine_router_cost_in_heartbeat(self):
"""Verify heartbeat includes 9Router cost data."""
record("app.heartbeat", {
"active_session_count": 2,
"nine_router_total_cost": 235.50,
"nine_router_total_prompt_tokens": 5000000,
"nine_router_total_completion_tokens": 1500000,
"nine_router_total_requests": 1200,
"cost_model_claude_sonnet_4_20250514": 180.00,
"cost_model_claude_opus_4_20250514": 55.50,
})
e = last_event("app.heartbeat")
assert e["properties"]["nine_router_total_cost"] == 235.50
assert e["properties"]["cost_model_claude_sonnet_4_20250514"] == 180.00
def test_cost_snapshot_separate_event(self):
"""Verify cost.snapshot fires independently with accurate totals."""
record("cost.snapshot", {
"total_cost_usd": 235.50,
"total_prompt_tokens": 5000000,
"total_completion_tokens": 1500000,
"total_requests": 1200,
})
e = last_event("cost.snapshot")
assert e["properties"]["total_cost_usd"] == 235.50
# ===========================================================================
# 24. Edge cases
# ===========================================================================
class TestEdgeCases:
@pytest.mark.asyncio
async def test_close_session_with_no_messages(self, manager):
"""Session closed without any messages should still fire session.completed."""
config = AgentConfig(name="Empty", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.status = "completed"
await manager.close_session(session.id)
e = last_event("session.completed")
assert e["properties"]["message_count"] == 0
assert e["properties"]["tool_count"] == 0
assert e["properties"]["first_user_message"] == ""
@pytest.mark.asyncio
async def test_close_session_with_zero_cost(self, manager):
"""Session with 0 cost should still report cost_usd=0."""
config = AgentConfig(name="Free", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
session.status = "completed"
await manager.close_session(session.id)
e = last_event("session.completed")
assert e["properties"]["cost_usd"] == 0.0
assert e["properties"]["input_tokens"] == 0
assert e["properties"]["output_tokens"] == 0
def test_record_with_no_posthog(self):
"""record() should not crash if PostHog is not initialized."""
import backend.apps.analytics.collector as collector
old_ph = collector._posthog
collector._posthog = None
# Should not raise
record("test.event", {"key": "value"})
collector._posthog = old_ph
def test_record_with_none_properties(self):
"""record() handles None properties gracefully."""
record("test.event", None)
e = last_event("test.event")
assert "os" in e["properties"] # system props still added
+3 -304
View File
@@ -1,27 +1,8 @@
"""Stress tests for the Phase 1 / 2 / 3 perceived-latency changes.
Hits everything we touched on the eric/v2 branch:
"""Stress tests for live perceived-latency paths.
- Message.client_message_id round-trip (optimistic dedupe)
- Mode migration: 'chat' -> 'ask' on session reconcile + lifespan
deletion of stale built-in chat.json
- ContentBlock + StreamEvent now accept type='thinking' /
delta_type='thinking_delta' without breaking existing types
- Anthropic provider forwards thinking content_block_start /
content_block_delta with the right shape
- Agent loop emits agent:stream_start{role:'thinking'},
agent:stream_delta, agent:stream_end for thinking blocks AND
persists a Message(role='thinking') after stream end
- DashboardLayout serializes notes round-trip
- exclude_dynamic_sections reaches the SDK kwargs (presence-only;
we don't run the real CLI here)
Each test runs many randomized iterations to surface race conditions
and bad assumptions. Stub the network and CLI throughout these
tests are pure logic, no real Anthropic calls.
Run:
cd backend && .venv/bin/python -m pytest tests/test_phase1_stress.py -v
- Mode migration: 'chat' -> 'ask' on reconcile + lifespan deletion
- DashboardLayout notes round-trip
"""
from __future__ import annotations
@@ -213,288 +194,6 @@ def test_reconcile_idempotent():
assert mtime_after_first == mtime_after_second, "reconcile must be idempotent"
# ---------------------------------------------------------------------------
# Group 3 — ContentBlock / StreamEvent thinking acceptance
# ---------------------------------------------------------------------------
def test_content_block_thinking_type():
from backend.apps.agents.providers.base import ContentBlock
cb = ContentBlock(type="thinking", text="some reasoning")
assert cb.type == "thinking"
assert cb.text == "some reasoning"
assert cb.tool_call is None
def test_stream_event_thinking_delta():
from backend.apps.agents.providers.base import StreamEvent
e = StreamEvent(type="content_block_delta", delta_type="thinking_delta", text="hmm")
assert e.delta_type == "thinking_delta"
assert e.text == "hmm"
# Existing types still work — no regression
e2 = StreamEvent(type="content_block_delta", delta_type="text_delta", text="hi")
assert e2.delta_type == "text_delta"
# ---------------------------------------------------------------------------
# Group 4 — Anthropic provider thinking forwarding
#
# We feed a fake raw_stream (mimicking the SDK's async generator) through
# AnthropicProvider.stream_message and confirm the right StreamEvents come
# out. No network.
# ---------------------------------------------------------------------------
class _FakeRawEvent:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
class _FakeBlock:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
class _FakeDelta:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
@pytest.mark.asyncio
async def test_anthropic_provider_forwards_thinking_blocks():
"""Mock the raw Anthropic stream with a thinking block + thinking_delta
+ content_block_stop, and assert AnthropicProvider yields the
normalized StreamEvents the agent_loop expects."""
from backend.apps.agents.providers.anthropic import AnthropicProvider
raw_events = [
# thinking block opens at index 0
_FakeRawEvent(type="content_block_start", index=0,
content_block=_FakeBlock(type="thinking")),
_FakeRawEvent(type="content_block_delta", index=0,
delta=_FakeDelta(type="thinking_delta", thinking="step 1, ")),
_FakeRawEvent(type="content_block_delta", index=0,
delta=_FakeDelta(type="thinking_delta", thinking="step 2.")),
# signature_delta on thinking — must be ignored, not crash
_FakeRawEvent(type="content_block_delta", index=0,
delta=_FakeDelta(type="signature_delta", signature="abc==")),
_FakeRawEvent(type="content_block_stop", index=0),
# text block follows at index 1
_FakeRawEvent(type="content_block_start", index=1,
content_block=_FakeBlock(type="text")),
_FakeRawEvent(type="content_block_delta", index=1,
delta=_FakeDelta(type="text_delta", text="hi")),
_FakeRawEvent(type="content_block_stop", index=1),
]
async def fake_stream():
for ev in raw_events:
yield ev
# AnthropicProvider takes api_key/auth_token/base_url; we monkeypatch
# its `client.messages.create` after construction so no real
# SDK client is needed.
provider = AnthropicProvider(api_key="test-key")
provider.client.messages.create = AsyncMock(return_value=fake_stream())
out_events = []
async for ev in provider.stream_message(model="sonnet", system=None, messages=[], tools=[]):
out_events.append(ev)
types = [(e.type, e.block_type, e.delta_type) for e in out_events]
# Thinking block should produce: start, 2x delta, stop. signature_delta ignored.
assert ("content_block_start", "thinking", "") in types
assert types.count(("content_block_delta", "", "thinking_delta")) == 2
assert ("content_block_start", "text", "") in types
assert ("content_block_delta", "", "text_delta") in types
thinking_text = "".join(
e.text for e in out_events
if e.type == "content_block_delta" and e.delta_type == "thinking_delta"
)
assert thinking_text == "step 1, step 2."
# ---------------------------------------------------------------------------
# Group 5 — Agent loop end-to-end thinking → WS events + persisted message
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_agent_loop_emits_thinking_stream_and_persists_message():
"""Drive the agent loop with a fake provider that yields thinking,
text, and one tool_use. Verify it emits the right WS events AND
persists a Message(role='thinking') via _emit_collected_messages."""
from backend.apps.agents.providers.base import StreamEvent
captured_ws: list[tuple[str, dict]] = []
async def fake_emitter(event: str, payload: dict):
captured_ws.append((event, payload))
# Build a fake provider yielding our normalized StreamEvents.
class FakeProvider:
async def stream_message(self, **kwargs):
yield StreamEvent(type="content_block_start", index=0, block_type="thinking")
yield StreamEvent(type="content_block_delta", index=0,
delta_type="thinking_delta", text="reasoning… ")
yield StreamEvent(type="content_block_delta", index=0,
delta_type="thinking_delta", text="more.")
yield StreamEvent(type="content_block_stop", index=0)
yield StreamEvent(type="content_block_start", index=1, block_type="text")
yield StreamEvent(type="content_block_delta", index=1,
delta_type="text_delta", text="hello!")
yield StreamEvent(type="content_block_stop", index=1)
yield StreamEvent(type="message_stop")
from backend.apps.agents.agent_loop import AgentLoop
loop = AgentLoop(
session_id="s1",
provider=FakeProvider(),
model="sonnet",
system_prompt="x",
tools=[],
ws_emitter=fake_emitter,
hitl_handler=AsyncMock(return_value=(True, None)),
tool_executor=AsyncMock(return_value=[{"type": "text", "text": "ok"}]),
)
response = await loop._stream_and_collect()
# Stream events: thinking start + 2 deltas + stream_end, then text start + delta + (text end at message_stop)
events_by_type = {}
for ev, payload in captured_ws:
events_by_type.setdefault(ev, []).append(payload)
# Thinking should have its own stream_start with role='thinking'
starts = events_by_type.get("agent:stream_start", [])
thinking_starts = [s for s in starts if s.get("role") == "thinking"]
assistant_starts = [s for s in starts if s.get("role") == "assistant"]
assert len(thinking_starts) == 1, f"expected 1 thinking start, got {len(thinking_starts)}"
assert len(assistant_starts) == 1, "expected 1 assistant text start"
# Two thinking deltas
deltas = events_by_type.get("agent:stream_delta", [])
thinking_msg_id = thinking_starts[0]["message_id"]
thinking_deltas = [d for d in deltas if d.get("message_id") == thinking_msg_id]
assert len(thinking_deltas) == 2
assert "".join(d["delta"] for d in thinking_deltas) == "reasoning… more."
# Thinking stream_end fires (text doesn't get stream_end inside _stream_and_collect — closes at message_stop)
ends = events_by_type.get("agent:stream_end", [])
assert any(e["message_id"] == thinking_msg_id for e in ends), "thinking must emit stream_end"
# Now persist via _emit_collected_messages and verify a thinking
# Message went out
captured_ws.clear()
await loop._emit_collected_messages(
response.content,
text_msg_id=assistant_starts[0]["message_id"],
tool_msg_ids={},
)
persisted = [p for ev, p in captured_ws if ev == "agent:message"]
roles = [p["message"]["role"] for p in persisted]
assert "thinking" in roles, "thinking content must be persisted as a Message"
assert "assistant" in roles
thinking_msg = next(p for p in persisted if p["message"]["role"] == "thinking")
assert thinking_msg["message"]["content"] == "reasoning… more."
@pytest.mark.asyncio
async def test_agent_loop_handles_no_thinking_gracefully():
"""Provider that emits zero thinking blocks must still work.
Regression guard against the new branch breaking text-only paths."""
from backend.apps.agents.providers.base import StreamEvent
from backend.apps.agents.agent_loop import AgentLoop
captured_ws = []
async def fake_emitter(event, payload):
captured_ws.append((event, payload))
class TextOnly:
async def stream_message(self, **kwargs):
yield StreamEvent(type="content_block_start", index=0, block_type="text")
yield StreamEvent(type="content_block_delta", index=0,
delta_type="text_delta", text="just text")
yield StreamEvent(type="content_block_stop", index=0)
yield StreamEvent(type="message_stop")
loop = AgentLoop(
session_id="s2", provider=TextOnly(), model="sonnet", system_prompt=None,
tools=[],
ws_emitter=fake_emitter,
hitl_handler=AsyncMock(return_value=(True, None)),
tool_executor=AsyncMock(return_value=[]),
)
resp = await loop._stream_and_collect()
starts = [p for ev, p in captured_ws if ev == "agent:stream_start"]
# Exactly one assistant start, zero thinking starts
assert len([s for s in starts if s.get("role") == "thinking"]) == 0
assert len([s for s in starts if s.get("role") == "assistant"]) == 1
assert any(b.type == "text" for b in resp.content)
@pytest.mark.asyncio
async def test_agent_loop_stress_many_thinking_blocks():
"""Hammer the loop with a long sequence of interleaved thinking +
text + tool blocks. Ensures the per-index buffers don't leak and
every block gets the right WS events."""
from backend.apps.agents.providers.base import StreamEvent
from backend.apps.agents.agent_loop import AgentLoop
captured = []
async def fake_emitter(ev, p):
captured.append((ev, p))
class Mix:
async def stream_message(self, **kwargs):
idx = 0
for turn in range(40):
yield StreamEvent(type="content_block_start", index=idx, block_type="thinking")
for _ in range(random.randint(1, 5)):
yield StreamEvent(type="content_block_delta", index=idx,
delta_type="thinking_delta", text=f"t{idx} ")
yield StreamEvent(type="content_block_stop", index=idx)
idx += 1
yield StreamEvent(type="content_block_start", index=idx, block_type="text")
yield StreamEvent(type="content_block_delta", index=idx,
delta_type="text_delta", text=f"text-{idx}")
yield StreamEvent(type="content_block_stop", index=idx)
idx += 1
yield StreamEvent(type="message_stop")
loop = AgentLoop(
session_id="s3", provider=Mix(), model="sonnet", system_prompt=None,
tools=[],
ws_emitter=fake_emitter,
hitl_handler=AsyncMock(return_value=(True, None)),
tool_executor=AsyncMock(return_value=[]),
)
resp = await loop._stream_and_collect()
starts = [p for ev, p in captured if ev == "agent:stream_start"]
ends = [p for ev, p in captured if ev == "agent:stream_end"]
# 40 thinking + 1 assistant (text accumulates into one stream_text_msg_id)
thinking_starts = [s for s in starts if s.get("role") == "thinking"]
assistant_starts = [s for s in starts if s.get("role") == "assistant"]
assert len(thinking_starts) == 40, f"got {len(thinking_starts)} thinking starts, want 40"
assert len(assistant_starts) == 1, "all text blocks share one assistant stream id"
# Each thinking block must have its own stream_end
thinking_ids = {s["message_id"] for s in thinking_starts}
end_ids = {e["message_id"] for e in ends}
assert thinking_ids.issubset(end_ids), "every thinking block needs a stream_end"
# ---------------------------------------------------------------------------
# Group 6 — Notes layout serialization
+353
View File
@@ -0,0 +1,353 @@
"""Tests for the service-sync layer.
Public surface is a single `sync(data)` function. The desktop hands off
opaque dicts; the cloud determines what they are. Tests verify:
- Envelope (install_id, user_id) stamped on every submission
- Opt-out gate works
- Test sink intercepts every sync
- Spool round-trip (enqueue/drain/acknowledge)
- Legacy shims (submit, record, identify) route through sync
Run:
cd backend && python -m pytest tests/test_service.py -v
"""
from __future__ import annotations
import json
import os
import tempfile
import time
from unittest.mock import patch
import pytest
_tmpdir = tempfile.mkdtemp()
os.environ.setdefault("OPENSWARM_DATA_DIR", _tmpdir)
@pytest.fixture(autouse=True)
def patch_settings(tmp_path):
sf = tmp_path / "settings.json"
sf.write_text(json.dumps({
"installation_id": "test-install-abc",
"analytics_opt_in": True,
}))
import backend.apps.settings.settings as settings_mod
old = settings_mod.SETTINGS_FILE
settings_mod.SETTINGS_FILE = str(sf)
yield
settings_mod.SETTINGS_FILE = old
@pytest.fixture(autouse=True)
def fresh_client(tmp_path):
import backend.apps.service.client as client
client._install_id = None
client._user_id = None
client._test_sink = None
spool = tmp_path / "spool.db"
with patch.object(client, "_spool_path", lambda: str(spool)):
yield
@pytest.fixture
def sink():
captured: list[tuple[str, dict]] = []
import backend.apps.service.client as client
client.set_test_sink(lambda kind, body: captured.append((kind, body)))
yield captured
client.set_test_sink(None)
# --- core sync ---------------------------------------------------------------
def test_sync_basic(sink):
from backend.apps.service.client import sync
sync({"foo": "bar"})
assert len(sink) == 1
_, body = sink[0]
assert body["d"] == {"foo": "bar"}
def test_sync_carries_install_id(sink):
from backend.apps.service.client import sync
sync({})
_, body = sink[0]
assert body["client_state"]["install_id"] == "test-install-abc"
def test_sync_carries_user_id_when_set(sink):
from backend.apps.service.client import sync, set_user_id
set_user_id("alice@example.com")
sync({})
_, body = sink[0]
assert body["client_state"]["user_id"] == "alice@example.com"
def test_sync_no_user_id_when_not_set(sink):
from backend.apps.service.client import sync
sync({})
_, body = sink[0]
assert "user_id" not in body["client_state"]
def test_sync_user_id_cleared_with_none(sink):
from backend.apps.service.client import sync, set_user_id
set_user_id("alice")
set_user_id(None)
sync({})
_, body = sink[0]
assert "user_id" not in body["client_state"]
def test_sync_user_id_cleared_with_empty(sink):
from backend.apps.service.client import sync, set_user_id
set_user_id("alice")
set_user_id("")
sync({})
_, body = sink[0]
assert "user_id" not in body["client_state"]
def test_sync_environment_metadata(sink):
from backend.apps.service.client import sync
sync({})
_, body = sink[0]
cs = body["client_state"]
assert cs.get("device_type") == "desktop"
assert cs.get("os")
assert cs.get("os_version")
def test_sync_payload_round_trips(sink):
from backend.apps.service.client import sync
data = {"deeply": {"nested": [1, 2]}, "flag": True, "n": 3.14}
sync(data)
_, body = sink[0]
assert body["d"] == data
def test_sync_empty_data(sink):
from backend.apps.service.client import sync
sync({})
assert len(sink) == 1
def test_sync_none_treated_as_empty(sink):
from backend.apps.service.client import sync
sync(None)
_, body = sink[0]
assert body["d"] == {}
def test_sync_timestamp_present(sink):
from backend.apps.service.client import sync
sync({})
_, body = sink[0]
assert isinstance(body["t"], float)
assert body["t"] > 0
# --- opt-out gating ----------------------------------------------------------
def test_opt_out_blocks_sync(sink, tmp_path):
sf = tmp_path / "minimal.json"
sf.write_text(json.dumps({
"installation_id": "test-install-abc",
"analytics_opt_in": False,
}))
import backend.apps.settings.settings as settings_mod
settings_mod.SETTINGS_FILE = str(sf)
from backend.apps.service.client import sync
sync({"x": 1})
assert sink == []
def test_standard_mode_allows_sync(sink):
from backend.apps.service.client import sync
sync({})
sync({})
assert len(sink) == 2
def test_settings_load_failure_defaults_to_enabled(sink):
import backend.apps.settings.settings as settings_mod
settings_mod.SETTINGS_FILE = "/nonexistent/path/settings.json"
from backend.apps.service.client import sync
sync({})
assert len(sink) == 1
# --- legacy shims ------------------------------------------------------------
def test_legacy_submit_routes_through_sync(sink):
from backend.apps.service.client import submit
submit("event", {"test": True})
assert len(sink) == 1
_, body = sink[0]
assert body["d"] == {"test": True}
def test_legacy_record_routes_through_sync(sink):
from backend.apps.service.client import record
record("some.event", {"k": "v"})
assert len(sink) == 1
def test_legacy_identify_routes_through_sync(sink):
from backend.apps.service.client import identify
identify({"plan": "pro"})
assert len(sink) == 1
def test_legacy_submit_session_close(sink):
from backend.apps.service.client import submit_session_close
submit_session_close({"id": "s-1", "cost_usd": 0.42})
assert len(sink) == 1
def test_legacy_submit_diagnostic(sink):
from backend.apps.service.client import submit_diagnostic
submit_diagnostic({"kind": "error_caught"})
assert len(sink) == 1
# --- spool -------------------------------------------------------------------
def test_buffer_enqueue_and_drain(tmp_path):
from backend.apps.service import buffer
spool = str(tmp_path / "s.db")
buffer.enqueue(spool, "s:/api/service/sync", {"a": 1}, now=time.time())
buffer.enqueue(spool, "s:/api/service/sync", {"a": 2}, now=time.time())
assert buffer.count(spool) == 2
rows = buffer.drain(spool, batch_size=10)
assert [r[2]["a"] for r in rows] == [1, 2]
buffer.acknowledge(spool, [r[0] for r in rows])
assert buffer.count(spool) == 0
def test_buffer_drain_partial(tmp_path):
from backend.apps.service import buffer
spool = str(tmp_path / "s.db")
for i in range(5):
buffer.enqueue(spool, "s:/x", {"i": i}, now=time.time())
rows = buffer.drain(spool, batch_size=2)
assert len(rows) == 2
assert buffer.count(spool) == 5
buffer.acknowledge(spool, [r[0] for r in rows])
assert buffer.count(spool) == 3
def test_buffer_clear(tmp_path):
from backend.apps.service import buffer
spool = str(tmp_path / "s.db")
buffer.enqueue(spool, "s:/x", {}, now=time.time())
buffer.clear(spool)
assert buffer.count(spool) == 0
def test_buffer_missing_file(tmp_path):
from backend.apps.service import buffer
assert buffer.count(str(tmp_path / "nope.db")) == 0
assert buffer.drain(str(tmp_path / "nope.db")) == []
def test_buffer_corrupt_row_dropped(tmp_path):
from backend.apps.service import buffer
spool = str(tmp_path / "s.db")
with buffer._conn(spool) as c:
c.execute(
"INSERT INTO spool (kind, payload, created_at) VALUES (?, ?, ?)",
("s:/x", "{not json", time.time()),
)
rows = buffer.drain(spool)
assert rows == []
assert buffer.count(spool) == 0
def test_buffer_size_cap(tmp_path):
from backend.apps.service import buffer
spool = str(tmp_path / "s.db")
big = "x" * 1024
for i in range(200):
buffer.enqueue(spool, "s:/x", {"i": i, "pad": big}, now=time.time())
assert buffer.count(spool) == 200
@pytest.mark.asyncio
async def test_drain_spool_empty():
from backend.apps.service.client import drain_spool
n = await drain_spool()
assert n == 0
# --- identity ----------------------------------------------------------------
def test_install_id_persisted(sink, tmp_path):
sf = tmp_path / "fresh.json"
sf.write_text(json.dumps({"analytics_opt_in": True}))
import backend.apps.settings.settings as settings_mod
settings_mod.SETTINGS_FILE = str(sf)
import backend.apps.service.client as client
client._install_id = None
from backend.apps.service.client import sync
sync({})
_, body = sink[0]
iid = body["client_state"]["install_id"]
assert iid
raw = json.loads(sf.read_text())
assert raw["installation_id"] == iid
def test_install_id_stable(sink):
from backend.apps.service.client import sync
sync({})
sync({})
iid1 = sink[0][1]["client_state"]["install_id"]
iid2 = sink[1][1]["client_state"]["install_id"]
assert iid1 == iid2
# --- SubApp endpoints --------------------------------------------------------
@pytest.mark.asyncio
async def test_endpoint_submit(sink):
from backend.apps.service.service import post_submit
res = await post_submit({"kind": "state", "payload": {"x": 1}})
assert res == {"ok": True}
assert len(sink) == 1
@pytest.mark.asyncio
async def test_endpoint_submit_missing_payload(sink):
from backend.apps.service.service import post_submit
res = await post_submit({"kind": "state"})
assert res["ok"] is False
@pytest.mark.asyncio
async def test_endpoint_event_happy(sink):
from backend.apps.service.service import post_event
res = await post_event({"surface": "test", "action": "happy"})
assert res == {"ok": True}
assert len(sink) == 1
@pytest.mark.asyncio
async def test_endpoint_event_missing_surface(sink):
from backend.apps.service.service import post_event
res = await post_event({"action": "x"})
assert res["ok"] is False
@pytest.mark.asyncio
async def test_endpoint_spool_count(tmp_path):
from backend.apps.service import client as svc, buffer
from backend.apps.service.service import spool_count
spool = str(tmp_path / "spool.db")
with patch.object(svc, "_spool_path", lambda: spool):
buffer.enqueue(spool, "s:/x", {}, now=time.time())
result = await spool_count()
assert result == {"pending": 1}
+222
View File
@@ -0,0 +1,222 @@
"""Service-sync compatibility tests.
Verifies the legacy compatibility helpers on backend/apps/service/client.py
(record, submit_event, submit_session_close, etc.) still produce the right
opaque payload through the unified sync() entry point. Forward-looking
contract tests live in test_service.py; this file covers the legacy shim
surface so it can be deprecated cleanly later.
Run with:
cd backend && python -m pytest tests/test_service_legacy.py -v
"""
import json
import os
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
# Sandbox the data dir before any module import touches settings on disk.
_tmpdir = tempfile.mkdtemp()
os.environ.setdefault("OPENSWARM_DATA_DIR", _tmpdir)
# Captured syncs from this test run.
_captured_syncs: list[dict] = []
@pytest.fixture(autouse=True)
def reset_captured_syncs():
_captured_syncs.clear()
yield
_captured_syncs.clear()
@pytest.fixture(autouse=True)
def install_sync_sink():
"""Install a service-sync sink and decode the opaque payload back into
a structured shape for assertions. The sink translates the new shape
{client_state, d, t} into a legacy-compatible {kind, distinct_id, props}
bag so existing tests can keep their assertions terse."""
import backend.apps.service.client as svc_client
def _sink(label: str, body: dict):
cs = body.get("client_state") or {}
payload = body.get("d") or body.get("payload") or {}
# Infer a synthetic kind from payload shape — same dispatch logic
# as the cloud uses in production.
if "status" in payload and "messages" in payload:
status = payload.get("status", "unknown")
kind = f"session.{status}" if status != "unknown" else "session.completed"
props = dict(payload)
elif "identity" in payload:
kind = "state.update"
props = dict(payload)
elif "diagnostic" in payload:
kind = "diagnostic.fired"
props = dict(payload)
elif "s" in payload and "a" in payload:
kind = f"{payload['s']}.{payload['a']}"
props = dict(payload.get("p") or {})
elif "surface" in payload:
surface = payload.get("surface", "")
action = payload.get("action", "fired")
kind = f"{surface}.{action}"
props = dict(payload.get("props") or {})
else:
kind = "state.update"
props = dict(payload)
if payload.get("session_id"):
props["session_id"] = payload["session_id"]
if payload.get("dashboard_id"):
props["dashboard_id"] = payload["dashboard_id"]
props.setdefault("os", cs.get("os", ""))
props.setdefault("platform", cs.get("os", ""))
_captured_syncs.append({
"kind": kind,
"distinct_id": cs.get("install_id", ""),
"properties": props,
})
old_sink = svc_client._test_sink
old_iid = svc_client._install_id
svc_client.set_test_sink(_sink)
svc_client._install_id = "test-install-id"
yield
svc_client.set_test_sink(old_sink)
svc_client._install_id = old_iid
@pytest.fixture(autouse=True)
def mock_settings(tmp_path):
"""Sandbox settings so tests don't read or write the real config."""
settings_file = tmp_path / "settings.json"
settings_file.write_text(json.dumps({
"service_diagnostics_mode": "standard",
"installation_id": "test-install-id",
}))
import backend.apps.settings.settings as settings_mod
old_file = settings_mod.SETTINGS_FILE
settings_mod.SETTINGS_FILE = str(settings_file)
yield
settings_mod.SETTINGS_FILE = old_file
@pytest.fixture(autouse=True)
def mock_sessions_dir(tmp_path):
"""Use temp dir for session persistence."""
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
import backend.config.paths as paths_mod
old_dir = paths_mod.SESSIONS_DIR
paths_mod.SESSIONS_DIR = str(sessions_dir)
yield str(sessions_dir)
paths_mod.SESSIONS_DIR = old_dir
def syncs(kind: str | None = None) -> list[dict]:
"""Return captured syncs, optionally filtered by inferred kind."""
if kind:
return [s for s in _captured_syncs if s["kind"] == kind]
return list(_captured_syncs)
def last_sync(kind: str) -> dict:
"""Return the last captured sync of a given inferred kind."""
matching = syncs(kind)
assert matching, f"No {kind} syncs captured. Got: {[s['kind'] for s in _captured_syncs]}"
return matching[-1]
# Import application modules (after fixtures are wired).
from backend.apps.service.client import record
from backend.apps.agents.models import AgentConfig, AgentSession, Message, ApprovalRequest
from backend.apps.agents.agent_manager import AgentManager
@pytest.fixture
def manager():
"""Fresh AgentManager per test."""
return AgentManager()
# ---------------------------------------------------------------------------
# 1. record() — legacy shim correctness
# ---------------------------------------------------------------------------
class TestRecordBasics:
def test_record_sends_payload(self):
record("test.report", {"key": "value"})
s = last_sync("test.report")
assert s["properties"]["key"] == "value"
assert s["distinct_id"] == "test-install-id"
def test_record_adds_os_and_platform(self):
record("test.report", {})
s = last_sync("test.report")
assert "os" in s["properties"]
assert "platform" in s["properties"]
def test_record_includes_session_id(self):
record("test.report", {}, session_id="sess123")
s = last_sync("test.report")
assert s["properties"]["session_id"] == "sess123"
def test_record_includes_dashboard_id(self):
record("test.report", {}, dashboard_id="dash456")
s = last_sync("test.report")
assert s["properties"]["dashboard_id"] == "dash456"
# ---------------------------------------------------------------------------
# 2. Multi-message session — close fires exactly once
# ---------------------------------------------------------------------------
class TestMultiMessageSession:
@pytest.mark.asyncio
async def test_session_completes_only_on_close(self, manager):
"""Verify a completed-session sync does NOT fire mid-loop. It should
only fire on close_session() or persist_all_sessions()."""
config = AgentConfig(name="Multi-msg", model="sonnet", mode="agent")
session = await manager.launch_agent(config)
for i in range(3):
session.messages.append(Message(role="user", content=f"msg {i}"))
session.messages.append(Message(role="assistant", content=f"reply {i}"))
completed = syncs("session.completed")
assert len(completed) == 0, f"session-completed fired {len(completed)} times before close"
session.status = "completed"
await manager.close_session(session.id)
completed = syncs("session.completed")
assert len(completed) == 1, f"expected 1 completed sync, got {len(completed)}"
# ---------------------------------------------------------------------------
# 3. Token + cost capture on close
# ---------------------------------------------------------------------------
class TestTokenTracking:
@pytest.mark.asyncio
async def test_tokens_and_cost_in_session_close(self, manager):
config = AgentConfig(name="Token Test", model="opus", mode="agent")
session = await manager.launch_agent(config)
session.tokens = {"input": 50000, "output": 15000}
session.cost_usd = 0.25
session.status = "completed"
await manager.close_session(session.id)
s = last_sync("session.completed")
assert s["properties"]["tokens"]["input"] == 50000
assert s["properties"]["tokens"]["output"] == 15000
assert s["properties"]["cost_usd"] == 0.25
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
"""Mirror tests for the frontend label/result logic.
The JS implementations live in:
- frontend/src/app/pages/AgentChat/toolLabels.ts
- frontend/src/app/pages/AgentChat/ToolCallBubble.tsx (getResultSummary,
getInputSummary, parseMcpToolName, bashCommandDetail, prettyPath, prettyUrl,
quoteQuery)
We re-implement the rules in Python and pin them as tests so we get
regression coverage from `pytest` too. Any drift between the JS source
and these Python mirrors is the production-side breakage we want to
catch.
"""
from __future__ import annotations
import random
import re
import pytest
# ===========================================================================
# Mirror: parseMcpToolName.displayName (sentence-case rule)
# ===========================================================================
def parse_mcp_tool_name_display(raw_name: str) -> str | None:
"""Mirror of frontend parseMcpToolName().displayName."""
m = re.match(r"^mcp__([^_]+(?:-[^_]+)*)__(.+)$", raw_name)
if not m:
return None
action = m.group(2)
spaced = action.replace("_", " ").lower()
return spaced[0].upper() + spaced[1:] if spaced else ""
def test_parse_mcp_tool_name_get_message_details():
assert parse_mcp_tool_name_display(
"mcp__google-workspace__get_message_details"
) == "Get message details"
def test_parse_mcp_tool_name_send_email():
assert parse_mcp_tool_name_display(
"mcp__google-workspace__send_gmail_message"
) == "Send gmail message"
def test_parse_mcp_tool_name_search_emails():
assert parse_mcp_tool_name_display(
"mcp__google-workspace__query_gmail_emails"
) == "Query gmail emails"
def test_parse_mcp_tool_name_returns_none_for_non_mcp():
assert parse_mcp_tool_name_display("Bash") is None
assert parse_mcp_tool_name_display("Read") is None
def test_parse_mcp_tool_name_no_title_case():
"""Regression test: NEVER capitalize every word."""
bad = parse_mcp_tool_name_display("mcp__notion__create_a_new_page")
assert bad == "Create a new page"
assert "A New Page" not in bad
# ===========================================================================
# Mirror: getResultSummary (glyph-free regression test)
# ===========================================================================
def get_result_summary_bash_success(stdout: str, exit_code: int = 0) -> str:
"""Mirror of getResultSummary for bash success case."""
if exit_code != 0:
return f"exit {exit_code}"
lines = [l for l in stdout.split("\n") if l.strip()]
n = len(lines)
return f"{n} line{'s' if n != 1 else ''}"
def test_bash_success_summary_no_glyph():
"""Regression: bash success used to return '✓ N lines'. Must now be glyph-free."""
assert "" not in get_result_summary_bash_success("hello\nworld")
assert get_result_summary_bash_success("hello\nworld") == "2 lines"
assert get_result_summary_bash_success("just one line") == "1 line"
assert get_result_summary_bash_success("") == "0 lines"
def test_bash_failure_summary_no_glyph():
"""Failure summary too: 'exit 1' not '✗ exit 1'."""
assert get_result_summary_bash_success("", exit_code=1) == "exit 1"
assert "" not in get_result_summary_bash_success("", exit_code=1)
assert "" not in get_result_summary_bash_success("", exit_code=1)
def test_no_check_glyph_in_summaries():
"""Sweep: every plausible summary string never contains a check glyph."""
summaries = [
get_result_summary_bash_success("a"),
get_result_summary_bash_success("a\nb\nc"),
get_result_summary_bash_success("", exit_code=1),
get_result_summary_bash_success("", exit_code=127),
]
for s in summaries:
assert "" not in s and "" not in s and "" not in s and "" not in s
# ===========================================================================
# Mirror: bashCommandDetail extraction
# ===========================================================================
def bash_command_detail(raw_cmd: str) -> str:
"""Mirror of frontend bashCommandDetail."""
if not raw_cmd:
return ""
cmd = raw_cmd.strip()
# strip env var assignments + sudo/time/nice/env
cmd = re.sub(r"^(?:[A-Z_][A-Z0-9_]*=\S+\s+)+", "", cmd)
cmd = re.sub(r"^(?:sudo|time|nice|env)\s+", "", cmd)
tokens = cmd.split()
if not tokens:
return ""
bin_path = tokens[0].split("/")[-1]
if bin_path == "git":
sub = (tokens[1] if len(tokens) > 1 else "").lower()
if sub in ("commit", "status", "log", "diff", "pull", "push", "fetch"):
return ""
return tokens[2].split("/")[-1] if len(tokens) > 2 else ""
if bin_path in ("npm", "pnpm", "yarn", "bun", "pip", "pip3", "brew", "apt", "apt-get"):
if len(tokens) > 2:
args = [t for t in tokens[2:] if not t.startswith("-")][:2]
return " ".join(args)
return ""
# First non-flag positional arg
arg = next((t for t in tokens[1:] if not t.startswith("-")), "")
if not arg:
return ""
if "/" in arg or "\\" in arg:
# basename
cleaned = arg.rstrip("/\\")
parts = cleaned.replace("\\", "/").split("/")
return parts[-1] if parts[-1] else cleaned
return arg if len(arg) <= 50 else arg[:47] + "..."
def test_bash_detail_rm_extracts_path():
assert bash_command_detail("rm /tmp/foo.txt") == "foo.txt"
assert bash_command_detail("rm foo.txt") == "foo.txt"
def test_bash_detail_git_commit_empty():
"""git commit -m 'message' → no detail (verb covers it)."""
assert bash_command_detail("git commit -m 'fix bug'") == ""
assert bash_command_detail("git commit -m hi") == ""
def test_bash_detail_git_status_empty():
assert bash_command_detail("git status") == ""
def test_bash_detail_git_checkout_branch():
assert bash_command_detail("git checkout main") == "main"
def test_bash_detail_npm_install():
assert bash_command_detail("npm install lodash") == "lodash"
assert bash_command_detail("npm install lodash @types/node") == "lodash @types/node"
def test_bash_detail_strips_sudo():
assert bash_command_detail("sudo rm /etc/foo") == "foo"
def test_bash_detail_strips_env_assignments():
assert bash_command_detail("FOO=bar BAZ=qux rm /tmp/a") == "a"
def test_bash_detail_handles_empty():
assert bash_command_detail("") == ""
assert bash_command_detail(" ") == ""
# ===========================================================================
# Mirror: prettyPath (basename a path)
# ===========================================================================
def pretty_path(p: str) -> str:
if not p:
return ""
cleaned = p.rstrip("/\\")
parts = cleaned.replace("\\", "/").split("/")
return parts[-1] if parts[-1] else cleaned
def test_pretty_path_absolute():
assert pretty_path("/Users/eric/Downloads/openswarm/foo.ts") == "foo.ts"
def test_pretty_path_relative():
assert pretty_path("a/b/c.tsx") == "c.tsx"
def test_pretty_path_trailing_slash():
assert pretty_path("/a/b/c/") == "c"
def test_pretty_path_empty():
assert pretty_path("") == ""
# ===========================================================================
# Mirror: prettyUrl (host-only)
# ===========================================================================
def pretty_url(u: str) -> str:
if not u:
return ""
try:
from urllib.parse import urlparse
host = urlparse(u).hostname or ""
return host[4:] if host.startswith("www.") else host or u[:60]
except Exception:
no_proto = re.sub(r"^https?://", "", u).split("/")[0].split("?")[0].split("#")[0]
return no_proto[:60]
def test_pretty_url_https():
assert pretty_url("https://example.com/long/path?q=1") == "example.com"
def test_pretty_url_strips_www():
assert pretty_url("https://www.example.com/path") == "example.com"
def test_pretty_url_subdomain_kept():
assert pretty_url("https://api.example.com/v1") == "api.example.com"
def test_pretty_url_empty():
assert pretty_url("") == ""
# ===========================================================================
# Mirror: quoteQuery
# ===========================================================================
def quote_query(q: str, max_len: int = 60) -> str:
if not q:
return ""
trimmed = q if len(q) <= max_len else q[:max_len - 1] + ""
return f'"{trimmed}"'
def test_quote_query_short():
assert quote_query("TODO") == '"TODO"'
def test_quote_query_long_truncated():
long = "a" * 100
result = quote_query(long)
assert result.startswith('"')
assert result.endswith('"')
assert len(result) <= 62 # 60 chars + 2 quotes
def test_quote_query_empty():
assert quote_query("") == ""
# ===========================================================================
# Mirror: stable-seeded variant pick (djb2 hash → mod n)
# ===========================================================================
def stable_index(seed: str | None, n: int) -> int:
"""Mirror of frontend _stableIndex."""
if n <= 1 or not seed:
return 0
h = 5381
for ch in seed:
h = ((h << 5) + h + ord(ch)) & 0xFFFFFFFF # 32-bit
# JS does `| 0` which produces signed int; Math.abs handles that
if h >= 0x80000000:
h -= 0x100000000
return abs(h) % n
def test_stable_index_same_seed_same_result():
"""Critical: same call.id always → same variant index."""
n = 5
for seed in ("abc-123", "xyz-789", "tool-call-uuid-deadbeef"):
a = stable_index(seed, n)
b = stable_index(seed, n)
c = stable_index(seed, n)
assert a == b == c, f"unstable for seed={seed!r}"
def test_stable_index_different_seeds_diverge():
"""Different seeds usually give different results (probabilistic)."""
n = 7
seeds = [f"seed-{i}-{random.randint(0, 99999)}" for i in range(50)]
indices = [stable_index(s, n) for s in seeds]
# All same is statistically extremely unlikely
assert len(set(indices)) > 1
def test_stable_index_in_range():
"""Index always in [0, n-1]."""
for _ in range(200):
seed = "".join(random.choices(string.ascii_letters + string.digits, k=20))
n = random.randint(2, 20)
idx = stable_index(seed, n)
assert 0 <= idx < n, f"out of range: {idx} for n={n}"
def test_stable_index_empty_seed_zero():
"""No seed → safe-default (index 0)."""
assert stable_index(None, 5) == 0
assert stable_index("", 5) == 0
def test_stable_index_n_one():
"""Single-variant pool → always index 0."""
assert stable_index("anything", 1) == 0
# ===========================================================================
# Mirror: bash verb extraction (the leading-binary lookup)
# ===========================================================================
BIN_VERB_MAP = {
"rm": ("Deleting", "Deleted"),
"mv": ("Moving", "Moved"),
"cp": ("Copying", "Copied"),
"mkdir": ("Creating folder", "Created folder"),
"ls": ("Listing folder", "Listed folder"),
"find": ("Hunting for files", "Hunted for files"),
"grep": ("Searching files", "Searched files"),
"cat": ("Reading", "Read"),
"echo": ("Printing", "Printed"),
"make": ("Building", "Built"),
}
GIT_VERB_MAP = {
"commit": ("Committing", "Committed"),
"push": ("Pushing to git", "Pushed to git"),
"pull": ("Pulling from git", "Pulled from git"),
"checkout": ("Switching branches", "Switched branches"),
"merge": ("Merging", "Merged"),
}
PKG_VERB_MAP_INSTALL = ("Installing packages", "Installed packages")
PKG_VERB_MAP_UNINSTALL = ("Removing packages", "Removed packages")
def bash_verb(cmd: str, past: bool = False):
if not cmd:
return None
stripped = re.sub(r"^(?:[A-Z_][A-Z0-9_]*=\S+\s+)+", "", cmd.strip())
stripped = re.sub(r"^(?:sudo|time|nice|env)\s+", "", stripped)
tokens = stripped.split()
if not tokens:
return None
bin_path = tokens[0].split("/")[-1].lower()
sub = (tokens[1] if len(tokens) > 1 else "").lower()
if bin_path == "git" and sub in GIT_VERB_MAP:
return GIT_VERB_MAP[sub][1 if past else 0]
if bin_path in ("npm", "pnpm", "yarn", "pip", "pip3", "brew"):
if sub in ("install", "add", "i"):
return PKG_VERB_MAP_INSTALL[1 if past else 0]
if sub in ("uninstall", "remove", "rm"):
return PKG_VERB_MAP_UNINSTALL[1 if past else 0]
if bin_path in BIN_VERB_MAP:
return BIN_VERB_MAP[bin_path][1 if past else 0]
return None
def test_bash_verb_rm_deleted():
assert bash_verb("rm foo", past=True) == "Deleted"
assert bash_verb("rm foo", past=False) == "Deleting"
def test_bash_verb_git_commit():
assert bash_verb("git commit -m hi", past=True) == "Committed"
def test_bash_verb_git_push():
assert bash_verb("git push origin main", past=True) == "Pushed to git"
def test_bash_verb_npm_install():
assert bash_verb("npm install lodash", past=True) == "Installed packages"
def test_bash_verb_unknown_returns_none():
"""Truly unknown command falls through to default 'Ran command'."""
assert bash_verb("supercustomtool foo bar") is None
def test_bash_verb_strips_sudo():
assert bash_verb("sudo rm -rf /tmp/x", past=True) == "Deleted"
def test_bash_verb_strips_env():
assert bash_verb("DEBUG=1 npm test", past=False) is None # 'test' isn't in pkg map for bash_verb
# string is needed for stable_index test
import string # noqa: E402