Merge remote-tracking branch 'origin/main' into pr-46081

# Conflicts:
#	agent/conversation_loop.py
#	apps/desktop/src/app/settings/model-settings.tsx
#	apps/desktop/src/app/shell/model-menu-panel.tsx
#	gateway/run.py
#	hermes_cli/commands.py
#	run_agent.py
This commit is contained in:
teknium1 2026-06-18 11:40:22 -07:00
commit 481b2b4327
No known key found for this signature in database
336 changed files with 24660 additions and 2638 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@
*.pyc* *.pyc*
__pycache__/ __pycache__/
.venv/ .venv/
.venv
.vscode/ .vscode/
.env .env
.env.local .env.local

View File

@ -9,8 +9,11 @@ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df228
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
FROM debian:13.4 FROM debian:13.4
# Disable Python stdout buffering to ensure logs are printed immediately # Disable Python stdout buffering to ensure logs are printed immediately.
# Do not write .pyc files at runtime: /opt/hermes is immutable in the
# published container and writable state belongs under /opt/data.
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# Store Playwright browsers outside the volume mount so the build-time # Store Playwright browsers outside the volume mount so the build-time
# install survives the /opt/data volume overlay at runtime. # install survives the /opt/data volume overlay at runtime.
@ -186,36 +189,38 @@ RUN cd web && npm run build && \
# ---------- Source code ---------- # ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive. # .dockerignore excludes node_modules, so the installs above survive.
COPY --chown=hermes:hermes . . COPY . .
# ---------- Permissions ---------- # ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime. # Link hermes-agent itself (editable). Deps are already installed in the
# The venv needs to be traversable too. # cached layer above; `--no-deps` makes this a fast egg-link creation with no
# node_modules trees additionally need to be writable by the hermes user # resolution or downloads.
# so the runtime `npm install` triggered by _tui_need_npm_install() in RUN uv pip install --no-cache-dir --no-deps -e "."
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally # Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
# not chowned here. # instances must not be able to self-edit the installed source or venv; user
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and # data, skills, plugins, config, logs, and dashboard uploads live under
# gateway state artifacts beneath the package after services drop privileges, # /opt/data instead. Root can still repair the image during build/boot, but
# especially when the hermes UID is remapped at boot (#27221). # supervised Hermes processes drop to the non-root hermes user.
# The .venv MUST remain hermes-writable so lazy_deps.py can install
# remaining optional platform packages and future pin bumps at first use.
# Without this, `uv pip install` fails with EACCES and adapters silently
# fail to load. See tools/lazy_deps.py.
USER root USER root
RUN chmod -R a+rX /opt/hermes && \ RUN mkdir -p /opt/hermes/bin && \
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
chmod 0755 /opt/hermes/bin/hermes && \
printf 'docker\n' > /opt/hermes/.install_method && \
chown -R root:root /opt/hermes && \
chmod -R a+rX /opt/hermes && \
chmod -R a-w /opt/hermes
# The ``.install_method`` stamp is baked next to the running code (the install
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
# volume that is commonly bind-mounted from the host and even shared with a
# host-side Desktop/CLI install; stamping it at boot used to clobber that
# host install's marker and wrongly block its ``hermes update``. A code-scoped
# stamp is read first by detect_install_method() and is immune to the share.
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown # Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
# the data volume. Each supervised service then drops to the hermes user via # the data volume. Each supervised service then drops to the hermes user via
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services # `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
# run as the default hermes user (UID 10000). # run as the default hermes user (UID 10000).
# ---------- Link hermes-agent itself (editable) ----------
# Deps are already installed in the cached layer above; `--no-deps` makes
# this a fast (~1s) egg-link creation with no resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
# ---------- Bake build-time git revision ---------- # ---------- Bake build-time git revision ----------
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the # .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
# container always returns nothing — meaning `hermes dump` reports # container always returns nothing — meaning `hermes dump` reports
@ -235,8 +240,9 @@ RUN uv pip install --no-cache-dir --no-deps -e "."
# every published image has it. # every published image has it.
ARG HERMES_GIT_SHA= ARG HERMES_GIT_SHA=
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \ RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
chmod u+w /opt/hermes && \
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \ printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
chown hermes:hermes /opt/hermes/.hermes_build_sha; \ chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
fi fi
# ---------- s6-overlay service wiring ---------- # ---------- s6-overlay service wiring ----------
@ -282,6 +288,8 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
# check. (A separate launcher hardening is tracked independently.) # check. (A separate launcher hardening is tracked independently.)
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data ENV HERMES_HOME=/opt/data
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
ENV HERMES_DISABLE_LAZY_INSTALLS=1
# `docker exec` privilege-drop shim. When operators run # `docker exec` privilege-drop shim. When operators run
# `docker exec <c> hermes ...` they default to root, and any file the # `docker exec <c> hermes ...` they default to root, and any file the
@ -294,7 +302,6 @@ ENV HERMES_HOME=/opt/data
# Recursion is impossible because the shim exec's the venv binary by # Recursion is impossible because the shim exec's the venv binary by
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for # absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1). # the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported # Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the # the venv bin onto PATH; Architecture B's main-wrapper.sh does the

View File

@ -27,7 +27,7 @@ import threading
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
from urllib.parse import urlparse, parse_qs, urlunparse from urllib.parse import urlparse, parse_qs, urlunparse
from agent.context_compressor import ContextCompressor from agent.context_compressor import ContextCompressor
@ -195,6 +195,7 @@ def init_agent(
status_callback: callable = None, status_callback: callable = None,
notice_callback: callable = None, notice_callback: callable = None,
notice_clear_callback: callable = None, notice_clear_callback: callable = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
max_tokens: int = None, max_tokens: int = None,
reasoning_config: Dict[str, Any] = None, reasoning_config: Dict[str, Any] = None,
service_tier: str = None, service_tier: str = None,
@ -426,6 +427,7 @@ def init_agent(
agent.status_callback = status_callback agent.status_callback = status_callback
agent.notice_callback = notice_callback agent.notice_callback = notice_callback
agent.notice_clear_callback = notice_clear_callback agent.notice_clear_callback = notice_clear_callback
agent.event_callback = event_callback
agent.tool_gen_callback = tool_gen_callback agent.tool_gen_callback = tool_gen_callback
@ -597,6 +599,7 @@ def init_agent(
# (e.g. CLI voice mode adds a temporary prefix for the live call only). # (e.g. CLI voice mode adds a temporary prefix for the live call only).
agent._persist_user_message_idx = None agent._persist_user_message_idx = None
agent._persist_user_message_override = None agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None
# Cache anthropic image-to-text fallbacks per image payload/URL so a # Cache anthropic image-to-text fallbacks per image payload/URL so a
# single tool loop does not repeatedly re-run auxiliary vision on the # single tool loop does not repeatedly re-run auxiliary vision on the
@ -1162,6 +1165,9 @@ def init_agent(
"hermes_home": str(get_hermes_home()), "hermes_home": str(get_hermes_home()),
"agent_context": "primary", "agent_context": "primary",
} }
if _init_kwargs["platform"] == "cli":
_init_kwargs["warning_callback"] = agent._emit_warning
_init_kwargs["status_callback"] = agent._emit_status
# Thread session title for memory provider scoping # Thread session title for memory provider scoping
# (e.g. honcho uses this to derive chat-scoped session keys) # (e.g. honcho uses this to derive chat-scoped session keys)
if agent._session_db: if agent._session_db:
@ -1230,6 +1236,12 @@ def init_agent(
# targets. # targets.
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True)) agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
# Universal parallel-tool-call guidance toggle. Default True. Separate
# flag from task_completion_guidance because a user may want one but not
# the other. Steers the model to batch independent tool calls into a
# single turn; the runtime already executes such batches concurrently.
agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True))
# Local Python toolchain probe toggle. Default True. When False, # Local Python toolchain probe toggle. Default True. When False,
# the probe is skipped entirely (no subprocess calls, no system-prompt # the probe is skipped entirely (no subprocess calls, no system-prompt
# line). Useful for users on exotic setups where the probe heuristics # line). Useful for users on exotic setups where the probe heuristics

View File

