diff --git a/backend/apps/agents/9router_gpt5_patch.js b/backend/apps/agents/9router_gpt5_patch.js index c236c2c7..9d32cdf7 100644 --- a/backend/apps/agents/9router_gpt5_patch.js +++ b/backend/apps/agents/9router_gpt5_patch.js @@ -64,20 +64,31 @@ const _http = require('http'); const backendPort = process.env.OPENSWARM_PORT || '8324'; const path = '/api/subscriptions/callback' + url.slice('/callback'.length); let done = false; - const finish = () => { + // Relay the backend's real outcome page: the old static close-page rendered success even when the exchange failed, so a broken claude connect looked like it worked and left nothing to debug from user reports. + const finish = (body) => { if (done) return; done = true; - try { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(closePage); } catch (_) {} + try { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(body || closePage); } catch (_) {} }; try { const proxyReq = http.request( { host: '127.0.0.1', port: backendPort, path: path, method: 'GET' }, - (proxyRes) => { proxyRes.resume(); proxyRes.on('end', finish); } + (proxyRes) => { + const chunks = []; + proxyRes.on('data', (c) => { if (chunks.length < 64) chunks.push(c); }); + proxyRes.on('end', () => finish(Buffer.concat(chunks).toString('utf8') || null)); + proxyRes.on('error', () => finish(null)); + } ); - proxyReq.on('error', finish); - proxyReq.setTimeout(5000, () => { try { proxyReq.destroy(); } catch (_) {} finish(); }); + proxyReq.on('error', () => finish( + '' + + 'Connection failed: OpenSwarm is not reachable on this machine (port ' + backendPort + '). ' + + 'Open the OpenSwarm app and try connecting again.' + )); + proxyReq.setTimeout(15000, () => { try { proxyReq.destroy(); } catch (_) {} finish(null); }); proxyReq.end(); - } catch (_) { finish(); } + } catch (_) { finish(null); } return true; } } catch (_) {} diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 37875ba6..0195e04a 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -20,6 +20,7 @@ from backend.apps.agents.manager.session.session_store import ( from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState from backend.apps.agents.manager.streaming.PartialReply import PartialReply from backend.apps.agents.manager.session.SessionLifecycle import SessionLifecycle +from backend.apps.agents.manager.SpawnAgentRun import SpawnAgentRun from backend.apps.agents.manager.session.SessionPersistence import SessionPersistence from backend.apps.agents.manager.Messaging import Messaging from backend.apps.agents.manager.SessionControl import SessionControl @@ -38,7 +39,7 @@ os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000") MAX_CONCURRENT_TURNS = int(os.environ.get("OSW_MAX_CONCURRENT_TURNS", "8") or "0") -class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionControl, AgentLaunch, MockAgent, TurnRunner, RunOptions, RunSupport): +class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionControl, AgentLaunch, SpawnAgentRun, MockAgent, TurnRunner, RunOptions, RunSupport): @typechecked def __init__(self): self.sessions: Dict[str, AgentSession] = {} diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 34291509..e1688c4c 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -1,15 +1,17 @@ -from backend.config.Apps import SubApp -from backend.apps.agents.agent_manager import agent_manager -from backend.apps.agents.core.ws_manager import ws_manager -from backend.apps.agents.core.models import AgentConfig, ApprovalResponse -from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input -from contextlib import asynccontextmanager -from fastapi import WebSocket, WebSocketDisconnect, HTTPException -from fastapi.responses import JSONResponse import asyncio -import json import logging import time +from contextlib import asynccontextmanager +from typing import Any, Dict + +from fastapi import HTTPException +from typeguard import typechecked + +from backend.apps.agents.agent_manager import agent_manager +from backend.apps.agents.core.models import AgentConfig, AgentSession, ApprovalResponse +from backend.apps.agents.core.seq_log import seq_log +from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input +from backend.config.Apps import SubApp logger = logging.getLogger(__name__) @@ -39,17 +41,46 @@ async def agents_lifespan(): agents = SubApp("agents", agents_lifespan) +@typechecked +def p_session_list_item(session: AgentSession) -> Dict[str, Any]: + """Serialize dashboard metadata without retaining the full chat history.""" + data = session.model_dump(mode="json", exclude={"messages"}) + messages = session.messages + last_content = messages[-1].content if messages else "" + first_user_content = next( + (message.content for message in messages if message.role == "user"), + "", + ) + data.update( + messages=[], + last_message_preview=last_content[:120] if isinstance(last_content, str) else "", + first_user_message=( + first_user_content[:200] if isinstance(first_user_content, str) else "" + ), + message_count=len(messages), + ) + return data + + @agents.router.get("/sessions") async def list_sessions(dashboard_id: str = ""): sessions = agent_manager.get_all_sessions(dashboard_id=dashboard_id or None) - return {"sessions": [s.model_dump(mode="json") for s in sessions]} + return {"sessions": [p_session_list_item(s) for s in sessions]} @agents.router.get("/activity") async def agent_activity(): - """How many agent tasks are live right now. Drives the desktop's idle-update gate so a - silent update-on-idle never lands on top of a running agent.""" + """How many agent tasks are live right now, plus seconds until the next scheduled + workflow fires. Drives the desktop's idle-update gate so a silent update-on-idle + never lands on top of a running agent or right before a scheduled run.""" active = sum(1 for t in agent_manager.tasks.values() if not t.done()) - return {"active": active} + try: + # Local import: workflows pulls in agent machinery, a module-level import here would cycle. + from backend.apps.workflows.scheduler import seconds_to_next_fire + next_run_in_s = seconds_to_next_fire() + except Exception: + # Fail open (None = no block): a broken lookahead must never wedge updates forever; the agents gate still protects running work. + next_run_in_s = None + return {"active": active, "next_run_in_s": next_run_in_s} @agents.router.get("/sessions/{session_id}") async def get_session(session_id: str): @@ -70,7 +101,11 @@ async def get_session(session_id: str): session = await agent_manager.resume_session(session_id) except ValueError: raise HTTPException(status_code=404, detail="Session not found") - return session.model_dump(mode="json") + # Seq read before the dump (no await between = atomic): the client seeds its WS resume cursor from this, so a REST hydrate isn't followed by a full from-zero replay of everything it just received. + event_seq = seq_log.current_seq(session_id) + payload = session.model_dump(mode="json") + payload["event_seq"] = event_seq + return payload @agents.router.post("/launch") async def launch_agent(config: AgentConfig): @@ -251,10 +286,11 @@ async def delete_session(session_id: str): return {"ok": True} @agents.router.get("/history") -async def get_history(q: str = "", limit: int = 20, offset: int = 0, dashboard_id: str = ""): +async def get_history(q: str = "", limit: int = 20, offset: int = 0, dashboard_id: str = "", closed_only: int = 0): return agent_manager.get_history( q=q, limit=limit, offset=offset, dashboard_id=dashboard_id or None, + closed_only=bool(closed_only), ) @agents.router.get("/sessions/{session_id}/browser-agents") diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index b4a7d6e9..957e4831 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -2011,6 +2011,11 @@ async def run_browser_agent( f"[browser-agent {session_id}] browser card {browser_id} is unusable " f"({card_gone_streak} consecutive gone/hung results); aborting fast" ) + if os.environ.get("OSW_DEADCARD_EVICT", "1") != "0": + DEAD_CARDS.add(browser_id) + logger.info(f"[browser-agent] {browser_id} marked dead; same-host reuse will skip it") + # Tear the wedged webview DOWN now, before recovery spawns a fresh card. Two heavy pages (the dead one + the recovery one) starve the renderer's event loop = the recovery-card wedge; unmounting the dead one frees its renderer so the recovery card is the only heavy neighbor. + await evict_dead_card(dashboard_id, browser_id) break if cancel_event.is_set(): @@ -2217,6 +2222,8 @@ async def run_browser_agent( # Cards a sub-agent is actively driving in this process. Reuse must never hand two agents one webview (their commands would interleave into chaos). ACTIVE_AGENT_CARDS: set[str] = set() +# Cards a browser agent gave up on (gone/hung). Same-host reuse skips them so a retry never grabs a wedged card; the evict tears the agent-spawned ones down. +DEAD_CARDS: set[str] = set() # find+claim+create must be one critical section or two parallel dispatches race to claim the same idle card (or both miss and double-create). p_card_pick_lock = asyncio.Lock() @@ -2239,7 +2246,7 @@ def find_reusable_card(dashboard_id: str, url: str, parent_session_id: str | Non own, orphan = "", "" for bid, card in cards.items(): spawned = getattr(card, "spawned_by", None) - if not spawned or bid in ACTIVE_AGENT_CARDS: + if not spawned or bid in ACTIVE_AGENT_CARDS or bid in DEAD_CARDS: continue if browser_skills.host_of(getattr(card, "url", "") or "") != want: continue @@ -2252,6 +2259,43 @@ def find_reusable_card(dashboard_id: str, url: str, parent_session_id: str | Non return own or orphan +# The renderer needs a beat to unmount the and let Electron free its renderer process. Recovery spawns its fresh card the instant this returns, so we hold here until the teardown has almost certainly landed, else the new card mounts next to a still-freeing dead one and eats the same 15s starvation cap. Failure-path only, so its cost is invisible next to the cap it prevents. +P_EVICT_SETTLE_S = 1.5 + + +async def evict_dead_card(dashboard_id: str | None, browser_id: str) -> None: + """Free a wedged card's webview so the recovery card isn't its heavy neighbor: tell the + renderer to unmount it (frees the renderer process), drop it from the persisted layout, and + WAIT for teardown before the caller spawns the recovery card. Fail-open, never raises into the + abort path. ONLY agent-spawned cards are evicted: a user's own card must never be deleted out + from under them, wedged or not; for those the DEAD_CARDS reuse-skip is the whole remedy.""" + ACTIVE_AGENT_CARDS.discard(browser_id) + try: + from backend.apps.dashboards.dashboards import load as p_dash_load + p_card = p_dash_load(dashboard_id).layout.browser_cards.get(browser_id) if dashboard_id else None + if p_card is None or not getattr(p_card, "spawned_by", None): + logger.info(f"[browser-agent] {browser_id} is not an agent-spawned card; skipping evict (reuse-skip only)") + return + except Exception: + return + try: + await ws_manager.broadcast_global("dashboard:browser_card_evict", { + "dashboard_id": dashboard_id or "", "browser_id": browser_id}) + except Exception: + pass + if dashboard_id: + try: + from backend.apps.dashboards.dashboards import load, save + dash = load(dashboard_id) + if browser_id in dash.layout.browser_cards: + del dash.layout.browser_cards[browser_id] + dash.updated_at = datetime.now() + save(dash) + except Exception: + pass + await asyncio.sleep(P_EVICT_SETTLE_S) + + async def p_create_browser_card(dashboard_id: str, url: str, parent_session_id: str | None = None) -> str: """Create a new browser card on the dashboard and return its browser_id.""" from backend.apps.dashboards.dashboards import load, save diff --git a/backend/apps/agents/core/ws_manager.py b/backend/apps/agents/core/ws_manager.py index 5b8022ff..6996e5cc 100644 --- a/backend/apps/agents/core/ws_manager.py +++ b/backend/apps/agents/core/ws_manager.py @@ -23,6 +23,29 @@ BROWSER_CMD_REBROADCAST_S = 3.0 P_WS_RECONNECT_WAIT_S = 8.0 +def slim_status_data(event: str, data: dict) -> dict: + """agent:status frames carry session METADATA, never the transcript: every message already + reaches clients as its own agent:message event (and the stream), so re-shipping full history + per status flip was pure duplication, and replayed stale copies rolled clients backwards. + Preview fields mirror p_session_list_item so collapsed-card previews keep working.""" + if event != "agent:status": + return data + sess = data.get("session") + if not isinstance(sess, dict) or not sess.get("messages"): + return data + messages = sess["messages"] + last = messages[-1].get("content", "") + first_user = next((m.get("content") for m in messages if m.get("role") == "user"), "") + slim = dict(sess) + slim["messages"] = [] + slim["last_message_preview"] = last[:120] if isinstance(last, str) else "" + slim["first_user_message"] = first_user[:200] if isinstance(first_user, str) else "" + slim["message_count"] = len(messages) + out = dict(data) + out["session"] = slim + return out + + async def await_reconnect(has_conn) -> bool: """Poll up to P_WS_RECONNECT_WAIT_S for a dashboard socket to (re)appear. `has_conn` is a 0-arg callable returning truthy when connected.""" @@ -94,6 +117,7 @@ class ConnectionManager: async def send_to_session(self, session_id: str, event: str, data: dict): """Broadcast a session event with monotonic sequencing; terminal statuses also persist to disk.""" + data = slim_status_data(event, data) async with seq_log.stamp(session_id, event, data) as (seq, payload_str): for ws in list(self.connections.get(session_id, [])): try: @@ -163,6 +187,10 @@ class ConnectionManager: "to_seq": newest, } + # Live log and the client is at (or past) the top: caught up, nothing to send. Without this, every cursor-seeded reconnect got the persisted terminal frame re-sent as "replay". + if newest is not None and last_seq >= newest and newest > 0: + return {"ok": True, "replayed": 0, "current_seq": newest} + terminal = seq_log.load_terminal(session_id) if terminal is not None: try: @@ -225,7 +253,7 @@ class ConnectionManager: async def broadcast_global(self, event: str, data: dict): """Send to all dashboard connections; bypasses seq_log (dashboard resumes via full state refetch).""" - payload = json.dumps({"event": event, "data": data}) + payload = json.dumps({"event": event, "data": slim_status_data(event, data)}) dead: list[WebSocket] = [] for ws in list(self.global_connections): try: diff --git a/backend/apps/agents/manager/SpawnAgentRun.py b/backend/apps/agents/manager/SpawnAgentRun.py new file mode 100644 index 00000000..1c3e601a --- /dev/null +++ b/backend/apps/agents/manager/SpawnAgentRun.py @@ -0,0 +1,99 @@ +"""spawn_agent: back the SpawnAgent MCP tool with a FRESH sub-agent session (no history +copy; the prompt must be self-contained). Replaces the CLI's built-in Agent tool, which is +blocked in RunOptions: its subagent types resolve to models router setups can't serve, and +its schema drags description/subagent_type/model/isolation along. Mixin, same MRO pattern +as AgentLaunch.""" + +import asyncio +import logging +from datetime import datetime +from typing import Dict, Optional +from uuid import uuid4 + +from typeguard import typechecked + +from backend.apps.agents.core.models import AgentSession, Message +from backend.apps.agents.core.ws_manager import ws_manager +from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol +from backend.apps.agents.manager.session.apply_context_window import apply_context_window +from backend.apps.agents.manager.session.session_store import load_session_data + +logger = logging.getLogger(__name__) + + +def last_assistant_text(session: AgentSession) -> Optional[str]: + for msg in reversed(session.messages): + if msg.role == "assistant": + content = msg.content + if isinstance(content, str): + return content + if isinstance(content, list): + texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] + return "\n".join(texts) + return str(content) + return None + + +class SpawnAgentRun(AgentManagerProtocol): + @typechecked + async def spawn_agent( + self, + prompt: str, + parent_session_id: str, + dashboard_id: Optional[str] = None, + run_in_background: bool = False, + ) -> Dict: + parent = self.sessions.get(parent_session_id) + if not parent: + data = load_session_data(parent_session_id) + if data is None: + raise ValueError(f"Parent session {parent_session_id} not found") + parent = AgentSession(**data) + + title = (prompt.strip().splitlines() or [""])[0][:60] or "Sub-agent" + child = AgentSession( + id=uuid4().hex, + name=title, + status="running", + model=parent.model, + mode="sub-agent", + system_prompt=parent.system_prompt, + allowed_tools=list(parent.allowed_tools), + max_turns=parent.max_turns or 25, + cwd=parent.cwd, + created_at=datetime.now(), + dashboard_id=dashboard_id or parent.dashboard_id, + parent_session_id=parent_session_id, + ) + apply_context_window(child) + self.sessions[child.id] = child + + await ws_manager.broadcast_global("agent:status", { + "session_id": child.id, + "status": child.status, + "session": child.model_dump(mode="json"), + }) + + user_msg = Message( + role="user", + content=prompt, + branch_id=child.active_branch_id, + ) + child.messages.append(user_msg) + await ws_manager.send_to_session(child.id, "agent:message", { + "session_id": child.id, + "message": user_msg.model_dump(mode="json"), + }) + + if run_in_background: + # Fire-and-forget; the child's card carries its progress and result. Keep a handle in self.tasks so stop/shutdown machinery sees it. + task = asyncio.create_task(self.run_agent_loop(child.id, prompt)) + self.tasks[child.id] = task + return {"session_id": child.id, "background": True} + + await self.run_agent_loop(child.id, prompt) + return { + "session_id": child.id, + "response": last_assistant_text(child) or "No response from sub-agent.", + "cost_usd": child.cost_usd, + } diff --git a/backend/apps/agents/manager/configure_provider_env.py b/backend/apps/agents/manager/configure_provider_env.py index 327a2033..a7fa44f5 100644 --- a/backend/apps/agents/manager/configure_provider_env.py +++ b/backend/apps/agents/manager/configure_provider_env.py @@ -1,10 +1,9 @@ """Configure the SDK environment for the run's provider route: set ANTHROPIC/OPENAI/GOOGLE auth env vars (direct key, OpenSwarm Pro proxy, OpenRouter, or 9Router) and pin subagent models, -ensuring 9Router is up where the route needs it. sub_conns is the active-connection list for -subagent-model fallback (empty today).""" +ensuring 9Router is up where the route needs it.""" import os -from typing import Dict, List, Optional +from typing import Dict, Optional from typeguard import typechecked @@ -50,7 +49,6 @@ async def configure_provider_env( resolved_model: object, api_type: Optional[str], global_settings: AppSettings, - sub_conns: List, ) -> None: from backend.apps.nine_router import is_running as nine_router_running from backend.apps.agents.providers.registry import NINEROUTER_MODEL_PREFIXES as NINEROUTER_MODEL_PREFIXES @@ -198,7 +196,9 @@ async def configure_provider_env( "ANTHROPIC_API_KEY": "9router", "ANTHROPIC_BASE_URL": "http://localhost:20128", } - # Pin subagents to whichever lane the user has, else CLI's default Haiku 4.5 hits 9Router with no Claude route and 401s. NOTE: callers pass sub_conns=[] today so this is inert (latent regression from the run/ split; pyright caught the dangling _conns ref). + # Pin subagents to whichever lane the user has, else the CLI's default Haiku 4.5 hits 9Router with no Claude route and every sub-agent 401s while the parent turn works. Fetched live here (fail-open []) so no caller can starve the pin with a stale list again, the run/ split did exactly that and silently killed sub-agents on router routes. + from backend.apps.nine_router import get_providers as p_get_providers + sub_conns = await p_get_providers() active = {c.get("provider") for c in sub_conns if isinstance(c, dict) and c.get("isActive")} sub_model = None diff --git a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py index 3dc7f3bf..4a3833f6 100644 --- a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py +++ b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py @@ -64,6 +64,14 @@ def build_effective_tool_lists( effective_disallowed.append(f"mcp__openswarm-invoke-agent__{it}") continue + if name == "openswarm-spawn-agent": + policy = builtin_perms.get("Agent", "always_allow") + if policy == "always_allow": + effective_allowed.append("mcp__openswarm-spawn-agent__SpawnAgent") + elif policy == "deny": + effective_disallowed.append("mcp__openswarm-spawn-agent__SpawnAgent") + continue + if name == "openswarm-skill": policy = builtin_perms.get("Skill", "always_allow") if policy == "always_allow": diff --git a/backend/apps/agents/manager/prompt/tool_catalog.py b/backend/apps/agents/manager/prompt/tool_catalog.py index 1fe141ae..dfb41235 100644 --- a/backend/apps/agents/manager/prompt/tool_catalog.py +++ b/backend/apps/agents/manager/prompt/tool_catalog.py @@ -1,5 +1,6 @@ import logging -from typing import List, Optional, Set +import os +from typing import Dict, List, Optional, Set, Union from typeguard import typechecked @@ -12,19 +13,33 @@ from backend.apps.tools_lib.tools_lib import ( logger = logging.getLogger(__name__) +# Cron* live only in the force-deny list (path_gate): our Schedule MCP replaces the CLI scheduler, so allowing them here just churned the allow/deny lists. InvokeAgent's real tool is the mcp__openswarm-invoke-agent__ ref; the bare name was a no-op. FULL_TOOLS = [ "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", "WebSearch", "WebFetch", "NotebookEdit", "TodoWrite", "EnterPlanMode", "ExitPlanMode", "EnterWorktree", "TaskOutput", "TaskStop", - "CronCreate", "CronList", "CronDelete", - "InvokeAgent", - "Agent", # ToolSearch is the loader the CLI uses to expose deferred tool schemas on demand. Must be in the allowedTools whitelist or the model can't call it, which means none of the deferred extended tools become reachable even when the CLI advertises them in the system prompt. "ToolSearch", ] +@typechecked +def resolve_builtin_tools_option() -> Union[List[str], Dict[str, str]]: + """The SDK `tools` option (CLI `--tools`), the base set of built-in tools. + + Default: the full claude_code preset, which ships every preset built-in's schema. With + OSW_TOOL_MANIFEST=1, an explicit FULL_TOOLS list instead, ONLY the built-ins OpenSwarm exposes, + which prunes the ~9 preset extras nothing here references (Cron*/Monitor/Task*/PushNotification/ + RemoteTrigger/etc) for ~940 schema tokens/turn, cache-stable. MCP tools ride mcp_servers, so + SpawnAgent/browser/schedule/skill/web/user MCPs are untouched; ToolSearch stays in FULL_TOOLS so + deferred loading survives (live-proven: the model still ToolSearch-loads + calls MCP tools under + the manifest). Flag-gated pending a real-app soak before default-on.""" + if os.environ.get("OSW_TOOL_MANIFEST") == "1": + return list(FULL_TOOLS) + return {"type": "preset", "preset": "claude_code"} + + @typechecked def get_denied_tool_names(tool: ToolDefinition) -> Set[str]: """Return the set of MCP sub-tool names whose permission is 'deny'.""" diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py index 75bd4d5a..80d54966 100644 --- a/backend/apps/agents/manager/register_builtin_mcp_servers.py +++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py @@ -78,6 +78,23 @@ def register_builtin_mcp_servers( "type": "stdio", } + # SpawnAgent replaces the CLI's built-in Agent tool (blocked in RunOptions); gated by the same "Agent" permission so the Tools-page toggle keeps working. + if builtin_perms.get("Agent", "always_allow") != "deny": + spawn_agent_server_path = os.path.join( + agents_dir, "spawn_agent_mcp_server.py" + ) + mcp_servers["openswarm-spawn-agent"] = { + "command": sys.executable, + "args": [spawn_agent_server_path], + "env": { + "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), + "OPENSWARM_AUTH_TOKEN": get_auth_token(), + "OPENSWARM_PARENT_SESSION_ID": session.id, + "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", + }, + "type": "stdio", + } + # Always-on meta-MCP server. Exposes MCPList / MCPSearch / MCPActivate so the model can discover and activate user MCPs at runtime. The activation gate (active_mcps filter in build_mcp_servers above) ensures the model cannot reach any other MCP server's tools without going through this layer first. mcp_meta_server_path = os.path.join( agents_dir, "mcp_meta_server.py" diff --git a/backend/apps/agents/manager/run/RunOptions.py b/backend/apps/agents/manager/run/RunOptions.py index bab69e7a..e58e72e8 100644 --- a/backend/apps/agents/manager/run/RunOptions.py +++ b/backend/apps/agents/manager/run/RunOptions.py @@ -25,11 +25,11 @@ from backend.apps.agents.manager.configure_provider_env import configure_provide from backend.apps.agents.manager.session.workspace_git import ensure_cwd_git_repo from backend.apps.agents.manager.session.history_compaction import build_history_prefix, get_branch_messages from backend.apps.agents.manager.prompt.compose_turn_system_prompt import compose_turn_system_prompt -from backend.apps.agents.manager.prompt.tool_catalog import get_all_tool_names +from backend.apps.agents.manager.prompt.tool_catalog import get_all_tool_names, resolve_builtin_tools_option from backend.apps.agents.manager.prompt.prompt_context import resolve_mode from backend.apps.agents.manager.run.run_options_helpers import ( pre_send_context_guard, set_framework_overhead, register_web_mcp_server, - append_web_tools_hint, inject_thinking_options, + append_web_tools_hint, inject_thinking_options, merge_hard_blocked_tools, ) logger = logging.getLogger(__name__) @@ -188,17 +188,14 @@ class RunOptions(AgentManagerProtocol): } # cc/cx/gc/ag/gemini/openrouter prefixes force 9Router; route="api" bypasses to the provider's host directly; otherwise Pro proxy or key. await configure_provider_env( - options_kwargs, session, resolved_model, api_type, global_settings, [] + options_kwargs, session, resolved_model, api_type, global_settings ) if mcp_servers: options_kwargs["mcp_servers"] = mcp_servers mcp_json_len = len(json.dumps({"mcpServers": mcp_servers})) logger.info(f"[MCP-DEBUG] mcp_servers passed to SDK: {list(mcp_servers.keys())}, JSON length={mcp_json_len}") - # claude_code preset for BOTH system_prompt and tools so the CLI's deferred-tools scaffolding survives. Raw string would replace it. - options_kwargs["tools"] = { - "type": "preset", - "preset": "claude_code", - } + # Built-in tool surface (preset vs pruned FULL_TOOLS manifest); see resolve_builtin_tools_option. system_prompt keeps the preset regardless. + options_kwargs["tools"] = resolve_builtin_tools_option() # exclude_dynamic_sections=True moves cwd/git/OS grounding out of the cached prefix and into the first user message, unlocks Anthropic prompt cache (~80% input-token cut, 13-31% faster TTFT). Trade-off: grounding freezes at turn 1. if composed_prompt: options_kwargs["system_prompt"] = { @@ -227,12 +224,8 @@ class RunOptions(AgentManagerProtocol): options_kwargs["extra_args"] = p_ea # The claude_code preset auto-attaches the user's claude.ai- connected partner MCPs (`mcp__claude_ai_*`). Those bypass our MCPActivate gate, don't share OAuth state with the OpenSwarm Gmail/Calendar/Drive connectors the user actually configured here, and confuse the model into picking the partner shim instead of our vetted server. Hard-block them at the SDK layer so the model can't even attempt the call. - # EXTEND, never reassign: a plain assignment here silently discarded every effective_disallowed - # entry (read-only sessions could still run Bash). Same fix as eric/dev ce3e67d6. - options_kwargs["disallowed_tools"] = [ - *(options_kwargs.get("disallowed_tools") or []), - "mcp__claude_ai_*", - ] + # merge EXTENDS effective_disallowed: the old plain assignment silently discarded the computed denies (Cron*/Skill/web-swap/per-tool MCP) and left the runtime gate as the only wall. + options_kwargs["disallowed_tools"] = merge_hard_blocked_tools(effective_disallowed) if session.cwd: # Pre-existing sessions may have workspaces that predate the git-init block in launch_agent, leaving them without a valid HEAD. Ensure it here so subagent worktree-add always works. diff --git a/backend/apps/agents/manager/run/run_options_helpers.py b/backend/apps/agents/manager/run/run_options_helpers.py index c88fbd81..a471a974 100644 --- a/backend/apps/agents/manager/run/run_options_helpers.py +++ b/backend/apps/agents/manager/run/run_options_helpers.py @@ -12,6 +12,15 @@ from backend.apps.agents.manager.session.history_compaction import estimate_post logger = logging.getLogger(__name__) +# Always SDK-blocked regardless of permissions: claude.ai partner MCPs bypass our MCPActivate gate, and the CLI's built-in sub-agent tool (Task on 2.1.122, Agent on older builds) is replaced by our SpawnAgent MCP. +HARD_BLOCKED_TOOLS: List[str] = ["mcp__claude_ai_*", "Agent", "Task"] + + +@typechecked +def merge_hard_blocked_tools(effective_disallowed: List[str]) -> List[str]: + """The SDK deny list = the computed per-turn denies PLUS the unconditional hard blocks. A plain assignment here once silently discarded effective_disallowed (Cron*/Skill/web-swap/per-tool MCP denies), leaving the runtime gate as the only wall; merge, never overwrite.""" + return effective_disallowed + [t for t in HARD_BLOCKED_TOOLS if t not in effective_disallowed] + # `manager` is the AgentManager; it isn't annotated because typing it would import agent_manager back into a module agent_manager already imports (a cycle). Same reason self is never annotated. @typechecked diff --git a/backend/apps/agents/manager/session/SessionLifecycle.py b/backend/apps/agents/manager/session/SessionLifecycle.py index 19ba4654..60eec353 100644 --- a/backend/apps/agents/manager/session/SessionLifecycle.py +++ b/backend/apps/agents/manager/session/SessionLifecycle.py @@ -153,6 +153,7 @@ class SessionLifecycle(AgentManagerProtocol): limit: int = 20, offset: int = 0, dashboard_id: Optional[str] = None, + closed_only: bool = False, ) -> Dict: """Return paginated, optionally filtered summaries of closed sessions.""" all_data = load_all_session_data() @@ -161,6 +162,9 @@ class SessionLifecycle(AgentManagerProtocol): q_lower = q.strip().lower() history = [] for sid, data in all_data: + # The boot fetch wants CLOSED sessions only: open ones landing in the client's history map made its resurrection gate swallow their terminal frames. Search keeps the full pool (open sessions on other dashboards are reachable nowhere else). + if closed_only and not data.get("closed_at"): + continue if dashboard_id and data.get("dashboard_id") != dashboard_id: continue if q_lower: diff --git a/backend/apps/agents/manager/streaming/post_tool_hook.py b/backend/apps/agents/manager/streaming/post_tool_hook.py index 26f5e0ae..bfadff6f 100644 --- a/backend/apps/agents/manager/streaming/post_tool_hook.py +++ b/backend/apps/agents/manager/streaming/post_tool_hook.py @@ -1,22 +1,19 @@ """The SDK PostToolUse hook, lifted out of the agent loop. Runs after every tool call: records per-tool latency, normalizes the raw tool response into displayable text, re-renders -view-builder writes (and drains build errors), materializes a spawned Agent sub-session into -the manager registry, spills oversized results to disk, and broadcasts the tool_result message. +view-builder writes (and drains build errors), spills oversized results to disk, and +broadcasts the tool_result message. Operates on the HookContext (its `sessions` is the manager's live registry). The dict returns and payloads are the SDK hook protocol / existing message shapes, not internal models.""" import asyncio import logging import time -from datetime import datetime from typing import Dict -from uuid import uuid4 from typeguard import typechecked -from backend.apps.agents.core.models import AgentSession, Message +from backend.apps.agents.core.models import Message from backend.apps.agents.core.ws_manager import ws_manager -from backend.apps.agents.manager.session.apply_context_window import apply_context_window from backend.apps.agents.manager.session.history_compaction import ( truncate_large_tool_result, wrap_platform_note, @@ -145,68 +142,7 @@ async def post_tool_hook(ctx: HookContext, input_data: dict, tool_use_id, contex if elapsed_ms is not None: result_payload["elapsed_ms"] = elapsed_ms - if hook_tool_name == "Agent": - tool_input = input_data.get("tool_input", {}) - agent_prompt = tool_input.get("prompt", tool_input.get("task", "")) - - sub_text = content - sub_cost = 0.0 - sub_tokens = {"input": 0, "output": 0} - sub_model = session.model - if isinstance(raw_response, dict): - blocks = raw_response.get("content") - if isinstance(blocks, list): - parts = [ - b.get("text", "") - for b in blocks - if isinstance(b, dict) and b.get("type") == "text" - ] - if parts: - sub_text = "\n".join(parts) if len(parts) > 1 else parts[0] - elif isinstance(raw_response.get("text"), str): - sub_text = raw_response["text"] - usage = raw_response.get("usage", {}) - if isinstance(usage, dict): - sub_tokens["input"] = usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0) - # Pill-only lane: NEW (uncached) input, excludes the cached static prefix so the bubble shows what this turn added. - sub_tokens["input_fresh"] = usage.get("input_tokens", 0) - sub_tokens["output"] = usage.get("output_tokens", 0) - if raw_response.get("total_cost_usd"): - sub_cost = raw_response["total_cost_usd"] - if raw_response.get("model"): - sub_model = raw_response["model"] - - sub_session_id = uuid4().hex - sub_name = agent_prompt[:50] if agent_prompt else "Sub-agent" - # Subagent context isolation invariant (Phase 3, Layer P): children DO NOT inherit the parent's active_mcps or compaction state. They start with the AgentSession defaults (empty lists). Reasoning: - Security: a parent that activated Gmail shouldn't leak Gmail tools to a subagent doing an unrelated task. The user only approved Gmail for the parent. - Token cost: subagents typically have a narrow task, they don't need the parent's full activated set. - Failure isolation: if the parent compacted history, the subagent shouldn't inherit a summary it can't re-expand. If a subagent ever needs a parent activation, the user must approve it explicitly via MCPActivate inside the subagent session, same gate as a fresh top-level chat. - sub_session = AgentSession( - id=sub_session_id, - name=sub_name, - status="completed", - model=sub_model, - mode="sub-agent", - cwd=session.cwd, - created_at=datetime.now(), - cost_usd=sub_cost, - tokens=sub_tokens, - messages=[ - Message(role="user", content=agent_prompt, branch_id="main"), - Message(role="assistant", content=sub_text, branch_id="main"), - ], - dashboard_id=session.dashboard_id, - parent_session_id=session_id, - # Explicit empty list (matches the model default) so the invariant is visible at the spawn site rather than relying on the field's default_factory. - active_mcps=[], - ) - apply_context_window(sub_session) - ctx.sessions[sub_session_id] = sub_session - await ws_manager.broadcast_global("agent:status", { - "session_id": sub_session_id, - "status": sub_session.status, - "session": sub_session.model_dump(mode="json"), - }) - result_payload["sub_session_id"] = sub_session_id - + # The CLI's built-in Agent/Task sub-agent tool is hard-blocked (disallowed_tools) and replaced by the SpawnAgent MCP route, which materializes real child sessions itself; no per-tool branch needed here anymore. result_msg = Message(role="tool_result", content=result_payload, branch_id=session.active_branch_id) # Spill oversized tool results to per-session disk storage. The replacement keeps the first 4KB inline so the model retains some signal; the rest lives on disk for the UI to surface in the compaction drawer. Crucially this happens at *write* time (before the next turn ships history to the SDK) so the bloat never re-enters context. try: diff --git a/backend/apps/agents/providers/pricing.py b/backend/apps/agents/providers/pricing.py index 32d636ed..b6f274fb 100644 --- a/backend/apps/agents/providers/pricing.py +++ b/backend/apps/agents/providers/pricing.py @@ -20,6 +20,8 @@ MODEL_TIERS: dict[str, tuple[int, int, int]] = { "claude-opus-4-5": (5, 2, 5), "claude-opus-4": (5, 2, 5), "anthropic/claude-opus-4": (5, 2, 5), + "claude-sonnet-5": (5, 4, 3), + "anthropic/claude-sonnet-5": (5, 4, 3), "claude-sonnet-4-6": (4, 4, 3), "claude-sonnet-4.6": (4, 4, 3), "anthropic/claude-sonnet-4.6": (4, 4, 3), diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 1b38c21a..a2d0e1ff 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -93,6 +93,13 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = { "context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini", "api": "codex", "subscription_only": True, "reasoning": True}, # gpt-5.3-codex (+ high/xhigh) removed: superseded by GPT-5.5 as OpenAI's recommended Codex model, and high/xhigh were never separate models (just reasoning-effort variants), so they were redundant clutter. API-key entries: route through 9Router's `cp-openai` provider-node (registered by sync_openai_api_key) so 9Router's translator dispatches to our local openai-passthrough proxy. The passthrough renames `max_tokens` → `max_completion_tokens` before forwarding to api.openai.com, fixing OpenAI's GPT-5 family 400. The bare router_model_id (e.g. "gpt-5.5") still appears in the request body; only the routing prefix changes. + # GPT-5.6 (Sol / Terra / Luna, 2026-07) is HELD, not offered: it is Responses-API-only + # (api model ids gpt-5.6-sol [alias gpt-5.6], gpt-5.6-terra, gpt-5.6-luna; per-1M in/out + # $5/$30, $2.50/$15, $1/$6). Our lane goes user -> 9Router 0.3.60 -> cp-openai passthrough, + # and 0.3.60 only speaks /chat/completions, so a gpt-5.6 request hits the wrong endpoint and + # OpenAI rejects it (plus it is a trusted-partner limited preview, so most keys 404 anyway). + # No working lane = not offered (same rule as gpt-5.5's cx entry). Enable all three tiers + # once 9Router can translate to /v1/responses AND the model is generally available. {"value": "gpt-5.5-api", "label": "GPT-5.5 (API key)", "context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.5", "model_id": "gpt-5.5", "api": "openai", "reasoning": True, "route": "api"}, diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index 5396f531..d1f735a9 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -295,7 +295,7 @@ def send_response(id_, result=None, error=None): sys.stdout.flush() -def _call(method: str, path: str, body=None) -> dict: +def _call(method: str, path: str, body=None, timeout: int = 30) -> dict: url = BACKEND_BASE + path data = json.dumps(body).encode() if body is not None else None headers = {"Content-Type": "application/json"} @@ -303,7 +303,7 @@ def _call(method: str, path: str, body=None) -> dict: headers["Authorization"] = f"Bearer {BACKEND_AUTH}" req = urllib.request.Request(url, data=data, headers=headers, method=method) try: - with urllib.request.urlopen(req, timeout=30) as resp: + with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read().decode() or "null") or {} except urllib.error.HTTPError as e: body_err = e.read().decode() if e.fp else str(e) @@ -370,7 +370,12 @@ def handle_list(_args: dict) -> dict: title = w.get("title", "(untitled)") wid = w.get("id", "") state = "ON" if enabled else "off" - lines.append(f" - {title} [{state}] {unit} at {hour:02d}:00 (id: {wid})") + desc = (w.get("description") or "").strip().replace("\n", " ") + if len(desc) > 120: + desc = desc[:117] + "..." + invocable = " [agent-invocable]" if w.get("exposed_as_tool") else "" + suffix = f" - {desc}" if desc else "" + lines.append(f" - {title} [{state}] {unit} at {hour:02d}:00 (id: {wid}){invocable}{suffix}") return _ok("\n".join(lines)) @@ -560,7 +565,53 @@ def handle_suggest_convert_to_workflow(args: dict) -> dict: return {"content": [{"type": "text", "text": result}]} +# Matches the backend's INVOKE_WAIT_TIMEOUT_S; the HTTP call outlives the run wait by a margin. +INVOKE_WAIT_TIMEOUT_S = 15 * 60 + +TOOLS.append({ + "name": "InvokeWorkflow", + "description": ( + "Run one of the user's saved workflows and WAIT for its result (status + full transcript). " + "Only workflows the user marked agent-invocable on the Actions page can be run; " + "ListScheduledWorkflows marks those with [agent-invocable]. Pass the workflow id or exact title. " + "Long workflows may take minutes; the call blocks until the run finishes (15 min cap)." + ), + "inputSchema": { + "type": "object", + "properties": { + "workflow": {"type": "string", "description": "Workflow id or exact title"}, + }, + "required": ["workflow"], + }, +}) + + +def handle_invoke_workflow(args: dict) -> dict: + ident = str(args.get("workflow") or "").strip() + if not ident: + return _err("workflow (id or exact title) is required") + r = _call("GET", "/list") + if "_error" in r: + return _err(r["_error"]) + exposed = [w for w in r.get("workflows", []) if w.get("exposed_as_tool")] + match = next((w for w in exposed if w.get("id") == ident), None) or next( + (w for w in exposed if (w.get("title") or "").strip().lower() == ident.lower()), None) + if not match: + names = ", ".join(f"{w.get('title')} (id: {w.get('id')})" for w in exposed) or "(none)" + return _err(f"No agent-invocable workflow matches '{ident}'. Invocable workflows: {names}") + res = _call("POST", f"/{match['id']}/invoke", body={}, timeout=INVOKE_WAIT_TIMEOUT_S + 30) + if "_error" in res: + return _err(res["_error"]) + if res.get("timed_out"): + return _ok(f"Run of '{match.get('title')}' is still going after 15 minutes; it continues in the background. Check the workflow's History for the outcome.") + status = res.get("status") or "unknown" + err_line = f"\nError: {res.get('error')}" if res.get("error") else "" + transcript = res.get("transcript") or "(no transcript)" + return _ok(f"Workflow '{match.get('title')}' run {status}.{err_line}\n\n=== RUN TRANSCRIPT ===\n{transcript}\n=== END TRANSCRIPT ===") + + HANDLERS = { + "InvokeWorkflow": handle_invoke_workflow, "ScheduleWorkflow": handle_schedule_workflow, "ListScheduledWorkflows": handle_list, "UpdateScheduledWorkflow": handle_update, diff --git a/backend/apps/agents/spawn_agent_mcp_server.py b/backend/apps/agents/spawn_agent_mcp_server.py new file mode 100644 index 00000000..6c5a9255 --- /dev/null +++ b/backend/apps/agents/spawn_agent_mcp_server.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Stdio MCP server exposing the SpawnAgent tool; proxies to /api/spawn-agent/run. + +Replaces the CLI's built-in Agent tool (blocked in RunOptions): that schema drags +description/subagent_type/model/isolation along, and its subagent types resolve to +models our router setups can't serve. This one takes prompt + run_in_background, +nothing else; the child runs as a real OpenSwarm session card on the dashboard.""" + +import json +import sys +import os +import urllib.request +import urllib.error + +BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324") +BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "") +BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/spawn-agent/run" +PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "") +DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "") + +TOOLS = [ + { + "name": "SpawnAgent", + "description": ( + "Spawn a sub-agent to handle a task. The sub-agent runs as its own " + "agent session (visible on the dashboard) with the same working " + "directory and model as you. By default this blocks until the " + "sub-agent finishes and returns its final answer; set " + "run_in_background=true to return immediately and let it work on " + "its own, its progress and result appear on its dashboard card." + ), + "inputSchema": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": ( + "The task for the sub-agent. Include all context it " + "needs; it does not see your conversation." + ), + }, + "run_in_background": { + "type": "boolean", + "description": ( + "true = return immediately with the sub-agent's session " + "id instead of waiting for its result." + ), + }, + }, + "required": ["prompt"], + }, + }, +] + + +def send_response(id_, result=None, error=None): + msg = {"jsonrpc": "2.0", "id": id_} + if error is not None: + msg["error"] = error + else: + msg["result"] = result + sys.stdout.write(json.dumps(msg) + "\n") + sys.stdout.flush() + + +def call_backend(prompt: str, run_in_background: bool) -> dict: + payload = json.dumps({ + "prompt": prompt, + "run_in_background": run_in_background, + "parent_session_id": PARENT_SESSION_ID, + "dashboard_id": DASHBOARD_ID, + }).encode() + headers = {"Content-Type": "application/json"} + if BACKEND_AUTH: + headers["Authorization"] = f"Bearer {BACKEND_AUTH}" + req = urllib.request.Request( + BACKEND_URL, + data=payload, + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=1800) 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)} + + +def handle_tool_call(tool_name: str, arguments: dict) -> dict: + if tool_name != "SpawnAgent": + return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True} + + prompt = arguments.get("prompt", "") + run_in_background = bool(arguments.get("run_in_background", False)) + + if not prompt: + return {"content": [{"type": "text", "text": "Error: prompt is required"}], "isError": True} + + result = call_backend(prompt, run_in_background) + + if "error" in result: + return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True} + + sid = result.get("session_id", "") + if run_in_background: + return {"content": [{"type": "text", "text": ( + f"Spawned background sub-agent (session: {sid}). It is working on its own " + "dashboard card; its result will appear there. Do not wait for it." + )}]} + + response = result.get("response", "No response from sub-agent.") + cost = result.get("cost_usd", 0) + lines = [f"**Sub-Agent Result** (session: {sid})"] + if cost > 0: + lines.append(f"*Cost: ${cost:.4f}*") + lines.append("") + lines.append(response) + return {"content": [{"type": "text", "text": "\n".join(lines)}]} + + +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-spawn-agent", + "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() diff --git a/backend/apps/outputs/html_inject.py b/backend/apps/outputs/html_inject.py index 4ac6ff06..3eae929b 100644 --- a/backend/apps/outputs/html_inject.py +++ b/backend/apps/outputs/html_inject.py @@ -14,17 +14,6 @@ from jsonschema import validate as schema_validate, ValidationError as SchemaVal logger = logging.getLogger(__name__) -MODEL_MAP = { - "sonnet": "claude-sonnet-4-20250514", - "opus": "claude-opus-4-20250514", - "haiku": "claude-haiku-4-5-20251001", -} - - -def resolve_model(short_name: str) -> str: - return MODEL_MAP.get(short_name, short_name) - - def get_anthropic_client(api_model: str | None = None): """Create an AsyncAnthropic client using the API key from app settings. diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 62dfd17c..51c480a4 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -28,8 +28,6 @@ from backend.apps.outputs.view_builder_templates import ( from backend.apps.settings.settings import load_settings from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR from backend.apps.outputs.html_inject import ( - MODEL_MAP, - resolve_model, get_anthropic_client, validate_against_schema, build_data_injection, diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index d6784403..e107543f 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -21,8 +21,10 @@ import platform from collections import Counter from contextlib import asynccontextmanager from datetime import datetime +from typing import Literal, Optional from fastapi import Body +from pydantic import BaseModel, ConfigDict from backend.config.Apps import SubApp from backend.config.paths import SESSIONS_DIR @@ -512,6 +514,25 @@ async def post_event(body: dict): return {"ok": True} +class UpdaterEventBody(BaseModel): + model_config = ConfigDict(validate_assignment=True) + kind: Literal["idle_install"] + staged_version: Optional[str] = None + + +@service.router.post("/updater-event") +async def post_updater_event(body: UpdaterEventBody): + """Electron main reports updater milestones (today just the evergreen idle install) so fleet convergence shows up in analytics logs instead of being inferred.""" + from backend.apps.service.analytics.client import get_analytics_client + client = get_analytics_client() + if client is not None: + try: + client.logs.write(tag="updater", subtag=body.kind, data={"staged_version": body.staged_version or "", "app_version": APP_VERSION}) + except Exception: + pass + return {"ok": True} + + @service.router.get("/spool/count") async def spool_count(): from backend.apps.service import buffer diff --git a/backend/apps/service/version.py b/backend/apps/service/version.py index b0deca57..8579a248 100644 --- a/backend/apps/service/version.py +++ b/backend/apps/service/version.py @@ -10,18 +10,22 @@ import os def read_app_version() -> str: # Preferred: Electron's main process injects this when spawning the backend (see electron/main.js; OPENSWARM_APP_VERSION). Always reliable in packaged builds because it comes from app.getVersion() rather than path-based file resolution. - env_v = os.environ.get("OPENSWARM_APP_VERSION", "").strip() - if env_v: - return env_v - # Fallback: read electron/package.json via relative path. Works in `bash run.sh` dev mode where the repo layout is intact, but FAILS in packaged dmg/exe builds because electron/package.json isn't shipped into Resources/; which made every shipped install report app_version="unknown" pre-fix. Kept for backward compatibility with dev runs and as a safety net if the env var is ever unset. - try: - p_here = os.path.dirname(os.path.abspath(__file__)) - p_repo = os.path.dirname(os.path.dirname(os.path.dirname(p_here))) - p_pkg = os.path.join(p_repo, "electron", "package.json") - with open(p_pkg, encoding="utf-8") as p_f: - return json.load(p_f).get("version", "unknown") - except (OSError, ValueError, KeyError): - return "unknown" + base = os.environ.get("OPENSWARM_APP_VERSION", "").strip() + if not base: + # Fallback: read electron/package.json via relative path. Works in `bash run.sh` dev mode where the repo layout is intact, but FAILS in packaged dmg/exe builds because electron/package.json isn't shipped into Resources/; which made every shipped install report app_version="unknown" pre-fix. Kept for backward compatibility with dev runs and as a safety net if the env var is ever unset. + try: + p_here = os.path.dirname(os.path.abspath(__file__)) + p_repo = os.path.dirname(os.path.dirname(os.path.dirname(p_here))) + p_pkg = os.path.join(p_repo, "electron", "package.json") + with open(p_pkg, encoding="utf-8") as p_f: + base = json.load(p_f).get("version", "unknown") + except (OSError, ValueError, KeyError): + base = "unknown" + # A dev/hackathon cohort (OPENSWARM_APP_CHANNEL=dev) reports e.g. "1.5.8-dev" so its events stay filterable from real installs; prod or unset keeps the bare version. + channel = os.environ.get("OPENSWARM_APP_CHANNEL", "").strip().lower() + if channel and channel != "prod": + return f"{base}-{channel}" + return base APP_VERSION = read_app_version() diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 191ac6cf..828c6f12 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -16,8 +16,8 @@ DEFAULT_SYSTEM_PROMPT = ( "giving up or repeating the same action. Always stay focused on what the user " "actually wants to accomplish; their intent matters more than the specific method.\n\n" "## Tool Priority\n" - "1. Connected MCP tools; fastest and most reliable. Use ToolSearch to discover " - "what integrations are available if you're unsure.\n" + "1. Connected MCP tools; fastest and most reliable. To reach an integration you " + "don't already see, use MCPSearch then MCPActivate; never ToolSearch for it.\n" "2. WebSearch / WebFetch; for general web lookups when no MCP tool fits.\n" "3. BrowserAgent; last resort, only for visual interaction with websites, " "filling forms, or tasks no other tool can handle.\n\n" diff --git a/backend/apps/swarm/closure.py b/backend/apps/swarm/closure.py index 9d13c8ce..b09ec962 100644 --- a/backend/apps/swarm/closure.py +++ b/backend/apps/swarm/closure.py @@ -126,9 +126,9 @@ def build_manifest(root_type: EntityType, root_id: str) -> Manifest: return p_assemble(root_type, root_id)[0] -def build_bundle(root_type: EntityType, root_id: str) -> tuple[bytes, str]: +def build_bundle(root_type: EntityType, root_id: str, allow_file_secrets: bool = False) -> tuple[bytes, str]: manifest, payloads, files = p_assemble(root_type, root_id) - raw = pack(manifest.model_dump(by_alias=True, mode="json"), payloads, files) + raw = pack(manifest.model_dump(by_alias=True, mode="json"), payloads, files, allow_file_secrets=allow_file_secrets) return raw, manifest.root.name diff --git a/backend/apps/swarm/entities/workflows.py b/backend/apps/swarm/entities/workflows.py index 42a8e92e..0b2ddef3 100644 --- a/backend/apps/swarm/entities/workflows.py +++ b/backend/apps/swarm/entities/workflows.py @@ -1,8 +1,5 @@ """WorkflowExportable: shares a scheduled-task/workflow recipe (steps, schedule -shape, actions, model). The workflow store lives on the eric/workflow branch and -is NOT on eric/dev yet, so every store touch is lazy: on a build without it, -export finds nothing and import fails with a clear message, and the module still -imports cleanly. It lights up the moment the workflow forward-port lands. +shape, actions, model). Safety: an imported workflow must never silently start running on someone else's machine, so the schedule is forced off on import (the importer re-arms it). The @@ -12,6 +9,8 @@ from __future__ import annotations from backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable from backend.apps.swarm.models import EntityType, Requirement, RequirementKind +from backend.apps.workflows import storage +from backend.apps.workflows.models import Workflow P_BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"} @@ -52,10 +51,7 @@ class WorkflowExportable: @classmethod def load(cls, local_id: str) -> "WorkflowExportable | None": - store = p_store() - if store is None: - return None - wf = store.get_workflow(local_id) + wf = storage.get_workflow(local_id) if wf is None: return None data = wf.model_dump(mode="json") @@ -92,38 +88,15 @@ class WorkflowExportable: @classmethod def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: - store = p_store() - model = p_model() - if store is None or model is None: - from backend.apps.swarm.ziputil import BundleError - raise BundleError("this build doesn't support workflows yet; please update OpenSwarm") clean = sanitize_workflow(payload) clean.pop("id", None) # fresh id via the model's default_factory - wf = model(**clean) - store.save_workflow(wf) + wf = Workflow(**clean) + storage.save_workflow(wf) return wf.id @classmethod def rollback(cls, local_id: str) -> None: - store = p_store() - if store is not None: - try: - store.delete_workflow(local_id) - except Exception: - pass - - -def p_store(): - try: - from backend.apps.workflows import storage - return storage - except Exception: - return None - - -def p_model(): - try: - from backend.apps.workflows.models import Workflow - return Workflow - except Exception: - return None + try: + storage.delete_workflow(local_id) + except Exception: + pass diff --git a/backend/apps/swarm/models.py b/backend/apps/swarm/models.py index c5c02460..c47f7652 100644 --- a/backend/apps/swarm/models.py +++ b/backend/apps/swarm/models.py @@ -104,6 +104,8 @@ class ReviewSummary(BaseModel): class ExportRequest(BaseModel): type: EntityType id: str + # User-confirmed "export anyway": skips the file-content secret heuristic on direct download only; denied payload fields stay blocked. + allow_secrets: bool = False class ExportPreflightResponse(BaseModel): diff --git a/backend/apps/swarm/swarm.py b/backend/apps/swarm/swarm.py index 57c3a66f..ceca79ca 100644 --- a/backend/apps/swarm/swarm.py +++ b/backend/apps/swarm/swarm.py @@ -71,7 +71,7 @@ async def export_preflight(body: ExportRequest) -> ExportPreflightResponse: @swarm.router.post("/export") async def export_bundle(body: ExportRequest) -> Response: try: - raw, name = closure.build_bundle(body.type, body.id) + raw, name = closure.build_bundle(body.type, body.id, allow_file_secrets=body.allow_secrets) except BundleError as e: raise HTTPException(status_code=400, detail=str(e)) fname = closure.swarm_filename(name) diff --git a/backend/apps/swarm/ziputil.py b/backend/apps/swarm/ziputil.py index 4e1e3ceb..f47f240c 100644 --- a/backend/apps/swarm/ziputil.py +++ b/backend/apps/swarm/ziputil.py @@ -37,21 +37,25 @@ def p_content_digest(entries: dict[str, bytes]) -> str: return h.hexdigest() -def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) -> bytes: +def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes], allow_file_secrets: bool = False) -> bytes: """payloads: bundle_id -> JSON payload (-> entities//payload.json). - files: full zip path -> bytes (e.g. entities//files/).""" + files: full zip path -> bytes (e.g. entities//files/). + allow_file_secrets is a user-confirmed override for the FILE-content heuristic only + (workspace code trips it on look-alike strings); denied payload fields are our own + credential store and are never exportable, override or not.""" for bid, payload in payloads.items(): leaked = find_denied_keys(payload) if leaked: raise BundleError( f"refusing to export: secret-shaped field(s) in {bid}: {leaked[:3]}" ) - leaky_files = find_secrets_in_files(files) - if leaky_files: - raise BundleError( - f"refusing to export: a secret-shaped value is in {leaky_files[0]}; " - "remove it (use an environment variable) and try again" - ) + if not allow_file_secrets: + leaky_files = find_secrets_in_files(files) + if leaky_files: + raise BundleError( + f"refusing to export: a secret-shaped value is in {leaky_files[0]}; " + "remove it (use an environment variable) and try again" + ) entries: dict[str, bytes] = {} for bid, payload in payloads.items(): entries[f"entities/{bid}/payload.json"] = json.dumps(payload, indent=2).encode("utf-8") diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index df3fcc63..84c176e0 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -85,6 +85,8 @@ class Workflow(BaseModel): deleted_at: Optional[datetime] = None system_prompt: Optional[str] = None use_synced_prompt: bool = True + # Agents may call this workflow via the InvokeWorkflow MCP tool (user opt-in per workflow on the Actions page). + exposed_as_tool: bool = False steps: list[WorkflowStep] = Field(default_factory=list) actions: ActionsConfig = Field(default_factory=ActionsConfig) schedule: ScheduleConfig = Field(default_factory=ScheduleConfig) @@ -162,6 +164,8 @@ class WorkflowCreate(BaseModel): color: Optional[str] = None system_prompt: Optional[str] = None use_synced_prompt: bool = True + # Agents may call this workflow via the InvokeWorkflow MCP tool (user opt-in per workflow on the Actions page). + exposed_as_tool: bool = False steps: list[WorkflowStep] = Field(default_factory=list) actions: ActionsConfig = Field(default_factory=ActionsConfig) schedule: ScheduleConfig = Field(default_factory=ScheduleConfig) @@ -198,6 +202,7 @@ class WorkflowUpdate(BaseModel): color: Optional[str] = None system_prompt: Optional[str] = None use_synced_prompt: Optional[bool] = None + exposed_as_tool: Optional[bool] = None steps: Optional[list[WorkflowStep]] = None actions: Optional[ActionsConfig] = None schedule: Optional[ScheduleConfig] = None diff --git a/backend/apps/workflows/scheduler.py b/backend/apps/workflows/scheduler.py index 37c8f25c..5bb1f303 100644 --- a/backend/apps/workflows/scheduler.py +++ b/backend/apps/workflows/scheduler.py @@ -322,10 +322,11 @@ async def _fire(wf: Workflow, scheduled_for: Optional[datetime]) -> None: logger.exception("scheduler fire failed for workflow=%s", wf.id) -def _seconds_until_next() -> float: - # While globally paused, _tick no-ops and never rolls next_run_at forward, so an overdue slot would otherwise spin this loop at the 1s floor. Resume calls kick(), so idling the full interval here costs nothing. +def seconds_to_next_fire() -> Optional[float]: + """Seconds until the soonest enabled scheduled workflow fires; None when nothing is + queued or scheduling is globally paused. Also feeds the desktop's idle-update gate.""" if storage.get_paused(): - return 60.0 + return None now_utc = datetime.now(timezone.utc) soonest: Optional[datetime] = None for wf in storage.list_workflows(): @@ -337,8 +338,15 @@ def _seconds_until_next() -> float: if soonest is None or nra < soonest: soonest = nra if soonest is None: + return None + return max(0.0, (soonest - now_utc).total_seconds()) + + +def _seconds_until_next() -> float: + # While globally paused, _tick no-ops and never rolls next_run_at forward, so an overdue slot would otherwise spin this loop at the 1s floor. Resume calls kick(), so idling the full interval here costs nothing. + delta = seconds_to_next_fire() + if delta is None: return 60.0 - delta = (soonest - now_utc).total_seconds() return max(1.0, min(delta, 60.0)) diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index d01a9056..fe930c1a 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -1438,6 +1438,41 @@ async def run_workflow_now(workflow_id: str, body: Optional[dict] = None): return {"run_id": "", "status": None, "error": None} +# Bounded so a runaway workflow can't pin the calling agent's tool call forever; on timeout the run keeps going and lands in History. +INVOKE_WAIT_TIMEOUT_S = 15 * 60 + + +@workflows.router.post("/{workflow_id}/invoke") +async def invoke_workflow(workflow_id: str): + """Run a workflow AND wait for the result: the InvokeWorkflow MCP tool's backend. Unlike /run + (fire-and-forget for the History pane), the caller here is an agent that needs the outcome inline, + so this awaits the executor and returns status + transcript. Gated on the per-workflow opt-in.""" + wf = storage.get_workflow(workflow_id) + if not wf or wf.deleted_at: + raise HTTPException(status_code=404, detail="Workflow not found") + if not wf.exposed_as_tool: + raise HTTPException(status_code=403, detail="Workflow is not agent-invocable (enable it on the Actions page)") + task = asyncio.create_task(executor.execute(wf, triggered_by="manual")) + try: + # shield: a timeout must not cancel the run, it continues and History has it. + run = await asyncio.wait_for(asyncio.shield(task), timeout=INVOKE_WAIT_TIMEOUT_S) + except asyncio.TimeoutError: + return {"run_id": "", "status": "running", "error": None, "cost_usd": 0.0, "transcript": "", + "timed_out": True} + transcript = "" + if run.session_id: + from backend.apps.agents.agent_manager import agent_manager + sess = agent_manager.sessions.get(run.session_id) + if sess is None: + try: + sess = await agent_manager.resume_session(run.session_id) + except ValueError: + sess = None + transcript = p_render_test_transcript(getattr(sess, "messages", []) or []) if sess else "" + return {"run_id": run.id, "status": run.status, "error": run.error, + "cost_usd": run.cost_usd, "transcript": transcript, "timed_out": False} + + def _find_active_run(run_id: str): """Locate a currently-running run by id, returning (workflow_id, run).""" for wf in storage.list_workflows(): diff --git a/backend/main.py b/backend/main.py index edee807f..d4f793fe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -871,6 +871,37 @@ async def settings_meta(action: str, request: Request): +@app.post("/api/spawn-agent/run") +async def spawn_agent_run(request: Request): + """Spawn a fresh sub-agent session for the SpawnAgent tool. + Called by the spawn_agent_mcp_server stdio subprocess.""" + body = await request.json() + prompt = body.get("prompt", "") + parent_session_id = body.get("parent_session_id", "") + dashboard_id = body.get("dashboard_id", "") + run_in_background = bool(body.get("run_in_background", False)) + + if not prompt: + return JSONResponse({"error": "prompt is required"}, status_code=400) + if not parent_session_id: + return JSONResponse({"error": "parent_session_id is required"}, status_code=400) + + try: + from backend.apps.agents.agent_manager import agent_manager + result = await agent_manager.spawn_agent( + prompt=prompt, + parent_session_id=parent_session_id, + dashboard_id=dashboard_id or None, + run_in_background=run_in_background, + ) + return JSONResponse(result) + except ValueError as e: + return JSONResponse({"error": str(e)}, status_code=404) + except Exception as e: + logger.exception("spawn_agent_run failed") + return JSONResponse({"error": str(e)}, status_code=500) + + @app.post("/api/invoke-agent/run") async def invoke_agent_run(request: Request): """Fork an existing agent session and send it a new message. diff --git a/backend/run.sh b/backend/run.sh index ffedede8..cf4c220a 100755 --- a/backend/run.sh +++ b/backend/run.sh @@ -56,7 +56,9 @@ fi # (compared via `dir in path.parents`). So we pass ABSOLUTE paths to # the dirs we want to exclude — those are the only patterns uvicorn's # WatchFilesReload actually honors for "anywhere under this tree". -echo "Starting backend server on http://0.0.0.0:8324 ..." +# Dev only: OPENSWARM_PORT lets a parallel worktree bind its own backend port instead of colliding on 8324. Packaged builds never set it. +BACKEND_PORT="${OPENSWARM_PORT:-8324}" +echo "Starting backend server on http://0.0.0.0:${BACKEND_PORT} ..." cd "$PROJECT_ROOT_ABSPATH" UVICORN_EXCLUDE_ARGS=(--reload-exclude '*.pyc') @@ -79,10 +81,10 @@ done # launcher does). Packaged builds leave it unset → fast, lean, # single-process uvicorn. if [[ "${OPENSWARM_DEV:-}" == "1" ]]; then - echo "OPENSWARM_DEV=1 detected — running uvicorn with --reload." - python3 -m uvicorn backend.main:app --host 0.0.0.0 --port 8324 --reload \ + echo "OPENSWARM_DEV=1 detected, running uvicorn with --reload." + python3 -m uvicorn backend.main:app --host 0.0.0.0 --port "$BACKEND_PORT" --reload \ --reload-dir "$BACKEND_DIR_ABSPATH" \ "${UVICORN_EXCLUDE_ARGS[@]}" else - python3 -m uvicorn backend.main:app --host 0.0.0.0 --port 8324 + python3 -m uvicorn backend.main:app --host 0.0.0.0 --port "$BACKEND_PORT" fi diff --git a/backend/tests/test_agent_session_list.py b/backend/tests/test_agent_session_list.py new file mode 100644 index 00000000..7fbefeb6 --- /dev/null +++ b/backend/tests/test_agent_session_list.py @@ -0,0 +1,59 @@ +import asyncio + +from pytest import MonkeyPatch + +from backend.apps.agents import agents as agents_module +from backend.apps.agents.core.models import AgentSession, Message + + +def test_session_list_item_replaces_messages_with_compact_metadata( + monkeypatch: MonkeyPatch, +) -> None: + first_prompt = "p" * 250 + last_reply = "r" * 150 + session = AgentSession( + name="Test session", + messages=[ + Message(role="system", content="system"), + Message(role="user", content=first_prompt), + Message(role="assistant", content=last_reply), + ], + ) + + monkeypatch.setattr( + agents_module.agent_manager, + "get_all_sessions", + lambda dashboard_id=None: [session], + ) + item = asyncio.run(agents_module.list_sessions())["sessions"][0] + + assert item["messages"] == [] + assert item["message_count"] == 3 + assert item["first_user_message"] == first_prompt[:200] + assert item["last_message_preview"] == last_reply[:120] + + +def test_session_list_item_handles_empty_and_non_text_content( + monkeypatch: MonkeyPatch, +) -> None: + sessions = [ + AgentSession(name="Empty"), + AgentSession( + name="Images", + messages=[Message(role="user", content=[{"type": "image"}])], + ), + ] + monkeypatch.setattr( + agents_module.agent_manager, + "get_all_sessions", + lambda dashboard_id=None: sessions, + ) + empty, non_text = asyncio.run(agents_module.list_sessions())["sessions"] + + assert empty["messages"] == [] + assert empty["message_count"] == 0 + assert empty["first_user_message"] == "" + assert empty["last_message_preview"] == "" + assert non_text["message_count"] == 1 + assert non_text["first_user_message"] == "" + assert non_text["last_message_preview"] == "" diff --git a/backend/tests/test_deadcard_evict.py b/backend/tests/test_deadcard_evict.py new file mode 100644 index 00000000..898d11e7 --- /dev/null +++ b/backend/tests/test_deadcard_evict.py @@ -0,0 +1,68 @@ +"""The recovery-card wedge fix: when a card is declared dead, its webview must be +torn down (renderer unmount + layout removal) BEFORE recovery spawns a fresh card, +so two heavy pages never co-exist and starve the renderer. Pins evict_dead_card.""" +import asyncio + +import backend.apps.agents.browser.browser_agent as ba + + +class FakeLayout: + def __init__(self, cards): + self.browser_cards = cards + + +class FakeDash: + def __init__(self, cards): + self.layout = FakeLayout(cards) + self.updated_at = None + + +def p_patch(monkeypatch, cards): + broadcasts = [] + saved = [] + + async def fake_broadcast(event, data): + broadcasts.append((event, data)) + + dash = FakeDash(cards) + monkeypatch.setattr(ba, "P_EVICT_SETTLE_S", 0, raising=True) # don't pay the renderer-settle wait in a unit test + monkeypatch.setattr(ba.ws_manager, "broadcast_global", fake_broadcast, raising=True) + import backend.apps.dashboards.dashboards as dmod + monkeypatch.setattr(dmod, "load", lambda did: dash, raising=True) + monkeypatch.setattr(dmod, "save", lambda d: saved.append(d), raising=True) + return broadcasts, saved, dash + + +def test_evict_broadcasts_unmount_and_removes_from_layout(monkeypatch): + broadcasts, saved, dash = p_patch(monkeypatch, {"browser-dead": FakeCard("sess-1"), "browser-keep": FakeCard("sess-1")}) + ba.ACTIVE_AGENT_CARDS.add("browser-dead") + asyncio.run(ba.evict_dead_card("dash-1", "browser-dead")) + # the renderer is told to unmount exactly the dead card + assert ("dashboard:browser_card_evict", {"dashboard_id": "dash-1", "browser_id": "browser-dead"}) in broadcasts + # it's gone from the persisted layout, its neighbor is untouched + assert "browser-dead" not in dash.layout.browser_cards + assert "browser-keep" in dash.layout.browser_cards + assert saved # the layout was persisted + assert "browser-dead" not in ba.ACTIVE_AGENT_CARDS + + +def test_evict_without_a_dashboard_deletes_nothing(monkeypatch): + # No dashboard = ownership unverifiable = fail SAFE: never unmount or delete + # what might be the user's card; the reuse-skip alone handles it. + broadcasts, saved, _ = p_patch(monkeypatch, {}) + asyncio.run(ba.evict_dead_card("", "browser-x")) + assert not broadcasts and not saved + + +class FakeCard: + def __init__(self, spawned_by=None): + self.spawned_by = spawned_by + + +def test_user_card_is_never_evicted(monkeypatch): + """A wedged USER card (no spawned_by) must never be deleted out from under the + user; reuse-skip is the whole remedy. Only agent-spawned cards evict.""" + broadcasts, saved, dash = p_patch(monkeypatch, {"browser-user": FakeCard(None)}) + asyncio.run(ba.evict_dead_card("dash-1", "browser-user")) + assert not broadcasts and not saved + assert "browser-user" in dash.layout.browser_cards diff --git a/backend/tests/test_idle_update_gate.py b/backend/tests/test_idle_update_gate.py new file mode 100644 index 00000000..be7c2ae2 --- /dev/null +++ b/backend/tests/test_idle_update_gate.py @@ -0,0 +1,63 @@ +"""API surface of the desktop idle-update gate: the /agents/activity lookahead +fields Electron polls before a silent install, and the /service/updater-event +breadcrumb it fires when one happens.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from pydantic import ValidationError + + +@pytest.mark.asyncio +async def test_activity_reports_active_and_next_run(monkeypatch): + from backend.apps.agents import agents as agents_module + monkeypatch.setattr(agents_module.agent_manager, "tasks", {}) + monkeypatch.setattr("backend.apps.workflows.scheduler.seconds_to_next_fire", lambda: 123.0) + out = await agents_module.agent_activity() + assert out == {"active": 0, "next_run_in_s": 123.0} + + +@pytest.mark.asyncio +async def test_activity_lookahead_fails_open(monkeypatch): + from backend.apps.agents import agents as agents_module + + def boom(): + raise RuntimeError("lookahead broke") + + monkeypatch.setattr(agents_module.agent_manager, "tasks", {}) + monkeypatch.setattr("backend.apps.workflows.scheduler.seconds_to_next_fire", boom) + out = await agents_module.agent_activity() + assert out["next_run_in_s"] is None + + +@pytest.mark.asyncio +async def test_updater_event_writes_analytics_log(monkeypatch): + from backend.apps.service import service as service_module + from backend.apps.service.analytics import client as analytics_client_module + logs = Mock() + monkeypatch.setattr(analytics_client_module, "get_analytics_client", lambda: SimpleNamespace(logs=logs)) + body = service_module.UpdaterEventBody(kind="idle_install", staged_version="1.5.9") + out = await service_module.post_updater_event(body) + assert out == {"ok": True} + kw = logs.write.call_args.kwargs + assert kw["tag"] == "updater" + assert kw["subtag"] == "idle_install" + assert kw["data"]["staged_version"] == "1.5.9" + + +@pytest.mark.asyncio +async def test_updater_event_survives_missing_client(monkeypatch): + from backend.apps.service import service as service_module + from backend.apps.service.analytics import client as analytics_client_module + monkeypatch.setattr(analytics_client_module, "get_analytics_client", lambda: None) + out = await service_module.post_updater_event(service_module.UpdaterEventBody(kind="idle_install")) + assert out == {"ok": True} + + +def test_updater_event_kind_is_constrained(): + from backend.apps.service.service import UpdaterEventBody + with pytest.raises(ValidationError): + UpdaterEventBody(kind="anything_else") diff --git a/backend/tests/test_invoke_workflow_tool.py b/backend/tests/test_invoke_workflow_tool.py new file mode 100644 index 00000000..b6dc0d22 --- /dev/null +++ b/backend/tests/test_invoke_workflow_tool.py @@ -0,0 +1,59 @@ +"""The InvokeWorkflow MCP tool: resolves id-or-title among EXPOSED workflows only, +lists the invocable set on a miss, and relays the backend's run result.""" +import backend.apps.agents.schedule_mcp_server as srv + + +def p_patch_call(monkeypatch, workflows, invoke_result=None): + calls = [] + + def p_fake_call(method, path, body=None, timeout=30): + calls.append((method, path)) + if path == "/list": + return {"workflows": workflows} + if path.endswith("/invoke"): + return invoke_result or {} + return {} + + monkeypatch.setattr(srv, "_call", p_fake_call) + return calls + + +def test_resolves_exact_title_among_exposed_only(monkeypatch): + wfs = [ + {"id": "w1", "title": "Daily brief", "exposed_as_tool": True}, + {"id": "w2", "title": "Secret ops", "exposed_as_tool": False}, + ] + calls = p_patch_call(monkeypatch, wfs, {"status": "success", "error": None, "transcript": "did it", "timed_out": False}) + out = srv.handle_invoke_workflow({"workflow": "daily brief"}) + assert not out.get("isError") + text = out["content"][0]["text"] + assert "success" in text and "did it" in text + assert ("POST", "/w1/invoke") in calls + + +def test_unexposed_workflow_is_not_invocable(monkeypatch): + wfs = [{"id": "w2", "title": "Secret ops", "exposed_as_tool": False}] + p_patch_call(monkeypatch, wfs) + out = srv.handle_invoke_workflow({"workflow": "Secret ops"}) + assert out.get("isError") + + +def test_miss_lists_the_invocable_set(monkeypatch): + wfs = [{"id": "w1", "title": "Daily brief", "exposed_as_tool": True}] + p_patch_call(monkeypatch, wfs) + out = srv.handle_invoke_workflow({"workflow": "nope"}) + assert out.get("isError") + assert "Daily brief" in out["content"][0]["text"] + + +def test_timeout_reports_background_continuation(monkeypatch): + wfs = [{"id": "w1", "title": "Daily brief", "exposed_as_tool": True}] + p_patch_call(monkeypatch, wfs, {"timed_out": True}) + out = srv.handle_invoke_workflow({"workflow": "w1"}) + assert not out.get("isError") + assert "History" in out["content"][0]["text"] + + +def test_tool_is_declared_and_dispatchable(): + assert any(t["name"] == "InvokeWorkflow" for t in srv.TOOLS) + assert srv.HANDLERS["InvokeWorkflow"] is srv.handle_invoke_workflow diff --git a/backend/tests/test_spawn_agent.py b/backend/tests/test_spawn_agent.py new file mode 100644 index 00000000..54550eed --- /dev/null +++ b/backend/tests/test_spawn_agent.py @@ -0,0 +1,120 @@ +"""SpawnAgent: the native replacement for the CLI's built-in Agent tool. The child is a +fresh session inheriting the parent's model/cwd/dashboard; sync waits and returns the last +assistant text, background returns immediately. The builtin stays blocked so the model only +ever sees the two-arg schema.""" +import asyncio +import json +import subprocess +import sys +from typing import Dict, List, Optional + +from pytest import MonkeyPatch, raises + +from backend.apps.agents.agent_manager import agent_manager +from backend.apps.agents.core.models import AgentSession, Message + + +def seed_parent() -> AgentSession: + parent = AgentSession(name="parent", model="opus-4-8", cwd="/tmp/pw", dashboard_id="dashX") + agent_manager.sessions[parent.id] = parent + return parent + + +def test_spawn_agent_sync_returns_child_answer(monkeypatch: MonkeyPatch) -> None: + parent = seed_parent() + + async def fake_loop(session_id: str, prompt: str, **kwargs: object) -> None: + s = agent_manager.sessions[session_id] + s.messages.append(Message(role="assistant", content="child says done", branch_id=s.active_branch_id)) + s.status = "completed" + + monkeypatch.setattr(agent_manager, "run_agent_loop", fake_loop) + result = asyncio.run(agent_manager.spawn_agent(prompt="do the thing", parent_session_id=parent.id)) + + child = agent_manager.sessions[result["session_id"]] + assert result["response"] == "child says done" + assert child.mode == "sub-agent" + assert child.parent_session_id == parent.id + assert child.model == parent.model + assert child.cwd == parent.cwd + assert child.dashboard_id == "dashX" + assert child.messages[0].role == "user" and child.messages[0].content == "do the thing" + + +def test_spawn_agent_background_returns_immediately(monkeypatch: MonkeyPatch) -> None: + parent = seed_parent() + started: List[str] = [] + + async def slow_loop(session_id: str, prompt: str, **kwargs: object) -> None: + started.append(session_id) + await asyncio.sleep(30) + + monkeypatch.setattr(agent_manager, "run_agent_loop", slow_loop) + + async def run() -> Dict: + result = await asyncio.wait_for( + agent_manager.spawn_agent(prompt="long task", parent_session_id=parent.id, run_in_background=True), + timeout=2.0, + ) + await asyncio.sleep(0.05) + agent_manager.tasks[result["session_id"]].cancel() + return result + + result = asyncio.run(run()) + assert result["background"] is True + assert started == [result["session_id"]] + + +def test_spawn_agent_unknown_parent_raises() -> None: + with raises(ValueError): + asyncio.run(agent_manager.spawn_agent(prompt="x", parent_session_id="nope-" + "0" * 28)) + + +def test_builtin_subagent_tools_stay_blocked() -> None: + # The CLI's built-in sub-agent tool (Task on 2.1.122, Agent on older builds) must not be offered: out of the catalog AND hard-blocked at the SDK layer. + from backend.apps.agents.manager.prompt.tool_catalog import FULL_TOOLS + from backend.apps.agents.manager.run.run_options_helpers import merge_hard_blocked_tools + assert "Agent" not in FULL_TOOLS + assert "Task" not in FULL_TOOLS + blocked = merge_hard_blocked_tools([]) + assert "Agent" in blocked and "Task" in blocked and "mcp__claude_ai_*" in blocked + + +def test_effective_disallowed_reaches_the_sdk_deny_list() -> None: + # Regression: a plain assignment once DISCARDED the computed denies (Cron*/Skill/web-swap/per-tool MCP), leaving the runtime gate as the only wall. The merge must keep them AND append the hard blocks, deduped. + from backend.apps.agents.manager.run.run_options_helpers import merge_hard_blocked_tools + computed = ["CronCreate", "CronList", "CronDelete", "Skill", "mcp__gmail__DeleteEmail", "Task"] + merged = merge_hard_blocked_tools(computed) + for t in computed: + assert t in merged + assert "Agent" in merged and "mcp__claude_ai_*" in merged + assert merged.count("Task") == 1 + assert merged[:len(computed)] == computed + + +def test_spawn_server_schema_is_prompt_plus_background_only() -> None: + from backend.apps.agents import spawn_agent_mcp_server as srv + tool = srv.TOOLS[0] + assert tool["name"] == "SpawnAgent" + assert set(tool["inputSchema"]["properties"].keys()) == {"prompt", "run_in_background"} + assert tool["inputSchema"]["required"] == ["prompt"] + + +def test_spawn_server_speaks_mcp_stdio() -> None: + from backend.apps.agents import spawn_agent_mcp_server as srv + proc = subprocess.Popen( + [sys.executable, srv.__file__], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, + ) + try: + msgs = [ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + ] + out, _ = proc.communicate("\n".join(json.dumps(m) for m in msgs) + "\n", timeout=15) + lines = [json.loads(line) for line in out.strip().splitlines()] + assert lines[0]["result"]["serverInfo"]["name"] == "openswarm-spawn-agent" + assert lines[1]["result"]["tools"][0]["name"] == "SpawnAgent" + finally: + proc.kill() diff --git a/backend/tests/test_subagent_model_pin.py b/backend/tests/test_subagent_model_pin.py new file mode 100644 index 00000000..d4a7318e --- /dev/null +++ b/backend/tests/test_subagent_model_pin.py @@ -0,0 +1,45 @@ +"""The sub-agent model pin on the 9Router-direct route must come from LIVE router +connections. The run/ split passed a hardcoded empty list, the pin never fired, and every +sub-agent 401'd ("No credentials for provider: anthropic") while the parent turn worked.""" +import asyncio +from typing import Dict + +from pytest import MonkeyPatch + +import backend.apps.agents.manager.configure_provider_env as cpe +from backend.apps.agents.core.models import AgentSession +from backend.apps.settings.models import AppSettings + + +def run_env_for(connections: list, monkeypatch: MonkeyPatch) -> Dict: + import backend.apps.nine_router as nr_pkg + + async def fake_get_providers() -> list: + return connections + + monkeypatch.setattr(nr_pkg, "is_running", lambda: True) + monkeypatch.setattr(nr_pkg, "get_providers", fake_get_providers) + session = AgentSession(name="t", model="opus-4-8-cc") + options_kwargs: Dict = {} + asyncio.run( + cpe.configure_provider_env( + options_kwargs, session, "cc/claude-opus-4-8", "anthropic", AppSettings() + ) + ) + return options_kwargs.get("env", {}) + + +def test_subagent_pin_set_from_live_claude_connection(monkeypatch: MonkeyPatch) -> None: + env = run_env_for([{"provider": "claude", "isActive": True}], monkeypatch) + assert env.get("CLAUDE_CODE_SUBAGENT_MODEL") == "cc/claude-sonnet-4-6" + assert env.get("ANTHROPIC_SMALL_FAST_MODEL") == "cc/claude-haiku-4-5-20251001" + + +def test_subagent_pin_absent_only_when_no_active_lane(monkeypatch: MonkeyPatch) -> None: + env = run_env_for([], monkeypatch) + assert "CLAUDE_CODE_SUBAGENT_MODEL" not in env + + +def test_subagent_pin_codex_lane(monkeypatch: MonkeyPatch) -> None: + env = run_env_for([{"provider": "codex", "isActive": True}], monkeypatch) + assert env.get("CLAUDE_CODE_SUBAGENT_MODEL") == "cx/gpt-5.4-mini" diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 410991f8..a1b50e51 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -110,6 +110,15 @@ def test_pack_allows_clean_workspace_file(): assert zipfile.is_zipfile(io.BytesIO(raw)) +def test_pack_export_anyway_overrides_file_scan_but_never_denied_keys(): + # User-confirmed override ships a flagged workspace FILE (trusted recipient); our own credential fields stay unexportable no matter what. + leak = b"const KEY = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA';\n" + raw = pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/config.js": leak}, allow_file_secrets=True) + assert zipfile.is_zipfile(io.BytesIO(raw)) + with pytest.raises(BundleError): + pack({"format_version": 1}, {"bid1": {"api_key": "leak"}}, {}, allow_file_secrets=True) + + def test_app_export_drops_machine_env(tmp_path, monkeypatch): # The live .env holds the source machine's absolute paths + pinned port; it must never ride along. .env.example (portable) does. from backend.apps.swarm.entities import apps as appmod diff --git a/backend/tests/test_tool_manifest.py b/backend/tests/test_tool_manifest.py new file mode 100644 index 00000000..67c0ce39 --- /dev/null +++ b/backend/tests/test_tool_manifest.py @@ -0,0 +1,46 @@ +"""OSW_TOOL_MANIFEST gate: default ships the full claude_code preset; flag-on ships an explicit +FULL_TOOLS list that prunes dead preset built-ins WITHOUT dropping anything OpenSwarm uses. The +manifest MUST keep every built-in in effective_allowed's source set + ToolSearch (so deferred MCP +loading survives); MCP tools ride mcp_servers, not this list.""" + +import os + +from backend.apps.agents.manager.prompt.tool_catalog import ( + FULL_TOOLS, + resolve_builtin_tools_option, +) + + +def test_default_is_the_full_preset(monkeypatch): + monkeypatch.delenv("OSW_TOOL_MANIFEST", raising=False) + assert resolve_builtin_tools_option() == {"type": "preset", "preset": "claude_code"} + + +def test_flag_on_is_the_explicit_full_tools_manifest(monkeypatch): + monkeypatch.setenv("OSW_TOOL_MANIFEST", "1") + out = resolve_builtin_tools_option() + assert isinstance(out, list) + assert out == list(FULL_TOOLS) + # A fresh copy, never the module list itself (a caller mutation must not poison FULL_TOOLS). + assert out is not FULL_TOOLS + + +def test_manifest_keeps_toolsearch_so_deferred_mcp_loading_survives(monkeypatch): + monkeypatch.setenv("OSW_TOOL_MANIFEST", "1") + out = resolve_builtin_tools_option() + assert "ToolSearch" in out + + +def test_manifest_keeps_every_core_builtin_openswarm_exposes(monkeypatch): + # These are the built-ins the agent actually uses; none may vanish from the manifest. + monkeypatch.setenv("OSW_TOOL_MANIFEST", "1") + out = resolve_builtin_tools_option() + for core in ("Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion"): + assert core in out + + +def test_only_the_exact_flag_value_flips_it(monkeypatch): + monkeypatch.setenv("OSW_TOOL_MANIFEST", "true") # not "1" + assert resolve_builtin_tools_option() == {"type": "preset", "preset": "claude_code"} + monkeypatch.setenv("OSW_TOOL_MANIFEST", "0") + assert resolve_builtin_tools_option() == {"type": "preset", "preset": "claude_code"} diff --git a/backend/tests/test_tool_result_hook.py b/backend/tests/test_tool_result_hook.py index 6060e7f2..e5d52398 100644 --- a/backend/tests/test_tool_result_hook.py +++ b/backend/tests/test_tool_result_hook.py @@ -1,7 +1,7 @@ """Unit coverage for the extracted PostToolUse hook (tool_result_hook). The streaming harness mocks claude_agent_sdk.query, so it never fires the SDK's PostToolUse hooks; this pins the -behavior directly: a tool result becomes a tool_result message, and an Agent tool spawns a -sub-session into the manager's LIVE registry (the InstanceOf[dict] sharing, the subtle bit).""" +behavior directly: a tool result becomes a tool_result message, and view-builder writes surface +build/console errors into the result. SpawnAgent (the sub-agent path) is pinned in test_spawn_agent.py.""" import pytest from unittest.mock import patch, AsyncMock, MagicMock @@ -42,32 +42,6 @@ async def test_normal_tool_result_appends_message_and_continues(): send.assert_awaited() # the tool_result is broadcast to the UI -@pytest.mark.asyncio -async def test_agent_tool_spawns_subsession_into_live_registry(): - registry: dict = {} - ctx = p_ctx(registry) - parent_id = ctx.session_id - raw = { - "content": [{"type": "text", "text": "sub-agent did the work"}], - "usage": {"input_tokens": 7, "output_tokens": 3}, - "total_cost_usd": 0.01, - "model": "sonnet", - } - with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()), \ - patch.object(tool_result_hook.ws_manager, "broadcast_global", new=AsyncMock()): - out = await tool_result_hook.post_tool_hook( - ctx, {"tool_name": "Agent", "tool_response": raw, "tool_input": {"prompt": "do x"}}, "tu1", None - ) - assert out == {"continue_": True} - # exactly one NEW session registered (besides the parent), parented correctly - children = [s for sid, s in registry.items() if sid != parent_id] - assert len(children) == 1 - child = children[0] - assert child.parent_session_id == parent_id - assert child.active_mcps == [] # context-isolation invariant: no inherited activations - assert "sub-agent did the work" in str(child.messages[-1].content) - - @pytest.mark.asyncio async def test_view_builder_dep_install_broadcasts_app_deps_changed(): """An npm install in a view-builder session must tell the app card this turn diff --git a/backend/tests/test_workflows_api.py b/backend/tests/test_workflows_api.py index 700d0ee0..c1f4c0e3 100644 --- a/backend/tests/test_workflows_api.py +++ b/backend/tests/test_workflows_api.py @@ -113,3 +113,56 @@ def test_pause_all_and_resume_all_flip_global_flag(make_wf): assert storage.get_paused() is True assert _run(resume_all_schedules()) == {"paused": False} assert storage.get_paused() is False + + +# ---- InvokeWorkflow (agents run a workflow as a tool and wait for the result) ---- + +def test_invoke_unknown_workflow_404(): + from fastapi import HTTPException + from backend.apps.workflows.workflows import invoke_workflow + with pytest.raises(HTTPException) as ei: + _run(invoke_workflow("nope")) + assert ei.value.status_code == 404 + + +def test_invoke_requires_the_exposed_opt_in(make_wf): + # A workflow the user never opted in must refuse: exposure is the whole permission model here. + from fastapi import HTTPException + from backend.apps.workflows import storage + from backend.apps.workflows.workflows import invoke_workflow + wf = make_wf() + storage.save_workflow(wf) + with pytest.raises(HTTPException) as ei: + _run(invoke_workflow(wf.id)) + assert ei.value.status_code == 403 + + +def test_invoke_waits_and_returns_transcript(make_wf, monkeypatch): + from backend.apps.workflows import storage, executor + from backend.apps.workflows.models import WorkflowRun + from backend.apps.workflows.workflows import invoke_workflow + wf = make_wf(exposed_as_tool=True) + storage.save_workflow(wf) + + async def p_fake_execute(w, triggered_by="schedule", scheduled_for=None, tested_signature=None): + assert triggered_by == "manual" + return WorkflowRun(workflow_id=w.id, status="success", session_id="sess-invoke-1", cost_usd=0.02) + + monkeypatch.setattr(executor, "execute", p_fake_execute) + + from backend.apps.agents.agent_manager import agent_manager + + class p_FakeMsg: + role = "assistant" + content = "invoked step done" + hidden = False + + class p_FakeSess: + messages = [p_FakeMsg()] + + monkeypatch.setitem(agent_manager.sessions, "sess-invoke-1", p_FakeSess()) + res = _run(invoke_workflow(wf.id)) + assert res["status"] == "success" + assert res["run_id"] + assert res["timed_out"] is False + assert "invoked step done" in res["transcript"] diff --git a/backend/tests/test_workflows_semantics.py b/backend/tests/test_workflows_semantics.py index 4077254f..8c544775 100644 --- a/backend/tests/test_workflows_semantics.py +++ b/backend/tests/test_workflows_semantics.py @@ -888,3 +888,52 @@ def test_escalation_noop_for_single_tier(): run = WorkflowRun(workflow_id=wf.id, status="success") escalation.schedule(wf, run) assert escalation.status(run.id) is None + + +# --- idle-update gate lookahead ---------------------------------------------- + +def test_seconds_to_next_fire_none_when_nothing_queued(): + from backend.apps.workflows import storage + from backend.apps.workflows.scheduler import seconds_to_next_fire + storage.init() + assert seconds_to_next_fire() is None + + +def test_seconds_to_next_fire_reports_soonest_enabled_only(): + from backend.apps.workflows import storage + from backend.apps.workflows.scheduler import seconds_to_next_fire + storage.init() + soon = _make_wf() + soon.next_run_at = datetime.now(timezone.utc) + timedelta(minutes=10) + storage.save_workflow(soon) + later = _make_wf() + later.next_run_at = datetime.now(timezone.utc) + timedelta(hours=3) + storage.save_workflow(later) + disabled = _make_wf() + disabled.schedule.enabled = False + disabled.next_run_at = datetime.now(timezone.utc) + timedelta(minutes=1) + storage.save_workflow(disabled) + got = seconds_to_next_fire() + assert got is not None + assert 9 * 60 < got <= 10 * 60 + + +def test_seconds_to_next_fire_clamps_overdue_to_zero(): + from backend.apps.workflows import storage + from backend.apps.workflows.scheduler import seconds_to_next_fire + storage.init() + wf = _make_wf() + wf.next_run_at = datetime.now(timezone.utc) - timedelta(minutes=5) + storage.save_workflow(wf) + assert seconds_to_next_fire() == 0.0 + + +def test_seconds_to_next_fire_none_while_paused(monkeypatch): + from backend.apps.workflows import storage + from backend.apps.workflows.scheduler import seconds_to_next_fire + storage.init() + wf = _make_wf() + wf.next_run_at = datetime.now(timezone.utc) + timedelta(minutes=1) + storage.save_workflow(wf) + monkeypatch.setattr(storage, "get_paused", lambda: True) + assert seconds_to_next_fire() is None diff --git a/backend/tests/test_ws_efficiency.py b/backend/tests/test_ws_efficiency.py new file mode 100644 index 00000000..2e1b749f --- /dev/null +++ b/backend/tests/test_ws_efficiency.py @@ -0,0 +1,155 @@ +"""WS efficiency batch: agent:status frames are slimmed (metadata + previews, never the +transcript), GET /sessions returns the WS seq cursor for resume seeding, and /history's +closed_only filter keeps open sessions out of the client's resurrection gate. Each pins a +live-proven failure mode: full transcripts on status frames were replayed stale and rolled +clients backwards, and an open session in the history map had its terminal frame swallowed.""" + +import asyncio + +import pytest +from fastapi.testclient import TestClient + +from backend.apps.agents.core.ws_manager import ws_manager, slim_status_data +from backend.apps.agents.core.seq_log import seq_log +from backend.apps.agents.agent_manager import agent_manager +from backend.apps.agents.core.models import AgentSession, Message +from backend.main import app + + +def p_client() -> TestClient: + import backend.auth as auth_mod + if not auth_mod.TOKEN: + import secrets + auth_mod.TOKEN = secrets.token_urlsafe(32) + return TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"}) + + +def p_status_data(n_msgs: int = 3) -> dict: + session = { + "id": "s1", + "status": "completed", + "messages": ( + [{"role": "user", "content": "first user question here"}] + + [{"role": "assistant", "content": f"reply {i} " + "x" * 200} for i in range(n_msgs - 1)] + ), + "name": "t", + } + return {"session_id": "s1", "status": "completed", "session": session} + + +def test_slim_drops_transcript_and_adds_previews(): + out = slim_status_data("agent:status", p_status_data(3)) + sess = out["session"] + assert sess["messages"] == [] + assert sess["message_count"] == 3 + assert sess["first_user_message"].startswith("first user question") + assert sess["last_message_preview"].startswith("reply 1") + assert len(sess["last_message_preview"]) <= 120 + # Original input is not mutated (callers may reuse their dicts). + assert p_status_data(3)["session"]["messages"] != [] + + +def test_slim_leaves_non_status_and_messageless_frames_alone(): + msg_data = {"session_id": "s1", "message": {"role": "user", "content": "hello"}} + assert slim_status_data("agent:message", msg_data) is msg_data + no_sess = {"session_id": "s1", "status": "running"} + assert slim_status_data("agent:status", no_sess) is no_sess + + +class p_FakeWs: + def __init__(self) -> None: + self.frames: list = [] + + async def send_text(self, s: str) -> None: + import json + self.frames.append(json.loads(s)) + + +def test_send_to_session_slims_status_for_both_socket_kinds_and_the_replay_buffer(): + sess_ws, dash_ws = p_FakeWs(), p_FakeWs() + sid = "slimtest-session" + ws_manager.connections[sid] = [sess_ws] + ws_manager.global_connections.append(dash_ws) + try: + asyncio.run(ws_manager.send_to_session(sid, "agent:status", p_status_data(4))) + asyncio.run(ws_manager.send_to_session(sid, "agent:message", { + "session_id": sid, "message": {"role": "assistant", "content": "full text stays"}, + })) + for ws in (sess_ws, dash_ws): + status = ws.frames[0] + assert status["data"]["session"]["messages"] == [] + assert status["data"]["session"]["message_count"] == 4 + msg = ws.frames[1] + assert msg["data"]["message"]["content"] == "full text stays" + # Both sockets got the SAME stamped seq (the frontend dedupes on it). + assert sess_ws.frames[0]["seq"] == dash_ws.frames[0]["seq"] + # The ring buffer stores the slim frame, so replays are slim too. + _, _, events = seq_log.replay(sid, 0) + import json + assert json.loads(events[0])["data"]["session"]["messages"] == [] + finally: + ws_manager.connections.pop(sid, None) + ws_manager.global_connections.remove(dash_ws) + seq_log.clear(sid) + + +def test_get_session_returns_event_seq_cursor(): + s = AgentSession(name="t", model="sonnet") + s.messages = [Message(role="user", content="hi")] + agent_manager.sessions[s.id] = s + try: + asyncio.run(ws_manager.send_to_session(s.id, "agent:status", {"session_id": s.id, "status": "running"})) + asyncio.run(ws_manager.send_to_session(s.id, "agent:status", {"session_id": s.id, "status": "completed"})) + res = p_client().get(f"/api/agents/sessions/{s.id}") + assert res.status_code == 200 + body = res.json() + assert body["event_seq"] == seq_log.current_seq(s.id) == 2 + assert body["messages"][0]["content"] == "hi" + finally: + agent_manager.sessions.pop(s.id, None) + seq_log.clear(s.id) + + +def test_replay_caught_up_client_gets_nothing_not_the_terminal_frame(): + sid = "caughtup-session" + ws = p_FakeWs() + try: + asyncio.run(ws_manager.send_to_session(sid, "agent:status", {"session_id": sid, "status": "running"})) + asyncio.run(ws_manager.send_to_session(sid, "agent:status", {"session_id": sid, "status": "completed"})) + top = seq_log.current_seq(sid) + ack = asyncio.run(ws_manager.replay_to(sid, ws, top)) + assert ack == {"ok": True, "replayed": 0, "current_seq": top} + assert ws.frames == [] + # A behind client still gets the real replay. + ack2 = asyncio.run(ws_manager.replay_to(sid, ws, top - 1)) + assert ack2["replayed"] == 1 + finally: + seq_log.clear(sid) + + +def test_history_closed_only_filters_open_sessions(monkeypatch): + rows = [ + ("open1", {"id": "open1", "name": "open chat", "closed_at": None, "dashboard_id": None}), + ("closed1", {"id": "closed1", "name": "closed chat", "closed_at": "2026-07-01T00:00:00", "dashboard_id": None}), + ] + import backend.apps.agents.manager.session.SessionLifecycle as lifecycle_mod + monkeypatch.setattr(lifecycle_mod, "load_all_session_data", lambda: list(rows)) + closed = agent_manager.get_history(closed_only=True) + assert [s["id"] for s in closed["sessions"]] == ["closed1"] + # Search keeps the full pool: open sessions on other dashboards are reachable nowhere else. + everything = agent_manager.get_history() + assert {s["id"] for s in everything["sessions"]} == {"open1", "closed1"} + + +def test_history_route_threads_closed_only(monkeypatch): + seen = {} + + def p_spy(**kwargs): + seen.update(kwargs) + return {"sessions": [], "total": 0, "has_more": False} + + monkeypatch.setattr(agent_manager, "get_history", p_spy) + assert p_client().get("/api/agents/history?closed_only=1").status_code == 200 + assert seen["closed_only"] is True + assert p_client().get("/api/agents/history").status_code == 200 + assert seen["closed_only"] is False diff --git a/electron/main.js b/electron/main.js index 80d3fa55..1303894e 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1239,8 +1239,8 @@ function createWindow() { }); if (isDev) { - // OPENSWARM_DEV_URL lets a worktree stack (webpack on an alternate port) run its own Electron. - mainWindow.loadURL(process.env.OPENSWARM_DEV_URL || `http://localhost:3000`); + // Dev only: OPENSWARM_DEV_URL (full override) or OPENSWARM_DEV_PORT lets a second worktree's Electron point at its own webpack-dev-server instead of colliding on the shared :3000. Packaged builds never hit this branch. + mainWindow.loadURL(process.env.OPENSWARM_DEV_URL || `http://localhost:${process.env.OPENSWARM_DEV_PORT || 3000}`); } else if (frontendServerPort) { mainWindow.loadURL(`http://127.0.0.1:${frontendServerPort}/index.html`); } else { @@ -1664,18 +1664,40 @@ function setupAutoUpdater() { // the button uses. Deliberately conservative so it can never land on top of a live task. const IDLE_INSTALL_MIN_IDLE_S = 30 * 60; const IDLE_INSTALL_MIN_UPTIME_MS = 2 * 60 * 60 * 1000; + const IDLE_INSTALL_WORKFLOW_LOOKAHEAD_S = 15 * 60; const _idleInstallStart = Date.now(); - const _backendActiveAgents = () => new Promise((resolve) => { - if (!backendPort) return resolve(-1); + const _backendActivity = () => new Promise((resolve) => { + if (!backendPort) return resolve(null); const req = http.request({ hostname: '127.0.0.1', port: backendPort, path: '/api/agents/activity', method: 'GET', headers: { ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}) }, timeout: 4000, }, (res) => { let d = ''; res.on('data', (c) => (d += c)); - res.on('end', () => { try { resolve(Number(JSON.parse(d).active)); } catch (_) { resolve(-1); } }); + res.on('end', () => { + try { + const j = JSON.parse(d); + resolve({ active: Number(j.active), nextRunInS: j.next_run_in_s == null ? null : Number(j.next_run_in_s) }); + } catch (_) { resolve(null); } + }); }); - req.on('error', () => resolve(-1)); - req.on('timeout', () => { req.destroy(); resolve(-1); }); + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + req.end(); + }); + // Breadcrumb so fleet convergence is queryable in analytics; bounded + best-effort, the install never waits on it failing. + const _reportIdleInstall = () => new Promise((resolve) => { + if (!backendPort) return resolve(); + const payload = JSON.stringify({ + kind: 'idle_install', + staged_version: (cachedUpdateStatus && cachedUpdateStatus.info && cachedUpdateStatus.info.version) || null, + }); + const req = http.request({ + hostname: '127.0.0.1', port: backendPort, path: '/api/service/updater-event', method: 'POST', + headers: { 'Content-Type': 'application/json', ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}) }, timeout: 2000, + }, (res) => { res.resume(); res.on('end', resolve); }); + req.on('error', resolve); + req.on('timeout', () => { req.destroy(); resolve(); }); + req.write(payload); req.end(); }); setInterval(async () => { @@ -1683,9 +1705,12 @@ function setupAutoUpdater() { if (isInstallingUpdate || !cachedUpdateStatus || cachedUpdateStatus.status !== 'downloaded') return; if (Date.now() - _idleInstallStart < IDLE_INSTALL_MIN_UPTIME_MS) return; if (powerMonitor.getSystemIdleTime() < IDLE_INSTALL_MIN_IDLE_S) return; - const active = await _backendActiveAgents(); - if (active !== 0) return; // unknown (-1) or busy -> stay put, never interrupt a task - console.log('[updater] staged update + machine idle + no agents; applying silently'); + const act = await _backendActivity(); + if (!act || act.active !== 0) return; // unknown or busy -> stay put, never interrupt a task + // A scheduled workflow fires soon; restarting now would race it. Let it run, catch the next idle window. + if (act.nextRunInS != null && act.nextRunInS < IDLE_INSTALL_WORKFLOW_LOOKAHEAD_S) return; + console.log('[updater] staged update + machine idle + no agents + no imminent workflow; applying silently'); + try { await _reportIdleInstall(); } catch (_) {} installDownloadedUpdate(); } catch (_) { /* a heartbeat must never throw */ } }, 5 * 60 * 1000); diff --git a/electron/package-lock.json b/electron/package-lock.json index 3ee946d6..1781b23d 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.5.7", + "version": "1.5.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.5.7", + "version": "1.5.8", "hasInstallScript": true, "dependencies": { "electron-updater": "6.8.3", diff --git a/electron/package.json b/electron/package.json index a8c7c7fe..3d1c78a9 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.5.7", + "version": "1.5.8", "description": "OpenSwarm — AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", diff --git a/electron/webview-preload.js b/electron/webview-preload.js index cbb51b7d..cb1bddd7 100644 --- a/electron/webview-preload.js +++ b/electron/webview-preload.js @@ -225,11 +225,9 @@ try { } catch (_) {} return; } + // A browser page or app owns its OWN scroll/zoom (Google Maps zoom, Figma pan, page scroll), so plain wheel stays with the guest. Only horizontal-dominant swipes with nothing to scroll horizontally fall through to a canvas pan. if (isInteractive) return; - // Vertical-dominant scroll stays with the page. if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; - // Horizontal-dominant: defer to the page if anything inside can absorb - // it; otherwise forward to the host as a canvas pan. if (pageCanScrollX(e.target, e.deltaX)) return; e.preventDefault(); e.stopPropagation(); diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 31ec0b9f..26a9f24d 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -272,7 +272,9 @@ const AppShell: React.FC = () => { dispatch(setPendingBrowserUrl(url)); const lastId = (window as any).__openswarm_last_dashboard_id as string | undefined; const firstDashboard = dashboardList[0]; - const targetId = lastId || firstDashboard?.id; + // Only navigate to lastId if it's a REAL dashboard: a stale localStorage id for a deleted dashboard used to route to /dashboard/, which 404s and re-fires the layout wipe (drops your cards / breaks a drag). + const lastIsReal = !!lastId && dashboardList.some((d) => d.id === lastId); + const targetId = (lastIsReal ? lastId : undefined) || firstDashboard?.id; if (targetId) { navigate(`/dashboard/${targetId}`); } else { diff --git a/frontend/src/app/components/share/ImportEntryPoint.tsx b/frontend/src/app/components/share/ImportEntryPoint.tsx index a9a8ead0..8089d2b5 100644 --- a/frontend/src/app/components/share/ImportEntryPoint.tsx +++ b/frontend/src/app/components/share/ImportEntryPoint.tsx @@ -9,6 +9,8 @@ import FileDownloadIcon from '@mui/icons-material/FileDownload'; import { useNavigate } from 'react-router-dom'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { fetchWorkflows } from '@/shared/state/workflowsSlice'; import ImportDigest, { DigestHandle } from './ImportDigest'; import ImportModal from './ImportModal'; @@ -43,6 +45,8 @@ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); const ImportEntryPoint: React.FC = () => { const c = useClaudeTokens(); const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined; const inputRef = useRef(null); const digestRef = useRef(null); const depth = useRef(0); @@ -56,10 +60,12 @@ const ImportEntryPoint: React.FC = () => { (rootType: string, rootId: string, name: string) => { const msg = rootType === 'app' ? `Added ${name} to your Apps` : `Added ${name}`; setToast({ msg, sev: 'success' }); + // A workflow has no route of its own, so nothing would pull it in: an open Workflows hub only fetches on mount and would keep showing a stale list. Import drops dashboard_id, and /list keeps unassigned workflows for every dashboard, so this surfaces it wherever the user is. + if (rootType === 'workflow') dispatch(fetchWorkflows(dashboardId)); const to = DEST[rootType]?.(rootId); if (to) navigate(to); }, - [navigate], + [navigate, dispatch, dashboardId], ); const commitAndFinish = useCallback( diff --git a/frontend/src/app/components/share/ShareModal.tsx b/frontend/src/app/components/share/ShareModal.tsx index 0b571459..0b61fa25 100644 --- a/frontend/src/app/components/share/ShareModal.tsx +++ b/frontend/src/app/components/share/ShareModal.tsx @@ -55,11 +55,11 @@ const ShareModal: React.FC = ({ target, open, onClose }) => { return load(); }, [open, load]); - const handleDownload = async () => { + const handleDownload = async (allowSecrets = false) => { if (!preflight) return; setDownloading(true); try { - await downloadSwarm(target, preflight.filename); + await downloadSwarm(target, preflight.filename, allowSecrets); setToast(`Saved ${preflight.filename}`); onClose(); } catch (e: any) { @@ -68,6 +68,8 @@ const ShareModal: React.FC = ({ target, open, onClose }) => { setDownloading(false); } }; + // The file-content secret heuristic is overridable (download goes to people you trust); our own credential fields ("secret-shaped field(s)") are not. + const secretOverridable = error.includes('secret-shaped value'); const optionRow = ( selected: boolean, @@ -150,6 +152,16 @@ const ShareModal: React.FC = ({ target, open, onClose }) => { + {secretOverridable && ( + + )} ) : preflight ? ( @@ -179,7 +191,7 @@ const ShareModal: React.FC = ({ target, open, onClose }) => {