Merge remote-tracking branch 'origin/eric/dev' into eric/browser

# Conflicts:
#	backend/apps/agents/providers/registry.py
#	backend/tests/test_v2_invariants.py
This commit is contained in:
ciregenz
2026-07-06 18:37:26 -07:00
86 changed files with 2296 additions and 1294 deletions
+12
View File
@@ -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.
+12
View File
@@ -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.
+55 -5
View File
@@ -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.
+12 -4
View File
@@ -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
@@ -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")
@@ -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
+5
View File
@@ -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.
+1 -2
View File
@@ -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,
@@ -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.")
@@ -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:
+35 -12
View File
@@ -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
+45 -10
View File
@@ -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
@@ -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)
@@ -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",
}
@@ -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:
@@ -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 <transcript> 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<transcript>\n{body}\n</transcript>"
)
@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()
@@ -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(<truncated input>)."""
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): <truncated text>."""
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}"
@@ -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
-25
View File
@@ -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=<name> mono=<t>`.
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
+2 -2
View File
@@ -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":
+3 -1
View File
@@ -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)
@@ -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
@@ -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")
+120 -1
View File
@@ -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()
+2 -1
View File
@@ -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:
+15 -2
View File
@@ -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,
}
-55
View File
@@ -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")
+3
View File
@@ -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):
@@ -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)
+209
View File
@@ -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())
+61
View File
@@ -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
+21
View File
@@ -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):
@@ -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:")]
+100
View File
@@ -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 == []
+77
View File
@@ -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
+31
View File
@@ -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())
+30
View File
@@ -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)}"
)
+208
View File
@@ -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())
+1 -1
View File
@@ -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
+32
View File
@@ -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
+4
View File
@@ -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.
+55 -1
View File
@@ -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);
};
+2 -2
View File
@@ -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",
+17 -8
View File
@@ -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",
+9 -1
View File
@@ -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
Binary file not shown.
Binary file not shown.
-17
View File
@@ -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 = () => {
<Route path="/" element={<DashboardSelection />} />
{/* Dashboard renders persistently in AppShell so webviews survive nav. */}
<Route path="/dashboard/:id" element={null} />
<Route path="/customization" element={<Customization />} />
<Route path="/skills" element={<Skills />} />
<Route path="/actions" element={<Tools />} />
<Route path="/modes" element={<Modes />} />
<Route path="/analytics" element={<Analytics />} />
</Route>
</Routes>
@@ -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: <PsychologyIcon />, onboarding: 'sidebar-skills' },
{ label: 'Actions', path: '/actions', icon: <BuildIcon />, 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<string | null>(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. */}
<Box sx={{ my: 0.75 }} />
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={() => {
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',
}}
>
<ListItemIcon sx={{ color: isCustomizationRoute ? c.accent.primary : c.text.tertiary, minWidth: 28 }}>
<Palette size={18} />
</ListItemIcon>
<ListItemText
primary="Customization"
sx={{
'& .MuiListItemText-primary': {
color: isCustomizationRoute ? c.text.primary : c.text.muted,
fontSize: '0.9rem',
fontWeight: isCustomizationRoute ? 600 : 400,
},
}}
/>
<ExpandMoreIcon
sx={{
color: c.text.ghost,
fontSize: 16,
transition: 'transform 0.2s',
transform: customizationExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
</ListItemButton>
<Collapse in={customizationExpanded} timeout={200}>
<Box sx={{ ml: 2, mt: 0.25, mb: 0.5 }}>
{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 (
<Box
key={item.path}
data-onboarding={item.onboarding}
onClick={() => 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',
}}
>
<Typography
sx={{
color: isActive ? c.text.secondary : c.text.ghost,
fontSize: '0.86rem',
fontWeight: isActive ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
>
{item.label}
</Typography>
</Box>
);
})}
</Box>
</Collapse>
</Box>
{/* Sections separate with air, not lines. */}
<Box sx={{ my: 0.75 }} />
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={handleAppsClick}
@@ -64,6 +64,7 @@ const OnboardingPanel: React.FC = () => {
const lastShowMeClickRef = useRef<number>(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;
@@ -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 = () => {
@@ -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);
@@ -311,15 +311,11 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
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<string>([
'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<string>([
'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<HTMLElement>(
@@ -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<HTMLElement>(
'[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;
}
@@ -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',
@@ -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,
];
@@ -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 ?? {};
@@ -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' },
],
};
@@ -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' },
],
};
@@ -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;
@@ -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<Props> = ({ 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]);
@@ -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<AgentChatProps> = ({ 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<AgentChatProps> = ({ 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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
)}
<RateLimitPill sessionId={session.id} />
<ContextRecoveredPill sessionId={session.id} />
{isGlowing ? (
<Box
@@ -2198,7 +2199,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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 }));
@@ -107,8 +107,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
};
}, [prefillPrompt]);
useDraftLoad(editorRef, ownerId);
const [hasContent, setHasContent] = useState(() => !!loadDraft(ownerId));
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
const [previewPasteId, setPreviewPasteId] = useState<string | null>(null);
@@ -294,6 +292,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ onSend, disabled, mode,
return (
<>
<PastePreviewDialog pasteId={previewPasteId} onClose={() => setPreviewPasteId(null)} />
<PastePreviewDialog pasteId={previewPasteId} onClose={() => setPreviewPasteId(null)} onSave={savePasteCard} />
<ChatInputView
c={c}
containerRef={containerRef}
@@ -1,5 +1,5 @@
import { useEffect, RefObject } from 'react';
import { PASTE_CARD_ATTR, getPasteContent } from '@/app/components/editor/richEditorUtils';
import { PASTE_CARD_ATTR, getPasteContent, createPasteCardElement } from '@/app/components/editor/richEditorUtils';
// Module-level draft store keyed by sessionId; survives unmount/remount and preserves skill pills via innerHTML.
const _draftStore = new Map<string, string>();
@@ -26,7 +26,14 @@ export function deleteDraft(ownerId: string) {
_draftStore.delete(ownerId);
}
export function useDraftLoad(editorRef: RefObject<HTMLDivElement>, ownerId: string) {
export function useDraftLoad(
editorRef: RefObject<HTMLDivElement>,
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<HTMLDivElement>, 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);
@@ -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,
};
}
@@ -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<Props> = (p) => {
pt: 0,
}}
>
<ModelControl
c={c}
setModelAnchor={setModelAnchor}
allModelFlat={allModelFlat}
model={model}
/>
<ModelPickerMenu
c={c}
menuPaperProps={menuPaperProps}
@@ -0,0 +1,44 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
import { useAppSelector } from '@/shared/hooks';
import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
interface Props {
c: ClaudeTokens;
setModelAnchor: (el: HTMLElement | null) => void;
allModelFlat: Array<any>;
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<Props> = ({ 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 (
<Box
onClick={(e) => 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',
}}
>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 500, color: 'inherit', lineHeight: 1 }}>
{(() => { const m = allModelFlat.find((m) => m.value === model); return m ? m.label : model; })()}
</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: 'inherit', opacity: 0.7 }} />
</Box>
);
};
@@ -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<Props> = ({ 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 (
<textarea
defaultValue={initial}
autoFocus
onChange={(e) => { onSave(pasteId, e.target.value); onCount(e.target.value.length); }}
style={{
width: '100%',
minHeight: '9rem',
maxHeight: '55vh',
height: '40vh',
resize: 'vertical',
overflowY: 'auto',
fontFamily: c.font.mono,
fontSize: '0.78rem',
lineHeight: 1.5,
color: c.text.primary,
background: 'transparent',
border: `1px solid ${c.border.subtle}`,
borderRadius: 8,
padding: '10px 12px',
outline: 'none',
boxSizing: 'border-box',
}}
/>
);
};
export const PastePreviewDialog: React.FC<Props> = ({ pasteId, onClose, onSave }) => {
const c = useClaudeTokens();
const open = !!pasteId;
const content = pasteId ? (getPasteContent(pasteId) ?? '') : '';
const chars = content.length;
const original = pasteId ? getPasteContent(pasteId) : undefined;
// Keyed by paste id so an id swap can never show the previous paste's count.
const [charCount, setCharCount] = useState<{ id: string; n: number } | null>(null);
const liveCount = charCount && charCount.id === pasteId ? charCount.n : null;
return (
<Dialog
open={open}
onClose={onClose}
onClose={() => { setCharCount(null); onClose(); }}
PaperProps={{ sx: { bgcolor: c.bg.elevated, borderRadius: 3, p: 0, minWidth: 520, maxWidth: 760, width: '70vw' } }}
>
<Box sx={{ p: 2, borderBottom: `1px solid ${c.border.subtle}` }}>
@@ -27,26 +60,17 @@ export const PastePreviewDialog: React.FC<Props> = ({ pasteId, onClose }) => {
Pasted text
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.75rem', mt: 0.25 }}>
{chars.toLocaleString()} characters
{(liveCount ?? original?.length ?? 0).toLocaleString()} characters
</Typography>
</Box>
<Box
sx={{
p: 2,
maxHeight: '60vh',
overflowY: 'auto',
fontFamily: c.font.mono,
fontSize: '0.78rem',
color: c.text.primary,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
bgcolor: c.bg.surface,
}}
>
{content || (
<Box sx={{ p: 2, bgcolor: c.bg.surface }}>
{original === undefined || pasteId === null ? (
<Typography sx={{ color: c.text.tertiary, fontSize: '0.85rem', fontStyle: 'italic' }}>
This pasted text is no longer available. Re-paste to restore it.
</Typography>
) : (
// key remounts the editor per paste id, so a reopened dialog always seeds from the current stored content (no stale-draft sync effect to get wrong).
<PasteEditor key={pasteId} pasteId={pasteId} initial={original} onSave={onSave} onCount={(n) => setCharCount({ id: pasteId, n })} />
)}
</Box>
</Dialog>
@@ -0,0 +1,45 @@
import React, { useEffect } from 'react';
import Box from '@mui/material/Box';
import Fade from '@mui/material/Fade';
import Typography from '@mui/material/Typography';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { clearContextRecovered } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// Muted, transient pill shown when the backend self-healed a context-overflow crash mid-turn (rebuilt the chat from its local copy and retried). Visible so the recovery isn't silent, calm so it doesn't read as an error; the "why" lives in the hover.
export const ContextRecoveredPill: React.FC<{ sessionId: string }> = ({ sessionId }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const cr = useAppSelector((s) => s.agents.sessions[sessionId]?.context_recovered);
useEffect(() => {
if (!cr) return;
const t = setTimeout(() => dispatch(clearContextRecovered({ sessionId })), 12000);
return () => clearTimeout(t);
}, [cr, sessionId, dispatch]);
return (
<Fade in={!!cr} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
title="This chat's memory overflowed mid-reply. OpenSwarm recovered it and retried automatically; nothing was lost."
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.6,
alignSelf: 'flex-start',
mx: 2,
mb: 1,
px: 1.25,
py: 0.5,
borderRadius: 999,
bgcolor: c.bg.secondary,
color: c.text.tertiary,
}}
>
<RestartAltIcon sx={{ fontSize: 14 }} />
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500 }}>Recovered and retried</Typography>
</Box>
</Fade>
);
};
@@ -1,105 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import PsychologyIcon from '@mui/icons-material/Psychology';
import BuildIcon from '@mui/icons-material/Build';
import TuneIcon from '@mui/icons-material/Tune';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const PANELS = [
{
label: 'Skills',
path: '/skills',
icon: <PsychologyIcon />,
description:
'Install or author reusable skill packages that teach your agents new capabilities and workflows.',
},
{
label: 'Actions',
path: '/actions',
icon: <BuildIcon />,
description:
'Define and manage the actions your agents can take.',
},
{
label: 'Modes',
path: '/modes',
icon: <TuneIcon />,
description:
'Configure agent interaction modes with custom system prompts, allowed actions, and auto-switching rules.',
},
];
const Customization: React.FC = () => {
const c = useClaudeTokens();
const navigate = useNavigate();
return (
<Box sx={{ height: '100%', overflow: 'auto', p: 4 }}>
<Box sx={{ maxWidth: 900, mx: 'auto' }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: c.text.primary }}>
Customization
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem', mt: 0.5 }}>
Tailor how your agents behave, what they can do, and how they interact.
</Typography>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 2.5,
}}
>
{PANELS.map((panel) => (
<Card
key={panel.path}
sx={{
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: 2.5,
boxShadow: c.shadow.sm,
willChange: 'transform',
'&:hover': { borderColor: c.accent.primary },
transition: 'border-color 0.2s',
}}
>
<CardActionArea
onClick={() => navigate(panel.path)}
sx={{ p: 3, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1.5 }}
>
<Box
sx={{
width: 44,
height: 44,
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${c.accent.primary}12`,
color: c.accent.primary,
}}
>
{React.cloneElement(panel.icon, { sx: { fontSize: 24 } })}
</Box>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1.05rem' }}>
{panel.label}
</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.55 }}>
{panel.description}
</Typography>
</CardActionArea>
</Card>
))}
</Box>
</Box>
</Box>
);
};
export default Customization;
-604
View File
@@ -1,604 +0,0 @@
import React, { useEffect, useState, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardActions from '@mui/material/CardActions';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Tooltip from '@mui/material/Tooltip';
import { Skeleton } from '@/app/components/feedback/Loading';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Checkbox from '@mui/material/Checkbox';
import ListItemText from '@mui/material/ListItemText';
import OutlinedInput from '@mui/material/OutlinedInput';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import TuneIcon from '@mui/icons-material/Tune';
import LockIcon from '@mui/icons-material/Lock';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RestoreIcon from '@mui/icons-material/Restore';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
fetchModes,
createMode,
updateMode,
deleteMode,
resetMode,
Mode,
} from '@/shared/state/modesSlice';
import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice';
import { fetchSkills } from '@/shared/state/skillsSlice';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import ExtensionIcon from '@mui/icons-material/Extension';
import ListSubheader from '@mui/material/ListSubheader';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import DirectoryBrowser from '@/app/components/editor/DirectoryBrowser';
import RichPromptEditor from '@/app/components/editor/RichPromptEditor';
const ICON_MAP: Record<string, React.ReactNode> = {
smart_toy: <SmartToyOutlinedIcon sx={{ fontSize: 20 }} />,
question_answer: <QuestionAnswerOutlinedIcon sx={{ fontSize: 20 }} />,
map: <MapOutlinedIcon sx={{ fontSize: 20 }} />,
category: <CategoryOutlinedIcon sx={{ fontSize: 20 }} />,
tune: <TuneIcon sx={{ fontSize: 20 }} />,
};
const ICON_OPTIONS = [
{ value: 'smart_toy', label: 'Robot' },
{ value: 'question_answer', label: 'Q&A' },
{ value: 'map', label: 'Map' },
{ value: 'category', label: 'Category' },
{ value: 'tune', label: 'Tune' },
];
const COLOR_OPTIONS = [
{ value: '#ae5630', label: 'Terra Cotta' },
{ value: '#4ade80', label: 'Green' },
{ value: '#fbbf24', label: 'Amber' },
{ value: '#f87171', label: 'Red' },
{ value: '#38bdf8', label: 'Sky' },
{ value: '#c084fc', label: 'Purple' },
{ value: '#fb923c', label: 'Orange' },
{ value: '#2dd4bf', label: 'Teal' },
];
interface ModeForm {
name: string;
description: string;
system_prompt: string;
tools: string[];
toolsEnabled: boolean;
default_next_mode: string;
icon: string;
color: string;
default_folder: string;
}
const emptyForm: ModeForm = {
name: '',
description: '',
system_prompt: '',
tools: [],
toolsEnabled: false,
default_next_mode: '',
icon: 'smart_toy',
color: '#ae5630',
default_folder: '',
};
const ALL_BUILTIN_TOOL_NAMES = ['Read', 'Edit', 'Write', 'Bash', 'Glob', 'Grep', 'AskUserQuestion'];
const Modes: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { items, builtinDefaults, loading } = useAppSelector((s) => s.modes);
const toolItems = useAppSelector((s) => s.tools.items);
const modes = useMemo(() => Object.values(items), [items]);
const mcpToolNames = useMemo(() => {
return Object.values(toolItems)
.filter((t) => t.mcp_config && Object.keys(t.mcp_config).length > 0 && t.auth_status !== 'none')
.map((t) => `mcp:${t.name}`);
}, [toolItems]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<ModeForm>(emptyForm);
const [browseOpen, setBrowseOpen] = useState(false);
useEffect(() => {
dispatch(fetchModes());
dispatch(fetchBuiltinTools());
dispatch(fetchTools());
dispatch(fetchSkills());
}, [dispatch]);
const openCreate = () => {
setEditingId(null);
setForm(emptyForm);
setDialogOpen(true);
};
const openEdit = (mode: Mode) => {
setEditingId(mode.id);
setForm({
name: mode.name,
description: mode.description,
system_prompt: mode.system_prompt ?? '',
tools: mode.tools ?? [],
toolsEnabled: mode.tools !== null,
default_next_mode: mode.default_next_mode ?? '',
icon: mode.icon,
color: mode.color,
default_folder: mode.default_folder ?? '',
});
setDialogOpen(true);
};
const handleSave = async () => {
const payload = {
name: form.name,
description: form.description,
system_prompt: form.system_prompt || null,
tools: form.toolsEnabled ? form.tools : null,
default_next_mode: form.default_next_mode || null,
icon: form.icon,
color: form.color,
default_folder: form.default_folder || null,
};
if (editingId) {
await dispatch(updateMode({ id: editingId, ...payload }));
} else {
await dispatch(createMode(payload as any));
}
setDialogOpen(false);
};
const handleDelete = async (id: string) => {
await dispatch(deleteMode(id));
};
const editingIsBuiltin = editingId ? items[editingId]?.is_builtin ?? false : false;
const hasDiverged = useMemo(() => {
if (!editingId || !editingIsBuiltin) return false;
const defaults = builtinDefaults[editingId];
if (!defaults) return false;
const current = items[editingId];
if (!current) return false;
return (
current.name !== defaults.name ||
current.description !== defaults.description ||
(current.system_prompt ?? '') !== (defaults.system_prompt ?? '') ||
JSON.stringify(current.tools) !== JSON.stringify(defaults.tools) ||
(current.default_next_mode ?? '') !== (defaults.default_next_mode ?? '') ||
current.icon !== defaults.icon ||
current.color !== defaults.color ||
(current.default_folder ?? '') !== (defaults.default_folder ?? '')
);
}, [editingId, editingIsBuiltin, items, builtinDefaults]);
const handleReset = async () => {
if (!editingId) return;
const action = await dispatch(resetMode(editingId));
if (resetMode.fulfilled.match(action)) {
const m = action.payload;
setForm({
name: m.name,
description: m.description,
system_prompt: m.system_prompt ?? '',
tools: m.tools ?? [],
toolsEnabled: m.tools !== null,
default_next_mode: m.default_next_mode ?? '',
icon: m.icon,
color: m.color,
default_folder: m.default_folder ?? '',
});
}
};
const otherModes = modes.filter((m) => m.id !== editingId);
return (
<Box sx={{ p: 3, height: '100%', overflow: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 700, mb: 0.5 }}>
Modes
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem' }}>
Configure agent interaction modes with custom system prompts, actions, and auto-switching.
</Typography>
</Box>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={openCreate}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
borderRadius: 2,
}}
>
New Mode
</Button>
</Box>
{loading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2, mt: 1 }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} variant="card" height={120} />
))}
</Box>
) : modes.length === 0 ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
py: 8,
color: c.text.ghost,
gap: 2,
}}
>
<TuneIcon sx={{ fontSize: 48, opacity: 0.4 }} />
<Typography>No modes defined yet. Create one to get started.</Typography>
</Box>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 2,
}}
>
{modes.map((mode) => (
<Card
key={mode.id}
sx={{
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: 2,
boxShadow: c.shadow.sm,
// Own compositor layer per card so hover-cross re-paints one card, not the whole grid.
willChange: 'transform',
// Hover animates only border-color; box-shadow animation caused per-frame CPU paint.
'&:hover': { borderColor: mode.color },
transition: 'border-color 0.2s',
}}
>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<Box sx={{ color: mode.color, display: 'flex', alignItems: 'center' }}>
{ICON_MAP[mode.icon] || ICON_MAP.smart_toy}
</Box>
<Typography variant="h6" sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem', flex: 1 }}>
{mode.name}
</Typography>
{mode.is_builtin && (
<Chip
icon={<LockIcon sx={{ fontSize: 12 }} />}
label="Built-in"
size="small"
sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 22 }}
/>
)}
</Box>
{mode.description && (
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', mb: 1.5 }}>
{mode.description}
</Typography>
)}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{mode.tools !== null ? (
<Chip
label={`${mode.tools.length} action${mode.tools.length !== 1 ? 's' : ''}`}
size="small"
sx={{ bgcolor: `${mode.color}18`, color: mode.color, fontSize: '0.75rem', height: 24 }}
/>
) : (
<Chip
label="All actions"
size="small"
sx={{ bgcolor: `${mode.color}18`, color: mode.color, fontSize: '0.75rem', height: 24 }}
/>
)}
{mode.system_prompt && (
<Chip
label="System prompt"
size="small"
sx={{ bgcolor: 'rgba(174,86,48,0.15)', color: c.accent.hover, fontSize: '0.75rem', height: 24 }}
/>
)}
{mode.default_next_mode && (
<Chip
icon={<ArrowForwardIcon sx={{ fontSize: 12 }} />}
label={items[mode.default_next_mode]?.name || mode.default_next_mode}
size="small"
sx={{ bgcolor: 'rgba(251,191,36,0.15)', color: '#fbbf24', fontSize: '0.75rem', height: 24 }}
/>
)}
{mode.default_folder && (
<Chip
icon={<FolderOpenIcon sx={{ fontSize: 12 }} />}
label={mode.default_folder.split('/').pop() || mode.default_folder}
size="small"
sx={{ bgcolor: 'rgba(56,189,248,0.15)', color: '#38bdf8', fontSize: '0.75rem', height: 24 }}
/>
)}
</Box>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 1.5 }}>
<Tooltip title="Edit">
<IconButton size="small" onClick={() => openEdit(mode)} sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
{!mode.is_builtin && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => handleDelete(mode.id)} sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error } }}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</CardActions>
</Card>
))}
</Box>
)}
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
maxWidth="md"
fullWidth
PaperProps={{
sx: { bgcolor: c.bg.surface, backgroundImage: 'none', borderRadius: 4, border: `1px solid ${c.border.subtle}` },
}}
>
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600 }}>
{editingId ? 'Edit Mode' : 'New Mode'}
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '8px !important' }}>
<TextField
label="Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
fullWidth
size="small"
sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.page } }}
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
fullWidth
size="small"
sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.page } }}
/>
<RichPromptEditor
label="System Prompt"
value={form.system_prompt}
onChange={(v) => setForm({ ...form, system_prompt: v })}
placeholder="Instructions for the agent when using this mode... (@ for context, / for commands)"
minRows={3}
maxRows={8}
/>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Checkbox
checked={form.toolsEnabled}
onChange={(e) => setForm({ ...form, toolsEnabled: e.target.checked, tools: e.target.checked ? form.tools : [] })}
size="small"
sx={{ color: c.text.tertiary, '&.Mui-checked': { color: c.accent.primary }, p: 0 }}
/>
<Typography sx={{ color: c.text.secondary, fontSize: '0.85rem' }}>
Restrict actions {!form.toolsEnabled && <span style={{ color: c.text.tertiary }}>(all actions allowed)</span>}
</Typography>
</Box>
{form.toolsEnabled && (
<FormControl fullWidth size="small">
<InputLabel sx={{ color: c.text.tertiary }}>Allowed Actions</InputLabel>
<Select
multiple
value={form.tools}
onChange={(e) => setForm({ ...form, tools: typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value })}
input={<OutlinedInput label="Allowed Actions" />}
renderValue={(selected) => selected.join(', ')}
sx={{ bgcolor: c.bg.page }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
<ListSubheader sx={{ bgcolor: c.bg.page, color: c.text.tertiary, fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', lineHeight: '32px' }}>Built-in Actions</ListSubheader>
{ALL_BUILTIN_TOOL_NAMES.map((name) => (
<MenuItem key={name} value={name}>
<Checkbox checked={form.tools.includes(name)} size="small" sx={{ '&.Mui-checked': { color: c.accent.primary } }} />
<ListItemText primary={name} />
</MenuItem>
))}
{mcpToolNames.length > 0 && (
<ListSubheader sx={{ bgcolor: c.bg.page, color: '#f59e0b', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', lineHeight: '32px', display: 'flex', alignItems: 'center', gap: 0.5 }}>
<ExtensionIcon sx={{ fontSize: 14 }} /> MCP Actions
</ListSubheader>
)}
{mcpToolNames.map((name) => (
<MenuItem key={name} value={name}>
<Checkbox checked={form.tools.includes(name)} size="small" sx={{ '&.Mui-checked': { color: '#f59e0b' } }} />
<ListItemText primary={name} primaryTypographyProps={{ sx: { display: 'flex', alignItems: 'center', gap: 0.5 } }}>
{name}
</ListItemText>
</MenuItem>
))}
</Select>
</FormControl>
)}
</Box>
<FormControl fullWidth size="small">
<InputLabel sx={{ color: c.text.tertiary }}>Default Next Mode</InputLabel>
<Select
value={form.default_next_mode}
label="Default Next Mode"
onChange={(e) => setForm({ ...form, default_next_mode: e.target.value })}
sx={{ bgcolor: c.bg.page }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{otherModes.map((m) => (
<MenuItem key={m.id} value={m.id}>{m.name}</MenuItem>
))}
</Select>
</FormControl>
<Box>
<Typography sx={{ color: c.text.secondary, fontSize: '0.85rem', mb: 0.75 }}>
Default Folder
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
value={form.default_folder}
onChange={(e) => setForm({ ...form, default_folder: e.target.value })}
fullWidth
size="small"
placeholder="Not set (uses global default)"
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: c.bg.page,
fontFamily: 'monospace',
fontSize: '0.85rem',
},
}}
/>
<Button
variant="outlined"
onClick={() => setBrowseOpen(true)}
startIcon={<FolderOpenIcon />}
sx={{
color: c.accent.primary,
borderColor: c.border.medium,
textTransform: 'none',
whiteSpace: 'nowrap',
minWidth: 'auto',
}}
>
Browse
</Button>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 2 }}>
<FormControl size="small" sx={{ flex: 1 }}>
<InputLabel sx={{ color: c.text.tertiary }}>Icon</InputLabel>
<Select
value={form.icon}
label="Icon"
onChange={(e) => setForm({ ...form, icon: e.target.value })}
sx={{ bgcolor: c.bg.page }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
{ICON_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{ICON_MAP[opt.value]}
<span>{opt.label}</span>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ flex: 1 }}>
<InputLabel sx={{ color: c.text.tertiary }}>Color</InputLabel>
<Select
value={form.color}
label="Color"
onChange={(e) => setForm({ ...form, color: e.target.value })}
sx={{ bgcolor: c.bg.page }}
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
>
{COLOR_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 14, height: 14, borderRadius: '50%', bgcolor: opt.value }} />
<span>{opt.label}</span>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, justifyContent: 'space-between' }}>
<Box>
{editingIsBuiltin && (
<Tooltip title={hasDiverged ? 'Restore this mode to its original built-in defaults' : 'Mode matches built-in defaults'}>
<span>
<Button
startIcon={<RestoreIcon sx={{ fontSize: 16 }} />}
onClick={handleReset}
disabled={!hasDiverged}
sx={{
color: hasDiverged ? c.text.muted : c.text.ghost,
textTransform: 'none',
fontSize: '0.82rem',
'&:hover': hasDiverged ? { color: c.status.error, bgcolor: `${c.status.error}10` } : {},
}}
>
Reset to Default
</Button>
</span>
</Tooltip>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button onClick={() => setDialogOpen(false)} sx={{ color: c.text.tertiary, textTransform: 'none' }}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!form.name}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
borderRadius: 2,
}}
>
{editingId ? 'Save Changes' : 'Create Mode'}
</Button>
</Box>
</DialogActions>
</Dialog>
<DirectoryBrowser
open={browseOpen}
onClose={() => setBrowseOpen(false)}
onSelect={(item) => setForm({ ...form, default_folder: item.path })}
initialPath={form.default_folder || ''}
/>
</Box>
);
};
export default Modes;
+22 -1
View File
@@ -4,6 +4,7 @@ import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import CircularProgress from '@mui/material/CircularProgress';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { updateSettingsPatch, closeSettingsModal, AppSettings } from '@/shared/state/settingsSlice';
import { onboardingBus } from '@/app/components/Onboarding/eventBus';
@@ -18,6 +19,10 @@ import UsageStats from './sections/usage/UsageStats';
import SettingsHeader from './sections/SettingsHeader';
import { makeSettingsStyles } from './sections/settingsStyles';
// Skills/Tools moved here from the old sidebar Customization section; lazy since both pull heavy deps and Settings opens nearly every session.
const SkillsTab = React.lazy(() => import('@/app/pages/Skills/Skills'));
const ToolsTab = React.lazy(() => import('@/app/pages/Tools/Tools'));
// Brand colors for provider group headers; mirrors ChatInput picker.
const PROVIDER_COLORS: Record<string, string> = {
anthropic: '#E8927A',
@@ -85,7 +90,7 @@ const Settings: React.FC = () => {
}, [modelsByProvider, modelsLoaded, settings.connection_mode, settings.default_model]);
const initialTab = useAppSelector((s) => s.settings.initialTab);
const TAB_VALUES = ['general', 'models', 'usage', 'commands'] as const;
const TAB_VALUES = ['general', 'models', 'skills', 'tools', 'commands', 'usage'] as const;
type SettingsTab = typeof TAB_VALUES[number];
const isValidTab = (t: string | null | undefined): t is SettingsTab =>
!!t && (TAB_VALUES as readonly string[]).includes(t);
@@ -219,6 +224,8 @@ const Settings: React.FC = () => {
sx: {
width: 780,
height: '85vh',
display: 'flex',
flexDirection: 'column',
bgcolor: c.bg.page,
borderRadius: 2,
border: `1px solid ${c.border.subtle}`,
@@ -236,6 +243,8 @@ const Settings: React.FC = () => {
<DialogContent sx={{
px: 3,
py: 0,
flex: 1,
minHeight: 0,
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3, '&:hover': { background: c.border.strong } },
@@ -265,6 +274,18 @@ const Settings: React.FC = () => {
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<UsageStats />
</Box>
) : activeTab === 'skills' ? (
<Box sx={{ height: '100%', mx: -3, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<React.Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}><CircularProgress size={24} /></Box>}>
<SkillsTab />
</React.Suspense>
</Box>
) : activeTab === 'tools' ? (
<Box sx={{ height: '100%', mx: -3, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<React.Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}><CircularProgress size={24} /></Box>}>
<ToolsTab />
</React.Suspense>
</Box>
) : (
<Box sx={{ pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
<CommandsContent />
@@ -54,8 +54,10 @@ const SettingsHeader: React.FC<{
>
<Tab label="General" value="general" disableRipple />
<Tab label="Models" value="models" disableRipple data-onboarding="settings-models-tab" />
<Tab label="Usage" value="usage" disableRipple />
<Tab label="Skills" value="skills" disableRipple />
<Tab label="Tools" value="tools" disableRipple />
<Tab label="Commands" value="commands" disableRipple />
<Tab label="Usage" value="usage" disableRipple />
</Tabs>
</DialogTitle>
);
+2 -2
View File
@@ -128,7 +128,7 @@ const Skills: React.FC = () => {
const regGrouped = useMemo(() => {
const groups: Record<string, RegistrySkill[]> = {};
const q = searchFilter.toLowerCase();
const q = searchFilter.trim().toLowerCase();
for (const sk of regSkills) {
if (q && !sk.name.toLowerCase().includes(q) && !sk.description.toLowerCase().includes(q)) continue;
const cat = sk.category || 'General';
@@ -139,7 +139,7 @@ const Skills: React.FC = () => {
}, [regSkills, searchFilter]);
const filteredLocal = useMemo(() => {
const q = searchFilter.toLowerCase();
const q = searchFilter.trim().toLowerCase();
if (!q) return localSkills;
return localSkills.filter((s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
}, [localSkills, searchFilter]);
+8 -8
View File
@@ -106,8 +106,8 @@ const Tools: React.FC = () => {
<Box sx={{ p: 3, height: '100%', overflow: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 700, mb: 0.5 }}>Action Library</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem' }}>Define and manage custom actions for your Claude Code agents.</Typography>
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 700, mb: 0.5 }}>Tool Library</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem' }}>Define and manage custom tools for your Claude Code agents.</Typography>
</Box>
<Box>
<Button
@@ -117,7 +117,7 @@ const Tools: React.FC = () => {
onClick={handleMenuOpen}
sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed }, textTransform: 'none', borderRadius: 2 }}
>
New Action
New Tool
</Button>
<Menu
anchorEl={menuAnchor}
@@ -144,18 +144,18 @@ const Tools: React.FC = () => {
>
{builtinSectionOpen ? <KeyboardArrowDownIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} /> : <KeyboardArrowRightIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} />}
<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Built-in Action Sets</Typography>
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Built-in</Typography>
<Chip label={coreTools.length + deferredTools.length + browserTools.length} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 18, minWidth: 24, '& .MuiChip-label': { px: 0.8 } }} />
</Box>
<Collapse in={builtinSectionOpen} timeout={0} unmountOnExit>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1 }}>
{coreTools.length > 0 && (
<ToolSection label="Core Actions" icon={<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={coreTools.length} open={coreSectionOpen} onToggle={() => setCoreSectionOpen((v) => !v)} grouped={groupedCore} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} builtinPermissions={builtinPermissions} onPermissionChange={a.handleBuiltinPermissionChange} onCategoryPermissionChange={a.handleBuiltinCategoryPermissionChange} enabled={coreSectionEnabled} onEnabledChange={(v) => a.handleSectionEnabledChange(coreTools, v)} />
<ToolSection label="Core Tools" icon={<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={coreTools.length} open={coreSectionOpen} onToggle={() => setCoreSectionOpen((v) => !v)} grouped={groupedCore} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} builtinPermissions={builtinPermissions} onPermissionChange={a.handleBuiltinPermissionChange} onCategoryPermissionChange={a.handleBuiltinCategoryPermissionChange} enabled={coreSectionEnabled} onEnabledChange={(v) => a.handleSectionEnabledChange(coreTools, v)} />
)}
{deferredTools.length > 0 && (
<ToolSection label="Extended Actions" icon={<HourglassEmptyIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={deferredTools.length} open={deferredSectionOpen} onToggle={() => setDeferredSectionOpen((v) => !v)} grouped={groupedDeferred} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} deferred builtinPermissions={builtinPermissions} onPermissionChange={a.handleBuiltinPermissionChange} onCategoryPermissionChange={a.handleBuiltinCategoryPermissionChange} enabled={deferredSectionEnabled} onEnabledChange={(v) => a.handleSectionEnabledChange(deferredTools, v)} />
<ToolSection label="Extended Tools" icon={<HourglassEmptyIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={deferredTools.length} open={deferredSectionOpen} onToggle={() => setDeferredSectionOpen((v) => !v)} grouped={groupedDeferred} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} deferred builtinPermissions={builtinPermissions} onPermissionChange={a.handleBuiltinPermissionChange} onCategoryPermissionChange={a.handleBuiltinCategoryPermissionChange} enabled={deferredSectionEnabled} onEnabledChange={(v) => a.handleSectionEnabledChange(deferredTools, v)} />
)}
{browserTools.length > 0 && (
@@ -183,7 +183,7 @@ const Tools: React.FC = () => {
<Box onClick={() => setCustomSectionOpen((v) => !v)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1, cursor: 'pointer', userSelect: 'none', '&:hover .section-arrow': { color: c.text.secondary } }}>
{customSectionOpen ? <KeyboardArrowDownIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} /> : <KeyboardArrowRightIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} />}
<BuildIcon sx={{ fontSize: 14, color: c.text.tertiary }} />
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Custom Action Sets</Typography>
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Connections</Typography>
<Chip label={tools.length + uninstalledIntegrations.length} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 18, minWidth: 24, '& .MuiChip-label': { px: 0.8 } }} />
</Box>
<Collapse in={customSectionOpen} timeout={0} unmountOnExit>
@@ -196,7 +196,7 @@ const Tools: React.FC = () => {
) : (tools.length === 0 && uninstalledIntegrations.length === 0) ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 6, color: c.text.ghost, gap: 1.5 }}>
<BuildIcon sx={{ fontSize: 40, opacity: 0.3 }} />
<Typography sx={{ fontSize: '0.9rem' }}>No custom actions defined yet. Create one to get started.</Typography>
<Typography sx={{ fontSize: '0.9rem' }}>No custom tools defined yet. Create one to get started.</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1 }}>
@@ -64,9 +64,9 @@ const BrowserPermissionCard: React.FC<BrowserPermissionCardProps> = ({
<Box sx={{ flex: 1, minWidth: 0, opacity: browserSectionEnabled ? 1 : 0.4, transition: 'opacity 0.2s' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }}>Browser</Typography>
<Chip label={`${browserTools.length} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
<Chip label={`${browserTools.length} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>Browser automation delegation and individual browser actions</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>Browser automation delegation and individual browser tools</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
<Switch
@@ -90,8 +90,8 @@ const BrowserPermissionCard: React.FC<BrowserPermissionCardProps> = ({
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<SecurityIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Action Permissions</Typography>
<Chip label={`${browserTools.length} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Tool Permissions</Typography>
<Chip label={`${browserTools.length} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
@@ -156,7 +156,7 @@ const BrowserPermissionCard: React.FC<BrowserPermissionCardProps> = ({
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<KeyboardArrowDownIcon sx={{ fontSize: 16, color: c.text.ghost, transition: 'transform 0.15s', transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }} />
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', fontWeight: 600 }}>Browser Actions</Typography>
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', fontWeight: 600 }}>Browser Tools</Typography>
<Chip label={browserActionTools.length} size="small" sx={{ bgcolor: c.bg.page, color: c.text.muted, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
<Box sx={{ display: 'flex', gap: 0.25 }} onClick={(e) => e.stopPropagation()}>
@@ -129,7 +129,7 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
<Chip icon={<SettingsIcon sx={{ fontSize: 12 }} />} label="Configured" size="small" sx={{ bgcolor: c.status.warningBg, color: c.status.warning, fontSize: '0.7rem', height: 20, '& .MuiChip-icon': { color: c.status.warning } }} />
)}
{ig && totalToolCount > 0 && (
<Chip label={`${totalToolCount} actions`} size="small" sx={{ bgcolor: `${ig.color}15`, color: ig.color, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
<Chip label={`${totalToolCount} tools`} size="small" sx={{ bgcolor: `${ig.color}15`, color: ig.color, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
)}
{ig && (
<Chip component="a" href={ig.website} clickable icon={<OpenInNewIcon sx={{ fontSize: 10 }} />} label="docs" size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, fontSize: 10 } }} />
@@ -190,13 +190,13 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<SecurityIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Action Permissions</Typography>
{hasPerms && <Chip label={`${totalToolCount} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />}
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Tool Permissions</Typography>
{hasPerms && <Chip label={`${totalToolCount} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{hasPerms && (
<>
<Tooltip title="Allow all read-only actions">
<Tooltip title="Allow all read-only tools">
<Button size="small" onClick={() => handleBulkReadOnly(tool.id)} sx={{ color: c.status.info, textTransform: 'none', fontSize: '0.7rem', minWidth: 'auto', px: 1, py: 0.25 }}>
Allow reads
</Button>
@@ -208,7 +208,7 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
</Tooltip>
</>
)}
<Tooltip title="Discover / refresh actions from MCP server">
<Tooltip title="Discover / refresh tools from MCP server">
<IconButton
size="small"
onClick={() => handleDiscover(tool.id)}
@@ -224,7 +224,7 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
{!hasPerms ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 3, gap: 1.5 }}>
<ExtensionIcon sx={{ fontSize: 28, color: c.text.ghost, opacity: 0.4 }} />
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem' }}>No actions discovered yet</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem' }}>No tools discovered yet</Typography>
<Button
size="small"
variant="outlined"
@@ -233,10 +233,10 @@ const CustomToolCard: React.FC<CustomToolCardProps> = ({
disabled={discovering || !canDiscover}
sx={{ borderColor: c.border.medium, color: c.text.secondary, '&:hover': { borderColor: c.accent.primary, color: c.accent.primary }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5 }}
>
Discover Actions
Discover Tools
</Button>
{!canDiscover && (
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Add an MCP configuration to enable action discovery</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Add an MCP configuration to enable tool discovery</Typography>
)}
</Box>
) : (
@@ -84,8 +84,8 @@ const ToolSection: React.FC<ToolSectionProps> = ({
const overallPolicy = getCatGroupPolicy(allSectionTools);
const categoryCount = CATEGORY_ORDER.filter((cat) => grouped[cat]).length;
const sectionDescription = deferred
? 'On-demand actions loaded via ToolSearch for planning, scheduling, and extended operations'
: 'Built-in Claude Agent SDK actions for file operations, shell commands, and search';
? 'On-demand tools loaded via ToolSearch for planning, scheduling, and extended operations'
: 'Built-in Claude Agent SDK tools for file operations, shell commands, and search';
const firstSentence = (desc: string) => {
if (!desc) return '';
@@ -110,7 +110,7 @@ const ToolSection: React.FC<ToolSectionProps> = ({
<Box sx={{ flex: 1, minWidth: 0, opacity: enabled ? 1 : 0.4, transition: 'opacity 0.2s' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }}>{label}</Typography>
<Chip label={`${count} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
<Chip label={`${count} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
{deferred && (
<Chip label="on-demand" size="small" sx={{ bgcolor: c.status.warningBg, color: c.status.warning, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
)}
@@ -139,8 +139,8 @@ const ToolSection: React.FC<ToolSectionProps> = ({
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<SecurityIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Action Permissions</Typography>
<Chip label={`${count} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Tool Permissions</Typography>
<Chip label={`${count} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
@@ -140,10 +140,10 @@ export function useRegistryBrowser({ regServersRaw, setSnackbar, setEditingId, s
}));
if (createTool.fulfilled.match(result)) {
const newTool = result.payload;
setSnackbar({ open: true, message: `Installed "${f.name}", discovering actions…` });
setSnackbar({ open: true, message: `Installed "${f.name}", discovering tools…` });
const discoverResult = await dispatch(discoverTools(newTool.id));
if (discoverTools.fulfilled.match(discoverResult)) {
setSnackbar({ open: true, message: `${f.name} ready, actions discovered` });
setSnackbar({ open: true, message: `${f.name} ready, tools discovered` });
} else {
const detail = (discoverResult as any).error?.message
|| 'discovery failed; the MCP server may need setup first';
@@ -45,7 +45,7 @@ export function useToolConnections({ items, setSnackbar, setExpandedToolId }: De
const afterConnect = async () => {
const statusResult = await dispatch(fetchToolStatus(toolId));
if (fetchToolStatus.fulfilled.match(statusResult) && statusResult.payload.auth_status === 'connected') {
setSnackbar({ open: true, message: 'Account connected! Discovering actions…' });
setSnackbar({ open: true, message: 'Account connected! Discovering tools…' });
setExpandedToolId(toolId);
dispatch(discoverTools(toolId));
} else {
@@ -97,7 +97,7 @@ export function useToolConnections({ items, setSnackbar, setExpandedToolId }: De
if (status === 'connected') {
clearInterval(poll);
setDeviceCodeStatus('connected');
setSnackbar({ open: true, message: `Connected to Microsoft 365${email ? ` as ${email}` : ''}! Discovering actions…` });
setSnackbar({ open: true, message: `Connected to Microsoft 365${email ? ` as ${email}` : ''}! Discovering tools…` });
setDeviceCodeDialogOpen(false);
setExpandedToolId(toolId);
await dispatch(fetchToolStatus(toolId));
@@ -148,7 +148,7 @@ export function useToolConnections({ items, setSnackbar, setExpandedToolId }: De
}));
if (updateTool.fulfilled.match(result)) {
setCredDialogOpen(false);
setSnackbar({ open: true, message: `${credDialogIntegration.name} connected! Re-discovering actions…` });
setSnackbar({ open: true, message: `${credDialogIntegration.name} connected! Re-discovering tools…` });
dispatch(discoverTools(credDialogToolId));
} else {
setSnackbar({ open: true, message: 'Failed to save credentials', severity: 'error' });
@@ -177,7 +177,7 @@ export function useToolConnections({ items, setSnackbar, setExpandedToolId }: De
}));
if (updateTool.fulfilled.match(result)) {
setCredDialogOpen(false);
setSnackbar({ open: true, message: 'Slack connected! Re-discovering actions…' });
setSnackbar({ open: true, message: 'Slack connected! Re-discovering tools…' });
dispatch(discoverTools(credDialogToolId));
} else {
setSnackbar({ open: true, message: 'Failed to save Slack credentials', severity: 'error' });
@@ -56,12 +56,12 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T
} else if (existing && existing.enabled === false) {
await dispatch(updateTool({ id: existing.id, enabled: true }));
if (integration.authType === 'oauth2' && existing.auth_status !== 'connected') {
setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover actions` });
setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover tools` });
} else {
setSnackbar({ open: true, message: `Enabled ${integration.name}, re-discovering actions…` });
setSnackbar({ open: true, message: `Enabled ${integration.name}, re-discovering tools…` });
const discoverResult = await dispatch(discoverTools(existing.id));
if (discoverTools.fulfilled.match(discoverResult)) {
setSnackbar({ open: true, message: `${integration.name} ready, actions discovered` });
setSnackbar({ open: true, message: `${integration.name} ready, tools discovered` });
} else {
const detail = (discoverResult as any).error?.message || 'discovery failed';
setSnackbar({ open: true, message: `${integration.name}: ${detail}`, severity: 'error' });
@@ -80,12 +80,12 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T
if (createTool.fulfilled.match(result)) {
const newTool = result.payload;
if (integration.authType === 'oauth2' || integration.authType === 'device_code') {
setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover actions` });
setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover tools` });
} else {
setSnackbar({ open: true, message: `Enabled ${integration.name}, discovering actions…` });
setSnackbar({ open: true, message: `Enabled ${integration.name}, discovering tools…` });
const discoverResult = await dispatch(discoverTools(newTool.id));
if (discoverTools.fulfilled.match(discoverResult)) {
setSnackbar({ open: true, message: `${integration.name} ready, actions discovered` });
setSnackbar({ open: true, message: `${integration.name} ready, tools discovered` });
} else {
const detail = (discoverResult as any).error?.message
|| `discovery failed; is ${integration.mcp_config.command || 'the server'} installed?`;
@@ -104,7 +104,7 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T
try {
const result = await dispatch(discoverTools(toolId));
if (discoverTools.fulfilled.match(result)) {
setSnackbar({ open: true, message: 'Actions discovered successfully' });
setSnackbar({ open: true, message: 'Tools discovered successfully' });
} else {
const detail = (result as any).error?.message || 'Discovery failed; is the MCP server running?';
setSnackbar({ open: true, message: detail, severity: 'error' });
+13
View File
@@ -109,6 +109,7 @@ export interface AgentSession {
framework_overhead_tokens?: number;
context_overflow?: { reason: string; message: string; at: string } | null;
rate_limited?: { retry_after_s: number | null; at: string } | null;
context_recovered?: { at: string } | null;
mcp_suggestions?: Array<{ id: string; title: string; description: string; reason?: string }>;
mcp_suggestions_is_vague?: boolean;
compacted_through_msg_id?: string | null;
@@ -959,6 +960,16 @@ const agentsSlice = createSlice({
if (session) session.rate_limited = null;
},
setContextRecovered(state, action: PayloadAction<{ sessionId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) session.context_recovered = { at: new Date().toISOString() };
},
clearContextRecovered(state, action: PayloadAction<{ sessionId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) session.context_recovered = null;
},
clearContextOverflow(
state,
action: PayloadAction<{ sessionId: string }>
@@ -1440,6 +1451,8 @@ export const {
setContextOverflow,
setRateLimited,
clearRateLimited,
setContextRecovered,
clearContextRecovered,
clearContextOverflow,
setMcpSuggestions,
clearMcpSuggestions,
@@ -13,6 +13,7 @@ import {
updateSessionContext,
setContextOverflow,
setRateLimited,
setContextRecovered,
setMcpSuggestions,
addBranch,
setActiveBranch,
@@ -554,6 +555,13 @@ class WebSocketManager {
}
break;
case 'agent:context_recovered':
// The backend hit a context-overflow crash mid-turn, rebuilt from its local copy, and retried on its own. Transient muted pill so the recovery is visible without reading like an error.
if (session_id) {
store.dispatch(setContextRecovered({ sessionId: session_id }));
}
break;
case 'agent:context_status':
// Auto-compaction collapsed older turns into a summary. Mirror compacted_through_msg_id locally so the renderer can drop a visible "N earlier turns summarized" chip into the transcript. Other reasons (cleared, etc.) flow through this same event but don't currently need a chip, ignore them for now.
if (session_id && data.reason === 'compacted') {
+4
View File
@@ -70,6 +70,10 @@ if ($Sign) {
Write-Host "Copy .env.windows.example to .env.windows and fill in values."
exit 1
}
# A signed build is one users actually run, so its Widevine VMP signature is
# mandatory: the afterPack hook hard-fails on a missing/failed signature rather
# than ship an installer whose Spotify/Netflix audio is silently dead.
$env:VMP_REQUIRE_SIGN = '1'
}
# --- Step 0: Bundled uv + uvx for Windows ---
+47 -43
View File
@@ -26,6 +26,22 @@ elif [[ "${1:-}" == "--sign" ]]; then
SIGN_MODE=true
fi
# Arch targets for this run. Publish always builds both DMGs; otherwise
# OSW_BUILD_ARCH (arm64|x64|both) overrides, defaulting to the host. Node,
# python-env, and the electron-builder flags below all derive from this ONE
# list, so a staged-arch vs packed-arch mismatch can't happen (the class of
# bug that shipped arm64 python inside the x64 DMG).
if $PUBLISH_MODE; then
BUILD_ARCHS=(arm64 x64)
else
case "${OSW_BUILD_ARCH:-host}" in
both) BUILD_ARCHS=(arm64 x64) ;;
x64) BUILD_ARCHS=(x64) ;;
arm64) BUILD_ARCHS=(arm64) ;;
*) if [[ "$(uname -m)" == "x86_64" ]]; then BUILD_ARCHS=(x64); else BUILD_ARCHS=(arm64); fi ;;
esac
fi
# Defensive: detach any leftover OpenSwarm DMG volumes from prior failed builds.
# hdiutil's "Resource busy" / volume-name-collision errors almost always trace
# back to a stale mount in /Volumes (e.g. after a build crash or a still-open
@@ -66,6 +82,11 @@ if $SIGN_MODE; then
echo "See script header for details."
exit 1
fi
# A signed build is a build users actually run, so its Widevine VMP signature
# is mandatory: the afterPack hook hard-fails on a missing/failed signature
# instead of shipping a DMG whose Spotify/Netflix audio is silently dead.
# Respect an explicit outer VMP_REQUIRE_SIGN=0: a cred-less local cut may consciously ship DRM-limited (CI always has EVS secrets, so releases from CI keep the hard gate).
export VMP_REQUIRE_SIGN=${VMP_REQUIRE_SIGN:-1}
fi
# Step 0: Ensure bundled uv + uvx binaries exist.
@@ -260,17 +281,6 @@ fi
echo "Frontend build complete."
echo ""
# Step 2: Build Python environment
echo "[2/4] Building Python environment..."
bash "$SCRIPT_DIR/build-python-env.sh"
if [[ ! -d "$PROJECT_ROOT/electron/python-env" ]]; then
echo "ERROR: Python environment not found at electron/python-env/"
exit 1
fi
echo "Python environment ready."
echo ""
# Step 3: Fetch Router from npm
# The 9router Next.js server is published as an npm package with a pre-built
# standalone output. We install it into a scratch dir and stage it directly
@@ -288,6 +298,19 @@ fi
echo "Router staged."
echo ""
# Step 3a: Bundled Python env, one per target arch (must run AFTER the
# build-staging reset above or the freshly staged envs get wiped).
echo "[3a] Building bundled Python env(s): ${BUILD_ARCHS[*]}"
for A in "${BUILD_ARCHS[@]}"; do
bash "$SCRIPT_DIR/build-python-env.sh" "$A"
if [[ ! -f "$STAGING_DIR/python-env/$A/bin/python3.13" ]]; then
echo "ERROR: python-env ($A) missing at $STAGING_DIR/python-env/$A"
exit 1
fi
done
echo "Python environment(s) ready."
echo ""
# Step 3b: Bundle a real Node.js binary so 9Router and MCP servers don't
# fall back to ELECTRON_RUN_AS_NODE on user machines without system node.
# Two wins:
@@ -346,21 +369,10 @@ NPMSH
echo "[3b] Node $NODE_VERSION ($arch) staged ($(du -h "$out_dir/bin/node" | cut -f1))"
}
# Publish mode builds both DMGs from one invocation, so always stage both.
# Single-arch local/sign builds only need the host arch.
if $PUBLISH_MODE; then
download_node_for_arch arm64
download_node_for_arch x64
else
HOST_ARCH=$(uname -m)
if [[ "$HOST_ARCH" == "arm64" ]]; then
download_node_for_arch arm64
elif [[ "$HOST_ARCH" == "x86_64" ]]; then
download_node_for_arch x64
else
echo "WARNING: unknown host arch $HOST_ARCH — skipping node bundle (will fall back to ELECTRON_RUN_AS_NODE)"
fi
fi
# Stage node for every arch this run packs (BUILD_ARCHS decides, top of file).
for A in "${BUILD_ARCHS[@]}"; do
download_node_for_arch "$A"
done
echo ""
# Step 3c: Pre-build the webapp-template node_modules archive so first-app
@@ -483,27 +495,19 @@ fi
# Caller's NODE_OPTIONS is respected if already set.
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=12288}"
# Pack exactly the arches we staged for (BUILD_ARCHS, top of file).
EB_ARCH_FLAGS=()
for A in "${BUILD_ARCHS[@]}"; do
EB_ARCH_FLAGS+=("--$A")
done
if $PUBLISH_MODE; then
npx electron-builder --mac --arm64 --x64 --publish always
npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish always
elif $SIGN_MODE; then
ARCH=$(uname -m)
if [[ "$ARCH" == "arm64" ]]; then
npx electron-builder --mac --arm64 --publish never
elif [[ "$ARCH" == "x86_64" ]]; then
npx electron-builder --mac --x64 --publish never
else
npx electron-builder --mac --publish never
fi
npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish never
else
export CSC_IDENTITY_AUTO_DISCOVERY=false
ARCH=$(uname -m)
if [[ "$ARCH" == "arm64" ]]; then
npx electron-builder --mac --arm64 --publish never
elif [[ "$ARCH" == "x86_64" ]]; then
npx electron-builder --mac --x64 --publish never
else
npx electron-builder --mac --publish never
fi
npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish never
fi
rm -rf "$PROJECT_ROOT/electron/build-staging"
+27 -12
View File
@@ -1,28 +1,42 @@
#!/bin/bash
set -euo pipefail
# Build an embedded Python environment for the Electron app.
# Build an embedded Python environment for the Electron app (macOS).
#
# Downloads a standalone Python build from python-build-standalone,
# creates a venv, and installs all backend dependencies.
# The resulting python-env/ directory is bundled into the Electron app.
#
# Usage: build-python-env.sh [arm64|x64] (default: host arch)
#
# The env stages under electron/build-staging/python-env/<arch> so
# electron-builder's ${arch} macro bundles the MATCHING env per pack, same
# pattern as node/${arch}. Never bundle one host-arch env into both DMGs:
# that shipped arm64 python inside the x64 app and killed every Intel Mac.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ELECTRON_DIR="$PROJECT_ROOT/electron"
PYTHON_ENV_DIR="$ELECTRON_DIR/python-env"
PYTHON_VERSION="3.13"
PYTHON_FULL_VERSION="3.13.2"
ARCH="$(uname -m)"
ARCH="${1:-$(uname -m)}"
if [[ "$ARCH" == "arm64" ]]; then
PLATFORM_TAG="aarch64-apple-darwin"
elif [[ "$ARCH" == "x86_64" ]]; then
PLATFORM_TAG="x86_64-apple-darwin"
else
echo "Unsupported architecture: $ARCH"
exit 1
case "$ARCH" in
arm64|aarch64) ARCH="arm64"; PLATFORM_TAG="aarch64-apple-darwin" ;;
x64|x86_64) ARCH="x64"; PLATFORM_TAG="x86_64-apple-darwin" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
PYTHON_ENV_DIR="$ELECTRON_DIR/build-staging/python-env/$ARCH"
# Cross-building x64 on Apple Silicon runs the x64 python under Rosetta
# (pip then resolves x86_64 wheels, including the SDK's bundled claude CLI).
if [[ "$ARCH" == "x64" && "$(uname -m)" == "arm64" ]]; then
if ! arch -x86_64 /usr/bin/true 2>/dev/null; then
echo "ERROR: building the x64 python-env on Apple Silicon requires Rosetta 2."
echo " Install it with: softwareupdate --install-rosetta --agree-to-license"
exit 1
fi
fi
RELEASE_TAG="20250212"
@@ -44,6 +58,7 @@ if [[ -d "$PYTHON_ENV_DIR" ]]; then
echo "Removing old python-env..."
rm -rf "$PYTHON_ENV_DIR"
fi
mkdir -p "$(dirname "$PYTHON_ENV_DIR")"
# Download standalone Python
echo "Downloading standalone Python from python-build-standalone..."
@@ -301,7 +316,7 @@ PLIST
# python-env/ via realpath, and libpython loads via the rewritten
# @executable_path path.
if ! "$PY_APP/Contents/MacOS/python3" -c \
"import sys; assert sys.prefix.endswith('python-env'), sys.prefix" 2>/dev/null; then
"import sys, os; assert os.path.realpath(sys.prefix) == os.path.realpath('$PYTHON_ENV_DIR'), sys.prefix" 2>/dev/null; then
echo "ERROR: Python.app wrapper failed self-test (libpython or stdlib not findable)" >&2
echo " Try: $PY_APP/Contents/MacOS/python3 -c 'import sys; print(sys.prefix)'" >&2
exit 1
+27
View File
@@ -54,6 +54,33 @@ function main() {
}
process.stdout.write(` ok python ${versionLine}\n`);
// 1b) macOS: the bundled python's arch slices must cover the app's. An arm64
// python inside the x64 app RUNS on an arm64 build host (native, not Rosetta),
// so --version alone can never catch the cross-arch bundle bug that bricked
// every Intel Mac. lipo compares what the file IS, not what the host can run.
if (process.platform === 'darwin') {
const i = appExe.indexOf('.app');
const appRoot = i === -1 ? appExe : appExe.slice(0, i + 4);
const mainBin = path.join(appRoot, 'Contents', 'MacOS', path.basename(appRoot, '.app'));
const archsOf = (bin) => {
const r = spawnSync('lipo', ['-archs', bin], { encoding: 'utf8', timeout: 15000 });
if (r.status !== 0) return null;
return (r.stdout || '').trim().split(/\s+/).filter(Boolean);
};
const appArchs = archsOf(mainBin);
const pyArchs = archsOf(fs.realpathSync(py));
if (!appArchs || !pyArchs) {
process.stderr.write(`\nPYTHON-HEALTH FAIL: lipo could not read archs (app=${appArchs}, python=${pyArchs})\n`);
process.exit(1);
}
const missing = appArchs.filter((a) => !pyArchs.includes(a));
if (missing.length > 0) {
process.stderr.write(`\nPYTHON-HEALTH FAIL: app is [${appArchs}] but bundled python is [${pyArchs}] (missing ${missing}). This build would brick ${missing.join('/')} Macs.\n`);
process.exit(1);
}
process.stdout.write(` ok arch match (app [${appArchs}] / python [${pyArchs}])\n`);
}
// 2) Import smoke: load the heaviest deps to catch a half-extracted site-packages tree (rare but lethal).
const smoke = spawnSync(py, ['-c', 'import sys, fastapi, anthropic, pydantic, httpx, jsonschema; print(sys.version_info[:3])'], { encoding: 'utf8', timeout: 30000 });
if (smoke.status !== 0) {