@ -1839,21 +1839,35 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
elif function_name == "memory": elif function_name == "memory":
def _execute(next_args: dict) -> Any: def _execute(next_args: dict) -> Any:
target = next_args.get("target", "memory") target = next_args.get("target", "memory")
operations = next_args.get("operations")
from tools.memory_tool import memory_tool as _memory_tool from tools.memory_tool import memory_tool as _memory_tool
result = _memory_tool( result = _memory_tool(
action=next_args.get("action"), action=next_args.get("action"),
target=target, target=target,
content=next_args.get("content"), content=next_args.get("content"),
old_text=next_args.get("old_text"), old_text=next_args.get("old_text"),
operations=operations,
store=agent._memory_store, store=agent._memory_store,
) )
# Bridge: notify external memory provider of built-in memory writes # Bridge: notify external memory provider of built-in memory writes.
if agent._memory_manager and next_args.get("action") in {"add", "replace"}: # Covers both the single-op shape and each add/replace inside a batch.
if agent._memory_manager:
if operations:
_mem_ops = [
op for op in operations
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
]
else:
_mem_ops = (
[{"action": next_args.get("action"), "content": next_args.get("content")}]
if next_args.get("action") in {"add", "replace"} else []
)
for _op in _mem_ops:
try: try:
agent._memory_manager.on_memory_write( agent._memory_manager.on_memory_write(
next_args.get("action", ""), _op.get("action", ""),
target, target,
next_args.get("content", ""), _op.get("content", "") or "",
metadata=agent._build_memory_write_metadata( metadata=agent._build_memory_write_metadata(
task_id=effective_task_id, task_id=effective_task_id,
tool_call_id=tool_call_id, tool_call_id=tool_call_id,

View File

@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str:
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude." _CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
_MCP_TOOL_PREFIX = "mcp_" _MCP_TOOL_PREFIX = "mcp__"
def _get_claude_code_version() -> str: def _get_claude_code_version() -> str:
@ -2349,25 +2349,46 @@ def build_anthropic_kwargs(
text = text.replace("Nous Research", "Anthropic") text = text.replace("Nous Research", "Anthropic")
block["text"] = text block["text"] = text
# 3. Prefix tool names with mcp_ (Claude Code convention) # 3. Normalize tool names so NOTHING goes on the OAuth wire with a
# Skip names that already begin with the marker — native MCP server # single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth
# tools (from mcp_servers: in config.yaml) are registered under their # billing classifier treats a single-underscore ``mcp_`` tool name as
# full mcp_<server>_<tool> name and would double-prefix otherwise, # a third-party-app fingerprint and rejects the request with HTTP 400
# breaking round-trip registry lookup in normalize_response. GH-25255. # "Third-party apps now draw from extra usage, not plan limits"
# (verified empirically: a single ``mcp_foo`` tool flips a request
# from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted).
#
# Two cases, both must land on the double-underscore ``mcp__`` form:
# a) bare Hermes-native tools (``read_file``) -> ``mcp__read_file``
# b) native MCP server tools registered under their full
# single-underscore ``mcp_<server>_<tool>`` name
# (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue``
# Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap
# left open: those tools were *skipped* and stayed single-underscore,
# so any session with an MCP server configured still tripped the
# classifier. normalize_response reverses both forms via registry
# lookup so the dispatcher still sees the original name. GH-25255.
def _to_oauth_wire_name(name: str) -> str:
if name.startswith("mcp__"):
return name # already correct, don't double-prefix
if name.startswith("mcp_"):
# single-underscore native MCP tool -> promote to double
return "mcp__" + name[len("mcp_"):]
return _MCP_TOOL_PREFIX + name # bare name -> mcp__<name>
if anthropic_tools: if anthropic_tools:
for tool in anthropic_tools: for tool in anthropic_tools:
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX): if "name" in tool:
tool["name"] = _MCP_TOOL_PREFIX + tool["name"] tool["name"] = _to_oauth_wire_name(tool["name"])
# 4. Prefix tool names in message history (tool_use and tool_result blocks) # 4. Apply the same normalization to tool names in message history
# (tool_use blocks) so replayed turns match the wire names above.
for msg in anthropic_messages: for msg in anthropic_messages:
content = msg.get("content") content = msg.get("content")
if isinstance(content, list): if isinstance(content, list):
for block in content: for block in content:
if isinstance(block, dict): if isinstance(block, dict):
if block.get("type") == "tool_use" and "name" in block: if block.get("type") == "tool_use" and "name" in block:
if not block["name"].startswith(_MCP_TOOL_PREFIX): block["name"] = _to_oauth_wire_name(block["name"])
block["name"] = _MCP_TOOL_PREFIX + block["name"]
elif block.get("type") == "tool_result" and "tool_use_id" in block: elif block.get("type") == "tool_result" and "tool_use_id" in block:
pass # tool_result uses ID, not name pass # tool_result uses ID, not name

View File

@ -3079,23 +3079,20 @@ def _try_configured_fallback_chain(
if not fb_provider or fb_provider.lower() == skip: if not fb_provider or fb_provider.lower() == skip:
continue continue
fb_model = str(entry.get("model", "")).strip() or None fb_model = str(entry.get("model", "")).strip() or None
fb_base_url = str(entry.get("base_url", "")).strip() or None
fb_api_key = str(entry.get("api_key", "")).strip() or None
label = f"fallback_chain[{i}]({fb_provider})" label = f"fallback_chain[{i}]({fb_provider})"
try: try:
fb_client = _resolve_single_provider( fb_client, resolved_model = _resolve_fallback_entry(entry)
fb_provider, fb_model, fb_base_url, fb_api_key)
except Exception: except Exception:
fb_client = None fb_client, resolved_model = None, None
if fb_client is not None: if fb_client is not None:
logger.info( logger.info(
"Auxiliary %s: %s on %s — configured fallback to %s (%s)", "Auxiliary %s: %s on %s — configured fallback to %s (%s)",
task, reason, failed_provider, label, fb_model or "default", task, reason, failed_provider, label, resolved_model or fb_model or "default",
) )
return fb_client, fb_model, label return fb_client, resolved_model or fb_model, label
tried.append(label) tried.append(label)
if tried: if tried:
@ -3106,6 +3103,103 @@ def _try_configured_fallback_chain(
return None, None, "" return None, None, ""
def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
"""Resolve inline or env-backed API key from a fallback-chain entry."""
explicit = str(entry.get("api_key") or "").strip()
if explicit:
return explicit
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
if key_env:
return os.getenv(key_env, "").strip() or None
return None
def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:
"""Resolve one fallback entry through the central provider router."""
provider = str(entry.get("provider") or "").strip()
model = str(entry.get("model") or "").strip() or None
if not provider or not model:
return None, None
base_url = str(entry.get("base_url") or "").strip() or None
api_key = _fallback_entry_api_key(entry)
api_mode = str(entry.get("api_mode") or entry.get("transport") or "").strip() or None
return resolve_provider_client(
provider,
model=model,
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
)
def _try_main_fallback_chain(
task: Optional[str],
failed_provider: str = "",
reason: str = "error",
) -> Tuple[Optional[Any], Optional[str], str]:
"""Try the top-level main-agent fallback chain for an auxiliary call.
``provider: auto`` auxiliary tasks should respect the user's declared
main fallback policy before dropping into Hermes' built-in discovery
chain. The top-level chain is read through ``get_fallback_chain`` so
both modern ``fallback_providers`` and legacy ``fallback_model`` entries
participate in the same order as the main agent.
"""
try:
from hermes_cli.config import load_config
from hermes_cli.fallback_config import get_fallback_chain
chain = get_fallback_chain(load_config())
except Exception as exc:
logger.debug("Auxiliary %s: could not load main fallback chain: %s", task or "call", exc)
return None, None, ""
if not chain:
return None, None, ""
failed_norm = (failed_provider or "").strip().lower()
main_norm = (_read_main_provider() or "").strip().lower()
skip = {p for p in (failed_norm, main_norm, "auto") if p}
tried: List[str] = []
for i, entry in enumerate(chain):
if not isinstance(entry, dict):
continue
fb_provider = str(entry.get("provider") or "").strip()
fb_model = str(entry.get("model") or "").strip()
if not fb_provider or not fb_model:
continue
fb_norm = fb_provider.lower()
label = f"fallback_providers[{i}]({fb_provider})"
if fb_norm in skip:
tried.append(f"{label} (skipped)")
continue
if _is_provider_unhealthy(fb_norm):
_log_skip_unhealthy(fb_norm, task)
tried.append(f"{label} (unhealthy)")
continue
try:
fb_client, resolved_model = _resolve_fallback_entry(entry)
except Exception as exc:
logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc)
fb_client, resolved_model = None, None
if fb_client is not None:
logger.info(
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
task or "call", reason, failed_provider or "auto", label,
resolved_model or fb_model,
)
return fb_client, resolved_model or fb_model, fb_provider
tried.append(label)
if tried:
logger.debug(
"Auxiliary %s: main fallback chain exhausted (tried: %s)",
task or "call", ", ".join(tried),
)
return None, None, ""
def _resolve_single_provider( def _resolve_single_provider(
provider: str, provider: str,
model: Optional[str] = None, model: Optional[str] = None,
@ -3116,16 +3210,19 @@ def _resolve_single_provider(
Uses the existing provider resolution infrastructure where possible. Uses the existing provider resolution infrastructure where possible.
""" """
# Reuse resolve_provider_client which handles provider→client mapping # Reuse resolve_provider_client which handles provider→client mapping.
client, resolved_model = resolve_provider_client( client, resolved_model = resolve_provider_client(
provider=provider, provider=provider,
model=model, model=model,
base_url=base_url, explicit_base_url=base_url,
api_key=api_key, explicit_api_key=api_key,
) )
return client return client
def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]: def _resolve_auto(
main_runtime: Optional[Dict[str, Any]] = None,
task: Optional[str] = None,
) -> Tuple[Optional[OpenAI], Optional[str]]:
"""Full auto-detection chain. """Full auto-detection chain.
Priority: Priority:
@ -3223,7 +3320,22 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
main_provider, resolved or main_model) main_provider, resolved or main_model)
return client, resolved or main_model return client, resolved or main_model
# ── Step 2: aggregator / fallback chain ────────────────────────────── # ── Step 2: user-configured fallback policy ─────────────────────────
# In auto mode, respect the task-specific fallback chain first, then the
# main agent's top-level fallback_providers/fallback_model chain. The
# hardcoded provider discovery chain below is only the convenience default
# for users who have not declared a fallback policy.
if task:
fb_client, fb_model, _fb_label = _try_configured_fallback_chain(
task, main_provider or "auto", reason="main provider unavailable")
if fb_client is not None:
return fb_client, fb_model
fb_client, fb_model, _fb_label = _try_main_fallback_chain(
task, main_provider or "auto", reason="main provider unavailable")
if fb_client is not None:
return fb_client, fb_model
# ── Step 3: aggregator / fallback chain ──────────────────────────────
tried = [] tried = []
for label, try_fn in _get_provider_chain(): for label, try_fn in _get_provider_chain():
if _is_provider_unhealthy(label): if _is_provider_unhealthy(label):
@ -3344,6 +3456,7 @@ def resolve_provider_client(
api_mode: str = None, api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None, main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False, is_vision: bool = False,
task: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str]]: ) -> Tuple[Optional[Any], Optional[str]]:
"""Central router: given a provider name and optional model, return a """Central router: given a provider name and optional model, return a
configured client with the correct auth, base URL, and API format. configured client with the correct auth, base URL, and API format.
@ -3464,7 +3577,7 @@ def resolve_provider_client(
# ── Auto: try all providers in priority order ──────────────────── # ── Auto: try all providers in priority order ────────────────────
if provider == "auto": if provider == "auto":
client, resolved = _resolve_auto(main_runtime=main_runtime) client, resolved = _resolve_auto(main_runtime=main_runtime, task=task)
if client is None: if client is None:
return None, None return None, None
# When auto-detection lands on a non-OpenRouter provider (e.g. a # When auto-detection lands on a non-OpenRouter provider (e.g. a
@ -4357,11 +4470,16 @@ def _client_cache_key(
api_mode: Optional[str] = None, api_mode: Optional[str] = None,
main_runtime: Optional[Dict[str, Any]] = None, main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False, is_vision: bool = False,
task: Optional[str] = None,
) -> tuple: ) -> tuple:
runtime = _normalize_main_runtime(main_runtime) runtime = _normalize_main_runtime(main_runtime)
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else () runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
# `auto` can now resolve through task-specific or main fallback policy,
# so the task participates in the cache key. Non-auto providers keep the
# old cache shape because the explicit provider/model tuple is sufficient.
task_key = (task or "") if provider == "auto" else ""
pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime) pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, pool_hint) return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint)
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None: def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
@ -4554,6 +4672,7 @@ def _get_cached_client(
api_mode: str = None, api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None, main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False, is_vision: bool = False,
task: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str]]: ) -> Tuple[Optional[Any], Optional[str]]:
"""Get or create a cached client for the given provider. """Get or create a cached client for the given provider.
@ -4591,6 +4710,7 @@ def _get_cached_client(
api_mode=api_mode, api_mode=api_mode,
main_runtime=main_runtime, main_runtime=main_runtime,
is_vision=is_vision, is_vision=is_vision,
task=task,
) )
with _client_cache_lock: with _client_cache_lock:
if cache_key in _client_cache: if cache_key in _client_cache:
@ -4635,6 +4755,7 @@ def _get_cached_client(
api_mode=api_mode, api_mode=api_mode,
main_runtime=runtime, main_runtime=runtime,
is_vision=is_vision, is_vision=is_vision,
task=task,
) )
if client is not None: if client is not None:
# For async clients, remember which loop they were created on so we # For async clients, remember which loop they were created on so we
@ -5140,7 +5261,7 @@ def call_llm(
if not resolved_base_url: if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain", logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider) task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", main_runtime=main_runtime) client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
if client is None: if client is None:
raise RuntimeError( raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. " f"No LLM provider configured for task={task} provider={resolved_provider}. "
@ -5466,12 +5587,17 @@ def call_llm(
# Fallback order (#26882, #26803): # Fallback order (#26882, #26803):
# 1. User-configured fallback_chain (per-task) if set # 1. User-configured fallback_chain (per-task) if set
# 2. Main agent model (last-resort safety net) # 2. For auto: top-level main fallback_providers/fallback_model
# For auto users (no explicit aux provider), use the full # 3. For auto: built-in auxiliary discovery chain
# auto-detection chain instead — its Step 1 IS the main agent # 4. For explicit aux providers: main agent model safety net
# model, so users on `auto` already get main-model fallback.
fb_client, fb_model, fb_label = (None, None, "") fb_client, fb_model, fb_label = (None, None, "")
if is_auto: if is_auto:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_payment_fallback( fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason) resolved_provider, task, reason=reason)
else: else:
@ -5636,7 +5762,7 @@ async def async_call_llm(
if not resolved_base_url: if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain", logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider) task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", async_mode=True) client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
if client is None: if client is None:
raise RuntimeError( raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. " f"No LLM provider configured for task={task} provider={resolved_provider}. "
@ -5904,11 +6030,17 @@ async def async_call_llm(
# Fallback order (#26882, #26803): # Fallback order (#26882, #26803):
# 1. User-configured fallback_chain (per-task) if set # 1. User-configured fallback_chain (per-task) if set
# 2. Main agent model (last-resort safety net) # 2. For auto: top-level main fallback_providers/fallback_model
# Auto users get the full auto-detection chain instead — its # 3. For auto: built-in auxiliary discovery chain
# Step 1 IS the main agent model. # 4. For explicit aux providers: main agent model safety net
fb_client, fb_model, fb_label = (None, None, "") fb_client, fb_model, fb_label = (None, None, "")
if is_auto: if is_auto:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_payment_fallback( fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason) resolved_provider, task, reason=reason)
else: else:

View File

@ -300,6 +300,7 @@ def summarize_background_review_actions(
"target": args.get("target", "memory"), "target": args.get("target", "memory"),
"content": args.get("content", ""), "content": args.get("content", ""),
"old_text": args.get("old_text", ""), "old_text": args.get("old_text", ""),
"operations": args.get("operations") or [],
"name": args.get("name", ""), "name": args.get("name", ""),
"old_string": args.get("old_string", ""), "old_string": args.get("old_string", ""),
"new_string": args.get("new_string", ""), "new_string": args.get("new_string", ""),
@ -353,6 +354,7 @@ def summarize_background_review_actions(
content = detail.get("content", "") content = detail.get("content", "")
old_text = detail.get("old_text", "") old_text = detail.get("old_text", "")
skill_name = detail.get("name", "") skill_name = detail.get("name", "")
operations = detail.get("operations") or []
max_preview = 120 max_preview = 120
if is_skill: if is_skill:
change = data.get("_change", {}) change = data.get("_change", {})
@ -376,6 +378,21 @@ def summarize_background_review_actions(
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}") actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
else: else:
actions.append(f"📝 {message}" if message else f"Skill {action}") actions.append(f"📝 {message}" if message else f"Skill {action}")
elif operations:
for op in operations:
op = op or {}
op_act = op.get("action", "")
op_content = (op.get("content") or "")
op_old = (op.get("old_text") or "")
if op_act == "add" and op_content:
preview = op_content[:max_preview] + ("" if len(op_content) > max_preview else "")
actions.append(f"{label} {preview}")
elif op_act == "replace" and op_content:
preview = op_content[:max_preview] + ("" if len(op_content) > max_preview else "")
actions.append(f"{label} ✏️ {preview}")
elif op_act == "remove" and op_old:
preview = op_old[:60] + ("" if len(op_old) > 60 else "")
actions.append(f"{label} {preview}")
elif action == "add" and content: elif action == "add" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "") preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} {preview}") actions.append(f"{label} {preview}")
@ -391,6 +408,7 @@ def summarize_background_review_actions(
"added" in message_lower "added" in message_lower
or "replaced" in message_lower or "replaced" in message_lower
or "removed" in message_lower or "removed" in message_lower
or "applied" in message_lower
or (target and "add" in message.lower()) or (target and "add" in message.lower())
or "Entry added" in message or "Entry added" in message
): ):

View File

@ -262,6 +262,26 @@ def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[L
return converted or None return converted or None
# Provider-executed built-in tool *declaration* types accepted on the
# Responses ``tools`` array. These are declared by ``type`` alone (no
# client-side name/parameters schema) and run server-side — the provider
# owns the implementation and reports progress via the matching ``*_call``
# output items. Hermes injects xAI's native ``web_search`` for the xAI
# transport (see agent/transports/codex.py); the rest are listed so the
# preflight validator passes them through rather than rejecting them as
# "unsupported type". Mirrors the ``*_call`` item-type set used in
# _normalize_codex_response.
_RESPONSES_BUILTIN_TOOL_TYPES = {
"web_search",
"web_search_preview",
"file_search",
"code_interpreter",
"image_generation",
"computer_use_preview",
"local_shell",
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Message format conversion # Message format conversion
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -802,7 +822,22 @@ def _preflight_codex_api_kwargs(
for idx, tool in enumerate(tools): for idx, tool in enumerate(tools):
if not isinstance(tool, dict): if not isinstance(tool, dict):
raise ValueError(f"Codex Responses tools[{idx}] must be an object.") raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
if tool.get("type") != "function":
tool_type = tool.get("type")
# Provider-executed built-in tools (xAI native web_search, code
# interpreter, etc.) are declared by ``type`` alone and carry no
# ``name``/``parameters`` schema — the provider owns the
# implementation. Pass them through verbatim instead of forcing
# them through the function-tool validation below (which would
# otherwise reject them with "unsupported type"). See
# agent/transports/codex.py for where xAI's native web_search is
# injected.
if tool_type in _RESPONSES_BUILTIN_TOOL_TYPES:
normalized_tools.append(dict(tool))
continue
if tool_type != "function":
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.") raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
name = tool.get("name") name = tool.get("name")
@ -1086,6 +1121,33 @@ def _normalize_codex_response(
saw_final_answer_phase = False saw_final_answer_phase = False
saw_reasoning_item = False saw_reasoning_item = False
# Server-side built-in tool calls (xAI's native web_search, code
# interpreter, etc.) are executed by the provider and reported as
# discrete ``*_call`` output items. xAI's /v1/responses surface
# (e.g. grok-composer-2.5-fast on SuperGrok OAuth) routinely leaves
# these items at ``status="in_progress"`` even when the overall
# ``response.status == "completed"`` — the search ran to completion
# server-side, the per-item status simply isn't reconciled. These
# are NOT a signal that the model's turn is unfinished, so they must
# not flip ``has_incomplete_items``. Only the response-level status
# and genuine model output items (message/reasoning/function_call)
# govern the incomplete verdict. Without this guard, any turn where
# grok-composer invokes server-side search is misclassified as
# ``finish_reason="incomplete"`` and burns 3 fruitless continuation
# retries before failing with "Codex response remained incomplete
# after 3 continuation attempts". client-side function/custom tool
# calls keep their own in_progress handling below (they are skipped,
# not awaited).
_SERVER_SIDE_TOOL_CALL_TYPES = {
"web_search_call",
"file_search_call",
"code_interpreter_call",
"image_generation_call",
"computer_call",
"local_shell_call",
"mcp_call",
}
for item in output: for item in output:
item_type = getattr(item, "type", None) item_type = getattr(item, "type", None)
item_status = getattr(item, "status", None) item_status = getattr(item, "status", None)
@ -1094,7 +1156,10 @@ def _normalize_codex_response(
else: else:
item_status = None item_status = None
if item_status in {"queued", "in_progress", "incomplete"}: if (
item_status in {"queued", "in_progress", "incomplete"}
and item_type not in _SERVER_SIDE_TOOL_CALL_TYPES
):
has_incomplete_items = True has_incomplete_items = True
saw_streaming_or_item_incomplete = True saw_streaming_or_item_incomplete = True

View File

@ -603,6 +603,20 @@ def compress_context(
force=True, force=True,
) )
# Emit session:compress event so hooks (e.g. MemPalace sync) can ingest
# the completed old session before its details are lost.
_old_sid_for_event = locals().get("old_session_id")
if getattr(agent, "event_callback", None):
try:
agent.event_callback("session:compress", {
"platform": agent.platform or "",
"session_id": agent.session_id,
"old_session_id": _old_sid_for_event or "",
"compression_count": agent.context_compressor.compression_count,
})
except Exception as e:
logger.debug("event_callback error on session:compress: %s", e)
# Keep the post-compression rough estimate for diagnostics, but do not # Keep the post-compression rough estimate for diagnostics, but do not
# treat it as provider-reported prompt usage. Schema-heavy rough estimates # treat it as provider-reported prompt usage. Schema-heavy rough estimates
# can remain above threshold even after the next real API request fits. # can remain above threshold even after the next real API request fits.

View File

@ -300,11 +300,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
agent.session_id, exc, agent.session_id, exc,
) )
if stored_prompt: if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt):
# Continuing session — reuse the exact system prompt from the # Continuing session — reuse the exact system prompt from the
# previous turn so the Anthropic cache prefix matches. # previous turn so the Anthropic cache prefix matches.
agent._cached_system_prompt = stored_prompt agent._cached_system_prompt = stored_prompt
return return
if stored_prompt:
stored_state = "stale_runtime"
logger.info(
"Stored system prompt for session %s has stale runtime identity; "
"rebuilding for model=%s provider=%s.",
agent.session_id,
getattr(agent, "model", "") or "",
getattr(agent, "provider", "") or "",
)
if conversation_history and stored_state in ("null", "empty"): if conversation_history and stored_state in ("null", "empty"):
# Continuing session whose stored prompt is unusable. The # Continuing session whose stored prompt is unusable. The
@ -366,6 +375,30 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
) )
def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
"""Return False when the persisted Model/Provider lines are stale."""
def line_value(label: str) -> str:
prefix = f"{label}:"
value = ""
for line in prompt.splitlines():
if line.startswith(prefix):
value = line[len(prefix):].strip()
return value
stored_model = line_value("Model")
current_model = str(getattr(agent, "model", "") or "").strip()
if stored_model and current_model and stored_model != current_model:
return False
stored_provider = line_value("Provider")
current_provider = str(getattr(agent, "provider", "") or "").strip()
if stored_provider and current_provider and stored_provider != current_provider:
return False
return True
def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str: def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:
if is_partial_stub and dropped_tools: if is_partial_stub and dropped_tools:
tool_list = ", ".join(dropped_tools[:3]) tool_list = ", ".join(dropped_tools[:3])
@ -441,6 +474,7 @@ def run_conversation(
task_id: str = None, task_id: str = None,
stream_callback: Optional[callable] = None, stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None, persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
moa_config: Optional[dict[str, Any]] = None, moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
@ -457,6 +491,8 @@ def run_conversation(
persist_user_message: Optional clean user message to store in persist_user_message: Optional clean user message to store in
transcripts/history when user_message contains API-only transcripts/history when user_message contains API-only
synthetic prefixes. synthetic prefixes.
persist_user_timestamp: Optional platform event timestamp to store
as metadata on that persisted user message.
or queuing follow-up prefetch work. or queuing follow-up prefetch work.
Returns: Returns:
@ -491,6 +527,7 @@ def run_conversation(
task_id, task_id,
stream_callback, stream_callback,
persist_user_message, persist_user_message,
persist_user_timestamp,
restore_or_build_system_prompt=_restore_or_build_system_prompt, restore_or_build_system_prompt=_restore_or_build_system_prompt,
install_safe_stdio=_install_safe_stdio, install_safe_stdio=_install_safe_stdio,
sanitize_surrogates=_sanitize_surrogates, sanitize_surrogates=_sanitize_surrogates,
@ -3756,8 +3793,30 @@ def run_conversation(
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason) assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(assistant_msg) messages.append(assistant_msg)
for tc in assistant_message.tool_calls: for tc in assistant_message.tool_calls:
if tc.function.name not in agent.valid_tool_names: _tc_name = tc.function.name
content = f"Tool '{tc.function.name}' does not exist. Available tools: {available}" if _tc_name not in agent.valid_tool_names:
# A blank/whitespace-only name is not a typo the
# model can fuzzy-correct toward a real tool — it is
# almost always a weak open model echoing tool-call
# XML/JSON it saw in file or tool output (#47967:
# <tool_call>/<invoke name=...> payloads in a file
# prime mimo/nemotron-class models to emit empty
# structured calls). Dumping the full tool catalog
# in that case feeds the priming loop more names to
# mimic and inflates context 3-4x across retries, so
# send a terse error that tells the model in-context
# tool-call syntax is DATA, not a call to make.
if not (_tc_name or "").strip():
content = (
"Tool call rejected: the tool name was empty. "
"If tool-call XML or JSON appeared in file "
"contents or tool output, that is data — do "
"not re-emit it as a tool call. To call a "
"tool, use a valid name from your tool list; "
"otherwise reply in plain text."
)
else:
content = f"Tool '{_tc_name}' does not exist. Available tools: {available}"
else: else:
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call." content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
messages.append({ messages.append({

View File

@ -57,6 +57,11 @@ DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
DEFAULT_MIN_IDLE_HOURS = 2 DEFAULT_MIN_IDLE_HOURS = 2
DEFAULT_STALE_AFTER_DAYS = 30 DEFAULT_STALE_AFTER_DAYS = 30
DEFAULT_ARCHIVE_AFTER_DAYS = 90 DEFAULT_ARCHIVE_AFTER_DAYS = 90
# Consolidation (the LLM umbrella-building fork) is OFF by default. The
# deterministic inactivity prune (apply_automatic_transitions) still runs
# whenever the curator is enabled; only the opinionated, aux-model-cost
# consolidation pass is opt-in.
DEFAULT_CONSOLIDATE = False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -182,6 +187,22 @@ def get_prune_builtins() -> bool:
return bool(cfg.get("prune_builtins", True)) return bool(cfg.get("prune_builtins", True))
def get_consolidate() -> bool:
"""Whether the curator runs its LLM consolidation (umbrella-building) pass.
OFF by default. When off, a curator run does ONLY the deterministic
inactivity prune (mark stale / archive long-unused skills) and skips the
forked aux-model review entirely no consolidation, no umbrella-building,
no aux-model cost. Set ``curator.consolidate: true`` to opt back into the
LLM pass that merges overlapping skills into class-level umbrellas.
The explicit ``hermes curator run --consolidate`` flag overrides this for
a single invocation regardless of the config value.
"""
cfg = _load_config()
return bool(cfg.get("consolidate", DEFAULT_CONSOLIDATE))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Idle / interval check # Idle / interval check
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -1408,25 +1429,38 @@ def run_curator_review(
on_summary: Optional[Callable[[str], None]] = None, on_summary: Optional[Callable[[str], None]] = None,
synchronous: bool = False, synchronous: bool = False,
dry_run: bool = False, dry_run: bool = False,
consolidate: Optional[bool] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Execute a single curator review pass. """Execute a single curator review pass.
Steps: Steps:
1. Apply automatic state transitions (pure, no LLM). 1. Apply automatic state transitions (pure, no LLM).
2. If there are agent-created skills, spawn a forked AIAgent that runs 2. If consolidation is enabled AND there are agent-created skills, spawn
the LLM review prompt against the current candidate list. a forked AIAgent that runs the LLM review prompt against the current
candidate list.
3. Update .curator_state with last_run_at and a one-line summary. 3. Update .curator_state with last_run_at and a one-line summary.
4. Invoke *on_summary* with a user-visible description. 4. Invoke *on_summary* with a user-visible description.
If *synchronous* is True, the LLM review runs in the calling thread; the If *synchronous* is True, the LLM review runs in the calling thread; the
default is to spawn a daemon thread so the caller returns immediately. default is to spawn a daemon thread so the caller returns immediately.
*consolidate* gates the LLM umbrella-building pass. ``None`` (the default)
reads ``curator.consolidate`` from config (OFF by default). Passing
``True``/``False`` overrides the config for this invocation used by the
``hermes curator run --consolidate`` flag. When consolidation is off, only
the deterministic inactivity prune runs and the forked aux-model review is
skipped entirely (no aux-model cost).
If *dry_run* is True, the automatic stale/archive transitions are SKIPPED If *dry_run* is True, the automatic stale/archive transitions are SKIPPED
and the LLM review pass is instructed to produce a report only no and the LLM review pass is instructed to produce a report only no
skill_manage mutations, no terminal archive moves. The REPORT.md still skill_manage mutations, no terminal archive moves. The REPORT.md still
gets written and ``state.last_report_path`` still records it so users gets written and ``state.last_report_path`` still records it so users
can read what the curator WOULD have done. can read what the curator WOULD have done. A dry-run also honors
*consolidate*: when consolidation is off, the preview only reports the
deterministic prune candidates.
""" """
if consolidate is None:
consolidate = get_consolidate()
start = datetime.now(timezone.utc) start = datetime.now(timezone.utc)
if dry_run: if dry_run:
# Count candidates without mutating state. # Count candidates without mutating state.
@ -1489,6 +1523,53 @@ def run_curator_review(
before_report = [] before_report = []
before_names = {r.get("name") for r in before_report if isinstance(r, dict)} before_names = {r.get("name") for r in before_report if isinstance(r, dict)}
# Consolidation gate. When off (the default), the curator does ONLY the
# deterministic inactivity prune above — no forked aux-model review, no
# umbrella-building, no aux-model cost. Record the run, write a report
# reflecting the prune-only outcome, and return without spawning a fork.
if not consolidate:
final_summary = (
f"{prefix}{auto_summary}; llm: skipped (consolidation off)"
)
llm_meta = {
"final": "",
"summary": "skipped (consolidation off)",
"model": "",
"provider": "",
"tool_calls": [],
"error": None,
}
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
state2 = load_state()
state2["last_run_duration_seconds"] = elapsed
state2["last_run_summary"] = final_summary
try:
after_report = skill_usage.agent_created_report()
except Exception:
after_report = []
try:
report_path = _write_run_report(
started_at=start,
elapsed_seconds=elapsed,
auto_counts=counts,
auto_summary=auto_summary,
before_report=before_report,
before_names=before_names,
after_report=after_report,
llm_meta=llm_meta,
)
if report_path is not None:
state2["last_report_path"] = str(report_path)
except Exception as e:
logger.debug("Curator report write failed: %s", e, exc_info=True)
save_state(state2)
if on_summary:
try:
on_summary(f"curator: {final_summary}")
except Exception:
pass
return
llm_meta: Dict[str, Any] = {} llm_meta: Dict[str, Any] = {}
try: try:
candidate_list = _render_candidate_list() candidate_list = _render_candidate_list()

View File

@ -46,7 +46,7 @@ import shutil
import tarfile import tarfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
from agent.skill_utils import is_excluded_skill_path from agent.skill_utils import is_excluded_skill_path
@ -208,13 +208,17 @@ def _write_manifest(dest: Path, reason: str, archive_path: Path,
) )
def snapshot_skills(reason: str = "manual") -> Optional[Path]: def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] = None) -> Optional[Path]:
"""Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones. """Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones.
Returns the snapshot directory path, or ``None`` if the snapshot was Returns the snapshot directory path, or ``None`` if the snapshot was
skipped (backup disabled, skills dir missing, or an IO error occurred skipped (backup disabled, skills dir missing, or an IO error occurred
in which case we log at debug and return None so the curator never in which case we log at debug and return None so the curator never
aborts a pass because of a backup failure). aborts a pass because of a backup failure).
``protect_ids`` is forwarded to the prune step so callers can guarantee
specific snapshot ids survive even when they fall outside the keep
window (rollback passes the id it is about to restore from).
""" """
if not is_enabled(): if not is_enabled():
logger.debug("Curator backup disabled by config; skipping snapshot") logger.debug("Curator backup disabled by config; skipping snapshot")
@ -276,15 +280,19 @@ def snapshot_skills(reason: str = "manual") -> Optional[Path]:
pass pass
return None return None
_prune_old(keep=get_keep()) _prune_old(keep=get_keep(), protect=protect_ids)
logger.info("Curator snapshot created: %s (%s)", snap_id, reason) logger.info("Curator snapshot created: %s (%s)", snap_id, reason)
return dest return dest
def _prune_old(keep: int) -> List[str]: def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
"""Delete regular snapshots beyond the newest *keep*. Returns deleted """Delete regular snapshots beyond the newest *keep*. Returns deleted
ids. Staging dirs (``.rollback-staging-*``) are implementation detail ids. Snapshot ids in *protect* are never deleted even when they fall
and pruned independently on every call.""" outside the keep window rollback() uses this so the mandatory
pre-rollback safety snapshot can never evict the very snapshot being
restored. Staging dirs (``.rollback-staging-*``) are implementation
detail and pruned independently on every call."""
protect = protect or set()
backups = _backups_dir() backups = _backups_dir()
if not backups.exists(): if not backups.exists():
return [] return []
@ -305,6 +313,8 @@ def _prune_old(keep: int) -> List[str]:
entries.sort(key=lambda t: t[0], reverse=True) entries.sort(key=lambda t: t[0], reverse=True)
deleted: List[str] = [] deleted: List[str] = []
for _, path in entries[keep:]: for _, path in entries[keep:]:
if path.name in protect:
continue
try: try:
shutil.rmtree(path) shutil.rmtree(path)
deleted.append(path.name) deleted.append(path.name)
@ -564,7 +574,13 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
# out before touching anything — otherwise a failed extract could leave # out before touching anything — otherwise a failed extract could leave
# the user with no skills. # the user with no skills.
try: try:
snapshot_skills(reason=f"pre-rollback to {target.name}") # Protect the target from this snapshot's prune step: at the steady
# keep limit, pruning the oldest snapshot would otherwise delete the
# very snapshot we are about to extract from.
snapshot_skills(
reason=f"pre-rollback to {target.name}",
protect_ids={target.name},
)
except Exception as e: except Exception as e:
return (False, f"pre-rollback safety snapshot failed: {e}", None) return (False, f"pre-rollback safety snapshot failed: {e}", None)

View File

@ -33,6 +33,7 @@ from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider from agent.memory_provider import MemoryProvider
from agent.skill_commands import extract_user_instruction_from_skill_message
from tools.registry import tool_error from tools.registry import tool_error
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -430,16 +431,37 @@ class MemoryManager:
# -- Prefetch / recall --------------------------------------------------- # -- Prefetch / recall ---------------------------------------------------
@staticmethod
def _strip_skill_scaffolding(text: str) -> Optional[str]:
"""Return memory-worthy user text, or None to skip the turn.
When a user invokes a /skill or /bundle, Hermes expands the turn into
a model-facing message that embeds the entire skill body. Feeding that
verbatim to memory providers pollutes their stores/embeddings with
prompt scaffolding instead of what the user actually asked. We recover
just the user's instruction here, once, for every provider — so this
is fixed for the whole provider fan-out, not per backend.
- Non-skill messages pass through unchanged.
- Skill turns with a user instruction return that instruction.
- Bare skill invocations (no instruction) return None callers skip
the turn, since there is no user content worth remembering.
"""
return extract_user_instruction_from_skill_message(text)
def prefetch_all(self, query: str, *, session_id: str = "") -> str: def prefetch_all(self, query: str, *, session_id: str = "") -> str:
"""Collect prefetch context from all providers. """Collect prefetch context from all providers.
Returns merged context text labeled by provider. Empty providers Returns merged context text labeled by provider. Empty providers
are skipped. Failures in one provider don't block others. are skipped. Failures in one provider don't block others.
""" """
clean_query = self._strip_skill_scaffolding(query)
if not clean_query:
return ""
parts = [] parts = []
for provider in self._providers: for provider in self._providers:
try: try:
result = provider.prefetch(query, session_id=session_id) result = provider.prefetch(clean_query, session_id=session_id)
if result and result.strip(): if result and result.strip():
parts.append(result) parts.append(result)
except Exception as e: except Exception as e:
@ -460,10 +482,14 @@ class MemoryManager:
if not providers: if not providers:
return return
clean_query = self._strip_skill_scaffolding(query)
if not clean_query:
return
def _run() -> None: def _run() -> None:
for provider in providers: for provider in providers:
try: try:
provider.queue_prefetch(query, session_id=session_id) provider.queue_prefetch(clean_query, session_id=session_id)
except Exception as e: except Exception as e:
logger.debug( logger.debug(
"Memory provider '%s' queue_prefetch failed (non-fatal): %s", "Memory provider '%s' queue_prefetch failed (non-fatal): %s",
@ -515,6 +541,11 @@ class MemoryManager:
if not providers: if not providers:
return return
clean_user_content = self._strip_skill_scaffolding(user_content)
if not clean_user_content:
return
user_content = clean_user_content
def _run() -> None: def _run() -> None:
for provider in providers: for provider in providers:
try: try:

View File

@ -275,6 +275,11 @@ DEFAULT_CONTEXT_LENGTHS = {
# via a custom provider. Values sourced from models.dev (2026-04). # via a custom provider. Values sourced from models.dev (2026-04).
# Keys use substring matching (longest-first), so e.g. "grok-4.20" # Keys use substring matching (longest-first), so e.g. "grok-4.20"
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309". # matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
# OAuth-only slug; absent from GET /v1/models. xAI publishes a 200k
# usable context window for Composer 2.5 on Grok Build (SuperGrok /
# Premium+); /v1/responses additionally enforces a ~262144 input+output
# budget, but the usable context (what we track here) is 200k.
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
"grok-build": 256000, # grok-build-0.1 "grok-build": 256000, # grok-build-0.1
"grok-code-fast": 256000, # grok-code-fast-1 "grok-code-fast": 256000, # grok-code-fast-1
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest "grok-2-vision": 8192, # grok-2-vision, -1212, -latest

View File

@ -8,6 +8,7 @@ import json
import logging import logging
import os import os
import threading import threading
import contextvars
from collections import OrderedDict from collections import OrderedDict
from pathlib import Path from pathlib import Path
@ -304,6 +305,47 @@ TASK_COMPLETION_GUIDANCE = (
"is always better than inventing a result." "is always better than inventing a result."
) )
# Universal parallel-tool-call guidance — applied to ALL models.
#
# Why this matters for cost: every assistant turn resends the entire
# accumulated conversation (and, on cache-friendly providers, re-reads the
# cached prefix and pays for the newly-appended turn). A model that issues
# one tool call per turn multiplies the number of round-trips — and therefore
# the resent context — for any task that needs several independent reads,
# searches, or safe lookups. Batching independent calls into a single
# assistant response collapses N turns into one, cutting both latency and the
# resent-context cost that compounds over a long conversation.
#
# The hermes-agent runtime already executes a batch of tool calls
# concurrently when they are independent (read-only tools always; path-scoped
# file ops when their targets don't overlap — see
# run_agent._execute_tool_calls / tool_dispatch_helpers). The missing piece
# was telling the *model* to emit those calls together in the first place.
# Until now the only batching steer in the prompt lived in
# GOOGLE_MODEL_OPERATIONAL_GUIDANCE — Gemini/Gemma got it, every other model
# got nothing. This block makes the steer universal; the now-redundant
# Google-only bullet has been dropped so no model receives it twice.
#
# Short on purpose — shipped in the cached system prompt to every user, every
# session. Token cost is paid once at install and amortised across all
# sessions via prefix caching. Keep it tight.
#
# Ported from cline/cline#11514 ("encourage parallel tool calls"), adapted
# from Cline's TypeScript tool-surface guidance to hermes-agent's Python
# prompt-assembly architecture.
PARALLEL_TOOL_CALL_GUIDANCE = (
"# Parallel tool calls\n"
"When you need several pieces of information that don't depend on each "
"other, request them together in a single response instead of one tool "
"call per turn. Independent reads, searches, web fetches, and read-only "
"commands should be batched into the same assistant turn — the runtime "
"executes independent calls concurrently, and batching avoids resending "
"the whole conversation on every extra round-trip.\n"
"Only serialize calls when a later call genuinely depends on an earlier "
"call's result (e.g. you must read a file before you can patch it). When "
"in doubt and the calls are independent, batch them."
)
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes # OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
# where GPT models abandon work on partial results, skip prerequisite lookups, # where GPT models abandon work on partial results, skip prerequisite lookups,
# hallucinate instead of using tools, and declare "done" without verification. # hallucinate instead of using tools, and declare "done" without verification.
@ -385,9 +427,10 @@ GOOGLE_MODEL_OPERATIONAL_GUIDANCE = (
"package.json, requirements.txt, Cargo.toml, etc. before importing.\n" "package.json, requirements.txt, Cargo.toml, etc. before importing.\n"
"- **Conciseness:** Keep explanatory text brief — a few sentences, not " "- **Conciseness:** Keep explanatory text brief — a few sentences, not "
"paragraphs. Focus on actions and results over narration.\n" "paragraphs. Focus on actions and results over narration.\n"
"- **Parallel tool calls:** When you need to perform multiple independent " # Parallel-tool-call steering now lives in the universal
"operations (e.g. reading several files), make all the tool calls in a " # PARALLEL_TOOL_CALL_GUIDANCE block (injected for all models), so it is no
"single response rather than sequentially.\n" # longer duplicated here — keeping it would send Gemini/Gemma the same
# instruction twice.
"- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive " "- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
"to prevent CLI tools from hanging on prompts.\n" "to prevent CLI tools from hanging on prompts.\n"
"- **Keep going:** Work autonomously until the task is fully resolved. " "- **Keep going:** Work autonomously until the task is fully resolved. "
@ -957,6 +1000,80 @@ CONTEXT_FILE_MAX_CHARS = 20_000
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7 CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2 CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
# Dynamic-cap parameters (used when no explicit context_file_max_chars is set).
# The cap scales with the model's context window so large-context models rarely
# truncate a project doc, while small-context models stay at the historical
# 20K floor. ~4 chars/token is the usual English heuristic; we spend a small
# slice of the window on context files since they share the cached prefix with
# the system prompt, tools, memory, and the whole conversation.
_CONTEXT_FILE_CHARS_PER_TOKEN = 4
_CONTEXT_FILE_WINDOW_FRACTION = 0.06
_CONTEXT_FILE_DYNAMIC_CEILING = 500_000
def _dynamic_context_file_max_chars(context_length: Optional[int]) -> int:
"""Derive a char cap from the model's context window.
Returns at least ``CONTEXT_FILE_MAX_CHARS`` (the historical 20K floor) and
at most ``_CONTEXT_FILE_DYNAMIC_CEILING``. When ``context_length`` is
unknown/invalid, returns the flat default so behavior is unchanged.
"""
if not isinstance(context_length, int) or context_length <= 0:
return CONTEXT_FILE_MAX_CHARS
budget = int(
context_length * _CONTEXT_FILE_CHARS_PER_TOKEN * _CONTEXT_FILE_WINDOW_FRACTION
)
return max(CONTEXT_FILE_MAX_CHARS, min(budget, _CONTEXT_FILE_DYNAMIC_CEILING))
def _get_context_file_max_chars(context_length: Optional[int] = None) -> int:
"""Return the context-file truncation limit.
Resolution order:
1. Explicit ``context_file_max_chars`` in config.yaml user knows best,
always wins (including over the dynamic cap).
2. Dynamic cap derived from the model's ``context_length`` when provided
(scales the budget to the window; floor 20K, ceiling 500K).
3. ``CONTEXT_FILE_MAX_CHARS`` (20K) as the upstream-compatible fallback.
"""
try:
from hermes_cli.config import load_config
val = load_config().get("context_file_max_chars")
if isinstance(val, (int, float)) and val > 0:
return int(val)
except Exception as e:
logger.debug("Could not read context_file_max_chars from config: %s", e)
return _dynamic_context_file_max_chars(context_length)
# Collect truncation warnings so the caller (run_agent) can surface them.
# A ContextVar (not a module-global list) isolates accumulation per thread /
# per async task, so concurrent gateway-session prompt builds can't drain or
# clear each other's pending warnings (cross-session leak). Each build runs in
# its own context, collects its own warnings, and drains them synchronously.
_truncation_warnings: "contextvars.ContextVar[Optional[list]]" = contextvars.ContextVar(
"context_file_truncation_warnings", default=None
)
def _record_truncation_warning(msg: str) -> None:
"""Append a truncation warning to the current context's accumulator."""
warnings = _truncation_warnings.get()
if warnings is None:
warnings = []
_truncation_warnings.set(warnings)
warnings.append(msg)
def drain_truncation_warnings() -> list:
"""Return and clear any truncation warnings accumulated in this context."""
warnings = _truncation_warnings.get()
if not warnings:
return []
drained = list(warnings)
warnings.clear()
return drained
# ========================================================================= # =========================================================================
# Skills prompt cache # Skills prompt cache
@ -1463,19 +1580,47 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
# Context files (SOUL.md, AGENTS.md, .cursorrules) # Context files (SOUL.md, AGENTS.md, .cursorrules)
# ========================================================================= # =========================================================================
def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str: def _truncate_content(
"""Head/tail truncation with a marker in the middle.""" content: str,
filename: str,
max_chars: Optional[int] = None,
context_length: Optional[int] = None,
read_path: Optional[str] = None,
) -> str:
"""Head/tail truncation with a marker in the middle.
``filename`` is the human label used in warnings. ``read_path`` is the
concrete path the agent should ``read_file`` to recover the full content
(defaults to ``filename`` when not supplied). ``context_length`` lets the
cap scale to the model's window when no explicit config override is set.
"""
if max_chars is None:
max_chars = _get_context_file_max_chars(context_length)
if len(content) <= max_chars: if len(content) <= max_chars:
return content return content
target = read_path or filename
msg = (
f"⚠️ Context file {filename} TRUNCATED: "
f"{len(content)} chars exceeds limit of {max_chars}"
f"trim the file, pin a larger context_file_max_chars, or use a "
f"larger-context model!"
)
logger.warning(msg)
_record_truncation_warning(msg)
head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO) head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO)
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO) tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
head = content[:head_chars] head = content[:head_chars]
tail = content[-tail_chars:] tail = content[-tail_chars:]
marker = f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of {len(content)} chars. Use file tools to read the full file.]\n\n" marker = (
f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of "
f"{len(content)} chars. The middle is omitted — if you need the full "
f"instructions, read the complete file with the read_file tool: "
f"{target}]\n\n"
)
return head + marker + tail return head + marker + tail
def load_soul_md() -> Optional[str]: def load_soul_md(context_length: Optional[int] = None) -> Optional[str]:
"""Load SOUL.md from HERMES_HOME and return its content, or None. """Load SOUL.md from HERMES_HOME and return its content, or None.
Used as the agent identity (slot #1 in the system prompt). When this Used as the agent identity (slot #1 in the system prompt). When this
@ -1496,14 +1641,17 @@ def load_soul_md() -> Optional[str]:
if not content: if not content:
return None return None
content = _scan_context_content(content, "SOUL.md") content = _scan_context_content(content, "SOUL.md")
content = _truncate_content(content, "SOUL.md") content = _truncate_content(
content, "SOUL.md", context_length=context_length,
read_path=str(soul_path),
)
return content return content
except Exception as e: except Exception as e:
logger.debug("Could not read SOUL.md from %s: %s", soul_path, e) logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
return None return None
def _load_hermes_md(cwd_path: Path) -> str: def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
""".hermes.md / HERMES.md — walk to git root.""" """.hermes.md / HERMES.md — walk to git root."""
hermes_md_path = _find_hermes_md(cwd_path) hermes_md_path = _find_hermes_md(cwd_path)
if not hermes_md_path: if not hermes_md_path:
@ -1520,13 +1668,16 @@ def _load_hermes_md(cwd_path: Path) -> str:
pass pass
content = _scan_context_content(content, rel) content = _scan_context_content(content, rel)
result = f"## {rel}\n\n{content}" result = f"## {rel}\n\n{content}"
return _truncate_content(result, ".hermes.md") return _truncate_content(
result, ".hermes.md", context_length=context_length,
read_path=str(hermes_md_path),
)
except Exception as e: except Exception as e:
logger.debug("Could not read %s: %s", hermes_md_path, e) logger.debug("Could not read %s: %s", hermes_md_path, e)
return "" return ""
def _load_agents_md(cwd_path: Path) -> str: def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
"""AGENTS.md — top-level only (no recursive walk).""" """AGENTS.md — top-level only (no recursive walk)."""
for name in ["AGENTS.md", "agents.md"]: for name in ["AGENTS.md", "agents.md"]:
candidate = cwd_path / name candidate = cwd_path / name
@ -1536,13 +1687,16 @@ def _load_agents_md(cwd_path: Path) -> str:
if content: if content:
content = _scan_context_content(content, name) content = _scan_context_content(content, name)
result = f"## {name}\n\n{content}" result = f"## {name}\n\n{content}"
return _truncate_content(result, "AGENTS.md") return _truncate_content(
result, "AGENTS.md", context_length=context_length,
read_path=str(candidate),
)
except Exception as e: except Exception as e:
logger.debug("Could not read %s: %s", candidate, e) logger.debug("Could not read %s: %s", candidate, e)
return "" return ""
def _load_claude_md(cwd_path: Path) -> str: def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
"""CLAUDE.md / claude.md — cwd only.""" """CLAUDE.md / claude.md — cwd only."""
for name in ["CLAUDE.md", "claude.md"]: for name in ["CLAUDE.md", "claude.md"]:
candidate = cwd_path / name candidate = cwd_path / name
@ -1552,13 +1706,16 @@ def _load_claude_md(cwd_path: Path) -> str:
if content: if content:
content = _scan_context_content(content, name) content = _scan_context_content(content, name)
result = f"## {name}\n\n{content}" result = f"## {name}\n\n{content}"
return _truncate_content(result, "CLAUDE.md") return _truncate_content(
result, "CLAUDE.md", context_length=context_length,
read_path=str(candidate),
)
except Exception as e: except Exception as e:
logger.debug("Could not read %s: %s", candidate, e) logger.debug("Could not read %s: %s", candidate, e)
return "" return ""
def _load_cursorrules(cwd_path: Path) -> str: def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> str:
""".cursorrules + .cursor/rules/*.mdc — cwd only.""" """.cursorrules + .cursor/rules/*.mdc — cwd only."""
cursorrules_content = "" cursorrules_content = ""
cursorrules_file = cwd_path / ".cursorrules" cursorrules_file = cwd_path / ".cursorrules"
@ -1585,10 +1742,17 @@ def _load_cursorrules(cwd_path: Path) -> str:
if not cursorrules_content: if not cursorrules_content:
return "" return ""
return _truncate_content(cursorrules_content, ".cursorrules") return _truncate_content(
cursorrules_content, ".cursorrules", context_length=context_length,
read_path=str(cwd_path / ".cursorrules"),
)
def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = False) -> str: def build_context_files_prompt(
cwd: Optional[str] = None,
skip_soul: bool = False,
context_length: Optional[int] = None,
) -> str:
"""Discover and load context files for the system prompt. """Discover and load context files for the system prompt.
Priority (first found wins only ONE project context type is loaded): Priority (first found wins only ONE project context type is loaded):
@ -1598,7 +1762,11 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals
4. .cursorrules / .cursor/rules/*.mdc (cwd only) 4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME is independent and always included when present. SOUL.md from HERMES_HOME is independent and always included when present.
Each context source is capped at 20,000 chars.
Each context source is capped before injection. The cap defaults to the
model's context window (scaled — see ``_dynamic_context_file_max_chars``)
when *context_length* is provided, falling back to 20,000 chars otherwise.
An explicit ``context_file_max_chars`` in config.yaml always wins.
When *skip_soul* is True, SOUL.md is not included here (it was already When *skip_soul* is True, SOUL.md is not included here (it was already
loaded via ``load_soul_md()`` for the identity slot). loaded via ``load_soul_md()`` for the identity slot).
@ -1611,17 +1779,17 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals
# Priority-based project context: first match wins # Priority-based project context: first match wins
project_context = ( project_context = (
_load_hermes_md(cwd_path) _load_hermes_md(cwd_path, context_length)
or _load_agents_md(cwd_path) or _load_agents_md(cwd_path, context_length)
or _load_claude_md(cwd_path) or _load_claude_md(cwd_path, context_length)
or _load_cursorrules(cwd_path) or _load_cursorrules(cwd_path, context_length)
) )
if project_context: if project_context:
sections.append(project_context) sections.append(project_context)
# SOUL.md from HERMES_HOME only — skip when already loaded as identity # SOUL.md from HERMES_HOME only — skip when already loaded as identity
if not skip_soul: if not skip_soul:
soul_content = load_soul_md() soul_content = load_soul_md(context_length)
if soul_content: if soul_content:
sections.append(soul_content) sections.append(soul_content)

View File

@ -26,6 +26,91 @@ _skill_commands_platform: Optional[str] = None
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]") _SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}") _SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
# ---------------------------------------------------------------------------
# Skill-scaffolding markers and the canonical extractor.
#
# When a user invokes a /skill (or /bundle), Hermes expands the turn into a
# model-facing message that embeds the full skill body plus scaffolding. That
# expanded text is what flows into the agent loop — and into memory providers
# via MemoryManager. Providers that store or embed the raw user turn (mem0,
# openviking, hindsight, retaindb, byterover, honcho, supermemory) would
# otherwise capture the entire skill body instead of what the user actually
# asked. ``extract_user_instruction_from_skill_message`` recovers just the
# user's instruction so memory stays clean.
#
# These markers MUST stay byte-identical to the builders below
# (``_build_skill_message`` here, ``build_bundle_invocation_message`` in
# agent/skill_bundles.py). They are co-located with the single-skill builder
# on purpose, and the bundle markers are asserted against the bundle builder in
# tests/openviking_plugin/test_openviking.py::test_skill_markers_match_hermes_scaffolding.
# ---------------------------------------------------------------------------
_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the "
_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]"
_SINGLE_SKILL_INSTRUCTION = (
"The user has provided the following instruction alongside the skill invocation: "
)
_RUNTIME_NOTE = "\n\n[Runtime note:"
_BUNDLE_MARKER = " skill bundle,"
_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
"""Recover the user's instruction from a slash-skill-expanded turn.
Returns:
- The original string unchanged when it is NOT skill scaffolding
(a normal user message passes straight through).
- The extracted user instruction when the scaffolding carried one.
- ``None`` when the content is skill scaffolding with no user
instruction (i.e. a bare ``/skill`` invocation). Callers that feed
memory providers should skip the turn in that case there is no
user content worth storing.
"""
if not isinstance(content, str):
return None
if not content.startswith(_SKILL_INVOCATION_PREFIX):
return content
if _BUNDLE_MARKER in content:
return _extract_bundle_user_instruction(content)
if _SINGLE_SKILL_MARKER in content:
return _extract_single_skill_user_instruction(content)
return None
def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
# Single-skill format appends the user instruction after the skill body, so
# the last occurrence is the user-provided one; the body may quote this text.
marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION)
if marker_idx < 0:
return None
instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):]
runtime_idx = instruction.find(_RUNTIME_NOTE)
if runtime_idx >= 0:
instruction = instruction[:runtime_idx]
instruction = instruction.strip()
return instruction or None
def _extract_bundle_user_instruction(message: str) -> Optional[str]:
# Bundle format puts the user instruction before the loaded skills, so the
# first occurrence is the user-provided one.
marker_idx = message.find(_BUNDLE_USER_INSTRUCTION)
if marker_idx < 0:
return None
instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):]
first_skill_idx = instruction.find(_BUNDLE_FIRST_SKILL_BLOCK)
if first_skill_idx >= 0:
instruction = instruction[:first_skill_idx]
instruction = instruction.strip()
return instruction or None
def _resolve_skill_commands_platform() -> Optional[str]: def _resolve_skill_commands_platform() -> Optional[str]:
"""Return the current platform scope used for disabled-skill filtering. """Return the current platform scope used for disabled-skill filtering.

View File

@ -43,14 +43,20 @@ EXCLUDED_SKILL_DIRS = frozenset(
) )
) )
# Supporting files live inside a skill package and are loaded explicitly via
# skill_view(skill, file_path=...). They are not standalone skills and must not
# be scanned for active SKILL.md/DESCRIPTION.md entries, even if a Curator or
# archive workflow preserves a complete old skill package under references/.
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
def is_excluded_skill_path(path) -> bool: def is_excluded_skill_path(path) -> bool:
"""True if any component of *path* is in EXCLUDED_SKILL_DIRS. """True if *path* should be skipped by active skill scanners.
Use this on every SKILL.md path produced by ``rglob`` to prune Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
dependency, virtualenv, VCS, and cache directories. Centralising the prune dependency, virtualenv, VCS, cache, and progressive-disclosure
check here keeps every skill-scanning site in sync with the shared support-package paths. Centralising the check here keeps every
exclusion set. skill-scanning site in sync with the shared exclusion set.
Accepts a Path or string. Accepts a Path or string.
""" """
@ -59,7 +65,36 @@ def is_excluded_skill_path(path) -> bool:
except AttributeError: except AttributeError:
from pathlib import PurePath from pathlib import PurePath
parts = PurePath(str(path)).parts parts = PurePath(str(path)).parts
return any(part in EXCLUDED_SKILL_DIRS for part in parts) return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path(
path
)
def is_skill_support_path(path) -> bool:
"""True if *path* is under a support dir of an actual skill root.
``references/``, ``templates/``, ``assets/``, and ``scripts/`` are
progressive-disclosure support areas when they sit directly inside a skill
directory containing ``SKILL.md``. They are not active discovery roots for
standalone skills. A preserved package such as
``some-skill/references/old-skill-package/SKILL.md`` is documentation data
unless the caller explicitly loads it via ``file_path``.
Legitimate categories or skill names such as ``skills/scripts/foo`` remain
discoverable because their ``scripts`` component is not directly under a
directory that contains ``SKILL.md``.
"""
path_obj = path if isinstance(path, Path) else Path(str(path))
parts = path_obj.parts
# Last component may be a file or candidate skill directory name. Only
# components before the leaf can be containing support directories.
for idx, part in enumerate(parts[:-1]):
if part not in SKILL_SUPPORT_DIRS or idx == 0:
continue
skill_root = Path(*parts[:idx])
if (skill_root / "SKILL.md").exists():
return True
return False
# ── Lazy YAML loader ───────────────────────────────────────────────────── # ── Lazy YAML loader ─────────────────────────────────────────────────────
@ -661,12 +696,21 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
def iter_skill_index_files(skills_dir: Path, filename: str): def iter_skill_index_files(skills_dir: Path, filename: str):
"""Walk skills_dir yielding sorted paths matching *filename*. """Walk skills_dir yielding sorted paths matching *filename*.
Excludes Hermes metadata, VCS, virtualenv/dependency, and cache Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill
directories so dependencies cannot register nested skills. support directories. Support directories (references/templates/assets/
scripts) can contain arbitrary markdown and even archived package
``SKILL.md`` files, but they are progressive-disclosure data loaded through
``skill_view(..., file_path=...)`` rather than active skill roots.
""" """
matches = [] matches = []
for root, dirs, files in os.walk(skills_dir, followlinks=True): for root, dirs, files in os.walk(skills_dir, followlinks=True):
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS] has_skill_md = "SKILL.md" in files
dirs[:] = [
d
for d in dirs
if d not in EXCLUDED_SKILL_DIRS
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
if filename in files: if filename in files:
matches.append(Path(root) / filename) matches.append(Path(root) / filename)
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))): for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):

View File

@ -33,6 +33,7 @@ from agent.prompt_builder import (
KANBAN_GUIDANCE, KANBAN_GUIDANCE,
MEMORY_GUIDANCE, MEMORY_GUIDANCE,
OPENAI_MODEL_EXECUTION_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE,
PARALLEL_TOOL_CALL_GUIDANCE,
PLATFORM_HINTS, PLATFORM_HINTS,
SESSION_SEARCH_GUIDANCE, SESSION_SEARCH_GUIDANCE,
SKILLS_GUIDANCE, SKILLS_GUIDANCE,
@ -40,6 +41,7 @@ from agent.prompt_builder import (
TASK_COMPLETION_GUIDANCE, TASK_COMPLETION_GUIDANCE,
TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_GUIDANCE,
TOOL_USE_ENFORCEMENT_MODELS, TOOL_USE_ENFORCEMENT_MODELS,
drain_truncation_warnings,
) )
from agent.runtime_cwd import resolve_context_cwd from agent.runtime_cwd import resolve_context_cwd
@ -82,6 +84,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# we resolve through ``_ra()`` to honor those patches. # we resolve through ``_ra()`` to honor those patches.
_r = _ra() _r = _ra()
# Resolve the model's context window once so context-file caps can scale
# to it (dynamic cap — see prompt_builder._dynamic_context_file_max_chars).
# None falls back to the historical flat default. This value is stable for
# the life of the conversation, so it does not threaten prompt caching.
_ctx_len: Optional[int] = None
_cc = getattr(agent, "context_compressor", None)
if _cc is not None:
_cc_len = getattr(_cc, "context_length", None)
if isinstance(_cc_len, int) and _cc_len > 0:
_ctx_len = _cc_len
# ── Stable tier ──────────────────────────────────────────────── # ── Stable tier ────────────────────────────────────────────────
stable_parts: List[str] = [] stable_parts: List[str] = []
@ -90,7 +103,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# cwd project instructions disabled. # cwd project instructions disabled.
_soul_loaded = False _soul_loaded = False
if agent.load_soul_identity or not agent.skip_context_files: if agent.load_soul_identity or not agent.skip_context_files:
_soul_content = _r.load_soul_md() _soul_content = _r.load_soul_md(_ctx_len)
if _soul_content: if _soul_content:
stable_parts.append(_soul_content) stable_parts.append(_soul_content)
_soul_loaded = True _soul_loaded = True
@ -111,6 +124,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names: if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names:
stable_parts.append(TASK_COMPLETION_GUIDANCE) stable_parts.append(TASK_COMPLETION_GUIDANCE)
# Universal parallel-tool-call guidance. Tells the model to batch
# independent tool calls into one assistant turn rather than emitting one
# call per turn — the runtime already runs independent calls concurrently
# (read-only tools always; non-overlapping path-scoped file ops), so the
# only thing missing was steering the model to produce the batch. Cuts
# round-trips and the resent-context cost that compounds over a long
# conversation. Gated by config.yaml ``agent.parallel_tool_call_guidance``
# (default True) and only injected when tools are actually loaded.
if getattr(agent, "_parallel_tool_call_guidance", True) and agent.valid_tool_names:
stable_parts.append(PARALLEL_TOOL_CALL_GUIDANCE)
# Tool-aware behavioral guidance: only inject when the tools are loaded # Tool-aware behavioral guidance: only inject when the tools are loaded
tool_guidance = [] tool_guidance = []
if "memory" in agent.valid_tool_names: if "memory" in agent.valid_tool_names:
@ -333,7 +357,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# dir — the user's real cwd there, but the install dir for the gateway # dir — the user's real cwd there, but the install dir for the gateway
# daemon, which is why the gateway sets TERMINAL_CWD. # daemon, which is why the gateway sets TERMINAL_CWD.
context_files_prompt = _r.build_context_files_prompt( context_files_prompt = _r.build_context_files_prompt(
cwd=resolve_context_cwd(), skip_soul=_soul_loaded) cwd=resolve_context_cwd(), skip_soul=_soul_loaded,
context_length=_ctx_len)
if context_files_prompt: if context_files_prompt:
context_parts.append(context_files_prompt) context_parts.append(context_files_prompt)
@ -400,7 +425,14 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
warm across turns. warm across turns.
""" """
parts = build_system_prompt_parts(agent, system_message=system_message) parts = build_system_prompt_parts(agent, system_message=system_message)
return "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p) joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
# Surface context-file truncation warnings through the normal agent status
# channel so gateway/CLI users see them in chat instead of only in logs.
for warning in drain_truncation_warnings():
agent._emit_status(warning)
return joined
def invalidate_system_prompt(agent: Any) -> None: def invalidate_system_prompt(agent: Any) -> None:

View File

@ -1012,21 +1012,35 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
elif function_name == "memory": elif function_name == "memory":
def _execute(next_args: dict) -> Any: def _execute(next_args: dict) -> Any:
target = next_args.get("target", "memory") target = next_args.get("target", "memory")
operations = next_args.get("operations")
from tools.memory_tool import memory_tool as _memory_tool from tools.memory_tool import memory_tool as _memory_tool
result = _memory_tool( result = _memory_tool(
action=next_args.get("action"), action=next_args.get("action"),
target=target, target=target,
content=next_args.get("content"), content=next_args.get("content"),
old_text=next_args.get("old_text"), old_text=next_args.get("old_text"),
operations=operations,
store=agent._memory_store, store=agent._memory_store,
) )
# Bridge: notify external memory provider of built-in memory writes # Bridge: notify external memory provider of built-in memory writes.
if agent._memory_manager and next_args.get("action") in {"add", "replace"}: # Covers both the single-op shape and each add/replace inside a batch.
if agent._memory_manager:
if operations:
_mem_ops = [
op for op in operations
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
]
else:
_mem_ops = (
[{"action": next_args.get("action"), "content": next_args.get("content")}]
if next_args.get("action") in {"add", "replace"} else []
)
for _op in _mem_ops:
try: try:
agent._memory_manager.on_memory_write( agent._memory_manager.on_memory_write(
next_args.get("action", ""), _op.get("action", ""),
target, target,
next_args.get("content", ""), _op.get("content", "") or "",
metadata=agent._build_memory_write_metadata( metadata=agent._build_memory_write_metadata(
task_id=effective_task_id, task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", None), tool_call_id=getattr(tool_call, "id", None),

View File

@ -88,7 +88,7 @@ class AnthropicTransport(ProviderTransport):
from agent.transports.types import ToolCall from agent.transports.types import ToolCall
strip_tool_prefix = kwargs.get("strip_tool_prefix", False) strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
_MCP_PREFIX = "mcp_" _MCP_PREFIX = "mcp__"
text_parts = [] text_parts = []
reasoning_parts = [] reasoning_parts = []
@ -132,17 +132,25 @@ class AnthropicTransport(ProviderTransport):
elif block.type == "tool_use": elif block.type == "tool_use":
name = block.name name = block.name
if strip_tool_prefix and name.startswith(_MCP_PREFIX): if strip_tool_prefix and name.startswith(_MCP_PREFIX):
stripped = name[len(_MCP_PREFIX):] # On the OAuth wire every tool carries a double-underscore
# Only strip the mcp_ prefix for OAuth-injected tools # ``mcp__`` prefix (added in build_anthropic_kwargs to avoid
# (where Hermes adds the prefix when sending to Anthropic # Anthropic's single-underscore third-party classifier).
# and must remove it on the way back). Native MCP server # Reverse it back to the name the registry/dispatcher knows.
# tools (from mcp_servers: in config.yaml) are registered # Two original forms map onto the same ``mcp__`` wire name:
# in the tool registry under their FULL mcp_<server>_<tool> # ``mcp__read_file`` <- bare native tool ``read_file``
# name and must NOT be stripped. GH-25255. # ``mcp__linear_get_issue`` <- MCP server tool
# ``mcp_linear_get_issue``
# Resolve by registry lookup, preferring whichever original
# is actually registered; never rewrite a name the LLM used
# that already resolves natively. GH-25255.
from tools.registry import registry as _tool_registry from tools.registry import registry as _tool_registry
if (_tool_registry.get_entry(stripped) if not _tool_registry.get_entry(name):
and not _tool_registry.get_entry(name)): bare = name[len(_MCP_PREFIX):] # read_file
name = stripped single = "mcp_" + bare # mcp_read_file / mcp_linear_get_issue
if _tool_registry.get_entry(single):
name = single
elif _tool_registry.get_entry(bare):
name = bare
tool_calls.append( tool_calls.append(
ToolCall( ToolCall(
id=block.id, id=block.id,

View File

@ -128,6 +128,65 @@ class ResponsesApiTransport(ProviderTransport):
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort) reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
response_tools = _responses_tools(tools) response_tools = _responses_tools(tools)
# xAI server-side web search.
#
# grok models on xAI's /v1/responses surface (notably
# grok-composer-2.5-fast on SuperGrok OAuth) have a *native*,
# server-executed web search. When the model is handed a
# client-side function literally named ``web_search``, it routes
# the intent to that native engine — but because the tool is
# declared as a plain ``function`` rather than xAI's first-class
# ``{"type": "web_search"}`` built-in, the server-side search is
# dispatched but never reconciled: the response streams reasoning
# + ``web_search_call`` progress items, the searches never reach
# ``status="completed"`` in the assembled output, no final
# message is emitted, and ``_normalize_codex_response`` correctly
# sees reasoning-with-no-answer and reports ``incomplete``. The
# turn then burns 3 continuation retries and fails with "Codex
# response remained incomplete after 3 continuation attempts".
# Verified live against grok-composer-2.5-fast (2026-06).
#
# Fix: when the agent HAS a client-side ``web_search`` function (i.e.
# the user enabled the web toolset), declare xAI's native
# ``web_search`` built-in instead so the search actually runs to
# completion server-side and the model streams a real answer. The
# Responses API rejects two tools sharing the name ``web_search``
# (HTTP 400 "Duplicate tool names"), so we drop the client-side
# ``web_search`` function for the xAI path and let the native tool
# satisfy it. All other client-side tools (read_file, terminal,
# web_extract, MCP tools, …) are untouched and continue to dispatch
# through Hermes's agent loop.
#
# Scope: we ONLY swap in the native built-in when the client
# ``web_search`` was actually present. We do NOT force-enable Grok
# server-side search on turns where the user never had web enabled —
# that would silently route around Hermes's web-provider config and
# tool-trace/citation plumbing for every xai-oauth turn. The swap is
# a 1:1 replacement of an already-requested capability, not an
# additive grant.
#
# NOTE: for the swapped case this routes ``web_search`` to Grok's
# native search engine for xAI sessions instead of Hermes's
# configured web provider (Tavily/etc.), and those results bypass
# Hermes's tool-trace / citation plumbing (they arrive baked into the
# model's answer rather than as a tool result the loop observes).
# Scoped to ``is_xai_responses`` deliberately; narrow to specific
# models if a future grok variant should keep the client-side
# function.
if is_xai_responses and response_tools:
has_client_web_search = any(
isinstance(t, dict) and t.get("name") == "web_search"
for t in response_tools
)
if has_client_web_search:
filtered = [
t for t in response_tools
if not (isinstance(t, dict) and t.get("name") == "web_search")
]
filtered.append({"type": "web_search"})
response_tools = filtered
# ``tools`` MUST be omitted entirely when there are no functions to # ``tools`` MUST be omitted entirely when there are no functions to
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()`` # expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools`` # eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
@ -218,10 +277,28 @@ class ResponsesApiTransport(ProviderTransport):
kwargs.pop("timeout", None) kwargs.pop("timeout", None)
if is_codex_backend: if is_codex_backend:
# chatgpt.com/backend-api/codex rejects body-level # The Codex backend rejects body-level ``extra_headers`` with
# ``extra_headers`` with HTTP 400. Correlation/cache routing for # HTTP 400, but the OpenAI SDK's ``extra_headers`` kwarg maps
# this backend must not be sent through the Responses payload. # to actual HTTP request headers (not body fields). We need
kwargs.pop("extra_headers", None) # these headers for cache-scope routing so prompt cache hits
# remain high. Send session_id / x-client-request-id as HTTP
# headers while keeping ``prompt_cache_key`` in the body for
# standard OpenAI routing as a belt-and-braces fallback.
cache_scope_id = str(session_id or "").strip()
if cache_scope_id:
existing_extra_headers = kwargs.get("extra_headers")
merged_extra_headers: Dict[str, str] = {}
if isinstance(existing_extra_headers, dict):
merged_extra_headers.update(
{
str(key): str(value)
for key, value in existing_extra_headers.items()
if key and value is not None
}
)
merged_extra_headers["session_id"] = cache_scope_id
merged_extra_headers["x-client-request-id"] = cache_scope_id
kwargs["extra_headers"] = merged_extra_headers
max_tokens = params.get("max_tokens") max_tokens = params.get("max_tokens")
if max_tokens is not None and not is_codex_backend: if max_tokens is not None and not is_codex_backend:

View File

@ -69,6 +69,7 @@ def build_turn_context(
task_id: Optional[str], task_id: Optional[str],
stream_callback, stream_callback,
persist_user_message: Optional[str], persist_user_message: Optional[str],
persist_user_timestamp: Optional[float] = None,
*, *,
restore_or_build_system_prompt, restore_or_build_system_prompt,
install_safe_stdio, install_safe_stdio,
@ -121,6 +122,7 @@ def build_turn_context(
agent._stream_callback = stream_callback agent._stream_callback = stream_callback
agent._persist_user_message_idx = None agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message agent._persist_user_message_override = persist_user_message
agent._persist_user_message_timestamp = persist_user_timestamp
# Generate unique task_id if not provided to isolate VMs between tasks. # Generate unique task_id if not provided to isolate VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4()) effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id agent._current_task_id = effective_task_id

View File

@ -286,7 +286,7 @@ async fn run_update(app: AppHandle) -> Result<()> {
emit_stage(&app, "rebuild", StageState::Running, None, None); emit_stage(&app, "rebuild", StageState::Running, None, None);
let started = Instant::now(); let started = Instant::now();
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()]; let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
let rebuild = run_streamed( let mut rebuild = run_streamed(
&app, &app,
&hermes, &hermes,
&rebuild_args, &rebuild_args,
@ -295,6 +295,33 @@ async fn run_update(app: AppHandle) -> Result<()> {
Some("rebuild"), Some("rebuild"),
) )
.await?; .await?;
// Retry-once: the first `--build-only` can return nonzero on a still-settling
// post-update tree or a network-blocked Electron fetch that our self-heal
// repaired mid-run. A second attempt then builds clean off the healed dist
// (the content-hash stamp makes it a near-no-op when the first actually
// succeeded). Without this the updater bails here and never reaches the
// relaunch below — the app updates but doesn't restart. Matches the
// retry-once `hermes update` already does above, and `hermes update`'s own
// desktop rebuild in cmd_update.
if rebuild_needs_retry(rebuild.exit_code) {
emit_log(
&app,
Some("rebuild"),
LogStream::Stdout,
"[rebuild] first desktop rebuild failed; retrying once (a self-healed \
Electron download builds clean on the second run)",
);
rebuild = run_streamed(
&app,
&hermes,
&rebuild_args,
&install_root,
&child_env,
Some("rebuild"),
)
.await?;
}
let rebuild_ms = started.elapsed().as_millis() as u64; let rebuild_ms = started.elapsed().as_millis() as u64;
if rebuild.exit_code != Some(0) { if rebuild.exit_code != Some(0) {
@ -533,6 +560,14 @@ fn is_locked(path: &Path) -> bool {
} }
} }
/// Whether the `desktop --build-only` rebuild should be retried once. Any
/// non-success exit qualifies: the common cause is a transient first-attempt
/// failure (still-settling tree / self-healed Electron download) that a clean
/// second run resolves.
fn rebuild_needs_retry(exit_code: Option<i32>) -> bool {
exit_code != Some(0)
}
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the /// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script /// bootstrap channel, and return the exit code. Mirrors powershell::run_script
/// but for an arbitrary command (no install.ps1 -File wrapping). /// but for an arbitrary command (no install.ps1 -File wrapping).
@ -970,6 +1005,16 @@ mod tests {
assert_eq!(update_branch_from_args(["--update"]), None); assert_eq!(update_branch_from_args(["--update"]), None);
} }
#[test]
fn rebuild_retries_only_on_failure() {
assert!(!rebuild_needs_retry(Some(0)), "a clean rebuild must not retry");
assert!(rebuild_needs_retry(Some(1)), "a failed rebuild retries once");
assert!(
rebuild_needs_retry(None),
"a killed/signalled rebuild (no exit code) retries once"
);
}
#[test] #[test]
fn parses_only_app_targets() { fn parses_only_app_targets() {
assert_eq!( assert_eq!(

View File

@ -28,6 +28,7 @@ const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = requ
const { runBootstrap } = require('./bootstrap-runner.cjs') const { runBootstrap } = require('./bootstrap-runner.cjs')
const { const {
buildSessionWindowUrl, buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry, createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH SESSION_WINDOW_MIN_WIDTH
@ -44,6 +45,7 @@ const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs') const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs') const { worktreesForIpc } = require('./git-worktrees.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
const { const {
buildPosixCleanupScript, buildPosixCleanupScript,
buildWindowsCleanupScript, buildWindowsCleanupScript,
@ -2008,10 +2010,14 @@ async function applyUpdatesPosixInApp() {
} }
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 }) emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], { // Retry-once: a first rebuild can fail on a still-settling tree or a
cwd: updateRoot, // self-healed (network-blocked) Electron download; a second run builds clean
env, // off the healed dist so we reach the swap+relaunch below instead of bailing.
stage: 'rebuild' const rebuilt = await runRebuildWithRetry(attempt => {
if (attempt > 0) {
emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 })
}
return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' })
}) })
if (rebuilt.code !== 0) { if (rebuilt.code !== 0) {
emitUpdateProgress({ emitUpdateProgress({
@ -5106,14 +5112,7 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
// themes/context.tsx, so the window appears already themed. // themes/context.tsx, so the window appears already themed.
show: false, show: false,
backgroundColor: getWindowBackgroundColor(), backgroundColor: getWindowBackgroundColor(),
webPreferences: { webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
}) })
if (IS_MAC) { if (IS_MAC) {
@ -5180,23 +5179,11 @@ function createWindow() {
// material before the renderer paints the app theme. See createSessionWindow. // material before the renderer paints the app theme. See createSessionWindow.
show: false, show: false,
backgroundColor: getWindowBackgroundColor(), backgroundColor: getWindowBackgroundColor(),
webPreferences: { // Shared with the secondary session windows (chatWindowWebPreferences) so
preload: path.join(__dirname, 'preload.cjs'), // both keep `backgroundThrottling: false` — the chat transcript streams via
contextIsolation: true, // a requestAnimationFrame-gated flush that Chromium pauses for blurred
webviewTag: true, // windows, stalling the live answer until refocus. See session-windows.cjs.
sandbox: true, webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
nodeIntegration: false,
devTools: true,
// Keep timers + requestAnimationFrame running at full speed when the
// window is blurred/occluded. The chat transcript streams to the screen
// through a requestAnimationFrame-gated flush (useSessionStateCache),
// so with Chromium's default background throttling the live answer
// stalls whenever this window isn't focused (e.g. you switch to your
// editor mid-turn, or open detached devtools) and only appears once you
// refocus or refresh. A streaming chat app must render in the
// background, so opt out — matching the secondary windows above.
backgroundThrottling: false
}
}) })
if (IS_MAC) { if (IS_MAC) {
@ -6564,6 +6551,12 @@ app.on('before-quit', () => {
flushDesktopLogBufferSync() flushDesktopLogBufferSync()
closePreviewWatchers() closePreviewWatchers()
// Kill open PTYs before environment teardown to avoid the node-pty#904
// ThreadSafeFunction SIGABRT race.
for (const id of [...terminalSessions.keys()]) {
disposeTerminalSession(id)
}
if (hermesProcess && !hermesProcess.killed) { if (hermesProcess && !hermesProcess.killed) {
hermesProcess.kill('SIGTERM') hermesProcess.kill('SIGTERM')
} }

View File

@ -10,6 +10,29 @@ const { pathToFileURL } = require('node:url')
const SESSION_WINDOW_MIN_WIDTH = 420 const SESSION_WINDOW_MIN_WIDTH = 420
const SESSION_WINDOW_MIN_HEIGHT = 620 const SESSION_WINDOW_MIN_HEIGHT = 620
// Shared webPreferences for every window that renders the chat transcript — the
// primary window AND the secondary session windows. Keeping it in one place is
// the whole point: the two BrowserWindow definitions in main.cjs used to be
// hand-copied, and the secondary windows silently lost `backgroundThrottling:
// false`, so a streamed answer stalled until the window regained focus.
//
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
// blurred/occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath) {
return {
preload: preloadPath,
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
backgroundThrottling: false
}
}
// Build the renderer URL for a secondary window. The renderer uses a // Build the renderer URL for a secondary window. The renderer uses a
// HashRouter, so the session route lives after the '#'. The `?win=secondary` // HashRouter, so the session route lives after the '#'. The `?win=secondary`
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is // flag MUST sit in the query string BEFORE the '#': anything after the '#' is
@ -94,6 +117,7 @@ function createSessionWindowRegistry() {
module.exports = { module.exports = {
buildSessionWindowUrl, buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry, createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH SESSION_WINDOW_MIN_WIDTH

View File

@ -1,7 +1,11 @@
const assert = require('node:assert/strict') const assert = require('node:assert/strict')
const test = require('node:test') const test = require('node:test')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs') const {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry
} = require('./session-windows.cjs')
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a // A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
// test fire the 'closed' event, mirroring the slice of the Electron API the // test fire the 'closed' event, mirroring the slice of the Electron API the
@ -175,3 +179,21 @@ test('registry trims the session id before keying', () => {
assert.equal(registry.has('s1'), true) assert.equal(registry.has('s1'), true)
}) })
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
// Regression: secondary session windows used to omit this flag, so a streamed
// answer stalled until the window regained focus (Chromium pauses the
// requestAnimationFrame-gated transcript flush for backgrounded windows).
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
assert.equal(prefs.backgroundThrottling, false)
})
test('chatWindowWebPreferences passes the preload path through and keeps the hardened defaults', () => {
const prefs = chatWindowWebPreferences('/some/preload.cjs')
assert.equal(prefs.preload, '/some/preload.cjs')
assert.equal(prefs.contextIsolation, true)
assert.equal(prefs.sandbox, true)
assert.equal(prefs.nodeIntegration, false)
})

View File

@ -0,0 +1,29 @@
'use strict'
/**
* Retry-once policy for the desktop `--build-only` rebuild during self-update.
*
* The first rebuild can return nonzero on a still-settling post-update tree or a
* network-blocked Electron fetch that the installer's self-heal repaired mid-run.
* A second attempt then builds clean off the healed dist (the content-hash stamp
* makes it a near-no-op when the first actually succeeded). Without the retry the
* updater bails before the relaunch step the app updates but doesn't restart.
*/
function shouldRetryRebuild(code) {
return code !== 0
}
/**
* Run `rebuild()` (async, resolves `{ code, ... }`), retrying once on failure.
* Returns the final result.
*/
async function runRebuildWithRetry(rebuild) {
let result = await rebuild(0)
if (shouldRetryRebuild(result.code)) {
result = await rebuild(1)
}
return result
}
module.exports = { shouldRetryRebuild, runRebuildWithRetry }

View File

@ -0,0 +1,55 @@
/**
* Tests for electron/update-rebuild.cjs the retry-once policy for the desktop
* `--build-only` rebuild during self-update.
*
* Run with: node --test electron/update-rebuild.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* Why this matters: a first rebuild can return nonzero on a still-settling tree
* or a self-healed (network-blocked) Electron download. Without a second attempt
* the updater bails before the relaunch step the app updates but never restarts
* (the field report behind this fix). The retry must fire on failure, not on
* success, and must run at most twice.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs')
test('shouldRetryRebuild retries only on a non-success exit', () => {
assert.equal(shouldRetryRebuild(0), false)
assert.equal(shouldRetryRebuild(1), true)
assert.equal(shouldRetryRebuild(null), true)
})
test('a clean first rebuild runs once and does not retry', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 0 })
})
assert.deepEqual(codes, [0])
assert.equal(result.code, 0)
})
test('a failed first rebuild retries once and succeeds', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: attempt === 0 ? 1 : 0 })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 0)
})
test('a rebuild that keeps failing runs at most twice and reports the failure', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 1, error: 'rebuild-failed' })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 1)
assert.equal(result.error, 'rebuild-failed')
})

View File

@ -21,7 +21,7 @@
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs", "postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs", "prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
"pack": "npm run build && npm run builder -- --dir", "pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder", "dist": "npm run build && npm run builder",
"dist:mac": "npm run build && npm run builder -- --mac", "dist:mac": "npm run build && npm run builder -- --mac",
@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs", "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit", "typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/", "lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix", "lint:fix": "eslint src/ electron/ --fix",
@ -117,7 +117,7 @@
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "^40.9.3", "electron": "40.10.2",
"electron-builder": "^26.8.1", "electron-builder": "^26.8.1",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-perfectionist": "^5.9.0",
@ -134,8 +134,7 @@
"wait-on": "^9.0.5" "wait-on": "^9.0.5"
}, },
"build": { "build": {
"electronVersion": "40.9.3", "electronVersion": "40.10.2",
"electronDist": "../../node_modules/electron/dist",
"appId": "com.nousresearch.hermes", "appId": "com.nousresearch.hermes",
"productName": "Hermes", "productName": "Hermes",
"executableName": "Hermes", "executableName": "Hermes",

View File

@ -24,6 +24,11 @@ const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes cop
if (!fs.existsSync(bundledElectronBinary)) { if (!fs.existsSync(bundledElectronBinary)) {
const candidates = [ const candidates = [
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName), path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
// npm may nest the workspace-only electron devDep under
// apps/desktop/node_modules (process.cwd() during pack), or hoist
// it to the repo root. Try the workspace-local install first, then
// the root hoist, so the fallback works under either layout.
path.join(process.cwd(), "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName), path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
]; ];
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate)); const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));

View File

@ -0,0 +1,57 @@
"use strict"
// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can
// re-unpack a broken Electron.app; reusing the installed dist dodges that.
// npm workspace hoisting is non-deterministic — require.resolve finds electron
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
const fs = require("node:fs")
const path = require("node:path")
const { spawnSync } = require("node:child_process")
function electronDistDir() {
try {
return path.join(path.dirname(require.resolve("electron/package.json")), "dist")
} catch {
return null
}
}
function distBinary(dist) {
if (process.platform === "darwin") {
return path.join(dist, "Electron.app", "Contents", "MacOS", "Electron")
}
if (process.platform === "win32") {
return path.join(dist, "electron.exe")
}
return path.join(dist, "electron")
}
function electronBuilderCli() {
const pkgJson = require.resolve("electron-builder/package.json")
const bin = require(pkgJson).bin
const rel = typeof bin === "string" ? bin : bin["electron-builder"]
return path.join(path.dirname(pkgJson), rel)
}
const dist = electronDistDir()
const args = []
if (dist && fs.existsSync(distBinary(dist))) {
args.push(`-c.electronDist=${dist}`)
} else {
console.warn(
"[run-electron-builder] no local electron dist; electron-builder will fetch " +
"via @electron/get (electronVersion + ELECTRON_MIRROR)."
)
}
args.push(...process.argv.slice(2))
const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], {
stdio: "inherit",
})
if (result.error) {
console.error(`[run-electron-builder] spawn failed: ${result.error.message}`)
process.exit(1)
}
process.exit(result.status == null ? 1 : result.status)

View File

@ -9,6 +9,7 @@ import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation' import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
import type { ChatBarState, VoiceStatus } from './types' import type { ChatBarState, VoiceStatus } from './types'
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md' export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
@ -66,6 +67,7 @@ export function ComposerControls({
const c = t.composer const c = t.composer
const steerCombo = formatCombo('mod+enter') const steerCombo = formatCombo('mod+enter')
const steerLabel = `${c.steer} (${steerCombo})` const steerLabel = `${c.steer} (${steerCombo})`
const steerTip = ( const steerTip = (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
{c.steer} {c.steer}
@ -81,8 +83,10 @@ export function ComposerControls({
return ( return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)"> <div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} /> <ModelPill disabled={disabled} model={state.model} />
{canSteer && ( {/* While the agent runs and the user is typing, steer takes over the mic's
slot rather than crowding the row with an extra button. */}
{canSteer ? (
<Tip label={steerTip}> <Tip label={steerTip}>
<Button <Button
aria-label={steerLabel} aria-label={steerLabel}
@ -96,6 +100,8 @@ export function ComposerControls({
<SteeringWheel size={16} /> <SteeringWheel size={16} />
</Button> </Button>
</Tip> </Tip>
) : (
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
)} )}
{showVoicePrimary ? ( {showVoicePrimary ? (
<Tip label={c.startVoice}> <Tip label={c.startVoice}>

View File

@ -0,0 +1,86 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
setModelPickerOpen
} from '@/store/session'
import type { ChatBarState } from './types'
const PILL = cn(
'h-(--composer-control-size) max-w-40 shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
/**
* Composer model selector the relocated status-bar pill. Reuses the live
* `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the
* full picker when the gateway is closed and no live menu exists.
*/
export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
const copy = useI18n().t.shell.statusbar
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const fastMode = useStore($currentFastMode)
const reasoningEffort = useStore($currentReasoningEffort)
const [open, setOpen] = useState(false)
// The model resolves a beat after the gateway/session comes up. Rather than
// flash a literal "No model", show a quiet loader (inherits the pill text
// color at half opacity) until a model lands.
const label = (
<>
{currentModel.trim() ? (
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</>
)
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
if (!model.modelMenuContent) {
return (
<Button
aria-label={copy.openModelPicker}
className={PILL}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
title={copy.openModelPicker}
type="button"
variant="ghost"
>
{label}
</Button>
)
}
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>
<Button aria-label={title} className={PILL} disabled={disabled} title={title} type="button" variant="ghost">
{label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
{model.modelMenuContent}
</ModelMenuCloseContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,3 +1,5 @@
import type { ReactNode } from 'react'
import type { HermesGateway } from '@/hermes' import type { HermesGateway } from '@/hermes'
import type { ComposerAttachment } from '@/store/composer' import type { ComposerAttachment } from '@/store/composer'
@ -22,6 +24,8 @@ export interface ChatBarState {
canSwitch: boolean canSwitch: boolean
loading?: boolean loading?: boolean
quickModels?: QuickModelOption[] quickModels?: QuickModelOption[]
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
modelMenuContent?: ReactNode
} }
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] } tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
voice: { enabled: boolean; active: boolean } voice: { enabled: boolean; active: boolean }

View File

@ -15,7 +15,9 @@ import { Backdrop } from '@/components/Backdrop'
import { PromptOverlays } from '@/components/prompt-overlays' import { PromptOverlays } from '@/components/prompt-overlays'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon' import { Codicon } from '@/components/ui/codicon'
import { ErrorState } from '@/components/ui/error-state'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes' import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages' import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
@ -38,11 +40,12 @@ import {
$lastVisibleMessageIsUser, $lastVisibleMessageIsUser,
$messages, $messages,
$messagesEmpty, $messagesEmpty,
$resumeExhaustedSessionId,
$selectedStoredSessionId, $selectedStoredSessionId,
$sessions, $sessions,
sessionPinId sessionPinId
} from '@/store/session' } from '@/store/session'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' import { isSecondaryWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes' import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes' import { routeSessionId } from '../routes'
@ -62,6 +65,7 @@ import { threadLoadingState } from './thread-loading'
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> { interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
gateway: HermesGateway | null gateway: HermesGateway | null
modelMenuContent?: React.ReactNode
onToggleSelectedPin: () => void onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void onDeleteSelectedSession: () => void
onCancel: () => Promise<void> | void onCancel: () => Promise<void> | void
@ -85,7 +89,9 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onEdit: (message: AppendMessage) => Promise<void> onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void> onReload: (parentId: string | null) => Promise<void>
onRestoreToMessage?: (messageId: string) => Promise<void> onRestoreToMessage?: (messageId: string) => Promise<void>
onRetryResume: (sessionId: string) => void
onTranscribeAudio?: (audio: Blob) => Promise<string> onTranscribeAudio?: (audio: Blob) => Promise<string>
onDismissError?: (messageId: string) => void
} }
interface ChatHeaderProps { interface ChatHeaderProps {
@ -120,10 +126,10 @@ function ChatHeader({
? pinnedSessionIds.includes(selectedSessionId) ? pinnedSessionIds.includes(selectedSessionId)
: false : false
// A brand-new session has no session to pin/delete/rename, so the header is // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// just a dead "New session" label + chevron. Drop it (and its border) // are compact side panels — they drop the session-actions header + border
// entirely until there's a real session to act on. // entirely. A brand-new draft has nothing to pin/delete/rename either.
if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) { if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
return null return null
} }
@ -250,6 +256,7 @@ function ChatRuntimeBoundary({
export function ChatView({ export function ChatView({
className, className,
gateway, gateway,
modelMenuContent,
onToggleSelectedPin, onToggleSelectedPin,
onDeleteSelectedSession, onDeleteSelectedSession,
onCancel, onCancel,
@ -270,9 +277,12 @@ export function ChatView({
onEdit, onEdit,
onReload, onReload,
onRestoreToMessage, onRestoreToMessage,
onTranscribeAudio onRetryResume,
onTranscribeAudio,
onDismissError
}: ChatViewProps) { }: ChatViewProps) {
const location = useLocation() const location = useLocation()
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId) const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse) const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy) const busy = useStore($busy)
@ -294,6 +304,7 @@ export function ChatView({
const messagesEmpty = useStore($messagesEmpty) const messagesEmpty = useStore($messagesEmpty)
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser) const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
const selectedSessionId = useStore($selectedStoredSessionId) const selectedSessionId = useStore($selectedStoredSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const routedSessionId = routeSessionId(location.pathname) const routedSessionId = routeSessionId(location.pathname)
const isRoutedSessionView = Boolean(routedSessionId) const isRoutedSessionView = Boolean(routedSessionId)
@ -313,9 +324,21 @@ export function ChatView({
// session exists — even if it has zero messages (a brand-new routed // session exists — even if it has zero messages (a brand-new routed
// session). The flicker where `busy` flips true briefly during hydrate // session). The flicker where `busy` flips true briefly during hydrate
// is handled by `threadLoadingState`'s last-visible-user gate. // is handled by `threadLoadingState`'s last-visible-user gate.
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId)) //
// resumeExhausted: the bounded auto-retry in use-route-resume gave up on this
// routed session (gateway RPC + REST fallback failed through every attempt).
// Suppress the loader and show an explicit error + manual Retry instead of
// spinning forever. Gated on the route matching so a stale latch from another
// session can't blank the current one.
const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
const loadingSession =
!resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser) const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
const showChatBar = !loadingSession // Hide the composer in the exhausted error state too: there's no live runtime
// to send to until a retry rebinds one.
const showChatBar = !loadingSession && !resumeExhausted
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const modelOptionsQuery = useQuery<ModelOptionsResponse>({ const modelOptionsQuery = useQuery<ModelOptionsResponse>({
@ -346,6 +369,7 @@ export function ChatView({
provider: currentProvider, provider: currentProvider,
canSwitch: gatewayOpen, canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider), loading: !gatewayOpen || (!currentModel && !currentProvider),
modelMenuContent,
quickModels quickModels
}, },
tools: { tools: {
@ -358,7 +382,7 @@ export function ChatView({
active: false active: false
} }
}), }),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels] [contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
) )
// Drop files anywhere in the conversation area, not just on the composer // Drop files anywhere in the conversation area, not just on the composer
@ -429,6 +453,7 @@ export function ChatView({
loading={threadLoading} loading={threadLoading}
onBranchInNewChat={onBranchInNewChat} onBranchInNewChat={onBranchInNewChat}
onCancel={onCancel} onCancel={onCancel}
onDismissError={onDismissError}
onRestoreToMessage={onRestoreToMessage} onRestoreToMessage={onRestoreToMessage}
sessionId={activeSessionId} sessionId={activeSessionId}
sessionKey={threadKey} sessionKey={threadKey}
@ -462,6 +487,21 @@ export function ChatView({
</Suspense> </Suspense>
)} )}
</ChatRuntimeBoundary> </ChatRuntimeBoundary>
{resumeExhausted && routedSessionId && (
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
<ErrorState
className="max-w-sm"
description={t.desktop.resumeStrandedBody}
title={t.desktop.resumeStrandedTitle}
>
<div className="grid justify-items-center">
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
{t.desktop.resumeRetry}
</Button>
</div>
</ErrorState>
</div>
)}
{showChatBar && <ScrollToBottomButton />} {showChatBar && <ScrollToBottomButton />}
<ChatDropOverlay kind={dragKind} /> <ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} /> <ChatSwapOverlay profile={gatewaySwapTarget} />

View File

@ -13,7 +13,7 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text' import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes' import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { import {
isMessagingSource, isMessagingSource,
LOCAL_SESSION_SOURCE_IDS, LOCAL_SESSION_SOURCE_IDS,
@ -52,7 +52,10 @@ import {
$currentCwd, $currentCwd,
$freshDraftReady, $freshDraftReady,
$gatewayState, $gatewayState,
$messages,
$messagingSessions, $messagingSessions,
$resumeFailedSessionId,
$resumeExhaustedSessionId,
$selectedStoredSessionId, $selectedStoredSessionId,
$sessions, $sessions,
$workingSessionIds, $workingSessionIds,
@ -199,6 +202,8 @@ export function DesktopController() {
const activeSessionId = useStore($activeSessionId) const activeSessionId = useStore($activeSessionId)
const currentCwd = useStore($currentCwd) const currentCwd = useStore($currentCwd)
const freshDraftReady = useStore($freshDraftReady) const freshDraftReady = useStore($freshDraftReady)
const resumeFailedSessionId = useStore($resumeFailedSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const filePreviewTarget = useStore($filePreviewTarget) const filePreviewTarget = useStore($filePreviewTarget)
const previewTarget = useStore($previewTarget) const previewTarget = useStore($previewTarget)
const selectedStoredSessionId = useStore($selectedStoredSessionId) const selectedStoredSessionId = useStore($selectedStoredSessionId)
@ -711,7 +716,9 @@ export function DesktopController() {
} }
lastGatewayProfileRef.current = activeGatewayProfile lastGatewayProfileRef.current = activeGatewayProfile
void refreshCurrentModel() // Force: the new profile has its own default, so reseed even if the composer
// already shows the previous profile's model.
void refreshCurrentModel(true)
void refreshActiveProfile() void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel]) }, [activeGatewayProfile, refreshCurrentModel])
@ -734,6 +741,49 @@ export function DesktopController() {
[branchCurrentSession, refreshSessions] [branchCurrentSession, refreshSessions]
) )
// Clear a failed turn's red error banner from the transcript. Errors are
// renderer-local state (never persisted), so dismissing is purely a view +
// session-cache edit. A message that errored before emitting any visible
// text is a bare error placeholder → drop it entirely; one that streamed
// partial output then failed keeps its content and just sheds the error.
// Both the per-runtime cache AND the live $messages view must be updated:
// `preserveLocalAssistantErrors` re-grafts any still-errored message it
// finds in the view onto the next session.info flush, so clearing only the
// cache would let the heartbeat resurrect the banner.
const dismissError = useCallback(
(messageId: string) => {
const runtimeSessionId = activeSessionIdRef.current
if (!runtimeSessionId) {
return
}
const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] =>
messages.flatMap(message => {
if (message.id !== messageId || !message.error) {
return [message]
}
if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) {
return []
}
return [{ ...message, error: undefined, pending: false }]
})
// View first: the flush below reads $messages as the "current" baseline
// for error preservation, so the banner must be gone from it before the
// cache update triggers a re-sync.
setMessages(clearErrorIn($messages.get()))
updateSessionState(runtimeSessionId, state => ({
...state,
messages: clearErrorIn(state.messages)
}))
},
[activeSessionIdRef, updateSessionState]
)
const startSessionInWorkspace = useCallback( const startSessionInWorkspace = useCallback(
(path: null | string) => { (path: null | string) => {
startFreshSessionDraft() startFreshSessionDraft()
@ -843,6 +893,8 @@ export function DesktopController() {
gatewayState, gatewayState,
locationPathname: location.pathname, locationPathname: location.pathname,
resumeSession, resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId, routedSessionId,
runtimeIdByStoredSessionIdRef, runtimeIdByStoredSessionIdRef,
selectedStoredSessionId, selectedStoredSessionId,
@ -859,7 +911,6 @@ export function DesktopController() {
gatewayLogLines, gatewayLogLines,
gatewayState, gatewayState,
inferenceStatus, inferenceStatus,
modelMenuContent,
openAgents, openAgents,
freshDraftReady, freshDraftReady,
openCommandCenterSection, openCommandCenterSection,
@ -981,6 +1032,7 @@ export function DesktopController() {
<ChatView <ChatView
gateway={gatewayRef.current} gateway={gatewayRef.current}
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds} maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
modelMenuContent={modelMenuContent}
onAddContextRef={composer.addContextRefAttachment} onAddContextRef={composer.addContextRefAttachment}
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems} onAttachDroppedItems={composer.attachDroppedItems}
@ -992,6 +1044,7 @@ export function DesktopController() {
void removeSession(selectedStoredSessionId) void removeSession(selectedStoredSessionId)
} }
}} }}
onDismissError={dismissError}
onEdit={editMessage} onEdit={editMessage}
onPasteClipboardImage={() => void composer.pasteClipboardImage()} onPasteClipboardImage={() => void composer.pasteClipboardImage()}
onPickFiles={() => void composer.pickContextPaths('file')} onPickFiles={() => void composer.pickContextPaths('file')}
@ -1000,6 +1053,7 @@ export function DesktopController() {
onReload={reloadFromMessage} onReload={reloadFromMessage}
onRemoveAttachment={id => void composer.removeAttachment(id)} onRemoveAttachment={id => void composer.removeAttachment(id)}
onRestoreToMessage={restoreToMessage} onRestoreToMessage={restoreToMessage}
onRetryResume={sessionId => void resumeSession(sessionId, true)}
onSteer={steerPrompt} onSteer={steerPrompt}
onSubmit={submitText} onSubmit={submitText}
onThreadMessagesChange={handleThreadMessagesChange} onThreadMessagesChange={handleThreadMessagesChange}

View File

@ -9,3 +9,22 @@ export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active)) $terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active) export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
/** A command queued to run in the embedded terminal. The terminal pane flushes
* (and clears) it once its session is live, so a value set before the pane
* mounts still runs. Cleared after flush so a later remount can't replay it. */
export const $terminalInjection = atom<null | string>(null)
/** Open the terminal pane and run a command in it. Used to disconnect external
* (CLI-managed) providers, which Hermes can't clear via the API the user
* sees exactly what runs instead of Hermes silently deleting their creds. */
export const runInTerminal = (command: string) => {
const trimmed = command.trim()
if (!trimmed) {
return
}
setTerminalTakeover(true)
$terminalInjection.set(trimmed)
}

View File

@ -10,6 +10,8 @@ import { triggerHaptic } from '@/lib/haptics'
import { $filePreviewTarget, $previewTarget } from '@/store/preview' import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { useTheme } from '@/themes/context' import { useTheme } from '@/themes/context'
import { $terminalInjection } from '../store'
import { makeTerminalReader, setActiveTerminalReader } from './buffer' import { makeTerminalReader, setActiveTerminalReader } from './buffer'
import { import {
isAddSelectionShortcut, isAddSelectionShortcut,
@ -675,6 +677,28 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
return () => cancelAnimationFrame(raf) return () => cancelAnimationFrame(raf)
}, [activeTheme, themeName]) }, [activeTheme, themeName])
// Flush a queued command (e.g. a provider-disconnect) into the live session.
// Only active while open; the subscribe fires immediately, so a command set
// before this pane mounted runs as soon as the session is ready. Clearing the
// atom after writing stops a later remount from replaying a stale command.
useEffect(() => {
if (status !== 'open') {
return
}
return $terminalInjection.subscribe(command => {
const id = sessionIdRef.current
if (!command || !id) {
return
}
void window.hermesDesktop?.terminal?.write(id, `${command}\r`)
$terminalInjection.set(null)
termRef.current?.focus()
})
}, [status])
return { return {
addSelectionToChat, addSelectionToChat,
hostRef, hostRef,

View File

@ -13,6 +13,7 @@ import {
type GatewayEventPayload, type GatewayEventPayload,
reasoningPart, reasoningPart,
renderMediaTags, renderMediaTags,
textPart,
upsertToolPart upsertToolPart
} from '@/lib/chat-messages' } from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime' import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
@ -1080,6 +1081,32 @@ export function useMessageStream({
// completions / watch matches here — re-sync the status stack. // completions / watch matches here — re-sync the status stack.
void refreshBackgroundProcesses(sessionId) void refreshBackgroundProcesses(sessionId)
} }
} else if (event.type === 'review.summary') {
// Self-improvement background review saved something to memory/skills
// and emitted a persistent summary (Python formats it as
// "💾 Self-improvement review: …"). The CLI prints this via
// prompt_toolkit and the Ink TUI renders it as a system line; the
// desktop has neither, so without this handler the skill/memory
// change happens silently. Surface it as a persistent system message
// in the transcript so the user is always informed — it must not be a
// transient toast that can be missed.
const text = coerceGatewayText(payload?.text).trim()
if (text && sessionId) {
flushQueuedDeltas(sessionId)
updateSessionState(sessionId, state => ({
...state,
messages: [
...state.messages,
{
id: `review-summary-${Date.now()}`,
role: 'system',
parts: [textPart(text)],
timestamp: Math.floor(Date.now() / 1000)
}
]
}))
}
} else if (event.type === 'error') { } else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error' const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage) const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
@ -1102,8 +1129,13 @@ export function useMessageStream({
if (looksLikeProviderSetup) { if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage) requestDesktopOnboarding(errorMessage)
} else if (isActiveEvent) { } else {
// Toast globally, not just when the failing thread is focused: a
// turn-ending error (e.g. out of funds) blocks every thread, so the
// inline error alone is too easy to miss. The stable id collapses the
// same error from multiple blocked threads into one toast.
notify({ notify({
id: `gateway-error:${errorMessage}`,
kind: 'error', kind: 'error',
title: 'Hermes error', title: 'Hermes error',
message: errorMessage message: errorMessage

View File

@ -130,7 +130,6 @@ describe('useModelControls', () => {
await expect( await expect(
controls.selectModel({ controls.selectModel({
model: 'claude-sonnet-4.6', model: 'claude-sonnet-4.6',
persistGlobal: false,
provider: 'anthropic' provider: 'anthropic'
}) })
).resolves.toBe(true) ).resolves.toBe(true)
@ -143,26 +142,57 @@ describe('useModelControls', () => {
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
}) })
it('keeps the global path on setGlobalModel when there is no active session', async () => { it('stores a no-session pick as UI state with no gateway or global write', async () => {
setGlobalModel.mockResolvedValue(undefined) const requestGateway = vi.fn()
let controls!: Controls let controls!: Controls
render( render(
<Harness <Harness
activeSessionId={null} activeSessionId={null}
onReady={value => (controls = value)} onReady={value => (controls = value)}
requestGateway={vi.fn()} requestGateway={requestGateway}
/> />
) )
await expect( await expect(
controls.selectModel({ controls.selectModel({
model: 'claude-sonnet-4.6', model: 'claude-sonnet-4.6',
persistGlobal: false,
provider: 'anthropic' provider: 'anthropic'
}) })
).resolves.toBe(true) ).resolves.toBe(true)
expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6') // The pick is plain UI state; session.create ships it later. Nothing touches
// the gateway or the profile default here.
expect($currentModel.get()).toBe('claude-sonnet-4.6')
expect($currentProvider.get()).toBe('anthropic')
expect(requestGateway).not.toHaveBeenCalled()
expect(setGlobalModel).not.toHaveBeenCalled()
})
it('seeds an empty composer model from global but never clobbers a pick', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
const { result } = renderHook(() =>
useModelControls({
activeSessionId: null,
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)
// Empty → seeds the default.
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openai/gpt-5.5')
// A user pick must survive the lifecycle refreshes that fire on boot / fresh
// draft / session events.
setCurrentModel('anthropic/claude-sonnet-4.6')
setCurrentProvider('anthropic')
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')
// A profile swap forces a reseed to the new profile's default.
await result.current.refreshCurrentModel(true)
expect($currentModel.get()).toBe('openai/gpt-5.5')
}) })
}) })

View File

@ -1,7 +1,7 @@
import { type QueryClient } from '@tanstack/react-query' import { type QueryClient } from '@tanstack/react-query'
import { useCallback } from 'react' import { useCallback } from 'react'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes' import { getGlobalModelInfo } from '@/hermes'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications' import { notifyError } from '@/store/notifications'
import { import {
@ -15,7 +15,6 @@ import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection { interface ModelSelection {
model: string model: string
persistGlobal: boolean
provider: string provider: string
} }
@ -28,6 +27,7 @@ interface ModelControlsOptions {
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) { export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
const { t } = useI18n() const { t } = useI18n()
const copy = t.desktop const copy = t.desktop
const updateModelOptionsCache = useCallback( const updateModelOptionsCache = useCallback(
(provider: string, model: string, includeGlobal: boolean) => { (provider: string, model: string, includeGlobal: boolean) => {
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model }) const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
@ -41,14 +41,24 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
[activeSessionId, queryClient] [activeSessionId, queryClient]
) )
const refreshCurrentModel = useCallback(async () => { // Seed the composer's model state from the profile default. `force` reseeds
// for a profile swap (the new profile has its own default); otherwise this
// only fills an EMPTY selection so a user's pick (plain UI state in
// $currentModel) survives the lifecycle refreshes that fire on boot / fresh
// draft / session events. A live session owns the footer, so skip entirely.
const refreshCurrentModel = useCallback(async (force = false) => {
try { try {
if ($activeSessionId.get()) {
return
}
if (!force && $currentModel.get()) {
return
}
const result = await getGlobalModelInfo() const result = await getGlobalModelInfo()
// A resumed/live session owns the footer model state. Global config if ($activeSessionId.get() || (!force && $currentModel.get())) {
// refreshes (gateway boot, profile swap, settings save) must not clobber
// the active chat's runtime model/provider in the status bar.
if ($activeSessionId.get()) {
return return
} }
@ -64,12 +74,14 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
} }
}, []) }, [])
// Returns whether the switch succeeded so callers can await it before // Returns whether the switch succeeded so callers can await it before applying
// applying follow-up changes (e.g. editing a model's reasoning/fast must land // follow-up changes. The composer model is plain UI state: with no live
// on the right active model — bail rather than write to the previous one). // session it's just stored (and shipped on the next session.create); with one
// it's scoped to that session via config.set. It NEVER writes the profile
// default — that lives in Settings → Model — so picking a model here can't
// silently mutate global config.
const selectModel = useCallback( const selectModel = useCallback(
async (selection: ModelSelection): Promise<boolean> => { async (selection: ModelSelection): Promise<boolean> => {
const includeGlobal = selection.persistGlobal || !activeSessionId
// Snapshot for rollback: the switch is applied optimistically, so a // Snapshot for rollback: the switch is applied optimistically, so a
// failure must restore the prior model/provider (store + query cache) // failure must restore the prior model/provider (store + query cache)
// rather than leave the UI showing a model the backend never selected. // rather than leave the UI showing a model the backend never selected.
@ -78,42 +90,34 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
setCurrentModel(selection.model) setCurrentModel(selection.model)
setCurrentProvider(selection.provider) setCurrentProvider(selection.provider)
updateModelOptionsCache(selection.provider, selection.model, includeGlobal) updateModelOptionsCache(selection.provider, selection.model, !activeSessionId)
try {
if (activeSessionId) {
await requestGateway('config.set', {
session_id: activeSessionId,
key: 'model',
value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
if (selection.persistGlobal) {
void refreshCurrentModel()
}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
// No live session yet: the pick is pure UI state. session.create reads
// $currentModel/$currentProvider and applies it as that session's override.
if (!activeSessionId) {
return true return true
} }
await setGlobalModel(selection.provider, selection.model) try {
void refreshCurrentModel() await requestGateway('config.set', {
void queryClient.invalidateQueries({ queryKey: ['model-options'] }) session_id: activeSessionId,
key: 'model',
value: `${selection.model} --provider ${selection.provider}`
})
void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] })
return true return true
} catch (err) { } catch (err) {
setCurrentModel(prevModel) setCurrentModel(prevModel)
setCurrentProvider(prevProvider) setCurrentProvider(prevProvider)
updateModelOptionsCache(prevProvider, prevModel, includeGlobal) updateModelOptionsCache(prevProvider, prevModel, !activeSessionId)
notifyError(err, copy.modelSwitchFailed) notifyError(err, copy.modelSwitchFailed)
return false return false
} }
}, },
[activeSessionId, copy.modelSwitchFailed, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache] [activeSessionId, copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache]
) )
return { refreshCurrentModel, selectModel, updateModelOptionsCache } return { refreshCurrentModel, selectModel, updateModelOptionsCache }

View File

@ -58,6 +58,7 @@ import { clearSessionTodos } from '@/store/todos'
import type { import type {
ClientSessionState, ClientSessionState,
BrowserManageResponse,
FileAttachResponse, FileAttachResponse,
HandoffFailResponse, HandoffFailResponse,
HandoffRequestResponse, HandoffRequestResponse,
@ -1141,6 +1142,81 @@ export function usePromptActions({
} catch (err) { } catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
} }
},
// /browser connect|disconnect|status manages the live CDP connection on
// the gateway host, mirroring the TUI's browser.manage RPC. It mutates
// BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only
// meaningful when that process runs on this machine, so it's gated to
// local connections. A remote gateway would act on the wrong host.
browser: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
if ($connection.get()?.mode === 'remote') {
renderSlashOutput(
'/browser manages a Chromium-family browser on the gateway host — only available when connected to a local gateway.'
)
return
}
const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean)
const cmdAction = rawAction.toLowerCase()
if (!['connect', 'disconnect', 'status'].includes(cmdAction)) {
renderSlashOutput(
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
)
return
}
const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
if (url) {
renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`)
}
try {
const result = await requestGateway<BrowserManageResponse>('browser.manage', {
action: cmdAction,
session_id: sessionId,
...(url && { url })
})
// Without a streamed session subscription, the gateway bundles its
// progress lines into `messages` — flush them inline.
result?.messages?.forEach(message => renderSlashOutput(message))
if (cmdAction === 'status') {
renderSlashOutput(
result?.connected
? `browser connected: ${result.url || '(url unavailable)'}`
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
)
return
}
if (cmdAction === 'disconnect') {
renderSlashOutput('browser disconnected')
return
}
if (result?.connected) {
renderSlashOutput('Browser connected to live Chromium-family browser via CDP')
renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`)
renderSlashOutput('next browser tool call will use this CDP endpoint')
}
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
} }
} }

