diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index a4dfa5d7..1931a214 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -13,6 +13,8 @@ name: Release (macOS) # APPLE_TEAM_ID Apple Developer Team ID # CSC_LINK base64-encoded Developer ID Application .p12 # CSC_KEY_PASSWORD password for that .p12 +# EVS_ACCOUNT_NAME castlabs EVS account name (Widevine VMP signing; free signup) +# EVS_PASSWD password for that EVS account # GOOGLE_OAUTH_CLIENT_ID shipped in production .env (Google OAuth) # GOOGLE_OAUTH_CLIENT_SECRET shipped in production .env (Google OAuth) # @@ -70,6 +72,8 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + EVS_ACCOUNT_NAME: ${{ secrets.EVS_ACCOUNT_NAME }} + EVS_PASSWD: ${{ secrets.EVS_PASSWD }} PUBLISH_INPUT: ${{ github.event.inputs.publish }} steps: @@ -87,6 +91,14 @@ jobs: with: python-version: '3.13' + # Widevine VMP signing tool. The afterPack hook invokes `castlabs_evs.vmp + # sign-pkg` with the EVS_* secrets; without this the build aborts (publish + # path sets VMP_REQUIRE_SIGN=1) rather than ship a DMG with dead Spotify DRM. + - name: Install castlabs-evs (Widevine VMP signing) + if: ${{ env.APPLE_ID != '' }} + shell: bash + run: python3 -m pip install --upgrade castlabs-evs + - name: Build app # Skip (green) when Apple signing secrets aren't in CI: Mac ships via local # publish.sh, so a secret-less CI run should no-op, not fail red. diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 3fcc910a..c6d328e9 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -17,6 +17,8 @@ name: Release (Windows) # AZURE_SIGNING_ENDPOINT e.g. https://wus2.codesigning.azure.net/ # AZURE_SIGNING_ACCOUNT mist-code-signing # AZURE_SIGNING_CERT_PROFILE Mist-Windows-Signing +# EVS_ACCOUNT_NAME castlabs EVS account name (Widevine VMP signing; free signup) +# EVS_PASSWD password for that EVS account # GOOGLE_OAUTH_CLIENT_ID shipped in production .env (Google OAuth) # GOOGLE_OAUTH_CLIENT_SECRET shipped in production .env (Google OAuth) # v1.0.29 cloud-proxied the OAuth flow itself, @@ -77,6 +79,8 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_SIGNING_CERT_PROFILE: ${{ secrets.AZURE_SIGNING_CERT_PROFILE }} + EVS_ACCOUNT_NAME: ${{ secrets.EVS_ACCOUNT_NAME }} + EVS_PASSWD: ${{ secrets.EVS_PASSWD }} PUBLISH_INPUT: ${{ github.event.inputs.publish }} steps: @@ -96,6 +100,14 @@ jobs: with: python-version: '3.13' + # Widevine VMP signing tool. The afterPack hook invokes `castlabs_evs.vmp + # sign-pkg` with the EVS_* secrets; the -Sign path sets VMP_REQUIRE_SIGN=1 so + # a missing/failed signature aborts the build rather than ship an installer + # whose Spotify/Netflix audio is silently dead. + - name: Install castlabs-evs (Widevine VMP signing) + shell: pwsh + run: python -m pip install --upgrade castlabs-evs + # The signing hook calls `signtool.exe` directly. signtool ships in the # Windows 10 SDK, preinstalled on windows-latest runners — we just need # the dlib for Azure Trusted Signing, pulled via NuGet. diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index a68a4777..e69b44ad 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -28,7 +28,6 @@ from backend.apps.agents.manager.RunSupport import RunSupport from backend.apps.agents.manager.run.handle_run_error import handle_run_error from backend.apps.agents.manager.run.TurnRunner import TurnRunner from backend.apps.agents.manager.run.RunOptions import RunOptions -from backend.apps.agents.manager.ttft_probe import ttft_probe logger = logging.getLogger(__name__) @@ -44,15 +43,19 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr self.live_partial: Dict[str, PartialReply] = {} # Per-session cancel signal: the loop stashes its asyncio.Event here so a stop/close can set it. Lives on the manager, not the AgentSession model, so it stays out of serialization (an Event can't be model_dump'd). self.cancel_events: Dict[str, asyncio.Event] = {} + # Persistent-client pool (lever A, flag-gated): one live CLI per session, reused across turns. + self.client_pool: Dict[str, object] = {} + # Per-SESSION hook context + stderr buffer, updated in place each turn: a persistent client's hooks/stderr callback were bound at connect, so they must read stable objects, not per-turn rebuilds. + self.hook_ctxs: Dict[str, object] = {} + self.stderr_buffers: Dict[str, List[str]] = {} @typechecked - async def run_agent_loop(self, session_id: str, prompt: str, images: Optional[List] = None, context_paths: Optional[List] = None, forced_tools: Optional[List[str]] = None, attached_skills: Optional[List] = None, fork_session: bool = False, selected_browser_ids: Optional[List[str]] = None, selected_app_output_ids: Optional[List[str]] = None, selected_setting_ids: Optional[List[str]] = None): + async def run_agent_loop(self, session_id: str, prompt: str, images: Optional[List] = None, context_paths: Optional[List] = None, forced_tools: Optional[List[str]] = None, attached_skills: Optional[List] = None, fork_session: bool = False, selected_browser_ids: Optional[List[str]] = None, selected_app_output_ids: Optional[List[str]] = None, selected_setting_ids: Optional[List[str]] = None, context_valve_retry: bool = False): """Run the Claude Agent SDK query loop for a session.""" session = self.sessions.get(session_id) if not session: return - ttft_probe(session_id, "loop_start", fork=fork_session, model=session.model, msgs=len(session.messages)) from backend.apps.agents.providers.registry import get_api_type as p_get_api_type p_api = p_get_api_type(session.model) @@ -60,7 +63,6 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr prompt, images, context_paths, forced_tools, attached_skills, api_type=p_api, model=session.model, ) - ttft_probe(session_id, "prompt_built") try: # SDK presence check: fall to mock mode here, before the options build, so a missing SDK is a clean mock run, not an error card. The real use is in run_options / turn_runner (lazy-imported there). @@ -85,13 +87,14 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr # Builtins default to always_allow (frictionless); path_gate still force-prompts on catastrophic patterns (rm -rf), OS-scheduling, and sensitive paths, so poisoned-email -> destructive-command is still caught. Flip Bash to "ask" in the UI for a prompt on every command. Bind turn + stderr first: build_agent_options can raise early (no provider) and the except hands both to handle_run_error. turn = TurnState() p_stderr_buffer: List[str] = [] + # Read BEFORE build_agent_options consumes these flags: a fresh-session/fork request must force the persistent client to respawn (same branch id would otherwise fingerprint-match a client still holding the old transcript). + p_force_respawn = bool(session.needs_fresh_session or session.needs_fork or fork_session) try: (options, options_kwargs, prompt_content, p_stderr_buffer, global_settings) = await self.build_agent_options( session, session_id, prompt, prompt_content, builtin_perms, selected_browser_ids, selected_app_output_ids, selected_setting_ids, fork_session, p_router_model_id, p_api_type_for_session) - ttft_probe(session_id, "options_built") resolved_model = p_router_model_id api_type = p_api_type_for_session @@ -99,6 +102,7 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr await self.run_turn_with_retry( session, session_id, prompt_content, options, options_kwargs, turn, thinking, p_stderr_buffer, resolved_model, api_type, global_settings, + force_respawn=p_force_respawn, ) session.status = "completed" @@ -127,6 +131,52 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr turn.stream_text_msg_id = None turn.stream_text_accum = "" except Exception as e: + from backend.apps.agents.core.error_classify import is_context_pressure_death + p_stderr_tail = "\n".join(p_stderr_buffer[-50:]) + if not context_valve_retry and is_context_pressure_death(e, turn.compact_boundaries, extra_text=p_stderr_tail): + # Pressure-release valve: the CLI compacted this turn and still died (its "autocompact is thrashing" giving-up class). Its resume transcript is beyond saving, but ours isn't: rebuild from the local mirror via the proven fresh-session recap path and transparently re-run the turn ONCE. + logger.warning( + f"Agent {session_id}: context-pressure death after " + f"{turn.compact_boundaries} compact boundaries; one fresh-session recap retry" + ) + session.needs_fresh_session = True + if turn.stream_text_msg_id: + await ws_manager.send_to_session(session_id, "agent:stream_end", { + "session_id": session_id, + "message_id": turn.stream_text_msg_id, + }) + for p_tool_msg_id in turn.stream_tool_msg_ids_ordered: + await ws_manager.send_to_session(session_id, "agent:stream_end", { + "session_id": session_id, + "message_id": p_tool_msg_id, + }) + self.live_partial.pop(session_id, None) + # Tell the user we self-healed instead of retrying in silence: the frontend renders this as a muted transient pill (same language as the rate-limit pill), not an error card. + try: + await ws_manager.send_to_session(session_id, "agent:context_recovered", { + "session_id": session_id, + }) + except Exception: + logger.debug("context_recovered broadcast failed", exc_info=True) + try: + from backend.apps.service.client import submit_diagnostic + from backend.apps.agents.core.error_classify import redact_for_telemetry + submit_diagnostic({ + "kind": "context_pressure_valve", + "session_id": session_id, + "model": session.model, + "compact_boundaries": turn.compact_boundaries, + "error_preview": redact_for_telemetry(str(e), limit=300), + }) + except Exception: + logger.debug("submit_diagnostic context_pressure_valve failed", exc_info=True) + await self.run_agent_loop( + session_id, prompt, images, context_paths, forced_tools, + attached_skills, fork_session, selected_browser_ids, + selected_app_output_ids, selected_setting_ids, + context_valve_retry=True, + ) + return await handle_run_error(e, session, session_id, turn, p_stderr_buffer) except BaseException as e: # Catch BaseExceptionGroup from anyio task groups (e.g. concurrent CLI crash + pending approval cancellation) so it doesn't escape and kill the uvicorn process. diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 081ab740..8f70b0c7 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -29,6 +29,9 @@ async def agents_lifespan(): for session_id in list(agent_manager.tasks.keys()): await agent_manager.stop_agent(session_id) await agent_manager.persist_all_sessions() + # Persistent CLI clients outlive turns; without this a uvicorn reload/quit orphans one subprocess per live session. + from backend.apps.agents.manager.run.client_pool import dispose_all_clients + await dispose_all_clients(agent_manager.client_pool) agents = SubApp("agents", agents_lifespan) @@ -305,16 +308,19 @@ async def warm_session_cache(session_id: str): async def compact_session(session_id: str): """Run the summarizer over older turns to free up context. - Wired to the 'Compact memory' button in the pre-send overflow banner - and the /compact slash command. Sets compacted_through_msg_id so the - next turn's history-builder uses the summary in place of the - original messages. + Wired to the 'Compact memory' button in the pre-send overflow banner and the + /compact slash command. Marks compacted_through_msg_id AND sets + needs_fresh_session: the user explicitly opted into the prompt-cache loss for a + real visible trim, so the next turn drops the SDK convo and rebuilds from history + with the cutoff (and distilled summary) actually applied. Auto-compact only marks; + the button is the user paying for the rebuild. """ session = agent_manager.sessions.get(session_id) if not session: raise HTTPException(status_code=404, detail="session not found") fired = agent_manager.maybe_compact(session, force=True) if fired: + session.needs_fresh_session = True from backend.apps.agents.core.ws_manager import ws_manager try: await ws_manager.send_to_session(session_id, "agent:context_status", { @@ -344,6 +350,8 @@ async def clear_session(session_id: str): raise HTTPException(status_code=404, detail="session not found") session.messages = [] session.compacted_through_msg_id = None + session.compacted_summary = None + session.compacted_summary_through = None session.tokens = {"input": 0, "output": 0} session.needs_fresh_session = True from backend.apps.agents.core.ws_manager import ws_manager diff --git a/backend/apps/agents/browser_agent_mcp_server.py b/backend/apps/agents/browser_agent_mcp_server.py index 6e3b71a5..39021d23 100644 --- a/backend/apps/agents/browser_agent_mcp_server.py +++ b/backend/apps/agents/browser_agent_mcp_server.py @@ -5,6 +5,7 @@ import base64 import json import sys import os +import time import urllib.request import urllib.error from io import BytesIO @@ -190,6 +191,45 @@ def call_backend(tasks: list[dict]) -> dict: MAX_IMAGE_B64_BYTES = 400_000 +MAX_SUMMARY_CHARS = 16_000 +MAX_ACTION_LOG_ENTRIES = 40 +REPORT_DIR = os.environ.get( + "OPENSWARM_TOOL_REPORT_DIR", + os.path.join(os.path.expanduser("~"), ".openswarm", "tool-reports"), +) + + +def spill_full_report(text: str, prefix: str) -> str: + """Write the unabridged report to disk so trimming is lossless: the agent can Read + the file (with offset/limit) whenever the capped version isn't enough. Empty string + when the write fails; callers degrade to cap-only.""" + try: + os.makedirs(REPORT_DIR, exist_ok=True) + # Reports are point-in-time working files, not archives; prune week-old ones so the folder can't grow forever. + cutoff = time.time() - 7 * 86400 + for old in os.listdir(REPORT_DIR): + p = os.path.join(REPORT_DIR, old) + try: + if os.path.getmtime(p) < cutoff: + os.remove(p) + except OSError: + pass + path = os.path.join(REPORT_DIR, f"{prefix}-{os.getpid()}-{int(time.time()*1000)}.md") + with open(path, "w", encoding="utf-8") as f: + f.write(text) + return path + except Exception: + return "" + + +def p_cap_summary(text: str) -> tuple[str, bool]: + """Head+tail split, plus a truncated? flag so the caller can spill the full text: the CLI hard-rejects tool results past ~25K tokens, and a vanished report is worse than a trimmed one.""" + if len(text) <= MAX_SUMMARY_CHARS: + return text, False + head = text[: MAX_SUMMARY_CHARS - 4_000] + tail = text[-3_500:] + omitted = len(text) - len(head) - len(tail) + return f"{head}\n\n[... {omitted} chars of the report omitted ...]\n\n{tail}", True def p_sniff_image_mime(b64: str) -> str: @@ -232,19 +272,36 @@ def format_result(result: dict) -> dict: browser_id = result.get("browser_id", "") action_log = result.get("action_log", []) + capped_summary, summary_truncated = p_cap_summary(summary) lines = [f"**Browser Agent Result** (browser: {browser_id}, session: {session_id})", ""] - lines.append(f"**Summary:** {summary}") + lines.append(f"**Summary:** {capped_summary}") + actions_omitted = 0 if action_log: lines.append("") lines.append("**Actions taken:**") - for i, entry in enumerate(action_log, 1): + entries = action_log[-MAX_ACTION_LOG_ENTRIES:] + actions_omitted = len(action_log) - len(entries) + if actions_omitted > 0: + lines.append(f" (... {actions_omitted} earlier actions omitted ...)") + for i, entry in enumerate(entries, actions_omitted + 1): tool = entry.get("tool", "?") inp = entry.get("input", {}) ms = entry.get("elapsed_ms", 0) brief = json.dumps(inp)[:120] lines.append(f" {i}. {tool}({brief}) [{ms}ms]") + if summary_truncated or actions_omitted > 0: + full_lines = [f"# Browser Agent Full Report (browser: {browser_id}, session: {session_id})", "", summary, ""] + if action_log: + full_lines.append("## Actions") + for i, entry in enumerate(action_log, 1): + full_lines.append(f"{i}. {entry.get('tool', '?')}({json.dumps(entry.get('input', {}))}) [{entry.get('elapsed_ms', 0)}ms]") + report_path = spill_full_report("\n".join(full_lines), "browser-report") + if report_path: + lines.append("") + lines.append(f"Full unabridged report saved to: {report_path} (use Read with offset/limit for the omitted parts)") + content.append({"type": "text", "text": "\n".join(lines)}) screenshot = result.get("final_screenshot") diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index 2e262e68..2f683d21 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -232,6 +232,29 @@ def is_out_of_tokens(exc: BaseException, extra_text: str = "") -> bool: )) +@typechecked +def is_context_pressure_death(exc: BaseException, compact_boundaries: int, extra_text: str = "") -> bool: + """The CLI autocompact-thrash class: the process compacted during this turn and then + died with a bare exit-1 ProcessError (its thrash detector gives up after 3 refill + cycles, which can straddle turns on a persistent client, so one boundary in the dying + turn is the reliable tell). Only claims deaths no other classifier owns, so auth/ + capacity/credit errors keep their specific handling; a misfire costs one bounded + silent retry, a miss just means today's error card. + """ + if compact_boundaries < 1: + return False + # Type-name check, not isinstance: the SDK is lazy-imported (mock mode must work without it), mirroring the client-pool dead-client idiom. + if "ProcessError" not in type(exc).__name__: + return False + for p_claimed_by in ( + is_long_context_error, is_transient_capacity_error, is_free_trial_exhausted, + is_out_of_tokens, is_auth_error, is_unknown_model_error, + ): + if p_claimed_by(exc, extra_text=extra_text): + return False + return True + + @typechecked def extract_reset_hint(text: str) -> str: """Pull a human reset phrase ('at 7:42 AM', 'in 2h 30m', 'after 1m 59s') out of diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index 6d8f588d..48bc5bc3 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -132,7 +132,12 @@ class AgentSession(BaseModel): framework_overhead_tokens: int = 0 # Live ctx_used ratio triggering _maybe_compact at the next turn boundary; turn-based thresholds break under uneven workloads. Ratio of context_window, so 0.65 means 650K on a 1M-window model and 130K on a 200K-window model. compact_threshold_pct: float = 0.65 + # Absolute token ceiling so big-window models don't sit at 650K before marking; the marker fires at the TIGHTER of the pct or this cap, so it's never "just 65%". + compact_abs_ceiling_tokens: int = 180_000 compacted_through_msg_id: Optional[str] = None + # Aux-LLM distilled summary of the turns dropped by compaction, cached against the cutoff id it was built for; keeps the gist of old history on a rebuild instead of a hard drop. + compacted_summary: Optional[str] = None + compacted_summary_through: Optional[str] = None # Hard pre-send guard at 0.90; past compaction we LRU-trim active_mcps, then surface the overflow card. context_soft_cap_pct: float = 0.90 # Conservative default. Always overwritten at session creation, restore, and model-switch via apply_context_window in agent_manager so the real model cap is used instead. Don't bump this without re-checking the trim/guard logic. diff --git a/backend/apps/agents/manager/AgentLaunch.py b/backend/apps/agents/manager/AgentLaunch.py index c8d2a54a..83c5057d 100644 --- a/backend/apps/agents/manager/AgentLaunch.py +++ b/backend/apps/agents/manager/AgentLaunch.py @@ -1,4 +1,4 @@ -"""Agent run entry points for AgentManager: launch a new top-level run and the staticmethod +"""Agent run entry points for AgentManager: launch a new top-level run and the invoke_agent helper (fork-and-send a sub-agent). The no-SDK mock fallback lives in MockAgent. Split into a mixin to keep the manager file under the size ceiling; self.run_agent_loop / self.sessions resolve across the MRO exactly as before.""" @@ -137,7 +137,6 @@ class AgentLaunch(AgentManagerProtocol): return session - @staticmethod @typechecked async def invoke_agent( self, diff --git a/backend/apps/agents/manager/configure_provider_env.py b/backend/apps/agents/manager/configure_provider_env.py index ee5e3cec..327a2033 100644 --- a/backend/apps/agents/manager/configure_provider_env.py +++ b/backend/apps/agents/manager/configure_provider_env.py @@ -15,6 +15,34 @@ from backend.auth import get_auth_token logger = __import__("logging").getLogger(__name__) +@typechecked +async def router_available(global_settings: AppSettings) -> bool: + """True when 9Router is up, reviving it first if it died. A dead router must never masquerade + as "no provider configured": detection now shares the dispatch path's lazy-start, so a crashed + or orphaned router self-heals on the very next send instead of erroring the turn. Revival is + gated on EVIDENCE of a provider (a settings key, proxy mode, or an active connection in the + router's on-disk db) so a zero-config user keeps the clean no-provider message instead of us + booting a router with nothing to route.""" + from backend.apps.nine_router import ensure_running as p_ensure, is_running as p_running + from backend.apps.nine_router.process import has_persisted_connections + if p_running(): + return True + p_evidence = any([ + getattr(global_settings, "anthropic_api_key", None), + getattr(global_settings, "openai_api_key", None), + getattr(global_settings, "google_api_key", None), + getattr(global_settings, "openrouter_api_key", None), + getattr(global_settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial"), + bool(getattr(global_settings, "custom_providers", None) or []), + has_persisted_connections(), + ]) + if not p_evidence: + return False + logger.info("[MCP-DEBUG] 9Router down at provider detection; reviving before concluding") + await p_ensure() + return p_running() + + @typechecked async def configure_provider_env( options_kwargs: Dict, @@ -153,7 +181,7 @@ async def configure_provider_env( elif api_type == "anthropic" and not resolved_is_9router and global_settings.anthropic_api_key: options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key} logger.info("[MCP-DEBUG] Using direct Anthropic API key") - elif nine_router_running(): + elif await router_available(global_settings): # Gemini-bound ids go through the local proxy for schema scrubbing; everything else hits 9Router directly. is_gemini_bound = ( isinstance(resolved_model, str) @@ -203,21 +231,11 @@ async def configure_provider_env( options_kwargs["env"] = env logger.info(f"[MCP-DEBUG] Using 9Router (api_type={api_type})") else: - if api_type != "anthropic": - from backend.apps.nine_router import ensure_running as nine_router_ensure - logger.info(f"[MCP-DEBUG] 9Router not running for non-Anthropic model {session.model}; waiting for startup") - await nine_router_ensure() - if nine_router_running(): - options_kwargs["env"] = { - "ANTHROPIC_API_KEY": "9router", - "ANTHROPIC_BASE_URL": "http://localhost:20128", - } - logger.info(f"[MCP-DEBUG] 9Router started; routing {session.model} via 9Router") - else: - raise ValueError( - f"9Router is not running; cannot use {session.model}. " - "Install Node.js and restart the app, or switch to a model " - "with a direct API key." - ) - else: - raise ValueError("No AI provider configured. Set an API key or connect a subscription.") + # router_available() above already attempted a revival; reaching here means it truly can't start. + if api_type != "anthropic" or resolved_is_9router: + raise ValueError( + f"9Router is not running; cannot use {session.model}. " + "Install Node.js and restart the app, or switch to a model " + "with a direct API key." + ) + raise ValueError("No AI provider configured. Set an API key or connect a subscription.") diff --git a/backend/apps/agents/manager/context_budget.py b/backend/apps/agents/manager/context_budget.py index ac42ba37..8e4832d2 100644 --- a/backend/apps/agents/manager/context_budget.py +++ b/backend/apps/agents/manager/context_budget.py @@ -22,8 +22,12 @@ def maybe_compact(session: AgentSession, force: bool = False) -> bool: Returns True if a NEW summary boundary was set. Summarizes everything up to (but not including) the last 6 messages so recent intent stays visible to the model. Never touches session.messages.""" - ctx_used = session.tokens.get("input", 0) / max(1, session.context_window) - if not force and ctx_used < session.compact_threshold_pct: + window = max(1, session.context_window) + # Fire at the TIGHTER of the pct or the absolute ceiling: on a 200K window the pct wins (130K), on a 1M window the ceiling wins (180K, not 650K). Not "just 65%". + abs_pct = min(1.0, session.compact_abs_ceiling_tokens / window) + trigger = min(session.compact_threshold_pct, abs_pct) + ctx_used = session.tokens.get("input", 0) / window + if not force and ctx_used < trigger: return False msgs = get_branch_messages(session) if len(msgs) < 4: diff --git a/backend/apps/agents/manager/run/RunOptions.py b/backend/apps/agents/manager/run/RunOptions.py index b1578178..37b7f3da 100644 --- a/backend/apps/agents/manager/run/RunOptions.py +++ b/backend/apps/agents/manager/run/RunOptions.py @@ -50,14 +50,27 @@ class RunOptions(AgentManagerProtocol): from claude_agent_sdk import ClaudeAgentOptions from claude_agent_sdk.types import HookMatcher - hook_ctx = HookContext( - session=session, - session_id=session_id, - prompt=prompt, - builtin_perms=builtin_perms, - policy_defaults={}, - sessions=self.sessions, - ) + # Per-SESSION hook context, updated in place each turn: with a persistent client the hooks the + # CLI holds were bound at connect, so they must read this stable object, not a per-turn rebuild. + hook_ctx = self.hook_ctxs.get(session_id) + if hook_ctx is None: + hook_ctx = HookContext( + session=session, + session_id=session_id, + prompt=prompt, + builtin_perms=builtin_perms, + policy_defaults={}, + sessions=self.sessions, + ) + self.hook_ctxs[session_id] = hook_ctx + else: + hook_ctx.session = session + hook_ctx.prompt = prompt + hook_ctx.builtin_perms = builtin_perms + # Per-RUN counters reset each turn (same semantics a fresh ctx used to give). + hook_ctx.tool_start_times = {} + hook_ctx.ts_loop_count = 0 + hook_ctx.mcp_offer_sent = False async def can_use_tool(tool_name, input_data, context): return await gate_hooks.can_use_tool(hook_ctx, tool_name, input_data, context) @@ -119,7 +132,8 @@ class RunOptions(AgentManagerProtocol): connection_mode=getattr(global_settings, "connection_mode", "own_key"), ) if need_web_mcp: - register_web_mcp_server(mcp_servers, p_m) + # browser_ok gates the search-dead fallback nudge: never tell the model to call CreateBrowserAgent in a session where browser delegation is denied. + register_web_mcp_server(mcp_servers, p_m, browser_ok=bool(browser_delegation_tools)) effective_allowed, effective_disallowed = build_effective_tool_lists( session, mcp_servers, builtin_perms, need_web_mcp, @@ -143,7 +157,9 @@ class RunOptions(AgentManagerProtocol): session.provider = api_type # Capture the Claude CLI's stderr into a buffer so the retry classifier can see the real cause of a process crash (e.g. "No pool capacity available" from the OpenSwarm proxy, or the Anthropic SDK's 429/overloaded error body). Without this the SDK's ProcessError only stringifies to "Command failed with exit code 1 / Check stderr output for details", which masks transient capacity issues. - p_stderr_buffer: List[str] = [] + # Per-SESSION buffer cleared in place each turn: a persistent client's stderr callback was bound at connect and must keep pointing at this exact list. + p_stderr_buffer = self.stderr_buffers.setdefault(session_id, []) + p_stderr_buffer.clear() def p_stderr_cb(line: str) -> None: p_stderr_buffer.append(line) @@ -204,8 +220,8 @@ class RunOptions(AgentManagerProtocol): # claude.ai partner MCPs (Notion/Google/Gmail). We already hard-block their tools just below, # but the CLI still spawned+connected them every turn (~1.5s of pure dead-weight TTFT, measured). # Our builtins + any MCPActivate'd server go through mcp_servers, so they're unaffected; this - # only stops the already-blocked account MCPs from booting. Kill switch: OSW_TTFT_STRICT_MCP=0. - if os.environ.get("OSW_TTFT_STRICT_MCP", "1") != "0": + # only stops the already-blocked account MCPs from booting. Kill switch: OPENSWARM_STRICT_MCP=0. + if os.environ.get("OPENSWARM_STRICT_MCP", "1") != "0": p_ea = dict(options_kwargs.get("extra_args") or {}) p_ea["strict-mcp-config"] = None options_kwargs["extra_args"] = p_ea @@ -244,6 +260,13 @@ class RunOptions(AgentManagerProtocol): get_branch_messages(session), cutoff_msg_id=session.compacted_through_msg_id, ) + # Distill the dropped span into a cached aux summary so a rebuild keeps the gist of old turns instead of hard-dropping them. Fail-open: "" -> the plain recap above, exactly today's behavior. + from backend.apps.agents.manager.session.distill_history import distilled_history_summary + from backend.apps.agents.manager.session.history_compaction import wrap_platform_note + distilled = await distilled_history_summary(session, global_settings) + if distilled: + fenced = wrap_platform_note(f"Summary of earlier conversation (older turns compacted):\n{distilled}") + history = f"{fenced}\n\n{history}" if history else fenced if history: if isinstance(prompt_content, str): prompt_content = history + "\n\n" + prompt_content diff --git a/backend/apps/agents/manager/run/TurnRunner.py b/backend/apps/agents/manager/run/TurnRunner.py index 8a979c02..6acecd96 100644 --- a/backend/apps/agents/manager/run/TurnRunner.py +++ b/backend/apps/agents/manager/run/TurnRunner.py @@ -16,7 +16,12 @@ from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState from backend.apps.agents.manager.streaming.handle_stream_event import handle_stream_event from backend.apps.agents.manager.streaming.handle_assistant_message import handle_assistant_message from backend.apps.agents.manager.streaming.handle_result_message import handle_result_message -from backend.apps.agents.manager.ttft_probe import ttft_probe +from backend.apps.agents.manager.run.client_pool import ( + acquire_client, + boot_fingerprint, + dispose_client, + persistent_client_enabled, +) from backend.apps.agents.manager.streaming import thinking as thinking_mod from backend.apps.settings.models import AppSettings @@ -33,10 +38,9 @@ class TurnRunner(AgentManagerProtocol): prompt_content: Union[str, List], options, options_kwargs: Dict, turn: TurnState, thinking: ThinkingState, p_stderr_buffer: List[str], resolved_model: str, api_type: str, - global_settings: AppSettings) -> None: + global_settings: AppSettings, force_respawn: bool = False) -> None: from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, ResultMessage from claude_agent_sdk.types import StreamEvent, SystemMessage - ttft_probe(session_id, "query_enter") async def prompt_stream(): yield { @@ -44,12 +48,10 @@ class TurnRunner(AgentManagerProtocol): "message": {"role": "user", "content": prompt_content}, } - async def p_run_streaming_turn(): + async def p_run_streaming_turn(p_stream=None): # Per-turn thinking aggregation trackers (added for the "Thought for Ns · M tokens" persisted label). Without nonlocal, the int reassignments at AssistantMessage emission below shadow them as locals and the dict access at content_block_start crashes with UnboundLocalError. - async for message in query( - prompt=prompt_stream(), - options=options, - ): + # p_stream lets the persistent-client path feed receive_response() through this same consumption loop (one body, two transports). + async for message in (p_stream if p_stream is not None else query(prompt=prompt_stream(), options=options)): if isinstance(message, ResultMessage): turn.current_turn_emitted = False else: @@ -90,7 +92,6 @@ class TurnRunner(AgentManagerProtocol): logger.exception("pre-emit thinking pill failed; continuing") if turn.first_event: - ttft_probe(session_id, "first_event", type=type(message).__name__) logger.info(f"[MCP-DEBUG] First event received: {type(message).__name__}") turn.first_event = False @@ -98,6 +99,8 @@ class TurnRunner(AgentManagerProtocol): if isinstance(message, SystemMessage): raw = message.__dict__ if hasattr(message, '__dict__') else str(message) logger.info(f"[MCP-DEBUG] SystemMessage: {raw}") + if getattr(message, "subtype", "") == "compact_boundary": + turn.compact_boundaries += 1 if isinstance(message, StreamEvent): await handle_stream_event( @@ -114,10 +117,36 @@ class TurnRunner(AgentManagerProtocol): resolved_model, api_type, global_settings, ) + async def p_run_streaming_turn_persistent(): + from claude_agent_sdk import ClaudeSDKClient + + async def p_connect(): + p_client = ClaudeSDKClient(options=options) + await p_client.connect() + return p_client + + fp = boot_fingerprint(options_kwargs, session) + handle = await acquire_client( + self.client_pool, session_id, fp, p_connect, force_respawn=force_respawn, + ) + async with handle.lock: + handle.turns_served += 1 + try: + await handle.client.query(prompt_stream()) + await p_run_streaming_turn(p_stream=handle.client.receive_response()) + except BaseException: + # Fail-safe: an error or stop mid-turn poisons the live conversation; drop the client so the next attempt/turn reconnects fresh (== today's one-shot behavior, never worse). Pool pop is sync-first, so even a cancelled disconnect can't leave a reusable stale handle. + await dispose_client(self.client_pool, session_id) + raise + + p_use_persistent = persistent_client_enabled() capacity_retry_attempt = 0 while True: try: - await p_run_streaming_turn() + if p_use_persistent: + await p_run_streaming_turn_persistent() + else: + await p_run_streaming_turn() break except Exception as e: # Make sure the consolidated-thinking ticker doesn't outlive the turn on error/retry. Without this, an exception mid-stream leaves a dangling task that keeps re-emitting against a stale msg id. @@ -130,6 +159,12 @@ class TurnRunner(AgentManagerProtocol): thinking.ticker_task = None stderr_snapshot = "\n".join(p_stderr_buffer[-50:]) wait = capacity_retry_wait(e, capacity_retry_attempt, extra_text=stderr_snapshot) + # Persistent-client fail-safe: a dead/wedged CLI raises a connection-class error that the capacity classifier won't retry. The client is already disposed (see p_run_streaming_turn_persistent), so ONE immediate retry reconnects fresh == today's cold behavior; a second failure surfaces normally. + if wait is None and p_use_persistent and capacity_retry_attempt == 0 and not turn.current_turn_emitted: + p_name = type(e).__name__ + if "CLIConnection" in p_name or "ProcessError" in p_name or "Transport" in p_name: + logger.warning(f"[client-pool] {session_id}: dead client ({p_name}); one transparent respawn retry") + wait = 0.0 if wait is not None: capacity_retry_attempt += 1 mid_stream = turn.current_turn_emitted diff --git a/backend/apps/agents/manager/run/client_pool.py b/backend/apps/agents/manager/run/client_pool.py new file mode 100644 index 00000000..e895451b --- /dev/null +++ b/backend/apps/agents/manager/run/client_pool.py @@ -0,0 +1,156 @@ +"""Per-session persistent SDK client pool (lever A of the TTFT work, gated by +OSW_TTFT_PERSISTENT_CLIENT=1, default OFF). One live Claude CLI per session, reused across +follow-up turns so the ~0.5s subprocess + MCP boot is paid once, not per message. + +Safety model, from the red-teamed plan: reuse is gated on a BOOT FINGERPRINT (a hash of every +boot-frozen input), never on session flags. Any change to the booted config (MCPActivate growing +mcp_servers, branch switch, compaction, provider env, selection-context system prompt) changes the +fingerprint and forces a dispose+respawn, so "live client with stale config" is unrepresentable. +Every error path collapses to dispose+respawn, which IS today's one-shot behavior, never worse.""" + +import asyncio +import hashlib +import json +import logging +import os +import time +from typing import Awaitable, Callable, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, InstanceOf +from typeguard import typechecked + +from backend.apps.agents.core.models import AgentSession + +logger = logging.getLogger(__name__) + +# Options entries that are per-turn or non-serializable; everything else is boot-frozen and hashed. +P_NON_BOOT_KEYS = frozenset({"can_use_tool", "stderr", "hooks", "resume", "fork_session"}) + + +def persistent_client_enabled() -> bool: + """Default ON (soak-proven: warm turns 535ms -> 6ms). Kill switch: OPENSWARM_PERSISTENT_CLIENT=0.""" + return os.environ.get("OPENSWARM_PERSISTENT_CLIENT", "1") != "0" + + +# Per-session field-level digests from the last fingerprint call; lets a mismatch log WHICH boot field drifted (probe-gated diagnostics only). +p_last_field_digests: Dict[str, Dict[str, str]] = {} + + +@typechecked +def boot_fingerprint(options_kwargs: Dict, session: AgentSession) -> str: + """Hash of every input the CLI subprocess freezes at boot. Includes the full mcp_servers config + (so MCPActivate / model-env changes respawn), the composed system prompt (so per-turn selection + context respawns instead of silently not applying), branch, and the compaction cutoff (else a + live client would keep the untrimmed transcript forever).""" + frozen = {k: v for k, v in options_kwargs.items() if k not in P_NON_BOOT_KEYS} + frozen["p_branch"] = session.active_branch_id + frozen["p_compacted_through"] = session.compacted_through_msg_id + # Pool diagnostics (OPENSWARM_POOL_DIAG=1): on a respawn, names WHICH boot field drifted; the tool for debugging respawn churn (e.g. the thinking short/long-prompt flip) in the field. + if os.environ.get("OPENSWARM_POOL_DIAG") == "1": + digests = {k: hashlib.sha256(json.dumps(v, sort_keys=True, default=str).encode()).hexdigest()[:10] for k, v in frozen.items()} + prev = p_last_field_digests.get(session.id) + if prev is not None: + changed = [k for k in digests if prev.get(k) != digests.get(k)] + [k for k in prev if k not in digests] + if changed: + logger.info(f"[client-pool] {session.id}: fingerprint fields changed: {sorted(set(changed))}") + p_last_field_digests[session.id] = digests + blob = json.dumps(frozen, sort_keys=True, default=str) + return hashlib.sha256(blob.encode()).hexdigest() + + +class ClientHandle(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + fingerprint: str + client: InstanceOf[object] + lock: InstanceOf[asyncio.Lock] + connected_at: float + last_used: float + turns_served: int = 0 + + +# A pooled CLI holds ~100MB+ per session; evict clients idle past this so parked chats don't accumulate subprocesses (respawn on the next message is the normal cold path). +IDLE_EVICT_SECONDS = float(os.environ.get("OSW_CLIENT_IDLE_EVICT_SECONDS", "1800")) + + +@typechecked +async def evict_idle_clients(pool: Dict[str, "ClientHandle"]) -> None: + """Dispose every handle idle past the TTL, skipping any mid-turn (lock held).""" + now = time.monotonic() + for sid in list(pool.keys()): + handle = pool.get(sid) + if handle is None or handle.lock.locked(): + continue + if now - handle.last_used > IDLE_EVICT_SECONDS: + logger.info(f"[client-pool] {sid}: idle-evict after {int(now - handle.last_used)}s") + await dispose_client(pool, sid) + + +@typechecked +async def acquire_client( + pool: Dict[str, ClientHandle], + session_id: str, + fingerprint: str, + connect_fn: Callable[[], Awaitable[object]], + force_respawn: bool = False, +) -> ClientHandle: + """Return a live client whose boot matches `fingerprint`, connecting fresh when there is none, + the fingerprint mismatches, or the caller demands a fresh session (needs_fresh/fork consumed + upstream, so the flag must be read BEFORE build_agent_options and passed in).""" + await evict_idle_clients(pool) + existing = pool.get(session_id) + if existing is not None: + if not force_respawn and existing.fingerprint == fingerprint: + existing.last_used = time.monotonic() + return existing + reason = "force_respawn" if force_respawn else "fingerprint_changed" + logger.info(f"[client-pool] {session_id}: respawn ({reason})") + await dispose_client(pool, session_id) + client = await connect_fn() + now = time.monotonic() + handle = ClientHandle( + fingerprint=fingerprint, client=client, lock=asyncio.Lock(), connected_at=now, last_used=now, + ) + pool[session_id] = handle + logger.info(f"[client-pool] {session_id}: connected fresh client") + return handle + + +@typechecked +async def dispose_client(pool: Dict[str, ClientHandle], session_id: str) -> None: + """Pop first so a concurrent turn can never re-grab a disposing client, then disconnect + (terminates the CLI subprocess). Never raises: teardown must not block a turn or a close.""" + handle = pool.pop(session_id, None) + if handle is None: + return + try: + await handle.client.disconnect() + except Exception: + logger.exception(f"[client-pool] {session_id}: disconnect failed (subprocess may already be dead)") + + +@typechecked +def dispose_client_soon(pool: Dict[str, ClientHandle], session_id: str) -> None: + """Sync-context teardown (purge_session_memory): pop now, disconnect in a detached task.""" + handle = pool.pop(session_id, None) + if handle is None: + return + + async def p_bg() -> None: + try: + await handle.client.disconnect() + except Exception: + logger.exception(f"[client-pool] {session_id}: background disconnect failed") + + try: + asyncio.get_running_loop().create_task(p_bg()) + except RuntimeError: + logger.warning(f"[client-pool] {session_id}: no loop for background disconnect; subprocess reaped on exit") + + +@typechecked +async def dispose_all_clients(pool: Dict[str, ClientHandle]) -> None: + """Process-shutdown hook: a persistent subprocess outlives turns, so uvicorn reload/quit would + orphan one CLI per live session without this.""" + for sid in list(pool.keys()): + await dispose_client(pool, sid) diff --git a/backend/apps/agents/manager/run/run_options_helpers.py b/backend/apps/agents/manager/run/run_options_helpers.py index 74dc1b38..c88fbd81 100644 --- a/backend/apps/agents/manager/run/run_options_helpers.py +++ b/backend/apps/agents/manager/run/run_options_helpers.py @@ -92,7 +92,7 @@ def set_framework_overhead(session: AgentSession, composed_prompt: Optional[str] @typechecked -def register_web_mcp_server(mcp_servers: Dict, p_m: str) -> None: +def register_web_mcp_server(mcp_servers: Dict, p_m: str, browser_ok: bool = False) -> None: """Register the DDG-backed openswarm-web stdio MCP into the server set when the primary has no reliable native web path. The server script lives in the agents package (not here), so resolve it off that package dir, not __file__.""" @@ -115,6 +115,7 @@ def register_web_mcp_server(mcp_servers: Dict, p_m: str) -> None: "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), "OPENSWARM_AUTH_TOKEN": p_get_auth_token3(), "OPENSWARM_PRIMARY_API": p_primary_hint, + "OPENSWARM_BROWSER_OK": "1" if browser_ok else "0", }, "type": "stdio", } diff --git a/backend/apps/agents/manager/session/SessionLifecycle.py b/backend/apps/agents/manager/session/SessionLifecycle.py index b48b776a..19ba4654 100644 --- a/backend/apps/agents/manager/session/SessionLifecycle.py +++ b/backend/apps/agents/manager/session/SessionLifecycle.py @@ -23,6 +23,7 @@ from backend.apps.agents.manager.view_builder_state import ( view_builder_render_retry_counts, view_builder_dirty_sessions, ) +from backend.apps.agents.manager.run.client_pool import dispose_client_soon logger = logging.getLogger(__name__) @@ -103,6 +104,9 @@ class SessionLifecycle(AgentManagerProtocol): self.cancel_events.pop(session_id, None) view_builder_render_retry_counts.pop(session_id, None) view_builder_dirty_sessions.discard(session_id) + dispose_client_soon(self.client_pool, session_id) + self.hook_ctxs.pop(session_id, None) + self.stderr_buffers.pop(session_id, None) @typechecked async def delete_session(self, session_id: str) -> None: diff --git a/backend/apps/agents/manager/session/distill_history.py b/backend/apps/agents/manager/session/distill_history.py new file mode 100644 index 00000000..f9227539 --- /dev/null +++ b/backend/apps/agents/manager/session/distill_history.py @@ -0,0 +1,114 @@ +"""Aux-LLM distillation of the turns dropped by compaction. + +On a fresh rebuild (valve rescue, MCPActivate continuation, branch edit) the recap +hard-drops everything before the cutoff. That loses the thread of a long conversation. +This distills the dropped span into a dense summary via the user's cheap-tier model +(provider-agnostic) and caches it against the cutoff id, so the rebuild keeps the gist +instead of the void. Fail-open at every step: any error, no provider, or the kill switch +returns "" and the caller falls back to the plain hard-drop.""" + +import logging +import os +from typing import List + +from typeguard import typechecked + +from backend.apps.agents.core.models import AgentSession +from backend.apps.settings.models import AppSettings +from backend.apps.agents.manager.session.history_compaction import ( + get_branch_messages, + recap_tool_call_line, + recap_tool_result_line, + strip_forged_sentinels, +) + +logger = logging.getLogger(__name__) + +DISTILL_ENABLED = os.environ.get("OPENSWARM_DISTILL_HISTORY", "1") != "0" +MAX_DISTILL_INPUT_CHARS = 60_000 + +P_SYSTEM = ( + "You are a note-taker that condenses a conversation transcript into a briefing. " + "You NEVER continue, answer, reply to, or role-play the conversation. You only " + "DESCRIBE it, in the third person ('The user asked...', 'The agent decided...'). " + "Your entire output is the briefing and nothing else." +) +P_USER_TEMPLATE = ( + "Below, between tags, is the earlier part of a conversation between a " + "user and an AI agent. Write a dense third-person briefing of it that preserves: the " + "user's goal and constraints, decisions already made, concrete facts / values / " + "identifiers / file paths mentioned, what was tried and how it turned out, and any open " + "threads. Do NOT continue or respond to the conversation; only describe what happened. " + "No preamble.\n\n\n{body}\n" +) + + +@typechecked +def p_format_dropped(messages: List) -> str: + """Compact transcript of the dropped span: user/assistant text in full, tool I/O clipped (the same caps the recap uses), bounded so the aux call stays cheap.""" + lines: List[str] = [] + for m in messages: + if getattr(m, "hidden", False): + continue + if m.role in ("user", "assistant"): + text = m.content if isinstance(m.content, str) else str(m.content) + lines.append(f"{m.role.capitalize()}: {strip_forged_sentinels(text)}") + elif m.role == "tool_call": + lines.append(recap_tool_call_line(m.content)) + elif m.role == "tool_result": + lines.append(recap_tool_result_line(m.content)) + body = "\n".join(lines) + return body[-MAX_DISTILL_INPUT_CHARS:] if len(body) > MAX_DISTILL_INPUT_CHARS else body + + +@typechecked +async def distilled_history_summary(session: AgentSession, settings: AppSettings) -> str: + """Cached aux summary of everything up to and including compacted_through_msg_id. + Empty string when there's nothing to distill, the feature is off, or the call fails.""" + cutoff = session.compacted_through_msg_id + if not DISTILL_ENABLED or not cutoff: + return "" + msgs = get_branch_messages(session) + idx = next((i for i, m in enumerate(msgs) if m.id == cutoff), -1) + # Membership check BEFORE the cache: after a branch edit the cutoff can vanish from the active branch, and a summary keyed on that id would be stale. If the cutoff is still here, everything before it is shared pre-fork history, so a cache hit is provably valid. + if idx < 0: + return "" + if session.compacted_summary and session.compacted_summary_through == cutoff: + return session.compacted_summary + dropped = msgs[: idx + 1] + body = p_format_dropped(dropped) + if not body.strip(): + return "" + try: + summary = await p_call_distiller(session, settings, body) + except Exception: + logger.debug("history distill aux call failed; falling back to hard-drop", exc_info=True) + return "" + if not summary: + return "" + session.compacted_summary = summary + session.compacted_summary_through = cutoff + return summary + + +@typechecked +async def p_call_distiller(session: AgentSession, settings: AppSettings, body: str) -> str: + from backend.apps.agents.providers.registry import resolve_aux_model + from backend.apps.settings.credentials import get_anthropic_client_for_model + + # No primary_api: a background summary wants the most RELIABLE cheap tier, not the chat's family. Forcing the family routed a gemini/codex chat's distill onto a same-family aux that 404s (gemini-direct google endpoint the Anthropic client can't call) or 401s (codex token rotation); the proven classifier omits it too and resolves to whatever anthropic-compatible lane the user has. + aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku") + client = get_anthropic_client_for_model(settings, aux_model) + resp = await client.messages.create( + model=aux_model, + max_tokens=1024, + system=P_SYSTEM, + messages=[{"role": "user", "content": P_USER_TEMPLATE.format(body=body)}], + ) + text = "" + if isinstance(resp.content, list): + for block in resp.content: + t = getattr(block, "text", None) + if t: + text += t + return text.strip() diff --git a/backend/apps/agents/manager/session/history_compaction.py b/backend/apps/agents/manager/session/history_compaction.py index 04c3e4d2..5408119b 100644 --- a/backend/apps/agents/manager/session/history_compaction.py +++ b/backend/apps/agents/manager/session/history_compaction.py @@ -45,7 +45,7 @@ def strip_forged_sentinels(text: str) -> str: @typechecked -def p_recap_tool_call_line(content: object) -> str: +def recap_tool_call_line(content: object) -> str: """One compact line for a tool_call turn: Tool call: name().""" if isinstance(content, dict): tool = content.get("tool") or content.get("name") or "tool" @@ -63,7 +63,7 @@ def p_recap_tool_call_line(content: object) -> str: @typechecked -def p_recap_tool_result_line(content: object) -> str: +def recap_tool_result_line(content: object) -> str: """One compact line for a tool_result turn: Tool result (name): .""" tool_name = "" if isinstance(content, dict): @@ -142,9 +142,9 @@ def build_history_prefix(messages, cutoff_msg_id: Optional[str] = None) -> str: text = m.content if isinstance(m.content, str) else str(m.content) lines.append(f"Assistant: {strip_forged_sentinels(text)}") elif m.role == "tool_call": - lines.append(p_recap_tool_call_line(m.content)) + lines.append(recap_tool_call_line(m.content)) elif m.role == "tool_result": - lines.append(p_recap_tool_result_line(m.content)) + lines.append(recap_tool_result_line(m.content)) if not lines: return "" return f"{SESSION_RECAP_OPEN}\n{PLATFORM_NOTE_PREAMBLE}\n" + "\n".join(lines) + f"\n{SESSION_RECAP_CLOSE}" diff --git a/backend/apps/agents/manager/streaming/state.py b/backend/apps/agents/manager/streaming/state.py index d2545985..f7537cc0 100644 --- a/backend/apps/agents/manager/streaming/state.py +++ b/backend/apps/agents/manager/streaming/state.py @@ -54,3 +54,5 @@ class TurnState(BaseModel): baseline_children_in: int = 0 baseline_children_out: int = 0 baseline_captured: bool = False + # CLI compact_boundary events seen this turn; one plus a ProcessError = the autocompact-thrash death the context-pressure valve retries. + compact_boundaries: int = 0 diff --git a/backend/apps/agents/manager/ttft_probe.py b/backend/apps/agents/manager/ttft_probe.py deleted file mode 100644 index 3472d453..00000000 --- a/backend/apps/agents/manager/ttft_probe.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Temporary time-to-first-token phase probe for the send->first-token A/B sweep. A no-op unless -OSW_TTFT_PROBE=1, so it never spams a normal run. Strip once the persistent-client work lands.""" - -import logging -import os -import time - -from typeguard import typechecked - -logger = logging.getLogger(__name__) - -P_TTFT_ENABLED = os.environ.get("OSW_TTFT_PROBE") == "1" - - -@typechecked -def ttft_probe(session_id: str, phase: str, **extra: object) -> None: - """One monotonic phase stamp for the TTFT breakdown; the A/B parser reads `phase= mono=`. - A no-op unless OSW_TTFT_PROBE=1, and it swallows any error so instrumentation can NEVER break a turn.""" - if not P_TTFT_ENABLED: - return - try: - tail = " ".join(f"{k}={v}" for k, v in extra.items()) - logger.warning(f"[TTFT] sid={session_id} phase={phase} mono={time.monotonic():.4f} {tail}".rstrip()) - except Exception: - pass diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index bb8ecd68..4544f2a3 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -112,7 +112,7 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = { {"value": "gemini-3.1-flash-lite", "label": "Gemini 3.1 Flash Lite", "context_window": 1_000_000, "router_model_id": "gc/gemini-3.1-flash-lite-preview", "api": "gemini-cli", "subscription_only": True, "reasoning": True}, - # gemini-3-pro removed 2026-03-09 (shut down). gemini-3-flash pulled too (both sub + api-key rows): the direct-API lane is flaky and 429-throttled (measured 7-21s + quota errors), so it only sold a slow option; ag/gemini-3-flash lives on as an aux model, not a picker row. + # gemini-3-pro removed 2026-03-09 and gemini-3-flash removed 2026-07-03 (both rows, independently on two branches): gemini-3-flash-preview aged out upstream (API-key lane hangs/429s with no fail-fast, measured 7-21s; only an Antigravity sub masked it). 3.5-flash / 3.1-flash-lite cover the slots; ag/gemini-3-flash lives on as an aux model, not a picker row. # API-key entries: bypass 9Router, call generativelanguage.googleapis.com. {"value": "gemini-3.5-flash-api", "label": "Gemini 3.5 Flash (API key)", "context_window": 1_000_000, "router_model_id": "gemini-3.5-flash", "model_id": "gemini-3.5-flash", @@ -245,7 +245,7 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str: return entry.get("model_id", short_name) # Gemini lane order: Antigravity OAuth (for the models it serves), then AI Studio apikey, then Gemini CLI. AG bypasses the thoughtSignature validator that breaks multi-step Gemini turns AND supports real reasoning, so a connected AG sub is preferred over the AI Studio key, which otherwise silently shadowed it. The map is AG's allowlist; pro variants 404/400 on AG and are deliberately absent, so they fall through to the key. P_ANTIGRAVITY_MAP = { - # gemini-3-pro-preview disabled: AG returns 404 even with active conn. gemini-3.1-pro-preview disabled: AG's `gemini-3.1-pro-high` variant 400s every request with "invalid argument" (the `-high` thinking- budget alias on AG requires a thinking_config the CLI doesn't emit). Falls through to the AI Studio key / gc/ instead. + # gemini-3-pro-preview disabled: AG returns 404 even with active conn. gemini-3.1-pro-preview disabled: AG's `gemini-3.1-pro-high` variant 400s every request with "invalid argument" (the `-high` thinking- budget alias on AG requires a thinking_config the CLI doesn't emit). Falls through to the AI Studio key / gc/ instead. gemini-3-flash-preview key dropped with its registry entry (aged out upstream). "gemini-3.1-flash-lite-preview": "gemini-3-flash", # 3.1-flash-lite has no AG variant, so AG serves it via gemini-3-flash } if entry.get("api") == "gemini-cli": diff --git a/backend/apps/agents/web_mcp_server.py b/backend/apps/agents/web_mcp_server.py index 09b827d9..dada7086 100755 --- a/backend/apps/agents/web_mcp_server.py +++ b/backend/apps/agents/web_mcp_server.py @@ -14,6 +14,8 @@ FETCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/fetch" # Primary-provider hint from agent_manager; backend picks the native search tool (googleSearch/web_search_preview) so searches use the user's existing budget. PRIMARY_HINT = os.environ.get("OPENSWARM_PRIMARY_API", "") or None +# Whether this session actually has browser-delegation tools; gates the backend's "fall back to the browser" nudge. +BROWSER_OK = os.environ.get("OPENSWARM_BROWSER_OK", "0") == "1" TOOLS = [ { @@ -104,7 +106,7 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict: return {"content": [{"type": "text", "text": "Error: query is required"}], "isError": True} num = int(arguments.get("num_results", 5)) num = max(1, min(num, 10)) - body = {"query": query, "num_results": num} + body = {"query": query, "num_results": num, "browser_ok": BROWSER_OK} if PRIMARY_HINT: body["primary"] = PRIMARY_HINT r = p_post(SEARCH_URL, body, timeout=45.0) diff --git a/backend/apps/google_workspace_mcp_shim/cap_tool_result.py b/backend/apps/google_workspace_mcp_shim/cap_tool_result.py new file mode 100644 index 00000000..8ce75f21 --- /dev/null +++ b/backend/apps/google_workspace_mcp_shim/cap_tool_result.py @@ -0,0 +1,80 @@ +"""Cap the cumulative text of a FastMCP call_tool return so one Gmail/Drive dump can't +blow the model's context. Pure + stdlib-only (no upstream imports) so it's importable +and unit-testable outside the shim's ephemeral uv env. + +The bundled Claude CLI hard-rejects any MCP result over ~25K tokens and spills it to a +file, which the model then re-reads back in, refilling the context and tripping the CLI's +autocompact-thrash. Capping under that spill threshold keeps the result inline and the +model out of the re-read loop. Lossless: the full text is saved to a report file the +model can Read selectively, and the truncation note points at it.""" + +import os +import time +from typing import Any + +MAX_RESULT_CHARS = 48_000 +REPORT_DIR = os.environ.get( + "OPENSWARM_TOOL_REPORT_DIR", + os.path.join(os.path.expanduser("~"), ".openswarm", "tool-reports"), +) +P_TRUNCATION_NOTE = ( + "\n\n[Truncated: this tool returned more than {cap} characters, too much to fit " + "in context at once.{saved} Narrow the request (add a search filter, a date range, " + "or a smaller max_results / page size) or fetch the next page.]" +) + + +def p_spill(text: str) -> str: + """Write the full result to disk so the cap is lossless; empty string on failure.""" + try: + os.makedirs(REPORT_DIR, exist_ok=True) + # Reports are point-in-time working files, not archives; prune week-old ones so the folder can't grow forever. + cutoff = time.time() - 7 * 86400 + for old in os.listdir(REPORT_DIR): + p = os.path.join(REPORT_DIR, old) + try: + if os.path.getmtime(p) < cutoff: + os.remove(p) + except OSError: + pass + path = os.path.join(REPORT_DIR, f"gws-result-{os.getpid()}-{int(time.time()*1000)}.txt") + with open(path, "w", encoding="utf-8") as f: + f.write(text) + return path + except Exception: + return "" + + +def cap_tool_result(result: Any, max_chars: int = MAX_RESULT_CHARS) -> Any: + """Cap the text content blocks of a call_tool return in place. Duck-typed and + fail-open: any shape we don't recognize passes through unchanged, so an upstream + contract change degrades to no-cap, never a crash.""" + try: + blocks = result[0] if isinstance(result, tuple) else result + if not isinstance(blocks, list): + return result + texts = [ + b.text for b in blocks + if getattr(b, "type", None) == "text" and getattr(b, "text", None) is not None + ] + if sum(len(t) for t in texts) <= max_chars: + return result + full_path = p_spill("\n".join(texts)) + saved = f" The complete result was saved to {full_path}; Read it with offset/limit if you truly need the rest." if full_path else "" + used = 0 + truncated = False + for b in blocks: + if getattr(b, "type", None) != "text" or getattr(b, "text", None) is None: + continue + if truncated: + b.text = "" + continue + text = b.text + if used + len(text) <= max_chars: + used += len(text) + continue + b.text = text[: max(0, max_chars - used)] + P_TRUNCATION_NOTE.format(cap=max_chars, saved=saved) + truncated = True + return result + except Exception: + return result diff --git a/backend/apps/google_workspace_mcp_shim/run.py b/backend/apps/google_workspace_mcp_shim/run.py index 87bd44fb..ddd978da 100644 --- a/backend/apps/google_workspace_mcp_shim/run.py +++ b/backend/apps/google_workspace_mcp_shim/run.py @@ -17,11 +17,21 @@ CLIENT_ID/SECRET become unused placeholders. """ import functools +import importlib.util import os +import sys import google_workspace_mcp.auth.gauth as gauth from google.oauth2.credentials import Credentials +# Load the cap helper as a loose sibling file (not `from backend...`): the shim runs in uv's ephemeral env where the project isn't a package, and a path-load can't drag in backend's transitive deps. Kept next to run.py so the bundle always ships them together. +def p_load_cap(): + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cap_tool_result.py") + spec = importlib.util.spec_from_file_location("gws_cap_tool_result", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.cap_tool_result + @functools.lru_cache(maxsize=1) def p_patched_get_credentials(): @@ -47,6 +57,20 @@ from google_workspace_mcp import __main__ as p_gw_main # noqa: E402,F401 from google_workspace_mcp.app import mcp # noqa: E402 +# Patch the TOOL MANAGER, not mcp.call_tool: FastMCP.__init__ registers self.call_tool as a bound method with the low-level server, so rebinding the attribute never reaches stdio dispatch; the bound handler resolves self._tool_manager.call_tool dynamically on every request, so this one does. Fail-open: if the helper can't load or upstream reshapes, run uncapped rather than break the whole Google Workspace tool. +try: + p_cap = p_load_cap() + p_tool_manager = mcp._tool_manager # noqa: SLF001 + p_orig_tm_call_tool = p_tool_manager.call_tool + + async def p_capped_tm_call_tool(name, arguments, **kwargs): + return p_cap(await p_orig_tm_call_tool(name, arguments, **kwargs)) + + p_tool_manager.call_tool = p_capped_tm_call_tool +except Exception as p_e: + print(f"[gws-shim] result cap disabled ({p_e}); running uncapped", file=sys.stderr) + + if __name__ == "__main__": # Upstream google_workspace_mcp.__main__.main() wraps a synchronous mcp.run() in asyncio.run() which throws "a coroutine was expected, got None" against current FastMCP. Skip it and invoke FastMCP's stdio loop directly. The `_gw_main` import above is what actually registers every tool/prompt/resource module against the shared `mcp` instance via its top-level imports. mcp.run("stdio") diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index 7b94d2c8..eb1ca81e 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -353,6 +353,118 @@ async def ensure_running(): p_start_lock = asyncio.Lock() async with p_start_lock: await p_ensure_running_impl() + # Arm both healers the moment the router becomes a live dependency; users who never route through it never spawn them. + if is_running(): + start_watchdog() + start_death_watcher() + + +def has_persisted_connections() -> bool: + """True when 9Router's on-disk db shows an active provider connection. Readable while the + router is DOWN, so revival logic can tell a sub-only user (revive!) from a zero-config one + (don't boot a router that has nothing to route). Fail-closed on any read problem.""" + try: + import json as p_json + with open(os.path.join(p_nine_router_data_dir(), "db.json"), encoding="utf-8") as f: + db = p_json.load(f) + return any( + isinstance(c, dict) and c.get("isActive") + for c in (db.get("providerConnections") or []) + ) + except Exception: + return False + + +# 20s pulse while healthy; after 3 straight failed revives (no node, broken install) back way off so a dead-end setup logs once per 5min instead of crash-looping. +WATCHDOG_INTERVAL_SECONDS = 20.0 +WATCHDOG_BACKOFF_SECONDS = 300.0 +watchdog_task: "asyncio.Task | None" = None + + +async def watchdog_loop() -> None: + """Backstop healer for routers we DIDN'T spawn (adopted port-holders have no handle for the + death-watcher). Two-strike confirmation before reviving: the sync is_running probe can + false-negative while a busy router streams, and acting on one bad probe would rotate a LIVE + router's request log and burn a duplicate spawn attempt.""" + failures = 0 + p_loop = asyncio.get_running_loop() + while True: + await asyncio.sleep(WATCHDOG_BACKOFF_SECONDS if failures >= 3 else WATCHDOG_INTERVAL_SECONDS) + try: + # is_running()'s HTTP confirm is SYNC and can stall 2s while the router is busy streaming; a periodic pulse must never block the event loop, so probe from a thread. + if await p_loop.run_in_executor(None, is_running): + failures = 0 + continue + await asyncio.sleep(2) + if await p_loop.run_in_executor(None, is_running): + failures = 0 + continue + logger.warning("9Router watchdog: router is down (confirmed twice); reviving") + await ensure_running() + if is_running(): + failures = 0 + logger.info("9Router watchdog: revived") + else: + failures += 1 + except asyncio.CancelledError: + raise + except Exception: + failures += 1 + logger.exception("9Router watchdog iteration failed") + + +# Instant healer for the process WE spawned: its exit wakes us the moment it happens (no polling, +# no false positives), so total heal time = just the respawn. Crash-loop guard: 3 deaths inside +# 60s defers to the backed-off watchdog instead of hot-spinning a broken install. +p_death_watcher_task: "asyncio.Task | None" = None +recent_death_monos: "list[float]" = [] + + +async def death_watch(proc_handle: "subprocess.Popen[Any]") -> None: + global p_is_running_last_ok + loop = asyncio.get_running_loop() + try: + await loop.run_in_executor(None, proc_handle.wait) + except asyncio.CancelledError: + raise + except Exception: + return + # stop() nulls p_process before this continuation can run (it blocks the loop through wait), so a deliberate quit or a superseded handle never triggers a revive. + if proc_handle is not p_process: + return + now = time.monotonic() + recent_death_monos.append(now) + del recent_death_monos[:-3] + if len(recent_death_monos) == 3 and now - recent_death_monos[0] < 60: + logger.warning("9Router died 3x in 60s; leaving revival to the backed-off watchdog") + return + logger.warning("9Router process died; instant revive") + p_is_running_last_ok = 0.0 + await ensure_running() + + +def start_death_watcher() -> None: + """Idempotent per spawned handle; no-op for adopted routers (no handle to wait on).""" + global p_death_watcher_task + if p_process is None or p_process.poll() is not None: + return + if p_death_watcher_task is not None and not p_death_watcher_task.done(): + return + try: + p_death_watcher_task = asyncio.get_running_loop().create_task(death_watch(p_process)) + except RuntimeError: + logger.warning("9Router death-watcher: no running loop; not armed") + + +def start_watchdog() -> None: + """Idempotent; armed by ensure_running() on success, cancelled by stop().""" + global watchdog_task + if watchdog_task is not None and not watchdog_task.done(): + return + try: + watchdog_task = asyncio.get_running_loop().create_task(watchdog_loop()) + except RuntimeError: + logger.warning("9Router watchdog: no running loop; not armed") async def p_ensure_running_impl(): @@ -481,7 +593,14 @@ async def p_ensure_running_impl(): def stop(): """Stop the 9Router subprocess.""" - global p_process + global p_process, watchdog_task, p_death_watcher_task + # Cancel the healers FIRST or they would revive the router we're about to kill (shutdown = the one sanctioned "down"). + if watchdog_task is not None: + watchdog_task.cancel() + watchdog_task = None + if p_death_watcher_task is not None: + p_death_watcher_task.cancel() + p_death_watcher_task = None if p_process: try: p_process.terminate() diff --git a/backend/apps/nine_router/sync_custom.py b/backend/apps/nine_router/sync_custom.py index 3c0f03f8..c6f2cc64 100644 --- a/backend/apps/nine_router/sync_custom.py +++ b/backend/apps/nine_router/sync_custom.py @@ -253,7 +253,8 @@ async def sync_custom_providers(providers: list) -> None: # Drop managed nodes no longer in settings; DELETE cascades to connections. for prefix, node in managed_by_prefix.items(): - if prefix in seen_prefixes: + # cp-openai wears the same managed suffix but belongs to sync_openai_compat_node; reaping it here killed every gpt-*-api request with "No credentials". + if prefix in seen_prefixes or prefix == NINE_ROUTER_OPENAI_KEYED_PREFIX: continue try: async with nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: diff --git a/backend/apps/web/web.py b/backend/apps/web/web.py index 0f2ef54f..1f4712a7 100644 --- a/backend/apps/web/web.py +++ b/backend/apps/web/web.py @@ -36,6 +36,8 @@ class SearchBody(BaseModel): num_results: int = Field(5, ge=1, le=10, description="Max results to return.") # Hint from the MCP server about which primary provider the session is using. Lets us route to that provider's native search tool (Gemini googleSearch, OpenAI web_search_preview) when available, costs come out of the user's existing primary budget. primary: str | None = Field(None, description="Primary provider hint: 'gemini' | 'openai' | 'anthropic' | None") + # Set by the openswarm-web shim from OPENSWARM_BROWSER_OK; the browser-fallback nudge must never fire in a session without browser-delegation tools. + browser_ok: bool = Field(False, description="Whether this session has browser-delegation tools available.") class FetchBody(BaseModel): @@ -70,6 +72,15 @@ P_GROUNDED_ATTEMPT_TIMEOUT = 48.0 # just above the providers' own 45s httpx tim # Local httpx + trafilatura fetch of a real page; the fast path for /fetch (normal pages return in <2s). Set just above WebFetchTool's own 30s httpx ceiling so a valid-but-slow page still completes locally instead of being clipped down to a grounded summary; only a truly hung server gets cut. P_LOCAL_FETCH_TIMEOUT = 32.0 +# When every search backend fails, point the model at the in-product browser (always-on CreateBrowserAgent tool) instead of telling it to "wait and retry", which it can't do and just relays as a dead end. The real Chromium renders pages and isn't subject to the DDG scrape throttle. +def p_browser_fallback_nudge(query: str) -> str: + return ( + "Don't stop here: fall back to the in-product browser, which renders real pages and " + "isn't subject to this rate limit. Call CreateBrowserAgent with a task like: " + f'"Search the web for: {query}. Report the top results with their titles and URLs, ' + 'plus a direct answer if you find one."' + ) + async def p_gemini_grounded_call(api_key: str, prompt: str, *, use_url_context: bool) -> dict: """Call Gemini with googleSearch (+ optionally urlContext) grounding. @@ -466,11 +477,13 @@ async def search(body: SearchBody) -> dict: else: tail = ( "DuckDuckGo is rate-limiting this network and every configured provider " - "errored (see details below). Wait a moment and retry." + "errored (see details below)." ) + nudge = p_browser_fallback_nudge(body.query) if body.browser_ok else "" + p_results_text = f"No results for: {body.query}\n\n{tail}" + (f"\n\n{nudge}" if nudge else "") return { "query": body.query, - "results": f"No results for: {body.query}\n\n{tail}", + "results": p_results_text, "backend": "none", "cascade_errors": errors, } diff --git a/backend/main.py b/backend/main.py index 91ca4b38..37b09c74 100644 --- a/backend/main.py +++ b/backend/main.py @@ -863,61 +863,6 @@ async def settings_meta(action: str, request: Request): return JSONResponse({"error": f"unknown action: {action}"}, status_code=400) -@app.post("/api/agents/sessions/{session_id}/compact") -async def session_compact(session_id: str): - """Force a compaction pass on a session (Phase 2 /compact slash cmd). - - User explicitly clicked compact, so we accept the prompt-cache loss in exchange - for a real visible trim: needs_fresh_session drops the SDK convo so the next turn - rebuilds from history with compacted_through_msg_id actually applied (auto-compact - only sets the marker; the button is the user opting into the cost). - """ - from backend.apps.agents.agent_manager import agent_manager - from backend.apps.agents.core.ws_manager import ws_manager as p_ws - session = agent_manager.sessions.get(session_id) - if not session: - return JSONResponse({"error": "session not found"}, status_code=404) - did_compact = agent_manager.maybe_compact(session, force=True) - if did_compact: - session.needs_fresh_session = True - await p_ws.send_to_session(session_id, "agent:context_status", { - "session_id": session_id, - "reason": "compacted_manual" if did_compact else "noop", - "compacted_through_msg_id": session.compacted_through_msg_id, - }) - return JSONResponse({"compacted": did_compact, "compacted_through_msg_id": session.compacted_through_msg_id}) - - -@app.post("/api/agents/sessions/{session_id}/clear") -async def session_clear(session_id: str): - """Wipe the session's UI history AND its SDK convo state (/clear slash cmd, Reset history button).""" - from backend.apps.agents.agent_manager import agent_manager - from backend.apps.agents.core.ws_manager import ws_manager as p_ws - from backend.apps.agents.core.models import MessageBranch - session = agent_manager.sessions.get(session_id) - if not session: - return JSONResponse({"error": "session not found"}, status_code=404) - session.sdk_session_id = None - session.active_mcps = [] - session.compacted_through_msg_id = None - session.tokens = {"input": 0, "output": 0} - session.cost_usd = 0.0 - session.needs_fork = False - session.messages = [] - session.pending_approvals = [] - session.branches = {"main": MessageBranch(id="main")} - session.active_branch_id = "main" - session.tool_group_meta = {} - await p_ws.send_to_session(session_id, "agent:status", { - "session_id": session_id, - "status": session.status, - "session": session.model_dump(mode="json"), - }) - await p_ws.send_to_session(session_id, "agent:context_status", { - "session_id": session_id, - "reason": "cleared", - }) - return JSONResponse({"cleared": True}) @app.post("/api/invoke-agent/run") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 78db2ce7..1fc44abc 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -20,6 +20,9 @@ from types import SimpleNamespace import pytest +# Tests mock claude_agent_sdk.query, not ClaudeSDKClient; the now-default-ON persistent client would route mocked turns onto a REAL CLI spawn and wedge the suite. Pin it OFF explicitly; the persistent path has its own tests (test_client_pool.py + live gates). +os.environ["OPENSWARM_PERSISTENT_CLIENT"] = "0" + @pytest.fixture(autouse=True) def _isolate_browser_state(monkeypatch): diff --git a/backend/tests/test_browser_agent_mcp_format.py b/backend/tests/test_browser_agent_mcp_format.py new file mode 100644 index 00000000..1439379c --- /dev/null +++ b/backend/tests/test_browser_agent_mcp_format.py @@ -0,0 +1,79 @@ +"""Browser-agent MCP result payload caps. + +The bug: format_result forwarded the sub-agent's summary and full action log +uncapped. The bundled Claude CLI rejects any MCP tool result past ~25K tokens +(the model never sees the report at all), and repeated near-cap results were +the refill mass behind the CLI's "Autocompact is thrashing" turn-killer seen +on 1.5.4 installs. + +The seal: summary is head+tail capped at MAX_SUMMARY_CHARS and the action log +keeps only the last MAX_ACTION_LOG_ENTRIES entries, so one delegation result +can never approach the CLI rejection threshold on the text side. +""" + +from backend.apps.agents.browser_agent_mcp_server import ( + MAX_ACTION_LOG_ENTRIES, + MAX_SUMMARY_CHARS, + format_result, +) + + +def result_text(result: dict) -> str: + blocks = [b for b in result["content"] if b.get("type") == "text"] + return "\n".join(b["text"] for b in blocks) + + +def test_small_summary_passes_through_unchanged() -> None: + text = result_text(format_result({"summary": "all done"})) + assert "**Summary:** all done" in text + assert "omitted" not in text + + +def test_giant_summary_keeps_head_and_tail_and_spills_full_report(tmp_path, monkeypatch) -> None: + import backend.apps.agents.browser_agent_mcp_server as srv + monkeypatch.setattr(srv, "REPORT_DIR", str(tmp_path)) + summary = "HEADSTART " + ("x" * 60_000) + " TAILEND" + text = result_text(format_result({"summary": summary})) + assert len(text) < MAX_SUMMARY_CHARS + 500 + assert "HEADSTART" in text + assert "omitted" in text + assert "Full unabridged report saved to:" in text + reports = list(tmp_path.iterdir()) + assert len(reports) == 1 + assert summary in reports[0].read_text() + + +def test_action_log_keeps_last_entries_with_original_numbering(tmp_path, monkeypatch) -> None: + import backend.apps.agents.browser_agent_mcp_server as srv + monkeypatch.setattr(srv, "REPORT_DIR", str(tmp_path)) + log = [{"tool": f"Act{i}", "input": {}, "elapsed_ms": i} for i in range(100)] + text = result_text(format_result({"summary": "ok", "action_log": log})) + assert "(... 60 earlier actions omitted ...)" in text + assert "61. Act60(" in text + assert "100. Act99(" in text + # The full log (including the 60 omitted entries) lands in the spilled report. + reports = list(tmp_path.iterdir()) + assert len(reports) == 1 + assert "Act59" in reports[0].read_text() + + +def test_short_action_log_has_no_omission_line() -> None: + log = [{"tool": "Click", "input": {"x": 1}, "elapsed_ms": 5}] + text = result_text(format_result({"summary": "ok", "action_log": log})) + assert "omitted" not in text + assert "1. Click(" in text + + +def test_pathological_result_stays_far_under_cli_rejection_cap(tmp_path, monkeypatch) -> None: + import backend.apps.agents.browser_agent_mcp_server as srv + monkeypatch.setattr(srv, "REPORT_DIR", str(tmp_path)) + log = [{"tool": "T", "input": {"v": "y" * 500}, "elapsed_ms": 1} for i in range(500)] + out = format_result({"summary": "z" * 200_000, "action_log": log}) + total = len(result_text(out)) + assert total < MAX_SUMMARY_CHARS + MAX_ACTION_LOG_ENTRIES * 160 + 800 + + +def test_error_result_untouched() -> None: + out = format_result({"error": "boom"}) + assert out["isError"] is True + assert "boom" in result_text(out) diff --git a/backend/tests/test_client_pool.py b/backend/tests/test_client_pool.py new file mode 100644 index 00000000..2722e5be --- /dev/null +++ b/backend/tests/test_client_pool.py @@ -0,0 +1,209 @@ +"""Invariant + seeded-simulation tests for the persistent-client pool (lever A of the TTFT work). +Proves the red-teamed safety properties hold by construction: fingerprint-gated reuse, respawn on +any boot-input change, pop-first disposal, never-raising teardown, and (seeded sim) that random op +sequences never reuse a stale client, never double-boot needlessly, and always recover a dead one.""" + +import asyncio +import random +from typing import Dict, List + +import pytest + +from backend.apps.agents.core.models import AgentSession +from backend.apps.agents.manager.run.client_pool import ( + ClientHandle, + acquire_client, + boot_fingerprint, + dispose_all_clients, + dispose_client, + dispose_client_soon, +) + + +class FakeClient: + """Stands in for ClaudeSDKClient: counts connects/disconnects, can be killed, can raise on disconnect.""" + + def __init__(self, registry: List["FakeClient"], raise_on_disconnect: bool = False): + self.alive = True + self.disconnected = False + self.raise_on_disconnect = raise_on_disconnect + registry.append(self) + + async def disconnect(self): + self.disconnected = True + self.alive = False + if self.raise_on_disconnect: + raise RuntimeError("teardown boom") + + +def make_session(branch: str = "main", compacted: str | None = None) -> AgentSession: + s = AgentSession(name="t", model="haiku", mode="agent") + s.active_branch_id = branch + s.compacted_through_msg_id = compacted + return s + + +BASE_KWARGS = { + "model": "haiku", + "cwd": "/tmp/ws", + "system_prompt": {"type": "preset", "preset": "claude_code"}, + "allowed_tools": ["Read"], + "disallowed_tools": ["mcp__claude_ai_*"], + "mcp_servers": {"openswarm-mcp-meta": {"command": "python", "args": ["m.py"], "type": "stdio"}}, + "can_use_tool": lambda: None, + "stderr": lambda line: None, + "hooks": {"PreToolUse": []}, +} + + +def test_fingerprint_stable_across_per_turn_keys(): + s = make_session() + a = boot_fingerprint(dict(BASE_KWARGS), s) + changed = dict(BASE_KWARGS) + changed["can_use_tool"] = lambda: 1 + changed["stderr"] = lambda line: 1 + changed["hooks"] = {"PreToolUse": ["different"]} + changed["resume"] = "sdk-session-xyz" + changed["fork_session"] = True + assert boot_fingerprint(changed, s) == a + + +@pytest.mark.parametrize("mutate", [ + lambda k, s: k.__setitem__("mcp_servers", {**k["mcp_servers"], "x": {"command": "node", "type": "stdio"}}), + lambda k, s: k.__setitem__("system_prompt", {"type": "preset", "preset": "claude_code", "append": "sel"}), + lambda k, s: k.__setitem__("model", "gpt-5-mini"), + lambda k, s: k.__setitem__("cwd", "/tmp/other"), + lambda k, s: k.__setitem__("allowed_tools", ["Read", "Bash"]), + lambda k, s: setattr(s, "active_branch_id", "branch2"), + lambda k, s: setattr(s, "compacted_through_msg_id", "msg42"), +]) +def test_fingerprint_changes_on_boot_inputs(mutate): + s = make_session() + kwargs = dict(BASE_KWARGS) + kwargs["mcp_servers"] = dict(BASE_KWARGS["mcp_servers"]) + before = boot_fingerprint(kwargs, s) + mutate(kwargs, s) + assert boot_fingerprint(kwargs, s) != before + + +def test_reuse_respawn_force_and_teardown(): + async def run(): + pool: Dict[str, ClientHandle] = {} + made: List[FakeClient] = [] + + async def connect(): + return FakeClient(made) + + h1 = await acquire_client(pool, "s1", "fpA", connect) + h2 = await acquire_client(pool, "s1", "fpA", connect) + assert h1 is h2 and len(made) == 1 + + h3 = await acquire_client(pool, "s1", "fpB", connect) + assert h3 is not h1 and len(made) == 2 and made[0].disconnected + + h4 = await acquire_client(pool, "s1", "fpB", connect, force_respawn=True) + assert h4 is not h3 and len(made) == 3 and made[1].disconnected + + await dispose_client(pool, "s1") + assert "s1" not in pool and made[2].disconnected + await dispose_client(pool, "s1") # idempotent + + async def connect_bad(): + return FakeClient(made, raise_on_disconnect=True) + + await acquire_client(pool, "s2", "fp", connect_bad) + await dispose_client(pool, "s2") # teardown error swallowed + assert "s2" not in pool + + await acquire_client(pool, "s3", "fp", connect) + dispose_client_soon(pool, "s3") + assert "s3" not in pool # pop is sync-first + await asyncio.sleep(0.01) + assert made[-1].disconnected + + await acquire_client(pool, "s4", "fp", connect) + await acquire_client(pool, "s5", "fp", connect) + await dispose_all_clients(pool) + assert not pool and all(c.disconnected for c in made) + + asyncio.run(run()) + + +def test_idle_eviction(): + async def run(): + import backend.apps.agents.manager.run.client_pool as cp + pool: Dict[str, ClientHandle] = {} + made: List[FakeClient] = [] + + async def connect(): + return FakeClient(made) + + old_ttl = cp.IDLE_EVICT_SECONDS + cp.IDLE_EVICT_SECONDS = 0.05 + try: + h = await acquire_client(pool, "s1", "fp", connect) + await acquire_client(pool, "s2", "fp", connect) + await asyncio.sleep(0.1) + # s1 is mid-turn (lock held): the sweep must skip it and evict only the idle s2. + async with h.lock: + await cp.evict_idle_clients(pool) + assert "s1" in pool and "s2" not in pool and made[1].disconnected + await asyncio.sleep(0.1) + await cp.evict_idle_clients(pool) + assert "s1" not in pool and made[0].disconnected + # a fresh acquire after eviction reconnects transparently + h2 = await acquire_client(pool, "s1", "fp", connect) + assert h2.client.alive + finally: + cp.IDLE_EVICT_SECONDS = old_ttl + + asyncio.run(run()) + + +def test_seeded_simulation_invariants(): + """Random op sequences: reuse only on identical fingerprint, dead clients always replaced, pool + never re-serves a disposed client, and boots never exceed the one-shot baseline (one per turn).""" + async def run(): + rng = random.Random(1337) + pool: Dict[str, ClientHandle] = {} + made: List[FakeClient] = [] + boots = 0 + turns = 0 + fp = "fp0" + force = False + + async def connect(): + nonlocal boots + boots += 1 + return FakeClient(made) + + for _ in range(300): + op = rng.choice(["follow_up", "activate", "branch_or_fresh", "kill", "close"]) + if op == "follow_up": + turns += 1 + h = await acquire_client(pool, "sim", fp, connect, force_respawn=force) + force = False + assert h.fingerprint == fp and not h.client.disconnected + if not h.client.alive: # dead client detected by the turn -> dispose + one respawn + await dispose_client(pool, "sim") + h = await acquire_client(pool, "sim", fp, connect) + assert h.client.alive + async with h.lock: + assert h.lock.locked() # single consumer while a turn drains + h.turns_served += 1 + elif op == "activate": + fp = f"fp{rng.randint(0, 10**9)}" # mcp_servers grew -> fingerprint changed + elif op == "branch_or_fresh": + force = True # needs_fresh/fork read pre-build forces respawn + elif op == "kill" and "sim" in pool: + pool["sim"].client.alive = False + elif op == "close": + await dispose_client(pool, "sim") + + assert boots <= turns, f"persistent booted {boots}x for {turns} turns; one-shot baseline is {turns}" + live = [c for c in made if not c.disconnected] + assert len(live) <= 1, "at most the pooled client may be alive; everything else must be torn down" + if "sim" in pool: + assert not pool["sim"].client.disconnected + + asyncio.run(run()) diff --git a/backend/tests/test_compact_endpoint.py b/backend/tests/test_compact_endpoint.py new file mode 100644 index 00000000..acc1101a --- /dev/null +++ b/backend/tests/test_compact_endpoint.py @@ -0,0 +1,61 @@ +"""The /compact endpoint must actually trigger a rebuild, not just mark. + +The bug: two handlers registered POST .../compact; the live one (agents.py) only set +the compaction marker, so /compact never dropped the SDK session and the trim (and the +distilled summary) was never applied, the button silently did nothing visible. After +consolidating to one handler, /compact sets needs_fresh_session so the next turn rebuilds. +This pins that wiring against the real route. +""" + +from fastapi.testclient import TestClient + +from backend.main import app +from backend.apps.agents.agent_manager import agent_manager +from backend.apps.agents.core.models import AgentSession, Message + + +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_seed(n: int) -> AgentSession: + s = AgentSession(name="t", model="sonnet") + s.context_window = 100 + s.tokens = {"input": 90, "output": 0} # 0.90 -> over threshold + s.messages = [Message(role="user", content=f"m{i}") for i in range(n)] + s.sdk_session_id = "sdk-live-123" + agent_manager.sessions[s.id] = s + return s + + +def test_compact_sets_needs_fresh_session_so_it_rebuilds() -> None: + s = p_seed(10) + try: + r = p_client().post(f"/api/agents/sessions/{s.id}/compact") + assert r.status_code == 200 + assert r.json()["compacted"] is True + assert s.compacted_through_msg_id is not None + # The whole point: the button opts into the rebuild, so the next turn drops the SDK convo and applies the cutoff/distill. + assert s.needs_fresh_session is True + finally: + agent_manager.sessions.pop(s.id, None) + + +def test_compact_noop_when_nothing_to_trim_leaves_state_clean() -> None: + s = p_seed(3) # too few messages to compact + try: + r = p_client().post(f"/api/agents/sessions/{s.id}/compact") + assert r.status_code == 200 + assert r.json()["compacted"] is False + assert s.needs_fresh_session is False + finally: + agent_manager.sessions.pop(s.id, None) + + +def test_compact_unknown_session_404() -> None: + r = p_client().post("/api/agents/sessions/no-such-session/compact") + assert r.status_code == 404 diff --git a/backend/tests/test_context_budget.py b/backend/tests/test_context_budget.py index b6e482d5..8a7a413e 100644 --- a/backend/tests/test_context_budget.py +++ b/backend/tests/test_context_budget.py @@ -72,6 +72,27 @@ def test_force_bypasses_threshold_and_idempotency(): assert cb.maybe_compact(s, force=True) is True # force re-marks even when unchanged +# ---- absolute ceiling: "not just 65%" on big windows ----------------------- + +def test_abs_ceiling_fires_earlier_than_pct_on_a_big_window(): + # 1M window, 200K used = 0.20: below the 0.65 pct but above the 180K ceiling (0.18), so it fires. + s = p_session_with(messages=7, input_tokens=200_000, context_window=1_000_000) + assert cb.maybe_compact(s) is True + + +def test_abs_ceiling_does_not_fire_below_it_on_a_big_window(): + s = p_session_with(messages=7, input_tokens=150_000, context_window=1_000_000) # 0.15 < 0.18 + assert cb.maybe_compact(s) is False + + +def test_small_window_still_governed_by_pct(): + # 200K window: 130K (0.65) is tighter than the 180K ceiling, so pct still rules. + s = p_session_with(messages=7, input_tokens=120_000, context_window=200_000) # 0.60 < 0.65 + assert cb.maybe_compact(s) is False + s2 = p_session_with(messages=7, input_tokens=140_000, context_window=200_000) # 0.70 >= 0.65 + assert cb.maybe_compact(s2) is True + + # ---- emit_context_update ---------------------------------------------------- def test_emit_persists_tokens_and_broadcasts(monkeypatch): diff --git a/backend/tests/test_context_pressure_valve.py b/backend/tests/test_context_pressure_valve.py new file mode 100644 index 00000000..8f80d2b9 --- /dev/null +++ b/backend/tests/test_context_pressure_valve.py @@ -0,0 +1,121 @@ +"""Context-pressure valve invariant. + +The bug class (1.5.4 field reports): an oversized/incompressible context makes +the CLI's autocompact churn until its own thrash detector gives up and the +process dies with a bare exit-1 ProcessError; the user got a cryptic error card +and had to type "continue". + +The seal: run_agent_loop detects that death shape structurally (2+ CLI +compact_boundary events this turn + a ProcessError no other classifier claims) +and transparently re-runs the turn ONCE through the proven fresh-session recap +path. Anything else keeps today's error handling, and the retry can never loop. +""" + +import asyncio + +from backend.apps.agents.agent_manager import agent_manager +import backend.apps.agents.agent_manager as agent_manager_module +from backend.apps.agents.core.error_classify import is_context_pressure_death +from backend.apps.agents.core.models import AgentSession + + +class ProcessError(Exception): + pass + + +def test_predicate_claims_thrash_death() -> None: + e = ProcessError("Command failed with exit code 1 (exit code: 1)\nError output: Check stderr output for details") + assert is_context_pressure_death(e, 1) is True + assert is_context_pressure_death(e, 3) is True + + +def test_predicate_needs_compaction_this_turn() -> None: + e = ProcessError("Command failed with exit code 1") + assert is_context_pressure_death(e, 0) is False + + +def test_predicate_needs_a_process_death() -> None: + assert is_context_pressure_death(ValueError("Command failed with exit code 1"), 3) is False + + +def test_predicate_defers_to_specific_classifiers() -> None: + assert is_context_pressure_death(ProcessError("529 overloaded, try again shortly"), 3) is False + assert is_context_pressure_death(ProcessError("credit balance is too low"), 3) is False + assert is_context_pressure_death(ProcessError("Command failed with exit code 1"), 3, extra_text="401 authentication_error: invalid x-api-key") is False + + +def p_seed_session() -> AgentSession: + session = AgentSession(name="t", model="sonnet", dashboard_id="d") + agent_manager.sessions[session.id] = session + return session + + +def p_install_run_fakes(monkeypatch, run_turn_fake) -> None: + async def fake_build(session, session_id, prompt, prompt_content, builtin_perms, + selected_browser_ids, selected_app_output_ids, selected_setting_ids, + fork_session, router_model_id, api_type): + from backend.apps.settings.settings import load_settings + return object(), {}, prompt_content, [], load_settings() + + monkeypatch.setattr(agent_manager, "build_agent_options", fake_build) + monkeypatch.setattr(agent_manager, "run_turn_with_retry", run_turn_fake) + monkeypatch.setattr(agent_manager_module, "save_session", lambda sid, data: None) + + +def test_valve_retries_once_through_fresh_path(monkeypatch) -> None: + session = p_seed_session() + calls: list = [] + + async def fake_run_turn(sess, session_id, prompt_content, options, options_kwargs, + turn, thinking, stderr, resolved_model, api_type, + global_settings, force_respawn=False): + calls.append({"force_respawn": force_respawn, "needs_fresh": sess.needs_fresh_session}) + if len(calls) == 1: + turn.compact_boundaries = 3 + raise ProcessError("Command failed with exit code 1 (exit code: 1)") + + p_install_run_fakes(monkeypatch, fake_run_turn) + asyncio.run(agent_manager.run_agent_loop(session.id, "hello")) + + assert len(calls) == 2 + assert calls[1]["force_respawn"] is True + assert calls[1]["needs_fresh"] is True + assert session.status == "completed" + assert not [m for m in session.messages if m.role == "system" and str(m.content).startswith("Error:")] + + +def test_no_valve_without_compaction_churn(monkeypatch) -> None: + session = p_seed_session() + calls: list = [] + + async def fake_run_turn(sess, session_id, prompt_content, options, options_kwargs, + turn, thinking, stderr, resolved_model, api_type, + global_settings, force_respawn=False): + calls.append(1) + raise ProcessError("Command failed with exit code 1 (exit code: 1)") + + p_install_run_fakes(monkeypatch, fake_run_turn) + asyncio.run(agent_manager.run_agent_loop(session.id, "hello")) + + assert len(calls) == 1 + assert session.status == "error" + assert [m for m in session.messages if m.role == "system" and str(m.content).startswith("Error:")] + + +def test_valve_never_loops(monkeypatch) -> None: + session = p_seed_session() + calls: list = [] + + async def fake_run_turn(sess, session_id, prompt_content, options, options_kwargs, + turn, thinking, stderr, resolved_model, api_type, + global_settings, force_respawn=False): + calls.append(1) + turn.compact_boundaries = 3 + raise ProcessError("Command failed with exit code 1 (exit code: 1)") + + p_install_run_fakes(monkeypatch, fake_run_turn) + asyncio.run(agent_manager.run_agent_loop(session.id, "hello")) + + assert len(calls) == 2 + assert session.status == "error" + assert [m for m in session.messages if m.role == "system" and str(m.content).startswith("Error:")] diff --git a/backend/tests/test_distill_history.py b/backend/tests/test_distill_history.py new file mode 100644 index 00000000..63fc91bc --- /dev/null +++ b/backend/tests/test_distill_history.py @@ -0,0 +1,100 @@ +"""Distilled-history summary invariant. + +On a rebuild the recap hard-drops everything before the cutoff, losing the thread of a +long chat. distilled_history_summary replaces that void with a cached aux-LLM summary of +the dropped span. These pin: it summarizes the dropped span, caches against the cutoff id, +recomputes when the cutoff advances, and fails open (no provider / kill switch / aux error +-> "", so the caller keeps today's hard-drop). +""" + +import asyncio + +import backend.apps.agents.manager.session.distill_history as dh +from backend.apps.agents.core.models import AgentSession, Message +from backend.apps.settings.settings import load_settings + + +def p_session(n: int) -> AgentSession: + s = AgentSession(name="t", model="sonnet") + s.messages = [Message(role="user", content=f"turn {i}") for i in range(n)] + return s + + +def p_stub_distiller(monkeypatch, calls: list) -> None: + async def fake(session, settings, body): + calls.append(body) + return f"SUMMARY[{len(body)} chars]" + monkeypatch.setattr(dh, "p_call_distiller", fake) + + +def test_no_cutoff_returns_empty(monkeypatch) -> None: + calls: list = [] + p_stub_distiller(monkeypatch, calls) + s = p_session(8) + out = asyncio.run(dh.distilled_history_summary(s, load_settings())) + assert out == "" + assert calls == [] + + +def test_summarizes_dropped_span_and_caches(monkeypatch) -> None: + calls: list = [] + p_stub_distiller(monkeypatch, calls) + s = p_session(8) + s.compacted_through_msg_id = s.messages[3].id # drop turns 0..3 + out = asyncio.run(dh.distilled_history_summary(s, load_settings())) + assert out.startswith("SUMMARY[") + assert s.compacted_summary == out + assert s.compacted_summary_through == s.messages[3].id + assert "turn 0" in calls[0] and "turn 3" in calls[0] + assert "turn 4" not in calls[0] # surviving turns aren't distilled + # Second call at the same cutoff reuses the cache, no new aux call. + again = asyncio.run(dh.distilled_history_summary(s, load_settings())) + assert again == out + assert len(calls) == 1 + + +def test_recomputes_when_cutoff_advances(monkeypatch) -> None: + calls: list = [] + p_stub_distiller(monkeypatch, calls) + s = p_session(10) + s.compacted_through_msg_id = s.messages[3].id + asyncio.run(dh.distilled_history_summary(s, load_settings())) + s.compacted_through_msg_id = s.messages[6].id # cutoff moved forward + asyncio.run(dh.distilled_history_summary(s, load_settings())) + assert len(calls) == 2 + assert "turn 6" in calls[1] + + +def test_fail_open_on_aux_error(monkeypatch) -> None: + async def boom(session, settings, body): + raise RuntimeError("provider down") + monkeypatch.setattr(dh, "p_call_distiller", boom) + s = p_session(8) + s.compacted_through_msg_id = s.messages[3].id + out = asyncio.run(dh.distilled_history_summary(s, load_settings())) + assert out == "" + assert s.compacted_summary is None + + +def test_stale_cache_not_served_when_cutoff_left_the_branch(monkeypatch) -> None: + calls: list = [] + p_stub_distiller(monkeypatch, calls) + s = p_session(8) + s.compacted_through_msg_id = s.messages[3].id + asyncio.run(dh.distilled_history_summary(s, load_settings())) # caches + assert s.compacted_summary is not None + # Simulate a branch edit that dropped the cutoff message from the active branch. + s.messages = [m for m in s.messages if m.id != s.messages[3].id] + out = asyncio.run(dh.distilled_history_summary(s, load_settings())) + assert out == "" # membership check fires before the cache, so the stale summary is not served + + +def test_kill_switch_disables(monkeypatch) -> None: + calls: list = [] + p_stub_distiller(monkeypatch, calls) + monkeypatch.setattr(dh, "DISTILL_ENABLED", False) + s = p_session(8) + s.compacted_through_msg_id = s.messages[3].id + out = asyncio.run(dh.distilled_history_summary(s, load_settings())) + assert out == "" + assert calls == [] diff --git a/backend/tests/test_gws_cap_tool_result.py b/backend/tests/test_gws_cap_tool_result.py new file mode 100644 index 00000000..90d47e36 --- /dev/null +++ b/backend/tests/test_gws_cap_tool_result.py @@ -0,0 +1,77 @@ +"""Google-workspace shim result cap invariant. + +The bug class (1.5.4 field report, Alex's query_gmail_emails thrash): a single +oversized Gmail/Drive dump exceeds the CLI's ~25K-token MCP cap, gets spilled to +a file, the model re-reads it back, and the context refills into the CLI's +autocompact-thrash. The seal: the shim caps its own tool-result text under that +spill threshold, with a clear paginate marker, and fails open on any shape it +doesn't recognize so an upstream contract change never crashes the shim. +""" + +from types import SimpleNamespace + +from backend.apps.google_workspace_mcp_shim.cap_tool_result import ( + MAX_RESULT_CHARS, + cap_tool_result, +) + + +def block(text: str) -> SimpleNamespace: + return SimpleNamespace(type="text", text=text) + + +def test_small_result_untouched() -> None: + b = block("one short email") + cap_tool_result(([b], {"result": "one short email"})) + assert b.text == "one short email" + + +def test_oversized_single_block_capped_with_marker_and_spilled(tmp_path, monkeypatch) -> None: + import backend.apps.google_workspace_mcp_shim.cap_tool_result as capmod + monkeypatch.setattr(capmod, "REPORT_DIR", str(tmp_path)) + b = block("E" * 300_000) + cap_tool_result(([b], {"result": "E" * 300_000})) + assert len(b.text) < MAX_RESULT_CHARS + 600 + assert b.text.startswith("E") + assert "Truncated" in b.text + assert "saved to" in b.text + assert len(b.text) // 4 < 25_000 + reports = list(tmp_path.iterdir()) + assert len(reports) == 1 + assert reports[0].read_text() == "E" * 300_000 + + +def test_budget_spans_multiple_blocks(tmp_path, monkeypatch) -> None: + import backend.apps.google_workspace_mcp_shim.cap_tool_result as capmod + monkeypatch.setattr(capmod, "REPORT_DIR", str(tmp_path)) + a, b, c = block("A" * 40_000), block("B" * 40_000), block("C" * 40_000) + cap_tool_result([a, b, c]) + assert a.text == "A" * 40_000 + assert "Truncated" in b.text and b.text.startswith("B") + assert c.text == "" + + +def test_non_text_blocks_pass_through() -> None: + img = SimpleNamespace(type="image", data="zzz") + txt = block("hello") + cap_tool_result([img, txt]) + assert img.data == "zzz" + assert txt.text == "hello" + + +def test_bare_list_return_shape(tmp_path, monkeypatch) -> None: + import backend.apps.google_workspace_mcp_shim.cap_tool_result as capmod + monkeypatch.setattr(capmod, "REPORT_DIR", str(tmp_path)) + b = block("Z" * 100_000) + out = cap_tool_result([b]) + assert out is not None + assert "Truncated" in b.text + + +def test_fail_open_on_unexpected_shapes() -> None: + assert cap_tool_result(None) is None + assert cap_tool_result({"structured": "only"}) == {"structured": "only"} + assert cap_tool_result("raw string") == "raw string" + junk = [SimpleNamespace(nope=1)] + cap_tool_result(junk) # no .type/.text -> untouched, no raise + assert junk[0].nope == 1 diff --git a/backend/tests/test_invoke_agent.py b/backend/tests/test_invoke_agent.py new file mode 100644 index 00000000..ea4c88a0 --- /dev/null +++ b/backend/tests/test_invoke_agent.py @@ -0,0 +1,31 @@ +"""InvokeAgent (agent-to-agent) binding invariant. + +The bug: invoke_agent carried a spurious @staticmethod on a def whose first +parameter is self, so the instance never bound and EVERY call raised +TypeError("missing 1 required positional argument: 'self'"), which +/api/invoke-agent/run surfaced as a 500 to the calling agent. + +The seal: call it exactly the way the route does (instance, all-keyword args) +and pin that it reaches the method body: an unknown session must raise the +body's ValueError, never a binding TypeError. +""" + +import asyncio + +import pytest + +import backend.apps.agents.manager.AgentLaunch as agent_launch_module +from backend.apps.agents.agent_manager import agent_manager + + +def test_invoke_agent_binds_as_instance_method(monkeypatch) -> None: + monkeypatch.setattr(agent_launch_module, "load_session_data", lambda sid: None) + + async def run() -> None: + with pytest.raises(ValueError, match="not found"): + await agent_manager.invoke_agent( + source_session_id="no-such-session", + message="what did you do?", + ) + + asyncio.run(run()) diff --git a/backend/tests/test_no_duplicate_routes.py b/backend/tests/test_no_duplicate_routes.py new file mode 100644 index 00000000..59de4b6c --- /dev/null +++ b/backend/tests/test_no_duplicate_routes.py @@ -0,0 +1,30 @@ +"""Route-collision guard: no two handlers may register the same (method, path). + +The bug class: two files registered POST /api/agents/sessions/{id}/compact (and +/clear). Starlette silently serves the first-registered one, so the second handler +was dead code AND the live one had the wrong behavior (marker-only /compact never +rebuilt). Nothing surfaced it, because a duplicate route is not an error to Starlette. + +The seal: enumerate the built app's routes and fail on any duplicate (method, path). +A shadowed route can never ship again; the machine catches it, not a human months later. +""" + +from collections import Counter + +from backend.main import app + + +def test_no_duplicate_method_path_routes() -> None: + pairs = [] + for route in app.routes: + path = getattr(route, "path", None) + methods = getattr(route, "methods", None) + if path is None or not methods: + continue + for method in methods: + pairs.append((method, path)) + dupes = [pair for pair, n in Counter(pairs).items() if n > 1] + assert not dupes, ( + "Duplicate route registrations (one silently shadows the other; " + f"consolidate to a single handler): {sorted(dupes)}" + ) diff --git a/backend/tests/test_router_watchdog.py b/backend/tests/test_router_watchdog.py new file mode 100644 index 00000000..68ce21eb --- /dev/null +++ b/backend/tests/test_router_watchdog.py @@ -0,0 +1,208 @@ +"""9Router resilience: the watchdog revives a dead router (backing off on repeated failure and +dying with stop()), and provider DETECTION revives before concluding "no provider" — gated on +evidence so a zero-config user never boots a router with nothing to route.""" + +import asyncio +import json +import os +from unittest.mock import patch + +import pytest + +import backend.apps.nine_router.process as proc +from backend.apps.settings.models import AppSettings + + +def test_watchdog_revives_then_backs_off(): + async def run(): + sleeps: list = [] + ensures: list = [] + + async def fake_sleep(d): + sleeps.append(d) + await real_sleep(0) + + async def fake_ensure(): + ensures.append(1) + + real_sleep = asyncio.sleep + with patch.object(proc, "is_running", return_value=False), \ + patch.object(proc, "ensure_running", fake_ensure), \ + patch.object(proc.asyncio, "sleep", fake_sleep): + task = asyncio.get_running_loop().create_task(proc.watchdog_loop()) + while len(sleeps) < 9: + await real_sleep(0) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + assert len(ensures) >= 2, "a confirmed-down router must be revived" + assert sleeps[0] == proc.WATCHDOG_INTERVAL_SECONDS + assert sleeps[1] == 2, "two-strike: a single failed probe must be re-confirmed before reviving" + assert proc.WATCHDOG_BACKOFF_SECONDS in sleeps, "3 straight failures must back off" + + asyncio.run(run()) + + +def test_watchdog_single_false_negative_never_revives(): + async def run(): + sleeps: list = [] + ensures: list = [] + probes: list = [] + + async def fake_sleep(d): + sleeps.append(d) + await real_sleep(0) + + async def fake_ensure(): + ensures.append(1) + + def flaky_is_running(): + # First probe of each pulse fails (busy-router false negative); the confirm succeeds. + probes.append(1) + return len(probes) % 2 == 0 + + real_sleep = asyncio.sleep + with patch.object(proc, "is_running", flaky_is_running), \ + patch.object(proc, "ensure_running", fake_ensure), \ + patch.object(proc.asyncio, "sleep", fake_sleep): + task = asyncio.get_running_loop().create_task(proc.watchdog_loop()) + while len(probes) < 8: + await real_sleep(0) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + assert not ensures, "a transient probe failure must never trigger a revive" + + asyncio.run(run()) + + +def test_death_watcher_revives_instantly_and_guards_loops(): + async def run(): + ensures: list = [] + + async def fake_ensure(): + ensures.append(1) + + class FakeProc: + def __init__(self): + self.dead = False + def wait(self): + while not self.dead: + pass + def poll(self): + return 1 if self.dead else None + + fp = FakeProc() + proc.recent_death_monos.clear() + with patch.object(proc, "ensure_running", fake_ensure), \ + patch.object(proc, "p_process", fp): + task = asyncio.get_running_loop().create_task(proc.death_watch(fp)) + await asyncio.sleep(0.05) + assert not ensures, "no revive while the process lives" + fp.dead = True + for _ in range(200): + if ensures: + break + await asyncio.sleep(0.01) + assert ensures, "process death must trigger an instant revive" + await task + # Crash-loop guard: a 3rd death inside 60s defers to the watchdog. + ensures.clear() + proc.recent_death_monos[:] = [proc.time.monotonic() - 5, proc.time.monotonic() - 3] + fp2 = FakeProc(); fp2.dead = True + with patch.object(proc, "ensure_running", fake_ensure), \ + patch.object(proc, "p_process", fp2): + await proc.death_watch(fp2) + assert not ensures, "3 deaths in 60s must defer to the backed-off watchdog" + # A superseded/stopped handle never revives. + ensures.clear() + proc.recent_death_monos.clear() + fp3 = FakeProc(); fp3.dead = True + with patch.object(proc, "ensure_running", fake_ensure), \ + patch.object(proc, "p_process", None): + await proc.death_watch(fp3) + assert not ensures, "a deliberately stopped router must stay down" + + asyncio.run(run()) + + +def test_watchdog_healthy_router_never_spawns(): + async def run(): + sleeps: list = [] + ensures: list = [] + + async def fake_sleep(d): + sleeps.append(d) + await real_sleep(0) + + async def fake_ensure(): + ensures.append(1) + + real_sleep = asyncio.sleep + with patch.object(proc, "is_running", return_value=True), \ + patch.object(proc, "ensure_running", fake_ensure), \ + patch.object(proc.asyncio, "sleep", fake_sleep): + task = asyncio.get_running_loop().create_task(proc.watchdog_loop()) + while len(sleeps) < 4: + await real_sleep(0) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + assert not ensures + assert all(d == proc.WATCHDOG_INTERVAL_SECONDS for d in sleeps) + + asyncio.run(run()) + + +def test_stop_cancels_watchdog(): + async def run(): + async def forever(): + while True: + await asyncio.sleep(3600) + + proc.watchdog_task = asyncio.get_running_loop().create_task(forever()) + proc.stop() + assert proc.watchdog_task is None + + asyncio.run(run()) + + +def test_has_persisted_connections(tmp_path, monkeypatch): + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + assert proc.has_persisted_connections() is False # no db at all + (tmp_path / "db.json").write_text(json.dumps({"providerConnections": [{"provider": "claude", "isActive": False}]})) + assert proc.has_persisted_connections() is False # inactive only + (tmp_path / "db.json").write_text(json.dumps({"providerConnections": [{"provider": "claude", "isActive": True}]})) + assert proc.has_persisted_connections() is True + (tmp_path / "db.json").write_text("{corrupt") + assert proc.has_persisted_connections() is False # fail-closed + + +def test_detection_revival_gated_on_evidence(): + from backend.apps.agents.manager import configure_provider_env as cpe + + async def run(): + ensures: list = [] + + async def fake_ensure(): + ensures.append(1) + + import backend.apps.nine_router as nr_pkg + with patch.object(nr_pkg, "is_running", return_value=False), \ + patch.object(nr_pkg, "ensure_running", fake_ensure), \ + patch.object(proc, "has_persisted_connections", return_value=False): + # Zero-config: no keys, no proxy mode, no persisted connections -> no revival attempt. + assert await cpe.router_available(AppSettings()) is False + assert not ensures + # A persisted subscription connection alone IS evidence -> revival attempted. + with patch.object(proc, "has_persisted_connections", return_value=True): + assert await cpe.router_available(AppSettings()) is False # ensure failed (router stays down) + assert ensures, "sub-only users must get a revival attempt" + + asyncio.run(run()) diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index db82e333..adba1790 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -555,7 +555,7 @@ def test_resolve_sdk_gemini_prefers_antigravity_over_api_key(): s = AppSettings() s.google_api_key = "ai-studio-key" with patch.object(registry, "p_antigravity_connected", return_value=True): - # flash-lite IS AG-serveable (via ag/gemini-3-flash) -> AG wins over the key + # flash-lite IS AG-serveable (via ag/gemini-3-flash) -> AG wins over the key (probe retargeted after gemini-3-flash was removed on both branches) assert registry.resolve_model_id_for_sdk("gemini-3.1-flash-lite", s) == "ag/gemini-3-flash" with patch.object(registry, "p_antigravity_connected", return_value=False): # AG not connected -> key diff --git a/backend/tests/test_web_search_cascade.py b/backend/tests/test_web_search_cascade.py index c5099471..d394f31a 100644 --- a/backend/tests/test_web_search_cascade.py +++ b/backend/tests/test_web_search_cascade.py @@ -128,6 +128,38 @@ async def test_everything_fails_is_honest_not_empty(monkeypatch): assert "Settings" in res["results"] or "API key" in res["results"] +@pytest.mark.asyncio +async def test_everything_fails_nudges_browser_not_retry(monkeypatch): + # All-fail must hand the model the browser as an escape hatch, not a dead-end "wait and retry". + p_ddg_throttled(monkeypatch) + monkeypatch.setattr(W, "p_resolve_openai_api_key", lambda: "okey") # configured but errors + + async def p_openai_boom(*a, **k): + raise RuntimeError("openai down") + monkeypatch.setattr(W, "p_openai_websearch", p_openai_boom) + + res = await search(SearchBody(query="sony zv-e10 price", browser_ok=True)) + assert res["backend"] == "none" + assert "CreateBrowserAgent" in res["results"] + assert "retry" not in res["results"].lower() + + +@pytest.mark.asyncio +async def test_nudge_suppressed_when_browser_denied(monkeypatch): + # A session without browser-delegation tools must never be told to call CreateBrowserAgent. + p_ddg_throttled(monkeypatch) + monkeypatch.setattr(W, "p_resolve_openai_api_key", lambda: "okey") + + async def p_openai_boom(*a, **k): + raise RuntimeError("openai down") + monkeypatch.setattr(W, "p_openai_websearch", p_openai_boom) + + res = await search(SearchBody(query="sony zv-e10 price")) + assert res["backend"] == "none" + assert "CreateBrowserAgent" not in res["results"] + assert "retry" not in res["results"].lower() + + # -------------------------------------------------------------------------- /fetch mirrors /search: local httpx + trafilatura is the fast path, grounded fetchers are the fallback for JS/paywalled pages, every attempt is bounded. -------------------------------------------------------------------------- from backend.apps.web.web import fetch, FetchBody diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 10fb08ba..14bc0f71 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -37,6 +37,10 @@ platform. See `RELEASE_RUNBOOK.md` for the how; this is the gate. - [ ] macOS Intel (x64), macOS 12+: same. - [ ] Auto-update: previous stable installed → this release detected, downloads, installs on quit, relaunches on the new version. Verify on both platforms. +- [ ] Widevine DRM: in a Browser card open a Spotify playlist (or any DRM title) + and confirm a track plays PAST the ~10s encrypted boundary and auto-advances, + with no `[drm-diag] License response 500` in the logs. A signed-but-not-VMP + build boots fine and only fails here, so this box catches it. Both platforms. ## Promote - [ ] All boxes above ticked. diff --git a/electron/build/after-pack.js b/electron/build/after-pack.js index 95e4d621..f6b5177e 100644 --- a/electron/build/after-pack.js +++ b/electron/build/after-pack.js @@ -11,8 +11,55 @@ // this rescue. const fs = require('fs'); const path = require('path'); +const { execFileSync } = require('child_process'); -exports.default = async function afterPack(context) { +// Widevine VMP signing of the PACKAGED app. Has to happen here in afterPack, not +// at npm-install time on node_modules: the OS code-sign electron-builder runs +// right after this seals the VMP signature into the bundle, so signing the source +// electron earlier gets stripped/relocated and Spotify's license server then 500s. +// Lenient by default (a dev `npm run dist` without an EVS account still produces an +// app, just with limited DRM); VMP_REQUIRE_SIGN=1 (set by the signed release paths) +// turns a missing/failed signature into a hard build failure so prod never ships +// an unsigned-for-DRM client silently. +function signVmp(context) { + const { appOutDir, electronPlatformName, packager } = context; + const required = process.env.VMP_REQUIRE_SIGN === '1'; + const acct = process.env.EVS_ACCOUNT_NAME; + const pass = process.env.EVS_PASSWD; + + if (!acct || !pass) { + if (required) { + throw new Error('[afterPack] VMP_REQUIRE_SIGN=1 but EVS_ACCOUNT_NAME/EVS_PASSWD are absent — refusing to ship a release whose Widevine DRM (Spotify/Netflix) would be dead'); + } + console.warn('[afterPack] EVS creds absent — skipping VMP signing; DRM playback will be limited (dev build)'); + return; + } + + // mac: sign the .app bundle; win: sign the unpacked dir holding the exe + framework. + const target = electronPlatformName === 'darwin' + ? path.join(appOutDir, `${packager.appInfo.productFilename}.app`) + : appOutDir; + const py = process.platform === 'win32' ? 'python' : 'python3'; + + try { + console.log(`[afterPack] VMP-signing ${target}`); + // Creds go via the environment (EVS reads EVS_ACCOUNT_NAME/EVS_PASSWD), never on + // the argv — a password in a command line is readable by any `ps` on the host. + // --no-ask is a GLOBAL castlabs flag; it must precede the subcommand or vmp.py rejects it (killed the first 1.5.5 release run). + execFileSync(py, ['-m', 'castlabs_evs.vmp', '--no-ask', 'sign-pkg', target], { + stdio: 'inherit', + env: { ...process.env, EVS_ACCOUNT_NAME: acct, EVS_PASSWD: pass }, + }); + console.log('[afterPack] VMP signing successful — full DRM playback enabled'); + } catch (err) { + if (required) { + throw new Error(`[afterPack] VMP signing failed (release would have broken DRM): ${err && err.message}`); + } + console.warn(`[afterPack] VMP signing failed (non-fatal in dev): ${err && err.message}`); + } +} + +function stageRouterNodeModules(context) { const { appOutDir, electronPlatformName, packager } = context; const src = path.join(__dirname, '..', 'build-staging', 'router', 'node_modules'); if (!fs.existsSync(src)) return; // dev/no-router build; nothing to do @@ -34,4 +81,11 @@ exports.default = async function afterPack(context) { throw new Error(`afterPack: 9Router node_modules/next missing in ${routerDir} after copy`); } console.log(`[afterPack] staged 9Router node_modules into ${routerDir}`); +} + +exports.default = async function afterPack(context) { + stageRouterNodeModules(context); + // VMP signing runs last and unconditionally, after every file is staged, so the + // OS code-sign that electron-builder runs next seals the VMP signature too. + signVmp(context); }; diff --git a/electron/package-lock.json b/electron/package-lock.json index bef06b63..4e6a8802 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.5.4", + "version": "1.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.5.4", + "version": "1.5.5", "hasInstallScript": true, "dependencies": { "electron-updater": "6.8.3", diff --git a/electron/package.json b/electron/package.json index 3056c855..72213ded 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.5.4", + "version": "1.5.5", "description": "OpenSwarm — AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", @@ -72,6 +72,13 @@ "filter": [ "**/*" ] + }, + { + "from": "build-staging/python-env/${arch}", + "to": "python-env", + "filter": [ + "**/*" + ] } ] }, @@ -94,6 +101,15 @@ }, "win": { "icon": "build/icon.ico", + "extraResources": [ + { + "from": "python-env", + "to": "python-env", + "filter": [ + "**/*" + ] + } + ], "target": [ { "target": "squirrel", @@ -148,13 +164,6 @@ "**/*" ] }, - { - "from": "python-env", - "to": "python-env", - "filter": [ - "**/*" - ] - }, { "from": "build-staging/router", "to": "router", diff --git a/electron/scripts/sign-vmp.sh b/electron/scripts/sign-vmp.sh index ba5eb833..56badf84 100755 --- a/electron/scripts/sign-vmp.sh +++ b/electron/scripts/sign-vmp.sh @@ -42,6 +42,14 @@ if ! python3 -c "import castlabs_evs" 2>/dev/null; then exit 0 fi +# When creds are in the env (EVS reads EVS_ACCOUNT_NAME/EVS_PASSWD itself), go +# non-interactive so CI / non-TTY runs don't hang on a prompt. Creds stay in the +# environment, never on the argv where any `ps` on the host could read them. +EVS_AUTH=() +if [ -n "${EVS_ACCOUNT_NAME:-}" ] && [ -n "${EVS_PASSWD:-}" ]; then + EVS_AUTH=(--no-ask) +fi + VERIFY_OUTPUT=$(python3 -m castlabs_evs.vmp verify-pkg "$ELECTRON_DIR" 2>&1) if echo "$VERIFY_OUTPUT" | grep -q "Signature is valid" && ! echo "$VERIFY_OUTPUT" | grep -q "development only"; then echo "[vmp] Electron already has a valid production VMP signature" @@ -49,7 +57,7 @@ if echo "$VERIFY_OUTPUT" | grep -q "Signature is valid" && ! echo "$VERIFY_OUTPU fi echo "[vmp] Signing Electron with production VMP certificate..." -if python3 -m castlabs_evs.vmp sign-pkg "$ELECTRON_DIR" 2>&1; then +if python3 -m castlabs_evs.vmp "${EVS_AUTH[@]}" sign-pkg "$ELECTRON_DIR" 2>&1; then echo "[vmp] VMP signing successful — full DRM playback enabled" # Re-fix symlinks in case signing modified the bundle fix_framework_symlinks diff --git a/frontend/public/onboarding-videos/v2/02.mp4 b/frontend/public/onboarding-videos/v2/02.mp4 deleted file mode 100644 index 36a79e31..00000000 Binary files a/frontend/public/onboarding-videos/v2/02.mp4 and /dev/null differ diff --git a/frontend/public/onboarding-videos/v2/07.mp4 b/frontend/public/onboarding-videos/v2/07.mp4 deleted file mode 100644 index fdadb924..00000000 Binary files a/frontend/public/onboarding-videos/v2/07.mp4 and /dev/null differ diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 4e8ca448..f9e6263a 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -27,10 +27,6 @@ import DashboardSelection from './pages/DashboardSelection/DashboardSelection'; import ErrorBoundary from './components/feedback/ErrorBoundary'; import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboardingProgressSlice'; -const Skills = React.lazy(() => import('./pages/Skills/Skills')); -const Tools = React.lazy(() => import('./pages/Tools/Tools')); -const Modes = React.lazy(() => import('./pages/Modes/Modes')); -const Customization = React.lazy(() => import('./pages/Customization/Customization')); const Analytics = React.lazy(() => import('./pages/Analytics/Analytics')); const OnboardingRoot = React.lazy(() => import('./components/Onboarding').then((m) => ({ default: m.OnboardingRoot })), @@ -54,20 +50,11 @@ if (typeof window !== 'undefined') { (window as any).__openswarmPrefetchRoute = (path: string) => { switch (path) { - case '/skills': void import('./pages/Skills/Skills'); return; - case '/actions': - case '/tools': void import('./pages/Tools/Tools'); return; - case '/modes': void import('./pages/Modes/Modes'); return; case '/views': - case '/customization': void import('./pages/Customization/Customization'); return; case '/analytics': void import('./pages/Analytics/Analytics'); return; } }; const prefetchAll = () => { - void import('./pages/Skills/Skills'); - void import('./pages/Tools/Tools'); - void import('./pages/Modes/Modes'); - void import('./pages/Customization/Customization'); void import('./pages/Analytics/Analytics'); }; const ric = (window as any).requestIdleCallback as @@ -528,10 +515,6 @@ const ThemedApp: React.FC = () => { } /> {/* Dashboard renders persistently in AppShell so webviews survive nav. */} - } /> - } /> - } /> - } /> } /> diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index f20d7be9..2b47615c 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -18,12 +18,9 @@ import Alert from '@mui/material/Alert'; import InputBase from '@mui/material/InputBase'; // One outlined icon language for the sidebar: thin monoline glyphs (not the filled Material clip-art) so the rail reads as designed, not assembled. import { LayoutDashboard } from 'lucide-react'; -import PsychologyIcon from '@mui/icons-material/PsychologyOutlined'; -import BuildIcon from '@mui/icons-material/BuildOutlined'; import { LayoutGrid } from 'lucide-react'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { Settings as LucideSettings } from 'lucide-react'; -import { Palette } from 'lucide-react'; import { ArrowLeft, ArrowRight, Plus, Clock } from 'lucide-react'; import { AnimatedPanelLeft } from './animatedIcons'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; @@ -59,13 +56,6 @@ const SIDEBAR_DEFAULT = 260; const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width'; const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed'; -const CUSTOMIZATION_ITEMS = [ - { label: 'Skills', path: '/skills', icon: , onboarding: 'sidebar-skills' }, - { label: 'Actions', path: '/actions', icon: , onboarding: 'sidebar-actions' }, -]; - -const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path)); - const AppShell: React.FC = () => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); @@ -88,8 +78,6 @@ const AppShell: React.FC = () => { const canGoForward = historyIdx < maxHistoryIdx.current; const [dashboardsExpanded, setDashboardsExpanded] = useState(true); const [appsExpanded, setAppsExpanded] = useState(true); - // Collapsed by default: config rows are progressive disclosure, not daily nav. Onboarding reads data-expanded and clicks to open when it needs them. - const [customizationExpanded, setCustomizationExpanded] = useState(false); // Starts collapsed so a fresh boot lands on a clean canvas; the toggle brings it back. const [sidebarCollapsed, setSidebarCollapsed] = useState(true); const [renamingDashboardId, setRenamingDashboardId] = useState(null); @@ -433,7 +421,6 @@ const AppShell: React.FC = () => { const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/'); const isDashboardViewActive = location.pathname.startsWith('/dashboard/'); const isAppsRoute = false; // /apps route removed; app cards live on the dashboard now. - const isCustomizationRoute = location.pathname === '/customization' || CUSTOMIZATION_PATHS.has(location.pathname); const activeDashboardId = location.pathname.startsWith('/dashboard/') ? location.pathname.split('/dashboard/')[1] : null; @@ -819,9 +806,6 @@ const AppShell: React.FC = () => { '& [data-onboarding="sidebar-dashboards"]:hover .MuiListItemIcon-root svg': { transform: 'scale(1.14)', }, - '& [data-onboarding="sidebar-customization"]:hover .MuiListItemIcon-root svg': { - transform: 'rotate(-14deg) scale(1.06)', - }, '& [data-onboarding="sidebar-apps"]:hover .MuiListItemIcon-root svg': { transform: 'rotate(8deg) scale(1.08)', }, @@ -982,106 +966,6 @@ const AppShell: React.FC = () => { {/* Sections separate with air, not lines. */} - - { - if (isCustomizationRoute) { - setCustomizationExpanded((prev) => !prev); - } else { - navigate('/customization'); - setCustomizationExpanded(true); - } - }} - data-onboarding="sidebar-customization" - data-expanded={customizationExpanded ? 'true' : 'false'} - aria-expanded={customizationExpanded} - sx={{ - borderRadius: 1.5, - py: 0.6, - px: 1.25, - bgcolor: isCustomizationRoute ? `${c.accent.primary}12` : 'transparent', - '&:hover': { bgcolor: isCustomizationRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` }, - transition: 'background-color 0.15s', - }} - > - - - - - - - - - - {CUSTOMIZATION_ITEMS.map((item) => { - // Manual click handler instead of NavLink: NavLink's internal navigate bypasses our startTransition wrapper. - const isActive = location.pathname === item.path; - return ( - navigate(item.path)} - onMouseEnter={() => { - // Hover-prefetch lazy chunk so click is ~0ms (see Main.tsx for path -> import map). - const fn = (window as any).__openswarmPrefetchRoute; - if (typeof fn === 'function') fn(item.path); - }} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 0.75, - pl: 1.25, - pr: 1, - py: 0.5, - mx: 0.5, - cursor: 'pointer', - // 25% accent alpha needed for readable contrast on dark-mode bg.secondary; 10% muddied to grey. - borderRadius: `${c.radius.md}px`, - bgcolor: isActive ? `${c.accent.primary}40` : 'transparent', - '&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` }, - transition: 'background-color 0.12s', - }} - > - - {item.label} - - - ); - })} - - - - - {/* Sections separate with air, not lines. */} - - { const lastShowMeClickRef = useRef(0); const unlockedIds = useUnlockedStepIds(); + const liveStepIds = useMemo(() => new Set(STEPS.map((s) => s.id)), []); const currentStep = useMemo(() => { // Spotlight only lands on an unlocked, not-yet-done step, so we never tell the user to "Show me" something they haven't unlocked yet. const explicit = progress.currentStepId @@ -87,8 +88,11 @@ const OnboardingPanel: React.FC = () => { const stageOf = currentStep?.stage ?? 'get_started'; // Count only what's UNLOCKED, not all 8. A brand-new user sees "0/2" (launch + connect), and the denominator grows as the first win unlocks the rest, so we never dump the whole feature surface on someone before their first output. Guard: never let completed exceed the shown total (data-weirdness safety). - const done = progress.completedSteps.length; - const total = Math.max(unlockedIds.size, done); + const done = progress.completedSteps.filter((id) => liveStepIds.has(id)).length; + const total = Math.max( + Array.from(unlockedIds).filter((id) => liveStepIds.has(id)).length, + done, + ); // Timer lives inside CelebrationView so parent re-renders can't cancel it. const justDoneStepId = progress.justCompletedStepId; diff --git a/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx b/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx index ad4022fa..1c52a8d4 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx @@ -32,7 +32,8 @@ const OnboardingRoadmapModal: React.FC = () => { return STEPS.find((s) => !progress.completedSteps.includes(s.id) && unlockedIds.has(s.id)); })(); - const totalDone = progress.completedSteps.length; + // Filter to live steps so a user who finished a since-removed step can't read e.g. 8/6. + const totalDone = progress.completedSteps.filter((id) => findStepById(id)).length; const total = STEPS.length; const jumpToCurrent = () => { diff --git a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx index 3372749d..5c211e3f 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx @@ -13,6 +13,7 @@ import { persistToStorage, markStepCompleted, setPanelMode, + setCurrentStep, markRevealedAfterWin, } from '@/shared/state/onboardingProgressSlice'; import AgenticCursor, { type AgenticCursorHandle } from './ac/AgenticCursor'; @@ -98,6 +99,13 @@ const OnboardingRoot: React.FC = () => { ); }, [progress.initialized, settingsLoaded, dispatch, store]); + useEffect(() => { + if (!progress.initialized || !progress.currentStepId) return; + if (STEPS.some((step) => step.id === progress.currentStepId)) return; + const nextStep = STEPS.find((step) => !(progress.completedSteps ?? []).includes(step.id)); + dispatch(setCurrentStep(nextStep?.id ?? null)); + }, [progress.initialized, progress.currentStepId, progress.completedSteps, dispatch]); + // Bridge Redux signals to bus + auto-mark on skipIf. Coalesces microtask-bursts of dispatches. useEffect(() => { let last = new Set(progress.completedSteps); diff --git a/frontend/src/app/components/Onboarding/ac/acRuntime.ts b/frontend/src/app/components/Onboarding/ac/acRuntime.ts index 7183627f..fc536586 100644 --- a/frontend/src/app/components/Onboarding/ac/acRuntime.ts +++ b/frontend/src/app/components/Onboarding/ac/acRuntime.ts @@ -311,15 +311,11 @@ async function runOp(op: ACOp, ctx: RunContext): Promise { switch (op.kind) { case 'move_to': { - // Order matters: open the whole sidebar first (sub-section markers must exist in DOM), THEN expand Customization, THEN target. + // Open the whole sidebar first so its markers exist in the DOM before we target one. const expandSidebarOps = maybeBuildExpandSidebarOps(op.target); if (expandSidebarOps) { await runOps(expandSidebarOps, ctx); } - const expandOps = maybeBuildExpandCustomizationOps(op.target); - if (expandOps) { - await runOps(expandOps, ctx); - } const el = await waitForSelector(op.target); const scrolled = scrollIntoViewIfNeeded(el); const offX = op.offset?.x ?? 0; @@ -755,25 +751,15 @@ function buildOpenDashboardOps(): ACOp[] { return ops; } -const CUSTOMIZATION_AREA_TARGETS = new Set([ - 'sidebar-actions', - 'sidebar-skills', - 'sidebar-modes', -]); - // `sidebar-toggle` excluded: it lives in the top bar (we click it to expand). Recursing would loop. const SIDEBAR_AREA_TARGETS = new Set([ 'sidebar-settings-button', 'sidebar-dashboards', - 'sidebar-customization', - 'sidebar-skills', - 'sidebar-actions', - 'sidebar-modes', 'sidebar-apps', 'dashboard-row-first', ]); -/** MUST run before maybeBuildExpandCustomizationOps: Customization header is inside the collapsible panel, so expand-check on hidden panel queues an impossible click. */ +/** Expands the collapsed sidebar so its row markers exist before a move_to targets one. */ function maybeBuildExpandSidebarOps(target: string): ACOp[] | null { if (!SIDEBAR_AREA_TARGETS.has(target)) return null; const toggle = document.querySelector( @@ -789,26 +775,6 @@ function maybeBuildExpandSidebarOps(target: string): ACOp[] | null { ]; } -function maybeBuildExpandCustomizationOps(target: string): ACOp[] | null { - if (!CUSTOMIZATION_AREA_TARGETS.has(target)) return null; - const header = document.querySelector( - '[data-onboarding="sidebar-customization"]', - ); - const expanded = - header?.dataset.expanded === 'true' || - header?.getAttribute('aria-expanded') === 'true'; - if (expanded) return null; - return [ - { kind: 'move_to', target: 'sidebar-customization' }, - { kind: 'popup', text: 'Open Customization.' }, - { - kind: 'wait_user', - condition: { kind: 'click_target', target: 'sidebar-customization' }, - timeoutMs: 60000, - }, - ]; -} - interface WaitResult { timedOut: boolean; } diff --git a/frontend/src/app/components/Onboarding/selectors.ts b/frontend/src/app/components/Onboarding/selectors.ts index 1d9c9935..f74a2240 100644 --- a/frontend/src/app/components/Onboarding/selectors.ts +++ b/frontend/src/app/components/Onboarding/selectors.ts @@ -1,9 +1,6 @@ // Central registry of data-onboarding / data-select-type selectors. Step files import S.*; never inline. export const S = { - sidebarSkills: 'sidebar-skills', - sidebarActions: 'sidebar-actions', - sidebarModes: 'sidebar-modes', sidebarApps: 'sidebar-apps', sidebarSettingsButton: 'sidebar-settings-button', @@ -35,22 +32,9 @@ export const S = { chatSendButton: 'chat-send-button', elementSelectionToggle: 'element-selection-toggle', - actionsRedditToggle: 'actions-reddit-toggle', - actionsRedditChevron: 'actions-reddit-chevron', - actionsSubredditsChevron: 'actions-subreddits-chevron', - actionsPermissionToggle: 'actions-permission-toggle', - actionsYoutubeToggle: 'actions-youtube-toggle', - actionsYoutubeChevron: 'actions-youtube-chevron', - canvasFitToView: 'canvas-fit-to-view', canvasTidyLayout: 'canvas-tidy-layout', canvasMinimapToggle: 'canvas-minimap-toggle', - /** Header for sidebar's Customization section; runtime auto-expands before targeting children. */ - sidebarCustomization: 'sidebar-customization', - - skillItemPdf: 'skill-item-pdf', - skillInstallButton: 'skill-install-button', - skillBuilderFab: 'skill-builder-fab', appsNewButton: 'apps-new-button', appCardLatest: 'app-card-latest', diff --git a/frontend/src/app/components/Onboarding/steps/index.ts b/frontend/src/app/components/Onboarding/steps/index.ts index 5b218938..3b3e0bbf 100644 --- a/frontend/src/app/components/Onboarding/steps/index.ts +++ b/frontend/src/app/components/Onboarding/steps/index.ts @@ -1,11 +1,9 @@ import type { OnboardingStep, StepStage } from './types'; import { step01 } from './step01_connectModel'; -import { step02 } from './step02_enableActions'; import { step03 } from './step03_launchAgent'; import { step04 } from './step04_useBrowser'; import { step05 } from './step05_agentUseBrowser'; import { step06 } from './step06_agentControlAgents'; -import { step07 } from './step07_installSkill'; import { step08 } from './step08_makeApp'; import { welcomeOpenStep } from './step00_welcomeNudge'; @@ -13,11 +11,9 @@ import { welcomeOpenStep } from './step00_welcomeNudge'; export const STEPS: OnboardingStep[] = [ step03, step01, - step02, step04, step05, step06, - step07, step08, ]; diff --git a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts index 73082e7d..781398d1 100644 --- a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts +++ b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts @@ -76,18 +76,6 @@ export function hasAnySkillInstalled(s: RootState): boolean { return Object.keys(items).length > 0; } -/** True if PDF skill installed (id/name/command); step 7 uses this so other skills don't auto-skip. */ -export function hasPdfSkillInstalled(s: RootState): boolean { - const items = s.skills?.items as any; - const list: any[] = Array.isArray(items) ? items : Object.values(items ?? {}); - return list.some((sk: any) => { - const id = (sk?.id ?? '').toString().toLowerCase(); - const name = (sk?.name ?? '').toString().toLowerCase(); - const cmd = (sk?.command ?? '').toString().toLowerCase(); - return id.includes('pdf') || name.includes('pdf') || cmd.includes('pdf'); - }); -} - /** True if a browser card exists; step 4 auto-skips the open-a-browser walkthrough. */ export function hasAnyBrowserSpawned(s: RootState): boolean { const cards = (s as any).dashboardLayout?.browserCards ?? {}; diff --git a/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts b/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts deleted file mode 100644 index dc220549..00000000 --- a/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { OnboardingStep } from './types'; -import { S } from '../selectors'; -import { isYoutubeEnabled } from './skipPredicates'; - -export const step02: OnboardingStep = { - id: 'enable_actions', - // Demoted out of the first-run path: a feature to discover after the first win. - stage: 'learn_features', - index: 3, - title: 'Enable agentic actions', - description: 'Allow agents to work across your apps.', - videoSrc: './onboarding-videos/v2/02.mp4', - videoDurationLabel: '0:24', - // Narrowed to YouTube so users with other tools still get walked. - skipIf: isYoutubeEnabled, - // Two beats only (open Actions, flip YouTube on); the chevron-peek and permission fine-tune popups were trimmed to give the step room to breathe. - ops: [ - { kind: 'move_to', target: S.sidebarActions }, - { kind: 'popup', text: 'Open Actions.' }, - { - kind: 'wait_user', - condition: { kind: 'click_target', target: S.sidebarActions }, - }, - // YouTube on the throughline; step 3 needs it. Waits on Redux state, not click, so toggling stays synced. - { kind: 'move_to', target: S.actionsYoutubeToggle }, - { kind: 'popup', text: 'Flip YouTube on.' }, - { - kind: 'wait_user', - condition: { - kind: 'redux_predicate', - selector: isYoutubeEnabled, - truthy: true, - }, - timeoutMs: 90000, - }, - { kind: 'delay', ms: 1200 }, - { kind: 'outro' }, - ], -}; diff --git a/frontend/src/app/components/Onboarding/steps/step07_installSkill.ts b/frontend/src/app/components/Onboarding/steps/step07_installSkill.ts deleted file mode 100644 index a3cd61a8..00000000 --- a/frontend/src/app/components/Onboarding/steps/step07_installSkill.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { OnboardingStep } from './types'; -import { S } from '../selectors'; -import { hasPdfSkillInstalled } from './skipPredicates'; - -export const step07: OnboardingStep = { - id: 'install_skill', - stage: 'learn_features', - index: 7, - title: 'Install a skill', - description: 'Teach agents how to handle specific tasks.', - videoSrc: './onboarding-videos/v2/07.mp4', - videoDurationLabel: '0:24', - // Narrowed to PDF so other-skill users still walk through this demo. - skipIf: hasPdfSkillInstalled, - ops: [ - { kind: 'move_to', target: S.sidebarSkills }, - { kind: 'popup', text: 'Wander into Skills.' }, - { - kind: 'wait_user', - condition: { kind: 'click_target', target: S.sidebarSkills }, - }, - { kind: 'move_to', target: S.skillItemPdf }, - { kind: 'popup', text: 'Pick the PDF one.' }, - { - kind: 'wait_user', - condition: { kind: 'click_target', target: S.skillItemPdf }, - }, - { kind: 'move_to', target: S.skillInstallButton }, - { kind: 'popup', text: 'Install it!' }, - { - kind: 'wait_user', - condition: { kind: 'event_bus', event: 'skill:installed' }, - timeoutMs: 60000, - }, - { - kind: 'popup', - text: 'Boom! Now any chat is way better with PDFs.', - }, - { kind: 'move_to', target: S.skillBuilderFab }, - { kind: 'click', target: S.skillBuilderFab, simulate: true }, - { - kind: 'popup', - text: 'Got an idea? Type it here and the skill builder whips one up.', - }, - { kind: 'delay', ms: 3500 }, - { kind: 'outro' }, - ], -}; diff --git a/frontend/src/app/components/editor/richEditorUtils.ts b/frontend/src/app/components/editor/richEditorUtils.ts index 9de93af0..543fd99b 100644 --- a/frontend/src/app/components/editor/richEditorUtils.ts +++ b/frontend/src/app/components/editor/richEditorUtils.ts @@ -91,6 +91,11 @@ function formatPasteLabel(charCount: number): string { return `Pasted text (${charCount.toLocaleString()} chars)`; } +export function updatePasteCardLabel(card: HTMLElement, charCount: number): void { + const label = card.firstElementChild as HTMLElement | null; + if (label) label.textContent = formatPasteLabel(charCount); +} + export function createPasteCardElement( pasteId: string, charCount: number, @@ -122,7 +127,13 @@ export function createPasteCardElement( const label = document.createElement('span'); label.textContent = formatPasteLabel(charCount); - Object.assign(label.style, { maxWidth: '240px', overflow: 'hidden', textOverflow: 'ellipsis' }); + Object.assign(label.style, { + maxWidth: '240px', + overflow: 'hidden', + textOverflow: 'ellipsis', + opacity: '0.85', + transition: 'transform 0.15s ease, opacity 0.15s ease', + }); label.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); onExpand(pasteId); }); const closeBtn = document.createElement('span'); @@ -145,6 +156,9 @@ export function createPasteCardElement( closeBtn.addEventListener('mouseout', () => { closeBtn.style.opacity = '0.6'; closeBtn.style.color = 'inherit'; }); closeBtn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); onRemove(pasteId); }); + card.addEventListener('mouseenter', () => { label.style.transform = 'translateX(2px)'; label.style.opacity = '1'; }); + card.addEventListener('mouseleave', () => { label.style.transform = 'translateX(0)'; label.style.opacity = '0.85'; }); + card.appendChild(label); card.appendChild(closeBtn); return card; diff --git a/frontend/src/app/components/overlays/GlobalSearchPalette.tsx b/frontend/src/app/components/overlays/GlobalSearchPalette.tsx index 2c86b398..e792e4c9 100644 --- a/frontend/src/app/components/overlays/GlobalSearchPalette.tsx +++ b/frontend/src/app/components/overlays/GlobalSearchPalette.tsx @@ -53,7 +53,6 @@ const ACTIONS: ActionResult[] = [ { kind: 'action', id: 'settings-models', name: 'Connect a model', keywords: 'settings models api key provider subscription' }, { kind: 'action', id: 'go-skills', name: 'Go to Skills', keywords: 'customize skills' }, { kind: 'action', id: 'go-actions', name: 'Go to Actions', keywords: 'customize tools actions mcp' }, - { kind: 'action', id: 'go-modes', name: 'Go to Modes', keywords: 'customize modes' }, { kind: 'action', id: 'all-dashboards', name: 'All dashboards', keywords: 'overview picker browse boards' }, ]; @@ -149,9 +148,9 @@ const GlobalSearchPalette: React.FC = ({ open, onClose }) => { break; case 'settings': dispatch(openSettingsModal()); break; case 'settings-models': dispatch(openSettingsModal('models')); break; - case 'go-skills': navigate('/skills'); break; - case 'go-actions': navigate('/actions'); break; - case 'go-modes': navigate('/modes'); break; + // Skills/Actions live in Settings now (the sidebar Customization section moved there). + case 'go-skills': dispatch(openSettingsModal('skills')); break; + case 'go-actions': dispatch(openSettingsModal('tools')); break; case 'all-dashboards': navigate('/'); break; } }, [dispatch, navigate]); diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index f5378152..097f6dd5 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; @@ -61,6 +61,7 @@ import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar'; import ForceStopAgentBar from './ForceStopAgentBar'; import { RateLimitPill } from './shell/RateLimitPill'; +import { ContextRecoveredPill } from './shell/ContextRecoveredPill'; import ChatInput, { ChatInputHandle } from './ChatInput'; import ContextDrawer from './shell/ContextDrawer'; import { ErrorSlime } from '@/app/components/feedback/ErrorSlime'; @@ -279,7 +280,6 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose ); }); const testState = useAppSelector((s) => (id ? s.agents.sessions[id]?.workflow_test_state : null) ?? null); - const navigate = useNavigate(); const dispatch = useAppDispatch(); const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined)); const modesMap = useAppSelector((state) => state.modes.items); @@ -1721,7 +1721,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose setActivateError(`Activation failed (${r.status})`); } else if (body?.status === 'unknown_server') { // Not yet connected; jump to Actions so the user can finish OAuth. - navigate('/actions'); + dispatch(openSettingsModal('tools')); } else if (id) { dispatch(clearMcpSuggestions({ sessionId: id })); } @@ -1842,6 +1842,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose )} + {isGlowing ? ( = ({ sessionId: sessionIdProp, onClose setActivateError(`Activation failed (${r.status})`); } else if (body?.status === 'unknown_server') { // Not yet connected; jump straight to Actions so the user can finish OAuth. Nothing here can do it on their behalf. - navigate('/actions'); + dispatch(openSettingsModal('tools')); } else if (id) { // Activation succeeded; clear the banner so the user gets visual confirmation the click did something. dispatch(clearMcpSuggestions({ sessionId: id })); diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index a12ff56b..6027f1cb 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -107,8 +107,6 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, }; }, [prefillPrompt]); - useDraftLoad(editorRef, ownerId); - const [hasContent, setHasContent] = useState(() => !!loadDraft(ownerId)); const [attachedSkills, setAttachedSkills] = useState>({}); const [previewPasteId, setPreviewPasteId] = useState(null); @@ -294,6 +292,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, isDragOver, handleInput, handleEditorClick, handlePickerSelect, handleKeyDown, handlePaste, handleDragOver, handleDragLeave, handleDrop, + removePasteCard, savePasteCard, } = useEditorHandlers({ editorRef, generalFileInputRef, ownerId, sessionId, autoRunMode, c, skills, elementSelection, setHasContent, setAttachedSkills, setForcedTools, onModeChange, @@ -301,6 +300,8 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, onPasteExpand: setPreviewPasteId, }); + useDraftLoad(editorRef, ownerId, setPreviewPasteId, removePasteCard, c.font.mono, c.status.error); + const currentMode = modesMap[mode]; const FALLBACK_MODE = { ...FALLBACK_MODE_BASE, color: c.accent.primary }; const modeConf = currentMode @@ -312,7 +313,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, return ( <> - setPreviewPasteId(null)} /> + setPreviewPasteId(null)} onSave={savePasteCard} /> (); @@ -26,7 +26,14 @@ export function deleteDraft(ownerId: string) { _draftStore.delete(ownerId); } -export function useDraftLoad(editorRef: RefObject, ownerId: string) { +export function useDraftLoad( + editorRef: RefObject, + ownerId: string, + onPasteExpand: (id: string) => void, + onPasteRemove: (id: string) => void, + monoFont: string, + errorColor: string, +) { useEffect(() => { const saved = _draftStore.get(ownerId); const editor = editorRef.current; @@ -41,10 +48,17 @@ export function useDraftLoad(editorRef: RefObject, ownerId: stri } if (!editor.textContent?.trim()) { editor.innerHTML = saved; + // innerHTML restore drops JS listeners, so stale paste cards are rebuilt fresh below instead of just kept. const staleCards = editor.querySelectorAll(`[${PASTE_CARD_ATTR}]`); staleCards.forEach((el) => { const pid = el.getAttribute(PASTE_CARD_ATTR); - if (!pid || !getPasteContent(pid)) el.remove(); + const content = pid ? getPasteContent(pid) : undefined; + if (!pid || content === undefined) { + el.remove(); + return; + } + const fresh = createPasteCardElement(pid, content.length, onPasteExpand, onPasteRemove, monoFont, errorColor); + el.replaceWith(fresh); }); const range = document.createRange(); range.selectNodeContents(editor); diff --git a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts index 1df6af7e..a743fece 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts @@ -11,6 +11,7 @@ import { createPasteId, setPasteContent, deletePasteContent, + updatePasteCardLabel, detectEditorTrigger, TriggerState, EMPTY_TRIGGER, @@ -112,6 +113,15 @@ export function useEditorHandlers(p: Params) { editor.focus(); }, [updateHasContent]); + const savePasteCard = useCallback((pasteId: string, text: string) => { + setPasteContent(pasteId, text); + const editor = editorRef.current; + if (!editor) return; + const card = editor.querySelector(`[${PASTE_CARD_ATTR}="${pasteId}"]`) as HTMLElement | null; + if (card) updatePasteCardLabel(card, text.length); + scheduleDraftSave(ownerId, () => readEditorHTML(editor)); + }, [ownerId]); + const removeSkillPill = useCallback((skillId: string) => { const editor = editorRef.current; if (!editor) return; @@ -349,5 +359,6 @@ export function useEditorHandlers(p: Params) { updateHasContent, handleInput, handleEditorClick, handlePickerSelect, handleKeyDown, handlePaste, handleDragOver, handleDragLeave, handleDrop, + removePasteCard, savePasteCard, }; } diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx index bbc491e5..045e199f 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx @@ -3,6 +3,7 @@ import Box from '@mui/material/Box'; import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; import { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { ContextRing } from './ContextRing'; +import { ModelControl } from './ModelControl'; import { ModelPickerMenu } from '../model-picker/ModelPickerMenu'; import { ThinkingLevelControl } from './ThinkingLevelControl'; import { ToolbarActions } from './ToolbarActions'; @@ -93,6 +94,13 @@ export const ChatInputToolbar: React.FC = (p) => { pt: 0, }} > + + void; + allModelFlat: Array; + model: string; +} + +// The model-name trigger that opens ModelPickerMenu. Lived inside ModeControl until modes were hidden from the UI; the picker needs its button regardless of modes. +export const ModelControl: React.FC = ({ c, setModelAnchor, allModelFlat, model }) => { + // On the free trial the model is fixed server-side, so there's nothing to pick: hide the control. The moment a real model is connected we show it again, even if trial state lingers (gate on !hasModelConnected, not just the trial flag). + const hideModelPicker = useAppSelector((s) => hasFreeTrialActive(s) && !hasModelConnected(s)); + if (hideModelPicker) return null; + return ( + setModelAnchor(e.currentTarget)} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.25, + px: 0.75, + py: 0.25, + borderRadius: '6px', + cursor: 'pointer', + userSelect: 'none', + color: c.text.muted, + '&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }, + transition: 'background 0.15s', + }} + > + + {(() => { const m = allModelFlat.find((m) => m.value === model); return m ? m.label : model; })()} + + + + ); +}; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/PastePreviewDialog.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/PastePreviewDialog.tsx index f884ad8d..213bcc31 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/PastePreviewDialog.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/PastePreviewDialog.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import Dialog from '@mui/material/Dialog'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; @@ -8,18 +8,51 @@ import { getPasteContent } from '@/app/components/editor/richEditorUtils'; interface Props { pasteId: string | null; onClose: () => void; + onSave: (pasteId: string, text: string) => void; } -export const PastePreviewDialog: React.FC = ({ pasteId, onClose }) => { +// Uncontrolled on purpose: huge pastes are this dialog's whole job, and a controlled MUI autosize field re-reconciles the full string + re-measures a shadow textarea per keystroke (typing molasses at ~300KB). +const PasteEditor: React.FC<{ pasteId: string; initial: string; onSave: Props['onSave']; onCount: (n: number) => void }> = ({ pasteId, initial, onSave, onCount }) => { + const c = useClaudeTokens(); + return ( +