Files
hermes-agent/tui_gateway/methods_config.py

570 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Config / projects / setup JSON-RPC handlers.
Handlers and module-level helpers are rebound onto server.py's globals at
install time (see method_ctx.bind_module), so bodies reference server.py
globals bare (``_ok``, ``_err``, ``_load_cfg``, ``_sessions``, ...).
``config.set`` still lives in server.py.
"""
from .method_ctx import HandlerRegistry, bind_module
from hermes_constants import DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES
_registry = HandlerRegistry()
method = _registry.method
_profile_scoped = _registry.profile_scoped
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)
)
@method("projects.discover_repos")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Repos for the desktop overview: scanned-from-disk (cached) ∪ session-derived."""
try:
with _profile_db(params) as db:
if db is None:
return _ok(rid, {"repos": []})
from hermes_cli import projects_db as pdb
policy = _repo_discovery_policy()
with pdb.connect_closing() as conn:
_reconcile_repo_discovery(pdb, conn, policy, _repo_discovery_policy_key(policy))
# `scan=true` (desktop in remote-gateway mode): the desktop's
# native scan only sees its local filesystem, so ask the host
# to scan the policy roots itself 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"])
return _ok(rid, {"repos": repos, "discovery_policy": policy})
except Exception as e:
return _err(rid, 5061, str(e))
@method("projects.record_repos")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Persist git repo roots found by the client's filesystem scan (the native
crawl runs on the desktop), then return the merged repo list."""
try:
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")))
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:
return _ok(
rid,
{
"repos": _discover_repos_payload(db, include_cached=policy["enabled"])
if db is not None
else [],
"accepted": accepted,
"discovery_policy": policy,
},
)
except Exception as e:
return _err(rid, 5061, str(e))
def _stamped_project_tree(db, params, **kwargs):
"""``_build_project_tree`` + profile stamping shared by the two tree RPCs."""
from tui_gateway.project_tree import stamp_profile
tree, active_id = _build_project_tree(db, **kwargs)
stamp_profile(tree["projects"], _response_profile_name(params.get("profile")))
return tree, active_id
@method("projects.tree")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Authoritative project overview: project -> repo -> lane structure with
counts + a few preview sessions per project, plus the flat set of session
ids claimed by any project (so the desktop excludes them from flat Recents).
Lanes carry no session rows here; drill-in uses ``projects.project_sessions``.
"""
try:
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"],
},
)
except Exception as e:
return _err(rid, 5061, str(e))
@method("projects.project_sessions")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Fully hydrated lanes (repo -> lane -> session rows) for one project,
built from the same authoritative grouping as ``projects.tree`` so ids and
membership match exactly."""
try:
project_id = str(params.get("project_id") or "")
if not project_id:
return _err(rid, 5063, "project_id required")
with _profile_db(params) as db:
if db is None:
return _ok(rid, {"project": None})
# Drill-in only needs the entered project (which has sessions):
# skip the zero-session discovery tier.
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})
except Exception as e:
return _err(rid, 5061, str(e))
# ---------------------------------------------------------------------------
# config.get — one getter per key. Each returns the result payload (a dict) or
# a full ``_err`` response (dicts containing "error" pass through untouched).
# ---------------------------------------------------------------------------
def _display_cfg() -> dict:
display = _load_cfg().get("display")
return display if isinstance(display, dict) else {}
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()
return raw if raw in allowed else default
_DETAILS_MODES = frozenset({"hidden", "collapsed", "expanded"})
_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",
"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()
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):
# EFFECTIVE personality via the single owner — a stale/unknown name in
# config must not display 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):
cfg = _load_cfg()
session = _sessions.get(params.get("session_id", ""))
reasoning_config = None
if session is not None:
if isinstance(session.get("create_reasoning_override"), dict):
reasoning_config = session.get("create_reasoning_override")
else:
agent_reasoning = getattr(session.get("agent"), "reasoning_config", None)
if isinstance(agent_reasoning, dict):
reasoning_config = agent_reasoning
if isinstance(reasoning_config, dict):
if reasoning_config.get("enabled") is False:
effort = "none"
else:
effort = str(reasoning_config.get("effort") or "medium")
else:
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"
return {"value": effort, "display": display}
def _cfg_get_fast(rid, params):
# `config.set fast` is session-scoped, so 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"]
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(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", _DETAILS_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_focus(rid, params):
on = bool(_display_cfg().get("focus_view", False))
return {"value": "on" if on else "off", "tool_progress": _load_tool_progress_mode()}
def _cfg_get_mtime(rid, params):
cfg_path = _hermes_home / "config.yaml"
try:
mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0
except Exception:
return {"mtime": 0}
# mcp_rev: hash of the MCP-relevant config sections so the TUI's poller
# reloads MCP servers only when their config changed — a /skin write bumps
# mtime but must not cost a multi-second MCP reconnect.
return {"mtime": mtime, "mcp_rev": _compute_mcp_rev()}
def _config_getters() -> dict:
"""key -> getter(rid, params). Built inside a function (not a module-level
dict) so, once rebound onto server.py, every entry resolves to the rebound
helper copies rather than this module's un-rebound 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", _DETAILS_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": _cfg_get_focus,
"mouse": lambda rid, params: {"value": _display_mouse_tracking(_load_cfg().get("display"))},
"mtime": _cfg_get_mtime,
}
@method("config.get")
@_profile_scoped
def _(rid, params: dict) -> dict:
key = params.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)
# ---------------------------------------------------------------------------
# setup readiness
# ---------------------------------------------------------------------------
def _readiness_profile_scope(params: dict):
"""Resolve the optional ``profile`` param of the setup readiness RPCs.
Returns ``(profile, scope)``: ``scope`` binds that profile's HERMES_HOME and
``.env`` secret scope (ContextVars, so concurrent checks stay isolated); the
launch profile / no param yields ``("", nullcontext())``. A profile unknown
to this host raises ``FileNotFoundError`` — a readiness check must never
quietly 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 and returns the payload;
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)})
with scope:
payload = probe(profile)
return _ok(rid, payload)
@method("setup.status")
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)))
payload = {"provider_configured": configured}
if profile:
payload["profile"] = profile
return payload
return _readiness_check(rid, params, probe)
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, incl.
indirect fallbacks like ``gh auth token``), this runs the same
resolve_runtime_provider() the agent uses on session creation and returns
ok=False with the auth error when the model cannot actually be served, so
UIs can surface onboarding before a doomed prompt. ``profile`` (optional)
answers for THAT profile's config.yaml pin and ``.env``; an unknown profile
answers ``ok=False`` rather than the launch profile's readiness.
"""
try:
from hermes_cli.runtime_provider import resolve_runtime_provider
from hermes_cli.auth import has_usable_secret
from hermes_cli.main import _has_any_provider_configured
requested = str(params.get("provider") or "").strip() or None
def probe(profile):
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 = runtime.get("provider") or "provider"
source = str(runtime.get("source") or "")
if (
not provider_configured
and provider == "bedrock"
and source in {"iam-role", "aws-sdk-default-chain"}
):
return {
"ok": False,
"provider": provider,
"model": runtime.get("model"),
"source": source,
"error": "No Hermes provider is configured.",
**scoped,
}
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:
return {
"ok": False,
"provider": provider,
"model": runtime.get("model"),
"source": runtime.get("source"),
"error": f"No usable credentials found for {provider}.",
**scoped,
}
return {
"ok": True,
"provider": runtime.get("provider"),
"model": runtime.get("model"),
"source": runtime.get("source"),
**scoped,
}
return _readiness_check(rid, params, probe)
except Exception as e:
return _ok(rid, {"ok": False, "error": str(e)})
@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. Consent lives with the CALLER (the
desktop shows the privacy notice + Upload button first). Structured
``ok``/``error`` envelope rather than JSON-RPC errors so the client can
render upload failures inline.
Params (optional): ``error_context`` (client text about the failure,
redacted, attached as ``error-context.txt``), ``extra_files`` ({label →
text} client-side artifacts such as a remote desktop.log; force-redacted,
labels sanitized and size-capped), ``log_lines`` (default 200).
"""
try:
from hermes_cli.debug import _redact_log_text, build_nous_bundle, collect_share_bundle
from hermes_cli.diagnostics_upload import share_to_nous
log_lines = params.get("log_lines")
if not isinstance(log_lines, int) or not (10 <= log_lines <= 2000):
log_lines = 200
bundle = collect_share_bundle(log_lines=log_lines, redact=True)
# Client text goes through the SAME upload-safe redactor as backend
# logs (force secret redaction + email masking), never the weaker bare
# secret pass.
error_context = params.get("error_context")
if isinstance(error_context, str) and error_context.strip():
bundle["error-context.txt"] = _redact_log_text(error_context.strip()[:8_000])
# Bounded: at most 4 files, 512KB each, sanitized labels — a
# diagnostics channel, 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 even cosmetically.
while ".." in safe_label:
safe_label = safe_label.replace("..", ".")
safe_label = safe_label.lstrip(".").strip()
if not safe_label or not text.strip():
continue
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"),
},
)
except Exception as e:
return _ok(rid, {"ok": False, "error": str(e)})
def register(server) -> None:
"""Publish helpers + handlers onto ``server``, rebound to its globals."""
bind_module(globals(), server, skip=("_",))