mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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:")]
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user