mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-23 10:04:53 +02:00
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:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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']}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
@@ -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")
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}]
|
||||
@@ -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
|
||||
@@ -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 = {}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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("```"):
|
||||
|
||||
@@ -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")
|
||||
@@ -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 {})
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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())}
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
]
|
||||
|
||||
+1240
-135
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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user