chore: uptick

This commit is contained in:
Brooklyn Nicholson
2026-05-02 03:19:39 -05:00
parent 420f68e4e2
commit db884f4646
240 changed files with 25206 additions and 3155 deletions
+386 -70
View File
@@ -18,6 +18,7 @@ from typing import Any, Optional
from hermes_constants import get_hermes_home
from hermes_cli.env_loader import load_hermes_dotenv
from utils import is_truthy_value
from tui_gateway.transport import (
StdioTransport,
Transport,
@@ -126,9 +127,11 @@ _cfg_lock = threading.Lock()
_cfg_cache: dict | None = None
_cfg_mtime: float | None = None
_cfg_path = None
_SLASH_WORKER_TIMEOUT_S = max(
5.0, float(os.environ.get("HERMES_TUI_SLASH_TIMEOUT_S", "45") or 45)
)
try:
_slash_timeout = float(os.environ.get("HERMES_TUI_SLASH_TIMEOUT_S") or "45")
except (ValueError, TypeError):
_slash_timeout = 45.0
_SLASH_WORKER_TIMEOUT_S = max(5.0, _slash_timeout)
_DETAIL_SECTION_NAMES = ("thinking", "tools", "subagents", "activity")
_DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"})
@@ -154,8 +157,12 @@ _LONG_HANDLERS = frozenset(
}
)
try:
_rpc_pool_workers = max(2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "4"))
except (ValueError, TypeError):
_rpc_pool_workers = 4
_pool = concurrent.futures.ThreadPoolExecutor(
max_workers=max(2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS", "4") or 4)),
max_workers=_rpc_pool_workers,
thread_name_prefix="tui-rpc",
)
atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True))
@@ -274,7 +281,7 @@ def _notify_session_boundary(event_type: str, session_id: str | None) -> None:
pass
def _finalize_session(session: dict | None) -> None:
def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> None:
"""Best-effort finalize hook + memory commit for a session."""
if not session or session.get("_finalized"):
return
@@ -293,13 +300,24 @@ def _finalize_session(session: dict | None) -> None:
except Exception:
pass
session_id = getattr(agent, "session_id", None) or session.get("session_key")
session_key = session.get("session_key")
session_id = getattr(agent, "session_id", None) or session_key
_notify_session_boundary("on_session_finalize", session_id)
# Mark session ended in DB so it doesn't linger as a ghost row in /resume.
# Adapted from #18283 (luyao618) and #18299 (Bartok9).
if session_key:
try:
db = _get_db()
if db is not None:
db.end_session(session_key, end_reason)
except Exception:
pass
def _shutdown_sessions() -> None:
for session in list(_sessions.values()):
_finalize_session(session)
_finalize_session(session, end_reason="tui_shutdown")
try:
worker = session.get("slash_worker")
if worker:
@@ -418,11 +436,35 @@ def method(name: str):
return dec
def _normalize_request(req: Any) -> tuple[Any, str, dict] | dict:
"""Validate a JSON-RPC request enough for safe local dispatch."""
if not isinstance(req, dict):
return _err(None, -32600, "invalid request: expected an object")
rid = req.get("id")
method = req.get("method")
if not isinstance(method, str) or not method:
return _err(rid, -32600, "invalid request: method must be a non-empty string")
params = req.get("params", {})
if params is None:
params = {}
elif not isinstance(params, dict):
return _err(rid, -32602, "invalid params: expected an object")
return rid, method, params
def handle_request(req: dict) -> dict | None:
fn = _methods.get(req.get("method", ""))
normalized = _normalize_request(req)
if isinstance(normalized, dict):
return normalized
rid, method, params = normalized
fn = _methods.get(method)
if not fn:
return _err(req.get("id"), -32601, f"unknown method: {req.get('method')}")
return fn(req.get("id"), req.get("params", {}))
return _err(rid, -32601, f"unknown method: {method}")
return fn(rid, params)
def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None:
@@ -440,7 +482,12 @@ def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None:
t = transport or _stdio_transport
token = bind_transport(t)
try:
if req.get("method") not in _LONG_HANDLERS:
normalized = _normalize_request(req)
if isinstance(normalized, dict):
return normalized
_rid, method, _params = normalized
if method not in _LONG_HANDLERS:
return handle_request(req)
# Snapshot the context so the pool worker sees the bound transport.
@@ -504,40 +551,8 @@ def _start_agent_build(sid: str, session: dict) -> None:
finally:
_clear_session_context(tokens)
db = _get_db()
if db is not None:
db.create_session(key, source="tui", model=_resolve_model())
seed_history = current.get("history") or []
for msg in seed_history:
if isinstance(msg, dict) and msg.get("role") in ("user", "assistant", "system"):
db.append_message(
session_id=key,
role=msg.get("role", "user"),
content=msg.get("content"),
)
pending_title = (current.get("pending_title") or "").strip()
if pending_title:
try:
title_applied = db.set_session_title(key, pending_title)
if title_applied:
current["pending_title"] = None
else:
existing_row = db.get_session(key)
existing_title = ((existing_row or {}).get("title") or "").strip()
if existing_title == pending_title:
current["pending_title"] = None
else:
logger.info(
"Pending title still queued for session %s (wanted=%r, current=%r)",
sid,
pending_title,
existing_title,
)
except ValueError as e:
current["pending_title"] = None
logger.info("Dropping pending title for session %s: %s", sid, e)
except Exception:
logger.warning("Failed to apply pending title for session %s", sid, exc_info=True)
# Session DB row deferred to first run_conversation() call.
# pending_title applied post-first-message (see cli.exec handler).
current["agent"] = agent
try:
@@ -1133,9 +1148,7 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
from hermes_cli.config import get_compatible_custom_providers, load_config
cfg = load_config()
user_provs = [
{"provider": k, **v} for k, v in (cfg.get("providers") or {}).items()
]
user_provs = cfg.get("providers")
custom_provs = get_compatible_custom_providers(cfg)
except Exception:
pass
@@ -1192,7 +1205,7 @@ def _compress_session_history(
before_messages: list | None = None,
history_version: int | None = None,
) -> tuple[int, dict]:
from agent.model_metadata import estimate_messages_tokens_rough
from agent.model_metadata import estimate_request_tokens_rough
agent = session["agent"]
# Snapshot history under the lock so the LLM-bound compression call
@@ -1208,7 +1221,13 @@ def _compress_session_history(
usage = _get_usage(agent)
return 0, usage
if approx_tokens is None:
approx_tokens = estimate_messages_tokens_rough(history)
# Include system prompt + tool schemas so the figure reflects real
# request pressure, not a transcript-only underestimate (#6217).
_sys_prompt = getattr(agent, "_cached_system_prompt", "") or ""
_tools = getattr(agent, "tools", None) or None
approx_tokens = estimate_request_tokens_rough(
history, system_prompt=_sys_prompt, tools=_tools
)
# Pass system_message=None so AIAgent._compress_context rebuilds the
# system prompt cleanly via _build_system_prompt(None). Passing the
# cached prompt (which already contains the agent identity block)
@@ -1745,6 +1764,17 @@ def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str
return name, _render_personality_prompt(personalities[name])
def _prompt_text(value) -> str:
"""Normalize config prompt values from YAML before handing them to AIAgent."""
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, list):
return "\n".join(str(item).strip() for item in value if str(item).strip())
return str(value).strip()
def _apply_personality_to_session(
sid: str, session: dict, new_prompt: str, personality: str = ""
) -> tuple[bool, dict | None]:
@@ -1839,7 +1869,7 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
cfg = _load_cfg()
agent_cfg = cfg.get("agent") or {}
system_prompt = (agent_cfg.get("system_prompt", "") or "").strip()
system_prompt = _prompt_text(agent_cfg.get("system_prompt", ""))
model, requested_provider = _resolve_startup_runtime()
runtime = resolve_runtime_provider(
requested=requested_provider,
@@ -1980,6 +2010,53 @@ def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str:
return text or "What do you see in this image?"
def _coerce_message_text(content: Any) -> str:
"""Render ``message['content']`` as a plain string for transport.
Provider-side, ``content`` may be a string (most common) or a list of
multimodal parts (e.g. ``[{"type": "text", "text": "..."},
{"type": "image_url", "image_url": {...}}]``). Calling ``.strip()`` on
the list raises ``'list' object has no attribute 'strip'`` and breaks
session resume entirely.
Image parts (``image_url``) are preserved by appending the underlying
URL (data: or http:) into the text. The desktop renderer pulls these
back out via ``extractEmbeddedImages`` so the user sees the image
instead of the URL — and it stops the resume payload from disagreeing
with the cached message (which would otherwise cause the inline image
to flash, then disappear when the resume payload overwrites the cache).
"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
chunks: list[str] = []
for part in content:
if isinstance(part, str):
chunks.append(part)
continue
if not isinstance(part, dict):
continue
text = part.get("text")
if isinstance(text, str):
chunks.append(text)
continue
if part.get("type") == "image_url":
image_url = part.get("image_url")
url = ""
if isinstance(image_url, dict):
candidate = image_url.get("url")
if isinstance(candidate, str):
url = candidate
elif isinstance(image_url, str):
url = image_url
if url:
chunks.append(f"\n{url}")
return "".join(chunks)
return str(content)
def _history_to_messages(history: list[dict]) -> list[dict]:
messages = []
tool_call_args = {}
@@ -1990,6 +2067,7 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
role = m.get("role")
if role not in ("user", "assistant", "tool", "system"):
continue
content_text = _coerce_message_text(m.get("content"))
if role == "assistant" and m.get("tool_calls"):
for tc in m["tool_calls"]:
fn = tc.get("function", {})
@@ -2000,7 +2078,7 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
except (json.JSONDecodeError, TypeError):
args = {}
tool_call_args[tc_id] = (fn["name"], args)
if not (m.get("content") or "").strip():
if not content_text.strip():
continue
if role == "tool":
tc_id = m.get("tool_call_id", "")
@@ -2011,9 +2089,19 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
{"role": "tool", "name": name, "context": _tool_ctx(name, args)}
)
continue
if not (m.get("content") or "").strip():
if not content_text.strip():
continue
messages.append({"role": role, "text": m.get("content") or ""})
msg = {"role": role, "text": content_text}
if role == "assistant":
for key in (
"reasoning",
"reasoning_content",
"reasoning_details",
"codex_reasoning_items",
):
if key in m and m.get(key) is not None:
msg[key] = m.get(key)
messages.append(msg)
return messages
@@ -2446,14 +2534,21 @@ def _(rid, params: dict) -> dict:
focus_topic = str(params.get("focus_topic", "") or "").strip()
try:
from agent.manual_compression_feedback import summarize_manual_compression
from agent.model_metadata import estimate_messages_tokens_rough
from agent.model_metadata import estimate_request_tokens_rough
with session["history_lock"]:
before_messages = list(session.get("history", []))
history_version = int(session.get("history_version", 0))
before_count = len(before_messages)
_agent = session["agent"]
_sys_prompt = getattr(_agent, "_cached_system_prompt", "") or ""
_tools = getattr(_agent, "tools", None) or None
before_tokens = (
estimate_messages_tokens_rough(before_messages) if before_count else 0
estimate_request_tokens_rough(
before_messages, system_prompt=_sys_prompt, tools=_tools
)
if before_count
else 0
)
if before_count >= 4:
@@ -2476,8 +2571,18 @@ def _(rid, params: dict) -> dict:
with session["history_lock"]:
messages = list(session.get("history", []))
after_count = len(messages)
# Re-read system prompt + tools after compression — _compress_context
# may have rebuilt the system prompt (_cached_system_prompt=None).
_sys_prompt_after = getattr(_agent, "_cached_system_prompt", "") or _sys_prompt
_tools_after = getattr(_agent, "tools", None) or _tools
after_tokens = (
estimate_messages_tokens_rough(messages) if after_count else 0
estimate_request_tokens_rough(
messages,
system_prompt=_sys_prompt_after,
tools=_tools_after,
)
if after_count
else 0
)
agent = session["agent"]
_sync_session_key_after_compress(sid, session)
@@ -2897,6 +3002,7 @@ def _(rid, params: dict) -> dict:
@method("prompt.submit")
def _(rid, params: dict) -> dict:
sid, text = params.get("session_id", ""), params.get("text", "")
truncate_user_ordinal = params.get("truncate_before_user_ordinal")
session, err = _sess_nowait(params, rid)
if err:
return err
@@ -2908,6 +3014,23 @@ def _(rid, params: dict) -> dict:
with session["history_lock"]:
if session.get("running"):
return _err(rid, 4009, "session busy")
if truncate_user_ordinal is not None:
try:
ordinal = int(truncate_user_ordinal)
except (TypeError, ValueError):
return _err(rid, 4004, "truncate_before_user_ordinal must be an integer")
history = session.get("history", [])
user_indices = [i for i, m in enumerate(history) if m.get("role") == "user"]
if ordinal >= len(user_indices):
return _err(rid, 4018, "target user message is no longer in session history")
truncated = history[: user_indices[ordinal]]
session["history"] = truncated
session["history_version"] = int(session.get("history_version", 0)) + 1
if (db := _get_db()) is not None:
try:
db.replace_messages(session["session_key"], truncated)
except Exception as exc:
print(f"[tui_gateway] prompt.submit: replace_messages failed: {exc}", file=sys.stderr)
session["running"] = True
_start_agent_build(sid, session)
@@ -3103,6 +3226,17 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
payload["rendered"] = rendered
_emit("message.complete", sid, payload)
# Apply pending_title now that the DB row exists.
_pending = session.get("pending_title")
if _pending and status == "complete":
_pdb = _get_db()
if _pdb:
try:
if _pdb.set_session_title(session.get("session_key") or sid, _pending):
session["pending_title"] = None
except Exception:
pass # Best effort — auto-title will handle it below
if (
status == "complete"
and isinstance(raw, str)
@@ -3573,7 +3707,7 @@ def _(rid, params: dict) -> dict:
enable_session_yolo(session["session_key"])
nv = "1"
else:
current = bool(os.environ.get("HERMES_YOLO_MODE"))
current = is_truthy_value(os.environ.get("HERMES_YOLO_MODE"))
if current:
os.environ.pop("HERMES_YOLO_MODE", None)
nv = "0"
@@ -4288,11 +4422,15 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"type": "alias", "target": qc.get("target", "")})
try:
from hermes_cli.plugins import get_plugin_command_handler
from hermes_cli.plugins import (
get_plugin_command_handler,
resolve_plugin_command_result,
)
handler = get_plugin_command_handler(name)
if handler:
return _ok(rid, {"type": "plugin", "output": str(handler(arg) or "")})
result = resolve_plugin_command_result(handler(arg))
return _ok(rid, {"type": "plugin", "output": str(result or "")})
except Exception:
pass
@@ -4879,6 +5017,7 @@ def _(rid, params: dict) -> dict:
def _(rid, params: dict) -> dict:
try:
from hermes_cli.model_switch import list_authenticated_providers
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
session = _sessions.get(params.get("session_id", ""))
agent = session.get("agent") if session else None
@@ -4892,6 +5031,127 @@ def _(rid, params: dict) -> dict:
# provider_model_ids() — that bypasses curation and pulls in
# non-agentic models (e.g. Nous /models returns ~400 IDs including
# TTS, embeddings, rerankers, image/video generators).
user_provs = (
cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
)
custom_provs = (
cfg.get("custom_providers")
if isinstance(cfg.get("custom_providers"), list)
else []
)
authenticated = list_authenticated_providers(
current_provider=current_provider,
current_base_url=current_base_url,
current_model=current_model,
user_providers=user_provs,
custom_providers=custom_provs,
max_models=50,
)
# Mark authenticated providers and build lookup by slug
authed_map: dict = {}
authed_extra: list = [] # user-defined/custom not in CANONICAL_PROVIDERS
canonical_slugs = {e.slug for e in CANONICAL_PROVIDERS}
for p in authenticated:
p["authenticated"] = True
authed_map[p["slug"]] = p
if p["slug"] not in canonical_slugs:
authed_extra.append(p)
# Build final list in CANONICAL_PROVIDERS order, merging auth data
from hermes_cli.auth import PROVIDER_REGISTRY as _auth_reg
ordered: list = []
for entry in CANONICAL_PROVIDERS:
if entry.slug in authed_map:
ordered.append(authed_map[entry.slug])
else:
pconfig = _auth_reg.get(entry.slug)
auth_type = pconfig.auth_type if pconfig else "api_key"
key_env = pconfig.api_key_env_vars[0] if (pconfig and pconfig.api_key_env_vars) else ""
if auth_type == "api_key" and key_env:
warning = f"paste {key_env} to activate"
else:
warning = f"run `hermes model` to configure ({auth_type})"
ordered.append({
"slug": entry.slug,
"name": _PROVIDER_LABELS.get(entry.slug, entry.label),
"is_current": entry.slug == current_provider,
"is_user_defined": False,
"models": [],
"total_models": 0,
"source": "built-in",
"authenticated": False,
"auth_type": auth_type,
"key_env": key_env,
"warning": warning,
})
# Append user-defined/custom providers not in canonical list
ordered.extend(authed_extra)
return _ok(
rid,
{
"providers": ordered,
"model": current_model,
"provider": current_provider,
},
)
except Exception as e:
return _err(rid, 5033, str(e))
@method("model.save_key")
def _(rid, params: dict) -> dict:
"""Save an API key for a provider, then return its refreshed model list.
Params:
slug: provider slug (e.g. "deepseek", "xai")
api_key: the key value to save
Returns the provider dict with models populated (same shape as
model.options entries) on success.
"""
try:
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.config import is_managed, save_env_value
from hermes_cli.model_switch import list_authenticated_providers
slug = (params.get("slug") or "").strip()
api_key = (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:
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 — "
f"run `hermes model` to configure"
)
if not pconfig.api_key_env_vars:
return _err(rid, 4004, f"no env var defined for {pconfig.name}")
# Save the key to ~/.hermes/.env
env_var = pconfig.api_key_env_vars[0]
save_env_value(env_var, api_key)
# Also set in current process so list_authenticated_providers sees it
import os
os.environ[env_var] = api_key
# Refresh provider data
cfg = _load_cfg()
session = _sessions.get(params.get("session_id", ""))
agent = session.get("agent") if session else None
current_provider = getattr(agent, "provider", "") or ""
current_model = getattr(agent, "model", "") or _resolve_model()
current_base_url = getattr(agent, "base_url", "") or ""
providers = list_authenticated_providers(
current_provider=current_provider,
current_base_url=current_base_url,
@@ -4906,16 +5166,72 @@ def _(rid, params: dict) -> dict:
),
max_models=50,
)
return _ok(
rid,
{
"providers": providers,
"model": current_model,
"provider": current_provider,
},
)
# Find the newly-authenticated provider
provider_data = None
for p in providers:
if p["slug"] == slug:
provider_data = p
break
if not provider_data:
# Key was saved but provider didn't appear — still return success
provider_data = {
"slug": slug,
"name": pconfig.name,
"is_current": False,
"models": [],
"total_models": 0,
"authenticated": True,
}
provider_data["authenticated"] = True
return _ok(rid, {"provider": provider_data})
except Exception as e:
return _err(rid, 5033, str(e))
return _err(rid, 5034, str(e))
@method("model.disconnect")
def _(rid, params: dict) -> dict:
"""Remove credentials for a provider.
Params:
slug: provider slug (e.g. "deepseek", "xai")
Returns success status and the provider's slug.
"""
try:
from hermes_cli.auth import PROVIDER_REGISTRY, clear_provider_auth
from hermes_cli.config import remove_env_value
slug = (params.get("slug") or "").strip()
if not slug:
return _err(rid, 4001, "slug is required")
pconfig = PROVIDER_REGISTRY.get(slug)
cleared_env = False
cleared_auth = False
# Remove API key env vars from .env and process
if pconfig and pconfig.api_key_env_vars:
for ev in pconfig.api_key_env_vars:
if remove_env_value(ev):
cleared_env = True
# Clear OAuth / credential pool state
cleared_auth = clear_provider_auth(slug)
if not cleared_env and not cleared_auth:
return _err(rid, 4005, f"no credentials found for {slug}")
provider_name = pconfig.name if pconfig else slug
return _ok(rid, {
"slug": slug,
"name": provider_name,
"disconnected": True,
})
except Exception as e:
return _err(rid, 5035, str(e))
# ── Methods: slash.exec ──────────────────────────────────────────────
@@ -4950,7 +5266,7 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str:
_apply_personality_to_session(sid, session, new_prompt, pname)
elif name == "prompt" and agent:
cfg = _load_cfg()
new_prompt = (cfg.get("agent") or {}).get("system_prompt", "") or ""
new_prompt = _prompt_text((cfg.get("agent") or {}).get("system_prompt", ""))
agent.ephemeral_system_prompt = new_prompt or None
agent._cached_system_prompt = None
elif name == "compress" and agent: