mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 10:47:44 +02:00
[eric] backend: one-line every comment + collapse 3+ blank runs across apps/ (AST-verified identical, -lines); CLAUDE.md one-line-comment rule
This commit is contained in:
@@ -10,8 +10,7 @@ from backend.apps.agents.core.models import (
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
from backend.apps.settings.settings import load_settings
|
||||
from backend.apps.tools_lib.tools_lib import load_builtin_permissions
|
||||
# SESSIONS_DIR is re-exported on purpose: session_store reads agent_manager.SESSIONS_DIR at
|
||||
# call time (dodging a circular import), and the disk-resilience test monkeypatches it here.
|
||||
# SESSIONS_DIR is re-exported on purpose: session_store reads agent_manager.SESSIONS_DIR at call time (dodging a circular import), and the disk-resilience test monkeypatches it here.
|
||||
from backend.config.paths import SESSIONS_DIR as SESSIONS_DIR
|
||||
from backend.apps.agents.manager.session.session_store import (
|
||||
save_session,
|
||||
@@ -40,13 +39,9 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
def __init__(self):
|
||||
self.sessions: Dict[str, AgentSession] = {}
|
||||
self.tasks: Dict[str, asyncio.Task] = {}
|
||||
# Live mirror of the in-flight streamed assistant text per session, so a
|
||||
# stop can persist the partial reply instantly instead of waiting out the
|
||||
# multi-second SDK teardown the cancel handler sits behind.
|
||||
# Live mirror of the in-flight streamed assistant text per session, so a stop can persist the partial reply instantly instead of waiting out the multi-second SDK teardown the cancel handler sits behind.
|
||||
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).
|
||||
# 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] = {}
|
||||
|
||||
|
||||
@@ -65,9 +60,7 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
)
|
||||
|
||||
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).
|
||||
# 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).
|
||||
import claude_agent_sdk # noqa: F401
|
||||
except ImportError:
|
||||
logger.warning("claude_agent_sdk not installed, running in mock mode")
|
||||
@@ -76,11 +69,7 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
|
||||
session.status = "running"
|
||||
|
||||
# Resolve the model id now so every closure (approval hook, tool
|
||||
# executed handler, etc.) has both the short name and the
|
||||
# 9Router-prefixed id available without re-resolving. The short
|
||||
# name is what the user sees; the router id is what 9Router
|
||||
# reports its per-model counters under.
|
||||
# Resolve the model id now so every closure (approval hook, tool executed handler, etc.) has both the short name and the 9Router-prefixed id available without re-resolving. The short name is what the user sees; the router id is what 9Router reports its per-model counters under.
|
||||
from backend.apps.agents.providers.registry import (
|
||||
resolve_model_id_for_sdk as p_resolve_model_id_early,
|
||||
get_api_type as p_get_api_type_early,
|
||||
@@ -90,17 +79,8 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
|
||||
builtin_perms = load_builtin_permissions()
|
||||
|
||||
# Per-tool DEFAULT policy (overridden by anything the user has set
|
||||
# explicitly in builtin_permissions.json). Bash defaults to
|
||||
# always_allow like every other builtin, for a frictionless run.
|
||||
# Three guards in path_gate STILL force a prompt even on always_allow:
|
||||
# the catastrophic-pattern match (rm -rf and friends), OS-scheduling
|
||||
# (cron/launchd persistence), and the sensitive-path gate. So the
|
||||
# poisoned-email -> destructive-command case is still caught; what
|
||||
# this trades away is the prompt on ordinary shell commands. Users
|
||||
# who want a prompt on every command can flip Bash to "ask" in the UI.
|
||||
# Bind turn + stderr buffer first: build_agent_options can raise early (e.g.
|
||||
# no provider configured), and the except hands both to handle_run_error.
|
||||
# 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] = []
|
||||
try:
|
||||
@@ -119,15 +99,7 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
)
|
||||
session.status = "completed"
|
||||
|
||||
# Auto-continuation hook (Phase 3). If MCPActivate (or any
|
||||
# analogous flow) flagged pending_continuation during this
|
||||
# turn, kick off a follow-up turn immediately with the
|
||||
# captured prompt. We dispatch as a fire-and-forget task so
|
||||
# the current run_agent_loop frame can unwind cleanly
|
||||
# before the next turn's options + history rebuild kicks in.
|
||||
# The follow-up is `hidden=True` so it doesn't add a user
|
||||
# bubble to the visible chat; the model sees it as a
|
||||
# synthetic prompt to keep working.
|
||||
# Auto-continuation hook (Phase 3). If MCPActivate (or any analogous flow) flagged pending_continuation during this turn, kick off a follow-up turn immediately with the captured prompt. We dispatch as a fire-and-forget task so the current run_agent_loop frame can unwind cleanly before the next turn's options + history rebuild kicks in. The follow-up is `hidden=True` so it doesn't add a user bubble to the visible chat; the model sees it as a synthetic prompt to keep working.
|
||||
try:
|
||||
if getattr(session, "pending_continuation", False):
|
||||
p_continuation_prompt = session.pending_continuation_prompt or "Continue."
|
||||
@@ -142,30 +114,19 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
except Exception:
|
||||
logger.exception("auto-continuation dispatch failed")
|
||||
except asyncio.CancelledError:
|
||||
# Only act if we're still the session's live task. A user stop pops
|
||||
# this task (stop_agent already finalized status + partial), and a
|
||||
# follow-up message may have started a newer turn; either way this
|
||||
# dying task must NOT clobber the live status or pop the new turn's
|
||||
# in-flight partial mirror.
|
||||
# Only act if we're still the session's live task. A user stop pops this task (stop_agent already finalized status + partial), and a follow-up message may have started a newer turn; either way this dying task must NOT clobber the live status or pop the new turn's in-flight partial mirror.
|
||||
if self.tasks.get(session_id) is asyncio.current_task():
|
||||
session.status = "stopped"
|
||||
# A cancelled turn desyncs the CLI's resume transcript from
|
||||
# session.messages (the SDK never recorded the interrupted
|
||||
# turn), so force the next turn to rebuild history from
|
||||
# session.messages, else resume/follow-ups replay a transcript
|
||||
# with no trace of the stopped reply ("nothing to continue").
|
||||
# A cancelled turn desyncs the CLI's resume transcript from session.messages (the SDK never recorded the interrupted turn), so force the next turn to rebuild history from session.messages, else resume/follow-ups replay a transcript with no trace of the stopped reply ("nothing to continue").
|
||||
session.needs_fresh_session = True
|
||||
# Persist whatever streamed before the cancel (edit / branch
|
||||
# switch paths; the user-stop path already did this in stop_agent).
|
||||
# Persist whatever streamed before the cancel (edit / branch switch paths; the user-stop path already did this in stop_agent).
|
||||
await self.commit_partial_now(session)
|
||||
turn.stream_text_msg_id = None
|
||||
turn.stream_text_accum = ""
|
||||
except Exception as e:
|
||||
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.
|
||||
# Catch BaseExceptionGroup from anyio task groups (e.g. concurrent CLI crash + pending approval cancellation) so it doesn't escape and kill the uvicorn process.
|
||||
logger.exception(f"Agent {session_id} fatal error: {e}")
|
||||
session.status = "error"
|
||||
error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id)
|
||||
@@ -175,31 +136,18 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
"message": error_msg.model_dump(mode="json"),
|
||||
})
|
||||
finally:
|
||||
# Only the session's live task finalizes. A stopped task (popped by
|
||||
# stop_agent, which already finalized status + saved) or one
|
||||
# superseded by a newer turn must not pop the new turn's partial
|
||||
# mirror, broadcast a stale terminal status, or overwrite the
|
||||
# snapshot the live turn is writing.
|
||||
# Only the session's live task finalizes. A stopped task (popped by stop_agent, which already finalized status + saved) or one superseded by a newer turn must not pop the new turn's partial mirror, broadcast a stale terminal status, or overwrite the snapshot the live turn is writing.
|
||||
p_is_live_task = self.tasks.get(session_id) is asyncio.current_task()
|
||||
if p_is_live_task:
|
||||
self.live_partial.pop(session_id, None)
|
||||
if session_id in self.sessions and p_is_live_task:
|
||||
# For canvas-launched App Builder sessions, the workspace
|
||||
# folder IS the session_id (see launch_agent), so meta.json
|
||||
# lives at outputs_workspace/<session_id>/meta.json. Read it
|
||||
# and propagate name/description into the Output row before
|
||||
# the terminal status fires; without this, the row stays
|
||||
# "Untitled App" forever because no React component polls
|
||||
# the file on the canvas path. Best-effort, only acts when
|
||||
# the row's name is still the default placeholder.
|
||||
# For canvas-launched App Builder sessions, the workspace folder IS the session_id (see launch_agent), so meta.json lives at outputs_workspace/<session_id>/meta.json. Read it and propagate name/description into the Output row before the terminal status fires; without this, the row stays "Untitled App" forever because no React component polls the file on the canvas path. Best-effort, only acts when the row's name is still the default placeholder.
|
||||
if session.mode == "view-builder":
|
||||
try:
|
||||
from backend.apps.outputs.outputs import sync_output_from_meta_json
|
||||
from backend.apps.outputs.workspace_io import load_all as load_outputs
|
||||
if sync_output_from_meta_json(session_id, fallback_name=session.name):
|
||||
# Broadcast the renamed row so the sidebar
|
||||
# flips from "Untitled App" to the real name
|
||||
# without waiting for the next mount.
|
||||
# Broadcast the renamed row so the sidebar flips from "Untitled App" to the real name without waiting for the next mount.
|
||||
try:
|
||||
matching = [o for o in load_outputs() if o.workspace_id == session_id]
|
||||
if matching:
|
||||
@@ -221,19 +169,4 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
logger.warning(f"Failed to snapshot session {session_id}: {e}")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
agent_manager = AgentManager()
|
||||
|
||||
@@ -458,8 +458,7 @@ async def subscriptions_exchange(body: dict):
|
||||
try:
|
||||
result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state)
|
||||
if result.get("success"):
|
||||
# Claude races this path against /api/subscriptions/callback (popup + 9router patch
|
||||
# 302 to backend); dedup so the loser sees the success page, not "Session expired".
|
||||
# Claude races this path against /api/subscriptions/callback (popup + 9router patch 302 to backend); dedup so the loser sees the success page, not "Session expired".
|
||||
if state:
|
||||
pending_oauth.pop(state, None)
|
||||
mark_completed(state)
|
||||
@@ -651,32 +650,26 @@ async def list_models():
|
||||
result["Anthropic"] = p_serialize(anth_alternates)
|
||||
elif has_api_key or has_claude_sub:
|
||||
rows = p_serialize(adaptive)
|
||||
# When an Anthropic key is set, these adaptive rows run on it: own-key routing prefers the
|
||||
# user's key over any sub (agent_manager + anthropic_proxy._pick_upstream), so it holds even
|
||||
# with a Claude sub connected. Label + bucket as API key (not 9router-state dependent).
|
||||
# When an Anthropic key is set, these adaptive rows run on it: own-key routing prefers the user's key over any sub (agent_manager + anthropic_proxy._pick_upstream), so it holds even with a Claude sub connected. Label + bucket as API key (not 9router-state dependent).
|
||||
if has_api_key:
|
||||
for r in rows:
|
||||
if not r["label"].endswith("(API key)"):
|
||||
r["label"] += " (API key)"
|
||||
r["billing_kind"] = "api_key"
|
||||
r["is_free"] = False
|
||||
# Models that only exist on the API-key route (Fable 5, whose sub route 404s
|
||||
# on our pinned 9Router) have no adaptive twin to relabel, so add them or they vanish.
|
||||
# Models that only exist on the API-key route (Fable 5, whose sub route 404s on our pinned 9Router) have no adaptive twin to relabel, so add them or they vanish.
|
||||
adaptive_ids = {m.get("model_id") for m in adaptive}
|
||||
api_only = [m for m in api_variants if m.get("model_id") not in adaptive_ids]
|
||||
rows = p_serialize(api_only) + rows
|
||||
elif has_claude_sub:
|
||||
# Only a sub: the adaptive rows route through 9router's cc/ lane, so they're covered
|
||||
# by the subscription, not pay-per-use.
|
||||
# Only a sub: the adaptive rows route through 9router's cc/ lane, so they're covered by the subscription, not pay-per-use.
|
||||
for r in rows:
|
||||
r["billing_kind"] = "subscription"
|
||||
# Sub-only models with no adaptive twin (Fable 5) won't ride the relabeled rows, so add their cc/ entry.
|
||||
adaptive_ids = {m.get("model_id") for m in adaptive}
|
||||
cc_only = [m for m in cc_variants if m.get("model_id") not in adaptive_ids]
|
||||
rows = p_serialize(cc_only) + rows
|
||||
# With BOTH a key and a sub the adaptive rows above run on the key, so also surface the
|
||||
# subscription (cc) variants; they route via 9router's cc/ lane and stay selectable, the
|
||||
# way OpenAI/Gemini show both a subscription row and an API-key row.
|
||||
# With BOTH a key and a sub the adaptive rows above run on the key, so also surface the subscription (cc) variants; they route via 9router's cc/ lane and stay selectable, the way OpenAI/Gemini show both a subscription row and an API-key row.
|
||||
if has_api_key and has_claude_sub:
|
||||
rows += p_serialize(cc_variants)
|
||||
result["Anthropic"] = rows
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,8 +51,7 @@ P_STEP_TOOLS: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
# Reads/navigation don't mutate anything irreversible; safe to loop freely.
|
||||
P_READONLY_ACTIONS = {"navigate", "get_text", "evaluate", "scroll", "replay_route"}
|
||||
|
||||
# Irreversible / outward-facing words on a clicked control. Conservative on
|
||||
# purpose: we'd rather refuse a borderline loop than auto-send 10 messages.
|
||||
# Irreversible / outward-facing words on a clicked control. Conservative on purpose: we'd rather refuse a borderline loop than auto-send 10 messages.
|
||||
P_SEND_NAME_RE = re.compile(
|
||||
r"\b(send|submit|post|publish|connect|invite|follow|like|react|comment|reply|"
|
||||
r"share|message|dm|pay|buy|order|checkout|purchase|place\s*order|book|"
|
||||
@@ -105,8 +104,7 @@ def template_safety(steps) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
# Like P_SEND_NAME_RE minus composer-openers ("Message"/"DM" buttons open a
|
||||
# compose box, they don't send), so routine flows still batch freely.
|
||||
# Like P_SEND_NAME_RE minus composer-openers ("Message"/"DM" buttons open a compose box, they don't send), so routine flows still batch freely.
|
||||
P_LIVE_IRREVERSIBLE_RE = re.compile(
|
||||
r"\b(send|submit|post|publish|connect|invite|follow|like|react|comment|reply|"
|
||||
r"share|pay|buy|order|checkout|purchase|place\s*order|book|"
|
||||
@@ -182,8 +180,7 @@ def live_batch_guard(actions, seen_lines, composer_pending: bool = False) -> str
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
# selectors hide words behind underscores/dashes (msg-form__send-button),
|
||||
# which defeat \b; flatten separators so the word check still sees them
|
||||
# selectors hide words behind underscores/dashes (msg-form__send-button), which defeat \b; flatten separators so the word check still sees them
|
||||
if label and P_LIVE_IRREVERSIBLE_RE.search(re.sub(r"[_\-./#\[\]]+", " ", label)):
|
||||
return (f"sub-action {i+1} ({typ}) targets {label.strip()!r}, "
|
||||
"which is irreversible/outward-facing")
|
||||
@@ -207,8 +204,7 @@ def send_payload_from_log(action_log, prompt: str = "") -> str:
|
||||
name = str(a.get("clicked_name") or "")
|
||||
role = str(a.get("clicked_role") or "")
|
||||
summ = str(a.get("result_summary") or "")
|
||||
# focus+type results carry no clicked fields (r47's live miss); the
|
||||
# executor's own "typed the text" wording is the surviving signal
|
||||
# focus+type results carry no clicked fields (r47's live miss); the executor's own "typed the text" wording is the surviving signal
|
||||
if P_COMPOSE_SEL_RE.search(name) or (len(text) >= 20 and (
|
||||
role == "textbox" or "typed the text" in summ.lower())):
|
||||
typed.append(text)
|
||||
@@ -229,8 +225,7 @@ def send_payload_from_log(action_log, prompt: str = "") -> str:
|
||||
typed.append(sub_text)
|
||||
if not typed:
|
||||
return ""
|
||||
# the task usually quotes the message; a candidate echoed there beats a
|
||||
# longer search query or a garbled retype
|
||||
# the task usually quotes the message; a candidate echoed there beats a longer search query or a garbled retype
|
||||
for t in reversed(typed):
|
||||
if t in (prompt or ""):
|
||||
return t
|
||||
@@ -268,9 +263,7 @@ def is_readonly_template(steps) -> bool:
|
||||
return all(s.get("action") in P_READONLY_ACTIONS for s in steps)
|
||||
|
||||
|
||||
# A batch READ is useless if it doesn't hand the data back. We return each item's
|
||||
# read output, capped so a 20-item batch stays cheap, and stay honest about
|
||||
# failures (named, with the error) and truncation (named, never silently dropped).
|
||||
# A batch READ is useless if it doesn't hand the data back. We return each item's read output, capped so a 20-item batch stays cheap, and stay honest about failures (named, with the error) and truncation (named, never silently dropped).
|
||||
P_MAX_ITEM_CHARS = 500
|
||||
P_MAX_TOTAL_CHARS = 6000
|
||||
|
||||
|
||||
@@ -23,8 +23,7 @@ import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Zero-cost smell test: only prompts that mention the web at all are worth a
|
||||
# classifier call. False negatives just take the normal path.
|
||||
# Zero-cost smell test: only prompts that mention the web at all are worth a classifier call. False negatives just take the normal path.
|
||||
P_BROWSY_RE = re.compile(
|
||||
r"https?://|www\.|\b[a-z0-9-]+\.(com|org|net|io|co|ai|dev|app)\b"
|
||||
r"|\b(browse|browser|website|web ?page|webpage|site|url|tab)\b"
|
||||
|
||||
@@ -15,10 +15,7 @@ BROWSER_HISTORY: dict[str, list[dict]] = {}
|
||||
# Cap history to prevent unbounded growth on long-lived browsers.
|
||||
MAX_HISTORY_MESSAGES = 30
|
||||
|
||||
# Per-apex-domain advisory notes, distilled from the agent's own ReportProgress
|
||||
# working_memory. Process-lifetime only (never written to disk); seeds a later
|
||||
# agent on the same domain so it skips re-learning the same quirks. Advisory
|
||||
# text only, never auto-executed.
|
||||
# Per-apex-domain advisory notes, distilled from the agent's own ReportProgress working_memory. Process-lifetime only (never written to disk); seeds a later agent on the same domain so it skips re-learning the same quirks. Advisory text only, never auto-executed.
|
||||
DOMAIN_NOTES: dict[str, str] = {}
|
||||
MAX_DOMAIN_NOTE_CHARS = 600
|
||||
|
||||
@@ -94,8 +91,7 @@ def prune_old_screenshots(messages: list[dict], keep_first: bool = True, keep_re
|
||||
return collapsed
|
||||
|
||||
|
||||
# Sentinel prefixing the auto-attached element list on mutating action results.
|
||||
# Lives here so the attacher (browser_agent) and the pruner share one spelling.
|
||||
# Sentinel prefixing the auto-attached element list on mutating action results. Lives here so the attacher (browser_agent) and the pruner share one spelling.
|
||||
PAGE_STATE_MARKER = "[page state after action]"
|
||||
P_STATE_STUB = "[stale page state pruned; see the latest action result for current state]"
|
||||
P_HEAVY_READ_TOOLS = {"BrowserListInteractives", "BrowserGetText"}
|
||||
@@ -338,8 +334,7 @@ def trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict]
|
||||
target_tail_size = max_messages - 1 # leave room for the summary message
|
||||
cut_index: int | None = None
|
||||
|
||||
# First pass: walk forward looking for the EARLIEST clean cut point that
|
||||
# gets us under the cap. This preserves the most recent detail.
|
||||
# First pass: walk forward looking for the EARLIEST clean cut point that gets us under the cap. This preserves the most recent detail.
|
||||
for i in range(1, len(messages)):
|
||||
if not p_is_fresh_user_message(messages[i]):
|
||||
continue
|
||||
@@ -347,10 +342,7 @@ def trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict]
|
||||
cut_index = i
|
||||
break
|
||||
|
||||
# Second pass: if no cut point gets us under the cap (e.g. the current
|
||||
# turn alone is bigger than max_messages), use the LATEST clean cut point
|
||||
# available. The tail will still exceed the cap, but it's the smallest
|
||||
# safe history we can produce; and any compaction is better than none.
|
||||
# Second pass: if no cut point gets us under the cap (e.g. the current turn alone is bigger than max_messages), use the LATEST clean cut point available. The tail will still exceed the cap, but it's the smallest safe history we can produce; and any compaction is better than none.
|
||||
if cut_index is None:
|
||||
for i in range(len(messages) - 1, 0, -1):
|
||||
if p_is_fresh_user_message(messages[i]):
|
||||
@@ -358,12 +350,10 @@ def trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict]
|
||||
break
|
||||
|
||||
if cut_index is None:
|
||||
# No clean cut anywhere in the history. Return original; better to
|
||||
# exceed the cap than to corrupt the conversation.
|
||||
# No clean cut anywhere in the history. Return original; better to exceed the cap than to corrupt the conversation.
|
||||
return list(messages)
|
||||
|
||||
# Compact: summarize messages[0..cut_index-1], prepend as a single
|
||||
# user-text message, then keep messages[cut_index..end] verbatim.
|
||||
# Compact: summarize messages[0..cut_index-1], prepend as a single user-text message, then keep messages[cut_index..end] verbatim.
|
||||
summary_text = p_summarize_messages(messages[:cut_index])
|
||||
summary_msg = {"role": "user", "content": summary_text}
|
||||
return [summary_msg] + list(messages[cut_index:])
|
||||
|
||||
@@ -10,9 +10,7 @@ prevents the model from burning the entire turn budget on a failing approach.
|
||||
import json
|
||||
import re
|
||||
|
||||
# Tools that are read-only / idempotent and should NOT count toward loop
|
||||
# detection. Repeating these is normal (scrolling through a feed, taking
|
||||
# successive screenshots, polling for an element to appear).
|
||||
# Tools that are read-only / idempotent and should NOT count toward loop detection. Repeating these is normal (scrolling through a feed, taking successive screenshots, polling for an element to appear).
|
||||
LOOP_DETECTION_EXCLUDED_TOOLS = {
|
||||
"BrowserScreenshot",
|
||||
"BrowserGetText",
|
||||
@@ -32,13 +30,7 @@ P_LOOP_REPEAT_THRESHOLD = 2 # the SECOND identical (tool,input,result) is alrea
|
||||
LOOP_HARD_CAP = 5
|
||||
|
||||
|
||||
# Universal close-affordance vocabulary for blocking popups (cookie walls,
|
||||
# upsells, app-install nags, coachmarks). These phrases sit on a throwaway
|
||||
# dismiss and NEVER on a control a real task needs (you never "No thanks" your
|
||||
# way through a send), so a mechanical dismiss of one cannot close something the
|
||||
# task required. Deliberately omits generic "Close"/"Dismiss"/"Skip", which DO
|
||||
# appear on needed dialogs (e.g. "Close your conversation"). Keys on the pattern,
|
||||
# not any one site, so it generalizes.
|
||||
# Universal close-affordance vocabulary for blocking popups (cookie walls, upsells, app-install nags, coachmarks). These phrases sit on a throwaway dismiss and NEVER on a control a real task needs (you never "No thanks" your way through a send), so a mechanical dismiss of one cannot close something the task required. Deliberately omits generic "Close"/"Dismiss"/"Skip", which DO appear on needed dialogs (e.g. "Close your conversation"). Keys on the pattern, not any one site, so it generalizes.
|
||||
P_DISMISS_NAMES = frozenset({
|
||||
"no thanks", "no, thanks", "maybe later", "not now", "skip for now",
|
||||
"remind me later", "got it", "decline", "no, maybe later", "not interested",
|
||||
@@ -118,16 +110,9 @@ LOOP_WARNING_TEXT = (
|
||||
)
|
||||
|
||||
|
||||
# --- Stagnation detection -------------------------------------------------
|
||||
# Distinct from the exact-repeat loop above. The agent can be "busy but stuck":
|
||||
# trying selector A, then B, then C, all failing. The inputs differ so the
|
||||
# exact-repeat detector never fires, yet the page never changes. We watch for a
|
||||
# run of state-mutating actions that produced no URL change AND looked like
|
||||
# failures (or just repeated the same observation), and nudge the model down
|
||||
# the strategy ladder before it burns the whole turn budget.
|
||||
# --- Stagnation detection ------------------------------------------------- Distinct from the exact-repeat loop above. The agent can be "busy but stuck": trying selector A, then B, then C, all failing. The inputs differ so the exact-repeat detector never fires, yet the page never changes. We watch for a run of state-mutating actions that produced no URL change AND looked like failures (or just repeated the same observation), and nudge the model down the strategy ladder before it burns the whole turn budget.
|
||||
|
||||
# Read-only / meta tools don't count toward stagnation (same exemption set as
|
||||
# the loop detector): re-orienting is not "being stuck".
|
||||
# Read-only / meta tools don't count toward stagnation (same exemption set as the loop detector): re-orienting is not "being stuck".
|
||||
P_STAGNATION_NEUTRAL_TOOLS = LOOP_DETECTION_EXCLUDED_TOOLS
|
||||
STAGNATION_ESCALATION_AT = 3
|
||||
STAGNATION_MAX = 5
|
||||
@@ -229,11 +214,7 @@ def stagnation_exhausted(streak: int) -> bool:
|
||||
return streak >= STAGNATION_MAX
|
||||
|
||||
|
||||
# --- completion honesty gate ----------------------------------------------
|
||||
# A model that ends its turn is NOT proof the goal happened. The worst ghost we
|
||||
# measured: multi-minute runs where every tool errored, still reported
|
||||
# "completed". This deterministic gate reality-checks the run before we let the
|
||||
# status say "done", so a fake success is reported as the failure it actually is.
|
||||
# --- completion honesty gate ---------------------------------------------- A model that ends its turn is NOT proof the goal happened. The worst ghost we measured: multi-minute runs where every tool errored, still reported "completed". This deterministic gate reality-checks the run before we let the status say "done", so a fake success is reported as the failure it actually is.
|
||||
|
||||
# State-changing tools: a task that needed to DO something must land one of these.
|
||||
P_PRODUCTIVE_TOOLS = {
|
||||
@@ -247,12 +228,7 @@ P_READ_TOOLS = {
|
||||
}
|
||||
|
||||
|
||||
# A card the agent can't make progress on, EITHER gone (closed/dashboard not open;
|
||||
# unrecoverable) OR hung (a wedged tab where every command times out / the page
|
||||
# never responds). Both look the same to the agent: retrying just burns time (the
|
||||
# 20-minute LinkedIn spin), so we fail fast. The streak (reset on any good result)
|
||||
# absorbs a one-off transient; only a SUSTAINED pattern trips it, so a merely-busy
|
||||
# page that recovers is never mistaken for dead.
|
||||
# A card the agent can't make progress on, EITHER gone (closed/dashboard not open; unrecoverable) OR hung (a wedged tab where every command times out / the page never responds). Both look the same to the agent: retrying just burns time (the 20-minute LinkedIn spin), so we fail fast. The streak (reset on any good result) absorbs a one-off transient; only a SUSTAINED pattern trips it, so a merely-busy page that recovers is never mistaken for dead.
|
||||
P_CARD_GONE_MARKERS = (
|
||||
"not an electron webview", # card closed / destroyed
|
||||
"no dashboard is connected", # dashboard view not mounted
|
||||
@@ -267,11 +243,7 @@ def card_is_unavailable(result: dict) -> bool:
|
||||
return any(m in err for m in P_CARD_GONE_MARKERS)
|
||||
|
||||
|
||||
# Errors where the action MISSED but the page is alive (stale index after a
|
||||
# reshuffle, a transient overlay covering the target, off-screen). The page
|
||||
# itself is fine, so re-attaching the CURRENT element list to the error lets the
|
||||
# model re-act next turn instead of burning a turn re-listing. This NEVER retries
|
||||
# the action (no double-send risk); it only enriches the error with fresh state.
|
||||
# Errors where the action MISSED but the page is alive (stale index after a reshuffle, a transient overlay covering the target, off-screen). The page itself is fine, so re-attaching the CURRENT element list to the error lets the model re-act next turn instead of burning a turn re-listing. This NEVER retries the action (no double-send risk); it only enriches the error with fresh state.
|
||||
P_RECOVERABLE_ERR_MARKERS = (
|
||||
"no longer valid", "no node with given id", "page may have changed",
|
||||
"covered it", "obscured", "intercepted", "not clickable",
|
||||
@@ -288,9 +260,7 @@ def recoverable_tool_error(err: str) -> bool:
|
||||
return any(m in e for m in P_RECOVERABLE_ERR_MARKERS)
|
||||
|
||||
|
||||
# Actions that DIRTY the page so replay-from-here is no longer equivalent to a
|
||||
# clean dispatch. Navigation and reads don't dirty anything (they just get us to
|
||||
# the page), so the deferred replay re-check is allowed after only those.
|
||||
# Actions that DIRTY the page so replay-from-here is no longer equivalent to a clean dispatch. Navigation and reads don't dirty anything (they just get us to the page), so the deferred replay re-check is allowed after only those.
|
||||
P_REPLAY_DIRTYING_TOOLS = {
|
||||
"BrowserType", "BrowserClick", "BrowserClickIndex",
|
||||
"BrowserPressKey", "BrowserScroll", "BrowserBatch",
|
||||
@@ -304,8 +274,7 @@ def replay_recheck_is_safe(action_log: list[dict]) -> bool:
|
||||
return not any(a.get("tool") in P_REPLAY_DIRTYING_TOOLS for a in action_log)
|
||||
|
||||
|
||||
# What the user ASKED FOR outranks how the sub narrated it: an info ask can
|
||||
# never replay (the answer must be fresh), an action ask can.
|
||||
# What the user ASKED FOR outranks how the sub narrated it: an info ask can never replay (the answer must be fresh), an action ask can.
|
||||
P_INFO_ASK_RE = re.compile(
|
||||
r"\b(tell me|what(?:'s| is| are)|how (?:many|much)|count|list|summari[sz]e|"
|
||||
r"extract|find (?:me|out)|show me|look up|read (?:me|the)|get the|give me|which|"
|
||||
|
||||
@@ -140,8 +140,7 @@ def clear(wipe_disk: bool = False) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# Shipped starting priors: the hard-won universal lessons from this codebase's own
|
||||
# browser work, so tier 3 is useful on day one and accrues more as sites confirm them.
|
||||
# Shipped starting priors: the hard-won universal lessons from this codebase's own browser work, so tier 3 is useful on day one and accrues more as sites confirm them.
|
||||
P_SEED = (
|
||||
"A message composer CLEARS when the send goes through; the empty box IS your "
|
||||
"confirmation, do not hunt the thread for the sent text to 'verify'.",
|
||||
|
||||
@@ -28,8 +28,7 @@ from collections import Counter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Map each tool to the waterfall tier it represents, so per-tier speed/cost
|
||||
# rolls up cleanly. Control/meta tools are their own bucket.
|
||||
# Map each tool to the waterfall tier it represents, so per-tier speed/cost rolls up cleanly. Control/meta tools are their own bucket.
|
||||
P_TIER = {
|
||||
"BrowserDetectWebMCP": "t1_webmcp",
|
||||
"BrowserListRoutes": "t2_route_list",
|
||||
@@ -94,9 +93,7 @@ def p_append(filename: str, obj: dict) -> None:
|
||||
logger.debug(f"[browser-metrics] write failed: {e}")
|
||||
|
||||
|
||||
# A task prompt can carry a literal secret ("log in with password hunter2");
|
||||
# scrub the value before it lands in tasks.jsonl. Keyword+value and known
|
||||
# token prefixes only; the task's normal words stay greppable.
|
||||
# A task prompt can carry a literal secret ("log in with password hunter2"); scrub the value before it lands in tasks.jsonl. Keyword+value and known token prefixes only; the task's normal words stay greppable.
|
||||
P_TASK_SECRET_RE = re.compile(
|
||||
r"\b(password|passcode|passphrase|pin|otp|token|secret|api[_-]?key)\b\s*(?:is|[:=])?\s*\S+",
|
||||
re.I,
|
||||
|
||||
@@ -47,8 +47,7 @@ P_MIN_TURNS_TO_LEARN = 4 # a 1-3 turn run taught nothing worth a durable bulle
|
||||
# In-memory hot cache: host -> list[str] bullets.
|
||||
CACHE: dict[str, list[str]] = {}
|
||||
|
||||
# Same sensitivity guard the skill layer uses: a strategy bullet must never carry
|
||||
# a secret (email/token/etc.). We scrub bullets through this before persisting.
|
||||
# Same sensitivity guard the skill layer uses: a strategy bullet must never carry a secret (email/token/etc.). We scrub bullets through this before persisting.
|
||||
P_SECRET_RE = re.compile(
|
||||
r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" # email
|
||||
r"|\b(sk-|ghp_|gho_|pk_|xox[bap]-|AIza|eyJ)[A-Za-z0-9._-]+" # token prefixes
|
||||
@@ -290,8 +289,7 @@ async def distill_and_store(host, task, working_memory, summary,
|
||||
return False
|
||||
stored = p_store(host, new_bullets)
|
||||
changed = stored != existing
|
||||
# Fold any site-agnostic lessons into the cross-site meta-playbook (no extra
|
||||
# LLM call, they rode along in this same reply). Best-effort, never fatal.
|
||||
# Fold any site-agnostic lessons into the cross-site meta-playbook (no extra LLM call, they rode along in this same reply). Best-effort, never fatal.
|
||||
try:
|
||||
from backend.apps.agents.browser import browser_meta_playbook
|
||||
browser_meta_playbook.absorb(p_parse_universal(text))
|
||||
|
||||
@@ -42,9 +42,7 @@ def save_page_data(cwd: str | None, session_id: str, filename: str, content: str
|
||||
dest_dir = p_dest_dir(cwd, session_id)
|
||||
dest_real = os.path.realpath(dest_dir)
|
||||
full = os.path.realpath(os.path.join(dest_dir, name))
|
||||
# realpath + os.sep guard: defeats traversal, absolute paths, symlinks, AND a
|
||||
# prefix-collision sibling (browser-data vs browser-data-evil). basename already
|
||||
# neutralizes most of it; this is the belt to that suspenders.
|
||||
# realpath + os.sep guard: defeats traversal, absolute paths, symlinks, AND a prefix-collision sibling (browser-data vs browser-data-evil). basename already neutralizes most of it; this is the belt to that suspenders.
|
||||
if full != dest_real and not full.startswith(dest_real + os.sep):
|
||||
return "Save failed: that filename escapes the workspace; use a plain name."
|
||||
with open(full, "w", encoding="utf-8") as f:
|
||||
|
||||
@@ -6,10 +6,7 @@ prompt, and the turn/report invariants. Exceeds the 300-LOC soft ceiling on
|
||||
purpose because it is one cohesive data blob, not multiple responsibilities.
|
||||
"""
|
||||
|
||||
# Two prompt levers that A/B-proved out and now ship unconditionally. THINK_SHORTER
|
||||
# (no prose beside action tools; ReportProgress IS the thinking) cut per-turn output
|
||||
# ~28% and roughly halved narration turns. MERGE_VERIFY (a confirmed `expect` is the
|
||||
# proof, skip the re-check) drops a wasted round-trip at the end.
|
||||
# Two prompt levers that A/B-proved out and now ship unconditionally. THINK_SHORTER (no prose beside action tools; ReportProgress IS the thinking) cut per-turn output ~28% and roughly halved narration turns. MERGE_VERIFY (a confirmed `expect` is the proof, skip the re-check) drops a wasted round-trip at the end.
|
||||
P_THINK_SHORTER = (
|
||||
"Do NOT write a free-text sentence next to your action tools: your ReportProgress "
|
||||
"fields ARE your thinking, and a separate prose explanation just repeats them and slows "
|
||||
@@ -32,9 +29,7 @@ MODEL_MAP = {
|
||||
"haiku": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
|
||||
# The change an action should cause, declared by the agent and CONFIRMED after the
|
||||
# action runs (success is observed, never assumed). A hit returns fast; a miss tells
|
||||
# the agent it may not have worked instead of letting it claim a false success.
|
||||
# The change an action should cause, declared by the agent and CONFIRMED after the action runs (success is observed, never assumed). A hit returns fast; a miss tells the agent it may not have worked instead of letting it claim a false success.
|
||||
P_EXPECT_DESC = {
|
||||
"type": "string",
|
||||
"description": (
|
||||
@@ -648,11 +643,7 @@ BROWSER_TOOLS_SCHEMA = [
|
||||
},
|
||||
]
|
||||
|
||||
# Schema-forced batching: the model ignored every prompt-level batching
|
||||
# invitation (0 adoptions across 8 measured runs), so the single-step mutating
|
||||
# tools are not offered to it at all; acting means a BrowserBatch array, and
|
||||
# the one deliberate solo path is BrowserClickIndex (irreversible step with
|
||||
# expect, or a text-box fill). Executors and replay still support everything.
|
||||
# Schema-forced batching: the model ignored every prompt-level batching invitation (0 adoptions across 8 measured runs), so the single-step mutating tools are not offered to it at all; acting means a BrowserBatch array, and the one deliberate solo path is BrowserClickIndex (irreversible step with expect, or a text-box fill). Executors and replay still support everything.
|
||||
P_SOLO_MUTATORS_HIDDEN = {"BrowserNavigate", "BrowserClick", "BrowserType", "BrowserScroll", "BrowserPressKey"}
|
||||
MODEL_VISIBLE_TOOLS = [t for t in BROWSER_TOOLS_SCHEMA if t["name"] not in P_SOLO_MUTATORS_HIDDEN]
|
||||
|
||||
@@ -674,8 +665,7 @@ ACTION_MAP = {
|
||||
"BrowserDetectWebMCP": "detect_webmcp",
|
||||
"BrowserListRoutes": "list_routes",
|
||||
"BrowserReplayRoute": "replay_route",
|
||||
# Internal replay primitive (skill replay calls it directly; not in the
|
||||
# LLM-facing schema). Re-resolves a click target by role+name.
|
||||
# Internal replay primitive (skill replay calls it directly; not in the LLM-facing schema). Re-resolves a click target by role+name.
|
||||
"BrowserClickByName": "click_by_name",
|
||||
}
|
||||
|
||||
@@ -905,9 +895,7 @@ SYSTEM_PROMPT = (
|
||||
|
||||
MAX_TURNS = 40
|
||||
|
||||
# Tools that count as "action tools"; calling any of these in a turn requires
|
||||
# the model to also call ReportProgress in the same turn (after the first
|
||||
# turn). Read-only tools and meta tools are exempt.
|
||||
# Tools that count as "action tools"; calling any of these in a turn requires the model to also call ReportProgress in the same turn (after the first turn). Read-only tools and meta tools are exempt.
|
||||
ACTION_TOOLS_REQUIRING_REPORT = {
|
||||
"BrowserClick",
|
||||
"BrowserType",
|
||||
|
||||
@@ -63,9 +63,7 @@ def audit(metrics_dir: str) -> dict:
|
||||
skill_events = p_read_jsonl(os.path.join(metrics_dir, "skill_events.jsonl"))
|
||||
findings: list[dict] = []
|
||||
|
||||
# 1) THRASH: a skill re-versioned (edit) or sent to quarantine many times but
|
||||
# never PROMOTED, the kinds the skill layer actually records. It keeps re-learning
|
||||
# and never earns trust = the recorded steps don't hold up at replay.
|
||||
# 1) THRASH: a skill re-versioned (edit) or sent to quarantine many times but never PROMOTED, the kinds the skill layer actually records. It keeps re-learning and never earns trust = the recorded steps don't hold up at replay.
|
||||
churn: dict[tuple, int] = defaultdict(int)
|
||||
promotes: dict[tuple, int] = defaultdict(int)
|
||||
for e in skill_events:
|
||||
@@ -136,8 +134,7 @@ def audit(metrics_dir: str) -> dict:
|
||||
|
||||
|
||||
def p_host_of_task(task: dict) -> str:
|
||||
# tasks.jsonl doesn't store host directly; task_sig is host-agnostic, so fall
|
||||
# back to a coarse bucket. browser_id groups a card's runs well enough for norms.
|
||||
# tasks.jsonl doesn't store host directly; task_sig is host-agnostic, so fall back to a coarse bucket. browser_id groups a card's runs well enough for norms.
|
||||
return task.get("browser_id") or task.get("task_sig") or "unknown"
|
||||
|
||||
|
||||
|
||||
@@ -72,14 +72,11 @@ P_MAX_MEM_SKILLS = 200
|
||||
P_MAX_DISK_SKILLS = 1000 # bound the on-disk library; evict oldest by mtime
|
||||
P_SKILL_FORMAT_VERSION = 1
|
||||
|
||||
# Trust state (the verify gate). A skill moves PROBATION -> TRUSTED only by a
|
||||
# successful end-to-end replay; an unproven (probation) skill that fails a replay
|
||||
# goes to QUARANTINE and is never replayed again (task falls back to pure LLM).
|
||||
# Trust state (the verify gate). A skill moves PROBATION -> TRUSTED only by a successful end-to-end replay; an unproven (probation) skill that fails a replay goes to QUARANTINE and is never replayed again (task falls back to pure LLM).
|
||||
PROBATION = "probation"
|
||||
TRUSTED = "trusted"
|
||||
QUARANTINE = "quarantine"
|
||||
# A proven skill tolerates this many consecutive transient replay misses before
|
||||
# it's demoted back to probation (forced to re-earn trust).
|
||||
# A proven skill tolerates this many consecutive transient replay misses before it's demoted back to probation (forced to re-earn trust).
|
||||
P_FAIL_DEMOTE_THRESHOLD = 2
|
||||
|
||||
# Tools that change page state (worth replaying). Reads/meta are never recorded.
|
||||
@@ -154,15 +151,7 @@ def normalize_task(task: str) -> str:
|
||||
return " ".join(toks)
|
||||
|
||||
|
||||
# --- parameterization (reuse one skill for "the same task, different input") ---
|
||||
# A quoted value in the task is treated as a SLOT: it's abstracted out of the
|
||||
# skill key (so `search "shoes"` and `search "hats"` share one skill) and the
|
||||
# value is filled from the LIVE task at replay (so the value is never stored on
|
||||
# disk, a redaction win, and the skill generalizes). Quoting is the explicit,
|
||||
# high-precision signal that this token is a parameter; we never guess.
|
||||
# Lookarounds keep word-internal apostrophes (chen's, don't) from opening a
|
||||
# span; without them every possessive made each task wording a unique sig and
|
||||
# silently disabled skill matching for those tasks.
|
||||
# --- parameterization (reuse one skill for "the same task, different input") --- A quoted value in the task is treated as a SLOT: it's abstracted out of the skill key (so `search "shoes"` and `search "hats"` share one skill) and the value is filled from the LIVE task at replay (so the value is never stored on disk, a redaction win, and the skill generalizes). Quoting is the explicit, high-precision signal that this token is a parameter; we never guess. Lookarounds keep word-internal apostrophes (chen's, don't) from opening a span; without them every possessive made each task wording a unique sig and silently disabled skill matching for those tasks.
|
||||
P_QUOTE_RE = re.compile(r'(?<!\w)["“”‘’\']([^"“”‘’\']{1,200})["“”‘’\'](?!\w)')
|
||||
P_SLOT_TOKEN = " slotvalue "
|
||||
|
||||
@@ -355,9 +344,7 @@ def first_unsafe_step(steps: list[dict]) -> tuple[int, str]:
|
||||
probe = None
|
||||
if tool in ("BrowserClickByName", "BrowserClick"):
|
||||
name = p.get("name") or p.get("selector") or ""
|
||||
# Real Send controls have short names ("Send", "Send InMail"); a
|
||||
# 100ch profile-card blob containing "Send a..." is not one, and
|
||||
# flagging it cut a 6-step prefix to 1 (measured, r19).
|
||||
# Real Send controls have short names ("Send", "Send InMail"); a 100ch profile-card blob containing "Send a..." is not one, and flagging it cut a 6-step prefix to 1 (measured, r19).
|
||||
if len(name) <= 40:
|
||||
probe = {"action": "click", "name": name}
|
||||
elif tool == "BrowserType":
|
||||
@@ -550,13 +537,7 @@ def p_host_skills(host: str) -> dict[str, dict]:
|
||||
return out
|
||||
|
||||
|
||||
# --- composition (build on what's already proven) --------------------------
|
||||
# When a freshly learned skill's steps OPEN with the full step list of an
|
||||
# already-TRUSTED skill on the same host, we record that it "builds on" the
|
||||
# sub-skill. The big steps stay inline (the skill is self-contained and robust on
|
||||
# its own); the link is provenance + a safety wire: if that foundation is later
|
||||
# deprecated or goes stale, every skill built on it is knocked back to probation
|
||||
# so it must re-prove instead of silently riding a now-broken sub-sequence.
|
||||
# --- composition (build on what's already proven) -------------------------- When a freshly learned skill's steps OPEN with the full step list of an already-TRUSTED skill on the same host, we record that it "builds on" the sub-skill. The big steps stay inline (the skill is self-contained and robust on its own); the link is provenance + a safety wire: if that foundation is later deprecated or goes stale, every skill built on it is knocked back to probation so it must re-prove instead of silently riding a now-broken sub-sequence.
|
||||
P_COMPOSE_MIN_SUB_STEPS = 2
|
||||
|
||||
|
||||
@@ -619,10 +600,7 @@ def record_skill(host: str, task: str, action_log: list[dict]) -> bool:
|
||||
existing = SKILLS.get(k) or p_load_from_disk(host, sig)
|
||||
|
||||
if existing and steps_equal(existing.get("steps", []), steps):
|
||||
# Same skill re-derived: the replay that triggered this was a transient
|
||||
# miss, not a stale skill. Keep rev + trust; just clear the fail streak.
|
||||
# If it was quarantined (a known-bad distillation), leave it quarantined
|
||||
# so the task keeps running on the pure-LLM baseline, never re-replayed.
|
||||
# Same skill re-derived: the replay that triggered this was a transient miss, not a stale skill. Keep rev + trust; just clear the fail streak. If it was quarantined (a known-bad distillation), leave it quarantined so the task keeps running on the pure-LLM baseline, never re-replayed.
|
||||
existing["fails"] = 0
|
||||
existing["recorded_at"] = time.time()
|
||||
existing["persisted"] = persistable
|
||||
@@ -684,12 +662,7 @@ def find_skill(host: str, task: str) -> dict | None:
|
||||
return hit
|
||||
|
||||
|
||||
# --- route hints (advisory reuse when mechanical replay can't run) ---------
|
||||
# Replay is exact-key and refuses send-class flows, so a known route often sits
|
||||
# unused while the model re-explores it. A route HINT closes that gap: the best
|
||||
# similar skill is rendered as advisory text the live agent adapts and verifies,
|
||||
# so it generalizes across wordings and stays send-safe (the agent still
|
||||
# confirms everything; a stale hint just wastes one glance).
|
||||
# --- route hints (advisory reuse when mechanical replay can't run) --------- Replay is exact-key and refuses send-class flows, so a known route often sits unused while the model re-explores it. A route HINT closes that gap: the best similar skill is rendered as advisory text the live agent adapts and verifies, so it generalizes across wordings and stays send-safe (the agent still confirms everything; a stale hint just wastes one glance).
|
||||
P_HINT_MIN_OVERLAP = 0.5
|
||||
P_HINT_MAX_STEPS = 10
|
||||
|
||||
@@ -755,8 +728,7 @@ def render_route_hint(skill: dict, task: str, score: float) -> tuple[str, list[t
|
||||
return "", []
|
||||
from backend.apps.agents.browser import browser_batch_replay
|
||||
_, values = template_task(task)
|
||||
# first_unsafe_step is the batching boundary (it stops at composer typing
|
||||
# too); the IRREVERSIBLE flag goes only on genuinely outward-facing clicks
|
||||
# first_unsafe_step is the batching boundary (it stops at composer typing too); the IRREVERSIBLE flag goes only on genuinely outward-facing clicks
|
||||
unsafe_i, p_why = first_unsafe_step(steps)
|
||||
lines = []
|
||||
for i, s in enumerate(steps):
|
||||
|
||||
@@ -28,12 +28,7 @@ import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# One probe, built per wait so it can also look for the agent's target. Returns:
|
||||
# ready - document.readyState === 'complete'
|
||||
# quiet - ms since the last network resource (the old network-idle signal)
|
||||
# elems - element count; the loop watches it stop changing = DOM/visual settle
|
||||
# found - the agent's `until` target is present + visible (visible text or selector)
|
||||
# `until` is JSON-encoded into a string literal, so it's data, never executable.
|
||||
# One probe, built per wait so it can also look for the agent's target. Returns: ready - document.readyState === 'complete' quiet - ms since the last network resource (the old network-idle signal) elems - element count; the loop watches it stop changing = DOM/visual settle found - the agent's `until` target is present + visible (visible text or selector) `until` is JSON-encoded into a string literal, so it's data, never executable.
|
||||
def p_probe_js(until: str) -> str:
|
||||
spec = json.dumps(until or "")
|
||||
return (
|
||||
@@ -53,12 +48,7 @@ def p_probe_js(until: str) -> str:
|
||||
P_QUIET_WINDOW_MS = 400 # network must be silent this long to count as settled
|
||||
P_FLOOR_MS = 250 # never return before this (a momentary gap isn't 'settled')
|
||||
P_POLL_MS = 150
|
||||
# A healthy probe is tens of ms. A busy-but-fine SPA (heavy main-thread work mid-
|
||||
# hydration) can occasionally block longer, so a slow probe is NOT proof of death,
|
||||
# it's just a reason to stop THIS wait early instead of inheriting the 30s command
|
||||
# timeout. We bound each probe at this, and after a few consecutive non-responses
|
||||
# we surface hung=True as a SIGNAL (the loop folds it into a cross-command streak
|
||||
# and only then acts), never as a unilateral abort from a single wait.
|
||||
# A healthy probe is tens of ms. A busy-but-fine SPA (heavy main-thread work mid- hydration) can occasionally block longer, so a slow probe is NOT proof of death, it's just a reason to stop THIS wait early instead of inheriting the 30s command timeout. We bound each probe at this, and after a few consecutive non-responses we surface hung=True as a SIGNAL (the loop folds it into a cross-command streak and only then acts), never as a unilateral abort from a single wait.
|
||||
P_PROBE_TIMEOUT_S = 2.5
|
||||
MAX_PROBE_TIMEOUTS = 3
|
||||
|
||||
@@ -113,10 +103,7 @@ async def smart_wait(execute_fn, browser_id, tab_id, max_ms, *, until="",
|
||||
await asyncio.sleep(min(poll_ms, max(0, max_ms - p_elapsed())) / 1000)
|
||||
if p_elapsed() >= max_ms:
|
||||
break
|
||||
# Bound each probe so a wedged tab can't make us inherit the 30s command
|
||||
# timeout. A timeout is a not-responding signal (not a verdict): count
|
||||
# consecutive ones and surface hung only after the threshold; any non-
|
||||
# timeout error is a different problem, treated as 'keep waiting'.
|
||||
# Bound each probe so a wedged tab can't make us inherit the 30s command timeout. A timeout is a not-responding signal (not a verdict): count consecutive ones and surface hung only after the threshold; any non- timeout error is a different problem, treated as 'keep waiting'.
|
||||
try:
|
||||
res = await asyncio.wait_for(
|
||||
execute_fn("BrowserEvaluate", {"expression": probe_js}, browser_id, tab_id),
|
||||
@@ -146,9 +133,7 @@ async def smart_wait(execute_fn, browser_id, tab_id, max_ms, *, until="",
|
||||
last_elems = elems
|
||||
elems_changed_at = time.monotonic()
|
||||
dom_stable_ms = (time.monotonic() - elems_changed_at) * 1000
|
||||
# Confirming an action (target_only): only the target appearing counts; a
|
||||
# bare settle without it keeps waiting, so a late-rendering result isn't a
|
||||
# false miss. Bounded by max_ms either way.
|
||||
# Confirming an action (target_only): only the target appearing counts; a bare settle without it keeps waiting, so a late-rendering result isn't a false miss. Bounded by max_ms either way.
|
||||
if target_only and until:
|
||||
if probe.get("found"):
|
||||
settled = True
|
||||
|
||||
@@ -3,10 +3,7 @@ from typing import Optional
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
# Secret shapes that must never ride along when we ship a stderr tail or an
|
||||
# error string to telemetry. own_key mode means the subprocess stderr can echo
|
||||
# the user's OWN provider key, so this scrub is the wall between a diagnostic
|
||||
# and a key leak; over-redacting is fine, leaking is not.
|
||||
# Secret shapes that must never ride along when we ship a stderr tail or an error string to telemetry. own_key mode means the subprocess stderr can echo the user's OWN provider key, so this scrub is the wall between a diagnostic and a key leak; over-redacting is fine, leaking is not.
|
||||
P_TELEMETRY_SECRET_PATTERNS = (
|
||||
re.compile(r"sk-ant-[A-Za-z0-9_\-]{12,}"),
|
||||
re.compile(r"sk-[A-Za-z0-9_\-]{16,}"),
|
||||
@@ -28,9 +25,7 @@ def redact_for_telemetry(text: str, *, limit: int = 2000) -> str:
|
||||
return text[-limit:]
|
||||
|
||||
|
||||
# Patterns that indicate an upstream transient problem (overload / rate limit /
|
||||
# infra blip), safe to silently retry with backoff. Checked against the
|
||||
# stringified exception from claude_agent_sdk / Claude CLI.
|
||||
# Patterns that indicate an upstream transient problem (overload / rate limit / infra blip), safe to silently retry with backoff. Checked against the stringified exception from claude_agent_sdk / Claude CLI.
|
||||
TRANSIENT_CAPACITY_PATTERNS = re.compile(
|
||||
r"(?:\b(?:429|500|502|503|504|529)\b"
|
||||
r"|overloaded"
|
||||
@@ -45,12 +40,7 @@ TRANSIENT_CAPACITY_PATTERNS = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# A first message ships the full tool schema; 9Router rewrites Anthropic
|
||||
# tools[].input_schema into Gemini function_declarations / OpenAI params, and a
|
||||
# construct it can't translate makes the provider 400 (INVALID_ARGUMENT) with
|
||||
# zero tokens. That is NOT auth, reconnecting won't help, the request shape is
|
||||
# wrong, so we classify it apart and stop the catch-all from showing a
|
||||
# "reconnect your subscription" card for a tool-schema 400.
|
||||
# A first message ships the full tool schema; 9Router rewrites Anthropic tools[].input_schema into Gemini function_declarations / OpenAI params, and a construct it can't translate makes the provider 400 (INVALID_ARGUMENT) with zero tokens. That is NOT auth, reconnecting won't help, the request shape is wrong, so we classify it apart and stop the catch-all from showing a "reconnect your subscription" card for a tool-schema 400.
|
||||
P_TRANSLATION_ERROR_PATTERNS = re.compile(
|
||||
r"(?:function_declarations"
|
||||
r"|invalid_argument"
|
||||
@@ -64,12 +54,7 @@ P_TRANSLATION_ERROR_PATTERNS = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Patterns that look rate-limit-ish but are actually non-transient (user quota,
|
||||
# auth, context-window tier gate). Must NOT retry, upgrading, reauthing, or
|
||||
# trimming context is required. The long-context-required variant is what
|
||||
# Anthropic returns when an OAuth Pro/Max account ships a request whose input
|
||||
# exceeds the 200K standard tier and would need the "extra usage" tier; the
|
||||
# user can't recover by waiting, so we surface it instead of looping.
|
||||
# Patterns that look rate-limit-ish but are actually non-transient (user quota, auth, context-window tier gate). Must NOT retry, upgrading, reauthing, or trimming context is required. The long-context-required variant is what Anthropic returns when an OAuth Pro/Max account ships a request whose input exceeds the 200K standard tier and would need the "extra usage" tier; the user can't recover by waiting, so we surface it instead of looping.
|
||||
NON_TRANSIENT_PATTERNS = re.compile(
|
||||
r"(?:usage\s+cap\s+exceeded"
|
||||
r"|reached\s+your\s+OpenSwarm.*plan\s+limit"
|
||||
@@ -143,8 +128,7 @@ def is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
combined = f"{exc!s}\n{extra_text}".strip()
|
||||
if not combined:
|
||||
return False
|
||||
# A tool-schema translation 400 can carry provider/connection wording that
|
||||
# trips the auth regex below; it isn't auth, so don't claim it is.
|
||||
# A tool-schema translation 400 can carry provider/connection wording that trips the auth regex below; it isn't auth, so don't claim it is.
|
||||
if is_translation_error(exc, extra_text):
|
||||
return False
|
||||
return bool(re.search(
|
||||
@@ -201,13 +185,7 @@ def parse_retry_after(exc: BaseException, extra_text: str = "") -> int | None:
|
||||
|
||||
@typechecked
|
||||
def is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
# The Claude CLI's underlying ProcessError stringifies to a generic
|
||||
# "Command failed with exit code 1 / Check stderr output for details";
|
||||
# the real cause (rate_limit_error / No pool capacity available / 429
|
||||
# / overloaded) only surfaces in the subprocess's stderr stream, which
|
||||
# we capture via the SDK's `stderr` callback and pass in as extra_text.
|
||||
# Classify against both so we catch capacity errors regardless of which
|
||||
# channel carried the message.
|
||||
# The Claude CLI's underlying ProcessError stringifies to a generic "Command failed with exit code 1 / Check stderr output for details"; the real cause (rate_limit_error / No pool capacity available / 429 / overloaded) only surfaces in the subprocess's stderr stream, which we capture via the SDK's `stderr` callback and pass in as extra_text. Classify against both so we catch capacity errors regardless of which channel carried the message.
|
||||
combined = f"{exc!s}\n{extra_text}".strip()
|
||||
if not combined:
|
||||
return False
|
||||
@@ -215,15 +193,13 @@ def is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> boo
|
||||
return False
|
||||
if TRANSIENT_CAPACITY_PATTERNS.search(combined):
|
||||
return True
|
||||
# Pool-exhaustion copy from the OpenSwarm proxy ("No pool capacity
|
||||
# available. Try again shortly."), matches the capacity family too.
|
||||
# Pool-exhaustion copy from the OpenSwarm proxy ("No pool capacity available. Try again shortly."), matches the capacity family too.
|
||||
if re.search(r"no\s+pool\s+capacity", combined, re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Exponential-ish backoff schedule (seconds) for silently retrying a transient upstream
|
||||
# capacity error before giving up and surfacing the rate-limit pill.
|
||||
# Exponential-ish backoff schedule (seconds) for silently retrying a transient upstream capacity error before giving up and surfacing the rate-limit pill.
|
||||
CAPACITY_BACKOFFS = [5, 15, 45, 90, 180]
|
||||
|
||||
|
||||
|
||||
@@ -154,8 +154,7 @@ def offer_for_gated_server(server_name: str, settings) -> CuratedEntry | None:
|
||||
server is vetted AND inactive AND not dismissed, reusing the same filter as the preflight."""
|
||||
if not server_name or not isinstance(server_name, str):
|
||||
return None
|
||||
# The hot-path hands us a sanitized slug ("google-workspace"); curated ids are display names
|
||||
# ("Google Workspace"). Match on the slug of both sides so neither form is a load-bearing string.
|
||||
# The hot-path hands us a sanitized slug ("google-workspace"); curated ids are display names ("Google Workspace"). Match on the slug of both sides so neither form is a load-bearing string.
|
||||
slug = sanitize_server_name(server_name)
|
||||
entry = next(
|
||||
(e for e in p_build_available_shortlist(settings) if sanitize_server_name(e["id"]) == slug),
|
||||
|
||||
@@ -13,8 +13,7 @@ class AgentConfig(BaseModel):
|
||||
max_turns: Optional[int] = None
|
||||
target_directory: Optional[str] = None
|
||||
dashboard_id: Optional[str] = None
|
||||
# App cards the user picked to edit. When exactly one resolves, launch
|
||||
# binds the chat's cwd to that app instead of seeding a new "Untitled App".
|
||||
# App cards the user picked to edit. When exactly one resolves, launch binds the chat's cwd to that app instead of seeding a new "Untitled App".
|
||||
selected_app_output_ids: Optional[list[str]] = None
|
||||
|
||||
class ApprovalRequest(BaseModel):
|
||||
@@ -23,14 +22,7 @@ class ApprovalRequest(BaseModel):
|
||||
tool_name: str
|
||||
tool_input: dict[str, Any]
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
# Set when this approval was triggered by the sensitive-path override
|
||||
# rather than the user's normal "ask" policy. Three correlated fields:
|
||||
# - sensitive_pattern: the fnmatch pattern (canonical id; what we
|
||||
# persist into the trusted allowlist if the user opts in).
|
||||
# - sensitive_label: short human label (e.g. "SSH folder (~/.ssh)").
|
||||
# - sensitive_why: plain-English risk explanation; lets the modal
|
||||
# justify itself to a non-developer.
|
||||
# All three None for ordinary "ask" approvals.
|
||||
# Set when this approval was triggered by the sensitive-path override rather than the user's normal "ask" policy. Three correlated fields: - sensitive_pattern: the fnmatch pattern (canonical id; what we persist into the trusted allowlist if the user opts in). - sensitive_label: short human label (e.g. "SSH folder (~/.ssh)"). - sensitive_why: plain-English risk explanation; lets the modal justify itself to a non-developer. All three None for ordinary "ask" approvals.
|
||||
sensitive_pattern: Optional[str] = None
|
||||
sensitive_label: Optional[str] = None
|
||||
sensitive_why: Optional[str] = None
|
||||
@@ -40,14 +32,9 @@ class ApprovalResponse(BaseModel):
|
||||
behavior: Literal["allow", "deny"]
|
||||
message: Optional[str] = None
|
||||
updated_input: Optional[dict[str, Any]] = None
|
||||
# When the user checked "Always allow files like this" on a sensitive-
|
||||
# path approval, the backend persists the matched fnmatch pattern
|
||||
# (from ApprovalRequest.sensitive_pattern) to disk so future writes
|
||||
# against the same pattern skip the modal.
|
||||
# When the user checked "Always allow files like this" on a sensitive- path approval, the backend persists the matched fnmatch pattern (from ApprovalRequest.sensitive_pattern) to disk so future writes against the same pattern skip the modal.
|
||||
trust_pattern: bool = False
|
||||
# "Always approve" button: persist this tool's policy to always_allow so
|
||||
# the same tool stops prompting (the catastrophic/sensitive guards still
|
||||
# fire, so this can't blanket-approve an rm -rf or a sensitive-path write).
|
||||
# "Always approve" button: persist this tool's policy to always_allow so the same tool stops prompting (the catastrophic/sensitive guards still fire, so this can't blanket-approve an rm -rf or a sensitive-path write).
|
||||
set_always_allow: bool = False
|
||||
|
||||
class Message(BaseModel):
|
||||
@@ -124,8 +111,7 @@ class AgentSession(BaseModel):
|
||||
browser_id: Optional[str] = None
|
||||
parent_session_id: Optional[str] = None
|
||||
workflow_test_state: Optional[Literal["running", "complete", "error"]] = None
|
||||
# Browser memory signals, drive the subtle "remembered/learned" card chip so
|
||||
# the user feels the agent getting smarter without lifting a finger.
|
||||
# Browser memory signals, drive the subtle "remembered/learned" card chip so the user feels the agent getting smarter without lifting a finger.
|
||||
memory_recalled: bool = False
|
||||
memory_learned: bool = False
|
||||
needs_fork: bool = False
|
||||
|
||||
@@ -42,10 +42,7 @@ def p_is_gpt5(model: str) -> bool:
|
||||
return any(m.startswith(p) for p in P_GPT5_PREFIXES)
|
||||
|
||||
|
||||
# GPT-5 reasoning models reject sampling knobs: temperature must be the default
|
||||
# (only 1 is allowed), and top_p / penalties / logprobs are unsupported outright.
|
||||
# 9Router 0.3.60 is pinned and forwards whatever the user's picked model carried,
|
||||
# so we strip them at this last hop before OpenAI or the whole request 400s.
|
||||
# GPT-5 reasoning models reject sampling knobs: temperature must be the default (only 1 is allowed), and top_p / penalties / logprobs are unsupported outright. 9Router 0.3.60 is pinned and forwards whatever the user's picked model carried, so we strip them at this last hop before OpenAI or the whole request 400s.
|
||||
P_GPT5_UNSUPPORTED_PARAMS = (
|
||||
"top_p", "top_k", "frequency_penalty", "presence_penalty",
|
||||
"logprobs", "top_logprobs", "logit_bias",
|
||||
|
||||
@@ -7,11 +7,7 @@ from backend.apps.agents.core.seq_log import TERMINAL_STATUSES, seq_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-action browser-command timeouts (seconds). A hung tab makes EVERY command
|
||||
# block to its timeout, so these bound how fast a freeze surfaces. Reads/clicks
|
||||
# operate on an already-loaded page and should be quick; navigation legitimately
|
||||
# loads the network so it gets a longer leash. Was a flat 30s, which let one
|
||||
# wedged page spin for ~20 minutes across retries.
|
||||
# Per-action browser-command timeouts (seconds). A hung tab makes EVERY command block to its timeout, so these bound how fast a freeze surfaces. Reads/clicks operate on an already-loaded page and should be quick; navigation legitimately loads the network so it gets a longer leash. Was a flat 30s, which let one wedged page spin for ~20 minutes across retries.
|
||||
BROWSER_CMD_TIMEOUT_DEFAULT = 15.0 # modest load headroom; still "short" so a wedged tab fails fast
|
||||
BROWSER_CMD_TIMEOUTS = {
|
||||
"navigate": 25.0, # a real page load can be slow (more leash under load)
|
||||
@@ -19,11 +15,7 @@ BROWSER_CMD_TIMEOUTS = {
|
||||
"wait": 12.0, # smart-wait already caps itself well under this
|
||||
}
|
||||
BROWSER_CMD_REBROADCAST_S = 3.0
|
||||
# A CPU-starved renderer can briefly drop its WS (a missed heartbeat) and the
|
||||
# frontend auto-reconnects a beat later; bridge that gap instead of hard-failing
|
||||
# a live run into it. Short enough that a genuinely-closed window still fails
|
||||
# quickly (and no LLM turns are ever burned waiting); long enough to ride out a
|
||||
# reconnect even on a loaded machine.
|
||||
# A CPU-starved renderer can briefly drop its WS (a missed heartbeat) and the frontend auto-reconnects a beat later; bridge that gap instead of hard-failing a live run into it. Short enough that a genuinely-closed window still fails quickly (and no LLM turns are ever burned waiting); long enough to ride out a reconnect even on a loaded machine.
|
||||
P_WS_RECONNECT_WAIT_S = 8.0
|
||||
|
||||
|
||||
@@ -128,21 +120,7 @@ class ConnectionManager:
|
||||
}
|
||||
|
||||
if events:
|
||||
# Drop already-resolved approval requests from the replay. The
|
||||
# ring buffer holds every event we ever stamped, including the
|
||||
# original `agent:approval_request`. Without this filter, a
|
||||
# client that reconnects (e.g. after navigating away and back,
|
||||
# which re-mounts AgentChat with last_seq=0) re-fires every
|
||||
# past approval as if it were live, but the backing future was
|
||||
# popped from pending_futures the moment the user answered, so
|
||||
# the resurrected card is a dead no-op. Lifecycle is simple:
|
||||
# send_approval_request() inserts into pending_futures BEFORE
|
||||
# the event is stamped, and resolve_approval()/timeout/cancel
|
||||
# all pop it; so "in pending_futures" is the authoritative
|
||||
# is-still-live signal for the request_id. A process restart
|
||||
# wipes pending_futures, which is correct because
|
||||
# reconcile_on_startup also marks waiting_approval sessions as
|
||||
# stopped so there's nothing to answer anyway.
|
||||
# Drop already-resolved approval requests from the replay. The ring buffer holds every event we ever stamped, including the original `agent:approval_request`. Without this filter, a client that reconnects (e.g. after navigating away and back, which re-mounts AgentChat with last_seq=0) re-fires every past approval as if it were live, but the backing future was popped from pending_futures the moment the user answered, so the resurrected card is a dead no-op. Lifecycle is simple: send_approval_request() inserts into pending_futures BEFORE the event is stamped, and resolve_approval()/timeout/cancel all pop it; so "in pending_futures" is the authoritative is-still-live signal for the request_id. A process restart wipes pending_futures, which is correct because reconcile_on_startup also marks waiting_approval sessions as stopped so there's nothing to answer anyway.
|
||||
events = self.p_filter_stale_approvals(events)
|
||||
events = self.p_strip_replayed_closes(events)
|
||||
for s in events:
|
||||
@@ -284,18 +262,10 @@ class ConnectionManager:
|
||||
}
|
||||
|
||||
try:
|
||||
# Bound each command so a wedged tab can't block for 30s (the cost
|
||||
# that turned one hung LinkedIn page into a 20-minute spin). Navigation
|
||||
# legitimately takes longer than reads/clicks on an already-loaded page,
|
||||
# so it gets a longer leash; everything else fails fast. A one-off slow
|
||||
# command just times out and the next success resets the agent's streak,
|
||||
# so only a SUSTAINED hang trips the fast-fail abort.
|
||||
# Bound each command so a wedged tab can't block for 30s (the cost that turned one hung LinkedIn page into a 20-minute spin). Navigation legitimately takes longer than reads/clicks on an already-loaded page, so it gets a longer leash; everything else fails fast. A one-off slow command just times out and the next success resets the agent's streak, so only a SUSTAINED hang trips the fast-fail abort.
|
||||
timeout = BROWSER_CMD_TIMEOUTS.get(action, BROWSER_CMD_TIMEOUT_DEFAULT)
|
||||
deadline = loop.time() + timeout
|
||||
# Re-broadcast until a client answers: a silently-dead dashboard
|
||||
# socket takes up to ~35s of heartbeat to notice, and a command
|
||||
# sent into that gap is lost forever (broadcast skips seq_log).
|
||||
# The renderer dedupes by request_id so re-sends can't double-act.
|
||||
# Re-broadcast until a client answers: a silently-dead dashboard socket takes up to ~35s of heartbeat to notice, and a command sent into that gap is lost forever (broadcast skips seq_log). The renderer dedupes by request_id so re-sends can't double-act.
|
||||
while True:
|
||||
await self.broadcast_global("browser:command", payload)
|
||||
remaining = deadline - loop.time()
|
||||
|
||||
@@ -8,8 +8,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
|
||||
# `or:` prefix on picker values so resolve_model_id_for_sdk recognises them
|
||||
# without a side-table.
|
||||
# `or:` prefix on picker values so resolve_model_id_for_sdk recognises them without a side-table.
|
||||
OPENROUTER_VALUE_PREFIX = "or:"
|
||||
|
||||
P_OR_MODELS_TTL_OK = 3600.0
|
||||
@@ -19,12 +18,7 @@ p_or_models_cache: dict = {"models": None, "fetched_at": 0.0, "ok": False}
|
||||
p_9router_cache: dict = {"available": None, "checked_at": 0}
|
||||
|
||||
|
||||
# Per-model published pricing in $/1M tokens (input, output) for direct
|
||||
# API key lanes. Sourced from each provider's official pricing page as of
|
||||
# May 2026. The Claude Agent SDK ALWAYS computes total_cost_usd at
|
||||
# Anthropic rates; for any non-Anthropic upstream the SDK number is
|
||||
# 50-1000x wrong and we MUST recompute. Used by agent_manager's cost
|
||||
# recompute logic.
|
||||
# Per-model published pricing in $/1M tokens (input, output) for direct API key lanes. Sourced from each provider's official pricing page as of May 2026. The Claude Agent SDK ALWAYS computes total_cost_usd at Anthropic rates; for any non-Anthropic upstream the SDK number is 50-1000x wrong and we MUST recompute. Used by agent_manager's cost recompute logic.
|
||||
P_DIRECT_API_PRICING: dict[str, tuple[float, float]] = {
|
||||
# OpenAI GPT-5.x family (source: platform.openai.com/docs/pricing).
|
||||
"gpt-5.5": (1.25, 10.00),
|
||||
|
||||
@@ -3,30 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curated model tiers; Intelligence, Speed, Cost on a 1-5 scale
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Hand-tuned from public benchmarks + per-token pricing (knowledge cutoff
|
||||
# Jan 2026). The tier numbers serve the picker hover card so users can
|
||||
# pick a model that fits the task without reading a leaderboard.
|
||||
#
|
||||
# Intelligence: 5 = frontier reasoner, 1 = nano / specialised tiny
|
||||
# Speed: 5 = sub-second TTFT + 250 tok/s, 1 = slow + thinking
|
||||
# Cost: 5 = $25+/M output, 1 = under $0.50/M output (or free)
|
||||
#
|
||||
# Lookup order (compute_tiers below):
|
||||
# 1. Bare model_id direct
|
||||
# 2. ":free" stripped (so anthropic/claude-opus-4.7:free shares scoring
|
||||
# with anthropic/claude-opus-4.7)
|
||||
# 3. Vendor-prefixed and bare-after-slash variants for cross-format
|
||||
# coverage (so "claude-opus-4-7" matches "anthropic/claude-opus-4.7")
|
||||
# 4. Last-path-component normalised (dashes ↔ dots)
|
||||
#
|
||||
# Models not in this map fall through to a heuristic that uses cost
|
||||
# bucket + reasoning flag + name-keyword adjustments.
|
||||
# (intelligence, speed, cost) on a 1-5 scale. Tiers: 5 frontier, 4 top
|
||||
# open / strong sub, 3 solid mid, 2 small specialised, 1 nano.
|
||||
# --------------------------------------------------------------------------- Curated model tiers; Intelligence, Speed, Cost on a 1-5 scale --------------------------------------------------------------------------- Hand-tuned from public benchmarks + per-token pricing (knowledge cutoff Jan 2026). The tier numbers serve the picker hover card so users can pick a model that fits the task without reading a leaderboard. Intelligence: 5 = frontier reasoner, 1 = nano / specialised tiny Speed: 5 = sub-second TTFT + 250 tok/s, 1 = slow + thinking Cost: 5 = $25+/M output, 1 = under $0.50/M output (or free) Lookup order (compute_tiers below): 1. Bare model_id direct 2. ":free" stripped (so anthropic/claude-opus-4.7:free shares scoring with anthropic/claude-opus-4.7) 3. Vendor-prefixed and bare-after-slash variants for cross-format coverage (so "claude-opus-4-7" matches "anthropic/claude-opus-4.7") 4. Last-path-component normalised (dashes ↔ dots) Models not in this map fall through to a heuristic that uses cost bucket + reasoning flag + name-keyword adjustments. (intelligence, speed, cost) on a 1-5 scale. Tiers: 5 frontier, 4 top open / strong sub, 3 solid mid, 2 small specialised, 1 nano.
|
||||
MODEL_TIERS: dict[str, tuple[int, int, int]] = {
|
||||
# Anthropic
|
||||
"claude-fable-5": (5, 2, 5),
|
||||
@@ -219,10 +196,7 @@ def heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) -> t
|
||||
else:
|
||||
cb = 5
|
||||
|
||||
# Try to parse a parameter count. Label often carries something
|
||||
# like "Llama 3.3 70B" or "Qwen3 235B". 235B → 5, 70B → 4, 30B
|
||||
# → 3, 14B → 2, 7B → 1. We only trust the param count when it's
|
||||
# clearly above 1B (so we don't pick up version numbers).
|
||||
# Try to parse a parameter count. Label often carries something like "Llama 3.3 70B" or "Qwen3 235B". 235B → 5, 70B → 4, 30B → 3, 14B → 2, 7B → 1. We only trust the param count when it's clearly above 1B (so we don't pick up version numbers).
|
||||
lower = (label or "").lower()
|
||||
param_b = 0.0
|
||||
for m in p_re.finditer(r"\b(\d{1,4}(?:\.\d+)?)\s*b\b", lower):
|
||||
@@ -246,15 +220,10 @@ def heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) -> t
|
||||
else:
|
||||
size_tier = 0 # unknown; fall back to cost
|
||||
|
||||
# Intelligence is the max of cost bucket and parsed size tier.
|
||||
# Cost is high-confidence for closed-source frontier; size is
|
||||
# high-confidence for open-source ladders. Whichever is higher
|
||||
# is closer to the truth.
|
||||
# Intelligence is the max of cost bucket and parsed size tier. Cost is high-confidence for closed-source frontier; size is high-confidence for open-source ladders. Whichever is higher is closer to the truth.
|
||||
intel = max(cb, size_tier)
|
||||
if reasoning and intel < 4:
|
||||
# Reasoning is a strong intelligence signal but only for
|
||||
# genuinely smaller models; frontier closed-source already
|
||||
# caps at 5, so don't double-count there.
|
||||
# Reasoning is a strong intelligence signal but only for genuinely smaller models; frontier closed-source already caps at 5, so don't double-count there.
|
||||
intel += 1
|
||||
|
||||
# Speed inverse of intel.
|
||||
@@ -264,8 +233,7 @@ def heuristic_tiers(label: str, output_cost_per_1m: float, reasoning: bool) -> t
|
||||
if p_re.search(r"\b(opus|ultra|max|xlarge|titan|huge)\b", lower):
|
||||
speed -= 1
|
||||
if reasoning and intel >= 4:
|
||||
# Frontier reasoning models burn lots of tokens on hidden
|
||||
# thoughts; user-perceived speed drops.
|
||||
# Frontier reasoning models burn lots of tokens on hidden thoughts; user-perceived speed drops.
|
||||
speed -= 1
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,19 +33,13 @@ logger = logging.getLogger(__name__)
|
||||
# Full set of model-id prefixes that force routing through 9Router.
|
||||
NINEROUTER_MODEL_PREFIXES = ("cc/", "cx/", "gc/", "ag/", "gemini/", "openrouter/")
|
||||
|
||||
# Entry fields: value, label, context_window, model_id, router_model_id, api,
|
||||
# subscription_only, reasoning, route ("cc"|"api"|"openrouter"|None).
|
||||
# 9Router prefixes: cc/ Claude sub (dashes), cx/ Codex sub (dots), gc/ Gemini CLI.
|
||||
# Entry fields: value, label, context_window, model_id, router_model_id, api, subscription_only, reasoning, route ("cc"|"api"|"openrouter"|None). 9Router prefixes: cc/ Claude sub (dashes), cx/ Codex sub (dots), gc/ Gemini CLI.
|
||||
BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
"Anthropic": [
|
||||
# Opus 4.8 (released 2026-05-28): Anthropic's flagship, recommended for the
|
||||
# most complex work. Adaptive thinking (not extended), effort param defaults
|
||||
# to high. 1M ctx, 128k max output, $5/$25. Verified live on the cc sub route
|
||||
# (this app runs on it) and the API.
|
||||
# Opus 4.8 (released 2026-05-28): Anthropic's flagship, recommended for the most complex work. Adaptive thinking (not extended), effort param defaults to high. 1M ctx, 128k max output, $5/$25. Verified live on the cc sub route (this app runs on it) and the API.
|
||||
{"value": "opus-4-8", "label": "Claude Opus 4.8", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-4-8", "router_model_id": "cc/claude-opus-4-8", "api": "anthropic", "reasoning": True},
|
||||
# Opus 4.7: SDK currently strips plaintext thinking deltas (encrypted only)
|
||||
# so the live "Thought for Ns" pill loses mid-turn text. Final answer + tokens fine.
|
||||
# Opus 4.7: SDK currently strips plaintext thinking deltas (encrypted only) so the live "Thought for Ns" pill loses mid-turn text. Final answer + tokens fine.
|
||||
{"value": "opus-4-7", "label": "Claude Opus 4.7", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-4-7", "router_model_id": "cc/claude-opus-4-7", "api": "anthropic", "reasoning": True},
|
||||
{"value": "sonnet", "label": "Claude Sonnet 4.6", "context_window": 1_000_000,
|
||||
@@ -66,8 +60,7 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
{"value": "haiku-cc", "label": "Claude Haiku 4.5", "context_window": 200_000,
|
||||
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True, "route": "cc"},
|
||||
|
||||
# Fable 5 pulled: the model got banned, so both its cc/ sub and api-key
|
||||
# rows are gone. Don't re-add without confirming access is restored.
|
||||
# Fable 5 pulled: the model got banned, so both its cc/ sub and api-key rows are gone. Don't re-add without confirming access is restored.
|
||||
{"value": "opus-4-8-api", "label": "Claude Opus 4.8 (API key)", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-4-8", "router_model_id": "claude-opus-4-8", "api": "anthropic", "reasoning": True, "route": "api"},
|
||||
{"value": "opus-4-7-api", "label": "Claude Opus 4.7 (API key)", "context_window": 1_000_000,
|
||||
@@ -91,16 +84,7 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
{"value": "gpt-5.4-mini", "label": "GPT-5.4 Mini",
|
||||
"context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini",
|
||||
"api": "codex", "subscription_only": True, "reasoning": True},
|
||||
# gpt-5.3-codex (+ high/xhigh) removed: superseded by GPT-5.5 as OpenAI's
|
||||
# recommended Codex model, and high/xhigh were never separate models (just
|
||||
# reasoning-effort variants), so they were redundant clutter.
|
||||
# API-key entries: route through 9Router's `cp-openai` provider-node
|
||||
# (registered by sync_openai_api_key) so 9Router's translator
|
||||
# dispatches to our local openai-passthrough proxy. The passthrough
|
||||
# renames `max_tokens` → `max_completion_tokens` before forwarding
|
||||
# to api.openai.com, fixing OpenAI's GPT-5 family 400. The bare
|
||||
# router_model_id (e.g. "gpt-5.5") still appears in the request
|
||||
# body; only the routing prefix changes.
|
||||
# gpt-5.3-codex (+ high/xhigh) removed: superseded by GPT-5.5 as OpenAI's recommended Codex model, and high/xhigh were never separate models (just reasoning-effort variants), so they were redundant clutter. API-key entries: route through 9Router's `cp-openai` provider-node (registered by sync_openai_api_key) so 9Router's translator dispatches to our local openai-passthrough proxy. The passthrough renames `max_tokens` → `max_completion_tokens` before forwarding to api.openai.com, fixing OpenAI's GPT-5 family 400. The bare router_model_id (e.g. "gpt-5.5") still appears in the request body; only the routing prefix changes.
|
||||
{"value": "gpt-5.5-api", "label": "GPT-5.5 (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.5", "model_id": "gpt-5.5",
|
||||
"api": "openai", "reasoning": True, "route": "api"},
|
||||
@@ -111,26 +95,13 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
"context_window": 400_000, "router_model_id": "cp-openai/gpt-5.4-mini", "model_id": "gpt-5.4-mini",
|
||||
"api": "openai", "reasoning": True, "route": "api"},
|
||||
],
|
||||
# Google: Gemini 3.x thoughtSignature continuity is bypassed via 9Router's
|
||||
# skip_thought_signature_validator (model can't build on prior reasoning,
|
||||
# but tools and thinking work). 3-pro / 3-flash route via Antigravity when
|
||||
# the AG OAuth lane is active; gc/ otherwise.
|
||||
# Google: Gemini 3.x thoughtSignature continuity is bypassed via 9Router's skip_thought_signature_validator (model can't build on prior reasoning, but tools and thinking work). 3-pro / 3-flash route via Antigravity when the AG OAuth lane is active; gc/ otherwise.
|
||||
"Google": [
|
||||
# Gemini 3.5 Flash (GA 2026-05-19) is offered on the API-key route ONLY (see
|
||||
# the api entry below). Its gc/ subscription entry was pulled because the
|
||||
# pinned 9Router 0.3.60 registry has no gemini-3.5-flash and the gc/ route
|
||||
# allowlists (every other shipped Gemini sub model IS in 0.3.60), so gc/
|
||||
# gemini-3.5-flash would 404. Re-add the gc/ entry once 9Router is bumped
|
||||
# past 0.3.60 (gated by the WebSearch-translation regression; see CLAUDE.md).
|
||||
# gemini-3.1-pro pulled (both sub + api-key rows): Antigravity can't serve
|
||||
# it (its -high variant 400s) and the AI Studio key 429s pro-preview hard,
|
||||
# so it had no working lane and only sold a dead option.
|
||||
# Gemini 3.5 Flash (GA 2026-05-19) is offered on the API-key route ONLY (see the api entry below). Its gc/ subscription entry was pulled because the pinned 9Router 0.3.60 registry has no gemini-3.5-flash and the gc/ route allowlists (every other shipped Gemini sub model IS in 0.3.60), so gc/ gemini-3.5-flash would 404. Re-add the gc/ entry once 9Router is bumped past 0.3.60 (gated by the WebSearch-translation regression; see CLAUDE.md). gemini-3.1-pro pulled (both sub + api-key rows): Antigravity can't serve it (its -high variant 400s) and the AI Studio key 429s pro-preview hard, so it had no working lane and only sold a dead option.
|
||||
{"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: gemini-3-pro-preview was shut down 2026-03-09 (dead on
|
||||
# both the direct API and the Gemini CLI backend). gemini-3-flash kept: it's
|
||||
# superseded on the direct API but still serves on the CLI subscription route.
|
||||
# gemini-3-pro removed: gemini-3-pro-preview was shut down 2026-03-09 (dead on both the direct API and the Gemini CLI backend). gemini-3-flash kept: it's superseded on the direct API but still serves on the CLI subscription route.
|
||||
{"value": "gemini-3-flash", "label": "Gemini 3 Flash",
|
||||
"context_window": 1_000_000, "router_model_id": "gc/gemini-3-flash-preview",
|
||||
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
|
||||
@@ -148,9 +119,7 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model resolution (used by the live claude_agent_sdk path)
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Model resolution (used by the live claude_agent_sdk path) ---------------------------------------------------------------------------
|
||||
|
||||
CUSTOM_VALUE_PREFIX = "custom/"
|
||||
|
||||
@@ -206,8 +175,7 @@ def find_builtin_model(short_name: str) -> dict | None:
|
||||
rest = short_name[len(CUSTOM_VALUE_PREFIX):]
|
||||
slug, p_sep, bare_model = rest.partition("/")
|
||||
if slug and bare_model:
|
||||
# Routing string `cp-<slug>/<model>` matches the prefix we use
|
||||
# when sync_custom_providers registers the provider node.
|
||||
# Routing string `cp-<slug>/<model>` matches the prefix we use when sync_custom_providers registers the provider node.
|
||||
routed = f"cp-{slug}/{bare_model}"
|
||||
return {
|
||||
"value": short_name,
|
||||
@@ -255,37 +223,21 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
|
||||
if entry.get("route") == "cc":
|
||||
return entry.get("router_model_id", entry.get("model_id", short_name))
|
||||
if entry.get("route") == "api":
|
||||
# OpenAI own-key still rides 9Router (the cp-openai node fixes max_tokens
|
||||
# + translates Anthropic->OpenAI), so it MUST keep its cp-openai/ routing
|
||||
# prefix or 9Router has no node to dispatch to. Anthropic own-key goes
|
||||
# straight to api.anthropic.com and Gemini own-key via the local proxy,
|
||||
# both on the bare id.
|
||||
# OpenAI own-key still rides 9Router (the cp-openai node fixes max_tokens + translates Anthropic->OpenAI), so it MUST keep its cp-openai/ routing prefix or 9Router has no node to dispatch to. Anthropic own-key goes straight to api.anthropic.com and Gemini own-key via the local proxy, both on the bare id.
|
||||
if entry.get("api") == "openai":
|
||||
return entry.get("router_model_id", entry.get("model_id", short_name))
|
||||
return entry.get("model_id", short_name)
|
||||
if entry.get("route") == "openrouter":
|
||||
return entry.get("router_model_id", short_name)
|
||||
if entry.get("api") == "anthropic":
|
||||
# openswarm-pro AND free-trial both proxy-route, so resolve to the bare
|
||||
# id (the proxy serves it) instead of the cc/-prefixed id that 401s when
|
||||
# no Claude subscription is connected. This is the line that otherwise
|
||||
# turns a free-trial user's first run into "No AI provider connected".
|
||||
# openswarm-pro AND free-trial both proxy-route, so resolve to the bare id (the proxy serves it) instead of the cc/-prefixed id that 401s when no Claude subscription is connected. This is the line that otherwise turns a free-trial user's first run into "No AI provider connected".
|
||||
if getattr(settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial"):
|
||||
return entry.get("model_id", short_name)
|
||||
if getattr(settings, "anthropic_api_key", None):
|
||||
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.
|
||||
# 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": "gemini-3-flash",
|
||||
"gemini-3.1-flash-lite-preview": "gemini-3-flash",
|
||||
}
|
||||
@@ -312,8 +264,7 @@ async def resolve_aux_model(
|
||||
paying for (Codex chat → Codex aux, OR chat → OR aux, etc.).
|
||||
Returns (model_id, base_url); base_url=None means default Anthropic.
|
||||
"""
|
||||
# Must track the canonical Anthropic entries in BUILTIN_MODELS (sonnet/haiku); a stale id here
|
||||
# 404s every aux call (sonnet was pinned to the long-dead 4.0 "20250514" and silently broke).
|
||||
# Must track the canonical Anthropic entries in BUILTIN_MODELS (sonnet/haiku); a stale id here 404s every aux call (sonnet was pinned to the long-dead 4.0 "20250514" and silently broke).
|
||||
haiku_bare = "claude-haiku-4-5-20251001"
|
||||
sonnet_bare = "claude-sonnet-4-6"
|
||||
or_haiku = "openrouter/anthropic/claude-haiku-4.5"
|
||||
@@ -385,9 +336,7 @@ def get_context_window(provider: str, model: str, settings: AppSettings | None =
|
||||
if m["value"] == model:
|
||||
return m.get("context_window", 128_000)
|
||||
|
||||
# Check custom providers; picker values are `custom/<slug>/<bare_model>`;
|
||||
# cp.models[].value stores the bare model id the user typed. Match the
|
||||
# bare-model tail against any custom provider's models list.
|
||||
# Check custom providers; picker values are `custom/<slug>/<bare_model>`; cp.models[].value stores the bare model id the user typed. Match the bare-model tail against any custom provider's models list.
|
||||
if settings:
|
||||
bare_model = model
|
||||
if isinstance(model, str) and model.startswith(CUSTOM_VALUE_PREFIX):
|
||||
@@ -403,18 +352,10 @@ def get_context_window(provider: str, model: str, settings: AppSettings | None =
|
||||
return 128_000 # safe default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cost tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Cost tracking ---------------------------------------------------------------------------
|
||||
|
||||
COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
|
||||
# (provider, model): (input_cost_per_1M, output_cost_per_1M)
|
||||
# NOTE: real cost numbers come from 9Router's usage stats. These entries
|
||||
# are kept so the table matches BUILTIN_MODELS and can
|
||||
# be used by any future native-loop path. Subscription-routed models
|
||||
# are zero-cost to the user, but API rates are recorded here for
|
||||
# reference where they exist.
|
||||
# Anthropic (direct API rates).
|
||||
# (provider, model): (input_cost_per_1M, output_cost_per_1M) NOTE: real cost numbers come from 9Router's usage stats. These entries are kept so the table matches BUILTIN_MODELS and can be used by any future native-loop path. Subscription-routed models are zero-cost to the user, but API rates are recorded here for reference where they exist. Anthropic (direct API rates).
|
||||
("Anthropic", "sonnet"): (3.0, 15.0),
|
||||
("Anthropic", "opus"): (5.0, 25.0),
|
||||
("Anthropic", "opus-4-7"): (5.0, 25.0),
|
||||
|
||||
@@ -20,15 +20,13 @@ def thinking_params_for(api: str, level: str, model_id: str = "") -> dict | None
|
||||
|
||||
if level == "off":
|
||||
if api == "anthropic":
|
||||
# Fable 5 400s on an explicit thinking:disabled; omit the param to
|
||||
# turn thinking off (off is its default). Other Claude models accept it.
|
||||
# Fable 5 400s on an explicit thinking:disabled; omit the param to turn thinking off (off is its default). Other Claude models accept it.
|
||||
if "fable" in model_id:
|
||||
return None
|
||||
return {"thinking": {"type": "disabled"}}
|
||||
if api == "codex":
|
||||
return {"reasoning": {"effort": "none"}}
|
||||
# Gemini: budget=0 actually disables reasoning. Anything else still
|
||||
# emits thoughtSignatures and 400s the next tool turn.
|
||||
# Gemini: budget=0 actually disables reasoning. Anything else still emits thoughtSignatures and 400s the next tool turn.
|
||||
if api == "gemini-cli":
|
||||
return {"thinkingConfig": {"thinkingBudget": 0}}
|
||||
return None
|
||||
|
||||
@@ -35,14 +35,7 @@ P_GEMINI_MODEL_PREFIXES = ("gemini/", "gc/", "ag/")
|
||||
# Own-key Gemini ("gemini-3-flash-api" etc.) skips the gemini/ prefix; match bare names so $schema scrub still fires.
|
||||
P_GEMINI_BARE_MODEL_PATTERNS = ("gemini-",)
|
||||
|
||||
# Gemini's function_declarations validator accepts only a small OpenAPI subset.
|
||||
# A denylist was whack-a-mole: every new JSON Schema construct that slipped
|
||||
# through (union `type`, anyOf, $comment, format, ...) was a fresh prod 400 with
|
||||
# zero tokens in. We invert it: keep ONLY the keys Gemini is known to accept, and
|
||||
# fold the two "optional" encodings Anthropic emits (a union `type` list, and an
|
||||
# anyOf whose other branch is `{"type":"null"}`) into the `nullable` flag Gemini
|
||||
# actually understands. Everything dropped is advisory; the model still reads it
|
||||
# from `description`. The win is structural: an unknown future key can't 400 us.
|
||||
# Gemini's function_declarations validator accepts only a small OpenAPI subset. A denylist was whack-a-mole: every new JSON Schema construct that slipped through (union `type`, anyOf, $comment, format, ...) was a fresh prod 400 with zero tokens in. We invert it: keep ONLY the keys Gemini is known to accept, and fold the two "optional" encodings Anthropic emits (a union `type` list, and an anyOf whose other branch is `{"type":"null"}`) into the `nullable` flag Gemini actually understands. Everything dropped is advisory; the model still reads it from `description`. The win is structural: an unknown future key can't 400 us.
|
||||
P_GEMINI_ALLOWED_SCHEMA_KEYS = {
|
||||
"type", "description", "nullable", "enum", "items", "properties",
|
||||
"required", "minimum", "maximum", "minItems", "maxItems",
|
||||
@@ -62,8 +55,7 @@ def normalize_schema_for_gemini(node):
|
||||
|
||||
nullable = bool(node.get("nullable"))
|
||||
|
||||
# Gemini can't represent unions; collapse anyOf/oneOf/allOf to one branch.
|
||||
# A bare {"type": "null"} member just means the field is nullable.
|
||||
# Gemini can't represent unions; collapse anyOf/oneOf/allOf to one branch. A bare {"type": "null"} member just means the field is nullable.
|
||||
for combiner in ("anyOf", "oneOf", "allOf"):
|
||||
branches = node.get(combiner)
|
||||
if isinstance(branches, list) and branches:
|
||||
@@ -141,15 +133,7 @@ def p_rewrite_document_to_openai_file(parsed: dict) -> None:
|
||||
continue
|
||||
media_type = src.get("media_type") or ""
|
||||
|
||||
# 9router 0.3.60 chunk 318 stringifies ANY non-`text`/`image_url`
|
||||
# block. Image blocks → image_url with data: URL.
|
||||
# PDFs on OpenAI direct are REFUSED upstream (agent_manager
|
||||
# _resolve_attachments has openai NOT in supports_pdf) because
|
||||
# OpenAI Chat Completions rejects non-image mime types inside
|
||||
# image_url with "Invalid MIME type. Only image types are
|
||||
# supported." (verified empirically May 2026). The shipping
|
||||
# path for OpenAI PDFs is openrouter/openai/gpt-5 which uses
|
||||
# OR's file-parser plugin.
|
||||
# 9router 0.3.60 chunk 318 stringifies ANY non-`text`/`image_url` block. Image blocks → image_url with data: URL. PDFs on OpenAI direct are REFUSED upstream (agent_manager _resolve_attachments has openai NOT in supports_pdf) because OpenAI Chat Completions rejects non-image mime types inside image_url with "Invalid MIME type. Only image types are supported." (verified empirically May 2026). The shipping path for OpenAI PDFs is openrouter/openai/gpt-5 which uses OR's file-parser plugin.
|
||||
if btype != "image":
|
||||
continue
|
||||
mt = media_type or "image/png"
|
||||
@@ -179,8 +163,7 @@ def scrub_request_for_openai_gpt5(body: bytes) -> bytes:
|
||||
elif "max_tokens" in parsed and "max_completion_tokens" in parsed:
|
||||
parsed.pop("max_tokens", None)
|
||||
mutated = True
|
||||
# GPT-5 reasoning models reject sampling knobs (temperature must be 1, top_p
|
||||
# and penalties unsupported); the wire carries them for the user's picked model.
|
||||
# GPT-5 reasoning models reject sampling knobs (temperature must be 1, top_p and penalties unsupported); the wire carries them for the user's picked model.
|
||||
if "temperature" in parsed and parsed["temperature"] != 1:
|
||||
parsed.pop("temperature", None)
|
||||
mutated = True
|
||||
@@ -409,11 +392,7 @@ async def proxy(rest: str, request: Request):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 9router-bypass paths for PDF-bearing requests on providers where
|
||||
# 9router 0.3.60 strips or mangles the relevant content/plugin
|
||||
# fields. We translate + POST directly to the provider's API and
|
||||
# convert the streaming response back to Anthropic SSE so the
|
||||
# bundled Claude CLI subprocess consumes it unchanged.
|
||||
# 9router-bypass paths for PDF-bearing requests on providers where 9router 0.3.60 strips or mangles the relevant content/plugin fields. We translate + POST directly to the provider's API and convert the streaming response back to Anthropic SSE so the bundled Claude CLI subprocess consumes it unchanged.
|
||||
try:
|
||||
parsed_for_bypass = json.loads(body) if body else None
|
||||
except Exception:
|
||||
@@ -475,10 +454,7 @@ async def proxy(rest: str, request: Request):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Gemini (especially the AI Studio key) intermittently 503s and 9Router holds
|
||||
# the retry, which hangs the whole turn for the full read window. Bound Gemini
|
||||
# so a stalled first response fails fast (~2 min) instead of stalling ~10 min;
|
||||
# other providers keep the generous window for long reasoning turns.
|
||||
# Gemini (especially the AI Studio key) intermittently 503s and 9Router holds the retry, which hangs the whole turn for the full read window. Bound Gemini so a stalled first response fails fast (~2 min) instead of stalling ~10 min; other providers keep the generous window for long reasoning turns.
|
||||
p_read_timeout = 120.0 if p_is_gemini_model(model) else 600.0
|
||||
|
||||
try:
|
||||
|
||||
@@ -30,22 +30,11 @@ logger = logging.getLogger(__name__)
|
||||
P_OPENAI_UPSTREAM = "https://api.openai.com/v1"
|
||||
P_OPENROUTER_UPSTREAM = "https://openrouter.ai/api/v1"
|
||||
|
||||
# Concurrency cap for bypass-route requests. Each in-flight request holds
|
||||
# the base64'd PDF (raw_bytes * 1.33) in memory across httpx's request
|
||||
# pipeline + our SSE translator's chunk buffer + the response body. A
|
||||
# 30MB PDF is ~40MB base64; four in-flight = ~160MB of httpx buffers
|
||||
# plus Python overhead, which OOM-killed the dev backend on macOS during
|
||||
# concurrent probes. Cap at 2 so a Mehmet-style multi-PDF attach in one
|
||||
# session can't take the whole backend down. Requests above the cap
|
||||
# queue rather than fail.
|
||||
# Concurrency cap for bypass-route requests. Each in-flight request holds the base64'd PDF (raw_bytes * 1.33) in memory across httpx's request pipeline + our SSE translator's chunk buffer + the response body. A 30MB PDF is ~40MB base64; four in-flight = ~160MB of httpx buffers plus Python overhead, which OOM-killed the dev backend on macOS during concurrent probes. Cap at 2 so a Mehmet-style multi-PDF attach in one session can't take the whole backend down. Requests above the cap queue rather than fail.
|
||||
BYPASS_CONCURRENCY = 2
|
||||
bypass_sema = asyncio.Semaphore(BYPASS_CONCURRENCY)
|
||||
|
||||
# Hard per-request body size ceiling. Anthropic API caps at 32MB,
|
||||
# OpenAI Chat Completions at 50MB, OpenRouter at whatever underlying
|
||||
# model accepts. We refuse anything over 40MB raw (≈53MB base64) before
|
||||
# we even build the request body, so a malicious or accidental huge
|
||||
# attach never reaches the in-memory pipeline.
|
||||
# Hard per-request body size ceiling. Anthropic API caps at 32MB, OpenAI Chat Completions at 50MB, OpenRouter at whatever underlying model accepts. We refuse anything over 40MB raw (≈53MB base64) before we even build the request body, so a malicious or accidental huge attach never reaches the in-memory pipeline.
|
||||
P_BYPASS_MAX_RAW_BYTES = 40 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -169,10 +158,7 @@ def translate_request(parsed: dict) -> dict:
|
||||
openai_body["max_completion_tokens"] = mt
|
||||
if isinstance(parsed.get("temperature"), (int, float)):
|
||||
openai_body["temperature"] = parsed["temperature"]
|
||||
# OpenAI omits usage from streamed chunks unless explicitly asked.
|
||||
# Without this, our Anthropic message_delta would always report 0
|
||||
# tokens, breaking cost tracking + the context meter for bypass-route
|
||||
# turns. OpenRouter respects the same flag.
|
||||
# OpenAI omits usage from streamed chunks unless explicitly asked. Without this, our Anthropic message_delta would always report 0 tokens, breaking cost tracking + the context meter for bypass-route turns. OpenRouter respects the same flag.
|
||||
openai_body["stream_options"] = {"include_usage": True}
|
||||
return openai_body
|
||||
|
||||
@@ -210,9 +196,7 @@ async def p_translate_response_stream(
|
||||
if not line:
|
||||
continue
|
||||
for ln in line.split("\n"):
|
||||
# SSE comments (`:` prefix) are keep-alives, e.g.
|
||||
# OpenRouter emits `: OPENROUTER PROCESSING` while
|
||||
# its file-parser plugin works. Drop them.
|
||||
# SSE comments (`:` prefix) are keep-alives, e.g. OpenRouter emits `: OPENROUTER PROCESSING` while its file-parser plugin works. Drop them.
|
||||
if ln.startswith(":"):
|
||||
continue
|
||||
if not ln.startswith("data:"):
|
||||
@@ -367,11 +351,7 @@ async def p_forward(
|
||||
"Accept": "text/event-stream",
|
||||
}
|
||||
|
||||
# Acquire the bypass-concurrency semaphore before opening a streaming
|
||||
# connection. Without this, N simultaneous PDF attaches each hold a
|
||||
# ~40MB request body + a streaming response buffer, and the OS
|
||||
# OOM-kills the backend (observed on macOS during a 3-PDF probe
|
||||
# burst). Semaphore serializes excess requests instead of failing.
|
||||
# Acquire the bypass-concurrency semaphore before opening a streaming connection. Without this, N simultaneous PDF attaches each hold a ~40MB request body + a streaming response buffer, and the OS OOM-kills the backend (observed on macOS during a 3-PDF probe burst). Semaphore serializes excess requests instead of failing.
|
||||
await bypass_sema.acquire()
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0))
|
||||
try:
|
||||
|
||||
@@ -463,14 +463,11 @@ def handle_edit_step(args: dict) -> dict:
|
||||
cur = _call("GET", f"/{wid}")
|
||||
if "_error" in cur:
|
||||
return _err(cur["_error"])
|
||||
# Edit against the pending draft when one exists (Edit-Agent flow); else
|
||||
# the live steps (main-agent direct edit).
|
||||
# Edit against the pending draft when one exists (Edit-Agent flow); else the live steps (main-agent direct edit).
|
||||
steps = cur.get("draft_steps") or cur.get("steps") or []
|
||||
if idx < 0 or idx >= len(steps):
|
||||
return _err(f"step_idx {idx} out of range (workflow has {len(steps)} steps).")
|
||||
# Refresh the at-a-glance label so the card reflects the edit; a preserved
|
||||
# stale label left the step looking unchanged. Agent-supplied label wins,
|
||||
# else clear it so the card falls back to the new text's first words.
|
||||
# Refresh the at-a-glance label so the card reflects the edit; a preserved stale label left the step looking unchanged. Agent-supplied label wins, else clear it so the card falls back to the new text's first words.
|
||||
new_label = (args.get("new_label") or "").strip()
|
||||
new_steps = list(steps)
|
||||
new_steps[idx] = {**new_steps[idx], "text": new_text, "label": new_label}
|
||||
|
||||
@@ -34,9 +34,7 @@ from backend.apps.agents.providers.registry import (
|
||||
if TYPE_CHECKING:
|
||||
from backend.apps.settings.models import AppSettings
|
||||
|
||||
# AppSettings fields holding a user-writable API key, keyed by provider api-type.
|
||||
# Blanking whichever of these powers the current run is the one suicide the guard
|
||||
# stops. Anything not here (subscription tokens, bearers) is not settings-writable.
|
||||
# AppSettings fields holding a user-writable API key, keyed by provider api-type. Blanking whichever of these powers the current run is the one suicide the guard stops. Anything not here (subscription tokens, bearers) is not settings-writable.
|
||||
P_API_KEY_FIELD_BY_API: dict[str, str] = {
|
||||
"anthropic": "anthropic_api_key",
|
||||
"openai": "openai_api_key",
|
||||
@@ -45,8 +43,7 @@ P_API_KEY_FIELD_BY_API: dict[str, str] = {
|
||||
"openrouter": "openrouter_api_key",
|
||||
}
|
||||
|
||||
# Every settings field that can hold an API key (the full guarded set). Custom
|
||||
# providers keep their keys inside the custom_providers list, guarded separately.
|
||||
# Every settings field that can hold an API key (the full guarded set). Custom providers keep their keys inside the custom_providers list, guarded separately.
|
||||
ALL_API_KEY_FIELDS: frozenset[str] = frozenset(P_API_KEY_FIELD_BY_API.values())
|
||||
|
||||
CredentialKind = Literal["api_key", "subscription", "unknown"]
|
||||
@@ -94,9 +91,7 @@ def resolve_powering_credential(model_value: str, settings: AppSettings) -> Powe
|
||||
route = (entry or {}).get("route")
|
||||
mode = getattr(settings, "connection_mode", "own_key")
|
||||
|
||||
# Custom provider (LM Studio, Ollama, Together, ...). Local servers use a
|
||||
# placeholder key, so suicide is removing the provider ENTRY, not blanking
|
||||
# its key; the guard keys off the slug.
|
||||
# Custom provider (LM Studio, Ollama, Together, ...). Local servers use a placeholder key, so suicide is removing the provider ENTRY, not blanking its key; the guard keys off the slug.
|
||||
if api == "custom":
|
||||
slug = p_custom_slug_for_model(model_value, settings)
|
||||
return PoweringCredential(
|
||||
@@ -114,14 +109,12 @@ def resolve_powering_credential(model_value: str, settings: AppSettings) -> Powe
|
||||
return PoweringCredential(kind="unknown", provider=api,
|
||||
label=f"{api} api route (unclassified)")
|
||||
|
||||
# Subscription-only routes (cx/ Codex, gc/ Gemini CLI) and pinned cc/ Claude:
|
||||
# these lanes live in 9router, never in settings.
|
||||
# Subscription-only routes (cx/ Codex, gc/ Gemini CLI) and pinned cc/ Claude: these lanes live in 9router, never in settings.
|
||||
if route == "cc" or (entry or {}).get("subscription_only"):
|
||||
return PoweringCredential(kind="subscription", provider=api,
|
||||
label=f"{api} subscription")
|
||||
|
||||
# OpenRouter (its own `openrouter` route, plus xai/meta/deepseek/etc routed
|
||||
# through it): always an API key, never a subscription.
|
||||
# OpenRouter (its own `openrouter` route, plus xai/meta/deepseek/etc routed through it): always an API key, never a subscription.
|
||||
if api == "openrouter":
|
||||
return PoweringCredential(kind="api_key", provider="openrouter",
|
||||
protected_field="openrouter_api_key",
|
||||
@@ -140,8 +133,7 @@ def resolve_powering_credential(model_value: str, settings: AppSettings) -> Powe
|
||||
return PoweringCredential(kind="subscription", provider="anthropic",
|
||||
label="Claude subscription")
|
||||
|
||||
# Default Gemini rows (api gemini-cli, route None): the AG/gc OAuth lane is a
|
||||
# subscription. A bare AI Studio key only powers the explicit -api rows above.
|
||||
# Default Gemini rows (api gemini-cli, route None): the AG/gc OAuth lane is a subscription. A bare AI Studio key only powers the explicit -api rows above.
|
||||
if api in ("gemini", "gemini-cli"):
|
||||
return PoweringCredential(kind="subscription", provider="gemini",
|
||||
label="Gemini subscription")
|
||||
@@ -179,9 +171,7 @@ def write_would_suicide(field: str, new_value: Any, powering: PoweringCredential
|
||||
credential counts; SETTING a fresh key is a (re)connect, never suicide.
|
||||
"""
|
||||
if field == "custom_providers":
|
||||
# Removing the entry that powers a custom-provider run is suicide; a
|
||||
# local provider's placeholder key being blanked is not. When the run is
|
||||
# unknown, any custom run could be the live one, so refuse a vanish.
|
||||
# Removing the entry that powers a custom-provider run is suicide; a local provider's placeholder key being blanked is not. When the run is unknown, any custom run could be the live one, so refuse a vanish.
|
||||
if powering.kind == "api_key" and powering.provider == "custom" and powering.protected_custom_slug:
|
||||
return not p_powering_custom_slug_present(new_value, powering.protected_custom_slug)
|
||||
if powering.kind == "unknown":
|
||||
|
||||
@@ -163,9 +163,7 @@ class WebSearchTool(BaseTool):
|
||||
"https://html.duckduckgo.com/html/",
|
||||
data={"q": query},
|
||||
)
|
||||
# DDG serves its throttle challenge as 202 (a ~14KB no-results page),
|
||||
# which is a 2xx so raise_for_status() sails right past it. Catch it
|
||||
# explicitly so we report "rate-limited" instead of a bogus "no hits".
|
||||
# DDG serves its throttle challenge as 202 (a ~14KB no-results page), which is a 2xx so raise_for_status() sails right past it. Catch it explicitly so we report "rate-limited" instead of a bogus "no hits".
|
||||
if resp.status_code == 202:
|
||||
raise DDGRateLimited(query)
|
||||
resp.raise_for_status()
|
||||
@@ -200,9 +198,7 @@ class WebSearchTool(BaseTool):
|
||||
|
||||
raw_url = html.unescape(link_match.group(1))
|
||||
|
||||
# Drop sponsored rows: DDG ads point at its own y.js click-tracker
|
||||
# (ad_domain/ad_provider) instead of a real uddg= redirect, so they'd
|
||||
# otherwise show up as junk "duckduckgo.com/y.js?ad_..." results.
|
||||
# Drop sponsored rows: DDG ads point at its own y.js click-tracker (ad_domain/ad_provider) instead of a real uddg= redirect, so they'd otherwise show up as junk "duckduckgo.com/y.js?ad_..." results.
|
||||
if "/y.js?" in raw_url or "ad_provider=" in raw_url or "ad_domain=" in raw_url:
|
||||
continue
|
||||
|
||||
|
||||
@@ -84,9 +84,7 @@ def p_sync_identity_to_service(settings_obj) -> None:
|
||||
logger.debug("analytics link_email sync failed: %s", e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/auth/signin-activate
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- POST /api/auth/signin-activate ---------------------------------------------------------------------------
|
||||
|
||||
class SigninActivateRequest(BaseModel):
|
||||
token: str
|
||||
@@ -143,10 +141,7 @@ async def signin_activate(body: SigninActivateRequest):
|
||||
settings_obj.user_id = user_id
|
||||
settings_obj.user_email = email
|
||||
settings_obj.signin_method = method
|
||||
# If the user happens to be a paying customer too (Stripe + sign-in
|
||||
# share a user row by email), surface plan/expires so the chat picker
|
||||
# exposes Pro models. Free-tier signups land here with plan="free"
|
||||
# and expires=null; connection_mode stays own_key.
|
||||
# If the user happens to be a paying customer too (Stripe + sign-in share a user row by email), surface plan/expires so the chat picker exposes Pro models. Free-tier signups land here with plan="free" and expires=null; connection_mode stays own_key.
|
||||
if isinstance(plan, str) and plan != "free":
|
||||
settings_obj.connection_mode = "openswarm-pro"
|
||||
settings_obj.openswarm_bearer_token = body.token
|
||||
@@ -155,10 +150,7 @@ async def signin_activate(body: SigninActivateRequest):
|
||||
if isinstance(expires, str):
|
||||
settings_obj.openswarm_subscription_expires = expires
|
||||
else:
|
||||
# Free-tier: still store the bearer so future API calls can identify
|
||||
# the user (used by /api/me/profile, /api/auth/signout). Do NOT flip
|
||||
# connection_mode; that's reserved for paid plans only so chat
|
||||
# routing keeps using own_key/BYO.
|
||||
# Free-tier: still store the bearer so future API calls can identify the user (used by /api/me/profile, /api/auth/signout). Do NOT flip connection_mode; that's reserved for paid plans only so chat routing keeps using own_key/BYO.
|
||||
settings_obj.openswarm_bearer_token = body.token
|
||||
settings_obj.openswarm_proxy_url = proxy
|
||||
|
||||
@@ -175,9 +167,7 @@ async def signin_activate(body: SigninActivateRequest):
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/auth/signout
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- POST /api/auth/signout ---------------------------------------------------------------------------
|
||||
|
||||
@auth.router.post("/signout")
|
||||
async def signout():
|
||||
@@ -200,21 +190,10 @@ async def signout():
|
||||
headers={"Authorization": f"Bearer {bearer}"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
# Network failure shouldn't strand the user signed-in locally;
|
||||
# the cloud token is invalidated lazily on next use anyway.
|
||||
# Network failure shouldn't strand the user signed-in locally; the cloud token is invalidated lazily on next use anyway.
|
||||
logger.warning("cloud signout failed (clearing local anyway): %s", e)
|
||||
|
||||
# Stop every running agent session AND drop their cached SDK resume
|
||||
# state BEFORE clearing local settings. Two failure modes this prevents:
|
||||
# 1. A 9Router subprocess captured the now-revoked bearer at spawn
|
||||
# time and would 401 on the next /v1/messages call.
|
||||
# 2. A session has an `sdk_session_id` from a conversation served by
|
||||
# the previous identity's Claude account; resuming against the new
|
||||
# bearer would 404 or 401 because the new account has no record
|
||||
# of that thread. Wiping it forces the SDK to start a fresh thread
|
||||
# on next send (transcript replay still works; only the SDK's
|
||||
# server-side resume cache is reset).
|
||||
# Best-effort: failures here shouldn't block the sign-out itself.
|
||||
# Stop every running agent session AND drop their cached SDK resume state BEFORE clearing local settings. Two failure modes this prevents: 1. A 9Router subprocess captured the now-revoked bearer at spawn time and would 401 on the next /v1/messages call. 2. A session has an `sdk_session_id` from a conversation served by the previous identity's Claude account; resuming against the new bearer would 404 or 401 because the new account has no record of that thread. Wiping it forces the SDK to start a fresh thread on next send (transcript replay still works; only the SDK's server-side resume cache is reset). Best-effort: failures here shouldn't block the sign-out itself.
|
||||
try:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.manager.session.session_store import save_session
|
||||
@@ -226,9 +205,7 @@ async def signout():
|
||||
except Exception as e:
|
||||
logger.warning("signout: stop_agent(%s) failed: %s", session_id, e)
|
||||
|
||||
# Walk every loaded session (running, stopped, persisted-but-resumed)
|
||||
# and clear the SDK resume id so the next send starts a fresh thread
|
||||
# under whichever identity the user re-signs-in with.
|
||||
# Walk every loaded session (running, stopped, persisted-but-resumed) and clear the SDK resume id so the next send starts a fresh thread under whichever identity the user re-signs-in with.
|
||||
for sess in list(agent_manager.sessions.values()):
|
||||
if sess.sdk_session_id:
|
||||
sess.sdk_session_id = None
|
||||
|
||||
@@ -37,8 +37,7 @@ def load_all() -> list[Dashboard]:
|
||||
try:
|
||||
result.append(Dashboard(**data))
|
||||
except Exception as e:
|
||||
# Parseable JSON, wrong shape (e.g. an older/newer schema). Skip from the
|
||||
# list but leave the file alone so a later version can still read it.
|
||||
# Parseable JSON, wrong shape (e.g. an older/newer schema). Skip from the list but leave the file alone so a later version can still read it.
|
||||
logger.warning("Skipping invalid dashboard file %s: %s", fname, e)
|
||||
return result
|
||||
|
||||
@@ -90,8 +89,7 @@ def migrate_if_needed():
|
||||
if not fname.endswith(".json"):
|
||||
continue
|
||||
fpath = os.path.join(SESSIONS_DIR, fname)
|
||||
# Per-file guard: one unreadable session must not halt the migration partway
|
||||
# and orphan the rest (the dashboard is already created above).
|
||||
# Per-file guard: one unreadable session must not halt the migration partway and orphan the rest (the dashboard is already created above).
|
||||
session_data = read_json_or_none(fpath)
|
||||
if session_data is None:
|
||||
continue
|
||||
@@ -331,8 +329,7 @@ async def generate_name(dashboard_id: str):
|
||||
aux_model, p_aux_base = await resolve_aux_model(global_settings, preferred_tier="haiku")
|
||||
client = get_anthropic_client_for_model(global_settings, aux_model)
|
||||
|
||||
# Mirrors generate_title's hardening: the tasks are inert text to LABEL, never answer,
|
||||
# or the aux model happily replies with a markdown essay that becomes the title.
|
||||
# Mirrors generate_title's hardening: the tasks are inert text to LABEL, never answer, or the aux model happily replies with a markdown essay that becomes the title.
|
||||
system = (
|
||||
"You label tasks with a 2-4 word workspace name. "
|
||||
"Examples: 'Travel planning', 'Code review', 'Sales dashboard'. "
|
||||
|
||||
@@ -36,13 +36,9 @@ class BrowserCardPosition(BaseModel):
|
||||
y: float = 0
|
||||
width: float = 1280
|
||||
height: float = 800
|
||||
# Agent session id that spawned this browser, or None for user-created.
|
||||
# Used by the frontend to auto-remove the browser when its owner agent
|
||||
# reaches a terminal completed/error state.
|
||||
# Agent session id that spawned this browser, or None for user-created. Used by the frontend to auto-remove the browser when its owner agent reaches a terminal completed/error state.
|
||||
spawned_by: Optional[str] = None
|
||||
# When the agent leaves the deliverable on the page (a video playing, a page
|
||||
# to read), it sets this so the frontend's auto-close on parent finish skips
|
||||
# the card and the browser stays put.
|
||||
# When the agent leaves the deliverable on the page (a video playing, a page to read), it sets this so the frontend's auto-close on parent finish skips the card and the browser stays put.
|
||||
keep_open: bool = False
|
||||
|
||||
|
||||
|
||||
@@ -22,10 +22,7 @@ ALLOWED_GUILDS = set(
|
||||
)
|
||||
|
||||
|
||||
# -- MCP tool definitions (exposed to the agent) ---------------------------
|
||||
# Names match the original mcp-discord surface so prompts that referenced
|
||||
# `discord_send` etc. keep working. inputSchema deliberately matches what
|
||||
# the original package documented.
|
||||
# -- MCP tool definitions (exposed to the agent) --------------------------- Names match the original mcp-discord surface so prompts that referenced `discord_send` etc. keep working. inputSchema deliberately matches what the original package documented.
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
@@ -378,8 +375,7 @@ def handle_tool_call(name: str, args: dict) -> dict:
|
||||
if (e := p_check_guild(gid)): return p_err(e)
|
||||
status, body = p_call("GET", f"/guilds/{gid}/channels")
|
||||
if status != 200: return p_err(f"HTTP {status}: {body}")
|
||||
# Filter to type 15 (forum). Discord channel types reference:
|
||||
# GUILD_FORUM = 15
|
||||
# Filter to type 15 (forum). Discord channel types reference: GUILD_FORUM = 15
|
||||
forums = [ch for ch in (body or []) if isinstance(ch, dict) and ch.get("type") == 15]
|
||||
return p_ok(forums)
|
||||
|
||||
|
||||
@@ -48,10 +48,5 @@ from google_workspace_mcp.app import mcp # noqa: E402
|
||||
|
||||
|
||||
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.
|
||||
# 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")
|
||||
|
||||
@@ -15,12 +15,7 @@ from backend.apps.oauth_state import pending_oauth, mark_oauth_completed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OpenAI's Codex OAuth client is registered with a fixed redirect URI
|
||||
# `http://localhost:1455/auth/callback` and rejects any other with `unknown_error`.
|
||||
# Anthropic and Google's clients accept arbitrary localhost callbacks (we use
|
||||
# 9Router's 20128 callback page). For Codex we spawn a one-shot listener on
|
||||
# 1455 that serves the same postMessage/BroadcastChannel/localStorage relay so
|
||||
# the frontend's existing popup + msgHandler flow works unchanged.
|
||||
# OpenAI's Codex OAuth client is registered with a fixed redirect URI `http://localhost:1455/auth/callback` and rejects any other with `unknown_error`. Anthropic and Google's clients accept arbitrary localhost callbacks (we use 9Router's 20128 callback page). For Codex we spawn a one-shot listener on 1455 that serves the same postMessage/BroadcastChannel/localStorage relay so the frontend's existing popup + msgHandler flow works unchanged.
|
||||
|
||||
P_CODEX_CALLBACK_PORT = 1455
|
||||
P_CODEX_CALLBACK_PATH = "/auth/callback"
|
||||
@@ -90,18 +85,13 @@ async def p_start_codex_callback_listener(timeout: float = 300.0) -> asyncio.bas
|
||||
if not line or line in (b"\r\n", b"\n"):
|
||||
break
|
||||
|
||||
# Only respond to the OAuth callback path. Chrome preflights and
|
||||
# favicon fetches get a 404 so they don't trigger the served-event.
|
||||
# Only respond to the OAuth callback path. Chrome preflights and favicon fetches get a 404 so they don't trigger the served-event.
|
||||
parts = request_line.split(" ")
|
||||
path = parts[1] if len(parts) >= 2 else ""
|
||||
method = parts[0] if parts else ""
|
||||
|
||||
if method == "GET" and path.startswith(P_CODEX_CALLBACK_PATH):
|
||||
# Parse code/state out of the query string and exchange
|
||||
# server-side before serving the HTML. Duplicate exchanges
|
||||
# are harmless (single-use auth codes fail the second call,
|
||||
# which we swallow) so racing with the frontend's
|
||||
# msgHandler-driven exchange is fine.
|
||||
# Parse code/state out of the query string and exchange server-side before serving the HTML. Duplicate exchanges are harmless (single-use auth codes fail the second call, which we swallow) so racing with the frontend's msgHandler-driven exchange is fine.
|
||||
try:
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
parsed = urlparse(path)
|
||||
@@ -124,11 +114,7 @@ async def p_start_codex_callback_listener(timeout: float = 300.0) -> asyncio.bas
|
||||
f"Codex callback: server-side exchange succeeded for state {state[:8]}..."
|
||||
)
|
||||
except Exception as e:
|
||||
# Put the pending entry back so the
|
||||
# frontend's msgHandler retry via
|
||||
# /agents/subscriptions/exchange still
|
||||
# has a shot. Safe because we only popped
|
||||
# it a moment ago.
|
||||
# Put the pending entry back so the frontend's msgHandler retry via /agents/subscriptions/exchange still has a shot. Safe because we only popped it a moment ago.
|
||||
pending_oauth[state] = pending
|
||||
logger.debug(
|
||||
f"Codex callback: server-side exchange failed ({e}); leaving for frontend retry"
|
||||
@@ -167,8 +153,7 @@ async def p_start_codex_callback_listener(timeout: float = 300.0) -> asyncio.bas
|
||||
try:
|
||||
server = await asyncio.start_server(p_handle, "127.0.0.1", P_CODEX_CALLBACK_PORT)
|
||||
except OSError as e:
|
||||
# Port already in use; probably another Codex connect attempt still
|
||||
# running, or an actual Codex CLI process holding 1455. Log and bail.
|
||||
# Port already in use; probably another Codex connect attempt still running, or an actual Codex CLI process holding 1455. Log and bail.
|
||||
logger.warning(
|
||||
f"Could not start Codex callback listener on port {P_CODEX_CALLBACK_PORT}: {e}. "
|
||||
"If another connection attempt is in progress, wait for it to finish or time out."
|
||||
@@ -178,9 +163,7 @@ async def p_start_codex_callback_listener(timeout: float = 300.0) -> asyncio.bas
|
||||
async def p_lifecycle():
|
||||
try:
|
||||
await asyncio.wait_for(callback_served.wait(), timeout=timeout)
|
||||
# Give the served HTML a moment to run its JS (postMessage +
|
||||
# window.close) before we close the socket. Chromium closes
|
||||
# the tab on window.close() but the JS needs to run first.
|
||||
# Give the served HTML a moment to run its JS (postMessage + window.close) before we close the socket. Chromium closes the tab on window.close() but the JS needs to run first.
|
||||
await asyncio.sleep(2.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.info(f"Codex callback listener timed out after {timeout}s")
|
||||
@@ -198,20 +181,7 @@ async def p_start_codex_callback_listener(timeout: float = 300.0) -> asyncio.bas
|
||||
return server
|
||||
|
||||
|
||||
# Providers whose OAuth flow MUST run in the user's real browser via
|
||||
# shell.openExternal, not the in-Electron window.open popup:
|
||||
# - gemini-cli, antigravity: Google's Embedded WebView Restrictions policy uses
|
||||
# JS-fingerprint detection that no UA spoof defeats. RFC 8252 and Google's
|
||||
# own Desktop-app OAuth guidance both prescribe the system browser.
|
||||
# - codex: auth.openai.com renders blank in our popup on some machines (newer
|
||||
# embed detection + regional checks); system browser surfaces the real error.
|
||||
# - claude: email magic-link opens in the user's default browser, which is a
|
||||
# different cookie jar from the embedded popup, so the popup can never receive
|
||||
# the auth. Forcing the OAuth flow into the system browser keeps everything
|
||||
# in one cookie jar.
|
||||
# The callback for gemini-cli/antigravity lands on /api/subscriptions/callback
|
||||
# and runs the exchange server-side; codex uses its fixed 1455 listener; claude
|
||||
# is special-cased in p_callback_uri_for_provider below.
|
||||
# Providers whose OAuth flow MUST run in the user's real browser via shell.openExternal, not the in-Electron window.open popup: - gemini-cli, antigravity: Google's Embedded WebView Restrictions policy uses JS-fingerprint detection that no UA spoof defeats. RFC 8252 and Google's own Desktop-app OAuth guidance both prescribe the system browser. - codex: auth.openai.com renders blank in our popup on some machines (newer embed detection + regional checks); system browser surfaces the real error. - claude: email magic-link opens in the user's default browser, which is a different cookie jar from the embedded popup, so the popup can never receive the auth. Forcing the OAuth flow into the system browser keeps everything in one cookie jar. The callback for gemini-cli/antigravity lands on /api/subscriptions/callback and runs the exchange server-side; codex uses its fixed 1455 listener; claude is special-cased in p_callback_uri_for_provider below.
|
||||
P_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli", "antigravity", "codex", "claude"}
|
||||
|
||||
|
||||
@@ -248,8 +218,7 @@ def p_callback_uri_for_provider(provider: str) -> str:
|
||||
"""
|
||||
if provider == "codex":
|
||||
return f"http://localhost:{P_CODEX_CALLBACK_PORT}{P_CODEX_CALLBACK_PATH}"
|
||||
# Anthropic's OAuth client only whitelists localhost:20128/callback;
|
||||
# 9router_gpt5_patch.js 302-rewrites the hit to the backend handler.
|
||||
# Anthropic's OAuth client only whitelists localhost:20128/callback; 9router_gpt5_patch.js 302-rewrites the hit to the backend handler.
|
||||
if provider == "claude":
|
||||
return f"http://localhost:{NINE_ROUTER_PORT}/callback"
|
||||
if provider in P_EXTERNAL_BROWSER_PROVIDERS:
|
||||
|
||||
@@ -31,38 +31,10 @@ NINE_ROUTER_URL = f"http://localhost:{NINE_ROUTER_PORT}"
|
||||
NINE_ROUTER_API = f"{NINE_ROUTER_URL}/api"
|
||||
NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
|
||||
|
||||
# Pinned 9router npm package version. Prod default stays 0.3.60; set
|
||||
# OPENSWARM_ROUTER_VERSION to stage a bump in dev (keys the dev cache by
|
||||
# version, so the override pulls a clean install) without shipping it.
|
||||
#
|
||||
# 0.4.x gates its internal /api/* routes behind auth (the old bump blocker):
|
||||
# bare `POST /api/providers` / `/api/oauth/<prov>/device-code` now 401 instead
|
||||
# of working. That auth is now PORTED here: see cli_auth_token() / cli_auth_headers()
|
||||
# below, which compute the `x-9r-cli-token` 9Router checks and which every
|
||||
# /api/* call in this package attaches. The header is empty on 0.3.60 (no
|
||||
# machine-id file), so the old auth-free path is untouched.
|
||||
#
|
||||
# What the bump buys: cc/claude-opus-4-8 and cx/gpt-5.5 on the sub routes
|
||||
# (gpt-5.5 404s on 0.3.60), a reworked WebSearch behind /api/v1/search, and
|
||||
# 3 months of cross-provider translator robustness.
|
||||
#
|
||||
# REMAINING gate before flipping the prod default to 0.4.x: re-qualify
|
||||
# cross-provider WebSearch. The original 0.3.60 pin reason was that 0.3.60-0.3.96
|
||||
# regressed it (a Codex/Gemini primary delegating WebSearch saw
|
||||
# "claude-haiku-4-5-20251001 unavailable" or hallucinated output); 0.4.x reworked
|
||||
# it but that's unverified here. Also confirmed on 0.4.80: it STILL emits
|
||||
# `max_tokens` (not max_completion_tokens) on Anthropic->OpenAI, so our
|
||||
# /api/openai-passthrough rename (core/openai_passthrough.py + sync_openai_api_key,
|
||||
# routed via an `openai-compatible` node that honors `baseUrl`) STAYS necessary.
|
||||
# Pinned 9router npm package version. Prod default stays 0.3.60; set OPENSWARM_ROUTER_VERSION to stage a bump in dev (keys the dev cache by version, so the override pulls a clean install) without shipping it. 0.4.x gates its internal /api/* routes behind auth (the old bump blocker): bare `POST /api/providers` / `/api/oauth/<prov>/device-code` now 401 instead of working. That auth is now PORTED here: see cli_auth_token() / cli_auth_headers() below, which compute the `x-9r-cli-token` 9Router checks and which every /api/* call in this package attaches. The header is empty on 0.3.60 (no machine-id file), so the old auth-free path is untouched. What the bump buys: cc/claude-opus-4-8 and cx/gpt-5.5 on the sub routes (gpt-5.5 404s on 0.3.60), a reworked WebSearch behind /api/v1/search, and 3 months of cross-provider translator robustness. REMAINING gate before flipping the prod default to 0.4.x: re-qualify cross-provider WebSearch. The original 0.3.60 pin reason was that 0.3.60-0.3.96 regressed it (a Codex/Gemini primary delegating WebSearch saw "claude-haiku-4-5-20251001 unavailable" or hallucinated output); 0.4.x reworked it but that's unverified here. Also confirmed on 0.4.80: it STILL emits `max_tokens` (not max_completion_tokens) on Anthropic->OpenAI, so our /api/openai-passthrough rename (core/openai_passthrough.py + sync_openai_api_key, routed via an `openai-compatible` node that honors `baseUrl`) STAYS necessary.
|
||||
NINE_ROUTER_NPM_VERSION = os.environ.get("OPENSWARM_ROUTER_VERSION", "0.3.60")
|
||||
|
||||
# 9Router (our pinned 0.3.60) appends every request to ~/.9router/request-details.json and
|
||||
# reloads the WHOLE file on each write; once it reaches tens of MB the router's node process
|
||||
# OOM-aborts and takes the app down, even while idle (verified from crash dumps). Two cheap,
|
||||
# pin-safe guards until the real fix (a 9Router bump past 0.4.66, which moved off this file):
|
||||
# 1. rotate that log before we spawn 9Router when it gets large, so growth can't run away;
|
||||
# 2. give node an explicit, generous heap ceiling for legitimate large multimodal bodies.
|
||||
# Neither touches routing, so WebSearch/WebFetch translation and the 0.3.60 pin are unaffected.
|
||||
# 9Router (our pinned 0.3.60) appends every request to ~/.9router/request-details.json and reloads the WHOLE file on each write; once it reaches tens of MB the router's node process OOM-aborts and takes the app down, even while idle (verified from crash dumps). Two cheap, pin-safe guards until the real fix (a 9Router bump past 0.4.66, which moved off this file): 1. rotate that log before we spawn 9Router when it gets large, so growth can't run away; 2. give node an explicit, generous heap ceiling for legitimate large multimodal bodies. Neither touches routing, so WebSearch/WebFetch translation and the 0.3.60 pin are unaffected.
|
||||
P_REQUEST_LOG_PATH = os.path.expanduser("~/.9router/request-details.json")
|
||||
P_REQUEST_LOG_MAX_BYTES = 5 * 1024 * 1024
|
||||
P_NODE_HEAP_MB = 4096
|
||||
@@ -86,18 +58,10 @@ def p_rotate_request_log() -> None:
|
||||
|
||||
p_process: subprocess.Popen | None = None
|
||||
|
||||
# Serializes ensure_running() so a background auto-start and a concurrent
|
||||
# dispatch-time ensure can't both spawn 9Router (double-bind on :20128). Lazily
|
||||
# created so module import doesn't require a running event loop.
|
||||
# Serializes ensure_running() so a background auto-start and a concurrent dispatch-time ensure can't both spawn 9Router (double-bind on :20128). Lazily created so module import doesn't require a running event loop.
|
||||
p_start_lock: "asyncio.Lock | None" = None
|
||||
|
||||
# Short TTL cache for positive is_running() results. The probe is a sync
|
||||
# httpx.get that blocks the event loop, and under load (9Router busy
|
||||
# streaming inference) it can exceed its 2s timeout and return False even
|
||||
# though 9Router is fine. Caching a recent True result avoids those false
|
||||
# negatives without masking a real crash for more than P_IS_RUNNING_TTL seconds.
|
||||
# Negative results are NOT cached so startup detection in ensure_running()
|
||||
# remains correct.
|
||||
# Short TTL cache for positive is_running() results. The probe is a sync httpx.get that blocks the event loop, and under load (9Router busy streaming inference) it can exceed its 2s timeout and return False even though 9Router is fine. Caching a recent True result avoids those false negatives without masking a real crash for more than P_IS_RUNNING_TTL seconds. Negative results are NOT cached so startup detection in ensure_running() remains correct.
|
||||
P_IS_RUNNING_TTL = 10.0
|
||||
p_is_running_last_ok: float = 0.0
|
||||
|
||||
@@ -329,9 +293,7 @@ def p_ensure_router_cached() -> str | None:
|
||||
"Installing 9router@%s into %s (one-time, ~30s)...",
|
||||
NINE_ROUTER_NPM_VERSION, cache_dir,
|
||||
)
|
||||
# Note: we do NOT pass --ignore-scripts. The package's postinstall
|
||||
# rebuilds better-sqlite3 for the host platform; skipping it leaves
|
||||
# the server unable to load its native addon.
|
||||
# Note: we do NOT pass --ignore-scripts. The package's postinstall rebuilds better-sqlite3 for the host platform; skipping it leaves the server unable to load its native addon.
|
||||
subprocess.run(
|
||||
[npm, "install", f"9router@{NINE_ROUTER_NPM_VERSION}",
|
||||
"--no-save", "--no-audit", "--no-fund", "--silent"],
|
||||
@@ -399,8 +361,7 @@ async def p_ensure_running_impl():
|
||||
p_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
|
||||
|
||||
if is_running():
|
||||
# In dev mode, kill stale standalone servers (from previous builds)
|
||||
# so we can start `next dev` which always uses latest source code
|
||||
# In dev mode, kill stale standalone servers (from previous builds) so we can start `next dev` which always uses latest source code
|
||||
if not p_is_packaged:
|
||||
import subprocess as p_sp
|
||||
try:
|
||||
@@ -426,10 +387,7 @@ async def p_ensure_running_impl():
|
||||
p_patch = p_gpt5_patch_path()
|
||||
|
||||
if p_is_packaged:
|
||||
# Packaged: run the pre-built standalone server staged at
|
||||
# <resources>/router/server.js by fetch-router at build time. We do NOT
|
||||
# fall back to the dev npm path here, a user machine has no npm, so that
|
||||
# only ever fails silently; every miss is reported instead.
|
||||
# Packaged: run the pre-built standalone server staged at <resources>/router/server.js by fetch-router at build time. We do NOT fall back to the dev npm path here, a user machine has no npm, so that only ever fails silently; every miss is reported instead.
|
||||
if not p_9router_dir:
|
||||
p_report_start_failure("router_not_bundled")
|
||||
return
|
||||
@@ -450,9 +408,7 @@ async def p_ensure_running_impl():
|
||||
if node == os.environ.get("OPENSWARM_ELECTRON_PATH"):
|
||||
env["ELECTRON_RUN_AS_NODE"] = "1"
|
||||
else:
|
||||
# Dev: install the pinned npm package into a local cache once, then spawn
|
||||
# `node app/server.js` directly (bypasses the package cli.js tray icon
|
||||
# users confusingly quit, its update-check spinner, and the TUI).
|
||||
# Dev: install the pinned npm package into a local cache once, then spawn `node app/server.js` directly (bypasses the package cli.js tray icon users confusingly quit, its update-check spinner, and the TUI).
|
||||
cached_server = p_ensure_router_cached()
|
||||
if not cached_server:
|
||||
return
|
||||
@@ -468,11 +424,7 @@ async def p_ensure_running_impl():
|
||||
cwd = os.path.dirname(cached_server)
|
||||
env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"}
|
||||
|
||||
# Capture stdout+stderr so a failed start can tell us WHY (the old DEVNULL
|
||||
# default made every "router never came up" a silent mystery, which is the
|
||||
# whole reason #90 was un-diagnosable). Packaged prod (NODE_ENV=production
|
||||
# standalone) is quiet, so one fixed temp file, truncated each start attempt,
|
||||
# won't grow; dev keeps its chatty-Next.js DEVNULL unless debug is set.
|
||||
# Capture stdout+stderr so a failed start can tell us WHY (the old DEVNULL default made every "router never came up" a silent mystery, which is the whole reason #90 was un-diagnosable). Packaged prod (NODE_ENV=production standalone) is quiet, so one fixed temp file, truncated each start attempt, won't grow; dev keeps its chatty-Next.js DEVNULL unless debug is set.
|
||||
p_cap_path = os.path.join(tempfile.gettempdir(), "openswarm-9router-start.log")
|
||||
p_cap_file = None
|
||||
if p_is_packaged:
|
||||
@@ -502,8 +454,7 @@ async def p_ensure_running_impl():
|
||||
if is_running():
|
||||
logger.info("9Router started successfully")
|
||||
return
|
||||
# Verify-at-boot: it never answered. Report with the captured tail + the
|
||||
# exit code (non-None = it crashed; None = wedged or just slow).
|
||||
# Verify-at-boot: it never answered. Report with the captured tail + the exit code (non-None = it crashed; None = wedged or just slow).
|
||||
p_report_start_failure(
|
||||
"not_ready_in_time",
|
||||
detail=p_read_capture_tail(p_cap_path) if p_is_packaged else "",
|
||||
|
||||
@@ -19,19 +19,14 @@ def nr():
|
||||
from backend.apps import nine_router
|
||||
return nine_router
|
||||
|
||||
# API-key auth (provider="gemini", authType="apikey") and OAuth hit different
|
||||
# Google quotas: OAuth uses the Code Assist free tier (aggressively rate-limited;
|
||||
# 429s on Gemini 3 Pro/Flash even for paid users), while an AI Studio API key
|
||||
# uses generativelanguage.googleapis.com (independent and far higher). We mirror
|
||||
# google_api_key into 9Router so the API-key path is preferred when a key is set.
|
||||
# API-key auth (provider="gemini", authType="apikey") and OAuth hit different Google quotas: OAuth uses the Code Assist free tier (aggressively rate-limited; 429s on Gemini 3 Pro/Flash even for paid users), while an AI Studio API key uses generativelanguage.googleapis.com (independent and far higher). We mirror google_api_key into 9Router so the API-key path is preferred when a key is set.
|
||||
|
||||
NINE_ROUTER_KEYED_NAME = "AI Studio (OpenSwarm-managed)"
|
||||
NINE_ROUTER_OPENAI_KEYED_NAME = "OpenAI (OpenSwarm-managed)"
|
||||
NINE_ROUTER_OPENROUTER_KEYED_NAME = "OpenRouter (OpenSwarm-managed)"
|
||||
NINE_ROUTER_CLAUDE_PRO_NAME = "OpenSwarm Pro (OpenSwarm-managed)"
|
||||
|
||||
# Reserved prefix that registry.py's gpt-5.*-api router_model_ids depend on.
|
||||
# Changing this breaks model resolution for OpenAI own-key users.
|
||||
# Reserved prefix that registry.py's gpt-5.*-api router_model_ids depend on. Changing this breaks model resolution for OpenAI own-key users.
|
||||
NINE_ROUTER_OPENAI_KEYED_PREFIX = "cp-openai"
|
||||
|
||||
|
||||
@@ -71,8 +66,7 @@ async def p_sync_apikey_provider(
|
||||
"authType": "apikey",
|
||||
"name": name,
|
||||
"apiKey": api_key,
|
||||
# Priority 0 = highest. OAuth connections default to 1,
|
||||
# so keyed connections are preferred when both exist.
|
||||
# Priority 0 = highest. OAuth connections default to 1, so keyed connections are preferred when both exist.
|
||||
"priority": 0,
|
||||
}
|
||||
if existing:
|
||||
|
||||
@@ -20,8 +20,7 @@ from backend.apps.nine_router.sync import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# We mirror settings.custom_providers[] with prefix `cp-<slug>` so they don't
|
||||
# collide with the user's primary OpenAI key.
|
||||
# We mirror settings.custom_providers[] with prefix `cp-<slug>` so they don't collide with the user's primary OpenAI key.
|
||||
NINE_ROUTER_CUSTOM_NAME_SUFFIX = " (OpenSwarm-managed)"
|
||||
|
||||
|
||||
@@ -183,9 +182,7 @@ async def sync_custom_providers(providers: list) -> None:
|
||||
api_key = getattr(cp, "api_key", None) or (cp.get("api_key") if isinstance(cp, dict) else None) or ""
|
||||
if not name.strip() or not base_url.strip():
|
||||
continue
|
||||
# Local OpenAI-compat servers (LM Studio, Ollama, etc.) reject a blank
|
||||
# Bearer header even with auth disabled. Substitute a placeholder; real
|
||||
# auth deployments always have api_key set.
|
||||
# Local OpenAI-compat servers (LM Studio, Ollama, etc.) reject a blank Bearer header even with auth disabled. Substitute a placeholder; real auth deployments always have api_key set.
|
||||
api_key = api_key.strip() or "no-auth-required"
|
||||
slug = p_custom_provider_slug(name)
|
||||
prefix = f"cp-{slug}"
|
||||
@@ -283,9 +280,7 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str
|
||||
if not nr().is_running():
|
||||
return
|
||||
|
||||
# 9Router's POST /api/providers only accepts direct-API provider ids
|
||||
# for apikey auth; `claude` is the subscription/IDE id, `anthropic`
|
||||
# is the direct-API id. Use `anthropic`.
|
||||
# 9Router's POST /api/providers only accepts direct-API provider ids for apikey auth; `claude` is the subscription/IDE id, `anthropic` is the direct-API id. Use `anthropic`.
|
||||
existing = await find_keyed_connection("anthropic", NINE_ROUTER_CLAUDE_PRO_NAME)
|
||||
try:
|
||||
async with nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
@@ -295,9 +290,7 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str
|
||||
"authType": "apikey",
|
||||
"name": NINE_ROUTER_CLAUDE_PRO_NAME,
|
||||
"apiKey": bearer_token,
|
||||
# Priority 1 so a real user-owned Claude subscription
|
||||
# (priority 0) still takes precedence if they have one.
|
||||
# Pro is the fallback, not the default.
|
||||
# Priority 1 so a real user-owned Claude subscription (priority 0) still takes precedence if they have one. Pro is the fallback, not the default.
|
||||
"priority": 1,
|
||||
"providerSpecificData": {
|
||||
"baseUrl": proxy_url.rstrip("/") + "/v1",
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# In-memory store for pending OAuth flows (state -> {provider, code_verifier, redirect_uri})
|
||||
pending_oauth: dict[str, dict] = {}
|
||||
# Recently-completed OAuth states so the /api/subscriptions/callback handler
|
||||
# can distinguish a legitimate duplicate callback (browser prefetch, refresh,
|
||||
# or Google redirect retry after a slow first response) from a truly stale
|
||||
# request. Bounded FIFO, drops the oldest entries once it grows past
|
||||
# MAX_COMPLETED_OAUTH so it can't leak memory.
|
||||
# Recently-completed OAuth states so the /api/subscriptions/callback handler can distinguish a legitimate duplicate callback (browser prefetch, refresh, or Google redirect retry after a slow first response) from a truly stale request. Bounded FIFO, drops the oldest entries once it grows past MAX_COMPLETED_OAUTH so it can't leak memory.
|
||||
completed_oauth: list[str] = []
|
||||
MAX_COMPLETED_OAUTH = 64
|
||||
|
||||
|
||||
@@ -11,12 +11,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT_SECONDS = 30
|
||||
|
||||
# Modules backend code is allowed to import. Trade-off: a determined attacker
|
||||
# can find ways around this (e.g. string-encoded imports via tricks the AST
|
||||
# validator can't see), but the allowlist kills the easy paths cheaply and
|
||||
# pairs with cwd=tempdir + minimal env so the blast radius is small even if
|
||||
# a payload slips past. Keep this list to "data shaping" libraries; no I/O,
|
||||
# no networking, no subprocess.
|
||||
# Modules backend code is allowed to import. Trade-off: a determined attacker can find ways around this (e.g. string-encoded imports via tricks the AST validator can't see), but the allowlist kills the easy paths cheaply and pairs with cwd=tempdir + minimal env so the blast radius is small even if a payload slips past. Keep this list to "data shaping" libraries; no I/O, no networking, no subprocess.
|
||||
P_ALLOWED_MODULES = frozenset({
|
||||
"json", "math", "re", "datetime", "collections", "itertools",
|
||||
"functools", "statistics", "decimal", "fractions", "random",
|
||||
@@ -25,10 +20,7 @@ P_ALLOWED_MODULES = frozenset({
|
||||
"base64", "binascii", "operator", "heapq", "bisect", "array",
|
||||
})
|
||||
|
||||
# Builtin functions that punch holes through the allowlist or do I/O. Direct
|
||||
# calls (e.g. `eval(...)`) are caught here. Attribute-style calls
|
||||
# (`__builtins__.eval(...)`) are blocked by the preamble's `delattr` loop in
|
||||
# the subprocess.
|
||||
# Builtin functions that punch holes through the allowlist or do I/O. Direct calls (e.g. `eval(...)`) are caught here. Attribute-style calls (`__builtins__.eval(...)`) are blocked by the preamble's `delattr` loop in the subprocess.
|
||||
P_BLOCKED_BUILTINS = frozenset({
|
||||
"exec", "eval", "compile", "__import__", "open", "input",
|
||||
"breakpoint", "exit", "quit",
|
||||
@@ -96,9 +88,7 @@ def p_validate_code_safety(code: str) -> None:
|
||||
raise UnsafeCodeError(warnings[0])
|
||||
|
||||
|
||||
# Env vars we always scrub from the subprocess, regardless of strict-vs-force.
|
||||
# These are the keys an attacker would actually want; install token, provider
|
||||
# API keys, cloud credentials. Everything else is local-machine convenience.
|
||||
# Env vars we always scrub from the subprocess, regardless of strict-vs-force. These are the keys an attacker would actually want; install token, provider API keys, cloud credentials. Everything else is local-machine convenience.
|
||||
P_SCRUBBED_ENV_KEYS = frozenset({
|
||||
"OPENSWARM_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
@@ -136,10 +126,7 @@ def p_minimal_env(force: bool = False) -> dict:
|
||||
if force:
|
||||
env = {k: v for k, v in os.environ.items() if k not in P_SCRUBBED_ENV_KEYS}
|
||||
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
# Force UTF-8 even if the parent somehow lacked it (dev mode where
|
||||
# Electron didn't inject PYTHONUTF8). Without this, a child reading
|
||||
# non-ASCII stdin/files on a cp1252 Windows machine raises
|
||||
# UnicodeDecodeError, the "works on my laptop, not theirs" failure.
|
||||
# Force UTF-8 even if the parent somehow lacked it (dev mode where Electron didn't inject PYTHONUTF8). Without this, a child reading non-ASCII stdin/files on a cp1252 Windows machine raises UnicodeDecodeError, the "works on my laptop, not theirs" failure.
|
||||
env["PYTHONUTF8"] = "1"
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
return env
|
||||
@@ -148,10 +135,7 @@ def p_minimal_env(force: bool = False) -> dict:
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"LANG": os.environ.get("LANG", "C.UTF-8"),
|
||||
"LC_ALL": os.environ.get("LC_ALL", "C.UTF-8"),
|
||||
# LANG/LC_ALL are POSIX-only; on Windows the active code page (cp1252)
|
||||
# decides default encoding instead. PYTHONUTF8 + PYTHONIOENCODING force
|
||||
# UTF-8 for this from-scratch env so json.loads(sys.stdin.read()) of
|
||||
# non-ASCII input_data doesn't blow up on stock Windows machines.
|
||||
# LANG/LC_ALL are POSIX-only; on Windows the active code page (cp1252) decides default encoding instead. PYTHONUTF8 + PYTHONIOENCODING force UTF-8 for this from-scratch env so json.loads(sys.stdin.read()) of non-ASCII input_data doesn't blow up on stock Windows machines.
|
||||
"PYTHONUTF8": "1",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
}
|
||||
@@ -196,16 +180,7 @@ async def execute_backend_code(
|
||||
|
||||
preamble = (
|
||||
"import json, sys, io, builtins\n"
|
||||
# Defense-in-depth: scrub dangerous attrs off `builtins` so
|
||||
# attribute-style accesses (metaclass.__subclasses__ chains) can't
|
||||
# reach them. NOTE: __import__ is deliberately NOT scrubbed ,
|
||||
# Python's `import` statement bytecode reads `__import__` from
|
||||
# builtins, so removing it makes EVERY import (including allowlisted
|
||||
# ones like `import math`) fail with "ImportError: __import__ not
|
||||
# found". The AST allowlist on the host is what blocks `import
|
||||
# subprocess`; the per-subprocess scrub just plugs the named-builtin
|
||||
# attack vectors that the AST can't see (eval/exec via attribute
|
||||
# access on objects, etc.).
|
||||
# Defense-in-depth: scrub dangerous attrs off `builtins` so attribute-style accesses (metaclass.__subclasses__ chains) can't reach them. NOTE: __import__ is deliberately NOT scrubbed, Python's `import` statement bytecode reads `__import__` from builtins, so removing it makes EVERY import (including allowlisted ones like `import math`) fail with "ImportError: __import__ not found". The AST allowlist on the host is what blocks `import subprocess`; the per-subprocess scrub just plugs the named-builtin attack vectors that the AST can't see (eval/exec via attribute access on objects, etc.).
|
||||
"for _b in ('exec','eval','compile','open','input',\n"
|
||||
" 'breakpoint','exit','quit'):\n"
|
||||
" try: delattr(builtins, _b)\n"
|
||||
|
||||
@@ -120,10 +120,7 @@ def backend_url_for_workspace(workspace_id: str) -> str:
|
||||
return "null"
|
||||
|
||||
|
||||
# URL schemes / prefixes that must NOT have ?token= appended. These are either
|
||||
# external (CDNs, mailto) or non-network references that the auth middleware
|
||||
# never sees. Anything else is treated as a same-origin relative URL pointing
|
||||
# at our /api/outputs/.../serve/ subtree, which DOES need the token.
|
||||
# URL schemes / prefixes that must NOT have ?token= appended. These are either external (CDNs, mailto) or non-network references that the auth middleware never sees. Anything else is treated as a same-origin relative URL pointing at our /api/outputs/.../serve/ subtree, which DOES need the token.
|
||||
P_ABSOLUTE_URL_PREFIXES = (
|
||||
"http://", "https://", "//", "data:", "blob:",
|
||||
"mailto:", "tel:", "javascript:", "about:", "#",
|
||||
@@ -154,8 +151,7 @@ def inject_token_into_relative_urls(html: str, token: str) -> str:
|
||||
return match.group(0)
|
||||
if "token=" in url:
|
||||
return match.group(0)
|
||||
# Split off any hash fragment so `?token=` lands in the query, not in
|
||||
# the fragment: `page.html?v=1#sec` → `page.html?v=1&token=X#sec`.
|
||||
# Split off any hash fragment so `?token=` lands in the query, not in the fragment: `page.html?v=1#sec` → `page.html?v=1&token=X#sec`.
|
||||
hash_idx = url.find("#")
|
||||
if hash_idx >= 0:
|
||||
base, frag = url[:hash_idx], url[hash_idx:]
|
||||
|
||||
@@ -18,15 +18,12 @@ class Output(BaseModel):
|
||||
thumbnail: Optional[str] = None
|
||||
# Bumped only when a fresh thumbnail is saved; drives sidebar/grid order so merely opening an app doesn't reshuffle the list.
|
||||
preview_updated_at: Optional[str] = None
|
||||
# Linkage so reopening the App Builder reattaches to the in-progress session
|
||||
# and reuses the same on-disk workspace folder instead of seeding a fresh one
|
||||
# (which would orphan the running agent + lose chat history on every navigate).
|
||||
# Linkage so reopening the App Builder reattaches to the in-progress session and reuses the same on-disk workspace folder instead of seeding a fresh one (which would orphan the running agent + lose chat history on every navigate).
|
||||
session_id: Optional[str] = None
|
||||
workspace_id: Optional[str] = None
|
||||
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
# App publishing to {slug}.openswarm.host. Server-managed: set by the publish
|
||||
# endpoint, never accepted from OutputUpdate (so a client can't spoof a live URL).
|
||||
# App publishing to {slug}.openswarm.host. Server-managed: set by the publish endpoint, never accepted from OutputUpdate (so a client can't spoof a live URL).
|
||||
published_slug: Optional[str] = None
|
||||
published_url: Optional[str] = None
|
||||
publish_status: Optional[Literal["publishing", "published", "error"]] = None
|
||||
@@ -68,8 +65,7 @@ class OutputVersion(BaseModel):
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
label: str = ""
|
||||
# auto: saved after a builder edit run. manual: user clicked Save this version.
|
||||
# pre_restore: the automatic backup taken right before a restore (so restore undoes).
|
||||
# auto: saved after a builder edit run. manual: user clicked Save this version. pre_restore: the automatic backup taken right before a restore (so restore undoes).
|
||||
source: Literal["auto", "manual", "pre_restore"] = "auto"
|
||||
parent_id: Optional[str] = None
|
||||
thumbnail: Optional[str] = None
|
||||
@@ -143,13 +139,7 @@ class OutputUpdate(BaseModel):
|
||||
class OutputExecute(BaseModel):
|
||||
output_id: str
|
||||
input_data: dict[str, Any] = Field(default_factory=dict)
|
||||
# When False (default), `/execute` returns AST warnings instead of
|
||||
# running if the backend code touches anything outside the safe
|
||||
# data-shaping allowlist. The UI shows those warnings to the user and
|
||||
# re-submits with force=True after they click "Run Anyway." This is
|
||||
# a UX gate, not a security one; anyone holding the auth token can
|
||||
# set force=True; the value is providing the user explicit visibility
|
||||
# of what's about to execute.
|
||||
# When False (default), `/execute` returns AST warnings instead of running if the backend code touches anything outside the safe data-shaping allowlist. The UI shows those warnings to the user and re-submits with force=True after they click "Run Anyway." This is a UX gate, not a security one; anyone holding the auth token can set force=True; the value is providing the user explicit visibility of what's about to execute.
|
||||
force: bool = False
|
||||
|
||||
|
||||
@@ -162,9 +152,7 @@ class OutputExecuteResult(BaseModel):
|
||||
stdout: Optional[str] = None
|
||||
stderr: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
# Populated when the AST validator flagged risky constructs and the
|
||||
# caller didn't set force=True. When present, `backend_result` is null
|
||||
# because execution was deferred pending user consent.
|
||||
# Populated when the AST validator flagged risky constructs and the caller didn't set force=True. When present, `backend_result` is null because execution was deferred pending user consent.
|
||||
warnings: Optional[list[str]] = None
|
||||
code_preview: Optional[str] = None
|
||||
|
||||
@@ -173,15 +161,7 @@ class WorkspaceSeedRequest(BaseModel):
|
||||
workspace_id: str
|
||||
files: Optional[dict[str, str]] = None
|
||||
meta: Optional[dict[str, Any]] = None
|
||||
# "webapp_template" (default) → seed the vendored
|
||||
# openswarm-ai/webapp-template snapshot (React + Vite + TS frontend
|
||||
# with optional FastAPI backend), allocate a free FRONTEND_PORT,
|
||||
# leave BACKEND_PORT=NONE. Runtime spawns `bash run.sh`; preview
|
||||
# pane points at `http://localhost:{FRONTEND_PORT}/`.
|
||||
# "flat" → legacy single-`index.html` workspace, kept for explicit
|
||||
# opt-in (migration helper, regression tests). Workspaces predating
|
||||
# this flip continue to work in old-mode automatically since the
|
||||
# runtime detects mode via the presence of `run.sh`.
|
||||
# "webapp_template" (default) → seed the vendored openswarm-ai/webapp-template snapshot (React + Vite + TS frontend with optional FastAPI backend), allocate a free FRONTEND_PORT, leave BACKEND_PORT=NONE. Runtime spawns `bash run.sh`; preview pane points at `http://localhost:{FRONTEND_PORT}/`. "flat" → legacy single-`index.html` workspace, kept for explicit opt-in (migration helper, regression tests). Workspaces predating this flip continue to work in old-mode automatically since the runtime detects mode via the presence of `run.sh`.
|
||||
template_mode: Literal["flat", "webapp_template"] = "webapp_template"
|
||||
|
||||
@model_validator(mode="before")
|
||||
@@ -244,8 +224,7 @@ class PublishResult(BaseModel):
|
||||
ok: bool = True
|
||||
published_slug: Optional[str] = None
|
||||
published_url: Optional[str] = None
|
||||
# When the AST safety net blocks a non-force publish, carry the findings so
|
||||
# the UI shows the review modal instead of a generic error toast.
|
||||
# When the AST safety net blocks a non-force publish, carry the findings so the UI shows the review modal instead of a generic error toast.
|
||||
blocked: bool = False
|
||||
review: Optional[PublishReview] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
@@ -57,10 +57,7 @@ async def outputs_lifespan():
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Reap every per-app subprocess. Without this each `bash run.sh`
|
||||
# (and its vite/uvicorn descendants) reparents to PID 1 when the
|
||||
# main backend dies, leaving ghost listeners on the .env-pinned
|
||||
# ports that block the next OpenSwarm launch's reload preview.
|
||||
# Reap every per-app subprocess. Without this each `bash run.sh` (and its vite/uvicorn descendants) reparents to PID 1 when the main backend dies, leaving ghost listeners on the .env-pinned ports that block the next OpenSwarm launch's reload preview.
|
||||
try:
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
killed = await runtime_manager.stop_all()
|
||||
@@ -73,9 +70,7 @@ async def outputs_lifespan():
|
||||
outputs = SubApp("outputs", outputs_lifespan)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File-serving endpoints (for iframe preview with multi-file support)
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- File-serving endpoints (for iframe preview with multi-file support) ---------------------------------------------------------------------------
|
||||
|
||||
@outputs.router.get("/workspace/{workspace_id}/serve/{filepath:path}")
|
||||
async def serve_workspace_file(workspace_id: str, filepath: str, p_d: str = ""):
|
||||
@@ -94,9 +89,7 @@ async def serve_workspace_file(workspace_id: str, filepath: str, p_d: str = ""):
|
||||
input_json, result_json = decode_data_param(p_d) if p_d else ("{}", "null")
|
||||
backend_url_json = backend_url_for_workspace(workspace_id)
|
||||
content = inject_data_into_html(content, input_json, result_json, backend_url_json, with_runtime=True)
|
||||
# Iframe sub-resource fetches (<link>, <script src>, <img>) drop the
|
||||
# parent's ?token= query string, so rewrite the HTML to put the token
|
||||
# back on every relative URL; otherwise sub-resources 401.
|
||||
# Iframe sub-resource fetches (<link>, <script src>, <img>) drop the parent's ?token= query string, so rewrite the HTML to put the token back on every relative URL; otherwise sub-resources 401.
|
||||
content = inject_token_into_relative_urls(content, get_auth_token())
|
||||
|
||||
mime, _ = mimetypes.guess_type(filepath)
|
||||
@@ -121,9 +114,7 @@ async def serve_output_file(output_id: str, filepath: str, p_d: str = ""):
|
||||
return Response(content=content, media_type=mime or "text/plain")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD + workspace endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- CRUD + workspace endpoints ---------------------------------------------------------------------------
|
||||
|
||||
@outputs.router.get("/list")
|
||||
async def list_outputs():
|
||||
@@ -146,9 +137,7 @@ async def read_workspace(workspace_id: str):
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Include `path` so the frontend can rehydrate without re-calling /seed.
|
||||
# /seed unconditionally overwrites, which would clobber any in-progress edits
|
||||
# the agent made since the last save.
|
||||
# Include `path` so the frontend can rehydrate without re-calling /seed. /seed unconditionally overwrites, which would clobber any in-progress edits the agent made since the last save.
|
||||
return {"files": files, "meta": meta, "path": os.path.abspath(folder)}
|
||||
|
||||
|
||||
@@ -276,20 +265,13 @@ async def seed_workspace(body: WorkspaceSeedRequest):
|
||||
folder = os.path.join(WORKSPACE_DIR, body.workspace_id)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
|
||||
# An explicit non-empty `files` payload means the caller has flat-mode
|
||||
# content to write (a saved legacy Output being reseeded). Don't
|
||||
# clobber that with the React template even if template_mode is the
|
||||
# new default; the migration helper has its own path for that.
|
||||
# An explicit non-empty `files` payload means the caller has flat-mode content to write (a saved legacy Output being reseeded). Don't clobber that with the React template even if template_mode is the new default; the migration helper has its own path for that.
|
||||
effective_mode = body.template_mode
|
||||
if body.files:
|
||||
effective_mode = "flat"
|
||||
|
||||
if effective_mode == "webapp_template":
|
||||
# Idempotency guard: re-seeding an existing webapp_template
|
||||
# workspace would clobber the agent's edits (the helper uses
|
||||
# dirs_exist_ok=True + copytree). If `run.sh` already exists,
|
||||
# the workspace was seeded on a previous visit; skip the file
|
||||
# copy and only re-derive the frontend port from .env.
|
||||
# Idempotency guard: re-seeding an existing webapp_template workspace would clobber the agent's edits (the helper uses dirs_exist_ok=True + copytree). If `run.sh` already exists, the workspace was seeded on a previous visit; skip the file copy and only re-derive the frontend port from .env.
|
||||
from backend.apps.outputs.runtime import find_free_port, read_env_value
|
||||
already_seeded = os.path.exists(os.path.join(folder, "run.sh"))
|
||||
if already_seeded:
|
||||
@@ -301,23 +283,14 @@ async def seed_workspace(body: WorkspaceSeedRequest):
|
||||
else:
|
||||
frontend_port = find_free_port()
|
||||
seed_webapp_template_workspace(folder, frontend_port)
|
||||
# SKILL.md still goes in workspace root; agent reads it for
|
||||
# context. Live content (user-editable via Skills page) is
|
||||
# injected into the system prompt regardless.
|
||||
# SKILL.md still goes in workspace root; agent reads it for context. Live content (user-editable via Skills page) is injected into the system prompt regardless.
|
||||
with open(os.path.join(folder, "SKILL.md"), "w", encoding="utf-8") as f:
|
||||
f.write(load_app_builder_skill())
|
||||
meta = body.meta or {}
|
||||
if body.meta and not already_seeded:
|
||||
with open(os.path.join(folder, "meta.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(body.meta, f, indent=2)
|
||||
# Create (or look up) the Output record so the app appears in
|
||||
# the Apps sidebar the moment the user kicks off generation.
|
||||
# Previously the record only landed when the editor's autosave
|
||||
# fired, which itself was gated on `files['index.html']` being
|
||||
# non-empty (a flat-template invariant); meaning React+Vite
|
||||
# apps that navigated-away mid-build had no way back. The record
|
||||
# is a thin pointer (name + workspace_id); the workspace itself
|
||||
# remains the source of truth for the code.
|
||||
# Create (or look up) the Output record so the app appears in the Apps sidebar the moment the user kicks off generation. Previously the record only landed when the editor's autosave fired, which itself was gated on `files['index.html']` being non-empty (a flat-template invariant); meaning React+Vite apps that navigated-away mid-build had no way back. The record is a thin pointer (name + workspace_id); the workspace itself remains the source of truth for the code.
|
||||
output_id: Optional[str] = None
|
||||
try:
|
||||
existing = [o for o in load_all() if o.workspace_id == body.workspace_id]
|
||||
@@ -346,11 +319,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
|
||||
"already_seeded": already_seeded,
|
||||
}
|
||||
|
||||
# Legacy flat path. Seed only fills in MISSING files; it never overwrites
|
||||
# what's already on disk. A reopen re-sends the inline output.files snapshot,
|
||||
# which lags behind whatever the agent just wrote to the workspace; writing it
|
||||
# back reverted every edited file (new files survived, edited ones snapped to
|
||||
# the snapshot). Disk wins once an app exists.
|
||||
# Legacy flat path. Seed only fills in MISSING files; it never overwrites what's already on disk. A reopen re-sends the inline output.files snapshot, which lags behind whatever the agent just wrote to the workspace; writing it back reverted every edited file (new files survived, edited ones snapped to the snapshot). Disk wins once an app exists.
|
||||
if body.files:
|
||||
for rel_path, content in body.files.items():
|
||||
full_path = os.path.normpath(os.path.join(folder, rel_path))
|
||||
@@ -369,8 +338,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
|
||||
with open(full_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
# SKILL.md is a creation-time snapshot; the live rules reach the agent via
|
||||
# the system-prompt injection regardless, so never rewrite an existing one.
|
||||
# SKILL.md is a creation-time snapshot; the live rules reach the agent via the system-prompt injection regardless, so never rewrite an existing one.
|
||||
skill_path = os.path.join(folder, "SKILL.md")
|
||||
if not os.path.exists(skill_path):
|
||||
with open(skill_path, "w", encoding="utf-8") as f:
|
||||
@@ -385,11 +353,7 @@ async def seed_workspace(body: WorkspaceSeedRequest):
|
||||
return {"path": os.path.abspath(folder), "template_mode": "flat"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistent app-backend runtime control. backend.py runs as a long-lived
|
||||
# subprocess for the lifetime of the App being open; auto-allocated port,
|
||||
# log streaming via WebSocket. See runtime.py for the manager.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Persistent app-backend runtime control. backend.py runs as a long-lived subprocess for the lifetime of the App being open; auto-allocated port, log streaming via WebSocket. See runtime.py for the manager. ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def runtime_status_payload(workspace_id: str) -> dict:
|
||||
@@ -397,12 +361,7 @@ def runtime_status_payload(workspace_id: str) -> dict:
|
||||
from backend.apps.outputs.runtime import is_new_mode
|
||||
rt = runtime_manager.get(workspace_id)
|
||||
if not rt:
|
||||
# Even without a live runtime, the editor needs is_new_mode to
|
||||
# decide whether the preview pane should fall back to the legacy
|
||||
# /serve/index.html URL (old-mode flat workspaces) or show the
|
||||
# "starting preview…" placeholder (new-mode webapp_template).
|
||||
# Compute from disk so a failed runtime/start still gives the
|
||||
# client the right hint instead of dumping it onto a 404.
|
||||
# Even without a live runtime, the editor needs is_new_mode to decide whether the preview pane should fall back to the legacy /serve/index.html URL (old-mode flat workspaces) or show the "starting preview…" placeholder (new-mode webapp_template). Compute from disk so a failed runtime/start still gives the client the right hint instead of dumping it onto a 404.
|
||||
folder = os.path.join(WORKSPACE_DIR, workspace_id)
|
||||
is_new = is_new_mode(folder) if os.path.isdir(folder) else False
|
||||
return {
|
||||
@@ -418,13 +377,9 @@ def runtime_status_payload(workspace_id: str) -> dict:
|
||||
"running": rt.running,
|
||||
"port": rt.port,
|
||||
"has_backend_file": rt.has_backend_file,
|
||||
# For old-mode: backend.py serves; backend_url is its port. For
|
||||
# new-mode: backend.py is optional (gated by BACKEND_PORT!=NONE);
|
||||
# only populated if the agent ran bash backend_init.sh.
|
||||
# For old-mode: backend.py serves; backend_url is its port. For new-mode: backend.py is optional (gated by BACKEND_PORT!=NONE); only populated if the agent ran bash backend_init.sh.
|
||||
"backend_url": f"http://127.0.0.1:{rt.port}" if rt.running and rt.port else None,
|
||||
# New-mode only: where the Vite dev server is reachable.
|
||||
# Old-mode workspaces report null and the editor falls back to
|
||||
# the legacy /api/outputs/workspace/{ws}/serve/... path.
|
||||
# New-mode only: where the Vite dev server is reachable. Old-mode workspaces report null and the editor falls back to the legacy /api/outputs/workspace/{ws}/serve/... path.
|
||||
"frontend_port": rt.frontend_port,
|
||||
"frontend_url": rt.frontend_url if rt.running else None,
|
||||
"is_new_mode": rt.is_new_mode,
|
||||
@@ -454,9 +409,7 @@ async def runtime_restart(workspace_id: str):
|
||||
if not os.path.isdir(folder):
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
# Restart only if something's attached; otherwise this is a no-op
|
||||
# silently (a hard-reload click while the runtime was already torn
|
||||
# down; we'd rather not silently respawn an orphan).
|
||||
# Restart only if something's attached; otherwise this is a no-op silently (a hard-reload click while the runtime was already torn down; we'd rather not silently respawn an orphan).
|
||||
rt = runtime_manager.get(workspace_id)
|
||||
if rt:
|
||||
await runtime_manager.restart(workspace_id, os.path.abspath(folder))
|
||||
@@ -514,11 +467,7 @@ async def write_workspace_file(workspace_id: str, filepath: str, body: dict):
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
folder_norm = os.path.normpath(folder)
|
||||
full_path = os.path.normpath(os.path.join(folder, filepath))
|
||||
# `startswith(folder_norm + os.sep)` (not just folder_norm) so a workspace
|
||||
# `abc-123` can't be tricked into writing into a sibling `abc-1234-evil` ,
|
||||
# prefix-string collision rather than path-component containment. Today's
|
||||
# UUID-format ids make the collision unlikely in practice, but the check
|
||||
# is one character and immunizes future id schemes.
|
||||
# `startswith(folder_norm + os.sep)` (not just folder_norm) so a workspace `abc-123` can't be tricked into writing into a sibling `abc-1234-evil`, prefix-string collision rather than path-component containment. Today's UUID-format ids make the collision unlikely in practice, but the check is one character and immunizes future id schemes.
|
||||
if full_path != folder_norm and not full_path.startswith(folder_norm + os.sep):
|
||||
raise HTTPException(status_code=403, detail="Path traversal not allowed")
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
@@ -574,10 +523,7 @@ async def create_output(body: OutputCreate):
|
||||
@outputs.router.put("/{output_id}")
|
||||
async def update_output(output_id: str, body: OutputUpdate):
|
||||
output = load(output_id)
|
||||
# exclude_unset, NOT exclude_none: a PUT that explicitly sends session_id=null
|
||||
# (the Apps stale-link self-heal) must clear the field. exclude_none silently
|
||||
# dropped that null, so the dead pointer never cleared and the app 404'd on
|
||||
# every open, forever. Unset fields stay untouched; only what the client sent applies.
|
||||
# exclude_unset, NOT exclude_none: a PUT that explicitly sends session_id=null (the Apps stale-link self-heal) must clear the field. exclude_none silently dropped that null, so the dead pointer never cleared and the app 404'd on every open, forever. Unset fields stay untouched; only what the client sent applies.
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(output, k, v)
|
||||
now = datetime.now().isoformat()
|
||||
@@ -709,22 +655,14 @@ async def execute_output(body: OutputExecute):
|
||||
warnings_out: Optional[list[str]] = None
|
||||
code_preview: Optional[str] = None
|
||||
if output.backend_code:
|
||||
# HITL gate: collect warnings up front. If the caller hasn't opted
|
||||
# in via force=True AND the code touches anything outside the safe
|
||||
# allowlist, return the warnings + the code itself so the UI can
|
||||
# show a preview dialog. No subprocess is spawned on this path ,
|
||||
# zero-cost when warnings exist, identical-to-before when they
|
||||
# don't.
|
||||
# HITL gate: collect warnings up front. If the caller hasn't opted in via force=True AND the code touches anything outside the safe allowlist, return the warnings + the code itself so the UI can show a preview dialog. No subprocess is spawned on this path, zero-cost when warnings exist, identical-to-before when they don't.
|
||||
if not body.force:
|
||||
warnings_out = get_code_warnings(output.backend_code)
|
||||
if warnings_out:
|
||||
code_preview = output.backend_code
|
||||
if not warnings_out:
|
||||
try:
|
||||
# We've either already vetted (no warnings above) or the
|
||||
# user explicitly opted in with force=True. Pass
|
||||
# skip_validation=True so we don't pay for a redundant
|
||||
# AST walk inside execute_backend_code.
|
||||
# We've either already vetted (no warnings above) or the user explicitly opted in with force=True. Pass skip_validation=True so we don't pay for a redundant AST walk inside execute_backend_code.
|
||||
exec_result = await execute_backend_code(
|
||||
output.backend_code, body.input_data, skip_validation=True
|
||||
)
|
||||
@@ -748,9 +686,7 @@ async def execute_output(body: OutputExecute):
|
||||
).model_dump()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Publishing to {slug}.openswarm.host
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Publishing to {slug}.openswarm.host ---------------------------------------------------------------------------
|
||||
|
||||
@outputs.router.post("/publish/preflight")
|
||||
async def publish_preflight(body: PublishPreflightRequest):
|
||||
|
||||
@@ -39,8 +39,7 @@ async def upload_to_cloud(
|
||||
r = await client.post(
|
||||
f"{base}/api/apps/publish",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
# output_id lets the cloud reuse this app's slug on republish instead
|
||||
# of minting a duplicate; override marks a publish past a non-clean scan.
|
||||
# output_id lets the cloud reuse this app's slug on republish instead of minting a duplicate; override marks a publish past a non-clean scan.
|
||||
data={"name": name, "slug": slug_hint, "output_id": output_id, "override": "1" if override else "0"},
|
||||
files={"bundle": ("app.tar.gz", bundle, "application/gzip")},
|
||||
)
|
||||
|
||||
+37
-132
@@ -74,30 +74,17 @@ class AppRuntime:
|
||||
def __init__(self, workspace_id: str, workspace_path: str):
|
||||
self.workspace_id = workspace_id
|
||||
self.workspace_path = workspace_path
|
||||
# Old-mode: `port` is the backend.py port. New-mode: `port` is
|
||||
# the workspace's optional FastAPI backend (only set if
|
||||
# BACKEND_PORT!=NONE) and `frontend_port` is the Vite dev
|
||||
# server port. Both Nones until start() decides what's there.
|
||||
# Old-mode: `port` is the backend.py port. New-mode: `port` is the workspace's optional FastAPI backend (only set if BACKEND_PORT!=NONE) and `frontend_port` is the Vite dev server port. Both Nones until start() decides what's there.
|
||||
self.port: Optional[int] = None
|
||||
self.frontend_port: Optional[int] = None
|
||||
# New-mode only: flips True once something is actually listening
|
||||
# on frontend_port (we kick off a background poll task in
|
||||
# p_start_new_mode). frontend_url returns null until this flips,
|
||||
# so the preview pane doesn't try to navigate to an unbound port
|
||||
# and show a "Site can't be reached" error mid-npm-install.
|
||||
# New-mode only: flips True once something is actually listening on frontend_port (we kick off a background poll task in p_start_new_mode). frontend_url returns null until this flips, so the preview pane doesn't try to navigate to an unbound port and show a "Site can't be reached" error mid-npm-install.
|
||||
self.p_frontend_ready: bool = False
|
||||
# True while the process tree is SIGSTOP'd in the idle pool. A frozen
|
||||
# vite still holds its port but can't answer it, so frontend_url must
|
||||
# stay null while suspended (else the webview loads a dead port = the
|
||||
# ERR_FAILED on fast app-switching).
|
||||
# True while the process tree is SIGSTOP'd in the idle pool. A frozen vite still holds its port but can't answer it, so frontend_url must stay null while suspended (else the webview loads a dead port = the ERR_FAILED on fast app-switching).
|
||||
self.p_suspended: bool = False
|
||||
self.process: Optional[asyncio.subprocess.Process] = None
|
||||
self.log_buffer: deque[LogLine] = deque(maxlen=LOG_BUFFER_LINES)
|
||||
self.p_subscribers: set[LogSubscriber] = set()
|
||||
# Recent build/runtime errors scraped from stderr; drained by
|
||||
# the agent's post-tool hook after Write/Edit so the agent sees
|
||||
# vite/babel/uvicorn errors in its next turn and can self-fix
|
||||
# instead of leaving the user with a red iframe overlay.
|
||||
# Recent build/runtime errors scraped from stderr; drained by the agent's post-tool hook after Write/Edit so the agent sees vite/babel/uvicorn errors in its next turn and can self-fix instead of leaving the user with a red iframe overlay.
|
||||
self.recent_errors: deque[str] = deque(maxlen=RECENT_ERRORS_MAX)
|
||||
self.render_state: Optional[str] = None
|
||||
self.render_error_text: str = ""
|
||||
@@ -141,16 +128,7 @@ class AppRuntime:
|
||||
|
||||
@property
|
||||
def frontend_url(self) -> Optional[str]:
|
||||
# Gated on `_frontend_ready` (set by the background bind-poll
|
||||
# task in p_start_new_mode) so the preview pane only switches
|
||||
# over once Vite is actually accepting connections. Without
|
||||
# this, the editor flashes a "Site can't be reached" error
|
||||
# while `npm install` is running.
|
||||
# Also gated on `running`: a vite that crashed or got orphaned still
|
||||
# has _frontend_ready=True, and handing the webview that dead port is
|
||||
# the ERR_FAILED you see on reopen. No live process, no URL.
|
||||
# And gated on `not _suspended`: a SIGSTOP'd idle runtime is "running"
|
||||
# (returncode is None) but frozen, so its port won't answer.
|
||||
# Gated on `_frontend_ready` (set by the background bind-poll task in p_start_new_mode) so the preview pane only switches over once Vite is actually accepting connections. Without this, the editor flashes a "Site can't be reached" error while `npm install` is running. Also gated on `running`: a vite that crashed or got orphaned still has _frontend_ready=True, and handing the webview that dead port is the ERR_FAILED you see on reopen. No live process, no URL. And gated on `not _suspended`: a SIGSTOP'd idle runtime is "running" (returncode is None) but frozen, so its port won't answer.
|
||||
if self.frontend_port and self.p_frontend_ready and self.running and not self.p_suspended:
|
||||
return f"http://127.0.0.1:{self.frontend_port}/"
|
||||
return None
|
||||
@@ -183,20 +161,12 @@ class AppRuntime:
|
||||
return True
|
||||
|
||||
if self.is_new_mode:
|
||||
# Acquire the module-level boot lock BEFORE the spawn so
|
||||
# only one new-mode workspace is mid-bundle at a time.
|
||||
# The lock is released by the bind-poll task the moment
|
||||
# vite emits "frontend ready" (or its 180s timeout
|
||||
# fires), which is the moment the next workspace can
|
||||
# start its own vite without competing for the same
|
||||
# CPU. See `p_await_frontend_bind` for the release.
|
||||
# Acquire the module-level boot lock BEFORE the spawn so only one new-mode workspace is mid-bundle at a time. The lock is released by the bind-poll task the moment vite emits "frontend ready" (or its 180s timeout fires), which is the moment the next workspace can start its own vite without competing for the same CPU. See `p_await_frontend_bind` for the release.
|
||||
await p_vite_boot_lock.acquire()
|
||||
try:
|
||||
ok = await self.p_start_new_mode()
|
||||
if not ok:
|
||||
# Spawn failed before the bind-poll task was
|
||||
# created; release synchronously so we don't
|
||||
# wedge the next workspace.
|
||||
# Spawn failed before the bind-poll task was created; release synchronously so we don't wedge the next workspace.
|
||||
p_vite_boot_lock.release()
|
||||
return ok
|
||||
except Exception:
|
||||
@@ -208,18 +178,12 @@ class AppRuntime:
|
||||
env_path = os.path.join(self.workspace_path, ".env")
|
||||
fp_raw = read_env_value(env_path, "FRONTEND_PORT")
|
||||
bp_raw = read_env_value(env_path, "BACKEND_PORT")
|
||||
# FRONTEND_PORT is allocated by seed_workspace; should always be
|
||||
# a number. If missing, fall back to a fresh allocation (rare
|
||||
# edge case: workspace seeded by an older OpenSwarm).
|
||||
# FRONTEND_PORT is allocated by seed_workspace; should always be a number. If missing, fall back to a fresh allocation (rare edge case: workspace seeded by an older OpenSwarm).
|
||||
try:
|
||||
self.frontend_port = int(fp_raw) if fp_raw else find_free_port()
|
||||
except ValueError:
|
||||
self.frontend_port = find_free_port()
|
||||
# Port-collision safety net: if a ghost subprocess from a prior
|
||||
# OpenSwarm run is still bound to the persisted port (force-quit,
|
||||
# crash, OS killed the parent before stop_all could reap), Vite
|
||||
# would EADDRINUSE silently. Re-probe and reallocate, then rewrite
|
||||
# .env so the bash run.sh subprocess reads the new port.
|
||||
# Port-collision safety net: if a ghost subprocess from a prior OpenSwarm run is still bound to the persisted port (force-quit, crash, OS killed the parent before stop_all could reap), Vite would EADDRINUSE silently. Re-probe and reallocate, then rewrite .env so the bash run.sh subprocess reads the new port.
|
||||
if self.frontend_port and not is_port_free(self.frontend_port):
|
||||
new_port = find_free_port()
|
||||
self.p_broadcast(LogLine(
|
||||
@@ -228,16 +192,13 @@ class AppRuntime:
|
||||
))
|
||||
self.frontend_port = new_port
|
||||
write_env_value(env_path, "FRONTEND_PORT", str(new_port))
|
||||
# BACKEND_PORT may be the literal string "NONE" (frontend-only
|
||||
# app; the common case) or a number once `backend_init.sh` has
|
||||
# run. Only populate self.port when there's a real backend.
|
||||
# BACKEND_PORT may be the literal string "NONE" (frontend-only app; the common case) or a number once `backend_init.sh` has run. Only populate self.port when there's a real backend.
|
||||
if bp_raw and bp_raw != "NONE":
|
||||
try:
|
||||
self.port = int(bp_raw)
|
||||
except ValueError:
|
||||
self.port = None
|
||||
# Same collision check for the backend port; a leaked uvicorn
|
||||
# from a prior session would otherwise block the new spawn.
|
||||
# Same collision check for the backend port; a leaked uvicorn from a prior session would otherwise block the new spawn.
|
||||
if self.port and not is_port_free(self.port):
|
||||
new_port = find_free_port()
|
||||
self.p_broadcast(LogLine(
|
||||
@@ -250,13 +211,7 @@ class AppRuntime:
|
||||
self.port = None
|
||||
|
||||
env = self.p_spawn_env_base()
|
||||
# bash run.sh reads .env itself; we don't need to set
|
||||
# FRONTEND_PORT / BACKEND_PORT here. We DO export the install
|
||||
# paths so the template's `backend/run.sh` can find our
|
||||
# debugger to satisfy its `from swarm_debug import debug`.
|
||||
# (Also written into .env at seed time, but env-var path is
|
||||
# the more reliable read site for subshells.)
|
||||
# NOTE: keep these in sync with seed_webapp_template_workspace.
|
||||
# bash run.sh reads .env itself; we don't need to set FRONTEND_PORT / BACKEND_PORT here. We DO export the install paths so the template's `backend/run.sh` can find our debugger to satisfy its `from swarm_debug import debug`. (Also written into .env at seed time, but env-var path is the more reliable read site for subshells.) NOTE: keep these in sync with seed_webapp_template_workspace.
|
||||
from backend.apps.outputs.view_builder_templates import (
|
||||
DEBUGGER_PATH,
|
||||
TEMPLATE_BACKEND_PATH,
|
||||
@@ -286,8 +241,7 @@ class AppRuntime:
|
||||
self.p_stdout_task = asyncio.create_task(self.p_pipe_stream(self.process.stdout, "stdout"))
|
||||
self.p_stderr_task = asyncio.create_task(self.p_pipe_stream(self.process.stderr, "stderr"))
|
||||
self.p_wait_task = asyncio.create_task(self.p_await_exit())
|
||||
# Kick off the port-bind poller so frontend_url flips on once
|
||||
# Vite is actually accepting connections.
|
||||
# Kick off the port-bind poller so frontend_url flips on once Vite is actually accepting connections.
|
||||
self.p_frontend_ready = False
|
||||
self.p_frontend_ready_task = asyncio.create_task(self.p_await_frontend_bind())
|
||||
return True
|
||||
@@ -331,8 +285,7 @@ class AppRuntime:
|
||||
release exactly once so the next queued workspace can start its
|
||||
own vite spawn. A try/finally on the lock guarantees that even
|
||||
an exception in the poll body doesn't strand the lock holding."""
|
||||
# Track whether we've already released so the cleanup at the
|
||||
# end doesn't double-release if a success path beat it.
|
||||
# Track whether we've already released so the cleanup at the end doesn't double-release if a success path beat it.
|
||||
lock_released = False
|
||||
|
||||
def p_release_boot_lock() -> None:
|
||||
@@ -343,8 +296,7 @@ class AppRuntime:
|
||||
try:
|
||||
p_vite_boot_lock.release()
|
||||
except RuntimeError:
|
||||
# Lock already released (e.g. start() failure path
|
||||
# released synchronously before spawning the poll task).
|
||||
# Lock already released (e.g. start() failure path released synchronously before spawning the poll task).
|
||||
pass
|
||||
|
||||
try:
|
||||
@@ -353,14 +305,11 @@ class AppRuntime:
|
||||
port = self.frontend_port
|
||||
deadline = asyncio.get_event_loop().time() + FRONTEND_BIND_TIMEOUT_SECONDS
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
# Stop polling if the process died; pointless to keep
|
||||
# checking a port nothing will bind.
|
||||
# Stop polling if the process died; pointless to keep checking a port nothing will bind.
|
||||
if self.process is None or self.process.returncode is not None:
|
||||
return
|
||||
try:
|
||||
# asyncio.open_connection is the non-blocking equivalent
|
||||
# of socket.create_connection. 0.5s connect timeout to
|
||||
# avoid hanging if the host's TCP stack is under load.
|
||||
# asyncio.open_connection is the non-blocking equivalent of socket.create_connection. 0.5s connect timeout to avoid hanging if the host's TCP stack is under load.
|
||||
fut = asyncio.open_connection("127.0.0.1", port)
|
||||
reader, writer = await asyncio.wait_for(fut, timeout=0.5)
|
||||
writer.close()
|
||||
@@ -373,17 +322,13 @@ class AppRuntime:
|
||||
"runtime",
|
||||
f"[runtime] frontend ready at http://127.0.0.1:{port}/",
|
||||
))
|
||||
# Release the vite-boot mutex the INSTANT vite is
|
||||
# ready; the next queued workspace can start its
|
||||
# own bundle now even though we'll keep streaming
|
||||
# logs for this one.
|
||||
# Release the vite-boot mutex the INSTANT vite is ready; the next queued workspace can start its own bundle now even though we'll keep streaming logs for this one.
|
||||
p_release_boot_lock()
|
||||
return
|
||||
except (OSError, asyncio.TimeoutError):
|
||||
pass
|
||||
await asyncio.sleep(FRONTEND_BIND_POLL_INTERVAL)
|
||||
# Timed out; keep the runtime up (Terminal might show useful
|
||||
# errors) but surface why the preview never appeared.
|
||||
# Timed out; keep the runtime up (Terminal might show useful errors) but surface why the preview never appeared.
|
||||
self.p_broadcast(LogLine(
|
||||
"runtime",
|
||||
f"[runtime] frontend did NOT bind on port {port} after "
|
||||
@@ -391,10 +336,7 @@ class AppRuntime:
|
||||
f"for npm/vite errors.",
|
||||
))
|
||||
finally:
|
||||
# Catches process-death return, timeout fall-through, and
|
||||
# any exception in the poll body. _release_boot_lock is
|
||||
# idempotent so this is safe even after the success path
|
||||
# already released.
|
||||
# Catches process-death return, timeout fall-through, and any exception in the poll body. _release_boot_lock is idempotent so this is safe even after the success path already released.
|
||||
p_release_boot_lock()
|
||||
|
||||
async def p_start_old_mode(self) -> bool:
|
||||
@@ -406,9 +348,7 @@ class AppRuntime:
|
||||
env["PORT"] = str(self.port)
|
||||
env["BACKEND_PORT"] = str(self.port) # alias; both common names work
|
||||
try:
|
||||
# -u forces unbuffered stdout/stderr so the Terminal pane
|
||||
# sees lines in real time, not whenever Python decides to
|
||||
# flush its block buffer.
|
||||
# -u forces unbuffered stdout/stderr so the Terminal pane sees lines in real time, not whenever Python decides to flush its block buffer.
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
sys.executable, "-u", "backend.py",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
@@ -434,28 +374,19 @@ class AppRuntime:
|
||||
REST API back via its own creds if it really needs to, but it
|
||||
shouldn't inherit the host process's token by default."""
|
||||
env = {k: v for k, v in os.environ.items() if k != "OPENSWARM_AUTH_TOKEN"}
|
||||
# Hand the workspace's backend/run.sh the exact interpreter we're
|
||||
# running on. In the packaged build that's the bundled standalone
|
||||
# Python, so a fresh machine with no system `python3` still works;
|
||||
# in dev it's whatever launched uvicorn. OPENSWARM_NODE_PATH already
|
||||
# rides in via os.environ (set by the Electron shell) for run.sh's
|
||||
# Node resolution.
|
||||
# Hand the workspace's backend/run.sh the exact interpreter we're running on. In the packaged build that's the bundled standalone Python, so a fresh machine with no system `python3` still works; in dev it's whatever launched uvicorn. OPENSWARM_NODE_PATH already rides in via os.environ (set by the Electron shell) for run.sh's Node resolution.
|
||||
env["OPENSWARM_PYTHON"] = sys.executable
|
||||
return env
|
||||
|
||||
async def stop(self) -> None:
|
||||
async with self.p_lock:
|
||||
if not self.process or self.process.returncode is not None:
|
||||
# Still cancel the bind poller in case stop() races a
|
||||
# never-launched runtime; defensive no-op otherwise.
|
||||
# Still cancel the bind poller in case stop() races a never-launched runtime; defensive no-op otherwise.
|
||||
if self.p_frontend_ready_task and not self.p_frontend_ready_task.done():
|
||||
self.p_frontend_ready_task.cancel()
|
||||
return
|
||||
try:
|
||||
# Walk the descendant tree first so vite/uvicorn grandchildren
|
||||
# die before bash exits and orphans them to PID 1. The webapp
|
||||
# template's run.sh only traps EXIT, not TERM, so a flat
|
||||
# SIGTERM to bash kills bash silently and leaves vite alive.
|
||||
# Walk the descendant tree first so vite/uvicorn grandchildren die before bash exits and orphans them to PID 1. The webapp template's run.sh only traps EXIT, not TERM, so a flat SIGTERM to bash kills bash silently and leaves vite alive.
|
||||
kill_descendant_tree(self.process.pid, "TERM")
|
||||
self.process.terminate()
|
||||
try:
|
||||
@@ -466,8 +397,7 @@ class AppRuntime:
|
||||
await self.process.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
# Cancel the bind poller so it stops scanning a port that's
|
||||
# gone away, and reset the readiness flag.
|
||||
# Cancel the bind poller so it stops scanning a port that's gone away, and reset the readiness flag.
|
||||
if self.p_frontend_ready_task and not self.p_frontend_ready_task.done():
|
||||
self.p_frontend_ready_task.cancel()
|
||||
self.p_frontend_ready = False
|
||||
@@ -533,9 +463,7 @@ class AppRuntime:
|
||||
if not self.process:
|
||||
return
|
||||
rc = await self.process.wait()
|
||||
# Unclean death (vite crash, OOM, orphaned parent) must drop readiness;
|
||||
# otherwise frontend_url keeps advertising a dead port and the preview
|
||||
# navigates into ERR_FAILED. stop() already does this for clean stops.
|
||||
# Unclean death (vite crash, OOM, orphaned parent) must drop readiness; otherwise frontend_url keeps advertising a dead port and the preview navigates into ERR_FAILED. stop() already does this for clean stops.
|
||||
self.p_frontend_ready = False
|
||||
self.p_broadcast(LogLine("runtime", f"[runtime] backend exited with code {rc}"))
|
||||
|
||||
@@ -554,46 +482,35 @@ class AppRuntimeManager:
|
||||
# workspace_id → AppRuntime, currently has >=1 subscriber.
|
||||
self.runtimes: dict[str, AppRuntime] = {}
|
||||
self.p_attached: dict[str, int] = {}
|
||||
# workspace_id → AppRuntime with no subscribers but still
|
||||
# alive. OrderedDict gives O(1) move_to_end + popitem(last=False)
|
||||
# for LRU semantics.
|
||||
# workspace_id → AppRuntime with no subscribers but still alive. OrderedDict gives O(1) move_to_end + popitem(last=False) for LRU semantics.
|
||||
self.idle_lru: "OrderedDict[str, AppRuntime]" = OrderedDict()
|
||||
self.p_lock = asyncio.Lock()
|
||||
|
||||
async def attach(self, workspace_id: str, workspace_path: str) -> AppRuntime:
|
||||
revived = False
|
||||
# Defined here so every code path below leaves it bound; the
|
||||
# revive-idle branch used to skip the assignment, leaving the
|
||||
# post-lock `if dead is not None:` check throwing UnboundLocalError.
|
||||
# Defined here so every code path below leaves it bound; the revive-idle branch used to skip the assignment, leaving the post-lock `if dead is not None:` check throwing UnboundLocalError.
|
||||
dead: Optional[AppRuntime] = None
|
||||
async with self.p_lock:
|
||||
rt = self.runtimes.get(workspace_id)
|
||||
if rt is None:
|
||||
# Maybe the runtime is sitting idle in the LRU; revive
|
||||
# it without paying the spawn cost again.
|
||||
# Maybe the runtime is sitting idle in the LRU; revive it without paying the spawn cost again.
|
||||
idle_rt = self.idle_lru.pop(workspace_id, None)
|
||||
if idle_rt is not None and idle_rt.running:
|
||||
rt = idle_rt
|
||||
rt.workspace_path = workspace_path
|
||||
self.runtimes[workspace_id] = rt
|
||||
revived = True
|
||||
# SIGCONT the process tree if A2 had it paused while
|
||||
# idle. Pair with the SIGSTOP in detach() below.
|
||||
# SIGCONT the process tree if A2 had it paused while idle. Pair with the SIGSTOP in detach() below.
|
||||
resume_process_tree(rt.process)
|
||||
rt.p_suspended = False
|
||||
else:
|
||||
if idle_rt is not None:
|
||||
# Stale idle entry; process died while idling.
|
||||
# Drop and spawn a fresh one below; old one
|
||||
# gets stopped outside the lock.
|
||||
# Stale idle entry; process died while idling. Drop and spawn a fresh one below; old one gets stopped outside the lock.
|
||||
dead = idle_rt
|
||||
rt = AppRuntime(workspace_id, workspace_path)
|
||||
self.runtimes[workspace_id] = rt
|
||||
else:
|
||||
# Workspace paths shouldn't change for a given id, but if
|
||||
# somehow they did (e.g. the user moved the workspace
|
||||
# folder), trust the latest caller; they have the
|
||||
# current truth.
|
||||
# Workspace paths shouldn't change for a given id, but if somehow they did (e.g. the user moved the workspace folder), trust the latest caller; they have the current truth.
|
||||
rt.workspace_path = workspace_path
|
||||
self.p_attached[workspace_id] = self.p_attached.get(workspace_id, 0) + 1
|
||||
if not revived and not rt.running:
|
||||
@@ -618,10 +535,7 @@ class AppRuntimeManager:
|
||||
rt = self.runtimes.pop(workspace_id, None)
|
||||
if rt is None:
|
||||
return
|
||||
# If the process is already dead, no point keeping it
|
||||
# around; just clean up. Otherwise move to the LRU AND
|
||||
# SIGSTOP the process tree so it consumes 0% CPU while
|
||||
# idle. The matching SIGCONT lives in attach() above.
|
||||
# If the process is already dead, no point keeping it around; just clean up. Otherwise move to the LRU AND SIGSTOP the process tree so it consumes 0% CPU while idle. The matching SIGCONT lives in attach() above.
|
||||
if not rt.running:
|
||||
to_reap.append(rt)
|
||||
else:
|
||||
@@ -631,16 +545,12 @@ class AppRuntimeManager:
|
||||
rt.p_suspended = True
|
||||
while len(self.idle_lru) > MAX_IDLE_RUNTIMES:
|
||||
_, old_rt = self.idle_lru.popitem(last=False)
|
||||
# Reaping a stopped process: SIGCONT first so the
|
||||
# SIGTERM in stop() can be delivered cleanly (a
|
||||
# SIGSTOP'd process can't run its own shutdown).
|
||||
# Reaping a stopped process: SIGCONT first so the SIGTERM in stop() can be delivered cleanly (a SIGSTOP'd process can't run its own shutdown).
|
||||
resume_process_tree(old_rt.process)
|
||||
to_reap.append(old_rt)
|
||||
to_idle = rt if rt.running else None
|
||||
|
||||
# Stop any reaped runtimes OUTSIDE the lock. stop() is async and
|
||||
# can take up to TERMINATE_GRACE_SECONDS; holding the lock for
|
||||
# it would block every other attach/detach.
|
||||
# Stop any reaped runtimes OUTSIDE the lock. stop() is async and can take up to TERMINATE_GRACE_SECONDS; holding the lock for it would block every other attach/detach.
|
||||
for old in to_reap:
|
||||
try:
|
||||
await old.stop()
|
||||
@@ -650,9 +560,7 @@ class AppRuntimeManager:
|
||||
logger.debug("workspace %s idled (LRU size now %d)", workspace_id, len(self.idle_lru))
|
||||
|
||||
def get(self, workspace_id: str) -> Optional[AppRuntime]:
|
||||
# Active subscribers see the live runtime; idle-pool members
|
||||
# are also accessible so a status probe between detach and
|
||||
# the next attach still works.
|
||||
# Active subscribers see the live runtime; idle-pool members are also accessible so a status probe between detach and the next attach still works.
|
||||
rt = self.runtimes.get(workspace_id)
|
||||
if rt is not None:
|
||||
return rt
|
||||
@@ -671,10 +579,7 @@ class AppRuntimeManager:
|
||||
abs_path = os.path.abspath(file_path)
|
||||
except Exception:
|
||||
return []
|
||||
# Walk both active and idle runtimes; the user might have
|
||||
# navigated away from the workspace mid-build, but the agent
|
||||
# could still be editing files; the LRU keeps the runtime alive
|
||||
# for ~3 idle slots.
|
||||
# Walk both active and idle runtimes; the user might have navigated away from the workspace mid-build, but the agent could still be editing files; the LRU keeps the runtime alive for ~3 idle slots.
|
||||
for rt in (*self.runtimes.values(), *self.idle_lru.values()):
|
||||
try:
|
||||
ws_root = os.path.abspath(rt.workspace_path)
|
||||
|
||||
@@ -240,8 +240,7 @@ def read_env_value(env_path: str, key: str) -> Optional[str]:
|
||||
if k.strip() != key:
|
||||
continue
|
||||
v = v.strip()
|
||||
# Strip an inline `# comment`. Naive; bash semantics are
|
||||
# more permissive, but values we write don't contain `#`.
|
||||
# Strip an inline `# comment`. Naive; bash semantics are more permissive, but values we write don't contain `#`.
|
||||
if "#" in v:
|
||||
v = v.split("#", 1)[0].rstrip()
|
||||
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
|
||||
|
||||
@@ -54,8 +54,7 @@ async def restore_output_version(output_id: str, version_id: str):
|
||||
output = load_output(output_id)
|
||||
if output is None:
|
||||
raise HTTPException(status_code=404, detail="Output not found")
|
||||
# Don't restore out from under a live builder run. The frontend disables the
|
||||
# button while the agent is active; this is the backend half of that guard.
|
||||
# Don't restore out from under a live builder run. The frontend disables the button while the agent is active; this is the backend half of that guard.
|
||||
if output.session_id:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
session = agent_manager.sessions.get(output.session_id)
|
||||
|
||||
@@ -50,25 +50,16 @@ def p_resolve_python() -> str:
|
||||
first and which exits non-zero with 'Python was not found'."""
|
||||
return sys.executable
|
||||
|
||||
# Absolute path to the bundled skill source. Surfaced as a constant so the
|
||||
# skills subsystem can register it as a built-in skill (copy into
|
||||
# ~/.claude/skills/ on first boot) without re-deriving the path.
|
||||
# Absolute path to the bundled skill source. Surfaced as a constant so the skills subsystem can register it as a built-in skill (copy into ~/.claude/skills/ on first boot) without re-deriving the path.
|
||||
APP_BUILDER_SKILL_SOURCE_PATH = os.path.join(os.path.dirname(__file__), "app_builder_skill.md")
|
||||
|
||||
# Second built-in skill: documentation for `swarm-debug`, the colored
|
||||
# frame-aware logger pre-installed in every webapp-template workspace's
|
||||
# backend. Registered the same way as the App Builder skill.
|
||||
# Second built-in skill: documentation for `swarm-debug`, the colored frame-aware logger pre-installed in every webapp-template workspace's backend. Registered the same way as the App Builder skill.
|
||||
SWARM_DEBUG_SKILL_SOURCE_PATH = os.path.join(os.path.dirname(__file__), "swarm_debug_skill.md")
|
||||
|
||||
# Root of the vendored openswarm-ai/webapp-template snapshot. seed_workspace
|
||||
# copytrees this into new-mode workspaces (excluding backend/, which gets
|
||||
# brought in on-demand by the workspace's own backend_init.sh). See
|
||||
# scripts/fetch-webapp-template.sh for the snapshot fetch + patches.
|
||||
# Root of the vendored openswarm-ai/webapp-template snapshot. seed_workspace copytrees this into new-mode workspaces (excluding backend/, which gets brought in on-demand by the workspace's own backend_init.sh). See scripts/fetch-webapp-template.sh for the snapshot fetch + patches.
|
||||
WEBAPP_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "webapp_template")
|
||||
|
||||
# Bundled default; used as the read-once fallback if the user-editable
|
||||
# copy at ~/.claude/skills/app_builder_skill.md has been removed despite
|
||||
# the built-in flag (defensive; shouldn't happen in normal use).
|
||||
# Bundled default; used as the read-once fallback if the user-editable copy at ~/.claude/skills/app_builder_skill.md has been removed despite the built-in flag (defensive; shouldn't happen in normal use).
|
||||
with open(APP_BUILDER_SKILL_SOURCE_PATH, encoding="utf-8") as p_f:
|
||||
APP_BUILDER_SKILL_DEFAULT = p_f.read()
|
||||
|
||||
@@ -89,9 +80,7 @@ def load_app_builder_skill() -> str:
|
||||
return APP_BUILDER_SKILL_DEFAULT
|
||||
|
||||
|
||||
# Backward-compat alias. Older callers import VIEW_BUILDER_SKILL directly ,
|
||||
# point them at the same content as the user-editable version so a "frozen
|
||||
# at import" stale copy can't drift from what the skills page shows.
|
||||
# Backward-compat alias. Older callers import VIEW_BUILDER_SKILL directly, point them at the same content as the user-editable version so a "frozen at import" stale copy can't drift from what the skills page shows.
|
||||
VIEW_BUILDER_SKILL = APP_BUILDER_SKILL_DEFAULT
|
||||
|
||||
VIEW_TEMPLATE_INDEX = """\
|
||||
@@ -161,9 +150,7 @@ VIEW_TEMPLATE_FILES = {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# webapp_template (new-mode) seed helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- webapp_template (new-mode) seed helpers ---------------------------------------------------------------------------
|
||||
|
||||
def p_ignore_backend(src: str, names: list[str]) -> list[str]:
|
||||
"""copytree filter; when copying the template root, drop only the
|
||||
@@ -180,28 +167,13 @@ DEBUGGER_PATH = os.path.abspath(
|
||||
TEMPLATE_BACKEND_PATH = os.path.abspath(os.path.join(WEBAPP_TEMPLATE_DIR, "backend"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared node_modules cache; every new webapp-template workspace symlinks
|
||||
# its frontend/node_modules to a single warm directory. First-app create
|
||||
# pays the ~22s npm-install cost once; every subsequent app is instant
|
||||
# (just a symlink + vite startup, ~1s).
|
||||
#
|
||||
# Cache directory is keyed by a sha of the template's package.json, so a
|
||||
# template dep bump invalidates the cache automatically; old caches sit
|
||||
# until the user clears ~/.openswarm/cache.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Shared node_modules cache; every new webapp-template workspace symlinks its frontend/node_modules to a single warm directory. First-app create pays the ~22s npm-install cost once; every subsequent app is instant (just a symlink + vite startup, ~1s). Cache directory is keyed by a sha of the template's package.json, so a template dep bump invalidates the cache automatically; old caches sit until the user clears ~/.openswarm/cache. ---------------------------------------------------------------------------
|
||||
|
||||
p_warm_cache_lock = threading.Lock()
|
||||
p_warm_cache_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
# Pre-built node_modules archive bundled with packaged releases. Generated
|
||||
# by `scripts/build-template-archive.sh` and shipped at this path inside
|
||||
# the app's resources. When present (and tagged with the current
|
||||
# package.json sha), extract instead of running npm; decompression is
|
||||
# ~3 s vs ~22 s for the live install. Stale archives (package.json bumped
|
||||
# but archive not rebuilt) are silently ignored, so the live-install
|
||||
# fallback always wins on correctness.
|
||||
# Pre-built node_modules archive bundled with packaged releases. Generated by `scripts/build-template-archive.sh` and shipped at this path inside the app's resources. When present (and tagged with the current package.json sha), extract instead of running npm; decompression is ~3 s vs ~22 s for the live install. Stale archives (package.json bumped but archive not rebuilt) are silently ignored, so the live-install fallback always wins on correctness.
|
||||
P_BUNDLED_ARCHIVE_DIR = os.path.join(
|
||||
os.path.dirname(__file__), "webapp_template_cache"
|
||||
)
|
||||
@@ -242,9 +214,7 @@ def p_try_extract_bundled_archive(cache_dir: str, digest: str) -> bool:
|
||||
archive_path,
|
||||
)
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
# Archive root is `node_modules/`; extracting into cache_dir places
|
||||
# it at the expected path. tarfile uses zlib internally for .gz ,
|
||||
# no extra dep needed.
|
||||
# Archive root is `node_modules/`; extracting into cache_dir places it at the expected path. tarfile uses zlib internally for .gz, no extra dep needed.
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
tar.extractall(cache_dir)
|
||||
cache_modules = os.path.join(cache_dir, "node_modules")
|
||||
@@ -309,8 +279,7 @@ def ensure_warm_cache() -> str | None:
|
||||
if warm_cache_is_complete(cache_modules):
|
||||
return cache_modules
|
||||
|
||||
# Prefer a pre-extracted bundled tree: junction the workspace straight at it,
|
||||
# no tar-extract and no npm. This is the #9 first-app speed win on Windows.
|
||||
# Prefer a pre-extracted bundled tree: junction the workspace straight at it, no tar-extract and no npm. This is the #9 first-app speed win on Windows.
|
||||
bundled = bundled_extracted_modules()
|
||||
if bundled:
|
||||
logger.info("webapp-template: using bundled pre-extracted node_modules (zero extract)")
|
||||
@@ -319,15 +288,10 @@ def ensure_warm_cache() -> str | None:
|
||||
with p_warm_cache_lock:
|
||||
if warm_cache_is_complete(cache_modules):
|
||||
return cache_modules
|
||||
# A node_modules that exists but flunks the completeness check is a
|
||||
# half-finished install; wipe it so the rebuild below starts on clean
|
||||
# ground instead of layering onto a broken tree.
|
||||
# A node_modules that exists but flunks the completeness check is a half-finished install; wipe it so the rebuild below starts on clean ground instead of layering onto a broken tree.
|
||||
if os.path.isdir(cache_modules):
|
||||
shutil.rmtree(cache_modules, ignore_errors=True)
|
||||
# Fast path: pre-built archive shipped inside the release. The
|
||||
# build script generates this so users hitting OpenSwarm for the
|
||||
# first time skip the ~22 s live `npm install`. Falls through on
|
||||
# any failure so dev installs (no archive) keep working.
|
||||
# Fast path: pre-built archive shipped inside the release. The build script generates this so users hitting OpenSwarm for the first time skip the ~22 s live `npm install`. Falls through on any failure so dev installs (no archive) keep working.
|
||||
if p_try_extract_bundled_archive(cache_dir, warm_cache_digest()):
|
||||
if warm_cache_is_complete(cache_modules):
|
||||
logger.info("webapp-template: warm cache ready from bundled archive")
|
||||
@@ -336,10 +300,7 @@ def ensure_warm_cache() -> str | None:
|
||||
shutil.rmtree(cache_modules, ignore_errors=True)
|
||||
try:
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
# Copy package.json + lockfile (if it exists) into the cache
|
||||
# dir so npm has something to install from. We don't write
|
||||
# back to the template; the lockfile generated here stays
|
||||
# local to the cache.
|
||||
# Copy package.json + lockfile (if it exists) into the cache dir so npm has something to install from. We don't write back to the template; the lockfile generated here stays local to the cache.
|
||||
tmpl_pkg = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package.json")
|
||||
tmpl_lock = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package-lock.json")
|
||||
shutil.copyfile(tmpl_pkg, os.path.join(cache_dir, "package.json"))
|
||||
@@ -352,19 +313,13 @@ def ensure_warm_cache() -> str | None:
|
||||
shutil.copyfile(tmpl_lock, os.path.join(cache_dir, "package-lock.json"))
|
||||
cmd = [*npm, "ci", *base_flags]
|
||||
else:
|
||||
# No lockfile yet; `npm install` resolves the tree and
|
||||
# writes one into the cache dir for future use.
|
||||
# No lockfile yet; `npm install` resolves the tree and writes one into the cache dir for future use.
|
||||
cmd = [*npm, "install", *base_flags]
|
||||
logger.info("webapp-template: warming node_modules cache at %s", cache_dir)
|
||||
result = subprocess.run(
|
||||
cmd, cwd=cache_dir, capture_output=True, text=True, timeout=600
|
||||
)
|
||||
# --prefer-offline reuses npm's metadata cache, which can be
|
||||
# stale: if a pinned transitive (e.g. a @babel/* helper) was
|
||||
# published after the cache snapshot, resolution fails ETARGET
|
||||
# even though the registry has it. Retry once online (drops
|
||||
# --prefer-offline) so a partially-stale cache self-heals
|
||||
# instead of dead-ending the whole App Builder frontend.
|
||||
# --prefer-offline reuses npm's metadata cache, which can be stale: if a pinned transitive (e.g. a @babel/* helper) was published after the cache snapshot, resolution fails ETARGET even though the registry has it. Retry once online (drops --prefer-offline) so a partially-stale cache self-heals instead of dead-ending the whole App Builder frontend.
|
||||
if result.returncode != 0 and "ETARGET" in (result.stderr or ""):
|
||||
online_cmd = [c for c in cmd if c != "--prefer-offline"]
|
||||
logger.info("webapp-template: warm-cache offline pass hit ETARGET; retrying online")
|
||||
@@ -411,8 +366,7 @@ def p_try_link_dir(src: str, target: str) -> bool:
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# Slow + uses disk, but guarantees the workspace can boot vite even when
|
||||
# neither symlink nor junction is available.
|
||||
# Slow + uses disk, but guarantees the workspace can boot vite even when neither symlink nor junction is available.
|
||||
shutil.copytree(src, target, dirs_exist_ok=True)
|
||||
return True
|
||||
except OSError as exc:
|
||||
@@ -439,10 +393,7 @@ def p_link_node_modules(workspace_dir: str) -> None:
|
||||
except OSError:
|
||||
return
|
||||
elif os.path.isdir(target):
|
||||
# If the dir is EMPTY (left over from copytree of the template's
|
||||
# placeholder node_modules; `.gitkeep`-style scenarios) nuke it
|
||||
# so we can symlink to the warm cache. A non-empty directory is
|
||||
# treated as a real npm install; respect it and bail.
|
||||
# If the dir is EMPTY (left over from copytree of the template's placeholder node_modules; `.gitkeep`-style scenarios) nuke it so we can symlink to the warm cache. A non-empty directory is treated as a real npm install; respect it and bail.
|
||||
try:
|
||||
has_content = any(True for _ in os.scandir(target))
|
||||
except OSError:
|
||||
@@ -462,12 +413,7 @@ def p_link_node_modules(workspace_dir: str) -> None:
|
||||
logger.info("webapp-template: linked %s -> %s", target, cache_modules)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared Python venv cache; same pattern as the node_modules cache, but
|
||||
# for the workspace backend's FastAPI + transitive deps. Eliminates the
|
||||
# ~25s `python -m venv` + `pip install -e .` that backend_init.sh
|
||||
# otherwise pays per workspace.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Shared Python venv cache; same pattern as the node_modules cache, but for the workspace backend's FastAPI + transitive deps. Eliminates the ~25s `python -m venv` + `pip install -e .` that backend_init.sh otherwise pays per workspace. ---------------------------------------------------------------------------
|
||||
|
||||
p_warm_venv_lock = threading.Lock()
|
||||
|
||||
@@ -503,13 +449,7 @@ def p_ensure_warm_python_venv() -> str | None:
|
||||
return venv_dir
|
||||
try:
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
# Pick the same python the workspace's run.sh would have
|
||||
# picked, so the venv's binary is compatible. Includes
|
||||
# bare `python` as the last fallback for Windows, where
|
||||
# there's no `python3` symlink; the installer ships just
|
||||
# `python.exe`. On macOS/Linux the versioned candidates
|
||||
# match first so we don't accidentally pick a system
|
||||
# Python 2.x via the bare name.
|
||||
# Pick the same python the workspace's run.sh would have picked, so the venv's binary is compatible. Includes bare `python` as the last fallback for Windows, where there's no `python3` symlink; the installer ships just `python.exe`. On macOS/Linux the versioned candidates match first so we don't accidentally pick a system Python 2.x via the bare name.
|
||||
py = p_resolve_python()
|
||||
|
||||
# Wipe any half-populated venv from a previous crashed run.
|
||||
@@ -525,12 +465,7 @@ def p_ensure_warm_python_venv() -> str | None:
|
||||
logger.warning("warm-venv create failed: %s", r.stderr[-1500:])
|
||||
return None
|
||||
|
||||
# Install the template's dependencies (fastapi[standard],
|
||||
# typeguard, transitives); NOT the workspace's own backend,
|
||||
# which gets editable-installed per-workspace by run.sh after
|
||||
# the cache copy. The venv layout differs by platform:
|
||||
# POSIX puts executables in `bin/`, Windows in `Scripts/`,
|
||||
# and the executable name itself gets `.exe`.
|
||||
# Install the template's dependencies (fastapi[standard], typeguard, transitives); NOT the workspace's own backend, which gets editable-installed per-workspace by run.sh after the cache copy. The venv layout differs by platform: POSIX puts executables in `bin/`, Windows in `Scripts/`, and the executable name itself gets `.exe`.
|
||||
if os.name == "nt":
|
||||
pip = os.path.join(venv_dir, "Scripts", "pip.exe")
|
||||
else:
|
||||
@@ -582,10 +517,7 @@ def warm_cache_in_background() -> None:
|
||||
p_warm_cache_thread.start()
|
||||
|
||||
|
||||
# Trigger pre-warm on module import; backend startup hits this and the
|
||||
# installs run in parallel with the rest of the boot. By the time the
|
||||
# user creates their first app, node_modules + the backend venv are
|
||||
# usually ready.
|
||||
# Trigger pre-warm on module import; backend startup hits this and the installs run in parallel with the rest of the boot. By the time the user creates their first app, node_modules + the backend venv are usually ready.
|
||||
warm_cache_in_background()
|
||||
|
||||
|
||||
@@ -637,8 +569,7 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No
|
||||
ignore=p_ignore_backend,
|
||||
dirs_exist_ok=True,
|
||||
)
|
||||
# Symlink the workspace's frontend/node_modules at the warm cache so
|
||||
# `npm install` can be skipped entirely by the workspace run.sh.
|
||||
# Symlink the workspace's frontend/node_modules at the warm cache so `npm install` can be skipped entirely by the workspace run.sh.
|
||||
p_link_node_modules(workspace_dir)
|
||||
env_path = os.path.join(workspace_dir, ".env")
|
||||
env_example_path = os.path.join(workspace_dir, ".env.example")
|
||||
@@ -646,13 +577,7 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No
|
||||
if os.path.exists(src_example):
|
||||
shutil.copyfile(src_example, env_path)
|
||||
else:
|
||||
# .env.example can be absent from a packaged build whose copy step
|
||||
# stripped dotfiles (the Windows build's recursive '.env.*' exclude did
|
||||
# exactly this). Write the default directly so the workspace always has
|
||||
# a .env with BACKEND_PORT=NONE; without it run.sh sees no BACKEND_PORT,
|
||||
# takes the backend branch, and dies on a backend that isn't there,
|
||||
# leaving the app stuck on the splash. Mac was unaffected because its
|
||||
# build anchors the exclude and ships .env.example.
|
||||
# .env.example can be absent from a packaged build whose copy step stripped dotfiles (the Windows build's recursive '.env.*' exclude did exactly this). Write the default directly so the workspace always has a .env with BACKEND_PORT=NONE; without it run.sh sees no BACKEND_PORT, takes the backend branch, and dies on a backend that isn't there, leaving the app stuck on the splash. Mac was unaffected because its build anchors the exclude and ships .env.example.
|
||||
with open(env_path, "w", encoding="utf-8") as f:
|
||||
f.write("BACKEND_PORT=NONE\nFRONTEND_PORT=4949\n")
|
||||
|
||||
@@ -662,14 +587,10 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No
|
||||
# Install-specific paths; .env only.
|
||||
patch_env_port(env_path, "OPENSWARM_TEMPLATE_BACKEND_PATH", TEMPLATE_BACKEND_PATH)
|
||||
patch_env_port(env_path, "OPENSWARM_DEBUGGER_PATH", DEBUGGER_PATH)
|
||||
# Backend-venv warm-cache path; backend_init.sh checks this for a
|
||||
# pre-populated `.venv/` to cp -aR into the workspace instead of
|
||||
# paying the ~25s venv-create + pip-install cost. Written even if
|
||||
# the cache isn't ready yet; backend_init.sh re-checks at run time.
|
||||
# Backend-venv warm-cache path; backend_init.sh checks this for a pre-populated `.venv/` to cp -aR into the workspace instead of paying the ~25s venv-create + pip-install cost. Written even if the cache isn't ready yet; backend_init.sh re-checks at run time.
|
||||
patch_env_port(env_path, "OPENSWARM_BACKEND_VENV_CACHE", warm_venv_dir())
|
||||
|
||||
# Make the shipped scripts executable. tarball/git extracts may strip
|
||||
# the +x bit depending on how the snapshot was vendored.
|
||||
# Make the shipped scripts executable. tarball/git extracts may strip the +x bit depending on how the snapshot was vendored.
|
||||
for script in ("run.sh", "backend_init.sh", "frontend/run.sh"):
|
||||
p = os.path.join(workspace_dir, script)
|
||||
if os.path.exists(p):
|
||||
|
||||
@@ -13,16 +13,13 @@ async def health_lifespan():
|
||||
|
||||
health = SubApp("health", health_lifespan)
|
||||
|
||||
######################################
|
||||
# Health Check Endpoints #
|
||||
######################################
|
||||
# ##################################### Health Check Endpoints # #####################################
|
||||
|
||||
@health.router.get("/check")
|
||||
@typechecked
|
||||
async def check() -> PlainTextResponse:
|
||||
debug("Health check successful")
|
||||
# Use PlainTextResponse instead of JSONResponse for AWS ALB compatibility
|
||||
# ALB health checks can be sensitive to JSON responses and Content-Length headers
|
||||
# Use PlainTextResponse instead of JSONResponse for AWS ALB compatibility ALB health checks can be sensitive to JSON responses and Content-Length headers
|
||||
return PlainTextResponse(
|
||||
content="OK",
|
||||
status_code=status.HTTP_200_OK,
|
||||
|
||||
@@ -58,14 +58,7 @@ def app_workspace_dir(output_id: str) -> str | None:
|
||||
return path if os.path.isdir(path) else None
|
||||
|
||||
|
||||
# Build/install/cache directories that the polling endpoint must never
|
||||
# descend into. Without this skip-list the workspace endpoint reads
|
||||
# `node_modules/` (300 MB of MUI source, when it's a real dir and not a
|
||||
# symlink), `.venv/` (10k+ Python files from the hardlinked cache),
|
||||
# `__pycache__/`, `dist/`, `.git/`, etc; every 2 seconds while the
|
||||
# agent is active. Result: backend CPU pegged on JSON-serializing
|
||||
# auto-generated chunks the frontend will then throw away. The frontend
|
||||
# already filters these for display; this skip is the real fix.
|
||||
# Build/install/cache directories that the polling endpoint must never descend into. Without this skip-list the workspace endpoint reads `node_modules/` (300 MB of MUI source, when it's a real dir and not a symlink), `.venv/` (10k+ Python files from the hardlinked cache), `__pycache__/`, `dist/`, `.git/`, etc; every 2 seconds while the agent is active. Result: backend CPU pegged on JSON-serializing auto-generated chunks the frontend will then throw away. The frontend already filters these for display; this skip is the real fix.
|
||||
WALK_SKIP_DIRS = frozenset({
|
||||
"node_modules",
|
||||
".vite",
|
||||
@@ -82,11 +75,7 @@ WALK_SKIP_DIRS = frozenset({
|
||||
".ruff_cache",
|
||||
})
|
||||
|
||||
# Cap per-file response size at 256 KB. Hand-written source rarely
|
||||
# exceeds this; auto-generated bundles routinely run into the MBs and
|
||||
# they're not what the user/agent is editing. Anything over the cap
|
||||
# returns a truncated stub the frontend treats as "open the file
|
||||
# directly to see full contents."
|
||||
# Cap per-file response size at 256 KB. Hand-written source rarely exceeds this; auto-generated bundles routinely run into the MBs and they're not what the user/agent is editing. Anything over the cap returns a truncated stub the frontend treats as "open the file directly to see full contents."
|
||||
P_WALK_MAX_FILE_BYTES = 256 * 1024
|
||||
|
||||
|
||||
@@ -100,23 +89,14 @@ def walk_directory(folder: str) -> dict[str, str]:
|
||||
if not os.path.isdir(folder):
|
||||
return files
|
||||
for root, dirs, filenames in os.walk(folder):
|
||||
# Mutate `dirs` in place; that's how os.walk skips a subtree.
|
||||
# Doing it here means we never even stat the children, so a
|
||||
# 10k-file `.venv/` costs ~one stat (on the dir itself) instead
|
||||
# of 10k.
|
||||
# Mutate `dirs` in place; that's how os.walk skips a subtree. Doing it here means we never even stat the children, so a 10k-file `.venv/` costs ~one stat (on the dir itself) instead of 10k.
|
||||
dirs[:] = [d for d in dirs if d not in WALK_SKIP_DIRS]
|
||||
for fname in filenames:
|
||||
full_path = os.path.join(root, fname)
|
||||
# Normalize to forward-slash keys so the frontend's
|
||||
# `path.split('/')` and `.startsWith(prefix)` checks work
|
||||
# the same on Windows (where os.sep is '\\') as on macOS.
|
||||
# Without this, every workspace file came back as
|
||||
# `backend\\app.py` on Windows and the file tree silently
|
||||
# mis-parsed.
|
||||
# Normalize to forward-slash keys so the frontend's `path.split('/')` and `.startsWith(prefix)` checks work the same on Windows (where os.sep is '\\') as on macOS. Without this, every workspace file came back as `backend\\app.py` on Windows and the file tree silently mis-parsed.
|
||||
rel_path = os.path.relpath(full_path, folder).replace(os.sep, "/")
|
||||
try:
|
||||
# Stat first; cheap, lets us skip giant files without
|
||||
# opening + reading them.
|
||||
# Stat first; cheap, lets us skip giant files without opening + reading them.
|
||||
size = os.path.getsize(full_path)
|
||||
if size > P_WALK_MAX_FILE_BYTES:
|
||||
files[rel_path] = (
|
||||
|
||||
@@ -23,9 +23,7 @@ from typing import Iterator, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cap the spool at 50 MB on disk. SQLite's overhead means the actual ceiling
|
||||
# on retained payloads is somewhat smaller, which is fine; this is a
|
||||
# best-effort cushion, not a guaranteed retention window.
|
||||
# Cap the spool at 50 MB on disk. SQLite's overhead means the actual ceiling on retained payloads is somewhat smaller, which is fine; this is a best-effort cushion, not a guaranteed retention window.
|
||||
P_MAX_BYTES = 50 * 1024 * 1024
|
||||
|
||||
# Trim 25% when we cross the cap so we don't trim on every insert.
|
||||
@@ -70,8 +68,7 @@ def enqueue(spool_path: str, kind: str, payload: dict, *, now: float) -> None:
|
||||
size = 0
|
||||
if size > P_MAX_BYTES:
|
||||
target = int(P_MAX_BYTES * P_TRIM_TARGET_FRACTION)
|
||||
# Delete oldest rows until we're back under target. Use a
|
||||
# reasonable batch size so we don't block forever.
|
||||
# Delete oldest rows until we're back under target. Use a reasonable batch size so we don't block forever.
|
||||
dropped = 0
|
||||
for _ in range(64):
|
||||
row = c.execute("SELECT id FROM spool ORDER BY id ASC LIMIT 1").fetchone()
|
||||
@@ -87,8 +84,7 @@ def enqueue(spool_path: str, kind: str, payload: dict, *, now: float) -> None:
|
||||
break
|
||||
if dropped:
|
||||
logger.warning("Spool over %d MB cap; dropped %d oldest entries", P_MAX_BYTES // (1024 * 1024), dropped)
|
||||
# VACUUM is expensive; only run if we still appear oversized after
|
||||
# trimming, otherwise free pages get reused on next insert.
|
||||
# VACUUM is expensive; only run if we still appear oversized after trimming, otherwise free pages get reused on next insert.
|
||||
try:
|
||||
if os.path.getsize(spool_path) > P_MAX_BYTES:
|
||||
c.execute("VACUUM")
|
||||
|
||||
@@ -133,12 +133,7 @@ def p_get_user_id() -> Optional[str]:
|
||||
try:
|
||||
from backend.apps.settings.store import load_settings
|
||||
s = load_settings()
|
||||
# Prefer the cloud-issued user_id (UUID) if the user has signed in
|
||||
# via Google OAuth, magic link, or Stripe checkout; that's the
|
||||
# authoritative identity. Falls back to user_email for installs
|
||||
# that haven't completed sign-in yet (so existing onboarding-only
|
||||
# installs don't lose their Person history during the v1.0.29
|
||||
# rollout). After every install signs in, this fallback drops out.
|
||||
# Prefer the cloud-issued user_id (UUID) if the user has signed in via Google OAuth, magic link, or Stripe checkout; that's the authoritative identity. Falls back to user_email for installs that haven't completed sign-in yet (so existing onboarding-only installs don't lose their Person history during the v1.0.29 rollout). After every install signs in, this fallback drops out.
|
||||
return (
|
||||
getattr(s, "user_id", None)
|
||||
or getattr(s, "user_email", None)
|
||||
@@ -183,11 +178,7 @@ def p_envelope() -> dict:
|
||||
env["device_type"] = "desktop"
|
||||
except Exception:
|
||||
pass
|
||||
# Timezone: prefer the IANA zone name passed in by Electron (always
|
||||
# canonical, e.g. "America/Los_Angeles") so cloud-side localTimeFields()
|
||||
# can format hour-of-day correctly. Fall back to Python's local zone
|
||||
# which sometimes returns abbreviations (PDT, CDT) or localized names
|
||||
# ("Romance (zomertijd)") that don't round-trip through tzdata.
|
||||
# Timezone: prefer the IANA zone name passed in by Electron (always canonical, e.g. "America/Los_Angeles") so cloud-side localTimeFields() can format hour-of-day correctly. Fall back to Python's local zone which sometimes returns abbreviations (PDT, CDT) or localized names ("Romance (zomertijd)") that don't round-trip through tzdata.
|
||||
try:
|
||||
ianatz = os.environ.get("OPENSWARM_TIMEZONE", "").strip()
|
||||
if not ianatz:
|
||||
@@ -205,10 +196,7 @@ def p_envelope() -> dict:
|
||||
env["timezone"] = ianatz
|
||||
except Exception:
|
||||
pass
|
||||
# Locale: BCP 47 string ("en-US", "es-ES", etc.) injected by Electron via
|
||||
# app.getLocale(); see electron/main.js. We don't fall back to Python's
|
||||
# locale.getdefaultlocale() because that's deprecated, often empty, and
|
||||
# returns inconsistent OS-specific values across macOS/Windows/Linux.
|
||||
# Locale: BCP 47 string ("en-US", "es-ES", etc.) injected by Electron via app.getLocale(); see electron/main.js. We don't fall back to Python's locale.getdefaultlocale() because that's deprecated, often empty, and returns inconsistent OS-specific values across macOS/Windows/Linux.
|
||||
try:
|
||||
loc = os.environ.get("OPENSWARM_LOCALE", "").strip()
|
||||
if loc:
|
||||
@@ -216,9 +204,7 @@ def p_envelope() -> dict:
|
||||
except Exception:
|
||||
pass
|
||||
env["app_version"] = APP_VERSION
|
||||
# How this build was packaged. Set by the platform-specific build script
|
||||
# (electron-builder afterPack hooks for dmg / exe / appimage / deb / rpm).
|
||||
# Defaults to "dev" when running from `bash run.sh` in a checked-out repo.
|
||||
# How this build was packaged. Set by the platform-specific build script (electron-builder afterPack hooks for dmg / exe / appimage / deb / rpm). Defaults to "dev" when running from `bash run.sh` in a checked-out repo.
|
||||
env["install_method"] = os.environ.get("OPENSWARM_INSTALL_METHOD", "dev")
|
||||
return env
|
||||
|
||||
@@ -301,9 +287,7 @@ async def drain_spool(batch_size: int = 50) -> int:
|
||||
return len(succeeded)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Public API
|
||||
# --------------------------------------------------------------------------
|
||||
# -------------------------------------------------------------------------- Public API --------------------------------------------------------------------------
|
||||
|
||||
def p_log(kind: str, payload: dict) -> None:
|
||||
"""Append to the rolling operational log for diagnostics."""
|
||||
@@ -378,12 +362,7 @@ def p_schedule(coro) -> None:
|
||||
threading.Thread(target=p_run, daemon=True).start()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Backwards-compat shims for legacy call sites. New code calls submit()
|
||||
# directly. These keep the ~50 existing import sites in the codebase
|
||||
# working unchanged. Removed in a future cleanup once nothing imports
|
||||
# from older import paths.
|
||||
# --------------------------------------------------------------------------
|
||||
# -------------------------------------------------------------------------- Backwards-compat shims for legacy call sites. New code calls submit() directly. These keep the ~50 existing import sites in the codebase working unchanged. Removed in a future cleanup once nothing imports from older import paths. --------------------------------------------------------------------------
|
||||
|
||||
def submit_event(
|
||||
surface: str,
|
||||
|
||||
@@ -95,8 +95,7 @@ async def p_pulse_loop():
|
||||
if p_pulse_count >= p_pulse_batch_size:
|
||||
try:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
# Compact field names; the wire stays small and the cloud
|
||||
# is the only place that knows what each key means.
|
||||
# Compact field names; the wire stays small and the cloud is the only place that knows what each key means.
|
||||
svc.sync({
|
||||
"a": len(agent_manager.sessions), # active sessions
|
||||
"h": sorted(p_pulse_hours), # hour bucket set
|
||||
@@ -204,12 +203,7 @@ async def service_lifespan():
|
||||
|
||||
try:
|
||||
from backend.apps.nine_router import ensure_running as ensure_9router
|
||||
# Start 9Router in the BACKGROUND instead of awaiting it here. Awaiting
|
||||
# it was ~7s (up to ~18s cold) of the startup critical path, blocking the
|
||||
# HTTP bind and the whole UI behind it. 9Router is only needed when the
|
||||
# user sends an agent message, and the dispatch path calls ensure_running()
|
||||
# itself (now serialized, so no double-spawn), so the first message waits
|
||||
# for readiness lazily. This is the single biggest warm-startup win.
|
||||
# Start 9Router in the BACKGROUND instead of awaiting it here. Awaiting it was ~7s (up to ~18s cold) of the startup critical path, blocking the HTTP bind and the whole UI behind it. 9Router is only needed when the user sends an agent message, and the dispatch path calls ensure_running() itself (now serialized, so no double-spawn), so the first message waits for readiness lazily. This is the single biggest warm-startup win.
|
||||
p_9r_start_task = asyncio.create_task(ensure_9router())
|
||||
except Exception as e:
|
||||
logger.debug(f"9Router auto-start skipped: {e}")
|
||||
@@ -263,9 +257,7 @@ async def service_lifespan():
|
||||
service = SubApp("service", service_lifespan)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Usage endpoints (user-facing, read by the Settings / Usage page)
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Usage endpoints (user-facing, read by the Settings / Usage page) ---------------------------------------------------------------------------
|
||||
|
||||
def p_load_all_sessions() -> list[dict]:
|
||||
results = []
|
||||
@@ -290,8 +282,7 @@ async def usage_summary():
|
||||
sessions.append(s.model_dump(mode="json"))
|
||||
|
||||
def p_is_real(sess: dict) -> bool:
|
||||
# "Real" = actually ran. Empty draft/abandoned sessions (no assistant turn, no tokens,
|
||||
# no active time) otherwise inflate the count and drag every average toward zero.
|
||||
# "Real" = actually ran. Empty draft/abandoned sessions (no assistant turn, no tokens, no active time) otherwise inflate the count and drag every average toward zero.
|
||||
if (sess.get("agent_active_ms") or 0) > 0 or (sess.get("cost_usd") or 0) > 0:
|
||||
return True
|
||||
tk = sess.get("tokens") or {}
|
||||
@@ -319,9 +310,7 @@ async def usage_summary():
|
||||
provider_counts[s.get("provider", "anthropic")] += 1
|
||||
status_counts[s.get("status", "unknown")] += 1
|
||||
|
||||
# Tool calls: tool_latencies carries authoritative per-tool counts; older sessions only have
|
||||
# the sparse tool_call messages. Per session take whichever source recorded more so we never
|
||||
# undercount what's on record (and so the total never drops below the old message-only count).
|
||||
# Tool calls: tool_latencies carries authoritative per-tool counts; older sessions only have the sparse tool_call messages. Per session take whichever source recorded more so we never undercount what's on record (and so the total never drops below the old message-only count).
|
||||
lat_counts: Counter = Counter()
|
||||
for tool, d in (s.get("tool_latencies") or {}).items():
|
||||
cnt = (d or {}).get("count", 0) or 0
|
||||
@@ -438,9 +427,7 @@ async def service_status():
|
||||
return {"status": "ok", "enabled": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frontend event endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Frontend event endpoints ---------------------------------------------------------------------------
|
||||
|
||||
def p_bridge_to_analytics(item: dict) -> None:
|
||||
# Boundary adapter: validate the raw report() envelope into a typed event, hand it to the analytics bridge.
|
||||
@@ -472,9 +459,7 @@ async def post_submit(body=Body(...)):
|
||||
with a 200 + `{ok:false}`, so every UI event from `report()` was
|
||||
dropped; `frontend.event` count was 0 in production analytics.
|
||||
"""
|
||||
# Shape 3: batched array. Recurse per-item so single-item handling
|
||||
# logic stays in one place. Returns a single ok regardless of
|
||||
# individual item shape; analytics calls aren't transactional.
|
||||
# Shape 3: batched array. Recurse per-item so single-item handling logic stays in one place. Returns a single ok regardless of individual item shape; analytics calls aren't transactional.
|
||||
if isinstance(body, list):
|
||||
for item in body:
|
||||
if isinstance(item, dict):
|
||||
|
||||
@@ -9,19 +9,11 @@ import os
|
||||
|
||||
|
||||
def read_app_version() -> str:
|
||||
# Preferred: Electron's main process injects this when spawning the
|
||||
# backend (see electron/main.js; OPENSWARM_APP_VERSION). Always reliable
|
||||
# in packaged builds because it comes from app.getVersion() rather than
|
||||
# path-based file resolution.
|
||||
# Preferred: Electron's main process injects this when spawning the backend (see electron/main.js; OPENSWARM_APP_VERSION). Always reliable in packaged builds because it comes from app.getVersion() rather than path-based file resolution.
|
||||
env_v = os.environ.get("OPENSWARM_APP_VERSION", "").strip()
|
||||
if env_v:
|
||||
return env_v
|
||||
# Fallback: read electron/package.json via relative path. Works in
|
||||
# `bash run.sh` dev mode where the repo layout is intact, but FAILS in
|
||||
# packaged dmg/exe builds because electron/package.json isn't shipped
|
||||
# into Resources/; which made every shipped install report
|
||||
# app_version="unknown" pre-fix. Kept for backward compatibility with
|
||||
# dev runs and as a safety net if the env var is ever unset.
|
||||
# Fallback: read electron/package.json via relative path. Works in `bash run.sh` dev mode where the repo layout is intact, but FAILS in packaged dmg/exe builds because electron/package.json isn't shipped into Resources/; which made every shipped install report app_version="unknown" pre-fix. Kept for backward compatibility with dev runs and as a safety net if the env var is ever unset.
|
||||
try:
|
||||
p_here = os.path.dirname(os.path.abspath(__file__))
|
||||
p_repo = os.path.dirname(os.path.dirname(os.path.dirname(p_here)))
|
||||
|
||||
@@ -10,10 +10,7 @@ if TYPE_CHECKING:
|
||||
|
||||
OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.com"
|
||||
|
||||
# Connection modes that route Claude traffic through our cloud proxy with a
|
||||
# bearer instead of a user-held key. Free-trial is openswarm-pro's cheaper
|
||||
# sibling: same proxy, but pointed at the /free sub-path the cloud meters and
|
||||
# forces to Haiku.
|
||||
# Connection modes that route Claude traffic through our cloud proxy with a bearer instead of a user-held key. Free-trial is openswarm-pro's cheaper sibling: same proxy, but pointed at the /free sub-path the cloud meters and forces to Haiku.
|
||||
PROXY_CONNECTION_MODES = ("openswarm-pro", "free-trial")
|
||||
|
||||
|
||||
|
||||
@@ -74,15 +74,11 @@ class AppSettings(BaseModel):
|
||||
connection_mode: str = "own_key"
|
||||
openswarm_bearer_token: Optional[str] = None
|
||||
openswarm_proxy_url: Optional[str] = None
|
||||
# Zero-config free trial: server-funded runs for a brand-new user with no
|
||||
# key and no subscription. connection_mode flips to "free-trial" while armed;
|
||||
# the token + remaining count are server-owned (minted by the cloud, sticky
|
||||
# per machine). remaining is cached for the onboarding "runs low" nudge.
|
||||
# Zero-config free trial: server-funded runs for a brand-new user with no key and no subscription. connection_mode flips to "free-trial" while armed; the token + remaining count are server-owned (minted by the cloud, sticky per machine). remaining is cached for the onboarding "runs low" nudge.
|
||||
free_trial_token: Optional[str] = None
|
||||
free_trial_remaining: Optional[int] = None
|
||||
free_trial_runs_limit: Optional[int] = None
|
||||
# Epoch seconds when the rolling window refills to a fresh allotment; lets the spent-trial
|
||||
# nudge say "fresh runs in ~3h" instead of a vague "for now". Server-owned.
|
||||
# Epoch seconds when the rolling window refills to a fresh allotment; lets the spent-trial nudge say "fresh runs in ~3h" instead of a vague "for now". Server-owned.
|
||||
free_trial_resets_at: Optional[float] = None
|
||||
openswarm_subscription_plan: Optional[str] = None
|
||||
openswarm_subscription_expires: Optional[str] = None
|
||||
|
||||
@@ -20,8 +20,7 @@ from typing import Any
|
||||
from backend.common.secret_scan import looks_secret
|
||||
|
||||
P_SECRET_NAME_SUFFIXES = ("_key", "_token", "_secret")
|
||||
# Not a credential and doesn't match the suffix rule, but a stable hardware-ish
|
||||
# fingerprint used for cohorting/abuse; keep it out of the agent's eyes too.
|
||||
# Not a credential and doesn't match the suffix rule, but a stable hardware-ish fingerprint used for cohorting/abuse; keep it out of the agent's eyes too.
|
||||
P_SECRET_EXTRA_FIELDS = frozenset({"installation_id"})
|
||||
|
||||
|
||||
|
||||
@@ -54,20 +54,12 @@ async def settings_lifespan():
|
||||
await p_9r_ensure()
|
||||
except Exception as e:
|
||||
logger.warning(f"9Router lifespan boot failed: {e}")
|
||||
# Reconcile, don't just add: pass the key OR None so a cleared/never-set key
|
||||
# also REMOVES the managed connection 9Router persists across restarts. The
|
||||
# old add-only guards left a zombie managed key alive after disconnect, which
|
||||
# kept routing to it (the "still defaults to gemini") and blocked the free
|
||||
# trial from arming. Only acts when 9Router is already up (_sync no-ops if not).
|
||||
# Reconcile, don't just add: pass the key OR None so a cleared/never-set key also REMOVES the managed connection 9Router persists across restarts. The old add-only guards left a zombie managed key alive after disconnect, which kept routing to it (the "still defaults to gemini") and blocked the free trial from arming. Only acts when 9Router is already up (_sync no-ops if not).
|
||||
if p_9r_running():
|
||||
await sync_gemini_api_key(getattr(s, "google_api_key", None) or None)
|
||||
await sync_openai_api_key(getattr(s, "openai_api_key", None) or None)
|
||||
await sync_openrouter_api_key(getattr(s, "openrouter_api_key", None) or None)
|
||||
# Reconcile the managed Pro/anthropic connection symmetrically too: keep it only
|
||||
# for an active pro/free-trial bearer, else REMOVE it. Without the else, disconnecting
|
||||
# Pro left a zombie managed Claude connection in 9Router, so the backend kept seeing a
|
||||
# model and the free trial refused to arm ("disconnect Pro -> nothing happens"). Only
|
||||
# the OpenSwarm-managed Pro node is touched; a user's own Claude sub (priority 0) is safe.
|
||||
# Reconcile the managed Pro/anthropic connection symmetrically too: keep it only for an active pro/free-trial bearer, else REMOVE it. Without the else, disconnecting Pro left a zombie managed Claude connection in 9Router, so the backend kept seeing a model and the free trial refused to arm ("disconnect Pro -> nothing happens"). Only the OpenSwarm-managed Pro node is touched; a user's own Claude sub (priority 0) is safe.
|
||||
if getattr(s, "connection_mode", None) in ("openswarm-pro", "free-trial"):
|
||||
from backend.apps.settings.credentials import proxy_auth
|
||||
bearer, base = proxy_auth(s)
|
||||
@@ -124,8 +116,7 @@ async def get_settings():
|
||||
return load_settings().model_dump()
|
||||
|
||||
|
||||
# Written only by their dedicated flows (Stripe activate, sign-in, signout, OAuth connects);
|
||||
# a full-object PUT from a stale renderer snapshot must never revert or forge them.
|
||||
# Written only by their dedicated flows (Stripe activate, sign-in, signout, OAuth connects); a full-object PUT from a stale renderer snapshot must never revert or forge them.
|
||||
SERVER_OWNED_FIELDS = (
|
||||
"connection_mode",
|
||||
"openswarm_bearer_token",
|
||||
@@ -151,15 +142,7 @@ SERVER_OWNED_FIELDS = (
|
||||
|
||||
import weakref as p_weakref
|
||||
|
||||
# One serialization point for EVERY settings write (renderer PUT/PATCH + agent
|
||||
# tool), so two writes can't interleave and clobber each other mid read-modify-
|
||||
# write. Callers hold it across read->build->save; apply_settings_update itself
|
||||
# does NOT acquire it (would deadlock the agent path that reads under it), so
|
||||
# every caller wraps apply in it. Created lazily PER event loop: prod has one
|
||||
# loop so it's effectively a singleton, but a module-level asyncio.Lock binds to
|
||||
# the first loop that uses it and then errors on reuse from another loop (every
|
||||
# async test spins a fresh one). WeakKeyDictionary auto-drops a loop's lock once
|
||||
# the loop is gone.
|
||||
# One serialization point for EVERY settings write (renderer PUT/PATCH + agent tool), so two writes can't interleave and clobber each other mid read-modify- write. Callers hold it across read->build->save; apply_settings_update itself does NOT acquire it (would deadlock the agent path that reads under it), so every caller wraps apply in it. Created lazily PER event loop: prod has one loop so it's effectively a singleton, but a module-level asyncio.Lock binds to the first loop that uses it and then errors on reuse from another loop (every async test spins a fresh one). WeakKeyDictionary auto-drops a loop's lock once the loop is gone.
|
||||
p_settings_write_locks: "_weakref.WeakKeyDictionary" = p_weakref.WeakKeyDictionary()
|
||||
|
||||
|
||||
@@ -221,18 +204,12 @@ async def apply_settings_update(body: AppSettings, protect_fields: set[str] | No
|
||||
for k in SERVER_OWNED_FIELDS:
|
||||
setattr(body, k, getattr(old, k, None))
|
||||
|
||||
# Second wall: if a write tries to clear a credential that's currently set and
|
||||
# flagged as powering this run, restore it (like server-owned fields). The
|
||||
# endpoint guard already strips these; this is the backstop that can't be
|
||||
# bypassed by a logic slip upstream.
|
||||
# Second wall: if a write tries to clear a credential that's currently set and flagged as powering this run, restore it (like server-owned fields). The endpoint guard already strips these; this is the backstop that can't be bypassed by a logic slip upstream.
|
||||
for f in (protect_fields or ()):
|
||||
if getattr(old, f, None) and not getattr(body, f, None):
|
||||
setattr(body, f, getattr(old, f, None))
|
||||
|
||||
# If the user connects their own model while the free trial is armed, hand
|
||||
# the wheel back to their provider. Without this, connection_mode (server-
|
||||
# owned, so the loop above just restored it to "free-trial") would keep them
|
||||
# pinned to the forced Haiku lane even though they pasted a real key.
|
||||
# If the user connects their own model while the free trial is armed, hand the wheel back to their provider. Without this, connection_mode (server- owned, so the loop above just restored it to "free-trial") would keep them pinned to the forced Haiku lane even though they pasted a real key.
|
||||
if getattr(old, "connection_mode", "own_key") == "free-trial":
|
||||
from backend.apps.subscription.free_trial import has_own_model
|
||||
if has_own_model(body):
|
||||
@@ -398,10 +375,7 @@ async def reset_system_prompt():
|
||||
return {"ok": True, "settings": current.model_dump()}
|
||||
|
||||
|
||||
# A preferences reset (the iOS "Reset All Settings" analogue): everything back to
|
||||
# defaults EXCEPT the things a "reset my preferences" click must never silently
|
||||
# sever, your connections (server-owned subscription fields AND your pasted
|
||||
# provider credentials) and your identity. Hard-erase is the separate flow.
|
||||
# A preferences reset (the iOS "Reset All Settings" analogue): everything back to defaults EXCEPT the things a "reset my preferences" click must never silently sever, your connections (server-owned subscription fields AND your pasted provider credentials) and your identity. Hard-erase is the separate flow.
|
||||
P_RESET_PRESERVE_FIELDS = SERVER_OWNED_FIELDS + (
|
||||
"anthropic_api_key",
|
||||
"openai_api_key",
|
||||
@@ -451,12 +425,7 @@ def sniff_file_kind(contents: bytes, name: str) -> tuple[str, str | None]:
|
||||
return ("image", "image/gif")
|
||||
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
||||
return ("image", "image/webp")
|
||||
# Other common binary signatures that don't contain a null byte in the
|
||||
# first few bytes (so the null-byte fallback below would miss them):
|
||||
# zip/docx/xlsx/pptx/jar/apk/odt (PK\x03\x04), gzip (\x1f\x8b),
|
||||
# 7z (7z\xbc\xaf), tar (ustar magic at offset 257), rar (Rar!\x1a\x07),
|
||||
# ELF (\x7fELF), Mach-O (\xfe\xed\xfa\xce / \xce\xfa\xed\xfe), Win exe
|
||||
# (MZ), Java class (\xca\xfe\xba\xbe), sqlite (SQLite format 3\x00).
|
||||
# Other common binary signatures that don't contain a null byte in the first few bytes (so the null-byte fallback below would miss them): zip/docx/xlsx/pptx/jar/apk/odt (PK\x03\x04), gzip (\x1f\x8b), 7z (7z\xbc\xaf), tar (ustar magic at offset 257), rar (Rar!\x1a\x07), ELF (\x7fELF), Mach-O (\xfe\xed\xfa\xce / \xce\xfa\xed\xfe), Win exe (MZ), Java class (\xca\xfe\xba\xbe), sqlite (SQLite format 3\x00).
|
||||
if (head.startswith(b"PK\x03\x04") or head.startswith(b"PK\x05\x06") or
|
||||
head.startswith(b"\x1f\x8b") or head.startswith(b"7z\xbc\xaf\x27\x1c") or
|
||||
head.startswith(b"Rar!\x1a\x07") or head.startswith(b"\x7fELF") or
|
||||
@@ -465,9 +434,7 @@ def sniff_file_kind(contents: bytes, name: str) -> tuple[str, str | None]:
|
||||
head.startswith(b"MZ") or head.startswith(b"\xca\xfe\xba\xbe") or
|
||||
head.startswith(b"SQLite format 3\x00")):
|
||||
return ("binary", None)
|
||||
# Binary heuristic: any null bytes in the first 4KB is a strong "not text" signal.
|
||||
# Falls back gracefully for unusual encodings (UTF-16 has nulls too, but we treat
|
||||
# those as binary for safety since the agent's `open(..., "r")` would misread them).
|
||||
# Binary heuristic: any null bytes in the first 4KB is a strong "not text" signal. Falls back gracefully for unusual encodings (UTF-16 has nulls too, but we treat those as binary for safety since the agent's `open(..., "r")` would misread them).
|
||||
if b"\x00" in head:
|
||||
return ("binary", None)
|
||||
try:
|
||||
@@ -497,8 +464,7 @@ def estimate_pdf_tokens(contents: bytes) -> int:
|
||||
import re as p_re
|
||||
by_pages = 0
|
||||
try:
|
||||
# Prefer the root catalog's /Pages entry. PDFs can have nested
|
||||
# /Count fields (outlines, sub-pages), so anchor on /Type /Pages.
|
||||
# Prefer the root catalog's /Pages entry. PDFs can have nested /Count fields (outlines, sub-pages), so anchor on /Type /Pages.
|
||||
m = p_re.search(rb"/Type\s*/Pages\b[^>]{0,200}?/Count\s+(\d+)", contents, p_re.DOTALL)
|
||||
if not m:
|
||||
# Fallback: catalog declares /Pages then references /Count via /Kids.
|
||||
@@ -534,16 +500,11 @@ async def upload_files(files: list[UploadFile] = File(...)):
|
||||
results = []
|
||||
for f in files:
|
||||
safe_name = os.path.basename(f.filename or "untitled")
|
||||
# Strip path separators that survived basename on Windows-typed
|
||||
# uploads where filename arrived with backslashes preserved.
|
||||
# Strip path separators that survived basename on Windows-typed uploads where filename arrived with backslashes preserved.
|
||||
safe_name = safe_name.replace("\\", "_").replace("/", "_") or "untitled"
|
||||
contents = await f.read()
|
||||
|
||||
# Atomic create-with-collision-retry so two concurrent uploads with
|
||||
# the same filename never overwrite each other. The previous
|
||||
# exists() then open() pattern had a race window: both callers
|
||||
# would observe `dest` free and both would write, with the second
|
||||
# winning. O_EXCL fails the create if anyone else got there first.
|
||||
# Atomic create-with-collision-retry so two concurrent uploads with the same filename never overwrite each other. The previous exists() then open() pattern had a race window: both callers would observe `dest` free and both would write, with the second winning. O_EXCL fails the create if anyone else got there first.
|
||||
base, ext = os.path.splitext(safe_name)
|
||||
dest = os.path.join(UPLOAD_DIR, safe_name)
|
||||
counter = 0
|
||||
@@ -645,14 +606,7 @@ async def summarize_file(req: p_SummarizeRequest):
|
||||
"say so. Aim for roughly the target token budget."
|
||||
)
|
||||
|
||||
# Source can be bigger than the aux model's window (Haiku 4.5 is 200K).
|
||||
# Chunk by characters, summarize each, then merge. PDFs and other
|
||||
# binary-ish text tokenize WAY denser than the 4-chars-per-token rule
|
||||
# of thumb implies; a 480K-char PDF blob was hitting 210K tokens and
|
||||
# busting Haiku's 200K window. 200K chars / chunk caps the worst case
|
||||
# at ~100K tokens even for binary garbage, leaving ~100K for system +
|
||||
# output. Char-level cut intentionally; re-summarization tolerates a
|
||||
# mid-sentence split.
|
||||
# Source can be bigger than the aux model's window (Haiku 4.5 is 200K). Chunk by characters, summarize each, then merge. PDFs and other binary-ish text tokenize WAY denser than the 4-chars-per-token rule of thumb implies; a 480K-char PDF blob was hitting 210K tokens and busting Haiku's 200K window. 200K chars / chunk caps the worst case at ~100K tokens even for binary garbage, leaving ~100K for system + output. Char-level cut intentionally; re-summarization tolerates a mid-sentence split.
|
||||
CHUNK_CHARS = 200_000
|
||||
is_chunked = len(raw) > CHUNK_CHARS
|
||||
|
||||
@@ -682,11 +636,7 @@ async def summarize_file(req: p_SummarizeRequest):
|
||||
else:
|
||||
chunks = [raw[i:i + CHUNK_CHARS] for i in range(0, len(raw), CHUNK_CHARS)]
|
||||
per_chunk_budget = max(800, req.target_tokens // len(chunks) + 600)
|
||||
# Parallel summarization. Sequential was N chunks * ~60s each
|
||||
# (5+ min wall time for a 4-chunk PDF on Haiku). Aux providers
|
||||
# all handle parallel requests fine; the only ceiling is the
|
||||
# provider's per-key rate limit, and a single user summarizing
|
||||
# one file will never hit that.
|
||||
# Parallel summarization. Sequential was N chunks * ~60s each (5+ min wall time for a 4-chunk PDF on Haiku). Aux providers all handle parallel requests fine; the only ceiling is the provider's per-key rate limit, and a single user summarizing one file will never hit that.
|
||||
partials = await asyncio.gather(*[
|
||||
p_summarize_block(ch, per_chunk_budget, f"{os.path.basename(src)} (part {i + 1} of {len(chunks)})")
|
||||
for i, ch in enumerate(chunks)
|
||||
|
||||
@@ -49,8 +49,7 @@ def p_coerce_settings(raw: dict) -> AppSettings:
|
||||
try:
|
||||
return AppSettings(**cleaned)
|
||||
except ValidationError:
|
||||
# Still invalid after dropping the flagged fields (nested shape we
|
||||
# can't surgically repair); fall back to all defaults rather than crash.
|
||||
# Still invalid after dropping the flagged fields (nested shape we can't surgically repair); fall back to all defaults rather than crash.
|
||||
logger.warning("settings.json still invalid after dropping bad fields; using defaults")
|
||||
return AppSettings()
|
||||
|
||||
@@ -66,10 +65,7 @@ def p_preserve_corrupt_settings() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# In-memory mirror of SETTINGS_FILE, revalidated by stat (mtime+size) on every load
|
||||
# so even a hand-edited file or an unexpected writer is picked up immediately. A stat
|
||||
# skips the open+parse+validate that Defender turns into 5-50ms on Windows. Copies on
|
||||
# both sides keep handler isolation: callers mutate their copy, never the cache.
|
||||
# In-memory mirror of SETTINGS_FILE, revalidated by stat (mtime+size) on every load so even a hand-edited file or an unexpected writer is picked up immediately. A stat skips the open+parse+validate that Defender turns into 5-50ms on Windows. Copies on both sides keep handler isolation: callers mutate their copy, never the cache.
|
||||
p_cached_settings: AppSettings | None = None
|
||||
p_cached_sig: tuple[int, int] | None = None
|
||||
|
||||
|
||||
@@ -20,16 +20,11 @@ RAW_BASE = f"https://raw.githubusercontent.com/{REPO}/{BRANCH}"
|
||||
MANIFEST_URL = f"{RAW_BASE}/.claude-plugin/marketplace.json"
|
||||
REFRESH_INTERVAL_S = 3600
|
||||
CONCURRENT_FETCHES = 15
|
||||
# Retry the startup fetch on this short backoff (capped) until the FIRST success,
|
||||
# instead of waiting a full REFRESH_INTERVAL_S after a cold/slow/failed fetch.
|
||||
# That 1h gap was the "skills empty until reboot" bug on cold Windows networks.
|
||||
# Retry the startup fetch on this short backoff (capped) until the FIRST success, instead of waiting a full REFRESH_INTERVAL_S after a cold/slow/failed fetch. That 1h gap was the "skills empty until reboot" bug on cold Windows networks.
|
||||
P_RETRY_BACKOFF_START_S = 2
|
||||
P_RETRY_BACKOFF_MAX_S = 60
|
||||
|
||||
# Catalog ships in the repo so a brand-new install shows skills with zero network
|
||||
# (build snapshot), and every successful live fetch is persisted to the user's
|
||||
# cache so subsequent launches are instant + offline-safe. The live fetch always
|
||||
# overwrites both once it lands, so neither can go stale at runtime.
|
||||
# Catalog ships in the repo so a brand-new install shows skills with zero network (build snapshot), and every successful live fetch is persisted to the user's cache so subsequent launches are instant + offline-safe. The live fetch always overwrites both once it lands, so neither can go stale at runtime.
|
||||
BUNDLED_SNAPSHOT = os.path.join(os.path.dirname(__file__), "skills_snapshot.json")
|
||||
|
||||
p_cache: dict[str, dict] = {}
|
||||
@@ -184,9 +179,7 @@ async def p_refresh_loop():
|
||||
backoff = P_RETRY_BACKOFF_START_S
|
||||
await asyncio.sleep(REFRESH_INTERVAL_S)
|
||||
else:
|
||||
# Cold/slow/failed fetch: retry soon (capped) until the first success
|
||||
# so a transient network hiccup doesn't leave the catalog empty for
|
||||
# an hour. The seeded snapshot keeps it non-empty meanwhile.
|
||||
# Cold/slow/failed fetch: retry soon (capped) until the first success so a transient network hiccup doesn't leave the catalog empty for an hour. The seeded snapshot keeps it non-empty meanwhile.
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, P_RETRY_BACKOFF_MAX_S)
|
||||
|
||||
@@ -194,8 +187,7 @@ async def p_refresh_loop():
|
||||
@asynccontextmanager
|
||||
async def skill_registry_lifespan():
|
||||
global p_refresh_task, p_cache
|
||||
# Seed instantly from disk/bundled snapshot so the very first request never
|
||||
# sees an empty catalog (the live fetch below overwrites it when it lands).
|
||||
# Seed instantly from disk/bundled snapshot so the very first request never sees an empty catalog (the live fetch below overwrites it when it lands).
|
||||
if not p_cache:
|
||||
p_cache = load_seed_cache()
|
||||
p_refresh_task = asyncio.create_task(p_refresh_loop())
|
||||
@@ -280,14 +272,7 @@ async def registry_detail(skill_name: str):
|
||||
return {"skill": sk}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Community source: the skills.sh wild registry (~600k+ telemetry-ranked,
|
||||
# zero-curation community skills, GitHub-repo backed). The curated source above
|
||||
# (anthropics/skills) stays the default; community is opt-in via ?source=community
|
||||
# and the UI flags it as unvetted. See .claude/SECURITY.md for the posture: this
|
||||
# installs INERT files only (never executes), discloses scripts before commit,
|
||||
# and any skill script later runs through the same gated Bash path as anything.
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Community source: the skills.sh wild registry (~600k+ telemetry-ranked, zero-curation community skills, GitHub-repo backed). The curated source above (anthropics/skills) stays the default; community is opt-in via ?source=community and the UI flags it as unvetted. See .claude/SECURITY.md for the posture: this installs INERT files only (never executes), discloses scripts before commit, and any skill script later runs through the same gated Bash path as anything. ---------------------------------------------------------------------------
|
||||
|
||||
P_COMMUNITY_SEARCH_URL = "https://skills.sh/api/search"
|
||||
P_GH_API = "https://api.github.com"
|
||||
@@ -401,9 +386,7 @@ async def resolve_community_skill(source: str, skill_id: str) -> dict:
|
||||
raise ValueError("SKILL.md could not be fetched")
|
||||
|
||||
meta, p_body = p_parse_frontmatter(files["SKILL.md"])
|
||||
# Reuse the .swarm importer's content scan: flag files holding secret-shaped
|
||||
# literals (the author's leaked key, or a sketchy skill) so the user sees it
|
||||
# before installing from an unvetted repo.
|
||||
# Reuse the .swarm importer's content scan: flag files holding secret-shaped literals (the author's leaked key, or a sketchy skill) so the user sees it before installing from an unvetted repo.
|
||||
from backend.common.secret_scan import find_secrets_in_files
|
||||
secret_findings = find_secrets_in_files({rel: data.encode("utf-8", "ignore") for rel, data in files.items()})
|
||||
return {
|
||||
@@ -484,8 +467,7 @@ async def registry_install(req: p_InstallRequest):
|
||||
return {"installed": False, "disclosure": disclosure}
|
||||
|
||||
from backend.apps.skills.skills import write_folder_skill, unique_skill_slug
|
||||
# Never clobber an existing local skill that happens to share this slug; a
|
||||
# wild-registry name collision lands as a copy instead of overwriting.
|
||||
# Never clobber an existing local skill that happens to share this slug; a wild-registry name collision lands as a copy instead of overwriting.
|
||||
slug = unique_skill_slug(resolved["skill_id"])
|
||||
skill = write_folder_skill(
|
||||
slug,
|
||||
|
||||
@@ -12,9 +12,7 @@ class Skill(BaseModel):
|
||||
command: str = ""
|
||||
# Platform-shipped skills (e.g. App Builder): UI hides delete and DELETE returns 409, but content stays editable so users can tune them.
|
||||
built_in: bool = False
|
||||
# Multi-file skills live in ~/.claude/skills/<id>/ with a SKILL.md plus supporting files (scripts, templates).
|
||||
# dir_path is set for those; empty for a legacy flat <id>.md skill. has_supporting_files flags extra files
|
||||
# beyond SKILL.md so the prompt layer knows to point the agent at the folder for on-demand reading.
|
||||
# Multi-file skills live in ~/.claude/skills/<id>/ with a SKILL.md plus supporting files (scripts, templates). dir_path is set for those; empty for a legacy flat <id>.md skill. has_supporting_files flags extra files beyond SKILL.md so the prompt layer knows to point the agent at the folder for on-demand reading.
|
||||
dir_path: str = ""
|
||||
has_supporting_files: bool = False
|
||||
|
||||
|
||||
@@ -40,10 +40,7 @@ def load_index() -> dict[str, dict]:
|
||||
return {}
|
||||
|
||||
|
||||
# Guards the index write so an atomic replace is never interleaved by another
|
||||
# writer. Today every index write runs on the single backend event-loop thread
|
||||
# (no await between a load and its save, so no lost-update race), but this stays
|
||||
# correct if a save ever moves to a thread pool the way settings' did.
|
||||
# Guards the index write so an atomic replace is never interleaved by another writer. Today every index write runs on the single backend event-loop thread (no await between a load and its save, so no lost-update race), but this stays correct if a save ever moves to a thread pool the way settings' did.
|
||||
p_index_write_lock = threading.Lock()
|
||||
|
||||
|
||||
@@ -73,15 +70,9 @@ def save_index(index: dict[str, dict]):
|
||||
raise
|
||||
|
||||
|
||||
# Built-in skills shipped with OpenSwarm itself. Each entry describes a
|
||||
# skill file we copy into ~/.claude/skills/ on first boot and tag with
|
||||
# `built_in: true` in the index. Users can edit the content (their
|
||||
# changes flow through to the matching agent's prompt on the next turn),
|
||||
# but they can't delete the file; the DELETE endpoint refuses with 409.
|
||||
# Built-in skills shipped with OpenSwarm itself. Each entry describes a skill file we copy into ~/.claude/skills/ on first boot and tag with `built_in: true` in the index. Users can edit the content (their changes flow through to the matching agent's prompt on the next turn), but they can't delete the file; the DELETE endpoint refuses with 409.
|
||||
def p_built_in_skill_registry() -> list[dict]:
|
||||
# Imported lazily so this module stays cheap to import from
|
||||
# everywhere (the skills outputs module pulls in pydantic+fastapi
|
||||
# transitively and we don't want a cycle).
|
||||
# Imported lazily so this module stays cheap to import from everywhere (the skills outputs module pulls in pydantic+fastapi transitively and we don't want a cycle).
|
||||
from backend.apps.outputs.view_builder_templates import (
|
||||
APP_BUILDER_SKILL_SOURCE_PATH,
|
||||
SWARM_DEBUG_SKILL_SOURCE_PATH,
|
||||
@@ -135,9 +126,7 @@ def p_seed_built_in_skills() -> None:
|
||||
except FileNotFoundError:
|
||||
logger.warning("built-in skill source missing: %s", entry["source_path"])
|
||||
continue
|
||||
# Refresh index metadata. Existing user-changed name/description
|
||||
# in the index stays, but built_in always gets re-asserted in case
|
||||
# the index was created before this mechanism existed.
|
||||
# Refresh index metadata. Existing user-changed name/description in the index stays, but built_in always gets re-asserted in case the index was created before this mechanism existed.
|
||||
meta = dict(index.get(skill_id, {}))
|
||||
meta.setdefault("name", entry["name"])
|
||||
meta.setdefault("description", entry["description"])
|
||||
@@ -159,8 +148,7 @@ async def skills_lifespan():
|
||||
try:
|
||||
p_seed_built_in_skills()
|
||||
except Exception:
|
||||
# Don't block app startup on a skill-seed failure; the worst
|
||||
# case is the user has to manually paste the skill in once.
|
||||
# Don't block app startup on a skill-seed failure; the worst case is the user has to manually paste the skill in once.
|
||||
logger.exception("failed to seed built-in skills")
|
||||
yield
|
||||
|
||||
@@ -354,8 +342,7 @@ def write_folder_skill(skill_id: str, files: dict[str, str], meta: dict) -> Skil
|
||||
slug = p_safe_slug(skill_id)
|
||||
base = os.path.join(SKILLS_DIR, slug)
|
||||
base_abs = os.path.abspath(base)
|
||||
# A folder write supersedes any legacy flat <slug>.md, so we never leave a
|
||||
# phantom flat file shadowed by the folder (folder wins in skill_md_path).
|
||||
# A folder write supersedes any legacy flat <slug>.md, so we never leave a phantom flat file shadowed by the folder (folder wins in skill_md_path).
|
||||
legacy_flat = os.path.join(SKILLS_DIR, f"{slug}.md")
|
||||
if os.path.isfile(legacy_flat):
|
||||
try:
|
||||
@@ -390,9 +377,7 @@ def write_folder_skill(skill_id: str, files: dict[str, str], meta: dict) -> Skil
|
||||
|
||||
@skills.router.post("/create")
|
||||
async def create_skill(body: SkillCreate):
|
||||
# All user skills are folders now (<id>/SKILL.md); flat files stay readable
|
||||
# but are no longer written, so a skill's on-disk shape no longer depends on
|
||||
# how it was created vs imported.
|
||||
# All user skills are folders now (<id>/SKILL.md); flat files stay readable but are no longer written, so a skill's on-disk shape no longer depends on how it was created vs imported.
|
||||
meta = {"name": body.name, "description": body.description}
|
||||
if body.command:
|
||||
meta["command"] = body.command
|
||||
|
||||
@@ -26,17 +26,12 @@ from backend.apps.settings.settings import save_settings_async
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Namespaces the hash so a raw hardware UUID never leaves the device. Public on
|
||||
# purpose (open-source): it only prevents transmitting the raw id, not a secret.
|
||||
# Namespaces the hash so a raw hardware UUID never leaves the device. Public on purpose (open-source): it only prevents transmitting the raw id, not a secret.
|
||||
P_FP_SALT = "openswarm-free-trial-v1"
|
||||
|
||||
|
||||
def p_enabled() -> bool:
|
||||
# Default ON as of 1.2.80: the cloud free-trial proxy is live on prod
|
||||
# (api.openswarm.com) and arming + metered Haiku were verified end to end.
|
||||
# Set OPENSWARM_FREE_TRIAL_ENABLED=0 to force it off. The pool-shed gate +
|
||||
# daily global budget on the cloud cap total spend; arming only happens for a
|
||||
# truly-unconnected user (no key, no sub), so paid users are never touched.
|
||||
# Default ON as of 1.2.80: the cloud free-trial proxy is live on prod (api.openswarm.com) and arming + metered Haiku were verified end to end. Set OPENSWARM_FREE_TRIAL_ENABLED=0 to force it off. The pool-shed gate + daily global budget on the cloud cap total spend; arming only happens for a truly-unconnected user (no key, no sub), so paid users are never touched.
|
||||
return os.environ.get("OPENSWARM_FREE_TRIAL_ENABLED", "1") == "1"
|
||||
|
||||
|
||||
@@ -72,8 +67,7 @@ def p_raw_hardware_id() -> str | None:
|
||||
def p_fingerprint(settings_obj) -> str | None:
|
||||
raw = p_raw_hardware_id()
|
||||
if not raw:
|
||||
# Fail-soft: installation_id is less durable (regenerates on wipe) but
|
||||
# better than nothing on a machine where the hardware id can't be read.
|
||||
# Fail-soft: installation_id is less durable (regenerates on wipe) but better than nothing on a machine where the hardware id can't be read.
|
||||
raw = getattr(settings_obj, "installation_id", None)
|
||||
if not raw:
|
||||
return None
|
||||
@@ -110,9 +104,7 @@ async def p_has_connected_subscription() -> bool:
|
||||
if not p_9r_running():
|
||||
return False
|
||||
conns = await p_9r_providers()
|
||||
# Exclude our OWN managed node: the free trial registers itself as a `claude`
|
||||
# connection here, and counting it would make the trial think a real model is
|
||||
# connected and clear itself on the next boot (works once, dead on relaunch).
|
||||
# Exclude our OWN managed node: the free trial registers itself as a `claude` connection here, and counting it would make the trial think a real model is connected and clear itself on the next boot (works once, dead on relaunch).
|
||||
return any(
|
||||
c.get("isActive")
|
||||
and c.get("provider") in ("claude", "codex", "gemini-cli")
|
||||
@@ -140,11 +132,7 @@ async def clear_free_trial(settings_obj) -> None:
|
||||
(so the UI knows it's spent) and never touches a real paid mode."""
|
||||
if getattr(settings_obj, "connection_mode", "own_key") == "free-trial":
|
||||
settings_obj.connection_mode = "own_key"
|
||||
# arm() pinned default_model to "haiku" for the free run; once the wheel is
|
||||
# handed back, don't let that forced pick linger (it'd silently default a
|
||||
# real subscription user to Haiku). "sonnet" is the fresh default; the
|
||||
# frontend's DefaultModelGuard reconciles it to a reachable model if the
|
||||
# connected provider isn't Anthropic.
|
||||
# arm() pinned default_model to "haiku" for the free run; once the wheel is handed back, don't let that forced pick linger (it'd silently default a real subscription user to Haiku). "sonnet" is the fresh default; the frontend's DefaultModelGuard reconciles it to a reachable model if the connected provider isn't Anthropic.
|
||||
if getattr(settings_obj, "default_model", None) == "haiku":
|
||||
settings_obj.default_model = "sonnet"
|
||||
settings_obj.free_trial_token = None
|
||||
@@ -163,25 +151,13 @@ async def arm_free_trial(settings_obj) -> dict:
|
||||
own = has_own_model(settings_obj)
|
||||
has_sub = False
|
||||
if not own:
|
||||
# A subscription lives in 9Router, not settings, and 9Router now starts in
|
||||
# the BACKGROUND (non-blocking boot), so at first-launch mint time it isn't
|
||||
# up yet. Without this wait p_has_connected_subscription() reads False and
|
||||
# we'd arm the free trial OVER a real Claude/ChatGPT/Gemini sub, pinning the
|
||||
# user to Haiku until they manually reload. Bring 9Router up so the sub is
|
||||
# actually visible before we decide. Bounded + idempotent (shares the start
|
||||
# lock with the boot auto-start), and skipped when a settings-level model
|
||||
# already proves there's nothing to shadow.
|
||||
# A subscription lives in 9Router, not settings, and 9Router now starts in the BACKGROUND (non-blocking boot), so at first-launch mint time it isn't up yet. Without this wait p_has_connected_subscription() reads False and we'd arm the free trial OVER a real Claude/ChatGPT/Gemini sub, pinning the user to Haiku until they manually reload. Bring 9Router up so the sub is actually visible before we decide. Bounded + idempotent (shares the start lock with the boot auto-start), and skipped when a settings-level model already proves there's nothing to shadow.
|
||||
try:
|
||||
from backend.apps.nine_router import ensure_running as p_ensure_9r
|
||||
await p_ensure_9r()
|
||||
except Exception:
|
||||
pass
|
||||
# 9Router's /api/providers can lag /v1/models (what is_running probes) by a
|
||||
# beat on a cold start, so a real sub can read as absent for a sub-second
|
||||
# window. Re-check a few times before concluding "no sub", so we never arm
|
||||
# over a sub that's merely still loading. CAPPED on purpose: a genuinely
|
||||
# sub-less user exhausts these in ~1.2s and falls through to arm, so this
|
||||
# never waits on a subscription that doesn't exist.
|
||||
# 9Router's /api/providers can lag /v1/models (what is_running probes) by a beat on a cold start, so a real sub can read as absent for a sub-second window. Re-check a few times before concluding "no sub", so we never arm over a sub that's merely still loading. CAPPED on purpose: a genuinely sub-less user exhausts these in ~1.2s and falls through to arm, so this never waits on a subscription that doesn't exist.
|
||||
for p_i in range(5):
|
||||
if await p_has_connected_subscription():
|
||||
has_sub = True
|
||||
@@ -189,8 +165,7 @@ async def arm_free_trial(settings_obj) -> dict:
|
||||
if p_i < 4:
|
||||
await asyncio.sleep(0.3)
|
||||
if own or has_sub:
|
||||
# A real model exists now (key, custom provider, or a 9Router sub). If we
|
||||
# were on the free lane, hand the wheel back instead of re-arming.
|
||||
# A real model exists now (key, custom provider, or a 9Router sub). If we were on the free lane, hand the wheel back instead of re-arming.
|
||||
if mode == "free-trial":
|
||||
await clear_free_trial(settings_obj)
|
||||
return {"armed": False, "reason": "has_model"}
|
||||
@@ -224,10 +199,7 @@ async def arm_free_trial(settings_obj) -> dict:
|
||||
settings_obj.connection_mode = "free-trial"
|
||||
settings_obj.free_trial_token = data.get("trial_token")
|
||||
settings_obj.openswarm_proxy_url = base
|
||||
# Pin the trial to Haiku, the exact tier the cloud serves a free run as. Critical:
|
||||
# a sonnet/opus pick makes the Claude Code CLI attach an `effort`/thinking param
|
||||
# (reasoning models), which Haiku 400s on ("does not support the effort parameter").
|
||||
# Using Haiku end to end means the CLI never adds it, so the run just works.
|
||||
# Pin the trial to Haiku, the exact tier the cloud serves a free run as. Critical: a sonnet/opus pick makes the Claude Code CLI attach an `effort`/thinking param (reasoning models), which Haiku 400s on ("does not support the effort parameter"). Using Haiku end to end means the CLI never adds it, so the run just works.
|
||||
settings_obj.default_model = "haiku"
|
||||
await save_settings_async(settings_obj)
|
||||
await p_sync_routing(settings_obj)
|
||||
@@ -264,9 +236,7 @@ async def refresh_free_trial(settings_obj) -> dict:
|
||||
data = r.json()
|
||||
remaining = int(data.get("runs_remaining") or 0)
|
||||
settings_obj.free_trial_remaining = remaining
|
||||
# Stash an absolute refill time so the spent nudge can say "fresh runs in ~3h". Set before
|
||||
# clearing (clear keeps it) so it survives the hand-back to own_key. Relative -> absolute here
|
||||
# because the client reads it much later than we fetched it.
|
||||
# Stash an absolute refill time so the spent nudge can say "fresh runs in ~3h". Set before clearing (clear keeps it) so it survives the hand-back to own_key. Relative -> absolute here because the client reads it much later than we fetched it.
|
||||
resets_in = data.get("resets_in_seconds")
|
||||
if isinstance(resets_in, (int, float)) and resets_in > 0:
|
||||
settings_obj.free_trial_resets_at = time.time() + float(resets_in)
|
||||
|
||||
@@ -98,9 +98,7 @@ def p_sync_subscription_identity(settings_obj) -> None:
|
||||
logger.debug("identify sync failed: %s", e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/subscription/activate
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- POST /api/subscription/activate ---------------------------------------------------------------------------
|
||||
|
||||
class ActivateRequest(BaseModel):
|
||||
token: str
|
||||
@@ -142,8 +140,7 @@ async def activate(body: ActivateRequest):
|
||||
|
||||
me = r.json()
|
||||
|
||||
# Persist to settings. Prefer cloud-reported values; fall back to the
|
||||
# deep-link's own fields if cloud is sparse.
|
||||
# Persist to settings. Prefer cloud-reported values; fall back to the deep-link's own fields if cloud is sparse.
|
||||
settings_obj = load_settings()
|
||||
settings_obj.connection_mode = "openswarm-pro"
|
||||
settings_obj.openswarm_bearer_token = body.token
|
||||
@@ -171,9 +168,7 @@ async def activate(body: ActivateRequest):
|
||||
return {"ok": True, "plan": settings_obj.openswarm_subscription_plan}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/subscription/status
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- GET /api/subscription/status ---------------------------------------------------------------------------
|
||||
|
||||
@subscription.router.get("/status")
|
||||
async def status():
|
||||
@@ -191,9 +186,7 @@ async def status():
|
||||
"connection_mode": mode,
|
||||
}
|
||||
|
||||
# Best-effort live fetch; surface stale cache if cloud is unreachable.
|
||||
# Network errors leave upstream_code=None so we keep the cached state;
|
||||
# only explicit 401/402 from the cloud trigger a local clear.
|
||||
# Best-effort live fetch; surface stale cache if cloud is unreachable. Network errors leave upstream_code=None so we keep the cached state; only explicit 401/402 from the cloud trigger a local clear.
|
||||
live_usage = None
|
||||
live_status = None
|
||||
upstream_code: Optional[int] = None
|
||||
@@ -215,10 +208,7 @@ async def status():
|
||||
except httpx.HTTPError as e:
|
||||
logger.debug("subscription/status live fetch failed: %s", e)
|
||||
|
||||
# Cloud says the bearer is gone (401) or the sub is past its grace
|
||||
# period (402); drop local credentials so the desktop stops routing
|
||||
# through a dead subscription. Settings UI sees connected=False and
|
||||
# falls back to the Subscribe CTA; chat reverts to own_key routing.
|
||||
# Cloud says the bearer is gone (401) or the sub is past its grace period (402); drop local credentials so the desktop stops routing through a dead subscription. Settings UI sees connected=False and falls back to the Subscribe CTA; chat reverts to own_key routing.
|
||||
if upstream_code in (401, 402):
|
||||
await p_clear_subscription(settings_obj)
|
||||
return {
|
||||
@@ -238,9 +228,7 @@ async def status():
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/subscription/sync
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- POST /api/subscription/sync ---------------------------------------------------------------------------
|
||||
|
||||
@subscription.router.post("/sync")
|
||||
async def sync():
|
||||
@@ -252,8 +240,7 @@ async def sync():
|
||||
No-op when not in openswarm-pro mode. Best-effort: network failures are
|
||||
swallowed; the caller still gets a 200 with whatever local state we
|
||||
already had."""
|
||||
# Lazy-import the service-sync helper so subscription/router doesn't pay the
|
||||
# cost when analytics are disabled.
|
||||
# Lazy-import the service-sync helper so subscription/router doesn't pay the cost when analytics are disabled.
|
||||
from backend.apps.service.client import sync as p_sync
|
||||
|
||||
settings_obj = load_settings()
|
||||
@@ -275,9 +262,7 @@ async def sync():
|
||||
p_sync(settings_obj.model_dump())
|
||||
return {"ok": True, "synced": False, "reason": "network"}
|
||||
|
||||
# Same 401/402 handling as /status: if Stripe-side reconciliation proves
|
||||
# the bearer is dead or the sub expired, clear local state so the app
|
||||
# reverts to own_key instead of hammering a useless token.
|
||||
# Same 401/402 handling as /status: if Stripe-side reconciliation proves the bearer is dead or the sub expired, clear local state so the app reverts to own_key instead of hammering a useless token.
|
||||
if r.status_code in (401, 402):
|
||||
await p_clear_subscription(settings_obj)
|
||||
reason = "revoked" if r.status_code == 401 else "expired"
|
||||
@@ -298,8 +283,7 @@ async def sync():
|
||||
cloud_plan = data.get("plan")
|
||||
period_end_ms = data.get("current_period_end")
|
||||
|
||||
# Only touch local fields the cloud explicitly confirmed; don't paper
|
||||
# over missing keys with defaults that would downgrade an older record.
|
||||
# Only touch local fields the cloud explicitly confirmed; don't paper over missing keys with defaults that would downgrade an older record.
|
||||
if cloud_plan:
|
||||
settings_obj.openswarm_subscription_plan = cloud_plan
|
||||
if isinstance(period_end_ms, (int, float)) and period_end_ms > 0:
|
||||
@@ -319,9 +303,7 @@ async def sync():
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/subscription/portal
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- POST /api/subscription/portal ---------------------------------------------------------------------------
|
||||
|
||||
@subscription.router.post("/portal")
|
||||
async def portal():
|
||||
@@ -343,9 +325,7 @@ async def portal():
|
||||
return {"url": data.get("url")}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Free zero-config trial
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Free zero-config trial ---------------------------------------------------------------------------
|
||||
|
||||
@subscription.router.post("/free-trial/mint")
|
||||
async def free_trial_mint():
|
||||
@@ -363,9 +343,7 @@ async def free_trial_status():
|
||||
return await refresh_free_trial(load_settings())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/subscription/disconnect
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- POST /api/subscription/disconnect ---------------------------------------------------------------------------
|
||||
|
||||
@subscription.router.post("/disconnect")
|
||||
async def disconnect():
|
||||
|
||||
@@ -236,10 +236,7 @@ def stage_skill_from_zip(raw: bytes, filename: str, warnings: list[str]):
|
||||
if target is None:
|
||||
raise BundleError("zip has no SKILL.md")
|
||||
content = zf.read(target).decode("utf-8", errors="replace")
|
||||
# Carry supporting files (scripts, templates) through as a folder skill,
|
||||
# keyed relative to the SKILL.md's directory so a nested layout flattens
|
||||
# onto the skill folder. Cap count + per-file size so a hostile zip can't
|
||||
# balloon the install.
|
||||
# Carry supporting files (scripts, templates) through as a folder skill, keyed relative to the SKILL.md's directory so a nested layout flattens onto the skill folder. Cap count + per-file size so a hostile zip can't balloon the install.
|
||||
base_dir = target.rsplit("/", 1)[0] + "/" if "/" in target else ""
|
||||
extra_files: dict[str, bytes] = {}
|
||||
for n in zf.namelist():
|
||||
@@ -265,9 +262,7 @@ def p_synth_single_skill(content: str, name: str, warnings: list[str], extra_fil
|
||||
payload = {"slug": slug, "name": name, "description": "", "command": slug, "content": content, "builtin": False}
|
||||
with open(os.path.join(edir, "payload.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f)
|
||||
# Supporting files ride the same entities/<bid>/files/<rel> channel the
|
||||
# commit reader (p_read_files) feeds into import_, so a zip-of-SKILL.md
|
||||
# round-trips as a folder skill instead of getting flattened.
|
||||
# Supporting files ride the same entities/<bid>/files/<rel> channel the commit reader (p_read_files) feeds into import_, so a zip-of-SKILL.md round-trips as a folder skill instead of getting flattened.
|
||||
for rel, data in (extra_files or {}).items():
|
||||
dest = p_safe_join(edir, os.path.join("files", rel))
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
@@ -381,8 +376,7 @@ def commit(sandbox: str, manifest: Manifest, accept_requirements: list[str]):
|
||||
created.setdefault(e.type.value, []).append(new_id)
|
||||
trail.append((cls, new_id))
|
||||
except Exception as ex:
|
||||
# All-or-nothing: undo whatever already landed so a failed import never
|
||||
# leaves half a dashboard behind.
|
||||
# All-or-nothing: undo whatever already landed so a failed import never leaves half a dashboard behind.
|
||||
for cls, nid in reversed(trail):
|
||||
rb = getattr(cls, "rollback", None)
|
||||
if rb:
|
||||
|
||||
@@ -16,9 +16,7 @@ from backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable
|
||||
from backend.apps.swarm.models import EntityType, Requirement, RequirementKind
|
||||
|
||||
P_BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"}
|
||||
# Transcript fields ride along so the shared agent keeps its history; ids inside
|
||||
# (message ids, branch ids, their parent/fork refs) are self-consistent within
|
||||
# the one session file, so they carry verbatim with no remap.
|
||||
# Transcript fields ride along so the shared agent keeps its history; ids inside (message ids, branch ids, their parent/fork refs) are self-consistent within the one session file, so they carry verbatim with no remap.
|
||||
P_KEEP = (
|
||||
"name", "provider", "model", "mode", "system_prompt", "allowed_tools",
|
||||
"max_turns", "thinking_level",
|
||||
@@ -36,10 +34,7 @@ class SessionExportable:
|
||||
|
||||
@classmethod
|
||||
def load(cls, local_id: str) -> "SessionExportable | None":
|
||||
# Memory first, disk fallback, the same order duplicate_session uses.
|
||||
# The live session holds the freshest transcript; a disk-only read would
|
||||
# ship a stale one (missing the latest turns) or drop a just-created
|
||||
# agent that hasn't flushed yet, so its card vanishes from the bundle.
|
||||
# Memory first, disk fallback, the same order duplicate_session uses. The live session holds the freshest transcript; a disk-only read would ship a stale one (missing the latest turns) or drop a just-created agent that hasn't flushed yet, so its card vanishes from the bundle.
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
sess = agent_manager.sessions.get(local_id)
|
||||
if sess is not None:
|
||||
@@ -88,8 +83,7 @@ class SessionExportable:
|
||||
from backend.apps.agents.manager.session.session_store import save_session
|
||||
sid = uuid4().hex
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
# Older bundles (made before transcripts were carried) have no messages;
|
||||
# fall back to a single empty main branch so the imported agent is valid.
|
||||
# Older bundles (made before transcripts were carried) have no messages; fall back to a single empty main branch so the imported agent is valid.
|
||||
branches = payload.get("branches") or {
|
||||
"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": now}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,7 @@ class DashboardExportable:
|
||||
for oid, card in (layout.get("view_cards") or {}).items():
|
||||
bid = ctx.bundle_id_for(EntityType.app, oid)
|
||||
if bid:
|
||||
# parent_session_id tethers the app card to the agent that built it;
|
||||
# it's a session id, so it remaps like spawned_by on browser cards.
|
||||
# parent_session_id tethers the app card to the agent that built it; it's a session id, so it remaps like spawned_by on browser cards.
|
||||
parent = card.get("parent_session_id")
|
||||
view_cards[bid] = {
|
||||
**card, "output_id": bid,
|
||||
|
||||
@@ -52,8 +52,7 @@ class ModeExportable:
|
||||
from backend.apps.swarm.ziputil import BundleError
|
||||
raise BundleError("can't import this mode on this build")
|
||||
mid = payload.get("id") or (payload.get("name") or "mode").lower().replace(" ", "-")
|
||||
# Reuse a same-slug mode (incl. built-ins) instead of overwriting it;
|
||||
# sessions point at modes by this slug.
|
||||
# Reuse a same-slug mode (incl. built-ins) instead of overwriting it; sessions point at modes by this slug.
|
||||
if store.load_mode(mid) is not None:
|
||||
return mid
|
||||
data = {k: v for k, v in payload.items() if k != "is_builtin"}
|
||||
|
||||
@@ -73,9 +73,7 @@ class SkillExportable:
|
||||
"description": payload.get("description", ""),
|
||||
"command": payload.get("command", slug),
|
||||
}
|
||||
# Every imported skill lands as a folder (SKILL.md + any supporting files),
|
||||
# one path for one-file and multi-file skills alike. write_folder_skill is
|
||||
# path-traversal-safe, so an untrusted bundle can't escape the skill dir.
|
||||
# Every imported skill lands as a folder (SKILL.md + any supporting files), one path for one-file and multi-file skills alike. write_folder_skill is path-traversal-safe, so an untrusted bundle can't escape the skill dir.
|
||||
bundle = {"SKILL.md": payload.get("content", "")}
|
||||
for rel, data in files.items():
|
||||
bundle[rel] = data.decode("utf-8", errors="replace")
|
||||
|
||||
@@ -62,8 +62,7 @@ class Manifest(BaseModel):
|
||||
created_with: str = "OpenSwarm"
|
||||
created_at: str = ""
|
||||
bundle_id: str
|
||||
# sha256 over every entity payload + file (not the manifest itself); set at
|
||||
# pack time, re-checked on import to reject a corrupted or edited archive.
|
||||
# sha256 over every entity payload + file (not the manifest itself); set at pack time, re-checked on import to reject a corrupted or edited archive.
|
||||
checksum: Optional[str] = None
|
||||
root: EntityRef
|
||||
entities: list[EntityRef] = Field(default_factory=list)
|
||||
|
||||
@@ -14,8 +14,7 @@ P_DENY_SUBSTRINGS = (
|
||||
"session_token", "auth_token", "private_key",
|
||||
)
|
||||
|
||||
# Exact field names that are sensitive or per-install identity (the substring
|
||||
# pass alone would miss these).
|
||||
# Exact field names that are sensitive or per-install identity (the substring pass alone would miss these).
|
||||
P_DENY_EXACT = {
|
||||
"token", "installation_id", "user_id", "free_trial_token",
|
||||
"free_trial_remaining", "free_trial_runs_limit", "openswarm_bearer_token",
|
||||
@@ -23,8 +22,7 @@ P_DENY_EXACT = {
|
||||
"credentials", "sdk_session_id",
|
||||
}
|
||||
|
||||
# The secret-shape scanner moved to backend.common so skills + settings reuse it
|
||||
# without reaching into swarm; re-exported here so ziputil/closure keep their API.
|
||||
# The secret-shape scanner moved to backend.common so skills + settings reuse it without reaching into swarm; re-exported here so ziputil/closure keep their API.
|
||||
from backend.common.secret_scan import ( # noqa: E402
|
||||
REDACTED,
|
||||
find_secrets_in_files,
|
||||
@@ -73,5 +71,4 @@ def find_denied_keys(value: Any, p_path: str = "") -> list[str]:
|
||||
return found
|
||||
|
||||
|
||||
# _looks_secret + find_secrets_in_files now come from backend.common.secret_scan
|
||||
# (imported at the top); kept re-exported so ziputil's audit import is unchanged.
|
||||
# _looks_secret + find_secrets_in_files now come from backend.common.secret_scan (imported at the top); kept re-exported so ziputil's audit import is unchanged.
|
||||
|
||||
@@ -52,9 +52,7 @@ def resolve_command(command: str) -> str | None:
|
||||
found = shutil.which(command)
|
||||
if found:
|
||||
return found
|
||||
# Windows binaries need an extension. shutil.which() handles PATHEXT for
|
||||
# PATH lookups, but we manually scan p_extra_bin_dirs below; replicate
|
||||
# the suffix probing here so `uvx` finds `uvx.exe`, etc.
|
||||
# Windows binaries need an extension. shutil.which() handles PATHEXT for PATH lookups, but we manually scan p_extra_bin_dirs below; replicate the suffix probing here so `uvx` finds `uvx.exe`, etc.
|
||||
if sys.platform == "win32":
|
||||
suffixes = [""] + os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").lower().split(os.pathsep)
|
||||
else:
|
||||
@@ -120,16 +118,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
env["PRIVATE_APP_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"]
|
||||
if tool.oauth_tokens.get("refresh_token"):
|
||||
env["GOOGLE_WORKSPACE_REFRESH_TOKEN"] = tool.oauth_tokens["refresh_token"]
|
||||
# google_workspace_mcp's gauth.py hardcodes token_uri to
|
||||
# https://oauth2.googleapis.com/token and refreshes using the
|
||||
# local CLIENT_ID/SECRET on every API call. The OAuth flow
|
||||
# itself runs through the cloud's rotation pool, so the
|
||||
# refresh_token is bound to whichever pool slot minted it,
|
||||
# not the single client baked into the DMG. Mismatch -> Google
|
||||
# returns unauthorized_client. We point token_uri at a local
|
||||
# proxy that forwards the refresh to our cloud's pool-aware
|
||||
# /api/oauth/google/refresh endpoint; CLIENT_ID/SECRET become
|
||||
# unused placeholders (gauth.py only validates non-empty).
|
||||
# google_workspace_mcp's gauth.py hardcodes token_uri to https://oauth2.googleapis.com/token and refreshes using the local CLIENT_ID/SECRET on every API call. The OAuth flow itself runs through the cloud's rotation pool, so the refresh_token is bound to whichever pool slot minted it, not the single client baked into the DMG. Mismatch -> Google returns unauthorized_client. We point token_uri at a local proxy that forwards the refresh to our cloud's pool-aware /api/oauth/google/refresh endpoint; CLIENT_ID/SECRET become unused placeholders (gauth.py only validates non-empty).
|
||||
p_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
env["GOOGLE_WORKSPACE_TOKEN_URI"] = (
|
||||
f"http://127.0.0.1:{p_port}/api/tools/google-oauth-token"
|
||||
@@ -137,12 +126,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
env.setdefault("GOOGLE_WORKSPACE_CLIENT_ID", "openswarm-proxy")
|
||||
env.setdefault("GOOGLE_WORKSPACE_CLIENT_SECRET", "openswarm-proxy")
|
||||
|
||||
# Google Workspace MCP: redirect spawn through our shim that
|
||||
# monkey-patches gauth.get_credentials before the worker registers
|
||||
# tools, so token_uri points at our local proxy. Stays a stdio
|
||||
# subprocess; google-workspace-mcp gets installed into uv's
|
||||
# ephemeral env via --with, same way the upstream entry-point
|
||||
# invocation used to do it.
|
||||
# Google Workspace MCP: redirect spawn through our shim that monkey-patches gauth.get_credentials before the worker registers tools, so token_uri points at our local proxy. Stays a stdio subprocess; google-workspace-mcp gets installed into uv's ephemeral env via --with, same way the upstream entry-point invocation used to do it.
|
||||
if tool.name.lower() == "google workspace" and config.get("type") == "stdio":
|
||||
shim_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
@@ -152,9 +136,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
config["command"] = "uv"
|
||||
config["args"] = ["run", "--with", "google-workspace-mcp", "python", shim_path]
|
||||
|
||||
# Discord MCP runs as a small Python shim (backend.apps.discord_mcp_shim).
|
||||
# We pass install_id + base URL via env so the shim subprocess doesn't
|
||||
# need to import backend.config.* itself.
|
||||
# Discord MCP runs as a small Python shim (backend.apps.discord_mcp_shim). We pass install_id + base URL via env so the shim subprocess doesn't need to import backend.config.* itself.
|
||||
if tool.name.lower() == "discord" and config.get("type") == "stdio":
|
||||
from backend.config.install_id import get_install_id
|
||||
env = config.setdefault("env", {})
|
||||
@@ -164,9 +146,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
guild_ids = [g.get("id", "") for g in (tool.oauth_tokens.get("guilds") or []) if g.get("id")]
|
||||
if guild_ids:
|
||||
env["OPENSWARM_DISCORD_GUILD_IDS"] = ",".join(guild_ids)
|
||||
# The shim runs as a subprocess and needs to import
|
||||
# `backend.apps.discord_mcp_shim`; set PYTHONPATH to the project
|
||||
# root (parent of the backend/ dir) so that import resolves.
|
||||
# The shim runs as a subprocess and needs to import `backend.apps.discord_mcp_shim`; set PYTHONPATH to the project root (parent of the backend/ dir) so that import resolves.
|
||||
p_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
existing_pp = env.get("PYTHONPATH") or os.environ.get("PYTHONPATH", "")
|
||||
env["PYTHONPATH"] = (p_project_root + os.pathsep + existing_pp) if existing_pp else p_project_root
|
||||
@@ -181,13 +161,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
|
||||
if config.get("type") == "stdio":
|
||||
if config.get("command"):
|
||||
# `python` (no version suffix) doesn't exist on a stock macOS,
|
||||
# so a tool config that asks for "python" silently fails to
|
||||
# spawn; Claude Agent SDK then exposes zero tools from that
|
||||
# MCP. We resolve to the actual interpreter running the
|
||||
# backend (sys.executable), which is guaranteed to exist and
|
||||
# have backend modules importable. `python3` and absolute
|
||||
# paths pass through unchanged.
|
||||
# `python` (no version suffix) doesn't exist on a stock macOS, so a tool config that asks for "python" silently fails to spawn; Claude Agent SDK then exposes zero tools from that MCP. We resolve to the actual interpreter running the backend (sys.executable), which is guaranteed to exist and have backend modules importable. `python3` and absolute paths pass through unchanged.
|
||||
if config["command"] == "python":
|
||||
resolved_python = sys.executable or shutil.which("python3") or shutil.which("python")
|
||||
if resolved_python:
|
||||
@@ -198,24 +172,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
if pkg_name:
|
||||
p_backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
electron_path = os.environ.get("OPENSWARM_ELECTRON_PATH")
|
||||
# Two bundle layouts in mcp-bundles/, checked in priority order:
|
||||
#
|
||||
# 1. Multi-file bundle dir: mcp-bundles/<safe>/dist/index.js
|
||||
# Used when the SDK reads sibling files at runtime.
|
||||
# Examples: @softeria/ms-365-mcp-server reads
|
||||
# ../package.json for --version and dist/endpoints.json
|
||||
# for Graph API definitions; @notionhq/notion-mcp-server
|
||||
# reads ../scripts/notion-openapi.json. The build script
|
||||
# ships a stripped package.json (no "type":"module") next
|
||||
# to dist/ so __dirname/../package.json resolves correctly.
|
||||
# See scripts/build-app.sh `build_mcp_bundle_dir`.
|
||||
#
|
||||
# 2. Single-file bundle: mcp-bundles/<safe>.js
|
||||
# Used when the SDK is fully self-contained
|
||||
# (reddit-mcp-buddy).
|
||||
#
|
||||
# Scoped names get flattened ("@softeria/ms-365-mcp-server"
|
||||
# -> "softeria-ms-365-mcp-server") for filesystem safety.
|
||||
# Two bundle layouts in mcp-bundles/, checked in priority order: 1. Multi-file bundle dir: mcp-bundles/<safe>/dist/index.js Used when the SDK reads sibling files at runtime. Examples: @softeria/ms-365-mcp-server reads ../package.json for --version and dist/endpoints.json for Graph API definitions; @notionhq/notion-mcp-server reads ../scripts/notion-openapi.json. The build script ships a stripped package.json (no "type":"module") next to dist/ so __dirname/../package.json resolves correctly. See scripts/build-app.sh `build_mcp_bundle_dir`. 2. Single-file bundle: mcp-bundles/<safe>.js Used when the SDK is fully self-contained (reddit-mcp-buddy). Scoped names get flattened ("@softeria/ms-365-mcp-server" -> "softeria-ms-365-mcp-server") for filesystem safety.
|
||||
safe_bundle = pkg_name.replace("/", "-").replace("@", "")
|
||||
bundle_dir_path = os.path.join(p_backend, "mcp-bundles", safe_bundle, "dist", "index.js")
|
||||
bundle_file_path = os.path.join(p_backend, "mcp-bundles", f"{safe_bundle}.js")
|
||||
@@ -224,11 +181,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
bundle_path = bundle_dir_path
|
||||
elif os.path.isfile(bundle_file_path):
|
||||
bundle_path = bundle_file_path
|
||||
# Prefer the bundled real-Node binary over Electron-as-Node:
|
||||
# avoids the bouncing "exec" Dock icon on fresh user Macs +
|
||||
# spawns ~10x faster than re-execing the OpenSwarm Electron
|
||||
# binary as Node. Falls back to Electron-as-Node only if
|
||||
# the bundled node payload wasn't shipped (legacy builds).
|
||||
# Prefer the bundled real-Node binary over Electron-as-Node: avoids the bouncing "exec" Dock icon on fresh user Macs + spawns ~10x faster than re-execing the OpenSwarm Electron binary as Node. Falls back to Electron-as-Node only if the bundled node payload wasn't shipped (legacy builds).
|
||||
bundled_node = os.environ.get("OPENSWARM_NODE_PATH")
|
||||
if bundle_path and bundled_node and os.path.exists(bundled_node):
|
||||
config["command"] = bundled_node
|
||||
@@ -270,8 +223,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
env = config.setdefault("env", {})
|
||||
env.setdefault("PATH", augmented_path())
|
||||
env.setdefault("PYTHONPATH", "")
|
||||
# Point uv/uvx at our bundled Python; avoids macOS CLT popup on fresh Macs
|
||||
# and avoids downloading Python at runtime
|
||||
# Point uv/uvx at our bundled Python; avoids macOS CLT popup on fresh Macs and avoids downloading Python at runtime
|
||||
p_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
|
||||
p_is_windows = sys.platform == "win32"
|
||||
if p_is_packaged:
|
||||
|
||||
@@ -147,12 +147,7 @@ async def discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
|
||||
limit=10 * 1024 * 1024, # 10 MB buffer for large tool lists
|
||||
)
|
||||
|
||||
# Drain stderr in the background. Two reasons: (1) the OS pipe buffer is
|
||||
# ~64 KB; if npx prints more than that during a cold-cache install
|
||||
# (which happens when AV scanning slows npm), the child blocks on
|
||||
# write and we'd see what looks like a hang. (2) the rolling tail lets
|
||||
# us include npx's own diagnostic in any error we surface, instead of
|
||||
# the opaque "discovery failed" we used to show.
|
||||
# Drain stderr in the background. Two reasons: (1) the OS pipe buffer is ~64 KB; if npx prints more than that during a cold-cache install (which happens when AV scanning slows npm), the child blocks on write and we'd see what looks like a hang. (2) the rolling tail lets us include npx's own diagnostic in any error we surface, instead of the opaque "discovery failed" we used to show.
|
||||
stderr_tail: list[str] = []
|
||||
|
||||
async def p_drain_stderr() -> None:
|
||||
@@ -181,9 +176,7 @@ async def discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
|
||||
while True:
|
||||
line = await asyncio.wait_for(proc.stdout.readline(), timeout=timeout_s)
|
||||
if not line:
|
||||
# stdout EOF = child exited. Wait briefly for the stderr
|
||||
# drain to catch up so we capture the real failure reason
|
||||
# (which often arrives a few ms after stdout closes).
|
||||
# stdout EOF = child exited. Wait briefly for the stderr drain to catch up so we capture the real failure reason (which often arrives a few ms after stdout closes).
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(stderr_task), timeout=1.0)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
|
||||
@@ -212,11 +205,7 @@ async def discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
|
||||
"clientInfo": {"name": "self-swarm", "version": "0.1.0"},
|
||||
},
|
||||
})
|
||||
# First response is the slow one. On Windows with a cold npx cache,
|
||||
# `npx -y <pkg>` has to download the package + transitive deps and
|
||||
# AV-scan every file npm writes; total install time often exceeds
|
||||
# 60 s and occasionally pushes past 90 s. Subsequent reads run
|
||||
# against an already-running server and stay at the default 30 s.
|
||||
# First response is the slow one. On Windows with a cold npx cache, `npx -y <pkg>` has to download the package + transitive deps and AV-scan every file npm writes; total install time often exceeds 60 s and occasionally pushes past 90 s. Subsequent reads run against an already-running server and stay at the default 30 s.
|
||||
await p_recv(timeout_s=120.0)
|
||||
|
||||
await p_send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
@@ -228,17 +217,12 @@ async def discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
|
||||
return [{"name": t.get("name", ""), "description": t.get("description", ""), "inputSchema": t.get("inputSchema")} for t in tools_list]
|
||||
|
||||
except HTTPException as e:
|
||||
# Heal-on-corrupt-npx-cache still triggers from the EOF branch,
|
||||
# which now includes the full stderr tail in `e.detail`; so the
|
||||
# ERR_MODULE_NOT_FOUND signature is still discoverable here.
|
||||
# Heal-on-corrupt-npx-cache still triggers from the EOF branch, which now includes the full stderr tail in `e.detail`; so the ERR_MODULE_NOT_FOUND signature is still discoverable here.
|
||||
if p_attempt == 0 and p_try_heal_npx_cache(str(e.detail) if e.detail is not None else ""):
|
||||
return await discover_mcp_tools_stdio(command, args, env, p_attempt=1)
|
||||
raise
|
||||
except asyncio.TimeoutError:
|
||||
# Most common cause: cold npx cache on Windows. The npm install
|
||||
# persists across attempts, so a retry usually finishes against a
|
||||
# warm cache. Surface npx's own progress line if we have one; it
|
||||
# makes the cause obvious ("downloading X...") instead of opaque.
|
||||
# Most common cause: cold npx cache on Windows. The npm install persists across attempts, so a retry usually finishes against a warm cache. Surface npx's own progress line if we have one; it makes the cause obvious ("downloading X...") instead of opaque.
|
||||
tail_text = "".join(stderr_tail[-5:]).strip()
|
||||
detail = "MCP discovery timed out; the server may still be downloading on first run"
|
||||
if tail_text:
|
||||
|
||||
@@ -4,8 +4,7 @@ from dotenv import load_dotenv
|
||||
|
||||
from backend.config.paths import BACKEND_DIR, DATA_ROOT
|
||||
|
||||
# Loaded here (the leaf) so OPENSWARM_OAUTH_BASE_URL is set before any module
|
||||
# that reads it imports this. Both tools_lib.py and oauth_tokens.py pull from here.
|
||||
# Loaded here (the leaf) so OPENSWARM_OAUTH_BASE_URL is set before any module that reads it imports this. Both tools_lib.py and oauth_tokens.py pull from here.
|
||||
load_dotenv(os.path.join(BACKEND_DIR, ".env"))
|
||||
if os.environ.get("OPENSWARM_PACKAGED") == "1":
|
||||
load_dotenv(os.path.join(os.path.dirname(DATA_ROOT), ".env"), override=True)
|
||||
|
||||
@@ -18,18 +18,14 @@ def save(tool: ToolDefinition) -> None:
|
||||
json.dump(tool.model_dump(), f, indent=2)
|
||||
|
||||
|
||||
# Tool name → provider key for the OAuth helper service. All providers go
|
||||
# through the Fly cloud-proxy so client_secret values never ship inside the
|
||||
# desktop binary. v1.0.28 was the last release that used a local Google
|
||||
# callback with the client_secret in backend/.env.
|
||||
# Tool name → provider key for the OAuth helper service. All providers go through the Fly cloud-proxy so client_secret values never ship inside the desktop binary. v1.0.28 was the last release that used a local Google callback with the client_secret in backend/.env.
|
||||
P_TOOL_NAME_TO_PROVIDER = {
|
||||
"airtable": "airtable",
|
||||
"hubspot": "hubspot",
|
||||
"discord": "discord",
|
||||
"notion": "notion",
|
||||
"github": "github",
|
||||
# Built-in Google tool's name is "Google Workspace"; accept the bare
|
||||
# "google" alias too for forward compatibility.
|
||||
# Built-in Google tool's name is "Google Workspace"; accept the bare "google" alias too for forward compatibility.
|
||||
"google workspace": "google",
|
||||
"google": "google",
|
||||
}
|
||||
@@ -63,8 +59,7 @@ def persist_cloud_tokens(tool: ToolDefinition, tokens: dict) -> None:
|
||||
tool.oauth_tokens = {"access_token": tokens.get("access_token", "")}
|
||||
tool.connected_account_email = tokens.get("workspace_name", "Notion workspace")
|
||||
elif name == "github":
|
||||
# GitHub OAuth-App tokens don't expire and carry no refresh_token, so
|
||||
# store the bare token; the cloud callback enriches `login` for the label.
|
||||
# GitHub OAuth-App tokens don't expire and carry no refresh_token, so store the bare token; the cloud callback enriches `login` for the label.
|
||||
tool.oauth_tokens = {"access_token": tokens.get("access_token", "")}
|
||||
login = tokens.get("login")
|
||||
tool.connected_account_email = f"@{login}" if login else ""
|
||||
@@ -105,8 +100,7 @@ async def p_refresh_via_proxy(provider: str, tool: ToolDefinition, default_expir
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
# Provider rejected; user revoked at the provider's side. Mark
|
||||
# as needing re-auth so the UI prompts a Reconnect.
|
||||
# Provider rejected; user revoked at the provider's side. Mark as needing re-auth so the UI prompts a Reconnect.
|
||||
tool.auth_status = "expired"
|
||||
save(tool)
|
||||
logger.warning(f"{provider} refresh rejected (user revoked); marking tool as expired")
|
||||
@@ -122,8 +116,7 @@ async def p_refresh_via_proxy(provider: str, tool: ToolDefinition, default_expir
|
||||
tool.oauth_tokens["access_token"] = new_token
|
||||
tool.oauth_tokens["token_expiry"] = time.time() + (data.get("expires_in") or default_expiry)
|
||||
if data.get("refresh_token"):
|
||||
# Some providers (HubSpot, Airtable) rotate refresh_tokens on every
|
||||
# refresh. Persist the new one or future refreshes will fail.
|
||||
# Some providers (HubSpot, Airtable) rotate refresh_tokens on every refresh. Persist the new one or future refreshes will fail.
|
||||
tool.oauth_tokens["refresh_token"] = data["refresh_token"]
|
||||
# Backfill identity label on first successful refresh after upgrade.
|
||||
if not tool.connected_account_email and data.get("email"):
|
||||
@@ -171,9 +164,7 @@ def m365_server_script() -> str:
|
||||
)
|
||||
if os.path.isfile(bundle):
|
||||
return bundle
|
||||
# Fallback for any user still on a v1.0.25 install whose backend/ folder
|
||||
# was left over from before the bundle migration. Will return the legacy
|
||||
# path; if that doesn't exist either, the caller raises a clear error.
|
||||
# Fallback for any user still on a v1.0.25 install whose backend/ folder was left over from before the bundle migration. Will return the legacy path; if that doesn't exist either, the caller raises a clear error.
|
||||
return os.path.join(
|
||||
p_backend, "npm-servers", "softeria-ms-365-mcp-server",
|
||||
"node_modules", "@softeria", "ms-365-mcp-server", "dist", "index.js",
|
||||
|
||||
@@ -3,8 +3,7 @@ P_WRITE_PREFIXES = ("create", "write", "delete", "update", "send", "remove", "mo
|
||||
|
||||
|
||||
P_SERVICE_RULES: list[tuple[list[str], str, str]] = [
|
||||
# (keywords, service_name, group)
|
||||
# Google Workspace
|
||||
# (keywords, service_name, group) Google Workspace
|
||||
(["gmail"], "Gmail", "Google"),
|
||||
(["drive"], "Drive", "Google"),
|
||||
(["calendar", "event", "freebusy"], "Calendar", "Google"),
|
||||
|
||||
@@ -16,8 +16,7 @@ from backend.config.Apps import SubApp
|
||||
from backend.apps.tools_lib.models import ToolDefinition, ToolCreate, ToolUpdate, BUILTIN_TOOLS
|
||||
from backend.config.paths import DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH, TRUSTED_SENSITIVE_PATHS_PATH
|
||||
|
||||
# oauth_config runs the dotenv load (leaf) so OPENSWARM_OAUTH_BASE_URL is set
|
||||
# before anything reads it; re-exported here for the route handlers below.
|
||||
# oauth_config runs the dotenv load (leaf) so OPENSWARM_OAUTH_BASE_URL is set before anything reads it; re-exported here for the route handlers below.
|
||||
from backend.apps.tools_lib.oauth_config import OPENSWARM_OAUTH_BASE_URL
|
||||
# sanitize_server_name + derive_mcp_config re-exported for agent_manager/main.
|
||||
from backend.apps.tools_lib.mcp_config import sanitize_server_name, derive_mcp_config
|
||||
@@ -52,12 +51,7 @@ async def tools_lib_lifespan():
|
||||
tools_lib = SubApp("tools", tools_lib_lifespan)
|
||||
|
||||
|
||||
# Every built-in seeds to always_allow for a frictionless run. The agent's
|
||||
# runtime guards in agent_manager (catastrophic-command match, OS-scheduling,
|
||||
# sensitive-path gate) STILL force a prompt for the dangerous shapes even on
|
||||
# always_allow, so the poisoned-MCP-output -> destructive-command case is
|
||||
# still caught. Must match agent_manager._DEFAULTS (empty -> always_allow) so
|
||||
# the Settings UI and the agent agree on what "no policy set" means.
|
||||
# Every built-in seeds to always_allow for a frictionless run. The agent's runtime guards in agent_manager (catastrophic-command match, OS-scheduling, sensitive-path gate) STILL force a prompt for the dangerous shapes even on always_allow, so the poisoned-MCP-output -> destructive-command case is still caught. Must match agent_manager._DEFAULTS (empty -> always_allow) so the Settings UI and the agent agree on what "no policy set" means.
|
||||
P_DEFAULT_BUILTIN_POLICIES: dict[str, str] = {}
|
||||
|
||||
# One-time marker: older installs seeded Bash="ask"; we lift them once.
|
||||
@@ -78,9 +72,7 @@ def p_ensure_default_permissions() -> None:
|
||||
for t in BUILTIN_TOOLS
|
||||
}
|
||||
merged = {**desired, **existing}
|
||||
# One-time lift: installs seeded under the old default carry Bash="ask";
|
||||
# raise them to always_allow once so shell commands stop prompting. The
|
||||
# marker means a deliberate "ask" set afterward sticks (never re-flipped).
|
||||
# One-time lift: installs seeded under the old default carry Bash="ask"; raise them to always_allow once so shell commands stop prompting. The marker means a deliberate "ask" set afterward sticks (never re-flipped).
|
||||
if not os.path.exists(P_BASH_AUTOALLOW_MARKER):
|
||||
if merged.get("Bash") == "ask":
|
||||
merged["Bash"] = "always_allow"
|
||||
@@ -125,16 +117,11 @@ def p_reclassify_existing_tools() -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# All providers go through the Fly cloud-proxy claim handoff. The
|
||||
# v1.0.28 local Google callback was retired in v1.0.29 once the prod
|
||||
# Google OAuth client added the cloud's redirect URI.
|
||||
# All providers go through the Fly cloud-proxy claim handoff. The v1.0.28 local Google callback was retired in v1.0.29 once the prod Google OAuth client added the cloud's redirect URI.
|
||||
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
|
||||
|
||||
# Tool JSONs total ~1.5MB and load_all_tools runs on every dispatch, prompt build, and
|
||||
# MCPSearch keystroke; the cache skips re-parsing, revalidated by a per-file stat
|
||||
# signature so any write (ours or external) invalidates instantly. Callers treat
|
||||
# the returned ToolDefinitions as immutable; mutate via load(tool_id) + save.
|
||||
# Tool JSONs total ~1.5MB and load_all_tools runs on every dispatch, prompt build, and MCPSearch keystroke; the cache skips re-parsing, revalidated by a per-file stat signature so any write (ours or external) invalidates instantly. Callers treat the returned ToolDefinitions as immutable; mutate via load(tool_id) + save.
|
||||
p_tools_cache: list[ToolDefinition] | None = None
|
||||
p_tools_cache_sig: tuple | None = None
|
||||
|
||||
@@ -182,9 +169,7 @@ def load(tool_id: str) -> ToolDefinition:
|
||||
raise HTTPException(status_code=404, detail="Tool not found")
|
||||
with open(path) as f:
|
||||
tool = ToolDefinition(**json.load(f))
|
||||
# Migrate Discord tool configs from the old npx-based spawn (which
|
||||
# broke whenever the npx cache was partially populated) to the local
|
||||
# Python shim. Idempotent; if it's already on the shim, no-op.
|
||||
# Migrate Discord tool configs from the old npx-based spawn (which broke whenever the npx cache was partially populated) to the local Python shim. Idempotent; if it's already on the shim, no-op.
|
||||
if (
|
||||
tool.name.lower() == "discord"
|
||||
and tool.mcp_config
|
||||
@@ -434,8 +419,7 @@ async def discover_tools(tool_id: str):
|
||||
|
||||
tool_names = [t["name"] for t in raw_tools]
|
||||
services, service_groups, all_read, all_write = classify_services(tool_names, tool.name)
|
||||
# Read-only actions auto-allow by default (no prompt for safe, scoped reads);
|
||||
# writes still default to "ask". Any choice the user already made is kept.
|
||||
# Read-only actions auto-allow by default (no prompt for safe, scoped reads); writes still default to "ask". Any choice the user already made is kept.
|
||||
permissions: dict[str, Any] = {
|
||||
n: tool.tool_permissions.get(n, "always_allow" if n in all_read else "ask")
|
||||
for n in tool_names
|
||||
@@ -452,9 +436,7 @@ async def discover_tools(tool_id: str):
|
||||
return {"ok": True, "tool": tool.model_dump()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Microsoft 365 device-code login (runs in the backend, not the MCP server)
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Microsoft 365 device-code login (runs in the backend, not the MCP server) ---------------------------------------------------------------------------
|
||||
|
||||
p_m365_login_processes: dict[str, dict] = {} # tool_id -> {proc, device_code, status, email}
|
||||
|
||||
@@ -473,9 +455,7 @@ async def m365_device_login(tool_id: str):
|
||||
if not os.path.isfile(script):
|
||||
raise HTTPException(status_code=500, detail="M365 MCP server not installed")
|
||||
|
||||
# Same priority as MCP-bundle / 9Router paths: bundled real node first
|
||||
# (clean, no Dock flicker, fast cold-start), then system node, then
|
||||
# Electron-as-Node as last resort.
|
||||
# Same priority as MCP-bundle / 9Router paths: bundled real node first (clean, no Dock flicker, fast cold-start), then system node, then Electron-as-Node as last resort.
|
||||
bundled = os.environ.get("OPENSWARM_NODE_PATH")
|
||||
node = shutil.which("node")
|
||||
electron = os.environ.get("OPENSWARM_ELECTRON_PATH")
|
||||
@@ -705,9 +685,7 @@ async def oauth_cloud_claim(
|
||||
data = resp.json()
|
||||
tokens = data.get("tokens", {}) or {}
|
||||
tool = load(tool_id)
|
||||
# Google's token endpoint doesn't include the user's email; fetch it
|
||||
# from userinfo so the UI can show "you connected you@gmail.com"
|
||||
# rather than the generic "Google account" placeholder.
|
||||
# Google's token endpoint doesn't include the user's email; fetch it from userinfo so the UI can show "you connected you@gmail.com" rather than the generic "Google account" placeholder.
|
||||
if tool.name.lower().startswith("google") and tokens.get("access_token") and not tokens.get("email"):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as info_client:
|
||||
|
||||
+17
-58
@@ -32,18 +32,13 @@ async def web_lifespan():
|
||||
web = SubApp("web", web_lifespan)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request models
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Request models ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SearchBody(BaseModel):
|
||||
query: str = Field(..., description="The search query.")
|
||||
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.
|
||||
# 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")
|
||||
|
||||
|
||||
@@ -53,9 +48,7 @@ class FetchBody(BaseModel):
|
||||
primary: str | None = Field(None, description="Primary provider hint.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper; extract plain text from a tool's structured output list
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Helper; extract plain text from a tool's structured output list ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def p_join_text(parts: list[dict[str, Any]]) -> str:
|
||||
@@ -66,9 +59,7 @@ def p_join_text(parts: list[dict[str, Any]]) -> str:
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- Endpoints ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
||||
@@ -77,19 +68,10 @@ GEMINI_GROUNDING_MODEL = "gemini-2.5-flash" # cheapest + fastest for grounded c
|
||||
OPENAI_API_BASE = "https://api.openai.com/v1"
|
||||
OPENAI_SEARCH_MODEL = "gpt-5-mini" # cheapest model that supports web_search_preview
|
||||
|
||||
# Per-attempt timeouts for the search/fetch cascade. The fast-first ORDERING is
|
||||
# what fixes the ~75s stall (DDG answers in ~1s so the slow grounded backends are
|
||||
# rarely reached); these bounds are hang safety-nets, set just ABOVE each path's
|
||||
# own httpx timeout so a normally-slow call still completes and only a truly hung
|
||||
# provider (no response at all) gets cut. Grounded native search legitimately
|
||||
# takes 32-42s (httpx ceiling 45s), so its leash sits at 48s, NOT below 45, or
|
||||
# we'd clip the slow tail of a valid paid call.
|
||||
# Per-attempt timeouts for the search/fetch cascade. The fast-first ORDERING is what fixes the ~75s stall (DDG answers in ~1s so the slow grounded backends are rarely reached); these bounds are hang safety-nets, set just ABOVE each path's own httpx timeout so a normally-slow call still completes and only a truly hung provider (no response at all) gets cut. Grounded native search legitimately takes 32-42s (httpx ceiling 45s), so its leash sits at 48s, NOT below 45, or we'd clip the slow tail of a valid paid call.
|
||||
P_DDG_ATTEMPT_TIMEOUT = 6.0 # DDG answers <1s; >6s is a network hang, fall through
|
||||
P_GROUNDED_ATTEMPT_TIMEOUT = 48.0 # just above the providers' own 45s httpx timeout
|
||||
# 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.
|
||||
# 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
|
||||
|
||||
|
||||
@@ -182,10 +164,7 @@ def p_resolve_openai_api_key() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# Cache of which 9Router subscriptions are connected. Refreshed via
|
||||
# `_refresh_9r_connected()` rather than hit on every search call ,
|
||||
# 9Router's /api/providers is fast but not free, and we already
|
||||
# query it from many places.
|
||||
# Cache of which 9Router subscriptions are connected. Refreshed via `_refresh_9r_connected()` rather than hit on every search call, 9Router's /api/providers is fast but not free, and we already query it from many places.
|
||||
P_NINE_ROUTER_CONNECTED: set[str] = set()
|
||||
P_NINE_ROUTER_CACHE_AT: float = 0.0
|
||||
|
||||
@@ -228,8 +207,7 @@ async def p_gemini_grounded_via_9router(prompt: str, use_url_context: bool) -> d
|
||||
matches the existing `_gemini_grounded_call` so downstream
|
||||
`_format_grounded_as_search_results` works unchanged."""
|
||||
import httpx
|
||||
# Prefer Gemini CLI (broader model coverage). Fall back to
|
||||
# Antigravity if CLI isn't connected.
|
||||
# Prefer Gemini CLI (broader model coverage). Fall back to Antigravity if CLI isn't connected.
|
||||
connected = await p_refresh_9r_connected()
|
||||
if "gemini-cli" in connected:
|
||||
model = "gc/gemini-2.5-flash"
|
||||
@@ -259,11 +237,7 @@ async def p_gemini_grounded_via_9router(prompt: str, use_url_context: bool) -> d
|
||||
if r.status_code != 200:
|
||||
return {}
|
||||
data = r.json()
|
||||
# Synthesize a grounded shape so the existing formatter works:
|
||||
# _format_grounded_as_search_results expects {"text": str, "chunks":
|
||||
# [(title, uri), ...]}. 9Router doesn't surface citations as a
|
||||
# structured field uniformly across providers, so we hand back
|
||||
# text-only and let the formatter do its thing.
|
||||
# Synthesize a grounded shape so the existing formatter works: _format_grounded_as_search_results expects {"text": str, "chunks": [(title, uri), ...]}. 9Router doesn't surface citations as a structured field uniformly across providers, so we hand back text-only and let the formatter do its thing.
|
||||
text = ""
|
||||
for block in (data.get("content") or []):
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
@@ -447,25 +421,18 @@ async def search(body: SearchBody) -> dict:
|
||||
}
|
||||
|
||||
async def try_ddg():
|
||||
# Fast path: direct HTML search, sub-second when DDG isn't throttling us.
|
||||
# Returns None on a real no-hits OR a 202 throttle so the chain falls
|
||||
# through to the slower-but-grounded backends.
|
||||
# Fast path: direct HTML search, sub-second when DDG isn't throttling us. Returns None on a real no-hits OR a 202 throttle so the chain falls through to the slower-but-grounded backends.
|
||||
from backend.apps.agents.tools.web import WebSearchTool, DDGRateLimited
|
||||
try:
|
||||
text = await WebSearchTool.search_ddg(body.query, body.num_results)
|
||||
except DDGRateLimited:
|
||||
# Surface the throttle as a recorded error (not a silent None) so the
|
||||
# caller can see WHY we fell through to a slower backend.
|
||||
# Surface the throttle as a recorded error (not a silent None) so the caller can see WHY we fell through to a slower backend.
|
||||
raise RuntimeError("DuckDuckGo rate-limited (HTTP 202)") from None
|
||||
if not text:
|
||||
return None
|
||||
return {"query": body.query, "results": text, "backend": "ddg"}
|
||||
|
||||
# Fast-first cascade: DDG leads (~1s = human speed); the 30-42s LLM-grounded
|
||||
# backends are the reliable fallback when DDG is throttled or empty. The
|
||||
# primary hint only reorders the grounded tier (native key before the
|
||||
# same-provider subscription). Every attempt is wait_for-bounded so a slow
|
||||
# or hung provider fails over fast instead of stalling the whole request.
|
||||
# Fast-first cascade: DDG leads (~1s = human speed); the 30-42s LLM-grounded backends are the reliable fallback when DDG is throttled or empty. The primary hint only reorders the grounded tier (native key before the same-provider subscription). Every attempt is wait_for-bounded so a slow or hung provider fails over fast instead of stalling the whole request.
|
||||
grounded = [
|
||||
("gemini_native", try_gemini),
|
||||
("gemini_subscription", try_gemini_subscription),
|
||||
@@ -517,9 +484,7 @@ async def search(body: SearchBody) -> dict:
|
||||
@typechecked
|
||||
async def fetch(body: FetchBody) -> dict:
|
||||
"""Fetch a URL, primary-aware. Mirrors /search cascade logic."""
|
||||
# Belt-and-suspenders: even though we delegate to remote Gemini/OpenAI
|
||||
# fetchers (which can't reach private IPs), validating the URL here means
|
||||
# a private/metadata URL gets a 4xx instead of being silently forwarded.
|
||||
# Belt-and-suspenders: even though we delegate to remote Gemini/OpenAI fetchers (which can't reach private IPs), validating the URL here means a private/metadata URL gets a 4xx instead of being silently forwarded.
|
||||
from backend.apps.agents.tools.ssrf_guard import SSRFBlocked, assert_safe_url
|
||||
try:
|
||||
await assert_safe_url(body.url)
|
||||
@@ -571,8 +536,7 @@ async def fetch(body: FetchBody) -> dict:
|
||||
}
|
||||
|
||||
async def try_openai_subscription():
|
||||
# Codex's web_search is general; URL fetch via search query
|
||||
# works adequately for our use.
|
||||
# Codex's web_search is general; URL fetch via search query works adequately for our use.
|
||||
prompt = f"Fetch this URL and summarize: {body.url}"
|
||||
if body.prompt:
|
||||
prompt += f"\nFocus on: {body.prompt}"
|
||||
@@ -585,15 +549,11 @@ async def fetch(body: FetchBody) -> dict:
|
||||
"backend": "openai_subscription",
|
||||
}
|
||||
|
||||
# Remembered so a thin/errored local read is still returned as the last
|
||||
# resort if every grounded fetcher also fails (never worse than before).
|
||||
# Remembered so a thin/errored local read is still returned as the last resort if every grounded fetcher also fails (never worse than before).
|
||||
local_text: str | None = None
|
||||
|
||||
async def try_local():
|
||||
# Fast path: direct httpx + trafilatura, sub-second to a few seconds and
|
||||
# returns the page's ACTUAL text (the grounded fetchers summarize, which
|
||||
# is slower and loses detail). Thin/errored reads (JS walls, paywalls,
|
||||
# HTTP errors) fall through to the grounded fetchers that can render them.
|
||||
# Fast path: direct httpx + trafilatura, sub-second to a few seconds and returns the page's ACTUAL text (the grounded fetchers summarize, which is slower and loses detail). Thin/errored reads (JS walls, paywalls, HTTP errors) fall through to the grounded fetchers that can render them.
|
||||
nonlocal local_text
|
||||
from backend.apps.agents.tools.web import WebFetchTool
|
||||
parts = await WebFetchTool().execute(
|
||||
@@ -634,8 +594,7 @@ async def fetch(body: FetchBody) -> dict:
|
||||
except Exception as e:
|
||||
errors.append(f"{name}: {str(e)[:150]}")
|
||||
|
||||
# Grounded all failed; hand back whatever the local read got (even an error
|
||||
# string is useful signal) rather than nothing.
|
||||
# Grounded all failed; hand back whatever the local read got (even an error string is useful signal) rather than nothing.
|
||||
if local_text is not None:
|
||||
return {"url": body.url, "content": local_text, "backend": "local",
|
||||
**({"cascade_errors": errors} if errors else {})}
|
||||
|
||||
@@ -19,9 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
AUDIT_DIR = os.path.join(DATA_DIR, "audit")
|
||||
_io_lock = Lock()
|
||||
# Soft cap on bytes per audit file. When exceeded we truncate to the last
|
||||
# CAP/2 bytes on next write so attackers (or a runaway PATCH loop) can't
|
||||
# fill the disk. 256 KiB is ~2000 edits; we never expect to hit it.
|
||||
# Soft cap on bytes per audit file. When exceeded we truncate to the last CAP/2 bytes on next write so attackers (or a runaway PATCH loop) can't fill the disk. 256 KiB is ~2000 edits; we never expect to hit it.
|
||||
SOFT_CAP_BYTES = 256 * 1024
|
||||
|
||||
|
||||
@@ -58,8 +56,7 @@ def log_change(wid: str, who: str, before: dict, after: dict) -> None:
|
||||
os.makedirs(AUDIT_DIR, exist_ok=True)
|
||||
path = _audit_path(wid)
|
||||
if os.path.exists(path) and os.path.getsize(path) > SOFT_CAP_BYTES:
|
||||
# Keep the tail half. Cheap, lossy, prevents pathological
|
||||
# disk growth without crashing on a corrupt file.
|
||||
# Keep the tail half. Cheap, lossy, prevents pathological disk growth without crashing on a corrupt file.
|
||||
with open(path, "rb") as f:
|
||||
f.seek(-(SOFT_CAP_BYTES // 2), os.SEEK_END)
|
||||
tail = f.read()
|
||||
|
||||
@@ -66,9 +66,7 @@ async def _runner(wf: Workflow, run: WorkflowRun, tiers: list[PermissionTier]) -
|
||||
from backend.apps.workflows.notifier import send_tier
|
||||
|
||||
try:
|
||||
# Tier 0 is the initial notify; we don't re-fire it here. Walk
|
||||
# 1..N, sleeping the tier's delay before sending. If the user acks
|
||||
# via /workflows/runs/{run_id}/ack, the task is cancelled.
|
||||
# Tier 0 is the initial notify; we don't re-fire it here. Walk 1..N, sleeping the tier's delay before sending. If the user acks via /workflows/runs/{run_id}/ack, the task is cancelled.
|
||||
for idx in range(1, len(tiers)):
|
||||
tier = tiers[idx]
|
||||
delay = _tier_delay_seconds(tier)
|
||||
|
||||
@@ -18,9 +18,7 @@ from backend.apps.workflows import storage
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# In-process map: workflow_id -> currently running run id. Prevents two
|
||||
# overlapping fires for the same workflow (e.g. cron tick races a manual
|
||||
# Run button) without serializing across the whole executor.
|
||||
# In-process map: workflow_id -> currently running run id. Prevents two overlapping fires for the same workflow (e.g. cron tick races a manual Run button) without serializing across the whole executor.
|
||||
_running: dict[str, str] = {}
|
||||
_running_lock = asyncio.Lock()
|
||||
|
||||
@@ -34,11 +32,7 @@ def p_ran_late(started_at: datetime, scheduled_for: datetime) -> bool:
|
||||
return delta.total_seconds() > 300
|
||||
|
||||
|
||||
# run_id -> "stop". Set by the stop endpoint so the executor loop, not the
|
||||
# HTTP handler, owns the run's terminal write. Without this the still-running
|
||||
# executor task could overwrite a "Stopped by user" failure with success.
|
||||
# Pause is NOT in here: it rides the agent session's own "stopped" status,
|
||||
# which the step loop waits out (see _await_session_idle).
|
||||
# run_id -> "stop". Set by the stop endpoint so the executor loop, not the HTTP handler, owns the run's terminal write. Without this the still-running executor task could overwrite a "Stopped by user" failure with success. Pause is NOT in here: it rides the agent session's own "stopped" status, which the step loop waits out (see _await_session_idle).
|
||||
_run_control: dict[str, str] = {}
|
||||
_run_pause_override: dict[str, tuple[bool, float]] = {}
|
||||
|
||||
@@ -187,9 +181,7 @@ async def execute(
|
||||
triggered_by=triggered_by,
|
||||
)
|
||||
|
||||
# Cost cap pre-check happens before claiming `_running` so a capped
|
||||
# workflow doesn't block its own next fire. We still record the run so
|
||||
# the user sees it in History with a clear reason.
|
||||
# Cost cap pre-check happens before claiming `_running` so a capped workflow doesn't block its own next fire. We still record the run so the user sees it in History with a clear reason.
|
||||
if wf.cost_cap_usd_monthly is not None:
|
||||
spent = _monthly_spend_so_far(wf)
|
||||
if spent >= wf.cost_cap_usd_monthly:
|
||||
@@ -226,12 +218,7 @@ async def execute(
|
||||
"last_run_id": run.id,
|
||||
})
|
||||
|
||||
# Announce the run as running the instant it claims execution, not at
|
||||
# the first step. Without this a run that fails fast (e.g. no runnable
|
||||
# steps) or hasn't streamed yet never hits the Home "Ongoing runs" list.
|
||||
# Both this and the persist above sit inside the try whose finally frees
|
||||
# _running, so a persist/broadcast failure can't strand the workflow as
|
||||
# permanently "running" (which would block every future fire).
|
||||
# Announce the run as running the instant it claims execution, not at the first step. Without this a run that fails fast (e.g. no runnable steps) or hasn't streamed yet never hits the Home "Ongoing runs" list. Both this and the persist above sit inside the try whose finally frees _running, so a persist/broadcast failure can't strand the workflow as permanently "running" (which would block every future fire).
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager as _wsm_start
|
||||
await _wsm_start.broadcast_global("workflow:run", {
|
||||
@@ -262,11 +249,7 @@ async def execute(
|
||||
run.session_id = session.id
|
||||
storage.record_run(run)
|
||||
|
||||
# Reuse the user's earlier allow/deny answers so an unattended fire
|
||||
# doesn't park on a permission prompt. Scheduled runs prompt for an
|
||||
# unseen tool only briefly (30s) before failing; manual/test runs are
|
||||
# attended, so keep the roomy window. Sensitive-path prompts are never
|
||||
# remembered (handled by the gate); they keep prompting every run.
|
||||
# Reuse the user's earlier allow/deny answers so an unattended fire doesn't park on a permission prompt. Scheduled runs prompt for an unseen tool only briefly (30s) before failing; manual/test runs are attended, so keep the roomy window. Sensitive-path prompts are never remembered (handled by the gate); they keep prompting every run.
|
||||
set_workflow_approval_memory(
|
||||
session.id,
|
||||
decisions=dict(wf.remembered_approvals),
|
||||
@@ -275,11 +258,7 @@ async def execute(
|
||||
ask_timeout=30.0 if triggered_by == "schedule" else 600.0,
|
||||
)
|
||||
|
||||
# Background poller: surface the latest tool-call name as a
|
||||
# live "what's the agent doing" subtitle on the workflow:run
|
||||
# ws event. Cheap enough to run at 1.5s cadence; nothing else
|
||||
# is watching session.messages from here. Cancelled in the
|
||||
# finally block alongside _running cleanup.
|
||||
# Background poller: surface the latest tool-call name as a live "what's the agent doing" subtitle on the workflow:run ws event. Cheap enough to run at 1.5s cadence; nothing else is watching session.messages from here. Cancelled in the finally block alongside _running cleanup.
|
||||
async def _watch_tool_calls() -> None:
|
||||
last_seen = ""
|
||||
last_paused = False
|
||||
@@ -303,8 +282,7 @@ async def execute(
|
||||
if getattr(m, "role", None) != "tool_call":
|
||||
continue
|
||||
content = getattr(m, "content", None)
|
||||
# Content can be a string, a dict with "name", or
|
||||
# a list of blocks. Pick the first tool_use name.
|
||||
# Content can be a string, a dict with "name", or a list of blocks. Pick the first tool_use name.
|
||||
if isinstance(content, list):
|
||||
for b in content:
|
||||
if isinstance(b, dict) and b.get("type") == "tool_use":
|
||||
@@ -338,19 +316,13 @@ async def execute(
|
||||
|
||||
watcher_task = asyncio.create_task(_watch_tool_calls())
|
||||
|
||||
# Send each step sequentially. agent_manager.send_message is a no-op
|
||||
# while a prior turn is still streaming, so we await until the
|
||||
# session is idle before posting the next step. Keeps the runner
|
||||
# safe regardless of how long each turn takes.
|
||||
# Send each step sequentially. agent_manager.send_message is a no-op while a prior turn is still streaming, so we await until the session is idle before posting the next step. Keeps the runner safe regardless of how long each turn takes.
|
||||
step_error: Optional[str] = None
|
||||
for idx, step in enumerate(steps):
|
||||
if _run_control.get(run.id) == "stop":
|
||||
step_error = "Stopped by user"
|
||||
break
|
||||
# Broadcast the step bump before sending so RunningView flips
|
||||
# the disc immediately, not after the agent finishes the step.
|
||||
# Advancing means we're not paused; keep the broadcast authoritative
|
||||
# so it never races a stale paused=True from the watcher.
|
||||
# Broadcast the step bump before sending so RunningView flips the disc immediately, not after the agent finishes the step. Advancing means we're not paused; keep the broadcast authoritative so it never races a stale paused=True from the watcher.
|
||||
run.active_step_idx = idx
|
||||
run.last_tool_label = None
|
||||
run.paused = False
|
||||
@@ -389,8 +361,7 @@ async def execute(
|
||||
else:
|
||||
run.status = "success"
|
||||
wf.last_run_status = "success"
|
||||
# Bump runs_count for scheduled fires that reached a terminal state
|
||||
# other than "skipped". Manual runs don't count against max_runs.
|
||||
# Bump runs_count for scheduled fires that reached a terminal state other than "skipped". Manual runs don't count against max_runs.
|
||||
runs_delta = 1 if (triggered_by == "schedule" and run.status in ("success", "ran_late", "failure")) else 0
|
||||
storage.record_run(run)
|
||||
wf.last_run_at = run.finished_at
|
||||
@@ -416,17 +387,12 @@ async def execute(
|
||||
finally:
|
||||
_run_control.pop(run.id, None)
|
||||
_run_pause_override.pop(run.id, None)
|
||||
# Cancel the tool-call watcher before we tear the session down so
|
||||
# the next poll doesn't race close_session.
|
||||
# Cancel the tool-call watcher before we tear the session down so the next poll doesn't race close_session.
|
||||
try:
|
||||
watcher_task.cancel() # type: ignore[name-defined]
|
||||
except Exception:
|
||||
pass
|
||||
# Close the workflow's agent session so closed_at is set and the
|
||||
# run shows up in chat history (get_history sorts by closed_at;
|
||||
# sessions with closed_at=None sort to the bottom and fall off
|
||||
# the first page). close_session also drops in-memory state and
|
||||
# persists the final snapshot to disk.
|
||||
# Close the workflow's agent session so closed_at is set and the run shows up in chat history (get_history sorts by closed_at; sessions with closed_at=None sort to the bottom and fall off the first page). close_session also drops in-memory state and persists the final snapshot to disk.
|
||||
if session is not None:
|
||||
try:
|
||||
p_persist_step_tool_usage(wf.id, get_workflow_step_usage(session.id))
|
||||
@@ -488,8 +454,7 @@ async def _await_session_idle(session_id: str, run_id: Optional[str] = None, tim
|
||||
if status == "stopped":
|
||||
if not hold_on_pause:
|
||||
return "stopped"
|
||||
# Paused. Hold, and reset the deadline so paused wall-time
|
||||
# doesn't count against the step timeout.
|
||||
# Paused. Hold, and reset the deadline so paused wall-time doesn't count against the step timeout.
|
||||
deadline = asyncio.get_event_loop().time() + timeout_s
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
|
||||
@@ -4,9 +4,7 @@ from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
# Each "tier" in the permission chain: notify in app, fall through to text
|
||||
# after N minutes if no response, then to call after a further N minutes/hours.
|
||||
# Matches images 17 to 19 (Schedule edit). Order in the list = escalation order.
|
||||
# Each "tier" in the permission chain: notify in app, fall through to text after N minutes if no response, then to call after a further N minutes/hours. Matches images 17 to 19 (Schedule edit). Order in the list = escalation order.
|
||||
class PermissionTier(BaseModel):
|
||||
kind: Literal["notify", "text", "call"] = "notify"
|
||||
after_minutes: int = 0
|
||||
@@ -15,31 +13,19 @@ class PermissionTier(BaseModel):
|
||||
|
||||
class ScheduleConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
# Bounds keep the scheduler from blowing up on malformed input. The
|
||||
# FE clamps these too, but defense-in-depth: a misbehaving agent
|
||||
# tool, an old JSON file, or a curl-wielding power user shouldn't
|
||||
# be able to crash _next_fire_after by passing hour=99. The per-unit
|
||||
# upper bound on repeat_every is clamped (not rejected) in
|
||||
# _enforce_interval_bounds below, so only the floor lives on the Field.
|
||||
# Bounds keep the scheduler from blowing up on malformed input. The FE clamps these too, but defense-in-depth: a misbehaving agent tool, an old JSON file, or a curl-wielding power user shouldn't be able to crash _next_fire_after by passing hour=99. The per-unit upper bound on repeat_every is clamped (not rejected) in _enforce_interval_bounds below, so only the floor lives on the Field.
|
||||
repeat_every: int = Field(default=1, ge=1)
|
||||
repeat_unit: Literal["minute", "hour", "day", "week", "month"] = "week"
|
||||
on_days: list[int] = Field(default_factory=list)
|
||||
hour: int = Field(default=9, ge=0, le=23)
|
||||
minute: int = Field(default=0, ge=0, le=59)
|
||||
# Monthly schedules can pin a day-of-month explicitly. None preserves the
|
||||
# legacy "same day as the current reference" behavior for older records.
|
||||
# Monthly schedules can pin a day-of-month explicitly. None preserves the legacy "same day as the current reference" behavior for older records.
|
||||
day_of_month: Optional[int] = Field(default=None, ge=1, le=31)
|
||||
# When true, monthly schedules fire on the calendar's last day (28-31)
|
||||
# regardless of day_of_month, so "end of month" survives short months.
|
||||
# When true, monthly schedules fire on the calendar's last day (28-31) regardless of day_of_month, so "end of month" survives short months.
|
||||
last_day_of_month: bool = False
|
||||
# IANA zone name (e.g. "America/Los_Angeles") or "local" for legacy
|
||||
# records that predate explicit tz. storage._load_all_from_disk coerces
|
||||
# "local" to the host zone in memory; we leave it on disk until the
|
||||
# user's next save so backup/sync tools don't see spurious churn.
|
||||
# IANA zone name (e.g. "America/Los_Angeles") or "local" for legacy records that predate explicit tz. storage._load_all_from_disk coerces "local" to the host zone in memory; we leave it on disk until the user's next save so backup/sync tools don't see spurious churn.
|
||||
timezone: str = "local"
|
||||
# Optional end conditions. None = forever / unbounded. Schedule auto-
|
||||
# disables once either is satisfied; scheduler._tick zeroes out
|
||||
# next_run_at and flips enabled=False so the UI reflects reality.
|
||||
# Optional end conditions. None = forever / unbounded. Schedule auto- disables once either is satisfied; scheduler._tick zeroes out next_run_at and flips enabled=False so the UI reflects reality.
|
||||
ends_at: Optional[datetime] = None
|
||||
max_runs: Optional[int] = Field(default=None, ge=1)
|
||||
runs_count: int = Field(default=0, ge=0)
|
||||
@@ -47,9 +33,7 @@ class ScheduleConfig(BaseModel):
|
||||
@field_validator("on_days")
|
||||
@classmethod
|
||||
def _clean_on_days(cls, v: list[int]) -> list[int]:
|
||||
# Backend uses JS-style weekday (Sun=0..Sat=6). Drop entries
|
||||
# outside that range so a malformed PATCH can't trip the
|
||||
# scheduler later, and dedupe while preserving order.
|
||||
# Backend uses JS-style weekday (Sun=0..Sat=6). Drop entries outside that range so a malformed PATCH can't trip the scheduler later, and dedupe while preserving order.
|
||||
seen: set[int] = set()
|
||||
out: list[int] = []
|
||||
for d in v or []:
|
||||
@@ -60,10 +44,7 @@ class ScheduleConfig(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _enforce_interval_bounds(self) -> "ScheduleConfig":
|
||||
# Per-unit bounds, clamped rather than rejected so a stray value from
|
||||
# an agent tool or old record can't crash the scheduler. The minute
|
||||
# unit floors at 15 (no once-a-minute token-burning loop) and ceilings
|
||||
# at 1440 (24h); every other unit keeps the original 365 ceiling.
|
||||
# Per-unit bounds, clamped rather than rejected so a stray value from an agent tool or old record can't crash the scheduler. The minute unit floors at 15 (no once-a-minute token-burning loop) and ceilings at 1440 (24h); every other unit keeps the original 365 ceiling.
|
||||
if self.repeat_unit == "minute":
|
||||
self.repeat_every = max(15, min(self.repeat_every, 1440))
|
||||
else:
|
||||
@@ -80,11 +61,9 @@ class ActionsConfig(BaseModel):
|
||||
class WorkflowStep(BaseModel):
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
text: str = ""
|
||||
# 3 to 6 word LLM-generated headline shown in the collapsed step row.
|
||||
# The full prompt lives in `text`; this is the "at-a-glance" label.
|
||||
# 3 to 6 word LLM-generated headline shown in the collapsed step row. The full prompt lives in `text`; this is the "at-a-glance" label.
|
||||
label: Optional[str] = None
|
||||
# Disabled steps stay in the list but the executor skips them, so a user
|
||||
# can mute a step without losing its prompt. Defaults true for old records.
|
||||
# Disabled steps stay in the list but the executor skips them, so a user can mute a step without losing its prompt. Defaults true for old records.
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@@ -93,12 +72,7 @@ def _empty_str_default() -> str:
|
||||
|
||||
|
||||
class Workflow(BaseModel):
|
||||
# validate_assignment is load-bearing for the PATCH /workflows/{id} path
|
||||
# (workflows.py:update_workflow setattr's raw dicts from body.model_dump
|
||||
# straight onto the cached Workflow). Without coercion the nested
|
||||
# schedule/steps/actions/permissions fields become plain dicts in
|
||||
# memory, and every downstream call; scheduler tick, executor.execute,
|
||||
# subsequent PATCHes; crashes on `.enabled` / `.text`.
|
||||
# validate_assignment is load-bearing for the PATCH /workflows/{id} path (workflows.py:update_workflow setattr's raw dicts from body.model_dump straight onto the cached Workflow). Without coercion the nested schedule/steps/actions/permissions fields become plain dicts in memory, and every downstream call; scheduler tick, executor.execute, subsequent PATCHes; crashes on `.enabled` / `.text`.
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
@@ -107,8 +81,7 @@ class Workflow(BaseModel):
|
||||
icon: str = ""
|
||||
# User-chosen swatch (hex). None falls back to the id-hash color in the UI.
|
||||
color: Optional[str] = None
|
||||
# Soft-delete tombstone. Set = in Trash (hidden from lists + scheduler);
|
||||
# restore nulls it, purge removes the record entirely.
|
||||
# Soft-delete tombstone. Set = in Trash (hidden from lists + scheduler); restore nulls it, purge removes the record entirely.
|
||||
deleted_at: Optional[datetime] = None
|
||||
system_prompt: Optional[str] = None
|
||||
use_synced_prompt: bool = True
|
||||
@@ -119,10 +92,7 @@ class Workflow(BaseModel):
|
||||
default_factory=lambda: [PermissionTier(kind="notify")]
|
||||
)
|
||||
source_session_id: Optional[str] = None
|
||||
# Tool names observed in the source chat when this workflow was generated.
|
||||
# This preserves conversion context without pretending those calls map to
|
||||
# generated workflow step ids. Explicit approval decisions still live in
|
||||
# remembered_approvals and are the only values reused as permissions.
|
||||
# Tool names observed in the source chat when this workflow was generated. This preserves conversion context without pretending those calls map to generated workflow step ids. Explicit approval decisions still live in remembered_approvals and are the only values reused as permissions.
|
||||
source_tools: list[str] = Field(default_factory=list)
|
||||
dashboard_id: Optional[str] = None
|
||||
model: str = "sonnet"
|
||||
@@ -135,41 +105,23 @@ class Workflow(BaseModel):
|
||||
last_run_id: Optional[str] = None
|
||||
next_run_at: Optional[datetime] = None
|
||||
cost_cap_usd_monthly: Optional[float] = None
|
||||
# Sticky session id for the Edit Agent embedded in the workflow card
|
||||
# (Image #38, #48). Optional so older workflows don't fail validation
|
||||
# on rehydrate.
|
||||
# Sticky session id for the Edit Agent embedded in the workflow card (Image #38, #48). Optional so older workflows don't fail validation on rehydrate.
|
||||
edit_agent_session_id: Optional[str] = None
|
||||
# Sticky session id for the embedded scheduling agent (the chat that
|
||||
# turns "every Wednesday at 1pm" into a permission-gated tool call).
|
||||
# Sticky session id for the embedded scheduling agent (the chat that turns "every Wednesday at 1pm" into a permission-gated tool call).
|
||||
schedule_agent_session_id: Optional[str] = None
|
||||
# Pending Edit-Agent draft of the steps. None = no draft in flight. Edits
|
||||
# stage here and only land on `steps` when the user clicks Save; scheduled
|
||||
# runs read `steps`, so a pending draft never affects a fire.
|
||||
# Pending Edit-Agent draft of the steps. None = no draft in flight. Edits stage here and only land on `steps` when the user clicks Save; scheduled runs read `steps`, so a pending draft never affects a fire.
|
||||
draft_steps: Optional[list[WorkflowStep]] = None
|
||||
# Most recent Test Agent session for this workflow; read by ReadTestTranscript.
|
||||
last_test_session_id: Optional[str] = None
|
||||
# Tool permissions the user answered once and we reuse on later runs so an
|
||||
# unattended scheduled fire doesn't stall waiting for someone to click.
|
||||
# tool_name -> decision. Only ordinary "ask" tools land here; sensitive
|
||||
# paths keep their own per-pattern trust and never auto-remember.
|
||||
# Tool permissions the user answered once and we reuse on later runs so an unattended scheduled fire doesn't stall waiting for someone to click. tool_name -> decision. Only ordinary "ask" tools land here; sensitive paths keep their own per-pattern trust and never auto-remember.
|
||||
remembered_approvals: dict[str, Literal["allow", "deny"]] = Field(default_factory=dict)
|
||||
# Behind-the-scenes record of which tools each step touched and whether each
|
||||
# was permitted, keyed by stable step id (not index, so reorders don't
|
||||
# scramble it). Auto-maintained on runs; enforcement stays workflow-level
|
||||
# via remembered_approvals, this is the finer per-step picture.
|
||||
# Behind-the-scenes record of which tools each step touched and whether each was permitted, keyed by stable step id (not index, so reorders don't scramble it). Auto-maintained on runs; enforcement stays workflow-level via remembered_approvals, this is the finer per-step picture.
|
||||
step_tool_usage: dict[str, dict[str, bool]] = Field(default_factory=dict)
|
||||
# False once the user explicitly sets a title; True means the backend may
|
||||
# overwrite the title via auto-naming when steps are added/changed.
|
||||
# False once the user explicitly sets a title; True means the backend may overwrite the title via auto-naming when steps are added/changed.
|
||||
auto_named: bool = False
|
||||
# True for a brand-new "+ New" workflow that the user is still building in
|
||||
# the Edit Agent and hasn't saved yet. The Workflows hub hides these from
|
||||
# the scheduled/unscheduled lists until the first commit clears the flag,
|
||||
# so an in-progress build doesn't litter the sidebar.
|
||||
# True for a brand-new "+ New" workflow that the user is still building in the Edit Agent and hasn't saved yet. The Workflows hub hides these from the scheduled/unscheduled lists until the first commit clears the flag, so an in-progress build doesn't litter the sidebar.
|
||||
unsaved: bool = False
|
||||
# Stable signature of the steps last validated by a test run (or seeded at
|
||||
# chat conversion). The FE compares it against the current steps before
|
||||
# scheduling: a mismatch means "edited since you last approved tools" and
|
||||
# triggers the test-first warning. Computed FE-side so there's one algorithm.
|
||||
# Stable signature of the steps last validated by a test run (or seeded at chat conversion). The FE compares it against the current steps before scheduling: a mismatch means "edited since you last approved tools" and triggers the test-first warning. Computed FE-side so there's one algorithm.
|
||||
tested_signature: Optional[str] = None
|
||||
|
||||
|
||||
@@ -184,25 +136,16 @@ class WorkflowRun(BaseModel):
|
||||
error: Optional[str] = None
|
||||
cost_usd: float = 0.0
|
||||
triggered_by: Literal["schedule", "manual", "retry"] = "schedule"
|
||||
# Last tool-call label observed on the underlying agent session while
|
||||
# the workflow is running. Surfaced under the active step in RunningView
|
||||
# (Image #40) so the user can tell the run is still making progress.
|
||||
# Last tool-call label observed on the underlying agent session while the workflow is running. Surfaced under the active step in RunningView (Image #40) so the user can tell the run is still making progress.
|
||||
last_tool_label: Optional[str] = None
|
||||
# Currently-executing step index (0-based). Executor bumps this each
|
||||
# time it dispatches a step prompt and broadcasts the run. RunningView
|
||||
# uses this for the disc statuses; estimate fallback only when null.
|
||||
# Currently-executing step index (0-based). Executor bumps this each time it dispatches a step prompt and broadcasts the run. RunningView uses this for the disc statuses; estimate fallback only when null.
|
||||
active_step_idx: Optional[int] = None
|
||||
# True while the user has paused the in-flight agent turn (same mechanic
|
||||
# as the chat's stop/resume). Rides the workflow:run broadcast so the
|
||||
# card shows the paused state even when the live chat isn't open.
|
||||
# True while the user has paused the in-flight agent turn (same mechanic as the chat's stop/resume). Rides the workflow:run broadcast so the card shows the paused state even when the live chat isn't open.
|
||||
paused: bool = False
|
||||
|
||||
|
||||
class MissedRun(BaseModel):
|
||||
# A single scheduled fire that elapsed while OpenSwarm was closed. Captured
|
||||
# at startup and surfaced in the launch-time review card; leaves this store
|
||||
# only when the user runs it (becomes a ran_late run) or dismisses it
|
||||
# (becomes a skipped run). scheduled_for is the instant it should have fired.
|
||||
# A single scheduled fire that elapsed while OpenSwarm was closed. Captured at startup and surfaced in the launch-time review card; leaves this store only when the user runs it (becomes a ran_late run) or dismisses it (becomes a skipped run). scheduled_for is the instant it should have fired.
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
workflow_id: str
|
||||
scheduled_for: datetime
|
||||
@@ -212,8 +155,7 @@ class MissedRun(BaseModel):
|
||||
class WorkflowCreate(BaseModel):
|
||||
title: str = "Untitled workflow"
|
||||
auto_named: bool = True
|
||||
# Only the "+ New" build flow sets this; every other create path is a
|
||||
# deliberate save and stays visible immediately.
|
||||
# Only the "+ New" build flow sets this; every other create path is a deliberate save and stays visible immediately.
|
||||
unsaved: bool = False
|
||||
description: str = ""
|
||||
icon: str = ""
|
||||
@@ -231,8 +173,7 @@ class WorkflowCreate(BaseModel):
|
||||
provider: Optional[str] = None
|
||||
cost_cap_usd_monthly: Optional[float] = None
|
||||
tested_signature: Optional[str] = None
|
||||
# The FE already named + described + labeled this at preview time; skip the
|
||||
# backend aux call so we don't double-spend or change the title under the user.
|
||||
# The FE already named + described + labeled this at preview time; skip the backend aux call so we don't double-spend or change the title under the user.
|
||||
metadata_generated: bool = False
|
||||
|
||||
|
||||
@@ -250,8 +191,7 @@ class GenerateMetadataResponse(BaseModel):
|
||||
class WorkflowUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
auto_named: Optional[bool] = None
|
||||
# Revealing a compose draft (Save, or auto on first chat message) flips this
|
||||
# to False so the hub stops hiding it. Without it here the PATCH was a no-op.
|
||||
# Revealing a compose draft (Save, or auto on first chat message) flips this to False so the hub stops hiding it. Without it here the PATCH was a no-op.
|
||||
unsaved: Optional[bool] = None
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
@@ -275,8 +215,7 @@ class MissedRunAction(BaseModel):
|
||||
|
||||
|
||||
class AskRunBody(BaseModel):
|
||||
# Answer a chat question with a finished run's transcript folded in as context.
|
||||
# run_id picks the run's session to pull in; prompt is the user's question.
|
||||
# Answer a chat question with a finished run's transcript folded in as context. run_id picks the run's session to pull in; prompt is the user's question.
|
||||
run_id: str
|
||||
prompt: str
|
||||
mode: Optional[str] = None
|
||||
@@ -284,10 +223,7 @@ class AskRunBody(BaseModel):
|
||||
|
||||
|
||||
class DraftCommitBody(BaseModel):
|
||||
# The model the user settled on in the Edit Agent picker, applied to the
|
||||
# workflow's run model only on Save (save-gated; Discard drops it).
|
||||
# The model the user settled on in the Edit Agent picker, applied to the workflow's run model only on Save (save-gated; Discard drops it).
|
||||
model: Optional[str] = None
|
||||
# Keep the edit-agent session alive across the commit. The build flow
|
||||
# auto-commits steps as the agent adds them but must NOT close the chat,
|
||||
# the user keeps talking in the same conversation after it becomes saved.
|
||||
# Keep the edit-agent session alive across the commit. The build flow auto-commits steps as the agent adds them but must NOT close the chat, the user keeps talking in the same conversation after it becomes saved.
|
||||
keep_session: bool = False
|
||||
|
||||
@@ -34,9 +34,7 @@ async def notify_run_complete(wf: Workflow, run: WorkflowRun) -> None:
|
||||
payload = _base_payload(wf, run)
|
||||
await ws_manager.broadcast_global("workflow:notify", payload)
|
||||
|
||||
# Kick off server-side escalation only if there are additional tiers
|
||||
# beyond the default notify. The escalation runner will sleep + call
|
||||
# send_tier per tier.
|
||||
# Kick off server-side escalation only if there are additional tiers beyond the default notify. The escalation runner will sleep + call send_tier per tier.
|
||||
escalation.schedule(wf, run)
|
||||
|
||||
|
||||
|
||||
@@ -35,12 +35,9 @@ from backend.apps.workflows import storage, executor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How many recent missed fires we keep reviewable per workflow. Older ones
|
||||
# collapse into a single summarizing "skipped" run so a 15-minute schedule
|
||||
# that was off for days doesn't flood the card or the run history.
|
||||
# How many recent missed fires we keep reviewable per workflow. Older ones collapse into a single summarizing "skipped" run so a 15-minute schedule that was off for days doesn't flood the card or the run history.
|
||||
PER_WORKFLOW_MISSED_CAP = 20
|
||||
# Bound on the per-workflow enumeration walk at startup. 480 covers ~5 days of
|
||||
# a 15-minute schedule; past that the exact count stops mattering.
|
||||
# Bound on the per-workflow enumeration walk at startup. 480 covers ~5 days of a 15-minute schedule; past that the exact count stops mattering.
|
||||
MISSED_ENUM_CAP = 480
|
||||
|
||||
|
||||
@@ -131,9 +128,7 @@ def _next_fire_after(
|
||||
tz = _resolve_tz(sched.timezone)
|
||||
ref_local = ref_utc.astimezone(tz)
|
||||
base = ref_local.replace(second=0, microsecond=0)
|
||||
# Anchor recurring phases to a fixed origin (the workflow's creation), so a
|
||||
# recompute (tick, kick, startup reconcile) lands on the same grid instead
|
||||
# of re-phasing to "now" and sliding the cadence. Falls back to ref.
|
||||
# Anchor recurring phases to a fixed origin (the workflow's creation), so a recompute (tick, kick, startup reconcile) lands on the same grid instead of re-phasing to "now" and sliding the cadence. Falls back to ref.
|
||||
anchor_local = (anchor_utc or ref_utc).astimezone(tz)
|
||||
|
||||
if sched.repeat_unit == "minute":
|
||||
@@ -328,9 +323,7 @@ async def _fire(wf: Workflow, scheduled_for: Optional[datetime]) -> None:
|
||||
|
||||
|
||||
def _seconds_until_next() -> float:
|
||||
# While globally paused, _tick no-ops and never rolls next_run_at forward,
|
||||
# so an overdue slot would otherwise spin this loop at the 1s floor. Resume
|
||||
# calls kick(), so idling the full interval here costs nothing.
|
||||
# While globally paused, _tick no-ops and never rolls next_run_at forward, so an overdue slot would otherwise spin this loop at the 1s floor. Resume calls kick(), so idling the full interval here costs nothing.
|
||||
if storage.get_paused():
|
||||
return 60.0
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
@@ -380,9 +373,7 @@ def _mark_stuck_runs_failed() -> None:
|
||||
error="Interrupted: OpenSwarm or your computer shut down before this run finished.",
|
||||
finished_at=now,
|
||||
)
|
||||
# The run row is fixed, but the workflow still summarizes this
|
||||
# dead run as 'running' (that's what the detail header reads), so
|
||||
# heal the summary too when this was the latest run.
|
||||
# The run row is fixed, but the workflow still summarizes this dead run as 'running' (that's what the detail header reads), so heal the summary too when this was the latest run.
|
||||
if wf.last_run_id == r.id and wf.last_run_status == "running":
|
||||
executor._persist_run_fields(wf, {
|
||||
"last_run_status": "failure",
|
||||
|
||||
@@ -23,9 +23,7 @@ RUNS_DIR = os.path.join(DATA_DIR, "runs")
|
||||
PAUSED_FILE = os.path.join(DATA_DIR, "paused.json")
|
||||
MISSED_FILE = os.path.join(DATA_DIR, "missed.json")
|
||||
|
||||
# Hard ceiling on pending missed fires kept on disk. The review card only
|
||||
# shows 50; this just stops the file growing without bound if the user keeps
|
||||
# quitting without acting on the card.
|
||||
# Hard ceiling on pending missed fires kept on disk. The review card only shows 50; this just stops the file growing without bound if the user keeps quitting without acting on the card.
|
||||
MAX_MISSED = 200
|
||||
|
||||
_io_lock = Lock()
|
||||
@@ -47,9 +45,7 @@ def _resolve_host_tz_name() -> str:
|
||||
name = ""
|
||||
return name or "UTC"
|
||||
|
||||
# Keep this much run history per workflow on disk. Older runs are pruned;
|
||||
# the History tab caps at ~20 anyway, and unbounded growth turned the JSON
|
||||
# read into a real cost on hot-reload of the schedule page.
|
||||
# Keep this much run history per workflow on disk. Older runs are pruned; the History tab caps at ~20 anyway, and unbounded growth turned the JSON read into a real cost on hot-reload of the schedule page.
|
||||
RUNS_PER_WORKFLOW = 200
|
||||
|
||||
|
||||
@@ -99,10 +95,7 @@ def _load_all_from_disk() -> None:
|
||||
try:
|
||||
with open(os.path.join(DATA_DIR, fname)) as f:
|
||||
wf = Workflow(**json.load(f))
|
||||
# Coerce legacy timezone="local" to the host IANA zone in
|
||||
# memory only. We don't rewrite the file here so backup/sync
|
||||
# tooling doesn't see mtime churn on every startup; the next
|
||||
# user-driven save migrates the on-disk record naturally.
|
||||
# Coerce legacy timezone="local" to the host IANA zone in memory only. We don't rewrite the file here so backup/sync tooling doesn't see mtime churn on every startup; the next user-driven save migrates the on-disk record naturally.
|
||||
if wf.schedule.timezone == "local":
|
||||
wf.schedule.timezone = host_tz
|
||||
_workflow_cache[wf.id] = wf
|
||||
@@ -144,9 +137,7 @@ def init() -> None:
|
||||
def list_workflows() -> list[Workflow]:
|
||||
if not _cache_loaded:
|
||||
init()
|
||||
# Soft-deleted records are filtered here so the scheduler, calendar, and
|
||||
# every list view skip them with no per-caller guard. Trash reads via
|
||||
# list_deleted_workflows; restore/purge fetch by id with get_workflow.
|
||||
# Soft-deleted records are filtered here so the scheduler, calendar, and every list view skip them with no per-caller guard. Trash reads via list_deleted_workflows; restore/purge fetch by id with get_workflow.
|
||||
return [w for w in _workflow_cache.values() if w.deleted_at is None]
|
||||
|
||||
|
||||
@@ -253,8 +244,7 @@ def add_missed(run: MissedRun) -> MissedRun:
|
||||
with _io_lock:
|
||||
_ensure_dirs()
|
||||
_missed_cache.append(run)
|
||||
# Keep the newest MAX_MISSED by scheduled_for so a never-acked card
|
||||
# can't grow the file forever across repeated launches.
|
||||
# Keep the newest MAX_MISSED by scheduled_for so a never-acked card can't grow the file forever across repeated launches.
|
||||
if len(_missed_cache) > MAX_MISSED:
|
||||
_missed_cache.sort(key=lambda m: m.scheduled_for)
|
||||
del _missed_cache[: len(_missed_cache) - MAX_MISSED]
|
||||
|
||||
@@ -23,8 +23,7 @@ from backend.apps.workflows import storage, scheduler, executor, audit, escalati
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Fixed opener for an existing workflow's edit chat. Deterministic on purpose,
|
||||
# no aux LLM call, so it never drifts and always lays out what the user can do.
|
||||
# Fixed opener for an existing workflow's edit chat. Deterministic on purpose, no aux LLM call, so it never drifts and always lays out what the user can do.
|
||||
EDIT_AGENT_INTRO = (
|
||||
"Here's your workflow's edit space. Tell me what you want and I'll handle it:\n\n"
|
||||
"- Add, remove, or reorder steps\n"
|
||||
@@ -83,9 +82,7 @@ _cron_findings: list[str] = []
|
||||
async def workflows_lifespan():
|
||||
storage.init()
|
||||
await scheduler.start()
|
||||
# Cheap one-shot scan for prior cron entries that reference us. We
|
||||
# don't migrate automatically; the FE shows a banner with a "Convert
|
||||
# to OpenSwarm scheduled tasks" button so the user is in control.
|
||||
# Cheap one-shot scan for prior cron entries that reference us. We don't migrate automatically; the FE shows a banner with a "Convert to OpenSwarm scheduled tasks" button so the user is in control.
|
||||
global _cron_findings
|
||||
_cron_findings = _scan_cron_for_openswarm()
|
||||
try:
|
||||
@@ -206,9 +203,7 @@ async def list_workflows(dashboard_id: Optional[str] = None):
|
||||
if dashboard_id:
|
||||
items = [w for w in items if not w.dashboard_id or w.dashboard_id == dashboard_id]
|
||||
items.sort(key=lambda w: w.updated_at or w.created_at, reverse=True)
|
||||
# Enrich with cost_estimate so calendar tooltips and the WorkflowsHub
|
||||
# list don't have to round-trip to GET /workflows/{id} per row. Cheap
|
||||
# because fires_in_window walks at most ~30 fires per workflow.
|
||||
# Enrich with cost_estimate so calendar tooltips and the WorkflowsHub list don't have to round-trip to GET /workflows/{id} per row. Cheap because fires_in_window walks at most ~30 fires per workflow.
|
||||
return {"workflows": [_enriched(w) for w in items]}
|
||||
|
||||
|
||||
@@ -280,26 +275,16 @@ async def create_workflow(body: WorkflowCreate):
|
||||
source_approvals, source_tools, source_allowed_tools = p_source_session_memory(body.source_session_id)
|
||||
wf.remembered_approvals = source_approvals
|
||||
wf.source_tools = source_tools
|
||||
# Convert-from-chat passes the steps signature so the workflow counts as
|
||||
# already validated (the chat already prompted for permissions); a blank
|
||||
# "New" create leaves it None so the first schedule warns to test first.
|
||||
# Convert-from-chat passes the steps signature so the workflow counts as already validated (the chat already prompted for permissions); a blank "New" create leaves it None so the first schedule warns to test first.
|
||||
wf.tested_signature = body.tested_signature
|
||||
if not wf.icon:
|
||||
wf.icon = _derive_icon(wf)
|
||||
_normalize_schedule_state(wf, source_allowed_tools=source_allowed_tools)
|
||||
# Force-generate title + description + per-step labels from the steps
|
||||
# in a single aux call. Previously we only filled missing description,
|
||||
# leaving stale session names ("Inbox check") as titles. Step labels
|
||||
# are the 3-6 word at-a-glance headlines surfaced in StepList; without
|
||||
# them the UI falls back to truncated raw prompts.
|
||||
# When the FE already generated metadata at preview time it ships the title,
|
||||
# description, and per-step labels on the body, so we skip the aux call here.
|
||||
# Force-generate title + description + per-step labels from the steps in a single aux call. Previously we only filled missing description, leaving stale session names ("Inbox check") as titles. Step labels are the 3-6 word at-a-glance headlines surfaced in StepList; without them the UI falls back to truncated raw prompts. When the FE already generated metadata at preview time it ships the title, description, and per-step labels on the body, so we skip the aux call here.
|
||||
if not body.metadata_generated:
|
||||
try:
|
||||
title, description, labels = await _generate_workflow_metadata(wf)
|
||||
# Respect a user-supplied title (auto_named=False); only auto-fill the
|
||||
# name + description while the workflow is still auto-named. Labels are
|
||||
# always safe to fill since they don't override a user's title.
|
||||
# Respect a user-supplied title (auto_named=False); only auto-fill the name + description while the workflow is still auto-named. Labels are always safe to fill since they don't override a user's title.
|
||||
if wf.auto_named:
|
||||
if title:
|
||||
wf.title = title
|
||||
@@ -327,8 +312,7 @@ async def create_workflow(body: WorkflowCreate):
|
||||
|
||||
@workflows.router.post("/generate-metadata")
|
||||
async def generate_workflow_metadata(body: GenerateMetadataRequest) -> GenerateMetadataResponse:
|
||||
# Preview-time naming for the convert-to-workflow draft. Generates without
|
||||
# persisting so the card can show a real title before the user saves.
|
||||
# Preview-time naming for the convert-to-workflow draft. Generates without persisting so the card can show a real title before the user saves.
|
||||
wf = Workflow(steps=body.steps, model=body.model or "sonnet")
|
||||
title, description, labels = await _generate_workflow_metadata(wf)
|
||||
return GenerateMetadataResponse(title=title, description=description, step_labels=labels)
|
||||
@@ -360,9 +344,7 @@ async def p_generate_metadata_for_steps(
|
||||
return "", "", []
|
||||
settings = _ls()
|
||||
try:
|
||||
# Stay on the family the user is actually paying for (same as
|
||||
# generate_title); without primary_api the aux call can resolve to a
|
||||
# lane that returns nothing on subscription setups.
|
||||
# Stay on the family the user is actually paying for (same as generate_title); without primary_api the aux call can resolve to a lane that returns nothing on subscription setups.
|
||||
aux_model, _ = await resolve_aux_model(
|
||||
settings, preferred_tier="haiku", primary_api=get_api_type(model),
|
||||
)
|
||||
@@ -421,11 +403,7 @@ async def p_generate_metadata_for_steps(
|
||||
return None
|
||||
|
||||
try:
|
||||
# Stream, don't use messages.create: 9router's non-streaming response
|
||||
# translator drops `content` for some provider lanes (same reason
|
||||
# generate_title streams), which left the title empty. Streaming also
|
||||
# means no assistant-prefill hack; _extract_json_object finds the
|
||||
# object even if the model wraps it in prose or a code fence.
|
||||
# Stream, don't use messages.create: 9router's non-streaming response translator drops `content` for some provider lanes (same reason generate_title streams), which left the title empty. Streaming also means no assistant-prefill hack; _extract_json_object finds the object even if the model wraps it in prose or a code fence.
|
||||
chunks: list[str] = []
|
||||
async with client.messages.stream(
|
||||
model=aux_model,
|
||||
@@ -516,23 +494,18 @@ async def p_relabel_steps(
|
||||
title, description, labels = await p_generate_metadata_for_steps(steps, model)
|
||||
except Exception:
|
||||
return
|
||||
# One aux call covers labels AND auto-naming. A manual rename sets
|
||||
# auto_named=False, so the title/description below are left untouched then.
|
||||
# One aux call covers labels AND auto-naming. A manual rename sets auto_named=False, so the title/description below are left untouched then.
|
||||
if need_autoname:
|
||||
if title:
|
||||
wf.title = title
|
||||
else:
|
||||
# Aux model returned nothing (flaky lane / rate limit). Fall back to
|
||||
# a step-derived name so the workflow doesn't stay "Untitled workflow".
|
||||
# Aux model returned nothing (flaky lane / rate limit). Fall back to a step-derived name so the workflow doesn't stay "Untitled workflow".
|
||||
fb = p_fallback_title_for_steps(steps)
|
||||
if fb:
|
||||
wf.title = fb
|
||||
if description:
|
||||
wf.description = description
|
||||
# Per-index, not all-or-nothing: the cheap aux tier sometimes returns a
|
||||
# mis-sized (or non-list) step_labels, which used to drop EVERY label and
|
||||
# leave the raw prompt showing as the step title. Take whatever aux gave for
|
||||
# this slot, else a deterministic short label so a step is never its prompt.
|
||||
# Per-index, not all-or-nothing: the cheap aux tier sometimes returns a mis-sized (or non-list) step_labels, which used to drop EVERY label and leave the raw prompt showing as the step title. Take whatever aux gave for this slot, else a deterministic short label so a step is never its prompt.
|
||||
for i in regen_idxs:
|
||||
aux = labels[i].strip() if i < len(labels) and labels[i] else ""
|
||||
new_label = aux or p_short_step_label(steps[i].text)
|
||||
@@ -793,12 +766,7 @@ async def update_workflow(
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
# Optimistic concurrency: if the client passed If-Match, verify it
|
||||
# matches the current updated_at. Stale writes (another window or a
|
||||
# mid-edit background fire) get a 409 so the FE can prompt to reload
|
||||
# instead of silently clobbering the other actor's changes. Missing
|
||||
# header = legacy client, allow through (back-compat with the
|
||||
# frontend's pre-409 code path; FE rolls out If-Match immediately).
|
||||
# Optimistic concurrency: if the client passed If-Match, verify it matches the current updated_at. Stale writes (another window or a mid-edit background fire) get a 409 so the FE can prompt to reload instead of silently clobbering the other actor's changes. Missing header = legacy client, allow through (back-compat with the frontend's pre-409 code path; FE rolls out If-Match immediately).
|
||||
if if_match:
|
||||
current_stamp = wf.updated_at.isoformat() if hasattr(wf.updated_at, "isoformat") else str(wf.updated_at)
|
||||
# Strip quotes a well-behaved HTTP client might add per RFC 7232.
|
||||
@@ -813,30 +781,18 @@ async def update_workflow(
|
||||
)
|
||||
before = wf.model_dump(mode="json")
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
# A user-initiated title rename locks the name so later step edits don't
|
||||
# auto-rename over it. Only an actual change counts, so the full-object
|
||||
# editor save (which echoes the current title unchanged) doesn't lock. If
|
||||
# the FE passes auto_named explicitly, that wins (handled by setattr below).
|
||||
# A user-initiated title rename locks the name so later step edits don't auto-rename over it. Only an actual change counts, so the full-object editor save (which echoes the current title unchanged) doesn't lock. If the FE passes auto_named explicitly, that wins (handled by setattr below).
|
||||
if "title" in data and "auto_named" not in data and data.get("title") != before.get("title"):
|
||||
wf.auto_named = False
|
||||
# While an Edit-Agent draft is in flight, ANY PATCH that touches steps
|
||||
# stages those steps into the draft instead of the live workflow, so the
|
||||
# commit/discard pair is the only thing that moves the live steps. The
|
||||
# match is "steps present", not "steps only", so a mixed patch can never
|
||||
# leak an edit onto the live steps (which commit would then clobber with
|
||||
# the stale draft). The main chat agent never opens an Edit Agent, so it
|
||||
# has no draft and falls through to the live path below.
|
||||
# While an Edit-Agent draft is in flight, ANY PATCH that touches steps stages those steps into the draft instead of the live workflow, so the commit/discard pair is the only thing that moves the live steps. The match is "steps present", not "steps only", so a mixed patch can never leak an edit onto the live steps (which commit would then clobber with the stale draft). The main chat agent never opens an Edit Agent, so it has no draft and falls through to the live path below.
|
||||
if wf.draft_steps is not None and "steps" in data:
|
||||
before_draft = before.get("draft_steps") or []
|
||||
wf.draft_steps = data["steps"]
|
||||
# Any non-steps fields in the same patch still apply live (rare from
|
||||
# the Edit Agent, whose tools only touch steps).
|
||||
# Any non-steps fields in the same patch still apply live (rare from the Edit Agent, whose tools only touch steps).
|
||||
for k, v in data.items():
|
||||
if k != "steps":
|
||||
setattr(wf, k, v)
|
||||
# Label the new draft steps and name the workflow off them (once, while
|
||||
# still "Untitled"), so the title + step labels fill in the instant a
|
||||
# step lands instead of waiting for Save.
|
||||
# Label the new draft steps and name the workflow off them (once, while still "Untitled"), so the title + step labels fill in the instant a step lands instead of waiting for Save.
|
||||
await p_relabel_steps(wf, before_draft, wf.draft_steps, wf.model)
|
||||
wf.updated_at = datetime.now()
|
||||
_normalize_schedule_state(wf)
|
||||
@@ -863,9 +819,7 @@ async def update_workflow(
|
||||
storage.save_workflow(wf)
|
||||
audit.log_change(wf.id, "user", before, wf.model_dump(mode="json"))
|
||||
scheduler.kick()
|
||||
# Push the change to every open dashboard so an agent-driven edit (the
|
||||
# Edit Agent's add/delete/edit-step tools all PATCH here) refreshes the
|
||||
# card live instead of looking stale until the next full refetch.
|
||||
# Push the change to every open dashboard so an agent-driven edit (the Edit Agent's add/delete/edit-step tools all PATCH here) refreshes the card live instead of looking stale until the next full refetch.
|
||||
enriched = _enriched(wf)
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
@@ -956,11 +910,7 @@ async def edit_agent_session(workflow_id: str):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
# Reattach to an in-progress edit session (the user closed and reopened the
|
||||
# card mid-edit): resume the existing draft, don't reset it. Save/Discard
|
||||
# clear edit_agent_session_id, so once an edit is finished the next entry
|
||||
# falls through to the fresh path below: a brand-new chat against the
|
||||
# current committed workflow.
|
||||
# Reattach to an in-progress edit session (the user closed and reopened the card mid-edit): resume the existing draft, don't reset it. Save/Discard clear edit_agent_session_id, so once an edit is finished the next entry falls through to the fresh path below: a brand-new chat against the current committed workflow.
|
||||
existing_id = getattr(wf, "edit_agent_session_id", None) or None
|
||||
if existing_id:
|
||||
if wf.draft_steps is None:
|
||||
@@ -968,17 +918,14 @@ async def edit_agent_session(workflow_id: str):
|
||||
storage.save_workflow(wf)
|
||||
return {"session_id": existing_id}
|
||||
|
||||
# Fresh edit session: snapshot a clean draft from the current committed
|
||||
# steps so the Edit Agent's edits stage there (never the live workflow)
|
||||
# until the user clicks Save, and Discard reverts to exactly this.
|
||||
# Fresh edit session: snapshot a clean draft from the current committed steps so the Edit Agent's edits stage there (never the live workflow) until the user clicks Save, and Discard reverts to exactly this.
|
||||
wf.draft_steps = list(wf.steps)
|
||||
storage.save_workflow(wf)
|
||||
|
||||
from backend.apps.agents.core.models import AgentConfig
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
steps_lines = "\n".join(f"{i+1}. {(s.label or '').strip() or (s.text or '')[:60]}\n Prompt: {s.text}" for i, s in enumerate(wf.steps))
|
||||
# A brand-new workflow ("+ New" in the hub) opens here with zero steps, so
|
||||
# frame the agent as a builder rather than a fix-what-exists editor.
|
||||
# A brand-new workflow ("+ New" in the hub) opens here with zero steps, so frame the agent as a builder rather than a fix-what-exists editor.
|
||||
intro = (
|
||||
"Help the user iterate on it."
|
||||
if wf.steps
|
||||
@@ -1030,11 +977,7 @@ async def edit_agent_session(workflow_id: str):
|
||||
dashboard_id=wf.dashboard_id,
|
||||
)
|
||||
session = await agent_manager.launch_agent(config)
|
||||
# launch_agent marks the session "running" assuming a turn fires immediately,
|
||||
# but an edit-agent chat sits idle until the user sends something. Settle it
|
||||
# to idle or the chat is stuck "thinking" forever. An existing workflow also
|
||||
# gets a fixed (non-LLM) intro message; a brand-new build stays empty so the
|
||||
# compose page can show its own starter prompts.
|
||||
# launch_agent marks the session "running" assuming a turn fires immediately, but an edit-agent chat sits idle until the user sends something. Settle it to idle or the chat is stuck "thinking" forever. An existing workflow also gets a fixed (non-LLM) intro message; a brand-new build stays empty so the compose page can show its own starter prompts.
|
||||
session.status = "completed"
|
||||
if wf.steps:
|
||||
from backend.apps.agents.core.models import Message
|
||||
@@ -1151,8 +1094,7 @@ async def commit_draft(workflow_id: str, body: Optional[DraftCommitBody] = None)
|
||||
if wf.draft_steps is None:
|
||||
if not _has_nonempty_steps(wf.steps):
|
||||
raise HTTPException(status_code=400, detail="Workflow must have at least one step")
|
||||
# Clicking Save is the user committing to this workflow, so reveal it
|
||||
# in the hub (clears the "+ New" build-in-progress flag).
|
||||
# Clicking Save is the user committing to this workflow, so reveal it in the hub (clears the "+ New" build-in-progress flag).
|
||||
wf.unsaved = False
|
||||
p_sync_model_on_save(wf, body.model if body else None)
|
||||
if not (body and body.keep_session):
|
||||
@@ -1162,10 +1104,7 @@ async def commit_draft(workflow_id: str, body: Optional[DraftCommitBody] = None)
|
||||
before = wf.model_dump(mode="json")
|
||||
if not _has_nonempty_steps(wf.draft_steps):
|
||||
raise HTTPException(status_code=400, detail="Workflow must have at least one step")
|
||||
# Opening a workflow snapshots its own steps into the draft, and the card
|
||||
# silently commits that draft. When it matches the live steps that's a no-op:
|
||||
# clear it WITHOUT bumping updated_at, so merely viewing a workflow never
|
||||
# reorders the "last edited" sidebar. Real edits fall through and bump.
|
||||
# Opening a workflow snapshots its own steps into the draft, and the card silently commits that draft. When it matches the live steps that's a no-op: clear it WITHOUT bumping updated_at, so merely viewing a workflow never reorders the "last edited" sidebar. Real edits fall through and bump.
|
||||
no_change = [s.model_dump(mode="json") for s in wf.draft_steps] == (before.get("steps") or [])
|
||||
wf.unsaved = False
|
||||
wf.steps = wf.draft_steps
|
||||
@@ -1177,8 +1116,7 @@ async def commit_draft(workflow_id: str, body: Optional[DraftCommitBody] = None)
|
||||
await p_end_edit_session(wf)
|
||||
storage.save_workflow(wf)
|
||||
return _enriched(wf)
|
||||
# Clicking Save is the user committing to this workflow, so reveal it in
|
||||
# the hub (clears the "+ New" build-in-progress flag).
|
||||
# Clicking Save is the user committing to this workflow, so reveal it in the hub (clears the "+ New" build-in-progress flag).
|
||||
await p_relabel_changed_steps(wf, before.get("steps") or [])
|
||||
p_prune_step_tool_usage(wf)
|
||||
wf.updated_at = datetime.now()
|
||||
@@ -1209,8 +1147,7 @@ async def discard_draft(workflow_id: str):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
# Discard wipes the whole edit session: drop the draft AND end the chat, so
|
||||
# reopening Edit is a fresh conversation against the current committed steps.
|
||||
# Discard wipes the whole edit session: drop the draft AND end the chat, so reopening Edit is a fresh conversation against the current committed steps.
|
||||
wf.draft_steps = None
|
||||
await p_end_edit_session(wf)
|
||||
p_prune_step_tool_usage(wf)
|
||||
@@ -1252,8 +1189,7 @@ async def test_run_workflow(workflow_id: str, body: dict):
|
||||
if isinstance(s, dict) and str(s.get("text") or "").strip()
|
||||
]
|
||||
else:
|
||||
# No explicit override: prefer the pending draft so a mid-edit
|
||||
# TestWorkflow call (from the Edit Agent itself) tests the draft.
|
||||
# No explicit override: prefer the pending draft so a mid-edit TestWorkflow call (from the Edit Agent itself) tests the draft.
|
||||
src = wf.draft_steps if wf.draft_steps is not None else wf.steps
|
||||
step_entries = [s for s in src if s.text and s.text.strip()]
|
||||
if not step_entries:
|
||||
@@ -1290,8 +1226,7 @@ async def test_run_workflow(workflow_id: str, body: dict):
|
||||
remember=executor.p_make_remember_approval(wf.id),
|
||||
ask_timeout=600.0,
|
||||
)
|
||||
# Point the workflow at its latest test session so ReadTestTranscript can
|
||||
# fetch the transcript on demand.
|
||||
# Point the workflow at its latest test session so ReadTestTranscript can fetch the transcript on demand.
|
||||
try:
|
||||
wf.last_test_session_id = session.id
|
||||
storage.save_workflow(wf)
|
||||
@@ -1433,9 +1368,7 @@ async def run_workflow_now(workflow_id: str, body: Optional[dict] = None):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
# executor.execute() owns the run record. Don't pre-create a stub here
|
||||
# or we end up with two rows per manual fire (one orphan "running"
|
||||
# row from this handler plus the real one from the executor).
|
||||
# executor.execute() owns the run record. Don't pre-create a stub here or we end up with two rows per manual fire (one orphan "running" row from this handler plus the real one from the executor).
|
||||
pre_ids = {r.id for r in storage.list_runs(wf.id, limit=10)}
|
||||
tested_signature = body.get("signature") if isinstance(body, dict) else None
|
||||
asyncio.create_task(executor.execute(
|
||||
@@ -1444,10 +1377,7 @@ async def run_workflow_now(workflow_id: str, body: Optional[dict] = None):
|
||||
tested_signature=tested_signature if isinstance(tested_signature, str) else None,
|
||||
))
|
||||
|
||||
# Poll briefly for the newly created run id. We also surface the
|
||||
# run's status + error string when it lands quickly (e.g. cost-cap
|
||||
# short-circuit, _running collision) so the FE can render a toast
|
||||
# instead of silently switching to History.
|
||||
# Poll briefly for the newly created run id. We also surface the run's status + error string when it lands quickly (e.g. cost-cap short-circuit, _running collision) so the FE can render a toast instead of silently switching to History.
|
||||
for _ in range(25):
|
||||
for r in storage.list_runs(wf.id, limit=10):
|
||||
if r.id not in pre_ids and r.triggered_by == "manual":
|
||||
|
||||
Reference in New Issue
Block a user