refactor(tui_gateway): table-drive billing error kinds, share pet selection, compact group H docstrings
This commit is contained in:
@@ -23,28 +23,20 @@ def _serialize_billing_error(exc) -> dict:
|
||||
"""Map a BillingError into the result.error envelope the TUI branches on."""
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingRemoteSpendingRevoked, BillingScopeRequired, BillingSessionRevoked, BillingTransient)
|
||||
kind = "error"
|
||||
if isinstance(exc, BillingRemoteSpendingRevoked):
|
||||
kind = "remote_spending_revoked"
|
||||
elif isinstance(exc, BillingSessionRevoked):
|
||||
kind = "session_revoked"
|
||||
elif isinstance(exc, BillingScopeRequired):
|
||||
kind = "insufficient_scope"
|
||||
elif isinstance(exc, BillingTransient):
|
||||
kind = str(exc.error) if getattr(exc, "error", None) else "rate_limited"
|
||||
elif getattr(exc, "error", None):
|
||||
kind = str(exc.error)
|
||||
typed = {BillingRemoteSpendingRevoked: "remote_spending_revoked",
|
||||
BillingSessionRevoked: "session_revoked", BillingScopeRequired: "insufficient_scope"}
|
||||
kind = next((k for cls, k in typed.items() if isinstance(exc, cls)), None)
|
||||
if kind is None:
|
||||
error = getattr(exc, "error", None)
|
||||
fallback = "rate_limited" if isinstance(exc, BillingTransient) else "error"
|
||||
kind = str(error) if error else fallback
|
||||
return {
|
||||
"ok": False,
|
||||
"error": kind,
|
||||
"message": str(exc),
|
||||
"ok": False, "error": kind, "message": str(exc),
|
||||
"portal_url": getattr(exc, "portal_url", None),
|
||||
"retry_after": getattr(exc, "retry_after", None),
|
||||
"payload": getattr(exc, "payload", {}) or {},
|
||||
# Remote-Spending contract extras (threaded so the TUI can render
|
||||
# actor-aware copy + route recovery without re-parsing the message).
|
||||
"actor": getattr(exc, "actor", None),
|
||||
"code": getattr(exc, "code", None),
|
||||
# Remote-Spending contract extras: the TUI renders actor-aware copy + recovery from these.
|
||||
"actor": getattr(exc, "actor", None), "code": getattr(exc, "code", None),
|
||||
"recovery": getattr(exc, "recovery", None)}
|
||||
|
||||
|
||||
@@ -55,7 +47,8 @@ def _serialize_payment_method(pm) -> dict | None:
|
||||
return None
|
||||
if pm.kind == "card":
|
||||
return {
|
||||
"kind": "card", "brand": pm.brand, "last4": pm.last4, "wallet": pm.wallet, "resolved_via": pm.resolved_via
|
||||
"kind": "card", "brand": pm.brand, "last4": pm.last4, "wallet": pm.wallet,
|
||||
"resolved_via": pm.resolved_via,
|
||||
}
|
||||
if pm.kind == "link":
|
||||
return {"kind": "link", "email": pm.email, "resolved_via": pm.resolved_via}
|
||||
@@ -87,13 +80,9 @@ def _serialize_billing_state(state) -> dict:
|
||||
card = None
|
||||
if state.card is not None:
|
||||
card = {
|
||||
"brand": state.card.brand,
|
||||
"last4": state.card.last4,
|
||||
"masked": state.card.masked,
|
||||
# None/False on older NAS payloads; resolved_via is the resolution
|
||||
# rung for rung-gated surfaces (/subscription confirm).
|
||||
"display": state.card.display,
|
||||
"resolved_via": state.card.resolved_via}
|
||||
"brand": state.card.brand, "last4": state.card.last4, "masked": state.card.masked,
|
||||
# None/False on older NAS payloads; resolved_via = rung for rung-gated surfaces.
|
||||
"display": state.card.display, "resolved_via": state.card.resolved_via}
|
||||
monthly_cap = None
|
||||
if state.monthly_cap is not None:
|
||||
mc = state.monthly_cap
|
||||
@@ -103,34 +92,25 @@ def _serialize_billing_state(state) -> dict:
|
||||
"spent_display": format_money(mc.spent_this_month_usd),
|
||||
"is_default_ceiling": mc.is_default_ceiling}
|
||||
return {
|
||||
"ok": True,
|
||||
"logged_in": state.logged_in,
|
||||
"org_name": state.org_name,
|
||||
"org_slug": state.org_slug,
|
||||
"role": state.role,
|
||||
"is_admin": state.is_admin,
|
||||
"can_change_plan": state.can_change_plan,
|
||||
"can_charge": state.can_charge,
|
||||
"ok": True, "logged_in": state.logged_in, "org_name": state.org_name,
|
||||
"org_slug": state.org_slug, "role": state.role, "is_admin": state.is_admin,
|
||||
"can_change_plan": state.can_change_plan, "can_charge": state.can_charge,
|
||||
"balance_usd": _wire_str(state.balance_usd),
|
||||
"balance_display": format_money(state.balance_usd),
|
||||
"cli_billing_enabled": state.cli_billing_enabled,
|
||||
"charge_presets": [_wire_str(p) for p in state.charge_presets],
|
||||
"charge_presets_display": [format_money(p) for p in state.charge_presets],
|
||||
"min_usd": _wire_str(state.min_usd),
|
||||
"max_usd": _wire_str(state.max_usd),
|
||||
"card": card,
|
||||
"payment_method": _serialize_payment_method(state.payment_method),
|
||||
"min_usd": _wire_str(state.min_usd), "max_usd": _wire_str(state.max_usd),
|
||||
"card": card, "payment_method": _serialize_payment_method(state.payment_method),
|
||||
"monthly_cap": monthly_cap,
|
||||
"auto_reload": _serialize_auto_reload(state.auto_reload, format_money),
|
||||
"portal_url": state.portal_url,
|
||||
"error": state.error,
|
||||
# Shared two-bar dollar usage model so /topup matches /usage and
|
||||
# /subscription from one fetch; fail-open.
|
||||
"portal_url": state.portal_url, "error": state.error,
|
||||
# Shared two-bar dollar usage model so /topup matches /usage and /subscription; fail-open.
|
||||
"usage": _usage_payload(state)}
|
||||
|
||||
|
||||
def _usage_payload(state) -> dict:
|
||||
"""Shared usage model for the /topup + /subscription bars: fetched only when logged in, fail-open."""
|
||||
"""Shared usage model for the /topup + /subscription bars: only when logged in, fail-open."""
|
||||
if not getattr(state, "logged_in", False):
|
||||
return {"available": False}
|
||||
try:
|
||||
@@ -152,7 +132,7 @@ def _serialize_usage_bar(bar) -> Optional[dict]:
|
||||
|
||||
|
||||
def _serialize_usage_model(model) -> dict:
|
||||
"""Serialize a UsageModel for the wire — the shared two-bar dollar view (fail-open {ok, available:false})."""
|
||||
"""Serialize a UsageModel for the wire — the shared two-bar dollar view (fail-open)."""
|
||||
from agent.billing_usage import _fmt_usd, format_renews
|
||||
if model is None or not getattr(model, "available", False):
|
||||
return {"ok": True, "available": False}
|
||||
@@ -160,17 +140,13 @@ def _serialize_usage_model(model) -> dict:
|
||||
def _usd(value):
|
||||
return None if value is None else _fmt_usd(value)
|
||||
return {
|
||||
"ok": True,
|
||||
"available": True,
|
||||
"status": model.status,
|
||||
"plan_name": model.plan_name,
|
||||
"ok": True, "available": True, "status": model.status, "plan_name": model.plan_name,
|
||||
"renews_at": model.renews_at,
|
||||
"renews_display": getattr(model, "renews_display", None) or format_renews(model.renews_at),
|
||||
"subscription_remaining_display": _usd(model.subscription_remaining_usd),
|
||||
"topup_remaining_display": _usd(model.topup_remaining_usd),
|
||||
"total_spendable_display": _usd(model.total_spendable_usd),
|
||||
"has_topup": model.has_topup,
|
||||
"plan_bar": _serialize_usage_bar(model.plan_bar),
|
||||
"has_topup": model.has_topup, "plan_bar": _serialize_usage_bar(model.plan_bar),
|
||||
"topup_bar": _serialize_usage_bar(model.topup_bar)}
|
||||
|
||||
|
||||
@@ -185,54 +161,39 @@ def _serialize_subscription_state(state) -> dict:
|
||||
current = {
|
||||
"tier_id": c.tier_id, "tier_name": c.tier_name,
|
||||
"monthly_credits": _wire_str(c.monthly_credits),
|
||||
"credits_remaining": _wire_str(c.credits_remaining),
|
||||
"cycle_ends_at": c.cycle_ends_at,
|
||||
"credits_remaining": _wire_str(c.credits_remaining), "cycle_ends_at": c.cycle_ends_at,
|
||||
"pending_downgrade_tier_name": c.pending_downgrade_tier_name,
|
||||
"pending_downgrade_at": c.pending_downgrade_at,
|
||||
"pending_downgrade_display": format_renews(c.pending_downgrade_at),
|
||||
"cancel_at_period_end": c.cancel_at_period_end,
|
||||
"cancellation_effective_at": c.cancellation_effective_at,
|
||||
"cancellation_effective_display": format_renews(c.cancellation_effective_at)}
|
||||
# Selectable catalog for the in-terminal tier picker; price is pre-formatted
|
||||
# ($X / $X.YY) so the TUI renders it directly.
|
||||
# Selectable catalog for the in-terminal tier picker; price pre-formatted ($X / $X.YY).
|
||||
tiers = [
|
||||
{
|
||||
"tier_id": t.tier_id, "name": t.name, "tier_order": t.tier_order,
|
||||
"dollars_per_month_display": format_money(t.dollars_per_month),
|
||||
"monthly_credits": _wire_str(t.monthly_credits), "is_current": t.is_current,
|
||||
"is_enabled": t.is_enabled}
|
||||
{"tier_id": t.tier_id, "name": t.name, "tier_order": t.tier_order,
|
||||
"dollars_per_month_display": format_money(t.dollars_per_month),
|
||||
"monthly_credits": _wire_str(t.monthly_credits), "is_current": t.is_current,
|
||||
"is_enabled": t.is_enabled}
|
||||
for t in state.tiers]
|
||||
return {
|
||||
"ok": True,
|
||||
"logged_in": state.logged_in,
|
||||
"is_admin": state.is_admin,
|
||||
"can_change_plan": state.can_change_plan,
|
||||
"org_name": state.org_name,
|
||||
"ok": True, "logged_in": state.logged_in, "is_admin": state.is_admin,
|
||||
"can_change_plan": state.can_change_plan, "org_name": state.org_name,
|
||||
"org_id": state.org_id,
|
||||
"role": state.role,
|
||||
"context": state.context,
|
||||
"current": current,
|
||||
"tiers": tiers,
|
||||
"portal_url": state.portal_url,
|
||||
"error": state.error,
|
||||
# Shared two-bar usage model (account-info is the only source with
|
||||
# top-up dollars); fail-open → {available:false}; lazy when logged out.
|
||||
"role": state.role, "context": state.context, "current": current, "tiers": tiers,
|
||||
"portal_url": state.portal_url, "error": state.error,
|
||||
# Shared two-bar usage model (account-info is the only source with top-up dollars);
|
||||
# fail-open → {available:false}; lazy when logged out.
|
||||
"usage": _usage_payload(state)}
|
||||
|
||||
|
||||
def _serialize_subscription_preview(p) -> dict:
|
||||
"""Serialize a SubscriptionChangePreview for the wire (Decimal → string)."""
|
||||
return {
|
||||
"ok": True,
|
||||
"effect": p.effect,
|
||||
"reason": p.reason,
|
||||
"current_tier_id": p.current_tier_id,
|
||||
"current_tier_name": p.current_tier_name,
|
||||
"target_tier_id": p.target_tier_id,
|
||||
"target_tier_name": p.target_tier_name,
|
||||
"ok": True, "effect": p.effect, "reason": p.reason,
|
||||
"current_tier_id": p.current_tier_id, "current_tier_name": p.current_tier_name,
|
||||
"target_tier_id": p.target_tier_id, "target_tier_name": p.target_tier_name,
|
||||
"monthly_credits_delta": _wire_str(p.monthly_credits_delta),
|
||||
"amount_due_now_cents": p.amount_due_now_cents,
|
||||
"effective_at": p.effective_at}
|
||||
"amount_due_now_cents": p.amount_due_now_cents, "effective_at": p.effective_at}
|
||||
|
||||
|
||||
def register(server) -> None:
|
||||
|
||||
@@ -18,22 +18,18 @@ def resolve_skin() -> dict:
|
||||
init_skin_from_config(_load_cfg())
|
||||
skin = get_active_skin()
|
||||
return {
|
||||
"name": skin.name,
|
||||
"colors": skin.colors,
|
||||
"name": skin.name, "colors": skin.colors,
|
||||
# Paired palettes: the TUI prefers the block matching terminal polarity.
|
||||
"light_colors": skin.light_colors,
|
||||
"dark_colors": skin.dark_colors,
|
||||
"branding": skin.branding,
|
||||
"banner_logo": skin.banner_logo,
|
||||
"banner_hero": skin.banner_hero,
|
||||
"tool_prefix": skin.tool_prefix,
|
||||
"light_colors": skin.light_colors, "dark_colors": skin.dark_colors,
|
||||
"branding": skin.branding, "banner_logo": skin.banner_logo,
|
||||
"banner_hero": skin.banner_hero, "tool_prefix": skin.tool_prefix,
|
||||
"help_header": (skin.branding or {}).get("help_header", "")}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# (name, user-file mtime) of the last skin broadcast: ``skin.changed`` fires on a
|
||||
# name switch OR a live color edit of the active skin, and nothing else.
|
||||
# (name, user-file mtime) of the last skin broadcast: ``skin.changed`` fires on a name
|
||||
# switch OR a live color edit of the active skin, and nothing else.
|
||||
_last_skin_sig: tuple[str, float | None] | None = None
|
||||
|
||||
|
||||
@@ -52,7 +48,7 @@ def _watcher_mtime_ns(path: Path):
|
||||
|
||||
|
||||
def _newest_mtime_ns(paths) -> int | None:
|
||||
"""Max ``st_mtime_ns`` across ``paths`` (unstat-able ones ignored); None when none could be stat'ed."""
|
||||
"""Max ``st_mtime_ns`` across ``paths`` (unstat-able ignored); None when none stat'ed."""
|
||||
mtimes = (_watcher_mtime_ns(p) for p in paths)
|
||||
return max((m for m in mtimes if m is not None), default=None)
|
||||
|
||||
@@ -90,6 +86,12 @@ def _broadcast_skin_if_changed() -> None:
|
||||
_broadcast_global_event("skin.changed", resolve_skin())
|
||||
|
||||
|
||||
def _active_pet():
|
||||
"""(pet, scale) when an enabled pet with an existing sheet is selected, else None."""
|
||||
enabled, pet, scale = _pet_active_selection()
|
||||
return (pet, scale) if enabled and pet is not None and pet.exists else None
|
||||
|
||||
|
||||
def _pet_sig() -> tuple:
|
||||
"""(slug, spritesheet revision, scale) of the active pet — ("off",) when none."""
|
||||
display = _load_cfg().get("display") or {}
|
||||
@@ -97,9 +99,10 @@ def _pet_sig() -> tuple:
|
||||
if not pet_cfg or not is_truthy_value(pet_cfg.get("enabled"), default=False):
|
||||
return ("off",)
|
||||
try:
|
||||
enabled, pet, scale = _pet_active_selection()
|
||||
if not enabled or pet is None or not pet.exists:
|
||||
active = _active_pet()
|
||||
if not active:
|
||||
return ("off",)
|
||||
pet, scale = active
|
||||
return (pet.slug, _pet_sheet_revision(pet.spritesheet), scale)
|
||||
except Exception: # noqa: BLE001 - cosmetic, never break the watcher
|
||||
return ("off",)
|
||||
@@ -108,14 +111,12 @@ def _pet_sig() -> tuple:
|
||||
def _pet_changed_payload() -> dict:
|
||||
"""``pet.info.meta``-shaped payload so the renderer can decide whether to refetch sprites."""
|
||||
try:
|
||||
enabled, pet, scale = _pet_active_selection()
|
||||
if not enabled or pet is None or not pet.exists:
|
||||
active = _active_pet()
|
||||
if not active:
|
||||
return {"enabled": False}
|
||||
pet, scale = active
|
||||
return {
|
||||
"enabled": True,
|
||||
"slug": pet.slug,
|
||||
"displayName": pet.display_name,
|
||||
"scale": scale,
|
||||
"enabled": True, "slug": pet.slug, "displayName": pet.display_name, "scale": scale,
|
||||
"spritesheetRevision": _pet_sheet_revision(pet.spritesheet)}
|
||||
except Exception: # noqa: BLE001 - cosmetic, never break the watcher
|
||||
return {"enabled": False}
|
||||
@@ -131,7 +132,9 @@ def _sessions_sig():
|
||||
cron runs (which never touch this gateway's transports) all move. Served sibling
|
||||
profile homes are probed too, else a routed profile's Bot Chat never refreshes."""
|
||||
return _newest_mtime_ns(
|
||||
root / name for root in (_watcher_home(), *_served_profile_homes) for name in ("state.db", "state.db-wal")
|
||||
root / name
|
||||
for root in (_watcher_home(), *_served_profile_homes)
|
||||
for name in ("state.db", "state.db-wal")
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""Best-effort WebSocket publisher transport for the PTY-side gateway.
|
||||
|
||||
The dashboard's `/api/pty` spawns `hermes --tui`, which spawns its own
|
||||
``tui_gateway.entry`` — three processes removed from the dashboard server. To surface
|
||||
tool/reasoning/status events in the sidebar (`/api/events`), that gateway opens a
|
||||
back-WS to the dashboard at startup and mirrors every emit through this transport as
|
||||
newline-framed JSON (no JSON-RPC envelope; ``/api/pub`` rebroadcasts bytes verbatim).
|
||||
Failure mode: silent. The agent loop must never block on the sidecar — ``send`` runs on
|
||||
a daemon thread, ``write`` returns after enqueueing (drop when full), a dead WS
|
||||
short-circuits all subsequent writes.
|
||||
The dashboard's `/api/pty` spawns `hermes --tui`, which spawns ``tui_gateway.entry`` — three
|
||||
processes from the dashboard server. To surface events in the sidebar (`/api/events`), that gateway
|
||||
opens a back-WS to the dashboard at startup and mirrors every emit through this transport as
|
||||
newline-framed JSON (no JSON-RPC envelope; ``/api/pub`` rebroadcasts bytes verbatim). Failure mode:
|
||||
silent — the agent loop must never block on the sidecar: ``send`` runs on a daemon thread, ``write``
|
||||
returns after enqueueing (drop when full), a dead WS short-circuits all later writes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""Per-session event sequencing + bounded replay for WS reconnects.
|
||||
|
||||
Every event frame through :func:`server.write_json` (hence ``_emit``) is stamped with
|
||||
a per-session monotonic ``seq`` and appended to a small ring per session id; a
|
||||
reconnecting client calls ``session.events.since`` with its last seen seq and gets
|
||||
everything newer, then live events resume. Invariants: stdio TUI unaffected (``seq``
|
||||
only on event frames; Ink ignores unknown keys); one module lock guards counters +
|
||||
buffers, and write_json already serializes per-transport writes so stamping cannot
|
||||
reorder frames; memory bound = _REPLAY_BUFFER_MAX events x _REPLAY_SESSIONS_MAX
|
||||
sessions, oldest session evicted FIFO.
|
||||
Every event frame through :func:`server.write_json` (hence ``_emit``) gets a per-session monotonic
|
||||
``seq`` and lands in a small ring per session; a reconnecting client calls ``session.events.since``
|
||||
with its last seen seq and gets everything newer. Invariants: stdio TUI unaffected (``seq`` only on
|
||||
event frames; Ink ignores unknown keys); one lock guards counters + buffers, and write_json already
|
||||
serializes per-transport writes so stamping cannot reorder frames; memory bound =
|
||||
_REPLAY_BUFFER_MAX events x _REPLAY_SESSIONS_MAX sessions, oldest session evicted FIFO.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -63,7 +61,7 @@ def _stamp_event(obj: dict) -> None:
|
||||
|
||||
|
||||
def events_since(sid: str, last_seen: int) -> list[dict]:
|
||||
"""Recorded EVENT OBJECTS (each frame's ``params`` dict) with seq > last_seen for *sid*, in order.
|
||||
"""Recorded EVENT OBJECTS (each frame's ``params`` dict) with seq > last_seen for *sid*.
|
||||
|
||||
Returning the full JSON-RPC envelope would make every replayed event fail the
|
||||
client's ``event.type`` gate and be silently dropped.
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""Git working-tree probing for the gateway: run git, resolve repo roots, fold linked
|
||||
worktrees under their common root.
|
||||
"""Git working-tree probing for the gateway: run git, resolve repo roots, fold linked worktrees.
|
||||
|
||||
Probing runs where the gateway runs, so it covers local and remote backends. Roots go
|
||||
through a thread-safe single-flight cache: concurrent identical probes from worker
|
||||
threads share one ``git`` spawn. Positives are cached for the process lifetime;
|
||||
negatives (not a repo / deleted dir) only for ``_NEG_TTL`` — ``build_tree`` resolves a
|
||||
cwd once *per session*, so hundreds of non-git cwds would otherwise re-spawn ``git`` on
|
||||
every sidebar open, while the TTL keeps a not-yet-``git init``-ed folder re-probable.
|
||||
Probing runs where the gateway runs (covers remote backends). Roots go through a thread-safe
|
||||
single-flight cache so concurrent identical probes share one ``git`` spawn. Positives are cached
|
||||
for the process lifetime; negatives (not a repo / deleted dir) only for ``_NEG_TTL`` —
|
||||
``build_tree`` resolves a cwd once *per session*, so hundreds of non-git cwds would otherwise
|
||||
re-spawn ``git`` on every sidebar open, while the TTL keeps a fresh ``git init`` re-probable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,12 +27,11 @@ _NEG_TTL = 30.0
|
||||
def run_git(cwd: str, *args: str) -> str:
|
||||
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure.
|
||||
|
||||
``bounded_git_probe`` bounds post-kill cleanup on Windows — a plain
|
||||
``subprocess.run(timeout=...)`` deadlocked Desktop readiness when a killed git left
|
||||
a suspended descendant holding the pipe handles.
|
||||
``bounded_git_probe`` bounds post-kill cleanup on Windows — a plain ``subprocess.run(timeout)``
|
||||
deadlocked Desktop readiness when a killed git left a suspended descendant holding the pipes.
|
||||
"""
|
||||
# `git -C` on a missing dir can only fail, at the price of a fork; deleted
|
||||
# worktrees dominate a long session history's cwds, so the stat pays off.
|
||||
# `git -C` on a missing dir can only fail, at the price of a fork; deleted worktrees
|
||||
# dominate a long session history's cwds, so the stat pays off.
|
||||
if not cwd or not os.path.isdir(cwd):
|
||||
return ""
|
||||
return bounded_git_probe(["git", "-C", cwd, *args], timeout=_GIT_TIMEOUT)
|
||||
@@ -112,13 +109,12 @@ def common_repo_root(cwd: str) -> str:
|
||||
"""The MAIN (common) repo root for ``cwd``, folding linked worktrees.
|
||||
|
||||
``--show-toplevel`` returns a linked worktree's OWN root; the parent of the shared
|
||||
``--git-common-dir`` is the one true root (fallback: the toplevel root). Normalized
|
||||
to git's forward-slash spelling so it compares equal to :func:`repo_root` — with
|
||||
native ``\\`` on Windows the main checkout was misread as a linked worktree and the
|
||||
desktop sidebar rendered it twice.
|
||||
``--git-common-dir`` is the one true root (fallback: the toplevel root). Normalized to git's
|
||||
forward-slash spelling so it compares equal to :func:`repo_root` — with native ``\\`` on
|
||||
Windows the main checkout was misread as a linked worktree and the sidebar rendered it twice.
|
||||
"""
|
||||
# Not a repo: nothing to fold. Checking the (warmed, negative-cached) toplevel
|
||||
# first spares every non-repo cwd a second `git` spawn the parallel warm can't absorb.
|
||||
# Not a repo: nothing to fold. Checking the (warmed, negative-cached) toplevel first spares
|
||||
# every non-repo cwd a second `git` spawn the parallel warm can't absorb.
|
||||
if not cwd or not repo_root(cwd):
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Suppress benign event-loop teardown noise on the gateway serving loop.
|
||||
|
||||
When the Desktop client forcibly closes its WebSocket while the gateway still has
|
||||
pending socket operations, asyncio logs a full traceback for every pending
|
||||
``_call_connection_lost`` callback — ``ConnectionResetError`` (WinError 10054),
|
||||
``ConnectionAbortedError`` (10053), or ``BrokenPipeError`` on POSIX; one disconnect can
|
||||
emit 50+ identical tracebacks. They are the expected side effect of the peer hanging up
|
||||
before our writes drained, so the loop exception handler installed here collapses exactly
|
||||
that class to one debug line and forwards everything else to the previous handler.
|
||||
When the Desktop client forcibly closes its WebSocket while the gateway still has pending socket
|
||||
operations, asyncio logs a traceback per pending ``_call_connection_lost`` callback —
|
||||
``ConnectionResetError`` (WinError 10054), ``ConnectionAbortedError`` (10053) or ``BrokenPipeError``
|
||||
(POSIX); one disconnect can emit 50+. They are the expected side effect of the peer hanging up
|
||||
before our writes drained, so the handler here collapses exactly that class to one debug line and
|
||||
forwards everything else to the previous handler unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -124,7 +124,7 @@ def bind_module(module_globals: dict, server, *, skip=()) -> None:
|
||||
if obj.__module__ == mod_name:
|
||||
obj = rebind(obj, g, seen)
|
||||
elif name == obj.__name__:
|
||||
continue # plain import; server has its own (an ``_alias = other.fn`` publishes as-is)
|
||||
continue # plain import; server has its own (``_alias = other.fn`` publishes as-is)
|
||||
elif isinstance(obj, (dict, tuple, list)) and _has_own_fn(obj):
|
||||
obj = module_globals[name] = _rebind_in(obj) # keep the split module's own view in sync
|
||||
elif isinstance(obj, type):
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""Bot-relay JSON-RPC handlers — the gateway side of cross-connection A2A.
|
||||
|
||||
Connections ARE the peer set: the Desktop owns every gateway socket (local, remote,
|
||||
SSH, Cloud, docker) and relays between them through these four doors on EACH gateway:
|
||||
``roster.sync`` (push the union roster of OTHER connections' agents so ``message_agent``
|
||||
resolves them), ``outbox.drain`` (collect envelopes queued here for other connections),
|
||||
``deliver`` (run a one-turn Bot Chat delivery on the TARGET gateway, return the reply),
|
||||
``reply`` (write the reply/error back on the SENDER gateway for the waiter to pick up).
|
||||
Storage/validation plumbing lives in ``tools/bot_relay.py``. Handlers are rebound onto
|
||||
server.py's globals (method_ctx.py) and reference ``_ok``/``_err`` etc. bare.
|
||||
Connections ARE the peer set: the Desktop owns every gateway socket and relays between them via
|
||||
four doors on EACH gateway: ``roster.sync`` (push OTHER connections' agents so ``message_agent``
|
||||
resolves them), ``outbox.drain`` (collect envelopes queued here for other connections), ``deliver``
|
||||
(one-turn Bot Chat delivery on the TARGET gateway, returns the reply), ``reply`` (write the
|
||||
reply/error back on the SENDER gateway for its waiter). Plumbing: ``tools/bot_relay.py``.
|
||||
Handlers are rebound onto server.py's globals (method_ctx.py) and reference ``_ok``/``_err`` bare.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -36,10 +34,10 @@ def _run_delivery(profile: str, tmp: str) -> subprocess.CompletedProcess:
|
||||
|
||||
@method("bot_relay.roster.sync")
|
||||
def _(rid, params: dict, _root=_relay_root) -> dict:
|
||||
"""Replace this gateway's view of agents on OTHER connections.
|
||||
"""Replace this gateway's view of agents on OTHER connections → ``{count}`` accepted rows.
|
||||
|
||||
Params: ``agents`` — rows ``{profile, handle, connection_id, connection_label?, title?,
|
||||
description?}``; rows failing validation are dropped, not fatal. Result: ``{count}``.
|
||||
``agents``: rows ``{profile, handle, connection_id, connection_label?, title?, description?}``;
|
||||
rows failing validation are dropped, not fatal.
|
||||
"""
|
||||
try:
|
||||
from tools.bot_relay import write_remote_roster
|
||||
@@ -51,7 +49,7 @@ def _(rid, params: dict, _root=_relay_root) -> dict:
|
||||
|
||||
@method("bot_relay.outbox.drain")
|
||||
def _(rid, params: dict, _root=_relay_root) -> dict:
|
||||
"""Claim every pending cross-connection envelope queued on this gateway. Result: ``{envelopes}``.
|
||||
"""Claim every pending cross-connection envelope queued on this gateway → ``{envelopes}``.
|
||||
|
||||
Claimed envelopes move to ``claimed/`` atomically, so concurrent drains can't double-deliver.
|
||||
"""
|
||||
@@ -65,13 +63,9 @@ def _(rid, params: dict, _root=_relay_root) -> dict:
|
||||
|
||||
@method("bot_relay.deliver")
|
||||
def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
|
||||
"""Deliver a relayed DM into a profile's Bot Chat ON THIS GATEWAY.
|
||||
|
||||
Params: ``profile`` (target on this install), ``message`` (already attribution-prefixed).
|
||||
Runs the same one-turn ``hermes -p <profile> chat -c "Bot Chat"`` transport local DMs
|
||||
use and returns ``{reply}``. Blocking by design (the Desktop calls it from its relay
|
||||
worker; the RPC pool keeps it off the WS reader thread).
|
||||
"""
|
||||
"""Deliver a relayed DM (``profile``, attribution-prefixed ``message``) into a Bot Chat ON THIS
|
||||
GATEWAY via the one-turn ``hermes -p <profile> chat -c "Bot Chat"`` transport local DMs use →
|
||||
``{reply}``. Blocking by design (Desktop relay worker; the RPC pool keeps it off the reader)."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -95,11 +89,10 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
|
||||
if resolved not in known:
|
||||
return _err(rid, 4092, f"no profile '{profile}' on this gateway")
|
||||
|
||||
# When THIS gateway already hosts the target's Bot Chat live, the subprocess
|
||||
# transport is fenced out by the single-owner lease and the payload dropped. Land
|
||||
# the DM in the live session via prompt.submit — the composer's choke point, so
|
||||
# role alternation, persistence and streaming behave as a typed message would.
|
||||
# (Nested: needs server globals via method_ctx rebinding.)
|
||||
# When THIS gateway already hosts the target's Bot Chat live, the subprocess transport is
|
||||
# fenced out by the single-owner lease and the payload dropped. Land the DM in the live
|
||||
# session via prompt.submit — the composer's choke point, so role alternation, persistence
|
||||
# and streaming behave as a typed message would. (Nested: needs server globals via rebind.)
|
||||
def _live_bot_chat_sid(profile_name: str) -> str:
|
||||
from tools.bot_mode_probe import BOT_CHAT_TITLE
|
||||
|
||||
@@ -117,30 +110,27 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
|
||||
|
||||
live_sid = _live_bot_chat_sid(resolved)
|
||||
if live_sid:
|
||||
# queued=True: a teammate's DM runs as the NEXT turn and never interrupts or
|
||||
# steers a turn in flight (the default busy mode does); arrivals queue in order.
|
||||
# queued=True: a teammate's DM runs as the NEXT turn and never interrupts or steers a
|
||||
# turn in flight (the default busy mode does); arrivals queue in order.
|
||||
submitted = _methods["prompt.submit"](rid, {"session_id": live_sid, "text": message, "queued": True})
|
||||
if "error" in submitted:
|
||||
return submitted
|
||||
return _ok(
|
||||
rid, {"reply": f"Delivered into @{resolved}'s open Bot Chat; the reply will appear there."}
|
||||
)
|
||||
reply = f"Delivered into @{resolved}'s open Bot Chat; the reply will appear there."
|
||||
return _ok(rid, {"reply": reply})
|
||||
|
||||
fd, tmp = tempfile.mkstemp(prefix="hermes-relay-dm-", suffix=".txt", text=True)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(message)
|
||||
# Per-profile turn lock serializes with any other delivery turn into this
|
||||
# profile and covers only the turn window. Worst-case hold is lock wait
|
||||
# (bot_mode.turn_wait_seconds, default 120s) + the 600s turn timeout, doubled
|
||||
# when the retry policy grants one re-run — callers must tolerate ~1320s.
|
||||
# Per-profile turn lock serializes with any other delivery turn into this profile and
|
||||
# covers only the turn window. Worst-case hold is lock wait (bot_mode.turn_wait_seconds,
|
||||
# default 120s) + the 600s turn timeout, doubled on one retry — callers tolerate ~1320s.
|
||||
with acquire_turn_lock(root, resolved):
|
||||
proc = _run(resolved, tmp)
|
||||
if proc.returncode != 0:
|
||||
# Retry policy: transient classes re-run the SAME session once;
|
||||
# context_overflow too — the retried turn's pre-API compaction pass
|
||||
# compacts the over-threshold transcript first (no fresh session is
|
||||
# ever minted). Auth/quota/config classes never retry.
|
||||
# Retry policy: transient classes re-run the SAME session once; context_overflow
|
||||
# too — the retried turn's pre-API compaction pass compacts the over-threshold
|
||||
# transcript first (no fresh session is minted). Auth/quota/config never retry.
|
||||
from tools.bot_failure_reasons import (
|
||||
RETRY_NONE, classify_agent_error, retry_action)
|
||||
|
||||
@@ -169,11 +159,8 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
|
||||
|
||||
@method("bot_relay.reply")
|
||||
def _(rid, params: dict, _root=_relay_root) -> dict:
|
||||
"""Write a relayed reply (or delivery error) for a sender-side waiter.
|
||||
|
||||
Params: ``id`` (envelope id), ``reply`` and/or ``error``, optional ``reason``
|
||||
(typed failure code, see ``tools.bot_failure_reasons``).
|
||||
"""
|
||||
"""Write a relayed ``reply`` and/or ``error`` (+ optional typed ``reason``, see
|
||||
``tools.bot_failure_reasons``) for envelope ``id`` so the sender-side waiter picks it up."""
|
||||
envelope_id = str(params.get("id") or "").strip()
|
||||
if not envelope_id:
|
||||
return _err(rid, 4093, "id required")
|
||||
|
||||
@@ -16,11 +16,10 @@ _CDP_SCHEMES = {"http", "https", "ws", "wss"}
|
||||
def _resolve_browser_cdp_url() -> str:
|
||||
"""Configured browser CDP override without network I/O.
|
||||
|
||||
``/browser status`` must be fast: ``tools.browser_tool._get_cdp_override`` runs an
|
||||
HTTP probe with a multi-second timeout for discovery-style URLs. Mirrors its
|
||||
precedence (env var, then ``browser.cdp_url``) minus the WS-resolution step, so the
|
||||
answer reflects user intent even when the host is unreachable; ``browser_navigate``
|
||||
normalizes on the next tool call.
|
||||
``/browser status`` must be fast: ``tools.browser_tool._get_cdp_override`` runs an HTTP
|
||||
probe with a multi-second timeout for discovery-style URLs. Mirrors its precedence (env var,
|
||||
then ``browser.cdp_url``) minus the WS-resolution step, so the answer reflects user intent
|
||||
even when the host is unreachable; ``browser_navigate`` normalizes on the next tool call.
|
||||
"""
|
||||
env_url = os.environ.get("BROWSER_CDP_URL", "").strip()
|
||||
if env_url:
|
||||
@@ -39,17 +38,14 @@ def _resolve_browser_cdp_url() -> str:
|
||||
|
||||
def _is_default_local_cdp(parsed) -> bool:
|
||||
"""Match the discovery-style local default; never the concrete WS form — a
|
||||
``ws://127.0.0.1:9222/devtools/browser/<id>`` is connectable as-is and collapsing
|
||||
it to bare ``http://...:9222`` would break the connect."""
|
||||
``ws://127.0.0.1:9222/devtools/browser/<id>`` is connectable as-is and collapsing it to bare
|
||||
``http://...:9222`` would break the connect."""
|
||||
try:
|
||||
port = parsed.port or 80
|
||||
except ValueError:
|
||||
return False
|
||||
return (
|
||||
parsed.scheme in {"http", "ws"}
|
||||
and parsed.hostname in {"127.0.0.1", "localhost"}
|
||||
and port == 9222
|
||||
and parsed.path in {"", "/", "/json", "/json/version"})
|
||||
return (parsed.scheme in {"http", "ws"} and parsed.hostname in {"127.0.0.1", "localhost"}
|
||||
and port == 9222 and parsed.path in {"", "/", "/json", "/json/version"})
|
||||
|
||||
|
||||
def _cdp_http_reachable(parsed, timeout: float = 2.0) -> bool:
|
||||
@@ -94,13 +90,13 @@ def _launch_failure_hints(port: int, system: str) -> list[str]:
|
||||
|
||||
|
||||
def _connect_local_default(port: int, system: str, announce) -> str | None:
|
||||
"""Discover (or launch) the default local debug browser → its CDP URL, or None after announcing failure."""
|
||||
"""Discover (or launch) the default local debug browser → CDP URL, or None after announcing."""
|
||||
from hermes_cli.browser_connect import (
|
||||
discover_local_cdp_url, find_free_debug_port, launch_chrome_debug, local_port_in_use)
|
||||
|
||||
# Dual-stack discovery: when another app squats the IPv4 loopback on the debug
|
||||
# port, a browser bound there comes up on [::1] only. An IPv4-only probe misses
|
||||
# it AND hangs against squatters that accept TCP but never answer HTTP.
|
||||
# Dual-stack discovery: when another app squats the IPv4 loopback on the debug port, a
|
||||
# browser bound there comes up on [::1] only; an IPv4-only probe misses it AND hangs
|
||||
# against squatters that accept TCP but never answer HTTP.
|
||||
discovered = discover_local_cdp_url(port, timeout=2.0)
|
||||
if discovered is not None:
|
||||
announce(f"Chromium-family browser is already listening at {discovered}")
|
||||
@@ -169,8 +165,8 @@ def _browser_connect(rid, params: dict) -> dict:
|
||||
parsed = urlparse(url)
|
||||
port = parsed.port or 9222
|
||||
try:
|
||||
# Hosted ws[s]://.../devtools/browser/<id> endpoints don't serve the HTTP discovery
|
||||
# path: check TCP reachability only and let browser_navigate handshake.
|
||||
# Hosted ws[s]://.../devtools/browser/<id> endpoints don't serve the HTTP discovery path:
|
||||
# check TCP reachability only and let browser_navigate handshake.
|
||||
if parsed.scheme in {"ws", "wss"} and parsed.path.startswith("/devtools/browser/"):
|
||||
import socket
|
||||
|
||||
@@ -189,8 +185,8 @@ def _browser_connect(rid, params: dict) -> dict:
|
||||
elif not _cdp_http_reachable(parsed):
|
||||
return _err(rid, 5031, f"could not reach browser CDP at {url}")
|
||||
normalized = _normalize_cdp_url(parsed)
|
||||
# Reap BEFORE publishing the new env (an in-flight tool call sees the old supervisor
|
||||
# closed) and AFTER (the default task's cached supervisor drains against the new URL).
|
||||
# Reap BEFORE publishing the new env (an in-flight tool call sees the old supervisor closed)
|
||||
# and AFTER (the default task's cached supervisor drains against the new URL).
|
||||
cleanup_all_browsers()
|
||||
os.environ["BROWSER_CDP_URL"] = normalized
|
||||
cleanup_all_browsers()
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"""Browser controller registration and result routing for the dashboard.
|
||||
|
||||
The dashboard's browser controller (the extension driving a browser) registers over
|
||||
the authenticated ``/api/ws`` gateway. Everything binds to the SERVER-MINTED identity
|
||||
(``WSTransport.auth_identity``, stamped by ``hermes_cli.web_server`` from the single-use
|
||||
ticket); a client-supplied ``principal_id`` is ignored and replaced by a digest of it.
|
||||
Broker command/cancel frames are re-enveloped as standard Gateway ``event`` frames;
|
||||
``browser.controller.result`` resolves a command only on the owning transport and only
|
||||
for the exact attached scope (the broker's exact-scope ``complete`` is the backstop).
|
||||
Both transports share the broker's explicit capability allowlist (no raw CDP/eval/uploads).
|
||||
Handler bodies are rebound onto server.py's globals (method_ctx.bind_module publishes
|
||||
this module's helpers/constants there too), so they reference both bare.
|
||||
The controller extension registers over the authenticated ``/api/ws`` gateway. Everything
|
||||
binds to the SERVER-MINTED identity (``WSTransport.auth_identity``, stamped from the single-use
|
||||
ticket); a client-supplied ``principal_id`` is ignored and replaced by a digest of it. Broker
|
||||
frames are re-enveloped as Gateway ``event`` frames; ``result`` resolves a command only on the
|
||||
owning transport for the exact attached scope (the broker's exact-scope ``complete`` is the
|
||||
backstop). Capabilities come from the broker's explicit allowlist (no raw CDP/eval/uploads).
|
||||
Bodies are rebound onto server.py's globals (bind_module publishes this module's helpers too).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -49,7 +46,7 @@ def _is_authenticated_identity(identity: object) -> bool:
|
||||
|
||||
|
||||
def _principal_digest(identity: dict) -> str:
|
||||
"""Server-derived principal id: stable per user, unspoofable without the authenticated identity."""
|
||||
"""Server-derived principal id: stable per user, unspoofable without the minted identity."""
|
||||
raw = f"{identity.get('provider')}\x00{identity.get('user_id')}"
|
||||
return f"principal:dashboard:{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:32]}"
|
||||
|
||||
@@ -63,11 +60,13 @@ def _broker_event_writer(transport: object, session_id: str):
|
||||
accepted = transport.write({
|
||||
"jsonrpc": "2.0", "method": "event",
|
||||
"params": {
|
||||
"type": frame.get("method"), "session_id": session_id, "payload": frame.get("params"),
|
||||
"type": frame.get("method"), "session_id": session_id,
|
||||
"payload": frame.get("params"),
|
||||
}})
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"browser controller event write failed session=%s frame=%s", session_id, frame.get("method")
|
||||
"browser controller event write failed session=%s frame=%s",
|
||||
session_id, frame.get("method"),
|
||||
)
|
||||
raise
|
||||
if accepted is False:
|
||||
@@ -130,9 +129,13 @@ def _register_precheck(rid, params: dict):
|
||||
|
||||
if not browser_control_broker.browser_control_enabled():
|
||||
return _err(rid, _ERR_FORBIDDEN, "browser.extension_control.enabled is not set")
|
||||
if not browser_control_broker.browser_control_protocol_supported(params.get("protocol_version")):
|
||||
expected = browser_control_broker.BROWSER_CONTROL_PROTOCOL_VERSION
|
||||
return _err(rid, _ERR_FORBIDDEN, f"unsupported browser-control protocol version; expected {expected}")
|
||||
broker_mod = browser_control_broker
|
||||
if not broker_mod.browser_control_protocol_supported(params.get("protocol_version")):
|
||||
expected = broker_mod.BROWSER_CONTROL_PROTOCOL_VERSION
|
||||
return _err(
|
||||
rid, _ERR_FORBIDDEN,
|
||||
f"unsupported browser-control protocol version; expected {expected}",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -141,12 +144,8 @@ def _register_precheck(rid, params: dict):
|
||||
identity_message="browser.controller.register requires an authenticated non-internal identity",
|
||||
lookup_scope=False, precheck=_register_precheck)
|
||||
def _(rid, params: dict, transport, identity, session_id, broker, _scope, session) -> dict:
|
||||
"""Attach this connection as the browser controller for one session.
|
||||
|
||||
Fails closed (4403) unless the ``browser.extension_control.enabled`` flag is on, the
|
||||
protocol version is supported, the identity/session gates pass, and at least one
|
||||
requested capability survives the allowlist.
|
||||
"""
|
||||
"""Attach this connection as the browser controller for one session; fails closed (4403) unless
|
||||
the flag is on, the protocol version is supported, the gates pass and a capability survives."""
|
||||
from gateway import browser_control_broker
|
||||
|
||||
controller_id = str(params.get("controller_id") or "").strip()
|
||||
@@ -154,9 +153,12 @@ def _(rid, params: dict, transport, identity, session_id, broker, _scope, sessio
|
||||
profile_id = str(session.get("profile") or "").strip()
|
||||
if not controller_id or not browser_profile_id or not profile_id:
|
||||
return _err(
|
||||
rid, _ERR_FORBIDDEN, "controller_id, browser_profile_id, and server session profile are required"
|
||||
rid, _ERR_FORBIDDEN,
|
||||
"controller_id, browser_profile_id, and server session profile are required",
|
||||
)
|
||||
capabilities = browser_control_broker.filter_browser_control_capabilities(params.get("capabilities"))
|
||||
capabilities = browser_control_broker.filter_browser_control_capabilities(
|
||||
params.get("capabilities")
|
||||
)
|
||||
if not capabilities:
|
||||
return _err(rid, _ERR_FORBIDDEN, "no permitted controller capabilities requested")
|
||||
scope = browser_control_broker.ControllerScope(
|
||||
@@ -166,10 +168,8 @@ def _(rid, params: dict, transport, identity, session_id, broker, _scope, sessio
|
||||
broker.attach(scope, _broker_event_writer(transport, session_id), owner=transport)
|
||||
return _ok(rid, {
|
||||
"scope": {
|
||||
"principal_id": scope.principal_id,
|
||||
"profile_id": scope.profile_id,
|
||||
"session_id": scope.session_id,
|
||||
"controller_id": scope.controller_id,
|
||||
"principal_id": scope.principal_id, "profile_id": scope.profile_id,
|
||||
"session_id": scope.session_id, "controller_id": scope.controller_id,
|
||||
"browser_profile_id": scope.browser_profile_id,
|
||||
"transport_family": scope.transport_family,
|
||||
"capabilities": sorted(scope.capabilities)}})
|
||||
@@ -177,11 +177,8 @@ def _(rid, params: dict, transport, identity, session_id, broker, _scope, sessio
|
||||
|
||||
@_controller_method("browser.controller.result")
|
||||
def _(rid, params: dict, _transport, _identity, _session_id, broker, scope, _session) -> dict:
|
||||
"""Deliver one controller command result back to the broker.
|
||||
|
||||
``accepted`` is ``False`` for unknown / already-resolved / cancelled command ids —
|
||||
the broker's idempotent answer, surfaced verbatim.
|
||||
"""
|
||||
"""Deliver one command result to the broker; ``accepted`` is False for unknown / resolved /
|
||||
cancelled command ids (the broker's idempotent answer, surfaced verbatim)."""
|
||||
command_id = str(params.get("command_id") or "")
|
||||
if not command_id:
|
||||
return _err(rid, _ERR_FORBIDDEN, "command_id required")
|
||||
|
||||
@@ -14,14 +14,14 @@ _registry = HandlerRegistry()
|
||||
|
||||
|
||||
def _persist_model_switch(result) -> None:
|
||||
# Targeted key writes: a full `model:` block rewrite via save_config() would
|
||||
# destroy sibling keys the user set there (`model_slots`, `model_fallback`, ...).
|
||||
# Targeted key writes: a full `model:` block rewrite via save_config() would destroy
|
||||
# sibling keys the user set there (`model_slots`, `model_fallback`, ...).
|
||||
from cli import save_config_value
|
||||
|
||||
save_config_value("model.default", result.new_model)
|
||||
save_config_value("model.provider", result.target_provider)
|
||||
# A provider without a base_url must clear the stale one (custom endpoint ->
|
||||
# native) or the new model routes at the old host; reads coalesce null to absent.
|
||||
# A provider without a base_url must clear the stale one (custom endpoint -> native)
|
||||
# or the new model routes at the old host; reads coalesce null to absent.
|
||||
save_config_value("model.base_url", result.base_url or None)
|
||||
|
||||
|
||||
@@ -104,9 +104,10 @@ def _restart_completed_failed_agent_build(sid: str, session: dict, failed_ready:
|
||||
|
||||
|
||||
def _switch_request(raw_input: str, parsed_flags, persist_override) -> tuple[str, str, bool, bool]:
|
||||
"""Normalize /model flags → (model_input, explicit_provider, one_turn, persist_global); raises on conflict."""
|
||||
"""Normalize /model flags → (model_input, explicit_provider, one_turn, persist_global)."""
|
||||
from hermes_cli.model_switch import (
|
||||
MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL, MODEL_SWITCH_ERROR_TEXT, parse_model_switch_args, resolve_persist_behavior
|
||||
MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL, MODEL_SWITCH_ERROR_TEXT, parse_model_switch_args,
|
||||
resolve_persist_behavior,
|
||||
)
|
||||
|
||||
if parsed_flags is None:
|
||||
@@ -130,9 +131,11 @@ def _switch_request(raw_input: str, parsed_flags, persist_override) -> tuple[str
|
||||
|
||||
|
||||
def _current_model_runtime(agent, explicit_provider: str) -> tuple:
|
||||
"""(provider, model, base_url, api_key) the switch starts from: the live agent's, else the configured runtime."""
|
||||
"""(provider, model, base_url, api_key) to switch from: live agent, else configured runtime."""
|
||||
if agent:
|
||||
return tuple(getattr(agent, k, "") or "" for k in ("provider", "model", "base_url", "api_key"))
|
||||
return tuple(
|
||||
getattr(agent, k, "") or "" for k in ("provider", "model", "base_url", "api_key")
|
||||
)
|
||||
current_model = _resolve_model()
|
||||
if explicit_provider:
|
||||
return explicit_provider.strip(), current_model, "", ""
|
||||
@@ -149,7 +152,7 @@ def _current_model_runtime(agent, explicit_provider: str) -> tuple:
|
||||
|
||||
|
||||
def _provider_context() -> tuple:
|
||||
"""(user providers, compatible custom providers, cfg) from config; all None when config fails to load."""
|
||||
"""(user providers, compatible custom providers, cfg) from config; all None on load failure."""
|
||||
user_provs = custom_provs = cfg = None
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers, load_config
|
||||
@@ -199,17 +202,16 @@ def _expensive_model_confirm(result, current_base_url: str, current_api_key) ->
|
||||
return {"value": result.new_model, "warning": confirm_msg, "confirm_required": True, "confirm_message": confirm_msg}
|
||||
|
||||
|
||||
def _commit_agent_switch(sid: str, session: dict, agent, result, current_model: str, restore_snapshot):
|
||||
"""Swap the live agent in place, then restart/persist/mark/announce; a failed swap aborts it all."""
|
||||
def _commit_agent_switch(sid: str, session: dict, agent, result, current_model: str, snapshot):
|
||||
"""Swap the live agent in place, then restart/persist/mark/announce; a failed swap aborts."""
|
||||
try:
|
||||
agent.switch_model(
|
||||
new_model=result.new_model, new_provider=result.target_provider, api_key=result.api_key,
|
||||
base_url=result.base_url, api_mode=result.api_mode,
|
||||
capabilities=getattr(result, "runtime_capabilities", None))
|
||||
except Exception as exc:
|
||||
# The in-place swap rolled the agent back and re-raised. Abort the whole
|
||||
# commit (worker restart, persist, marker, override, config write) or the
|
||||
# session stays pinned to a broken model. A failed switch is a no-op.
|
||||
# The in-place swap rolled the agent back and re-raised. Abort the whole commit (worker
|
||||
# restart, persist, marker, override, config write) or the session pins a broken model.
|
||||
logger.warning("In-place model switch failed for TUI agent: %s", exc)
|
||||
raise ValueError(
|
||||
f"Model switch to {result.new_model} failed ({exc}); "
|
||||
@@ -220,8 +222,8 @@ def _commit_agent_switch(sid: str, session: dict, agent, result, current_model:
|
||||
_persist_live_session_system_prompt(session)
|
||||
_append_model_switch_marker(session, model=result.new_model, provider=result.target_provider)
|
||||
_emit_session_info(sid, session)
|
||||
if restore_snapshot is not None:
|
||||
session["one_turn_model_restore"] = restore_snapshot
|
||||
if snapshot is not None:
|
||||
session["one_turn_model_restore"] = snapshot
|
||||
else:
|
||||
session.pop("one_turn_model_restore", None)
|
||||
|
||||
@@ -258,10 +260,9 @@ def _apply_model_switch(
|
||||
return confirm
|
||||
if agent:
|
||||
_commit_agent_switch(sid, session, agent, result, current_model, restore_snapshot)
|
||||
# PER-SESSION override so a rebuild of THIS session (/new, resume) re-derives
|
||||
# the chosen model. Deliberately NOT written to process-global env vars
|
||||
# (HERMES_MODEL & co.): the desktop hosts every same-profile session in one
|
||||
# process, so os.environ would leak the switch into every other session.
|
||||
# PER-SESSION override so a rebuild of THIS session (/new, resume) re-derives the model.
|
||||
# Deliberately NOT written to process-global env (HERMES_MODEL & co.): the desktop hosts
|
||||
# every same-profile session in one process, so os.environ would leak the switch to all.
|
||||
if pin_session_override and isinstance(session, dict) and not one_turn:
|
||||
session["model_override"] = {
|
||||
"model": result.new_model, "provider": result.target_provider,
|
||||
@@ -269,8 +270,7 @@ def _apply_model_switch(
|
||||
if persist_global:
|
||||
_persist_model_switch(result)
|
||||
return {
|
||||
"value": result.new_model,
|
||||
"warning": result.warning_message or "",
|
||||
"value": result.new_model, "warning": result.warning_message or "",
|
||||
"confirm_required": False,
|
||||
"scope": "once" if one_turn else ("global" if persist_global else "session")}
|
||||
|
||||
@@ -278,10 +278,9 @@ def _apply_model_switch(
|
||||
def _sync_bot_capabilities(sid: str, session: dict) -> None:
|
||||
"""Rebuild a Bot Chat session's agent when its capability surface changed.
|
||||
|
||||
Bot Chats are eternal sessions with toolsets/MCP baked in at construction, so a
|
||||
capability edit would otherwise wait for /new. At turn start, fingerprint the
|
||||
profile's capabilities and on change swap in a fresh agent for the SAME session
|
||||
(history is DB-backed). One rebuild per change; identical state is a no-op.
|
||||
Bot Chats are eternal sessions with toolsets/MCP baked in at construction, so a capability
|
||||
edit would otherwise wait for /new. At turn start, fingerprint the profile's capabilities
|
||||
and on change swap in a fresh agent for the SAME session (history is DB-backed).
|
||||
"""
|
||||
agent = session.get("agent")
|
||||
if agent is None:
|
||||
@@ -340,14 +339,14 @@ def _sync_agent_model_with_config(sid: str, session: dict) -> None:
|
||||
if target == seen:
|
||||
return
|
||||
model, provider = target
|
||||
# Already on the configured model (resumed before first sync, or a config
|
||||
# revert after a failed switch): adopt without switching.
|
||||
# Already on the configured model (resumed before first sync, or a config revert after
|
||||
# a failed switch): adopt without switching.
|
||||
if model == getattr(agent, "model", "") and (not provider or provider == getattr(agent, "provider", "")):
|
||||
return
|
||||
raw = f"{model} --provider {provider}" if provider else model
|
||||
try:
|
||||
# This sync ADOPTS a config.yaml change; it must never write config back
|
||||
# (that is how `hermes --tui -m` once leaked into config.yaml).
|
||||
# This sync ADOPTS a config.yaml change; it must never write config back (that is
|
||||
# how `hermes --tui -m` once leaked into config.yaml).
|
||||
_apply_model_switch(
|
||||
sid, session, raw, confirm_expensive_model=True, pin_session_override=False, persist_override=False
|
||||
)
|
||||
@@ -358,9 +357,9 @@ def _sync_agent_model_with_config(sid: str, session: dict) -> None:
|
||||
def _pending_switch_selection_warning(model: str, provider: str) -> str | None:
|
||||
"""Selection-guard message for a model queued mid-turn, or ``None``.
|
||||
|
||||
Runs BEFORE the pick is stashed, while the client can still turn the response
|
||||
into a confirm prompt. Only pre-resolution inputs exist here, so this can only
|
||||
under-fire; ``_apply_model_switch`` is the backstop. Exceptions mean "no warning".
|
||||
Runs BEFORE the pick is stashed, while the client can still turn the response into a
|
||||
confirm prompt. Only pre-resolution inputs exist, so this can only under-fire;
|
||||
``_apply_model_switch`` is the backstop. Exceptions mean "no warning".
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user