View File

@ -2,6 +2,8 @@ import { cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react' import type { MutableRefObject } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { $resumeExhaustedSessionId, setResumeExhaustedSessionId } from '@/store/session'
import { useRouteResume } from './use-route-resume' import { useRouteResume } from './use-route-resume'
interface HarnessProps { interface HarnessProps {
@ -13,6 +15,8 @@ interface HarnessProps {
gatewayState: string gatewayState: string
locationPathname: string locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown> resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
resumeFailedSessionId?: null | string
resumeExhaustedSessionId?: null | string
routedSessionId: null | string routedSessionId: null | string
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: null | string selectedStoredSessionId: null | string
@ -20,8 +24,12 @@ interface HarnessProps {
startFreshSessionDraft: (focus: boolean) => unknown startFreshSessionDraft: (focus: boolean) => unknown
} }
function RouteResumeHarness(props: HarnessProps) { function RouteResumeHarness({
useRouteResume(props) resumeFailedSessionId = null,
resumeExhaustedSessionId = null,
...props
}: HarnessProps) {
useRouteResume({ ...props, resumeExhaustedSessionId, resumeFailedSessionId })
return null return null
} }
@ -256,3 +264,212 @@ describe('useRouteResume', () => {
expect(resumeSession).toHaveBeenCalledWith('session-1', true) expect(resumeSession).toHaveBeenCalledWith('session-1', true)
}) })
}) })
describe('useRouteResume bounded auto-retry after a failed resume', () => {
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
setResumeExhaustedSessionId(null)
})
// Common stranded-window props: gateway open, route on the session, no runtime
// yet, and the ref already synced to the route (resumeSession sets it at entry
// before failing) — the exact state that defeats the main effect's self-heal.
function strandedProps(resumeSession: (sid: string, focus: boolean) => Promise<unknown>) {
return {
activeSessionId: null,
activeSessionIdRef: { current: null } as MutableRefObject<null | string>,
creatingSessionRef: { current: false },
currentView: 'chat',
freshDraftReady: false,
gatewayState: 'open',
locationPathname: '/session-1',
resumeSession,
routedSessionId: 'session-1',
runtimeIdByStoredSessionIdRef: { current: new Map<string, string>() },
selectedStoredSessionId: 'session-1',
// Synced to the route by the failed resume's synchronous entry-write.
selectedStoredSessionIdRef: { current: 'session-1' } as MutableRefObject<null | string>,
startFreshSessionDraft: vi.fn()
}
}
it('retries the resume on backoff when the routed session is flagged as failed', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="session-1" />)
// The main effect fires one resume on mount (pathname-changed). Clear it so
// we assert purely the bounded-retry effect's scheduled retry below.
resumeSession.mockClear()
// No immediate fire — the retry is scheduled behind the backoff timer.
expect(resumeSession).not.toHaveBeenCalled()
// First backoff window (1s) elapses → one retry.
vi.advanceTimersByTime(1_000)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
it('does NOT retry a failed session that is not the routed one', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
// The failure flag points at a different session than the route.
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="other-session" />)
resumeSession.mockClear() // drop the mount resume
vi.advanceTimersByTime(10_000)
expect(resumeSession).not.toHaveBeenCalled()
})
it('skips the scheduled retry if the session already recovered when the timer fires', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear() // drop the mount resume
// A resume landed while we waited: runtime is now bound.
props.activeSessionIdRef.current = 'runtime-1'
vi.advanceTimersByTime(8_000)
expect(resumeSession).not.toHaveBeenCalled()
})
it('stops retrying after MAX_RESUME_RETRIES consecutive failures', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Model the real re-arm loop: resumeSession clears $resumeFailedSessionId at
// entry (null) and a repeat failure re-sets it ('session-1'). That null->id
// toggle is what re-runs the effect and advances the bounded counter. The
// routed session never changes, so the counter is NOT reset between cycles.
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear() // drop the mount resume; count only the retries
for (let i = 0; i < 8; i += 1) {
vi.advanceTimersByTime(8_000) // fire the scheduled retry (if any)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />) // cleared at entry
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />) // re-armed on failure
}
// Capped at MAX_RESUME_RETRIES (4): a persistently dead backend can't
// hot-loop the resume forever.
expect(resumeSession.mock.calls.length).toBe(4)
// Once auto-retry gives up, the exhausted latch is armed for the routed
// session so the chat view can swap the perpetual loader for an explicit
// error + manual Retry instead of spinning forever.
expect($resumeExhaustedSessionId.get()).toBe('session-1')
})
it('does not arm the exhausted latch while retries remain', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear()
// Two failure cycles — still under the 4-retry cap, so the latch must stay
// clear and the loader keeps spinning (auto-recovery hasn't given up yet).
for (let i = 0; i < 2; i += 1) {
vi.advanceTimersByTime(8_000)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
}
expect($resumeExhaustedSessionId.get()).toBeNull()
})
it('clears a stale exhausted latch when the route moves off the stranded session', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Pre-arm the latch as if this session had exhausted its retries.
setResumeExhaustedSessionId('session-1')
// Route is now on a different, healthy session that is not flagged as
// failed — the retry effect's "route moved off" branch clears the latch.
render(
<RouteResumeHarness
{...props}
activeSessionId="runtime-2"
activeSessionIdRef={{ current: 'runtime-2' }}
locationPathname="/session-2"
resumeFailedSessionId={null}
routedSessionId="session-2"
selectedStoredSessionId="session-2"
selectedStoredSessionIdRef={{ current: 'session-2' }}
/>
)
expect($resumeExhaustedSessionId.get()).toBeNull()
})
it('resets the retry counter for a fresh backoff cycle when the exhausted latch clears (manual retry, same session)', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Phase A — exhaust the bounded auto-retry (counter → MAX) like a dead
// backend. The resumeExhaustedSessionId prop stays null here: the hook sets
// the store, which doesn't feed back into the prop in this harness.
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear()
for (let i = 0; i < 8; i += 1) {
vi.advanceTimersByTime(8_000)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
}
expect(resumeSession.mock.calls.length).toBe(4) // capped
expect($resumeExhaustedSessionId.get()).toBe('session-1')
// Phase B — user clicks Retry on the SAME stranded session. resumeSession
// clears both latches at entry; the exhausted latch's armed->cleared edge
// must reset the attempt counter so a fresh bounded cycle runs, not a single
// one-shot attempt that immediately re-arms the error. Model the prop
// transitions: reflect the armed latch, then clear it (retry), then re-arm
// the failure latch on the fresh failure.
resumeSession.mockClear()
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId="session-1" resumeFailedSessionId="session-1" />)
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId="session-1" />)
// A real retry fires again instead of staying pinned at MAX (which would
// dispatch nothing). Without the reset the counter stays >= MAX and this
// advance dispatches zero resumes.
vi.advanceTimersByTime(8_000)
expect(resumeSession.mock.calls.length).toBeGreaterThan(0)
})
it('does not burn retry attempts on unrelated re-renders during the backoff window', () => {
vi.useFakeTimers()
const props = strandedProps(vi.fn())
// Mount schedules the first backoff timer. Then re-render repeatedly with a
// fresh resumeSession identity (referential instability — a real dep change
// for the retry effect) WITHOUT ever letting the timer fire. The old code
// incremented the attempt counter at schedule time, so >= MAX re-renders
// armed the exhausted error with zero resumes actually dispatched. The fix
// only advances the counter when a timer truly fires, so the latch stays
// clear no matter how many spurious re-renders happen mid-backoff.
const { rerender } = render(
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
)
for (let j = 0; j < 8; j += 1) {
rerender(
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
)
}
expect($resumeExhaustedSessionId.get()).toBeNull()
})
})

