Files
openswarm/backend/apps/settings/settings.py
T
3008f083ca [aidan] feat: scheduled tasks (#105)
* [eric] ci: gitleaks-ignore the known historical secrets so our branch stops failing on leaks it didnt add

* [eric] workflows: restore scheduled-tasks on the workflow line (revert removal, keep windows fixes + 1.1.69)

* [eric] workflows: re-apply uncommitted scheduling wip (schedule pill, calendar view, slice)

* [eric] ops: gitignore dev-team local state files

* [eric] ops: backlog item for download-tracking visibility

* [eric] ci: allowlist the cdp-routes redaction-test token in gitleaks

* [aidan] feat/scheduled-tasks: keep step labels in sync on edit and show chevron on every step

* [aidan] fix: schedule time in chat

* [aidan] ux/workflows: add workflow step removal (#91)

* [aidan] feat/scheduled-tasks: remember workflow tool permissions across runs

* [aidan] feat/task-scheduling: add hourly and minute (15-min minimum) schedule intervals (#93)

* [aidan] feat/scheduled-tasks: calendar, rename, and edit workflows (#94)

* [aidan] bug: fix schedule button

* [aidan] fix/agent-errors: surface provider rate limits

* [aidan] ux/cards: click-to-rename for chat and workflow titles

Single-click a card's title to enter edit mode inline. Commit on Enter/blur,
cancel on Escape. Rename persists via PATCH for workflows and sessions.

* [aidan] feat/workflows: seed build prompt for zero-step workflows

When a new workflow has no steps, seed the agent with a prompt asking
the user to describe what the workflow should do, rather than starting blank.

* [aidan] feat/workflows: add-to-schedule popover for unscheduled workflows

Clicking the "+" on an unscheduled workflow row opens a popover with two options:
- Keep this schedule: enables the workflow's existing cadence and moves it to Scheduled
- Change schedule: opens the scheduling editor to pick a different time

* [aidan] ux/workflows: wire add-to-schedule popover and simplify New button

- Made the "+" icon on unscheduled workflow rows clickable, opening a popover
  to keep or change the schedule
- Removed AddIcon from toolbar "New" button (now reads "New" instead of "+ New")

* [aidan] fix/scheduled-tasks: open schedule calendar when Schedule pill clicked

Fixed the Schedule pill click being swallowed by the toolbar's dismiss handler.
Exempted the toolbar pills via data-toolbar-pills so their click handlers fire.

* [aidan] ux/workflows: open New workflow in agent build chat instead of empty card

When creating a new workflow from the hub, open it in edit_agent view (with the
agent builder chat) instead of a preview card. The workflow is created on the
backend first so the embedded session has a real ID.

* [aidan] feat/workflow-edit: add draft testing save flow

* [aidan] ux/chat: remove continue chat button

* [aidan] ux/workflows: polish workflow card interactions

* [aidan] fix/workflow-scheduling: save unscheduled workflows as drafts

* aidan ui: schedule naming changes

* [aidan] ui: tool calling desc/naming

* [aidan] ui: calendar sidebar naming

* [aidan] ui: fix stop viewing closing chat

* [aidan] feat/workflows: auto-name workflows and polish the build flow (#95)

* [aidan] feat/workflow-auto-naming: auto-generate workflow titles from steps

Generate a title + description from a workflow's steps (one aux call,
reused for step labels) whenever it is still auto_named, so a workflow
built in the Edit Agent names itself on commit instead of staying
"New workflow". A manual rename sets auto_named=False and is never
overwritten. Stream the aux call (non-streaming drops content on some
9router lanes) and fall back to a step-derived title when the model is
unavailable.

* [aidan] feat/workflows: hide unsaved new workflows until first save

A brand-new "+ New" workflow is created with unsaved=true and kept out
of the hub's scheduled/unscheduled lists while the user is still building
it in the Edit Agent. The first commit (Save) clears the flag and the
workflow appears. Every other create path stays visible immediately.

* [aidan] ux/workflows: remove redundant save workflow button

The Edit Agent already has Discard/Save controls in its strip, so the
header "Save Workflow" button was a duplicate save path. Remove it and
its pulse/edit-session-id wiring; the model/time subtitle stays.

* [aidan] ux/workflows: animate title on auto-rename

Wrap the workflow card title in the same Typewriter the chat card uses,
so when the auto-generated name replaces the placeholder after Save it
retypes letter-by-letter. Gated on a real (non-placeholder) title so it
never animates on mount or for already-named workflows.

* [aidan] ux/workflows: animate sidebar title on auto-rename

Wrap the calendar hub's sidebar row title in the same Typewriter the
workflow card uses, so a title that auto-renames retypes letter-by-letter
in the sidebar too. Extract the placeholder/isRealTitle guard into the
shared workflowVisuals so the card and sidebar stay in sync.

* [aidan] fix/workflows: connect watch tether, keep watched chat open, wire draft run/history

* [aidan] ui: grey out chat pill when not selected

* [aidan] ui: fix running agent display

* [aidan] feat/history-popover: add chat history and scheduled tasks run log tabs (#96)

* [aidan] ux/schedule: toast when calendar view already open on expand

* [aidan] ui: fix popover descs

* [aidan] feat/workflow-runs: add pause, resume, and stop controls for live runs

* [aidan] feat/schedule-calendar: add calendar occurrences endpoint and concrete timezones

* [aidan] feat/workflows: require at least one step to save a workflow

* [aidan] ux/edit-agent: hide Discard for an unsaved new workflow

* [aidan] ux/edit-agent: move fix-prefix card below the step list

* [aidan] feat/workflows: toast when an unattended scheduled run starts

* [aidan] fix/dashboard-tethers: anchor workflow-sidecar tethers to measured card rects

* [aidan] feat/workflows: validate steps before scheduling and keep chat tool memory

* [aidan] feat/mcp-suggestions: dismissable integration banner with per-session cooldown

* [aidan] feat/workflows: add scheduled-run "running now" toast with click-to-view (#97)

* [aidan] ux/workflows: surface paused state on card, sidebar, and calendar; tidy run history

* [aidan] feat/mcp-suggestions: suggest both Google and Microsoft when provider is ambiguous

* [aidan] fix/agent-tokens: friendly out-of-tokens card across all agent surfaces

* [aidan] feat/workflow-model: persist edit-agent model on save with switch notice and fresh drafts

* [aidan] fix/workflow-chat: force stop on watched run mirrors workflow card stop

* [aidan] fix/workflow-cards: keep watched run tethered on finish to avoid duplicate chat

* [aidan] feat/schedule-calendar: mark current time with a now line in week view

* [aidan] refactor/private-names: rename error and schedule classifiers from _ to p_

* [aidan] feat/scheduled-tasks: agent workflow scheduling and in-chat convert (#98)

* [aidan] feat/workflow-suggest: nudge user to convert repeatable chat to workflow

Add SuggestConvertToWorkflow MCP tool that agents call at the end of a task
when they've completed something worth repeating (daily report, weekly check,
recurring data pull). Frontend detects the tool call and glows the "Convert to
workflow" button 3 times to draw the eye. When user clicks it, the suggested
cadence (e.g. "every weekday at 9am") is stored in the draft and seeded into
the scheduling agent's first prompt, so the agent can act on the suggestion
rather than asking the user again.

Tool is never auto-called — agents decide when a task is genuinely repeatable
(not debugging, creative work, one-off lookup). Tool description emphasizes
sparse, high-confidence use only (once per session max).

Files changed:
- backend/apps/agents/schedule_mcp_server.py: add SuggestConvertToWorkflow tool
- frontend/src/shared/mcpToolMeta.ts: add label for new tool
- frontend/src/app/pages/Dashboard/cards/AgentCard.tsx: detect suggestion in
  session messages, show+glow "Convert to workflow" button, pass cadence to draft
- frontend/src/shared/state/workflowsSlice.ts: add suggested_cadence field to
  Workflow interface
- frontend/src/app/pages/Workflows/SchedulingView.tsx: seed scheduling agent
  prompt with suggested cadence hint

* [aidan] feat/agent-scheduling: route recurring asks through native workflows, deny claude cron skill

* [aidan] feat/workflow-convert: in-chat convert popup and auto-open scheduled workflow card

* [aidan] ux/calendar-page: schedule calendar restyle + popover fixes (#99)

* [aidan] fix/dashboard-delete: remove workflows calendar panel on delete key

* [aidan] ux/workflows-calendar: restyle hub, fix today highlight, add toolbar toggle

* [aidan] ux/schedule-popover: compact density, fix sticky header bleed, add header spacing

* [aidan] ux/schedule-calendar: hollow ring dot for past fires in month view

* [aidan] ux/schedule-calendar: clickable +N more opens day's full run list

* [aidan] feat/run-log-filters: add success and skipped pills to scheduled task history

* [aidan] fix/convert-button: stop drag capture so convert-to-workflow click fires

* [aidan] ux/calendar-card: match border color and radius to chat and workflow cards

* [aidan] fix/minimap: render missed-runs card on the minimap

* [aidan] ux/run-sparkline: simplify tooltip to plain run tally

* [aidan] ux/run-history: collapse expanded run view to one clickable line

* [aidan] ux/calendar-card: match corner radius to browser cards

* [aidan] feat/workflows: launch-time scheduling UX and workflow-card polish (#101)

* [aidan] feat/schedule-list: lazy-load list view via scroll sentinel

* [aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card

* [aidan] fix/dashboard-tethers: keep watching line anchored on canvas zoom

* [aidan] feat/scheduled-tasks: review missed runs at launch instead of auto-firing on_missed

* [aidan] refactor/workflow-cards: use radius and status design tokens, polish card chrome

* [aidan] ux/agent-card: keep convert-to-workflow visible during runs with mid-turn toast

* [aidan] ux/mcp-bubble: drop redundant verb label when a workflow label is shown

* [aidan] chore/backend: remove stale explanatory comments

* [aidan] fix/workflows-hub: load workflows on hub mount so calendar fills at launch

* [aidan] feat/workflows: generate title, description, step labels at convert time

* [aidan] fix/tidy-layout: include workflows hub in tidy and fit-to-view

* [aidan] feat/schedule-list: window long list via measured-height virtualizer

* [aidan] ux/workflows-hub: remove time-saved badge from calendar header

* [aidan] fix/types: add missing semantic-type labels and drop stray fade arg

* [aidan] feat/schedule: pin monthly day-of-month and honor repeat-every intervals

* [aidan] feat/schedule: inherit source-session tool surface for scheduled runs

* [aidan] ux/calendar: restack hour-cell events as bars with overflow affordance

* [aidan] feat/calendar: open the run card when clicking a scheduled occurrence

* [aidan] ux/missed-runs: add per-group select-all toggle and rename skip action

* [aidan] feat: new scheduled task design ported

* [aidan] ui: sidebar reorder, repeat controls on schedule card

* [aidan] ui: sidebar, scheduling time

* [aidan] feat/schedule: pin monthly last-day-of-month

* [aidan] feat/steps: per-step enable toggle

* [aidan] feat/workflows: per-workflow color swatch

* [aidan] feat/trash: soft-delete workflows with restore and purge

* [aidan] feat/run-monitor: live run monitor card on the canvas

* [aidan] feat/run-context: attach a run as removable chat context

* [aidan] feat/compose: new-workflow landing page and auto-commit build flow

* [aidan] ui/workflows: dark mode and design-system cohesion

* [aidan] ui/calendar: overflow popover, condensed week view, scroll fix

* [aidan] feat/home: ongoing runs, missed review, and accurate Coming-up counts

* [aidan] fix/run-status: sync ongoing runs and heal stuck/interrupted runs

* [aidan] ux/schedule: last-day-of-month UI, Run-at time typing, interval input

* [aidan] ux/workflows: default window size and toolbar icon

* [aidan] fix/schedule: measure ran_late from start and anchor recurrences to created_at

* [aidan] feat/calendar: render fire times from backend, drop JS recurrence reimpl

* [aidan] chore/dashboard: drop dead configure/missed-run cards, refetch on reconnect

* [aidan] chore/agent-card: remove unreachable convert-to-workflow action

* [aidan] fix/workflows: don't bump updated_at on a no-op draft commit so viewing a workflow doesn't reorder the sidebar

* [aidan] feat/schedule: warn when scheduling a workflow that has no steps

* [aidan] fix/selection-tool: never select the workflows app, and exit the tool on Escape without dropping selections

* [aidan] ux/compose: diversify new-workflow starter prompts across personas

* [aidan] ux/run-monitor: spawn the run card a bit farther right of the workflows app

* [aidan] fix/schedule: harden run recovery and storage writes against crashes

* [aidan] ux/compose: restyle new-workflow starters as a clean pill cluster with rich prompts

* [aidan] fix/workflows: optimistically apply edits so the schedule banner updates instantly

* [aidan] ui/workflows: three-tone surface depth so the window lifts off the canvas in both themes

* [aidan] ui/workflows: close buttons turn red on hover, matching the chat card

* [aidan] test/schedule: cover executor pipeline, storage durability, and recurrence gaps

* [aidan] fix: remove package-lock json

* [aidan] fix/workflows-compose: keep compose view until edit agent replies

* [aidan] feat/workflows: auto-generate workflow + step titles with typewriter animation

* [eric] deps: restore frontend/package-lock.json (PR #105 deletion broke npm ci)

---------

Co-authored-by: Eric <ciregenz@berkeley.edu>
Co-authored-by: cire <134991075+ciregenz@users.noreply.github.com>
2026-06-24 18:58:43 -07:00

743 lines
31 KiB
Python

import asyncio
import os
import tempfile
import time
import logging
from datetime import datetime, timezone
from contextlib import asynccontextmanager
from fastapi import HTTPException, Query, UploadFile, File
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import Literal, Optional
from backend.config.Apps import SubApp
from backend.apps.settings.models import AppSettings, DEFAULT_SYSTEM_PROMPT
from backend.apps.settings.store import (
DATA_DIR,
SETTINGS_FILE,
load_settings,
save_settings,
atomic_write_settings,
migrate_legacy_fields,
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def settings_lifespan():
os.makedirs(DATA_DIR, exist_ok=True)
try:
from backend.apps.nine_router import (
ensure_running as p_9r_ensure,
is_running as p_9r_running,
sync_gemini_api_key,
sync_openai_api_key,
sync_openrouter_api_key,
sync_openswarm_pro_as_claude,
sync_custom_providers,
)
s = load_settings()
import asyncio as p_asyncio
async def p_boot_router_then_sync():
"""Boot 9Router then push key-based connections (sequential: sync helpers no-op pre-boot)."""
needs_router = any([
getattr(s, "google_api_key", None),
getattr(s, "openai_api_key", None),
getattr(s, "openrouter_api_key", None),
getattr(s, "connection_mode", None) in ("openswarm-pro", "free-trial"),
bool(getattr(s, "custom_providers", None) or []),
])
if needs_router:
try:
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).
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.
if getattr(s, "connection_mode", None) in ("openswarm-pro", "free-trial"):
from backend.apps.settings.credentials import proxy_auth
bearer, base = proxy_auth(s)
else:
bearer, base = None, None
await sync_openswarm_pro_as_claude(bearer, base)
await sync_custom_providers(getattr(s, "custom_providers", None) or [])
p_asyncio.create_task(p_boot_router_then_sync())
p_asyncio.create_task(p_upload_dir_gc_loop())
except Exception as e:
logger.warning(f"9Router sync startup failed: {e}")
yield
async def p_upload_dir_gc_loop():
"""Daily GC of UPLOAD_DIR. Without this, every PDF/image the user
drops sits in the OS temp dir forever, growing unbounded across
sessions. We keep files for 7 days to make resume-after-restart
work, then delete. macOS temp under /var/folders/... is auto-purged
by the OS but not aggressively; Windows temp is not. Belt and braces.
Errors are swallowed: a chmod hiccup or in-use lock should never
crash the backend."""
import asyncio as p_a
while True:
try:
now = time.time()
cutoff = now - 7 * 86400
if os.path.isdir(UPLOAD_DIR):
for entry in os.listdir(UPLOAD_DIR):
p = os.path.join(UPLOAD_DIR, entry)
try:
if os.path.isfile(p) and os.path.getmtime(p) < cutoff:
os.remove(p)
except Exception:
continue
except Exception:
pass
await p_a.sleep(24 * 3600)
settings = SubApp("settings", settings_lifespan)
async def save_settings_async(settings_obj: AppSettings) -> None:
"""Async atomic save via thread pool; shares the lock with the sync variant."""
payload = settings_obj.model_dump()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, atomic_write_settings, payload)
@settings.router.get("")
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.
SERVER_OWNED_FIELDS = (
"connection_mode",
"openswarm_bearer_token",
"openswarm_proxy_url",
"openswarm_subscription_plan",
"openswarm_subscription_expires",
"openswarm_usage_cached",
"free_trial_token",
"free_trial_remaining",
"free_trial_runs_limit",
"free_trial_resets_at",
"user_id",
"signin_method",
"installation_id",
"analytics_token",
"timezone",
"locale",
"claude_subscription_token",
"openai_subscription_token",
"gemini_subscription_token",
)
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.
p_settings_write_locks: "_weakref.WeakKeyDictionary" = p_weakref.WeakKeyDictionary()
def settings_write_lock() -> asyncio.Lock:
loop = asyncio.get_running_loop()
lock = p_settings_write_locks.get(loop)
if lock is None:
lock = asyncio.Lock()
p_settings_write_locks[loop] = lock
return lock
@settings.router.put("")
async def update_settings(body: AppSettings):
async with settings_write_lock():
saved = await apply_settings_update(body)
return {"ok": True, "settings": saved.model_dump()}
@settings.router.patch("")
async def patch_settings(changes: dict):
"""Save only the fields the user changed, merged onto the CURRENT on-disk
state. The renderer sends a diff (not a stale full object), so a save can't
clobber a field something else, an agent, an OAuth connect, changed
underneath it. Makes the lost-update unrepresentable: you can't overwrite a
field you never sent."""
async with settings_write_lock():
saved = await apply_settings_patch(changes)
return {"ok": True, "settings": saved.model_dump()}
async def apply_settings_patch(changes: dict) -> AppSettings:
"""Merge `changes` onto fresh on-disk settings and persist. Caller holds
settings_write_lock so the read is current. Reuses apply_settings_update for
every side effect: the object it hands over IS current state plus the diff,
which is exactly what a non-clobbering save means."""
valid = set(AppSettings.model_fields.keys())
data = load_settings().model_dump()
for k, v in changes.items():
if k in valid:
data[k] = v
return await apply_settings_update(AppSettings(**data))
async def apply_settings_update(body: AppSettings, protect_fields: set[str] | None = None) -> AppSettings:
"""Persist a full settings object with all the safety side effects: restore
server-owned fields, hand the wheel back from the free trial when a real
model is connected, reconcile 9router provider connections, and sync
analytics/identity. The PUT route and the agent settings tool both call this
so the write semantics can't drift between them. Returns the saved body.
Caller must hold settings_write_lock. `protect_fields` names credential fields
that must never be blanked by this write (the agent tool passes the field
powering the live run): a SECOND, independent wall behind the endpoint's
suicide-guard, so a guard bug still can't disconnect a run."""
from backend.apps.service.client import sync as p_sync
old = load_settings()
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.
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 getattr(old, "connection_mode", "own_key") == "free-trial":
from backend.apps.subscription.free_trial import has_own_model
if has_own_model(body):
body.connection_mode = "own_key"
body.free_trial_token = None
body.free_trial_remaining = None
try:
import asyncio as p_aio
from backend.apps.nine_router import sync_pro_routing as p_spr
p_aio.create_task(p_spr(body)) # drop the now-stale free-trial 9router node
except Exception:
pass
secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
"openswarm_bearer_token", "free_trial_token", "installation_id", "analytics_token"}
safe = {k: v for k, v in body.model_dump().items() if k not in secret_keys}
p_sync(safe)
if (body.user_email and body.user_email != getattr(old, "user_email", None)) or \
(body.user_name and body.user_name != getattr(old, "user_name", None)):
from backend.apps.service.client import identify as p_identify
id_props = {}
if body.user_email:
id_props["email"] = body.user_email
if body.user_name:
id_props["name"] = body.user_name
if body.user_use_case:
id_props["use_case"] = body.user_use_case
if body.user_referral_source:
id_props["referral_source"] = body.user_referral_source
if id_props:
p_identify(id_props)
if body.user_email:
from backend.apps.service.analytics.client import track_link_email
track_link_email(body.user_email)
await save_settings_async(body)
google_changed = (
getattr(body, "google_api_key", None) != getattr(old, "google_api_key", None)
)
openai_changed = (
getattr(body, "openai_api_key", None) != getattr(old, "openai_api_key", None)
)
openrouter_changed = (
getattr(body, "openrouter_api_key", None) != getattr(old, "openrouter_api_key", None)
)
custom_providers_changed = (
[cp.model_dump() for cp in (getattr(body, "custom_providers", None) or [])]
!= [cp.model_dump() for cp in (getattr(old, "custom_providers", None) or [])]
)
any_keyed_added = (
(getattr(body, "google_api_key", None) and not getattr(old, "google_api_key", None))
or (getattr(body, "openai_api_key", None) and not getattr(old, "openai_api_key", None))
or (getattr(body, "openrouter_api_key", None) and not getattr(old, "openrouter_api_key", None))
or (
bool(getattr(body, "custom_providers", None) or [])
and not bool(getattr(old, "custom_providers", None) or [])
)
)
if openrouter_changed:
try:
from backend.apps.agents.providers.registry import invalidate_openrouter_cache
invalidate_openrouter_cache()
except Exception:
pass
# Off the request path: ensure_running() can take 5min on first install (npm pull) and would freeze the loop.
if google_changed or openai_changed or openrouter_changed or custom_providers_changed:
async def p_boot_and_sync_keys(
google_key: str | None,
openai_key: str | None,
openrouter_key: str | None,
custom_providers: list,
do_google: bool,
do_openai: bool,
do_openrouter: bool,
do_custom: bool,
need_boot: bool,
):
try:
from backend.apps.nine_router import (
ensure_running as p_9r_ensure,
is_running as p_9r_running,
sync_gemini_api_key,
sync_openai_api_key,
sync_openrouter_api_key,
sync_custom_providers,
)
if need_boot and not p_9r_running():
await p_9r_ensure()
if do_google:
await sync_gemini_api_key(google_key or None)
if do_openai:
await sync_openai_api_key(openai_key or None)
if do_openrouter:
await sync_openrouter_api_key(openrouter_key or None)
if do_custom:
await sync_custom_providers(custom_providers or [])
except Exception as e:
logger.warning(f"Background apikey sync failed: {e}")
asyncio.create_task(p_boot_and_sync_keys(
getattr(body, "google_api_key", None),
getattr(body, "openai_api_key", None),
getattr(body, "openrouter_api_key", None),
getattr(body, "custom_providers", None) or [],
google_changed,
openai_changed,
openrouter_changed,
custom_providers_changed,
any_keyed_added,
))
return body
class AppThemeOverridePayload(BaseModel):
mode: Optional[Literal["light", "dark"]] = None
@settings.router.get("/app-theme-override")
async def get_app_theme_override():
"""Cross-app theme preference for App Builder workspaces; backend-held because each app uses its own localStorage origin."""
return {"mode": load_settings().app_template_theme_override}
@settings.router.put("/app-theme-override")
async def put_app_theme_override(body: AppThemeOverridePayload):
"""MERGE the override; the general PUT /api/settings replaces the whole object and would blank secrets, logging the user out."""
current = load_settings()
current.app_template_theme_override = body.mode
await save_settings_async(current)
return {"ok": True, "mode": current.app_template_theme_override}
class DismissMcpSuggestionPayload(BaseModel):
ids: list[str]
@settings.router.put("/dismiss-mcp-suggestion")
async def put_dismiss_mcp_suggestion(body: DismissMcpSuggestionPayload):
current = load_settings()
now = datetime.now(timezone.utc).isoformat()
for tool_id in body.ids:
current.dismissed_mcp_suggestions[tool_id] = now
await save_settings_async(current)
return {"ok": True, "settings": current.model_dump()}
@settings.router.get("/default-system-prompt")
async def get_default_system_prompt():
return {"default_system_prompt": DEFAULT_SYSTEM_PROMPT}
@settings.router.post("/reset-system-prompt")
async def reset_system_prompt():
current = load_settings()
current.default_system_prompt = DEFAULT_SYSTEM_PROMPT
await save_settings_async(current)
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.
P_RESET_PRESERVE_FIELDS = SERVER_OWNED_FIELDS + (
"anthropic_api_key",
"openai_api_key",
"google_api_key",
"openrouter_api_key",
"custom_providers",
"user_name",
"user_email",
"analytics_opt_in",
"first_opened_at",
)
@settings.router.post("/reset-to-defaults")
async def reset_to_defaults():
old = load_settings()
fresh = AppSettings()
for k in P_RESET_PRESERVE_FIELDS:
setattr(fresh, k, getattr(old, k, None))
await save_settings_async(fresh)
return {"ok": True, "settings": fresh.model_dump()}
class BrowseResponse(BaseModel):
current: str
parent: Optional[str]
directories: list[str]
files: list[str]
UPLOAD_DIR = os.path.join(tempfile.gettempdir(), "self-swarm-uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
def sniff_file_kind(contents: bytes, name: str) -> tuple[str, str | None]:
"""Classify an uploaded file as text/pdf/image/binary so the agent
layer can route it (inline as text, send as native document/image
block, or refuse). Returns (kind, media_type)."""
head = contents[:4096]
if head.startswith(b"%PDF-"):
return ("pdf", "application/pdf")
if head.startswith(b"\x89PNG\r\n\x1a\n"):
return ("image", "image/png")
if head.startswith(b"\xff\xd8\xff"):
return ("image", "image/jpeg")
if head.startswith(b"GIF87a") or head.startswith(b"GIF89a"):
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).
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
head.startswith(b"\xfe\xed\xfa\xce") or head.startswith(b"\xce\xfa\xed\xfe") or
head.startswith(b"\xfe\xed\xfa\xcf") or head.startswith(b"\xcf\xfa\xed\xfe") or
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).
if b"\x00" in head:
return ("binary", None)
try:
head.decode("utf-8")
return ("text", "text/plain")
except UnicodeDecodeError:
return ("binary", None)
def estimate_pdf_tokens(contents: bytes) -> int:
"""Conservative PDF token estimate without a parser dep.
We use two signals and take the MAX so the chip + dry-run never
under-report:
1) Page count from the PDF catalog (regex over /Type /Pages /Count
then a fallback for /Count just before /Kids). When found, we
estimate 750 tokens/page, a fair midpoint between dense academic
papers (~1200) and sparse decks (~300).
2) Byte-size heuristic. PDFs compress text and embed images; the
actual token cost on Anthropic's vision tier scales with file
size. ~1 token per 80 bytes is conservative.
Taking max() means a small page count on a huge PDF (image-heavy)
still reads as expensive, and a huge page count on a small PDF still
reads as expensive. The chip never lies that an attachment is cheap."""
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.
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.
m = p_re.search(rb"/Pages[^>]{0,200}?/Count\s+(\d+)", contents, p_re.DOTALL)
if m:
pages = int(m.group(1))
if 0 < pages < 10_000:
by_pages = pages * 750
except Exception:
pass
by_bytes = max(1_000, min(len(contents) // 80, 2_000_000))
return max(by_pages, by_bytes)
@settings.router.post("/upload-files")
async def upload_files(files: list[UploadFile] = File(...)):
"""Accept dropped files, sniff their kind, save them, and return
server-side paths + a `kind` + `tokens` estimate per file.
The chat UI uses `tokens` for the per-chip chip count and the pre-send
dry-run guard; it uses `kind` to decide whether the file routes as
inline text, a native document block (PDF on Anthropic/Gemini), an
image block (vision-capable models), or gets refused (other binary,
until we add Files API support).
Estimates per kind:
- text: char/4 of the actually-readable text (capped at 512KB)
- pdf: page-count * 750 (conservative; real text-heavy PDFs run
~500-1200 tokens/page)
- image: 1500 (Anthropic's per-image baseline; varies by size)
- binary: 0 (refused at agent time, won't enter context)
"""
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.
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.
base, ext = os.path.splitext(safe_name)
dest = os.path.join(UPLOAD_DIR, safe_name)
counter = 0
fd = None
while fd is None:
try:
fd = os.open(dest, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError:
counter += 1
if counter > 10_000:
raise HTTPException(status_code=500, detail="upload dedup exhausted")
dest = os.path.join(UPLOAD_DIR, f"{base}_{counter}{ext}")
try:
with os.fdopen(fd, "wb") as fh:
fh.write(contents)
except Exception:
try:
os.remove(dest)
except Exception:
pass
raise
kind, media_type = sniff_file_kind(contents, safe_name)
if kind == "text":
try:
with open(dest, "r", errors="replace") as fh:
txt = fh.read(512_000)
tokens_est = max(0, len(txt) // 4)
except Exception:
tokens_est = min(len(contents), 512_000) // 4
elif kind == "pdf":
tokens_est = estimate_pdf_tokens(contents)
elif kind == "image":
tokens_est = 1_500
else:
tokens_est = 0
results.append({
"path": dest,
"name": safe_name,
"size": len(contents),
"tokens": tokens_est,
"kind": kind,
"media_type": media_type,
})
return JSONResponse({"files": results})
class p_SummarizeRequest(BaseModel):
path: str
target_tokens: int = 4_000
primary_model: Optional[str] = None
@settings.router.post("/summarize-file")
async def summarize_file(req: p_SummarizeRequest):
"""Compress an attached file down to a fact-dense summary the agent can
still reason over without paying the full token cost.
Called from the chat-input attach handler when one file alone would
exceed 50% of the selected model's context window. The summary is
written to a sibling file with `.summary.txt` suffix in UPLOAD_DIR so
the existing attachment plumbing (paths flow through context_paths)
works unchanged. Aux model picked via provider-agnostic
resolve_aux_model, so users on OpenAI/Gemini/OpenRouter get summarized
via their own provider's cheap tier (never hardcoded to Haiku).
"""
src = req.path
if not os.path.isfile(src):
raise HTTPException(status_code=404, detail="file not found")
if not os.path.commonpath([os.path.realpath(src), os.path.realpath(UPLOAD_DIR)]) == os.path.realpath(UPLOAD_DIR):
raise HTTPException(status_code=400, detail="path outside upload dir")
try:
with open(src, "r", errors="replace") as fh:
raw = fh.read(2_000_000)
except Exception as e:
raise HTTPException(status_code=500, detail=f"read failed: {e}")
if (len(raw) // 4) <= max(1, req.target_tokens):
return JSONResponse({"path": src, "tokens": len(raw) // 4, "size": len(raw), "summarized": False})
try:
from backend.apps.agents.providers.registry import resolve_aux_model, get_api_type
from backend.apps.settings.credentials import get_anthropic_client_for_model
s = load_settings()
aux_model, p_base = await resolve_aux_model(
s,
preferred_tier="haiku",
primary_api=get_api_type(req.primary_model) if req.primary_model else None,
)
client = get_anthropic_client_for_model(s, aux_model)
system = (
"You compress a document into a fact-dense summary while preserving every "
"specific entity, number, date, quote, code identifier, and decision. Use "
"short bullets grouped by section. Never invent. If a section is unclear, "
"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.
CHUNK_CHARS = 200_000
is_chunked = len(raw) > CHUNK_CHARS
async def p_summarize_block(text: str, target_tokens: int, label: str) -> str:
user = (
f"Target length: ~{target_tokens} tokens.\n\n"
f"<document path=\"{label}\">\n{text}\n</document>\n\n"
"Summary:"
)
resp = await client.messages.create(
model=aux_model,
max_tokens=min(8_192, max(512, target_tokens + 1_024)),
system=system,
messages=[{"role": "user", "content": user}],
)
out = ""
for b in (getattr(resp, "content", None) or []):
t = getattr(b, "text", None)
if isinstance(t, str) and t:
out += t
if not out.strip():
raise RuntimeError("empty summary from aux model")
return out
if not is_chunked:
summary = await p_summarize_block(raw, req.target_tokens, os.path.basename(src))
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.
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)
])
merge_input = "\n\n".join(f"## Part {i + 1}\n{p}" for i, p in enumerate(partials))
summary = await p_summarize_block(merge_input, req.target_tokens, f"merged summary of {os.path.basename(src)}")
except Exception as e:
raise HTTPException(status_code=502, detail=f"summarize failed: {e}")
base, p_ext = os.path.splitext(src)
dest = f"{base}.summary.txt"
counter = 1
while os.path.exists(dest):
dest = f"{base}.summary_{counter}.txt"
counter += 1
body = (
f"Summary of {os.path.basename(src)} "
f"(compressed from ~{len(raw) // 4} tokens to ~{len(summary) // 4} tokens)\n\n"
f"{summary}\n"
)
with open(dest, "w") as fh:
fh.write(body)
return JSONResponse({
"path": dest,
"tokens": len(body) // 4,
"size": len(body),
"summarized": True,
})
@settings.router.get("/browse-directories")
async def browse_directories(path: str = Query(default="")) -> BrowseResponse:
target = path.strip() if path.strip() else os.path.expanduser("~")
target = os.path.expanduser(target)
target = os.path.abspath(target)
if not os.path.exists(target):
raise HTTPException(status_code=404, detail=f"Path not found: {target}")
if not os.path.isdir(target):
raise HTTPException(status_code=400, detail=f"Not a directory: {target}")
try:
entries = sorted(os.listdir(target))
except PermissionError:
raise HTTPException(status_code=403, detail=f"Permission denied: {target}")
visible = [e for e in entries if not e.startswith(".")]
directories = [e for e in visible if os.path.isdir(os.path.join(target, e))]
files = [e for e in visible if os.path.isfile(os.path.join(target, e))]
parent = os.path.dirname(target) if target != "/" else None
return BrowseResponse(current=target, parent=parent, directories=directories, files=files)