Merge branch 'simp/r3-36' into hermes/simplify-codebase

This commit is contained in:
Teknium
2026-09-03 04:34:20 -07:00
34 changed files with 4384 additions and 6708 deletions
@@ -463,7 +463,6 @@ def test_invalid_room_dispatch_http_403_is_definitively_not_admitted(monkeypatch
assert caught.value.error_code == "room_capability_catalog_changed"
assert caught.value.not_admitted is True
assert caught.value.ambiguous is False
assert caught.value.needs_capability_refresh is True
def test_capability_mismatch_requires_reauthorization_without_retry(tmp_path):
@@ -525,7 +524,6 @@ def test_peer_http_error_body_is_never_exposed_or_logged(monkeypatch, caplog):
assert caught.value.status_code == 500
assert hostile not in str(caught.value)
assert caught.value.error_message is None
assert hostile not in caplog.text
+92 -145
View File
@@ -1,9 +1,6 @@
"""Agent callback wiring: child-session live mirror, per-session agent callbacks,
personality overlay, background/preview agent kwargs, agent reset.
Bodies are rebound onto server.py's globals at install time (see
method_ctx.bind_module), so they reference server.py globals bare.
"""
"""Agent callback wiring: child-session live mirror, per-session agent callbacks, personality
overlay, background/preview agent kwargs, agent reset. Bodies are rebound onto server.py's
globals at install time (method_ctx.bind_module), so they reference server.py globals bare."""
from __future__ import annotations
@@ -12,18 +9,18 @@ import threading
from .method_ctx import bind_module
# Child-session live mirror: a delegated child's activity reaches the gateway only
# as relayed ``subagent.*`` events on the PARENT sid, so a window opened on the
# child's own session would sit silent until the run persists. Translate them into
# the native stream events emitted on the CHILD sid (write_json routes by sid).
# Child-session live mirror: a delegated child's activity reaches the gateway only as
# relayed ``subagent.*`` events on the PARENT sid; translate them into native stream
# events on the CHILD sid (write_json routes by sid) so its own window is not silent.
_child_mirrors: dict[str, dict] = {}
_child_mirrors_lock = threading.Lock()
# Child session ids with a run in flight (refreshed per relayed event, popped on
# complete) so a lazy watch resume reports running=true during a silent long tool.
# Child sids with a run in flight (refreshed per relayed event, popped on complete) so a
# lazy watch resume reports running=true during a silent long tool.
_active_child_runs: dict[str, float] = {}
# Anything quiet this long lost its completion event (callback raised, parent
# crashed) — don't pin "running".
# Anything quiet this long lost its completion event — don't pin "running".
_CHILD_RUN_STALE_S = 3600.0
_CHILD_DELTA_EVENTS = {"subagent.thinking": "reasoning.delta", "subagent.text": "message.delta",
"subagent.start": "message.delta"}
def _child_run_active(child_key: str) -> bool:
@@ -35,15 +32,13 @@ def _mirror_subagent_to_child(event_type: str, payload: dict) -> None:
child_key = str(payload.get("child_session_id") or "")
if not child_key:
return
# Liveness registry first: accurate with no window open, so one opened mid-run
# immediately knows the child is busy.
# Liveness registry first: accurate with no window open (one opened mid-run knows busy).
if event_type == "subagent.complete":
_active_child_runs.pop(child_key, None)
else:
_active_child_runs[child_key] = time.time()
# Mirror only into a live watch session NOT upgraded to a full agent: an
# upgraded one owns a real native stream and mirroring would interleave two
# turns on one sid. Either way drop state so a reopened window starts fresh.
# Mirror only into a live watch session NOT upgraded to a full agent (an upgraded one owns
# a real native stream). Either way drop state so a reopened window starts fresh.
live = _find_live_session_by_key(child_key)
if live is None or live[1].get("agent") is not None:
with _child_mirrors_lock:
@@ -51,19 +46,16 @@ def _mirror_subagent_to_child(event_type: str, payload: dict) -> None:
return
csid = live[0]
text = str(payload.get("text") or "")
# thinking/text/start (the child's goal, as a one-time header) are plain deltas.
delta = {"subagent.thinking": "reasoning.delta", "subagent.text": "message.delta",
"subagent.start": "message.delta"}
with _child_mirrors_lock:
st = _child_mirrors.setdefault(child_key, {"seq": 0, "open_tool": None, "started": False})
if not st["started"]:
st["started"] = True
_emit("message.start", csid)
if event_type in delta:
# thinking/text/start (the child's goal, as a one-time header) are plain deltas.
if event_type in _CHILD_DELTA_EVENTS:
if text:
if event_type == "subagent.start":
text = f"{text}\n"
_emit(delta[event_type], csid, {"text": text})
_emit(_CHILD_DELTA_EVENTS[event_type], csid,
{"text": f"{text}\n" if event_type == "subagent.start" else text})
return
if event_type not in ("subagent.tool", "subagent.complete"):
return
@@ -85,11 +77,11 @@ def _mirror_subagent_to_child(event_type: str, payload: dict) -> None:
def _agent_cbs(sid: str) -> dict:
def _read_block(event: str, timeout: int):
# read_terminal / read_preview (desktop GUI): blocking bridge like clarify; the
# preview read gets longer since a URL tab extracts text from a live page.
# read_terminal / read_preview (desktop GUI): blocking bridge like clarify; the preview
# read gets longer since a URL tab extracts text from a live page.
return lambda start=None, count=None: _block(
event, sid, {k: v for k, v in (("start", start), ("count", count)) if v is not None}, timeout=timeout
)
event, sid, {k: v for k, v in (("start", start), ("count", count)) if v is not None},
timeout=timeout)
callbacks = {
"tool_start_callback": lambda tc_id, name, args: _on_tool_start(sid, tc_id, name, args),
@@ -98,49 +90,42 @@ def _agent_cbs(sid: str) -> dict:
sid, event_type, name, preview, args, **kwargs),
"tool_gen_callback": lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}),
"thinking_callback": lambda text: _emit("thinking.delta", sid, {"text": text}),
# Affection reaction (ily / <3 / good bot) → hearts; core-detected so TUI and desktop share it.
# Affection reaction (ily / <3 / good bot) → hearts; core-detected so TUI/desktop share it.
"reaction_callback": lambda kind: _emit("reaction", sid, {"kind": kind}),
"reasoning_callback": lambda text: _emit(
"reasoning.delta", sid, {"text": text, **({"verbose": True} if _session_verbose(sid) else {})}
),
"reasoning.delta", sid, {"text": text, **({"verbose": True} if _session_verbose(sid) else {})}),
"status_callback": lambda kind, text=None: _status_update(sid, str(kind), None if text is None else str(text)),
# Credits/notice spine: AgentNotice → notification.show; recovery clear → notification.clear.
# Credits/notice spine: AgentNotice → notification.show; recovery → notification.clear.
"notice_callback": lambda n: _emit(
"notification.show", sid,
{"text": n.text, "level": n.level, "kind": n.kind, "ttl_ms": n.ttl_ms, "key": n.key, "id": n.id},
),
{"text": n.text, "level": n.level, "kind": n.kind, "ttl_ms": n.ttl_ms, "key": n.key, "id": n.id}),
"notice_clear_callback": lambda key: _emit("notification.clear", sid, {"key": key}),
"clarify_callback": lambda q, c, multi_select=False, questions=None: (
_clarify_block(sid, q, c, multi_select=multi_select, questions=questions)),
"read_terminal_callback": _read_block("terminal.read.request", 30),
"read_preview_callback": _read_block("preview.read.request", 45),
# drive_preview / annotate_preview (desktop GUI): renderer drives the preview webview and
# answers with outcome + refreshed element inventory; same budget as the preview read it ends with.
# drive_preview / annotate_preview (desktop GUI): same budget as the preview read it ends with.
"drive_preview_callback": lambda payload: _block("preview.act.request", sid, dict(payload), timeout=45),
# read_window_below (desktop GUI): main process enumerates native windows.
"read_window_below_callback": lambda: _block("window.read.request", sid, {}, timeout=30),
# setup_mcp (desktop GUI): consent card + install/enable/OAuth. Long timeout on purpose (typing
# an API key, browser OAuth); like clarify, timeout returns "unanswered" and a late answer is tolerated.
# setup_mcp (desktop GUI): consent card + install/enable/OAuth; long timeout on purpose
# (typing an API key, browser OAuth) and, like clarify, a late answer is tolerated.
"setup_mcp_callback": lambda server, action, reason: _block(
"mcp.setup.request", sid, {"server": server, "action": action, "reason": reason}, timeout=600
),
"mcp.setup.request", sid, {"server": server, "action": action, "reason": reason}, timeout=600),
# tour (desktop GUI): renderer drives driver.js and answers tour.respond.
"tour_callback": lambda payload: _tour_request(sid, payload)}
# Interim assistant commentary (text alongside tool calls). Gated on
# display.interim_assistant_messages (default true); _run_prompt_submit overwrites
# it per turn and clears it in its finally so a stale closure can't fire.
# Interim assistant commentary (text alongside tool calls), gated on display.interim_assistant_
# messages; _run_prompt_submit overwrites it per turn and clears it so a stale closure can't fire.
if _load_interim_assistant_messages():
callbacks["interim_assistant_callback"] = lambda text, *, already_streamed=False: _emit(
"message.interim", sid, {"text": str(text), "already_streamed": bool(already_streamed)})
return callbacks
def _apply_project_workspace(task_id: str, path: str, _name: str = "") -> None:
"""Intentional workspace move from the project_* tools: re-anchor the live
session's cwd and push session.info so the desktop follows. This is the ONLY
auto-cwd path — driven by an explicit tool call, never a terminal `cd`."""
"""Intentional workspace move from the project_* tools: re-anchor the live session's cwd
and push session.info. The ONLY auto-cwd path — an explicit tool call, never a `cd`."""
if not path:
return
# task_id is the durable session_key; _sessions (and desktop event routing) key by sid.
@@ -149,24 +134,19 @@ def _apply_project_workspace(task_id: str, path: str, _name: str = "") -> None:
sid, session = (key, _sessions[key]) if key in _sessions else next(
((s, c) for s, c in _sessions.items()
if c.get("session_key") == key or getattr(c.get("agent"), "session_id", None) == key),
("", None),
)
if session is None:
return
("", None))
resolved = os.path.abspath(os.path.expanduser(str(path)))
if not os.path.isdir(resolved):
if session is None or not os.path.isdir(resolved):
return
session["cwd"] = resolved
session["explicit_cwd"] = True
session["cwd_from_settle"] = False # explicit switch supersedes a settle-adopted cwd
# explicit switch supersedes a settle-adopted cwd
session.update(cwd=resolved, explicit_cwd=True, cwd_from_settle=False)
_register_session_cwd(session)
_persist_session_cwd_and_schedule_git_meta(session, resolved)
try:
agent = session.get("agent")
info = _session_info(agent, session) if agent is not None else {
"cwd": resolved, "branch": _git_branch_for_cwd(resolved),
"project": _project_info_for_cwd(resolved), "lazy": True,
}
"project": _project_info_for_cwd(resolved), "lazy": True}
_emit("session.info", sid, info)
except Exception:
logger.debug("failed to emit session.info after project workspace move", exc_info=True)
@@ -176,19 +156,17 @@ def _wire_callbacks(sid: str):
from tools.terminal_tool import set_sudo_password_callback
from tools.skills_tool import set_secret_capture_callback
from tools.project_tools import set_project_workspace_callback
set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120))
set_project_workspace_callback(_apply_project_workspace)
def secret_cb(env_var, prompt, metadata=None):
pl = {"prompt": prompt, "env_var": env_var}
if metadata:
pl["metadata"] = metadata
pl = {"prompt": prompt, "env_var": env_var, **({"metadata": metadata} if metadata else {})}
val = _block("secret.request", sid, pl)
if not val:
return {"success": True, "stored_as": env_var, "validated": False, "skipped": True, "message": "skipped"}
from hermes_cli.config import save_env_value_secure
return {**save_env_value_secure(env_var, val), "skipped": False, "message": "ok"}
set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120))
set_project_workspace_callback(_apply_project_workspace)
set_secret_capture_callback(secret_cb)
@@ -199,12 +177,10 @@ def _available_personalities(cfg: dict | None = None) -> dict:
def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str]:
"""Resolve a requested personality to (name, prompt) or raise ValueError. Same
contract as hermes_cli.personality.resolve_personality, but goes through the
module-level _available_personalities so tests keep a single patch point."""
"""(name, prompt) for a requested personality or ValueError; like resolve_personality but
via the module-level _available_personalities so tests keep a single patch point."""
from hermes_cli.personality import normalize_personality_name, render_personality_prompt
name = normalize_personality_name(value)
if not name:
if not (name := normalize_personality_name(value)):
return "", ""
personalities = _available_personalities(cfg)
if name not in personalities:
@@ -221,16 +197,13 @@ def _prompt_text(value) -> str:
def _apply_personality_to_session(
sid: str, session: dict, new_prompt: str, personality: str = "") -> tuple[bool, dict | None]:
"""Apply a personality change to a live session without resetting history: the
ephemeral system prompt is updated in place (appended at API-call time, so
prompt-cache hits survive) plus a pivot marker so the model stops pattern-matching
its earlier tone. Returns (history_reset=False, info)."""
"""Apply a personality change without resetting history: the ephemeral system prompt is
updated in place (appended at API-call time, so prompt-cache hits survive) plus a pivot
marker so the model stops pattern-matching its earlier tone. Returns (False, info)."""
if not session:
return False, None
session["personality"] = personality
agent = session.get("agent")
if not agent:
if not (agent := session.get("agent")):
return False, None
agent.ephemeral_system_prompt = new_prompt or None
marker = (
@@ -239,12 +212,10 @@ def _apply_personality_to_session(
f"accordingly: {new_prompt}]"
if new_prompt else
"[System: The user has cleared the personality overlay. "
"From this point forward, respond in your normal default style.]"
)
# Like the model-switch marker: role=user so strict providers accept it
# mid-conversation, but `display_kind` keeps it out of the
# `truncate_before_user_ordinal` addressing space (untagged, every rewind would
# land one turn early and `replace_messages` hard-delete the difference).
"From this point forward, respond in your normal default style.]")
# Like the model-switch marker: role=user so strict providers accept it mid-conversation,
# but `display_kind` keeps it out of the `truncate_before_user_ordinal` addressing space
# (untagged, every rewind would land one turn early and hard-delete the difference).
with session["history_lock"]:
session["history"].append({"role": "user", "content": marker, "display_kind": "personality_switch"})
session["history_version"] = int(session.get("history_version", 0)) + 1
@@ -256,8 +227,7 @@ def _apply_personality_to_session(
def _cfg_max_turns(cfg: dict, default: int) -> int:
from hermes_cli.config import resolve_turn_limit as _resolve_turn_limit
# Env override wins; resolve_turn_limit makes "none"/"unlimited"/0 first-class spellings.
env_val = os.environ.get("HERMES_TUI_MAX_TURNS")
if env_val:
if env_val := os.environ.get("HERMES_TUI_MAX_TURNS"):
return _resolve_turn_limit(env_val, default=default)
raw = (cfg.get("agent") or {}).get("max_turns")
if raw is None:
@@ -267,12 +237,7 @@ def _cfg_max_turns(cfg: dict, default: int) -> int:
def _parse_tui_skills_env() -> list[str]:
raw = os.environ.get("HERMES_TUI_SKILLS", "")
skills: list[str] = []
for part in raw.replace("\n", ",").split(","):
item = part.strip()
if item and item not in skills:
skills.append(item)
return skills
return list(dict.fromkeys(p.strip() for p in raw.replace("\n", ",").split(",") if p.strip()))
def _load_fallback_model():
@@ -282,40 +247,33 @@ def _load_fallback_model():
return get_fallback_chain(_load_cfg())
def _agent_fallback_model(agent):
"""Return an agent's fallback chain without rehydrating deliberately empty chains."""
if hasattr(agent, "_fallback_chain"):
return agent._fallback_chain or []
return agent._fallback_model if hasattr(agent, "_fallback_model") else _load_fallback_model()
def _background_agent_kwargs(agent, task_id: str) -> dict:
cfg = _load_cfg()
def g(name, default=None):
return getattr(agent, name, default)
kwargs = {k: g(k) or None for k in (
"base_url", "api_key", "provider", "api_mode", "acp_command", "acp_args",
"ephemeral_system_prompt")}
kwargs.update({k: g(k) for k in (
"providers_allowed", "providers_ignored", "providers_order", "provider_sort",
"provider_data_collection", "openrouter_min_coding_score")})
kwargs.update(
model=g("model") or _resolve_model(),
max_iterations=_cfg_max_turns(cfg, 25),
# Detached tasks declare platform="tui" (no UI sid for renderer-routed
# events), so resolve toolsets against it — never GUI schema they can't use.
enabled_toolsets=g("enabled_toolsets") or _load_enabled_toolsets("tui"),
quiet_mode=True, verbose_logging=False,
provider_require_parameters=g("provider_require_parameters", False),
session_id=task_id,
reasoning_config=g("reasoning_config") or _load_reasoning_config(str(g("model", "") or "")),
service_tier=g("service_tier") or _load_service_tier(),
request_overrides=dict(g("request_overrides", {}) or {}),
platform="tui", session_db=_get_db(), fallback_model=_agent_fallback_model(agent),
)
return kwargs
# Don't rehydrate a deliberately empty fallback chain.
if hasattr(agent, "_fallback_chain"):
fallback = agent._fallback_chain or []
else:
fallback = (agent._fallback_model if hasattr(agent, "_fallback_model")
else _load_fallback_model())
# Detached tasks declare platform="tui" (no UI sid for renderer-routed events), so resolve
# toolsets against it — never GUI schema they can't use.
return {
**{k: g(k) or None for k in ("base_url", "api_key", "provider", "api_mode", "acp_command",
"acp_args", "ephemeral_system_prompt")},
**{k: g(k) for k in ("providers_allowed", "providers_ignored", "providers_order", "provider_sort",
"provider_data_collection", "openrouter_min_coding_score")},
"model": g("model") or _resolve_model(), "max_iterations": _cfg_max_turns(cfg, 25),
"enabled_toolsets": g("enabled_toolsets") or _load_enabled_toolsets("tui"),
"quiet_mode": True, "verbose_logging": False,
"provider_require_parameters": g("provider_require_parameters", False), "session_id": task_id,
"reasoning_config": g("reasoning_config") or _load_reasoning_config(str(g("model", "") or "")),
"service_tier": g("service_tier") or _load_service_tier(),
"request_overrides": dict(g("request_overrides", {}) or {}),
"platform": "tui", "session_db": _get_db(), "fallback_model": fallback}
def _ephemeral_preview_agent_kwargs(agent, task_id: str) -> dict:
@@ -323,13 +281,10 @@ def _ephemeral_preview_agent_kwargs(agent, task_id: str) -> dict:
"enabled_toolsets": ["terminal", "file"], "session_db": None, "skip_memory": True}
_PREVIEW_HISTORY_ROLES = ("user", "assistant", "tool", "system")
def _preview_restart_history(session: dict, max_messages: int = 24, max_tool_chars: int = 1200) -> list[dict]:
"""Distill recent parent history for the ephemeral preview-restart agent (else it
guesses app/server/cwd/port from the bare URL). Keeps the last ``max_messages``
(always back to the last user turn); tool results truncated to ``max_tool_chars``."""
"""Distill recent parent history for the ephemeral preview-restart agent (else it guesses
app/cwd/port from the bare URL): last ``max_messages`` back to the last user turn, tool
results truncated to ``max_tool_chars``."""
try:
with session["history_lock"]:
history = list(session.get("history") or [])
@@ -337,14 +292,13 @@ def _preview_restart_history(session: dict, max_messages: int = 24, max_tool_cha
history = list(session.get("history") or [])
if not history:
return []
last_user = next((i for i in range(len(history) - 1, -1, -1) if history[i].get("role") == "user"), None)
start = max(0, len(history) - max_messages)
for idx in range(len(history) - 1, -1, -1):
if history[idx].get("role") == "user":
start = min(start, idx)
break
if last_user is not None:
start = min(start, last_user)
trimmed: list[dict] = []
for msg in history[start:]:
if not isinstance(msg, dict) or msg.get("role") not in _PREVIEW_HISTORY_ROLES:
if not isinstance(msg, dict) or msg.get("role") not in ("user", "assistant", "tool", "system"):
continue
copy = {k: v for k, v in msg.items() if k != "reasoning"}
content = copy.get("content")
@@ -358,7 +312,7 @@ def _preview_tool_result_preview(name: str, result: str) -> str:
try:
data = json.loads(result)
except Exception:
return ""
data = None
if not isinstance(data, dict):
return ""
if name == "terminal":
@@ -375,8 +329,7 @@ def _preview_restart_callbacks(parent: str, task_id: str) -> dict:
started_at: dict[str, float] = {}
def progress(message: str, level: str = "info") -> None:
text = str(message or "").strip()
if text:
if text := str(message or "").strip():
_emit("preview.restart.progress", parent, {"task_id": task_id, "level": level, "text": text})
def tool_start(tool_call_id: str, name: str, args: dict) -> None:
@@ -391,27 +344,22 @@ def _preview_restart_callbacks(parent: str, task_id: str) -> dict:
progress(summary + (f"\n{output}" if output else ""))
def tool_progress(event_type: str, name: str | None = None, preview: str | None = None, **_kwargs) -> None:
if preview:
progress(str(preview))
elif name:
progress(f"{event_type.replace('.', ' ')}: {name}")
if preview or name:
progress(str(preview) if preview else f"{event_type.replace('.', ' ')}: {name}")
return {
"tool_start_callback": tool_start, "tool_complete_callback": tool_complete,
"tool_progress_callback": tool_progress,
"tool_gen_callback": lambda name: progress(f"Preparing {name}"),
"status_callback": lambda kind, text=None: progress(text if text is not None else kind),
}
"status_callback": lambda kind, text=None: progress(text if text is not None else kind)}
def _reset_session_agent(sid: str, session: dict) -> dict:
tokens = _set_session_context(session["session_key"])
try:
# /new is a full conversation boundary: session-scoped runtime overrides
# (/model, /reasoning, /fast) do NOT carry forward — the fresh agent
# re-derives them from config.yaml, and the pins are cleared so a rebuild
# can't resurrect them. Global process state is never touched (see the
# cross-session-contamination note in _apply_model_switch).
# /new is a full conversation boundary: session-scoped runtime overrides (/model,
# /reasoning, /fast) do NOT carry forward and the pins are cleared so a rebuild can't
# resurrect them. Global process state is never touched (see _apply_model_switch).
for k in ("model_override", "create_reasoning_override", "create_service_tier_override", "one_turn_model_restore"):
session.pop(k, None)
new_agent = _make_agent(
@@ -425,8 +373,7 @@ def _reset_session_agent(sid: str, session: dict) -> dict:
queued_prompt=None,
_queued_prompt_generation=int(session.get("_queued_prompt_generation", 0)) + 1,
edit_snapshots={}, image_counter=0, running=False, show_reasoning=_load_show_reasoning(),
tool_progress_mode=_load_tool_progress_mode(), tool_started_at={},
)
tool_progress_mode=_load_tool_progress_mode(), tool_started_at={})
session.pop("queued_prompts", None)
with session["history_lock"]:
session["history"] = []
+43 -67
View File
@@ -1,8 +1,6 @@
"""Skin + config-change watcher: signatures for skin/pet/cron/sessions/platforms/pairing/bot-relay state and the broadcast loop that pushes *.changed events.
Bodies are rebound onto server.py's globals at install time (see
method_ctx.bind_module), so they reference server.py globals bare.
"""
"""Skin + config-change watcher: on-disk signatures for skin/pet/cron/sessions/platforms/
pairing/bot-relay state and the broadcast loop that pushes *.changed events. Bodies are
rebound onto server.py's globals at install time (method_ctx.bind_module)."""
from __future__ import annotations
@@ -14,12 +12,11 @@ _registry = HandlerRegistry()
def resolve_skin() -> dict:
try:
from hermes_cli.skin_engine import init_skin_from_config, get_active_skin
init_skin_from_config(_load_cfg())
skin = get_active_skin()
# light/dark are paired palettes: the TUI prefers the block matching terminal polarity.
return {
"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,
@@ -47,10 +44,13 @@ def _watcher_mtime_ns(path: Path):
return None
def _home_mtime_ns(*parts: str):
return _watcher_mtime_ns(_watcher_home().joinpath(*parts))
def _newest_mtime_ns(paths) -> int | None:
"""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)
return max((m for m in map(_watcher_mtime_ns, paths) if m is not None), default=None)
def _skin_sig() -> tuple[str, float | None]:
@@ -58,10 +58,9 @@ def _skin_sig() -> tuple[str, float | None]:
their name moves; a user skin's mtime lets an in-place color edit repaint too."""
name = str((_load_cfg().get("display") or {}).get("skin") or "default")
try:
mtime: float | None = (_watcher_home() / "skins" / f"{name}.yaml").stat().st_mtime
return name, (_watcher_home() / "skins" / f"{name}.yaml").stat().st_mtime
except OSError:
mtime = None
return name, mtime
return name, None
def _note_skin_broadcast() -> None:
@@ -75,14 +74,11 @@ def _broadcast_skin_if_changed() -> None:
"""Emit ``skin.changed`` when the active skin moved, via the SAME live path as
``/skin`` so every surface repaints. The check is a dict lookup + one stat."""
global _last_skin_sig
try:
sig = _skin_sig()
except Exception:
return
if sig == _last_skin_sig:
return
_last_skin_sig = sig
with contextlib.suppress(Exception):
sig = _skin_sig()
if sig == _last_skin_sig:
return
_last_skin_sig = sig
_broadcast_global_event("skin.changed", resolve_skin())
@@ -99,55 +95,39 @@ def _pet_sig() -> tuple:
if not pet_cfg or not is_truthy_value(pet_cfg.get("enabled"), default=False):
return ("off",)
try:
active = _active_pet()
if not active:
return ("off",)
pet, scale = active
return (pet.slug, _pet_sheet_revision(pet.spritesheet), scale)
if active := _active_pet():
pet, scale = active
return (pet.slug, _pet_sheet_revision(pet.spritesheet), scale)
except Exception: # noqa: BLE001 - cosmetic, never break the watcher
return ("off",)
pass
return ("off",)
def _pet_changed_payload() -> dict:
"""``pet.info.meta``-shaped payload so the renderer can decide whether to refetch sprites."""
try:
active = _active_pet()
if not active:
return {"enabled": False}
pet, scale = active
return {
"enabled": True, "slug": pet.slug, "displayName": pet.display_name, "scale": scale,
"spritesheetRevision": _pet_sheet_revision(pet.spritesheet)}
if active := _active_pet():
pet, scale = active
return {"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}
def _cron_sig():
"""mtime of cron/jobs.json — moves on edits AND scheduler tick bookkeeping."""
return _watcher_mtime_ns(_watcher_home() / "cron" / "jobs.json")
pass
return {"enabled": False}
def _sessions_sig():
"""Newest mtime across state.db + WAL: the one thing messaging-gateway turns and
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."""
"""Newest mtime across state.db + WAL: the one thing messaging-gateway turns and cron runs
all move. Served sibling profile homes are probed too, else a routed 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")
)
def _platforms_sig():
"""mtime of gateway_state.json — where the messaging gateway persists platform
connect/disconnect/health, i.e. the Messaging page's status-changed signal."""
return _watcher_mtime_ns(_watcher_home() / "gateway_state.json")
for name in ("state.db", "state.db-wal"))
def _pairing_sig():
"""Newest mtime across every profile's pairing ledgers (legacy ``pairing/`` and
``platforms/pairing/``). Pending codes are written by the gateway process, so the
files are the only shared signal; a pairing request moves nothing in gateway_state.json."""
``platforms/pairing/``): the gateway process writes pending codes, so the files are the only
shared signal (a pairing request moves nothing in gateway_state.json)."""
home = _watcher_home()
roots = [home / "pairing", home / "platforms" / "pairing"]
with contextlib.suppress(OSError):
@@ -168,29 +148,27 @@ _bot_relay_outbox_seen = 0
def _bot_relay_outbox_sig():
"""Newest mtime across pending bot-relay outbox envelopes (monotone). Written by
the AGENT process, so the files are the only shared signal; the Desktop reacts
to ``bot_relay.outbox.pending`` with an immediate debounced drain."""
"""Newest mtime across pending bot-relay outbox envelopes (monotone). Written by the AGENT
process, so the files are the only shared signal; the Desktop reacts with a debounced drain."""
global _bot_relay_outbox_seen
home = _watcher_home()
root = home.parent.parent if home.parent.name == "profiles" else home
newest = 0
with contextlib.suppress(OSError):
for entry in (root / "bot_relay" / "outbox").iterdir():
if entry.name.endswith(".json"):
newest = max(newest, _watcher_mtime_ns(entry) or 0)
if newest > _bot_relay_outbox_seen:
_bot_relay_outbox_seen = newest
_bot_relay_outbox_seen = max(_bot_relay_outbox_seen, _watcher_mtime_ns(entry) or 0)
return _bot_relay_outbox_seen or None
# event → (check interval, signature fn, payload fn). Signatures are stat-cheap;
# the interval keeps pricier probes (pet resolves the sheet off disk) off the 0.5s tick.
# event → (check interval, signature fn, payload fn). Signatures are stat-cheap; the interval
# keeps pricier probes (pet resolves the sheet off disk) off the 0.5s tick. cron/jobs.json
# moves on edits AND scheduler ticks; gateway_state.json is where the messaging gateway
# persists platform connect/disconnect/health (the Messaging page's status signal).
_CHANGE_WATCHES: dict[str, tuple[float, Any, Any]] = {
"pet.changed": (2.0, _pet_sig, _pet_changed_payload),
"cron.changed": (1.0, _cron_sig, lambda: {}),
"cron.changed": (1.0, lambda: _home_mtime_ns("cron", "jobs.json"), lambda: {}),
"sessions.changed": (0.5, _sessions_sig, lambda: {}),
"platforms.changed": (2.0, _platforms_sig, lambda: {}),
"platforms.changed": (2.0, lambda: _home_mtime_ns("gateway_state.json"), lambda: {}),
"pairing.changed": (2.0, _pairing_sig, lambda: {}),
# 1s so a queued DM envelope reaches the Desktop's push-triggered drain fast.
"bot_relay.outbox.pending": (1.0, _bot_relay_outbox_sig, lambda: {})}
@@ -220,9 +198,9 @@ def _broadcast_watched_changes(now: float | None = None) -> None:
if event not in _change_sigs:
_change_sigs[event] = sig
continue
floor = _CHANGE_BROADCAST_FLOOR_S.get(event, 0.0)
if sig == _change_sigs[event]:
continue
floor = _CHANGE_BROADCAST_FLOOR_S.get(event, 0.0)
if floor and now - _change_broadcast_at.get(event, -floor) < floor:
continue # floored: old signature stays so it re-fires when the window opens
_change_sigs[event] = sig
@@ -235,9 +213,8 @@ _skin_watcher_started = False
def _ensure_skin_watcher() -> None:
"""Start the process's one change watcher (named for its original skin-only
duty): cheap on-disk signatures → broadcast events, so skin/pet/cron/cross-process
changes go live everywhere within seconds without client polling. Idempotent."""
"""Start the process's one change watcher (named for its original skin-only duty): cheap
on-disk signatures → broadcast events, so changes go live without client polling. Idempotent."""
global _skin_watcher_started
if _skin_watcher_started:
return
@@ -249,7 +226,6 @@ def _ensure_skin_watcher() -> None:
time.sleep(0.5)
_broadcast_skin_if_changed()
_broadcast_watched_changes()
threading.Thread(target=_loop, name="hermes-change-watcher", daemon=True).start()
+136 -176
View File
@@ -1,8 +1,5 @@
"""Persistent dashboard compute-host process.
The long-lived child that owns live AIAgent objects when ``dashboard.turn_isolation``
is enabled; frames are line-JSON over stdin/stdout.
"""
"""Persistent dashboard compute-host child: owns live AIAgent objects when
``dashboard.turn_isolation`` is enabled; frames are line-JSON over stdin/stdout."""
from __future__ import annotations
@@ -43,16 +40,18 @@ class _HostTransport:
return None
# Slice of ``ComputeHost.shutdown``'s budget held back for the post-drain finalize.
# ``HostSupervisor._terminate_pid`` SIGKILLs the host ``_SHUTDOWN_TIMEOUT_SECS`` (10s,
# same as ``shutdown``'s default ``wait``) after SIGTERM, so a drain allowed to consume
# the whole budget would leave the flush racing that kill and persist nothing at all.
# Slice of ``ComputeHost.shutdown``'s budget held back for the post-drain finalize: the
# supervisor SIGKILLs the host 10s (= default ``wait``) after SIGTERM, so a drain that ate
# the whole budget would leave the flush racing that kill and persist nothing.
_FLUSH_RESERVE_SECS = 1.0
# Fallback control.error text when a routed server method returns an error without a message.
_CONTROL_FAILURES = {
"session.save": "session save failed", "session.compress": "session compression failed"}
class ComputeHost:
# frame ``type`` -> handler method name (resolved per call so instance
# monkeypatches of a handler still take effect).
# frame ``type`` -> handler method name (resolved per call so monkeypatches take effect).
_FRAME_HANDLERS: dict[str, str] = {
"turn.start": "_handle_turn_start", "interrupt": "_handle_interrupt",
"respond": "_handle_respond", "reload_mcp": "_handle_reload_mcp",
@@ -70,8 +69,7 @@ class ComputeHost:
self._boot_id = uuid.uuid4().hex
self._progress_counter = 0
self._progress_lock = threading.Lock()
# Future -> the ``sid`` whose turn it is running. ``shutdown`` needs to know
# *whose* turn is still live so it can leave those sessions unfinalized.
# Future -> the ``sid`` whose turn it runs; ``shutdown`` leaves live sids unfinalized.
self._turn_futures: dict[concurrent.futures.Future, str] = {}
self._turn_futures_lock = threading.Lock()
self._transport = _HostTransport(self.emit)
@@ -101,36 +99,25 @@ class ComputeHost:
def shutdown(self, *, reason: str = "shutdown", wait: float = 10.0) -> None:
"""Drain in-flight turns, then finalize every session.
Order matters: ``_finalize_session`` is a one-shot latch, so finalizing before
the drain would spend the flush's single chance mid-turn, fire
``on_session_end(interrupted=True)`` against a running session and release the
active-session lease under a live turn. ``_FLUSH_RESERVE_SECS`` (never more than
half of ``wait``) is withheld from the drain so the flush still runs when turns
outlast the window. Sessions whose turn is *still running* at the deadline are
excluded from the flush (``_executor.shutdown`` does not join them): finalizing
one mid-turn would leave it un-finalizable with its lease released, whereas
leaving it unfinalized keeps it recoverable. ``server._shutdown_sessions``
(atexit) may still re-finalize skipped sessions on the SIGTERM / stdin_closed
paths; the orphan path (``os._exit``) bypasses atexit.
``_finalize_session`` is a one-shot latch, so finalizing before the drain would spend
it mid-turn and release the lease. ``_FLUSH_RESERVE_SECS`` (at most half of ``wait``)
is withheld from the drain so the flush still runs when turns outlast the window.
Sessions still running at the deadline are skipped (unfinalized keeps them
recoverable; atexit ``server._shutdown_sessions`` may re-finalize them).
"""
self._closed.set()
budget = max(0.0, wait)
deadline = time.monotonic() + budget - min(_FLUSH_RESERVE_SECS, budget / 2.0)
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
if remaining <= 0 or not self._live_turns():
break
with self._turn_futures_lock:
pending = [f for f in self._turn_futures if not f.done()]
if not pending:
break
# Bounded by ``remaining``: a flat sleep would overshoot the deadline and
# eat the reserve it protects (all of it for small ``wait``).
# Bounded by ``remaining``: a flat sleep would eat the reserve it protects.
time.sleep(min(0.05, remaining))
with self._turn_futures_lock:
live_sids = {sid for f, sid in self._turn_futures.items() if sid and not f.done()}
self.flush_all_sessions(reason=reason, skip_sids=live_sids)
self._executor.shutdown(wait=False, cancel_futures=True)
self.close()
def flush_all_sessions(
self, *, reason: str = "shutdown", skip_sids: Collection[str] | None = None) -> None:
@@ -153,19 +140,16 @@ class ComputeHost:
self.emit({
"type": "error", "request_id": frame.get("request_id"),
"message": f"unknown frame type: {kind}"})
return
getattr(self, handler)(frame)
else:
getattr(self, handler)(frame)
def _handle_shutdown(self, frame: dict[str, Any]) -> None:
self.emit({"type": "shutdown.ack", "request_id": frame.get("request_id")})
# Explicit supervisor/test shutdown is a clean child-process close;
# SIGTERM and orphan paths are the durability flush paths.
self._closed.set()
self._executor.shutdown(wait=False, cancel_futures=True)
# Explicit shutdown is a clean close; SIGTERM and orphan paths do the durability flush.
self.close()
def _track_turn_future(self, future: concurrent.futures.Future, sid: str) -> None:
"""Register an in-flight turn against its session; the done callback must pop
under the lock or the mapping grows for the host's life."""
"""Track an in-flight turn; the done callback pops it or the map grows forever."""
with self._turn_futures_lock:
self._turn_futures[future] = sid
future.add_done_callback(self._untrack_turn_future)
@@ -178,41 +162,44 @@ class ComputeHost:
future = self._executor.submit(self._run_real_turn, dict(frame))
self._track_turn_future(future, str(frame.get("sid") or ""))
def _handle_interrupt(self, frame: dict[str, Any]) -> None:
def _guarded(
self, frame: dict[str, Any], error_kind: str, body: Callable, *,
on_error: Callable[[str], None] | None = None, **error_extra: Any) -> None:
"""Run ``body(server, sid, request_id)``; any exception becomes an ``error_kind`` reply."""
sid = str(frame.get("sid") or "")
request_id = frame.get("request_id")
try:
from tui_gateway import server
body(server, sid, request_id)
except Exception as exc:
if on_error is not None:
on_error(sid)
self._reply(error_kind, sid, request_id, **error_extra, message=str(exc))
def _handle_interrupt(self, frame: dict[str, Any]) -> None:
def body(server: Any, sid: str, request_id: Any) -> None:
session = server._sessions.get(sid)
if session is None:
self._reply("interrupt.ack", sid, request_id, applied=False)
return
# In the child, `_session_uses_compute_host()` is false, so the shared helper
# interrupts the local agent and releases this process's pending clarify
# Event; the parent only has a metadata mirror and cannot.
# In the child the shared helper interrupts the local agent and releases this
# process's pending clarify Event (the parent only has a metadata mirror).
server._interrupt_session_turn(sid, session)
self._reply("interrupt.ack", sid, request_id, applied=True, applied_ns=now_ns())
except Exception as exc:
self._reply("interrupt.ack", sid, request_id, applied=False, message=str(exc))
self._guarded(frame, "interrupt.ack", body, applied=False)
def _handle_respond(self, frame: dict[str, Any]) -> None:
"""Resolve an interactive request in the host-owned pending registry."""
sid = str(frame.get("sid") or "")
request_id = frame.get("request_id")
try:
from tui_gateway import server
if sid not in server._sessions:
self._reply("respond.error", sid, request_id, message="session not found")
return
def body(server: Any, sid: str, request_id: Any) -> None:
params = frame.get("params")
if not isinstance(params, dict):
self._reply(
"respond.error", sid, request_id, message="response params must be an object")
error = ("session not found" if sid not in server._sessions
else None if isinstance(params, dict) else "response params must be an object")
if error:
self._reply("respond.error", sid, request_id, message=error)
return
response = server._methods["clarify.respond"](request_id, params)
self._reply("respond.ack", sid, request_id, response=response)
except Exception as exc:
self._reply("respond.error", sid, request_id, message=str(exc))
self._guarded(frame, "respond.error", body)
def _run_real_turn(self, frame: dict[str, Any]) -> None:
sid = str(frame.get("sid") or "")
@@ -223,7 +210,8 @@ class ComputeHost:
try:
from tui_gateway import server
session = self._ensure_server_session(server, frame)
text = frame.get("text") if "text" in frame else frame.get("prompt", "")
text = frame["text"] if "text" in frame else frame.get("prompt", "")
inflight = frame["text"] if "text" in frame else frame.get("prompt")
with session["history_lock"]:
queued_gen = frame.get("queued_prompt_generation")
current_gen = int(session.get("_queued_prompt_generation", 0))
@@ -233,11 +221,8 @@ class ComputeHost:
if session.get("running"):
self._reply("turn.error", sid, request_id, message="session busy")
return
session["running"] = True
session["_turn_cancel_requested"] = False
session["last_active"] = time.time()
server._start_inflight_turn(
session, frame.get("text") if "text" in frame else frame.get("prompt"))
session.update(running=True, _turn_cancel_requested=False, last_active=time.time())
server._start_inflight_turn(session, inflight)
self._reply("turn.started", sid, request_id, started_ns=now_ns())
with contextlib.suppress(Exception):
server._ensure_session_db_row(session)
@@ -252,16 +237,14 @@ class ComputeHost:
if run_thread is not None and hasattr(run_thread, "join"):
run_thread.join()
with session["history_lock"]:
history_version = int(session.get("history_version", 0))
message_count = len(session.get("history") or [])
meta = _history_meta(session)
interrupted = bool(session.get("_turn_cancel_requested"))
session_key = str(session.get("session_key") or "")
session_info = server._session_info(session.get("agent"), session)
self._bump_progress()
with self._progress_lock:
self._progress_counter += 1
self._reply(
"turn.end", sid, request_id, history_version=history_version,
session_key=session_key, message_count=message_count, interrupted=interrupted,
ended_ns=now_ns(), session_info=session_info, session_info_emitted=True)
"turn.end", sid, request_id, **meta, interrupted=interrupted, ended_ns=now_ns(),
session_info=session_info, session_info_emitted=True)
except Exception as exc:
with contextlib.suppress(Exception):
from tui_gateway import server
@@ -274,25 +257,27 @@ class ComputeHost:
def _ensure_server_session(self, server: Any, frame: dict[str, Any]) -> dict:
sid = str(frame.get("sid") or "")
key = str(frame.get("session_key") or sid)
session = server._sessions.get(sid)
if session is not None:
session["transport"] = self._transport
if frame.get("cols") is not None:
session["cols"] = int(frame.get("cols") or 80)
if frame.get("cwd"):
session["cwd"] = str(frame.get("cwd"))
if frame.get("profile_home"):
session["profile_home"] = str(frame.get("profile_home"))
if isinstance(frame.get("attached_images"), list):
session["attached_images"] = list(frame.get("attached_images") or [])
return session
for key in ("cwd", "profile_home"):
if frame.get(key):
session[key] = str(frame[key])
else:
session = self._build_server_session(server, frame, sid)
if isinstance(frame.get("attached_images"), list):
session["attached_images"] = list(frame.get("attached_images") or [])
return session
def _build_server_session(self, server: Any, frame: dict[str, Any], sid: str) -> dict:
"""Build the agent under the frame's profile scope and register the session."""
key = str(frame.get("session_key") or sid)
history = frame.get("history") if isinstance(frame.get("history"), list) else []
profile_home = str(frame.get("profile_home") or "")
session_db = None
session_db = home_token = secret_token = None
owns_db = False
home_token = None
secret_token = None
try:
if profile_home:
from hermes_constants import set_hermes_home_override
@@ -300,10 +285,8 @@ class ComputeHost:
from hermes_state import get_shared_session_db
home_token = set_hermes_home_override(profile_home)
secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home)))
# DEDICATED handle — ours only until _make_agent succeeds; after that the
# agent (registered in server._sessions[sid] via _init_session or the
# fallback dict below) owns it. A RAISING _make_agent is the one path
# where nothing takes it, hence ``owns_db``.
# DEDICATED handle — ours only until _make_agent succeeds, then the agent owns
# it. A RAISING _make_agent is the one path where nothing takes it (``owns_db``).
session_db = get_shared_session_db(Path(profile_home) / "state.db")
owns_db = True
agent = server._make_agent(
@@ -338,9 +321,8 @@ class ComputeHost:
finally:
reset_transport(token)
except Exception:
# If _init_session's side machinery (slash worker, approval notify) is
# unavailable, keep a minimal host-owned session rather than failing the
# turn after the expensive agent build succeeded.
# _init_session's side machinery (slash worker, approval notify) unavailable: keep a
# minimal host-owned session rather than failing after the expensive agent build.
server._sessions[sid] = {
"agent": agent, "session_key": key, "history": list(history),
"history_lock": threading.Lock(),
@@ -356,117 +338,88 @@ class ComputeHost:
session = server._sessions[sid]
session["transport"] = self._transport
session["profile_home"] = profile_home or session.get("profile_home")
if isinstance(frame.get("attached_images"), list):
session["attached_images"] = list(frame.get("attached_images") or [])
if frame.get("model_override") is not None:
session["model_override"] = frame.get("model_override")
return session
def _handle_reload_mcp(self, frame: dict[str, Any]) -> None:
sid = str(frame.get("sid") or "")
request_id = frame.get("request_id")
try:
from tui_gateway import server
def body(server: Any, sid: str, request_id: Any) -> None:
resp = server.handle_request({
"id": request_id, "method": "reload.mcp",
"params": {"session_id": sid, "confirm": True}})
self._reply("reload_mcp.ack", sid, request_id, response=resp)
except Exception as exc:
self._reply("control.error", sid, request_id, message=str(exc))
self._guarded(frame, "control.error", body)
def _handle_control(self, frame: dict[str, Any]) -> None:
sid = str(frame.get("sid") or "")
request_id = frame.get("request_id")
route_name = str(frame.get("route_name") or "")
def _error(message: str) -> None:
self._reply("control.error", sid, request_id, message=message)
def _ack(**extra: Any) -> None:
self._reply("control.ack", sid, request_id, route_name=route_name, **extra)
def _call_method(name: str, params: dict[str, Any], failure: str) -> dict | None:
"""Run a server method; emit control.error and return None on error."""
response = server._methods[name](request_id, params)
if "error" in response:
_error(str(response["error"].get("message") or failure))
return None
return response
def _history_meta() -> dict[str, Any]:
"""Ack metadata read under ``history_lock`` (caller holds it)."""
return {
"session_key": str(session.get("session_key") or ""),
"history_version": int(session.get("history_version", 0)),
"message_count": len(session.get("history") or [])}
try:
from tui_gateway import server
def body(server: Any, sid: str, request_id: Any) -> None:
route = MUTATOR_ROUTE_TABLE.get(route_name)
if route is None:
_error(f"unclassified route: {route_name}")
return
session = server._sessions.get(sid)
if session is None:
_error("session not found")
return
if route == "idle-gated" and session.get("running"):
_error("session busy")
return
if route_name == "reload.mcp":
error = (f"unclassified route: {route_name}" if route is None
else "session not found" if session is None
else "session busy" if route == "idle-gated" and session.get("running")
else None)
if error:
self._reply("control.error", sid, request_id, message=error)
elif route_name == "reload.mcp":
self._handle_reload_mcp({**frame, "type": "reload_mcp"})
return
if route_name == "session.save":
response = _call_method("session.save", {"session_id": sid}, "session save failed")
if response is not None:
_ack(result=response.get("result") or {})
return
if route_name == "session.compress":
focus_topic = str(frame.get("command") or "").removeprefix("/compress").strip()
params = {"session_id": sid}
if focus_topic:
params["focus_topic"] = focus_topic
response = _call_method("session.compress", params, "session compression failed")
if response is None:
return
with session["history_lock"]:
meta = _history_meta()
_ack(
result=response.get("result") or {}, **meta,
session_info=server._session_info(session.get("agent"), session))
return
command = str(frame.get("command") or "")
output = server._mirror_slash_side_effects(sid, session, command) if command else ""
with session["history_lock"]:
messages = server._history_to_messages(list(session.get("history") or []))
meta = _history_meta()
_ack(
output=output, session_key=meta["session_key"],
history_version=meta["history_version"], message_count=meta["message_count"],
messages=messages, session_info=server._session_info(session.get("agent"), session))
except Exception as exc:
else:
ack = self._control_ack(server, frame, session)
if "error" in ack:
self._reply("control.error", sid, request_id, message=ack["error"])
else:
self._reply("control.ack", sid, request_id, route_name=route_name, **ack)
def on_error(sid: str) -> None:
if route_name in {"session.compress", "slash.compress"}:
# The compress mirror defers the context-engine boundary notification until
# the host commits. If anything raises between queueing and finalize (e.g.
# building the ack's session_info), discard the pending notification so it
# can't fire against a rejected boundary on a later compress. finalize is
# exactly-once, so this is a no-op if the mirror already emitted it.
# The compress mirror defers the context-engine boundary notification until the
# host commits; discard it so it can't fire against a rejected boundary later
# (finalize is exactly-once, so a no-op if the mirror already emitted it).
with contextlib.suppress(Exception):
from tui_gateway import server as _server
from agent.conversation_compression import (
finalize_context_engine_compression_notification)
finalize_context_engine_compression_notification as _finalize)
_agent = (_server._sessions.get(sid) or {}).get("agent")
if _agent is not None:
finalize_context_engine_compression_notification(_agent, committed=False)
_error(str(exc))
_finalize(_agent, committed=False)
self._guarded(frame, "control.error", body, on_error=on_error)
def _bump_progress(self) -> None:
with self._progress_lock:
self._progress_counter += 1
def _control_ack(self, server: Any, frame: dict[str, Any], session: dict) -> dict:
"""control.ack payload for one classified route, or ``{"error": message}``."""
sid = str(frame.get("sid") or "")
route_name = str(frame.get("route_name") or "")
command = str(frame.get("command") or "")
if route_name in {"session.save", "session.compress"}:
params = {"session_id": sid}
if route_name == "session.compress":
focus_topic = command.removeprefix("/compress").strip()
if focus_topic:
params["focus_topic"] = focus_topic
response = server._methods[route_name](frame.get("request_id"), params)
if "error" in response:
failure = _CONTROL_FAILURES[route_name]
return {"error": str(response["error"].get("message") or failure)}
ack = {"result": response.get("result") or {}}
if route_name == "session.save":
return ack
with session["history_lock"]:
ack.update(_history_meta(session))
else:
output = server._mirror_slash_side_effects(sid, session, command) if command else ""
with session["history_lock"]:
messages = server._history_to_messages(list(session.get("history") or []))
ack = {"output": output, **_history_meta(session), "messages": messages}
ack["session_info"] = server._session_info(session.get("agent"), session)
return ack
def _live_turns(self) -> list[concurrent.futures.Future]:
with self._turn_futures_lock:
return [f for f in self._turn_futures if not f.done()]
def _heartbeat_loop(self) -> None:
while not self._closed.wait(self._heartbeat_secs):
with self._turn_futures_lock:
active_turns = sum(1 for f in self._turn_futures if not f.done())
active_turns = len(self._live_turns())
with self._progress_lock:
counter = self._progress_counter
self.emit({
@@ -482,6 +435,14 @@ class ComputeHost:
os._exit(0)
def _history_meta(session: dict) -> dict[str, Any]:
"""Transcript identity for turn.end / control.ack frames; caller holds history_lock."""
return {
"session_key": str(session.get("session_key") or ""),
"history_version": int(session.get("history_version", 0)),
"message_count": len(session.get("history") or [])}
def _rss_mb(pid: int) -> float:
try:
out = subprocess.check_output(
@@ -547,8 +508,7 @@ def run_host(stdin: Any = None, stdout: Any = None) -> None:
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Dashboard compute-host process")
parser.parse_args(argv)
argparse.ArgumentParser(description="Dashboard compute-host process").parse_args(argv)
run_host()
return 0
+45 -69
View File
@@ -1,9 +1,6 @@
"""Compute-host (turn isolation) bridge: relay prompts/controls to the child process
and mirror its metadata/clarify/compress acks back into the session.
Bodies are rebound onto server.py's globals at install time (see
method_ctx.bind_module), so they reference server.py globals bare.
"""
"""Compute-host (turn isolation) bridge: relay prompts/controls to the child process and
mirror its metadata/clarify/compress acks back into the session. Bodies are rebound onto
server.py's globals at install time (method_ctx.bind_module), so they use them bare."""
from __future__ import annotations
@@ -14,7 +11,6 @@ from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
_compute_host_supervisor = None
_compute_host_supervisor_lock = threading.Lock()
# Cap on how long session.compress blocks its RPC on the compute host. Must stay
@@ -23,23 +19,18 @@ _compute_host_supervisor_lock = threading.Lock()
_COMPUTE_HOST_COMPRESS_WAIT_CAP_SECS = 630.0
def _inside_compute_host_child() -> bool:
return os.environ.get("HERMES_COMPUTE_HOST_CHILD") == "1"
def _turn_isolation_enabled(cfg: dict | None = None) -> bool:
if _inside_compute_host_child():
if os.environ.get("HERMES_COMPUTE_HOST_CHILD") == "1":
return False
return bool((cfg or _load_dashboard_process_isolation_config()).get("turn_isolation"))
def _session_uses_compute_host(session: dict, cfg: dict | None = None) -> bool:
if not _turn_isolation_enabled(cfg):
return False
# Routes lazy sessions whose AIAgent was never built in-process; already-built
# sessions keep the in-process path unless a prior isolated turn marked host ownership.
return bool(session.get("_compute_host_active")) or (
session.get("agent") is None and session.get("agent_ready") is not None)
return _turn_isolation_enabled(cfg) and (
bool(session.get("_compute_host_active"))
or (session.get("agent") is None and session.get("agent_ready") is not None))
def _get_compute_host_supervisor(cfg: dict | None = None):
@@ -61,8 +52,7 @@ def _compute_host_turn_frame(
with session["history_lock"]:
history = list(session.get("history", []))
history_version = int(session.get("history_version", 0))
attached_images = list(
image_paths if image_paths is not None else session.get("attached_images", []))
attached_images = list(image_paths if image_paths is not None else session.get("attached_images", []))
return {
"type": "turn.start", "sid": sid, "request_id": rid,
"session_key": session.get("session_key") or sid, "text": text,
@@ -97,8 +87,8 @@ def _compute_host_adopt_frame_meta(session: dict, frame: dict) -> None:
session["session_key"] = str(frame.get("session_key"))
if frame.get("history_version") is not None:
with contextlib.suppress(Exception):
session["history_version"] = max(
int(session.get("history_version", 0)), int(frame.get("history_version") or 0))
session["history_version"] = max(int(session.get("history_version", 0)),
int(frame.get("history_version") or 0))
def _relay_compute_host_rpc(message: dict) -> bool:
@@ -110,7 +100,7 @@ def _relay_compute_host_rpc(message: dict) -> bool:
payload = params.get("payload")
request_id = payload.get("request_id") if isinstance(payload, dict) else None
if session is not None and request_id:
with session.get("history_lock", threading.Lock()):
with _history_lock(session):
if kind == "clarify.request":
session["_compute_host_pending_clarify"] = dict(payload)
elif _pending_clarify_matches(session, request_id):
@@ -118,6 +108,10 @@ def _relay_compute_host_rpc(message: dict) -> bool:
return write_json(message)
def _history_lock(session: dict):
return session.get("history_lock", threading.Lock())
def _pending_clarify_matches(session: dict, request_id) -> bool:
"""Whether ``session``'s mirrored pending clarify is ``request_id``. Caller holds
history_lock."""
@@ -127,32 +121,26 @@ def _pending_clarify_matches(session: dict, request_id) -> bool:
def _compute_host_clarify_session(request_id: str) -> tuple[str, dict] | None:
"""Find the parent mirror for one host-owned clarify request."""
if not request_id:
return None
for sid, session in list(_sessions.items()):
with session.get("history_lock", threading.Lock()):
for sid, session in list(_sessions.items()) if request_id else ():
with _history_lock(session):
if _pending_clarify_matches(session, request_id):
return sid, session
return None
def _update_compute_host_clarify_snapshot(
sid: str, session: dict, params: dict, result: dict) -> None:
def _update_compute_host_clarify_snapshot(sid: str, session: dict, params: dict, result: dict) -> None:
"""Keep reconnect snapshots accurate while a batch clarify is answered."""
request_id = str(params.get("request_id") or "")
with session.get("history_lock", threading.Lock()):
question_id = str(params.get("question_id") or "")
with _history_lock(session):
if not _pending_clarify_matches(session, request_id):
return
pending = session["_compute_host_pending_clarify"]
expired = result.get("status") == "expired"
if expired or not result.get("remaining") and not params.get("question_id"):
if result.get("status") == "expired" or not result.get("remaining") and not question_id:
session.pop("_compute_host_pending_clarify", None)
return
question_id = str(params.get("question_id") or "")
if question_id and isinstance(result.get("remaining"), list):
answers = dict(pending.get("answers") or {})
answers[question_id] = str(params.get("answer") or "")
pending["answers"] = answers
elif question_id and isinstance(result.get("remaining"), list):
pending["answers"] = {**(pending.get("answers") or {}),
question_id: str(params.get("answer") or "")}
if not result["remaining"]:
session.pop("_compute_host_pending_clarify", None)
@@ -160,11 +148,9 @@ def _update_compute_host_clarify_snapshot(
def _respond_compute_host_clarify(rid: str, params: dict) -> dict | None:
"""Proxy a clarify answer into the process that owns its pending Event."""
located = _compute_host_clarify_session(str(params.get("request_id") or ""))
if located is None:
if located is None or not _session_uses_compute_host(located[1]):
return None
sid, session = located
if not _session_uses_compute_host(session):
return None
try:
ack = _get_compute_host_supervisor().respond(sid, params)
except Exception as exc:
@@ -176,9 +162,8 @@ def _respond_compute_host_clarify(rid: str, params: dict) -> dict | None:
return _err(rid, 5019, "compute-host clarify response returned an invalid response")
if "error" in response:
error = response["error"] if isinstance(response["error"], dict) else {}
return _err(
rid, int(error.get("code") or 5000),
str(error.get("message") or "clarify response failed"))
return _err(rid, int(error.get("code") or 5000),
str(error.get("message") or "clarify response failed"))
result = response.get("result")
if not isinstance(result, dict):
return _err(rid, 5019, "compute-host clarify response returned an invalid result")
@@ -191,7 +176,7 @@ def _apply_compute_host_metadata_mirror(session: dict, frame: dict | None) -> No
writer of live agent/history state, and UI reads must not build a second agent."""
if not isinstance(frame, dict):
return
with session.get("history_lock", threading.Lock()):
with _history_lock(session):
_compute_host_adopt_frame_meta(session, frame)
if frame.get("message_count") is not None:
with contextlib.suppress(Exception):
@@ -223,17 +208,15 @@ def _submit_prompt_to_compute_host(
rid: str, sid: str, session: dict, text: Any, image_paths: list[str] | None = None,
queued_prompt_generation: int | None = None, display_kind: str | None = None) -> dict:
cfg = _load_dashboard_process_isolation_config()
frame = _compute_host_turn_frame(
rid, sid, session, text, image_paths=image_paths,
queued_prompt_generation=queued_prompt_generation, display_kind=display_kind)
frame = _compute_host_turn_frame(rid, sid, session, text, image_paths=image_paths,
queued_prompt_generation=queued_prompt_generation,
display_kind=display_kind)
def _complete(done: dict) -> None:
# submit_turn reports a synchronous pipe failure via the callback before
# re-raising; leave the session untouched so prompt.submit can fail open
# to the in-process path without a duplicate terminal error.
if done.get("reason") == "send_failed":
return
_on_compute_host_turn_done(rid, sid, session, done)
# submit_turn reports a synchronous pipe failure via the callback before re-raising;
# leave the session untouched so prompt.submit can fail open to the in-process path.
if done.get("reason") != "send_failed":
_on_compute_host_turn_done(rid, sid, session, done)
try:
_get_compute_host_supervisor(cfg).submit_turn(frame, on_complete=_complete)
except Exception as exc:
@@ -271,29 +254,22 @@ def _compute_host_compress_wait_seconds(cfg: dict | None = None) -> float:
return float(min(max(ceiling + 30.0, 120.0), _COMPUTE_HOST_COMPRESS_WAIT_CAP_SECS))
def _announce_compute_host_compress_done(sid: str, session: dict, ack: dict) -> None:
"""Mirror a compress ack and push the ``session.info`` + ``compacted`` edges the
in-process /compress path emits, so a client whose RPC wait expired still learns."""
_apply_compute_host_metadata_mirror(session, ack)
_emit("session.info", sid, _compute_host_session_info(session))
_status_update(sid, "compacted", "✓ Context compression complete")
def _adopt_late_compute_host_compress_ack(
sid: str, session: dict, ack: dict, *, route_name: str) -> None:
"""Adopt a compress ack that arrived after its RPC waiter answered ``pending``: the
only place the rotated session_key / history_version / mirror can land and the
client's only signal. A late ``control.error`` goes out via ``error``."""
def _adopt_late_compute_host_compress_ack(sid: str, session: dict, ack: dict, *, route_name: str) -> None:
"""Adopt a compress ack that arrived after its RPC waiter answered ``pending``: the only place
the rotated session_key / history_version / mirror can land and the client's only signal
(the same ``session.info`` + ``compacted`` edges the in-process /compress path emits). A late
``control.error`` goes out via ``error``."""
with _sessions_lock:
live = _sessions.get(sid)
if live is not session:
return
if _sessions.get(sid) is not session:
return
if not isinstance(ack, dict) or ack.get("type") in {"control.error", "error"}:
message = str((ack or {}).get("message") or f"compute-host {route_name} failed")
_emit("error", sid, {"message": f"compression failed: {message}"})
_status_update(sid, "ready")
return
_announce_compute_host_compress_done(sid, session, ack)
_apply_compute_host_metadata_mirror(session, ack)
_emit("session.info", sid, _compute_host_session_info(session))
_status_update(sid, "compacted", "✓ Context compression complete")
def register(server) -> None:
+46 -82
View File
@@ -1,9 +1,8 @@
import os
import sys
# Stop a ``utils/`` (or ``proxy/``, ``ui/``) package in the launch directory from
# shadowing Hermes's own top-level modules. ``hermes_bootstrap`` lives at the repo
# root (its name can't collide with a user package), so importing it first is safe.
# Stop a ``utils/``-style package in the launch directory from shadowing Hermes's own
# top-level modules; ``hermes_bootstrap``'s name can't collide, so importing it first is safe.
import hermes_bootstrap
hermes_bootstrap.harden_import_path()
@@ -29,15 +28,13 @@ logger = logging.getLogger(__name__)
# Discovery thread spawned by THIS module; None when delegated to the shared owner in
# hermes_cli.mcp_startup (current path). The wait/in-flight/join helpers consult both.
_mcp_discovery_thread = None
# Set once MCP servers are found configured, so wait_for_mcp_discovery can re-invoke
# the idempotent spawn on later builds (retry-after-zero-connected) without a config
# re-probe — non-MCP sessions never pay the tools.mcp_tool import per build.
# Set once MCP servers are found configured so wait_for_mcp_discovery can re-invoke the
# idempotent spawn on later builds without a config re-probe.
_mcp_discovery_enabled = False
def _install_sidecar_publisher() -> None:
"""Mirror every dispatcher emit to the dashboard sidebar via WS when
`HERMES_TUI_SIDECAR_URL` is set (best-effort: a dropped WS falls back to stdio-only)."""
"""Mirror every dispatcher emit to the dashboard sidebar via WS when set (best-effort)."""
url = os.environ.get("HERMES_TUI_SIDECAR_URL")
if not url:
return
@@ -46,8 +43,7 @@ def _install_sidecar_publisher() -> None:
# Grace for orderly shutdown before ``os._exit(0)`` so a worker wedged mid-flush can't
# strand the process; ``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S`` overrides (a longer grace
# also means a longer wait on a real deadlock).
# strand the process; ``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S`` overrides.
_DEFAULT_SHUTDOWN_GRACE_S = 1.0
@@ -56,10 +52,6 @@ def _shutdown_grace_seconds() -> float:
return value if value > 0 else _DEFAULT_SHUTDOWN_GRACE_S
def _stamp() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
def _mcp_startup_call(name: str, *args, default=None, log=None, **kwargs):
"""Call ``hermes_cli.mcp_startup.<name>`` (lazy import); ``default`` on any failure,
optionally logged as ``(level, message)``."""
@@ -88,10 +80,9 @@ def _append_crash_log(header: str, dump=None) -> None:
def _log_signal(signum: int, frame) -> None:
"""Capture WHICH thread and WHERE a termination signal hit us, then exit.
``sys.exit(0)`` alone raced the worker pool (a thread holding ``_stdout_lock``
mid-flush blocks interpreter shutdown), so: log all thread stacks, give the
configured grace to drain, then ``os._exit(0)``."""
"""Capture WHICH thread and WHERE a termination signal hit us, then exit. ``sys.exit(0)``
alone raced the worker pool (a thread holding ``_stdout_lock`` mid-flush blocks interpreter
shutdown), so: log all thread stacks, give the configured grace to drain, then ``os._exit``."""
# SIGPIPE/SIGHUP don't exist on Windows — only look up attributes present.
names = {int(sig): attr for attr in ("SIGPIPE", "SIGTERM", "SIGHUP", "SIGINT", "SIGBREAK")
if (sig := getattr(signal, attr, None)) is not None}
@@ -106,41 +97,35 @@ def _log_signal(signum: int, frame) -> None:
f.write(f"\n--- thread {th.name} (id={tid}) ---\n")
f.write("".join(traceback.format_stack(sys._current_frames().get(tid))))
_append_crash_log(f"{name} received · {_stamp()}", _dump)
_append_crash_log(f"{name} received · {time.strftime('%Y-%m-%d %H:%M:%S')}", _dump)
print(f"[gateway-signal] {name}", file=sys.stderr, flush=True)
# ``os._exit`` skips atexit but breaks the mid-flush deadlock; the crash log
# + stderr line above are the forensic trail.
# ``os._exit`` skips atexit but breaks the mid-flush deadlock; the crash log is the trail.
timer = threading.Timer(_shutdown_grace_seconds(), lambda: os._exit(0))
timer.daemon = True
timer.start()
# atexit (_shutdown_sessions) can be blocked past the grace window by a worker
# holding the GIL/_stdout_lock; finalize explicitly so unpersisted messages reach
# state.db before the hard-exit timer fires.
# atexit (_shutdown_sessions) can be blocked past the grace window by a worker holding
# the GIL/_stdout_lock; finalize explicitly so unpersisted messages reach state.db first.
with suppress(Exception):
from tui_gateway.server import _shutdown_sessions
_shutdown_sessions()
# Unwind the main thread so atexit + finalisers run inside the grace window;
# the daemon timer is the safety net if that unwind hangs.
# Unwind the main thread so atexit + finalisers run; the daemon timer is the safety net.
sys.exit(0)
def _install_signal(signame, handler):
"""Install a signal handler if legal here: signal.signal() raises off the main
thread (Desktop build path: server._build imports entry from a worker), and
Windows lacks SIGPIPE/SIGHUP — both are skipped. Handlers are process-global."""
"""Install a signal handler if legal here: signal.signal() raises off the main thread
(Desktop build path imports entry from a worker) and Windows lacks SIGPIPE/SIGHUP."""
sig = getattr(signal, signame, None)
if sig is None or threading.current_thread() is not threading.main_thread():
return
# Off the main thread despite the check, or handler rejected by the platform.
with suppress(ValueError, OSError, RuntimeError):
with suppress(ValueError, OSError, RuntimeError): # platform rejected the handler
signal.signal(sig, handler)
# SIGPIPE: ignore, don't exit — SIG_DFL killed the process silently whenever a
# *background* thread (TTS, beep) wrote to a pipe the TUI had gone quiet on; ignoring
# lets write_json see BrokenPipeError and exit cleanly via _log_exit. Terminal signals
# route through _log_signal so kills/hangups are diagnosable (SIGBREAK = Windows SIGHUP).
# SIGPIPE: ignore, don't exit — SIG_DFL killed the process silently whenever a background
# thread wrote to a pipe the TUI had gone quiet on; ignoring lets write_json see
# BrokenPipeError and exit via _log_exit. Terminal signals route through _log_signal so
# kills/hangups are diagnosable (SIGBREAK = Windows SIGHUP).
_install_signal("SIGPIPE", signal.SIG_IGN)
_install_signal("SIGTERM", _log_signal)
if hasattr(signal, "SIGHUP"):
@@ -151,26 +136,23 @@ _install_signal("SIGINT", signal.SIG_IGN)
def _log_exit(reason: str) -> None:
"""Record why the gateway exits: every path collapses into a silent sys.exit(0),
and without this trail the TUI can't tell WHICH broken pipe triggered it."""
_append_crash_log(f"gateway exit · {_stamp()} · reason={reason}")
"""Record why the gateway exits (every path is a silent sys.exit(0) otherwise)."""
_append_crash_log(f"gateway exit · {time.strftime('%Y-%m-%d %H:%M:%S')} · reason={reason}")
print(f"[gateway-exit] {reason}", file=sys.stderr, flush=True)
def wait_for_mcp_discovery(timeout: "float | None" = None) -> None:
"""Block until background MCP discovery finishes, up to the resolved bound
(``mcp_discovery_timeout`` from config; ``timeout`` overrides). The agent snapshots
its tool list ONCE at build time, so this bounded join lets already-spawning
servers land without re-introducing the startup hang."""
"""Block until background MCP discovery finishes, up to the resolved bound (config
``mcp_discovery_timeout``; ``timeout`` overrides). The agent snapshots its tool list ONCE
at build time, so this bounded join lets already-spawning servers land."""
thread = _mcp_discovery_thread
if thread is not None and thread.is_alive():
fallback = timeout if timeout is not None else 0.75
bound = _mcp_startup_call("_resolve_discovery_timeout", timeout, default=fallback)
thread.join(timeout=bound)
return
# Shared-owner path: re-invoke the idempotent spawn first so a zero-connected run
# gets its retry instead of latching the process MCP-less. Runs under the CALLER's
# profile context (agent build binds the session profile's HERMES_HOME first).
# Shared-owner path: re-invoke the idempotent spawn first so a zero-connected run gets
# its retry instead of latching the process MCP-less (runs under the CALLER's profile).
if not _mcp_discovery_enabled:
return
_spawn_discovery(("debug", "TUI MCP discovery retry-spawn failed"))
@@ -178,10 +160,8 @@ def wait_for_mcp_discovery(timeout: "float | None" = None) -> None:
def mcp_discovery_in_flight() -> bool:
"""True if ANY background MCP discovery thread is still running. Two owners by
surface (stdio thread here, ``hermes_cli.mcp_startup`` for desktop/dashboard);
the late-refresh scheduler calls this regardless of surface, so it MUST consult
both or slow MCP servers' tools never surface on desktop."""
"""True if ANY background MCP discovery thread is still running: the late-refresh
scheduler calls this regardless of surface, so it MUST consult both owners."""
thread = _mcp_discovery_thread
if thread is not None and thread.is_alive():
return True
@@ -189,9 +169,8 @@ def mcp_discovery_in_flight() -> bool:
def join_mcp_discovery(timeout: float | None = None) -> bool:
"""Join both discovery owners; True once neither is alive. Unlike
``wait_for_mcp_discovery`` this accepts an unbounded wait (off-critical-path
late-refresh waiter); ``timeout`` bounds EACH join, entry thread first."""
"""Join both discovery owners; True once neither is alive. Accepts an unbounded wait
(off-critical-path late-refresh waiter); ``timeout`` bounds EACH join, entry thread first."""
entry_done = True
thread = _mcp_discovery_thread
if thread is not None:
@@ -211,13 +190,10 @@ def _has_configured_mcp_servers() -> bool:
def ensure_mcp_discovery_started() -> None:
"""Start background MCP discovery for the current profile context, once.
``main()`` calls this for stdio; WS/Desktop skip ``main()``, so
``server._start_agent_build`` also calls it AFTER binding the session profile's
HERMES_HOME so discovery reads the SELECTED profile's ``mcp_servers``. MCP
registration is process-global: the FIRST profile to build an agent wins."""
"""Start background MCP discovery for the current profile context, once. ``main()`` calls
this for stdio; ``server._start_agent_build`` also calls it AFTER binding the session
profile's HERMES_HOME. MCP registration is process-global: the FIRST profile wins."""
global _mcp_discovery_enabled
if not _has_configured_mcp_servers():
return
_mcp_discovery_enabled = True
@@ -233,40 +209,30 @@ def _write_or_exit(payload: dict, reason: str) -> None:
def main():
_install_sidecar_publisher()
# Heartbeat row lets the orphan sweep tell "live but idle" from "truly orphaned";
# it must run BEFORE the sweep. The sweep is once-per-process and config-gated.
# The heartbeat row lets the orphan sweep tell "live but idle" from "truly orphaned",
# so it must start BEFORE the sweep.
for start, what in (
(server._start_backend_heartbeat_refresher, "backend heartbeat refresher start"),
(server._schedule_startup_orphan_sweep, "startup orphan sweep scheduling"),
):
(server._start_backend_heartbeat_refresher, "backend heartbeat refresher start"),
(server._schedule_startup_orphan_sweep, "startup orphan sweep scheduling")):
try:
start()
except Exception:
logger.warning("%s failed", what, exc_info=True)
# Backgrounded so a dead MCP server (~7s of retries) can't freeze startup;
# _make_agent briefly joins it. The config gate keeps the MCP SDK import off
# the no-mcp_servers path.
# Backgrounded so a dead MCP server can't freeze startup; _make_agent briefly joins it.
ensure_mcp_discovery_started()
# change_events: clients demote legacy polls; replay_epoch: WS restart detection.
_write_or_exit({
"jsonrpc": "2.0",
"method": "event",
"params": {
"type": "gateway.ready",
# change_events: clients demote legacy polls (see tui_gateway/ws.py).
# replay_epoch: WS restart detection; the stdio TUI ignores it.
"payload": {
"skin": resolve_skin(), "change_events": True, "replay_epoch": replay_epoch(),
},
},
}, "startup write failed (broken stdout pipe before first event)")
"jsonrpc": "2.0", "method": "event",
"params": {"type": "gateway.ready", "payload": {
"skin": resolve_skin(), "change_events": True, "replay_epoch": replay_epoch()}}},
"startup write failed (broken stdout pipe before first event)")
# Live-apply skins Hermes activates mid-conversation.
server._ensure_skin_watcher()
# Warm the /model picker's provider-models cache in this idle window, else the
# first /model open blocks on serial /v1/models fetches. Fire-and-forget.
# Warm the /model picker's provider-models cache in this idle window (fire-and-forget).
try:
from hermes_cli.model_switch import prewarm_picker_cache_async
prewarm_picker_cache_async()
@@ -280,11 +246,9 @@ def main():
if not handle_spurious_eof(_recovery_times, _log_exit):
break
continue
line = raw.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError:
+20 -37
View File
@@ -1,11 +1,8 @@
"""Git working-tree probing for the gateway: run git, resolve repo roots, fold linked worktrees.
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.
"""
single-flight cache so concurrent identical probes share one ``git`` spawn: positives live for the
process, negatives (not a repo / deleted dir) for ``_NEG_TTL`` — hundreds of non-git session cwds
would otherwise re-spawn ``git`` on every sidebar open, while the TTL keeps ``git init`` re-probable."""
from __future__ import annotations
@@ -19,19 +16,14 @@ from hermes_cli._subprocess_compat import bounded_git_probe
_GIT_TIMEOUT = 1.5
_WARM_WORKERS = 8
# "Not a git repo" TTL: short enough that a fresh `git init` shows within seconds,
# long enough to collapse a tree build's hundreds of redundant probes.
_NEG_TTL = 30.0
_NEG_TTL = 30.0 # "not a git repo" TTL: a fresh `git init` shows within seconds
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 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 <cwd> <args>`` → stripped stdout, or ``""`` on any failure. ``bounded_git_probe``
bounds post-kill cleanup on Windows (a killed git's suspended descendant held the pipes)."""
# 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)
@@ -60,8 +52,7 @@ class _RootCache:
def resolve(self, key: str, probe) -> str:
while True:
with self._lock:
hit = self._roots.get(key)
if hit:
if hit := self._roots.get(key):
return hit
expiry = self._neg.get(key)
if expiry is not None:
@@ -72,8 +63,7 @@ class _RootCache:
leader = gate is None
if leader:
gate = self._inflight[key] = threading.Event()
if not leader:
# Another thread is probing this key — wait, then re-read.
if not leader: # another thread is probing this key — wait, then re-read
gate.wait(timeout=_GIT_TIMEOUT + 0.5)
continue
value = ""
@@ -100,21 +90,16 @@ def invalidate() -> None:
def repo_root(cwd: str) -> str:
"""Top-level git repo root for ``cwd`` (``""`` when not a repo)."""
if not cwd:
return ""
return _cache.resolve(cwd, lambda: run_git(cwd, "rev-parse", "--show-toplevel"))
return _cache.resolve(cwd, lambda: run_git(cwd, "rev-parse", "--show-toplevel")) if cwd else ""
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 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.
"""The MAIN (common) repo root for ``cwd``, folding linked worktrees: ``--show-toplevel`` is a
linked worktree's OWN root; the parent of the shared ``--git-common-dir`` is the one true root
(fallback: toplevel). Normalized to git's forward-slash spelling so it compares equal to
:func:`repo_root` (native ``\\`` on Windows made the main checkout look like a worktree)."""
# 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 ""
@@ -142,10 +127,8 @@ def warm_roots(cwds: Iterable[str], max_workers: int = _WARM_WORKERS) -> None:
"""Pre-resolve many cwds' roots in parallel (bounded) so a cold first paint
doesn't serialize one git spawn per session cwd; results land in the cache."""
pending = sorted({(cwd or "").strip() for cwd in cwds} - {""})
if not pending:
return
if len(pending) == 1:
resolve(pending[0])
return
with ThreadPoolExecutor(max_workers=min(max_workers, len(pending))) as pool:
list(pool.map(resolve, pending))
elif pending:
with ThreadPoolExecutor(max_workers=min(max_workers, len(pending))) as pool:
list(pool.map(resolve, pending))
+110 -171
View File
@@ -1,9 +1,6 @@
"""Supervisor for the dashboard compute-host child process.
When ``dashboard.turn_isolation`` is enabled, agent turns move behind one persistent
``python -m tui_gateway.compute_host`` child so compute-heavy agent threads do not
contend with the serving process' event loop for the same GIL.
"""
"""Supervisor for the dashboard compute-host child: with ``dashboard.turn_isolation``
agent turns run in one persistent ``python -m tui_gateway.compute_host`` child so heavy
agent threads do not contend with the serving process' event loop for the GIL."""
from __future__ import annotations
@@ -26,7 +23,6 @@ from hermes_constants import get_hermes_home
from tools.environments.local import hermes_subprocess_env
logger = logging.getLogger(__name__)
_Thread = threading.Thread
MUTATOR_ROUTE_TABLE: dict[str, str] = {
"prompt.submit": "turn-path", "session.interrupt": "turn-path", "reload.mcp": "run-concurrent",
@@ -39,9 +35,8 @@ MUTATOR_ROUTE_TABLE: dict[str, str] = {
_REGISTRY_NAME = "dashboard-compute-host.json"
_RESPAWN_WINDOW_SECS = 300.0
_SHUTDOWN_TIMEOUT_SECS = 10.0
# Late control-ack handlers: a compress that outlives its RPC waiter can run for the
# full compression ceiling plus a stall-fallback retry, so keep registrations well
# past that — but bounded.
# Late control-ack handlers: a compress that outlives its RPC waiter can run for the full
# compression ceiling plus a stall-fallback retry, so keep registrations past that — bounded.
_LATE_CONTROL_TTL_SECS = 1800.0
_LATE_CONTROL_MAX = 64
# Host frames whose ``request_id`` resolves a pending/late control waiter.
@@ -52,10 +47,9 @@ _CONTROL_REPLY_TYPES = frozenset({
def append_log_record(path: str | Path, record: str) -> None:
"""Append one log record using O_APPEND and exactly one os.write call."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
Path(path).parent.mkdir(parents=True, exist_ok=True)
text = record if record.endswith("\n") else f"{record}\n"
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
os.write(fd, text.encode("utf-8", errors="replace"))
finally:
@@ -68,31 +62,45 @@ def _repo_root() -> Path:
def _check_output(argv: list[str], **kwargs: Any) -> str:
"""Stripped stdout of a short subprocess, or ``""`` on any failure."""
try:
with contextlib.suppress(Exception):
return subprocess.check_output(
argv, text=True, encoding="utf-8", errors="replace", stderr=subprocess.DEVNULL,
timeout=2, **kwargs).strip()
except Exception:
return ""
return ""
def _build_sha() -> str:
"""Current checkout's HEAD sha, or ``"unknown"``. Shared with ``compute_host`` so
the hello handshake and the supervisor's expectation agree byte-for-byte."""
"""HEAD sha or ``"unknown"``; shared with ``compute_host`` so the hello handshake agrees."""
return _check_output(["git", "rev-parse", "HEAD"], cwd=str(_repo_root())) or "unknown"
def _call_logged(cb: Callable[[dict], None], frame: dict, failure: str) -> None:
"""Invoke a host-frame callback; a raising callback is logged, never propagated."""
try:
cb(frame)
except Exception:
logger.exception(failure)
def _pid_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
return True
except Exception as exc:
return isinstance(exc, PermissionError)
def _signal_pid(pid: int, sig: int, label: str) -> bool:
"""Send ``sig``; False when the pid is gone or the signal failed (logged)."""
try:
os.kill(pid, sig)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
except Exception:
logger.debug("failed to %s compute host pid=%s", label, pid, exc_info=True)
return False
@@ -128,10 +136,9 @@ class HostSupervisor:
self.rpc_sink = rpc_sink or (lambda _obj: None)
self.respawn_max = max(0, int(respawn_max))
self.heartbeat_secs = max(1, int(heartbeat_secs))
self.expected_build_sha = (
expected_build_sha if expected_build_sha is not None else _build_sha())
self.expected_build_sha = _build_sha() if expected_build_sha is None else expected_build_sha
self.expected_hermes_home = (
expected_hermes_home if expected_hermes_home is not None else str(get_hermes_home()))
str(get_hermes_home()) if expected_hermes_home is None else expected_hermes_home)
self._lock = threading.RLock()
self._proc: subprocess.Popen[str] | None = None
self._hello_event = threading.Event()
@@ -141,9 +148,8 @@ class HostSupervisor:
self._restart_times: list[float] = []
self._pending_turns: dict[str, tuple[str, Callable[[dict], None] | None]] = {}
self._pending_controls: dict[str, queue.Queue[dict]] = {}
# request_id -> (registered_at, handler) for control waiters that timed out
# while their host work still runs; without it the eventual control.ack
# matched no queue and was silently dropped.
# request_id -> (registered_at, handler) for control waiters that timed out while their
# host work still runs, so the eventual control.ack is not silently dropped.
self._late_control_handlers: dict[str, tuple[float, Callable[[dict], None]]] = {}
self._stderr_tail: list[str] = []
self._last_progress_counter = 0
@@ -155,10 +161,6 @@ class HostSupervisor:
proc = self._proc
return int(proc.pid or 0) if proc is not None else 0
@property
def hello(self) -> dict[str, Any]:
return dict(self._hello)
def is_running(self) -> bool:
proc = self._proc
return proc is not None and proc.poll() is None and not self._stopped_respawning
@@ -193,25 +195,24 @@ class HostSupervisor:
except FileNotFoundError:
return "none"
except Exception:
self._remove_registry()
return "invalid-registry"
data = None
try:
pid = int(data.get("host_pid") or 0)
pid = int((data or {}).get("host_pid") or 0)
except Exception:
pid = 0
if pid <= 0 or not _pid_alive(pid):
self._remove_registry()
return "not-running"
if not self._pid_matches_compute_host(pid):
# PID was reused by another process. Never signal it.
self._remove_registry()
return "pid-reuse-ignored"
self._terminate_pid(pid, timeout=_SHUTDOWN_TIMEOUT_SECS)
if data is None:
outcome = "invalid-registry"
elif pid <= 0 or not _pid_alive(pid):
outcome = "not-running"
elif not self._pid_matches_compute_host(pid):
outcome = "pid-reuse-ignored" # PID reused by another process: never signal it
else:
self._terminate_pid(pid, timeout=_SHUTDOWN_TIMEOUT_SECS)
outcome = "terminated"
self._remove_registry()
return "terminated"
return outcome
def submit_turn(
self, frame: dict[str, Any], *, on_complete: Callable[[dict], None] | None = None) -> str:
def submit_turn(self, frame: dict[str, Any], *, on_complete: Callable[[dict], None] | None = None) -> str:
self.start()
request_id = str(frame.get("request_id") or uuid.uuid4().hex)
sid = str(frame.get("sid") or "")
@@ -224,16 +225,15 @@ class HostSupervisor:
with self._lock:
self._pending_turns.pop(request_id, None)
if on_complete is not None:
on_complete({
"type": "turn.error", "sid": sid, "request_id": request_id,
"reason": "send_failed", "message": str(exc)})
on_complete({"type": "turn.error", "sid": sid, "request_id": request_id,
"reason": "send_failed", "message": str(exc)})
raise
return request_id
def interrupt(self, sid: str, *, request_id: str | None = None) -> None:
self.start()
self._send_frame({
"type": "interrupt", "sid": sid, "request_id": request_id or uuid.uuid4().hex})
self._send_frame(
{"type": "interrupt", "sid": sid, "request_id": request_id or uuid.uuid4().hex})
def _await_reply(self, frame: dict[str, Any], request_id: str, timeout: float) -> dict:
"""Send ``frame`` and block for the host reply carrying ``request_id``."""
@@ -255,29 +255,24 @@ class HostSupervisor:
return self._await_reply(frame, request_id, timeout)
def reload_mcp(self, sid: str, *, request_id: str | None = None) -> dict:
return self.control(
sid, route_name="reload.mcp", wait=True,
payload={
"type": "reload_mcp", "sid": sid, "request_id": request_id or uuid.uuid4().hex})
payload = {"type": "reload_mcp", "sid": sid, "request_id": request_id or uuid.uuid4().hex}
return self.control(sid, route_name="reload.mcp", wait=True, payload=payload)
def control(
self, sid: str, *, route_name: str, payload: dict[str, Any] | None = None,
wait: bool = True, timeout: float = 30.0, on_late_ack: Callable[[dict], None] | None = None,
) -> dict:
"""Send a control frame; with ``wait`` block up to ``timeout`` for its ack.
``on_late_ack`` (only with ``wait``) keeps the request adoptable after the
waiter gives up: the host's eventual ``control.ack``/``control.error``/``error``
for this ``request_id`` fires the handler once instead of being dropped.
Bounded by ``_LATE_CONTROL_TTL_SECS`` / ``_LATE_CONTROL_MAX``.
"""
"""Send a control frame; with ``wait`` block up to ``timeout`` for its ack. ``on_late_ack``
(only with ``wait``) keeps the request adoptable after the waiter gives up: the host's
eventual ``control.ack``/``control.error``/``error`` fires it once (bounded by
``_LATE_CONTROL_TTL_SECS``/``_MAX``) instead of being dropped."""
if route_name not in MUTATOR_ROUTE_TABLE:
raise ValueError(f"unclassified host mutator route: {route_name}")
self.start()
request_id = str((payload or {}).get("request_id") or uuid.uuid4().hex)
frame = {
"type": "control", **(payload or {}), "sid": sid, "route_name": route_name,
"request_id": request_id}
payload = payload or {}
request_id = str(payload.get("request_id") or uuid.uuid4().hex)
frame = {"type": "control", **payload, "sid": sid, "route_name": route_name,
"request_id": request_id}
if not wait:
self._send_frame(frame)
return {"status": "sent", "request_id": request_id}
@@ -288,13 +283,11 @@ class HostSupervisor:
self._register_late_control_handler(request_id, on_late_ack)
raise
def _register_late_control_handler(
self, request_id: str, handler: Callable[[dict], None]) -> None:
def _register_late_control_handler(self, request_id: str, handler: Callable[[dict], None]) -> None:
now = time.monotonic()
with self._lock:
handlers = self._late_control_handlers
expired = [r for r, (at, _cb) in handlers.items() if now - at > _LATE_CONTROL_TTL_SECS]
for rid in expired:
for rid in [r for r, (at, _cb) in handlers.items() if now - at > _LATE_CONTROL_TTL_SECS]:
handlers.pop(rid, None)
while len(handlers) >= _LATE_CONTROL_MAX:
handlers.pop(min(handlers, key=lambda rid: handlers[rid][0]), None)
@@ -307,41 +300,30 @@ class HostSupervisor:
if q is not None:
with contextlib.suppress(queue.Full):
q.put_nowait(frame)
return
if late is None:
return
try:
late[1](frame)
except Exception:
logger.exception(
"compute host late control ack handler failed (request_id=%s)", request_id)
elif late is not None:
_call_logged(late[1], frame, f"compute host late control ack handler failed (request_id={request_id})")
def _spawn_locked(self, *, reason: str) -> None:
if self._stopped_respawning:
raise RuntimeError("compute host respawn disabled after crash loop")
self._hello_event.clear()
self._hello = {}
env = hermes_subprocess_env(inherit_credentials=True)
env.update(os.environ)
if self.env:
env.update(self.env)
env = {**hermes_subprocess_env(inherit_credentials=True), **os.environ, **(self.env or {})}
env["HERMES_COMPUTE_HOST_HEARTBEAT_SECS"] = str(self.heartbeat_secs)
root = str(_repo_root())
env.setdefault("PYTHONPATH", root)
if root not in env["PYTHONPATH"].split(os.pathsep):
env["PYTHONPATH"] = root + os.pathsep + env["PYTHONPATH"]
# Lossy UTF-8 decode: a locale-mismatched byte must not raise inside the drain threads.
proc = subprocess.Popen(
self.argv, cwd=str(self.cwd), env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True,
# Lossy UTF-8 decode: a locale-mismatched byte must not raise inside the
# drain threads and kill the supervisor.
encoding="utf-8", errors="replace", bufsize=1, start_new_session=True)
stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", bufsize=1,
start_new_session=True)
self._proc = proc
for target, name in (
(self._drain_stdout, "compute-host-stdout"),
(self._drain_stderr, "compute-host-stderr"), (self._wait_for_exit, "compute-host-wait"),
):
_Thread(target=target, args=(proc,), name=name, daemon=True).start()
for target, name in ((self._drain_stdout, "compute-host-stdout"),
(self._drain_stderr, "compute-host-stderr"),
(self._wait_for_exit, "compute-host-wait")):
threading.Thread(target=target, args=(proc,), name=name, daemon=True).start()
if not self._hello_event.wait(timeout=10.0):
self._terminate_process(proc)
raise RuntimeError(f"compute host did not send hello; stderr={self._stderr_tail[-5:]}")
@@ -365,10 +347,9 @@ class HostSupervisor:
def _persist_registry(self) -> None:
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.registry_path.with_suffix(self.registry_path.suffix + ".tmp")
payload = {
"host_pid": self.pid, "boot_id": self._hello.get("boot_id") or "",
"build_sha": self._hello.get("build_sha") or "", "started_at": time.time(),
"argv": self.argv}
payload = {"host_pid": self.pid, "boot_id": self._hello.get("boot_id") or "",
"build_sha": self._hello.get("build_sha") or "", "started_at": time.time(),
"argv": self.argv}
tmp.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
tmp.replace(self.registry_path)
@@ -400,48 +381,29 @@ class HostSupervisor:
def _drain_stderr(self, proc: subprocess.Popen[str]) -> None:
assert proc.stderr is not None
for raw in proc.stderr:
text = raw.rstrip("\n")
if text:
if text := raw.rstrip("\n"):
self._stderr_tail = (self._stderr_tail + [text])[-80:]
logger.warning("compute host stderr: %s", text)
def _handle_host_frame(self, frame: dict[str, Any]) -> None:
ftype = str(frame.get("type") or "")
if ftype in _CONTROL_REPLY_TYPES or (ftype == "error" and frame.get("request_id")):
self._deliver_control_frame(str(frame.get("request_id") or ""), frame)
return
handler = self._HOST_FRAME_HANDLERS.get(ftype)
if handler is not None:
getattr(self, handler)(frame)
# host frame ``type`` -> handler method name (see also _CONTROL_REPLY_TYPES).
_HOST_FRAME_HANDLERS: dict[str, str] = {
"hello": "_on_hello", "hb": "_on_heartbeat", "rpc": "_on_rpc", "turn.end": "_complete_turn",
"turn.error": "_complete_turn"}
def _on_hello(self, frame: dict[str, Any]) -> None:
self._hello = dict(frame)
self._hello_event.set()
def _on_heartbeat(self, frame: dict[str, Any]) -> None:
self._last_progress_counter = int(
frame.get("progress_counter") or self._last_progress_counter)
logger.debug("compute host heartbeat: %s", frame)
def _on_rpc(self, frame: dict[str, Any]) -> None:
message = frame.get("message")
if isinstance(message, dict):
self.rpc_sink(message)
def _complete_turn(self, frame: dict[str, Any]) -> None:
request_id = str(frame.get("request_id") or "")
with self._lock:
pending = self._pending_turns.pop(request_id, None)
if pending is not None and pending[1] is not None:
try:
pending[1](frame)
except Exception:
logger.exception("compute host turn completion callback failed")
if ftype in _CONTROL_REPLY_TYPES or (ftype == "error" and request_id):
self._deliver_control_frame(request_id, frame)
elif ftype == "hello":
self._hello = dict(frame)
self._hello_event.set()
elif ftype == "hb":
self._last_progress_counter = int(frame.get("progress_counter") or self._last_progress_counter)
logger.debug("compute host heartbeat: %s", frame)
elif ftype == "rpc":
if isinstance(frame.get("message"), dict):
self.rpc_sink(frame["message"])
elif ftype in ("turn.end", "turn.error"):
with self._lock:
pending = self._pending_turns.pop(request_id, None)
if pending is not None and pending[1] is not None:
_call_logged(pending[1], frame, "compute host turn completion callback failed")
def _wait_for_exit(self, proc: subprocess.Popen[str]) -> None:
code = proc.wait()
@@ -459,32 +421,21 @@ class HostSupervisor:
with self._lock:
pending = self._pending_turns
self._pending_turns = {}
failure = {"reason": reason, "message": message}
for request_id, (sid, cb) in pending.items():
self.rpc_sink({
"jsonrpc": "2.0",
"method": "event",
"params": {
"type": "error", "session_id": sid,
"payload": {"message": message, "reason": reason}}})
self.rpc_sink({"jsonrpc": "2.0", "method": "event",
"params": {"type": "error", "session_id": sid, "payload": dict(failure)}})
if cb is not None:
try:
cb({
"type": "turn.error", "sid": sid, "request_id": request_id,
"reason": reason, "message": message})
except Exception:
logger.exception("compute host error callback failed")
# A crashed host never emits the late acks timed-out control waiters still
# expect; fail them too so the client's "still running" notice can't hang.
frame = {"type": "turn.error", "sid": sid, "request_id": request_id, **failure}
_call_logged(cb, frame, "compute host error callback failed")
# A crashed host never emits the late acks timed-out control waiters still expect; fail
# them too so the client's "still running" notice can't hang.
with self._lock:
late = self._late_control_handlers
self._late_control_handlers = {}
for request_id, (_registered_at, handler) in late.items():
try:
handler({
"type": "control.error", "request_id": request_id, "reason": reason,
"message": message})
except Exception:
logger.exception("compute host late control error handler failed")
frame = {"type": "control.error", "request_id": request_id, **failure}
_call_logged(handler, frame, "compute host late control error handler failed")
def _maybe_respawn_after_crash(self) -> None:
now = time.monotonic()
@@ -508,42 +459,30 @@ class HostSupervisor:
self._spawn_locked(reason="crash")
except Exception:
logger.exception("compute host respawn failed")
_Thread(target=_respawn, name="compute-host-respawn", daemon=True).start()
threading.Thread(target=_respawn, name="compute-host-respawn", daemon=True).start()
_pid_matches_compute_host = staticmethod(is_compute_host_identity)
def _terminate_pid(self, pid: int, *, timeout: float = _SHUTDOWN_TIMEOUT_SECS) -> None:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return
except Exception:
logger.debug("failed to SIGTERM compute host pid=%s", pid, exc_info=True)
if not _signal_pid(pid, signal.SIGTERM, "SIGTERM"):
return
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not _pid_alive(pid):
while _pid_alive(pid):
if time.monotonic() >= deadline:
_signal_pid(pid, signal.SIGKILL, "SIGKILL")
return
time.sleep(0.05)
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
return
except Exception:
logger.debug("failed to SIGKILL compute host pid=%s", pid, exc_info=True)
def _terminate_process(self, proc: subprocess.Popen[str]) -> None:
if proc.poll() is not None:
return
try:
with contextlib.suppress(Exception):
proc.terminate()
proc.wait(timeout=_SHUTDOWN_TIMEOUT_SECS)
return
except Exception:
pass
with contextlib.suppress(Exception):
proc.kill()
with contextlib.suppress(Exception):
proc.wait(timeout=2)
for step in (proc.kill, lambda: proc.wait(timeout=2)):
with contextlib.suppress(Exception):
step()
__all__ = ["MUTATOR_ROUTE_TABLE", "HostSupervisor", "append_log_record", "is_compute_host_identity"]
+184 -290
View File
@@ -1,12 +1,10 @@
"""Runtime adapter for gateway-owned hosted room turns.
The durable state machine lives in :mod:`gateway.hosted_room_driver`. This module
owns the process-local worker and a small injected session adapter (seven methods);
it never imports the gateway server or constructs agents. One bounded supervisor
schedules independent room workers: profile turn locks still serialize Bots sharing
a profile, while a room waiting for approval cannot stall unrelated rooms. Hosted
member sessions reuse ``Group: <room_id>`` so a local-to-hosted migration keeps one
canonical transcript instead of forking a second conversation.
The durable state machine lives in :mod:`gateway.hosted_room_driver`; this module owns the
process-local worker and an injected session adapter, never the gateway server or agents.
One bounded supervisor schedules independent room workers: profile turn locks serialize Bots
sharing a profile while a room waiting for approval cannot stall unrelated rooms. Member
sessions reuse ``Group: <room_id>`` so a local-to-hosted migration keeps one transcript.
"""
from __future__ import annotations
@@ -33,36 +31,28 @@ _STOP_PENDING = "stop retry remains pending: {exc}"
class InternalSessionRPC(Protocol):
"""Normalized in-process session operations required by the room driver."""
"""Normalized in-process session operations required by the room driver.
def resolve_exact(self, *, profile: str, title: str, source: str) -> Mapping[str, Any] | None:
"""Return the exact titled session under ``profile``, if it exists."""
def create(self, *, profile: str, title: str, source: str) -> Mapping[str, Any]:
"""Create a session without model or provider overrides."""
def resume(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]:
"""Resume the canonical room session."""
``submit`` durably reports one fenced turn's terminal result via ``on_terminal``;
``interrupt`` acts only while the current turn still matches ``expected_task_id``.
"""
def resolve_exact(
self, *, profile: str, title: str, source: str) -> Mapping[str, Any] | None: ...
def create(self, *, profile: str, title: str, source: str) -> Mapping[str, Any]: ...
def resume(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]: ...
def submit(
self, *, profile: str, session_id: str, prompt: str, source: str, task: state.TaskIdentity,
execution_generation: int, on_terminal: Callable[[Mapping[str, Any]], None],
) -> Mapping[str, Any]:
"""Submit one fenced room turn and durably report its terminal result."""
def history(self, *, profile: str, session_id: str, source: str) -> Sequence[Mapping[str, Any]]:
"""Return normalized session messages in durable order."""
def info(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]:
"""Return normalized live status for a session."""
) -> Mapping[str, Any]: ...
def history(
self, *, profile: str, session_id: str, source: str) -> Sequence[Mapping[str, Any]]: ...
def info(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]: ...
def interrupt(
self, *, profile: str, session_id: str, source: str, expected_task_id: str
) -> Mapping[str, Any] | None:
"""Interrupt only when the current turn still matches the expected task."""
self, *, profile: str, session_id: str, source: str, expected_task_id: str,
) -> Mapping[str, Any] | None: ...
# Resolve the local or peer session transport for one durable room task.
MemberTransportResolver = Callable[["HostedRoomBinding", Mapping[str, Any]], InternalSessionRPC]
@@ -97,9 +87,8 @@ def _session_kw(profile: str, session_id: str) -> dict[str, str]:
def _fences(task: Mapping[str, Any]) -> dict[str, int]:
return {
"expected_execution_generation": int(task["execution_generation"]),
"expected_cancel_generation": int(task["cancel_generation"])}
return {"expected_execution_generation": int(task["execution_generation"]),
"expected_cancel_generation": int(task["cancel_generation"])}
class HostedRoomRuntime:
@@ -119,19 +108,16 @@ class HostedRoomRuntime:
indeterminate_defer_seconds: float = 60.0, max_concurrent_rooms: int = 4,
unavailable_retry_min_seconds: float = 1.0, unavailable_retry_max_seconds: float = 30.0,
process_generation: str | None = None) -> None:
for name, value in (
("lease_ttl_seconds", lease_ttl_seconds),
("poll_interval_seconds", poll_interval_seconds),
("active_poll_interval_seconds", active_poll_interval_seconds),
("turn_timeout_seconds", turn_timeout_seconds),
("indeterminate_defer_seconds", indeterminate_defer_seconds)):
positive = dict(
lease_ttl_seconds=lease_ttl_seconds, poll_interval_seconds=poll_interval_seconds,
active_poll_interval_seconds=active_poll_interval_seconds,
turn_timeout_seconds=turn_timeout_seconds,
indeterminate_defer_seconds=indeterminate_defer_seconds)
for name, value in positive.items():
if value <= 0:
raise ValueError(f"{name} must be positive")
if (
not isinstance(max_concurrent_rooms, int)
or isinstance(max_concurrent_rooms, bool)
or max_concurrent_rooms < 1
):
if (not isinstance(max_concurrent_rooms, int) or isinstance(max_concurrent_rooms, bool)
or max_concurrent_rooms < 1):
raise ValueError("max_concurrent_rooms must be a positive integer")
if not 0 < unavailable_retry_min_seconds <= unavailable_retry_max_seconds:
raise ValueError("unavailable retry bounds are invalid")
@@ -141,22 +127,17 @@ class HostedRoomRuntime:
self.rpc, self.transport_resolver, self.turn_lock = rpc, transport_resolver, turn_lock
self.prepare_room, self.publish_terminal = prepare_room, publish_terminal
self.pending_action, self.clock = pending_action, clock
self.lease_ttl_seconds = float(lease_ttl_seconds)
self.poll_interval_seconds = float(poll_interval_seconds)
self.active_poll_interval_seconds = float(active_poll_interval_seconds)
self.turn_timeout_seconds = float(turn_timeout_seconds)
self.indeterminate_defer_seconds = float(indeterminate_defer_seconds)
for name, value in positive.items():
setattr(self, name, float(value))
self.max_concurrent_rooms = max_concurrent_rooms
self.unavailable_retry_min_seconds = float(unavailable_retry_min_seconds)
self.unavailable_retry_max_seconds = float(unavailable_retry_max_seconds)
self.unavailable_retry_min_seconds, self.unavailable_retry_max_seconds = (
float(unavailable_retry_min_seconds), float(unavailable_retry_max_seconds))
self.process_generation = process_generation or uuid.uuid4().hex
self._rooms_provider: Callable[[], Iterable[HostedRoomBinding]] = (
cast(Callable[[], Iterable[HostedRoomBinding]], rooms)
if callable(rooms)
cast(Callable[[], Iterable[HostedRoomBinding]], rooms) if callable(rooms)
else (lambda bindings=tuple(rooms): bindings))
self._stop, self._wake = threading.Event(), threading.Event()
self._thread: threading.Thread | None = None
self._thread = self._last_error = None
self._room_threads: dict[str, threading.Thread] = {}
self._rooms_needing_reschedule: set[str] = set()
self._leases: dict[str, state.DriverLease] = {}
@@ -165,10 +146,8 @@ class HostedRoomRuntime:
self._ambiguous_rooms: dict[str, float] = {}
self._unavailable_route_retries: dict[tuple[str, str], dict[str, float]] = {}
self._blocked_rooms: set[str] = set()
self._status_lock = threading.Lock()
self._current_tasks: dict[str, state.TaskIdentity] = {}
self._status_lock, self._current_tasks = threading.Lock(), {}
self._room_schedule_cursor, self._cycles = 0, 0
self._last_error: str | None = None
# ------------------------------------------------------------------ lifecycle
def start(self) -> None:
@@ -196,14 +175,13 @@ class HostedRoomRuntime:
room_threads = tuple(self._room_threads.values())
for room_thread in room_threads:
room_thread.join(max(0.0, deadline - time.monotonic()))
return not thread.is_alive() and all(not t.is_alive() for t in room_threads)
return not any(t.is_alive() for t in (thread, *room_threads))
def wakeup(self) -> None:
"""Wake the worker after task admission or a room-state change."""
with self._status_lock:
# Rooms that still own a worker slot are revisited once that thread
# exits, closing the race between terminal publication/route repair
# and the longer idle fallback without busy-looping idle rooms.
# Rooms still owning a worker slot are revisited once that thread exits, closing
# the terminal-publication/route-repair race without busy-looping idle rooms.
self._rooms_needing_reschedule.update(self._room_threads)
self._wake.set()
@@ -213,24 +191,19 @@ class HostedRoomRuntime:
thread = self._thread
current_tasks = tuple(self._current_tasks.values())
return {
"running": bool(thread and thread.is_alive()),
"stopping": self._stop.is_set(),
"running": bool(thread and thread.is_alive()), "stopping": self._stop.is_set(),
"process_generation": self.process_generation,
"current_task": current_tasks[0] if current_tasks else None,
"current_tasks": current_tasks,
"leased_rooms": tuple(sorted(self._leases)),
"current_tasks": current_tasks, "leased_rooms": tuple(sorted(self._leases)),
"blocked_rooms": tuple(sorted(self._blocked_rooms)),
"last_error": self._last_error,
"cycles": self._cycles}
"last_error": self._last_error, "cycles": self._cycles}
# ------------------------------------------------------------------ public ops
def cancel(self, identity: state.TaskIdentity, *, cancel_id: str) -> dict[str, Any]:
"""Persist a stop intent, then commit cancellation after acknowledgement.
The worker thread transitions tasks concurrently (queued -> running ->
terminal), so the status read is only a routing hint: every fast-path
failure caused by a concurrent transition re-reads and re-routes instead
of surfacing a transient `InvalidTaskTransitionError`/`StaleTaskError`.
The worker transitions tasks concurrently, so the status read is only a routing
hint: a fast-path fence failure re-reads and re-routes instead of surfacing it.
"""
for _ in range(_CANCEL_ROUTE_RETRIES):
before = state.get_task(self.db_path, identity)
@@ -239,34 +212,27 @@ class HostedRoomRuntime:
if before["status"] in state.TERMINAL_STATUSES:
raise state.InvalidTaskTransitionError(
f"cannot cancel task in state '{before['status']}'")
fenced = dict(
cancel_id=cancel_id, expected_cancel_generation=before["cancel_generation"],
clock=self.clock)
if before["status"] in {"queued", "deferred"}:
try:
cancelled = state.cancel_task(self.db_path, identity, **fenced)
except (state.InvalidTaskTransitionError, state.StaleTaskError):
continue # lost the race with the worker; re-route
self.wakeup()
return cancelled
direct = before["status"] in {"queued", "deferred"}
try:
stopping = state.begin_task_cancel(self.db_path, identity, **fenced)
result = (state.cancel_task if direct else state.begin_task_cancel)(
self.db_path, identity, cancel_id=cancel_id,
expected_cancel_generation=before["cancel_generation"], clock=self.clock)
except (state.InvalidTaskTransitionError, state.StaleTaskError):
continue # settled or re-queued mid-flight; re-route
binding = self._binding_for_room(identity.room_id)
try:
if binding is not None:
lease = self._ensure_lease(binding)
if self._peer_stop_acknowledged(binding, stopping) or (
not self._settle_stopping_completion(binding, stopping, lease)
and self._interrupt_stopping_task(binding, stopping)):
self._complete_cancel(stopping, cancel_id=cancel_id)
except Exception as exc:
self._record_error(f"stop remains pending: {exc}")
continue # lost the race with the worker (settled or re-queued); re-route
if not direct:
binding = self._binding_for_room(identity.room_id)
try:
if binding is not None:
lease = self._ensure_lease(binding)
if self._peer_stop_acknowledged(binding, result) or (
not self._settle_stopping_completion(binding, result, lease)
and self._interrupt_stopping_task(binding, result)):
self._complete_cancel(result, cancel_id=cancel_id)
except Exception as exc:
self._record_error(f"stop remains pending: {exc}")
self.wakeup()
return state.get_task(self.db_path, identity)
# Exhausted routing retries under sustained contention: surface the
# live status honestly rather than a transient transition error.
return result if direct else state.get_task(self.db_path, identity)
# Routing retries exhausted under contention: surface the live status honestly.
final = state.get_task(self.db_path, identity)
if final["status"] == "cancelled":
return final
@@ -285,20 +251,21 @@ class HostedRoomRuntime:
lease = self._ensure_lease(binding)
if task["status"] == "deferred":
return self._requeue(state.requeue_deferred_task, task, lease, identity.room_id)
# Explicit Retry may resume the exact stored session; an automatic
# abandoned-attempt scan remains non-resuming for local sessions.
# Explicit Retry may resume the exact stored session; the automatic abandoned-attempt
# scan stays non-resuming for local sessions.
inspection = self._inspect_recovery_session(binding, task)
if inspection.terminal is not None:
return self._resolve_indeterminate(binding, task, lease, inspection.terminal)
if inspection.status == "cancelled":
return self._resolve_remote_cancel(binding, task, lease)
return self._fenced(
state.resolve_indeterminate_cancellation, binding, task, lease,
cancel_id=f"remote-cancel:{task['execution_generation']}")
if inspection.active:
self._set_blocked(identity.room_id, True)
raise state.InvalidTaskTransitionError(
"cannot retry while the original task attempt is still active")
return self._requeue(state.requeue_indeterminate_task, task, lease, identity.room_id)
# ------------------------------------------------------------------ durable-state helpers
def _publish(self, binding: HostedRoomBinding, task: dict[str, Any]) -> dict[str, Any]:
if self.publish_terminal is not None:
self.publish_terminal(binding, task)
@@ -328,9 +295,9 @@ class HostedRoomRuntime:
def _complete_cancel(
self, task: Mapping[str, Any], *, cancel_id: str | None = None) -> dict[str, Any]:
return state.complete_task_cancel(
self.db_path, task["identity"],
self.db_path, task["identity"], clock=self.clock,
cancel_id=task["cancel_id"] if cancel_id is None else cancel_id,
expected_cancel_generation=task["cancel_generation"], clock=self.clock)
expected_cancel_generation=task["cancel_generation"])
def _resolve_indeterminate(
self, binding: HostedRoomBinding, task: Mapping[str, Any], lease: state.DriverLease,
@@ -339,19 +306,6 @@ class HostedRoomRuntime:
state.resolve_indeterminate_task, binding, task, lease, publish=publish,
**asdict(terminal))
def _resolve_remote_cancel(
self, binding: HostedRoomBinding, task: Mapping[str, Any], lease: state.DriverLease,
*, publish: bool = True) -> dict[str, Any]:
return self._fenced(
state.resolve_indeterminate_cancellation, binding, task, lease, publish=publish,
cancel_id=f"remote-cancel:{task['execution_generation']}")
def _settle_stopping(
self, binding: HostedRoomBinding, task: Mapping[str, Any], lease: state.DriverLease,
**terminal: Any) -> dict[str, Any]:
"""Settle a stopping task from ``terminal`` (settlement_id/status/result + fences)."""
return self._fenced(state.settle_stopping_task, binding, task, lease, **terminal)
def _finish_stop(
self, binding: HostedRoomBinding, task: Mapping[str, Any], lease: state.DriverLease
) -> bool:
@@ -363,13 +317,11 @@ class HostedRoomRuntime:
return True
return False
# ------------------------------------------------------------------ session probes
def _resume_exact(
self, transport: InternalSessionRPC, room_id: str, profile: str) -> str | None:
"""Resolve the canonical room session and return its resumed runtime id.
"""Resume the canonical room session and return its runtime id (None when absent).
Callers must use the returned id (not the stored one) for every
subsequent history/info probe; resume may hand back a different id.
Probes must use the returned id, not the stored one: resume may hand back another.
"""
session = self._resolve_or_create(transport, profile, room_id, create=False)
return None if session is None else _session_id(session)
@@ -377,10 +329,8 @@ class HostedRoomRuntime:
def _open_session(
self, binding: HostedRoomBinding, task: Mapping[str, Any], *, peer_only: bool = False
) -> tuple[InternalSessionRPC | None, str | None, str | None]:
"""Return ``(transport, profile, resumed session id)``; the id is None when unusable.
A missing transport (or the local one when ``peer_only``) yields no session id.
"""
"""Return ``(transport, profile, resumed session id)``; no id when the transport is
missing (or local under ``peer_only``) or the session is absent."""
transport = self._transport_for(binding, task)
if transport is None or (peer_only and transport is self.rpc):
return transport, None, None
@@ -395,43 +345,34 @@ class HostedRoomRuntime:
transport.history(**_session_kw(profile, session_id)),
task["identity"], int(task["execution_generation"]))
@staticmethod
def _info_acknowledges_peer_cancel(info: Mapping[str, Any], task: Mapping[str, Any]) -> bool:
"""Accept only one exact peer task attempt's terminal Stop receipt."""
def _peer_stop_acknowledged(self, binding: HostedRoomBinding, task: Mapping[str, Any]) -> bool:
"""Probe a peer's exact durable terminal Stop receipt before reading history."""
transport, profile, session_id = self._open_session(binding, task, peer_only=True)
if session_id is None:
return False
info = transport.info(**_session_kw(profile, session_id))
return (
not _info_active(info)
and str(info.get("status") or "") in _STOP_ACK_STATUSES
and str(info.get("task_id") or "") == task["identity"].task_id
and int(info.get("execution_generation") or 0) == int(task["execution_generation"]))
def _peer_stop_acknowledged(self, binding: HostedRoomBinding, task: Mapping[str, Any]) -> bool:
"""Probe a peer's exact durable terminal status before reading history."""
transport, profile, session_id = self._open_session(binding, task, peer_only=True)
if session_id is None:
return False
return self._info_acknowledges_peer_cancel(
transport.info(**_session_kw(profile, session_id)), task)
def _interrupt_stopping_task(self, binding: HostedRoomBinding, task: Mapping[str, Any]) -> bool:
transport, profile, session_id = self._open_session(binding, task)
if session_id is None:
# A local accepted turn cannot survive without its canonical
# session: an authoritative absence is a safe Stop acknowledgement
# (resolution errors raise). A peer remains uncertain instead.
# A local turn cannot survive without its canonical session, so an authoritative
# absence is a safe Stop acknowledgement (errors raise); a peer stays uncertain.
return transport is not None and transport is self.rpc
info = transport.info(**_session_kw(profile, session_id))
if not _info_active(info):
# History was checked immediately before this probe. An exact
# session that is no longer active cannot keep executing, and after
# a restart its process-local task marker is expected to be absent.
# History was checked just before this probe: an inactive exact session cannot
# keep executing, and after a restart its process-local task marker is absent.
return True
if not _info_is_active_for(info, task["identity"], require_exact=True):
return False
result = transport.interrupt(
**_session_kw(profile, session_id), expected_task_id=task["identity"].task_id)
if result is None:
return False
return (
return result is not None and (
result.get("interrupted") is True
or str(result.get("status") or "") in _STOP_ACK_STATUSES)
@@ -445,32 +386,28 @@ class HostedRoomRuntime:
receipt = self._terminal_from_history(transport, profile, session_id, task)
if receipt is None:
return False
self._settle_stopping(binding, task, lease, **asdict(receipt))
self._fenced(state.settle_stopping_task, binding, task, lease, **asdict(receipt))
return True
def _report_pending_action(
self, task: Mapping[str, Any], *, session_id: str, info: Mapping[str, Any]) -> None:
if self.pending_action is None:
return
approval = info.get("pending_approval") or info.get("approval")
action = None
approval, action = info.get("pending_approval") or info.get("approval"), None
if isinstance(approval, Mapping):
choices = [c for c in approval.get("choices") or () if c in {"once", "deny"}]
safe_approval = {**approval, "choices": choices or ["once", "deny"]}
action = {
"kind": "approval",
"task_id": task["identity"].task_id,
"kind": "approval", "task_id": task["identity"].task_id,
"execution_generation": int(task["execution_generation"]),
"run_id": info.get("run_id"),
"session_id": session_id,
"request_id": safe_approval.get("request_id"),
"approval": safe_approval}
"run_id": info.get("run_id"), "session_id": session_id,
"request_id": safe_approval.get("request_id"), "approval": safe_approval}
self.pending_action(task["identity"].room_id, _member_id(task), action)
def _retry_stopping_tasks(self, binding: HostedRoomBinding, lease: state.DriverLease) -> bool:
for task in state.list_tasks(self.db_path, room_id=binding.room_id, status="stopping"):
for task in self._tasks(binding, "stopping"):
try:
lease = self._renew_lease_if_needed(binding, lease)
lease = self._renew_lease_if_needed(lease)
if self._peer_stop_acknowledged(binding, task):
self._complete_cancel(task)
continue
@@ -485,8 +422,7 @@ class HostedRoomRuntime:
def _worker_loop(self) -> None:
try:
while not self._stop.is_set():
# Clear before work so a write racing the cycle remains set and
# causes an immediate follow-up pass rather than being lost.
# Clear before work so a write racing the cycle forces a follow-up pass.
self._wake.clear()
try:
self._run_cycle()
@@ -516,9 +452,7 @@ class HostedRoomRuntime:
return
with self._status_lock:
self._room_threads = {
room_id: thread
for room_id, thread in self._room_threads.items()
if thread.is_alive()}
room_id: t for room_id, t in self._room_threads.items() if t.is_alive()}
available = self.max_concurrent_rooms - len(self._room_threads)
active_rooms = set(self._room_threads)
if available <= 0:
@@ -546,16 +480,15 @@ class HostedRoomRuntime:
try:
self._process_room(binding)
except state.LeaseHeldError:
return
pass
except Exception as exc:
if isinstance(exc, (state.RoomUnavailableError, state.StaleLeaseError)):
self._drop_lease(binding.room_id)
self._set_blocked(binding.room_id, False)
self._record_error(f"room {binding.room_id}: {exc}")
finally:
current = threading.current_thread()
with self._status_lock:
if self._room_threads.get(binding.room_id) is current:
if self._room_threads.get(binding.room_id) is threading.current_thread():
self._room_threads.pop(binding.room_id, None)
should_wake = binding.room_id in self._rooms_needing_reschedule
self._rooms_needing_reschedule.discard(binding.room_id)
@@ -568,24 +501,25 @@ class HostedRoomRuntime:
self._inspect_abandoned_attempts(binding)
deferred_until = self._ambiguous_rooms.get(binding.room_id)
if deferred_until is not None:
running = state.list_tasks(self.db_path, room_id=binding.room_id, status="running")
if running and self.clock() < deferred_until:
if self._tasks(binding, "running") and self.clock() < deferred_until:
return
self._ambiguous_rooms.pop(binding.room_id, None)
lease = self._ensure_lease(binding)
recovery_key = (lease.room_id, lease.lease_generation)
if recovery_key not in self._recovered_leases:
if (lease.room_id, lease.lease_generation) not in self._recovered_leases:
state.recover_room(self.db_path, lease, clock=self.clock)
self._recovered_leases.add(recovery_key)
self._recovered_leases.add((lease.room_id, lease.lease_generation))
if self._retry_stopping_tasks(binding, lease):
self._set_blocked(binding.room_id, True)
return
if self._reconcile_indeterminate(binding, lease):
return
for task in state.list_tasks(self.db_path, room_id=binding.room_id, status="queued"):
if self._stop.is_set() or self._route_retry_is_deferred(task):
for task in self._tasks(binding, "queued"):
retry = self._unavailable_route_retries.get(
(task["identity"].room_id, _member_id(task)))
if self._stop.is_set() or (
retry is not None and self.clock() < retry["next_attempt_at"]):
return
lease = self._renew_lease_if_needed(binding, lease)
lease = self._renew_lease_if_needed(lease)
attempt = state.start_task(
self.db_path, task["identity"], lease,
expected_cancel_generation=task["cancel_generation"], clock=self.clock)
@@ -594,17 +528,8 @@ class HostedRoomRuntime:
if current["status"] not in state.TERMINAL_STATUSES:
return
# ------------------------------------------------------------------ route retry backoff
@staticmethod
def _route_retry_key(task: Mapping[str, Any]) -> tuple[str, str]:
return task["identity"].room_id, _member_id(task)
def _route_retry_is_deferred(self, task: Mapping[str, Any]) -> bool:
retry = self._unavailable_route_retries.get(self._route_retry_key(task))
return retry is not None and self.clock() < retry["next_attempt_at"]
def _defer_unavailable_route(self, task: Mapping[str, Any]) -> float:
key = self._route_retry_key(task)
key = (task["identity"].room_id, _member_id(task))
previous = self._unavailable_route_retries.get(key)
lo, hi = self.unavailable_retry_min_seconds, self.unavailable_retry_max_seconds
delay = lo if previous is None else min(hi, max(lo, previous["delay"] * 2))
@@ -612,35 +537,27 @@ class HostedRoomRuntime:
"delay": delay, "next_attempt_at": self.clock() + delay}
return delay
def _clear_unavailable_route_retry(self, task: Mapping[str, Any]) -> None:
self._unavailable_route_retries.pop(self._route_retry_key(task), None)
# ------------------------------------------------------------------ leases
def _ensure_lease(self, binding: HostedRoomBinding) -> state.DriverLease:
with self._status_lock:
current = self._leases.get(binding.room_id)
if current is not None:
try:
return self._renew_lease_if_needed(binding, current)
return self._renew_lease_if_needed(current)
except state.StaleLeaseError:
self._drop_lease(binding.room_id)
lease = state.acquire_lease(
self.db_path, room_id=binding.room_id, gateway_id=binding.gateway_id,
authority_epoch=binding.authority_epoch, process_generation=self.process_generation,
ttl_seconds=self.lease_ttl_seconds, clock=self.clock)
with self._status_lock:
self._leases[binding.room_id] = lease
self._recovered_leases = {
key for key in self._recovered_leases if key[0] != binding.room_id}
self._recovered_leases = {k for k in self._recovered_leases if k[0] != binding.room_id}
return lease
def _renew_lease_if_needed(
self, binding: HostedRoomBinding, lease: state.DriverLease, *, force: bool = False
) -> state.DriverLease:
del binding
renew_at = lease.expires_at - (self.lease_ttl_seconds / 2)
if not force and self.clock() < renew_at:
self, lease: state.DriverLease, *, force: bool = False) -> state.DriverLease:
if not force and self.clock() < lease.expires_at - (self.lease_ttl_seconds / 2):
return lease
renewed = state.renew_lease(
self.db_path, lease, ttl_seconds=self.lease_ttl_seconds, clock=self.clock)
@@ -662,25 +579,23 @@ class HostedRoomRuntime:
def _execute_attempt(
self, binding: HostedRoomBinding, task: Mapping[str, Any], attempt: state.TaskAttempt
) -> None:
profile = task["payload"]["target_profile"]
profile, submit_attempted = task["payload"]["target_profile"], False
transport = self._transport_for(binding, task)
submit_attempted = False
with self._status_lock:
self._current_tasks[binding.room_id] = attempt.identity
try:
with self.turn_lock(profile):
session = self._resolve_or_create(transport, profile, binding.room_id)
# An in-process submit should fail before admission or return
# after it, but an unexpected exception at that boundary is
# still ambiguous. Never terminalize it as a proven failure.
submit_attempted = True
session_id = _session_id(session)
# A submit should fail before admission or return after it; an unexpected
# exception at that boundary is ambiguous, never a proven failure.
submit_attempted, session_id = True, _session_id(session)
deadline_monotonic = time.monotonic() + self.turn_timeout_seconds
transport.submit(
**_session_kw(profile, session_id), prompt=task["payload"]["prompt"],
task=attempt.identity, execution_generation=attempt.execution_generation,
on_terminal=lambda receipt: self._on_terminal(binding, attempt, receipt))
self._clear_unavailable_route_retry(task)
self._unavailable_route_retries.pop(
(task["identity"].room_id, _member_id(task)), None)
receipt = self._wait_for_terminal(
binding, profile=profile, session_id=session_id, attempt=attempt,
transport=transport, deadline_monotonic=deadline_monotonic)
@@ -689,34 +604,30 @@ class HostedRoomRuntime:
state.settle_task(self.db_path, attempt, **asdict(receipt), clock=self.clock)
except (state.StaleLeaseError, state.StaleTaskError) as exc:
self._drop_lease(binding.room_id)
self._record_error(f"task {attempt.identity.task_id} fenced: {exc}")
self._record_task_error(attempt, f"fenced: {exc}")
except Exception as exc:
if submit_attempted and bool(getattr(exc, "not_admitted", False)):
try:
state.requeue_not_admitted_task(self.db_path, attempt, clock=self.clock)
except (state.StaleLeaseError, state.StaleTaskError) as fence_exc:
self._mark_ambiguous(binding, attempt)
self._record_error(
f"task {attempt.identity.task_id} not-admitted proof lost "
f"its fence: {fence_exc}")
self._record_task_error(
attempt, f"not-admitted proof lost its fence: {fence_exc}")
else:
delay = self._defer_unavailable_route(task)
self._record_error(
f"task {attempt.identity.task_id} was not admitted; "
f"queued for retry in {delay:g}s")
self._record_task_error(
attempt, f"was not admitted; queued for retry in {delay:g}s")
elif submit_attempted:
self._mark_ambiguous(binding, attempt)
self._record_error(
f"task {attempt.identity.task_id} observation failed after submit: {exc}")
self._record_task_error(attempt, f"observation failed after submit: {exc}")
else:
self._settle_failure_if_current(attempt, exc)
finally:
with self._status_lock:
self._current_tasks.pop(binding.room_id, None)
# The task may have published a reply, deferred a member, or
# exposed the next turn while this room thread still occupied
# its slot. Schedule exactly one immediate follow-up after the
# thread leaves; idle room scans never set this marker.
# The task may have published a reply or exposed the next turn while this
# thread held its slot: schedule exactly one follow-up after it leaves
# (idle room scans never set this marker).
self._rooms_needing_reschedule.add(binding.room_id)
def _mark_ambiguous(self, binding: HostedRoomBinding, attempt: state.TaskAttempt) -> None:
@@ -737,23 +648,23 @@ class HostedRoomRuntime:
or f"reply:{attempt.identity.task_id}:{attempt.execution_generation}",
result=_bounded_terminal_result(receipt))
try:
settled = state.settle_task(self.db_path, attempt, **asdict(terminal), clock=self.clock)
self._publish(binding, settled)
self._publish(
binding,
state.settle_task(self.db_path, attempt, **asdict(terminal), clock=self.clock))
except state.StaleTaskError:
with suppress(state.StaleLeaseError, state.StaleTaskError):
current = state.get_task(self.db_path, attempt.identity)
if current["status"] == "stopping":
self._settle_stopping(
binding, current, attempt.lease, **asdict(terminal),
self._fenced(
state.settle_stopping_task, binding, current, attempt.lease,
**asdict(terminal),
expected_execution_generation=attempt.execution_generation)
except state.StaleLeaseError:
# Cancellation, disband, or authority transfer won the durable
# race. The model result is intentionally discarded; never turn a
# correct fence into a worker thread exception.
# Cancellation, disband, or authority transfer won the durable race: the model
# result is discarded rather than turning a correct fence into a thread exception.
pass
except state.DriverStateError as exc:
# A malformed terminal receipt must not escape the callback and
# hold the profile lock until the deadline.
# A malformed receipt must not escape the callback and hold the profile lock.
self._settle_failure_if_current(
attempt, RuntimeError(f"terminal result could not be committed: {exc}"))
self.wakeup()
@@ -769,7 +680,7 @@ class HostedRoomRuntime:
return None
if task["status"] == "stopping":
try:
lease = self._renew_lease_if_needed(binding, lease)
lease = self._renew_lease_if_needed(lease)
if self._finish_stop(binding, task, lease):
return None
except Exception as exc:
@@ -780,7 +691,7 @@ class HostedRoomRuntime:
if time.monotonic() >= deadline_monotonic:
self._expire_attempt_deadline(binding, task, lease)
return None
lease = self._renew_lease_if_needed(binding, lease)
lease = self._renew_lease_if_needed(lease)
receipt = self._terminal_from_history(transport, profile, session_id, task)
if receipt is not None:
return receipt
@@ -791,19 +702,14 @@ class HostedRoomRuntime:
self._wake.clear()
return None
# ------------------------------------------------------------------ deadline stops
@staticmethod
def _is_deadline_stop(task: Mapping[str, Any]) -> bool:
return str(task.get("cancel_id") or "").startswith("deadline:")
def _complete_acknowledged_stop(
self, binding: HostedRoomBinding, task: Mapping[str, Any], lease: state.DriverLease
) -> dict[str, Any]:
"""Terminalize an acknowledged Stop: deadline stops publish an explicit failure."""
if not self._is_deadline_stop(task):
if not str(task.get("cancel_id") or "").startswith("deadline:"):
return self._complete_cancel(task)
return self._settle_stopping(
binding, task, lease,
return self._fenced(
state.settle_stopping_task, binding, task, lease,
settlement_id=f"deadline:{int(task['execution_generation'])}", status="failed",
result={
"error": "This Group Chat turn exceeded its configured time limit and was stopped.",
@@ -816,23 +722,22 @@ class HostedRoomRuntime:
"""Fence, stop, and terminalize one exact attempt at its deadline."""
if task["status"] == "running":
task = state.begin_task_cancel(
self.db_path, task["identity"],
self.db_path, task["identity"], clock=self.clock,
cancel_id=f"deadline:{int(task['execution_generation'])}",
expected_cancel_generation=int(task["cancel_generation"]), clock=self.clock)
expected_cancel_generation=int(task["cancel_generation"]))
elif task["status"] != "stopping":
return
# A user Stop that won the race keeps its own cancellation semantics.
if not self._is_deadline_stop(task):
if not str(task.get("cancel_id") or "").startswith("deadline:"):
return
lease = self._renew_lease_if_needed(binding, lease, force=True)
if self._finish_stop(binding, task, lease):
return
self._record_error(
f"task {task['identity'].task_id} exceeded its deadline; stop remains pending")
lease = self._renew_lease_if_needed(lease, force=True)
if not self._finish_stop(binding, task, lease):
self._record_error(
f"task {task['identity'].task_id} exceeded its deadline; stop remains pending")
# ------------------------------------------------------------------ recovery
def _inspect_abandoned_attempts(self, binding: HostedRoomBinding) -> None:
for task in state.list_tasks(self.db_path, room_id=binding.room_id, status="running"):
for task in self._tasks(binding, "running"):
if task["run_process_generation"] == self.process_generation:
continue
inspection = (
@@ -842,8 +747,7 @@ class HostedRoomRuntime:
if inspection.terminal is not None:
self._harvest_previous_attempt(binding, task, inspection.terminal)
elif inspection.active:
# The prior session still owns the turn. Do not contend for its
# lease or submit a duplicate prompt.
# The prior session still owns the turn: no lease contention, no duplicate prompt.
raise state.LeaseHeldError("recovered session turn is still active")
def _inspect_session(
@@ -851,9 +755,9 @@ class HostedRoomRuntime:
*, read_history: bool) -> _RecoveryInspection:
"""Probe one resolved session: optional terminal receipt from history, then live info."""
profile = task["payload"]["target_profile"]
receipt = None
if read_history:
receipt = self._terminal_from_history(transport, profile, session_id, task)
receipt = (
self._terminal_from_history(transport, profile, session_id, task)
if read_history else None)
info = transport.info(**_session_kw(profile, session_id))
self._report_pending_action(task, session_id=session_id, info=info)
return _RecoveryInspection(
@@ -862,8 +766,7 @@ class HostedRoomRuntime:
def _inspect_recovery_session(
self, binding: HostedRoomBinding, task: Mapping[str, Any]) -> _RecoveryInspection:
profile = task["payload"]["target_profile"]
transport = self._transport_for(binding, task)
profile, transport = task["payload"]["target_profile"], self._transport_for(binding, task)
with self.turn_lock(profile):
session_id = self._resume_exact(transport, task["identity"].room_id, profile)
if session_id is None:
@@ -871,13 +774,11 @@ class HostedRoomRuntime:
return self._inspect_session(transport, task, session_id, read_history=True)
def _inspect_local_recovery_session(self, task: Mapping[str, Any]) -> _RecoveryInspection:
"""Check only live process state before explicit local recovery.
"""Check only live process state (no resume, no history) before explicit local recovery.
A restart loses the in-process terminal callback identity. Session
history is a display projection that cannot prove which durable task
attempt authored a row, so never hydrate or infer completion from it.
An inactive abandoned attempt remains indeterminate until the user
explicitly retries it under a new fenced generation.
A restart loses the in-process terminal callback identity and history cannot prove
which attempt authored a row, so an inactive abandoned attempt stays indeterminate
until the user retries it under a new fenced generation.
"""
profile = task["payload"]["target_profile"]
with self.turn_lock(profile):
@@ -890,14 +791,14 @@ class HostedRoomRuntime:
def _reconcile_indeterminate(
self, binding: HostedRoomBinding, lease: state.DriverLease) -> bool:
unresolved = state.list_tasks(self.db_path, room_id=binding.room_id, status="indeterminate")
unresolved = self._tasks(binding, "indeterminate")
if not unresolved:
self._set_blocked(binding.room_id, False)
return False
inspected = self._inspected_indeterminate_attempts
for task in unresolved:
generation = int(task["execution_generation"])
attempt_key = (binding.room_id, task["identity"].task_id, generation)
attempt_key = (
binding.room_id, task["identity"].task_id, int(task["execution_generation"]))
is_local = self._transport_for(binding, task) is self.rpc
if is_local and attempt_key not in inspected:
inspection = self._inspect_local_recovery_session(task)
@@ -909,12 +810,9 @@ class HostedRoomRuntime:
if inspection.active:
self._set_blocked(binding.room_id, True)
return True
deferred_at = float(
task.get("indeterminate_at")
or task.get("updated_at")
or task.get("created_at")
deadline = self.indeterminate_defer_seconds + float(
task.get("indeterminate_at") or task.get("updated_at") or task.get("created_at")
or self.clock())
deadline = deferred_at + self.indeterminate_defer_seconds
inspection = _NO_INSPECTION
if attempt_key not in inspected or self.clock() >= deadline:
try:
@@ -926,7 +824,9 @@ class HostedRoomRuntime:
inspected.add(attempt_key)
if inspection.status == "cancelled":
# Remote-probe resolutions are not republished here.
self._resolve_remote_cancel(binding, task, lease, publish=False)
self._fenced(
state.resolve_indeterminate_cancellation, binding, task, lease, publish=False,
cancel_id=f"remote-cancel:{task['execution_generation']}")
inspected.discard(attempt_key)
continue
if inspection.terminal is not None:
@@ -948,21 +848,21 @@ class HostedRoomRuntime:
self, binding: HostedRoomBinding, task: Mapping[str, Any], receipt: _TerminalReceipt
) -> None:
previous_attempt = state.TaskAttempt(
identity=task["identity"],
identity=task["identity"], execution_generation=task["execution_generation"],
cancel_generation=task["cancel_generation"],
lease=state.DriverLease(
room_id=binding.room_id, gateway_id=task["run_gateway_id"],
authority_epoch=binding.authority_epoch,
process_generation=task["run_process_generation"],
lease_generation=task["run_lease_generation"], expires_at=0.0),
execution_generation=task["execution_generation"],
cancel_generation=task["cancel_generation"])
# Once the previous proof has expired there is deliberately no "trust
# this historical output" escape hatch; fenced recovery leaves the task
# indeterminate for explicit user action.
lease_generation=task["run_lease_generation"], expires_at=0.0))
# Once the previous proof has expired there is deliberately no "trust this historical
# output" escape hatch; fenced recovery leaves the task indeterminate for the user.
with suppress(state.StaleLeaseError, state.StaleTaskError):
state.settle_task(self.db_path, previous_attempt, **asdict(receipt), clock=self.clock)
# ------------------------------------------------------------------ misc
def _tasks(self, binding: HostedRoomBinding, status: str) -> list[dict[str, Any]]:
return state.list_tasks(self.db_path, room_id=binding.room_id, status=status)
def _binding_for_room(self, room_id: str) -> HostedRoomBinding | None:
return next((b for b in self._rooms_provider() if b.room_id == room_id), None)
@@ -991,7 +891,10 @@ class HostedRoomRuntime:
self.db_path, attempt,
settlement_id=f"failure:{attempt.identity.task_id}:{attempt.execution_generation}",
status="failed", result={"error": str(exc)}, clock=self.clock)
self._record_error(f"task {attempt.identity.task_id} failed: {exc}")
self._record_task_error(attempt, f"failed: {exc}")
def _record_task_error(self, attempt: state.TaskAttempt, message: str) -> None:
self._record_error(f"task {attempt.identity.task_id} {message}")
def _record_error(self, message: str) -> None:
with self._status_lock:
@@ -1004,8 +907,8 @@ def room_session_title(room_id: str) -> str:
def _member_id(task: Mapping[str, Any]) -> str:
payload = task.get("payload") or {}
return str(payload.get("target_member_id") or payload.get("target_profile") or "")
p = task.get("payload") or {}
return str(p.get("target_member_id") or p.get("target_profile") or "")
def _session_id(session: Mapping[str, Any]) -> str:
@@ -1016,12 +919,10 @@ def _session_id(session: Mapping[str, Any]) -> str:
def _truncate_utf8(value: Any, *, max_bytes: int) -> tuple[str, bool]:
text = str(value or "")
encoded = text.encode("utf-8")
text, encoded = str(value or ""), str(value or "").encode("utf-8")
if len(encoded) <= max_bytes:
return text, False
suffix = _TERMINAL_TRUNCATION_NOTICE.encode("utf-8")
prefix = encoded[: max(0, max_bytes - len(suffix))]
prefix = encoded[: max(0, max_bytes - len(_TERMINAL_TRUNCATION_NOTICE.encode("utf-8")))]
while prefix:
try:
return prefix.decode("utf-8") + _TERMINAL_TRUNCATION_NOTICE, True
@@ -1034,8 +935,7 @@ def _bounded_terminal_result(receipt: Mapping[str, Any]) -> dict[str, Any]:
text, truncated = _truncate_utf8(receipt.get("text", ""), max_bytes=MAX_TERMINAL_TEXT_BYTES)
error, error_truncated = _truncate_utf8(receipt.get("error", ""), max_bytes=4096)
return {
"message_id": receipt.get("message_id"),
"text": text,
"message_id": receipt.get("message_id"), "text": text,
**({"error": error} if error else {}),
**({"truncated": True} if truncated or error_truncated else {})}
@@ -1048,15 +948,13 @@ def _find_terminal_receipt(
if (
message.get("task_id") != identity.task_id
or message.get("execution_generation") != execution_generation
or message.get("role") != "assistant"
or status not in {"settled", "failed"}):
or message.get("role") != "assistant" or status not in {"settled", "failed"}):
continue
receipt_id = message.get("message_id")
if not isinstance(receipt_id, str) or not receipt_id:
receipt_id = f"reply:{identity.task_id}:{execution_generation}"
return _TerminalReceipt(
status=cast(state.TerminalStatus, status),
settlement_id=receipt_id,
status=cast(state.TerminalStatus, status), settlement_id=receipt_id,
result=_bounded_terminal_result(
{"message_id": receipt_id, "text": message.get("content", "")}))
return None
@@ -1068,9 +966,5 @@ def _info_active(info: Mapping[str, Any]) -> bool:
def _info_is_active_for(
info: Mapping[str, Any], identity: state.TaskIdentity, *, require_exact: bool = False) -> bool:
if not _info_active(info):
return False
active_task_id = info.get("task_id")
if require_exact:
return active_task_id == identity.task_id
return active_task_id in {None, identity.task_id}
accepted = (identity.task_id,) if require_exact else (None, identity.task_id)
return _info_active(info) and info.get("task_id") in accepted
+90 -159
View File
@@ -25,13 +25,11 @@ logger = logging.getLogger(__name__)
_NOT_ADMITTED_ERRNOS = frozenset(
value
for name in ("ECONNREFUSED", "ENETDOWN", "ENETUNREACH", "EHOSTDOWN", "EHOSTUNREACH")
value for name in ("ECONNREFUSED", "ENETDOWN", "ENETUNREACH", "EHOSTDOWN", "EHOSTUNREACH")
if (value := getattr(errno, name, None)) is not None)
_ERROR_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
# A replay page may legitimately contain many bounded 64 KiB room events. Keep
# enough room for the largest normal page while preventing peer-sized responses
# from scaling memory use without limit.
# A replay page may legitimately hold many bounded 64 KiB room events; cap it so a peer-sized
# response cannot scale memory use without limit.
MAX_PEER_RESPONSE_BYTES = 16 * 1024 * 1024
MAX_PEER_ERROR_RESPONSE_BYTES = 16 * 1024
_PEER_RESPONSE_CHUNK_BYTES = 64 * 1024
@@ -43,8 +41,8 @@ _RECEIPT_SCOPE_FIELDS = (
_TERMINAL_RUN_STATES = frozenset({"completed", "failed", "interrupted", "cancelled"})
_ACTIVE_RUN_STATES = frozenset({"queued", "running", "waiting_for_approval", "stopping"})
_KNOWN_RUN_STATES = _TERMINAL_RUN_STATES | _ACTIVE_RUN_STATES
# Older target gateways wrap these conditions inside the generic dispatch
# error; normalize locally until their wire code becomes specific.
_RUN_STATUS_KEYS = ("run_id", "status", "output", "error", "approval", "last_event")
# Older target gateways wrap these inside the generic dispatch error; normalize locally.
_LEGACY_DISPATCH_MESSAGE_CODES = (
("room grant", "invalid_room_grant"),
("capability catalog changed", "room_capability_catalog_changed"),
@@ -75,18 +73,9 @@ class _PeerResponseDeadlineExceeded(TimeoutError):
"""A peer response exceeded the request's monotonic wall-clock budget."""
def _content_length(response: Any) -> int | None:
try:
value = int(response.headers.get("Content-Length"))
except (AttributeError, TypeError, ValueError):
return None
return value if value >= 0 else None
def _set_response_socket_timeout(response: Any, remaining: float) -> None:
"""Best-effort urllib socket timeout tightened to the remaining budget."""
frontier = [response]
seen: set[int] = set()
frontier, seen = [response], set()
for _depth in range(5):
next_frontier = []
for value in frontier:
@@ -103,12 +92,14 @@ def _set_response_socket_timeout(response: Any, remaining: float) -> None:
def _read_bounded_response(response: Any, *, max_bytes: int, deadline: float) -> bytes:
declared = _content_length(response)
if declared is not None and declared > max_bytes:
try:
declared = int(response.headers.get("Content-Length"))
except (AttributeError, TypeError, ValueError):
declared = -1
if declared > max_bytes:
raise _PeerResponseTooLarge
reader = getattr(response, "read1", None)
if not callable(reader):
reader = response.read
reader = reader if callable(reader) else response.read
body = bytearray()
while len(body) <= max_bytes:
remaining = deadline - time.monotonic()
@@ -131,14 +122,25 @@ def _read_bounded_response(response: Any, *, max_bytes: int, deadline: float) ->
raise _PeerResponseTooLarge
def _read_body(response: Any, *, max_bytes: int, deadline: float, kind: str, **flags: Any) -> str:
"""Read a bounded body as text; budget overruns become classified ``PeerRunsHTTPError``."""
try:
return _read_bounded_response(
response, max_bytes=max_bytes, deadline=deadline).decode("utf-8", "replace")
except _PeerResponseTooLarge as exc:
raise PeerRunsHTTPError(_BUDGET_MESSAGES["size"].format(kind=kind), **flags) from exc
except _PeerResponseDeadlineExceeded as exc:
raise PeerRunsHTTPError(
_BUDGET_MESSAGES["time"].format(kind=kind), retryable=True, **flags) from exc
def _is_proven_pre_admission_failure(exc: BaseException) -> bool:
"""Return whether no HTTP connection could have carried the request."""
reason: Any = exc
while isinstance(reason, urllib.error.URLError):
reason = reason.reason
if isinstance(reason, socket.gaierror):
return True
return isinstance(reason, OSError) and reason.errno in _NOT_ADMITTED_ERRNOS
return isinstance(reason, socket.gaierror) or (
isinstance(reason, OSError) and reason.errno in _NOT_ADMITTED_ERRNOS)
def _valid_code(code: Any) -> str | None:
@@ -165,33 +167,18 @@ def _response_error_code(detail: str) -> str | None:
return _valid_code(payload.get("code"))
def _http_error_message(method: str, path: str, status: int, error_code: str | None) -> str:
renewal = status in {401, 403} and error_code in _GRANT_RENEWAL_CODES
drift = status == 403 and error_code in {_EXECUTION_POLICY_CHANGED[0], _CAPABILITY_CHANGED[0]}
if renewal or drift:
return _REAUTHORIZATION_MESSAGES[error_code]
return f"peer rejected {method} {path} with HTTP {status}"
class PeerRunsHTTPError(RuntimeError):
"""Controlled peer HTTP failure."""
def __init__(
self, message: str, *, retryable: bool = False, ambiguous: bool = False,
not_admitted: bool = False, status_code: int | None = None, error_code: str | None = None,
error_message: str | None = None) -> None:
) -> None:
super().__init__(message)
self.retryable = retryable
self.ambiguous = ambiguous
self.not_admitted = not_admitted
self.status_code = status_code
self.error_code = error_code
self.error_message = error_message
self.retryable, self.ambiguous, self.not_admitted = retryable, ambiguous, not_admitted
self.status_code, self.error_code = status_code, error_code
self.needs_reauthorization = (
status_code in {401, 403} and error_code in _REAUTHORIZATION_CODES)
self.needs_capability_refresh = status_code == 403 and error_code == _CAPABILITY_CHANGED[0]
self.needs_execution_policy_refresh = (
status_code == 403 and error_code == _EXECUTION_POLICY_CHANGED[0])
def digest_reauthorization_error(
@@ -225,15 +212,13 @@ class PeerRunsHTTPClient:
base_url, self.transport_security = validate_room_link_url(base_url)
if api_key and len(api_key) < 16:
raise ValueError("peer API key is missing or too short")
self.base_url = base_url
self.api_key = api_key
self.base_url, self.api_key, self.clock = base_url, api_key, clock
self.timeout_seconds = float(timeout_seconds)
self.receipt_db_path = Path(receipt_db_path) if receipt_db_path else None
if poll_min_seconds <= 0 or poll_max_seconds < poll_min_seconds:
raise ValueError("peer polling bounds are invalid")
self.poll_min_seconds = float(poll_min_seconds)
self.poll_max_seconds = float(poll_max_seconds)
self.clock = clock
self._runs: dict[tuple[str, int], dict[str, Any]] = {}
self._observation_key: tuple[str, int] | None = None
self._status_cache: dict[str, dict[str, Any]] = {}
@@ -253,11 +238,9 @@ class PeerRunsHTTPClient:
authority_epoch: int, member_id: str, target_install_id: str, target_profile: str) -> None:
"""Fence every in-memory and durable receipt to one room authority."""
epoch = int(authority_epoch or 0)
names = [
str(value or "")
for value in (
room_id, home_install_id, authority_gateway_id, member_id, target_install_id,
target_profile)]
names = [str(v or "") for v in (
room_id, home_install_id, authority_gateway_id, member_id, target_install_id,
target_profile)]
if not all(names):
raise PeerRunsHTTPError("peer room receipt scope is incomplete")
if epoch < 1:
@@ -265,15 +248,10 @@ class PeerRunsHTTPClient:
scope = dict(zip(_RECEIPT_SCOPE_FIELDS, names[:3] + [epoch] + names[3:]))
if self._room_scope == scope:
return
self._room_scope = scope
self._runs.clear()
self._observation_key = None
self._status_cache.clear()
self._recovery_backoff.clear()
self._terminal_receipts.clear()
def _bind_dispatch_scope(self, dispatch: HostedMemberDispatch) -> None:
self.bind_room_scope(**{field: getattr(dispatch, field) for field in _RECEIPT_SCOPE_FIELDS})
self._room_scope, self._observation_key = scope, None
for table in (
self._runs, self._status_cache, self._recovery_backoff, self._terminal_receipts):
table.clear()
def _receipt(self, task_id: str, execution_generation: int) -> dict[str, Any] | None:
"""Return the in-memory receipt, falling back to the durable store."""
@@ -281,7 +259,6 @@ class PeerRunsHTTPClient:
if record is not None or self.receipt_db_path is None or self._room_scope is None:
return record
from gateway import hosted_rooms
identity = {"task_id": task_id, "execution_generation": execution_generation}
return hosted_rooms.remote_run_receipt(
self.receipt_db_path, record={**self._room_scope, **identity})
@@ -291,47 +268,33 @@ class PeerRunsHTTPClient:
key = (str(task_id or ""), int(execution_generation or 0))
if not key[0] or key[1] < 1:
raise PeerRunsHTTPError("peer observation identity is invalid")
if self._observation_key != key:
for terminal_key in self._terminal_receipts - {key}:
self._runs.pop(terminal_key, None)
self._terminal_receipts.intersection_update({key})
self._observation_key = key
self._status_cache.clear()
self._recovery_backoff.clear()
if self._observation_key == key:
return
for terminal_key in self._terminal_receipts - {key}:
self._runs.pop(terminal_key, None)
self._terminal_receipts.intersection_update({key})
self._observation_key = key
self._status_cache.clear()
self._recovery_backoff.clear()
def _request(
self, path: str, *, method: str = "GET", body: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None, room_grant: str | None = None) -> dict[str, Any]:
from hermes_cli.urllib_security import open_credentialed_url
deadline = time.monotonic() + self.timeout_seconds
ambiguous = method == "POST"
deadline, ambiguous = time.monotonic() + self.timeout_seconds, method == "POST"
request = urllib.request.Request(
f"{self.base_url}{path}",
data=(
json.dumps(body, separators=(",", ":")).encode("utf-8")
if body is not None
else None),
method=method,
f"{self.base_url}{path}", method=method,
data=None if body is None else json.dumps(body, separators=(",", ":")).encode("utf-8"),
headers={
"Authorization": (
f"HermesRoom {room_grant}" if room_grant else f"Bearer {self.api_key}"),
"Content-Type": "application/json",
"User-Agent": "Hermes-RoomLink/1.0",
"Content-Type": "application/json", "User-Agent": "Hermes-RoomLink/1.0",
**(headers or {})})
try:
with open_credentialed_url(request, timeout=self.timeout_seconds) as response:
raw = _read_bounded_response(
response, max_bytes=MAX_PEER_RESPONSE_BYTES, deadline=deadline
).decode("utf-8", "replace")
except _PeerResponseTooLarge as exc:
raise PeerRunsHTTPError(
_BUDGET_MESSAGES["size"].format(kind=""), ambiguous=ambiguous,
) from exc
except _PeerResponseDeadlineExceeded as exc:
raise PeerRunsHTTPError(
_BUDGET_MESSAGES["time"].format(kind=""), retryable=True, ambiguous=ambiguous
) from exc
raw = _read_body(
response, max_bytes=MAX_PEER_RESPONSE_BYTES, deadline=deadline, kind="",
ambiguous=ambiguous)
except urllib.error.HTTPError as exc:
self._raise_http_error(exc, method=method, path=path, deadline=deadline)
except (urllib.error.URLError, TimeoutError, OSError) as exc:
@@ -354,28 +317,25 @@ class PeerRunsHTTPClient:
"""Raise the classified PeerRunsHTTPError for an HTTP error response."""
# A 4xx on admission proves the peer never admitted the run.
flags = {
"ambiguous": method == "POST" and exc.code >= 500,
"not_admitted": method == "POST" and path == "/v1/runs" and 400 <= exc.code < 500,
"status_code": exc.code}
"ambiguous": method == "POST" and exc.code >= 500, "status_code": exc.code,
"not_admitted": method == "POST" and path == "/v1/runs" and 400 <= exc.code < 500}
try:
detail = _read_bounded_response(
exc, max_bytes=MAX_PEER_ERROR_RESPONSE_BYTES, deadline=deadline
).decode("utf-8", "replace")[:500]
except _PeerResponseTooLarge as body_exc:
raise PeerRunsHTTPError(
_BUDGET_MESSAGES["size"].format(kind=" error"), **flags,
) from body_exc
except _PeerResponseDeadlineExceeded as body_exc:
raise PeerRunsHTTPError(
_BUDGET_MESSAGES["time"].format(kind=" error"), retryable=True, **flags
) from body_exc
detail = _read_body(
exc, max_bytes=MAX_PEER_ERROR_RESPONSE_BYTES, deadline=deadline, kind=" error",
**flags)[:500]
except PeerRunsHTTPError:
raise
except Exception:
detail = ""
error_code = _response_error_code(detail)
logger.debug(
"Peer RoomLink request returned HTTP %s (%s)", exc.code, error_code or "no-code")
renewal = exc.code in {401, 403} and error_code in _GRANT_RENEWAL_CODES
drift = exc.code == 403 and error_code in {
_EXECUTION_POLICY_CHANGED[0], _CAPABILITY_CHANGED[0]}
raise PeerRunsHTTPError(
_http_error_message(method, path, exc.code, error_code),
_REAUTHORIZATION_MESSAGES[error_code] if renewal or drift
else f"peer rejected {method} {path} with HTTP {exc.code}",
retryable=exc.code in {408, 425, 429} or exc.code >= 500,
error_code=error_code, **flags,
) from exc
@@ -386,8 +346,8 @@ class PeerRunsHTTPClient:
if source != "bot_room":
raise PeerRunsHTTPError("peer room source must be bot_room")
self._require_room_grant(grant)
logical_session = (
"roomlink_" + hashlib.sha256(f"{room_id}\0{profile}".encode("utf-8")).hexdigest()[:32])
logical_session = "roomlink_" + hashlib.sha256(
f"{room_id}\0{profile}".encode("utf-8")).hexdigest()[:32]
if expected_session_id and expected_session_id != logical_session:
raise PeerRunsHTTPError("peer room session identity changed")
return {"session_id": logical_session, "title": f"Group: {room_id}", "source": source}
@@ -396,7 +356,7 @@ class PeerRunsHTTPClient:
"""Validate a dispatch, its grant, and pin scope + observation to it."""
checked = HostedMemberDispatch.from_mapping(dispatch)
self._require_room_grant(grant)
self._bind_dispatch_scope(checked)
self.bind_room_scope(**{f: getattr(checked, f) for f in _RECEIPT_SCOPE_FIELDS})
self.bind_observation(
task_id=checked.task_id, execution_generation=checked.execution_generation)
return checked
@@ -412,8 +372,8 @@ class PeerRunsHTTPClient:
if any(existing[field] != getattr(checked, field) for field in _RECEIPT_SCOPE_FIELDS):
raise PeerRunsHTTPError("peer run receipt conflicts with the recovered dispatch")
return self._accepted(
checked, run_id=str(existing["run_id"]),
session_id=str(existing["session_id"]), replayed=True)
checked, run_id=str(existing["run_id"]), session_id=str(existing["session_id"]),
replayed=True)
key, now = (checked.task_id, checked.execution_generation), self.clock()
backoff = self._recovery_backoff.get(key)
if backoff is not None and now < float(backoff["next_attempt_at"]):
@@ -434,12 +394,9 @@ class PeerRunsHTTPClient:
checked: HostedMemberDispatch, *, run_id: str, session_id: str, replayed: bool,
) -> dict[str, Any]:
return {
"status": "accepted",
"task_id": checked.task_id,
"execution_generation": checked.execution_generation,
"run_id": run_id,
"session_id": session_id,
"replayed": replayed}
"status": "accepted", "task_id": checked.task_id,
"execution_generation": checked.execution_generation, "run_id": run_id,
"session_id": session_id, "replayed": replayed}
def _admit_dispatch(self, checked: HostedMemberDispatch, *, grant: str) -> Mapping[str, Any]:
session_id = self._session_id(checked, grant=grant)
@@ -462,14 +419,11 @@ class PeerRunsHTTPClient:
if not run_id:
raise PeerRunsHTTPError("peer did not return a run id")
receipt = {
"run_id": run_id,
"session_id": session_id,
"run_id": run_id, "session_id": session_id,
**{field: getattr(checked, field) for field in _RECEIPT_SCOPE_FIELDS},
"task_id": checked.task_id,
"execution_generation": checked.execution_generation}
"task_id": checked.task_id, "execution_generation": checked.execution_generation}
if self.receipt_db_path is not None:
from gateway import hosted_rooms
hosted_rooms.upsert_remote_run_receipt(self.receipt_db_path, record=receipt)
self._runs[(checked.task_id, checked.execution_generation)] = receipt
self._status_cache.pop(run_id, None)
@@ -490,9 +444,7 @@ class PeerRunsHTTPClient:
def _observation_receipt(
self, *, room_id: str, profile: str, session_id: str) -> dict[str, Any] | None:
if self._observation_key is None:
return None
record = self._receipt(*self._observation_key)
record = None if self._observation_key is None else self._receipt(*self._observation_key)
if record is None:
return None
scope = (record["room_id"], record["target_profile"], record["session_id"])
@@ -500,27 +452,16 @@ class PeerRunsHTTPClient:
raise PeerRunsHTTPError("peer observation receipt changed scope")
return record
@staticmethod
def _compact_run_status(status: Mapping[str, Any]) -> dict[str, Any]:
return {
key: status[key]
for key in ("run_id", "status", "output", "error", "approval", "last_event")
if key in status}
def _next_poll_delay(self, cached: Mapping[str, Any] | None) -> float:
previous = float(cached["delay"]) if cached is not None else self.poll_min_seconds / 2
return min(self.poll_max_seconds, max(self.poll_min_seconds, previous * 2))
@staticmethod
def _run_is_terminal(status: Mapping[str, Any]) -> bool:
return status.get("status") in _TERMINAL_RUN_STATES
def _poll_receipt(self, record: Mapping[str, Any], *, grant: str) -> dict[str, Any]:
run_id, now = str(record["run_id"]), self.clock()
cached = self._status_cache.get(run_id)
if cached is not None:
status = cached["status"]
if self._run_is_terminal(status):
if status.get("status") in _TERMINAL_RUN_STATES:
return status
if now < float(cached["next_poll_at"]):
error = cached.get("error")
@@ -530,8 +471,8 @@ class PeerRunsHTTPClient:
delay = self._next_poll_delay(cached)
entry = {"delay": delay, "next_poll_at": now + delay}
try:
status = self._compact_run_status(
self._request(_run_path(record), room_grant=self._require_room_grant(grant)))
full = self._request(_run_path(record), room_grant=self._require_room_grant(grant))
status = {key: full[key] for key in _RUN_STATUS_KEYS if key in full}
if (
str(status.get("run_id") or "") != run_id
or status.get("status") not in _KNOWN_RUN_STATES):
@@ -541,7 +482,7 @@ class PeerRunsHTTPClient:
self._status_cache = {run_id: {"status": previous, "error": exc, **entry}}
raise
self._status_cache = {run_id: {"status": status, **entry}}
if self._run_is_terminal(status):
if status.get("status") in _TERMINAL_RUN_STATES:
self._terminal_receipts.add(
(str(record["task_id"]), int(record["execution_generation"])))
return status
@@ -557,8 +498,7 @@ class PeerRunsHTTPClient:
if state not in {"completed", "failed", "interrupted"}:
return []
return [{
"role": "assistant",
"task_id": receipt["task_id"],
"role": "assistant", "task_id": receipt["task_id"],
"execution_generation": receipt["execution_generation"],
"status": "settled" if state == "completed" else "failed",
"message_id": f"peer-run:{status.get('run_id')}",
@@ -571,11 +511,9 @@ class PeerRunsHTTPClient:
return {"active": False, "task_id": None}
status = self._poll_receipt(receipt, grant=grant)
return {
"active": status.get("status") in _ACTIVE_RUN_STATES,
"task_id": receipt["task_id"],
"active": status.get("status") in _ACTIVE_RUN_STATES, "task_id": receipt["task_id"],
"execution_generation": receipt["execution_generation"],
"status": status.get("status"),
"run_id": status.get("run_id"),
"status": status.get("status"), "run_id": status.get("run_id"),
"approval": status.get("approval")}
def approve_receipt(
@@ -602,7 +540,7 @@ class PeerRunsHTTPClient:
def stop(self, *, dispatch: Mapping[str, Any], grant: str) -> Mapping[str, Any] | None:
checked = HostedMemberDispatch.from_mapping(dispatch)
self._bind_dispatch_scope(checked)
self.bind_room_scope(**{f: getattr(checked, f) for f in _RECEIPT_SCOPE_FIELDS})
return self.stop_receipt(
task_id=checked.task_id, execution_generation=checked.execution_generation, grant=grant)
@@ -614,7 +552,7 @@ class PeerRunsHTTPClient:
return None
result = self._post_run_action(
record, "stop", body={}, grant=self._require_room_grant(grant))
if self._run_is_terminal(result):
if result.get("status") in _TERMINAL_RUN_STATES:
self._terminal_receipts.add((str(task_id), int(execution_generation)))
return result
@@ -628,17 +566,11 @@ class PeerRunsHTTPClient:
return self._request(
"/v1/room-members/invitations", method="POST",
body={
"room_id": room_id,
"home_install_id": home_install_id,
"authority_gateway_id": authority_gateway_id,
"authority_epoch": authority_epoch,
"member_id": member_id,
"grant_id": grant_id,
"ttl_seconds": ttl_seconds,
**(
{"status_ttl_seconds": status_ttl_seconds}
if status_ttl_seconds is not None
else {})})
"room_id": room_id, "home_install_id": home_install_id,
"authority_gateway_id": authority_gateway_id, "authority_epoch": authority_epoch,
"member_id": member_id, "grant_id": grant_id, "ttl_seconds": ttl_seconds,
**({} if status_ttl_seconds is None else {
"status_ttl_seconds": status_ttl_seconds})})
def refresh_grant(
self, *, grant: str, ttl_seconds: float = 24 * 60 * 60,
@@ -650,8 +582,7 @@ class PeerRunsHTTPClient:
replacement = str(refreshed.get("grant") or "")
if not replacement:
raise PeerRunsHTTPError("peer returned no refreshed room grant")
# Persist only after the target proves the replacement can authorize
# the same scoped capability endpoint.
# Persist only after the target proves the replacement authorizes the scoped endpoint.
probe = self.probe(grant=replacement)
error = digest_reauthorization_error(
GatewayRoomCatalog.from_mapping(probe.get("catalog")),
+48 -89
View File
@@ -1,9 +1,6 @@
"""Peer-backed session transport for one hosted-room member task.
This adapter implements :class:`InternalSessionRPC` without using canonical
Bot Chat. The remote client must resolve a hidden ``Group: <room_id>`` session
with ``source=bot_room`` and verify the scoped grant at admission.
"""
"""Peer-backed session transport for one hosted-room member task: implements
:class:`InternalSessionRPC` without canonical Bot Chat. The remote client must resolve a hidden
``Group: <room_id>`` session with ``source=bot_room`` and verify the scoped grant at admission."""
from __future__ import annotations
@@ -24,27 +21,16 @@ class HostedRoomPeerClient(Protocol):
"""Authenticated client for a target gateway's narrow room-member API."""
def bind_room_scope(self, **scope: Any) -> None: ...
def prepare(
self, *, room_id: str, profile: str, source: str, grant: str, create: bool,
expected_session_id: str | None = None,
) -> Mapping[str, Any] | None: ...
def prepare(self, *, room_id: str, profile: str, source: str, grant: str, create: bool,
expected_session_id: str | None = None) -> Mapping[str, Any] | None: ...
def dispatch(self, *, dispatch: Mapping[str, Any], grant: str) -> Mapping[str, Any]: ...
def history(
self, *, room_id: str, profile: str, session_id: str, grant: str
) -> Sequence[Mapping[str, Any]]: ...
def status(
self, *, room_id: str, profile: str, session_id: str, grant: str
) -> Mapping[str, Any]: ...
def history(self, *, room_id: str, profile: str, session_id: str, grant: str
) -> Sequence[Mapping[str, Any]]: ...
def status(self, *, room_id: str, profile: str, session_id: str, grant: str
) -> Mapping[str, Any]: ...
def stop(self, *, dispatch: Mapping[str, Any], grant: str) -> Mapping[str, Any] | None: ...
def stop_receipt(
self, *, task_id: str, execution_generation: int, grant: str
) -> Mapping[str, Any] | None: ...
def stop_receipt(self, *, task_id: str, execution_generation: int, grant: str
) -> Mapping[str, Any] | None: ...
@dataclass(frozen=True)
@@ -69,10 +55,8 @@ class FailoverHostedRoomPeerClient:
raise ValueError("RoomLink candidates must target one installation")
if reprobe_interval_seconds <= 0:
raise ValueError("reprobe_interval_seconds must be positive")
self.candidates = tuple(candidates)
self._active = 0
self.candidates, self._active, self.clock = tuple(candidates), 0, clock
self.reprobe_interval_seconds = float(reprobe_interval_seconds)
self.clock = clock
self._last_primary_probe = 0.0
@property
@@ -80,11 +64,9 @@ class FailoverHostedRoomPeerClient:
return self.candidates[self._active]
def _call(self, method: str, **kwargs):
"""Try the active link (re-probing the primary after a cooldown), then the rest.
Ambiguous or non-retryable failures propagate immediately: failing over
after an ambiguous dispatch could run the same task twice.
"""
"""Try the active link (re-probing the primary after a cooldown), then the rest. Ambiguous
or non-retryable failures propagate: failing over after an ambiguous dispatch could run
the same task twice."""
now = self.clock()
order = [self._active]
if self._active != 0 and now - self._last_primary_probe >= self.reprobe_interval_seconds:
@@ -102,29 +84,20 @@ class FailoverHostedRoomPeerClient:
continue
self._active = index
return result
if last_error is not None:
raise last_error
raise RuntimeError("no RoomLink candidate was attempted")
raise last_error if last_error is not None else RuntimeError("no RoomLink candidate was attempted")
def prepare(self, **kwargs):
return self._call("prepare", **kwargs)
def _delegate(method: str):
def call(self, **kwargs):
return self._call(method, **kwargs)
call.__name__ = method
return call
def dispatch(self, **kwargs):
return self._call("dispatch", **kwargs)
def history(self, **kwargs):
return self._call("history", **kwargs)
def status(self, **kwargs):
return self._call("status", **kwargs)
def stop(self, **kwargs):
return self._call("stop", **kwargs)
prepare, dispatch, history, status, stop = map(_delegate, ("prepare", "dispatch", "history", "status", "stop"))
del _delegate
def bind_room_scope(self, **kwargs):
for candidate in self.candidates:
bind = getattr(candidate.client, "bind_room_scope", None)
if callable(bind):
if callable(bind := getattr(candidate.client, "bind_room_scope", None)):
bind(**kwargs)
@@ -149,23 +122,15 @@ def build_member_dispatch(
trace_id: str) -> HostedMemberDispatch:
"""Build the fully fenced member dispatch shared by submit and recovery."""
return HostedMemberDispatch.from_mapping({
"protocol_version": PROTOCOL_VERSION,
"room_id": room_id,
"home_install_id": route.home_install_id,
"authority_gateway_id": binding.gateway_id,
"authority_epoch": binding.authority_epoch,
"member_id": route.member_id,
"target_install_id": route.target_install_id,
"target_profile": target_profile,
"task_id": task_id,
"execution_generation": execution_generation,
"source_event_seq": source_event_seq,
"cancellation_scope_id": route.cancellation_scope_id,
"prompt": prompt,
"prompt_digest": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
"protocol_version": PROTOCOL_VERSION, "room_id": room_id,
"home_install_id": route.home_install_id, "authority_gateway_id": binding.gateway_id,
"authority_epoch": binding.authority_epoch, "member_id": route.member_id,
"target_install_id": route.target_install_id, "target_profile": target_profile,
"task_id": task_id, "execution_generation": execution_generation,
"source_event_seq": source_event_seq, "cancellation_scope_id": route.cancellation_scope_id,
"prompt": prompt, "prompt_digest": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
"capability_digest": route.capability_digest,
"execution_policy_digest": route.execution_policy_digest,
"trace_id": trace_id})
"execution_policy_digest": route.execution_policy_digest, "trace_id": trace_id})
class PeerHostedRoomTransport(InternalSessionRPC):
@@ -185,8 +150,7 @@ class PeerHostedRoomTransport(InternalSessionRPC):
self.execution_generation = execution_generation
self._session_id: str | None = None
self._dispatch: HostedMemberDispatch | None = None
bind_scope = getattr(self.client, "bind_room_scope", None)
if callable(bind_scope):
if callable(bind_scope := getattr(self.client, "bind_room_scope", None)):
bind_scope(
room_id=binding.room_id, home_install_id=route.home_install_id,
authority_gateway_id=binding.gateway_id, authority_epoch=binding.authority_epoch,
@@ -205,26 +169,23 @@ class PeerHostedRoomTransport(InternalSessionRPC):
"""Room id + grant keyword arguments shared by every scoped client call."""
return {"room_id": self.binding.room_id, "grant": self.route.grant, **extra}
def _prepare(self, *, profile: str, source: str, create: bool, **extra):
return self.client.prepare(
**self._scoped(profile=profile, source=source, create=create, **extra))
def _prepare(self, *, profile: str, source: str, create: bool, title: str | None = None, **extra):
"""Validate coordinates, then the scoped ``prepare`` call."""
self._validate_coordinates(profile=profile, source=source, title=title)
return self.client.prepare(**self._scoped(profile=profile, source=source, create=create, **extra))
def resolve_exact(self, *, profile: str, title: str, source: str) -> Mapping[str, Any] | None:
self._validate_coordinates(profile=profile, source=source, title=title)
return self._prepare(profile=profile, source=source, create=False)
return self._prepare(profile=profile, source=source, create=False, title=title)
def create(self, *, profile: str, title: str, source: str) -> Mapping[str, Any]:
self._validate_coordinates(profile=profile, source=source, title=title)
session = self._prepare(profile=profile, source=source, create=True)
session = self._prepare(profile=profile, source=source, create=True, title=title)
if session is None:
raise RuntimeError("peer did not create the room session")
self._session_id = str(session.get("session_id") or session.get("id") or "")
return session
def resume(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]:
self._validate_coordinates(profile=profile, source=source)
session = self._prepare(
profile=profile, source=source, create=False, expected_session_id=session_id)
session = self._prepare(profile=profile, source=source, create=False, expected_session_id=session_id)
if session is None:
raise RuntimeError("peer room session is unavailable")
self._session_id = session_id
@@ -262,15 +223,13 @@ class PeerHostedRoomTransport(InternalSessionRPC):
) -> Mapping[str, Any] | None:
self._validate_coordinates(profile=profile, source=source)
dispatch = self._dispatch
if dispatch is None:
if (
self.task_id != expected_task_id
or not self.execution_generation
or not hasattr(self.client, "stop_receipt")):
if dispatch is not None:
if dispatch.task_id != expected_task_id:
return None
return self.client.stop_receipt(
task_id=expected_task_id, execution_generation=self.execution_generation,
grant=self.route.grant)
if dispatch.task_id != expected_task_id:
return self.client.stop(dispatch=dispatch.as_mapping(), grant=self.route.grant)
if (self.task_id != expected_task_id or not self.execution_generation
or not hasattr(self.client, "stop_receipt")):
return None
return self.client.stop(dispatch=dispatch.as_mapping(), grant=self.route.grant)
return self.client.stop_receipt(
task_id=expected_task_id, execution_generation=self.execution_generation,
grant=self.route.grant)
+28 -61
View File
@@ -1,10 +1,6 @@
"""In-process session adapter for the hosted room driver.
The room worker must not depend on a Desktop/WebSocket transport, but it should
still use the same session handlers as every other TUI/Desktop turn. This
adapter calls the installed handler registry directly and keeps the extra
task proof as an in-process-only Python object that JSON clients cannot forge.
"""
"""In-process session adapter for the hosted room driver: the room worker uses the same
installed session handlers as every TUI/Desktop turn (no WebSocket transport), passing the
task proof as an in-process-only Python object that JSON clients cannot forge."""
from __future__ import annotations
@@ -57,50 +53,32 @@ class HostedRoomServerRPC:
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
return None
row = rows[0]
return {
"session_id": row.get("resolved_id") or row.get("id"),
"title": row.get("title") or title}
return {"session_id": row.get("resolved_id") or row.get("id"),
"title": row.get("title") or title}
def create(self, *, profile: str, title: str, source: str) -> Mapping[str, Any]:
return self._call(
"session.create",
{
"profile": profile,
"title": title,
"source": source,
"hidden": True,
"room_plumbing": True,
"follow_profile_config": True,
"close_on_disconnect": False})
return self._call("session.create", {
"profile": profile, "title": title, "source": source, "hidden": True,
"room_plumbing": True, "follow_profile_config": True, "close_on_disconnect": False})
def resume(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]:
return self._call(
"session.resume",
{"profile": profile, "session_id": session_id, "omit_messages": True, "source": source})
return self._call("session.resume", {
"profile": profile, "session_id": session_id, "omit_messages": True, "source": source})
def submit(
self, *, profile: str, session_id: str, prompt: str, source: str, task: state.TaskIdentity,
execution_generation: int, on_terminal: Callable[[Mapping[str, Any]], None],
) -> Mapping[str, Any]:
try:
return self._call(
"prompt.submit",
{
"profile": profile,
"session_id": session_id,
"text": prompt,
"source": source,
"_hosted_task": {
"room_id": task.room_id,
"task_id": task.task_id,
"thread_id": task.thread_id,
"turn_id": task.turn_id,
"execution_generation": execution_generation},
"_hosted_terminal_callback": on_terminal})
return self._call("prompt.submit", {
"profile": profile, "session_id": session_id, "text": prompt, "source": source,
"_hosted_task": {
"room_id": task.room_id, "task_id": task.task_id, "thread_id": task.thread_id,
"turn_id": task.turn_id, "execution_generation": execution_generation},
"_hosted_terminal_callback": on_terminal})
except HostedRoomSessionError as exc:
# In-process prompt.submit error envelopes are returned before the
# background turn is admitted. Preserve that proof so the driver
# can defer or requeue without waiting out an ambiguity lease.
# In-process prompt.submit error envelopes come back before the background turn is
# admitted; keep that proof so the driver can defer/requeue without an ambiguity lease.
exc.not_admitted = True
raise
@@ -115,10 +93,8 @@ class HostedRoomServerRPC:
record = self.server._sessions.get(session_id)
if record is not None:
return record
for candidate in self.server._sessions.values():
if str(candidate.get("session_key") or "") == session_id:
return candidate
return None
return next((c for c in self.server._sessions.values()
if str(c.get("session_key") or "") == session_id), None)
def info(self, *, profile: str, session_id: str, source: str) -> Mapping[str, Any]:
del profile, source
@@ -130,32 +106,23 @@ class HostedRoomServerRPC:
return {"active": bool(record.get("running")), "task_id": None}
with lock:
task = record.get("_hosted_room_task")
result = {
"active": bool(record.get("running")),
"task_id": task.get("task_id") if isinstance(task, dict) else None}
result = {"active": bool(record.get("running")),
"task_id": task.get("task_id") if isinstance(task, dict) else None}
pending_reader = getattr(self.server, "_pending_approval_request_payload", None)
pending = (
pending_reader(str(record.get("session_key") or ""))
if callable(pending_reader)
else None)
if pending:
if callable(pending_reader) and (pending := pending_reader(str(record.get("session_key") or ""))):
result["status"] = "waiting_for_approval"
result["pending_approval"] = pending
return result
def approve(self, *, session_id: str, request_id: str, choice: str) -> Mapping[str, Any]:
"""Resolve one exact local room approval without broad policy changes."""
return self._call(
"approval.respond",
{"session_id": session_id, "request_id": request_id, "choice": choice, "all": False})
return self._call("approval.respond", {
"session_id": session_id, "request_id": request_id, "choice": choice, "all": False})
def interrupt(
self, *, profile: str, session_id: str, source: str, expected_task_id: str
) -> Mapping[str, Any] | None:
del source
return self._call(
"session.interrupt",
{
"profile": profile,
"session_id": session_id,
"expected_hosted_task_id": expected_task_id})
return self._call("session.interrupt", {
"profile": profile, "session_id": session_id,
"expected_hosted_task_id": expected_task_id})
+122 -197
View File
@@ -22,11 +22,11 @@ from gateway.hosted_room_peer import (
GatewayRoomCatalog, HostedMemberDispatch, PROTOCOL_VERSION, room_grant_needs_dispatch_refresh)
from tui_gateway.hosted_room_driver import HostedRoomBinding, HostedRoomRuntime
from tui_gateway.hosted_room_server_rpc import HostedRoomServerRPC
from tui_gateway.hosted_room_peer_http import PeerRunsHTTPClient, PeerRunsHTTPError
from tui_gateway.hosted_room_peer_http import (
PeerRunsHTTPClient, PeerRunsHTTPError, digest_reauthorization_error)
from tui_gateway.hosted_room_peer_transport import (
HostedRoomPeerClient, PeerHostedRoomTransport, PeerMemberRoute, build_member_dispatch)
_HOSTED_ROOM_IDLE_FALLBACK_SECONDS = 5.0
_HOSTED_ROOM_ACTIVE_POLL_SECONDS = 0.25
_HOSTED_ROOM_TERMINAL_GRACE_SECONDS = 30.0
@@ -41,10 +41,8 @@ def _hosted_room_turn_timeout_seconds() -> float:
try:
agent_timeout = float(os.getenv("HERMES_AGENT_TIMEOUT", "1800"))
except (TypeError, ValueError):
agent_timeout = 1800.0
if agent_timeout <= 0:
agent_timeout = 1800.0
return agent_timeout + _HOSTED_ROOM_TERMINAL_GRACE_SECONDS
agent_timeout = 0.0
return (agent_timeout if agent_timeout > 0 else 1800.0) + _HOSTED_ROOM_TERMINAL_GRACE_SECONDS
def _grant_revoke_is_terminal(exc: PeerRunsHTTPError) -> bool:
@@ -70,8 +68,7 @@ class HostedRoomService:
self, server: ModuleType, *, db_path: Path | str | None = None,
peer_routes: Mapping[tuple[str, str], PeerMemberRoute] | None = None,
peer_clients: Mapping[Any, HostedRoomPeerClient] | None = None) -> None:
self.server = server
self.db_path = Path(db_path or hosted_rooms.default_db_path())
self.server, self.db_path = server, Path(db_path or hosted_rooms.default_db_path())
hosted_rooms.prune_disbanded_rooms(self.db_path)
self._policy_lock = threading.RLock()
self._pending_actions: dict[tuple[str, str], dict[str, Any]] = {}
@@ -85,13 +82,10 @@ class HostedRoomService:
self._load_stored_links()
except Exception as exc:
self._link_load_error = str(exc)
supplied_routes = dict(peer_routes or {})
supplied_clients = dict(peer_clients or {})
self.peer_routes.update(supplied_routes)
for key, route in supplied_routes.items():
client = supplied_clients.get(key)
if client is None:
client = supplied_clients.get(route.target_install_id)
for key, route in dict(peer_routes or {}).items():
self.peer_routes[key] = route
client = supplied_clients.get(key, supplied_clients.get(route.target_install_id))
if client is not None:
self.peer_clients[key] = client
self.runtime = HostedRoomRuntime(
@@ -108,16 +102,15 @@ class HostedRoomService:
stored_links, load_errors = hosted_room_links.load_room_links_tolerant(self.db_path)
errors = list(load_errors)
for stored in stored_links:
key = (stored.room_id, stored.member_id)
if PROTOCOL_VERSION not in stored.catalog.protocol_versions:
key, catalog = (stored.room_id, stored.member_id), stored.catalog
if PROTOCOL_VERSION not in catalog.protocol_versions:
errors.append(f"{stored.room_id}:{stored.member_id}:protocol-upgrade-required")
continue
self.peer_routes[key] = PeerMemberRoute(
home_install_id=hosted_rooms.local_authority_gateway_id(),
member_id=stored.member_id, target_install_id=stored.catalog.installation_id,
target_profile=stored.target_profile,
capability_digest=stored.catalog.catalog_digest,
execution_policy_digest=stored.catalog.execution_policy.policy_digest,
member_id=stored.member_id, target_install_id=catalog.installation_id,
target_profile=stored.target_profile, capability_digest=catalog.catalog_digest,
execution_policy_digest=catalog.execution_policy.policy_digest,
cancellation_scope_id=stored.cancellation_scope_id, trace_id=stored.trace_id,
grant=stored.grant)
self.peer_clients[key] = PeerRunsHTTPClient(
@@ -131,8 +124,7 @@ class HostedRoomService:
return self.db_path.parent
def local_profiles(self) -> tuple[str, ...]:
profiles = {"default"}
profiles_dir = self.root / "profiles"
profiles, profiles_dir = {"default"}, self.root / "profiles"
if profiles_dir.is_dir():
profiles.update(path.name for path in profiles_dir.iterdir() if path.is_dir())
return tuple(sorted(profiles))
@@ -140,27 +132,24 @@ class HostedRoomService:
def bindings(self) -> tuple[HostedRoomBinding, ...]:
local_gateway_id = hosted_rooms.local_authority_gateway_id()
return tuple(
HostedRoomBinding(
room_id=str(room["room_id"]), gateway_id=str(room["authority_gateway_id"]),
authority_epoch=int(room["authority_epoch"]))
HostedRoomBinding(str(room["room_id"]), *_authority(room))
for room in hosted_rooms.list_rooms(self.db_path)
if str(room["authority_gateway_id"]) == local_gateway_id)
def _room(self, room_id: str) -> dict[str, Any]:
return hosted_rooms.room_state(self.db_path, room_id=room_id)
def _owned_room(self, room_id: str) -> dict[str, Any]:
room = self._room(room_id)
if str(room["authority_gateway_id"]) != hosted_rooms.local_authority_gateway_id():
def _owned_authority(self, room_id: str) -> tuple[str, int]:
"""(gateway_id, epoch) of a room this gateway owns; conflict error otherwise."""
gateway_id, epoch = _authority(self._room(room_id))
if gateway_id != hosted_rooms.local_authority_gateway_id():
raise hosted_rooms.AuthorityConflictError(
"This Group Chat is managed by another gateway.")
return room
return gateway_id, epoch
@contextlib.contextmanager
def _turn_lock(self, profile: str) -> Iterator[None]:
def _turn_lock(self, profile: str) -> contextlib.AbstractContextManager[Path]:
from tools.bot_relay import acquire_turn_lock
with acquire_turn_lock(self.root, profile):
yield
return acquire_turn_lock(self.root, profile)
def start(self) -> None:
self.runtime.start()
@@ -175,15 +164,9 @@ class HostedRoomService:
for status in statuses:
yield from driver.list_tasks(self.db_path, room_id=room_id, status=status)
def _save_link(
self, *, room_id: str, member_id: str, target_url: str, target_profile: str, grant: str,
catalog: GatewayRoomCatalog, cancellation_scope_id: str, trace_id: str) -> None:
hosted_room_links.save_room_link(
self.db_path,
hosted_room_links.make_stored_link(
room_id=room_id, member_id=member_id, target_url=target_url,
target_profile=target_profile, grant=grant, catalog=catalog,
cancellation_scope_id=cancellation_scope_id, trace_id=trace_id))
def _save_link(self, **link: Any) -> None:
"""Persist one stored link (``make_stored_link`` keyword fields)."""
hosted_room_links.save_room_link(self.db_path, hosted_room_links.make_stored_link(**link))
def register_peer_route(
self, *, room_id: str, member_id: str, route: PeerMemberRoute,
@@ -208,18 +191,19 @@ class HostedRoomService:
cancellation_scope_id=route.cancellation_scope_id, trace_id=route.trace_id)
# Persistence is the publication boundary: a failed disk write must never
# leave a process-local route that disappears after restart.
with self._policy_lock:
self.peer_routes[(room_id, member_id)] = route
self.peer_clients[(room_id, member_id)] = client
self._peer_route_status[(room_id, member_id)] = "ready"
self._publish_route((room_id, member_id), route, client)
self.runtime.wakeup()
def revoke_room_routes(self, room_id: str) -> int:
"""Revoke and forget every scoped peer route for one room.
def _publish_route(self, key: tuple[str, str], route: PeerMemberRoute, client=None) -> None:
"""Make a persisted route live as ``ready`` (and bind its client when given)."""
with self._policy_lock:
self.peer_routes[key], self._peer_route_status[key] = route, "ready"
if client is not None:
self.peer_clients[key] = client
Remote revocation is the boundary: an unreachable target leaves the room
intact for retry rather than reporting a false disband with a live grant.
"""
def revoke_room_routes(self, room_id: str) -> int:
"""Revoke and forget every scoped peer route for one room; an unreachable target
leaves the room intact for retry rather than a false disband with a live grant."""
with self._policy_lock:
routes = [(key, route) for key, route in self.peer_routes.items() if key[0] == room_id]
for key, route in routes:
@@ -234,40 +218,38 @@ class HostedRoomService:
hosted_rooms.delete_room_link_records(self.db_path, room_id=room_id)
with self._policy_lock:
for key, _route in routes:
self.peer_routes.pop(key, None)
self._peer_route_status.pop(key, None)
self.peer_clients.pop(key, None)
for table in (self.peer_routes, self._peer_route_status, self.peer_clients):
table.pop(key, None)
return len(routes)
def _resolve_member_transport(self, binding: HostedRoomBinding, task: Mapping[str, Any]):
payload = task.get("payload", {})
member_id = str(payload.get("target_member_id") or payload.get("target_profile") or "")
route = self.peer_routes.get((binding.room_id, member_id))
key = (binding.room_id, member_id)
route = self.peer_routes.get(key)
if route is None:
if self._member_is_peer(binding.room_id, member_id):
raise RuntimeError("peer room route is unavailable")
return self.rpc
client = self.peer_clients.get((binding.room_id, member_id))
client = self.peer_clients.get(key)
if client is None:
raise RuntimeError("peer room client is unavailable")
identity = task.get("identity")
execution_generation = int(task.get("execution_generation") or 0)
bind_observation = _hook(client, "bind_observation")
if (
bind_observation is not None
and isinstance(identity, driver.TaskIdentity)
bind_observation is not None and isinstance(identity, driver.TaskIdentity)
and execution_generation > 0):
bind_observation(task_id=identity.task_id, execution_generation=execution_generation)
def set_status(status: str):
return lambda: self._set_route_status(binding.room_id, member_id, status)
return lambda: self._set_route_status(*key, status)
tracked_client = _RouteStatusPeerClient(
client,
on_ready=set_status("ready"),
client, on_ready=set_status("ready"),
on_reauthorization=set_status("needs_reauthorization"),
on_unavailable=set_status("unavailable"),
on_refreshed=lambda grant, catalog=None: self._rotate_route_grant(
binding.room_id, member_id, grant, catalog))
*key, grant, catalog))
self._recover_peer_admission(binding, task, route, tracked_client)
return PeerHostedRoomTransport(
binding=binding, route=route, client=tracked_client,
@@ -279,14 +261,11 @@ class HostedRoomService:
client: Any) -> None:
"""Rediscover an admitted peer run without advancing its generation."""
recover = _hook(client, "recover_dispatch")
identity = task.get("identity")
payload = task.get("payload")
identity, payload = task.get("identity"), task.get("payload")
execution_generation = int(task.get("execution_generation") or 0)
if (
recover is None
or not isinstance(identity, driver.TaskIdentity)
or not isinstance(payload, Mapping)
or execution_generation < 1
recover is None or not isinstance(identity, driver.TaskIdentity)
or not isinstance(payload, Mapping) or execution_generation < 1
or task.get("status") not in {"running", "indeterminate", "stopping"}):
return
prompt = payload.get("prompt")
@@ -300,32 +279,28 @@ class HostedRoomService:
recover(dispatch=dispatch.as_mapping(), grant=route.grant)
def _member_is_peer(self, room_id: str, member_id: str) -> bool:
for member in self._room(room_id).get("members") or []:
if not isinstance(member, Mapping):
continue
if str(member.get("member_id") or member.get("profile") or "") != member_id:
continue
target = member.get("target")
return isinstance(target, Mapping) and target.get("kind") == "peer"
for m in self._room(room_id).get("members") or []:
if isinstance(m, Mapping) and str(
m.get("member_id") or m.get("profile") or "") == member_id:
target = m.get("target")
return isinstance(target, Mapping) and target.get("kind") == "peer"
return False
def _set_route_status(self, room_id: str, member_id: str, status: str) -> None:
key = (room_id, member_id)
with self._policy_lock:
if self._peer_route_status.get(key) == status:
if self._peer_route_status.get((room_id, member_id)) == status:
return
self._peer_route_status[key] = status
self._peer_route_status[(room_id, member_id)] = status
hosted_room_links.mark_room_link_status(
self.db_path, room_id=room_id, member_id=member_id, status=status)
def _set_pending_action(
self, room_id: str, member_id: str, action: Mapping[str, Any] | None) -> None:
key = (room_id, member_id)
with self._policy_lock:
if action is None:
self._pending_actions.pop(key, None)
self._pending_actions.pop((room_id, member_id), None)
else:
self._pending_actions[key] = {**action, "member_id": member_id}
self._pending_actions[(room_id, member_id)] = {**action, "member_id": member_id}
def _rotate_route_grant(
self, room_id: str, member_id: str, grant: str, catalog: GatewayRoomCatalog | None = None
@@ -335,11 +310,9 @@ class HostedRoomService:
route = self.peer_routes.get(key)
if route is None:
raise RuntimeError("peer room route is unavailable")
stored = next(
(
link for link in hosted_room_links.load_room_links(self.db_path)
if (link.room_id, link.member_id) == key),
None)
stored = next((
l for l in hosted_room_links.load_room_links(self.db_path)
if (l.room_id, l.member_id) == key), None)
if stored is None:
raise RuntimeError("peer room route cannot be renewed before persistence")
digests = {}
@@ -348,8 +321,7 @@ class HostedRoomService:
catalog.installation_id != route.target_install_id
or catalog.execution_policy.target_profile != route.target_profile
or PROTOCOL_VERSION not in catalog.protocol_versions
or "direct" not in catalog.link_modes
or not catalog.text
or "direct" not in catalog.link_modes or not catalog.text
or catalog.execution_policy.policy_digest != route.execution_policy_digest):
self._set_route_status(room_id, member_id, "needs_reauthorization")
raise RuntimeError(
@@ -357,22 +329,18 @@ class HostedRoomService:
digests = {
"capability_digest": catalog.catalog_digest,
"execution_policy_digest": catalog.execution_policy.policy_digest}
rotated_route = replace(route, grant=grant, **digests)
self._save_link(
room_id=room_id, member_id=member_id, target_url=stored.target_url,
target_profile=stored.target_profile, grant=grant, catalog=catalog or stored.catalog,
cancellation_scope_id=stored.cancellation_scope_id, trace_id=stored.trace_id)
with self._policy_lock:
self.peer_routes[key] = rotated_route
self._peer_route_status[key] = "ready"
self._publish_route(key, replace(route, grant=grant, **digests))
def _route_statuses(self, room_id: str | None = None) -> list[dict[str, str]]:
with self._policy_lock:
rows = [
{"room_id": key[0], "member_id": key[1], "status": status}
for key, status in self._peer_route_status.items()
if room_id is None or key[0] == room_id]
return sorted(rows, key=lambda row: (row["room_id"], row["member_id"]))
rows = sorted(self._peer_route_status.items())
return [
{"room_id": key[0], "member_id": key[1], "status": status}
for key, status in rows if room_id is None or key[0] == room_id]
def _events(self, room_id: str) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
@@ -390,35 +358,29 @@ class HostedRoomService:
raise RuntimeError("hosted room replay cursor did not advance")
cursor = next_cursor
def _append_plan(self, room_id: str, plan: discussion.PublicationPlan) -> None:
for event in plan.events:
hosted_rooms.append_event(self.db_path, **event.append_kwargs(room_id))
def _policy_snapshot(self, room: Mapping[str, Any]) -> PolicySnapshot:
return self.policy_checkpoint.snapshot(
room_id=str(room["room_id"]), latest_seq=int(room["latest_seq"]))
def _publish_terminal_tasks(self, room: Mapping[str, Any]) -> bool:
changed = False
room_id = str(room["room_id"])
local_profiles = self.local_profiles()
for status in _TERMINAL_STATUSES:
for task in driver.list_tasks(self.db_path, room_id=room_id, status=status):
execution_generation = int(task["execution_generation"])
if self.policy_checkpoint.publication_exists(
room_id=room_id, task_id=task["identity"].task_id, status=status,
execution_generation=execution_generation):
continue
task_events = self.policy_checkpoint.events_for_task(
room_id=room_id, source_event_seq=int(task["payload"]["source_event_seq"]))
plan = discussion.reconstruct_task_plan(
room, task_events, task, local_profiles=local_profiles)
publication = discussion.plan_publication(
room, task_events, plan, status=status, result=task.get("result"),
execution_generation=execution_generation if status == "deferred" else None,
local_profiles=local_profiles)
self._append_plan(room_id, publication)
changed = True
changed, room_id, local_profiles = False, str(room["room_id"]), self.local_profiles()
for task in self._list_tasks(room_id, _TERMINAL_STATUSES):
status, execution_generation = task["status"], int(task["execution_generation"])
if self.policy_checkpoint.publication_exists(
room_id=room_id, task_id=task["identity"].task_id, status=status,
execution_generation=execution_generation):
continue
task_events = self.policy_checkpoint.events_for_task(
room_id=room_id, source_event_seq=int(task["payload"]["source_event_seq"]))
plan = discussion.reconstruct_task_plan(
room, task_events, task, local_profiles=local_profiles)
publication = discussion.plan_publication(
room, task_events, plan, status=status, result=task.get("result"),
execution_generation=execution_generation if status == "deferred" else None,
local_profiles=local_profiles)
for event in publication.events:
hosted_rooms.append_event(self.db_path, **event.append_kwargs(room_id))
changed = True
return changed
def _append_room_status(
@@ -427,29 +389,26 @@ class HostedRoomService:
return
gateway_id, epoch = _authority(room)
hosted_rooms.append_event(
self.db_path,
room_id=str(room["room_id"]),
self.db_path, room_id=str(room["room_id"]),
event_id=f"dactivity:{decision.discussion_event_id}:{decision.reason}",
kind="room.activity",
actor={"kind": "gateway", "id": gateway_id},
kind="room.activity", actor={"kind": "gateway", "id": gateway_id},
payload={
"status": decision.status, "reason_code": decision.reason,
"thread_id": decision.thread_id,
"discussion_event_id": decision.discussion_event_id},
authority_gateway_id=gateway_id,
authority_epoch=epoch)
authority_gateway_id=gateway_id, authority_epoch=epoch)
def prepare_room(self, binding: HostedRoomBinding) -> None:
with self._policy_lock:
room = self._room(binding.room_id)
snapshot = self._policy_snapshot(room)
snapshot = self._policy_snapshot(room) # sync() side effect feeds the publish below
if self._publish_terminal_tasks(room):
room = self._room(binding.room_id)
snapshot = self._policy_snapshot(room)
self.policy_checkpoint.compact_completed(room_id=binding.room_id)
driver.prune_published_terminal_tasks(
self.db_path, room_id=binding.room_id, clock=self.runtime.clock)
if any(True for _ in self._list_tasks(binding.room_id, _LIVE_STATUSES)):
if next(iter(self._list_tasks(binding.room_id, _LIVE_STATUSES)), None) is not None:
return
decision = discussion.plan_next_task(
room, list(snapshot.events), local_profiles=self.local_profiles(),
@@ -458,17 +417,11 @@ class HostedRoomService:
driver.admit_task(
self.db_path, decision.task.identity, payload=decision.task.payload,
clock=time.time)
# A stop can race the policy read from another process. Re-read after
# admission and cancel before the runtime can execute a task whose
# source event is now behind the room stop fence.
stopped_through_seq = self._policy_snapshot(
self._room(binding.room_id)
).stopped_through_seq
if (
decision.source_event_seq is not None
and decision.source_event_seq < stopped_through_seq):
self.runtime.cancel(
decision.task.identity, cancel_id=f"stop-fence:{stopped_through_seq}")
# A stop can race the policy read from another process: re-read after admission
# and cancel a task whose source event is now behind the room stop fence.
fence = self._policy_snapshot(self._room(binding.room_id)).stopped_through_seq
if decision.source_event_seq is not None and decision.source_event_seq < fence:
self.runtime.cancel(decision.task.identity, cancel_id=f"stop-fence:{fence}")
elif decision.status in {"settled", "bounded"}:
self._append_room_status(room, decision)
@@ -479,9 +432,7 @@ class HostedRoomService:
def create_room(self, *, room_id: str, name: str, members: Any) -> dict[str, Any]:
normalized = discussion.validate_roster(members, local_profiles=self.local_profiles())
room = hosted_rooms.create_room(
self.db_path,
room_id=room_id,
name=name,
self.db_path, room_id=room_id, name=name,
members=[
{
"member_id": member.member_id, "profile": member.profile,
@@ -494,7 +445,7 @@ class HostedRoomService:
def send(self, *, room_id: str, event_id: str, payload: Any) -> dict[str, Any]:
normalized = discussion.validate_user_payload(payload)
gateway_id, epoch = _authority(self._owned_room(room_id))
gateway_id, epoch = self._owned_authority(room_id)
event = hosted_rooms.append_event(
self.db_path, room_id=room_id, event_id=event_id, kind="message.user",
actor={"kind": "user", "id": "desktop"}, payload=normalized,
@@ -508,36 +459,30 @@ class HostedRoomService:
def stop_room(
self, room_id: str, *, cancel_id: str, require_acknowledged: bool = False) -> int:
gateway_id, epoch = _authority(self._owned_room(room_id))
gateway_id, epoch = self._owned_authority(room_id)
hosted_rooms.request_room_stop(
self.db_path, room_id=room_id, cancel_id=cancel_id, expected_gateway_id=gateway_id,
expected_epoch=epoch)
cancelled = 0
pending = 0
with self._policy_lock:
tasks = {
(task["identity"].room_id, task["identity"].task_id): task
for task in self._list_tasks(room_id, _STOPPABLE_STATUSES)}
for task in tasks.values():
task_cancel_id = (
str(task.get("cancel_id") or "") if task.get("status") == "stopping" else "")
result = self.runtime.cancel(
task["identity"], cancel_id=task_cancel_id or cancel_id)
cancelled += 1
own_cancel_id = (
task.get("status") == "stopping" and str(task.get("cancel_id") or ""))
result = self.runtime.cancel(task["identity"], cancel_id=own_cancel_id or cancel_id)
if result["status"] == "stopping":
pending += 1
if require_acknowledged and pending:
raise RuntimeError("room work is still stopping; retry deletion after Stop completes")
self.runtime.wakeup()
return cancelled
return len(tasks)
def retry_room_task(self, room_id: str, *, task_id: str) -> dict[str, Any]:
"""Retry one uncertain or deferred task only after explicit user action."""
task = next(
(
candidate for candidate in self._list_tasks(room_id, _RETRYABLE_STATUSES)
if candidate["identity"].task_id == task_id),
None)
candidates = self._list_tasks(room_id, _RETRYABLE_STATUSES)
task = next((c for c in candidates if c["identity"].task_id == task_id), None)
if task is None:
raise driver.InvalidTaskTransitionError("no retryable room task matches task_id")
return self.runtime.retry_indeterminate(task["identity"])
@@ -547,18 +492,16 @@ class HostedRoomService:
choice: str, request_id: str | None = None) -> Mapping[str, Any]:
"""Resolve one exact local or peer approval and wake room observation."""
key = (room_id, member_id)
route = self.peer_routes.get(key)
client = self.peer_clients.get(key)
route, client = self.peer_routes.get(key), self.peer_clients.get(key)
with self._policy_lock:
action = self._pending_actions.get(key)
requested_approval_id = str(request_id or "")
def matches(pending: Mapping[str, Any] | None) -> bool:
return (
pending is not None
and str(pending.get("request_id") or "") == requested_approval_id
and pending.get("task_id") == task_id
and int(pending.get("execution_generation") or 0) == execution_generation)
return pending is not None and (
str(pending.get("request_id") or ""), pending.get("task_id"),
int(pending.get("execution_generation") or 0),
) == (requested_approval_id, task_id, execution_generation)
if not requested_approval_id or not matches(action):
raise RuntimeError("room approval is no longer pending")
if choice not in {"once", "deny"}:
@@ -592,21 +535,16 @@ class HostedRoomService:
counts = Counter(str(task["status"]) for task in tasks)
pending_actions = [
{"kind": "retry", "task_id": task["identity"].task_id}
for task in tasks
if task["status"] in _RETRYABLE_STATUSES]
for task in tasks if task["status"] in _RETRYABLE_STATUSES]
with self._policy_lock:
pending_actions.extend(
dict(action)
for (action_room_id, _member_id), action in self._pending_actions.items()
if action_room_id == room_id)
dict(action) for (action_room_id, _member_id), action
in self._pending_actions.items() if action_room_id == room_id)
return {
"running": runtime["running"],
"working": bool(
counts.get("running") or counts.get("queued") or counts.get("stopping")),
"running": runtime["running"], "working": any(counts.get(s) for s in _LIVE_STATUSES),
"blocked": room_id in runtime["blocked_rooms"]
or bool(counts.get("indeterminate") or counts.get("stopping")),
"counts": dict(counts),
"pending_actions": pending_actions,
"counts": dict(counts), "pending_actions": pending_actions,
"peer_routes": self._route_statuses(room_id)}
@@ -615,20 +553,14 @@ class _RouteStatusPeerClient:
def __init__(
self, client, *, on_ready, on_reauthorization, on_unavailable, on_refreshed) -> None:
self._client = client
self._on_ready = on_ready
self._on_reauthorization = on_reauthorization
self._on_unavailable = on_unavailable
self._on_refreshed = on_refreshed
self._client, self._on_ready, self._on_refreshed = client, on_ready, on_refreshed
self._on_reauthorization, self._on_unavailable = on_reauthorization, on_unavailable
def _refresh_grant(self, kwargs: dict) -> dict:
"""Rotate an expiring grant before dispatch; return the kwargs to send.
Refresh failures only escalate to reauthorization when the peer says so or the
grant is already past its hard expiry; otherwise the original grant is tried
as-is. A refreshed catalog whose digests drift from the dispatch is a policy
change and is refused before any dispatch.
"""
"""Rotate an expiring grant before dispatch; return the kwargs to send. Refresh
failures escalate to reauthorization only when the peer says so or the grant is
past its hard expiry; otherwise the original grant is tried as-is. A refreshed
catalog whose digests drift from the dispatch is a policy change: refused."""
grant = kwargs["grant"]
if not room_grant_needs_dispatch_refresh(grant):
return kwargs
@@ -652,19 +584,12 @@ class _RouteStatusPeerClient:
refreshed_catalog = None
if refreshed.get("catalog") is not None:
refreshed_catalog = GatewayRoomCatalog.from_mapping(refreshed.get("catalog"))
drift = None
if refreshed_catalog.execution_policy.policy_digest != checked.execution_policy_digest:
drift = (
"peer room execution policy needs reauthorization",
"room_execution_policy_changed")
elif refreshed_catalog.catalog_digest != checked.capability_digest:
drift = (
"peer room capabilities need reauthorization", "room_capability_catalog_changed"
)
drift = digest_reauthorization_error(
refreshed_catalog, capability_digest=checked.capability_digest,
execution_policy_digest=checked.execution_policy_digest)
if drift is not None:
self._on_reauthorization()
raise PeerRunsHTTPError(
drift[0], status_code=403, error_code=drift[1], not_admitted=True)
raise drift
self._on_refreshed(replacement, refreshed_catalog)
return {**kwargs, "grant": replacement}
+38 -69
View File
@@ -1,12 +1,9 @@
"""Session-backed MCP OAuth flows for the gateway (mcp.servers.oauth.*).
``start`` kicks off a background worker and returns ``{session_id, auth_url, flow}``;
``poll`` reports ``{status: pending|approved|error}`` until tokens land on disk. No OAuth
logic is reimplemented: ``hermes mcp login``'s probe under ``force_interactive_oauth``
plus ``DashboardOAuthFlow`` as the bridge; the only new piece is a loopback listener
feeding ``deliver_callback``. Remote backends: the client hosts the listener, passes
``client_redirect_uri`` and relays via ``deliver_callback_flow`` (state check stays here).
"""
"""Session-backed MCP OAuth flows for the gateway (mcp.servers.oauth.*): ``start`` spawns a
worker and returns ``{session_id, auth_url, flow}``; ``poll`` reports ``{status}`` until tokens
land. Reuses ``hermes mcp login``'s probe under ``force_interactive_oauth`` plus
``DashboardOAuthFlow``; the only new piece is a loopback listener feeding ``deliver_callback``.
Remote backends host the listener (``client_redirect_uri``) and relay via
``deliver_callback_flow``."""
from __future__ import annotations
@@ -23,18 +20,8 @@ from urllib.parse import parse_qs, urlparse
_sessions: Dict[str, Dict[str, Any]] = {}
_sessions_lock = threading.Lock()
# How long a completed/abandoned session lingers before GC (seconds).
_SESSION_TTL_SECONDS = 900
# Cap concurrent in-flight flows so a runaway client can't exhaust ports/threads.
_MAX_PENDING = 12
def _gc_sessions() -> None:
"""Drop expired sessions. Called opportunistically on start."""
cutoff = time.time() - _SESSION_TTL_SECONDS
with _sessions_lock:
for sid in [sid for sid, rec in _sessions.items() if rec["created_at"] < cutoff]:
_shutdown_listener(_sessions.pop(sid))
_SESSION_TTL_SECONDS = 900 # completed/abandoned session lingers this long before GC
_MAX_PENDING = 12 # cap in-flight flows so a runaway client can't exhaust ports/threads
def _shutdown_listener(rec: Dict[str, Any]) -> None:
@@ -48,24 +35,20 @@ def _shutdown_listener(rec: Dict[str, Any]) -> None:
def _validate_client_redirect_uri(uri: str) -> str:
"""Accept only plain-http loopback URLs (RFC 8252 native-app rules) so the
gateway can't pin an attacker-controlled redirect into a DCR registration."""
"""Accept only plain-http loopback URLs (RFC 8252) so the gateway can't pin an
attacker-controlled redirect into a DCR registration."""
parsed = urlparse(str(uri or "").strip())
host = (parsed.hostname or "").lower()
if (parsed.scheme != "http" or host not in ("127.0.0.1", "localhost", "::1") or not parsed.port
or parsed.username is not None or parsed.password is not None):
raise ValueError(
"client_redirect_uri must be a loopback http URL like "
"http://127.0.0.1:<port>/callback"
)
"client_redirect_uri must be a loopback http URL like http://127.0.0.1:<port>/callback")
return f"http://{'[' + host + ']' if ':' in host else host}:{parsed.port}{parsed.path or '/callback'}"
def _start_loopback_listener(flow) -> "http.server.HTTPServer":
"""Bind a loopback callback listener feeding ``flow.deliver_callback``; returns the
HTTPServer already serving on a daemon thread. The caller pins ``flow.redirect_uri``
from ``server_address`` BEFORE the worker starts (fixed at authorization)."""
HTTPServer already serving on a daemon thread (caller pins ``flow.redirect_uri`` from it)."""
class _Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 — stdlib naming
parsed = urlparse(self.path)
@@ -74,11 +57,11 @@ def _start_loopback_listener(flow) -> "http.server.HTTPServer":
self.end_headers()
return
qs = parse_qs(parsed.query)
code, state, error = ((qs.get(k) or [None])[0] for k in ("code", "state", "error"))
body = b"<h1>Authorization received</h1><p>You can close this tab and return to Hermes.</p>"
status = 200
try:
flow.deliver_callback(code=code, state=state, error=error)
flow.deliver_callback(
**{k: (qs.get(k) or [None])[0] for k in ("code", "state", "error")})
except Exception:
body = b"<h1>OAuth callback rejected</h1><p>The callback was invalid or already used.</p>"
status = 400
@@ -129,9 +112,9 @@ def _probe_with_rollback(
raise
def _worker(session_id: str, hermes_home: str, server_name: str, cfg: dict, reconnect_live: bool) -> None:
"""Drive the interactive MCP OAuth probe under the shared dashboard bridge (same
HERMES_HOME + secret-scope + force_interactive_oauth + dashboard_oauth_flow wrapping
def _worker(
session_id: str, hermes_home: str, server_name: str, cfg: dict, reconnect_live: bool) -> None:
"""Drive the interactive MCP OAuth probe under the shared dashboard bridge (same wrapping
as ``web_server._run_dashboard_mcp_oauth``), keyed to our session record."""
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
rec = _sessions.get(session_id)
@@ -166,23 +149,18 @@ def _worker(session_id: str, hermes_home: str, server_name: str, cfg: dict, reco
def start_flow(
hermes_home: str,
server_name: str,
cfg: dict,
*,
reconnect_live: bool = False,
url_timeout: float = 30.0,
client_redirect_uri: Optional[str] = None) -> Dict[str, Any]:
"""Begin an MCP OAuth flow and return ``{session_id, auth_url, flow}``; blocks up
to ``url_timeout`` for the authorization URL. With ``client_redirect_uri`` (remote
backend; invalid values raise ``ValueError``) no gateway-side listener is bound and
the client relays ``code``/``state`` via ``deliver_callback_flow``."""
hermes_home: str, server_name: str, cfg: dict, *, reconnect_live: bool = False,
url_timeout: float = 30.0, client_redirect_uri: Optional[str] = None) -> Dict[str, Any]:
"""Begin an MCP OAuth flow and return ``{session_id, auth_url, flow}``; blocks up to
``url_timeout`` for the authorization URL. With ``client_redirect_uri`` (invalid values
raise ``ValueError``) no gateway-side listener is bound."""
from tools.mcp_dashboard_oauth import DashboardOAuthFlow
if client_redirect_uri is not None:
client_redirect_uri = _validate_client_redirect_uri(client_redirect_uri)
_gc_sessions()
cutoff = time.time() - _SESSION_TTL_SECONDS # opportunistic GC of expired sessions
with _sessions_lock:
for sid in [sid for sid, rec in _sessions.items() if rec["created_at"] < cutoff]:
_shutdown_listener(_sessions.pop(sid))
with _sessions_lock:
active = [r for r in _sessions.values() if not r["flow"].worker_done]
if len(active) >= _MAX_PENDING:
@@ -199,18 +177,14 @@ def start_flow(
httpd = None if client_redirect_uri else _start_loopback_listener(flow)
flow.redirect_uri = (
client_redirect_uri or f"http://127.0.0.1:{httpd.server_address[1]}/callback")
rec = {
"session_id": session_id, "server_name": server_name, "hermes_home": hermes_home,
"flow": flow, "httpd": httpd, "created_at": time.time(),
}
"flow": flow, "httpd": httpd, "created_at": time.time()}
with _sessions_lock:
_sessions[session_id] = rec
threading.Thread(
target=_worker, args=(session_id, hermes_home, server_name, dict(cfg), reconnect_live),
daemon=True, name=f"mcp-oauth-{server_name}").start()
try:
auth_url = None
# wait_for_authorization_url is async; run its wait synchronously.
@@ -220,7 +194,8 @@ def start_flow(
if auth_url := snap.get("authorization_url"):
break
if snap.get("status") == "error":
raise RuntimeError(snap.get("error") or "MCP OAuth flow failed before authorization")
raise RuntimeError(
snap.get("error") or "MCP OAuth flow failed before authorization")
time.sleep(0.1)
if not auth_url:
raise TimeoutError("Timed out waiting for MCP authorization URL")
@@ -228,9 +203,7 @@ def start_flow(
flow.mark_error("Timed out waiting for MCP authorization URL")
_shutdown_listener(rec)
raise
# ``flow`` mirrors the provider-OAuth discriminator: open a URL then poll
# (no user_code to type, unlike device_code).
# ``flow`` mirrors the provider-OAuth discriminator: open a URL then poll (no user_code).
return {"session_id": session_id, "auth_url": auth_url, "flow": "pkce"}
@@ -246,21 +219,19 @@ def _lookup(session_id: str, server_name: str) -> "tuple[Dict[str, Any] | None,
def poll_flow(session_id: str, server_name: str) -> Dict[str, Any]:
"""Poll a session → ``{status, error_message?, auth_url?, tools?}``; ``status``
is ``pending`` | ``approved`` | ``error`` (the bridge's ``authorization_required``
maps to ``pending`` — the client only needs to know whether to keep waiting)."""
"""Poll a session → ``{status, error_message?, auth_url?, tools?}``; ``status`` is
``pending`` | ``approved`` | ``error`` (the bridge's ``authorization_required`` maps to
``pending``)."""
rec, err = _lookup(session_id, server_name)
if rec is None:
return {"status": "error", "error_message": err}
flow = rec["flow"]
snap = flow.snapshot()
raw = snap.get("status")
status = raw if raw in ("approved", "error") else "pending"
out: Dict[str, Any] = {
"session_id": session_id, "status": status, "error_message": snap.get("error"),
"auth_url": snap.get("authorization_url"),
}
"auth_url": snap.get("authorization_url")}
if status == "approved":
out["tools"] = list(getattr(flow, "tools", []) or [])
return out
@@ -268,12 +239,10 @@ def poll_flow(session_id: str, server_name: str) -> Dict[str, Any]:
def deliver_callback_flow(
session_id: str, server_name: str, *, code: Optional[str], state: Optional[str],
error: Optional[str] = None,
) -> Dict[str, Any]:
"""Relay a client-captured OAuth redirect into a session's flow (remote-backend
companion to ``start_flow(client_redirect_uri=...)``). Security is unchanged:
``DashboardOAuthFlow.deliver_callback`` verifies ``state`` (constant-time) and
rejects replays. Returns ``{ok: true}`` or ``{ok: false, error_message}``."""
error: Optional[str] = None) -> Dict[str, Any]:
"""Relay a client-captured OAuth redirect into a session's flow (remote-backend companion
to ``start_flow(client_redirect_uri=...)``); ``deliver_callback`` still verifies ``state``
and rejects replays. Returns ``{ok: true}`` or ``{ok: false, error_message}``."""
rec, err = _lookup(session_id, server_name)
if rec is None:
return {"ok": False, "error_message": err}
+19 -35
View File
@@ -1,12 +1,9 @@
"""Seam for the server.py handler/helper split.
server.py's JSON-RPC handlers and helpers close over its module globals (``_sessions``,
``_ok``, ``_err``, ...). Split modules define their code normally and server.py calls
:func:`bind_module` at the end of its own import, once every global exists: bodies are
re-created with ``types.FunctionType`` against server.py's namespace, so they stay
byte-identical and ``global X`` statements keep mutating server.py state. No import
cycle: split modules never import server at module level — server passes itself in.
"""
"""Seam for the server.py handler/helper split. server.py's JSON-RPC handlers and helpers close
over its module globals (``_sessions``, ``_ok``, ``_err``, ...). Split modules define their code
normally and server.py calls :func:`bind_module` at the end of its own import, once every global
exists: bodies are re-created with ``types.FunctionType`` against server.py's namespace, so they
stay byte-identical and ``global X`` keeps mutating server.py state. No import cycle: split
modules never import server at module level — server passes itself in."""
import contextlib
import types
@@ -27,18 +24,15 @@ def rebind(fn, g: dict, _seen=None):
return contextlib.contextmanager(rebind(wrapped, g, _seen))
closure = fn.__closure__
if closure:
cells = []
for cell in closure:
def _cell(cell):
try:
val = cell.cell_contents
except ValueError: # empty cell
cells.append(cell)
continue
return cell
if isinstance(val, types.FunctionType) and val.__module__ == fn.__module__:
cells.append(types.CellType(rebind(val, g, _seen)))
else:
cells.append(cell)
closure = tuple(cells)
return types.CellType(rebind(val, g, _seen))
return cell
closure = tuple(_cell(c) for c in closure)
real = types.FunctionType(fn.__code__, g, fn.__name__, fn.__defaults__, closure)
real.__kwdefaults__ = fn.__kwdefaults__
real.__doc__ = fn.__doc__
@@ -55,11 +49,9 @@ class HandlerRegistry:
def method(self, name: str):
"""Drop-in for server.py's ``@method`` decorator (defers registration)."""
def dec(fn):
self._pending.append((name, fn))
return fn
return dec
def profile_scoped(self, fn):
@@ -82,16 +74,11 @@ _PLUMBING = {"HandlerRegistry", "method", "_profile_scoped", "register", "rebind
def bind_module(module_globals: dict, server, *, skip=()) -> None:
"""Publish everything a split module defines onto ``server``, rebound to its globals.
``module_globals`` is the caller's ``globals()`` (not ``sys.modules[__name__]``: tests
that ``patch.dict(sys.modules)`` around the server import drop the submodule entries
while the package attribute survives, so a re-import would KeyError). Functions are
rebound; classes get their methods rebound in place; dispatch tables (dict/tuple/list
holding this module's functions) get their values rebound; other values (constants,
``global``-mutated state seeds) are copied as-is. Imported modules/functions, dunders
and registry plumbing are skipped, so no hand-maintained export list is needed.
Finally the module's ``_registry`` (if any) installs its @method handlers.
"""
``module_globals`` is the caller's ``globals()`` (not ``sys.modules[__name__]``: tests that
``patch.dict(sys.modules)`` around the server import drop the submodule entries). Functions
are rebound; classes get their methods rebound in place; dispatch tables (dict/tuple/list of
this module's functions) get their values rebound; other values are copied as-is. Imported
modules/functions, dunders and registry plumbing are skipped; finally ``_registry`` installs."""
g = vars(server)
mod_name = module_globals["__name__"]
seen: dict = {}
@@ -104,18 +91,15 @@ def bind_module(module_globals: dict, server, *, skip=()) -> None:
return rebind(v, g, seen)
if isinstance(v, dict):
return {k: _rebind_in(x) for k, x in v.items()}
if isinstance(v, (tuple, list)):
return type(v)(_rebind_in(x) for x in v)
return v
return type(v)(_rebind_in(x) for x in v) if isinstance(v, (tuple, list)) else v
def _has_own_fn(v):
items = v.values() if isinstance(v, dict) else v if isinstance(v, (tuple, list)) else None
return _own_fn(v) if items is None else any(_has_own_fn(x) for x in items)
for name, obj in list(module_globals.items()):
if name.startswith("__") or name in _PLUMBING or name in skip:
continue
if isinstance(obj, (types.ModuleType, HandlerRegistry)):
if (name.startswith("__") or name in _PLUMBING or name in skip
or isinstance(obj, (types.ModuleType, HandlerRegistry))):
continue
if isinstance(obj, types.FunctionType):
if obj.__module__ == mod_name:
+32 -61
View File
@@ -1,12 +1,10 @@
"""Bot-relay JSON-RPC handlers — the gateway side of cross-connection A2A.
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.
"""
"""Bot-relay JSON-RPC handlers — the gateway side of cross-connection A2A. 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
import subprocess
@@ -26,7 +24,6 @@ def _relay_root() -> Path:
def _run_delivery(profile: str, tmp: str) -> subprocess.CompletedProcess:
from tools.bot_relay import local_delivery_command
return subprocess.run(
local_delivery_command(profile, tmp), capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=600)
@@ -34,14 +31,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 → ``{count}`` accepted rows.
``agents``: rows ``{profile, handle, connection_id, connection_label?, title?, description?}``;
rows failing validation are dropped, not fatal.
"""
"""Replace this gateway's view of agents on OTHER connections → ``{count}`` accepted rows
(``agents`` rows ``{profile, handle, connection_id, ...}``; invalid rows are dropped)."""
try:
from tools.bot_relay import write_remote_roster
return _ok(rid, {"count": write_remote_roster(_root(), params.get("agents"))})
except Exception as e:
return _err(rid, 5090, str(e))
@@ -49,13 +42,10 @@ 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 → ``{envelopes}``.
Claimed envelopes move to ``claimed/`` atomically, so concurrent drains can't double-deliver.
"""
"""Claim every pending cross-connection envelope queued here → ``{envelopes}``; claimed
envelopes move to ``claimed/`` atomically so concurrent drains can't double-deliver."""
try:
from tools.bot_relay import claim_pending_envelopes
return _ok(rid, {"envelopes": claim_pending_envelopes(_root())})
except Exception as e:
return _err(rid, 5091, str(e))
@@ -66,10 +56,7 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
"""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
profile = str(params.get("profile") or "").strip()
message = str(params.get("message") or "").strip()
if not profile or not message:
@@ -77,14 +64,12 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
try:
from tools.bot_mode_dm import MESSAGE_MAX_CHARS
from tools.bot_relay import acquire_turn_lock
if len(message) > MESSAGE_MAX_CHARS + 200: # + attribution headroom
return _err(rid, 4091, "message too long")
root = _root()
known = {"default"}
profiles_dir = root / "profiles"
if profiles_dir.is_dir():
known.update(c.name for c in profiles_dir.iterdir() if c.is_dir())
if (root / "profiles").is_dir():
known.update(c.name for c in (root / "profiles").iterdir() if c.is_dir())
resolved = "default" if profile.lower() == "hermes" else profile
if resolved not in known:
return _err(rid, 4092, f"no profile '{profile}' on this gateway")
@@ -92,23 +77,15 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
# 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
live_home = _profile_home(profile_name)
want_home = str(live_home) if live_home is not None else None
for live_sid, record in list(_sessions.items()):
if not isinstance(record, dict):
continue
if (record.get("profile_home") or None) != want_home:
continue
key = _session_lookup_key(record, fallback=live_sid)
if _session_live_title(record, key) == BOT_CHAT_TITLE:
return live_sid
return ""
live_sid = _live_bot_chat_sid(resolved)
# and streaming behave as a typed message would.
from tools.bot_mode_probe import BOT_CHAT_TITLE
live_home = _profile_home(resolved)
want_home = str(live_home) if live_home is not None else None
live_sid = next((
live_sid for live_sid, record in list(_sessions.items())
if isinstance(record, dict) and (record.get("profile_home") or None) == want_home
and _session_live_title(
record, _session_lookup_key(record, fallback=live_sid)) == BOT_CHAT_TITLE), "")
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.
@@ -118,6 +95,9 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
reply = f"Delivered into @{resolved}'s open Bot Chat; the reply will appear there."
return _ok(rid, {"reply": reply})
def _detail(p) -> str:
return (p.stderr or p.stdout or "").strip()[-500:]
fd, tmp = tempfile.mkstemp(prefix="hermes-relay-dm-", suffix=".txt", text=True)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
@@ -133,28 +113,22 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
# 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)
first_detail = (proc.stderr or proc.stdout or "").strip()[-500:]
if retry_action(classify_agent_error(first_detail)) != RETRY_NONE:
if retry_action(classify_agent_error(_detail(proc))) != RETRY_NONE:
proc = _run(resolved, tmp)
finally:
with contextlib.suppress(OSError):
os.unlink(tmp)
if proc.returncode != 0:
from tools.bot_failure_reasons import classify_agent_error
detail = (proc.stderr or proc.stdout or "").strip()[-500:]
return _err(
rid, 5092, f"delivery turn failed: {detail or proc.returncode}",
data={"reason": classify_agent_error(detail)})
detail = _detail(proc)
return _err(rid, 5092, f"delivery turn failed: {detail or proc.returncode}",
data={"reason": classify_agent_error(detail)})
return _ok(rid, {"reply": (proc.stdout or "").strip()})
except subprocess.TimeoutExpired:
return _err(rid, 5093, "delivery turn timed out")
except Exception as e:
# 'target_busy' extends the structured refusal enum.
if getattr(e, "reason", "") == "target_busy":
return _err(rid, 5096, str(e))
return _err(rid, 5094, str(e))
return _err(rid, 5096 if getattr(e, "reason", "") == "target_busy" else 5094, str(e))
@method("bot_relay.reply")
@@ -166,10 +140,8 @@ def _(rid, params: dict, _root=_relay_root) -> dict:
return _err(rid, 4093, "id required")
try:
from tools.bot_relay import write_reply
write_reply(
_root(), envelope_id, reply=str(params.get("reply") or ""),
error=str(params.get("error") or ""), reason=str(params.get("reason") or ""))
write_reply(_root(), envelope_id, reply=str(params.get("reply") or ""),
error=str(params.get("error") or ""), reason=str(params.get("reason") or ""))
return _ok(rid, {"ok": True})
except ValueError as e:
return _err(rid, 4094, str(e))
@@ -180,7 +152,6 @@ def _(rid, params: dict, _root=_relay_root) -> dict:
def register(server) -> None:
_registry.install(server)
from . import methods_groups
server._LONG_HANDLERS = server._LONG_HANDLERS | methods_groups.LONG_HANDLERS
for name in (
"get_hosted_room_service", "_WORKER_UNAVAILABLE", "_profile_name", "_requested_profile",
+35 -75
View File
@@ -1,8 +1,5 @@
"""Browser connect/disconnect helpers for the browser.* RPCs (CDP probing, no network I/O on status).
Bodies are rebound onto server.py's globals at install time (see
method_ctx.bind_module), so they reference server.py globals bare.
"""
"""Browser connect/disconnect helpers for the browser.* RPCs (CDP probing, no network I/O on
status). Bodies are rebound onto server.py's globals at install time (method_ctx.bind_module)."""
from __future__ import annotations
@@ -14,25 +11,17 @@ _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.
"""
env_url = os.environ.get("BROWSER_CDP_URL", "").strip()
if env_url:
"""Configured browser CDP override without network I/O (``/browser status`` must be fast;
``tools.browser_tool._get_cdp_override`` HTTP-probes discovery URLs). Same precedence (env,
then ``browser.cdp_url``) minus WS resolution; ``browser_navigate`` normalizes on the next call."""
if env_url := os.environ.get("BROWSER_CDP_URL", "").strip():
return env_url
try:
with contextlib.suppress(Exception):
from hermes_cli.config import read_raw_config
cfg = read_raw_config()
browser_cfg = cfg.get("browser", {}) if isinstance(cfg, dict) else {}
if isinstance(browser_cfg, dict):
return str(browser_cfg.get("cdp_url", "") or "").strip()
except Exception:
pass
return ""
@@ -40,59 +29,29 @@ 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."""
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"})
with contextlib.suppress(ValueError):
return (parsed.scheme in {"http", "ws"} and parsed.hostname in {"127.0.0.1", "localhost"}
and (parsed.port or 80) == 9222 and parsed.path in {"", "/", "/json", "/json/version"})
return False
def _cdp_http_reachable(parsed, timeout: float = 2.0) -> bool:
"""True when ``/json/version`` or ``/json`` on the CDP host answers 2xx."""
import urllib.request
scheme = {"ws": "http", "wss": "https"}.get(parsed.scheme, parsed.scheme)
root = f"{scheme}://{parsed.netloc}".rstrip("/")
for url in (f"{root}/json/version", f"{root}/json"):
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
if 200 <= getattr(resp, "status", 200) < 300:
return True
except Exception:
pass
with contextlib.suppress(Exception), urllib.request.urlopen(url, timeout=timeout) as resp:
if 200 <= getattr(resp, "status", 200) < 300:
return True
return False
def _normalize_cdp_url(parsed) -> str:
# Concrete ``/devtools/browser/<id>`` endpoints stay as-is; discovery-style inputs
# collapse to ``scheme://host:port`` so ``_resolve_cdp_override`` can append ``/json/version``.
if parsed.path.startswith("/devtools/browser/"):
return parsed.geturl()
return parsed._replace(path="", params="", query="", fragment="").geturl()
def _launch_failure_hints(port: int, system: str) -> list[str]:
from hermes_cli.browser_connect import manual_chrome_debug_command
command = manual_chrome_debug_command(port, system)
hint = (
["Start a Chromium-family browser with remote debugging, then retry /browser connect:", command]
if command
else [
"No supported Chromium-family browser executable was found in this environment.",
f"Install one or start a Chromium-family browser with --remote-debugging-port={port}, then retry /browser connect.",
])
return [
*hint,
"Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect",
]
def _connect_local_default(port: int, system: str, announce) -> str | None:
"""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)
discover_local_cdp_url, find_free_debug_port, launch_chrome_debug, local_port_in_use,
manual_chrome_debug_command)
# 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
@@ -104,10 +63,9 @@ def _connect_local_default(port: int, system: str, announce) -> str | None:
launch_port = port
if local_port_in_use(port):
launch_port = find_free_debug_port(port)
announce(
f"Port {port} is occupied by another application that isn't a CDP browser "
"(an IDE debugger or dev server may be using it) — launching a debug browser "
f"on port {launch_port} instead...")
announce(f"Port {port} is occupied by another application that isn't a CDP browser "
"(an IDE debugger or dev server may be using it) — launching a debug browser "
f"on port {launch_port} instead...")
else:
announce("Chromium-family browser isn't running with remote debugging — attempting to launch...")
launch = launch_chrome_debug(launch_port, system)
@@ -115,8 +73,7 @@ def _connect_local_default(port: int, system: str, announce) -> str | None:
# Bounded wait: the whole connect must finish inside the client RPC timeout.
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
discovered = discover_local_cdp_url(launch_port, timeout=1.0)
if discovered:
if discovered := discover_local_cdp_url(launch_port, timeout=1.0):
break
time.sleep(0.5)
if discovered:
@@ -124,18 +81,23 @@ def _connect_local_default(port: int, system: str, announce) -> str | None:
return discovered
if launch.hint:
announce(launch.hint, level="error")
for line in _launch_failure_hints(launch_port, system):
command = manual_chrome_debug_command(launch_port, system)
hints = (
["Start a Chromium-family browser with remote debugging, then retry /browser connect:", command]
if command else [
"No supported Chromium-family browser executable was found in this environment.",
f"Install one or start a Chromium-family browser with --remote-debugging-port={launch_port}, then retry /browser connect."])
hints.append("Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect")
for line in hints:
announce(line, level="error")
return None
def _browser_connect(rid, params: dict) -> dict:
import platform
from hermes_cli.browser_connect import DEFAULT_BROWSER_CDP_URL
from tools.browser_tool import cleanup_all_browsers
from urllib.parse import urlparse
raw_url = params.get("url")
if raw_url is not None and not isinstance(raw_url, str):
return _err(rid, 4015, f"browser url must be a string, got {type(raw_url).__name__}")
@@ -144,10 +106,9 @@ def _browser_connect(rid, params: dict) -> dict:
def announce(message: str, *, level: str = "info") -> None:
messages.append(message)
# Without a session id the TUI prints `messages` from the response; an event would double-render.
# Without a session id the TUI prints `messages` from the response (an event would double-render).
if sid:
_emit("browser.progress", sid, {"message": message, "level": level})
parsed = urlparse(url if "://" in url else f"http://{url}")
if parsed.scheme not in _CDP_SCHEMES:
return _err(rid, 4015, f"unsupported browser url: {url}")
@@ -167,7 +128,6 @@ def _browser_connect(rid, params: dict) -> dict:
# check TCP reachability only and let browser_navigate handshake.
if parsed.scheme in {"ws", "wss"} and parsed.path.startswith("/devtools/browser/"):
import socket
try:
with socket.create_connection((parsed.hostname, port), timeout=2.0):
pass
@@ -182,7 +142,10 @@ def _browser_connect(rid, params: dict) -> dict:
parsed = urlparse(url)
elif not _cdp_http_reachable(parsed):
return _err(rid, 5031, f"could not reach browser CDP at {url}")
normalized = _normalize_cdp_url(parsed)
# Concrete ``/devtools/browser/<id>`` endpoints stay as-is; discovery-style inputs collapse
# to ``scheme://host:port`` so ``_resolve_cdp_override`` can append ``/json/version``.
normalized = (parsed.geturl() if parsed.path.startswith("/devtools/browser/")
else parsed._replace(path="", params="", query="", fragment="").geturl())
# 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()
@@ -190,10 +153,8 @@ def _browser_connect(rid, params: dict) -> dict:
cleanup_all_browsers()
except Exception as e:
return _err(rid, 5031, str(e))
payload: dict[str, object] = {"connected": True, "url": normalized}
if messages:
payload["messages"] = messages
return _ok(rid, payload)
return _ok(rid, {"connected": True, "url": normalized,
**({"messages": messages} if messages else {})})
def _browser_disconnect(rid) -> dict:
@@ -201,7 +162,6 @@ def _browser_disconnect(rid) -> dict:
def reap() -> None:
with contextlib.suppress(Exception):
from tools.browser_tool import cleanup_all_browsers
cleanup_all_browsers()
reap()
+153 -203
View File
@@ -4,7 +4,6 @@ Rebound onto server.py's globals at install time (``method_ctx.bind_module``), s
bodies reference server globals bare (``_ok``, ``_err``, ``_sessions``, ...).
"""
from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
@@ -13,15 +12,10 @@ _profile_scoped = _registry.profile_scoped
_BUILTIN_AT_PREFIXES = frozenset({"file", "folder", "url", "git", "diff", "staged"})
_AT_DIRECTIVE_HINTS = [
("@diff", "git diff"),
("@staged", "staged diff"),
("@file:", "attach file"),
("@folder:", "attach folder"),
("@url:", "fetch url"),
("@git:", "git log")]
("@diff", "git diff"), ("@staged", "staged diff"), ("@file:", "attach file"),
("@folder:", "attach folder"), ("@url:", "fetch url"), ("@git:", "git log")]
_SLASH_EXTRAS = [
("/density", "Toggle compact display mode"),
("/details", "Control agent detail visibility"),
("/density", "Toggle compact display mode"), ("/details", "Control agent detail visibility"),
("/logs", "Show recent gateway log lines"),
("/mouse", "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]")]
@@ -30,6 +24,20 @@ def _item(text: str, meta: str, display: str | None = None) -> dict:
return {"text": text, "display": display if display is not None else text, "meta": meta}
def _catch(fail_code: int):
"""Handler body exceptions → ``_err(rid, fail_code, str(e))``."""
def deco(body):
def handler(rid, params: dict) -> dict:
try:
return body(rid, params)
except Exception as e:
return _err(rid, fail_code, str(e))
handler.__doc__ = body.__doc__
return handler
return deco
@method("paste.collapse")
def _(rid, params: dict) -> dict:
global _paste_counter
@@ -56,13 +64,11 @@ def _profile_mention_items(prefix: str) -> list[dict]:
from hermes_cli.profiles import list_profiles
seen: set[str] = set()
for p in list_profiles():
name = (p.name or "").strip()
if not name:
if not (name := (p.name or "").strip()):
continue
seen.add(name.lower())
desc = (getattr(p, "description", "") or "").strip()
if name.lower().startswith(prefix.lower()):
out.append(_item(f"@{name}", desc or "agent profile"))
out.append(_item(f"@{name}", (getattr(p, "description", "") or "").strip() or "agent profile"))
if "hermes".startswith(prefix.lower()) and "hermes" not in seen:
out.append(_item("@hermes", "agent profile (primary)"))
except Exception:
@@ -75,21 +81,18 @@ def _plugin_reference_items(pfx: str, qval: str) -> list[dict] | None:
no provider owns ``pfx`` or it fails."""
try:
from agent.context_references import get_context_reference_providers
prov = get_context_reference_providers().get(pfx)
if prov is None:
return None
import asyncio
if (prov := get_context_reference_providers().get(pfx)) is None:
return None
coro = prov.autocomplete(qval, limit=20)
try:
loop = asyncio.get_running_loop()
asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
ac = asyncio.run(coro)
else: # already inside a running loop: run the coroutine on a side thread
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
ac = pool.submit(asyncio.run, coro).result()
else:
ac = asyncio.run(coro)
return [{"text": f"@{pfx}:{it.text}", "display": it.display, "meta": it.meta} for it in ac]
except Exception:
return None
@@ -105,19 +108,16 @@ def _fuzzy_basename_items(root: str, path_part: str, prefix_tag: str) -> list[di
def _consider(rel: str, name: str, is_dir: bool) -> None:
if rel in seen or (name.startswith(".") and not want_hidden):
return
rank = _fuzzy_basename_rank(name, path_part)
if rank is not None:
if (rank := _fuzzy_basename_rank(name, path_part)) is not None:
seen.add(rel)
ranked.append((rank, rel, name, is_dir))
# Seed with root's immediate children: `_list_repo_files` is capped at _FUZZY_CACHE_MAX_FILES
# and the non-git fallback walk can burn the whole budget on one deep subtree.
try:
with contextlib.suppress(OSError):
for entry in os.listdir(root):
if entry not in _FUZZY_FALLBACK_EXCLUDES:
_consider(entry, entry, os.path.isdir(os.path.join(root, entry)))
except OSError:
pass
for rel in _list_repo_files(root):
_consider(rel, os.path.basename(rel), False)
# Rank each ancestor dir too — a folder with no name-matching file inside is otherwise invisible.
@@ -133,181 +133,141 @@ def _fuzzy_basename_items(root: str, path_part: str, prefix_tag: str) -> list[di
return [
_item(
f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}",
"dir" if is_dir else os.path.dirname(rel),
basename + ("/" if is_dir else ""))
"dir" if is_dir else os.path.dirname(rel), basename + ("/" if is_dir else ""))
for _, rel, basename, is_dir in ranked[:30]]
def _at_root_items() -> list[dict]:
"""Completions for a bare ``@``: directive hints, agent profiles, plugin ``@<prefix>:`` providers."""
items = [_item(t, m) for t, m in _AT_DIRECTIVE_HINTS] + _profile_mention_items("")
with contextlib.suppress(Exception):
from agent.context_references import get_context_reference_providers
for pfx, prov in sorted(get_context_reference_providers().items()):
items.append(_item(f"@{pfx}:", prov.description or f"plugin: {pfx}"))
return items
def _dir_listing_items(root: str, word: str, path_part: str, prefix_tag: str, is_context: bool) -> list[dict]:
"""Prefix-match entries of the directory ``path_part`` points at (max 30)."""
expanded = _normalize_completion_path(path_part) if path_part else "."
if expanded == "." or not expanded or expanded.endswith("/"):
search_dir, match = (expanded or "."), ""
else:
search_dir, match = os.path.dirname(expanded) or ".", os.path.basename(expanded)
search_dir = search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir)
items: list[dict] = []
if not os.path.isdir(search_dir):
return items
for entry in sorted(os.listdir(search_dir)):
if match and not entry.lower().startswith(match.lower()):
continue
if is_context and (entry in _FUZZY_FALLBACK_EXCLUDES or (not prefix_tag and entry.startswith("."))):
continue
full = os.path.join(search_dir, entry)
is_dir = os.path.isdir(full)
if prefix_tag and (prefix_tag == "folder") != is_dir: # explicit `@folder:`/`@file:` skip the other kind
continue
rel = os.path.relpath(full, root).replace(os.sep, "/")
suffix = "/" if is_dir else ""
if is_context:
text = f"@{prefix_tag or ('folder' if is_dir else 'file')}:{rel}{suffix}"
elif word.startswith("~"):
text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix
else:
text = ("./" if word.startswith("./") else "") + rel + suffix
items.append(_item(text, "dir" if is_dir else "", entry + suffix))
if len(items) >= 30:
break
return items
@method("complete.path")
@_catch(5021)
def _(rid, params: dict) -> dict:
word = params.get("word", "")
if not word:
return _ok(rid, {"items": []})
items: list[dict] = []
try:
root = _completion_cwd(params)
is_context = word.startswith("@")
query = word[1:] if is_context else word
if is_context and not query:
items = [_item(t, m) for t, m in _AT_DIRECTIVE_HINTS]
items.extend(_profile_mention_items("")) # `@` alone reveals agent profiles too
try:
from agent.context_references import get_context_reference_providers
for _pfx, _prov in sorted(get_context_reference_providers().items()):
items.append(_item(f"@{_pfx}:", _prov.description or f"plugin: {_pfx}"))
except Exception:
pass
return _ok(rid, {"items": items})
# Plugin `@<prefix>:<query>` runs before the built-in file/folder branching.
if is_context and ":" in query:
_pfx, _, _qval = query.partition(":")
if _pfx not in _BUILTIN_AT_PREFIXES:
plugin_items = _plugin_reference_items(_pfx, _qval)
if plugin_items is not None:
return _ok(rid, {"items": plugin_items})
# Bare `@folder` lists as soon as the keyword is typed (the static `@folder:` hint is not accepted).
if is_context and query in {"file", "folder"}:
prefix_tag, path_part = query, ""
elif is_context and query.startswith(("file:", "folder:")):
prefix_tag, _, path_part = query.partition(":")
else:
prefix_tag, path_part = "", query
# `@/foo` usually means "foo, from here": absolute only when that prefix exists,
# else resolve relative to cwd (`@/Desktop` must not dead-end; `@/usr/local` still resolves).
if is_context and path_part.startswith("/") and not path_part.startswith("//"):
if not _abs_completion_prefix_exists(path_part):
path_part = path_part.lstrip("/")
if is_context and path_part and len(path_part.strip()) >= 2 and "/" not in path_part and prefix_tag != "folder":
items = _fuzzy_basename_items(root, path_part, prefix_tag)
if not prefix_tag: # bare `@name` may be an agent mention: profiles rank ABOVE file hits
items = _profile_mention_items(path_part) + items
return _ok(rid, {"items": items})
expanded = _normalize_completion_path(path_part) if path_part else "."
if expanded == "." or not expanded:
search_dir, match = ".", ""
elif expanded.endswith("/"):
search_dir, match = expanded, ""
else:
search_dir = os.path.dirname(expanded) or "."
match = os.path.basename(expanded)
search_dir = search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir)
if not os.path.isdir(search_dir):
return _ok(rid, {"items": []})
want_dir = prefix_tag == "folder"
match_lower = match.lower()
for entry in sorted(os.listdir(search_dir)):
if match and not entry.lower().startswith(match_lower):
continue
if is_context and entry in _FUZZY_FALLBACK_EXCLUDES:
continue
if is_context and not prefix_tag and entry.startswith("."):
continue
full = os.path.join(search_dir, entry)
is_dir = os.path.isdir(full)
# Explicit `@folder:` / `@file:` skip the opposite kind (never rewrite the tag).
if prefix_tag and want_dir != is_dir:
continue
rel = os.path.relpath(full, root).replace(os.sep, "/")
suffix = "/" if is_dir else ""
if is_context and prefix_tag:
text = f"@{prefix_tag}:{rel}{suffix}"
elif is_context:
text = f"@{'folder' if is_dir else 'file'}:{rel}{suffix}"
elif word.startswith("~"):
text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix
elif word.startswith("./"):
text = "./" + rel + suffix
else:
text = rel + suffix
items.append(_item(text, "dir" if is_dir else "", entry + suffix))
if len(items) >= 30:
break
except Exception as e:
return _err(rid, 5021, str(e))
# Bare-word `@name` (incl. single chars, which skip the fuzzy branch): profiles rank above paths.
try:
if is_context and not prefix_tag and path_part and "/" not in path_part:
root = _completion_cwd(params)
is_context = word.startswith("@")
query = word[1:] if is_context else word
if is_context and not query:
return _ok(rid, {"items": _at_root_items()})
# Plugin `@<prefix>:<query>` runs before the built-in file/folder branching.
if is_context and ":" in query:
pfx, _, qval = query.partition(":")
if pfx not in _BUILTIN_AT_PREFIXES and (plugin_items := _plugin_reference_items(pfx, qval)) is not None:
return _ok(rid, {"items": plugin_items})
# Bare `@folder` lists as soon as the keyword is typed (the static `@folder:` hint is not accepted).
if is_context and (query in {"file", "folder"} or query.startswith(("file:", "folder:"))):
prefix_tag, _, path_part = query.partition(":")
else:
prefix_tag, path_part = "", query
# `@/foo` usually means "foo, from here": absolute only when that prefix exists,
# else resolve relative to cwd (`@/Desktop` must not dead-end; `@/usr/local` still resolves).
if (
is_context and path_part.startswith("/") and not path_part.startswith("//")
and not _abs_completion_prefix_exists(path_part)):
path_part = path_part.lstrip("/")
bare_word = is_context and path_part and "/" not in path_part
if bare_word and len(path_part.strip()) >= 2 and prefix_tag != "folder":
items = _fuzzy_basename_items(root, path_part, prefix_tag)
else:
items = _dir_listing_items(root, word, path_part, prefix_tag, is_context)
# Bare-word `@name` may be an agent mention: profiles rank ABOVE file hits.
if bare_word and not prefix_tag:
with contextlib.suppress(Exception):
items = _profile_mention_items(path_part) + items
except Exception:
pass
return _ok(rid, {"items": items})
@method("complete.slash")
@_catch(5020)
def _(rid, params: dict) -> dict:
text = params.get("text", "")
if not text.startswith("/"):
return _ok(rid, {"items": []})
try:
from hermes_cli.commands import SlashCommandCompleter
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import to_plain_text
from agent.skill_commands import get_skill_commands
from agent.skill_bundles import get_skill_bundles
completer = SlashCommandCompleter(
skill_commands_provider=lambda: get_skill_commands(), skill_bundles_provider=lambda: get_skill_bundles()
)
# `kind` reaches the TUI as data (from the providers, not sniffed from ⚡/▣ glyphs):
# skills/bundles are the only completions for an inline `/skill` typed mid-message.
skill_names = {key.lstrip("/").lower() for key in (*get_skill_commands(), *get_skill_bundles())}
from hermes_cli.commands import SlashCommandCompleter
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import to_plain_text
from agent.skill_commands import get_skill_commands
from agent.skill_bundles import get_skill_bundles
completer = SlashCommandCompleter(
skill_commands_provider=lambda: get_skill_commands(), skill_bundles_provider=lambda: get_skill_bundles())
# `kind` reaches the TUI as data (from the providers, not sniffed from ⚡/▣ glyphs):
# skills/bundles are the only completions for an inline `/skill` typed mid-message.
skill_names = {key.lstrip("/").lower() for key in (*get_skill_commands(), *get_skill_bundles())}
def to_items(doc: Document) -> list[dict]:
# display/display_meta are FormattedText; the TUI contract is a plain string
# (the raw list trips Ink's row layout into 1-char truncation).
return [
{
"text": c.text,
"display": to_plain_text(c.display) if c.display else c.text,
"meta": to_plain_text(c.display_meta) if c.display_meta else "",
"kind": "skill" if c.text.strip().lstrip("/").lower() in skill_names else "command",
}
for c in completer.get_completions(doc, None)]
items = to_items(Document(text, len(text)))
# Rank + bound while a `/token` is under the cursor (the one stage skills are
# offered at); an argument stage (`/personality `) keeps its command's order.
if text.rsplit(" ", 1)[-1].startswith("/"):
score_of = None
# Command-token stage: the completer only emits name-prefix matches, so merge in
# catalog entries whose name SUBSTRING or DESCRIPTION words match (name outranks description).
if " " not in text and len(text) > 1:
from tui_gateway.slash_fuzzy import fuzzy_rank_slash_items, normalize_slash_search_query
items, score_of = fuzzy_rank_slash_items(
items, to_items(Document("/", 1)), normalize_slash_search_query(text))
usage, origin_of = _skill_usage_lookup()
items = _rank_slash_completions(items, usage, origin_of, browsing=text == "/", score_of=score_of)
else:
items = items[:_SLASH_COMPLETION_LIMIT]
text_lower = text.lower()
for extra_text, extra_meta in _SLASH_EXTRAS:
if extra_text.startswith(text_lower) and not any(item["text"] == extra_text for item in items):
items.append({"text": extra_text, "display": extra_text, "meta": extra_meta, "kind": "command"})
details_items = _details_completions(text)
if details_items is not None:
return _ok(rid, {"items": details_items, "replace_from": text.rfind(" ") + 1 if " " in text else len(text)})
return _ok(rid, {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1})
except Exception as e:
return _err(rid, 5020, str(e))
def _catch(fail_code: int):
"""Handler body exceptions → ``_err(rid, fail_code, str(e))``."""
def deco(body):
def handler(rid, params: dict) -> dict:
try:
return body(rid, params)
except Exception as e:
return _err(rid, fail_code, str(e))
handler.__doc__ = body.__doc__
return handler
return deco
def to_items(doc: Document) -> list[dict]:
# display/display_meta are FormattedText; the TUI contract is a plain string
# (the raw list trips Ink's row layout into 1-char truncation).
return [
{
"text": c.text, "display": to_plain_text(c.display) if c.display else c.text,
"meta": to_plain_text(c.display_meta) if c.display_meta else "",
"kind": "skill" if c.text.strip().lstrip("/").lower() in skill_names else "command"}
for c in completer.get_completions(doc, None)]
items = to_items(Document(text, len(text)))
# Rank + bound while a `/token` is under the cursor (the one stage skills are
# offered at); an argument stage (`/personality `) keeps its command's order.
if text.rsplit(" ", 1)[-1].startswith("/"):
score_of = None
# Command-token stage: the completer only emits name-prefix matches, so merge in
# catalog entries whose name SUBSTRING or DESCRIPTION words match (name outranks description).
if " " not in text and len(text) > 1:
from tui_gateway.slash_fuzzy import fuzzy_rank_slash_items, normalize_slash_search_query
items, score_of = fuzzy_rank_slash_items(
items, to_items(Document("/", 1)), normalize_slash_search_query(text))
usage, origin_of = _skill_usage_lookup()
items = _rank_slash_completions(items, usage, origin_of, browsing=text == "/", score_of=score_of)
else:
items = items[:_SLASH_COMPLETION_LIMIT]
text_lower = text.lower()
for extra_text, extra_meta in _SLASH_EXTRAS:
if extra_text.startswith(text_lower) and not any(item["text"] == extra_text for item in items):
items.append({**_item(extra_text, extra_meta), "kind": "command"})
if (details_items := _details_completions(text)) is not None:
return _ok(rid, {"items": details_items, "replace_from": text.rfind(" ") + 1 if " " in text else len(text)})
return _ok(rid, {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1})
def _session_agent(params: dict):
@@ -322,13 +282,9 @@ def _(rid, params: dict) -> dict:
from hermes_cli.inventory import build_model_options_payload
# A spawned agent owns the live provider/model/base_url; empty attributes must
# NOT clobber disk config (with_overrides is truthy-only).
ctx = _model_picker_context(_session_agent(params))
payload = build_model_options_payload(
ctx,
explicit_only=bool(params.get("explicit_only")),
include_unconfigured=bool(params.get("include_unconfigured")),
refresh=bool(params.get("refresh")))
return _ok(rid, payload)
return _ok(rid, build_model_options_payload(
_model_picker_context(_session_agent(params)), explicit_only=bool(params.get("explicit_only")),
include_unconfigured=bool(params.get("include_unconfigured")), refresh=bool(params.get("refresh"))))
@method("model.save_key")
@@ -337,26 +293,23 @@ def _(rid, params: dict) -> dict:
"""Save an API key for ``slug``; return its refreshed provider row (model.options shape + ``authenticated``)."""
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.config import is_managed
from hermes_cli.inventory import build_models_payload
slug = (params.get("slug") or "").strip()
api_key = (params.get("api_key") or "").strip()
slug, api_key = (params.get("slug") or "").strip(), (params.get("api_key") or "").strip()
if not slug or not api_key:
return _err(rid, 4001, "slug and api_key are required")
if is_managed():
return _err(rid, 4006, "managed install — credentials are read-only")
pconfig = PROVIDER_REGISTRY.get(slug)
if not pconfig:
if not (pconfig := PROVIDER_REGISTRY.get(slug)):
return _err(rid, 4002, f"unknown provider: {slug}")
if pconfig.auth_type != "api_key":
return _err(rid, 4003, f"{pconfig.name} uses {pconfig.auth_type} auth — run `hermes model` to configure")
if not pconfig.api_key_env_vars:
return _err(rid, 4004, f"no env var defined for {pconfig.name}")
# Unified lifecycle rotates stale config.yaml mirrors of the old key too.
env_var = pconfig.api_key_env_vars[0]
from hermes_cli.credential_lifecycle import save_provider_env_credential
from hermes_cli.credential_lifecycle import save_provider_env_credential # also rotates stale config.yaml mirrors
save_provider_env_credential(env_var, api_key)
os.environ[env_var] = api_key # so the refreshed inventory sees it
# Shared inventory builder (lock-step with model.options / dashboard); picker_hints carries `authenticated`.
from hermes_cli.inventory import build_models_payload
payload = build_models_payload(_model_picker_context(_session_agent(params)), picker_hints=True, max_models=50)
provider_data = next((p for p in payload["providers"] if p["slug"] == slug), None)
if provider_data is None: # key saved but provider didn't appear — still success
@@ -371,16 +324,13 @@ def _(rid, params: dict) -> dict:
"""Remove all credentials (env keys AND OAuth/pool state) for provider ``slug``."""
from hermes_cli.auth import PROVIDER_REGISTRY, clear_provider_auth
from hermes_cli.credential_lifecycle import remove_provider_env_credential
slug = (params.get("slug") or "").strip()
if not slug:
if not (slug := (params.get("slug") or "").strip()):
return _err(rid, 4001, "slug is required")
pconfig = PROVIDER_REGISTRY.get(slug)
# Remove EVERY env var plus its mirrors (env-seeded pool entries, model cache rows,
# value-matched config.yaml copies) or the provider resurrects in the picker after restart.
# Remove EVERY env var plus its mirrors or the provider resurrects in the picker after restart.
env_vars = (pconfig.api_key_env_vars if pconfig else None) or ()
cleared_env = any([remove_provider_env_credential(ev).get("found") for ev in env_vars])
# Full disconnect: removing OAuth grants is intended here, unlike key-only deletes.
cleared_auth = clear_provider_auth(slug)
cleared_auth = clear_provider_auth(slug) # full disconnect: OAuth grants go too
if not cleared_env and not cleared_auth:
return _err(rid, 4005, f"no credentials found for {slug}")
return _ok(rid, {"slug": slug, "name": pconfig.name if pconfig else slug, "disconnected": True})
+69 -103
View File
@@ -12,9 +12,6 @@ from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
# ── Methods: complete ─────────────────────────────────────────────────
_FUZZY_CACHE_TTL_S = 5.0
_FUZZY_CACHE_MAX_FILES = 20000
_FUZZY_FALLBACK_EXCLUDES = frozenset(
@@ -24,54 +21,54 @@ _fuzzy_cache_lock = threading.Lock()
_fuzzy_cache: dict[str, tuple[float, list[str]]] = {}
def _git_repo_files(root: str):
"""Yield ``git ls-files`` paths (tracked + untracked) relative to ``root``; empty outside a
repo or on git failure/timeout. Entries above ``root`` are skipped (Cmd-P workspace scope)."""
from hermes_cli._subprocess_compat import windows_hide_flags
run_kw = dict(capture_output=True, timeout=2.0, check=False, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
try:
top_result = subprocess.run(["git", "-C", root, "rev-parse", "--show-toplevel"], **run_kw)
if top_result.returncode != 0:
return
top = top_result.stdout.decode("utf-8", "replace").strip()
list_result = subprocess.run(
["git", "-C", top, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], **run_kw)
if list_result.returncode != 0:
return
except (OSError, subprocess.TimeoutExpired):
return
for p in list_result.stdout.decode("utf-8", "replace").split("\0"):
if p:
rel = os.path.relpath(os.path.join(top, p), root).replace(os.sep, "/")
if not rel.startswith("../"):
yield rel
def _walk_repo_files(root: str):
"""Non-git fallback: ``os.walk`` skipping vendor/build dirs + dot-dirs; dotfiles survive
(the ranker decides based on whether the query starts with `.`)."""
try:
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
dirnames[:] = [d for d in dirnames if d not in _FUZZY_FALLBACK_EXCLUDES and not d.startswith(".")]
rel_dir = os.path.relpath(dirpath, root)
for f in filenames:
yield (f if rel_dir == "." else f"{rel_dir}/{f}").replace(os.sep, "/")
except OSError:
return
def _list_repo_files(root: str) -> list[str]:
"""File paths relative to ``root`` (tracked + untracked via ``git ls-files`` from the
repo top; files outside ``root`` excluded so the picker stays Cmd-P scoped). Falls
back to a bounded ``os.walk(root)`` outside a git repo. Cached per-root for
"""File paths relative to ``root`` (git listing, else a bounded walk), cached per-root for
``_FUZZY_CACHE_TTL_S`` so rapid keystrokes don't respawn git."""
now = time.monotonic()
with _fuzzy_cache_lock:
cached = _fuzzy_cache.get(root)
if cached and now - cached[0] < _FUZZY_CACHE_TTL_S:
return cached[1]
files: list[str] = []
from hermes_cli._subprocess_compat import windows_hide_flags
run_kw = dict(capture_output=True, timeout=2.0, check=False, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
try:
top_result = subprocess.run(["git", "-C", root, "rev-parse", "--show-toplevel"], **run_kw)
if top_result.returncode == 0:
top = top_result.stdout.decode("utf-8", "replace").strip()
list_result = subprocess.run(
["git", "-C", top, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], **run_kw
)
if list_result.returncode == 0:
for p in list_result.stdout.decode("utf-8", "replace").split("\0"):
if not p:
continue
rel = os.path.relpath(os.path.join(top, p), root).replace(os.sep, "/")
if rel.startswith("../"): # parents/siblings of cwd: keep Cmd-P workspace scope
continue
files.append(rel)
if len(files) >= _FUZZY_CACHE_MAX_FILES:
break
except (OSError, subprocess.TimeoutExpired):
pass
from itertools import islice
files = list(islice(_git_repo_files(root), _FUZZY_CACHE_MAX_FILES))
if not files:
# Fallback walk skips vendor/build dirs + dot-dirs; dotfiles survive (the ranker
# decides based on whether the query starts with `.`).
try:
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
dirnames[:] = [d for d in dirnames if d not in _FUZZY_FALLBACK_EXCLUDES and not d.startswith(".")]
rel_dir = os.path.relpath(dirpath, root)
for f in filenames:
rel = f if rel_dir == "." else f"{rel_dir}/{f}"
files.append(rel.replace(os.sep, "/"))
if len(files) >= _FUZZY_CACHE_MAX_FILES:
break
if len(files) >= _FUZZY_CACHE_MAX_FILES:
break
except OSError:
pass
files = list(islice(_walk_repo_files(root), _FUZZY_CACHE_MAX_FILES))
with _fuzzy_cache_lock:
_fuzzy_cache[root] = (now, files)
return files
@@ -83,44 +80,32 @@ def _fuzzy_basename_rank(name: str, query: str) -> tuple[int, int] | None:
· 3 substring · 4 subsequence (query chars appear in order)."""
if not query:
return (3, len(name))
nl = name.lower()
ql = query.lower()
nl, ql = name.lower(), query.lower()
if nl == ql:
return (0, len(name))
if nl.startswith(ql):
return (1, len(name))
# Split on -_. and camelCase (`appChrome` → ["app","Chrome"]); cheap approximation,
# falls through to substring/subsequence if it misses.
# Word boundaries: split on -_. and camelCase (`appChrome` → ["app","Chrome"]); cheap
# approximation, falls through to substring/subsequence if it misses.
parts: list[str] = []
buf = ""
for ch in name:
if ch in "-_." or (ch.isupper() and buf and not buf[-1].isupper()):
if buf:
parts.append(buf)
parts += [buf] if buf else []
buf = ch if ch not in "-_." else ""
else:
buf += ch
if buf:
parts.append(buf)
for p in parts:
if p.lower().startswith(ql):
return (2, len(name))
if any(p.lower().startswith(ql) for p in parts + ([buf] if buf else [])):
return (2, len(name))
if ql in nl:
return (3, len(name))
i = 0
for ch in nl:
if ch == ql[i]:
i += 1
if i == len(ql):
return (4, len(name))
return None
it = iter(nl)
return (4, len(name)) if all(any(c == q for c in it) for q in ql) else None
def _abs_completion_prefix_exists(path_part: str) -> bool:
"""True when ``path_part`` reads sensibly as an absolute path: the parent dir exists
and a partially-typed final segment matches at least one entry. Decides whether
`@/foo` is the absolute `/foo` or shorthand for `foo` under the cwd."""
"""True when ``path_part`` reads sensibly as an absolute path (parent exists and a
partially-typed final segment matches an entry): decides `@/foo` = `/foo` vs cwd `foo`."""
expanded = _normalize_completion_path(path_part)
parent = os.path.dirname(expanded.rstrip("/")) or "/"
tail = os.path.basename(expanded.rstrip("/"))
@@ -135,14 +120,6 @@ def _abs_completion_prefix_exists(path_part: str) -> bool:
return False
def _details_completion_item(value: str, meta: str = "") -> dict:
return {"text": value, "display": value, "meta": meta}
def _details_root_completion_item(value: str, meta: str, needs_leading_space: bool) -> dict:
return _details_completion_item(f" {value}" if needs_leading_space else value, meta)
_DETAILS_SECTIONS = ("thinking", "tools", "subagents", "activity")
_DETAILS_MODES = ("hidden", "collapsed", "expanded")
@@ -154,39 +131,34 @@ def _details_root_meta(candidate: str) -> str:
def _details_completions(text: str) -> list[dict] | None:
"""Argument completions for ``/details [section] [mode]``; None when ``text`` is not that command."""
if not text.lower().startswith("/details"):
return None
stripped = text.strip()
if stripped and not "/details".startswith(stripped.lower().split()[0]):
return None
body = text[len("/details") :]
if body.startswith(" "):
body = body[1:]
body = text[len("/details") :].removeprefix(" ")
parts = body.split()
has_trailing_space = text.endswith(" ")
sections, modes = _DETAILS_SECTIONS, _DETAILS_MODES
root_candidates = (*modes, "cycle", *sections)
if not body or (len(parts) == 0 and has_trailing_space):
return [_details_root_completion_item(c, _details_root_meta(c), not has_trailing_space) for c in root_candidates]
if len(parts) == 1 and not has_trailing_space:
trailing = text.endswith(" ")
root_candidates = (*_DETAILS_MODES, "cycle", *_DETAILS_SECTIONS)
if not body or (not parts and trailing):
lead = "" if trailing else " "
return [_item(f"{lead}{c}", _details_root_meta(c)) for c in root_candidates]
if len(parts) == 1 and not trailing:
prefix = parts[0].lower()
return [
_details_completion_item(c, _details_root_meta(c))
for c in root_candidates
if c.startswith(prefix) and c != prefix]
return [_item(c, _details_root_meta(c)) for c in root_candidates if c.startswith(prefix) and c != prefix]
section = parts[0].lower() if parts else ""
if section not in sections:
if section not in _DETAILS_SECTIONS:
return []
def section_meta(candidate: str) -> str:
return f"clear {section} override" if candidate == "reset" else f"set {section}"
if len(parts) == 1 and has_trailing_space:
return [_details_completion_item(c, section_meta(c)) for c in (*modes, "reset")]
if len(parts) == 2 and not has_trailing_space:
mode_candidates = (*_DETAILS_MODES, "reset")
if len(parts) == 1: # trailing space after the section
return [_item(c, section_meta(c)) for c in mode_candidates]
if len(parts) == 2 and not trailing:
prefix = parts[1].lower()
return [
_details_completion_item(c, section_meta(c)) for c in (*modes, "reset") if c.startswith(prefix) and c != prefix
]
return [_item(c, section_meta(c)) for c in mode_candidates if c.startswith(prefix) and c != prefix]
return []
@@ -194,22 +166,16 @@ def _model_picker_context(agent):
"""Layer live session state onto config without losing custom identity."""
from hermes_cli.inventory import load_picker_context
ctx = load_picker_context()
provider = getattr(agent, "provider", "") if agent else ""
base_url = getattr(agent, "base_url", "") if agent else ""
model = getattr(agent, "model", "") if agent else ""
provider, base_url, model = (getattr(agent, k, "") if agent else "" for k in ("provider", "base_url", "model"))
if str(provider or "").strip().lower() == "custom":
try:
from hermes_cli.runtime_provider import canonical_custom_identity
provider = (
canonical_custom_identity(
base_url=base_url or None, config_provider=ctx.current_provider, model=model or None
)
or provider)
provider = canonical_custom_identity(
base_url=base_url or None, config_provider=ctx.current_provider, model=model or None) or provider
except Exception:
logger.debug("custom provider identity recovery failed (model picker)", exc_info=True)
return ctx.with_overrides(
current_provider=provider, current_model=model or _resolve_model(), current_base_url=base_url
)
current_provider=provider, current_model=model or _resolve_model(), current_base_url=base_url)
def register(server) -> None:
+133 -196
View File
@@ -5,6 +5,7 @@
from .method_ctx import HandlerRegistry, bind_module
from hermes_constants import DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES
from hermes_constants import display_hermes_home as _display_hermes_home
_registry = HandlerRegistry()
method = _registry.method
@@ -24,8 +25,8 @@ def _projects_handler(name: str):
def _reconcile_repo_discovery(pdb, conn, policy, policy_key):
pdb.reconcile_discovered_repos_policy(
conn, policy_key, preserve_unversioned=_repo_discovery_policy_is_default(policy))
pdb.reconcile_discovered_repos_policy(conn, policy_key,
preserve_unversioned=_repo_discovery_policy_is_default(policy))
@_projects_handler("projects.discover_repos")
@@ -38,8 +39,8 @@ def _(rid, params: dict) -> dict:
policy = _repo_discovery_policy()
with pdb.connect_closing() as conn:
_reconcile_repo_discovery(pdb, conn, policy, _repo_discovery_policy_key(policy))
# `scan=true` (remote-gateway desktop): its native scan only sees its own
# filesystem, so the host scans the policy roots so zero-session repos surface.
# `scan=true` (remote-gateway desktop): its native scan only sees its own filesystem,
# so the host scans the policy roots so zero-session repos surface.
if params.get("scan") and policy["enabled"]:
_scan_discovered_repos_remote(conn, policy)
repos = _discover_repos_payload(db, conn=conn, include_cached=policy["enabled"])
@@ -52,29 +53,23 @@ def _(rid, params: dict) -> dict:
from hermes_cli import projects_db as pdb
policy = _repo_discovery_policy()
policy_key = _repo_discovery_policy_key(policy)
incoming_raw = params.get("discovery_policy")
incoming_policy = (
_repo_discovery_policy(incoming_raw) if isinstance(incoming_raw, dict) else None)
incoming_matches = (incoming_policy is not None
and _repo_discovery_policy_key(incoming_policy) == policy_key)
accept_legacy_default = (incoming_policy is None
and _repo_discovery_policy_is_default(policy))
pairs: list[tuple[str, str | None]] = []
for item in params.get("repos") or []:
if isinstance(item, str):
pairs.append((item, None))
elif isinstance(item, dict) and item.get("root"):
pairs.append((str(item["root"]), item.get("label")))
incoming = params.get("discovery_policy")
if isinstance(incoming, dict):
accepted = _repo_discovery_policy_key(_repo_discovery_policy(incoming)) == policy_key
else:
accepted = _repo_discovery_policy_is_default(policy) # legacy client without a policy
accepted = bool(policy["enabled"] and accepted)
pairs = [(item, None) if isinstance(item, str) else (str(item["root"]), item.get("label"))
for item in params.get("repos") or []
if isinstance(item, str) or (isinstance(item, dict) and item.get("root"))]
with pdb.connect_closing() as conn:
_reconcile_repo_discovery(pdb, conn, policy, policy_key)
accepted = bool(policy["enabled"] and (incoming_matches or accept_legacy_default))
if accepted:
pdb.record_discovered_repos(conn, pairs, replace=True, policy_key=policy_key)
elif not policy["enabled"]:
pdb.clear_discovered_repos(conn, policy_key=policy_key)
with _profile_db(params) as db:
repos = ([] if db is None
else _discover_repos_payload(db, include_cached=policy["enabled"]))
repos = [] if db is None else _discover_repos_payload(db, include_cached=policy["enabled"])
return _ok(rid, {"repos": repos, "accepted": accepted, "discovery_policy": policy})
@@ -88,18 +83,17 @@ def _stamped_project_tree(db, params, **kwargs):
@_projects_handler("projects.tree")
def _(rid, params: dict) -> dict:
"""Project -> repo -> lane overview with counts + a few preview sessions per project, plus
the flat set of session ids claimed by any project (excluded from flat Recents). Lanes carry
no session rows here; drill-in uses ``projects.project_sessions``."""
"""Project -> repo -> lane overview with counts + a few preview sessions per project, plus the
flat set of session ids claimed by any project (excluded from flat Recents). Lanes carry no
session rows; drill-in uses ``projects.project_sessions``."""
with _profile_db(params) as db:
if db is None:
return _ok(rid, {"projects": [], "active_id": None, "scoped_session_ids": []})
tree, active_id = _stamped_project_tree(
db, params, preview_limit=int(params.get("preview_limit") or 3), hydrate=False,
session_limit=int(params.get("session_limit") or 2000), include_discovered=True)
return _ok(rid, {
"projects": tree["projects"], "active_id": active_id,
"scoped_session_ids": tree["scoped_session_ids"]})
return _ok(rid, {"projects": tree["projects"], "active_id": active_id,
"scoped_session_ids": tree["scoped_session_ids"]})
@_projects_handler("projects.project_sessions")
@@ -115,68 +109,50 @@ def _(rid, params: dict) -> dict:
tree, _active = _stamped_project_tree(
db, params, preview_limit=0, hydrate=True,
session_limit=int(params.get("session_limit") or 5000), include_discovered=False)
proj = next((p for p in tree["projects"] if p["id"] == project_id), None)
return _ok(rid, {"project": proj})
return _ok(rid, {"project": next((p for p in tree["projects"] if p["id"] == project_id), None)})
# ── config.get — one getter per key; returns the result payload or a full ``_err`` response
# (dicts containing "error" pass through untouched).
# ── config.get — one getter per key returning the result payload.
def _display_raw() -> dict:
return _load_cfg().get("display") or {}
def _display_mode(cfg: dict, key: str, allowed: frozenset, default: str) -> str:
raw = str((cfg.get("display") or {}).get(key, default) or default).strip().lower()
def _display_word(key: str, default: str, allowed) -> str:
"""Normalised ``display.<key>``; unknown/garbage values read back as ``default``."""
raw = str(_display_raw().get(key, default) or "").strip().lower()
return raw if raw in allowed else default
_THINKING_MODES = frozenset({"collapsed", "truncated", "full"})
def _cfg_get_provider(rid, params):
try:
from hermes_cli.models import list_available_providers, normalize_provider
model = _resolve_model()
parts = model.split("/", 1)
return {
"model": model,
"provider": normalize_provider(parts[0]) if len(parts) > 1 else "unknown",
def _cfg_get_provider(params):
from hermes_cli.models import list_available_providers, normalize_provider
model = _resolve_model()
parts = model.split("/", 1)
return {"model": model, "provider": normalize_provider(parts[0]) if len(parts) > 1 else "unknown",
"providers": list_available_providers()}
except Exception as e:
return _err(rid, 5013, str(e))
def _cfg_get_profile(rid, params):
from hermes_constants import display_hermes_home
return {"home": str(_hermes_home), "display": display_hermes_home()}
def _cfg_get_project(rid, params):
cfg_terminal = _load_cfg().get("terminal") or {}
raw = str(params.get("cwd", "") or cfg_terminal.get("cwd", "") or "").strip()
def _cfg_get_project(params):
raw = str(params.get("cwd", "") or (_load_cfg().get("terminal") or {}).get("cwd", "") or "").strip()
cwd = _completion_cwd({"cwd": raw} if raw else {})
return {"cwd": cwd, "branch": _git_branch_for_cwd(cwd)}
def _cfg_get_indicator(rid, params):
# Normalize so a hand-edited config.yaml (stray casing / unknown value) reads back the SAME
# value the TUI rendered (frontend falls back to DEFAULT_INDICATOR_STYLE for the same inputs).
norm = str((_load_cfg().get("display") or {}).get("tui_status_indicator", "")).strip().lower()
return {"value": norm if norm in INDICATOR_STYLES else DEFAULT_INDICATOR_STYLE}
def _cfg_get_personality(rid, params):
def _cfg_get_personality(params):
# EFFECTIVE personality via the single owner — a stale/unknown name must not show as active.
from hermes_cli.personality import active_personality_name
return {"value": active_personality_name(_load_cfg()) or "none"}
def _cfg_get_reasoning(rid, params):
def _cfg_get_reasoning(params):
cfg = _load_cfg()
session = _sessions.get(params.get("session_id", ""))
reasoning_config = None
if session is not None:
reasoning_config = session.get("create_reasoning_override")
if not isinstance(reasoning_config, dict):
reasoning_config = getattr(session.get("agent"), "reasoning_config", None)
session = _sessions.get(params.get("session_id", "")) or {}
reasoning_config = session.get("create_reasoning_override")
if session and not isinstance(reasoning_config, dict):
reasoning_config = getattr(session.get("agent"), "reasoning_config", None)
if isinstance(reasoning_config, dict):
enabled = reasoning_config.get("enabled") is not False
effort = str(reasoning_config.get("effort") or "medium") if enabled else "none"
@@ -184,48 +160,30 @@ def _cfg_get_reasoning(rid, params):
raw_effort = (cfg.get("agent") or {}).get("reasoning_effort", "")
# YAML `reasoning_effort: false` means thinking disabled, not "unset".
effort = "none" if raw_effort is False else str(raw_effort or "medium")
display = "show" if bool((cfg.get("display") or {}).get("show_reasoning", True)) else "hide"
display = "show" if (cfg.get("display") or {}).get("show_reasoning", True) else "hide"
return {"value": effort, "display": display}
def _cfg_get_fast(rid, params):
def _cfg_get_fast(params):
# `config.set fast` is session-scoped: prefer the session's live/pinned value over the
# global key (a pre-build session keeps its pin in create_service_tier_override).
session = _sessions.get(params.get("session_id", ""))
tier = None
if session is not None:
agent = session.get("agent")
if agent is not None:
tier = getattr(agent, "service_tier", None)
elif session.get("create_service_tier_override") is not None:
tier = session["create_service_tier_override"]
session = _sessions.get(params.get("session_id", "")) or {}
agent = session.get("agent")
tier = (getattr(agent, "service_tier", None) if agent is not None
else session.get("create_service_tier_override"))
if tier is None:
tier = _load_service_tier()
return {"value": "fast" if tier == "priority" else "normal"}
def _cfg_get_approval_mode(rid, params):
try:
return {"value": _load_approval_mode()}
except Exception as e:
return _err(rid, 5001, str(e))
def _cfg_get_thinking_mode(params):
raw = _display_word("thinking_mode", "", _THINKING_MODES)
if not raw: # legacy details_mode fallback
raw = "full" if _display_word("details_mode", "collapsed", _DETAIL_MODES) == "expanded" else "collapsed"
return {"value": raw}
def _cfg_get_thinking_mode(rid, params):
cfg = _load_cfg()
raw = str((cfg.get("display") or {}).get("thinking_mode", "") or "").strip().lower()
if raw in _THINKING_MODES:
return {"value": raw}
dm = _display_mode(cfg, "details_mode", _DETAIL_MODES, "collapsed")
return {"value": "full" if dm == "expanded" else "collapsed"}
def _cfg_get_theme(rid, params):
raw = str(_display_cfg().get("tui_theme", "auto")).strip().lower()
return {"value": raw if raw in {"auto", "light", "dark"} else "auto"}
def _cfg_get_mtime(rid, params):
def _cfg_get_mtime(params):
cfg_path = _hermes_home / "config.yaml"
try:
mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0
@@ -236,82 +194,72 @@ def _cfg_get_mtime(rid, params):
return {"mtime": mtime, "mcp_rev": _compute_mcp_rev()}
def _config_getters() -> dict:
"""key -> getter(rid, params). Built per call so, once rebound onto server.py, every entry
resolves to the rebound helper copies rather than this module's originals."""
return {
"provider": _cfg_get_provider,
"profile": _cfg_get_profile,
"project": _cfg_get_project,
"full": lambda rid, params: {"config": _load_cfg()},
"prompt": lambda rid, params: {"prompt": _load_cfg().get("custom_prompt", "")},
"skin": lambda rid, params: {"value": (_load_cfg().get("display") or {}).get("skin", "default")},
"indicator": _cfg_get_indicator,
"personality": _cfg_get_personality,
"reasoning": _cfg_get_reasoning,
"fast": _cfg_get_fast,
"busy": lambda rid, params: {"value": _load_busy_input_mode()},
"approval_mode": _cfg_get_approval_mode,
"approvals.mode": _cfg_get_approval_mode,
"details_mode": lambda rid, params: {
"value": _display_mode(_load_cfg(), "details_mode", _DETAIL_MODES, "collapsed")},
"thinking_mode": _cfg_get_thinking_mode,
"density": lambda rid, params: {
"value": "on" if bool((_load_cfg().get("display") or {}).get("tui_compact", False)) else "off"
},
"theme": _cfg_get_theme,
"statusbar": lambda rid, params: {
"value": _coerce_statusbar(_display_cfg().get("tui_statusbar", "top"))},
"focus": lambda rid, params: {
"value": "on" if bool(_display_cfg().get("focus_view", False)) else "off",
"tool_progress": _load_tool_progress_mode()},
"mouse": lambda rid, params: {"value": _display_mouse_tracking(_load_cfg().get("display"))},
"mtime": _cfg_get_mtime}
# key -> getter(params); bind_module rebinds the table's functions onto server.py's globals.
_CONFIG_GETTERS = {
"provider": _cfg_get_provider,
"profile": lambda params: {"home": str(_hermes_home), "display": _display_hermes_home()},
"project": _cfg_get_project,
"full": lambda params: {"config": _load_cfg()},
"prompt": lambda params: {"prompt": _load_cfg().get("custom_prompt", "")},
"skin": lambda params: {"value": _display_raw().get("skin", "default")},
# Normalised like the TUI renders it (frontend falls back to the default for the same inputs).
"indicator": lambda params: {
"value": _display_word("tui_status_indicator", DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES)},
"personality": _cfg_get_personality,
"reasoning": _cfg_get_reasoning,
"fast": _cfg_get_fast,
"busy": lambda params: {"value": _load_busy_input_mode()},
"approval_mode": lambda params: {"value": _load_approval_mode()},
"approvals.mode": lambda params: {"value": _load_approval_mode()},
"details_mode": lambda params: {"value": _display_word("details_mode", "collapsed", _DETAIL_MODES)},
"thinking_mode": _cfg_get_thinking_mode,
"density": lambda params: {"value": "on" if bool(_display_raw().get("tui_compact", False)) else "off"},
"theme": lambda params: {"value": _display_word("tui_theme", "auto", {"auto", "light", "dark"})},
"statusbar": lambda params: {"value": _coerce_statusbar(_display_cfg().get("tui_statusbar", "top"))},
"focus": lambda params: {"value": "on" if bool(_display_cfg().get("focus_view", False)) else "off",
"tool_progress": _load_tool_progress_mode()},
"mouse": lambda params: {"value": _display_mouse_tracking(_load_cfg().get("display"))},
"mtime": _cfg_get_mtime}
# Getters whose failure is a JSON-RPC error of this code (others propagate to dispatch).
_CONFIG_GET_ERR = {"provider": 5013, "approval_mode": 5001, "approvals.mode": 5001}
@method("config.get")
@_profile_scoped
def _(rid, params: dict) -> dict:
key = params.get("key", "")
getter = _config_getters().get(key)
getter = _CONFIG_GETTERS.get(key)
if getter is None:
return _err(rid, 4002, f"unknown config key: {key}")
payload = getter(rid, params)
if "error" in payload:
return payload
return _ok(rid, payload)
try:
return _ok(rid, getter(params))
except Exception as e:
if key not in _CONFIG_GET_ERR:
raise
return _err(rid, _CONFIG_GET_ERR[key], str(e))
# ── setup readiness
def _readiness_profile_scope(params: dict):
"""``(profile, scope)`` for the readiness RPCs' optional ``profile`` param: ``scope`` binds
that profile's HERMES_HOME + ``.env`` secret scope (ContextVars, so concurrent checks stay
isolated); no param yields ``("", nullcontext())``. An unknown profile raises
``FileNotFoundError`` — never quietly answer for the launch profile instead."""
def _readiness_check(rid, params, probe):
"""Shared shell of setup.status / setup.runtime_check. ``probe(profile, scoped)`` runs inside the
optional ``profile`` param's HERMES_HOME + ``.env`` secret scope (ContextVars: concurrent checks
stay isolated); ``scoped`` is the ``{"profile": ...}`` payload stamp (``{}`` for the launch
profile). An unknown profile answers ``ok=False`` (never a JSON-RPC error, never a quiet answer
for the launch profile instead)."""
import contextlib
profile = str(params.get("profile") or "").strip() if isinstance(params, dict) else ""
if not profile:
return "", contextlib.nullcontext()
from hermes_cli import profiles as profiles_mod
if not profiles_mod.profile_exists(profile):
raise FileNotFoundError(f"Profile '{profile}' does not exist on this backend.")
home = _profile_home(profile)
if home is None:
return profile, contextlib.nullcontext()
return profile, _session_profile_runtime_scope({"profile_home": str(home)})
def _readiness_check(rid, params, probe):
"""Shared shell of setup.status / setup.runtime_check: ``probe(profile)`` runs inside the
profile scope; an unknown profile answers ``ok=False`` (never a JSON-RPC error)."""
try:
profile, scope = _readiness_profile_scope(params)
except FileNotFoundError as e:
return _ok(rid, {"ok": False, "profile": params.get("profile"), "error": str(e)})
scope = contextlib.nullcontext()
if profile:
from hermes_cli import profiles as profiles_mod
if not profiles_mod.profile_exists(profile):
return _ok(rid, {"ok": False, "profile": params.get("profile"),
"error": f"Profile '{profile}' does not exist on this backend."})
home = _profile_home(profile)
if home is not None:
scope = _session_profile_runtime_scope({"profile_home": str(home)})
with scope:
payload = probe(profile)
payload = probe(profile, {"profile": profile} if profile else {})
return _ok(rid, payload)
@@ -320,21 +268,18 @@ def _(rid, params: dict) -> dict:
"""Loose provider check; ``profile`` (optional) scopes it to that profile's home."""
try:
from hermes_cli.main import _has_any_provider_configured
def probe(profile):
configured = bool(_has_any_provider_configured(strict_profile_scope=bool(profile)))
return {"provider_configured": configured, **({"profile": profile} if profile else {})}
return _readiness_check(rid, params, probe)
return _readiness_check(rid, params, lambda profile, scoped: {
"provider_configured": bool(_has_any_provider_configured(strict_profile_scope=bool(profile))),
**scoped})
except Exception as e:
return _err(rid, 5016, str(e))
@method("setup.runtime_check")
def _(rid, params: dict) -> dict:
"""Strict provider check: does the configured/default model resolve to a usable runtime?
Unlike setup.status (True if ANY provider auth state is discoverable), this runs the same
resolve_runtime_provider() the agent uses on session creation and returns ok=False with the
auth error when the model can't be served, so UIs surface onboarding before a doomed prompt.
"""Strict provider check via the same resolve_runtime_provider() the agent uses on session
creation (setup.status is True if ANY provider auth state is discoverable): ok=False + the auth
error when the model can't be served, so UIs surface onboarding before a doomed prompt.
``profile`` answers for THAT profile's pin and ``.env``; unknown -> ``ok=False``."""
try:
from hermes_cli.runtime_provider import resolve_runtime_provider
@@ -342,11 +287,9 @@ def _(rid, params: dict) -> dict:
from hermes_cli.main import _has_any_provider_configured
requested = str(params.get("provider") or "").strip() or None
def probe(profile):
def probe(profile, scoped):
runtime = resolve_runtime_provider(requested=requested)
provider_configured = bool(
_has_any_provider_configured(strict_profile_scope=bool(profile)))
scoped = {"profile": profile} if profile else {}
provider_configured = bool(_has_any_provider_configured(strict_profile_scope=bool(profile)))
provider = runtime.get("provider") or "provider"
source = str(runtime.get("source") or "")
@@ -358,10 +301,8 @@ def _(rid, params: dict) -> dict:
return fail("No Hermes provider is configured.", source)
api_key = runtime.get("api_key")
api_key_text = "" if callable(api_key) else str(api_key or "").strip()
credential_ok = (
callable(api_key) or api_key_text in {"aws-sdk", "no-key-required"}
or has_usable_secret(api_key_text) or bool(runtime.get("command")))
if not credential_ok:
if not (callable(api_key) or api_key_text in {"aws-sdk", "no-key-required"}
or has_usable_secret(api_key_text) or bool(runtime.get("command"))):
return fail(f"No usable credentials found for {provider}.", runtime.get("source"))
return {"ok": True, "provider": runtime.get("provider"), "model": runtime.get("model"),
"source": runtime.get("source"), **scoped}
@@ -370,14 +311,21 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"ok": False, "error": str(e)})
def _safe_client_label(label: str) -> str:
"""Alnum/._- () only, ≤64 chars, dot-runs and leading dots collapsed (no traversal shapes)."""
safe = "".join(ch for ch in label if ch.isalnum() or ch in "._- ()").strip()[:64]
while ".." in safe:
safe = safe.replace("..", ".")
return safe.lstrip(".").strip()
@method("diagnostics.share_nous")
def _(rid, params: dict) -> dict:
"""Upload a redacted debug bundle to Nous-internal diagnostics storage — same collection +
force-redaction pipeline as ``hermes debug share --nous``; redaction is NOT
client-controllable and consent lives with the CALLER (privacy notice first). Structured
``ok``/``error`` envelope so the client renders upload failures inline. Optional params:
``error_context`` (redacted, attached as ``error-context.txt``), ``extra_files`` ({label ->
text}, force-redacted, labels sanitized and size-capped), ``log_lines`` (default 200)."""
force-redaction pipeline as ``hermes debug share --nous``; redaction is NOT client-controllable
and consent lives with the CALLER (privacy notice first). Structured ``ok``/``error`` envelope so
upload failures render inline. Optional: ``error_context`` (-> ``error-context.txt``),
``extra_files`` ({label -> text}), ``log_lines`` (default 200); all force-redacted."""
try:
from hermes_cli.debug import _redact_log_text, build_nous_bundle, collect_share_bundle
from hermes_cli.diagnostics_upload import share_to_nous
@@ -392,28 +340,17 @@ def _(rid, params: dict) -> dict:
bundle["error-context.txt"] = _redact_log_text(error_context.strip()[:8_000])
# Bounded: at most 4 files, 512KB each, sanitized labels — not an arbitrary upload surface.
extra_files = params.get("extra_files")
if isinstance(extra_files, dict):
for label, text in list(extra_files.items())[:4]:
if not isinstance(label, str) or not isinstance(text, str):
continue
safe_label = "".join(ch for ch in label if ch.isalnum() or ch in "._- ()").strip()[:64]
# Collapse dot-runs / leading dots so traversal-shaped labels can't survive.
while ".." in safe_label:
safe_label = safe_label.replace("..", ".")
safe_label = safe_label.lstrip(".").strip()
if not safe_label or not text.strip():
continue
for label, text in list(extra_files.items())[:4] if isinstance(extra_files, dict) else ():
safe_label = _safe_client_label(label) if isinstance(label, str) else ""
if safe_label and isinstance(text, str) and text.strip():
bundle[f"client/{safe_label}"] = _redact_log_text(text[:524_288])
res = share_to_nous(build_nous_bundle(bundle, redact=True))
view_url = res.get("viewUrl") or res.get("view_url")
upload_id = res.get("id")
if not view_url and not upload_id:
# An upload the user can't reference is useless to support.
return _ok(rid, {"ok": False,
"error": "upload succeeded but returned no view URL or id"})
return _ok(rid, {
"ok": True, "view_url": view_url, "upload_id": upload_id,
"expires_at": res.get("expiresAt") or res.get("expires_at")})
if not view_url and not upload_id: # an upload the user can't reference is useless to support
return _ok(rid, {"ok": False, "error": "upload succeeded but returned no view URL or id"})
return _ok(rid, {"ok": True, "view_url": view_url, "upload_id": upload_id,
"expires_at": res.get("expiresAt") or res.get("expires_at")})
except Exception as e:
return _ok(rid, {"ok": False, "error": str(e)})
+251 -302
View File
@@ -1,5 +1,5 @@
"""``config.set`` — one JSON-RPC method, dispatched on ``key`` through a table. Bodies are
rebound onto server.py's globals (method_ctx.bind_module) and reference them bare. Each
"""``config.set`` — one JSON-RPC method, dispatched on ``key`` through ``_CONFIG_SETTERS``. Bodies
are rebound onto server.py's globals (method_ctx.bind_module) and reference them bare. Each
``_set_*`` takes ``(rid, params, key, value, session)`` and returns the JSON-RPC envelope.
Keys match exactly except ``details_mode.<section>`` (prefix) and ``_DISPLAY_TOGGLE_KEYS``.
"""
@@ -20,10 +20,8 @@ _profile_scoped = _registry.profile_scoped
def _write_display_sections(*, sections=None, drop_sections=(), **display_fields) -> None:
"""Persist ``display.<field>`` + ``display.sections`` edits via the raw (uncached) write-back."""
cfg = _load_cfg_raw()
display = cfg.get("display")
display = display if isinstance(display, dict) else {}
cur = display.get("sections")
cur = cur if isinstance(cur, dict) else {}
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
cur = display.get("sections") if isinstance(display.get("sections"), dict) else {}
display.update(display_fields)
cur.update(sections or {})
for name in drop_sections:
@@ -44,24 +42,19 @@ def _emit_all_session_info() -> None:
_emit_session_info(sid, sess)
def _toggle_display_bool(rid, key, value, *, cfg_key, on_words, off_words):
"""Shared body of the on/off/toggle display booleans (``density``, ``battery``)."""
raw = _word(value)
cur_b = bool(_display_cfg().get(cfg_key, False))
if raw in {"", "toggle"}:
nv_b = not cur_b
elif raw in on_words or raw in off_words:
nv_b = raw in on_words
else:
return _err(rid, 4002, f"unknown {key} value: {value}")
_write_config_key(f"display.{cfg_key}", nv_b)
return _ok(rid, {"key": key, "value": "on" if nv_b else "off"})
def _word(value) -> str:
return str(value or "").strip().lower()
def _raw_word(value) -> str:
"""Like ``_word`` but only None is blank: falsy non-strings (0, False, []) keep their text."""
return ("" if value is None else str(value)).strip().lower()
def _kv(rid, key, value, **extra):
return _ok(rid, {"key": key, "value": value, **extra})
def _cfgset_await_agent(session, rid):
"""Wait for an in-progress agent build; the error envelope if it failed, else None."""
init_err = _wait_agent(session, rid)
@@ -70,82 +63,87 @@ def _cfgset_await_agent(session, rid):
return _err(rid, 5032, "agent initialization failed") if session.get("agent") is None else None
def _cfgset_model_ok(rid, key, value, warning, confirm_required, confirm_message, scope, **extra):
return _ok(rid, {"key": key, "value": value, "warning": warning, "confirm_required": confirm_required,
"confirm_message": confirm_message, "scope": scope, **extra})
def _cfgset_model_ok(rid, key, value, warning="", confirm_message="", scope="session", **extra):
"""Model-switch envelope; ``confirm_required`` follows ``confirm_message`` (canonical; ``warning``
is its legacy alias on the deferred path)."""
return _kv(rid, key, value, warning=warning, confirm_required=bool(confirm_message),
confirm_message=confirm_message, scope=scope, **extra)
def _stash_pending_model_switch(rid, key, value, session, confirmed, parsed):
"""No live swap while a turn streams (agent.switch_model() mutates fields the worker thread
reads every iteration): stash the pick for the NEXT turn start. Selection guards run HERE (the
only moment a confirm round-trip is possible; an unconfirmed stashed pick is dropped at turn
start) — on a warning nothing is stashed."""
try:
pending_model = parsed.model_input
except Exception:
pending_model = str(value)
pending_provider = (getattr(parsed, "explicit_provider", "") or "").strip()
if not confirmed:
pending_warning = _pending_switch_selection_warning(pending_model, pending_provider)
if pending_warning is not None:
return _cfgset_model_ok(rid, key, pending_model, pending_warning, pending_warning, deferred=False)
# display_*: _session_info shows the user's pick while pending, not the live old model.
session["pending_model_switch"] = {
"raw": value, "confirm_expensive_model": confirmed,
"display_model": pending_model, "display_provider": pending_provider}
return _cfgset_model_ok(rid, key, pending_model, deferred=True)
def _cfgset_guarded(fn):
"""Setter whose uncaught exception becomes ``_err(rid, 5001, str(e))``."""
def setter(rid, params, key, value, session):
try:
return fn(rid, params, key, value, session)
except Exception as e:
return _err(rid, 5001, str(e))
return setter
# ── per-key handlers
@_cfgset_guarded
def _set_model(rid, params, key, value, session):
"""Live/deferred model switch; see _apply_model_switch and _apply_pending_model_switch."""
try:
if not value:
return _err(rid, 4002, "model value required")
confirmed = bool(params.get("confirm_expensive_model", False))
if session:
from hermes_cli.model_switch import parse_model_switch_args
sid = params.get("session_id", "")
# No live swap while a turn streams (agent.switch_model() mutates fields the worker
# thread reads every iteration): stash the pick for the NEXT turn start.
if session.get("running"):
parsed = parse_model_switch_args(value)
try:
pending_model = parsed.model_input
except Exception:
pending_model = str(value)
pending_provider = (getattr(parsed, "explicit_provider", "") or "").strip()
# Selection guards run HERE (the only moment a confirm round-trip is possible);
# otherwise an unconfirmed stashed pick is dropped at turn start.
if not confirmed:
pending_warning = _pending_switch_selection_warning(pending_model, pending_provider)
if pending_warning is not None:
# Nothing stashed; the client re-sends with confirm_expensive_model.
# `confirm_message` is canonical, `warning` its legacy alias.
return _cfgset_model_ok(
rid, key, pending_model, pending_warning, True, pending_warning, "session", deferred=False
)
session["pending_model_switch"] = {
"raw": value,
"confirm_expensive_model": confirmed,
# _session_info reports these while pending so the end-of-turn settle keeps
# showing the user's pick, not the still-live old model.
"display_model": pending_model,
"display_provider": pending_provider}
return _cfgset_model_ok(rid, key, pending_model, "", False, "", "session", deferred=True)
parsed_flags = parse_model_switch_args(value)
explicit_provider = parsed_flags.explicit_provider
failed_agent_init = session.get("agent") is None and session.get("agent_error") is not None
failed_ready = session.get("agent_ready") if failed_agent_init else None
if failed_agent_init:
if failed_ready is None:
return _err(rid, 5032, session.get("agent_error") or "agent initialization failed")
if not failed_ready.wait(timeout=30.0):
return _err(rid, 5032, "agent initialization timed out")
failed_agent_init = (
failed_agent_init and session.get("agent") is None and session.get("agent_error") is not None
and session.get("agent_ready") is failed_ready and failed_ready.is_set())
if session.get("agent") is None and not explicit_provider.strip() and not failed_agent_init:
_start_agent_build(sid, session)
if init_err := _cfgset_await_agent(session, rid):
return init_err
if not value:
return _err(rid, 4002, "model value required")
confirmed = bool(params.get("confirm_expensive_model", False))
if session:
from hermes_cli.model_switch import parse_model_switch_args
sid = params.get("session_id", "")
parsed_flags = parse_model_switch_args(value)
if session.get("running"):
return _stash_pending_model_switch(rid, key, value, session, confirmed, parsed_flags)
explicit_provider = parsed_flags.explicit_provider
failed_agent_init = session.get("agent") is None and session.get("agent_error") is not None
failed_ready = session.get("agent_ready") if failed_agent_init else None
if failed_agent_init:
if failed_ready is None:
return _err(rid, 5032, session.get("agent_error") or "agent initialization failed")
if not failed_ready.wait(timeout=30.0):
return _err(rid, 5032, "agent initialization timed out")
failed_agent_init = (
failed_agent_init and session.get("agent") is None and session.get("agent_error") is not None
and session.get("agent_ready") is failed_ready and failed_ready.is_set())
if session.get("agent") is None and not explicit_provider.strip() and not failed_agent_init:
_start_agent_build(sid, session)
if init_err := _cfgset_await_agent(session, rid):
return init_err
with _session_profile_runtime_scope(session):
result = _apply_model_switch(sid, session, value, confirm_expensive_model=confirmed,
parsed_flags=parsed_flags)
if failed_agent_init and not result.get("confirm_required"):
_restart_completed_failed_agent_build(sid, session, failed_ready)
if init_err := _cfgset_await_agent(session, rid):
return init_err
with _session_profile_runtime_scope(session):
result = _apply_model_switch(
sid, session, value, confirm_expensive_model=confirmed, parsed_flags=parsed_flags
)
if failed_agent_init and not result.get("confirm_required"):
_restart_completed_failed_agent_build(sid, session, failed_ready)
if init_err := _cfgset_await_agent(session, rid):
return init_err
with _session_profile_runtime_scope(session):
_persist_live_session_runtime(session)
else:
result = _apply_model_switch("", {"agent": None}, value, confirm_expensive_model=confirmed)
return _cfgset_model_ok(
rid, key, result["value"], result["warning"], result.get("confirm_required", False),
result.get("confirm_message", ""), result.get("scope", "session"))
except Exception as e:
return _err(rid, 5001, str(e))
_persist_live_session_runtime(session)
else:
result = _apply_model_switch("", {"agent": None}, value, confirm_expensive_model=confirmed)
return _kv(rid, key, result["value"], warning=result["warning"],
confirm_required=result.get("confirm_required", False),
confirm_message=result.get("confirm_message", ""), scope=result.get("scope", "session"))
_FAST_WORDS = {"fast": "fast", "on": "fast", "normal": "normal", "off": "normal",
@@ -158,15 +156,12 @@ def _set_fast(rid, params, key, value, session):
if agent is not None:
current_tier = getattr(agent, "service_tier", None)
elif session is not None and session.get("create_service_tier_override") is not None:
# Pre-build session with a pinned tier: report/toggle from the pin, not the global.
current_tier = session["create_service_tier_override"] or None
current_tier = session["create_service_tier_override"] or None # pre-build pin beats global
else:
current_tier = _load_service_tier()
current_fast = current_tier == "priority"
if raw == "status":
return _ok(rid, {"key": key, "value": {"priority": "fast", None: "normal"}.get(current_tier, current_tier)})
toggled = ("normal" if current_fast else "fast") if raw in {"", "toggle"} else None
nv = _FAST_WORDS.get(raw, toggled)
return _kv(rid, key, {"priority": "fast", None: "normal"}.get(current_tier, current_tier))
nv = _FAST_WORDS.get(raw, ("normal" if current_tier == "priority" else "fast") if raw in {"", "toggle"} else None)
if nv is None:
return _err(rid, 4002, f"unknown fast mode: {value}")
overrides = None
@@ -174,8 +169,7 @@ def _set_fast(rid, params, key, value, session):
from hermes_cli.models import resolve_fast_mode_overrides
if agent is not None:
target_model = getattr(agent, "model", None)
else:
# A pre-build session may carry a picked model (desktop draft) — validate against THAT.
else: # a pre-build session may carry a picked model (desktop draft): validate against THAT
session_override = (session or {}).get("model_override") or {}
target_model = (isinstance(session_override, dict) and session_override.get("model")) or _resolve_model()
if not target_model:
@@ -185,56 +179,46 @@ def _set_fast(rid, params, key, value, session):
if overrides is None:
return _err(rid, 4002, "fast mode is not available for this model")
if session is not None:
# Session-scoped like `reasoning` (global persistence is `--global` / Settings → Model):
# writing config.yaml here flipped fast mode for every other surface. The create
# override keeps the choice across lazy builds and rebuilds; "" pins normal.
# Session-scoped like `reasoning` (global = `--global` / Settings → Model): writing config.yaml
# here flipped fast mode for every surface. The create override survives rebuilds; "" pins normal.
session["create_service_tier_override"] = {"fast": "priority", "normal": ""}.get(nv, nv)
else:
_write_config_key("agent.service_tier", nv)
if agent is not None:
agent.service_tier = {"fast": "priority", "normal": None}.get(nv, nv)
current_overrides = dict(getattr(agent, "request_overrides", {}) or {})
current_overrides.pop("service_tier", None)
current_overrides.pop("speed", None)
if nv == "fast":
current_overrides.update(overrides)
agent.request_overrides = current_overrides
current_overrides = {k: v for k, v in (getattr(agent, "request_overrides", {}) or {}).items()
if k not in ("service_tier", "speed")}
agent.request_overrides = {**current_overrides, **(overrides or {})}
_persist_live_session_runtime(session)
_emit_session_info(params.get("session_id", ""), session)
return _ok(rid, {"key": key, "value": nv})
return _kv(rid, key, nv)
def _set_busy(rid, params, key, value, session):
raw = _word(value)
if raw in {"", "status"}:
return _ok(rid, {"key": key, "value": _load_busy_input_mode()})
if raw not in {"queue", "steer", "interrupt"}:
return _err(rid, 4002, f"unknown busy mode: {value}")
_write_config_key("display.busy_input_mode", raw)
return _ok(rid, {"key": key, "value": raw})
if _word(value) in {"", "status"}:
return _kv(rid, key, _load_busy_input_mode())
return _set_word(rid, params, key, value, session)
def _set_verbose(rid, params, key, value, session):
cycle = ["off", "new", "all", "verbose"]
cur = session.get("tool_progress_mode", _load_tool_progress_mode()) if session else _load_tool_progress_mode()
if value and value != "cycle":
nv = str(value).strip().lower()
if nv not in cycle:
return _err(rid, 4002, f"unknown verbose mode: {value}")
else:
idx = cycle.index(cur) if cur in cycle else 2
nv = cycle[(idx + 1) % len(cycle)]
cur = session.get("tool_progress_mode", _load_tool_progress_mode()) if session else _load_tool_progress_mode()
nv = cycle[((cycle.index(cur) if cur in cycle else 2) + 1) % len(cycle)]
_write_config_key("display.tool_progress", nv)
if session:
session["tool_progress_mode"] = nv
if session.get("agent") is not None:
session["agent"].verbose_logging = nv == "verbose"
return _ok(rid, {"key": key, "value": nv})
return _kv(rid, key, nv)
def _set_focus(rid, params, key, value, session):
# Focus view (/focus): enabling stashes the configured tool_progress mode and pins it
# "off"; disabling restores the stash.
# /focus: enabling stashes the configured tool_progress mode and pins it "off"; disabling restores.
from hermes_cli.focus_view import FOCUS_TOOL_PROGRESS_MODE, normalize_tool_progress_mode, resolve_focus_arg
d_f = _display_cfg()
cur_focus = bool(d_f.get("focus_view", False))
@@ -242,15 +226,14 @@ def _set_focus(rid, params, key, value, session):
if action == "usage":
return _err(rid, 4002, f"unknown focus value: {value} (use on|off|status)")
if action == "status" or target is None:
return _ok(rid, {"key": key, "value": "on" if cur_focus else "off", "tool_progress": _load_tool_progress_mode()})
return _kv(rid, key, "on" if cur_focus else "off", tool_progress=_load_tool_progress_mode())
if target:
saved = (cur_focus and d_f.get("focus_saved_tool_progress")) or _load_tool_progress_mode()
_write_config_key("display.focus_saved_tool_progress", normalize_tool_progress_mode(saved))
_write_config_key("display.tool_progress", FOCUS_TOOL_PROGRESS_MODE)
effective = FOCUS_TOOL_PROGRESS_MODE
else:
effective = normalize_tool_progress_mode(d_f.get("focus_saved_tool_progress") or "all")
_write_config_key("display.tool_progress", effective)
_write_config_key("display.tool_progress", effective)
_write_config_key("display.focus_view", bool(target))
if session:
session["focus_view"] = bool(target)
@@ -258,51 +241,39 @@ def _set_focus(rid, params, key, value, session):
if session.get("agent") is not None:
with contextlib.suppress(Exception):
session["agent"].tool_progress_mode = effective
return _ok(rid, {"key": key, "value": "on" if target else "off", "tool_progress": effective})
return _kv(rid, key, "on" if target else "off", tool_progress=effective)
def _set_approval_mode(rid, params, key, value, session):
raw = _word(value)
if raw not in _APPROVAL_MODES:
return _err(rid, 4002, f"unknown approval mode: {value}; pick one of manual|smart|off")
_write_config_key("approvals.mode", raw)
_emit_all_session_info()
return _ok(rid, {"key": "approvals.mode", "value": raw})
return _set_word(rid, params, "approvals.mode", value, session) # legacy alias reports the real key
@_cfgset_guarded
def _set_yolo(rid, params, key, value, session):
# scope="session" (default; Shift+Tab) toggles ONLY this session's flag. scope="global"
# scope="session" (default; Shift+Tab) toggles ONLY this session's flag; scope="global"
# (Shift+click the zap) flips persistent approvals.mode between "off" and "manual".
scope = _word(params.get("scope") or "session")
try:
from tools.approval import disable_session_yolo, enable_session_yolo, is_session_yolo_enabled
raw = _word(value)
def _resolve_toggle(current: bool) -> bool:
return _BOOL_WORDS.get(raw, not current)
if scope == "global":
from tools.approval import _normalize_approval_mode
appr = _load_cfg().get("approvals")
appr = appr if isinstance(appr, dict) else {}
enable = _resolve_toggle(_normalize_approval_mode(appr.get("mode", "manual")) == "off")
# Binary affordance: no restore of a prior "smart"/custom mode (those live in config.yaml).
_write_config_key("approvals.mode", "off" if enable else "manual")
_emit_all_session_info() # reflect the flip in every live indicator
return _ok(rid, {"key": key, "value": "1" if enable else "0", "scope": "global"})
if session:
skey = session["session_key"]
enable = _resolve_toggle(is_session_yolo_enabled(skey))
(enable_session_yolo if enable else disable_session_yolo)(skey)
_emit_session_info(params.get("session_id", ""), session)
from tools.approval import disable_session_yolo, enable_session_yolo, is_session_yolo_enabled
raw = _word(value)
if scope == "global":
from tools.approval import _normalize_approval_mode
appr = _load_cfg().get("approvals")
appr = appr if isinstance(appr, dict) else {}
enable = _BOOL_WORDS.get(raw, _normalize_approval_mode(appr.get("mode", "manual")) != "off")
_write_config_key("approvals.mode", "off" if enable else "manual") # binary: no "smart" restore
_emit_all_session_info() # reflect the flip in every live indicator
elif session:
skey = session["session_key"]
enable = _BOOL_WORDS.get(raw, not is_session_yolo_enabled(skey))
(enable_session_yolo if enable else disable_session_yolo)(skey)
_emit_session_info(params.get("session_id", ""), session)
else:
enable = _BOOL_WORDS.get(raw, not is_truthy_value(os.environ.get("HERMES_YOLO_MODE")))
if enable:
os.environ["HERMES_YOLO_MODE"] = "1"
else:
enable = _resolve_toggle(is_truthy_value(os.environ.get("HERMES_YOLO_MODE")))
if enable:
os.environ["HERMES_YOLO_MODE"] = "1"
else:
os.environ.pop("HERMES_YOLO_MODE", None)
return _ok(rid, {"key": key, "value": "1" if enable else "0", "scope": "session"})
except Exception as e:
return _err(rid, 5001, str(e))
os.environ.pop("HERMES_YOLO_MODE", None)
return _kv(rid, key, "1" if enable else "0", scope=scope if scope == "global" else "session")
# /reasoning display words: (accepted inputs, reported value, display field, sections.thinking,
@@ -314,125 +285,106 @@ _REASONING_DISPLAY_WORDS = (
({"clamp", "collapse", "short"}, "clamp", {"reasoning_full": False}, "collapsed", None))
@_cfgset_guarded
def _set_reasoning(rid, params, key, value, session):
try:
from hermes_constants import parse_reasoning_effort
arg = _word(value)
scope = _word(params.get("scope"))
for words, reported, fields, thinking, show in _REASONING_DISPLAY_WORDS:
if arg in words:
_write_display_sections(sections={"thinking": thinking}, **fields)
if show is not None and session:
session["show_reasoning"] = show
return _ok(rid, {"key": key, "value": reported})
parsed = parse_reasoning_effort(arg)
if parsed is None:
return _err(rid, 4002, f"unknown reasoning value: {value}")
if scope == "global" or session is None:
_write_config_key("agent.reasoning_effort", arg)
if session is not None:
session.pop("create_reasoning_override", None)
else:
# Session-scoped like the gateway's `/reasoning <level>`; otherwise every desktop
# model-menu pick rewrote the global default.
session["create_reasoning_override"] = parsed
if session and session.get("agent") is not None:
session["agent"].reasoning_config = parsed
_persist_live_session_runtime(session)
_emit_session_info(params.get("session_id", ""), session)
return _ok(rid, {"key": key, "value": arg})
except Exception as e:
return _err(rid, 5001, str(e))
from hermes_constants import parse_reasoning_effort
arg = _word(value)
scope = _word(params.get("scope"))
for words, reported, fields, thinking, show in _REASONING_DISPLAY_WORDS:
if arg in words:
_write_display_sections(sections={"thinking": thinking}, **fields)
if show is not None and session:
session["show_reasoning"] = show
return _kv(rid, key, reported)
parsed = parse_reasoning_effort(arg)
if parsed is None:
return _err(rid, 4002, f"unknown reasoning value: {value}")
if scope == "global" or session is None:
_write_config_key("agent.reasoning_effort", arg)
if session is not None:
session.pop("create_reasoning_override", None)
else: # session-scoped like the gateway's `/reasoning <level>`; a menu pick must not rewrite the global
session["create_reasoning_override"] = parsed
if session and session.get("agent") is not None:
session["agent"].reasoning_config = parsed
_persist_live_session_runtime(session)
_emit_session_info(params.get("session_id", ""), session)
return _kv(rid, key, arg)
def _set_details_mode(rid, params, key, value, session):
nv = _word(value)
if nv not in _DETAIL_MODES:
return _err(rid, 4002, f"unknown details_mode: {value}")
_write_display_sections(sections={section: nv for section in _DETAIL_SECTION_NAMES}, details_mode=nv)
return _ok(rid, {"key": key, "value": nv})
def _word_setters() -> dict:
"""key -> (normaliser, accepted words, error template, apply(word)); the reported value is the
accepted word. Built per call: the specs reference server.py globals (rebound at install)."""
return {
"busy": (_word, {"queue", "steer", "interrupt"}, "unknown busy mode: {value}",
lambda w: _write_config_key("display.busy_input_mode", w)),
"approvals.mode": (_word, _APPROVAL_MODES, "unknown approval mode: {value}; pick one of manual|smart|off",
lambda w: (_write_config_key("approvals.mode", w), _emit_all_session_info())),
"details_mode": (_word, _DETAIL_MODES, "unknown details_mode: {value}", lambda w: _write_display_sections(
sections={section: w for section in _DETAIL_SECTION_NAMES}, details_mode=w)),
# thinking_mode also keeps details_mode aligned (compat bridge).
"thinking_mode": (_word, {"collapsed", "truncated", "full"}, "unknown thinking_mode: {value}", lambda w: (
_write_config_key("display.thinking_mode", w),
_write_config_key("display.details_mode", "expanded" if w == "full" else "collapsed"))),
# 'light'/'dark' pin beats background auto-detection (xterm.js hosts misreport OSC 11).
"theme": (_word, {"auto", "light", "dark"}, "unknown theme value: {value} (use auto|light|dark)",
lambda w: _write_config_key("display.tui_theme", w)),
# _raw_word: 0/False/[] keep their text so the error names what was sent.
"indicator": (_raw_word, INDICATOR_STYLES, "unknown indicator: {raw!r}; pick one of " + "|".join(INDICATOR_STYLES),
lambda w: _write_config_key("display.tui_status_indicator", w))}
def _set_word(rid, params, key, value, session):
norm, allowed, err, apply = _word_setters()[key]
raw = norm(value)
if raw not in allowed:
return _err(rid, 4002, err.format(value=value, raw=raw))
apply(raw)
return _kv(rid, key, raw)
def _set_details_section(rid, params, key, value, session):
# `details_mode.<section>` -> `display.sections.<section>`; empty clears the override so the
# frontend applies built-in section defaults before the global details_mode.
# `details_mode.<section>` -> `display.sections.<section>`; empty clears the override (frontend
# then applies built-in section defaults before the global details_mode).
section = key.split(".", 1)[1]
if section not in _DETAIL_SECTION_NAMES:
return _err(rid, 4002, f"unknown section: {section}")
nv = _word(value)
if not nv:
_write_display_sections(drop_sections=(section,))
elif nv not in _DETAIL_MODES:
if nv and nv not in _DETAIL_MODES:
return _err(rid, 4002, f"unknown details_mode: {value}")
else:
_write_display_sections(sections={section: nv})
return _ok(rid, {"key": key, "value": nv})
_write_display_sections(sections={section: nv} if nv else None, drop_sections=() if nv else (section,))
return _kv(rid, key, nv)
def _set_thinking_mode(rid, params, key, value, session):
nv = _word(value)
if nv not in {"collapsed", "truncated", "full"}:
return _err(rid, 4002, f"unknown thinking_mode: {value}")
_write_config_key("display.thinking_mode", nv)
# Backward compatibility bridge: keep details_mode aligned.
_write_config_key("display.details_mode", "expanded" if nv == "full" else "collapsed")
return _ok(rid, {"key": key, "value": nv})
def _toggle_setters() -> dict:
"""key -> (normaliser, cfg key, alias word -> value, flipped(current), report). ``""``/``toggle``
flips the current value; an alias word maps directly; anything else is 4002. Built per call:
the specs reference server.py globals (rebound at install)."""
def on_off(v):
return "on" if v else "off"
return {
# density/battery are on/off/toggle booleans on display.<field>.
"density": (_word, "display.tui_compact", {"on": True, "off": False},
lambda: not bool(_display_cfg().get("tui_compact", False)), on_off),
"battery": (_word, "display.battery",
{"on": True, "true": True, "yes": True, "off": False, "false": False, "no": False},
lambda: not bool(_display_cfg().get("battery", False)), on_off),
"statusbar": (_word, "display.tui_statusbar", {"on": "top", **{m: m for m in _STATUSBAR_MODES}},
lambda: "top" if _coerce_statusbar(_display_cfg().get("tui_statusbar", "top")) == "off" else "off",
lambda v: v),
# _raw_word: falsy non-strings (0, False) reach the alias map as themselves (-> 'off'), not toggle.
"mouse": (_raw_word, "display.mouse_tracking", _MOUSE_TRACKING_ALIASES,
lambda: "all" if _display_mouse_tracking(_display_cfg()) == "off" else "off", lambda v: v)}
def _set_density(rid, params, key, value, session):
return _toggle_display_bool(rid, key, value, cfg_key="tui_compact", on_words={"on"}, off_words={"off"})
def _set_battery(rid, params, key, value, session):
return _toggle_display_bool(
rid, key, value, cfg_key="battery", on_words={"on", "true", "yes"}, off_words={"off", "false", "no"}
)
def _set_theme(rid, params, key, value, session):
# 'light'/'dark' pin beats background auto-detection (xterm.js hosts misreport OSC 11).
raw = _word(value)
if raw not in {"auto", "light", "dark"}:
return _err(rid, 4002, f"unknown theme value: {value} (use auto|light|dark)")
_write_config_key("display.tui_theme", raw)
return _ok(rid, {"key": key, "value": raw})
def _set_statusbar(rid, params, key, value, session):
raw = _word(value)
current = _coerce_statusbar(_display_cfg().get("tui_statusbar", "top"))
if raw in {"", "toggle"}:
nv = "top" if current == "off" else "off"
elif raw == "on" or raw in _STATUSBAR_MODES:
nv = "top" if raw == "on" else raw
else:
return _err(rid, 4002, f"unknown statusbar value: {value}")
_write_config_key("display.tui_statusbar", nv)
return _ok(rid, {"key": key, "value": nv})
def _set_mouse(rid, params, key, value, session):
# Explicit None check so falsy non-string inputs (0, False) reach the alias map as
# themselves (-> 'off') instead of toggling.
raw = ("" if value is None else str(value)).strip().lower()
current = _display_mouse_tracking(_display_cfg())
if raw in {"", "toggle"}:
nv = "all" if current == "off" else "off"
elif raw in _MOUSE_TRACKING_ALIASES:
nv = _MOUSE_TRACKING_ALIASES[raw]
else:
return _err(rid, 4002, f"unknown mouse value: {value}")
_write_config_key("display.mouse_tracking", nv)
return _ok(rid, {"key": key, "value": nv})
def _set_indicator(rid, params, key, value, session):
# Explicit None check so falsy non-string inputs (0, False, []) surface in the error message.
raw = ("" if value is None else str(value)).strip().lower()
if raw not in INDICATOR_STYLES:
return _err(rid, 4002, f"unknown indicator: {raw!r}; pick one of {'|'.join(INDICATOR_STYLES)}")
_write_config_key("display.tui_status_indicator", raw)
return _ok(rid, {"key": key, "value": raw})
def _set_toggle(rid, params, key, value, session):
norm, cfg_key, aliases, flipped, report = _toggle_setters()[key]
raw = norm(value)
nv = flipped() if raw in {"", "toggle"} else aliases.get(raw)
if nv is None:
return _err(rid, 4002, f"unknown {key} value: {value}")
_write_config_key(cfg_key, nv)
return _kv(rid, key, report(nv))
def _set_cwd(rid, params, key, value, session):
@@ -444,41 +396,38 @@ def _set_cwd(rid, params, key, value, session):
return _err(rid, 4002, f"working directory does not exist: {raw}")
_write_config_key("terminal.cwd", cwd)
os.environ["TERMINAL_CWD"] = cwd
return _ok(rid, {"key": "terminal.cwd", "value": cwd, "cwd": cwd, "branch": _git_branch_for_cwd(cwd)})
return _kv(rid, "terminal.cwd", cwd, cwd=cwd, branch=_git_branch_for_cwd(cwd))
def _set_prompt_like(rid, params, key, value, session):
try:
cfg = _load_cfg_raw() # write-back round-trip ("prompt" saves cfg)
resp = {"key": key, "value": value}
if key == "prompt":
if value == "clear":
cfg.pop("custom_prompt", None)
resp["value"] = ""
else:
cfg["custom_prompt"] = value
_save_cfg(cfg)
elif key == "personality":
pname, new_prompt = _validate_personality(str(value or ""), cfg)
# Personality persists through hermes_cli.personality (single owner), never the
# user-owned global system prompt.
from hermes_cli.personality import persist_personality
persist_personality(pname)
resp["value"] = str(value or "none")
history_reset, info = _apply_personality_to_session(params.get("session_id", ""), session, new_prompt, pname)
resp["history_reset"] = history_reset
if info is not None:
resp["info"] = info
else:
_write_config_key(f"display.{key}", value)
if key == "skin":
# Every surface repaints; sync the watcher baseline so the poll loop doesn't
# re-broadcast the skin this RPC just applied.
_broadcast_global_event("skin.changed", resolve_skin())
_note_skin_broadcast()
return _ok(rid, resp)
except Exception as e:
return _err(rid, 5001, str(e))
@_cfgset_guarded
def _set_prompt(rid, params, key, value, session):
cfg = _load_cfg_raw() # write-back round-trip
if value == "clear":
cfg.pop("custom_prompt", None)
else:
cfg["custom_prompt"] = value
_save_cfg(cfg)
return _kv(rid, key, "" if value == "clear" else value)
@_cfgset_guarded
def _set_personality(rid, params, key, value, session):
pname, new_prompt = _validate_personality(str(value or ""), _load_cfg_raw())
# Persists via hermes_cli.personality (single owner), never the user-owned system prompt.
from hermes_cli.personality import persist_personality
persist_personality(pname)
history_reset, info = _apply_personality_to_session(params.get("session_id", ""), session, new_prompt, pname)
return _kv(rid, key, str(value or "none"), history_reset=history_reset,
**({"info": info} if info is not None else {}))
@_cfgset_guarded
def _set_skin(rid, params, key, value, session):
_write_config_key("display.skin", value)
# Every surface repaints; sync the watcher baseline so the poll loop doesn't re-broadcast.
_broadcast_global_event("skin.changed", resolve_skin())
_note_skin_broadcast()
return _kv(rid, key, value)
def _set_display_toggle(rid, params, key, value, session):
@@ -486,19 +435,19 @@ def _set_display_toggle(rid, params, key, value, session):
if on is None:
return _err(rid, 4002, f"{key} takes true or false")
_write_config_key(key, on)
return _ok(rid, {"key": key, "value": on})
return _kv(rid, key, on)
# ── dispatch
_CONFIG_SETTERS = {
"model": _set_model, "fast": _set_fast, "busy": _set_busy, "verbose": _set_verbose, "focus": _set_focus,
"approval_mode": _set_approval_mode, "approvals.mode": _set_approval_mode, "yolo": _set_yolo,
"reasoning": _set_reasoning, "details_mode": _set_details_mode, "thinking_mode": _set_thinking_mode,
"density": _set_density, "battery": _set_battery, "theme": _set_theme, "statusbar": _set_statusbar,
"mouse": _set_mouse, "indicator": _set_indicator,
"approval_mode": _set_approval_mode, "approvals.mode": _set_word, "yolo": _set_yolo,
"reasoning": _set_reasoning, "details_mode": _set_word, "thinking_mode": _set_word,
"density": _set_toggle, "battery": _set_toggle, "theme": _set_word,
"statusbar": _set_toggle, "mouse": _set_toggle, "indicator": _set_word,
"cwd": _set_cwd, "terminal.cwd": _set_cwd, "workdir": _set_cwd,
"prompt": _set_prompt_like, "personality": _set_prompt_like, "skin": _set_prompt_like}
"prompt": _set_prompt, "personality": _set_personality, "skin": _set_skin}
@method("config.set")
+123 -165
View File
@@ -1,17 +1,13 @@
"""Hosted-room JSON-RPC contract.
"""Hosted-room JSON-RPC contract: durable room identity, replay, and the process-owned
same-gateway Discussion driver; ``groups.capabilities`` keeps that boundary machine-readable.
These methods expose durable room identity, replay, and the process-owned
same-gateway Discussion driver. ``groups.capabilities`` keeps that boundary
machine-readable so older clients stay on the renderer-owned room path.
Handlers are rebound onto server.py's globals at install (see method_ctx.py), so
bodies see only server globals plus the names methods_bot_relay.register publishes;
module-private helpers reach them through keyword defaults. ``_room_method`` wraps
each handler with the shared service-lookup / error-code envelope.
"""
Handlers are rebound onto server.py's globals at install (method_ctx.py); module-private
helpers reach them through keyword defaults. ``_room_method`` is the shared envelope."""
from .method_ctx import HandlerRegistry
import contextlib
import importlib
import os
import threading
@@ -93,6 +89,14 @@ def _current_profile() -> str:
return str(_bound_server._current_profile_name() or "").strip()
def _foreign_profile_home(profile: str):
"""Home of a routed profile other than the process's own, or ``ValueError``."""
home = _bound_server._profile_home(profile)
if home is None:
raise ValueError(f"profile '{profile}' is unavailable")
return home
def _requested_profile(params: dict) -> str:
requested = str(params.get("profile") or "").strip()
if not requested:
@@ -101,28 +105,24 @@ def _requested_profile(params: dict) -> str:
raise ValueError("profile routing is unavailable")
if requested == _current_profile():
return requested
if _bound_server._profile_home(requested) is None:
raise ValueError(f"profile '{requested}' is unavailable")
_foreign_profile_home(requested)
return str(_bound_server._response_profile_name(requested) or requested)
def _api_server_key(profile: str | None = None) -> str:
# Published onto the server by methods_bot_relay.register (an explicit routed profile is
# authoritative: never borrow the process profile's key on a multiplexed gateway).
if profile and _bound_server is not None and profile != _current_profile():
from agent.secret_scope import build_profile_secret_scope
home = _bound_server._profile_home(profile)
if home is None:
return ""
# An explicit routed profile is authoritative. Never borrow the
# process/default profile's API key on a multiplexed gateway.
return str(build_profile_secret_scope(home).get("API_SERVER_KEY") or "").strip()
try:
scoped = ""
with contextlib.suppress(Exception):
from agent.secret_scope import get_secret
scoped = (get_secret("API_SERVER_KEY", "") or "").strip()
if scoped:
return scoped
except Exception:
pass
return (os.getenv("API_SERVER_KEY") or "").strip()
return scoped or (os.getenv("API_SERVER_KEY") or "").strip()
def _profile_execution_policy(profile: str) -> dict:
@@ -131,10 +131,7 @@ def _profile_execution_policy(profile: str) -> dict:
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
token = None
if _bound_server is not None and profile not in {_current_profile(), _profile_name()}:
home = _bound_server._profile_home(profile)
if home is None:
raise ValueError(f"profile '{profile}' is unavailable")
token = set_hermes_home_override(str(home))
token = set_hermes_home_override(str(_foreign_profile_home(profile)))
try:
return execution_policy_mapping(target_profile=profile)
finally:
@@ -145,20 +142,17 @@ def _profile_execution_policy(profile: str) -> dict:
def _room_link_run_storage_durable() -> bool:
"""Return whether peer-run replay survives this gateway process."""
if _bound_server is None:
# Direct method-contract tests and embedded callers without a bound API
# server do not expose peer-run transport; production always binds first.
# Embedded callers without a bound server expose no peer-run transport.
return True
store = getattr(_bound_server, "_run_idempotency_store", None)
if store is None:
# The dashboard/TUI process owns groups.* but does not construct the API adapter
# that owns this store. Open the same shared SQLite-backed store lazily so
# capability negotiation reflects the real /v1/runs replay boundary.
# This process does not construct the API adapter that owns the store; open the
# same shared SQLite store lazily so negotiation reflects the real replay boundary.
from gateway.platforms.api_server import RunIdempotencyStore
with _run_store_lock:
store = getattr(_bound_server, "_run_idempotency_store", None)
if store is None:
store = RunIdempotencyStore()
_bound_server._run_idempotency_store = store
store = _bound_server._run_idempotency_store = RunIdempotencyStore()
return bool(getattr(store, "durable", False))
@@ -175,18 +169,28 @@ def _grant_expiry(claims: dict) -> float:
return float(claims.get("status_expires_at", claims["expires_at"]))
def _include_disbanded(params: dict) -> bool:
return params.get("include_disbanded") is True
def _room_error_class(replica_only: bool) -> type:
if replica_only:
from gateway.hosted_room_replicas import ReplicaError
return ReplicaError
from gateway.hosted_rooms import HostedRoomError
return HostedRoomError
def _room_method(
name: str, *, code: int, room_code: int | None = None, replica_only: bool = False,
with_reason: bool = True, service_code: int | None = None,
service_message: str = _DRIVER_UNAVAILABLE, db: bool = False):
"""Register ``fn`` under ``name`` with the shared hosted-room error envelope.
``service_code`` set: the live service is required and passed as a third argument;
when absent the handler fails with that code. ``db``: the default room db path is
passed as the next argument. ``room_code`` maps ``HostedRoomError`` (or only
``ReplicaError`` when ``replica_only``) to a 4xxx client error, attaching
``{"reason"}`` data when ``with_reason``; any other exception maps to ``code``.
"""
``service_code``: the live service is required (else that error) and passed as a third
argument; ``db``: the default room db path follows. ``room_code`` maps ``HostedRoomError``
(only ``ReplicaError`` when ``replica_only``) to a client error with ``{"reason"}`` data
when ``with_reason``; anything else maps to ``code``."""
error_class = _room_error_class # closure cell: handlers run under server.py globals
def dec(fn):
def handler(rid, params: dict) -> dict:
@@ -202,16 +206,9 @@ def _room_method(
try:
return fn(*args)
except Exception as exc:
if room_code is not None:
from gateway.hosted_rooms import HostedRoomError
klass = HostedRoomError
if replica_only:
from gateway.hosted_room_replicas import ReplicaError
klass = ReplicaError
if isinstance(exc, klass):
reason = getattr(exc, "reason", None) if with_reason else None
data = {"reason": reason} if reason else None
return _err(rid, room_code, str(exc), data)
if room_code is not None and isinstance(exc, error_class(replica_only)):
reason = getattr(exc, "reason", None) if with_reason else None
return _err(rid, room_code, str(exc), {"reason": reason} if reason else None)
return _err(rid, code, str(exc))
handler.__doc__ = fn.__doc__
return method(name)(handler)
@@ -233,26 +230,21 @@ def _(rid, params: dict, _catalog=_local_catalog, _methods=_METHODS) -> dict:
policy = _profile_execution_policy(profile)
catalog = _catalog(local_authority_gateway_id(), profile, policy)
room_link = {
"enabled": True, "profile": profile, "catalog": catalog, "endpoint": catalog["endpoint"]
}
"enabled": True, "profile": profile, "catalog": catalog,
"endpoint": catalog["endpoint"]}
except Exception:
room_link = {
"enabled": False,
"reason": (
"durable_run_storage_required" if not _room_link_run_storage_durable()
else "gateway_roomlink_secret_unavailable")}
room_link = {"enabled": False, "reason": (
"durable_run_storage_required" if not _room_link_run_storage_durable()
else "gateway_roomlink_secret_unavailable")}
return _ok(rid, {
"protocol_version": PROTOCOL_VERSION,
"driver": driver_ready,
"protocol_version": PROTOCOL_VERSION, "driver": driver_ready,
"persistent_process": bool(room_link.get("catalog", {}).get("persistent_process", False)),
"authority_gateway_id": local_authority_gateway_id(),
"room_link": room_link,
"authority_gateway_id": local_authority_gateway_id(), "room_link": room_link,
"features": [
"authority_epoch", "coordinator_fencing", "room_identity", "monotonic_log",
"idempotent_send", "replayable_disband", "typed_events", "actor_identity",
"log_replication", "authority_takeover"],
"methods": list(_methods),
"max_log_limit": MAX_LOG_LIMIT})
"methods": list(_methods), "max_log_limit": MAX_LOG_LIMIT})
@_room_method("groups.peer.invite", code=4120, db=True)
@@ -295,9 +287,8 @@ def _(rid, params: dict, db_path, _expiry=_grant_expiry) -> dict:
profile = _requested_profile(params)
claims = decode_room_grant(
gateway_room_grant_secret(), str(params.get("grant") or ""), permission="status")
if (
claims["target_profile"] != profile
or claims["target_install_id"] != local_authority_gateway_id()):
if (claims["target_profile"] != profile
or claims["target_install_id"] != local_authority_gateway_id()):
raise ValueError("room grant target does not match this profile")
revoke_room_grant_scope(db_path, claims=claims, expires_at=_expiry(claims))
return _ok(rid, {"revoked": True})
@@ -321,36 +312,29 @@ def _(rid, params: dict, service) -> dict:
grant = str(params.get("grant") or "")
client = PeerRunsHTTPClient(base_url=target_url, api_key="", receipt_db_path=service.db_path)
probe = client.probe(grant=grant)
live_catalog = GatewayRoomCatalog.from_mapping(probe.get("catalog"))
if live_catalog != catalog:
# Frozen dataclass equality: an equal live catalog already passed the checks above.
if GatewayRoomCatalog.from_mapping(probe.get("catalog")) != catalog:
raise ValueError("target capability catalog changed during setup")
if (
ROOM_LINK_PROTOCOL_VERSION not in live_catalog.protocol_versions
or "direct" not in live_catalog.link_modes):
raise ValueError("target RoomLink capability is incompatible")
room_id = str(params.get("room_id") or "")
member_id = str(params.get("member_id") or "")
home_install_id = local_authority_gateway_id()
home_room = room_state(service.db_path, room_id=room_id)
if (
probe.get("room_id") != room_id
or probe.get("home_install_id") != home_install_id
or probe.get("authority_gateway_id") != home_room.get("authority_gateway_id")
or int(probe.get("authority_epoch") or 0) != int(home_room.get("authority_epoch") or 0)
or probe.get("member_id") != member_id
or probe.get("target_profile") != target_profile):
expected_scope = {
"room_id": room_id, "home_install_id": home_install_id,
"authority_gateway_id": home_room.get("authority_gateway_id"),
"member_id": member_id, "target_profile": target_profile}
if (any(probe.get(k) != v for k, v in expected_scope.items())
or int(probe.get("authority_epoch") or 0)
!= int(home_room.get("authority_epoch") or 0)):
raise ValueError("room grant scope does not match this route")
route = PeerMemberRoute(
home_install_id=home_install_id,
member_id=member_id,
target_install_id=catalog.installation_id,
target_profile=target_profile,
home_install_id=home_install_id, member_id=member_id,
target_install_id=catalog.installation_id, target_profile=target_profile,
capability_digest=catalog.catalog_digest,
execution_policy_digest=catalog.execution_policy.policy_digest,
cancellation_scope_id=str(
params.get("cancellation_scope_id") or f"cancel-{params.get('room_id') or ''}"),
trace_id=str(params.get("trace_id") or f"trace-{os.urandom(16).hex()}"),
grant=grant)
trace_id=str(params.get("trace_id") or f"trace-{os.urandom(16).hex()}"), grant=grant)
service.register_peer_route(
room_id=room_id, member_id=member_id, route=route, client=client, target_url=target_url,
catalog=catalog)
@@ -376,11 +360,7 @@ def _(rid, params: dict, db_path) -> dict:
"groups.create", code=5111, room_code=4110, service_code=4123,
service_message=_WORKER_UNAVAILABLE)
def _(rid, params: dict, service) -> dict:
"""Create a hosted room idempotently.
Required params: ``room_id``, ``name``, and ``members``. Authority is
derived from this gateway's stable install identity, never from the client.
"""
"""Create a hosted room idempotently; authority is this gateway's stable install identity."""
room = service.create_room(
room_id=params.get("room_id"), name=params.get("name"), members=params.get("members"))
return _ok(rid, {"room": room})
@@ -401,33 +381,18 @@ def _(rid, params: dict, db_path) -> dict:
@_room_method(
"groups.send", code=5112, room_code=4111, service_code=4123, service_message=_WORKER_UNAVAILABLE
)
"groups.send", code=5112, room_code=4111, service_code=4123,
service_message=_WORKER_UNAVAILABLE)
def _(rid, params: dict, service) -> dict:
"""Append one typed event to a hosted room idempotently.
Required params: ``room_id``, ``event_id``, and object ``payload``. Only
inert ``message.user`` events are accepted through this client-facing
method; the actor is server-owned rather than trusted from params.
"""
"""Append one typed event idempotently (inert ``message.user`` only; actor is server-owned)."""
from gateway.hosted_rooms import user_event_id
client_event_id = params.get("event_id")
event = service.send(
room_id=params.get("room_id"), event_id=user_event_id(client_event_id),
payload=params.get("payload"))
return _ok(rid, {
"event": event, "client_event_id": client_event_id, "accepted": True, "driver_started": True
})
@_room_method("groups.rename", code=5117, room_code=4117, db=True)
def _(rid, params: dict, db_path) -> dict:
"""Rename one hosted room atomically with its replay event."""
from gateway.hosted_rooms import rename_room
renamed = rename_room(
db_path, room_id=params.get("room_id"), event_id=params.get("event_id"),
name=params.get("name"))
return _ok(rid, {"room": renamed})
"event": event, "client_event_id": client_event_id, "accepted": True,
"driver_started": True})
@_room_method(
@@ -491,79 +456,72 @@ def _(rid, params: dict, service) -> dict:
task = {}
identity = task.get("identity")
receipt = {
**{
field: str(getattr(identity, field, "") or "")
for field in ("room_id", "task_id", "thread_id", "turn_id")},
**{f: str(getattr(identity, f, "") or "")
for f in ("room_id", "task_id", "thread_id", "turn_id")},
"status": str(task.get("status") or ""),
"execution_generation": int(task.get("execution_generation") or 0),
"cancel_generation": int(task.get("cancel_generation") or 0)}
return _ok(rid, {"retried": True, "task": receipt})
@_room_method("groups.log", code=5113, room_code=4112, db=True)
def _(rid, params: dict, db_path) -> dict:
"""Return a monotonic room-log delta after ``since_seq``."""
from gateway.hosted_rooms import read_events
delta = read_events(
db_path, room_id=params.get("room_id"), since_seq=params.get("since_seq", 0),
limit=params.get("limit", 100), include_disbanded=params.get("include_disbanded") is True)
return _ok(rid, delta)
def _passthrough(
name: str, module: str, fn_name: str, doc: str, *, code: int, room_code: int,
params: tuple, replica_only: bool = False, wrap: str | None = None) -> None:
"""Register a method whose result is ``module.fn(db_path, **params)`` verbatim (or under key
``wrap``). ``params`` items are ``key`` (-> ``params.get(key)``) or ``(key, extractor)``."""
@_room_method(
name, code=code, room_code=room_code, replica_only=replica_only,
with_reason=not replica_only, db=True)
def handler(rid, params_in: dict, db_path, _import=importlib.import_module) -> dict:
kwargs = {
(spec if isinstance(spec, str) else spec[0]):
(params_in.get(spec) if isinstance(spec, str) else spec[1](params_in))
for spec in params}
result = getattr(_import(module), fn_name)(db_path, **kwargs)
return _ok(rid, {wrap: result} if wrap else result)
handler.__doc__ = doc
@_room_method(
"groups.replicate", code=5116, room_code=4116, replica_only=True, with_reason=False, db=True)
def _(rid, params: dict, db_path) -> dict:
"""Persist one authority-stamped replay page into the local replica store.
``page`` is the verbatim ``groups.log`` result read from the room's
authority gateway; ingest is idempotent and refuses sequence gaps and
authority-epoch regressions.
"""
from gateway.hosted_room_replicas import ingest_page
result = ingest_page(
db_path, room_id=params.get("room_id"), room_name=params.get("room_name"),
members=params.get("members"), page=params.get("page"))
return _ok(rid, result)
@_room_method(
"groups.replica_state", code=5117, room_code=4117, replica_only=True, with_reason=False, db=True
)
def _(rid, params: dict, db_path) -> dict:
"""Report the local replica's coverage and authority lineage."""
from gateway.hosted_room_replicas import replica_state
return _ok(rid, replica_state(db_path, room_id=params.get("room_id")))
_passthrough(
"groups.rename", "gateway.hosted_rooms", "rename_room",
"""Rename one hosted room atomically with its replay event.""",
code=5117, room_code=4117, params=("room_id", "event_id", "name"), wrap="room")
_passthrough(
"groups.log", "gateway.hosted_rooms", "read_events",
"""Return a monotonic room-log delta after ``since_seq``.""",
code=5113, room_code=4112,
params=(
"room_id", ("since_seq", lambda p: p.get("since_seq", 0)),
("limit", lambda p: p.get("limit", 100)), ("include_disbanded", _include_disbanded)))
_passthrough(
"groups.replicate", "gateway.hosted_room_replicas", "ingest_page",
"""Persist one authority-stamped replay page (a verbatim ``groups.log`` result) into
the local replica store; idempotent, refuses sequence gaps and epoch regressions.""",
code=5116, room_code=4116, params=("room_id", "room_name", "members", "page"),
replica_only=True)
_passthrough(
"groups.replica_state", "gateway.hosted_room_replicas", "replica_state",
"""Report the local replica's coverage and authority lineage.""",
code=5117, room_code=4117, params=("room_id",), replica_only=True)
@_room_method("groups.promote", code=5118, room_code=4118, with_reason=False, db=True)
def _(rid, params: dict, db_path) -> dict:
"""Continue a replicated room on THIS gateway at ``epoch + 1``.
Requires ``confirm: true`` — the caller asserts the previous authority can
no longer commit (explicit user action; a lease/quorum driver later).
"""
"""Continue a replicated room on THIS gateway at ``epoch + 1``. Requires ``confirm:
true`` — the caller asserts the previous authority can no longer commit."""
from gateway.hosted_room_replicas import promote_replica
if params.get("confirm") is not True:
return _err(
rid, 4118,
"promotion requires confirm=true acknowledging the previous "
"authority can no longer commit")
result = promote_replica(
db_path, room_id=params.get("room_id"), reason=params.get("reason", "authority-unreachable")
)
return _ok(rid, result)
return _err(rid, 4118, "promotion requires confirm=true acknowledging the previous "
"authority can no longer commit")
reason = params.get("reason", "authority-unreachable")
return _ok(rid, promote_replica(db_path, room_id=params.get("room_id"), reason=reason))
@_room_method(
"groups.demote", code=5119, room_code=4119, replica_only=True, with_reason=False, db=True)
def _(rid, params: dict, db_path) -> dict:
"""Fence this gateway's stale room authority against a proven newer epoch."""
from gateway.hosted_room_replicas import demote_room
result = demote_room(
db_path, room_id=params.get("room_id"),
observed_gateway_id=params.get("observed_gateway_id"),
observed_epoch=params.get("observed_epoch"))
return _ok(rid, result)
_passthrough(
"groups.demote", "gateway.hosted_room_replicas", "demote_room",
"""Fence this gateway's stale room authority against a proven newer epoch.""",
code=5119, room_code=4119, params=("room_id", "observed_gateway_id", "observed_epoch"),
replica_only=True)
def register(server) -> None:
+12 -21
View File
@@ -1,8 +1,7 @@
"""Image-generation JSON-RPC handler (ws twin of the image_generate tool) for UI surfaces
(avatar pickers, artifact panes). The result is a data URL: a remote desktop can't read a
gateway file path and hosted URLs are often CORS-opaque to a renderer canvas.
Bodies are rebound onto server.py's globals (method_ctx.bind_module) and reference them bare.
gateway file path and hosted URLs are often CORS-opaque to a renderer canvas. Bodies are
rebound onto server.py's globals (method_ctx.bind_module) and reference them bare.
"""
from .method_ctx import HandlerRegistry, bind_module
@@ -11,14 +10,6 @@ _registry = HandlerRegistry()
method = _registry.method
def _image_gen_available() -> bool:
try:
from tools.image_generation_tool import check_image_generation_requirements
return bool(check_image_generation_requirements())
except Exception:
return False
def _image_to_data_url(ref: str, cap: int):
"""Fetch a URL or read a local path into a data URL; None when missing, over *cap*, or failing."""
import base64
@@ -43,8 +34,7 @@ def _image_to_data_url(ref: str, cap: int):
return None
if len(data) > cap:
return None
if not mime.startswith("image/"):
mime = "image/png"
mime = mime if mime.startswith("image/") else "image/png"
return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"
except Exception:
return None
@@ -57,14 +47,17 @@ def _(rid, params: dict) -> dict:
on the data URL, default 8MB, max 16MB). Result: ``{available, success, image,
image_data, error}`` — ``image_data`` is omitted when the download fails, so
callers fall back to ``image`` (the backend's URL/path)."""
available = _image_gen_available()
try:
from tools.image_generation_tool import check_image_generation_requirements
available = bool(check_image_generation_requirements())
except Exception:
available = False
if is_truthy_value(params.get("probe", False)):
return _ok(rid, {"available": available})
if not available:
return _ok(rid, {
"available": False, "success": False,
"error": "No image generation backend configured (run `hermes tools` to enable one).",
})
"error": "No image generation backend configured (run `hermes tools` to enable one)."})
prompt = str(params.get("prompt") or "").strip()
if not prompt:
return _err(rid, 4071, "prompt required")
@@ -74,9 +67,9 @@ def _(rid, params: dict) -> dict:
except (TypeError, ValueError):
cap = 8_000_000
try:
from tools.image_generation_tool import _handle_image_generate
# Full provider dispatcher — same path as the model tool (source-image confinement,
# plugin providers, managed routing, FAL fallback); the FAL leaf bypassed providers.
from tools.image_generation_tool import _handle_image_generate
result = json.loads(_handle_image_generate({"prompt": prompt, "aspect_ratio": aspect}))
except Exception as e:
return _err(rid, 5071, str(e))
@@ -84,11 +77,9 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"available": True, "success": False,
"error": str(result.get("error") or "generation failed")})
image_ref = str(result.get("image") or "")
payload = {"available": True, "success": True, "image": image_ref}
data_url = _image_to_data_url(image_ref, cap) if image_ref else None
if data_url:
payload["image_data"] = data_url
return _ok(rid, payload)
return _ok(rid, {"available": True, "success": True, "image": image_ref,
**({"image_data": data_url} if data_url else {})})
def register(server) -> None:
+139 -200
View File
@@ -1,8 +1,7 @@
"""Profile JSON-RPC handlers — the ws twin of the dashboard's /api/profiles (desktop plugins
only have the ws door), on the same `hermes_cli.profiles` primitives.
Bodies are rebound onto server.py's globals (method_ctx.bind_module) and use them bare;
module-level names are published onto server.py, so they must not collide with its globals.
only have the ws door), on the same `hermes_cli.profiles` primitives. Bodies are rebound onto
server.py's globals (method_ctx.bind_module) and use them bare; module-level names are published
onto server.py, so they must not collide with its globals.
"""
import contextlib
@@ -14,11 +13,13 @@ method = _registry.method
# ext -> mime; iteration order is the on-disk lookup order for assets.
_ASSET_EXTS = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
# ext -> [(start, end, magic bytes)]; format is sniffed, the declared mime is never trusted.
_ASSET_MAGIC = {"png": [(0, 8, b"\x89PNG\r\n\x1a\n")], "jpg": [(0, 3, b"\xff\xd8\xff")],
"webp": [(0, 4, b"RIFF"), (8, 12, b"WEBP")]}
def _profile_handler(name: str, code: int):
"""``@method(name)`` whose body's uncaught exception becomes ``_err(rid, code, str(e))``."""
def deco(fn):
def handler(rid, params: dict) -> dict:
try:
@@ -39,13 +40,12 @@ def _pin_profile_model(profile_dir, provider, model) -> None:
_lazy("hermes_cli.web_routers.profiles", "_write_profile_model")(profile_dir, provider, model)
def _launch_mcp_catalog() -> dict:
mcp = (_lazy("hermes_cli.config", "load_config_readonly")() or {}).get("mcp_servers")
return mcp if isinstance(mcp, dict) else {}
def _model_provider_params(params) -> tuple:
return str(params.get("model") or "").strip(), str(params.get("provider") or "").strip()
def _try(fn, default):
"""``fn()`` or ``default`` on any exception — best-effort sections must never fail each other."""
"""``fn()`` or ``default`` on any exception (best-effort sections must never fail each other)."""
try:
return fn()
except Exception:
@@ -53,7 +53,6 @@ def _try(fn, default):
def _best_effort(fn) -> bool:
"""Run ``fn``; True on success, False on any exception."""
return _try(lambda: (fn(), True)[1], False)
@@ -68,7 +67,7 @@ def _hermes_home_scope(path):
def _resolve_profile(rid, params):
"""``(name, profile_dir, err)`` — err is the 4063 (name required) / 4064 (not found) response."""
"""``(name, profile_dir, err)``; err = 4063 (name required) / 4064 (not found) response."""
name = str(params.get("name") or "").strip()
if not name:
return name, None, _err(rid, 4063, "name required")
@@ -80,10 +79,12 @@ def _resolve_profile(rid, params):
def _read_profile_yaml(profile_dir) -> dict:
"""profile.yaml as a mapping; ``{}`` when missing, unparseable, or not a mapping."""
import yaml
meta_path = profile_dir / "profile.yaml"
loaded = (yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {}) if meta_path.is_file() else {}
"""profile.yaml as a mapping; ``{}`` when missing, unreadable, unparseable, or not a mapping."""
def load():
import yaml
meta_path = profile_dir / "profile.yaml"
return (yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {}) if meta_path.is_file() else {}
loaded = _try(load, {})
return loaded if isinstance(loaded, dict) else {}
@@ -94,7 +95,7 @@ def _clean_revisions(raw: dict) -> dict:
def _latest_message_preview(db, session_id):
"""≤80-char excerpt of the NEWEST active user/assistant message, or "" (roster semantics).
Same query shape as ``SessionDB.latest_message_row_id`` — keep them in step."""
Same query shape as ``SessionDB.latest_message_row_id``; keep them in step."""
try:
with db._lock:
row = db._conn.execute(
@@ -105,24 +106,13 @@ def _latest_message_preview(db, session_id):
(session_id,)).fetchone()
except Exception:
return ""
if not row:
return ""
text = " ".join(str(row[0] or "").split()).strip()
text = " ".join(str(row[0] or "").split()).strip() if row else ""
return text[:80] + "..." if len(text) > 80 else text
def _open_profile_session_db_readonly(profile_path):
"""Read-only attach for roster previews, or None (a writable ``SessionDB()`` waits up to 20s
for the write lock + runs DDL and stalled the 5s roster poll)."""
db_path = Path(profile_path) / "state.db"
if not _try(db_path.exists, False):
return None
return _try(lambda: _lazy("hermes_state", "SessionDB")(db_path=db_path, read_only=True), None)
def _resurrect_recoverable_canonical(db, profile_path, session_id):
"""Un-archive an accidentally archived canonical row (judged read-only, written via a
short-lived writable handle), or False."""
short-lived writable handle); False otherwise."""
try:
row = db.get_session(session_id)
if not row or not row.get("archived"):
@@ -145,13 +135,9 @@ def _canonical_session_row(db, profile_path):
"""Summary of the profile's canonical "Bot Chat" row (identity is the NAME), or None.
Lineages via ``get_compression_tip`` (NOT the resume walker's unmarked-child fallback);
worker sources count as absent. ``id`` is the registry row, ``resolved_id`` the live tip."""
if db is None:
return None
try:
row = db.get_session_by_title("Bot Chat")
if not row:
return None
session_id = str(row.get("id") or "").strip()
session_id = str((row or {}).get("id") or "").strip()
if not session_id or _denied_source(row):
return None
# Archived = retired (absent), except accidental reaper archives: resurrect those.
@@ -171,29 +157,22 @@ def _canonical_session_row(db, profile_path):
def _latest_profile_session_rows(db):
"""(newest human-facing session, newest worker session). The worker row lets rosters show
a profile as working (workers heartbeat ``last_activity_at`` every ≤60s)."""
if db is None:
return None, None
"""(newest human-facing session, newest worker session); the worker row lets rosters show a
profile as working (workers heartbeat ``last_activity_at`` every ≤60s)."""
try:
human = worker = None
for s in db.list_sessions_rich(source=None, limit=20, order_by_last_active=True, compact_rows=True):
title = s.get("title") or ""
last_active = s.get("last_active") or s.get("started_at") or 0
title, last_active = s.get("title") or "", s.get("last_active") or s.get("started_at") or 0
if _denied_source(s):
if worker is None:
src = (s.get("source") or "").strip().lower()
worker = {"id": s["id"], "source": src, "title": title, "last_active": last_active}
continue
if human is not None:
continue
# Rosters want "where the conversation IS": prefer the newest text.
human = {
"id": s["id"], "title": title,
"preview": _latest_message_preview(db, s["id"]) or s.get("preview") or "",
"started_at": s.get("started_at") or 0, "last_active": last_active,
"message_count": s.get("message_count") or 0}
if worker is not None:
worker = {"id": s["id"], "source": (s.get("source") or "").strip().lower(),
"title": title, "last_active": last_active}
elif human is None: # rosters want "where the conversation IS": prefer the newest text
human = {"id": s["id"], "title": title,
"preview": _latest_message_preview(db, s["id"]) or s.get("preview") or "",
"started_at": s.get("started_at") or 0, "last_active": last_active,
"message_count": s.get("message_count") or 0}
if human is not None and worker is not None:
break
return human, worker
except Exception:
@@ -201,8 +180,13 @@ def _latest_profile_session_rows(db):
def _profile_session_fields(row, profile_path):
"""Attach last_session / worker_session / canonical_session to a roster row."""
db = _open_profile_session_db_readonly(profile_path)
"""Attach last_session / worker_session / canonical_session to a roster row. The DB is a
read-only attach (a writable ``SessionDB()`` waits up to 20s for the write lock + runs DDL
and stalled the 5s roster poll); no/unreadable DB -> every field None (the readers swallow)."""
db_path = Path(profile_path) / "state.db"
db = None
if _try(db_path.exists, False):
db = _try(lambda: _lazy("hermes_state", "SessionDB")(db_path=db_path, read_only=True), None)
try:
row["last_session"], row["worker_session"] = _latest_profile_session_rows(db)
# Resolved server-side on every listing so no client carries a session pointer.
@@ -214,16 +198,13 @@ def _profile_session_fields(row, profile_path):
def _profile_ui_meta_fields(row: dict, profile_dir) -> None:
"""Attach ``ui_meta`` / ``ui_meta_revisions`` / ``has_avatar`` from profile.yaml + assets.
``ui_meta_revisions`` is always present: it feature-detects gateway-owned CAS even for a
brand-new profile."""
row["ui_meta_revisions"] = {}
raw_meta = _try(lambda: _read_profile_yaml(profile_dir), {})
ui_meta = raw_meta.get("ui_meta")
``ui_meta_revisions`` is always present: it feature-detects gateway-owned CAS for a new profile."""
raw_meta = _read_profile_yaml(profile_dir)
ui_meta, revisions = raw_meta.get("ui_meta"), raw_meta.get("_ui_meta_revisions")
# Key order is wire-visible: ui_meta_revisions precedes ui_meta.
row["ui_meta_revisions"] = _try(lambda: _clean_revisions(revisions), {}) if isinstance(revisions, dict) else {}
if isinstance(ui_meta, dict) and ui_meta:
row["ui_meta"] = ui_meta
revisions = raw_meta.get("_ui_meta_revisions")
if isinstance(revisions, dict) and revisions:
row["ui_meta_revisions"] = _try(lambda: _clean_revisions(revisions), {})
# Cheap existence flag so rosters skip a get_asset probe per paint.
row["has_avatar"] = _try(lambda: any((profile_dir / "assets" / f"avatar.{e}").is_file() for e in _ASSET_EXTS), False)
@@ -236,29 +217,22 @@ def _(rid, params: dict) -> dict:
include_sessions = is_truthy_value(params.get("include_sessions", True))
out = []
for p in list_profiles():
row = {
"name": p.name, "path": str(p.path), "is_default": bool(p.is_default),
"model": p.model, "provider": p.provider,
"description": p.description or "", "display_name": p.display_name or "",
"skill_count": p.skill_count or 0}
row = {"name": p.name, "path": str(p.path), "is_default": bool(p.is_default), "model": p.model,
"provider": p.provider, "description": p.description or "",
"display_name": p.display_name or "", "skill_count": p.skill_count or 0}
if include_sessions:
_profile_session_fields(row, p.path)
_profile_ui_meta_fields(row, Path(str(p.path)))
out.append(row)
# Capability flag: this backend injects the Bot Mode teammate-messaging
# protocol into every session, so clients must not append it to SOUL.md.
# bot_mode_protocol: this backend injects the Bot Mode teammate-messaging protocol into every
# session, so clients must not append it to SOUL.md.
return _ok(rid, {"profiles": out, "bot_mode_protocol": True})
def _has_real_env_content(env_path) -> bool:
"""True when .env has any non-comment, non-blank line."""
lines = env_path.read_text(encoding="utf-8", errors="replace").splitlines()
return any(s and not s.startswith("#") for s in map(str.strip, lines))
def _copy_secret_file(src, dst, wanted: bool) -> bool:
"""Copy ``src`` -> ``dst`` (0600) when ``src`` exists and ``wanted``; True if copied."""
if not (src.is_file() and wanted):
def _mirror_secret(path, launch_home, name: str, wanted) -> bool:
"""Copy the launch ``name`` file into the profile (0600) when it exists and ``wanted(src, dst)``."""
src, dst = launch_home / name, path / name
if not (src.is_file() and wanted(src, dst)):
return False
import shutil
shutil.copy2(src, dst)
@@ -267,28 +241,14 @@ def _copy_secret_file(src, dst, wanted: bool) -> bool:
return True
def _mirror_env(path, launch_home) -> bool:
"""Copy the launch .env only over the seeded comment-only stub (never a clone's secrets)."""
src, dst = launch_home / ".env", path / ".env"
return _copy_secret_file(
src, dst, _has_real_env_content(src) and not _try(lambda: _has_real_env_content(dst), False))
def _mirror_auth(path, launch_home) -> bool:
"""Copy the launch auth.json when absent (skipped under ``share_auth``: a copy forks token
state and the first refresh in either store strands the other)."""
src, dst = launch_home / "auth.json", path / "auth.json"
if not _copy_secret_file(src, dst, not dst.exists()):
return False
# Drop single-use OAuth grants (first refresh strands every sibling); they read from
# the root grant via the pool fallback. API keys stay.
_best_effort(lambda: _lazy("hermes_cli.auth", "strip_cloned_single_use_oauth_grants")(path))
return True
def _env_has_content(env_path) -> bool:
lines = env_path.read_text(encoding="utf-8", errors="replace").splitlines()
return any(s and not s.startswith("#") for s in map(str.strip, lines))
def _mirror_voice_sections(path) -> bool:
"""Copy stt/tts/voice sections from the launch profile (a fresh profile has only ``model``,
so voice fell back to defaults); True if written. Canonical loaders under the home override."""
so voice fell back to defaults); True if written."""
try:
from hermes_cli.config import load_config_readonly, read_user_config_raw, save_config
src_cfg = load_config_readonly() or {}
@@ -300,8 +260,7 @@ def _mirror_voice_sections(path) -> bool:
dst_cfg = read_user_config_raw() or {}
missing = {k: v for k, v in sections.items() if k not in dst_cfg}
if missing:
dst_cfg.update(missing)
save_config(dst_cfg)
save_config({**dst_cfg, **missing})
return bool(missing)
except Exception:
return False
@@ -316,10 +275,9 @@ def _inherit_launch_model(path) -> bool:
if dst_model.get("provider") and dst_model.get("default"):
return False
model_cfg = (load_config_readonly() or {}).get("model") or {}
provider, model = str(model_cfg.get("provider") or ""), str(model_cfg.get("default") or "")
if not (provider and model):
if not (model_cfg.get("provider") and model_cfg.get("default")):
return False
_pin_profile_model(path, provider, model)
_pin_profile_model(path, str(model_cfg["provider"]), str(model_cfg["default"]))
return True
@@ -327,16 +285,22 @@ def _mirror_launch_credentials(path, params: dict) -> dict:
"""Copy launch .env / auth.json / voice sections into a new profile (best-effort per item).
``share_auth`` reports ``auth: "shared"`` and skips the auth copy; ``mirror_credentials``
false skips everything. ``model_inherited`` is filled in by the caller."""
mirrored = {"env": False, "auth": False, "model_inherited": False, "voice": False}
share_auth = is_truthy_value(params.get("share_auth", False))
if share_auth:
mirrored["auth"] = "shared"
mirrored = {"env": False, "auth": "shared" if share_auth else False, "model_inherited": False,
"voice": False}
if not is_truthy_value(params.get("mirror_credentials", True)):
return mirrored
launch_home = get_hermes_home()
mirrored["env"] = _try(lambda: _mirror_env(path, launch_home), False)
if not share_auth:
mirrored["auth"] = _try(lambda: _mirror_auth(path, launch_home), False)
# .env: only over the seeded comment-only stub (never a clone's secrets).
mirrored["env"] = _try(lambda: _mirror_secret(path, launch_home, ".env", lambda src, dst: (
_env_has_content(src) and not _try(lambda: _env_has_content(dst), False))), False)
if not share_auth: # a copy forks token state: the first refresh in either store strands the other
mirrored["auth"] = _try(lambda: _mirror_secret(path, launch_home, "auth.json",
lambda src, dst: not dst.exists()), False)
if mirrored["auth"]:
# Drop single-use OAuth grants (first refresh strands every sibling); they read from the
# root grant via the pool fallback. API keys stay.
_best_effort(lambda: _lazy("hermes_cli.auth", "strip_cloned_single_use_oauth_grants")(path))
mirrored["voice"] = _mirror_voice_sections(path)
return mirrored
@@ -345,9 +309,8 @@ def _mirror_launch_credentials(path, params: dict) -> dict:
def _(rid, params: dict) -> dict:
"""Create a profile (ws twin of POST /api/profiles). Params: ``name``, ``description``,
``clone_from`` (omitted = fresh + bundled skills), ``clone_all``, ``no_skills``, ``soul``,
``model`` + ``provider``, ``share_auth``, ``mirror_credentials`` (default true — a
``create_profile()`` seeds a comment-only .env and no auth.json, so a headless profile had
NO provider)."""
``model`` + ``provider``, ``share_auth``, ``mirror_credentials`` (default true: a bare
``create_profile()`` seeds a comment-only .env and no auth.json = NO provider headless)."""
name = str(params.get("name") or "").strip()
if not name:
return _err(rid, 4061, "name required")
@@ -369,12 +332,10 @@ def _(rid, params: dict) -> dict:
_best_effort(lambda: profiles_mod.seed_profile_skills(path, quiet=True))
_best_effort(lambda: profiles_mod.check_alias_collision(name) or profiles_mod.create_wrapper_script(name))
soul = params.get("soul")
soul_written = False
if isinstance(soul, str) and soul.strip():
soul_written = _best_effort(lambda: (path / "SOUL.md").write_text(soul, encoding="utf-8"))
soul_written = isinstance(soul, str) and bool(soul.strip()) and _best_effort(
lambda: (path / "SOUL.md").write_text(soul, encoding="utf-8"))
mirrored = _mirror_launch_credentials(path, params)
model = str(params.get("model") or "").strip()
provider = str(params.get("provider") or "").strip()
model, provider = _model_provider_params(params)
model_set = False
if model and provider:
model_set = _best_effort(lambda: _pin_profile_model(path, provider, model))
@@ -386,7 +347,7 @@ def _(rid, params: dict) -> dict:
def _describe_toolsets(cfg):
"""``(toolsets, pinned_set)`` as the `hermes tools` checklist presents them (the raw registry
leaks platform composites and reports everything "enabled" without a pin)."""
leaks platform composites and reports everything enabled without a pin)."""
from hermes_cli.tools_config import (
_get_effective_configurable_toolsets, _get_platform_tools, _toolset_allowed_for_platform)
from toolsets import resolve_toolset
@@ -396,34 +357,21 @@ def _describe_toolsets(cfg):
default_off = _try(lambda: _lazy("hermes_cli.tools_config", "_DEFAULT_OFF_TOOLSETS"), set())
toolsets_out = []
for ts_name, ts_label, ts_desc in _get_effective_configurable_toolsets():
if not _toolset_allowed_for_platform(ts_name, "cli"):
continue
enabled = ts_name in pinned_set if pinned_set is not None else ts_name in platform_enabled
enabled = ts_name in (pinned_set if pinned_set is not None else platform_enabled)
# Default-off integrations (+ opt-in yuanbao) are noise unless already enabled.
if (ts_name in default_off or ts_name == "yuanbao") and not enabled:
if not _toolset_allowed_for_platform(ts_name, "cli") or (
(ts_name in default_off or ts_name == "yuanbao") and not enabled):
continue
tool_count = _try(lambda: len(set(resolve_toolset(ts_name))), 0)
toolsets_out.append({"name": ts_name, "label": ts_label, "description": ts_desc or "",
"tool_count": tool_count, "enabled": enabled})
"tool_count": _try(lambda: len(set(resolve_toolset(ts_name))), 0),
"enabled": enabled})
return toolsets_out, pinned_set
def _describe_mcp_servers(cfg):
"""``[{name, enabled, transport}]`` for the profile's ``mcp_servers`` (best-effort)."""
mcp_cfg = cfg.get("mcp_servers")
if not isinstance(mcp_cfg, dict):
return []
return _try(lambda: [
{"name": str(srv_name), "enabled": not is_truthy_value(entry.get("disabled", False)),
"transport": str(entry.get("transport") or "http") if entry.get("url") else "stdio"}
for srv_name in sorted(mcp_cfg.keys()) for entry in (mcp_cfg[srv_name],)
if isinstance(entry, dict)
], [])
@_profile_handler("profiles.describe", 5063)
def _(rid, params: dict) -> dict:
"""Editor snapshot; installed skills are enabled unless in ``skills.disabled``."""
"""Editor snapshot; installed skills are enabled unless in ``skills.disabled``; ``mcp_servers``
is ``[{name, enabled, transport}]`` (best-effort)."""
name, profile_dir, err = _resolve_profile(rid, params)
if err is not None:
return err
@@ -439,7 +387,13 @@ def _(rid, params: dict) -> dict:
toolsets_out, pinned_set = _describe_toolsets(cfg)
soul_path = profile_dir / "SOUL.md"
soul = _try(lambda: soul_path.read_text(encoding="utf-8", errors="replace") if soul_path.is_file() else "", "")
mcp_out = _describe_mcp_servers(cfg)
mcp_cfg = cfg.get("mcp_servers")
mcp_out = _try(lambda: [
{"name": str(srv_name), "enabled": not is_truthy_value(entry.get("disabled", False)),
"transport": str(entry.get("transport") or "http") if entry.get("url") else "stdio"}
for srv_name in sorted(mcp_cfg.keys()) for entry in (mcp_cfg[srv_name],)
if isinstance(entry, dict)
], []) if isinstance(mcp_cfg, dict) else []
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {}
meta = _try(lambda: _lazy("hermes_cli.profiles", "read_profile_meta")(profile_dir), {})
return _ok(rid, {
@@ -454,16 +408,16 @@ def _configure_ui_meta(profile_dir, params, applied) -> None:
"""Merge ``params["ui_meta"]`` key-wise into profile.yaml (None deletes). 64KB cap (rides
every roster paint). ``ui_meta_expected_revisions``: per-key CAS, any mismatch rejects the
whole write; revisions survive deletion so a stale client cannot recreate a removed key."""
applied["ui_meta"] = False
try:
incoming = params["ui_meta"]
if len(json.dumps(incoming)) > 65536:
applied["ui_meta"] = False
return
expected = params.get("ui_meta_expected_revisions")
if expected is not None and not isinstance(expected, dict):
raise ValueError("ui_meta_expected_revisions must be an object")
with _profile_ui_meta_lock:
existing = _try(lambda: _read_profile_yaml(profile_dir), {})
existing = _read_profile_yaml(profile_dir)
raw_revisions = existing.get("_ui_meta_revisions")
revisions = _clean_revisions(raw_revisions if isinstance(raw_revisions, dict) else {})
conflicts = {}
@@ -472,7 +426,6 @@ def _configure_ui_meta(profile_dir, params, applied) -> None:
if not isinstance(wanted, int) or isinstance(wanted, bool) or wanted < 0 or wanted != actual:
conflicts[key] = {"expected": wanted, "actual": actual}
if conflicts:
applied["ui_meta"] = False
applied["ui_meta_conflicts"] = conflicts
applied["ui_meta_revisions"] = {key: revisions.get(key, 0) for key in incoming}
return
@@ -499,12 +452,11 @@ def _configure_ui_meta(profile_dir, params, applied) -> None:
def _configure_model(profile_dir, params, applied):
"""Apply a ``model`` + ``provider`` pin, or return a confirm message and write NOTHING (client
resends with ``confirm_expensive_model``). A failing guard = "no warning" (as _apply_model_switch)."""
model = str(params.get("model") or "").strip()
provider = str(params.get("provider") or "").strip()
confirm_message = None
resends with ``confirm_expensive_model``). A failing guard = no warning (as _apply_model_switch)."""
model, provider = _model_provider_params(params)
if not (model and provider):
return None
confirm_message = None
if not is_truthy_value(params.get("confirm_expensive_model", False)):
warn = _lazy("hermes_cli.model_selection_guards", "combined_selection_warning")
confirm_message = _try(lambda: getattr(warn(model, provider=provider or None), "message", None), None)
@@ -513,31 +465,6 @@ def _configure_model(profile_dir, params, applied):
return confirm_message
def _configure_cfg_sections(profile_dir, params, applied) -> None:
"""Apply ``disabled_skills`` / ``enabled_toolsets`` / ``enabled_mcp_servers`` (replace
semantics; empty toolsets clears the pin). An undefined MCP server is copied from the LAUNCH
catalog (unknown names skipped); credentials stay in .env/auth."""
want_mcp = isinstance(params.get("enabled_mcp_servers"), list)
# Launch catalog read BEFORE the home override flips config resolution.
launch_mcp = _try(_launch_mcp_catalog, {}) if want_mcp else {}
with _hermes_home_scope(profile_dir):
from hermes_cli.config import load_config, save_config
cfg = load_config() or {}
if isinstance(params.get("disabled_skills"), list):
try:
from hermes_cli.skills_config import save_disabled_skills
save_disabled_skills(cfg, _clean_names(params["disabled_skills"]))
applied["skills"] = True
cfg = load_config() or {}
except Exception:
applied["skills"] = False
if isinstance(params.get("enabled_toolsets"), list):
applied["toolsets"] = _best_effort(lambda: _save_toolset_pin(cfg, params["enabled_toolsets"], save_config))
if want_mcp:
applied["mcp_servers"] = _best_effort(lambda: _save_mcp_toggles(
load_config() or {}, params["enabled_mcp_servers"], launch_mcp, save_config))
def _clean_names(values) -> set:
return {str(v).strip() for v in values if str(v).strip()}
@@ -557,10 +484,9 @@ def _save_mcp_toggles(cfg, enabled, launch_mcp, save_config) -> None:
wanted = _clean_names(enabled)
mcp_cfg = cfg.get("mcp_servers") if isinstance(cfg.get("mcp_servers"), dict) else {}
for srv in wanted:
if srv in mcp_cfg and isinstance(mcp_cfg[srv], dict):
mcp_cfg[srv].pop("disabled", None)
elif srv in launch_mcp and isinstance(launch_mcp[srv], dict):
if not isinstance(mcp_cfg.get(srv), dict) and isinstance(launch_mcp.get(srv), dict):
mcp_cfg[srv] = dict(launch_mcp[srv])
if isinstance(mcp_cfg.get(srv), dict):
mcp_cfg[srv].pop("disabled", None)
for srv, entry in mcp_cfg.items():
if srv not in wanted and isinstance(entry, dict):
@@ -570,11 +496,39 @@ def _save_mcp_toggles(cfg, enabled, launch_mcp, save_config) -> None:
save_config(cfg)
def _configure_cfg_sections(profile_dir, params, applied) -> None:
"""Apply ``disabled_skills`` / ``enabled_toolsets`` / ``enabled_mcp_servers`` (replace
semantics; empty toolsets clears the pin). An undefined MCP server is copied from the LAUNCH
catalog (unknown names skipped); credentials stay in .env/auth."""
want_mcp = isinstance(params.get("enabled_mcp_servers"), list)
launch_mcp = {}
if want_mcp: # launch catalog read BEFORE the home override flips config resolution
load_launch = _lazy("hermes_cli.config", "load_config_readonly")
launch_mcp = _try(lambda: (load_launch() or {}).get("mcp_servers"), {})
launch_mcp = launch_mcp if isinstance(launch_mcp, dict) else {}
with _hermes_home_scope(profile_dir):
from hermes_cli.config import load_config, save_config
cfg = load_config() or {}
if isinstance(params.get("disabled_skills"), list):
try:
from hermes_cli.skills_config import save_disabled_skills
save_disabled_skills(cfg, _clean_names(params["disabled_skills"]))
applied["skills"] = True
cfg = load_config() or {}
except Exception:
applied["skills"] = False
if isinstance(params.get("enabled_toolsets"), list):
applied["toolsets"] = _best_effort(lambda: _save_toolset_pin(cfg, params["enabled_toolsets"], save_config))
if want_mcp:
applied["mcp_servers"] = _best_effort(lambda: _save_mcp_toggles(
load_config() or {}, params["enabled_mcp_servers"], launch_mcp, save_config))
@_profile_handler("profiles.configure", 5064)
def _(rid, params: dict) -> dict:
"""Editor Save: ``name`` plus any of ``ui_meta`` (+ ``ui_meta_expected_revisions``), ``soul``,
``description``, ``model`` + ``provider`` (+ ``confirm_expensive_model``), ``disabled_skills``,
``enabled_toolsets``, ``enabled_mcp_servers``. Sections are independent; ``applied`` reports each."""
``enabled_toolsets``, ``enabled_mcp_servers``; sections are independent, ``applied`` reports each."""
_name, profile_dir, err = _resolve_profile(rid, params)
if err is not None:
return err
@@ -590,35 +544,22 @@ def _(rid, params: dict) -> dict:
confirm_message = _configure_model(profile_dir, params, applied)
if any(isinstance(params.get(k), list) for k in ("disabled_skills", "enabled_toolsets", "enabled_mcp_servers")):
_configure_cfg_sections(profile_dir, params, applied)
result = {"ok": all(applied.values()) if applied else True, "applied": applied}
if confirm_message is not None:
# Same shape config.set returns, so clients reuse one confirm handler.
result["confirm_required"] = True
result["confirm_message"] = confirm_message
return _ok(rid, result)
def _sniff_asset_ext(blob):
"""Extension for a PNG/JPEG/WebP blob by magic bytes (never trust declared mime), or None."""
if blob[:8] == b"\x89PNG\r\n\x1a\n":
return "png"
if blob[:3] == b"\xff\xd8\xff":
return "jpg"
return "webp" if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP" else None
# confirm_* is the shape config.set returns, so clients reuse one confirm handler.
return _ok(rid, {"ok": all(applied.values()) if applied else True, "applied": applied,
**({"confirm_required": True, "confirm_message": confirm_message}
if confirm_message is not None else {})})
def _unlink_asset_files(assets_dir, asset) -> int:
"""Delete every ``<asset>.<ext>`` in ``assets_dir``; returns how many existed."""
present = [t for t in (assets_dir / f"{asset}.{ext}" for ext in _ASSET_EXTS) if t.is_file()]
for target in present:
target.unlink()
return len(present)
return len([t.unlink() for t in present])
@_profile_handler("profiles.set_asset", 5065)
def _(rid, params: dict) -> dict:
"""Store ``assets/<asset>.<ext>`` atomically. Params: ``name``, ``asset`` (``"avatar"`` only),
``data`` (data URL or base64; PNG/JPEG/WebP ≤2MB) or ``clear: true``."""
``data`` (data URL or base64; PNG/JPEG/WebP ≤2MB, format sniffed) or ``clear: true``."""
asset = str(params.get("asset") or "avatar").strip().lower()
if not str(params.get("name") or "").strip():
return _err(rid, 4063, "name required")
@@ -631,8 +572,7 @@ def _(rid, params: dict) -> dict:
return err
assets_dir = profile_dir / "assets"
if is_truthy_value(params.get("clear", False)):
removed = _unlink_asset_files(assets_dir, asset)
return _ok(rid, {"ok": True, "asset": asset, "size": 0, "removed": removed})
return _ok(rid, {"ok": True, "asset": asset, "size": 0, "removed": _unlink_asset_files(assets_dir, asset)})
data = str(params.get("data") or "")
if not data:
return _err(rid, 4067, "data required (data URL or base64)")
@@ -643,15 +583,14 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4068, "data is not valid base64")
if len(blob) > 2_000_000:
return _err(rid, 4069, f"asset too large ({len(blob)} bytes; max 2MB)")
ext = _sniff_asset_ext(blob)
ext = next((e for e, magic in _ASSET_MAGIC.items() if all(blob[a:b] == m for a, b, m in magic)), None)
if ext is None:
return _err(rid, 4070, "unsupported image format (PNG/JPEG/WebP only)")
assets_dir.mkdir(parents=True, exist_ok=True)
_unlink_asset_files(assets_dir, asset) # one canonical file per asset
target = assets_dir / f"{asset}.{ext}"
tmp = target.with_suffix(target.suffix + ".tmp")
tmp = assets_dir / f"{asset}.{ext}.tmp"
tmp.write_bytes(blob)
tmp.replace(target)
tmp.replace(assets_dir / f"{asset}.{ext}")
return _ok(rid, {"ok": True, "asset": asset, "size": len(blob)})
@@ -667,8 +606,8 @@ def _(rid, params: dict) -> dict:
target = profile_dir / "assets" / f"{asset}.{ext}"
if target.is_file():
blob = target.read_bytes()
data = f"data:{mime};base64,{base64.b64encode(blob).decode('ascii')}"
return _ok(rid, {"found": True, "mime": mime, "size": len(blob), "data": data})
return _ok(rid, {"found": True, "mime": mime, "size": len(blob),
"data": f"data:{mime};base64,{base64.b64encode(blob).decode('ascii')}"})
return _ok(rid, {"found": False})
+62 -100
View File
@@ -1,8 +1,5 @@
"""Projects RPC surface: per-profile multi-folder workspaces, repo discovery, sidebar tree.
Bodies are rebound onto server.py's globals at install time (see
method_ctx.bind_module), so they reference server.py globals bare.
"""
Bodies are rebound onto server.py's globals at install (method_ctx.bind_module)."""
from __future__ import annotations
@@ -12,10 +9,8 @@ _registry = HandlerRegistry()
method = _registry.method
# JSON-RPC error codes for the projects surface.
_E_PROJECTS = 5061 # generic failure
_E_NO_PROJECT = 5062 # id resolved to nothing
_E_PROJECT_ARG = 5063 # invalid argument (e.g. bad name/slug)
# JSON-RPC error codes: generic failure / id resolved to nothing / invalid argument.
_E_PROJECTS, _E_NO_PROJECT, _E_PROJECT_ARG = 5061, 5062, 5063
class _NoProject(Exception):
@@ -30,13 +25,8 @@ def _projects_payload(conn) -> dict:
def _projects_method(name: str):
"""Register a projects RPC, injecting (pdb, conn) and unifying error mapping.
Binds ``params['profile']`` (via ``@_profile_scoped``) so app-global remote
mode reads that profile's ``projects.db``. Missing id maps to 5062, bad args
to 5063, everything else to 5061.
"""
"""Register a projects RPC, injecting (pdb, conn) and unifying error mapping; profile-scoped
so app-global remote mode reads that profile's ``projects.db``."""
def decorator(fn):
@method(name)
@_registry.profile_scoped
@@ -67,20 +57,9 @@ def _pick(params: dict, *keys: str) -> dict:
return {k: params.get(k) for k in keys}
# Per-project mutators: (rpc suffix, pdb function, takes params['path'], extra kwargs).
# Each resolves ``params['id']`` (5062 when missing), mutates, and answers with the
# refreshed project.
_PROJECT_MUTATORS = (
("update", "update_project", False,
lambda p: _pick(p, "name", "description", "icon", "color", "board_slug")),
("add_folder", "add_folder", True,
lambda p: {"label": p.get("label"), "is_primary": bool(p.get("is_primary"))}),
("remove_folder", "remove_folder", True, lambda p: {}),
("set_primary", "set_primary", True, lambda p: {}),
)
def _register_project_mutator(suffix: str, fn_name: str, takes_path: bool, kwargs_of) -> None:
"""``projects.<suffix>``: resolve ``params['id']`` (5062 when missing), call
``pdb.<fn_name>(conn, id[, path], **kwargs_of(params))``, answer with the refreshed project."""
@_projects_method(f"projects.{suffix}")
def _(rid, params, pdb, conn) -> dict:
proj = _require_project(pdb, conn, params)
@@ -89,9 +68,14 @@ def _register_project_mutator(suffix: str, fn_name: str, takes_path: bool, kwarg
return _ok(rid, {"project": pdb.get_project(conn, proj.id).to_dict()})
for _spec in _PROJECT_MUTATORS:
_register_project_mutator(*_spec)
del _spec
_register_project_mutator(
"update", "update_project", False,
lambda p: _pick(p, "name", "description", "icon", "color", "board_slug"))
_register_project_mutator(
"add_folder", "add_folder", True,
lambda p: {"label": p.get("label"), "is_primary": bool(p.get("is_primary"))})
_register_project_mutator("remove_folder", "remove_folder", True, lambda p: {})
_register_project_mutator("set_primary", "set_primary", True, lambda p: {})
@_projects_method("projects.list")
@@ -136,25 +120,26 @@ def _(rid, params, pdb, conn) -> dict:
@_projects_method("projects.for_cwd")
def _(rid, params, pdb, conn) -> dict:
cwd = _completion_cwd({"cwd": str(params.get("cwd") or "").strip()} if params.get("cwd") else {})
cwd = _completion_cwd(
{"cwd": str(params.get("cwd") or "").strip()} if params.get("cwd") else {})
proj = pdb.project_for_path(conn, cwd)
return _ok(rid, {"project": proj.to_dict() if proj else None, "cwd": cwd, "branch": _git_branch_for_cwd(cwd)})
return _ok(rid, {
"project": proj.to_dict() if proj else None, "cwd": cwd,
"branch": _git_branch_for_cwd(cwd)})
def _non_workspace_dirs() -> set[str]:
"""Never-a-workspace dirs: ``/``, the user's home, and the dir homes live in. Both
POSIX spellings are excluded on every host (macOS ships an empty ``/home`` autofs
stub; containers/remote shells hand back Linux paths) — promoting one mints a
catch-all project and ``/home`` renders as a second "home" row beside Home."""
"""Never-a-workspace dirs: ``/``, the user's home, the dir homes live in, plus both POSIX
spellings on every host (remote shells hand back Linux paths; promoting one mints a
catch-all project)."""
home = os.path.realpath(os.path.expanduser("~"))
candidates = (os.sep, home, os.path.dirname(home), "/home", "/Users")
return {os.path.normcase(os.path.realpath(path)) for path in candidates if path}
def _is_repo_junk(root: str) -> bool:
"""A git root never auto-surfaced as a project: a non-workspace dir or anything
under HERMES_HOME (config/sessions/skills). User-created projects pointing
there are still honored."""
"""A git root never auto-surfaced as a project: a non-workspace dir or anything under
HERMES_HOME. User-created projects pointing there are still honored."""
if not root:
return True
from hermes_constants import get_hermes_home
@@ -167,9 +152,8 @@ def _is_repo_junk(root: str) -> bool:
def _is_session_cwd_junk(cwd: str) -> bool:
"""A non-git cwd that stays in flat Recents rather than auto-grouping. Unlike git
roots, a selected DESCENDANT of HERMES_HOME may be an intentional prose/data
workspace, so only HERMES_HOME itself and ``_non_workspace_dirs`` are excluded."""
"""A non-git cwd that stays in flat Recents. A DESCENDANT of HERMES_HOME may be an
intentional prose/data workspace, so only HERMES_HOME itself is excluded here."""
if not cwd:
return True
from hermes_constants import get_hermes_home
@@ -194,25 +178,19 @@ def _repo_discovery_policy(raw: dict | None = None) -> dict:
if not isinstance(values, list):
return list(defaults[long])
return [v.strip() for v in values if isinstance(v, str) and v.strip()]
enabled = _get("enabled", "repo_scan_enabled")
return {
"enabled": enabled if isinstance(enabled, bool) else defaults["repo_scan_enabled"],
"roots": _paths("roots", "repo_scan_roots"),
"exclude_paths": _paths("exclude_paths", "repo_scan_exclude_paths"),
}
"exclude_paths": _paths("exclude_paths", "repo_scan_exclude_paths")}
def _repo_discovery_policy_key(policy: dict) -> str:
def _paths(values: list[str]) -> list[str]:
normalized = set()
home = os.path.expanduser("~")
for value in values:
expanded = os.path.expanduser(value)
if not os.path.isabs(expanded):
expanded = os.path.join(home, expanded)
normalized.add(os.path.normcase(os.path.abspath(expanded)))
return sorted(normalized)
return sorted({
os.path.normcase(os.path.abspath(os.path.join(home, os.path.expanduser(v))))
for v in values})
canonical = {
"enabled": bool(policy["enabled"]), "roots": _paths(policy["roots"]),
"exclude_paths": _paths(policy["exclude_paths"])}
@@ -226,11 +204,10 @@ def _repo_discovery_policy_is_default(policy: dict) -> bool:
def _scan_discovered_repos_remote(conn, policy: dict) -> bool:
"""Backend-side disk scan of the policy roots into the discovery cache (the desktop's
native scan only sees the local filesystem). Best-effort: failures log and leave
the cache untouched. Returns True only when the scan is authoritative (every root
walked to completion, cap not hit) — only then is the cache write ``replace=True``;
a partial/errored scan must MERGE, never wipe, or a failed refresh blanks the sidebar."""
"""Backend-side disk scan of the policy roots into the discovery cache. Best-effort:
failures log and leave the cache untouched. True only when the scan is authoritative
(every root walked to completion, cap not hit) — only then is the cache write
``replace=True``; a partial/errored scan must MERGE, or a failed refresh blanks the sidebar."""
from hermes_cli import projects_db as pdb
roots = policy.get("roots") or []
excludes = policy.get("exclude_paths") or []
@@ -239,11 +216,11 @@ def _scan_discovered_repos_remote(conn, policy: dict) -> bool:
authoritative = True
def _is_excluded(path: str) -> bool:
return any(path == ex or path.startswith(ex.rstrip("/\\") + os.sep) for ex in excludes if ex)
return any(
path == ex or path.startswith(ex.rstrip("/\\") + os.sep) for ex in excludes if ex)
for root in roots:
if not os.path.isdir(root):
# `os.walk` on a missing root yields nothing instead of raising; an unmounted
# volume would look like an empty scan and let the replace wipe its cache.
# `os.walk` on a missing root yields nothing; an unmounted volume must not wipe.
authoritative = False
logger.debug("discover_repos scan root missing, skipping: %s", root)
continue
@@ -264,8 +241,7 @@ def _scan_discovered_repos_remote(conn, policy: dict) -> bool:
except Exception:
authoritative = False
logger.debug("discover_repos scan failed for root %s", root, exc_info=True)
if len(pairs) >= 500:
# Cap hit: the walk didn't cover the full roots -> not authoritative.
if len(pairs) >= 500: # cap hit: the walk didn't cover the full roots
authoritative = False
break
if pairs:
@@ -280,18 +256,16 @@ def _scan_discovered_repos_remote(conn, policy: dict) -> bool:
def _discover_repos_payload(
db, *, conn=None, backfill: bool = True, include_cached: bool = True) -> list[dict]:
"""Merge filesystem-scanned repos (cached; may have zero sessions) with
session-derived roots, junk-filtered, with session totals. ``conn`` reuses an open
projects.db connection; ``backfill`` persists resolved roots onto session rows —
kept OFF the per-turn tree path and done only on explicit refresh."""
"""Merge cached filesystem-scanned repos with session-derived roots, junk-filtered, with
session totals. ``backfill`` persists resolved roots onto session rows — kept OFF the
per-turn tree path and done only on explicit refresh."""
repos: dict[str, dict] = {}
def _agg(root: str) -> dict:
return repos.setdefault(root, {"root": root, "label": "", "sessions": 0, "last_active": 0.0})
return repos.setdefault(
root, {"root": root, "label": "", "sessions": 0, "last_active": 0.0})
cwd_rows = list(db.distinct_session_cwds())
# Warm the per-cwd git probes in parallel so a cold first paint doesn't
# serialize one subprocess per distinct cwd before this loop reads the cache.
# Parallel-warm the per-cwd git probes so a cold first paint doesn't serialize them.
git_probe.warm_roots(str(r.get("cwd") or "") for r in cwd_rows)
cwd_to_root: dict[str, str] = {}
for row in cwd_rows:
@@ -311,8 +285,7 @@ def _discover_repos_payload(
except Exception:
logger.debug("failed to backfill repo roots", exc_info=True)
if include_cached:
# `last_seen` is scan time, not user activity — never fold it into
# `last_active` (made every scanned repo "just now").
# `last_seen` is scan time, not user activity — never fold it into `last_active`.
try:
from hermes_cli import projects_db as pdb
with (contextlib.nullcontext(conn) if conn is not None else pdb.connect_closing()) as c:
@@ -330,27 +303,23 @@ def _discover_repos_payload(
return out
# Not user conversations (cron has its own section; kanban runs are read on
# the board). Subagent/compression children are dropped by include_children=False.
# Not user conversations; subagent/compression children are dropped by include_children=False.
_PROJECT_TREE_EXCLUDED_SOURCES = ["cron", "kanban"]
def _project_tree_row(r: dict) -> dict:
"""Project a SessionDB row to the minimal shape the sidebar renders: the
grouping fields (cwd/git_branch/git_repo_root) + everything ``SidebarSessionRow``
reads (parent_session_id for the └─ connector, cost for Show → cost), minus the
heavy columns."""
"""Project a SessionDB row to the minimal shape the sidebar renders (grouping fields +
what ``SidebarSessionRow`` reads), minus the heavy columns."""
row = {k: r.get(k) for k in (
"id", "_lineage_root_id", "_lineage_ids", "parent_session_id", "title", "preview")}
row.update(
started_at=r.get("started_at") or 0, ended_at=r.get("ended_at"),
last_active=r.get("last_active") or r.get("started_at") or 0,
source=r.get("source"), archived=bool(r.get("archived")))
row.update({k: r.get(k) or 0 for k in (
"message_count", "tool_call_count", "input_tokens", "output_tokens")})
row.update({k: r.get(k) for k in ("actual_cost_usd", "estimated_cost_usd", "model")})
row["is_active"] = False
row.update({k: r.get(k) for k in ("cwd", "git_branch", "git_repo_root")})
source=r.get("source"), archived=bool(r.get("archived")),
**{k: r.get(k) or 0 for k in (
"message_count", "tool_call_count", "input_tokens", "output_tokens")},
**{k: r.get(k) for k in ("actual_cost_usd", "estimated_cost_usd", "model")},
is_active=False, **{k: r.get(k) for k in ("cwd", "git_branch", "git_repo_root")})
return row
@@ -358,17 +327,15 @@ def _project_tree_inputs(
db, session_limit: int, *, include_discovered: bool
) -> tuple[list[dict], list[dict], list[dict], str | None]:
"""Gather (sessions, projects, discovered_repos, active_id) for build_tree.
``include_discovered`` is the zero-session-repo overview tier; drill-in skips it,
avoiding the distinct-cwd scan + git probes on that per-turn path."""
# compact_rows: `_project_tree_row` drops the system-prompt blob; selecting it
# only to discard it costs tens of MB of B-tree reads per build on a big DB.
``include_discovered`` is the zero-session-repo overview tier; drill-in skips it (and
the distinct-cwd scan + git probes) on that per-turn path."""
# compact_rows: selecting the system-prompt blob only to drop it costs tens of MB of reads.
rows = db.list_sessions_rich(
limit=session_limit, offset=0, order_by_last_active=True, min_message_count=1,
include_children=False, exclude_sources=_PROJECT_TREE_EXCLUDED_SOURCES,
include_archived=False, compact_rows=True)
sessions = [_project_tree_row(r) for r in rows]
# Parallel-warm the git cache so build_tree's resolver reads it instead of
# cold-probing each cwd in sequence (matters on the drill-in path).
# Parallel-warm the git cache so build_tree's resolver doesn't cold-probe each cwd in turn.
git_probe.warm_roots(s["cwd"] for s in sessions if s.get("cwd"))
from hermes_cli import projects_db as pdb
policy = _repo_discovery_policy()
@@ -387,19 +354,15 @@ def _project_tree_inputs(
return sessions, projects, discovered, active_id
# Per-build memo for `_dir_exists_cached`; cleared at the top of every
# `_build_project_tree` so a dir created/deleted between refreshes is seen.
# Per-build memo for `_dir_exists_cached`; cleared by every `_build_project_tree`.
_DIR_EXISTS_CACHE: dict[str, bool] = {}
def _dir_exists_cached(path: str) -> bool:
"""``os.path.isdir`` memoized per build — ``build_tree`` asks per SESSION, not
per distinct path, so hundreds of sessions in a few dirs would otherwise fire
hundreds of redundant stats per sidebar open."""
"""``os.path.isdir`` memoized per build — ``build_tree`` asks per SESSION, not per path."""
hit = _DIR_EXISTS_CACHE.get(path)
if hit is None:
hit = os.path.isdir(path)
_DIR_EXISTS_CACHE[path] = hit
hit = _DIR_EXISTS_CACHE[path] = os.path.isdir(path)
return hit
@@ -411,8 +374,7 @@ def _build_project_tree(
_DIR_EXISTS_CACHE.clear()
sessions, projects, discovered, active_id = _project_tree_inputs(
db, session_limit, include_discovered=include_discovered)
# build_tree also resolves every declared project folder and discovered repo
# root — not session cwds, so warm them too or they probe git one at a time.
# build_tree also resolves declared project folders and discovered roots — warm them too.
git_probe.warm_roots(
[str(f.get("path") or "") for p in projects for f in (p.get("folders") or [])]
+ [str(r.get("root") or "") for r in discovered])
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+99 -164
View File
@@ -1,4 +1,4 @@
"""slash.exec helpers: live-session command output and side-effect mirroring after a slash command ran in the worker.
"""slash.exec helpers: live-session command output + side-effect mirroring after a worker slash command.
Bodies are rebound onto server.py's globals at install time (see
method_ctx.bind_module), so they reference server.py globals bare.
@@ -6,7 +6,6 @@ method_ctx.bind_module), so they reference server.py globals bare.
from __future__ import annotations
import contextlib
from .method_ctx import HandlerRegistry, bind_module
@@ -16,9 +15,6 @@ _registry = HandlerRegistry()
# ── Live-session slash output ────────────────────────────────────────
_LIVE_SESSION_DIRECT_COMMANDS = frozenset(
{"clear", "compress", "effort", "history", "models", "prompt", "rename", "review", "status", "usage"}
)
# Answered from the live session ONLY when the agent lives on a compute host.
_ISOLATED_SESSION_READ_COMMANDS = frozenset({"context", "tools", "help"})
@@ -26,27 +22,21 @@ _NO_AGENT_USAGE = "(._.) No active agent -- send a message first."
_NO_AGENT = "No active agent -- send a message first."
def _format_live_review_output(session: Optional[dict], arg: str) -> str:
"""Dispatch /review against the live session's agent.
The reviewer subagent runs on the async delegation rail; the TUI notification
poller drains its completion back into this chat. The dispatch stamps the
parent's durable session_id as the completion's session_key, which is what
``_session_owns_notification_event`` matches against.
"""
def _format_live_review_output(sid: str, session: Optional[dict], arg: str) -> str:
"""Dispatch /review against the live session's agent. The reviewer runs on the async
delegation rail; its completion is stamped with the parent's durable session_id, which
``_session_owns_notification_event`` matches to drain it back into this chat."""
if session is None:
return "Nothing to review yet — send a message first."
if _session_uses_compute_host(session):
return "/review runs on the local agent only for now — this session's agent lives on a remote compute host."
agent = session.get("agent")
if agent is None:
if (agent := session.get("agent")) is None:
return "Nothing to review yet — send a message first."
if session.get("running"):
return "session busy — wait for the current turn to finish, then /review"
with session.get("history_lock") or contextlib.nullcontext():
snapshot = list(session.get("history", []))
if not snapshot:
snapshot = list(getattr(agent, "_session_messages", None) or [])
snapshot = snapshot or list(getattr(agent, "_session_messages", None) or [])
try:
from agent.review_engine import format_dispatch_note, start_review
result = start_review(agent, snapshot, arg or "")
@@ -57,7 +47,7 @@ def _format_live_review_output(session: Optional[dict], arg: str) -> str:
return format_dispatch_note(result, arg or "")
def _format_live_usage_output(session: dict) -> str:
def _format_live_usage_output(sid: str, session: dict, arg: str) -> str:
agent = session.get("agent")
usage = _session_usage_snapshot(session)
if agent is None and not usage:
@@ -70,25 +60,18 @@ def _format_live_usage_output(session: dict) -> str:
def n(key: str) -> str:
return f"{int(usage.get(key) or 0):,}"
lines = [
"Session Token Usage",
"────────────────────────────────────────",
f"Model: {usage.get('model') or _metadata_mirror(session).get('model') or getattr(agent, 'model', '') or '(unknown)'}",
f"Input tokens: {n('input')}",
f"Output tokens: {n('output')}"]
rows = [("Input tokens:", n("input")), ("Output tokens:", n("output"))]
if int(usage.get("reasoning") or 0):
lines.append(f"Reasoning tokens: {n('reasoning')}")
lines += [
f"Prompt tokens: {n('prompt')}",
f"Completion tokens: {n('completion')}",
f"Total tokens: {n('total')}",
f"API calls: {n('calls')}"]
rows.append(("Reasoning tokens:", n("reasoning")))
rows += [("Prompt tokens:", n("prompt")), ("Completion tokens:", n("completion")),
("Total tokens:", n("total")), ("API calls:", n("calls"))]
if usage.get("context_max"):
lines.append(
f"Current context: {n('context_used')} / {n('context_max')} "
f"({int(usage.get('context_percent') or 0)}%)")
lines += [f"Messages: {message_count:,}", f"Compressions: {n('compressions')}"]
return "\n".join(lines)
pct = int(usage.get("context_percent") or 0)
rows.append(("Current context:", f"{n('context_used')} / {n('context_max')} ({pct}%)"))
rows += [("Messages:", f"{message_count:,}"), ("Compressions:", n("compressions"))]
model = usage.get("model") or _metadata_mirror(session).get("model") or getattr(agent, "model", "") or "(unknown)"
lines = ["Session Token Usage", "────────────────────────────────────────", f"Model: {model}"]
return "\n".join(lines + [f"{label:<30}{value}" for label, value in rows])
def _live_session_messages(session: dict) -> Optional[list]:
@@ -97,15 +80,13 @@ def _live_session_messages(session: dict) -> Optional[list]:
profile's state.db, and through the launch handle this read comes back empty."""
with _session_db(session) as db:
if db is not None and session.get("session_key"):
try:
with contextlib.suppress(Exception):
return db.get_messages_as_conversation(
session["session_key"], include_ancestors=True, include_row_ids=True)
except Exception:
pass
return None
def _format_live_history_output(session: dict) -> str:
def _format_live_history_output(sid: str, session: dict, arg: str) -> str:
with session["history_lock"]:
history = list(session.get("history", []))
db_history = _live_session_messages(session)
@@ -115,30 +96,28 @@ def _format_live_history_output(session: dict) -> str:
lines = ["Conversation History", "────────────────────────────────────────"]
for idx, message in enumerate(messages, start=1):
role = str(message.get("role") or "unknown")
label = "You" if role == "user" else "Hermes" if role == "assistant" else role.title()
label = {"user": "You", "assistant": "Hermes"}.get(role, role.title())
text = str(message.get("text") or message.get("context") or "").strip()
if len(text) > 400:
text = f"{text[:400]}..."
text = f"{text[:400]}..." if len(text) > 400 else text
lines.append(f"[{label} #{idx}] {text or '(no text)'}")
return "\n".join(lines)
def _format_live_prompt_output(session: dict) -> str:
def _format_live_prompt_output(sid: str, session: dict, arg: str) -> str:
agent = session.get("agent")
mirror = _metadata_mirror(session)
if agent is None and "system_prompt" not in mirror:
return _NO_AGENT
prompt = (
mirror.get("system_prompt")
or getattr(agent, "ephemeral_system_prompt", None)
or getattr(agent, "_cached_system_prompt", None)
or "")
mirror.get("system_prompt") or getattr(agent, "ephemeral_system_prompt", None)
or getattr(agent, "_cached_system_prompt", None) or "")
if not prompt:
return "Current system prompt is not built yet; send a message first."
return f"Current system prompt:\n{prompt}"
def _format_live_context_output(session: dict) -> str:
def _format_live_context_output(sid: str, session: dict, arg: str) -> str:
from collections import Counter
try:
messages = _history_to_messages(_live_session_messages(session) or [])
except Exception:
@@ -149,23 +128,16 @@ def _format_live_context_output(session: dict) -> str:
usage = _session_usage_snapshot(session)
mirror = _metadata_mirror(session)
lines = [f"Conversation: {len(messages)} messages" if messages else "Conversation is empty (no messages yet)."]
roles: dict[str, int] = {}
for msg in messages:
role = str(msg.get("role") or "unknown")
roles[role] = roles.get(role, 0) + 1
lines.append(
f" user: {roles.get('user', 0)}, assistant: {roles.get('assistant', 0)}, "
f"tool: {roles.get('tool', 0)}, system: {roles.get('system', 0)}")
model = mirror.get("model") or usage.get("model") or ""
if model:
roles = Counter(str(msg.get("role") or "unknown") for msg in messages)
lines.append(" " + ", ".join(f"{r}: {roles.get(r, 0)}" for r in ("user", "assistant", "tool", "system")))
if model := mirror.get("model") or usage.get("model") or "":
lines.append(f"Model: {model}")
lines.append(f"Provider: {mirror.get('provider') or 'auto'}")
context_used = int(usage.get("context_used") or usage.get("total") or 0)
context_max = int(usage.get("context_max") or 0)
if context_used and context_max:
lines.append(
f"Context usage: ~{context_used:,} / {context_max:,} tokens ({(context_used / context_max) * 100:.1f}%)"
)
f"Context usage: ~{context_used:,} / {context_max:,} tokens ({(context_used / context_max) * 100:.1f}%)")
elif context_used:
lines.append(f"Context usage: ~{context_used:,} tokens")
if usage.get("compressions"):
@@ -173,7 +145,7 @@ def _format_live_context_output(session: dict) -> str:
return "\n".join(lines)
def _format_live_tools_output(session: dict) -> str:
def _format_live_tools_output(sid: str, session: dict, arg: str) -> str:
info = _session_info(session.get("agent"), session)
groups = info.get("tools") if isinstance(info, dict) else {}
if not isinstance(groups, dict) or not groups:
@@ -184,7 +156,7 @@ def _format_live_tools_output(session: dict) -> str:
return "Available tools ({}):\n{}".format(len(names), "\n".join(f" {name}" for name in names))
def _format_live_help_output() -> str:
def _format_live_help_output(sid: str, session: dict, arg: str) -> str:
try:
from hermes_cli.commands import COMMANDS_BY_CATEGORY
lines = ["Available commands:", ""]
@@ -200,34 +172,35 @@ def _format_live_model_output(session: dict) -> str:
agent = session.get("agent")
model = getattr(agent, "model", "") if agent is not None else ""
provider = getattr(agent, "provider", "") if agent is not None else ""
if model and provider:
return f"Current model: {model} ({provider})"
return f"Current model: {model}" if model else "Current model: (unknown)"
if not model:
return "Current model: (unknown)"
return f"Current model: {model}" + (f" ({provider})" if provider else "")
def _format_live_status_output(sid: str) -> str:
def _format_live_status_output(sid: str, session: dict, arg: str) -> str:
response = _methods["session.status"]("status", {"session_id": sid})
if response.get("error"):
return str(response["error"].get("message") or "status unavailable")
return str(response.get("result", {}).get("output") or "")
# name → (reply when there is no session, formatter(sid, session, arg)). A None
# no-session reply means the formatter handles a missing session itself.
# name → (reply when there is no session, formatter(sid, session, arg) or a fixed reply).
# A None no-session reply means the formatter handles a missing session itself.
_LIVE_SLASH_OUTPUT = {
"compress": ("no active session for /compress", lambda sid, s, a: _mirror_slash_side_effects(sid, s, f"/compress {a}".strip())),
"usage": (_NO_AGENT_USAGE, lambda sid, s, a: _format_live_usage_output(s)),
"review": (None, lambda sid, s, a: _format_live_review_output(s, a)),
"history": ("No conversation history yet.", lambda sid, s, a: _format_live_history_output(s)),
"prompt": (_NO_AGENT, lambda sid, s, a: _format_live_prompt_output(s)),
"status": (None, lambda sid, s, a: _format_live_status_output(sid)),
"context": ("Conversation is empty (no messages yet).", lambda sid, s, a: _format_live_context_output(s)),
"tools": ("No tools available.", lambda sid, s, a: _format_live_tools_output(s)),
"help": (None, lambda sid, s, a: _format_live_help_output()),
"clear": (None, lambda sid, s, a: "Screen clear is terminal-only; desktop/TUI chat left unchanged."),
"models": (None, lambda sid, s, a: "Use /model to view or switch the current model; desktop users can also open the model picker."),
"rename": (None, lambda sid, s, a: "Use /title <name> to rename this session."),
"effort": (None, lambda sid, s, a: "Use /reasoning <effort> to change reasoning effort.")}
"compress": ("no active session for /compress",
lambda sid, session, arg: _mirror_slash_side_effects(sid, session, f"/compress {arg}".strip())),
"usage": (_NO_AGENT_USAGE, _format_live_usage_output),
"review": (None, _format_live_review_output),
"history": ("No conversation history yet.", _format_live_history_output),
"prompt": (_NO_AGENT, _format_live_prompt_output),
"status": (None, _format_live_status_output),
"context": ("Conversation is empty (no messages yet).", _format_live_context_output),
"tools": ("No tools available.", _format_live_tools_output),
"help": (None, _format_live_help_output),
"clear": (None, "Screen clear is terminal-only; desktop/TUI chat left unchanged."),
"models": (None, "Use /model to view or switch the current model; desktop users can also open the model picker."),
"rename": (None, "Use /title <name> to rename this session."),
"effort": (None, "Use /reasoning <effort> to change reasoning effort.")}
def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg: str) -> Optional[str]:
@@ -236,10 +209,7 @@ def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg
arg = arg or ""
if name == "model" and not arg.strip():
return _format_live_model_output(session or {})
if name in _ISOLATED_SESSION_READ_COMMANDS:
if not (session is not None and _session_uses_compute_host(session)):
return None
elif name not in _LIVE_SESSION_DIRECT_COMMANDS:
if name in _ISOLATED_SESSION_READ_COMMANDS and not (session is not None and _session_uses_compute_host(session)):
return None
entry = _LIVE_SLASH_OUTPUT.get(name)
if entry is None:
@@ -247,7 +217,7 @@ def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg
no_session_reply, fmt = entry
if session is None and no_session_reply is not None:
return no_session_reply
return fmt(sid, session, arg)
return fmt(sid, session, arg) if callable(fmt) else fmt
# ── Side-effect mirroring ────────────────────────────────────────────
@@ -259,17 +229,11 @@ _MUTATES_WHILE_RUNNING = frozenset({"model", "personality", "prompt", "compress"
def _compress_live_with_feedback(sid: str, session: dict, agent, arg: str, *, snapshot_kwargs: bool) -> str:
"""Compress the live session; return the user-facing feedback text.
Shared by command.dispatch /compress and the slash mirror so every route shows
"compressed N → M messages / ~X → ~Y tokens". ``snapshot_kwargs`` forwards the
pre-read snapshot (approx_tokens/before_messages/history_version) to
``_compress_session_history``; the slash mirror passes only the raw arg. The raw
arg goes through unparsed — the choke point parses ``here [N]`` / ``--keep N``.
CompressionLockHeld is a clean no-op (its skip note is returned; the choke point
already discarded the deferred context-engine notification); other errors propagate
to the caller, which finalizes that notification.
"""
"""Compress the live session; return the user-facing feedback text (shared by command.dispatch
/compress and the slash mirror). ``snapshot_kwargs`` forwards the pre-read snapshot to
``_compress_session_history``; the raw arg goes through unparsed (the choke point parses
``here [N]`` / ``--keep N``). CompressionLockHeld is a clean no-op (skip note returned);
other errors propagate to the caller, which finalizes the context-engine notification."""
from agent.conversation_compression import finalize_context_engine_compression_notification
from agent.manual_compression_feedback import describe_compression_lock_skip, summarize_manual_compression
from agent.model_metadata import estimate_request_tokens_rough
@@ -278,14 +242,14 @@ def _compress_live_with_feedback(sid: str, session: dict, agent, arg: str, *, sn
history_version = int(session.get("history_version", 0))
sys_prompt = getattr(agent, "_cached_system_prompt", "") or ""
tools = getattr(agent, "tools", None) or None
before_tokens = (
estimate_request_tokens_rough(before_messages, system_prompt=sys_prompt, tools=tools) if before_messages else 0
)
def estimate(messages, prompt, tool_defs) -> int:
return estimate_request_tokens_rough(messages, system_prompt=prompt, tools=tool_defs) if messages else 0
before_tokens = estimate(before_messages, sys_prompt, tools)
snapshot = {"approx_tokens": before_tokens, "before_messages": before_messages, "history_version": history_version}
try:
if snapshot_kwargs:
_compress_session_history(
session, arg.strip() or None, approx_tokens=before_tokens, before_messages=before_messages,
history_version=history_version)
_compress_session_history(session, arg.strip() or None, **snapshot)
else:
_compress_session_history(session, arg)
except CompressionLockHeld as e:
@@ -293,11 +257,8 @@ def _compress_live_with_feedback(sid: str, session: dict, agent, arg: str, *, sn
_sync_session_key_after_compress(sid, session)
with session["history_lock"]:
after_messages = list(session.get("history", []))
after_tokens = (
estimate_request_tokens_rough(
after_messages, system_prompt=getattr(agent, "_cached_system_prompt", "") or sys_prompt,
tools=getattr(agent, "tools", None) or tools)
if after_messages else 0)
after_tokens = estimate(
after_messages, getattr(agent, "_cached_system_prompt", "") or sys_prompt, getattr(agent, "tools", None) or tools)
_emit("session.info", sid, _session_info(agent, session))
fb = summarize_manual_compression(
before_messages, after_messages, before_tokens, after_tokens,
@@ -306,95 +267,71 @@ def _compress_live_with_feedback(sid: str, session: dict, agent, arg: str, *, sn
return "\n".join(filter(None, [fb["headline"], fb["token_line"], fb.get("note")]))
def _mirror_model(sid, session, agent, arg) -> str:
if arg and agent:
return _apply_model_switch(sid, session, arg).get("warning", "")
return ""
def _mirror_approvals(sid, session, agent, arg) -> str:
# The worker already persisted approvals.mode; the bare read-only form needs no repaint.
if arg:
def _mirror_approvals(sid, session, agent, arg) -> None:
if arg: # the worker already persisted approvals.mode; the bare read-only form needs no repaint
broadcast_session_info()
return ""
def _mirror_personality(sid, session, agent, arg) -> str:
def _mirror_personality(sid, session, agent, arg) -> None:
if arg and agent:
pname, new_prompt = _validate_personality(arg, _load_cfg())
# Persist through the single owner so this surface never drifts from the others.
from hermes_cli.personality import persist_personality
from hermes_cli.personality import persist_personality # single owner: no surface drift
persist_personality(pname)
_apply_personality_to_session(sid, session, new_prompt, pname)
return ""
def _mirror_prompt(sid, session, agent, arg) -> str:
def _mirror_prompt(sid, session, agent, arg) -> None:
if agent:
cfg = _load_cfg()
agent.ephemeral_system_prompt = _prompt_text((cfg.get("agent") or {}).get("system_prompt", "")) or None
agent._cached_system_prompt = None
return ""
def _mirror_compress(sid, session, agent, arg) -> str:
return _compress_live_with_feedback(sid, session, agent, arg, snapshot_kwargs=False) if agent else ""
_FAST_TIERS = {"fast": "priority", "on": "priority", "normal": None, "off": None, "auto": "auto", "cold": "cold"}
def _mirror_fast(sid, session, agent, arg) -> str:
def _mirror_fast(sid, session, agent, arg) -> None:
if agent:
mode = arg.lower()
if mode in _FAST_TIERS:
agent.service_tier = _FAST_TIERS[mode]
if arg.lower() in _FAST_TIERS:
agent.service_tier = _FAST_TIERS[arg.lower()]
_emit("session.info", sid, _session_info(agent, session))
return ""
def _mirror_reload_mcp(sid, session, agent, arg) -> str:
def _mirror_reload_mcp(sid, session, agent, arg) -> None:
if agent and hasattr(agent, "reload_mcp_tools"):
agent.reload_mcp_tools()
return ""
def _mirror_stop(sid, session, agent, arg) -> str:
def _mirror_stop(sid, session, agent, arg) -> None:
from tools.process_registry import process_registry
process_registry.kill_all()
return ""
# name → mirror(sid, session, agent, arg); a falsy return means "no warning".
_SLASH_MIRRORS = {
"model": _mirror_model,
"approvals": _mirror_approvals,
"personality": _mirror_personality,
"prompt": _mirror_prompt,
"compress": _mirror_compress,
"model": lambda sid, session, agent, arg: (
_apply_model_switch(sid, session, arg).get("warning", "") if arg and agent else ""),
"approvals": _mirror_approvals, "personality": _mirror_personality, "prompt": _mirror_prompt,
"compress": lambda sid, session, agent, arg: (
_compress_live_with_feedback(sid, session, agent, arg, snapshot_kwargs=False) if agent else ""),
"fast": _mirror_fast,
"reload-mcp": _mirror_reload_mcp,
"stop": _mirror_stop}
"reload-mcp": _mirror_reload_mcp, "stop": _mirror_stop}
def _compute_host_slash(sid: str, session: dict, name: str, command: str) -> tuple[str, str]:
"""Forward a mutating slash command to the session's compute host.
Returns ``(status, text)``: ``pending`` (compress still running after the wait),
``failed`` (transport error/timeout), ``rejected`` (host control.error), ``ok``
(host output; metadata mirror already applied). Compress waits longer and installs
a late-ack adopter so a slow compression still lands in this session.
"""
"""Forward a mutating slash command to the session's compute host → ``(status, text)``:
``pending`` (compress still running after the wait), ``failed`` (transport error/timeout),
``rejected`` (host control.error), ``ok`` (host output; metadata mirror applied). Compress
waits longer and installs a late-ack adopter so a slow compression still lands here."""
route_name = f"slash.{name}"
is_compress = name == "compress"
_late_session = session
def _on_late_ack(late: dict, _sid=sid) -> None:
_adopt_late_compute_host_compress_ack(_sid, _late_session, late, route_name=route_name)
_adopt_late_compute_host_compress_ack(_sid, session, late, route_name=route_name)
try:
ack = _send_compute_host_control(
sid, route_name=route_name, command=command, wait=True,
**({"timeout": _compute_host_compress_wait_seconds(), "on_late_ack": _on_late_ack} if is_compress else {}),
)
**({"timeout": _compute_host_compress_wait_seconds(), "on_late_ack": _on_late_ack} if is_compress else {}))
except queue.Empty:
if is_compress:
return "pending", "compression still running in the background; the transcript will refresh when it finishes"
@@ -413,19 +350,17 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str:
if not parts:
return ""
name, arg, agent = parts[0], (parts[1].strip() if len(parts) > 1 else ""), session.get("agent")
if name == "compact":
# /compact aliases /compress everywhere; the compute-host control forwards the
# raw alias verbatim, so without this the child mirror silently no-ops.
if name == "compact": # /compact aliases /compress; the compute-host control forwards the raw alias
name = "compress"
if _session_uses_compute_host(session) and name in _MUTATES_WHILE_RUNNING:
return _compute_host_slash(sid, session, name, command)[1]
if name in _MUTATES_WHILE_RUNNING and session.get("running"):
return f"session busy — /interrupt the current turn before running /{name}"
mirror = _SLASH_MIRRORS.get(name)
if mirror is None:
if name in _MUTATES_WHILE_RUNNING:
if _session_uses_compute_host(session):
return _compute_host_slash(sid, session, name, command)[1]
if session.get("running"):
return f"session busy — /interrupt the current turn before running /{name}"
if (mirror := _SLASH_MIRRORS.get(name)) is None:
return ""
try:
return mirror(sid, session, agent, arg)
return mirror(sid, session, agent, arg) or ""
except Exception as e:
if name == "compress" and agent:
from agent.conversation_compression import finalize_context_engine_compression_notification
File diff suppressed because it is too large Load Diff
+151 -217
View File
@@ -1,6 +1,5 @@
"""Voice / TTS / wake-word JSON-RPC handlers and their process-global state (one mic, one
speaker per process). Bodies are rebound onto server.py's globals (method_ctx.bind_module)
and reference them bare.
"""Voice / TTS / wake-word JSON-RPC handlers and their process-global state (one mic, one speaker
per process). Bodies are rebound onto server.py's globals (method_ctx.bind_module), used bare.
"""
from __future__ import annotations
@@ -14,7 +13,8 @@ _registry = HandlerRegistry()
method = _registry.method
# ── Voice state ──────────────────────────────────────────────────────────
# ── Voice state: HERMES_VOICE / HERMES_VOICE_TTS are runtime-only env flags (never config.yaml)
# so a prior session can't auto-start REC.
_voice_sid_lock = threading.Lock()
_voice_event_sid: str = ""
@@ -41,19 +41,16 @@ def _resume_voice_wake() -> None:
def _voice_mode_enabled() -> bool:
"""Runtime-only flag (env, never config.yaml) so a prior session can't auto-start REC."""
return os.environ.get("HERMES_VOICE", "").strip() == "1"
def _voice_tts_enabled() -> bool:
"""Whether agent replies are spoken back via TTS (runtime only)."""
return os.environ.get("HERMES_VOICE_TTS", "").strip() == "1"
def _end_voice_chat(*, stop_loop: bool, stop_tts: bool) -> None:
"""Flip voice + TTS off; optionally halt the continuous loop / cut live TTS (best-effort)."""
os.environ["HERMES_VOICE"] = "0"
os.environ["HERMES_VOICE_TTS"] = "0"
os.environ["HERMES_VOICE"] = os.environ["HERMES_VOICE_TTS"] = "0"
if stop_loop:
with contextlib.suppress(Exception):
from hermes_cli.voice import stop_continuous
@@ -64,8 +61,8 @@ def _end_voice_chat(*, stop_loop: bool, stop_tts: bool) -> None:
def _tts_lease_async(lease: str, active: bool) -> None:
"""Acquire/release a TTS engine lease off the RPC thread: acquiring warms the provider
(local engines load a model) and must not block the toggle's reply. Best-effort."""
"""Acquire/release a TTS lease off the RPC thread (acquiring warms a local engine; must not
block the toggle's reply). Best-effort."""
def _run():
try:
from tools.tts_tool import acquire_tts_lease, release_tts_lease
@@ -75,17 +72,21 @@ def _tts_lease_async(lease: str, active: bool) -> None:
threading.Thread(target=_run, name=f"tts-lease-{lease}", daemon=True).start()
def _running_sessions() -> list:
with _sessions_lock:
return [s for s in _sessions.values() if s.get("running")]
def _any_session_running() -> bool:
"""Voice busy-probe: silent captures during a long turn don't count toward the no-speech limit."""
try:
with _sessions_lock:
return any(s.get("running") for s in _sessions.values())
return bool(_running_sessions())
except Exception:
return False
# ── Streaming TTS: one pipeline per process (one speaker); a new turn's pipeline barges in
# on the previous. Token deltas feed a sentence-buffering consumer (stream_tts_to_speaker).
# ── Streaming TTS: one pipeline per process (one speaker); a new turn's pipeline barges in on
# the previous. Token deltas feed a sentence-buffering consumer (stream_tts_to_speaker).
_tts_stream_lock = threading.Lock()
_tts_stream_state: Optional[dict] = None
@@ -104,8 +105,7 @@ def _tts_stream_begin() -> Optional[queue.Queue]:
_tts_stream_stop()
text_queue: queue.Queue = queue.Queue()
stop, done = threading.Event(), threading.Event()
threading.Thread(target=stream_tts_to_speaker, args=(text_queue, stop, done),
daemon=True).start()
threading.Thread(target=stream_tts_to_speaker, args=(text_queue, stop, done), daemon=True).start()
global _tts_stream_state
with _tts_stream_lock:
_tts_stream_state = {"stop": stop, "done": done}
@@ -115,17 +115,17 @@ def _tts_stream_begin() -> Optional[queue.Queue]:
def _tts_stream_stop(user_barge: bool = True) -> None:
"""Cut in-flight streaming TTS. *user_barge* latches the interruption for the next turn's
model note — pass ``False`` for mode changes (/voice off)."""
model note; ``False`` for mode changes (/voice off)."""
global _tts_stream_state
with _tts_stream_lock:
state, _tts_stream_state = _tts_stream_state, None
if state is None:
return
if user_barge and not state["done"].is_set():
import traceback as _tb
logger.debug("TTS CUT: _tts_stream_stop(user_barge=True) — new turn or "
"interrupt cutting in-flight TTS\n%s", "".join(_tb.format_stack()))
import traceback
from tools.tts_streaming import mark_speech_interrupted
logger.debug("TTS CUT: _tts_stream_stop(user_barge=True) — new turn or "
"interrupt cutting in-flight TTS\n%s", "".join(traceback.format_stack()))
mark_speech_interrupted()
state["stop"].set()
with contextlib.suppress(Exception):
@@ -134,13 +134,13 @@ def _tts_stream_stop(user_barge: bool = True) -> None:
# ── Full-duplex agent-turn listener: arms at utterance-submit, spans generation AND playback
# (per-playback monitors were deaf during generation and mis-calibrated against speaker
# bleed), disarms when no session runs, no TTS is pending, and no audio flows.
# (per-playback monitors were deaf during generation and mis-calibrated against speaker bleed),
# disarms when no session runs, no TTS is pending, and no audio flows. _fd_speak_pipelines holds
# (stop, done) pairs of fallback whole-reply speak paths: the listener cuts their private stop
# events too, and keeps listening while any is still speaking.
_fd_listener_lock = threading.Lock()
_fd_listener_active = False
# (stop, done) pairs of fallback whole-reply speak paths: the listener cuts their private stop
# events too, and keeps listening while any is still speaking.
_fd_speak_pipelines: "set[tuple[threading.Event, threading.Event]]" = set()
@@ -160,34 +160,25 @@ def _arm_barge_listener_if_enabled() -> None:
_arm_full_duplex_listener()
def _fd_speak_pipelines_snapshot() -> list:
with _fd_listener_lock:
return list(_fd_speak_pipelines)
def _fd_tts_pending() -> bool:
"""True while any TTS (streaming pipeline or fallback speak) is unfinished."""
with _tts_stream_lock:
state = _tts_stream_state
if state is not None and not state["done"].is_set():
return True
return any(not done.is_set() for _stop, done in _fd_speak_pipelines_snapshot())
with _fd_listener_lock:
pending = ([state["done"]] if state is not None else []) + [done for _stop, done in _fd_speak_pipelines]
return any(not done.is_set() for done in pending)
def _full_duplex_listener() -> None:
"""Mic live from utterance-submit to turn-complete; a trip (see ``_fd_trip``) transcribes
the utterance and emits it as ``voice.transcript``."""
"""Mic live from utterance-submit to turn-complete; a trip transcribes -> ``voice.transcript``."""
global _fd_listener_active
try:
from tools.voice_mode import (full_duplex_listen, is_audio_output_active,
transcribe_recording)
def _should_stop() -> bool:
if not _voice_mode_enabled():
return True
if _any_session_running() or _fd_tts_pending():
return False
return not is_audio_output_active()
return not _voice_mode_enabled() or not (
_any_session_running() or _fd_tts_pending() or is_audio_output_active())
tripped = threading.Event()
def _on_trigger(phase: str) -> None:
@@ -201,9 +192,8 @@ def _full_duplex_listener() -> None:
return
try:
result = transcribe_recording(wav_path)
text = (result.get("transcript") or "").strip() if result.get("success") else ""
if text:
_deliver_fd_transcript(text)
if result.get("success") and (result.get("transcript") or "").strip():
_deliver_fd_transcript(result["transcript"].strip())
finally:
with contextlib.suppress(OSError):
os.unlink(wav_path)
@@ -216,43 +206,36 @@ def _full_duplex_listener() -> None:
def _fd_barge_params(cfg: dict) -> tuple[float, int]:
"""``(threshold multiplier, grace ms)`` from the voice config; malformed -> defaults."""
try:
mult = float(cfg.get("barge_in_threshold_multiplier", 0) or 0)
except (TypeError, ValueError):
mult = 0.0
try:
grace_ms = int(float(cfg.get("barge_in_grace_seconds", 0.5)) * 1000)
except (TypeError, ValueError):
grace_ms = 500
return mult, max(0, grace_ms)
def _cut_all_tts() -> None:
"""Cut streaming TTS, every fallback speak pipeline, and the file player."""
from tools.voice_mode import stop_playback
_tts_stream_stop(user_barge=True)
for _stop, _done in _fd_speak_pipelines_snapshot():
_stop.set()
stop_playback()
def num(conv, key, default):
try:
return conv(cfg.get(key, default))
except (TypeError, ValueError):
return conv(default)
mult = num(lambda v: float(v or 0), "barge_in_threshold_multiplier", 0)
return mult, max(0, num(lambda v: int(float(v) * 1000), "barge_in_grace_seconds", 0.5))
def _fd_trip(phase: str) -> None:
"""Listener tripped: latch the interruption, cut TTS, and during generation also
interrupt every running turn (the ``agent.interrupt()`` seam ``session.interrupt`` uses)."""
"""Listener tripped: latch the interruption, cut TTS FIRST (so a stale reply can never
speak), and during generation also interrupt every running turn (the ``agent.interrupt()``
seam ``session.interrupt`` uses)."""
from tools.tts_streaming import mark_speech_interrupted
from tools.voice_mode import stop_playback
mark_speech_interrupted()
if phase == "playback":
logger.debug("TTS CUT: full-duplex listener tripped during playback")
_cut_all_tts()
else:
logger.debug("full-duplex listener tripped during generation — "
"interrupting running turn(s)")
# Cut pending TTS FIRST so the stale reply can never speak.
_cut_all_tts()
# Cut streaming TTS, every fallback speak pipeline, and the file player.
_tts_stream_stop(user_barge=True)
with _fd_listener_lock:
for _stop, _done in _fd_speak_pipelines:
_stop.set()
stop_playback()
if phase != "playback":
try:
with _sessions_lock:
running = [s for s in _sessions.values() if s.get("running")]
for s in running:
for s in _running_sessions():
agent = s.get("agent")
if agent is not None and hasattr(agent, "interrupt"):
with contextlib.suppress(Exception):
@@ -263,25 +246,20 @@ def _fd_trip(phase: str) -> None:
def _deliver_fd_transcript(text: str) -> None:
"""Emit the captured interjection; a bare stop phrase also ends the voice chat."""
# Stop-check must never break transcript delivery (stubbed voice_mode in tests,
# partial installs) — treat as not-a-stop.
"""Emit the captured interjection; a bare stop phrase also ends the voice chat. The stop
check must never break delivery (stubbed voice_mode in tests, partial installs)."""
try:
from tools.voice_mode import is_voice_stop_phrase
is_stop = is_voice_stop_phrase(text)
except Exception:
is_stop = False
if is_stop:
# Turn already interrupted / TTS cut at trip time; now end the chat.
if is_stop: # turn already interrupted / TTS cut at trip time; now end the chat
_end_voice_chat(stop_loop=True, stop_tts=False)
_voice_emit("voice.transcript", {"stop_phrase": True, "text": text})
else:
_voice_emit("voice.transcript", {"text": text})
_voice_emit("voice.transcript", {"stop_phrase": True, "text": text} if is_stop else {"text": text})
def _speak_text_with_barge(text: str) -> None:
"""Speak via hermes_cli.voice.speak_text, registered in ``_fd_speak_pipelines`` so the
full-duplex listener can cut it and keeps listening while it is pending."""
"""speak_text registered in ``_fd_speak_pipelines`` so the listener can cut it / waits for it."""
from hermes_cli.voice import speak_text
stop, done = threading.Event(), threading.Event()
with _fd_listener_lock:
@@ -290,8 +268,7 @@ def _speak_text_with_barge(text: str) -> None:
def _speak():
try:
speak_text(text, stop)
except TypeError:
# Older wrapper without the stop_event parameter.
except TypeError: # older wrapper without the stop_event parameter
speak_text(text)
finally:
done.set()
@@ -313,10 +290,12 @@ def _voice_cfg_number(value, default):
return value if isinstance(value, (int, float)) and not isinstance(value, bool) else default
def _voice_record_key() -> str:
"""Current ``voice.record_key`` value, documented default on error."""
def _voice_status_payload(**extra) -> dict:
"""``{enabled, record_key, tts, **extra}``: record_key (default ``ctrl+b``) rides every voice.toggle
branch so a tts toggle never resets a custom binding."""
record_key = _voice_cfg_dict().get("record_key")
return str(record_key) if isinstance(record_key, str) and record_key else "ctrl+b"
record_key = record_key if isinstance(record_key, str) and record_key else "ctrl+b"
return {"enabled": _voice_mode_enabled(), "record_key": record_key, "tts": _voice_tts_enabled(), **extra}
# ── Wake word ("Hey Hermes"): process-global detector (one mic). The first eligible transport
@@ -333,12 +312,6 @@ def _wake_owner_snapshot():
return _wake_owner_transport, _wake_owner_surface
def _set_wake_owner(transport, surface: str) -> None:
global _wake_owner_transport, _wake_owner_surface
with _wake_lock:
_wake_owner_transport, _wake_owner_surface = transport, surface
def _release_wake_for_transport(transport: "Transport") -> bool:
"""Release the wake lease iff ``transport`` is the current gateway owner."""
global _wake_owner_transport, _wake_owner_surface
@@ -365,11 +338,10 @@ _wake_resume_retry_active = False
def _wake_resume_if_owner(owner: "Transport", *, retry_seconds: float = 15.0,
retry_interval: float = 1.0) -> bool:
"""Resume the wake detector for ``owner``; self-heal a busy microphone. Reopening the mic
right after a voice turn can fail while the device is still being released (browser WebRTC
tracks release async): on an exception retry in a background thread until it sticks, the
lease changes hands, or ``retry_seconds`` elapses. ``False`` from ``resume_listening``
(lease gone / other owner) is final — never retried, so this can't steal another's mic."""
"""Resume the wake detector for ``owner``, self-healing a busy microphone: reopening right after
a voice turn can fail while the device is still being released (browser WebRTC tracks release
async), so an exception retries in a background thread until it sticks, the lease changes hands,
or ``retry_seconds`` elapses. ``False`` (lease gone / other owner) is final — never retried."""
from tools.wake_word import resume_listening
try:
return resume_listening(owner=owner)
@@ -387,14 +359,10 @@ def _wake_resume_if_owner(owner: "Transport", *, retry_seconds: float = 15.0,
try:
while time.monotonic() < deadline:
time.sleep(retry_interval)
try:
with contextlib.suppress(Exception):
if resume_listening(owner=owner):
logger.info("wake: detector resumed after retry")
return
except Exception:
continue
# False — detector gone or lease moved: stop, don't fight it.
return
return # False — detector gone or lease moved: stop, don't fight it.
logger.warning("wake: could not resume detector after voice turn "
"(microphone still busy?) — toggle the wake word to re-arm")
finally:
@@ -414,18 +382,20 @@ def _persist_wake_enabled(enabled: bool) -> bool:
return False
def _wake_prefers_client(params: dict, surface: str) -> bool:
"""Desktop (gui) prefers client capture (Mac mic → wake.feed PCM); CLI/TUI stay local."""
return surface in ("gui", "desktop") or bool(params.get("client_capture"))
def _owner_result(rid, field: str, ok, **extra) -> dict:
"""``{field: ok, reason: None | "not_owner", **extra}`` for the owner-gated wake RPCs."""
return _ok(rid, {field: ok, "reason": None if ok else "not_owner", **extra})
def _frame_fields(frame: dict) -> dict:
return {"sample_rate": frame.get("sample_rate", 16000), "frame_length": frame.get("frame_length", 1280)}
def _wake_probe(cfg: dict, prefer_client: bool) -> tuple[str, dict]:
"""``(capture_mode, requirements)``; capture stamped so the probe matches what would arm."""
def _wake_probe(cfg: dict, params: dict, surface: str) -> tuple[str, dict]:
"""``(capture_mode, requirements)``; capture stamped so the probe matches what would arm.
Desktop (gui) prefers client capture (Mac mic → wake.feed PCM); CLI/TUI stay local."""
from tools.wake_word import check_wake_word_requirements, resolve_capture_mode
prefer_client = surface in ("gui", "desktop") or bool(params.get("client_capture"))
capture_mode = resolve_capture_mode(cfg, prefer_client=prefer_client)
return capture_mode, check_wake_word_requirements({**cfg, "capture": capture_mode})
@@ -455,27 +425,30 @@ def _wake_detect_handler(transport, sid: str, phrase: str, new_session: bool):
@method("gateway.capabilities")
def _(rid, params: dict) -> dict:
"""Advertise what THIS BUILD enforces (a client withholds unless the guarantee is advertised).
Sourced from the enforcing module, never config: a believed-but-absent capability is worse."""
"""What THIS BUILD enforces (a client withholds unless advertised), sourced from the enforcing
module, never config: a believed-but-absent capability is worse."""
from hermes_cli.active_sessions import PER_SESSION_EXCLUSIVE_SUBMIT
return _ok(rid, {"per_session_exclusive_submit": bool(PER_SESSION_EXCLUSIVE_SUBMIT)})
@method("ping")
def _(rid, params: dict) -> dict:
"""Cheapest liveness probe, answered on the WS reader thread (works while every agent is
mid-turn) so the desktop can tell a half-open socket after sleep/wake and reconnect."""
"""Cheapest liveness probe, answered on the WS reader thread (works while every agent is mid-turn)
so the desktop can tell a half-open socket after sleep/wake."""
return _ok(rid, {"pong": True})
@method("wake.start")
def _(rid, params: dict) -> dict:
"""Arm the wake-word listener for the calling surface ("tui" | "gui"); ``{started: False,
reason}`` when disabled, owned by another surface, or deps/mic aren't ready. ``persist: true``
(explicit gesture) flips ``wake_word.enabled`` on before arming; auto-arm callers omit it."""
reason}`` when disabled/owned/not ready. ``persist: true`` (explicit gesture) also flips
``wake_word.enabled`` on; auto-arm callers omit it."""
global _wake_owner_transport, _wake_owner_surface
surface = str(params.get("surface") or "auto").strip().lower()
persist = bool(params.get("persist"))
transport = _caller_transport()
def refused(reason, **extra):
return _ok(rid, {"started": False, "reason": reason, **extra})
try:
from tools.wake_word import (
WakeWordInUse, detector_frame_info, load_wake_word_config, owns_listener,
@@ -483,16 +456,13 @@ def _(rid, params: dict) -> dict:
except Exception as e:
return _err(rid, 5026, f"wake module unavailable: {e}")
cfg = load_wake_word_config()
capture_mode, reqs = _wake_probe(cfg, _wake_prefers_client(params, surface))
external_audio = capture_mode == "client"
capture_mode, reqs = _wake_probe(cfg, params, surface)
# Requirements first: a gesture on an un-armable setup must refuse WITHOUT flipping
# wake_word.enabled — else config says on while nothing can arm.
if not reqs["available"]:
logger.warning("wake.start(%s): not available — %s", surface, reqs.get("hint"))
return _ok(rid, {
"started": False, "reason": "unavailable", "hint": reqs.get("hint") or "",
"capture": capture_mode})
enabled_persisted = bool(persist and not cfg.get("enabled") and _persist_wake_enabled(True))
return refused("unavailable", hint=reqs.get("hint") or "", capture=capture_mode)
enabled_persisted = bool(params.get("persist")) and not cfg.get("enabled") and _persist_wake_enabled(True)
if enabled_persisted:
cfg = {**cfg, "enabled": True}
if not wake_surface_enabled(surface, cfg):
@@ -501,31 +471,28 @@ def _(rid, params: dict) -> dict:
reason = "disabled" if not cfg.get("enabled") else "disabled_for_surface"
logger.info("wake.start(%s): %s (enabled=%s, surface=%s)",
surface, reason, cfg.get("enabled"), cfg.get("surface"))
return _ok(rid, {"started": False, "reason": reason})
return refused(reason)
existing_owner, existing_surface = _wake_owner_snapshot()
if existing_owner is not None and (
_transport_is_dead(existing_owner) or not owns_listener(existing_owner)
):
if existing_owner is not None and (_transport_is_dead(existing_owner) or not owns_listener(existing_owner)):
_release_wake_for_transport(existing_owner)
existing_owner, existing_surface = None, ""
if existing_owner is not None and existing_owner is not transport:
return _ok(rid, {"started": False, "reason": "owned", "owner_surface": existing_surface})
sid = str(params.get("session_id") or "")
return refused("owned", owner_surface=existing_surface)
try:
on_detect = _wake_detect_handler(
transport, sid, wake_phrase(cfg), bool(cfg.get("start_new_session", True)))
start_listening(on_detect, owner=transport, config=cfg, external_audio=external_audio)
on_detect = _wake_detect_handler(transport, str(params.get("session_id") or ""),
wake_phrase(cfg), bool(cfg.get("start_new_session", True)))
start_listening(on_detect, owner=transport, config=cfg,
external_audio=capture_mode == "client")
except WakeWordInUse:
return _ok(rid, {"started": False, "reason": "owned",
"owner_surface": existing_surface or None})
return refused("owned", owner_surface=existing_surface or None)
except Exception as e:
logger.warning("wake.start(%s): failed to start listener: %s", surface, e)
return _err(rid, 5026, str(e))
_set_wake_owner(transport, surface)
with _wake_lock:
_wake_owner_transport, _wake_owner_surface = transport, surface
frame = detector_frame_info()
logger.info(
"wake.start(%s): listening for %r (%s) capture=%s frame=%s",
surface, reqs["phrase"], reqs["provider"], capture_mode, frame.get("frame_length"))
logger.info("wake.start(%s): listening for %r (%s) capture=%s frame=%s",
surface, reqs["phrase"], reqs["provider"], capture_mode, frame.get("frame_length"))
return _ok(rid, {
"started": True, "phrase": reqs["phrase"], "provider": reqs["provider"],
"owner_surface": surface, "enabled_persisted": enabled_persisted, "capture": capture_mode,
@@ -535,34 +502,29 @@ def _(rid, params: dict) -> dict:
@method("wake.stop")
def _(rid, params: dict) -> dict:
"""Stop this surface's listener; ``persist: true`` also writes ``wake_word.enabled: false``."""
transport = _caller_transport()
stopped = _release_wake_for_transport(transport)
stopped = _release_wake_for_transport(_caller_transport())
disabled_persisted = False
if bool(params.get("persist")):
if params.get("persist"):
try:
from tools.wake_word import load_wake_word_config
currently_enabled = bool(load_wake_word_config().get("enabled"))
except Exception:
currently_enabled = True
if currently_enabled:
disabled_persisted = _persist_wake_enabled(False)
return _ok(rid, {
"stopped": stopped, "reason": None if stopped else "not_owner",
"disabled_persisted": disabled_persisted})
disabled_persisted = currently_enabled and _persist_wake_enabled(False)
return _owner_result(rid, "stopped", stopped, disabled_persisted=disabled_persisted)
@method("wake.pause")
def _(rid, params: dict) -> dict:
"""Release the mic (e.g. while the desktop's browser captures audio)."""
transport = _caller_transport()
try:
from tools.wake_word import pause_listening
paused = pause_listening(owner=transport)
paused = pause_listening(owner=_caller_transport())
logger.info("wake.pause: detector paused=%s", paused)
except Exception as e:
logger.debug("wake.pause failed: %s", e)
paused = False
return _ok(rid, {"paused": paused, "reason": None if paused else "not_owner"})
return _owner_result(rid, "paused", paused)
@method("wake.resume")
@@ -570,7 +532,7 @@ def _(rid, params: dict) -> dict:
"""Reclaim the mic after a pause; no-op if the listener isn't armed."""
resumed = _wake_resume_if_owner(_caller_transport())
logger.info("wake.resume: detector resumed=%s", resumed)
return _ok(rid, {"resumed": resumed, "reason": None if resumed else "not_owner"})
return _owner_result(rid, "resumed", resumed)
@method("wake.status")
@@ -580,11 +542,9 @@ def _(rid, params: dict) -> dict:
audio_is_silent, detector_frame_info, get_input_device_status, is_listening,
load_wake_word_config, owns_listener, silent_audio_hint)
cfg = load_wake_word_config()
surface = str(params.get("surface") or "").strip().lower()
probe_capture, reqs = _wake_probe(cfg, _wake_prefers_client(params, surface))
transport = _caller_transport()
probe_capture, reqs = _wake_probe(cfg, params, str(params.get("surface") or "").strip().lower())
owner, owner_surface = _wake_owner_snapshot()
owned_by_caller = owns_listener(transport)
owned_by_caller = owns_listener(_caller_transport())
listening = owned_by_caller and is_listening()
silent = listening and audio_is_silent()
input_device = get_input_device_status(cfg)
@@ -596,22 +556,19 @@ def _(rid, params: dict) -> dict:
# Effective capture: prefer the *armed* detector over config/auto, else with capture:auto
# a bare status probe reports "local" and the desktop never reattaches the PCM feeder.
frame = detector_frame_info()
if owned_by_caller and frame.get("external_audio"):
capture = "client"
elif owned_by_caller and listening:
capture = "local"
if owned_by_caller and (frame.get("external_audio") or listening):
capture = "client" if frame.get("external_audio") else "local"
else:
capture = probe_capture or reqs.get("capture") or str(cfg.get("capture") or "auto")
# `enabled` is config truth (clients re-arm after a voice turn from it); `audio_silent` =
# armed but deaf despite an open stream (see the platform-specific hint).
return _ok(rid, {
"listening": listening, "owned_by_caller": owned_by_caller,
"owner_surface": owner_surface if owner is not None else None,
"phrase": reqs["phrase"], "provider": reqs["provider"],
"configured_surface": str(cfg.get("surface") or "auto"),
"input_device": input_device, "available": reqs["available"], "hint": hint,
# Config truth: clients re-arm after a voice turn ("permanent on") from this.
"enabled": bool(cfg.get("enabled")),
# Armed but deaf despite an open stream; see platform-specific hint.
"audio_silent": silent, "capture": capture,
"enabled": bool(cfg.get("enabled")), "audio_silent": silent, "capture": capture,
"local_input_available": bool(reqs.get("local_input_available")), **_frame_fields(frame)})
except Exception as e:
return _err(rid, 5026, str(e))
@@ -620,44 +577,38 @@ def _(rid, params: dict) -> dict:
@method("wake.feed")
def _(rid, params: dict) -> dict:
"""Push client-captured PCM (``pcm``/``pcm_b64``: base64 int16 mono LE, 16 kHz only) into the
armed detector (``capture: "client"``) so mic-less remote backends can run openWakeWord."""
transport = _caller_transport()
armed detector (``capture: "client"``) — mic-less remote backends can run openWakeWord."""
raw_b64 = params.get("pcm") or params.get("pcm_b64") or ""
if not isinstance(raw_b64, str) or not raw_b64.strip():
return _err(rid, 4001, "wake.feed requires base64 pcm")
import base64
try:
import base64
pcm = base64.b64decode(raw_b64, validate=False)
except Exception as e:
return _err(rid, 4001, f"invalid base64 pcm: {e}")
if not pcm:
return _ok(rid, {"fed": False, "reason": "empty"})
# Soft size cap: 64000 bytes = 2s of 16 kHz int16 mono
if len(pcm) > 64000:
if len(pcm) > 64000: # soft cap: 2s of 16 kHz int16 mono
return _err(rid, 4001, "pcm frame too large")
sr = params.get("sample_rate")
if sr is not None and int(sr) not in (0, 16000):
if params.get("sample_rate") is not None and int(params["sample_rate"]) not in (0, 16000):
return _err(rid, 4001, "wake.feed only accepts 16 kHz PCM")
try:
from tools.wake_word import feed_audio
ok = feed_audio(owner=transport, pcm_int16=pcm)
ok = feed_audio(owner=_caller_transport(), pcm_int16=pcm)
except Exception as e:
logger.debug("wake.feed failed: %s", e)
return _err(rid, 5026, str(e))
return _ok(rid, {"fed": bool(ok), "reason": None if ok else "not_owner"})
return _owner_result(rid, "fed", bool(ok))
def _voice_toggle_status(rid, params: dict) -> dict:
# Mirrors CLI _show_voice_status: STT/TTS availability tells the user WHY voice isn't
# working; record_key lets the TUI bind and display the shortcut.
payload: dict = {"enabled": _voice_mode_enabled(), "record_key": _voice_record_key(),
"tts": _voice_tts_enabled()}
payload = _voice_status_payload()
try:
from tools.voice_mode import check_voice_requirements
reqs = check_voice_requirements()
payload.update(available=bool(reqs.get("available")),
audio_available=bool(reqs.get("audio_available")),
stt_available=bool(reqs.get("stt_available")),
payload.update({k: bool(reqs.get(k)) for k in ("available", "audio_available", "stt_available")},
details=reqs.get("details") or "")
except Exception as e:
# Optional transcription deps — /voice status must always answer.
@@ -667,16 +618,13 @@ def _voice_toggle_status(rid, params: dict) -> dict:
def _voice_toggle_mode(rid, params: dict) -> dict:
enabled = params.get("action") == "on"
# Runtime-only flag — never persisted, so the next launch starts with voice OFF.
os.environ["HERMES_VOICE"] = "1" if enabled else "0"
stop_hint = ""
if enabled:
# Spoken-stop hint for the client; sourced from voice.stop_phrases, empty when disabled.
try:
with contextlib.suppress(Exception):
from tools.voice_mode import voice_stop_hint
stop_hint = voice_stop_hint()
except Exception:
stop_hint = ""
# Speech output already on → warm the engine now, not on the first reply.
if _voice_tts_enabled():
_tts_lease_async("tui:voice-tts", True)
@@ -689,25 +637,23 @@ def _voice_toggle_mode(rid, params: dict) -> dict:
pass
except Exception as e:
logger.warning("voice: stop_continuous failed during toggle off: %s", e)
# Clear TTS so it can be toggled independently later; silence live speech.
os.environ["HERMES_VOICE_TTS"] = "0"
_set_voice_tts(False) # TTS is toggled independently later
return _ok(rid, _voice_status_payload(stop_hint=stop_hint))
def _set_voice_tts(on: bool) -> None:
"""Flip TTS; off silences live speech. The lease pre-loads the engine (on) / releases it (off)."""
os.environ["HERMES_VOICE_TTS"] = "1" if on else "0"
if not on:
_tts_stream_stop(user_barge=False)
_tts_lease_async("tui:voice-tts", False)
return _ok(rid, {"enabled": enabled, "record_key": _voice_record_key(),
"tts": _voice_tts_enabled(), "stop_hint": stop_hint})
_tts_lease_async("tui:voice-tts", on)
def _voice_toggle_tts(rid, params: dict) -> dict:
if not _voice_mode_enabled():
return _err(rid, 4014, "enable voice mode first: /voice on")
new_value = not _voice_tts_enabled()
os.environ["HERMES_VOICE_TTS"] = "1" if new_value else "0"
if not new_value:
_tts_stream_stop(user_barge=False)
# on → pre-load the engine so the first reply starts hot; off → release the lease.
_tts_lease_async("tui:voice-tts", new_value)
# record_key on every branch so a tts toggle never resets a custom binding.
return _ok(rid, {"enabled": True, "record_key": _voice_record_key(), "tts": new_value})
_set_voice_tts(not _voice_tts_enabled())
return _ok(rid, _voice_status_payload())
_VOICE_TOGGLE_ACTIONS = {
@@ -726,14 +672,10 @@ def _(rid, params: dict) -> dict:
return handler(rid, params)
# voice.record callbacks (module-level: they touch only process-global state).
def _vr_on_transcript(t):
_voice_emit("voice.transcript", {"text": t})
_resume_voice_wake()
def _vr_on_silent():
_voice_emit("voice.transcript", {"no_speech_limit": True})
# voice.record callbacks: each terminal capture event resumes the wake detector so wake-triggered
# and manual captures coexist.
def _vr_transcript(payload: dict) -> None:
_voice_emit("voice.transcript", payload)
_resume_voice_wake()
@@ -741,8 +683,7 @@ def _vr_on_stop_phrase(t):
# A SPOKEN bare stop phrase: end the chat like /voice off and emit a distinct signal so
# clients end the conversation instead of treating it as a no-speech timeout.
_end_voice_chat(stop_loop=False, stop_tts=True)
_voice_emit("voice.transcript", {"stop_phrase": True, "text": t})
_resume_voice_wake()
_vr_transcript({"stop_phrase": True, "text": t})
def _vr_on_status(state):
@@ -775,36 +716,32 @@ def _(rid, params: dict) -> dict:
_resume_voice_wake()
return _ok(rid, {"status": "stopped"})
from hermes_cli.voice import start_continuous
# Busy probe holds the no-speech counter during long agent turns.
# Safe to re-register every start; older wrappers lack the setter.
# Busy probe holds the no-speech counter during long agent turns; safe to re-register every
# start (older wrappers lack the setter).
with contextlib.suppress(Exception):
from hermes_cli.voice import set_voice_busy_probe
set_voice_busy_probe(_any_session_running)
# Shape-safe: malformed voice YAML falls back to documented defaults.
# max_recording_seconds: explicit numeric <= 0 disables the cap (0.0).
# Shape-safe: malformed voice YAML falls back to documented defaults; an explicit numeric
# max_recording_seconds <= 0 disables the cap (0.0).
voice_cfg = _voice_cfg_dict()
max_rec = _voice_cfg_number(voice_cfg.get("max_recording_seconds"), 120.0)
# Hand the mic to STT if the wake detector holds it; resume on a terminal
# capture event so wake-triggered and manual captures coexist.
try:
# Hand the mic to STT if the wake detector holds it; a terminal capture event resumes it.
with contextlib.suppress(Exception):
from tools.wake_word import pause_listening
wake_paused = pause_listening(owner=transport)
except Exception:
wake_paused = False
if wake_paused:
with _voice_sid_lock:
_voice_wake_owner = transport
started = start_continuous(
on_transcript=_vr_on_transcript, on_status=_vr_on_status, on_silent_limit=_vr_on_silent,
on_transcript=lambda t: _vr_transcript({"text": t}), on_status=_vr_on_status,
on_silent_limit=lambda: _vr_transcript({"no_speech_limit": True}),
silence_threshold=_voice_cfg_number(voice_cfg.get("silence_threshold"), 200),
silence_duration=_voice_cfg_number(voice_cfg.get("silence_duration"), 3.0),
auto_restart=False, max_recording_seconds=max_rec if max_rec > 0 else 0.0,
on_stop_phrase=_vr_on_stop_phrase,
)
on_stop_phrase=_vr_on_stop_phrase)
if started is False:
_resume_voice_wake()
return _ok(rid, {"status": "busy"})
return _ok(rid, {"status": "recording"})
return _ok(rid, {"status": "busy" if started is False else "recording"})
except Exception as e:
if wake_paused or action == "stop":
_resume_voice_wake()
@@ -819,12 +756,9 @@ def _(rid, params: dict) -> dict:
if not text:
return _err(rid, 4020, "text required")
try:
# Import check up front so a missing voice module returns 5026, not a silent thread death.
import hermes_cli.voice # noqa: F401
except ImportError:
return _err(rid, 5026, "voice module not available")
import hermes_cli.voice # noqa: F401 (a missing module must answer 5026, not die in a thread)
except Exception as e:
return _err(rid, 5026, str(e))
return _err(rid, 5026, "voice module not available" if isinstance(e, ImportError) else str(e))
threading.Thread(target=_speak_text_with_barge, args=(text,), daemon=True).start()
return _ok(rid, {"status": "speaking"})
+52 -98
View File
@@ -1,8 +1,6 @@
"""Model switching for a live session: persist, snapshot/restore runtime, /model apply with guards, bot-capability + config sync.
Bodies are rebound onto server.py's globals at install time (see
method_ctx.bind_module), so they reference server.py globals bare.
"""
"""Model switching for a live session: persist, snapshot/restore runtime, /model apply with
guards, bot-capability + config sync. Bodies are rebound onto server.py's globals at install
time (method_ctx.bind_module), so they reference server.py globals bare."""
from __future__ import annotations
@@ -17,7 +15,6 @@ 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`, ...).
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)
@@ -25,11 +22,13 @@ def _persist_model_switch(result) -> None:
save_config_value("model.base_url", result.base_url or None)
_RUNTIME_KEYS = ("model", "provider", "api_key", "base_url", "api_mode")
def _snapshot_agent_model_runtime(agent) -> dict:
"""Capture the current agent model runtime for a one-turn restore."""
snap = {k: getattr(agent, k, "") for k in ("model", "provider", "api_key", "base_url", "api_mode")}
snap["primary_runtime"] = copy.deepcopy(getattr(agent, "_primary_runtime", None))
return snap
return {**{k: getattr(agent, k, "") for k in _RUNTIME_KEYS},
"primary_runtime": copy.deepcopy(getattr(agent, "_primary_runtime", None))}
def _restore_agent_model_runtime(agent, snapshot: dict | None) -> None:
@@ -47,10 +46,10 @@ def _restore_agent_model_runtime(agent, snapshot: dict | None) -> None:
except Exception:
logger.debug("TUI one-turn model restore via primary runtime failed", exc_info=True)
if hasattr(agent, "switch_model"):
model, provider, api_key, base_url, api_mode = (snapshot.get(k, "") for k in _RUNTIME_KEYS)
agent.switch_model(
new_model=snapshot.get("model", ""), new_provider=snapshot.get("provider", ""),
api_key=snapshot.get("api_key", ""), base_url=snapshot.get("base_url", ""),
api_mode=snapshot.get("api_mode", ""), capabilities=snapshot.get("capabilities"))
new_model=model, new_provider=provider, api_key=api_key, base_url=base_url,
api_mode=api_mode, capabilities=snapshot.get("capabilities"))
@contextlib.contextmanager
@@ -65,7 +64,6 @@ def _session_profile_runtime_scope(session: dict):
# Same terminal policy the gateway binds per turn: a docker-configured profile
# must never resolve the launch process's pinned env. Failure → refusal scope.
from tools.terminal_scope import install_profile_terminal_scope, reset_terminal_scope
terminal_token = install_profile_terminal_scope(Path(profile_home))
try:
yield
@@ -79,17 +77,14 @@ def _restart_completed_failed_agent_build(sid: str, session: dict, failed_ready:
"""Replace one completed failed build generation and start its retry."""
if failed_ready is None:
return False
build_lock = session.setdefault("agent_build_lock", threading.Lock())
with build_lock:
if (
session.get("agent") is not None or session.get("agent_error") is None
or session.get("agent_ready") is not failed_ready or not failed_ready.is_set()):
with session.setdefault("agent_build_lock", threading.Lock()):
if (session.get("agent") is not None or session.get("agent_error") is None
or session.get("agent_ready") is not failed_ready or not failed_ready.is_set()):
return False
model_override = session.get("model_override")
resume_overrides = session.get("resume_runtime_overrides")
if isinstance(model_override, dict) and isinstance(resume_overrides, dict):
resume_overrides = dict(resume_overrides)
resume_overrides["model_override"] = model_override
resume_overrides = {**resume_overrides, "model_override": model_override}
if provider := model_override.get("provider"):
resume_overrides["provider_override"] = provider
else:
@@ -107,18 +102,11 @@ def _switch_request(raw_input: str, parsed_flags, persist_override) -> tuple[str
"""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,
)
resolve_persist_behavior)
if parsed_flags is None:
parsed_flags = parse_model_switch_args(raw_input)
if hasattr(parsed_flags, "model_input"):
model_input, explicit_provider = parsed_flags.model_input, parsed_flags.explicit_provider
is_global_flag, is_session = parsed_flags.is_global, parsed_flags.is_session
one_turn = parsed_flags.is_once
else:
model_input, explicit_provider, is_global_flag, _force_refresh, is_session = parsed_flags
one_turn = False
f = parse_model_switch_args(raw_input) if parsed_flags is None else parsed_flags
model_input, explicit_provider, is_global_flag, is_session, one_turn = (
f.model_input, f.explicit_provider, f.is_global, f.is_session, f.is_once)
# Conflict validation is the shared parser's; surface it with the canonical copy.
if is_global_flag and one_turn:
raise ValueError(MODEL_SWITCH_ERROR_TEXT[MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL])
@@ -134,13 +122,11 @@ def _current_model_runtime(agent, explicit_provider: str) -> tuple:
"""(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")
)
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, "", ""
from hermes_cli.runtime_provider import resolve_runtime_provider
runtime = resolve_runtime_provider(requested=None)
# Keep a callable api_key (Azure Entra bearer) unchanged: ``str()`` would
# yield "<function ...>" and poison switch_model validation.
@@ -151,30 +137,14 @@ def _current_model_runtime(agent, explicit_provider: str) -> tuple:
return provider, current_model, str(runtime.get("base_url", "") or ""), key
def _provider_context() -> tuple:
"""(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
cfg = load_config()
user_provs = cfg.get("providers")
custom_provs = get_compatible_custom_providers(cfg)
except Exception:
pass
return user_provs, custom_provs, cfg
def _merge_preflight_warning(result, agent, session: dict, cfg, custom_provs) -> None:
"""Fold the context-compression preflight warning into ``result`` (best-effort)."""
try:
from hermes_cli.context_switch_guard import merge_preflight_compression_warning
cfg_ctx = None
if isinstance(cfg, dict):
mc = cfg.get("model", {})
if isinstance(mc, dict) and mc.get("context_length") is not None:
cfg_ctx = int(mc["context_length"])
mc = cfg.get("model", {}) if isinstance(cfg, dict) else None
if isinstance(mc, dict) and mc.get("context_length") is not None:
cfg_ctx = int(mc["context_length"])
merge_preflight_compression_warning(
result, agent=agent, messages=list(session.get("history", [])),
custom_providers=custom_provs, config_context_length=cfg_ctx)
@@ -186,7 +156,6 @@ def _expensive_model_confirm(result, current_base_url: str, current_api_key) ->
"""Deferred-confirm response when the selection guards flag the target model, else None."""
try:
from hermes_cli.model_selection_guards import combined_selection_warning
warning = combined_selection_warning(
result.new_model, provider=result.target_provider, base_url=result.base_url or current_base_url,
api_key=result.api_key or current_api_key, model_info=result.model_info)
@@ -194,12 +163,9 @@ def _expensive_model_confirm(result, current_base_url: str, current_api_key) ->
warning = None
if warning is None:
return None
confirm_msg = warning.message
if result.warning_message:
confirm_msg = f"{confirm_msg}\n\n{result.warning_message}"
# Same contract as _set_model's deferred branch: confirm_message is
# canonical, warning is the legacy alias — keep identical.
return {"value": result.new_model, "warning": confirm_msg, "confirm_required": True, "confirm_message": confirm_msg}
msg = f"{warning.message}\n\n{result.warning_message}" if result.warning_message else warning.message
# Same contract as _set_model's deferred branch: confirm_message is canonical, warning legacy.
return {"value": result.new_model, "warning": msg, "confirm_required": True, "confirm_message": msg}
def _commit_agent_switch(sid: str, session: dict, agent, result, current_model: str, snapshot):
@@ -213,10 +179,8 @@ def _commit_agent_switch(sid: str, session: dict, agent, result, current_model:
# 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}); "
f"staying on {getattr(agent, 'model', current_model)}."
) from exc
raise ValueError(f"Model switch to {result.new_model} failed ({exc}); "
f"staying on {getattr(agent, 'model', current_model)}.") from exc
_restart_slash_worker(sid, session)
_persist_live_session_runtime(session)
_persist_live_session_system_prompt(session)
@@ -233,7 +197,6 @@ def _apply_model_switch(
pin_session_override: bool = True, parsed_flags: Any | None = None,
persist_override: bool | None = None) -> dict:
from hermes_cli.model_switch import switch_model
model_input, explicit_provider, one_turn, persist_global = _switch_request(
raw_input, parsed_flags, persist_override)
agent = session.get("agent")
@@ -243,12 +206,17 @@ def _apply_model_switch(
agent, explicit_provider)
# User-defined providers let switch_model resolve named custom endpoints
# (e.g. "ollama-launch") and validate against saved model lists.
user_provs, custom_provs, cfg = _provider_context()
user_provs = custom_provs = cfg = None
with contextlib.suppress(Exception):
from hermes_cli.config import get_compatible_custom_providers, load_config
cfg = load_config()
user_provs = cfg.get("providers")
custom_provs = get_compatible_custom_providers(cfg)
result = switch_model(
raw_input=model_input, current_provider=current_provider, current_model=current_model,
current_base_url=current_base_url, current_api_key=current_api_key, is_global=persist_global,
explicit_provider=explicit_provider, user_providers=user_provs, custom_providers=custom_provs,
)
explicit_provider=explicit_provider, user_providers=user_provs,
custom_providers=custom_provs)
if not result.success:
raise ValueError(result.error_message or "model switch failed")
restore_snapshot = _snapshot_agent_model_runtime(agent) if (one_turn and agent) else None
@@ -276,12 +244,10 @@ 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).
"""
"""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: fingerprint at turn start and on change swap in a fresh agent for
the SAME session (history is DB-backed)."""
agent = session.get("agent")
if agent is None:
return
@@ -293,7 +259,6 @@ def _sync_bot_capabilities(sid: str, session: dict) -> None:
if title != "Bot Chat":
return
from tools.bot_mode_probe import capability_fingerprint
current = capability_fingerprint(session.get("profile_home") or None)
if current == "unavailable":
return
@@ -303,29 +268,23 @@ def _sync_bot_capabilities(sid: str, session: dict) -> None:
return
except Exception:
return
try:
tokens = _set_session_context(sid, cwd=_session_cwd(session))
try:
new_agent = _make_agent(
sid, session["session_key"], session_id=session["session_key"], platform_override=_session_source(session)
)
new_agent = _make_agent(sid, session["session_key"], session_id=session["session_key"],
platform_override=_session_source(session))
finally:
_clear_session_context(tokens)
new_agent._session_title_hint = "Bot Chat"
session["agent"] = new_agent
session["config_model_seen"] = _config_model_target()
session.update(agent=new_agent, config_model_seen=_config_model_target())
_emit("notice", sid, {"message": "Capabilities updated — this bot's tools and prompt were refreshed."})
except Exception as e:
logger.warning("Bot capability sync failed for %s: %s", sid, e)
def _sync_agent_model_with_config(sid: str, session: dict) -> None:
"""Adopt a config.yaml model change at turn start (like gateways do per message).
Sessions pinned with /model keep their choice; a failed switch keeps the current
model and never blocks the turn.
"""
"""Adopt a config.yaml model change at turn start (like gateways do per message). Sessions
pinned with /model keep their choice; a failed switch keeps the current model."""
agent = session.get("agent")
if agent is None or session.get("model_override"):
return
@@ -335,36 +294,31 @@ def _sync_agent_model_with_config(sid: str, session: dict) -> None:
seen = session.get("config_model_seen")
# Record first so a broken config gets one attempt per edit, not per turn.
session["config_model_seen"] = target
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.
if model == getattr(agent, "model", "") and (not provider or provider == getattr(agent, "provider", "")):
if target == seen or (
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).
_apply_model_switch(
sid, session, raw, confirm_expensive_model=True, pin_session_override=False, persist_override=False
)
sid, session, raw, confirm_expensive_model=True, pin_session_override=False,
persist_override=False)
except Exception as e:
_emit("error", sid, {"message": f"Could not switch to configured model {model}: {e}"})
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, so this can only under-fire;
``_apply_model_switch`` is the backstop. Exceptions mean "no warning".
"""
"""Selection-guard message for a model queued mid-turn, or ``None``. Runs BEFORE the pick is
stashed (the client can still turn the response into a confirm prompt); only pre-resolution
inputs exist so it can only under-fire — ``_apply_model_switch`` is the backstop."""
if not model:
return None
try:
from hermes_cli.model_selection_guards import combined_selection_warning
warning = combined_selection_warning(model, provider=provider or None)
except Exception:
return None
+74 -137
View File
@@ -12,11 +12,10 @@ from __future__ import annotations
import re
from typing import Any, Callable, Optional
# cwd -> ``{"repo_root", "worktree_root"}`` (COMMON main root shared across worktrees /
# this cwd's own checkout root); ``None`` when not in git or unprobeable (remote backend).
# cwd -> ``{"repo_root", "worktree_root"}`` (COMMON main root / this cwd's checkout root);
# ``None`` when not in git or unprobeable (remote backend).
Resolve = Callable[[str], Optional[dict]]
# "does this directory still exist?"; defaults to always-True so callers that can't
# stat (remote backends) don't hide a project living on the other host.
# "does this directory still exist?"; always-True default keeps remote-host projects visible.
Exists = Callable[[str], bool]
# Only KANBAN-TASK worktrees (`<repo>/.worktrees/t_<hex>`, the id kanban_db mints)
@@ -25,23 +24,21 @@ _KANBAN_DIR_RE = re.compile(r"^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$")
_TRUNK_BRANCHES = {"main", "master", "trunk", "develop"}
DEFAULT_BRANCH_LABEL = "main"
# Synthetic bucket for every session no project claimed (no cwd, bare home, HERMES
# state, deleted workspace); the id/flag name what it MEANS since membership keys off them.
# Synthetic bucket for every session no project claimed (no cwd, bare home, deleted workspace).
NO_PROJECT_ID = "__no_project__"
NO_PROJECT_LABEL = "Home"
# Sibling probes when recovering a deleted worktree's parent repo; each miss is a git
# invocation and real suffixes are one or two segments.
# Sibling probes when recovering a deleted worktree's parent repo (each miss is a git call).
_MAX_SIBLING_PROBES = 4
def stamp_profile(projects: list[dict], profile: str) -> None:
"""Stamp every session row with the request-scope profile (authoritative even
for legacy rows whose ``profile_name`` is NULL) for cross-profile routing."""
"""Stamp every session row with the request-scope profile (authoritative even for legacy
rows whose ``profile_name`` is NULL) for cross-profile routing."""
for project in projects:
lanes = [g for repo in project.get("repos") or [] for g in repo.get("groups") or []]
lane_rows = [s for g in lanes for s in g.get("sessions") or []]
for session in (project.get("previewSessions") or []) + lane_rows:
for session in (project.get("previewSessions") or []) + [
s for g in lanes for s in g.get("sessions") or []]:
session["profile"] = profile
@@ -59,15 +56,14 @@ def _segments(path: str) -> list[str]:
def _is_windows_path(path: str) -> bool:
# Drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any backslash-rooted path
# (`\wsl.localhost\…`, `\Users\…`). A single leading `/` stays POSIX.
# Drive-letter, UNC (`\\srv`, `//srv`) or backslash-rooted; a single leading `/` stays POSIX.
value = (path or "").strip()
return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\", "//"))
def _comparison_segments(path: str) -> list[str]:
"""Segments for identity comparison: Windows paths casefold (even when running
on POSIX); display paths and emitted IDs keep their spelling."""
"""Segments for identity comparison: Windows paths casefold (even on POSIX); display
paths and emitted IDs keep their spelling."""
segs = _segments(path)
return [s.casefold() for s in segs] if _is_windows_path(path) else segs
@@ -78,8 +74,7 @@ def _path_key(path: str) -> str:
def _lane_key(path_or_lane: str) -> str:
"""Canonicalize only the path portion of a lane id; branch labels stay
byte-preserved so equivalent Windows spellings don't fork lanes."""
"""Canonicalize only the path portion of a lane id (branch labels stay byte-preserved)."""
marker = next((m for m in ("::branch::", "::kanban") if m in path_or_lane), None)
if marker is None:
return _path_key(path_or_lane)
@@ -98,25 +93,15 @@ def kanban_worktree_dir(path: str) -> Optional[str]:
return m.group(1) if m else None
def _with_base_name(path: str, name: str) -> str:
return re.sub(r"[^/\\]+$", name, (path or "").rstrip("/\\"))
def _parent_dir(path: str) -> str:
"""The containing directory of ``path`` (``""`` once the root is passed)."""
return _with_base_name(path, "").rstrip("/\\")
def _with_base_name(path: str, name: str = "") -> str:
"""Swap the last segment for ``name`` (``""`` -> the parent dir, ``""`` past the root)."""
return re.sub(r"[^/\\]+$", name, (path or "").rstrip("/\\")).rstrip("" if name else "/\\")
def _field(row: dict, key: str) -> str:
return (row.get(key) or "").strip()
def _branch_label(branch: str) -> str:
# An unrecorded branch folds into the one trunk lane so a repo never shows two
# "main" lanes (recorded "main" + the empty-branch bucket).
return (branch or "").strip() or DEFAULT_BRANCH_LABEL
def _session_time(session: dict) -> float:
return float(session.get("last_active") or session.get("started_at") or 0)
@@ -131,12 +116,12 @@ def _placement(
return {
"repo_key": repo_root, "repo_label": base_name(repo_root) or repo_root,
"lane_key": lane_key, "lane_label": lane_label, "lane_path": lane_path,
"is_main": is_main, "is_kanban": is_kanban,
}
"is_main": is_main, "is_kanban": is_kanban}
def _trunk_placement(repo_root: str, branch: str) -> dict:
b = _branch_label(branch)
# An unrecorded branch folds into the trunk lane so a repo never shows two "main" lanes.
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
return _placement(repo_root, _branch_lane_id(repo_root, b), b, repo_root, True, False)
@@ -145,13 +130,9 @@ def _kanban_placement(repo_root: str, kanban_dir: str) -> dict:
def _probe_sibling_worktree(cwd: str, resolve: Resolve) -> str:
"""The parent repo root of a deleted ``<repo>-<suffix>`` worktree, else ``""``.
A deleted dir can't be probed, so trim one ``-<segment>`` at a time off its name
and return the first sibling that resolves. The cwd is often a SUBDIR of the dead
worktree (``<repo>-<suffix>/apps/desktop``), so the trim runs on each ANCESTOR,
deepest first. Probes are bounded in total (each is a git invocation).
"""
"""The parent repo root of a deleted ``<repo>-<suffix>`` worktree, else ``""``: trim one
``-<segment>`` at a time off each ancestor's name (the cwd is often a SUBDIR of the dead
worktree), deepest first, returning the first sibling that resolves; probes are bounded."""
probes = 0
path = (cwd or "").rstrip("/\\")
while path and probes < _MAX_SIBLING_PROBES:
@@ -163,7 +144,7 @@ def _probe_sibling_worktree(cwd: str, resolve: Resolve) -> str:
info = resolve(_with_base_name(path, "-".join(parts[:i])))
if info and info.get("repo_root"):
return (info["repo_root"] or "").strip()
path = _parent_dir(path)
path = _with_base_name(path)
return ""
@@ -174,14 +155,15 @@ def _place_by_heuristic(path: str) -> Optional[dict]:
return None
kanban_dir = kanban_worktree_dir(path)
if kanban_dir:
return _kanban_placement(_parent_dir(kanban_dir), kanban_dir)
return _kanban_placement(_with_base_name(kanban_dir), kanban_dir)
m = re.match(r"^(.+)-wt-(.+)$", base)
if m:
return _placement(_with_base_name(path, m.group(1)), path, m.group(2), path, False, False)
return _placement(path, _branch_lane_id(path, DEFAULT_BRANCH_LABEL), base, path, True, False)
def _place(cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: str) -> Optional[dict]:
def _place(
cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: str) -> Optional[dict]:
info = resolve(cwd) if resolve else None
if info and info.get("repo_root") and info.get("worktree_root"):
repo_root, worktree_root = info["repo_root"], info["worktree_root"]
@@ -193,16 +175,14 @@ def _place(cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: st
label = base_name(worktree_root) or worktree_root
return _placement(repo_root, worktree_root, label, worktree_root, False, False)
# No live probe: trust the backend-persisted root (split main by the recorded
# branch). Kanban tasks still collapse by path shape.
# No live probe: trust the persisted root; kanban tasks still collapse by path shape.
if persisted_root:
kanban_dir = kanban_worktree_dir(cwd)
if kanban_dir:
return _kanban_placement(persisted_root, kanban_dir)
return _trunk_placement(persisted_root, branch)
# Unresolvable cwd: a deleted ``<repo>-<suffix>`` worktree still belongs to its
# parent; absorb it into the trunk lane rather than stranding a dead-path lane.
# Unresolvable cwd: a deleted ``<repo>-<suffix>`` worktree still belongs to its parent.
sibling_root = _probe_sibling_worktree(cwd, resolve) if resolve else ""
if sibling_root:
return _trunk_placement(sibling_root, branch)
@@ -228,8 +208,7 @@ def _session_repo_root(session: dict, resolve: Optional[Resolve]) -> str:
def _lane_sort_key(group: dict) -> tuple:
# Trunk pins to the top; the kanban aggregate sinks to the bottom; the rest
# (branches + linked worktrees) sort by most-recent activity, then label.
# Trunk pins to the top, the kanban aggregate to the bottom; the rest by recency, then label.
is_trunk = bool(group.get("isMain")) and group["label"].lower() in _TRUNK_BRANCHES
return (0 if is_trunk else 1, 1 if group.get("isKanban") else 0,
-_last_active(group.get("sessions") or []), group["label"].lower())
@@ -240,7 +219,6 @@ def _disambiguate_labels(items: list[dict]) -> None:
by_label: dict[str, list[dict]] = {}
for item in items:
by_label.setdefault(item["label"], []).append(item)
for bucket in by_label.values():
pathed = [g for g in bucket if g.get("path")]
if len(pathed) < 2:
@@ -277,7 +255,6 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool
group["sessions"] = []
lanes[lane_identity] = (group, placement)
lanes[lane_identity][0]["sessions"].append(session)
repos: dict[str, dict] = {}
for group, placement in lanes.values():
group["sessions"].sort(key=_session_time, reverse=True)
@@ -285,13 +262,11 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool
repo = repos.setdefault(_path_key(repo_key), _repo_node(repo_key, placement["repo_label"]))
repo["groups"].append(group)
repo["sessionCount"] += len(group["sessions"])
repo_list = list(repos.values())
for repo in repo_list:
repo["groups"] = sorted(repo["groups"], key=_lane_sort_key)
_disambiguate_labels(repo["groups"])
# Drop per-lane rows only AFTER sorting: _lane_sort_key derives recency
# from them. Counts were captured above, so the overview payload stays slim.
# Drop per-lane rows only AFTER sorting (_lane_sort_key reads them); counts stay.
if not hydrate:
for group in repo["groups"]:
group["sessions"] = []
@@ -299,11 +274,10 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool
return repo_list
def _seed_folder_repos(repos: list[dict], folders: list[dict], resolve: Optional[Resolve]) -> list[dict]:
"""Ensure every declared project folder shows as a repo, even with 0 sessions:
otherwise the desktop's entered-project view renders blank and the optimistic
live-session overlay has no lane for a fresh session until a full refresh.
Folders already covered by a session-derived repo (same git root) are untouched."""
def _seed_folder_repos(
repos: list[dict], folders: list[dict], resolve: Optional[Resolve]) -> list[dict]:
"""Ensure every declared project folder shows as a repo, even with 0 sessions (else the
entered-project view renders blank); folders covered by a session-derived repo are untouched."""
seen = {_path_key(v) for repo in repos for v in (repo.get("id"), repo.get("path")) if v}
seeded = list(repos)
for folder in folders or []:
@@ -323,8 +297,7 @@ def _seed_folder_repos(repos: list[dict], folders: list[dict], resolve: Optional
class _FolderIndex:
"""Normalized folder path -> (owning project, depth): a session is matched by
walking its cwd's ancestors instead of scanning every project x folder."""
"""Normalized folder path -> (owning project, depth); matched by walking cwd ancestors."""
def __init__(self, projects: list[dict]) -> None:
self._by_path: dict[str, tuple[dict, int]] = {}
@@ -345,7 +318,8 @@ class _FolderIndex:
return None, -1
def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[Resolve]) -> Optional[dict]:
def _project_for_session(
session: dict, index: _FolderIndex, resolve: Optional[Resolve]) -> Optional[dict]:
cwd = _field(session, "cwd")
if not cwd:
return None
@@ -355,73 +329,49 @@ def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[R
return max((index.match(t) for t in candidates), key=lambda hit: hit[1])[0]
def _session_cost(session: dict) -> float:
"""A session's spend, billed if the provider reported it, else estimated."""
for key in ("actual_cost_usd", "estimated_cost_usd"):
if session.get(key):
return float(session[key])
return 0.0
def _project_node(
pid: str, label: str, path: Optional[str], repos: list[dict], session_count: int,
last_active: float, preview_sessions: list[dict], sessions: Optional[list[dict]] = None,
**flags: Any,
) -> dict:
"""``flags`` overrides ``color`` / ``icon`` / ``isAuto`` / ``isNoProject`` (key order is
fixed by the defaults below — the renderer's wire shape)."""
**flags: Any) -> dict:
"""``flags`` overrides ``color``/``icon``/``isAuto``/``isNoProject``; key order = wire shape."""
rows = sessions or []
node = {
"id": pid, "label": label, "path": path, "color": None, "icon": None,
"isAuto": False, "isNoProject": False,
"sessionCount": session_count, "lastActive": last_active,
# Totals over the same sessions `sessionCount` counts, so a project header
# adds up to what its rows show.
"totalTokens": sum((s.get("input_tokens") or 0) + (s.get("output_tokens") or 0) for s in sessions or []),
"totalCostUsd": sum(_session_cost(s) for s in sessions or []),
"repos": repos, "previewSessions": preview_sessions,
}
# Totals over the same sessions `sessionCount` counts (billed cost, else estimated).
"totalTokens": sum(
(s.get("input_tokens") or 0) + (s.get("output_tokens") or 0) for s in rows),
"totalCostUsd": sum(
float(s.get("actual_cost_usd") or s.get("estimated_cost_usd") or 0) for s in rows),
"repos": repos, "previewSessions": preview_sessions}
node.update(flags)
return node
def _auto_buckets(
unowned: list[dict], resolve: Optional[Resolve], junk: Callable, junk_cwd: Callable,
exists: Callable,
) -> tuple[dict[str, dict], list[dict]]:
"""Group leftover sessions by auto-project root; the rest go to the Home bucket.
Prefer the common git root, then the session cwd for non-git workspaces (the
pre-Projects desktop grouped every cwd; dropping that flattens them into Recents)."""
exists: Callable) -> tuple[dict[str, dict], list[dict]]:
"""Group leftover sessions by auto-project root (common git root, else the session cwd
for non-git workspaces); the rest go to the Home bucket."""
by_auto_root: dict[str, dict] = {}
homeless: list[dict] = []
def _add_auto(root: str, session: dict) -> None:
key = _path_key(root)
if not key:
homeless.append(session)
return
by_auto_root.setdefault(key, {"root": root, "sessions": []})["sessions"].append(session)
for session in unowned:
root = _session_repo_root(session, resolve)
if root:
# A real git root uses the stricter repo policy; never reinterpret a
# filtered internal repo as a cwd-only project. A root no longer on
# disk is a stale persisted value and must not resurrect as a project.
if not junk(root) and exists(root):
_add_auto(root, session)
else:
homeless.append(session)
continue
cwd = _field(session, "cwd")
if not cwd or junk_cwd(cwd):
homeless.append(session)
continue
placement = _place_session(session, resolve)
# A placement that only echoes back an unresolvable cwd is the path-only
# heuristic guessing. If that dir is also gone from disk, promoting it
# mints a phantom project that can only be dismissed by hand -> Home.
if placement and exists(placement["repo_key"]):
_add_auto(placement["repo_key"], session)
# Stricter repo policy for real git roots; a root gone from disk is stale and
# must not resurrect as a project (never reinterpret it as a cwd-only project).
if junk(root) or not exists(root):
root = ""
elif (cwd := _field(session, "cwd")) and not junk_cwd(cwd):
# A path-only heuristic placement whose dir is gone from disk would mint a phantom
# project that can only be dismissed by hand -> Home.
placement = _place_session(session, resolve)
if placement and exists(placement["repo_key"]):
root = placement["repo_key"]
key = _path_key(root) if root else ""
if key:
by_auto_root.setdefault(key, {"root": root, "sessions": []})["sessions"].append(session)
else:
homeless.append(session)
return by_auto_root, homeless
@@ -431,35 +381,26 @@ def _home_project(homeless: list[dict], hydrate: bool, previews: list[dict]) ->
"""The synthetic Home bucket: no folder => no repo/lane structure, one lane carries the rows."""
lane = {
"id": NO_PROJECT_ID, "label": NO_PROJECT_LABEL, "path": None, "isMain": False,
"isKanban": False, "sessions": homeless if hydrate else [],
}
"isKanban": False, "sessions": homeless if hydrate else []}
home_repo = {
"id": NO_PROJECT_ID, "label": NO_PROJECT_LABEL, "path": None, "groups": [lane],
"sessionCount": len(homeless),
}
"sessionCount": len(homeless)}
return _project_node(
NO_PROJECT_ID, NO_PROJECT_LABEL, None, [home_repo], len(homeless), _last_active(homeless),
previews, homeless, isNoProject=True)
def build_tree(
projects: list[dict],
sessions: list[dict],
discovered_repos: list[dict],
resolve: Optional[Resolve] = None,
*,
preview_limit: int = 3,
hydrate: bool = False,
projects: list[dict], sessions: list[dict], discovered_repos: list[dict],
resolve: Optional[Resolve] = None, *, preview_limit: int = 3, hydrate: bool = False,
is_junk_root: Optional[Callable[[str], bool]] = None,
is_junk_cwd: Optional[Callable[[str], bool]] = None,
exists: Optional[Exists] = None) -> dict:
is_junk_cwd: Optional[Callable[[str], bool]] = None, exists: Optional[Exists] = None) -> dict:
"""Build the authoritative project tree -> ``{"projects", "scoped_session_ids"}``.
``is_junk_root`` flags git roots that must never become an AUTO project (bare home,
HERMES_HOME); ``is_junk_cwd`` is the narrower policy for non-git folders; explicit
projects are honored regardless. ``exists`` keeps a DELETED workspace from becoming
a phantom AUTO project (omit on remote backends). ``hydrate`` False (overview) empties
lane ``sessions`` but keeps counts + ``preview_limit`` ``previewSessions``.
``is_junk_root`` flags git roots that must never become an AUTO project; ``is_junk_cwd``
is the narrower non-git policy (explicit projects are honored regardless); ``exists``
keeps a DELETED workspace from becoming a phantom AUTO project (omit on remote backends).
``hydrate`` False empties lane ``sessions`` but keeps counts + ``previewSessions``.
"""
active_projects = [p for p in projects if not p.get("archived")]
_junk = is_junk_root or (lambda _root: False)
@@ -482,7 +423,6 @@ def build_tree(
def _scope(project_sessions: list[dict]) -> None:
scoped_ids.extend(s["id"] for s in project_sessions if s.get("id"))
# Tier 1: explicit, user-created projects (always shown, even with 0 sessions).
for project in active_projects:
psessions = by_project.get(project["id"], [])
@@ -513,8 +453,7 @@ def build_tree(
repo_node["sessionCount"], _last_active(auto_sessions), _previews(auto_sessions),
auto_sessions, isAuto=True))
# Tier 3: repos discovered from full history / disk scan with no loaded
# sessions, folded to their common root and not owned by an explicit project.
# Tier 3: discovered repos with no loaded sessions, folded to their common root.
for repo in discovered_repos or []:
raw_root = _field(repo, "root")
if not raw_root:
@@ -530,12 +469,10 @@ def build_tree(
root, label, root, [_repo_node(root, label)], int(repo.get("sessions") or 0),
float(repo.get("last_active") or 0), [], isAuto=True))
# Auto projects are labelled by repo basename, which can collide; grow path
# prefixes so each is distinct. Explicit projects keep their user-chosen names.
# Auto-project basename labels can collide; explicit projects keep their user-chosen names.
_disambiguate_labels([p for p in result if p.get("isAuto")])
# Tier 0: everything above could not place, so the grouped view loses no
# session. Leads the list; omitted entirely when empty.
# Tier 0: whatever the tiers above could not place. Leads the list; omitted when empty.
if homeless:
homeless.sort(key=_session_time, reverse=True)
_scope(homeless)
+47 -85
View File
@@ -28,20 +28,17 @@ del _re # bodies are rebound onto server globals: import inside functions only
def _b64_payload(raw: str, data_url_re: str, flags: int) -> bytes:
"""Strip an optional ``data:...;base64,`` wrapper and all whitespace, then
strictly decode (raises ``binascii.Error``/``ValueError`` on bad base64)."""
"""Strip an optional ``data:...;base64,`` wrapper and all whitespace, then strictly decode."""
import base64 as _base64
import re as _re
cleaned = (raw or "").strip()
m = _re.match(data_url_re, cleaned, flags)
if m:
if m := _re.match(data_url_re, cleaned, flags):
cleaned = m.group(1)
return _base64.b64decode(_re.sub(r"\s+", "", cleaned), validate=True)
def _decode_attach_base64(raw: str, *, mime_prefix: str) -> bytes | None:
"""Decode a base64 payload, optionally ``data:<mime_prefix>...;base64,``-wrapped,
tolerating embedded whitespace. ``None`` when not valid base64."""
"""Decode a (``data:<mime_prefix>...;base64,``-wrapped) payload; None when invalid."""
import re as _re
try:
return _b64_payload(
@@ -52,8 +49,7 @@ def _decode_attach_base64(raw: str, *, mime_prefix: str) -> bytes | None:
def _decode_attach_payload(
rid, raw_b64: str, *, mime_prefix: str, max_bytes: int, label: str, empty_msg: str):
"""``(bytes, None)`` or ``(None, error)`` for an upload: 4017 on bad/empty
base64, 4018 over *max_bytes*."""
"""``(bytes, None)`` or ``(None, error)``: 4017 on bad/empty base64, 4018 over *max_bytes*."""
data = _decode_attach_base64(raw_b64, mime_prefix=mime_prefix)
if data is None:
return None, _err(rid, 4017, "data is not valid base64")
@@ -66,8 +62,7 @@ def _decode_attach_payload(
def _sniff_image_ext(img_bytes: bytes, filename: str = "") -> str:
"""Extension from the filename hint, else magic bytes (WebP needs the RIFF/WEBP
container check), else ``.png``."""
"""Extension from the filename hint, else magic bytes (WebP: RIFF container), else ``.png``."""
if filename and (suffix := Path(filename).suffix.lower()):
return suffix
head = img_bytes[:16]
@@ -85,26 +80,20 @@ def _allowed_image_extensions() -> frozenset[str]:
def _session_home_dir(session: dict, name: str) -> Path:
"""``<session home>/<name>``, anchored on the session's stored ``profile_home``.
Attach RPCs run BEFORE ``prompt.submit`` installs the profile HERMES_HOME
override, so ``get_hermes_home()`` would return the gateway's launch home —
while the sandbox mounts and the vision host-read allowlist resolve the
*session profile's* dirs at run time. Writing anywhere else means the agent
can never see the file.
"""
"""``<session home>/<name>``, anchored on the session's stored ``profile_home``: attach
RPCs run BEFORE ``prompt.submit`` installs the profile HERMES_HOME override, while
the sandbox mounts and the vision host-read allowlist resolve the *session profile's*
dirs at run time — writing anywhere else means the agent can never see the file."""
profile_home = session.get("profile_home")
return (Path(profile_home) if profile_home else _hermes_home) / name
def _session_images_dir(session: dict) -> Path:
"""Uploads ``images/`` dir for the session (see ``_session_home_dir``)."""
return _session_home_dir(session, "images")
def _queue_attached_image(session: dict, img_bytes: bytes, ext: str, *, prefix: str) -> Path:
"""Write image bytes into the session images dir and append to
``session["attached_images"]`` so the next ``prompt.submit`` picks them up."""
"""Write image bytes into the session images dir and queue them for the next submit."""
session["image_counter"] = session.get("image_counter", 0) + 1
img_dir = _session_images_dir(session)
img_dir.mkdir(parents=True, exist_ok=True)
@@ -120,8 +109,7 @@ def _queue_attached_image(session: dict, img_bytes: bytes, ext: str, *, prefix:
def _format_ref_value(value: str) -> str:
"""Quote a context-ref value containing whitespace/brackets/quotes so the staged
``@file:`` ref round-trips through ``agent.context_references``."""
"""Quote a value with whitespace/brackets/quotes so the ``@file:`` ref round-trips."""
if not value or not _ATTACHMENT_REF_NEEDS_QUOTING_RE.search(value):
return value
for q in ("`", '"', "'"):
@@ -139,74 +127,33 @@ def _attachment_ref_path(session: dict, target: Path) -> str:
return str(target.resolve())
def _desktop_attachment_dir(session: dict) -> Path:
"""File-attachment staging dir (``attachments/``, see ``_session_home_dir``);
registered in ``tools.credential_files._CACHE_DIRS`` and auto-mounted into
containers, so a staged file lands where the bind mount points."""
root = _session_home_dir(session, "attachments")
root.mkdir(parents=True, exist_ok=True)
return root
def _sanitize_attachment_name(name: str) -> str:
import re as _re
candidate = _re.sub(r"[\x00-\x1f]+", "_", Path(str(name or "").strip()).name)
return candidate.strip().strip(".") or "attachment"
def _unique_attachment_path(root: Path, filename: str) -> Path:
candidate = root / filename
if not candidate.exists():
return candidate
stem = Path(filename).stem or "attachment"
suffix = Path(filename).suffix
counter = 2
while (next_candidate := root / f"{stem}-{counter}{suffix}").exists():
counter += 1
return next_candidate
def _resolve_gateway_attachment_path(raw: str) -> Path | None:
"""Resolve a raw path token to a gateway-visible file, or None."""
if not raw:
return None
try:
from cli import _detect_file_drop, _resolve_attachment_path, _split_path_input
except Exception:
return None
dropped = _detect_file_drop(raw)
if dropped:
return Path(dropped["path"]).resolve()
path_token, _remainder = _split_path_input(raw)
resolved = _resolve_attachment_path(path_token)
return Path(resolved).resolve() if resolved is not None else None
def _decode_attachment_data_url(data_url: str) -> bytes:
"""Decode a ``data:<any-mime>;base64,<b64>`` payload (any media type, unlike the
image-specific ``_decode_attach_base64``); bare base64 also accepted."""
import binascii as _binascii
import re as _re
try:
return _b64_payload(
data_url, r"^data:[^;,]*(?:;[^;,=]+=[^;,]+)*;base64,(.*)$", _re.DOTALL | _re.I)
except (ValueError, _binascii.Error) as exc:
raise ValueError("invalid data_url payload") from exc
def _stage_session_file_attachment(
session: dict, *, raw_path: str, data_url: str, name: str) -> tuple[Path, bool]:
"""Make a desktop file attachment available to the gateway agent.
1. Path resolves INSIDE the session workspace -> use as-is (``uploaded=False``).
2. Gateway-visible file OUTSIDE the workspace -> copy into ``attachments/``
(bind-mounted into container backends) so ``@file:`` resolves in the sandbox.
3. Not on the gateway (remote client disk) -> decode ``data_url`` bytes into
``attachments/``.
Returns ``(stored_path, uploaded)``.
"""
"""Make a desktop file attachment available to the gateway agent: ``(stored_path, uploaded)``.
Inside the workspace -> as-is; gateway-visible but outside -> copied into ``attachments/``
(bind-mounted into container backends so ``@file:`` resolves in the sandbox); not on the
gateway -> ``data_url`` bytes decoded into ``attachments/``."""
workspace = Path(_session_cwd(session)).resolve()
resolved = _resolve_gateway_attachment_path(raw_path)
resolved = None
if raw_path:
try:
from cli import _detect_file_drop, _resolve_attachment_path, _split_path_input
except Exception:
_detect_file_drop = None
if _detect_file_drop is not None:
dropped = _detect_file_drop(raw_path)
if dropped:
resolved = Path(dropped["path"]).resolve()
else:
path_token, _remainder = _split_path_input(raw_path)
found = _resolve_attachment_path(path_token)
resolved = Path(found).resolve() if found is not None else None
if resolved is not None:
try:
resolved.relative_to(workspace)
@@ -217,10 +164,25 @@ def _stage_session_file_attachment(
else:
if not data_url:
raise ValueError("file not found on gateway and no data_url provided")
payload = _decode_attachment_data_url(data_url)
# Any media type (unlike the image-specific decoder); bare base64 also accepted.
import binascii as _binascii
import re as _re
try:
payload = _b64_payload(
data_url, r"^data:[^;,]*(?:;[^;,=]+=[^;,]+)*;base64,(.*)$", _re.DOTALL | _re.I)
except (ValueError, _binascii.Error) as exc:
raise ValueError("invalid data_url payload") from exc
filename = _sanitize_attachment_name(name or Path(str(raw_path or "")).name)
target = _unique_attachment_path(
_desktop_attachment_dir(session), _sanitize_attachment_name(filename))
root = _session_home_dir(session, "attachments")
root.mkdir(parents=True, exist_ok=True)
filename = _sanitize_attachment_name(filename)
target = root / filename
if target.exists():
stem = Path(filename).stem or "attachment"
suffix = Path(filename).suffix
counter = 2
while (target := root / f"{stem}-{counter}{suffix}").exists():
counter += 1
target.write_bytes(payload)
return target.resolve(), True
+285 -478
View File
File diff suppressed because it is too large Load Diff