View File

@ -1,6 +1,7 @@
import { type MutableRefObject, useEffect, useRef } from 'react' import { type MutableRefObject, useEffect, useRef } from 'react'
import { isNewChatRoute } from '@/app/routes' import { isNewChatRoute } from '@/app/routes'
import { setResumeExhaustedSessionId } from '@/store/session'
interface RouteResumeOptions { interface RouteResumeOptions {
activeSessionId: string | null activeSessionId: string | null
@ -11,6 +12,17 @@ interface RouteResumeOptions {
gatewayState: string | undefined gatewayState: string | undefined
locationPathname: string locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown> resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
// Stored-session id whose most recent resume failed terminally (set by
// useSessionActions, mirrored from $resumeFailedSessionId). While this equals
// routedSessionId the window would otherwise latch on the loader forever, so
// the bounded-retry effect below re-attempts the resume.
resumeFailedSessionId: string | null
// Stored-session id whose bounded auto-retry has EXHAUSTED (mirrored from
// $resumeExhaustedSessionId). Only resumeSession clears this latch (manual
// Retry / reconnect / reselect) — the auto-retry loop never does — so its
// armed->cleared edge is an unambiguous "give me a fresh backoff cycle"
// signal the effect below uses to reset the attempt counter.
resumeExhaustedSessionId: string | null
routedSessionId: string | null routedSessionId: string | null
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: string | null selectedStoredSessionId: string | null
@ -18,6 +30,19 @@ interface RouteResumeOptions {
startFreshSessionDraft: (focus: boolean) => unknown startFreshSessionDraft: (focus: boolean) => unknown
} }
// Bounded auto-retry for a stranded session window. A resume can fail terminally
// (gateway RPC reject + REST fallback failure) on a transiently wedged backend —
// dead provider key, a runaway turn hogging the dispatcher, flaky DNS. Without a
// retry the loader latches forever. We retry with backoff, capped, so a
// genuinely dead backend doesn't hot-loop the resume.
const MAX_RESUME_RETRIES = 4
const RESUME_RETRY_BASE_MS = 1_000
const RESUME_RETRY_MAX_MS = 8_000
function resumeRetryDelayMs(attempt: number): number {
return Math.min(RESUME_RETRY_MAX_MS, RESUME_RETRY_BASE_MS * 2 ** attempt)
}
// HashRouter boot edge case: pathname briefly reads `/` before the hash is // HashRouter boot edge case: pathname briefly reads `/` before the hash is
// parsed. If the hash references a real session, defer; resume picks it up // parsed. If the hash references a real session, defer; resume picks it up
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states. // next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
@ -49,6 +74,8 @@ export function useRouteResume({
gatewayState, gatewayState,
locationPathname, locationPathname,
resumeSession, resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId, routedSessionId,
runtimeIdByStoredSessionIdRef, runtimeIdByStoredSessionIdRef,
selectedStoredSessionId, selectedStoredSessionId,
@ -58,6 +85,16 @@ export function useRouteResume({
const lastPathnameRef = useRef<string | null>(null) const lastPathnameRef = useRef<string | null>(null)
const seenGatewayStateRef = useRef(false) const seenGatewayStateRef = useRef(false)
const wasGatewayOpenRef = useRef(false) const wasGatewayOpenRef = useRef(false)
// Per-session retry bookkeeping for the bounded auto-retry effect below. Keyed
// by the session id we're retrying so switching chats resets the counter.
const retrySessionIdRef = useRef<string | null>(null)
const retryAttemptRef = useRef(0)
// Tracks the previous exhausted-latch value so we can detect its armed->cleared
// edge. resumeSession clears $resumeExhaustedSessionId on a manual Retry /
// reconnect / reselect; that transition is our cue to reset the attempt counter
// for a fresh backoff cycle on the SAME session (the auto-retry loop itself
// never touches this latch, so it can't spuriously trigger the reset).
const prevResumeExhaustedRef = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
const gatewayOpen = gatewayState === 'open' const gatewayOpen = gatewayState === 'open'
@ -139,4 +176,111 @@ export function useRouteResume({
selectedStoredSessionIdRef, selectedStoredSessionIdRef,
startFreshSessionDraft startFreshSessionDraft
]) ])
// Bounded auto-retry: when the routed session's resume failed terminally
// (resumeFailedSessionId matches the route), schedule a backoff retry so the
// window recovers on its own instead of latching the loader forever. This is
// the safety net the main effect above can't provide: after a failed resume,
// selectedStoredSessionIdRef.current already equals the route (resumeSession
// sets it synchronously at entry) and the pathname/gateway are unchanged, so
// none of stuckOnRoutedSession / pathnameChanged / gatewayBecameOpen fire
// again. resumeSession clears resumeFailedSessionId on its next attempt; a
// success keeps it clear (the effect's guard then no-ops), a repeat failure
// re-arms it and we back off further, capped at MAX_RESUME_RETRIES.
useEffect(() => {
// Detect the exhausted-latch armed->cleared edge for the current route. Only
// resumeSession clears $resumeExhaustedSessionId (manual Retry / reconnect /
// reselect) — the auto-retry loop never touches it — so this transition
// uniquely means "the user asked for another go." Reset the attempt counter
// for a fresh bounded backoff cycle on the SAME session. Without this,
// retryAttemptRef stays pinned at MAX after exhaustion (the !stranded reset
// below only fires on a route CHANGE to a different session), so a manual
// retry on the same stranded session would get exactly ONE attempt and then
// immediately re-arm the exhausted error — never the renewed backoff cycle
// the store/session.ts + use-session-actions.ts comments promise. (Point 2)
const wasExhausted = prevResumeExhaustedRef.current
prevResumeExhaustedRef.current = resumeExhaustedSessionId
if (wasExhausted && wasExhausted === routedSessionId && resumeExhaustedSessionId !== wasExhausted) {
retrySessionIdRef.current = routedSessionId
retryAttemptRef.current = 0
}
if (currentView !== 'chat' || gatewayState !== 'open') {
return
}
const stranded =
Boolean(routedSessionId) &&
resumeFailedSessionId === routedSessionId &&
!creatingSessionRef.current
if (!stranded) {
// Route moved off the stranded session (or it recovered) — reset the
// counter so a future failure on another session starts fresh, and clear
// any exhausted-latch armed for a session we're no longer viewing (never
// the current route: that's the error state we want to keep showing).
// resumeSession also clears it on a fresh attempt; this covers a plain
// route-change away from the stranded window.
if (retrySessionIdRef.current !== routedSessionId) {
retrySessionIdRef.current = null
retryAttemptRef.current = 0
setResumeExhaustedSessionId(current => (current && current !== routedSessionId ? null : current))
}
return
}
// New stranded session id → reset the attempt counter.
if (retrySessionIdRef.current !== routedSessionId) {
retrySessionIdRef.current = routedSessionId
retryAttemptRef.current = 0
}
if (retryAttemptRef.current >= MAX_RESUME_RETRIES) {
// Give up auto-retrying a persistently dead backend; the user can still
// reconnect / reselect (which resets the counter via the branch above).
// Surface an explicit error + manual Retry in the chat view instead of
// spinning the loader forever — resumeSession (manual Retry / reconnect /
// reselect) clears this latch and resets the counter for a fresh cycle.
setResumeExhaustedSessionId(routedSessionId)
return
}
const attempt = retryAttemptRef.current
const sessionId = routedSessionId as string
const timer = setTimeout(() => {
// Re-check liveness at fire time: a resume may have landed while we waited.
if (
creatingSessionRef.current ||
selectedStoredSessionIdRef.current !== sessionId ||
activeSessionIdRef.current !== null
) {
return
}
// Consume an attempt ONLY now that a resume is actually dispatching.
// Incrementing at schedule time (the old behavior) let unrelated dep
// changes during the 1s8s backoff window — a transient gatewayState
// flip, a non-referentially-stable resumeSession — clear the pending
// timer and re-run the effect, burning an attempt without any resume
// having fired. A flapping backend could then hit MAX in a couple of
// re-renders with far fewer than MAX real attempts. (Point 3)
retryAttemptRef.current += 1
void resumeSession(sessionId, true)
}, resumeRetryDelayMs(attempt))
return () => clearTimeout(timer)
}, [
activeSessionIdRef,
creatingSessionRef,
currentView,
gatewayState,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
selectedStoredSessionIdRef
])
} }

View File

@ -3,8 +3,9 @@ import type { MutableRefObject } from 'react'
import { useEffect } from 'react' import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages } from '@/hermes'
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile' import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $currentCwd } from '@/store/session' import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
import type { ClientSessionState } from '../../types' import type { ClientSessionState } from '../../types'
@ -117,3 +118,142 @@ describe('createBackendSessionForSend profile routing', () => {
expect(params).toMatchObject({ profile: 'default' }) expect(params).toMatchObject({ profile: 'default' })
}) })
}) })
// ── Resume failure recovery (the "stuck loading session window" bug) ──────────
// When session.resume rejects AND the REST transcript fallback ALSO fails, the
// hook must (a) not throw out of the fallback (which stranded the loader), and
// (b) arm $resumeFailedSessionId so use-route-resume can retry. A resume that
// succeeds must NOT leave the flag armed.
function ResumeHarness({
onReady,
requestGateway
}: {
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
})
useEffect(() => {
onReady(actions.resumeSession)
}, [actions.resumeSession, onReady])
return null
}
describe('resumeSession failure recovery', () => {
afterEach(() => {
cleanup()
setResumeFailedSessionId(null)
setMessages([])
vi.restoreAllMocks()
})
async function runResume(
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
): Promise<void> {
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} />)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
}
it('arms $resumeFailedSessionId when resume RPC and REST fallback both fail', async () => {
// session.resume rejects (e.g. timeout against a wedged backend)...
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
// ...and the REST transcript fallback also rejects (backend unreachable).
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
await runResume(requestGateway)
// The window is no longer silently stranded: the failure latch is armed for
// the stored session, which use-route-resume consumes to retry.
expect($resumeFailedSessionId.get()).toBe('stored-1')
})
it('does NOT arm the failure latch when the resume RPC fails but the REST fallback paints history', async () => {
// session.resume rejects, but the REST transcript fallback succeeds and
// hydrates a readable transcript — the window is NOT stranded.
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [
{ content: 'hello', role: 'user', timestamp: 1 },
{ content: 'hi there', role: 'assistant', timestamp: 2 }
],
session_id: 'stored-1'
} as never)
await runResume(requestGateway)
// Arming here would auto-retry a window that already shows history and,
// on exhaustion, blank that transcript behind the error overlay — a
// regression vs. plain fallback-success. The latch must stay clear.
expect($resumeFailedSessionId.get()).toBeNull()
// The fallback transcript is visible.
expect($messages.get().length).toBeGreaterThan(0)
})
it('does NOT throw out of the fallback when REST also fails (no unhandled rejection)', async () => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
// resumeSession must resolve (swallow the fallback failure), not reject.
await expect(runResume(requestGateway)).resolves.toBeUndefined()
})
it('leaves the failure latch clear when resume succeeds', async () => {
// Pre-arm to prove a successful resume clears it (entry-clear path).
setResumeFailedSessionId('stored-1')
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
await runResume(requestGateway)
expect($resumeFailedSessionId.get()).toBeNull()
})
})

View File

@ -15,6 +15,10 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { import {
$currentCwd, $currentCwd,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$messages, $messages,
$sessions, $sessions,
$yoloActive, $yoloActive,
@ -34,6 +38,8 @@ import {
setFreshDraftReady, setFreshDraftReady,
setIntroSeed, setIntroSeed,
setMessages, setMessages,
setResumeExhaustedSessionId,
setResumeFailedSessionId,
setSelectedStoredSessionId, setSelectedStoredSessionId,
setSessions, setSessions,
setSessionStartedAt, setSessionStartedAt,
@ -407,13 +413,13 @@ export function useSessionActions({
}) })
setSessionStartedAt(null) setSessionStartedAt(null)
setTurnStartedAt(null) setTurnStartedAt(null)
// New chats start in the configured default project dir when set, // The composer's model/effort/fast is sticky UI state (persisted in
// otherwise the sticky last-used workspace (PR #37586). // localStorage) — a new chat FOLLOWS your last pick instead of snapping
setCurrentModel('') // back to the profile default, so we deliberately don't reset it here. The
setCurrentProvider('') // profile default still owns first-run seeding and profile switches (see
setCurrentReasoningEffort('') // refreshCurrentModel). Only $currentServiceTier (a live-session mirror)
// is cleared.
setCurrentServiceTier('') setCurrentServiceTier('')
setCurrentFastMode(false)
setYoloActive(false) setYoloActive(false)
setCurrentCwd(workspaceCwdForNewSession()) setCurrentCwd(workspaceCwdForNewSession())
setCurrentBranch('') setCurrentBranch('')
@ -443,11 +449,23 @@ export function useSessionActions({
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get()) const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(newChatProfile) await ensureGatewayProfile(newChatProfile)
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession() const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
// The composer's model/effort/fast is sticky UI state ($currentModel,
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
// with every session.create so the new chat opens on whatever the picker
// shows — applied as per-session overrides, never written to the profile
// default (that lives in Settings → Model).
const uiModel = $currentModel.get().trim()
const uiProvider = $currentProvider.get().trim()
const uiEffort = $currentReasoningEffort.get().trim()
const uiFast = $currentFastMode.get()
const created = await requestGateway<SessionCreateResponse>('session.create', { const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96, cols: 96,
...(cwd && { cwd }), ...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {}) ...(newChatProfile ? { profile: newChatProfile } : {}),
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
...(uiFast ? { fast: true } : {})
}) })
const stored = created.stored_session_id ?? null const stored = created.stored_session_id ?? null
@ -563,6 +581,15 @@ export function useSessionActions({
clearNotifications() clearNotifications()
setSelectedStoredSessionId(storedSessionId) setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId selectedStoredSessionIdRef.current = storedSessionId
// Optimistically clear any prior resume-failure latch for this session:
// we're attempting a fresh resume, so the self-heal in use-route-resume
// must not keep treating it as stranded. It's re-armed below only if THIS
// attempt fails terminally (RPC reject + REST fallback failure).
setResumeFailedSessionId(current => (current === storedSessionId ? null : current))
// Also clear the exhausted-latch: a fresh attempt (manual Retry, reconnect,
// reselect) gives the bounded auto-retry counter a clean cycle, so the
// chat view drops the error state and shows the loader again.
setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current))
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
@ -753,6 +780,15 @@ export function useSessionActions({
return return
} }
// The gateway resume RPC failed. Try the REST transcript as a fallback
// so the window at least shows history. CRITICAL: this fallback must be
// wrapped in its own try — if it ALSO throws (wedged/unreachable backend,
// the common case when resume failed in the first place), an unguarded
// throw here skips setMessages AND leaves activeSessionId null with an
// empty transcript. That is the exact state the thread loader latches on
// forever (messagesEmpty && !activeSessionId) with no recovery path —
// the "open in new window stays stuck loading, even after a nap" bug.
try {
const fallback = await getSessionMessages(storedSessionId, sessionProfile) const fallback = await getSessionMessages(storedSessionId, sessionProfile)
if (!isCurrentResume()) { if (!isCurrentResume()) {
@ -760,6 +796,25 @@ export function useSessionActions({
} }
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get())) setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
} catch {
// Fallback also failed: nothing to paint. Leave whatever messages are
// already shown and fall through to arm the resume-failure latch so
// use-route-resume re-attempts the resume on the next render / window
// focus / gateway reconnect instead of stranding the loader.
}
if (isCurrentResume() && $messages.get().length === 0) {
// Arm the self-heal ONLY when the window is still empty: the gateway
// resume rejected AND the REST fallback failed to paint a transcript.
// That is the exact stranded state the loader latches on
// (messagesEmpty && !activeSessionId), and matches $resumeFailedSessionId's
// documented contract. If the REST fallback DID paint history, the
// window is readable — arming here would needlessly auto-retry and,
// once retries exhaust, blank that visible transcript behind the
// exhausted-state error overlay (a regression vs. plain fallback success).
setResumeFailedSessionId(storedSessionId)
}
notifyError(err, copy.resumeFailed) notifyError(err, copy.resumeFailed)
} finally { } finally {
if (isCurrentResume()) { if (isCurrentResume()) {

View File

@ -2,12 +2,14 @@ import { act, cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react' import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import { import {
$currentFastMode, $currentFastMode,
$currentModel, $currentModel,
$currentProvider, $currentProvider,
$currentReasoningEffort, $currentReasoningEffort,
$currentServiceTier, $currentServiceTier,
$messages,
$turnStartedAt, $turnStartedAt,
setCurrentFastMode, setCurrentFastMode,
setCurrentModel, setCurrentModel,
@ -213,3 +215,113 @@ describe('useSessionStateCache — per-session turn timer', () => {
expect($currentFastMode.get()).toBe(false) expect($currentFastMode.get()).toBe(false)
}) })
}) })
function userMessage(id: string, text: string): ChatMessage {
return { id, role: 'user', parts: [{ type: 'text', text }] }
}
function assistantText(id: string, text: string): ChatMessage {
return { id, role: 'assistant', parts: [{ type: 'text', text }] }
}
function assistantError(id: string, error: string): ChatMessage {
return { id, role: 'assistant', parts: [], error, pending: false }
}
interface ViewHarnessProps {
activeSessionId: string | null
onReady: (cache: Cache) => void
}
function ViewHarness({ activeSessionId, onReady }: ViewHarnessProps) {
const busyRef: MutableRefObject<boolean> = { current: false }
const cache = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId: null,
setAwaitingResponse: () => undefined,
setBusy: () => undefined,
// Wire the published view back into the real $messages atom the flush
// reads from, so the round-trip matches production.
setMessages: messages => $messages.set(messages)
})
onReady(cache)
return null
}
describe('useSessionStateCache — cross-thread error isolation', () => {
afterEach(() => {
cleanup()
$messages.set([])
})
it('does not leak a failed turn into another thread on switch', () => {
$messages.set([])
let cache!: Cache
const { rerender } = render(<ViewHarness activeSessionId="thread-A" onReady={c => (cache = c)} />)
// Thread A ends its turn with an out-of-funds error and is on screen.
act(() => {
cache.updateSessionState(
'thread-A',
state => ({
...state,
busy: false,
messages: [userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'Out of funds')]
}),
'stored-A'
)
})
expect($messages.get().some(message => message.error === 'Out of funds')).toBe(true)
// Switch to thread B (which completed cleanly). Its cached state syncs to
// the view while $messages still holds thread A's transcript.
rerender(<ViewHarness activeSessionId="thread-B" onReady={c => (cache = c)} />)
act(() => {
cache.updateSessionState(
'thread-B',
state => ({
...state,
busy: false,
messages: [userMessage('user-b', 'hello'), assistantText('assistant-b', 'hi there')]
}),
'stored-B'
)
})
expect($messages.get().map(message => message.id)).toEqual(['user-b', 'assistant-b'])
expect($messages.get().some(message => message.error === 'Out of funds')).toBe(false)
})
it('still preserves a same-session local error a heartbeat dropped', () => {
$messages.set([])
let cache!: Cache
render(<ViewHarness activeSessionId="thread-A" onReady={c => (cache = c)} />)
// First paint establishes thread A as the on-screen session.
act(() => {
cache.updateSessionState(
'thread-A',
state => ({ ...state, busy: false, messages: [userMessage('user-a', 'do the thing')] }),
'stored-A'
)
})
// A local error lands in the view (e.g. failAssistantMessage wrote it).
$messages.set([userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'OpenRouter 403')])
// A later same-session heartbeat carries cached state that lost the error.
act(() => {
cache.updateSessionState('thread-A', state => ({
...state,
busy: false,
messages: [userMessage('user-a', 'do the thing')]
}))
})
expect($messages.get().some(message => message.error === 'OpenRouter 403')).toBe(true)
})
})

View File

@ -79,6 +79,9 @@ export function useSessionStateCache({
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>()) const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null) const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
const viewSyncRafRef = useRef<number | null>(null) const viewSyncRafRef = useRef<number | null>(null)
// Runtime id whose transcript currently occupies `$messages` — lets the
// flush below tell a same-session refresh from a thread switch.
const viewSessionIdRef = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
activeSessionIdRef.current = activeSessionId activeSessionIdRef.current = activeSessionId
@ -142,12 +145,22 @@ export function useSessionStateCache({
// jerks the scroll position while the user is reading. Skip the publish when // jerks the scroll position while the user is reading. Skip the publish when
// the merged result is content-identical to what's already on screen. // the merged result is content-identical to what's already on screen.
const currentMessages = $messages.get() const currentMessages = $messages.get()
const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages) // On a thread switch `$messages` still holds the *previous* thread, so
// preserving its local errors would graft that thread's failed turn (e.g.
// an out-of-funds error) onto this one — then cascade it everywhere as the
// polluted view becomes the next switch's baseline. Only carry errors
// across a same-session refresh; our cached state already keeps its own.
const nextMessages =
viewSessionIdRef.current === pending.sessionId
? preserveLocalAssistantErrors(pending.state.messages, currentMessages)
: pending.state.messages
if (!sameMessageList(nextMessages, currentMessages)) { if (!sameMessageList(nextMessages, currentMessages)) {
setMessages(nextMessages) setMessages(nextMessages)
} }
viewSessionIdRef.current = pending.sessionId
syncRuntimeMetadataToView(pending.state) syncRuntimeMetadataToView(pending.state)
setBusy(pending.state.busy) setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy) setMutableRef(busyRef, pending.state.busy)

View File

