From 5b0c6e1df3f4c7ad21ace5bc4ea9038ad5e8687a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 20 May 2026 05:25:23 -0700 Subject: [PATCH] defluff: strip em-dashes + shorten docstrings across backend (cosmetic only, no logic change) --- backend/apps/agents/9router_gpt5_patch.js | 85 ++------ backend/apps/agents/anthropic_proxy.py | 101 ++------- backend/apps/agents/browser_agent.py | 86 ++++---- .../apps/agents/browser_agent_mcp_server.py | 7 +- .../apps/agents/invoke_agent_mcp_server.py | 10 +- backend/apps/agents/mcp_preflight.py | 107 ++------- backend/apps/agents/openai_passthrough.py | 41 +--- backend/apps/agents/providers/registry.py | 34 +-- backend/apps/agents/seq_log.py | 98 +-------- backend/apps/agents/tools/web.py | 33 +-- backend/apps/agents/web_mcp_server.py | 27 +-- backend/apps/auth/router.py | 18 +- backend/apps/dashboards/dashboards.py | 6 +- backend/apps/discord_mcp_shim/server.py | 4 +- backend/apps/nine_router.py | 193 +++++------------ backend/apps/outputs/executor.py | 20 +- .../apps/outputs/view_builder_templates.py | 50 ++--- .../outputs/webapp_template/backend_init.sh | 10 +- backend/apps/service/buffer.py | 10 +- backend/apps/service/client.py | 16 +- backend/apps/service/ring_buffer.py | 8 +- backend/apps/service/service.py | 18 +- backend/apps/skill_registry/skill_registry.py | 2 +- backend/apps/skills/skills.py | 12 +- backend/apps/subscription/router.py | 12 +- backend/apps/tools_lib/tools_lib.py | 38 ++-- backend/apps/web/web.py | 16 +- backend/auth.py | 204 ++---------------- backend/config/Apps.py | 3 +- backend/config/install_id.py | 21 +- backend/config/paths.py | 14 +- frontend/src/shared/state/settingsSlice.ts | 71 ++---- 32 files changed, 353 insertions(+), 1022 deletions(-) diff --git a/backend/apps/agents/9router_gpt5_patch.js b/backend/apps/agents/9router_gpt5_patch.js index 43894aa3..742d4aef 100644 --- a/backend/apps/agents/9router_gpt5_patch.js +++ b/backend/apps/agents/9router_gpt5_patch.js @@ -1,34 +1,6 @@ // Node-runtime patch loaded via `node --require ` before 9router boots. -// -// Why this exists -// --------------- -// OpenAI's GPT-5 family (gpt-5.4, gpt-5.4-mini, gpt-5.5, gpt-5.3-codex, …) -// rejects the legacy `max_tokens` parameter with HTTP 400, requiring -// `max_completion_tokens`. 9router (every released version, including 0.4.20) -// blindly forwards `max_tokens` in its Anthropic→OpenAI translator. We can't -// fix 9router from outside (env vars are ignored, baseUrl on the openai -// provider is hardcoded, prefix routing falls back). Instead we intercept -// the HTTPS write at the Node syscall layer — the actual boundary OpenAI -// sees — and rename the field on the way out. -// -// Safety contract -// --------------- -// • Scope: only requests whose hostname is `api.openai.com`. Every other -// outbound HTTP/HTTPS call passes through unmodified. -// • Model gate: only requests whose body parses as JSON with -// `model.startsWith("gpt-5")` (after stripping common prefixes 9router -// adds). GPT-4 / Claude / etc. unaffected. -// • Failure mode: every step is wrapped in try/catch and falls back to the -// unmodified original on any error. Worst case is "request behaves -// exactly as it would without this patch" — never worse than baseline. -// • Idempotency: the patch self-flags so re-loading via multiple --require -// doesn't double-wrap. -// -// Verification -// ------------ -// Set OPENSWARM_DEBUG_GPT5_PATCH=1 in the env to log "[openswarm] 9router- -// gpt5-patch installed" on stderr and "rewrote max_tokens → max_completion_tokens" -// on each rewrite. +// Rewrites `max_tokens` to `max_completion_tokens` for GPT-5 calls (which 9router still emits) and floors completion tokens at 32K for reasoning headroom. +// Hostname-gated to api.openai.com; every step is try/catch so failure falls back to baseline behavior. 'use strict'; @@ -40,7 +12,7 @@ const DEBUG = process.env.OPENSWARM_DEBUG_GPT5_PATCH === '1'; function _log(msg) { if (DEBUG) { - try { process.stderr.write('[openswarm-gpt5-patch] ' + msg + '\n'); } catch (_) { /* ignore */ } + try { process.stderr.write('[openswarm-gpt5-patch] ' + msg + '\n'); } catch (_) {} } } @@ -48,9 +20,7 @@ function isGpt5Model(model) { if (typeof model !== 'string') return false; let m = model.trim().toLowerCase(); if (!m) return false; - // Strip routing prefixes 9router may have added: cp-openai/, openai/, - // cx/, openrouter/, or:openai/. Don't strip cp- (custom-provider) blindly - // because cp-anything could match a non-OpenAI custom node. + // Strip 9router prefixes; don't blindly strip cp- (could be a non-OpenAI custom node). const prefixes = ['cp-openai/', 'openai/', 'cx/', 'openrouter/', 'or:openai/']; for (const p of prefixes) { if (m.startsWith(p)) { m = m.slice(p.length); break; } @@ -58,19 +28,7 @@ function isGpt5Model(model) { return m.startsWith('gpt-5'); } -// Minimum completion-token budget for GPT-5 reasoning models. -// GPT-5 burns 8-30K tokens on internal reasoning BEFORE producing any -// user-visible output. The Anthropic CLI's default max_tokens (~4096) is -// way under that floor — OpenAI accepts the request, runs reasoning until -// it hits the cap, then returns "Could not finish the message because -// max_tokens or model output limit was reached" with zero user-visible -// content. Floor at 32K so reasoning has room AND the user gets an -// actual response. Cost is unaffected because OpenAI bills for -// tokens-consumed, not max_completion_tokens (which is just a cap). -// -// We use max(requestedValue, 32K) — never lower the user's value, only -// raise it. If the user explicitly sets a high value (e.g. 100K) we -// honor it untouched. +// GPT-5 burns 8-30K reasoning tokens before any output; the CLI's default 4096 caps before content lands. Floor at 32K and only raise, never lower. const GPT5_MIN_COMPLETION_TOKENS = 32768; function maybeRewriteBody(bodyStr) { @@ -80,8 +38,7 @@ function maybeRewriteBody(bodyStr) { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return bodyStr; if (!isGpt5Model(parsed.model)) return bodyStr; let mutated = false; - // Both fields present (unlikely but possible): drop the legacy one so - // OpenAI doesn't reject for "both specified". + // Drop legacy field if both present, else OpenAI 400s on "both specified". if ('max_tokens' in parsed && 'max_completion_tokens' in parsed) { delete parsed.max_tokens; mutated = true; @@ -90,15 +47,13 @@ function maybeRewriteBody(bodyStr) { parsed.max_completion_tokens = parsed.max_tokens; delete parsed.max_tokens; mutated = true; - _log('rewrote max_tokens → max_completion_tokens for ' + parsed.model); + _log('rewrote max_tokens to max_completion_tokens for ' + parsed.model); } - // Floor max_completion_tokens at 32K for reasoning headroom. Only raise, - // never lower — if the user explicitly set 100K, keep 100K. if (typeof parsed.max_completion_tokens === 'number' && parsed.max_completion_tokens < GPT5_MIN_COMPLETION_TOKENS) { const orig = parsed.max_completion_tokens; parsed.max_completion_tokens = GPT5_MIN_COMPLETION_TOKENS; mutated = true; - _log('raised max_completion_tokens ' + orig + ' → ' + GPT5_MIN_COMPLETION_TOKENS + ' for ' + parsed.model + ' (reasoning headroom)'); + _log('raised max_completion_tokens ' + orig + ' to ' + GPT5_MIN_COMPLETION_TOKENS + ' for ' + parsed.model); } return mutated ? JSON.stringify(parsed) : bodyStr; } @@ -112,7 +67,6 @@ function _hostFromOpts(opts) { function patchHttpRequest(orig) { return function patchedRequest() { const args = Array.prototype.slice.call(arguments); - // First arg may be a URL string, URL object, or options object. let opts = args[0]; let host = ''; try { @@ -125,33 +79,28 @@ function patchHttpRequest(orig) { return orig.apply(this, args); } - // Outbound request to OpenAI: intercept body. The Anthropic SDK and - // 9router both call .write(body) then .end(), or .end(body) directly. let req; try { req = orig.apply(this, args); } catch (e) { throw e; } const origWrite = req.write.bind(req); const origEnd = req.end.bind(req); const chunks = []; - let isStringMode = null; // null until first chunk; then true=string, false=buffer + let isStringMode = null; function recordChunk(chunk) { if (chunk == null) return; if (typeof chunk === 'string') { if (isStringMode === false) { - // Mixed mode — fall back: convert prior buffers to string for (let i = 0; i < chunks.length; i++) chunks[i] = chunks[i].toString('utf8'); } isStringMode = true; chunks.push(chunk); } else if (Buffer.isBuffer(chunk)) { if (isStringMode === true) { - // Mixed: convert prior strings to buffers for (let i = 0; i < chunks.length; i++) chunks[i] = Buffer.from(chunks[i], 'utf8'); } isStringMode = false; chunks.push(chunk); } else { - // Unknown shape — abandon interception throw new Error('unknown-chunk-shape'); } } @@ -162,12 +111,10 @@ function patchHttpRequest(orig) { recordChunk(chunk); return true; } catch (_) { - // Abandon interception — pass through immediately and disable buffering. - // Flush anything we'd buffered so far. try { for (const c of chunks) origWrite(c); chunks.length = 0; - } catch (_) { /* ignore */ } + } catch (_) {} return origWrite.apply(req, [chunk].concat(restArgs)); } }; @@ -186,19 +133,17 @@ function patchHttpRequest(orig) { if (req.getHeader && typeof req.getHeader === 'function' && req.getHeader('content-length')) { req.setHeader('Content-Length', newBuf.length); } - } catch (_) { /* ignore */ } + } catch (_) {} return origEnd.call(req, newBuf); } - // No rewrite — send original body intact if (chunks.length === 0) return origEnd.apply(req, restArgs); if (isStringMode === true) return origEnd.call(req, chunks.join('')); return origEnd.call(req, Buffer.concat(chunks)); } catch (_) { - // Abandon — flush any buffered content + tail chunk try { for (const c of chunks) origWrite(c); chunks.length = 0; - } catch (_) { /* ignore */ } + } catch (_) {} if (chunk != null) return origEnd.apply(req, [chunk].concat(restArgs)); return origEnd.apply(req, restArgs); } @@ -216,13 +161,11 @@ if (!_https.__openswarm_gpt5_patched) { _http.__openswarm_gpt5_patched = true; _log('installed https.request + http.request interceptors'); } catch (e) { - // Patch failed — log and continue. 9router will work as normal, - // GPT-5 calls will fail with the same 400 they did before. Never worse. _log('install failed: ' + (e && e.message ? e.message : String(e))); } } -// Also patch global fetch (Node 18+). 9router uses fetch in some paths. +// Node 18+ fetch path; 9router uses fetch in some routes. if (typeof globalThis.fetch === 'function' && !globalThis.fetch.__openswarm_gpt5_patched) { try { const origFetch = globalThis.fetch; @@ -249,7 +192,7 @@ if (typeof globalThis.fetch === 'function' && !globalThis.fetch.__openswarm_gpt5 if (k.toLowerCase() === 'content-length') newInit.headers[k] = newLen; } } - } catch (_) { /* ignore */ } + } catch (_) {} } return origFetch.call(this, input, newInit); } diff --git a/backend/apps/agents/anthropic_proxy.py b/backend/apps/agents/anthropic_proxy.py index f4cd2a56..3fd93da9 100644 --- a/backend/apps/agents/anthropic_proxy.py +++ b/backend/apps/agents/anthropic_proxy.py @@ -1,20 +1,4 @@ -"""Lightweight Anthropic-format HTTP proxy. - -When a user is on openswarm-pro with a non-Claude primary (GPT/Gemini/etc.), -the Claude Code CLI needs a single `ANTHROPIC_BASE_URL` that can serve BOTH: - - 1. the primary model calls (e.g. `cx/gpt-5` → must go to 9Router) - 2. auxiliary Claude calls for subagents, WebSearch delegation - (e.g. `claude-haiku-4-5` → must go to OpenSwarm Pro's cloud proxy) - -9Router doesn't know about OpenSwarm Pro, and we don't want to maintain a -custom 9Router provider-node for that. This proxy splits requests by the -`model` field in the body and forwards each to the correct upstream. - -Mounted at `/api/anthropic-proxy`. Set `ANTHROPIC_BASE_URL` to -`http://127.0.0.1:/api/anthropic-proxy` in the CLI env for -Pro users with non-Claude primaries. -""" +"""Anthropic-format HTTP proxy splitting requests by model field; primary to 9Router, aux Claude to Pro proxy.""" import json import logging @@ -48,42 +32,27 @@ _CLAUDE_MODEL_PREFIXES = ( _GEMINI_MODEL_PREFIXES = ("gemini/", "gc/", "ag/") -# Bare-model patterns that resolve to Gemini's native API (gemini-3-flash-api, -# gemini-3.1-pro-api, gemini-3.1-flash-lite-api, etc. — when user supplies own -# Google API key in Settings → Models). These bypass our `gemini/` prefix so -# the prefix-only check above misses them; we match on the bare-name shape -# here too so $schema scrubbing fires for own-key Gemini sessions. -# Pre-fix: 8/8 own-key Gemini sessions in production failed with 400 because -# JSON Schema's $schema field leaked into Google's tools[].function_declarations -# payload. (See raw_payloads where status=error on every gemini-*-api session.) +# Own-key Gemini ("gemini-3-flash-api" etc.) skips the gemini/ prefix; match bare names so $schema scrub still fires. _GEMINI_BARE_MODEL_PATTERNS = ("gemini-",) -# Fields Gemini's function_declarations validator rejects. 9Router 0.3.60's -# translator strips allOf/anyOf/oneOf/const-toplevel/required but misses -# these. Each one we've seen Gemini 400 on in production with "Unknown -# name 'X' at request.tools[N].function_declarations[N].parameters.…" +# Keys 9Router 0.3.60 misses that Gemini's function_declarations validator 400s on. Each was caught in prod. _GEMINI_FORBIDDEN_SCHEMA_KEYS = { - # JSON-Schema metadata fields Gemini's stricter validator doesn't accept. "$schema", - "$id", # ag/gemini-3.1-pro-high session, 2026-05-08 - "$ref", # JSON-Schema reference; Gemini wants inlined types - "$defs", # ditto - "definitions", # legacy alias for $defs - # Constraint fields Gemini doesn't implement. + "$id", + "$ref", + "$defs", + "definitions", "additionalProperties", "propertyNames", "patternProperties", "exclusiveMinimum", "exclusiveMaximum", - "const", # nested const leaks through 9Router's top-level-only strip. - # Anthropic-specific tool-call hints not part of vanilla JSON Schema. - # Anthropic's CLI emits these on tools that benefit from response - # priming; Gemini's validator rejects all unknown keys. - "prefill", # ag/gemini-3.1-pro-high session, 2026-05-08 - "enumTitles", # human-readable enum labels; OpenAI-only convention - "title", # safe to keep usually but Gemini sometimes rejects under nested arrays - "examples", # JSON-Schema 2019-09 keyword Gemini doesn't honor - "default", # often allowed but rejected in nested array.items + "const", + "prefill", + "enumTitles", + "title", + "examples", + "default", "readOnly", "writeOnly", "deprecated", @@ -106,28 +75,15 @@ def _scrub_gemini_schema(node): return node -# Models that REQUIRE max_completion_tokens instead of max_tokens. -# OpenAI's GPT-5.x family (gpt-5.4, gpt-5.4-mini, gpt-5.5, gpt-5.3-codex, -# etc.) introduced this in late 2025 — the legacy `max_tokens` field returns -# a 400 "Unsupported parameter: 'max_tokens' is not supported with this -# model. Use 'max_completion_tokens' instead." Anthropic's CLI / SDK still -# emits `max_tokens` because that's the Anthropic-format wire shape; we -# rename it on the way out for OpenAI-routed GPT-5 models. +# GPT-5.x rejects max_tokens; needs max_completion_tokens. Anthropic-format wire still emits max_tokens; we rename on the way out. _OPENAI_MAX_COMPLETION_TOKENS_MODELS = ("gpt-5",) def _is_openai_max_completion_tokens_model(model: str) -> bool: - """Match every shape a GPT-5 model name might arrive in. Includes: - - bare: "gpt-5", "gpt-5.5", "gpt-5.4-mini" - - api-suffixed: "gpt-5.5-api" (desktop's pinned-api naming) - - 9router-prefixed: "openai/gpt-5.5" (post-translation name) - - codex-routed: "cx/gpt-5.3-codex" (CLI subscription) - Anything WITHOUT "gpt-5" in the (lowercased) string is rejected. - """ + """Match every shape a GPT-5 name might arrive in (bare, api-suffixed, openai/-prefixed, cx/-routed).""" m = (model or "").strip().lower() if not m: return False - # Strip common routing prefixes so we can match the bare model body. for prefix in ("openai/", "cx/", "openrouter/", "or:openai/", "cp/", "cp-"): if m.startswith(prefix): m = m[len(prefix):] @@ -136,12 +92,7 @@ def _is_openai_max_completion_tokens_model(model: str) -> bool: def _scrub_request_for_openai_gpt5(body: bytes) -> bytes: - """Rename `max_tokens` → `max_completion_tokens` for GPT-5 models. - - Bytes-in/out, never raises. No-op if the body isn't JSON or doesn't - contain `max_tokens`. Drops the legacy field if BOTH are present so - the API doesn't reject for "both fields specified". - """ + """Rename max_tokens to max_completion_tokens for GPT-5; bytes in/out, never raises.""" if not body: return body try: @@ -179,8 +130,7 @@ def _scrub_request_for_gemini(body: bytes) -> bytes: return json.dumps(parsed).encode("utf-8") -# Headers we strip before forwarding — these change hop-by-hop or we -# replace them with upstream-specific auth. +# Hop-by-hop headers or auth we replace with the upstream-specific value. _HOP_HEADERS = { "host", "content-length", @@ -206,9 +156,7 @@ def _is_gemini_model(model: str) -> bool: m = (model or "").strip().lower() if m.startswith(_GEMINI_MODEL_PREFIXES): return True - # Bare-name match: "gemini-3-flash-api", "gemini-3.1-pro-api", etc. - # Excludes anthropic-routed gemini models (those carry "/" or other - # routing prefixes via the registry). + # Bare-name match for own-key Gemini; excludes anthropic-routed gemini (those carry "/"). if "/" in m: return False return any(m.startswith(p) for p in _GEMINI_BARE_MODEL_PATTERNS) @@ -220,15 +168,12 @@ def _pick_upstream(model: str) -> tuple[str, dict[str, str]]: s = load_settings() if _is_claude_model(model): - # Prefer Pro cloud proxy when configured. if getattr(s, "connection_mode", "own_key") == "openswarm-pro": bearer = getattr(s, "openswarm_bearer_token", "") or "" proxy = (getattr(s, "openswarm_proxy_url", "") or "https://api.openswarm.com").rstrip("/") if bearer and proxy: return (proxy, {"Authorization": f"Bearer {bearer}"}) - # Fall through — let 9Router handle it (maybe user has a real Claude sub). - # Default: 9Router for everything else (cx/, gc/, gh/, apikey-routed models). return ("http://127.0.0.1:20128", {"x-api-key": "9router"}) @@ -243,7 +188,7 @@ def _pick_upstream(model: str) -> tuple[str, dict[str, str]]: include_in_schema=False, ) async def _healthcheck(): - """CLI healthchecks the proxy root — return 200 so it doesn't 404.""" + """CLI healthchecks the proxy root; return 200 so it doesn't 404.""" return {"ok": True} @@ -272,13 +217,7 @@ async def proxy(rest: str, request: Request): for k, v in request.headers.items(): if k.lower() in _HOP_HEADERS: continue - # The CLI we spawn carries our per-install auth token via - # `x-api-key` (we set `ANTHROPIC_API_KEY=` on the - # spawn env, and the CLI forwards that value as x-api-key). We - # must NOT forward that header to the real upstream — it would - # leak our local token to api.openswarm.com / 9Router, AND it - # would shadow the real upstream auth (bearer or `9router` - # literal) that `_pick_upstream` wants to set. Strip it here. + # CLI carries our install token as x-api-key; never forward (leak + shadows real upstream auth). if k.lower() == "x-api-key": continue forward_headers[k] = v diff --git a/backend/apps/agents/browser_agent.py b/backend/apps/agents/browser_agent.py index 7c509075..728a07f6 100644 --- a/backend/apps/agents/browser_agent.py +++ b/backend/apps/agents/browser_agent.py @@ -73,7 +73,7 @@ def _hash_tool_call(tool_name: str, tool_input: dict, result: dict) -> tuple[str """Build a stable hash key for a tool call, including its result. Including the result hash means that legitimate progress (same input, - different output — e.g. BrowserScroll on a long feed) does NOT count + different output; e.g. BrowserScroll on a long feed) does NOT count as a loop. Only same-input + same-output is treated as stuck. """ try: @@ -108,7 +108,7 @@ def _detect_loop( _LOOP_WARNING_TEXT = ( "LOOP DETECTED: You have called this tool with these exact parameters and " "gotten the same result {count} times in a row. STOP retrying this approach " - "— it is not working. Try a fundamentally different strategy: " + ", it is not working. Try a fundamentally different strategy: " "(1) check the page state with BrowserScreenshot or BrowserGetText, " "(2) try a different selector or a different tool, " "(3) use BrowserPressKey for keyboard shortcuts if the site supports them, " @@ -121,7 +121,7 @@ def _validate_message_pairing(messages: list[dict]) -> bool: message in the same list. Returns False if there's an orphan, which means the cached history would 400 if sent to the API. - This is the last line of defense against cache corruption — if it ever + This is the last line of defense against cache corruption; if it ever returns False on a resume, we drop the cache and start fresh rather than crash on the next API call. """ @@ -145,7 +145,7 @@ def _validate_message_pairing(messages: list[dict]) -> bool: def _is_fresh_user_message(msg: dict) -> bool: - """A 'fresh' user message starts a new turn — string content or a list + """A 'fresh' user message starts a new turn; string content or a list that contains no tool_result blocks. These are the only safe cut points because they don't reference any prior assistant tool_use blocks.""" if msg.get("role") != "user": @@ -165,7 +165,7 @@ def _summarize_messages(messages: list[dict]) -> str: Extracts the original user task, a count of tool calls by name with their key parameters, the last few ReportProgress brain states, and the most - recent assistant text. No LLM call required — this is purely structural + recent assistant text. No LLM call required; this is purely structural extraction from the existing message history. """ if not messages: @@ -256,7 +256,7 @@ def _trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict function avoids that by: 1. Walking forward to find a clean turn boundary (a fresh user-text - message that starts a new turn — no tool_result content). + message that starts a new turn; no tool_result content). 2. Summarizing everything BEFORE that boundary into a single user-text message and prepending it to the kept tail. 3. If no clean boundary exists at all, returning the original history @@ -285,7 +285,7 @@ def _trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict # 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. + # 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 _is_fresh_user_message(messages[i]): @@ -293,7 +293,7 @@ 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 + # No clean cut anywhere in the history. Return original; better to # exceed the cap than to corrupt the conversation. return list(messages) @@ -326,7 +326,7 @@ BROWSER_TOOLS_SCHEMA = [ "working_memory": { "type": "string", "description": ( - "Short notes about what you've learned about this site so far — " + "Short notes about what you've learned about this site so far; " "selectors that work, keyboard shortcuts, layout quirks, what " "you've tried that failed. Carry this forward across turns." ), @@ -459,7 +459,7 @@ BROWSER_TOOLS_SCHEMA = [ "[2], etc. Use this BEFORE BrowserClickIndex. This is " "the PREFERRED way to discover clickable elements on hostile sites " "(Tinder, Instagram, TikTok) where CSS selectors fail because the page " - "uses unlabeled
s — the accessibility tree sees roles and names " + "uses unlabeled
s; the accessibility tree sees roles and names " "even when raw HTML doesn't expose them. Much more reliable than " "BrowserGetElements (which uses CSS selectors)." ), @@ -476,7 +476,7 @@ BROWSER_TOOLS_SCHEMA = [ "Uses native OS-level mouse events (event.isTrusted=true) so it works " "on sites that filter out synthetic JS events. Always call " "BrowserListInteractives first to get a fresh index list. If the click " - "returns 'index no longer valid', the page changed — re-list and retry." + "returns 'index no longer valid', the page changed; re-list and retry." ), "input_schema": { "type": "object", @@ -496,7 +496,7 @@ BROWSER_TOOLS_SCHEMA = [ "is executed in order, with the URL captured before/after each one. " "If the URL changes mid-batch (the page navigated), the rest of the " "batch is aborted and you get a partial result. Use this when you " - "have a known sequence — typing then pressing Enter, swiping multiple " + "have a known sequence; typing then pressing Enter, swiping multiple " "times, clicking through pagination. Max 5 actions per batch.\n\n" "Sub-action types and their params:\n" "- click_index: { index: int }\n" @@ -537,7 +537,7 @@ BROWSER_TOOLS_SCHEMA = [ "description": ( "Press a keyboard key (or key combination) on the page using a real native " "input event. Use this for keyboard shortcuts when JS-dispatched events get " - "ignored — sites like Tinder, Slack, Notion, Gmail listen for trusted key " + "ignored; sites like Tinder, Slack, Notion, Gmail listen for trusted key " "events. Examples: 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape', 'Tab', " "'Space', single letters like 'a'. Prefer this over BrowserEvaluate with " "dispatchEvent for keyboard shortcuts." @@ -579,7 +579,7 @@ BROWSER_TOOLS_SCHEMA = [ "name": "RequestHumanIntervention", "description": ( "Request the user's help when you encounter an obstacle you cannot solve " - "programmatically — captchas, login prompts, cookie consent walls, " + "programmatically; captchas, login prompts, cookie consent walls, " "two-factor authentication, or any blocking popup. The agent will pause " "until the user resolves the issue and clicks Continue." ), @@ -590,7 +590,7 @@ BROWSER_TOOLS_SCHEMA = [ "type": "string", "description": ( "One short sentence describing the obstacle. Keep it under " - "15 words. Example: 'Login required — please sign in to X/Twitter.'" + "15 words. Example: 'Login required; please sign in to X/Twitter.'" ), }, "instruction": { @@ -624,7 +624,7 @@ ACTION_MAP = { SYSTEM_PROMPT = ( "You are a website-agnostic browser automation agent. You can operate on ANY " - "website the user is signed into — social media, dating apps, email, productivity " + "website the user is signed into; social media, dating apps, email, productivity " "tools, dashboards, ecommerce, anything. Assume the user has already logged in.\n\n" "## Required output structure: ReportProgress before every action\n" @@ -654,36 +654,36 @@ SYSTEM_PROMPT = ( "If this is a continuation of an earlier conversation on the same browser, the " "messages above already contain everything you've tried, what worked, what failed, " "and the page state. READ THAT HISTORY before acting. Do NOT take a fresh screenshot " - "or re-explore the DOM if you already know what's on screen — just act. Only re-orient " + "or re-explore the DOM if you already know what's on screen; just act. Only re-orient " "if the page has clearly changed (after navigation, after a multi-second wait, or if " "your last action mutated the page in unexpected ways).\n\n" "## Try multiple strategies, learn from failures\n" - "Sites vary wildly. When one approach fails, switch tactics — don't retry the same " + "Sites vary wildly. When one approach fails, switch tactics; don't retry the same " "thing. The escalation ladder, fastest to slowest:\n" - "1. **Keyboard shortcuts via BrowserPressKey** — fastest and most reliable on sites " + "1. **Keyboard shortcuts via BrowserPressKey**; fastest and most reliable on sites " "that support them (Tinder swipes, Gmail navigation, Slack message jump, etc.). " "Always check if the site shows keyboard hints in the UI before falling back to clicks. " "BrowserPressKey sends real native events that pass the `event.isTrusted` check, so " "it works where dispatchEvent in BrowserEvaluate silently fails.\n" - "2. **Accessibility tree via BrowserListInteractives + BrowserClickIndex** — the " + "2. **Accessibility tree via BrowserListInteractives + BrowserClickIndex**; the " "accessibility tree sees roles and names that the raw DOM doesn't, even on sites " "like Tinder, Instagram, and TikTok that use unlabeled
s with click handlers. " "Call BrowserListInteractives to get a numbered list (`[1]
\s*(?=]*class="[^"]*result|$)', body, @@ -109,14 +98,13 @@ class WebSearchTool(BaseTool): if len(entries) >= num_results: break - # Title + URL — handle both class-before-href and href-before-class + # Handle both class-before-href and href-before-class attribute orders. link_match = re.search( r']*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)', block, flags=re.DOTALL, ) if not link_match: - # Try reversed attribute order link_match = re.search( r']*href="([^"]*)"[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)', block, @@ -128,7 +116,6 @@ class WebSearchTool(BaseTool): raw_url = html.unescape(link_match.group(1)) title = _strip_html(link_match.group(2)).strip() - # Snippet snippet_match = re.search( r']*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)', block, @@ -136,7 +123,7 @@ class WebSearchTool(BaseTool): ) snippet = _strip_html(snippet_match.group(1)).strip() if snippet_match else "" - # DuckDuckGo wraps URLs through a redirect; try to extract the real URL + # DDG wraps URLs in a redirect; extract the real one. real_url_match = re.search(r"uddg=([^&]+)", raw_url) if real_url_match: from urllib.parse import unquote @@ -152,11 +139,6 @@ class WebSearchTool(BaseTool): return "\n\n".join(entries) -# ─────────────────────────────────────────────────────────────────────────── -# WebFetchTool -# ─────────────────────────────────────────────────────────────────────────── - - class WebFetchTool(BaseTool): name = "WebFetch" description = ( @@ -202,10 +184,7 @@ class WebFetchTool(BaseTool): is_html = "html" in content_type or resp.text.strip().startswith(" str | None: The set is sourced from OPENSWARM_DISCORD_GUILD_IDS env var (CSV) which tools_lib.py populates from the tool's oauth_tokens.guilds. If the env - var is empty (no guild authorization yet), allow all — agent shouldn't + var is empty (no guild authorization yet), allow all; agent shouldn't be able to spawn this MCP without an OAuth flow having happened. """ if not ALLOWED_GUILDS: diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py index 25991c4a..ee9128af 100644 --- a/backend/apps/nine_router.py +++ b/backend/apps/nine_router.py @@ -30,13 +30,13 @@ NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1" # cross-provider WebSearch: the CLI's WebSearch call from Codex/Gemini # primaries used to route cleanly through 9Router's translator and hit # Anthropic's server-side web_search (returning real results), but later -# translator changes broke that path — non-Claude primaries now see +# translator changes broke that path; non-Claude primaries now see # "claude-haiku-4-5-20251001 unavailable" or hallucinated output. # Pinning to 0.3.60 restores v1.0.25 behavior. # # Note: 0.3.60-0.4.20 ALL emit `max_tokens` (not max_completion_tokens) # when translating Anthropic→OpenAI, which OpenAI's GPT-5 family rejects. -# The fix lives in our /api/openai-passthrough proxy — see openai_passthrough.py +# The fix lives in our /api/openai-passthrough proxy; see openai_passthrough.py # and sync_openai_api_key for how the translation lane is rerouted via an # `openai-compatible` provider-node that honors `baseUrl`. NINE_ROUTER_NPM_VERSION = "0.3.60" @@ -75,16 +75,12 @@ def _find_9router_dir() -> str | None: _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" if _is_packaged: - # Packaged Electron app — router is in extraResources import sys - # In packaged mode, backend is at /backend/ - # So router is at /router/ _resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) _candidate = os.path.join(_resources, "router") if os.path.isdir(_candidate): return _candidate else: - # Dev mode — router is at project root _backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _project_root = os.path.dirname(_backend_dir) _candidate = os.path.join(_project_root, "router") @@ -103,7 +99,7 @@ def _gpt5_patch_path() -> str | None: every gpt-5* own-key session 400's because OpenAI rejects the legacy field name and 9router (every version including 0.4.20) emits it. - Returns None if the file is missing — `subprocess.Popen` would fail + Returns None if the file is missing; `subprocess.Popen` would fail on `node --require `, so the caller drops the flag and spawns 9router unpatched (failure mode = identical to pre-patch baseline; GPT-5 still 400's but everything else works). @@ -121,14 +117,14 @@ def _find_node() -> str | None: """Find a Node.js binary (works in both dev and packaged mode). Priority order: - 1. OPENSWARM_NODE_PATH — set by electron/main.js when a real Node + 1. OPENSWARM_NODE_PATH; set by electron/main.js when a real Node binary is bundled in extraResources. Always preferred on user machines because it (a) avoids the bouncing "exec" Dock icon that ELECTRON_RUN_AS_NODE produces on fresh Macs and (b) starts - in ~50ms vs Electron-as-Node's 5–15s cold-start, shrinking the + in ~50ms vs Electron-as-Node's 5, 15s cold-start, shrinking the splash window the user stares at. - 2. System `node` on PATH — dev convenience. - 3. ELECTRON_RUN_AS_NODE fallback — last resort. Only hits this on + 2. System `node` on PATH; dev convenience. + 3. ELECTRON_RUN_AS_NODE fallback; last resort. Only hits this on packaged builds that for some reason shipped without the bundled node payload. """ @@ -163,7 +159,7 @@ def _ensure_router_cached() -> str | None: """Ensure the npm 9router package is installed in the dev cache. Returns the absolute path to `app/server.js` on success, or None if - npm isn't available or the install fails. Idempotent — returns + npm isn't available or the install fails. Idempotent; returns immediately when the server file already exists. Running `node app/server.js` directly (instead of `npx 9router`) @@ -178,7 +174,7 @@ def _ensure_router_cached() -> str | None: npm = shutil.which("npm") if not npm: - logger.warning("npm not found — install Node.js to auto-start 9Router in dev.") + logger.warning("npm not found; install Node.js to auto-start 9Router in dev.") return None try: @@ -242,7 +238,7 @@ async def ensure_running(): _9router_dir = _find_9router_dir() if _is_packaged and _9router_dir: - # Packaged mode — run the pre-built standalone server staged at + # Packaged mode; run the pre-built standalone server staged at # /router/server.js by scripts/fetch-router.sh at build time. standalone_server = os.path.join(_9router_dir, "server.js") if not os.path.exists(standalone_server): @@ -253,7 +249,7 @@ async def ensure_running(): node = _find_node() if not node: - logger.warning("Node.js not found — cannot start 9Router in packaged mode.") + logger.warning("Node.js not found; cannot start 9Router in packaged mode.") return logger.info("Starting 9Router (production) on port %d...", NINE_ROUTER_PORT) @@ -268,7 +264,7 @@ async def ensure_running(): env["ELECTRON_RUN_AS_NODE"] = "1" else: - # Dev mode — install the pinned 9router npm package into a local + # Dev mode; install the pinned 9router npm package into a local # cache the first time run.sh boots, then spawn `node app/server.js` # directly on subsequent launches. Bypassing the package's cli.js # avoids its menu-bar tray icon (which users confusingly quit, @@ -280,7 +276,7 @@ async def ensure_running(): node = _find_node() if not node: - logger.warning("Node.js not found — cannot start 9Router in dev mode.") + logger.warning("Node.js not found; cannot start 9Router in dev mode.") return logger.info( @@ -298,7 +294,7 @@ async def ensure_running(): # By default, 9Router's stdout/stderr go to /dev/null (Next.js dev mode # is extremely chatty and floods the openswarm console otherwise). When # debugging is needed, set OPENSWARM_DEBUG_9ROUTER=1 in the environment - # before launching the backend — output will then be appended to + # before launching the backend; output will then be appended to # backend/data/9router.log line-buffered, which can be `tail -f`'d. if os.environ.get("OPENSWARM_DEBUG_9ROUTER"): _log_path = os.path.join( @@ -323,7 +319,6 @@ async def ensure_running(): env=env, ) - # Wait up to 30 seconds for startup (production standalone is faster) timeout = 20 if _is_packaged else 30 for _ in range(timeout * 2): await asyncio.sleep(0.5) @@ -352,10 +347,6 @@ def stop(): logger.info("9Router stopped") -# --------------------------------------------------------------------------- -# API proxy helpers — call 9Router's API from OpenSwarm -# --------------------------------------------------------------------------- - async def get_usage_stats(period: str = "all") -> dict | None: """Get usage statistics from 9Router.""" try: @@ -378,8 +369,8 @@ async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | No in reverse chronological order with full token breakdowns including `reasoning_tokens` (OpenAI's `completion_tokens_details.reasoning_tokens`) and `thoughtsTokenCount` (Gemini's). For Anthropic via 9Router this - field will be absent/zero — Anthropic doesn't break out reasoning - tokens in its API response — so callers get None and should fall + field will be absent/zero; Anthropic doesn't break out reasoning + tokens in its API response; so callers get None and should fall back to the heuristic. """ if not is_running(): @@ -393,9 +384,6 @@ async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | No if r.status_code != 200: return None data = r.json() - # Endpoint returns either {requests: [...]} or {data: [...]} — - # be defensive about the shape since 9Router has rolled out - # multiple variants. requests = data.get("requests") or data.get("data") or [] for req in requests: tokens = req.get("tokens") or req.get("usage") or {} @@ -415,7 +403,7 @@ async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | No async def get_providers() -> list[dict]: """Get all providers and their connection status from 9Router. - 9Router's GET /api/providers returns `{"connections": [...]}` — we + 9Router's GET /api/providers returns `{"connections": [...]}`; we unwrap so callers always see a plain list of connection dicts. """ try: @@ -432,21 +420,11 @@ async def get_providers() -> list[dict]: return [] -# --------------------------------------------------------------------------- -# API-key connection sync (Gemini AI Studio, etc.) -# --------------------------------------------------------------------------- -# -# 9Router supports both OAuth (e.g. gemini-cli) and direct API-key auth -# (provider="gemini", authType="apikey"). The two hit different Google -# quotas — OAuth uses the Code Assist free tier which is aggressively -# rate-limited (429s on Gemini 3 Pro/Flash even for paid-subscription -# users), while an AI Studio API key uses the generativelanguage.googleapis.com -# quota which is independent and far higher. -# -# We expose `google_api_key` in settings; this helper mirrors it into -# 9Router's provider-connections list so the API-key path is preferred -# when a key is set. On removal, we delete the key-based connection so -# 9Router falls back to whatever OAuth connection the user still has. +# 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)" @@ -533,7 +511,7 @@ async def sync_openai_api_key(api_key: str | None) -> None: `baseUrl` field on the connection. Only the `openai-compatible-*` provider-node type honors `baseUrl` (verified statically against 9Router's compiled bundle). So we register our OpenAI lane AS an - openai-compatible node — same upstream protocol, different routing. + openai-compatible node; same upstream protocol, different routing. Why we route through openai-passthrough at all: OpenAI's GPT-5 family rejects the legacy `max_tokens` parameter with HTTP 400, but every @@ -565,7 +543,6 @@ async def _sync_openai_compat_node(api_key: str | None) -> None: base_url = f"http://127.0.0.1:{port}/api/openai-passthrough/v1" managed_name = f"OpenAI{NINE_ROUTER_CUSTOM_NAME_SUFFIX}" - # List existing managed nodes — we own the prefix `cp-openai`. try: async with httpx.AsyncClient(timeout=5.0) as client: r = await client.get(f"{NINE_ROUTER_API}/provider-nodes") @@ -578,7 +555,6 @@ async def _sync_openai_compat_node(api_key: str | None) -> None: None, ) - # Tear down when api_key is cleared. if not api_key: if existing_node: try: @@ -623,7 +599,6 @@ async def _sync_openai_compat_node(api_key: str | None) -> None: logger.warning(f"9Router OpenAI compat node sync failed: {e}") return - # Connection record carrying the api key, scoped to this provider node. try: existing_conn = await _find_keyed_connection(node_id, managed_name) conn_payload = { @@ -657,19 +632,10 @@ async def sync_openrouter_api_key(api_key: str | None) -> None: ) -# --------------------------------------------------------------------------- -# Custom OpenAI-compatible providers (Ollama Cloud, Together AI, local Ollama, etc.) -# --------------------------------------------------------------------------- -# -# 9Router supports arbitrary OpenAI-compatible endpoints via "provider nodes" -# (POST /api/provider-nodes with type="openai-compatible"). Each node gets a -# unique provider id like `openai-compatible-chat-` and a user-defined -# `prefix`. At request time, model_id `/` routes to that -# node's baseUrl, with auth from a connection of the node's provider type. -# -# We mirror settings.custom_providers[] into 9Router with prefix `cp-`, -# letting us address each provider as `cp-/` without colliding -# with the user's primary OpenAI key (different provider type). +# 9Router exposes arbitrary OpenAI-compatible endpoints via "provider nodes" +# (POST /api/provider-nodes, type="openai-compatible"). model_id / +# routes to that node's baseUrl. We mirror settings.custom_providers[] with +# prefix `cp-` so they don't collide with the user's primary OpenAI key. NINE_ROUTER_CUSTOM_NAME_SUFFIX = " (OpenSwarm-managed)" @@ -711,19 +677,14 @@ async def sync_custom_providers(providers: list) -> None: seen_prefixes: set[str] = set() for cp in providers or []: - # Tolerate both Pydantic instances and plain dicts. name = getattr(cp, "name", None) or (cp.get("name") if isinstance(cp, dict) else None) or "" base_url = getattr(cp, "base_url", None) or (cp.get("base_url") if isinstance(cp, dict) else None) or "" 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-compatible servers (LM Studio, Ollama, vLLM, llama.cpp, - # text-generation-webui, etc.) ship with auth disabled by default — - # they ignore the Authorization header entirely. But 9Router still - # creates the connection with `authType: "apikey"` and would send a - # blank Bearer if api_key is "", which some servers reject as a - # malformed header. Substitute a harmless placeholder when blank; - # servers that DO require auth always have api_key set anyway. + # 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 = _custom_provider_slug(name) prefix = f"cp-{slug}" @@ -765,7 +726,6 @@ async def sync_custom_providers(providers: list) -> None: logger.warning(f"9Router custom node {prefix} sync failed: {e}") continue - # Ensure a connection exists for this provider node carrying the apikey. try: existing_conn = await _find_keyed_connection(node_id, managed_name) conn_payload = { @@ -793,8 +753,7 @@ async def sync_custom_providers(providers: list) -> None: except Exception as e: logger.warning(f"9Router custom connection {prefix} sync failed: {e}") - # Drop managed nodes that no longer correspond to any settings entry. - # DELETE on a node cascades to its connections. + # Drop managed nodes no longer in settings; DELETE cascades to connections. for prefix, node in managed_by_prefix.items(): if prefix in seen_prefixes: continue @@ -818,13 +777,13 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str path for openswarm-pro users, so the search fails with "no credentials for provider: claude". With this sync, 9Router sees the OpenSwarm-Pro-backed Claude connection and routes the search - call through our cloud — same quota the user's Pro subscription + call through our cloud; same quota the user's Pro subscription already covers, no extra cost.""" if not is_running(): return # 9Router's POST /api/providers only accepts direct-API provider ids - # for apikey auth — `claude` is the subscription/IDE id, `anthropic` + # 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: @@ -865,31 +824,15 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str logger.warning(f"9Router OpenSwarm-Pro Claude sync failed: {e}") -# --------------------------------------------------------------------------- -# Per-provider OAuth redirect URIs -# --------------------------------------------------------------------------- -# -# Each upstream OAuth client is registered with the identity provider against -# a specific redirect URI. Anthropic's Claude Code client is lenient — any -# `http://localhost:*/callback` works — so we can use 9Router's built-in -# callback page at port 20128 for it. OpenAI's Codex client is NOT: it's -# registered with `http://localhost:1455/auth/callback` and OpenAI rejects -# any other redirect_uri with `unknown_error` at the auth page. Google's -# Gemini CLI client accepts arbitrary localhost URIs so we keep 20128 there. -# -# For Codex specifically we spawn a one-shot HTTP listener on port 1455 -# below that serves a callback page mirroring 9Router's callback page — -# postMessage to window.opener, BroadcastChannel fan-out, then close. This -# lets the frontend reuse its existing Claude/Anthropic flow unchanged -# (window.open popup + postMessage handler in Settings.tsx). +# 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. _CODEX_CALLBACK_PORT = 1455 _CODEX_CALLBACK_PATH = "/auth/callback" - -# Minimal callback page inlined as bytes. Mirrors 9router/src/app/callback/ -# page.js:27-55 — posts the OAuth data to window.opener via postMessage, -# BroadcastChannel, and localStorage so whatever detection path the caller -# is using will fire. Served to the Electron popup that OAuth redirects to. _CODEX_CALLBACK_HTML = b""" Authorization Complete