Merge branch 'simp/r3-36' into hermes/simplify-codebase
This commit is contained in:
@@ -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
@@ -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"] = []
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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])
|
||||
|
||||
+271
-457
File diff suppressed because it is too large
Load Diff
+776
-1148
File diff suppressed because it is too large
Load Diff
+99
-164
@@ -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
|
||||
|
||||
+599
-849
File diff suppressed because it is too large
Load Diff
+151
-217
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user