@ -228,7 +228,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
onMainModelChanged={onMainModelChanged} onMainModelChanged={onMainModelChanged}
/> />
) : activeView === 'providers' ? ( ) : activeView === 'providers' ? (
<ProvidersSettings onViewChange={setProviderView} view={providerView} /> <ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? ( ) : activeView === 'keys' ? (
<KeysSettings view={keysView} /> <KeysSettings view={keysView} />
) : activeView === 'mcp' ? ( ) : activeView === 'mcp' ? (

View File

@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn() const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn() const getRecommendedDefaultModel = vi.fn()
const setEnvVar = vi.fn() const setEnvVar = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
const startManualProviderOAuth = vi.fn() const startManualProviderOAuth = vi.fn()
vi.mock('@/hermes', () => ({ vi.mock('@/hermes', () => ({
@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({
getAuxiliaryModels: () => getAuxiliaryModels(), getAuxiliaryModels: () => getAuxiliaryModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body), setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug), getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
setEnvVar: (key: string, value: string) => setEnvVar(key, value) setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
})) }))
vi.mock('@/store/onboarding', () => ({ vi.mock('@/store/onboarding', () => ({
@ -35,7 +39,13 @@ beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' }) getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({ getGlobalModelOptions.mockResolvedValue({
providers: [ providers: [
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true }, {
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
// An unconfigured api_key provider — surfaced by the full-universe payload. // An unconfigured api_key provider — surfaced by the full-universe payload.
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' } { name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
] ]
@ -47,6 +57,8 @@ beforeEach(() => {
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] }) setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null }) getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true }) setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
}) })
afterEach(() => { afterEach(() => {
@ -100,6 +112,31 @@ describe('ModelSettings', () => {
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123')) await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
}) })
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
const fastSwitch = await screen.findByRole('switch')
fireEvent.click(fastSwitch)
await waitFor(() =>
expect(saveHermesConfig).toHaveBeenCalledWith(
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
)
)
})
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }]
})
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
expect(screen.queryByRole('switch')).toBeNull()
})
it('renders the auxiliary task rows', async () => { it('renders the auxiliary task rows', async () => {
await renderModelSettings() await renderModelSettings()

View File

@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { import {
getAuxiliaryModels, getAuxiliaryModels,
getGlobalModelInfo, getGlobalModelInfo,
@ -10,6 +11,8 @@ import {
getMoaModels, getMoaModels,
getRecommendedDefaultModel, getRecommendedDefaultModel,
saveMoaModels, saveMoaModels,
getHermesConfigRecord,
saveHermesConfig,
setEnvVar, setEnvVar,
setModelAssignment setModelAssignment
} from '@/hermes' } from '@/hermes'
@ -17,11 +20,26 @@ import type { AuxiliaryModelsResponse, MoaConfigResponse, MoaModelSlot, ModelOpt
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons' import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding' import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import type { HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT } from './constants' import { CONTROL_TEXT } from './constants'
import { getNested, setNested } from './helpers'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives' import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
// Empty config = Hermes default (medium), shown as Medium.
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
// normal (mirrors tui_gateway _load_service_tier).
const isFastTier = (tier: unknown): boolean =>
['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase())
// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1).
const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal'
// A provider row is "ready" to pick a model from when it reports models. The // A provider row is "ready" to pick a model from when it reports models. The
// backend now surfaces the full `hermes model` universe (every canonical // backend now surfaces the full `hermes model` universe (every canonical
// provider), so unconfigured providers come back with `authenticated:false` // provider), so unconfigured providers come back with `authenticated:false`
@ -102,6 +120,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [moa, setMoa] = useState<MoaConfigResponse | null>(null) const [moa, setMoa] = useState<MoaConfigResponse | null>(null)
const [selectedMoaPreset, setSelectedMoaPreset] = useState('') const [selectedMoaPreset, setSelectedMoaPreset] = useState('')
const [newMoaPresetName, setNewMoaPresetName] = useState('') const [newMoaPresetName, setNewMoaPresetName] = useState('')
// Full profile config, kept so the reasoning/speed defaults round-trip
// (read agent.* → write back the whole record) like the generic config page.
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [applying, setApplying] = useState(false) const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null) const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' }) const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
@ -118,11 +139,12 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setError('') setError('')
try { try {
const [modelInfo, modelOptions, auxiliaryModels, moaModels] = await Promise.all([ const [modelInfo, modelOptions, auxiliaryModels, moaModels, cfg] = await Promise.all([
getGlobalModelInfo(), getGlobalModelInfo(),
getGlobalModelOptions(), getGlobalModelOptions(),
getAuxiliaryModels(), getAuxiliaryModels(),
getMoaModels().catch(() => null) getMoaModels().catch(() => null),
getHermesConfigRecord()
]) ])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider }) setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
@ -135,6 +157,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
if (moaModels) { if (moaModels) {
setSelectedMoaPreset(prev => prev && moaModels.presets[prev] ? prev : moaModels.default_preset) setSelectedMoaPreset(prev => prev && moaModels.presets[prev] ? prev : moaModels.default_preset)
} }
setConfig(cfg)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)) setError(err instanceof Error ? err.message : String(err))
} finally { } finally {
@ -248,6 +271,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model })) .map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
}, [auxiliary, mainModel]) }, [auxiliary, mainModel])
// Capabilities of the APPLIED main model — gates the profile-default
// reasoning/speed controls the same way the composer picker gates per-model
// edits (reasoning defaults on, fast defaults off when unreported).
const mainCaps = useMemo(() => {
const row = providers.find(provider => provider.slug === mainModel?.provider)
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
}, [providers, mainModel])
const reasoningSupported = mainCaps?.reasoning ?? true
const fastSupported = mainCaps?.fast ?? false
const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium'
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
// Persist a single agent.* default by round-tripping the whole config record
// (PUT /api/config replaces it) — optimistic, with rollback on failure.
const writeAgentDefault = useCallback(
async (key: string, value: string) => {
if (!config) {
return
}
const prev = config
const next = setNested(config, key, value)
setConfig(next)
try {
await saveHermesConfig(next)
} catch (err) {
setConfig(prev)
notifyError(err, m.defaultsFailed)
}
},
[config, m.defaultsFailed]
)
// Paste an API key for the selected `api_key` provider, persist it, then // Paste an API key for the selected `api_key` provider, persist it, then
// refresh so the now-authenticated provider's models populate. Auto-selects // refresh so the now-authenticated provider's models populate. Auto-selects
// the recommended default model so the user can Apply in one more click. // the recommended default model so the user can Apply in one more click.
@ -500,6 +559,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`} : `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
</p> </p>
)} )}
{config && mainModel && (reasoningSupported || fastSupported) && (
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
<span className="text-xs text-muted-foreground">{m.defaultsLabel}</span>
{reasoningSupported && (
<div className="flex items-center gap-2 text-xs">
{m.reasoning}
<Select onValueChange={value => void writeAgentDefault('agent.reasoning_effort', value)} value={effortValue}>
<SelectTrigger className={cn('min-w-28', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_VALUES.map(value => (
<SelectItem key={value} value={value}>
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{fastSupported && (
<label className="flex items-center gap-2 text-xs">
{t.shell.modelOptions.fast}
<Switch
checked={fastOn}
onCheckedChange={checked => void writeAgentDefault('agent.service_tier', checked ? 'fast' : 'normal')}
size="xs"
/>
</label>
)}
</div>
)}
{error && <div className="mt-2 text-xs text-destructive">{error}</div>} {error && <div className="mt-2 text-xs text-destructive">{error}</div>}
{switchStaleAux.length > 0 && ( {switchStaleAux.length > 0 && (
<div className="mt-2"> <div className="mt-2">

View File

@ -55,7 +55,7 @@ afterEach(() => {
async function renderProvidersSettings() { async function renderProvidersSettings() {
const { ProvidersSettings } = await import('./providers-settings') const { ProvidersSettings } = await import('./providers-settings')
return render(<ProvidersSettings onViewChange={vi.fn()} view="accounts" />) return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
} }
describe('ProvidersSettings', () => { describe('ProvidersSettings', () => {
@ -95,6 +95,6 @@ describe('ProvidersSettings', () => {
expect(await screen.findByText('Qwen Code')).toBeTruthy() expect(await screen.findByText('Qwen Code')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull() expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
expect(screen.getByText(/managed outside Hermes/)).toBeTruthy() expect(screen.getByText(/managed by its own CLI/)).toBeTruthy()
}) })
}) })

View File

@ -1,6 +1,8 @@
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { runInTerminal } from '@/app/right-sidebar/store'
import { import {
FEATURED_ID, FEATURED_ID,
FeaturedProviderRow, FeaturedProviderRow,
@ -23,6 +25,20 @@ import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority } from './helpers' import { providerGroup, providerMeta, providerPriority } from './helpers'
import { LoadingState, SettingsContent } from './primitives' import { LoadingState, SettingsContent } from './primitives'
// The embedded terminal (and thus the "run disconnect command" path) only
// exists in the Electron desktop shell, not the web dashboard.
const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal)
// Parallel group headers ("Connected", "Other providers") so the expanded list
// reads as its own section instead of bleeding into the connected group.
function GroupLabel({ children }: { children: ReactNode }) {
return (
<p className="mt-3 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
{children}
</p>
)
}
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. // Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
@ -90,11 +106,13 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
function OAuthPicker({ function OAuthPicker({
disconnecting, disconnecting,
onDisconnect, onDisconnect,
onTerminalDisconnect,
onWantApiKey, onWantApiKey,
providers providers
}: { }: {
disconnecting: null | string disconnecting: null | string
onDisconnect: (provider: OAuthProvider) => void onDisconnect: (provider: OAuthProvider) => void
onTerminalDisconnect: (provider: OAuthProvider) => void
onWantApiKey: () => void onWantApiKey: () => void
providers: OAuthProvider[] providers: OAuthProvider[]
}) { }) {
@ -138,15 +156,14 @@ function OAuthPicker({
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />} {featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
{connected.length > 0 && ( {connected.length > 0 && (
<> <>
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)"> <GroupLabel>{p.connected}</GroupLabel>
{p.connected}
</p>
{connected.map(p => ( {connected.map(p => (
<ConnectedProviderRow <ConnectedProviderRow
disconnecting={disconnecting === p.id} disconnecting={disconnecting === p.id}
key={p.id} key={p.id}
onDisconnect={onDisconnect} onDisconnect={onDisconnect}
onSelect={select} onSelect={select}
onTerminalDisconnect={onTerminalDisconnect}
provider={p} provider={p}
/> />
))} ))}
@ -154,6 +171,7 @@ function OAuthPicker({
)} )}
{showOthers && ( {showOthers && (
<> <>
{connected.length > 0 && <GroupLabel>{p.otherProviders}</GroupLabel>}
{others.map(p => ( {others.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} /> <ProviderRow key={p.id} onSelect={select} provider={p} />
))} ))}
@ -180,21 +198,26 @@ function ConnectedProviderRow({
disconnecting, disconnecting,
onDisconnect, onDisconnect,
onSelect, onSelect,
onTerminalDisconnect,
provider provider
}: { }: {
disconnecting: boolean disconnecting: boolean
onDisconnect: (provider: OAuthProvider) => void onDisconnect: (provider: OAuthProvider) => void
onSelect: (provider: OAuthProvider) => void onSelect: (provider: OAuthProvider) => void
onTerminalDisconnect: (provider: OAuthProvider) => void
provider: OAuthProvider provider: OAuthProvider
}) { }) {
const { t } = useI18n() const { t } = useI18n()
const copy = t.settings.providers
const title = providerTitle(provider) const title = providerTitle(provider)
const Trail = provider.flow === 'external' ? Terminal : ChevronRight const Trail = provider.flow === 'external' ? Terminal : ChevronRight
// Hermes can clear this provider's creds via the API.
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external' const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
// External (CLI-managed) provider Hermes can't clear via the API, but ships a
const disconnectHint = provider.flow === 'external' // command we can run in the embedded terminal (Electron shell only).
? t.settings.providers.removeExternal(title, provider.cli_command) const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal()
: t.settings.providers.removeKeyManaged(title) // Only fall back to a static "remove it elsewhere" hint when we offer no button.
const showHint = !canDisconnect && !terminalDisconnect
return ( return (
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)"> <div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
@ -203,13 +226,13 @@ function ConnectedProviderRow({
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span> <span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"> <span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" /> <Check className="size-3" />
{t.settings.providers.connected} {copy.connected}
</span> </span>
</div> </div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p> <p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
{!canDisconnect && ( {showHint && (
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70"> <p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
{disconnectHint} {provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}
</p> </p>
)} )}
</button> </button>
@ -228,6 +251,18 @@ function ConnectedProviderRow({
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />} {disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button> </Button>
)} )}
{terminalDisconnect && (
<Button
aria-label={`${copy.disconnect} ${title}`}
onClick={() => onTerminalDisconnect(provider)}
size="icon-xs"
title={copy.disconnectInTerminal}
type="button"
variant="ghost"
>
<Trash2 className="size-3" />
</Button>
)}
</div> </div>
</div> </div>
) )
@ -243,7 +278,7 @@ function NoProviderKeys() {
) )
} }
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) { export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) {
const { t } = useI18n() const { t } = useI18n()
const { rowProps, vars } = useEnvCredentials() const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([]) const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
@ -282,6 +317,29 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
return () => void (cancelled = true) return () => void (cancelled = true)
}, [onboardingActive]) }, [onboardingActive])
// External (CLI-managed) providers can't be cleared via the API by design —
// Hermes never deletes creds another tool owns behind a silent API call.
// Instead we run the documented removal command in the embedded terminal so
// the user sees exactly what executes, then return them to chat to watch it.
function handleTerminalDisconnect(provider: OAuthProvider) {
const command = provider.disconnect_command
if (!command) {
return
}
const name = providerTitle(provider)
if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) {
return
}
// Leave the settings overlay so the terminal pane (chat-only) is visible.
onClose()
runInTerminal(command)
notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) })
}
async function handleDisconnect(provider: OAuthProvider) { async function handleDisconnect(provider: OAuthProvider) {
const name = providerTitle(provider) const name = providerTitle(provider)
@ -341,6 +399,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
<OAuthPicker <OAuthPicker
disconnecting={disconnecting} disconnecting={disconnecting}
onDisconnect={provider => void handleDisconnect(provider)} onDisconnect={provider => void handleDisconnect(provider)}
onTerminalDisconnect={handleTerminalDisconnect}
onWantApiKey={() => onViewChange('keys')} onWantApiKey={() => onViewChange('keys')}
providers={oauthProviders} providers={oauthProviders}
/> />
@ -359,6 +418,7 @@ interface ProviderKeyGroup {
} }
interface ProvidersSettingsProps { interface ProvidersSettingsProps {
onClose: () => void
onViewChange: (view: ProviderView) => void onViewChange: (view: ProviderView) => void
view: ProviderView view: ProviderView
} }

View File

@ -16,7 +16,7 @@ import {
} from '@/store/layout' } from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes' import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session' import { $connection } from '@/store/session'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' import { isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants' import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
@ -80,7 +80,10 @@ export function AppShell({
const connection = useStore($connection) const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
const hideTitlebarControls = isNewSessionWindow() // Every secondary window (new-session scratch, subagent watch, cmd-click
// pop-out) is a compact side panel — none of them carry the full titlebar
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
const hideTitlebarControls = isSecondaryWindow()
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen) const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero // Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
// on macOS, where window controls sit on the left and are reported via // on macOS, where window controls sit on the left and are reported via

View File

@ -1,5 +1,4 @@
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center' import type { CommandCenterSection } from '@/app/command-center'
@ -9,7 +8,6 @@ import { useI18n } from '@/i18n'
import { import {
Activity, Activity,
AlertCircle, AlertCircle,
ChevronDown,
Clock, Clock,
Command, Command,
Hash, Hash,
@ -19,7 +17,6 @@ import {
Zap, Zap,
ZapFilled ZapFilled
} from '@/lib/icons' } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -30,16 +27,11 @@ import {
$activeSessionId, $activeSessionId,
$busy, $busy,
$connection, $connection,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentUsage, $currentUsage,
$sessionStartedAt, $sessionStartedAt,
$turnStartedAt, $turnStartedAt,
$workingSessionIds, $workingSessionIds,
$yoloActive, $yoloActive,
setModelPickerOpen,
setYoloActive setYoloActive
} from '@/store/session' } from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents' import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
@ -65,7 +57,6 @@ interface StatusbarItemsOptions {
gatewayLogLines: readonly string[] gatewayLogLines: readonly string[]
gatewayState: string gatewayState: string
inferenceStatus: RuntimeReadinessResult | null inferenceStatus: RuntimeReadinessResult | null
modelMenuContent?: ReactNode
openAgents: () => void openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void openCommandCenterSection: (section: CommandCenterSection) => void
freshDraftReady: boolean freshDraftReady: boolean
@ -83,7 +74,6 @@ export function useStatusbarItems({
gatewayLogLines, gatewayLogLines,
gatewayState, gatewayState,
inferenceStatus, inferenceStatus,
modelMenuContent,
openAgents, openAgents,
openCommandCenterSection, openCommandCenterSection,
freshDraftReady, freshDraftReady,
@ -97,10 +87,6 @@ export function useStatusbarItems({
const terminalTakeover = useStore($terminalTakeover) const terminalTakeover = useStore($terminalTakeover)
const yoloActive = useStore($yoloActive) const yoloActive = useStore($yoloActive)
const busy = useStore($busy) const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const currentUsage = useStore($currentUsage) const currentUsage = useStore($currentUsage)
const desktopActionTasks = useStore($desktopActionTasks) const desktopActionTasks = useStore($desktopActionTasks)
const previewServerRestartStatus = useStore($previewServerRestartStatus) const previewServerRestartStatus = useStore($previewServerRestartStatus)
@ -416,37 +402,6 @@ export function useStatusbarItems({
title: yoloActive ? copy.yoloOn : copy.yoloOff, title: yoloActive ? copy.yoloOn : copy.yoloOff,
variant: 'action' variant: 'action'
}, },
{
id: 'model-summary',
label: (
<span className="inline-flex min-w-0 items-center gap-0.5">
<span className="truncate">
{formatModelStatusLabel(currentModel, {
fastMode: currentFastMode,
reasoningEffort: currentReasoningEffort
})}
</span>
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</span>
),
...(modelMenuContent
? {
menuAlign: 'end' as const,
menuClassName: 'w-64',
menuContent: modelMenuContent,
title: currentProvider
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
: copy.switchModel,
variant: 'menu' as const
}
: {
onSelect: () => setModelPickerOpen(true),
title: currentProvider
? copy.providerModelTitle(currentProvider, currentModel || copy.noModel)
: copy.openModelPicker,
variant: 'action' as const
})
},
{ {
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`, className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
hidden: !chatOpen, hidden: !chatOpen,
@ -465,11 +420,6 @@ export function useStatusbarItems({
contextBar, contextBar,
contextUsage, contextUsage,
copy, copy,
currentFastMode,
currentModel,
currentProvider,
currentReasoningEffort,
modelMenuContent,
sessionStartedAt, sessionStartedAt,
showYoloToggle, showYoloToggle,
terminalTakeover, terminalTakeover,

View File

@ -0,0 +1,84 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { DropdownMenu, DropdownMenuContent, DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu'
import { $modelPresets, getModelPreset } from '@/store/model-presets'
import { $activeSessionId } from '@/store/session'
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
beforeEach(() => {
$modelPresets.set({})
$activeSessionId.set(null)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
// Render the submenu inside an open menu/sub so its content (switches) mounts.
function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise<unknown> }) {
return render(
<DropdownMenu open>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort="medium"
fastControl={opts.fastControl}
isActive
model="m1"
onSelectModel={vi.fn()}
provider="p1"
reasoning={opts.reasoning}
requestGateway={opts.requestGateway as never}
/>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
}
// Regression: editing the active row before a live session exists must stay
// preset-only — the gateway's config.set falls back to global config when no
// session matches, so it must not be called. (Caught in the second review.)
describe('ModelEditSubmenu no-session guard', () => {
it('param fast: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').fast).toBe(true)
expect(requestGateway).not.toHaveBeenCalled()
})
it('reasoning: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway })
// Thinking starts on (medium); toggling it off routes through patchReasoning.
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').effort).toBe('none')
expect(requestGateway).not.toHaveBeenCalled()
})
it('param fast: pushes to the gateway once a session is active', async () => {
const requestGateway = vi.fn().mockResolvedValue({})
$activeSessionId.set('sess1')
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
fireEvent.click(screen.getByRole('switch'))
expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' })
})
})

View File

@ -12,13 +12,9 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { setModelPreset } from '@/store/model-presets'
import { notifyError } from '@/store/notifications' import { notifyError } from '@/store/notifications'
import { import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session'
$activeSessionId,
$currentReasoningEffort,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned // Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
// by the Thinking toggle, not the radio. // by the Thinking toggle, not the radio.
@ -76,96 +72,104 @@ export function resolveFastControl(
} }
interface ModelEditSubmenuProps { interface ModelEditSubmenuProps {
/** This row's effective reasoning effort (live for the active model, else its
* preset) the submenu shows and edits from this, never the raw session. */
effort: string
/** How fast mode is offered for this model (param toggle vs. variant swap). */ /** How fast mode is offered for this model (param toggle vs. variant swap). */
fastControl: FastControl fastControl: FastControl
/** Whether this row's model is the active one. */ /** Whether this row's model is the active one. */
isActive: boolean isActive: boolean
/** Switch to this model (resolves false on failure). Awaited before applying /** This row's model id — edits persist as its global preset. */
* edits when not active so a failed switch doesn't write to the old model. */ model: string
onActivate: () => Promise<boolean> | void
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */ /** Switch to a specific model id (used to swap base ⇄ -fast variant). */
onSelectModel: (model: string) => Promise<boolean> | void onSelectModel: (model: string) => Promise<boolean> | void
/** This row's provider slug — edits persist as its global preset. */
provider: string
/** Whether this model supports reasoning effort. */ /** Whether this model supports reasoning effort. */
reasoning: boolean reasoning: boolean
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
} }
export function ModelEditSubmenu({ export function ModelEditSubmenu({
effort,
fastControl, fastControl,
isActive, isActive,
onActivate, model,
onSelectModel, onSelectModel,
provider,
reasoning, reasoning,
requestGateway requestGateway
}: ModelEditSubmenuProps) { }: ModelEditSubmenuProps) {
const { t } = useI18n() const { t } = useI18n()
const copy = t.shell.modelOptions const copy = t.shell.modelOptions
// Reactive session state comes straight from the stores rather than being
// drilled through the panel, so editing it re-renders only this submenu.
const activeSessionId = useStore($activeSessionId) const activeSessionId = useStore($activeSessionId)
const currentReasoningEffort = useStore($currentReasoningEffort)
const effort = normalizeEffort(currentReasoningEffort) const effortValue = normalizeEffort(effort)
const thinkingOn = isThinkingEnabled(currentReasoningEffort) const thinkingOn = isThinkingEnabled(effort)
// Reasoning/fast are session-scoped (they apply to the active model), so // Editing always records the model's global preset; the active model also gets
// editing a non-active model first switches to it. Returns false if the // it pushed onto the live session. Non-active edits stay preset-only — they do
// switch failed, so callers skip applying to the wrong (previous) model. // not switch you to that model.
const ensureActive = async (): Promise<boolean> => { const patchReasoning = async (next: string) => {
if (isActive) { setModelPreset(provider, model, { effort: next })
return true
}
return (await onActivate()) !== false
}
const patchReasoning = async (next: string, rollback: string) => {
setCurrentReasoningEffort(next)
try {
if (!(await ensureActive())) {
setCurrentReasoningEffort(rollback)
if (!isActive) {
return return
} }
await requestGateway('config.set', { setCurrentReasoningEffort(next)
key: 'reasoning',
session_id: activeSessionId ?? '', // Preset-only without a session: `isActive` holds for the global/default
value: next // row pre-session, and the gateway's `config.set` falls back to global
}) // config when none matches — so don't reach it (preset + optimistic store
// are the whole effect). Same guard in applyModelPreset / toggleFast.
if (!activeSessionId) {
return
}
try {
await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
} catch (err) { } catch (err) {
setCurrentReasoningEffort(rollback) setCurrentReasoningEffort(effort)
setModelPreset(provider, model, { effort })
notifyError(err, copy.updateFailed) notifyError(err, copy.updateFailed)
} }
} }
const toggleFast = (enabled: boolean) => { const toggleFast = (enabled: boolean) => {
if (fastControl.kind === 'variant') { if (fastControl.kind === 'variant') {
// Fast is a separate model id — swap to it (or back to the base). // Fast is a separate model id. Record the choice on the base model's
// preset (selectFamily picks the `-fast` sibling later when set), and
// only swap models now if this is the active row — inactive edits must
// stay preset-only, same as the param path below.
setModelPreset(provider, fastControl.baseId, { fast: enabled })
if (isActive) {
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId) void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
}
return return
} }
if (fastControl.kind === 'param') { if (fastControl.kind === 'param') {
setCurrentFastMode(enabled) setModelPreset(provider, model, { fast: enabled })
void (async () => {
try {
if (!(await ensureActive())) {
setCurrentFastMode(!enabled)
if (!isActive) {
return return
} }
await requestGateway('config.set', { setCurrentFastMode(enabled)
key: 'fast',
session_id: activeSessionId ?? '', // Preset-only without a session (see patchReasoning).
value: enabled ? 'fast' : 'normal' if (!activeSessionId) {
}) return
}
void (async () => {
try {
await requestGateway('config.set', { key: 'fast', session_id: activeSessionId, value: enabled ? 'fast' : 'normal' })
} catch (err) { } catch (err) {
setCurrentFastMode(!enabled) setCurrentFastMode(!enabled)
setModelPreset(provider, model, { fast: !enabled })
notifyError(err, copy.fastFailed) notifyError(err, copy.fastFailed)
} }
})() })()
@ -188,9 +192,7 @@ export function ModelEditSubmenu({
<Switch <Switch
checked={thinkingOn} checked={thinkingOn}
className="ml-auto" className="ml-auto"
onCheckedChange={checked => onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')}
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
}
size="xs" size="xs"
/> />
</DropdownMenuItem> </DropdownMenuItem>
@ -205,10 +207,7 @@ export function ModelEditSubmenu({
<> <>
<DropdownMenuSeparator className="mx-0" /> <DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel> <DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
<DropdownMenuRadioGroup <DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
value={effort}
>
{EFFORT_OPTIONS.map(option => ( {EFFORT_OPTIONS.map(option => (
<DropdownMenuRadioItem <DropdownMenuRadioItem
className={dropdownMenuRow} className={dropdownMenuRow}

View File

@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react' import { createContext, useContext, useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon' import { Codicon } from '@/components/ui/codicon'
import { import {
@ -18,8 +18,9 @@ import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes' import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions, getMoaModels } from '@/hermes' import { getGlobalModelOptions, getMoaModels } from '@/hermes'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label' import { currentPickerSelection, displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import { import {
$visibleModels, $visibleModels,
collapseModelFamilies, collapseModelFamilies,
@ -40,9 +41,14 @@ import type { MoaConfigResponse, ModelOptionProvider, ModelOptionsResponse } fro
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
// Lets the host dropdown (model-pill) hand the panel a way to dismiss itself so
// clicking a model row commits + closes, while the hover-revealed edit submenu
// (reasoning/fast) stays open to play with (its items preventDefault on select).
export const ModelMenuCloseContext = createContext<() => void>(() => {})
interface ModelMenuPanelProps { interface ModelMenuPanelProps {
gateway?: HermesGateway gateway?: HermesGateway
onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise<boolean> | void onSelectModel: (selection: { model: string; provider: string }) => Promise<boolean> | void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
} }
@ -54,6 +60,7 @@ interface ProviderGroup {
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) { export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
const { t } = useI18n() const { t } = useI18n()
const copy = t.shell.modelMenu const copy = t.shell.modelMenu
const closeMenu = useContext(ModelMenuCloseContext)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [activeMoaPreset, setActiveMoaPreset] = useState('') const [activeMoaPreset, setActiveMoaPreset] = useState('')
// Reactive session state is read from the stores here (not drilled in), so // Reactive session state is read from the stores here (not drilled in), so
@ -64,6 +71,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const currentModel = useStore($currentModel) const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider) const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort) const currentReasoningEffort = useStore($currentReasoningEffort)
const modelPresets = useStore($modelPresets)
const visibleModels = useStore($visibleModels) const visibleModels = useStore($visibleModels)
const modelOptions = useQuery({ const modelOptions = useQuery({
@ -82,8 +90,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
queryFn: (): Promise<MoaConfigResponse> => getMoaModels() queryFn: (): Promise<MoaConfigResponse> => getMoaModels()
}) })
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '') const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '') !!activeSessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error const error = modelOptions.error
@ -99,8 +111,35 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
[visibleModels, providers] [visibleModels, providers]
) )
const switchTo = (model: string, provider: string) => // The composer picker never persists the profile default. With a session it
onSelectModel({ model, persistGlobal: !activeSessionId, provider }) // scopes the switch to that session; with none it's UI state shipped on the
// next session.create (see selectModel). The default lives in Settings → Model.
const switchTo = (model: string, provider: string) => onSelectModel({ model, provider })
// Selecting a model row restores that model's remembered preset onto the
// session (effort/fast), gated by capability. Unset → Hermes defaults.
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
const caps = provider.capabilities?.[family.id]
const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {}
// Variant-fast models (no speed param) express "fast" as a separate `-fast`
// id, so honor the saved preset by selecting that sibling. Param-fast is
// applied via applyModelPreset below instead.
const variantFast = !(caps?.fast ?? false) && !!family.fastId
const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
if ((await switchTo(targetId, provider.slug)) === false) {
return
}
await applyModelPreset(
{
effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined,
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
},
{ failMessage: t.shell.modelOptions.updateFailed, request: requestGateway, sessionId: activeSessionId }
)
}
const toggleMoaPreset = async (preset: string) => { const toggleMoaPreset = async (preset: string) => {
if (!activeSessionId) { if (!activeSessionId) {
@ -168,37 +207,42 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// -fast variant carries the same param support as its base. // -fast variant carries the same param support as its base.
const caps = group.provider.capabilities?.[family.id] const caps = group.provider.capabilities?.[family.id]
// Single source of truth for the active row's fast state — keeps // Effective settings for this row: live session state when it's
// the row label in lock-step with the submenu's Fast toggle and // the active model, otherwise its remembered preset (Hermes
// handles the standalone `-fast` id case. // defaults when unset). Row label AND submenu read from these so
// they never disagree.
const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {}
const effEffort = isCurrent ? currentReasoningEffort : preset.effort ?? ''
const effFast = isCurrent ? currentFastMode : preset.fast ?? false
const fastControl = resolveFastControl( const fastControl = resolveFastControl(
activeId ?? family.id, activeId ?? family.id,
group.provider.models ?? [], group.provider.models ?? [],
caps?.fast ?? false, caps?.fast ?? false,
currentFastMode effFast
) )
// Grayed text is live session state only. Do not label inactive const meta = [
// rows as "Fast" just because they have a fast-capable sibling:
// that makes an off Fast toggle look like it is already on.
const meta = isCurrent
? [
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
reasoningEffortLabel(currentReasoningEffort) || copy.medium (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null
] ]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
: ''
// Every row is a hover-Edit submenu trigger. Activating it // Every row is a hover-Edit submenu trigger. Activating it
// (pointer or keyboard) switches to the family's base model; // (pointer or keyboard) switches to the family's base model and
// the Fast toggle inside swaps to the -fast sibling (or flips // restores its preset; the Fast toggle inside swaps to the -fast
// the speed param). The sub-trigger has no `onSelect`, so wire // sibling (or flips the speed param). The sub-trigger has no
// both click and Enter/Space for keyboard parity. // `onSelect`, so wire both click and Enter/Space for keyboard parity.
// Clicking the row commits the model and closes the picker; the
// edit submenu (reasoning/fast) is reached by HOVER, so you can
// still tweak those without the click dismissing everything.
const activate = () => { const activate = () => {
if (!isCurrent) { if (!isCurrent) {
void switchTo(family.id, group.provider.slug) void selectFamily(family, group.provider)
} }
closeMenu()
} }
return ( return (
@ -220,10 +264,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null} {isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<ModelEditSubmenu <ModelEditSubmenu
effort={effEffort}
fastControl={fastControl} fastControl={fastControl}
isActive={isCurrent} isActive={isCurrent}
onActivate={() => switchTo(family.id, group.provider.slug)} model={family.id}
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)} onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
provider={group.provider.slug}
reasoning={caps?.reasoning ?? true} reasoning={caps?.reasoning ?? true}
requestGateway={requestGateway} requestGateway={requestGateway}
/> />

View File

@ -46,6 +46,12 @@ export interface SlashExecResponse {
warning?: string warning?: string
} }
export interface BrowserManageResponse {
connected?: boolean
url?: string
messages?: string[]
}
export interface SessionSteerResponse { export interface SessionSteerResponse {
// 'queued' == accepted into the live turn's steer slot (injected at the next // 'queued' == accepted into the live turn's steer slot (injected at the next
// tool-result boundary); 'rejected' == no live tool window, caller queues. // tool-result boundary); 'rejected' == no live tool window, caller queues.

View File

@ -0,0 +1,129 @@
// Lists and blockquotes have chrome beside the text (markers, the quote
// border) whose side is driven by the box's CSS direction, which the
// unicode-bidi:plaintext rules never touch. These tests pin the split of
// responsibilities: ul/ol/blockquote carry dir="auto" so the browser
// resolves their box direction from content, inline code carries dir="ltr"
// so it neither votes in that resolution nor reorders, and plain prose
// blocks stay attribute-free (the plaintext CSS owns them). jsdom does not
// resolve dir="auto", so the contract is asserted at the attribute level.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Thread } from './thread'
const createdAt = new Date('2026-06-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'hi' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(text: string): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function Harness({ text }: { text: string }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage(text)],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('block-level direction chrome', () => {
it('lists carry dir="auto" so markers follow the resolved direction', async () => {
render(<Harness text={'מקומות:\n\n1. חוף גורדון\n2. שוק הכרמל\n\n- פריט\n- item'} />)
const item = await screen.findByText(/חוף גורדון/)
expect(item.closest('ol')?.getAttribute('dir')).toBe('auto')
const bullet = await screen.findByText(/פריט/)
expect(bullet.closest('ul')?.getAttribute('dir')).toBe('auto')
})
it('blockquotes carry dir="auto" so the border follows the resolved direction', async () => {
render(<Harness text={'> ציטוט קצר בעברית'} />)
const quote = await screen.findByText(/ציטוט קצר/)
expect(quote.closest('blockquote')?.getAttribute('dir')).toBe('auto')
})
it('inline code carries dir="ltr" so it does not vote in dir="auto" resolution', async () => {
render(<Harness text={'1. `npm install` מתקין תלויות'} />)
const code = await screen.findByText('npm install')
expect(code.tagName).toBe('CODE')
expect(code.getAttribute('dir')).toBe('ltr')
expect(code.closest('ol')?.getAttribute('dir')).toBe('auto')
})
it('plain prose blocks stay attribute-free (plaintext CSS owns them)', async () => {
render(<Harness text={'שלום לכולם'} />)
const paragraph = await screen.findByText(/שלום לכולם/)
expect(paragraph.closest('p')?.hasAttribute('dir')).toBe(false)
})
})

View File

@ -322,13 +322,29 @@ function shortLabel(type: HermesRefType, id: string): string {
return tail || id return tail || id
} }
function safeEmbeddedImages(text: string) {
try {
return extractEmbeddedImages(text)
} catch {
return { cleanedText: text, images: [] as string[] }
}
}
function safeDirectiveSegments(text: string): Unstable_DirectiveSegment[] {
try {
return [...hermesDirectiveFormatter.parse(text)]
} catch {
return [{ kind: 'text', text }]
}
}
/** /**
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as * Renders text containing Hermes directives (`@file:...`, `@image:...`) as
* inline chips. Embedded MEDIA images render below as a thumbnail row. * inline chips. Embedded MEDIA images render below as a thumbnail row.
*/ */
export function DirectiveContent({ text }: { text: string }) { export function DirectiveContent({ text }: { text: string }) {
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text]) const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText]) const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
return ( return (
<span className="whitespace-pre-line" data-slot="aui_directive-text"> <span className="whitespace-pre-line" data-slot="aui_directive-text">

View File

@ -201,4 +201,13 @@ describe('preprocessMarkdown', () => {
expect(output).toContain('<https://example.com/a_b/c~d/page>') expect(output).toContain('<https://example.com/a_b/c~d/page>')
}) })
it('handles a fenced block larger than V8 spread-argument limit', () => {
// A single huge code block (e.g. a logged minified bundle) used to throw
// `RangeError: Maximum call stack size exceeded` via `out.push(...lines)`.
const body = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join('\n')
const input = `\`\`\`js\n${body}\n\`\`\``
expect(() => preprocessMarkdown(input)).not.toThrow()
})
}) })

View File

@ -19,8 +19,9 @@ import {
useState useState
} from 'react' } from 'react'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { PreviewAttachment } from '@/components/chat/preview-attachment' import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter' import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { ZoomableImage } from '@/components/chat/zoomable-image' import { ZoomableImage } from '@/components/chat/zoomable-image'
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link' import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
import { createMemoizedMathPlugin } from '@/lib/katex-memo' import { createMemoizedMathPlugin } from '@/lib/katex-memo'
@ -57,7 +58,11 @@ const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay // flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
// module-scope so the prop identity is stable across renders. // module-scope so the prop identity is stable across renders.
function preprocessWithTailRepair(text: string): string { function preprocessWithTailRepair(text: string): string {
try {
return tailBoundedRemend(preprocessMarkdown(text)) return tailBoundedRemend(preprocessMarkdown(text))
} catch {
return text
}
} }
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full // Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
@ -453,8 +458,35 @@ const MARKDOWN_CONTAINER_CLASS_NAME = cn(
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)' '[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
) )
const MAX_MARKDOWN_CHARS = 200_000
function HugeTextFallback({ containerClassName, text }: { containerClassName?: string; text: string }) {
const chunks = useMemo(() => chunkByLines(text, 200), [text])
return (
<div
className={cn(
'aui-md w-full max-w-none overflow-hidden rounded-[0.625rem] border border-border font-mono text-[0.7rem] leading-relaxed text-foreground/90',
containerClassName
)}
>
<ExpandableBlock className="p-2">
{chunks.map((chunk, index) => (
<div
className="[content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * 16}px` }}
>
{chunk.text}
</div>
))}
</ExpandableBlock>
</div>
)
}
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) { function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
const { status } = useMessagePartText() const { status, text } = useMessagePartText()
const isStreaming = status.type === 'running' const isStreaming = status.type === 'running'
// Keep code parsing enabled while streaming so incomplete fenced blocks still // Keep code parsing enabled while streaming so incomplete fenced blocks still
@ -484,19 +516,37 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} /> <p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
), ),
a: MarkdownLink, a: MarkdownLink,
// Inline code must not vote when an ancestor resolves `dir="auto"`
// (HTML's algorithm skips descendants that carry their own dir),
// mirroring the CSS isolate that already keeps it out of the
// plaintext scan. Fenced code never reaches this override; it goes
// through the code plugin's CodeCard path.
inlineCode: ({ className, ...props }: ComponentProps<'code'>) => (
<code className={className} dir="ltr" {...props} />
),
// `---` as quiet spacing, not a heavy full-width rule. // `---` as quiet spacing, not a heavy full-width rule.
hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />, hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
// Lists and blockquotes have chrome that sits *beside* the text
// (markers, the quote border), and that side is driven by the CSS
// `direction` of the box, which `unicode-bidi: plaintext` never
// touches — an RTL list otherwise renders its numbers stranded at
// the far left. `dir="auto"` lets the browser resolve the box
// direction from content; the plaintext rules in styles.css keep
// owning per-line text direction. Inline code carries `dir="ltr"`
// (see the `code` override) so it doesn't vote here either, same
// contract as the CSS isolate.
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => ( blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote <blockquote
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)} className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}
dir="auto"
{...props} {...props}
/> />
), ),
ul: ({ className, ...props }: ComponentProps<'ul'>) => ( ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('my-1 gap-0', className)} {...props} /> <ul className={cn('my-1 gap-0', className)} dir="auto" {...props} />
), ),
ol: ({ className, ...props }: ComponentProps<'ol'>) => ( ol: ({ className, ...props }: ComponentProps<'ol'>) => (
<ol className={cn('my-1 gap-0', className)} {...props} /> <ol className={cn('my-1 gap-0', className)} dir="auto" {...props} />
), ),
li: ({ className, ...props }: ComponentProps<'li'>) => ( li: ({ className, ...props }: ComponentProps<'li'>) => (
<li className={cn('leading-(--dt-line-height)', className)} {...props} /> <li className={cn('leading-(--dt-line-height)', className)} {...props} />
@ -533,6 +583,10 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
[isStreaming] [isStreaming]
) )
if (text.length > MAX_MARKDOWN_CHARS) {
return <HugeTextFallback containerClassName={containerClassName} text={text} />
}
return ( return (
<StreamdownTextPrimitive <StreamdownTextPrimitive
components={components} components={components}

View File

@ -378,6 +378,20 @@ function IntroHarness() {
) )
} }
function DismissibleErrorHarness({ onDismissError }: { onDismissError: (messageId: string) => void }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantErrorMessage('OpenRouter rejected the request (403).')],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onDismissError={onDismissError} />
</AssistantRuntimeProvider>
)
}
describe('assistant-ui streaming renderer', () => { describe('assistant-ui streaming renderer', () => {
beforeEach(() => { beforeEach(() => {
resizeObservers.clear() resizeObservers.clear()
@ -421,6 +435,23 @@ describe('assistant-ui streaming renderer', () => {
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).') expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
}) })
it('omits the dismiss control when no onDismissError handler is supplied', () => {
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
expect(screen.queryByRole('button', { name: 'Dismiss error' })).toBeNull()
})
it('invokes onDismissError with the errored message id when the dismiss control is clicked', () => {
const onDismissError = vi.fn()
render(<DismissibleErrorHarness onDismissError={onDismissError} />)
const dismiss = screen.getByRole('button', { name: 'Dismiss error' })
fireEvent.click(dismiss)
expect(onDismissError).toHaveBeenCalledTimes(1)
expect(onDismissError).toHaveBeenCalledWith('assistant-error-1')
})
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned // Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
// by the use-stick-to-bottom library and covered by its own test suite. We // by the use-stick-to-bottom library and covered by its own test suite. We
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real // don't re-assert its scrollTop mechanics here — doing so in jsdom (no real

View File

@ -22,7 +22,7 @@ import {
resetThreadScroll, resetThreadScroll,
setThreadAtBottom setThreadAtBottom
} from '@/store/thread-scroll' } from '@/store/thread-scroll'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' import { isSecondaryWindow } from '@/store/windows'
import { MessageRenderBoundary } from './message-render-boundary' import { MessageRenderBoundary } from './message-render-boundary'
@ -134,13 +134,20 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
const hiddenCount = firstVisible const hiddenCount = firstVisible
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
const restoreFromBottomRef = useRef<number | null>(null) const restoreFromBottomRef = useRef<number | null>(null)
const newSessionWindow = isNewSessionWindow() // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)' // hide the titlebar tool cluster + session header, but the OS traffic lights
const threadContentTopPad = newSessionWindow // still sit in the top-left, so reserve the titlebar gap above the transcript.
const secondaryWindow = isSecondaryWindow()
// NB: CSS calc() requires whitespace around the +/- operator. This string is
// assigned verbatim to the --sticky-human-top inline style below (it does not
// go through Tailwind, which would auto-space it), so the spaces are load-
// bearing — without them the declaration is invalid, gets dropped, and the
// sticky user bubble falls back to its ~4px default and slides under the OS
// traffic lights.
const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)'
const threadContentTopPad = secondaryWindow
? 'pt-[calc(var(--titlebar-height)+0.75rem)]' ? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
: isSecondaryWindow() : 'pt-[calc(var(--titlebar-height)-0.5rem)]'
? 'pt-6'
: 'pt-[calc(var(--titlebar-height)+1.5rem)]'
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom]) useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
useEffect(() => () => resetThreadScroll(), []) useEffect(() => () => resetThreadScroll(), [])
@ -247,10 +254,21 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
style={ style={
{ {
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%', height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {}) ...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {})
} as CSSProperties } as CSSProperties
} }
> >
{secondaryWindow && (
// Secondary windows hide the titlebar chrome, so the scroller runs to
// the window's top edge and streamed text slides up under the OS
// traffic lights. Content padding alone scrolls away with the text — a
// fixed opaque strip (the titlebar's drag region) masks anything behind
// it and keeps the window draggable, matching the main window's header.
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 z-10 h-(--titlebar-height) bg-background [-webkit-app-region:drag]"
/>
)}
<div <div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain" className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-following={isAtBottom ? 'true' : 'false'} data-following={isAtBottom ? 'true' : 'false'}

View File

@ -91,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { LinkifiedText } from '@/lib/external-link' import { LinkifiedText } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons' import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets' import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation' import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -169,6 +169,7 @@ export const Thread: FC<{
loading?: ThreadLoadingState loading?: ThreadLoadingState
onBranchInNewChat?: (messageId: string) => void onBranchInNewChat?: (messageId: string) => void
onCancel?: () => Promise<void> | void onCancel?: () => Promise<void> | void
onDismissError?: (messageId: string) => void
onRestoreToMessage?: (messageId: string) => Promise<void> | void onRestoreToMessage?: (messageId: string) => Promise<void> | void
sessionId?: string | null sessionId?: string | null
sessionKey?: string | null sessionKey?: string | null
@ -180,18 +181,19 @@ export const Thread: FC<{
loading, loading,
onBranchInNewChat, onBranchInNewChat,
onCancel, onCancel,
onDismissError,
onRestoreToMessage, onRestoreToMessage,
sessionId = null, sessionId = null,
sessionKey sessionKey
}) => { }) => {
const messageComponents = useMemo( const messageComponents = useMemo(
() => ({ () => ({
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />, AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />,
SystemMessage, SystemMessage,
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />, UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} /> UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
}), }),
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId] [cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId]
) )
const emptyPlaceholder = intro ? ( const emptyPlaceholder = intro ? (
@ -245,9 +247,13 @@ const CenteredThreadSpinner: FC = () => {
) )
} }
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => { const AssistantMessage: FC<{
onBranchInNewChat?: (messageId: string) => void
onDismissError?: (messageId: string) => void
}> = ({ onBranchInNewChat, onDismissError }) => {
const messageId = useAuiState(s => s.message.id) const messageId = useAuiState(s => s.message.id)
const messageRuntime = useMessageRuntime() const messageRuntime = useMessageRuntime()
const { t } = useI18n()
// PERF: this component must NOT subscribe to the streaming text. Every // PERF: this component must NOT subscribe to the streaming text. Every
// selector here returns a value that stays referentially stable across // selector here returns a value that stays referentially stable across
@ -306,10 +312,20 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
)} )}
<MessagePrimitive.Error> <MessagePrimitive.Error>
<ErrorPrimitive.Root <ErrorPrimitive.Root
className="mt-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]" className="mt-1.5 flex items-start gap-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
role="alert" role="alert"
> >
<ErrorPrimitive.Message /> <ErrorPrimitive.Message className="min-w-0 flex-1" />
{onDismissError && (
<TooltipIconButton
className="-my-0.5 shrink-0 text-current opacity-70 hover:opacity-100"
onClick={() => onDismissError(messageId)}
side="top"
tooltip={t.assistant.thread.dismissError}
>
<XIcon className="size-3.5" />
</TooltipIconButton>
)}
</ErrorPrimitive.Root> </ErrorPrimitive.Root>
</MessagePrimitive.Error> </MessagePrimitive.Error>
</div> </div>

View File

@ -66,7 +66,7 @@ function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
className={cn( className={cn(
'p-1.5 font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed', 'font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
className className
)} )}
data-slot="code-card-body" data-slot="code-card-body"

View File

@ -0,0 +1,52 @@
'use client'
import { type ReactNode, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface ExpandableBlockProps {
children: ReactNode
className?: string
}
export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
const innerRef = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useState(false)
const [overflowing, setOverflowing] = useState(false)
useLayoutEffect(() => {
const el = innerRef.current
if (!el) {return}
const measure = () => setOverflowing(el.scrollHeight > 121)
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [])
return (
<div className="relative">
<div
className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
ref={innerRef}
>
{children}
</div>
{overflowing && (
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
)}
</div>
)
}

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { chunkByLines, exceedsHighlightBudget } from '@/components/chat/shiki-highlighter'
describe('exceedsHighlightBudget', () => {
it('highlights normal-sized blocks', () => {
expect(exceedsHighlightBudget('const x = 1\n'.repeat(100))).toBe(false)
})
it('skips highlighting past the line budget', () => {
expect(exceedsHighlightBudget('x\n'.repeat(5_000))).toBe(true)
})
it('skips highlighting past the char budget on few lines', () => {
expect(exceedsHighlightBudget('a'.repeat(200_000))).toBe(true)
})
it('short-circuits on char budget before line loop', () => {
expect(exceedsHighlightBudget('y\n'.repeat(250_000))).toBe(true)
})
})
describe('chunkByLines', () => {
it('keeps a small block as a single chunk', () => {
const code = 'a\nb\nc'
expect(chunkByLines(code, 200)).toEqual([{ text: code, lines: 3 }])
})
it('splits a large block and reconstructs it losslessly', () => {
const code = Array.from({ length: 1000 }, (_, i) => `line ${i}`).join('\n')
const chunks = chunkByLines(code, 200)
expect(chunks).toHaveLength(5)
expect(chunks.map(chunk => chunk.text).join('\n')).toBe(code)
expect(chunks.reduce((sum, chunk) => sum + chunk.lines, 0)).toBe(1000)
})
})

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown' import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
import type { FC } from 'react' import { type FC, useMemo } from 'react'
import ShikiHighlighter from 'react-shiki' import ShikiHighlighter from 'react-shiki'
import { import {
@ -12,6 +12,7 @@ import {
CodeCardSubtitle, CodeCardSubtitle,
CodeCardTitle CodeCardTitle
} from '@/components/chat/code-card' } from '@/components/chat/code-card'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { CopyButton } from '@/components/ui/copy-button' import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code' import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
@ -43,6 +44,74 @@ const SHIKI_COLOR_REPLACEMENTS: Record<string, Record<string, string>> = {
'github-light-default': { '#6e7781': '#57606a' } 'github-light-default': { '#6e7781': '#57606a' }
} }
const MAX_HIGHLIGHT_CHARS = 150_000
const MAX_HIGHLIGHT_LINES = 3_000
const CHUNK_LINES = 200
const EST_LINE_PX = 16
export function exceedsHighlightBudget(code: string): boolean {
if (code.length > MAX_HIGHLIGHT_CHARS) {
return true
}
let lines = 1
let idx = code.indexOf('\n')
while (idx !== -1) {
if ((lines += 1) > MAX_HIGHLIGHT_LINES) {
return true
}
idx = code.indexOf('\n', idx + 1)
}
return false
}
interface CodeChunk {
text: string
lines: number
}
export function chunkByLines(code: string, perChunk: number): CodeChunk[] {
const lines = code.split('\n')
if (lines.length <= perChunk) {
return [{ text: code, lines: lines.length }]
}
const chunks: CodeChunk[] = []
for (let i = 0; i < lines.length; i += perChunk) {
const slice = lines.slice(i, i + perChunk)
chunks.push({ text: slice.join('\n'), lines: slice.length })
}
return chunks
}
const PlainCode: FC<{ code: string }> = ({ code }) => {
const chunks = useMemo(() => chunkByLines(code, CHUNK_LINES), [code])
if (chunks.length === 1) {
return <code className="block whitespace-pre">{code}</code>
}
return (
<>
{chunks.map((chunk, index) => (
<code
className="block whitespace-pre [content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * EST_LINE_PX}px` }}
>
{chunk.text}
</code>
))}
</>
)
}
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
components: { Pre }, components: { Pre },
language, language,
@ -64,6 +133,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
const cleanLanguage = sanitizeLanguageTag(language || '') const cleanLanguage = sanitizeLanguageTag(language || '')
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : '' const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
const plain = defer || exceedsHighlightBudget(trimmed)
return ( return (
<CodeCard data-streaming={defer ? 'true' : undefined}> <CodeCard data-streaming={defer ? 'true' : undefined}>
@ -83,9 +153,10 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
/> />
</CodeCardHeader> </CodeCardHeader>
<CodeCardBody> <CodeCardBody>
<ExpandableBlock>
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0"> <Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
{defer ? ( {plain ? (
<code className="block whitespace-pre">{trimmed}</code> <PlainCode code={trimmed} />
) : ( ) : (
<ShikiHighlighter <ShikiHighlighter
addDefaultStyles={false} addDefaultStyles={false}
@ -101,6 +172,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
</ShikiHighlighter> </ShikiHighlighter>
)} )}
</Pre> </Pre>
</ExpandableBlock>
</CodeCardBody> </CodeCardBody>
</CodeCard> </CodeCard>
) )

View File

@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { currentPickerSelection } from '@/lib/model-status-label'
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes' import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
import type { HermesGateway } from '../hermes' import type { HermesGateway } from '../hermes'
@ -11,7 +12,6 @@ import { startManualOnboarding } from '../store/onboarding'
import { InlineNotice } from './notifications' import { InlineNotice } from './notifications'
import { Button } from './ui/button' import { Button } from './ui/button'
import { Checkbox } from './ui/checkbox'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
import { Skeleton } from './ui/skeleton' import { Skeleton } from './ui/skeleton'
@ -23,7 +23,7 @@ interface ModelPickerDialogProps {
sessionId?: string | null sessionId?: string | null
currentModel: string currentModel: string
currentProvider: string currentProvider: string
onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void onSelect: (selection: { provider: string; model: string }) => void
/** /**
* Optional class to apply to DialogContent. Use to override z-index when * Optional class to apply to DialogContent. Use to override z-index when
* stacking the picker on top of another fixed overlay (e.g. the desktop * stacking the picker on top of another fixed overlay (e.g. the desktop
@ -45,7 +45,6 @@ export function ModelPickerDialog({
}: ModelPickerDialogProps) { }: ModelPickerDialogProps) {
const { t } = useI18n() const { t } = useI18n()
const copy = t.modelPicker const copy = t.modelPicker
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
// Own the search term so we can filter manually. cmdk's built-in // Own the search term so we can filter manually. cmdk's built-in
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with // shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
// an empty query), which destroys the backend's curated order. We disable // an empty query), which destroys the backend's curated order. We disable
@ -68,8 +67,13 @@ export function ModelPickerDialog({
}) })
const providers = modelOptions.data?.providers ?? [] const providers = modelOptions.data?.providers ?? []
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '') const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
!!sessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error const error = modelOptions.error
@ -79,11 +83,7 @@ export function ModelPickerDialog({
: null : null
const selectModel = (provider: ModelOptionProvider, model: string) => { const selectModel = (provider: ModelOptionProvider, model: string) => {
onSelect({ onSelect({ provider: provider.slug, model })
provider: provider.slug,
model,
persistGlobal: persistGlobal || !sessionId
})
onOpenChange(false) onOpenChange(false)
} }
@ -128,24 +128,13 @@ export function ModelPickerDialog({
</CommandList> </CommandList>
</Command> </Command>
<DialogFooter className="flex-row items-center justify-between gap-3 bg-card p-3 sm:justify-between"> <DialogFooter className="flex-row items-center justify-end gap-2 bg-card p-3">
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
<Checkbox
checked={persistGlobal || !sessionId}
disabled={!sessionId}
onCheckedChange={checked => setPersistGlobal(checked === true)}
/>
{sessionId ? copy.persistGlobalSession : copy.persistGlobal}
</label>
<div className="flex items-center gap-2">
<Button onClick={addProvider} variant="ghost"> <Button onClick={addProvider} variant="ghost">
{copy.addProvider} {copy.addProvider}
</Button> </Button>
<Button onClick={() => onOpenChange(false)} variant="outline"> <Button onClick={() => onOpenChange(false)} variant="outline">
{t.common.cancel} {t.common.cancel}
</Button> </Button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -538,6 +538,10 @@ export const en: Translations = {
provider: 'Provider', provider: 'Provider',
model: 'Model', model: 'Model',
applying: 'Applying...', applying: 'Applying...',
defaultsLabel: 'Defaults',
reasoning: 'Reasoning',
reasoningOff: 'Off',
defaultsFailed: 'Failed to save model defaults',
auxiliaryTitle: 'Auxiliary models', auxiliaryTitle: 'Auxiliary models',
resetAllToMain: 'Reset all to main', resetAllToMain: 'Reset all to main',
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.', auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',
@ -565,9 +569,14 @@ export const en: Translations = {
collapse: 'Collapse', collapse: 'Collapse',
connectAnother: 'Connect another provider', connectAnother: 'Connect another provider',
otherProviders: 'Other providers', otherProviders: 'Other providers',
disconnect: 'Disconnect',
disconnectInTerminal: 'Disconnect (runs the removal command in the terminal)',
removeConfirm: provider => `Remove ${provider}?`, removeConfirm: provider => `Remove ${provider}?`,
removeExternal: (provider, command) => `${provider} is managed outside Hermes. Remove it with ${command}.`, removeExternalGeneric: provider => `${provider} is managed by its own CLI — remove it there.`,
removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`, removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`,
removeTerminalConfirm: (provider, command) =>
`Disconnect ${provider}? This runs "${command}" in the terminal to clear the credential.`,
removeTerminalRunning: provider => `Running ${provider} disconnect in the terminal…`,
removedTitle: 'Account removed', removedTitle: 'Account removed',
removedMessage: provider => `${provider} was removed.`, removedMessage: provider => `${provider} was removed.`,
failedRemove: provider => `Could not remove ${provider}`, failedRemove: provider => `Could not remove ${provider}`,
@ -1498,8 +1507,6 @@ export const en: Translations = {
unknown: '(unknown)', unknown: '(unknown)',
search: 'Filter providers and models...', search: 'Filter providers and models...',
noModels: 'No models found.', noModels: 'No models found.',
persistGlobalSession: 'Persist globally (otherwise this session only)',
persistGlobal: 'Persist globally',
addProvider: 'Add provider', addProvider: 'Add provider',
loadFailed: 'Could not load models', loadFailed: 'Could not load models',
noAuthenticatedProviders: 'No authenticated providers.', noAuthenticatedProviders: 'No authenticated providers.',
@ -1726,6 +1733,7 @@ export const en: Translations = {
refresh: 'Refresh', refresh: 'Refresh',
moreActions: 'More actions', moreActions: 'More actions',
branchNewChat: 'Branch in new chat', branchNewChat: 'Branch in new chat',
dismissError: 'Dismiss error',
readAloudFailed: 'Read aloud failed', readAloudFailed: 'Read aloud failed',
preparingAudio: 'Preparing audio...', preparingAudio: 'Preparing audio...',
stopReading: 'Stop reading', stopReading: 'Stop reading',
@ -1835,6 +1843,9 @@ export const en: Translations = {
regenerateFailed: 'Regenerate failed', regenerateFailed: 'Regenerate failed',
editFailed: 'Edit failed', editFailed: 'Edit failed',
resumeFailed: 'Resume failed', resumeFailed: 'Resume failed',
resumeStrandedTitle: "Couldn't load this session",
resumeStrandedBody: 'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
resumeRetry: 'Retry',
nothingToBranch: 'Nothing to branch', nothingToBranch: 'Nothing to branch',
branchNeedsChat: 'Start or resume a chat before branching.', branchNeedsChat: 'Start or resume a chat before branching.',
sessionBusy: 'Session busy', sessionBusy: 'Session busy',

View File

@ -695,7 +695,6 @@ export const ja = defineLocale({
connectAnother: '別のプロバイダーを接続', connectAnother: '別のプロバイダーを接続',
otherProviders: 'その他のプロバイダー', otherProviders: 'その他のプロバイダー',
removeConfirm: provider => `${provider} を削除しますか?`, removeConfirm: provider => `${provider} を削除しますか?`,
removeExternal: (provider, command) => `${provider} は Hermes の外部で管理されています。${command} で削除してください。`,
removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`, removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`,
removedTitle: 'アカウントを削除しました', removedTitle: 'アカウントを削除しました',
removedMessage: provider => `${provider} を削除しました。`, removedMessage: provider => `${provider} を削除しました。`,
@ -1638,8 +1637,6 @@ export const ja = defineLocale({
unknown: '(不明)', unknown: '(不明)',
search: 'プロバイダーとモデルをフィルター...', search: 'プロバイダーとモデルをフィルター...',
noModels: 'モデルが見つかりません。', noModels: 'モデルが見つかりません。',
persistGlobalSession: 'グローバルに保持(それ以外はこのセッションのみ)',
persistGlobal: 'グローバルに保持',
addProvider: 'プロバイダーを追加', addProvider: 'プロバイダーを追加',
loadFailed: 'モデルを読み込めませんでした', loadFailed: 'モデルを読み込めませんでした',
noAuthenticatedProviders: '認証済みプロバイダーがありません。', noAuthenticatedProviders: '認証済みプロバイダーがありません。',
@ -1867,6 +1864,7 @@ export const ja = defineLocale({
refresh: '更新', refresh: '更新',
moreActions: 'その他のアクション', moreActions: 'その他のアクション',
branchNewChat: '新しいチャットでブランチ', branchNewChat: '新しいチャットでブランチ',
dismissError: 'エラーを閉じる',
readAloudFailed: '読み上げに失敗しました', readAloudFailed: '読み上げに失敗しました',
preparingAudio: '音声を準備中...', preparingAudio: '音声を準備中...',
stopReading: '読み上げを停止', stopReading: '読み上げを停止',
@ -1976,6 +1974,9 @@ export const ja = defineLocale({
regenerateFailed: '再生成に失敗しました', regenerateFailed: '再生成に失敗しました',
editFailed: '編集に失敗しました', editFailed: '編集に失敗しました',
resumeFailed: '再開に失敗しました', resumeFailed: '再開に失敗しました',
resumeStrandedTitle: 'このセッションを読み込めませんでした',
resumeStrandedBody: 'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
resumeRetry: '再試行',
nothingToBranch: 'ブランチするものがありません', nothingToBranch: 'ブランチするものがありません',
branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。', branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',
sessionBusy: 'セッションが使用中', sessionBusy: 'セッションが使用中',

View File

@ -430,6 +430,10 @@ export interface Translations {
provider: string provider: string
model: string model: string
applying: string applying: string
defaultsLabel: string
reasoning: string
reasoningOff: string
defaultsFailed: string
auxiliaryTitle: string auxiliaryTitle: string
resetAllToMain: string resetAllToMain: string
auxiliaryDesc: string auxiliaryDesc: string
@ -447,9 +451,13 @@ export interface Translations {
collapse: string collapse: string
connectAnother: string connectAnother: string
otherProviders: string otherProviders: string
disconnect: string
disconnectInTerminal: string
removeConfirm: (provider: string) => string removeConfirm: (provider: string) => string
removeExternal: (provider: string, command: string) => string removeExternalGeneric: (provider: string) => string
removeKeyManaged: (provider: string) => string removeKeyManaged: (provider: string) => string
removeTerminalConfirm: (provider: string, command: string) => string
removeTerminalRunning: (provider: string) => string
removedTitle: string removedTitle: string
removedMessage: (provider: string) => string removedMessage: (provider: string) => string
failedRemove: (provider: string) => string failedRemove: (provider: string) => string
@ -1141,8 +1149,6 @@ export interface Translations {
unknown: string unknown: string
search: string search: string
noModels: string noModels: string
persistGlobalSession: string
persistGlobal: string
addProvider: string addProvider: string
loadFailed: string loadFailed: string
noAuthenticatedProviders: string noAuthenticatedProviders: string
@ -1367,6 +1373,7 @@ export interface Translations {
refresh: string refresh: string
moreActions: string moreActions: string
branchNewChat: string branchNewChat: string
dismissError: string
readAloudFailed: string readAloudFailed: string
preparingAudio: string preparingAudio: string
stopReading: string stopReading: string
@ -1474,6 +1481,9 @@ export interface Translations {
regenerateFailed: string regenerateFailed: string
editFailed: string editFailed: string
resumeFailed: string resumeFailed: string
resumeStrandedTitle: string
resumeStrandedBody: string
resumeRetry: string
nothingToBranch: string nothingToBranch: string
branchNeedsChat: string branchNeedsChat: string
sessionBusy: string sessionBusy: string

View File

@ -672,7 +672,6 @@ export const zhHant = defineLocale({
connectAnother: '連結其他提供方', connectAnother: '連結其他提供方',
otherProviders: '其他提供方', otherProviders: '其他提供方',
removeConfirm: provider => `移除 ${provider}`, removeConfirm: provider => `移除 ${provider}`,
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。請使用 ${command} 移除。`,
removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`, removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`,
removedTitle: '帳號已移除', removedTitle: '帳號已移除',
removedMessage: provider => `${provider} 已移除。`, removedMessage: provider => `${provider} 已移除。`,
@ -1582,8 +1581,6 @@ export const zhHant = defineLocale({
unknown: '(未知)', unknown: '(未知)',
search: '篩選提供方和模型...', search: '篩選提供方和模型...',
noModels: '找不到模型。', noModels: '找不到模型。',
persistGlobalSession: '全域儲存(否則僅限此工作階段)',
persistGlobal: '全域儲存',
addProvider: '新增提供方', addProvider: '新增提供方',
loadFailed: '無法載入模型', loadFailed: '無法載入模型',
noAuthenticatedProviders: '沒有已驗證的提供方。', noAuthenticatedProviders: '沒有已驗證的提供方。',
@ -1809,6 +1806,7 @@ export const zhHant = defineLocale({
refresh: '重新整理', refresh: '重新整理',
moreActions: '更多動作', moreActions: '更多動作',
branchNewChat: '在新聊天中分支', branchNewChat: '在新聊天中分支',
dismissError: '关闭错误',
readAloudFailed: '朗讀失敗', readAloudFailed: '朗讀失敗',
preparingAudio: '正在準備音訊...', preparingAudio: '正在準備音訊...',
stopReading: '停止朗讀', stopReading: '停止朗讀',
@ -1916,6 +1914,9 @@ export const zhHant = defineLocale({
regenerateFailed: '重新生成失敗', regenerateFailed: '重新生成失敗',
editFailed: '編輯失敗', editFailed: '編輯失敗',
resumeFailed: '繼續失敗', resumeFailed: '繼續失敗',
resumeStrandedTitle: '無法載入此工作階段',
resumeStrandedBody: '與此工作階段的連線失敗,自動重試已停止。請確認閘道正在執行,然後重試。',
resumeRetry: '重試',
nothingToBranch: '沒有可分支的內容', nothingToBranch: '沒有可分支的內容',
branchNeedsChat: '分支前請先開始或繼續一個聊天。', branchNeedsChat: '分支前請先開始或繼續一個聊天。',
sessionBusy: '工作階段忙碌中', sessionBusy: '工作階段忙碌中',

View File

@ -733,6 +733,10 @@ export const zh: Translations = {
provider: '提供方', provider: '提供方',
model: '模型', model: '模型',
applying: '应用中...', applying: '应用中...',
defaultsLabel: '默认值',
reasoning: '推理',
reasoningOff: '关闭',
defaultsFailed: '保存模型默认值失败',
auxiliaryTitle: '辅助模型', auxiliaryTitle: '辅助模型',
resetAllToMain: '全部重置为主模型', resetAllToMain: '全部重置为主模型',
auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。', auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。',
@ -759,9 +763,13 @@ export const zh: Translations = {
collapse: '收起', collapse: '收起',
connectAnother: '连接其他提供方', connectAnother: '连接其他提供方',
otherProviders: '其他提供方', otherProviders: '其他提供方',
disconnect: '断开连接',
disconnectInTerminal: '断开连接(在终端中运行移除命令)',
removeConfirm: provider => `移除 ${provider}`, removeConfirm: provider => `移除 ${provider}`,
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。请使用 ${command} 移除。`, removeExternalGeneric: provider => `${provider} 由其自身的 CLI 管理 — 请在那里移除。`,
removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`, removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`,
removeTerminalConfirm: (provider, command) => `断开 ${provider}?这将在终端中运行 "${command}" 以清除凭据。`,
removeTerminalRunning: provider => `正在终端中断开 ${provider}`,
removedTitle: '账号已移除', removedTitle: '账号已移除',
removedMessage: provider => `${provider} 已移除。`, removedMessage: provider => `${provider} 已移除。`,
failedRemove: provider => `无法移除 ${provider}`, failedRemove: provider => `无法移除 ${provider}`,
@ -1679,8 +1687,6 @@ export const zh: Translations = {
unknown: '(未知)', unknown: '(未知)',
search: '筛选提供方和模型...', search: '筛选提供方和模型...',
noModels: '未找到模型。', noModels: '未找到模型。',
persistGlobalSession: '全局保存 (否则仅当前会话)',
persistGlobal: '全局保存',
addProvider: '添加提供方', addProvider: '添加提供方',
loadFailed: '无法加载模型', loadFailed: '无法加载模型',
noAuthenticatedProviders: '没有已认证的提供方。', noAuthenticatedProviders: '没有已认证的提供方。',
@ -1906,6 +1912,7 @@ export const zh: Translations = {
refresh: '刷新', refresh: '刷新',
moreActions: '更多操作', moreActions: '更多操作',
branchNewChat: '在新对话中分支', branchNewChat: '在新对话中分支',
dismissError: '关闭错误',
readAloudFailed: '朗读失败', readAloudFailed: '朗读失败',
preparingAudio: '正在准备音频...', preparingAudio: '正在准备音频...',
stopReading: '停止朗读', stopReading: '停止朗读',
@ -2014,6 +2021,9 @@ export const zh: Translations = {
regenerateFailed: '重新生成失败', regenerateFailed: '重新生成失败',
editFailed: '编辑失败', editFailed: '编辑失败',
resumeFailed: '恢复失败', resumeFailed: '恢复失败',
resumeStrandedTitle: '无法加载此会话',
resumeStrandedBody: '与此会话的连接失败,自动重试已停止。请确认网关正在运行,然后重试。',
resumeRetry: '重试',
nothingToBranch: '没有可分支的内容', nothingToBranch: '没有可分支的内容',
branchNeedsChat: '分支前请先开始或恢复一个对话。', branchNeedsChat: '分支前请先开始或恢复一个对话。',
sessionBusy: '会话忙碌中', sessionBusy: '会话忙碌中',

View File

@ -52,6 +52,17 @@ describe('desktop slash command curation', () => {
expect(desktopSlashUnavailableMessage('/personality')).toBeNull() expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
}) })
it('treats /browser as an executable action command (local-gateway connect)', () => {
// /browser used to be terminal-only; it now resolves to a desktop action
// handler that routes browser.manage RPC when the gateway is local.
expect(isDesktopSlashCommand('/browser')).toBe(true)
expect(isDesktopSlashSuggestion('/browser')).toBe(true)
expect(desktopSlashUnavailableMessage('/browser')).toBeNull()
expect(resolveDesktopCommand('/browser')?.surface).toEqual({ kind: 'action', action: 'browser' })
// Bare /browser expands to its sub-action options in the popover.
expect(resolveDesktopCommand('/browser')?.args).toBe(true)
})
it('allows aliases to execute without cluttering the popover', () => { it('allows aliases to execute without cluttering the popover', () => {
expect(isDesktopSlashSuggestion('/reset')).toBe(false) expect(isDesktopSlashSuggestion('/reset')).toBe(false)
expect(isDesktopSlashCommand('/reset')).toBe(true) expect(isDesktopSlashCommand('/reset')).toBe(true)

View File

@ -30,6 +30,7 @@ export interface DesktopThemeCommandOption {
*/ */
export type DesktopActionId = export type DesktopActionId =
| 'branch' | 'branch'
| 'browser'
| 'handoff' | 'handoff'
| 'help' | 'help'
| 'new' | 'new'
@ -103,6 +104,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true }, { name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
{ name: '/title', description: 'Rename the current session', surface: action('title') }, { name: '/title', description: 'Rename the current session', surface: action('title') },
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') }, { name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
{
name: '/browser',
description: 'Manage browser CDP connection [connect|disconnect|status] (local gateway only)',
surface: action('browser'),
args: true
},
// Overlay pickers // Overlay pickers
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true }, { name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
@ -142,7 +149,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
// per reason beats 40 identical object literals. // per reason beats 40 identical object literals.
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = { const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
terminal: [ terminal: [
'/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs', '/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart', '/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose' '/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'

View File

@ -151,12 +151,18 @@ function normalizeVisibleProse(text: string): string {
.join('') .join('')
} }
function extend(out: string[], lines: string[]) {
for (const line of lines) {
out.push(line)
}
}
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) { function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
if (info) { if (info) {
out.push(`${indent}${info}`.trimEnd()) out.push(`${indent}${info}`.trimEnd())
} }
out.push(...lines) extend(out, lines)
} }
function findClosingFence(lines: string[], start: number, marker: string): number { function findClosingFence(lines: string[], start: number, marker: string): number {
@ -241,7 +247,7 @@ function normalizeFenceBlocks(text: string): string {
} }
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) { if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
out.push(...bodyLines) extend(out, bodyLines)
index = closeIndex + 1 index = closeIndex + 1
continue continue
@ -264,10 +270,10 @@ function normalizeFenceBlocks(text: string): string {
// any literal `$$` characters in the body don't collide with // any literal `$$` characters in the body don't collide with
// an outer math wrapper. No close emitted yet — streaming. // an outer math wrapper. No close emitted yet — streaming.
out.push(`${indent}${marker}math`) out.push(`${indent}${marker}math`)
out.push(...bodyLines) extend(out, bodyLines)
} else { } else {
out.push(`${indent}${marker}${language}`) out.push(`${indent}${marker}${language}`)
out.push(...bodyLines) extend(out, bodyLines)
} }
break break
@ -288,7 +294,7 @@ function normalizeFenceBlocks(text: string): string {
// colliding with our wrapper. Without this rewrite the block // colliding with our wrapper. Without this rewrite the block
// would render as a syntax-highlighted "latex" code listing. // would render as a syntax-highlighted "latex" code listing.
out.push(`${indent}${marker}math`) out.push(`${indent}${marker}math`)
out.push(...bodyLines) extend(out, bodyLines)
out.push(`${indent}${marker}`) out.push(`${indent}${marker}`)
index = closeIndex + 1 index = closeIndex + 1
@ -296,7 +302,7 @@ function normalizeFenceBlocks(text: string): string {
} }
out.push(`${indent}${marker}${language}`) out.push(`${indent}${marker}${language}`)
out.push(...bodyLines) extend(out, bodyLines)
out.push(`${indent}${marker}`) out.push(`${indent}${marker}`)
index = closeIndex + 1 index = closeIndex + 1
} }

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label' import { currentPickerSelection, displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
describe('model-status-label', () => { describe('model-status-label', () => {
it('formats display names consistently', () => { it('formats display names consistently', () => {
@ -10,6 +10,11 @@ describe('model-status-label', () => {
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5') expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
}) })
it('strips trailing date-pin snapshots from the display name', () => {
expect(displayModelName('claude-opus-4-5-20251101')).toBe('Opus 4 5')
expect(displayModelName('anthropic/claude-haiku-4-5-20251001')).toBe('Haiku 4 5')
})
it('maps reasoning effort to compact labels', () => { it('maps reasoning effort to compact labels', () => {
expect(reasoningEffortLabel('high')).toBe('High') expect(reasoningEffortLabel('high')).toBe('High')
expect(reasoningEffortLabel('xhigh')).toBe('Max') expect(reasoningEffortLabel('xhigh')).toBe('Max')
@ -30,4 +35,25 @@ describe('model-status-label', () => {
it('returns just the placeholder name when there is no model', () => { it('returns just the placeholder name when there is no model', () => {
expect(formatModelStatusLabel('')).toBe('No model') expect(formatModelStatusLabel('')).toBe('No model')
}) })
describe('currentPickerSelection', () => {
const store = { model: 'opus', provider: 'anthropic' }
const options = { model: 'hermes-4', provider: 'nous' }
it('prefers the sticky composer pick over the profile default pre-session', () => {
expect(currentPickerSelection(false, store, options)).toEqual(store)
})
it('lets the live session model.options win when a session exists', () => {
expect(currentPickerSelection(true, store, options)).toEqual(options)
})
it('falls back to options when the store is empty', () => {
expect(currentPickerSelection(false, { model: '', provider: '' }, options)).toEqual(options)
})
it('falls back to the store while options are still loading', () => {
expect(currentPickerSelection(true, store, undefined)).toEqual(store)
})
})
}) })

View File

@ -17,6 +17,22 @@ export function reasoningEffortLabel(effort: string): string {
return REASONING_LABELS[key] ?? effort return REASONING_LABELS[key] ?? effort
} }
/** Which model/provider a picker should mark "current". With a live session the
* gateway's `model.options` is authoritative; pre-session there is no server
* "current", so the sticky composer pick wins over the profile default the
* global options query returns else the checkmark snaps back to the default
* and the pick looks ignored. */
export function currentPickerSelection(
hasSession: boolean,
store: { model: string; provider: string },
options?: { model?: string; provider?: string }
): { model: string; provider: string } {
return {
model: String((hasSession && options?.model) || store.model || options?.model || ''),
provider: String((hasSession && options?.provider) || store.provider || options?.provider || '')
}
}
/** Strip provider prefix and normalize for display. */ /** Strip provider prefix and normalize for display. */
export function modelBaseId(model: string): string { export function modelBaseId(model: string): string {
const trimmed = model.trim() const trimmed = model.trim()
@ -68,6 +84,9 @@ export function modelDisplayParts(model: string): { name: string; tag: string }
} }
} }
// Drop a trailing date-pin (`…-20251101`) — snapshot noise, not a name.
base = base.replace(/-\d{8}$/, '')
return { name: prettifyBase(base) || model.trim() || 'No model', tag } return { name: prettifyBase(base) || model.trim() || 'No model', tag }
} }

View File

@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $modelPresets, applyModelPreset, getModelPreset, modelPresetKey, setModelPreset } from './model-presets'
describe('model presets', () => {
beforeEach(() => $modelPresets.set({}))
it('round-trips a preset and merges patches without dropping prior fields', () => {
setModelPreset('anthropic', 'claude-opus-4-8', { effort: 'high' })
setModelPreset('anthropic', 'claude-opus-4-8', { fast: true })
expect(getModelPreset('anthropic', 'claude-opus-4-8')).toEqual({ effort: 'high', fast: true })
})
it('returns an empty preset for unknown models', () => {
expect(getModelPreset('x', 'y')).toEqual({})
})
it('keys by provider::model', () => {
expect(modelPresetKey('openai', 'gpt-5.5')).toBe('openai::gpt-5.5')
})
it('pushes only the provided dimensions to the gateway', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
const request = async <T>(method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as T
}
await applyModelPreset({ effort: 'high' }, { failMessage: 'x', request, sessionId: 's1' })
await applyModelPreset({}, { failMessage: 'x', request, sessionId: 's1' })
expect(calls).toEqual([{ method: 'config.set', params: { key: 'reasoning', session_id: 's1', value: 'high' } }])
})
it('no-ops without a session so selecting a model cannot mutate global config', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
const request = async <T>(method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as T
}
await applyModelPreset({ effort: 'high', fast: true }, { failMessage: 'x', request, sessionId: null })
expect(calls).toEqual([])
})
})

View File

@ -0,0 +1,86 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import { notifyError } from './notifications'
import { setCurrentFastMode, setCurrentReasoningEffort } from './session'
const STORAGE_KEY = 'hermes.desktop.model-presets'
/** Per-model reasoning/fast preset, remembered globally across sessions and
* re-applied to the session whenever that model is selected. Unset dimensions
* fall back to the Hermes default (medium effort, no fast). */
export interface ModelPreset {
effort?: string
fast?: boolean
}
type RequestGateway = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/** Stable `provider::model` key (matches the visibility-store format). */
export const modelPresetKey = (provider: string, model: string): string => `${provider}::${model}`
function load(): Record<string, ModelPreset> {
const raw = storedString(STORAGE_KEY)
if (!raw) {
return {}
}
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, ModelPreset>) : {}
} catch {
return {}
}
}
export const $modelPresets = atom<Record<string, ModelPreset>>(load())
export function getModelPreset(provider: string, model: string): ModelPreset {
return $modelPresets.get()[modelPresetKey(provider, model)] ?? {}
}
/** Merge a partial preset for one model and persist. */
export function setModelPreset(provider: string, model: string, patch: ModelPreset): void {
const key = modelPresetKey(provider, model)
const next = { ...$modelPresets.get(), [key]: { ...$modelPresets.get()[key], ...patch } }
$modelPresets.set(next)
persistString(STORAGE_KEY, JSON.stringify(next))
}
/** Push a model's preset onto the active session (optimistic + gateway).
* `undefined` skips that dimension; values are capability-gated upstream.
* No-ops without a session the gateway's `config.set` reasoning/fast fall
* back to persistent (global/profile) config when none matches, so selecting
* a model must not reach it (else it rewrites `agent.*`, defaults included). */
export async function applyModelPreset(
{ effort, fast }: ModelPreset,
ctx: { failMessage: string; request: RequestGateway; sessionId: null | string }
): Promise<void> {
if (!ctx.sessionId) {
return
}
if (effort !== undefined) {
setCurrentReasoningEffort(effort)
}
if (fast !== undefined) {
setCurrentFastMode(fast)
}
try {
if (effort !== undefined) {
await ctx.request('config.set', { key: 'reasoning', session_id: ctx.sessionId, value: effort })
}
if (fast !== undefined) {
await ctx.request('config.set', { key: 'fast', session_id: ctx.sessionId, value: fast ? 'fast' : 'normal' })
}
} catch (err) {
notifyError(err, ctx.failMessage)
}
}

View File

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { ModelOptionProvider } from '@/types/hermes' import type { ModelOptionProvider } from '@/types/hermes'
import { import {
collapseModelFamilies,
effectiveVisibleKeys, effectiveVisibleKeys,
emptyProviderSentinelKey, emptyProviderSentinelKey,
isProviderSentinel, isProviderSentinel,
@ -78,6 +79,18 @@ describe('model visibility', () => {
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false) expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
}) })
it('folds a date-pinned snapshot into its rolling alias when present', () => {
const families = collapseModelFamilies(['claude-opus-4-5', 'claude-opus-4-5-20251101'])
expect(families.map(f => f.id)).toEqual(['claude-opus-4-5'])
})
it('keeps a date-pinned snapshot standing alone when it has no alias', () => {
const families = collapseModelFamilies(['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001'])
expect(families.map(f => f.id)).toEqual(['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001'])
})
it('sentinel key helper produces correct format', () => { it('sentinel key helper produces correct format', () => {
expect(emptyProviderSentinelKey('openai')).toBe('openai::') expect(emptyProviderSentinelKey('openai')).toBe('openai::')
expect(isProviderSentinel('openai::')).toBe(true) expect(isProviderSentinel('openai::')).toBe(true)

View File

@ -51,6 +51,11 @@ export function collapseModelFamilies(models: readonly string[]): ModelFamily[]
continue continue
} }
if (/-\d{8}$/.test(model) && present.has(model.replace(/-\d{8}$/, ''))) {
// A date-pinned snapshot superseded by its rolling alias — drop the dupe.
continue
}
const fastId = `${model}-fast` const fastId = `${model}-fast`
const hasFast = present.has(fastId) const hasFast = present.has(fastId)
families.push({ fastId: hasFast ? fastId : null, id: model }) families.push({ fastId: hasFast ? fastId : null, id: model })

View File

@ -4,13 +4,23 @@ import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading'
import type { ContextSuggestion } from '@/app/types' import type { ContextSuggestion } from '@/app/types'
import type { HermesConnection } from '@/global' import type { HermesConnection } from '@/global'
import type { ChatMessage } from '@/lib/chat-messages' import type { ChatMessage } from '@/lib/chat-messages'
import { persistString, storedString } from '@/lib/storage' import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
import type { SessionInfo, UsageStats } from '@/types/hermes' import type { SessionInfo, UsageStats } from '@/types/hermes'
type Updater<T> = T | ((current: T) => T) type Updater<T> = T | ((current: T) => T)
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
// The composer's model/effort/fast is sticky UI state, NOT the profile default
// (that lives in Settings → Model). Persisting it in localStorage makes a pick
// follow across Cmd+N and app restarts instead of snapping back to the default.
// It's deliberately global (not per-profile): a profile switch force-reseeds to
// that profile's default, while within a profile new chats keep your last pick.
const COMPOSER_MODEL_KEY = 'hermes.desktop.composer.model'
const COMPOSER_PROVIDER_KEY = 'hermes.desktop.composer.provider'
const COMPOSER_EFFORT_KEY = 'hermes.desktop.composer.reasoning-effort'
const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast'
let configuredDefaultProjectDir = '' let configuredDefaultProjectDir = ''
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string { function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
@ -208,11 +218,28 @@ export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageI
export const $freshDraftReady = atom(false) export const $freshDraftReady = atom(false)
export const $busy = atom(false) export const $busy = atom(false)
export const $awaitingResponse = atom(false) export const $awaitingResponse = atom(false)
export const $currentModel = atom('') // Stored-session id whose most recent resume FAILED terminally (the gateway RPC
export const $currentProvider = atom('') // rejected AND the REST transcript fallback also failed), leaving the window
export const $currentReasoningEffort = atom('') // with no runtime and an empty transcript. Drives use-route-resume's self-heal:
// while this matches the routed session the loader would otherwise latch
// forever (messagesEmpty && !activeSessionId), so the hook re-attempts the
// resume on the next render/focus/reconnect instead of stranding the window.
// Null whenever the active route has a healthy (or in-flight) resume.
export const $resumeFailedSessionId = atom<string | null>(null)
// Stored-session id whose resume has EXHAUSTED its bounded auto-retries (the
// terminal-failure latch above kept failing through all MAX_RESUME_RETRIES
// attempts). Distinct from $resumeFailedSessionId, which is armed *during* the
// backoff window too: this fires only once auto-recovery has given up, so the
// chat view can swap the perpetual loader for an explicit error + manual Retry
// affordance. A fresh resumeSession() (manual Retry, reconnect, reselect)
// clears it and resets the retry counter. Null whenever the active route has a
// healthy, in-flight, or still-auto-retrying resume.
export const $resumeExhaustedSessionId = atom<string | null>(null)
export const $currentModel = atom(storedString(COMPOSER_MODEL_KEY) ?? '')
export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '')
export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '')
export const $currentServiceTier = atom('') export const $currentServiceTier = atom('')
export const $currentFastMode = atom(false) export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false))
// Effective approval-bypass state mirrored from the gateway (session.info). // Effective approval-bypass state mirrored from the gateway (session.info).
// Persistence lives in the backend config (approvals.mode), so this is a plain // Persistence lives in the backend config (approvals.mode), so this is a plain
// reflection of the truth the gateway reports rather than its own store. // reflection of the truth the gateway reports rather than its own store.
@ -252,13 +279,33 @@ export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next) export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next) export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next) export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
export const setResumeFailedSessionId = (next: Updater<string | null>) => updateAtom($resumeFailedSessionId, next)
export const setResumeExhaustedSessionId = (next: Updater<string | null>) => updateAtom($resumeExhaustedSessionId, next)
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next) export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next) export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
export const setCurrentModel = (next: Updater<string>) => updateAtom($currentModel, next)
export const setCurrentProvider = (next: Updater<string>) => updateAtom($currentProvider, next) export const setCurrentModel = (next: Updater<string>) => {
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next) updateAtom($currentModel, next)
persistString(COMPOSER_MODEL_KEY, $currentModel.get() || null)
}
export const setCurrentProvider = (next: Updater<string>) => {
updateAtom($currentProvider, next)
persistString(COMPOSER_PROVIDER_KEY, $currentProvider.get() || null)
}
export const setCurrentReasoningEffort = (next: Updater<string>) => {
updateAtom($currentReasoningEffort, next)
persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)
}
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next) export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
export const setCurrentFastMode = (next: Updater<boolean>) => {
updateAtom($currentFastMode, next)
persistBoolean(COMPOSER_FAST_KEY, $currentFastMode.get())
}
export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive, next) export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive, next)
export const setCurrentCwd = (next: Updater<string>) => { export const setCurrentCwd = (next: Updater<string>) => {

View File

@ -5,6 +5,9 @@ import type { DesktopUpdateStatus } from '@/global'
const storage = new Map<string, string>() const storage = new Map<string, string>()
vi.mock('@/lib/storage', () => ({ vi.mock('@/lib/storage', () => ({
persistBoolean: (key: string, value: boolean) => {
storage.set(key, String(value))
},
persistString: (key: string, value: null | string) => { persistString: (key: string, value: null | string) => {
if (value === null) { if (value === null) {
storage.delete(key) storage.delete(key)
@ -12,6 +15,11 @@ vi.mock('@/lib/storage', () => ({
storage.set(key, value) storage.set(key, value)
} }
}, },
storedBoolean: (key: string, fallback: boolean) => {
const value = storage.get(key)
return value === undefined ? fallback : value === 'true'
},
storedString: (key: string) => storage.get(key) ?? null storedString: (key: string) => storage.get(key) ?? null
})) }))
@ -33,7 +41,7 @@ vi.mock('@/hermes', () => ({
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args) getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
})) }))
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply } = await import('./updates') const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply, reportBackendContract } = await import('./updates')
const { setConnection } = await import('./session') const { setConnection } = await import('./session')
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({ const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
@ -87,6 +95,61 @@ describe('maybeNotifyUpdateAvailable', () => {
}) })
}) })
describe('reportBackendContract', () => {
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
dismissSpy.mockClear()
vi.useRealTimers()
})
it('dismisses the toast when the backend meets the contract', () => {
reportBackendContract(2)
expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew')
expect(notifySpy).not.toHaveBeenCalled()
})
it('warns when the backend is behind (or reports no contract)', () => {
reportBackendContract(undefined)
expect(notifySpy).toHaveBeenCalledTimes(1)
reportBackendContract(1)
expect(notifySpy).toHaveBeenCalledTimes(2)
})
it('stays quiet on later session opens once the user closed it', () => {
reportBackendContract(1)
lastToast().onDismiss() // user closes it → cooldown starts
notifySpy.mockClear()
// Opening another pre-existing session re-runs the check within cooldown.
reportBackendContract(1)
expect(notifySpy).not.toHaveBeenCalled()
})
it('reminds again after the cooldown elapses', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
reportBackendContract(1)
lastToast().onDismiss()
notifySpy.mockClear()
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
reportBackendContract(1)
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('clears the snooze once the backend catches up, so a regression warns again', () => {
reportBackendContract(1)
lastToast().onDismiss()
notifySpy.mockClear()
reportBackendContract(2) // backend updated → satisfied, snooze cleared
reportBackendContract(1) // a later regression must warn immediately
expect(notifySpy).toHaveBeenCalledTimes(1)
})
})
describe('checkBackendUpdates', () => { describe('checkBackendUpdates', () => {
beforeEach(() => { beforeEach(() => {
storage.clear() storage.clear()

View File

@ -91,26 +91,60 @@ function isUpdateToastSnoozed(): boolean {
// v2: requires the file.attach RPC (remote-gateway non-image file upload). // v2: requires the file.attach RPC (remote-gateway non-image file upload).
const REQUIRED_BACKEND_CONTRACT = 2 const REQUIRED_BACKEND_CONTRACT = 2
const SKEW_TOAST_ID = 'backend-contract-skew' const SKEW_TOAST_ID = 'backend-contract-skew'
// The contract check runs on every session.resume (applyRuntimeInfo), so
// without a snooze the warning re-popped on every thread the user opened, even
// right after they closed it. Mirror the update toast: persist a cooldown when
// the user dismisses it. It still reminds again after the window if the backend
// is still behind, and clears immediately once the backend catches up.
const SKEW_TOAST_SNOOZE_KEY = 'hermes:backend-skew-toast-snooze-until'
const SKEW_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
function snoozeSkewToast(): void {
persistString(SKEW_TOAST_SNOOZE_KEY, String(Date.now() + SKEW_TOAST_COOLDOWN_MS))
}
function isSkewToastSnoozed(): boolean {
const until = Number(storedString(SKEW_TOAST_SNOOZE_KEY) || 0)
return Number.isFinite(until) && Date.now() < until
}
/** /**
* Guard against a desktop GUI talking to a backend that predates its contract * Guard against a desktop GUI talking to a backend that predates its contract
* (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing * (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing
* cryptically downstream, surface a persistent warning with a one-click align * cryptically downstream, surface a warning with a one-click align that runs
* that runs the normal update flow (which self-heals to the right branch). * the normal update flow (which self-heals to the right branch).
*
* Runs on every session open; closing the toast snoozes it for a cooldown so it
* doesn't nag on every thread switch.
*/ */
export function reportBackendContract(contract: number | undefined): void { export function reportBackendContract(contract: number | undefined): void {
if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) { if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) {
dismissNotification(SKEW_TOAST_ID) dismissNotification(SKEW_TOAST_ID)
// Backend caught up — forget any prior snooze so a future regression warns
// immediately rather than staying silent for the rest of the window.
persistString(SKEW_TOAST_SNOOZE_KEY, null)
return return
} }
if (isSkewToastSnoozed()) {
return
}
notify({ notify({
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyBackendUpdate() }, action: {
label: translateNow('notifications.updateHermes'),
onClick: () => {
snoozeSkewToast()
void applyBackendUpdate()
}
},
durationMs: 0, durationMs: 0,
id: SKEW_TOAST_ID, id: SKEW_TOAST_ID,
kind: 'warning', kind: 'warning',
message: translateNow('notifications.backendOutOfDateMessage'), message: translateNow('notifications.backendOutOfDateMessage'),
onDismiss: () => snoozeSkewToast(),
title: translateNow('notifications.backendOutOfDateTitle') title: translateNow('notifications.backendOutOfDateTitle')
}) })
} }

View File

@ -47,6 +47,9 @@ export interface OAuthProviderStatus {
export interface OAuthProvider { export interface OAuthProvider {
cli_command: string cli_command: string
/** Shell command that clears an external provider's credentials, run in the
* embedded terminal. Null when Hermes doesn't know how to remove it. */
disconnect_command?: null | string
disconnect_hint?: null | string disconnect_hint?: null | string
disconnectable?: boolean disconnectable?: boolean
docs_url: string docs_url: string

9
cli.py
View File

@ -12082,6 +12082,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# Create the input area with multiline (Alt+Enter), autocomplete, and paste handling # Create the input area with multiline (Alt+Enter), autocomplete, and paste handling
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import ThreadedCompleter
_completer = SlashCommandCompleter( _completer = SlashCommandCompleter(
@ -12097,7 +12098,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
wrap_lines=True, wrap_lines=True,
read_only=Condition(lambda: bool(cli_ref._command_running)), read_only=Condition(lambda: bool(cli_ref._command_running)),
history=FileHistory(str(self._history_file)), history=FileHistory(str(self._history_file)),
completer=_completer, # complete_while_typing fires the completer on every keystroke. The
# completer does blocking work — fuzzy @-file indexing shells out to
# rg/fd (up to a 2s timeout) and path completion hits os.listdir/stat
# — so running it inline would stall the render loop on each key (very
# noticeable on WSL2/slow filesystems). ThreadedCompleter moves it off
# the UI event loop, keeping typing responsive.
completer=ThreadedCompleter(_completer),
complete_while_typing=True, complete_while_typing=True,
auto_suggest=SlashCommandAutoSuggest( auto_suggest=SlashCommandAutoSuggest(
history_suggest=AutoSuggestFromHistory(), history_suggest=AutoSuggestFromHistory(),

View File

@ -28,14 +28,13 @@ as_hermes() { [ "$(id -u)" = 0 ] || { "$@"; return; }; s6-setuidgid hermes "$@";
# arbitrary host UID (the classic `--user $(id -u):$(id -g)` invocation people # arbitrary host UID (the classic `--user $(id -u):$(id -g)` invocation people
# used in the tini era to make container-written files match their host user). # used in the tini era to make container-written files match their host user).
# #
# Under s6-overlay this no longer works: the bootstrap (UID remap, volume + # Under s6-overlay this no longer works: the bootstrap (UID remap, data-volume
# build-tree chown, config seeding) all require root, and they're skipped when # ownership, config seeding) requires root, and it is skipped when the container
# the container starts non-root. The baked image trees (/opt/data, /opt/hermes/ # starts non-root. The baked install tree under /opt/hermes is intentionally
# .venv, ui-tui, node_modules) stay owned by the hermes build UID (10000), so an # root-owned and non-writable; mutable runtime state must live under
# arbitrary `--user` UID can't write them — the runtime then fails with EACCES # $HERMES_HOME. An arbitrary `--user` UID therefore cannot repair or populate
# on a bind mount, or hard-crashes on a named volume (Docker initialises the # the data volume, and startup fails with EACCES. See #34837 for the
# volume from the image as UID 10000, and the non-root start can't even `cd` # supervision-tree side of this.
# into $HERMES_HOME). See #34837 for the supervision-tree side of this.
# #
# The supported way to match host-side ownership is to start as root (the image # The supported way to match host-side ownership is to start as root (the image
# default) and pass HERMES_UID/HERMES_GID — or the PUID/PGID aliases — which the # default) and pass HERMES_UID/HERMES_GID — or the PUID/PGID aliases — which the
@ -53,9 +52,10 @@ if [ "$cur_uid" != 0 ] && [ "$cur_uid" != "$(id -u hermes)" ]; then
[stage2] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID). [stage2] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID).
This is not supported under the s6-overlay image. The container bootstrap This is not supported under the s6-overlay image. The container bootstrap
(UID remap, volume ownership, dependency installs) needs to start as root, (UID remap, data-volume ownership, config seeding) needs to start as root,
and the baked image directories are owned by the hermes user (UID $(id -u hermes)), and the baked /opt/hermes install tree is intentionally root-owned and
so a pinned --user UID cannot write them — startup will fail. non-writable, so a pinned --user UID cannot repair startup state — startup
will fail.
To make container-written files match your HOST user, DON'T use --user. To make container-written files match your HOST user, DON'T use --user.
Start the container as root (the default) and pass your host UID/GID instead: Start the container as root (the default) and pass your host UID/GID instead:
@ -207,49 +207,13 @@ if [ "$needs_chown" = true ]; then
done done
fi fi
# --- Fix ownership of build trees under $INSTALL_DIR --- # --- Immutable install tree ---
# Hermes-owned trees under $INSTALL_DIR must be re-chowned whenever the # Do not chown runtime code or dependency trees under $INSTALL_DIR back to the
# runtime hermes UID no longer owns them — otherwise: # hermes user. Hosted/container instances keep mutable state under
# - .venv: lazy_deps.py cannot install platform packages (discord.py, # $HERMES_HOME (/opt/data) and run with PYTHONDONTWRITEBYTECODE plus
# telegram, slack, etc.) with EACCES (#15012, #21100) # HERMES_DISABLE_LAZY_INSTALLS=1. Keeping /opt/hermes root-owned and
# - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when # non-writable prevents an agent session from self-modifying the installed
# the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD # source, venv, TUI bundle, or node_modules and bricking the gateway.
# is set) and writes to ui-tui/dist/. Without this chown the new
# hermes UID can't write the build output (#28851).
# - gateway: Python writes __pycache__ and runtime artifacts beneath the
# gateway package on first import. After a UID remap those source-owned
# paths still belong to the build-time UID (10000) unless repaired here,
# producing EACCES for the supervised gateway (#27221).
# - node_modules: root-level dependencies (puppeteer, web tooling)
# that runtime code may walk/update.
# The set mirrors the build-time `chown -R hermes:hermes` line in the
# Dockerfile — keep them in sync if the Dockerfile chown set changes.
# These are under $INSTALL_DIR (not $HERMES_HOME), so the bind-mount
# concern doesn't apply — recursive is fine.
#
# This MUST be gated independently of the $HERMES_HOME ownership check
# above. `usermod -u <new> hermes` re-chowns the hermes home dir
# ($HERMES_HOME == /opt/data) to the new UID as a side effect, so after a
# HERMES_UID/PUID remap `stat $HERMES_HOME` always already matches the new
# UID and `needs_chown` is false — but the build trees under /opt/hermes
# are NOT touched by usermod and remain owned by the build-time UID
# (10000). Gating them on $HERMES_HOME ownership (as #35027 did) silently
# skipped this chown on the common PUID/NAS path, regressing lazy installs
# and TUI rebuilds. Probe the build trees directly instead: chown only
# when the venv is not already owned by the runtime hermes UID. Idempotent
# and skips the expensive recursive chown on every restart once ownership
# is settled.
venv_owner=$(stat -c %u "$INSTALL_DIR/.venv" 2>/dev/null || echo "")
if [ -n "$venv_owner" ] && [ "$venv_owner" != "$actual_hermes_uid" ]; then
echo "[stage2] Fixing ownership of build trees under $INSTALL_DIR to hermes ($actual_hermes_uid)"
chown -R hermes:hermes \
"$INSTALL_DIR/.venv" \
"$INSTALL_DIR/ui-tui" \
"$INSTALL_DIR/gateway" \
"$INSTALL_DIR/node_modules" \
2>/dev/null || \
echo "[stage2] Warning: chown of build trees failed (rootless container?) — continuing"
fi
# Always reset ownership of $HERMES_HOME/profiles to hermes on every # Always reset ownership of $HERMES_HOME/profiles to hermes on every
# boot. Profile dirs and files can land owned by root when commands # boot. Profile dirs and files can land owned by root when commands
@ -327,13 +291,25 @@ as_hermes mkdir -p \
"$HERMES_HOME/pairing" \ "$HERMES_HOME/pairing" \
"$HERMES_HOME/platforms/pairing" "$HERMES_HOME/platforms/pairing"
# --- Install-method stamp (read by detect_install_method() in hermes status) --- # --- Install-method stamp ---
# Preserved from the tini-era entrypoint (PR #27843). Must be written as # The 'docker' stamp is baked into the immutable install tree at
# the hermes user so ownership matches the file's documented owner. # /opt/hermes/.install_method (see Dockerfile), NOT written here into
# tee is invoked directly via s6-setuidgid (no `sh -c` wrapper) for the # $HERMES_HOME. detect_install_method() reads the code-scoped stamp first.
# same shell-metacharacter safety described above. #
printf 'docker\n' | as_hermes tee "$HERMES_HOME/.install_method" >/dev/null \ # Why we no longer stamp $HERMES_HOME: it is a shared DATA volume, commonly
|| true # bind-mounted from the host (~/.hermes:/opt/data) and sometimes shared with a
# host-side Desktop/CLI install. Stamping 'docker' here clobbered that host
# install's marker, so its in-app updater read 'docker' and refused to run
# 'hermes update'. To heal homes already poisoned by older images, remove a
# stale 'docker' stamp from $HERMES_HOME if one is present (the host install's
# own installer re-creates its code-scoped stamp; a genuine container relies on
# the baked /opt/hermes stamp, so deleting the data-dir copy is safe).
if [ -f "$HERMES_HOME/.install_method" ]; then
stamped="$(tr -d '[:space:]' < "$HERMES_HOME/.install_method" 2>/dev/null || true)"
if [ "$stamped" = "docker" ]; then
rm -f "$HERMES_HOME/.install_method" 2>/dev/null || true
fi
fi
# --- Seed config files (only on first boot) --- # --- Seed config files (only on first boot) ---
seed_one() { seed_one() {

View File

@ -0,0 +1,260 @@
# Relay ↔ Connector Contract (v1, EXPERIMENTAL)
> **Status:** EXPERIMENTAL. This contract MAY CHANGE without a deprecation
> cycle until at least two real Class-1 platforms (Discord + Telegram) have
> validated it. Evolution during the experimental phase is **additive-only**,
> gated by `contract_version`. A breaking change updates both repos in lockstep.
This document is the formal interface between the **Hermes gateway** (Python,
`gateway/relay/`) and the **connector** (Node/TypeScript,
`NousResearch/gateway-gateway`). The connector implementer's first action is to
read this file.
The gateway runs a generic `RelayAdapter` that dials **out** to the connector,
receives a `CapabilityDescriptor` at handshake, then exchanges normalized
`MessageEvent`s (inbound) and actions (outbound) over a per-turn bidirectional
WebSocket. The gateway never learns which concrete platform is fronting it; the
connector owns all platform-specific socket/identity logic.
---
## 1. Handshake
1. Gateway opens the transport (`connect`).
2. Gateway calls `handshake()`; connector returns a `CapabilityDescriptor`
(section 2) describing the platform this adapter instance fronts.
3. Gateway configures the adapter from the descriptor (char limit, length unit,
draft/edit/thread/markdown capabilities) and registers an inbound handler.
4. Connector then streams inbound events and accepts outbound actions.
`contract_version` (currently `1`) is carried in the descriptor. The gateway
ignores unknown descriptor fields (forward-compat) and fills missing optional
fields from defaults.
---
## 2. CapabilityDescriptor (handshake payload)
JSON object. Source of truth: `gateway/relay/descriptor.py`.
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `contract_version` | int | yes | Contract version (additive-only within a version). |
| `platform` | string | yes | Platform name (e.g. `"discord"`, `"telegram"`). |
| `label` | string | yes | Human-readable label. |
| `max_message_length` | int | yes | Char limit; gateway exposes as `MAX_MESSAGE_LENGTH`. 0 → treat as 4096. |
| `supports_draft_streaming` | bool | yes | Native draft-streaming preview support. |
| `supports_edit` | bool | yes | Edit-based streaming possible; if false, consumer degrades to one-message-per-segment. |
| `supports_threads` | bool | yes | `create_handoff_thread` capability. |
| `markdown_dialect` | string | yes | `"plain"`, `"markdown_v2"`, `"discord"`, … (drives `supports_code_blocks`). |
| `len_unit` | string | yes | `"chars"` (builtin len) or `"utf16"` (Telegram UTF-16 code units). |
| `emoji` | string | no | Display emoji (default 🔌). |
| `platform_hint` | string | no | System-prompt platform hint. |
| `pii_safe` | bool | no | Redact PII in session descriptions. |
Most fields are a projection of the gateway's existing `PlatformEntry`; the
runtime-only fields (`len_unit`, `supports_*`, `markdown_dialect`) come from the
live platform adapter's capability methods.
---
## 3. Inbound: `MessageEvent` envelope
The connector normalizes each platform wire event into a `MessageEvent`
(`gateway/platforms/base.py`) and delivers it to the gateway. **Inbound is
delivered over a signed HTTP POST, not the outbound `/relay` WebSocket** (see
the transport note below). The gateway keys the session via `build_session_key()`
from the embedded `SessionSource` — so populating the right discriminators is
the single highest-correctness responsibility of the connector.
### Inbound transport (signed HTTP POST, not the outbound WS)
The gateway dials **out** to the connector's `/relay` WebSocket for the
handshake + outbound actions (§4) + its own `/stop` egress (§5). Inbound,
however, is delivered the other way: the connector **POSTs** the normalized
event to the gateway's inbound endpoint (`HttpGatewayDelivery` on the connector;
`gateway/relay/inbound_receiver.py` on the gateway). The reason is
multi-instance: the connector instance that owns a platform's socket (and thus
produces inbound events) is generally **not** the instance a given gateway
dialed its outbound WS into, so inbound must target a tenant **endpoint** (which
may load-balance across gateway instances) rather than ride one gateway's
outbound socket. Each delivery is HMAC-signed with the per-tenant **delivery
key** (§6.1); the gateway verifies the signature over the exact raw bytes before
accepting the event. Two POST targets:
- `POST {gatewayEndpoint}``{"type":"message", "event": <MessageEvent>}`
- `POST {gatewayEndpoint}/interrupt``{"type":"interrupt", "session_key", "reason"?}` (§5)
> An earlier draft of this contract delivered inbound over the WS `inbound`
> frame. That only works single-instance and predates the multi-instance
> socket-ownership + channel-auth model; the signed-HTTP path above is the
> shipped design.
### SessionSource fields (the wire surface)
Source of truth: `SessionSource.to_dict()` in `gateway/session.py`. These are
every key the gateway accepts on the wire. `platform`, `chat_id`, `chat_type`,
`user_id`, `user_name`, `thread_id`, `chat_name`, and `chat_topic` are always
present (may be `null`); the rest are included only when set.
| Field | Type | Always sent | Meaning |
| --- | --- | --- | --- |
| `platform` | string | yes | Platform name (matches the descriptor's `platform`). |
| `chat_id` | string | yes | Primary conversation id (channel/chat). Session-key discriminator. |
| `chat_type` | string | yes | `dm` / `group` / `channel` / `thread` / `forum`. |
| `chat_name` | string\|null | yes | Human-readable chat name. |
| `user_id` | string\|null | yes | Message author id. Session-key discriminator. |
| `user_name` | string\|null | yes | Author display name. |
| `thread_id` | string\|null | yes | Thread/forum-topic id when in a thread. Session-key discriminator. |
| `chat_topic` | string\|null | yes | Channel topic/description (Discord, Slack). |
| `user_id_alt` | string | no | Platform-specific stable alt id (Signal UUID, Feishu union_id). |
| `chat_id_alt` | string | no | Alternate chat id (e.g. Signal group internal id). |
| `guild_id` | string | no | Discord guild / Slack workspace / Matrix server scope. **REQUIRED for Discord server isolation.** Session-key discriminator. |
| `parent_chat_id` | string | no | Parent channel when `chat_id` refers to a thread. |
| `message_id` | string | no | Id of the triggering message (for pin/reply/react). |
> `is_bot` (author-is-a-bot/webhook classification) exists on the gateway-side
> dataclass but is **intentionally NOT on the wire** in v1 — it is not part of
> `to_dict()`. Do not add it to the connector's `SessionSource` until it is
> first added here and to `to_dict()` (additive bump).
### SessionSource discriminators per platform
| Platform | chat_id | chat_type | user_id | thread_id | guild_id |
| --- | --- | --- | --- | --- | --- |
| **Discord** | channel id | `dm`/`group`/`thread` | author id | thread channel id (threads) | **guild id** (REQUIRED for server isolation) |
| **Telegram** | chat id | `dm`/`group`/`forum` | from id | forum topic id (forums) | — |
**Get Discord's `guild_id` wrong and two servers collide into one session.**
This is the #1 High-severity risk. The gateway's `build_session_key()` is the
conformance oracle: for a given `SessionSource`, the connector's normalization
must produce the same key the Python adapter would. (The Phase-1 stub tests
assert known-input → known-key.)
### Bot identity vs tenant (single-bot consolidation, Appendix A)
The envelope carries the **originating bot identity** as a field **distinct from
tenant**. Tenant is resolved from the event's own discriminator (Discord
`guild_id`, Telegram `chat_id`, webhook path/subdomain) — **never** from which
token/socket/process delivered it. This keeps one shared bot able to front many
tenants (Phase 6) without overloading an existing field.
---
## 4. Outbound: action set
The gateway calls the transport with action dicts. Source of truth:
`gateway/relay/transport.py` + `gateway/relay/adapter.py`.
| `op` | Fields | Result |
| --- | --- | --- |
| `send` | `chat_id`, `content`, `reply_to?`, `metadata?` | `{success: bool, message_id?, error?}` |
| `edit` | `chat_id`, `message_id`, `content`, `metadata?` | `{success: bool, error?}` |
| `typing` | `chat_id` | `{success: bool}` |
| `follow_up` | `session_key`, `kind`, `content`, `metadata?` | `{success: bool, message_id?, error?}` |
`get_chat_info(chat_id)` is a separate proxied call returning at least
`{name, type}`. Media actions follow the same envelope shape (deferred to a
later contract revision; additive).
**`follow_up` (A2 capability action).** Some inbound payloads carry a credential
that acts on the **shared** bot identity (e.g. a Discord interaction follow-up
token). Per §6 the connector strips that at the edge and binds it in its
capability vault keyed by the session; it **never reaches the gateway**. To use
it, the gateway issues `follow_up` naming the **session it is already in**
(`session_key`) plus the capability `kind` (e.g. `discord.interaction_token`) —
**never a token**. The connector resolves the real value from its vault,
enforces the tenant match (tenant B can never wield tenant A's capability), and
egresses. `success: false` when the capability is absent/expired or the tenant
doesn't match — the gateway has nothing to retry with, by design (a leaked
gateway holds zero capability material). Source of truth:
`gateway/relay/transport.py` (`send_follow_up`) + `gateway/relay/adapter.py`.
---
## 5. Interrupt (`/stop`) routing
- **Gateway → connector:** `send_interrupt(session_key, reason?)` egresses a
mid-turn `/stop` over the outbound WS. The connector MUST forward it to the
gateway instance running that `session_key` (the routing invariant).
- **Connector → gateway:** an inbound interrupt for a `session_key` is delivered
as a **signed HTTP POST** to `{gatewayEndpoint}/interrupt` (§3 transport note),
and bridged by the adapter's `on_interrupt(session_key, chat_id)` into the
existing per-session interrupt mechanism, cancelling exactly that turn
(siblings untouched).
The gateway→connector `/stop` rides the outbound WS; the connector→gateway
interrupt rides the same signed-HTTP inbound path as a normalized event.
---
## 6. Trust boundary & signed-body handling (A2)
**The connector is the sole crypto/identity boundary. The gateway re-validates
nothing.**
Webhook signatures (Discord ed25519, Twilio HMAC, WeCom BizMsgCrypt) are
computed over exact raw bytes, and some payloads are *encrypted* with a shared
secret. The connector fronts a **shared** bot for many tenants and holds every
tenant's platform secrets, so it:
- **verifies / decrypts at the edge** (the only place the secrets live),
- **normalizes** the payload into a tenant-scoped `MessageEvent` (§3),
- **strips any shared-identity capability** out of the payload and binds it in
its capability vault, keyed by the session (see §4 `follow_up`),
- **forwards only the sanitized `MessageEvent`** — never the raw signed body.
The gateway therefore performs **no** platform signature/crypto verification on
the relay path; it trusts the normalized event. This is an enforced invariant on
the gateway side (`tests/gateway/relay/test_relay_sheds_crypto.py`: the relay
package imports/calls no platform-crypto).
**Why not "forward the signed body byte-for-byte so the gateway re-validates"?**
That earlier model is incoherent under an untrusted, disposable tenant gateway:
- Re-validating Twilio HMAC / WeCom crypto would require handing the gateway the
**shared signing secret** — which is itself the leak, and on a shared bot it's
a *cross-tenant* leak.
- WeCom payloads are encrypted with the shared secret; the connector must decrypt
at the edge just to route, so forwarding ciphertext would again require giving
the gateway the secret.
- A Discord interaction token lives **inside** the signed JSON body — you cannot
both preserve the bytes and strip the credential; they are the same bytes.
So byte-preservation is abandoned deliberately: the connector re-serializes the
sanitized event and the gateway trusts it. This also unifies the passthrough and
relay planes — both are "verify at the edge → emit a normalized event," differing
only in transport. See `docs/capability-trust-boundary.md` (connector repo:
`gateway-gateway`) for the full A2 rationale and the connector-side vault.
### 6.1 Channel authentication (the connector⇄gateway link itself)
A2 makes the connector the sole holder of platform secrets while the gateway may
be **customer-managed and internet-exposed**, so the connector⇄gateway channel
is itself authenticated. The gateway holds two enrollment-issued credentials
(`hermes gateway enroll` → connector `/relay/enroll`): a **per-gateway secret**
and a **per-tenant delivery key**. Both are HMAC-SHA256 schemes with a
multi-secret rotation verify list (gateway side: `gateway/relay/auth.py`;
connector side: `src/core/relayAuthToken.ts` + `src/core/deliverySigning.ts`).
| Leg | Credential | Mechanism |
|-----|-----------|-----------|
| Gateway → connector WS upgrade | per-gateway secret | An `Authorization` bearer header on the `/relay` upgrade. The token is `base64url(payload:exp:sig)` where `payload = gatewayId` and `sig = HMAC(payload:exp, secret)`. Connector verifies and rejects the upgrade (**close 4401**) on mismatch/absence/revocation. The authenticated tenant comes from the connector's store, never the `hello` frame. |
| Connector → gateway inbound POST | per-tenant delivery key | Two headers: `x-relay-timestamp` (unix seconds) and `x-relay-signature` (hex `HMAC(ts.rawBody, deliveryKey)`). Gateway verifies over the **exact raw bytes** within a ±300s replay window before accepting the event; rejects **401** otherwise. |
This is the **channel** authenticator — distinct from platform crypto, which the
relay path still sheds entirely (§6). The gateway holds zero platform secrets;
these two keys authenticate only the connector link. Full threat model +
enrollment/rotation/kill-switch design: `docs/connector-gateway-auth-design.md`
(connector repo).
---
## 7. Versioning policy
- `contract_version` is an int; bump **only** for additive changes during the
experimental phase (new optional fields, new `op`s).
- A breaking change (renamed/removed field, changed semantics) requires a
coordinated update of both repos and a version bump.
- The connector's first PR references the commit SHA of this file it implements
against.

View File

@ -164,6 +164,7 @@ class Platform(Enum):
BLUEBUBBLES = "bluebubbles" BLUEBUBBLES = "bluebubbles"
QQBOT = "qqbot" QQBOT = "qqbot"
YUANBAO = "yuanbao" YUANBAO = "yuanbao"
RELAY = "relay" # generic relay adapter fronted by the connector (EXPERIMENTAL)
@classmethod @classmethod
def _missing_(cls, value): def _missing_(cls, value):
"""Accept unknown platform names only for known plugin adapters. """Accept unknown platform names only for known plugin adapters.
@ -492,6 +493,13 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID")) (cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET")) and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
), ),
# Relay dials OUT to a connector; it is "connected" once an endpoint URL is
# configured (extra["relay_url"] or extra["url"]). The capability descriptor
# is negotiated at handshake time, so the URL is the only config-level
# signal in the experimental phase. EXPERIMENTAL — may change.
Platform.RELAY: lambda cfg: bool(
cfg.extra.get("relay_url") or cfg.extra.get("url")
),
} }

View File

@ -0,0 +1,166 @@
"""Helpers for rendering gateway message timestamps exactly once.
Gateway messages need timestamps in the LLM context for temporal awareness, but
persisted message content should stay clean so replay does not accumulate
``[timestamp] [timestamp] ...`` prefixes across turns.
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import Any, Optional, Tuple
# Current gateway format: [Tue 2026-04-28 13:40:53 CEST]
_HUMAN_TIMESTAMP_RE = re.compile(
r"^\[(?P<dow>[A-Z][a-z]{2}) "
r"(?P<date>\d{4}-\d{2}-\d{2}) "
r"(?P<time>\d{2}:\d{2}:\d{2})"
r"(?: (?P<tz>[A-Za-z0-9_+\-/:]+))?\]\s*"
)
# Older gateway format: [2026-04-13T17:02:06+0200] or [+02:00]
_ISO_TIMESTAMP_RE = re.compile(
r"^\[(?P<iso>\d{4}-\d{2}-\d{2}T[^\]]+)\]\s*"
)
def coerce_message_timestamp(ts_value: Any, tz=None) -> Optional[float]:
"""Coerce a timestamp-like value to Unix epoch seconds.
Accepts Unix epoch numbers, datetime objects, ISO strings, and the gateway's
bracketed human-readable timestamp format. Returns ``None`` when the value
cannot be interpreted.
"""
if ts_value is None:
return None
if isinstance(ts_value, (int, float)):
return float(ts_value)
if hasattr(ts_value, "timestamp"):
try:
return float(ts_value.timestamp())
except Exception:
return None
if isinstance(ts_value, str):
text = ts_value.strip()
if not text:
return None
parsed = _parse_timestamp_prefix(text, tz=tz)
if parsed is not None:
return parsed
try:
return float(text)
except (TypeError, ValueError):
pass
try:
dt = datetime.fromisoformat(text)
except (TypeError, ValueError):
try:
dt = datetime.strptime(text, "%Y-%m-%dT%H:%M:%S%z")
except (TypeError, ValueError):
return None
if dt.tzinfo is None:
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())
return None
def format_message_timestamp(ts_value: Any, tz=None) -> str:
"""Format a timestamp value as ``[Tue 2026-04-28 13:40:53 CEST]``."""
epoch = coerce_message_timestamp(ts_value, tz=tz)
if epoch is None:
return ""
if tz is not None:
dt = datetime.fromtimestamp(epoch, tz=tz)
else:
dt = datetime.fromtimestamp(epoch).astimezone()
return "[" + dt.strftime("%a %Y-%m-%d %H:%M:%S %Z") + "]"
def strip_leading_message_timestamps(content: str, tz=None) -> Tuple[str, Optional[float]]:
"""Strip one or more leading gateway timestamp prefixes from ``content``.
Returns ``(clean_content, embedded_epoch)``. If multiple timestamp prefixes
are present, the timestamp closest to the actual message text wins. That
preserves the original platform-send time for legacy contaminated rows like
``[processing time] [platform time] [sender] message``.
"""
if not isinstance(content, str) or not content:
return content, None
text = content
embedded_epoch: Optional[float] = None
while True:
match = _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
if not match:
break
parsed = _parse_timestamp_match(match, tz=tz)
if parsed is not None:
embedded_epoch = parsed
text = text[match.end():]
return text, embedded_epoch
def render_user_content_with_timestamp(content: str, ts_value: Any = None, tz=None) -> str:
"""Render a user message for LLM context with exactly one timestamp prefix.
Existing leading timestamp prefixes are removed first. If such a prefix was
present, its parsed time wins over ``ts_value``; otherwise ``ts_value`` is
formatted and prepended. If no timestamp is available, the cleaned content is
returned unchanged.
"""
clean_content, embedded_epoch = strip_leading_message_timestamps(content, tz=tz)
effective_ts = embedded_epoch if embedded_epoch is not None else ts_value
prefix = format_message_timestamp(effective_ts, tz=tz)
if not prefix:
return clean_content
if clean_content:
return f"{prefix} {clean_content}"
return prefix
def _parse_timestamp_prefix(text: str, tz=None) -> Optional[float]:
match = _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
if not match:
return None
return _parse_timestamp_match(match, tz=tz)
def _parse_timestamp_match(match: re.Match, tz=None) -> Optional[float]:
if "iso" in match.groupdict() and match.group("iso"):
iso_text = match.group("iso")
try:
dt = datetime.fromisoformat(iso_text)
except ValueError:
try:
dt = datetime.strptime(iso_text, "%Y-%m-%dT%H:%M:%S%z")
except ValueError:
return None
if dt.tzinfo is None:
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())
date_part = match.group("date")
time_part = match.group("time")
try:
dt = datetime.strptime(f"{date_part} {time_part}", "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())

View File

@ -77,6 +77,13 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)
return metadata return metadata
def _mark_notify_metadata(metadata: dict | None) -> dict:
"""Clone metadata and mark a user-visible reply as notify-worthy."""
notify_metadata = dict(metadata) if metadata else {}
notify_metadata["notify"] = True
return notify_metadata
def _reply_anchor_for_event(event) -> str | None: def _reply_anchor_for_event(event) -> str | None:
"""Return reply_to id for platforms that need reply semantics. """Return reply_to id for platforms that need reply semantics.
@ -3889,7 +3896,7 @@ class BasePlatformAdapter(ABC):
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
content=_text, content=_text,
reply_to=_reply_anchor_for_event(event), reply_to=_reply_anchor_for_event(event),
metadata=thread_meta, metadata=_mark_notify_metadata(thread_meta),
) )
if _eph_ttl > 0 and _r.success and _r.message_id: if _eph_ttl > 0 and _r.success and _r.message_id:
self._schedule_ephemeral_delete( self._schedule_ephemeral_delete(
@ -3995,7 +4002,7 @@ class BasePlatformAdapter(ABC):
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
content=_text, content=_text,
reply_to=_reply_anchor_for_event(event), reply_to=_reply_anchor_for_event(event),
metadata=_thread_meta, metadata=_mark_notify_metadata(_thread_meta),
) )
if _eph_ttl > 0 and _r.success and _r.message_id: if _eph_ttl > 0 and _r.success and _r.message_id:
self._schedule_ephemeral_delete( self._schedule_ephemeral_delete(
@ -4045,7 +4052,7 @@ class BasePlatformAdapter(ABC):
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
content=_text, content=_text,
reply_to=_reply_anchor_for_event(event), reply_to=_reply_anchor_for_event(event),
metadata=_thread_meta, metadata=_mark_notify_metadata(_thread_meta),
) )
if _eph_ttl > 0 and _r.success and _r.message_id: if _eph_ttl > 0 and _r.success and _r.message_id:
self._schedule_ephemeral_delete( self._schedule_ephemeral_delete(
@ -4268,6 +4275,12 @@ class BasePlatformAdapter(ABC):
) )
text_content = _recovered text_content = _recovered
# Final user-visible content (text, TTS, media, files) gets
# the existing notify=True marker. Clone once so typing/status
# metadata stays unmarked and progress bubbles remain
# thread-strict.
_final_thread_metadata = _mark_notify_metadata(_thread_metadata)
# Auto-TTS: if voice message, generate audio FIRST (before sending text) # Auto-TTS: if voice message, generate audio FIRST (before sending text)
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has # Gated via ``_should_auto_tts_for_chat``: fires when the chat has
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is # an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
@ -4307,7 +4320,7 @@ class BasePlatformAdapter(ABC):
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
audio_path=_tts_path, audio_path=_tts_path,
caption=telegram_tts_caption, caption=telegram_tts_caption,
metadata=_thread_metadata, metadata=_final_thread_metadata,
) )
_tts_caption_delivered = bool( _tts_caption_delivered = bool(
telegram_tts_caption and getattr(tts_result, "success", False) telegram_tts_caption and getattr(tts_result, "success", False)
@ -4322,23 +4335,11 @@ class BasePlatformAdapter(ABC):
if text_content and not _tts_caption_delivered: if text_content and not _tts_caption_delivered:
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id) logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
_reply_anchor = _reply_anchor_for_event(event) _reply_anchor = _reply_anchor_for_event(event)
# Mark final response messages for notification delivery.
# Platform adapters that support per-message notification
# control (e.g. Telegram's disable_notification) use this
# flag to override silent-mode and ensure the final
# response triggers a push notification.
# Clone to avoid mutating the metadata shared with the
# typing-indicator task (which must remain unmarked).
if _thread_metadata is not None:
_thread_metadata = dict(_thread_metadata)
_thread_metadata["notify"] = True
else:
_thread_metadata = {"notify": True}
result = await self._send_with_retry( result = await self._send_with_retry(
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
content=text_content, content=text_content,
reply_to=_reply_anchor, reply_to=_reply_anchor,
metadata=_thread_metadata, metadata=_final_thread_metadata,
) )
_record_delivery(result) _record_delivery(result)
@ -4367,7 +4368,7 @@ class BasePlatformAdapter(ABC):
await self.send_multiple_images( await self.send_multiple_images(
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
images=images, images=images,
metadata=_thread_metadata, metadata=_final_thread_metadata,
human_delay=human_delay, human_delay=human_delay,
) )
except Exception as batch_err: except Exception as batch_err:
@ -4409,7 +4410,7 @@ class BasePlatformAdapter(ABC):
await self.send_multiple_images( await self.send_multiple_images(
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
images=_batch, images=_batch,
metadata=_thread_metadata, metadata=_final_thread_metadata,
human_delay=human_delay, human_delay=human_delay,
) )
except Exception as batch_err: except Exception as batch_err:
@ -4424,19 +4425,19 @@ class BasePlatformAdapter(ABC):
media_result = await self.send_voice( media_result = await self.send_voice(
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
audio_path=media_path, audio_path=media_path,
metadata=_thread_metadata, metadata=_final_thread_metadata,
) )
elif ext in _VIDEO_EXTS: elif ext in _VIDEO_EXTS:
media_result = await self.send_video( media_result = await self.send_video(
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
video_path=media_path, video_path=media_path,
metadata=_thread_metadata, metadata=_final_thread_metadata,
) )
else: else:
media_result = await self.send_document( media_result = await self.send_document(
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
file_path=media_path, file_path=media_path,
metadata=_thread_metadata, metadata=_final_thread_metadata,
) )
if not media_result.success: if not media_result.success:
@ -4454,13 +4455,13 @@ class BasePlatformAdapter(ABC):
await self.send_video( await self.send_video(
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
video_path=file_path, video_path=file_path,
metadata=_thread_metadata, metadata=_final_thread_metadata,
) )
else: else:
await self.send_document( await self.send_document(
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
file_path=file_path, file_path=file_path,
metadata=_thread_metadata, metadata=_final_thread_metadata,
) )
except Exception as file_err: except Exception as file_err:
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err) logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)

View File

@ -678,8 +678,13 @@ class EmailAdapter(BasePlatformAdapter):
image_url: str, image_url: str,
caption: Optional[str] = None, caption: Optional[str] = None,
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult: ) -> SendResult:
"""Send an image URL as part of an email body.""" """Send an image URL as part of an email body.
``metadata`` is accepted to honor the base-class contract; the
email body send doesn't use it.
"""
text = caption or "" text = caption or ""
text += f"\n\nImage: {image_url}" text += f"\n\nImage: {image_url}"
return await self.send(chat_id, text.strip(), reply_to) return await self.send(chat_id, text.strip(), reply_to)

View File

@ -1241,6 +1241,14 @@ class TelegramAdapter(BasePlatformAdapter):
message_id = (msg.get("result") or {}).get("message_id") message_id = (msg.get("result") or {}).get("message_id")
else: else:
message_id = getattr(msg, "message_id", None) message_id = getattr(msg, "message_id", None)
if message_id is not None:
# Telegram won't echo rich content in reply_to_message, so remember
# what we sent — replies to this message resolve via this index.
try:
from gateway import rich_sent_store
rich_sent_store.record(str(chat_id), str(message_id), content)
except Exception:
pass
return SendResult( return SendResult(
success=True, success=True,
message_id=str(message_id) if message_id is not None else None, message_id=str(message_id) if message_id is not None else None,
@ -6700,6 +6708,19 @@ class TelegramAdapter(BasePlatformAdapter):
or message.reply_to_message.caption or message.reply_to_message.caption
or None or None
) )
if not reply_to_text:
# Rich messages (sendRichMessage — the launchd briefings and
# the gateway's own rich finals) are NOT echoed with their
# content in reply_to_message; Telegram sends no text,
# caption, or api_kwargs for them. Recover the text we sent
# from our local send-time index, keyed by message id.
try:
from gateway import rich_sent_store
reply_to_text = rich_sent_store.lookup(
str(chat.id), reply_to_id
)
except Exception:
reply_to_text = None
# Per-channel/topic ephemeral prompt # Per-channel/topic ephemeral prompt
from gateway.platforms.base import resolve_channel_prompt from gateway.platforms.base import resolve_channel_prompt

View File

@ -846,13 +846,20 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
image_url: str, image_url: str,
caption: Optional[str] = None, caption: Optional[str] = None,
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult: ) -> SendResult:
"""Download image URL to cache, send natively via bridge.""" """Download image URL to cache, send natively via bridge.
``metadata`` is accepted to honor the base-class contract the
batch sender ``send_multiple_images`` passes it through to every
send path. The bridge media call doesn't use it, matching the
sibling overrides (send_video / send_voice / send_document).
"""
try: try:
local_path = await cache_image_from_url(image_url) local_path = await cache_image_from_url(image_url)
return await self._send_media_to_bridge(chat_id, local_path, "image", caption) return await self._send_media_to_bridge(chat_id, local_path, "image", caption)
except Exception: except Exception:
return await super().send_image(chat_id, image_url, caption, reply_to) return await super().send_image(chat_id, image_url, caption, reply_to, metadata)
async def send_image_file( async def send_image_file(
self, self,
@ -1136,6 +1143,15 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
body = data.get("body", "") body = data.get("body", "")
if data.get("isGroup"): if data.get("isGroup"):
body = self._clean_bot_mention_text(body, data) body = self._clean_bot_mention_text(body, data)
# If this is a reply, include the quoted message text so the agent
# knows exactly what the user is responding to (fixes "approve" context issue)
quoted_text = str(data.get("quotedText") or "").strip()
if quoted_text and data.get("hasQuotedMessage"):
# Truncate long quoted text to keep prompts reasonable
if len(quoted_text) > 300:
quoted_text = quoted_text[:297] + "..."
body = f"[Replying to: \"{quoted_text}\"]\n{body}"
MAX_TEXT_INJECT_BYTES = 100 * 1024 MAX_TEXT_INJECT_BYTES = 100 * 1024
if msg_type == MessageType.DOCUMENT and cached_urls: if msg_type == MessageType.DOCUMENT and cached_urls:
for doc_path in cached_urls: for doc_path in cached_urls:

398
gateway/relay/__init__.py Normal file
View File

@ -0,0 +1,398 @@
"""Relay/connector support package for the Hermes gateway.
EXPERIMENTAL. This package implements the gateway side of the "Gateway Gateway"
relay design: a generic ``RelayAdapter`` plus the wire-serializable
``CapabilityDescriptor`` the connector hands it at handshake time, and the
production ``WebSocketRelayTransport`` that dials the connector. The public API
(module names, descriptor field set, transport protocol) MAY CHANGE without a
deprecation cycle until at least two real Class-1 platforms (Discord + Telegram)
have shaken out the schema.
See ``docs/relay-connector-contract.md`` for the formal cross-repo interface.
Activation is driven by configuration, not a separate feature flag: the relay
platform is registered when a connector relay URL is configured
(``GATEWAY_RELAY_URL`` env or ``gateway.relay_url`` in config.yaml). Deployments
that don't set it are unaffected — exactly the same shape as ``gateway.proxy_url``.
"""
from __future__ import annotations
import os
from typing import Optional
def relay_url() -> Optional[str]:
"""The connector relay endpoint URL, or None when relay is not configured.
Checks ``GATEWAY_RELAY_URL`` (convenient for Docker) first, then
``gateway.relay_url`` in config.yaml. A non-empty value activates the relay
platform; absence means a normal direct/single-tenant gateway.
"""
url = os.environ.get("GATEWAY_RELAY_URL", "").strip()
if url:
return url.rstrip("/")
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = _load_gateway_config()
url = (cfg.get("gateway") or {}).get("relay_url", "").strip()
if url:
return url.rstrip("/")
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
pass
return None
def relay_platform_identity() -> tuple[str, str]:
"""Platform + bot id this gateway fronts over the relay (for the handshake hello).
Defaults to ``("relay", "")``; overridable via ``GATEWAY_RELAY_PLATFORM`` /
``GATEWAY_RELAY_BOT_ID`` so one connector can front several platforms.
"""
platform = os.environ.get("GATEWAY_RELAY_PLATFORM", "relay").strip() or "relay"
bot_id = os.environ.get("GATEWAY_RELAY_BOT_ID", "").strip()
return platform, bot_id
def relay_connection_auth() -> tuple[Optional[str], Optional[str]]:
"""The (gateway_id, upgrade_secret) this gateway authenticates the WS upgrade with.
Both come from enrollment (``hermes gateway enroll`` writes them to
``~/.hermes/.env``): ``GATEWAY_RELAY_ID`` identifies the enrolled instance,
``GATEWAY_RELAY_SECRET`` is the per-gateway signing secret. Either absent ->
``(None, None)`` and the transport dials unauthenticated (dev/test, or a
connector that doesn't enforce auth). Checks env first (Docker), then
``gateway.relay_id`` / ``gateway.relay_secret`` in config.yaml.
"""
gateway_id = os.environ.get("GATEWAY_RELAY_ID", "").strip()
secret = os.environ.get("GATEWAY_RELAY_SECRET", "").strip()
if not (gateway_id and secret):
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
gateway_id = gateway_id or str(cfg.get("relay_id", "") or "").strip()
secret = secret or str(cfg.get("relay_secret", "") or "").strip()
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
pass
return (gateway_id or None, secret or None)
def relay_inbound_config() -> tuple[Optional[str], Optional[str], int]:
"""Resolve (delivery_key, bind_host, bind_port) for the inbound receiver.
The connector delivers normalized inbound events to this gateway over a
SIGNED HTTP POST (not the outbound WS), verified with the per-tenant delivery
key issued at enrollment (``GATEWAY_RELAY_DELIVERY_KEY``). The receiver only
starts when a delivery key AND a bind port are configured a gateway with no
public inbound URL (e.g. a purely outbound dev run) simply doesn't run it.
Env first (Docker), then ``gateway.relay_delivery_key`` /
``gateway.relay_inbound_host`` / ``gateway.relay_inbound_port`` in config.yaml.
Port 0 (default/unset) -> receiver disabled.
"""
key = os.environ.get("GATEWAY_RELAY_DELIVERY_KEY", "").strip()
host = os.environ.get("GATEWAY_RELAY_INBOUND_HOST", "").strip()
port_raw = os.environ.get("GATEWAY_RELAY_INBOUND_PORT", "").strip()
if not (key and port_raw):
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
key = key or str(cfg.get("relay_delivery_key", "") or "").strip()
host = host or str(cfg.get("relay_inbound_host", "") or "").strip()
if not port_raw:
port_raw = str(cfg.get("relay_inbound_port", "") or "").strip()
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
pass
try:
port = int(port_raw) if port_raw else 0
except ValueError:
port = 0
return (key or None, host or "0.0.0.0", port)
def relay_endpoint() -> Optional[str]:
"""The gateway's own PUBLIC inbound URL, asserted to the connector at provision.
The connector delivers signed inbound POSTs to this URL and stores it on the
tenant's route rows. It is gateway-asserted (the connector scopes it to the
verified tenant, so a dishonest gateway can only misdirect its OWN inbound).
The *source* of the value differs by deployment but the code path is uniform:
a self-hosted operator sets ``GATEWAY_RELAY_ENDPOINT`` (mirrors how they set
``HERMES_DASHBOARD_PUBLIC_URL``); a hosted/NAS container has the same var
stamped in (NAS knows the public URL only in that case). Absent -> the
gateway provisions outbound-only (no inbound routes written).
Env first (Docker), then ``gateway.relay_endpoint`` in config.yaml.
"""
url = os.environ.get("GATEWAY_RELAY_ENDPOINT", "").strip()
if not url:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
url = str(cfg.get("relay_endpoint", "") or "").strip()
except Exception: # noqa: BLE001 - config absence/parse must never crash boot
url = ""
return url.rstrip("/") or None
def relay_route_keys() -> list[str]:
"""Discriminators (guild_ids / chat_ids / paths) this gateway's tenant owns.
Gateway-provided config, paired with ``relay_endpoint()``: the connector
writes one route row per (routeKey -> tenant, endpoint), so route keys only
take effect alongside an endpoint. Empty -> outbound-only provisioning (the
connector accepts an empty set and writes no route rows).
``GATEWAY_RELAY_ROUTE_KEYS`` is comma-separated; config.yaml
``gateway.relay_route_keys`` may be a list or a comma string.
"""
raw = os.environ.get("GATEWAY_RELAY_ROUTE_KEYS", "").strip()
if not raw:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
val = cfg.get("relay_route_keys", "")
if isinstance(val, (list, tuple)):
return [str(k).strip() for k in val if str(k).strip()]
raw = str(val or "").strip()
except Exception: # noqa: BLE001
raw = ""
return [k.strip() for k in raw.split(",") if k.strip()]
def _provision_url(relay_dial_url: str) -> str:
"""Map the ``ws(s)://…/relay`` dial URL to the ``http(s)://…/relay/provision`` POST URL."""
raw = relay_dial_url.rstrip("/")
if raw.startswith("ws://"):
raw = "http://" + raw[len("ws://"):]
elif raw.startswith("wss://"):
raw = "https://" + raw[len("wss://"):]
if raw.endswith("/relay"):
raw = raw[: -len("/relay")]
return f"{raw}/relay/provision"
def _post_provision(
*,
provision_url: str,
access_token: str,
gateway_id: str,
platform: str,
bot_id: str,
gateway_endpoint: Optional[str],
route_keys: list[str],
timeout: float = 15.0,
) -> dict:
"""POST to the connector's ``/relay/provision`` and return the JSON body.
The connector validates ``access_token`` against NAS, derives the
authoritative tenant, mints the per-gateway secret + per-tenant delivery key,
upserts the tenant's route rows, and returns
``{secret, deliveryKey, tenant, gatewayId, routeKeys}``. Raises RuntimeError
with a user-facing message on any non-2xx / transport failure.
"""
import json
import urllib.error
import urllib.request
body: dict = {
"gatewayId": gateway_id,
"platform": platform,
"botId": bot_id,
"gatewayEndpoint": gateway_endpoint or "",
"routeKeys": route_keys,
}
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
provision_url,
data=data,
method="POST",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
detail = ""
try:
detail = (json.loads(exc.read().decode()) or {}).get("error", "")
except Exception:
pass
raise RuntimeError(
f"connector returned HTTP {exc.code}" + (f": {detail}" if detail else "")
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"could not reach connector: {exc.reason}") from exc
if not isinstance(payload, dict) or not payload.get("secret"):
raise RuntimeError("connector returned an unexpected response (no secret)")
return payload
def self_provision_if_managed() -> bool:
"""Managed-boot self-provision: mint relay creds in-process, no human, no disk.
Fires only on a MANAGED boot (``is_managed()``) with relay configured
(``relay_url()`` set) and NO per-gateway secret already present. In that case
the runtime resolves the agent's own Nous access token (the same
``resolve_nous_access_token()`` the enroll CLI / dashboard register use),
POSTs ``/relay/provision`` asserting its own endpoint + route keys, and sets
``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` / ``GATEWAY_RELAY_DELIVERY_KEY``
into ``os.environ`` so the subsequent ``register_relay_adapter()`` picks them
up. The creds live ONLY in process memory never written to ``~/.hermes/.env``
(``save_env_value`` refuses under managed anyway, and keeping the secret off
any volume is the stronger posture).
Stateless: process-env creds don't survive a restart, so a managed container
re-provisions every boot; the connector's rotation window covers a still-
connected prior instance. An explicitly-pinned ``GATEWAY_RELAY_SECRET`` (env
or config) is RESPECTED self-provision skips so an operator pin isn't
stomped.
Returns True if it provisioned, False otherwise. NEVER raises: a provision
failure logs and returns False so the gateway still boots (and
``register_relay_adapter`` will simply dial unauthenticated / be rejected,
rather than the whole gateway crashing).
"""
import logging
logger = logging.getLogger("gateway.relay")
try:
from hermes_cli.config import is_managed
except Exception: # noqa: BLE001
return False
if not is_managed():
return False
dial_url = relay_url()
if not dial_url:
return False
# Respect an already-present (pinned/stamped) secret — don't stomp it.
existing_id, existing_secret = relay_connection_auth()
if existing_id and existing_secret:
logger.info("relay self-provision skipped: GATEWAY_RELAY_SECRET already set")
return False
try:
from hermes_cli.auth import resolve_nous_access_token
access_token = resolve_nous_access_token()
except Exception as exc: # noqa: BLE001 - boot must survive a token failure
logger.warning("relay self-provision skipped: could not resolve Nous token (%s)", exc)
return False
platform, bot_id = relay_platform_identity()
# gatewayId default mirrors the enroll CLI's hostname-based slug.
import socket
try:
host = socket.gethostname().strip()
except Exception: # noqa: BLE001
host = ""
gateway_id = os.environ.get("GATEWAY_RELAY_ID", "").strip() or f"gw-{host or 'hermes'}"
endpoint = relay_endpoint()
route_keys = relay_route_keys()
try:
result = _post_provision(
provision_url=_provision_url(dial_url),
access_token=access_token,
gateway_id=gateway_id,
platform=platform,
bot_id=bot_id,
gateway_endpoint=endpoint,
route_keys=route_keys,
)
except RuntimeError as exc:
logger.warning("relay self-provision failed (%s); gateway will boot without relay auth", exc)
return False
# Set creds in-process so register_relay_adapter() + relay_inbound_config()
# read them from os.environ. Never logged.
os.environ["GATEWAY_RELAY_ID"] = str(result.get("gatewayId") or gateway_id)
os.environ["GATEWAY_RELAY_SECRET"] = str(result.get("secret") or "")
os.environ["GATEWAY_RELAY_DELIVERY_KEY"] = str(result.get("deliveryKey") or "")
tenant = str(result.get("tenant") or "")
logger.info(
"relay self-provisioned (gateway_id=%s tenant=%s routes=%d inbound=%s)",
os.environ["GATEWAY_RELAY_ID"],
tenant or "?",
len(route_keys),
"yes" if endpoint else "outbound-only",
)
return True
def register_relay_adapter(force: bool = False, url: Optional[str] = None) -> bool:
"""Register the generic ``relay`` platform via the platform registry.
Registers when a relay URL is configured (or ``force=True`` for tests, which
builds a transport-less adapter the unit-test posture). Returns True if
registration happened. Additive: uses the same registry path as plugin
adapters, so no core dispatch changes are needed.
When a URL is present the factory builds a live ``WebSocketRelayTransport``;
the ``RelayAdapter`` negotiates the real ``CapabilityDescriptor`` at
``connect()`` time via ``transport.handshake()``.
"""
resolved_url = url if url is not None else relay_url()
if not (force or resolved_url):
return False
from gateway.platform_registry import PlatformEntry, platform_registry
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
platform, bot_id = relay_platform_identity()
def _factory(config):
# Placeholder descriptor; replaced by the negotiated one at connect time
# when a transport is present. With no URL (force/test) the adapter is
# transport-less and keeps the placeholder.
placeholder = CapabilityDescriptor(
contract_version=CONTRACT_VERSION,
platform=platform,
label="Relay",
max_message_length=4096,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=False,
markdown_dialect="plain",
len_unit="chars",
)
transport = None
if resolved_url:
from gateway.relay.ws_transport import WebSocketRelayTransport
gateway_id, upgrade_secret = relay_connection_auth()
transport = WebSocketRelayTransport(
resolved_url,
platform,
bot_id,
gateway_id=gateway_id,
upgrade_secret=upgrade_secret,
)
return RelayAdapter(config, placeholder, transport=transport)
platform_registry.register(
PlatformEntry(
name="relay",
label="Relay",
adapter_factory=_factory,
check_fn=lambda: True,
source="builtin",
emoji="\U0001f50c",
)
)
return True

220
gateway/relay/adapter.py Normal file
View File

@ -0,0 +1,220 @@
"""RelayAdapter — one generic gateway adapter fronted by the connector. EXPERIMENTAL.
A single ``BasePlatformAdapter`` subclass that, at handshake, receives a
``CapabilityDescriptor`` from the connector telling it which platform it is
fronting and which capabilities to advertise to the ``GatewayStreamConsumer``.
It implements the four abstract methods (``connect`` / ``disconnect`` / ``send``
/ ``get_chat_info``) plus the capability surface (``MAX_MESSAGE_LENGTH``,
``message_len_fn``, ``supports_draft_streaming``) by delegating wire I/O to an
injected transport and reading capabilities off the descriptor.
There is NO per-platform gateway code: the connector is the only side that knows
"this chat_id maps to a Discord channel, send it via the Discord websocket."
The gateway sees an ordinary ``MessageEvent`` in and calls ``adapter.send`` out.
EXPERIMENTAL: the transport protocol and descriptor schema may change without a
deprecation cycle until >=2 Class-1 platforms validate them.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Dict, Optional
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, SendResult
from gateway.relay.descriptor import CapabilityDescriptor
from gateway.relay.transport import RelayTransport
logger = logging.getLogger(__name__)
def _utf16_len(text: str) -> int:
"""Count UTF-16 code units (Telegram's length unit)."""
return len(text.encode("utf-16-le")) // 2
# Table-driven length-unit selection from the descriptor's ``len_unit``.
_LEN_FNS: Dict[str, Callable[[str], int]] = {
"chars": len,
"utf16": _utf16_len,
}
class RelayAdapter(BasePlatformAdapter):
"""Generic relay adapter advertising a connector-negotiated capability profile."""
def __init__(
self,
config: PlatformConfig,
descriptor: CapabilityDescriptor,
transport: Optional[RelayTransport] = None,
) -> None:
# The relay adapter fronts many platforms but presents as a single
# logical platform to the runner; Platform.RELAY identifies it.
super().__init__(config, Platform.RELAY)
self.descriptor = descriptor
self._transport = transport
# Capability surface read by stream_consumer (getattr(..., 4096)).
self.MAX_MESSAGE_LENGTH = descriptor.max_message_length
self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain")
# Inbound delivery receiver (signed connector→gateway HTTP POSTs). Built
# lazily in connect() when a delivery key + bind port are configured; a
# purely-outbound dev gateway runs without it. See inbound_receiver.py.
self._inbound_runner: Any = None
# ── capability surface (from descriptor) ─────────────────────────────
@property
def message_len_fn(self) -> Callable[[str], int]:
return _LEN_FNS.get(self.descriptor.len_unit, len)
def supports_draft_streaming(
self,
chat_type: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
return self.descriptor.supports_draft_streaming
# ── abstract methods (delegated to the transport) ────────────────────
async def connect(self) -> bool:
if self._transport is None:
raise RuntimeError("RelayAdapter has no transport configured")
self._transport.set_inbound_handler(self._on_inbound)
ok = await self._transport.connect()
if not ok:
return False
# Negotiate the real capability descriptor from the connector and adopt
# it — the placeholder passed at construction is replaced by what the
# connector advertises for the platform this gateway actually fronts.
try:
descriptor = await self._transport.handshake()
except Exception as exc: # noqa: BLE001 - a failed handshake = a failed connect
logger.warning("relay handshake failed: %s", exc)
return False
self._apply_descriptor(descriptor)
# Start the signed inbound-delivery receiver if configured (the connector
# POSTs normalized events to it over HTTP, verified with the tenant
# delivery key). Non-fatal: a receiver bind failure must not fail the
# outbound connection — the gateway can still send.
await self._maybe_start_inbound_receiver()
return True
async def _maybe_start_inbound_receiver(self) -> None:
"""Start the inbound HTTP receiver when a delivery key + port are set."""
from gateway.relay import relay_inbound_config
delivery_key, host, port = relay_inbound_config()
if not (delivery_key and port):
return # no inbound URL configured -> outbound-only gateway
try:
from aiohttp import web
from gateway.relay.inbound_receiver import InboundDeliveryReceiver
receiver = InboundDeliveryReceiver(
delivery_key_verify_list=lambda: [delivery_key],
on_message=self._on_inbound,
on_interrupt=self.on_interrupt,
)
runner = web.AppRunner(receiver.build_app(), access_log=None)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
self._inbound_runner = runner
logger.info("relay inbound receiver listening on http://%s:%s", host, port)
except Exception as exc: # noqa: BLE001 - inbound bind failure must not kill outbound
logger.warning("relay inbound receiver failed to start: %s", exc)
self._inbound_runner = None
def _apply_descriptor(self, descriptor: CapabilityDescriptor) -> None:
"""Adopt a (re)negotiated descriptor into the live capability surface."""
self.descriptor = descriptor
self.MAX_MESSAGE_LENGTH = descriptor.max_message_length
self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain")
async def _on_inbound(self, event) -> None:
"""Bridge a connector-delivered MessageEvent into the normal adapter path."""
await self.handle_message(event)
async def on_interrupt(self, session_key: str, chat_id: str) -> None:
"""Bridge a connector-delivered /stop into the adapter's interrupt path.
The connector forwards a mid-turn interrupt down the socket owned by
the gateway instance running ``session_key``; this routes it to the
existing per-session interrupt mechanism (sets the
``_active_sessions[session_key]`` Event and clears typing), cancelling
the right turn without touching sibling sessions.
"""
await self.interrupt_session_activity(session_key, chat_id)
async def disconnect(self) -> None:
if self._inbound_runner is not None:
try:
await self._inbound_runner.cleanup()
except Exception: # noqa: BLE001 - best-effort teardown
pass
self._inbound_runner = None
if self._transport is not None:
await self._transport.disconnect()
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
if self._transport is None:
return SendResult(success=False, error="no transport")
result = await self._transport.send_outbound(
{
"op": "send",
"chat_id": chat_id,
"content": content,
"reply_to": reply_to,
"metadata": metadata or {},
}
)
return SendResult(
success=bool(result.get("success")),
message_id=result.get("message_id"),
error=result.get("error"),
)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
# Proxied to the connector (it owns the platform connection / cache).
if self._transport is None:
return {"name": chat_id, "type": "dm"}
return await self._transport.get_chat_info(chat_id)
async def send_follow_up(
self,
session_key: str,
kind: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send via a shared-identity capability bound to a session (A2 outbound).
The gateway never holds the credential: it names the session it is
already in plus the capability ``kind``, and the connector resolves the
real value from its vault and egresses (enforcing the tenant match). Used
e.g. to post a Discord interaction follow-up as the shared bot without
the token ever reaching the gateway. See RelayTransport.send_follow_up.
"""
if self._transport is None:
return SendResult(success=False, error="no transport")
result = await self._transport.send_follow_up(
{
"op": "follow_up",
"session_key": session_key,
"kind": kind,
"content": content,
"metadata": metadata or {},
}
)
return SendResult(
success=bool(result.get("success")),
message_id=result.get("message_id"),
error=result.get("error"),
)

Some files were not shown because too many files have changed in this diff Show More