Merge upstream/main into OpenViking setup-UX (salvage #32445)
Resolves conflicts from the OpenViking churn that merged after #32445 was opened (#48042/#47662 session-switch + write hardening, #47311/#47973): - plugins/memory/openviking/__init__.py: keep both __init__ field groups (the PR's _runtime_start_* alongside main's _prefetch_threads/_shutting_down). - tests/plugins/memory/test_openviking_provider.py: keep BOTH the PR's new setup-validation tests and main's session-switch/concurrency tests (disjoint additions to the same region). Two fixes layered while reconciling (contributor work otherwise preserved): - Restore the merged tenant-header contract (#22414/#21232). The PR had changed _VikingClient defaults to '' and made empty account/user OMIT the tenant headers; main's contract is that empty falls back to 'default' and the X-OpenViking-Account/User headers are ALWAYS sent (ROOT API keys need them). Reverted the constructor to 'account or os.environ.get(..., "default")' and updated the two PR tests that asserted the omit-when-empty behavior. - Close a secret-file TOCTOU in the setup writers. _write_env_vars and _write_ovcli_config wrote the api_key/root_api_key file and chmod 0600 AFTERWARD, leaving a world-readable window on newly-created files. Added _precreate_secret_file() to create with 0600 before any secret bytes land.
This commit is contained in:
commit
1153b42b24
248 changed files with 16037 additions and 1929 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,6 +5,7 @@
|
|||
*.pyc*
|
||||
__pycache__/
|
||||
.venv/
|
||||
.venv
|
||||
.vscode/
|
||||
.env
|
||||
.env.local
|
||||
|
|
|
|||
57
Dockerfile
57
Dockerfile
|
|
@ -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 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 PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Store Playwright browsers outside the volume mount so the build-time
|
||||
# install survives the /opt/data volume overlay at runtime.
|
||||
|
|
@ -186,36 +189,38 @@ RUN cd web && npm run build && \
|
|||
|
||||
# ---------- Source code ----------
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
COPY --chown=hermes:hermes . .
|
||||
COPY . .
|
||||
|
||||
# ---------- Permissions ----------
|
||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||
# The venv needs to be traversable too.
|
||||
# node_modules trees additionally need to be writable by the hermes user
|
||||
# so the runtime `npm install` triggered by _tui_need_npm_install() in
|
||||
# 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
|
||||
# not chowned here.
|
||||
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
|
||||
# gateway state artifacts beneath the package after services drop privileges,
|
||||
# especially when the hermes UID is remapped at boot (#27221).
|
||||
# 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.
|
||||
# Link hermes-agent itself (editable). Deps are already installed in the
|
||||
# cached layer above; `--no-deps` makes this a fast egg-link creation with no
|
||||
# resolution or downloads.
|
||||
RUN uv pip install --no-cache-dir --no-deps -e "."
|
||||
|
||||
# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
|
||||
# instances must not be able to self-edit the installed source or venv; user
|
||||
# data, skills, plugins, config, logs, and dashboard uploads live under
|
||||
# /opt/data instead. Root can still repair the image during build/boot, but
|
||||
# supervised Hermes processes drop to the non-root hermes user.
|
||||
USER root
|
||||
RUN chmod -R a+rX /opt/hermes && \
|
||||
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
|
||||
RUN mkdir -p /opt/hermes/bin && \
|
||||
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
|
||||
# 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
|
||||
# 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 ----------
|
||||
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
|
||||
# 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.
|
||||
ARG HERMES_GIT_SHA=
|
||||
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
|
||||
chmod u+w /opt/hermes && \
|
||||
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
|
||||
|
||||
# ---------- 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.)
|
||||
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
||||
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 <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
|
||||
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
|
||||
# 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
|
||||
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import threading
|
|||
import time
|
||||
import uuid
|
||||
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 agent.context_compressor import ContextCompressor
|
||||
|
|
@ -195,6 +195,7 @@ def init_agent(
|
|||
status_callback: callable = None,
|
||||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
|
|
@ -426,6 +427,7 @@ def init_agent(
|
|||
agent.status_callback = status_callback
|
||||
agent.notice_callback = notice_callback
|
||||
agent.notice_clear_callback = notice_clear_callback
|
||||
agent.event_callback = event_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).
|
||||
agent._persist_user_message_idx = 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
|
||||
# single tool loop does not repeatedly re-run auxiliary vision on the
|
||||
|
|
|
|||
|
|
@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str:
|
|||
|
||||
|
||||
_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:
|
||||
|
|
@ -2349,25 +2349,46 @@ def build_anthropic_kwargs(
|
|||
text = text.replace("Nous Research", "Anthropic")
|
||||
block["text"] = text
|
||||
|
||||
# 3. Prefix tool names with mcp_ (Claude Code convention)
|
||||
# Skip names that already begin with the marker — native MCP server
|
||||
# tools (from mcp_servers: in config.yaml) are registered under their
|
||||
# full mcp_<server>_<tool> name and would double-prefix otherwise,
|
||||
# breaking round-trip registry lookup in normalize_response. GH-25255.
|
||||
# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
|
||||
# single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth
|
||||
# billing classifier treats a single-underscore ``mcp_`` tool name as
|
||||
# a third-party-app fingerprint and rejects the request with HTTP 400
|
||||
# "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:
|
||||
for tool in anthropic_tools:
|
||||
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
|
||||
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
|
||||
if "name" in tool:
|
||||
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:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "tool_use" and "name" in block:
|
||||
if not block["name"].startswith(_MCP_TOOL_PREFIX):
|
||||
block["name"] = _MCP_TOOL_PREFIX + block["name"]
|
||||
block["name"] = _to_oauth_wire_name(block["name"])
|
||||
elif block.get("type") == "tool_result" and "tool_use_id" in block:
|
||||
pass # tool_result uses ID, not name
|
||||
|
||||
|
|
|
|||
|
|
@ -262,6 +262,26 @@ def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[L
|
|||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -802,7 +822,22 @@ def _preflight_codex_api_kwargs(
|
|||
for idx, tool in enumerate(tools):
|
||||
if not isinstance(tool, dict):
|
||||
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}.")
|
||||
|
||||
name = tool.get("name")
|
||||
|
|
@ -1086,6 +1121,33 @@ def _normalize_codex_response(
|
|||
saw_final_answer_phase = 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:
|
||||
item_type = getattr(item, "type", None)
|
||||
item_status = getattr(item, "status", None)
|
||||
|
|
@ -1094,7 +1156,10 @@ def _normalize_codex_response(
|
|||
else:
|
||||
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
|
||||
saw_streaming_or_item_incomplete = True
|
||||
|
||||
|
|
|
|||
|
|
@ -603,6 +603,20 @@ def compress_context(
|
|||
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
|
||||
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
|
||||
# can remain above threshold even after the next real API request fits.
|
||||
|
|
|
|||
|
|
@ -474,6 +474,7 @@ def run_conversation(
|
|||
task_id: str = None,
|
||||
stream_callback: Optional[callable] = None,
|
||||
persist_user_message: Optional[str] = None,
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run a complete conversation with tool calling until completion.
|
||||
|
|
@ -489,6 +490,8 @@ def run_conversation(
|
|||
persist_user_message: Optional clean user message to store in
|
||||
transcripts/history when user_message contains API-only
|
||||
synthetic prefixes.
|
||||
persist_user_timestamp: Optional platform event timestamp to store
|
||||
as metadata on that persisted user message.
|
||||
or queuing follow-up prefetch work.
|
||||
|
||||
Returns:
|
||||
|
|
@ -510,6 +513,7 @@ def run_conversation(
|
|||
task_id,
|
||||
stream_callback,
|
||||
persist_user_message,
|
||||
persist_user_timestamp,
|
||||
restore_or_build_system_prompt=_restore_or_build_system_prompt,
|
||||
install_safe_stdio=_install_safe_stdio,
|
||||
sanitize_surrogates=_sanitize_surrogates,
|
||||
|
|
@ -3752,8 +3756,30 @@ def run_conversation(
|
|||
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
|
||||
messages.append(assistant_msg)
|
||||
for tc in assistant_message.tool_calls:
|
||||
if tc.function.name not in agent.valid_tool_names:
|
||||
content = f"Tool '{tc.function.name}' does not exist. Available tools: {available}"
|
||||
_tc_name = tc.function.name
|
||||
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:
|
||||
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
|
||||
messages.append({
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
|
|||
DEFAULT_MIN_IDLE_HOURS = 2
|
||||
DEFAULT_STALE_AFTER_DAYS = 30
|
||||
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))
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1408,25 +1429,38 @@ def run_curator_review(
|
|||
on_summary: Optional[Callable[[str], None]] = None,
|
||||
synchronous: bool = False,
|
||||
dry_run: bool = False,
|
||||
consolidate: Optional[bool] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a single curator review pass.
|
||||
|
||||
Steps:
|
||||
1. Apply automatic state transitions (pure, no LLM).
|
||||
2. If there are agent-created skills, spawn a forked AIAgent that runs
|
||||
the LLM review prompt against the current candidate list.
|
||||
2. If consolidation is enabled AND there are agent-created skills, spawn
|
||||
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.
|
||||
4. Invoke *on_summary* with a user-visible description.
|
||||
|
||||
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.
|
||||
|
||||
*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
|
||||
and the LLM review pass is instructed to produce a report only — no
|
||||
skill_manage mutations, no terminal archive moves. The REPORT.md still
|
||||
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)
|
||||
if dry_run:
|
||||
# Count candidates without mutating state.
|
||||
|
|
@ -1489,6 +1523,53 @@ def run_curator_review(
|
|||
before_report = []
|
||||
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] = {}
|
||||
try:
|
||||
candidate_list = _render_candidate_list()
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import shutil
|
|||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
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 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.
|
||||
|
||||
Returns the snapshot directory path, or ``None`` if the snapshot was
|
||||
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
|
||||
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():
|
||||
logger.debug("Curator backup disabled by config; skipping snapshot")
|
||||
|
|
@ -276,15 +280,19 @@ def snapshot_skills(reason: str = "manual") -> Optional[Path]:
|
|||
pass
|
||||
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)
|
||||
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
|
||||
ids. Staging dirs (``.rollback-staging-*``) are implementation detail
|
||||
and pruned independently on every call."""
|
||||
ids. Snapshot ids in *protect* are never deleted even when they fall
|
||||
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()
|
||||
if not backups.exists():
|
||||
return []
|
||||
|
|
@ -305,6 +313,8 @@ def _prune_old(keep: int) -> List[str]:
|
|||
entries.sort(key=lambda t: t[0], reverse=True)
|
||||
deleted: List[str] = []
|
||||
for _, path in entries[keep:]:
|
||||
if path.name in protect:
|
||||
continue
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
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
|
||||
# the user with no skills.
|
||||
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:
|
||||
return (False, f"pre-rollback safety snapshot failed: {e}", None)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from agent.skill_commands import extract_user_instruction_from_skill_message
|
||||
from tools.registry import tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -430,16 +431,37 @@ class MemoryManager:
|
|||
|
||||
# -- 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:
|
||||
"""Collect prefetch context from all providers.
|
||||
|
||||
Returns merged context text labeled by provider. Empty providers
|
||||
are skipped. Failures in one provider don't block others.
|
||||
"""
|
||||
clean_query = self._strip_skill_scaffolding(query)
|
||||
if not clean_query:
|
||||
return ""
|
||||
parts = []
|
||||
for provider in self._providers:
|
||||
try:
|
||||
result = provider.prefetch(query, session_id=session_id)
|
||||
result = provider.prefetch(clean_query, session_id=session_id)
|
||||
if result and result.strip():
|
||||
parts.append(result)
|
||||
except Exception as e:
|
||||
|
|
@ -460,10 +482,14 @@ class MemoryManager:
|
|||
if not providers:
|
||||
return
|
||||
|
||||
clean_query = self._strip_skill_scaffolding(query)
|
||||
if not clean_query:
|
||||
return
|
||||
|
||||
def _run() -> None:
|
||||
for provider in providers:
|
||||
try:
|
||||
provider.queue_prefetch(query, session_id=session_id)
|
||||
provider.queue_prefetch(clean_query, session_id=session_id)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
|
||||
|
|
@ -515,6 +541,11 @@ class MemoryManager:
|
|||
if not providers:
|
||||
return
|
||||
|
||||
clean_user_content = self._strip_skill_scaffolding(user_content)
|
||||
if not clean_user_content:
|
||||
return
|
||||
user_content = clean_user_content
|
||||
|
||||
def _run() -> None:
|
||||
for provider in providers:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -275,6 +275,11 @@ DEFAULT_CONTEXT_LENGTHS = {
|
|||
# via a custom provider. Values sourced from models.dev (2026-04).
|
||||
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
|
||||
# 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-code-fast": 256000, # grok-code-fast-1
|
||||
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import threading
|
||||
import contextvars
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -957,6 +958,80 @@ CONTEXT_FILE_MAX_CHARS = 20_000
|
|||
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
|
||||
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
|
||||
|
|
@ -1463,19 +1538,47 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
|
|||
# Context files (SOUL.md, AGENTS.md, .cursorrules)
|
||||
# =========================================================================
|
||||
|
||||
def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str:
|
||||
"""Head/tail truncation with a marker in the middle."""
|
||||
def _truncate_content(
|
||||
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:
|
||||
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)
|
||||
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
|
||||
head = content[:head_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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Used as the agent identity (slot #1 in the system prompt). When this
|
||||
|
|
@ -1496,14 +1599,17 @@ def load_soul_md() -> Optional[str]:
|
|||
if not content:
|
||||
return None
|
||||
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
|
||||
except Exception as e:
|
||||
logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
|
||||
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_path = _find_hermes_md(cwd_path)
|
||||
if not hermes_md_path:
|
||||
|
|
@ -1520,13 +1626,16 @@ def _load_hermes_md(cwd_path: Path) -> str:
|
|||
pass
|
||||
content = _scan_context_content(content, rel)
|
||||
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:
|
||||
logger.debug("Could not read %s: %s", hermes_md_path, e)
|
||||
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)."""
|
||||
for name in ["AGENTS.md", "agents.md"]:
|
||||
candidate = cwd_path / name
|
||||
|
|
@ -1536,13 +1645,16 @@ def _load_agents_md(cwd_path: Path) -> str:
|
|||
if content:
|
||||
content = _scan_context_content(content, name)
|
||||
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:
|
||||
logger.debug("Could not read %s: %s", candidate, e)
|
||||
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."""
|
||||
for name in ["CLAUDE.md", "claude.md"]:
|
||||
candidate = cwd_path / name
|
||||
|
|
@ -1552,13 +1664,16 @@ def _load_claude_md(cwd_path: Path) -> str:
|
|||
if content:
|
||||
content = _scan_context_content(content, name)
|
||||
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:
|
||||
logger.debug("Could not read %s: %s", candidate, e)
|
||||
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_content = ""
|
||||
cursorrules_file = cwd_path / ".cursorrules"
|
||||
|
|
@ -1585,10 +1700,17 @@ def _load_cursorrules(cwd_path: Path) -> str:
|
|||
|
||||
if not cursorrules_content:
|
||||
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.
|
||||
|
||||
Priority (first found wins — only ONE project context type is loaded):
|
||||
|
|
@ -1598,7 +1720,11 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals
|
|||
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
|
||||
|
||||
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
|
||||
loaded via ``load_soul_md()`` for the identity slot).
|
||||
|
|
@ -1611,17 +1737,17 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals
|
|||
|
||||
# Priority-based project context: first match wins
|
||||
project_context = (
|
||||
_load_hermes_md(cwd_path)
|
||||
or _load_agents_md(cwd_path)
|
||||
or _load_claude_md(cwd_path)
|
||||
or _load_cursorrules(cwd_path)
|
||||
_load_hermes_md(cwd_path, context_length)
|
||||
or _load_agents_md(cwd_path, context_length)
|
||||
or _load_claude_md(cwd_path, context_length)
|
||||
or _load_cursorrules(cwd_path, context_length)
|
||||
)
|
||||
if project_context:
|
||||
sections.append(project_context)
|
||||
|
||||
# SOUL.md from HERMES_HOME only — skip when already loaded as identity
|
||||
if not skip_soul:
|
||||
soul_content = load_soul_md()
|
||||
soul_content = load_soul_md(context_length)
|
||||
if soul_content:
|
||||
sections.append(soul_content)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,91 @@ _skill_commands_platform: Optional[str] = None
|
|||
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
|
||||
_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]:
|
||||
"""Return the current platform scope used for disabled-skill filtering.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""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
|
||||
dependency, virtualenv, VCS, and cache directories. Centralising the
|
||||
check here keeps every skill-scanning site in sync with the shared
|
||||
exclusion set.
|
||||
Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
|
||||
prune dependency, virtualenv, VCS, cache, and progressive-disclosure
|
||||
support-package paths. Centralising the check here keeps every
|
||||
skill-scanning site in sync with the shared exclusion set.
|
||||
|
||||
Accepts a Path or string.
|
||||
"""
|
||||
|
|
@ -59,7 +65,36 @@ def is_excluded_skill_path(path) -> bool:
|
|||
except AttributeError:
|
||||
from pathlib import PurePath
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
|
@ -661,12 +696,21 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
|
|||
def iter_skill_index_files(skills_dir: Path, filename: str):
|
||||
"""Walk skills_dir yielding sorted paths matching *filename*.
|
||||
|
||||
Excludes Hermes metadata, VCS, virtualenv/dependency, and cache
|
||||
directories so dependencies cannot register nested skills.
|
||||
Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill
|
||||
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 = []
|
||||
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:
|
||||
matches.append(Path(root) / filename)
|
||||
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ from agent.prompt_builder import (
|
|||
TASK_COMPLETION_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_MODELS,
|
||||
drain_truncation_warnings,
|
||||
)
|
||||
from agent.runtime_cwd import resolve_context_cwd
|
||||
|
||||
|
|
@ -82,6 +83,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||
# we resolve through ``_ra()`` to honor those patches.
|
||||
_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_parts: List[str] = []
|
||||
|
||||
|
|
@ -90,7 +102,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||
# cwd project instructions disabled.
|
||||
_soul_loaded = False
|
||||
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:
|
||||
stable_parts.append(_soul_content)
|
||||
_soul_loaded = True
|
||||
|
|
@ -333,7 +345,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
|
||||
# daemon, which is why the gateway sets TERMINAL_CWD.
|
||||
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:
|
||||
context_parts.append(context_files_prompt)
|
||||
|
||||
|
|
@ -400,7 +413,14 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
|
|||
warm across turns.
|
||||
"""
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class AnthropicTransport(ProviderTransport):
|
|||
from agent.transports.types import ToolCall
|
||||
|
||||
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
||||
_MCP_PREFIX = "mcp_"
|
||||
_MCP_PREFIX = "mcp__"
|
||||
|
||||
text_parts = []
|
||||
reasoning_parts = []
|
||||
|
|
@ -132,17 +132,25 @@ class AnthropicTransport(ProviderTransport):
|
|||
elif block.type == "tool_use":
|
||||
name = block.name
|
||||
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
|
||||
stripped = name[len(_MCP_PREFIX):]
|
||||
# Only strip the mcp_ prefix for OAuth-injected tools
|
||||
# (where Hermes adds the prefix when sending to Anthropic
|
||||
# and must remove it on the way back). Native MCP server
|
||||
# tools (from mcp_servers: in config.yaml) are registered
|
||||
# in the tool registry under their FULL mcp_<server>_<tool>
|
||||
# name and must NOT be stripped. GH-25255.
|
||||
# On the OAuth wire every tool carries a double-underscore
|
||||
# ``mcp__`` prefix (added in build_anthropic_kwargs to avoid
|
||||
# Anthropic's single-underscore third-party classifier).
|
||||
# Reverse it back to the name the registry/dispatcher knows.
|
||||
# Two original forms map onto the same ``mcp__`` wire name:
|
||||
# ``mcp__read_file`` <- bare native tool ``read_file``
|
||||
# ``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
|
||||
if (_tool_registry.get_entry(stripped)
|
||||
and not _tool_registry.get_entry(name)):
|
||||
name = stripped
|
||||
if not _tool_registry.get_entry(name):
|
||||
bare = name[len(_MCP_PREFIX):] # read_file
|
||||
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(
|
||||
ToolCall(
|
||||
id=block.id,
|
||||
|
|
|
|||
|
|
@ -128,6 +128,65 @@ class ResponsesApiTransport(ProviderTransport):
|
|||
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
|
||||
|
||||
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
|
||||
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
|
||||
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
|
||||
|
|
@ -218,10 +277,28 @@ class ResponsesApiTransport(ProviderTransport):
|
|||
kwargs.pop("timeout", None)
|
||||
|
||||
if is_codex_backend:
|
||||
# chatgpt.com/backend-api/codex rejects body-level
|
||||
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
|
||||
# this backend must not be sent through the Responses payload.
|
||||
kwargs.pop("extra_headers", None)
|
||||
# The Codex backend rejects body-level ``extra_headers`` with
|
||||
# HTTP 400, but the OpenAI SDK's ``extra_headers`` kwarg maps
|
||||
# to actual HTTP request headers (not body fields). We need
|
||||
# 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")
|
||||
if max_tokens is not None and not is_codex_backend:
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ def build_turn_context(
|
|||
task_id: Optional[str],
|
||||
stream_callback,
|
||||
persist_user_message: Optional[str],
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
*,
|
||||
restore_or_build_system_prompt,
|
||||
install_safe_stdio,
|
||||
|
|
@ -121,6 +122,7 @@ def build_turn_context(
|
|||
agent._stream_callback = stream_callback
|
||||
agent._persist_user_message_idx = None
|
||||
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.
|
||||
effective_task_id = task_id or str(uuid.uuid4())
|
||||
agent._current_task_id = effective_task_id
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
|||
emit_stage(&app, "rebuild", StageState::Running, None, None);
|
||||
let started = Instant::now();
|
||||
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
|
||||
let rebuild = run_streamed(
|
||||
let mut rebuild = run_streamed(
|
||||
&app,
|
||||
&hermes,
|
||||
&rebuild_args,
|
||||
|
|
@ -295,6 +295,33 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
|||
Some("rebuild"),
|
||||
)
|
||||
.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;
|
||||
|
||||
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
|
||||
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
|
||||
/// but for an arbitrary command (no install.ps1 -File wrapping).
|
||||
|
|
@ -970,6 +1005,16 @@ mod tests {
|
|||
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]
|
||||
fn parses_only_app_targets() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = requ
|
|||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
|
|
@ -44,6 +45,7 @@ const { readDirForIpc } = require('./fs-read-dir.cjs')
|
|||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||
const {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
|
|
@ -2008,10 +2010,14 @@ async function applyUpdatesPosixInApp() {
|
|||
}
|
||||
|
||||
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
|
||||
const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], {
|
||||
cwd: updateRoot,
|
||||
env,
|
||||
stage: 'rebuild'
|
||||
// Retry-once: a first rebuild can fail on a still-settling tree or a
|
||||
// self-healed (network-blocked) Electron download; a second run builds clean
|
||||
// off the healed dist so we reach the swap+relaunch below instead of bailing.
|
||||
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) {
|
||||
emitUpdateProgress({
|
||||
|
|
@ -5106,14 +5112,7 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
|
|||
// themes/context.tsx, so the window appears already themed.
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true
|
||||
}
|
||||
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
|
||||
})
|
||||
|
||||
if (IS_MAC) {
|
||||
|
|
@ -5180,23 +5179,11 @@ function createWindow() {
|
|||
// material before the renderer paints the app theme. See createSessionWindow.
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
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
|
||||
}
|
||||
// Shared with the secondary session windows (chatWindowWebPreferences) so
|
||||
// both keep `backgroundThrottling: false` — the chat transcript streams via
|
||||
// a requestAnimationFrame-gated flush that Chromium pauses for blurred
|
||||
// windows, stalling the live answer until refocus. See session-windows.cjs.
|
||||
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
|
||||
})
|
||||
|
||||
if (IS_MAC) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,29 @@ const { pathToFileURL } = require('node:url')
|
|||
const SESSION_WINDOW_MIN_WIDTH = 420
|
||||
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
|
||||
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
|
||||
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
||||
|
|
@ -94,6 +117,7 @@ function createSessionWindowRegistry() {
|
|||
|
||||
module.exports = {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
const assert = require('node:assert/strict')
|
||||
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
|
||||
// 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)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
|
|
|||
29
apps/desktop/electron/update-rebuild.cjs
Normal file
29
apps/desktop/electron/update-rebuild.cjs
Normal 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 }
|
||||
55
apps/desktop/electron/update-rebuild.test.cjs
Normal file
55
apps/desktop/electron/update-rebuild.test.cjs
Normal 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')
|
||||
})
|
||||
|
|
@ -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",
|
||||
"postbuild": "node scripts/assert-dist-built.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",
|
||||
"dist": "npm run build && npm run builder",
|
||||
"dist:mac": "npm run build && npm run builder -- --mac",
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"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",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^10.0.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^40.9.3",
|
||||
"electron": "40.10.2",
|
||||
"electron-builder": "^26.8.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-perfectionist": "^5.9.0",
|
||||
|
|
@ -134,8 +134,7 @@
|
|||
"wait-on": "^9.0.5"
|
||||
},
|
||||
"build": {
|
||||
"electronVersion": "40.9.3",
|
||||
"electronDist": "../../node_modules/electron/dist",
|
||||
"electronVersion": "40.10.2",
|
||||
"appId": "com.nousresearch.hermes",
|
||||
"productName": "Hermes",
|
||||
"executableName": "Hermes",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes cop
|
|||
if (!fs.existsSync(bundledElectronBinary)) {
|
||||
const candidates = [
|
||||
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),
|
||||
];
|
||||
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
|
||||
|
|
|
|||
57
apps/desktop/scripts/run-electron-builder.cjs
Normal file
57
apps/desktop/scripts/run-electron-builder.cjs
Normal 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)
|
||||
|
|
@ -15,7 +15,9 @@ import { Backdrop } from '@/components/Backdrop'
|
|||
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ErrorState } from '@/components/ui/error-state'
|
||||
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
|
||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
|
|
@ -38,11 +40,12 @@ import {
|
|||
$lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$resumeExhaustedSessionId,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
|
|
@ -86,7 +89,9 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
|||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void>
|
||||
onRetryResume: (sessionId: string) => void
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
onDismissError?: (messageId: string) => void
|
||||
}
|
||||
|
||||
interface ChatHeaderProps {
|
||||
|
|
@ -121,10 +126,10 @@ function ChatHeader({
|
|||
? pinnedSessionIds.includes(selectedSessionId)
|
||||
: false
|
||||
|
||||
// A brand-new session has no session to pin/delete/rename, so the header is
|
||||
// just a dead "New session" label + chevron. Drop it (and its border)
|
||||
// entirely until there's a real session to act on.
|
||||
if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
|
||||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
// are compact side panels — they drop the session-actions header + border
|
||||
// entirely. A brand-new draft has nothing to pin/delete/rename either.
|
||||
if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -272,9 +277,12 @@ export function ChatView({
|
|||
onEdit,
|
||||
onReload,
|
||||
onRestoreToMessage,
|
||||
onTranscribeAudio
|
||||
onRetryResume,
|
||||
onTranscribeAudio,
|
||||
onDismissError
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
const { t } = useI18n()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const awaitingResponse = useStore($awaitingResponse)
|
||||
const busy = useStore($busy)
|
||||
|
|
@ -296,6 +304,7 @@ export function ChatView({
|
|||
const messagesEmpty = useStore($messagesEmpty)
|
||||
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
const isRoutedSessionView = Boolean(routedSessionId)
|
||||
|
||||
|
|
@ -315,9 +324,21 @@ export function ChatView({
|
|||
// session exists — even if it has zero messages (a brand-new routed
|
||||
// session). The flicker where `busy` flips true briefly during hydrate
|
||||
// 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 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 modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
||||
|
|
@ -432,6 +453,7 @@ export function ChatView({
|
|||
loading={threadLoading}
|
||||
onBranchInNewChat={onBranchInNewChat}
|
||||
onCancel={onCancel}
|
||||
onDismissError={onDismissError}
|
||||
onRestoreToMessage={onRestoreToMessage}
|
||||
sessionId={activeSessionId}
|
||||
sessionKey={threadKey}
|
||||
|
|
@ -465,6 +487,21 @@ export function ChatView({
|
|||
</Suspense>
|
||||
)}
|
||||
</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 />}
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { useSkinCommand } from '@/themes/use-skin-command'
|
|||
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
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 {
|
||||
isMessagingSource,
|
||||
LOCAL_SESSION_SOURCE_IDS,
|
||||
|
|
@ -52,7 +52,10 @@ import {
|
|||
$currentCwd,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$messages,
|
||||
$messagingSessions,
|
||||
$resumeFailedSessionId,
|
||||
$resumeExhaustedSessionId,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
|
|
@ -199,6 +202,8 @@ export function DesktopController() {
|
|||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const resumeFailedSessionId = useStore($resumeFailedSessionId)
|
||||
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||
const filePreviewTarget = useStore($filePreviewTarget)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
|
|
@ -736,6 +741,49 @@ export function DesktopController() {
|
|||
[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(
|
||||
(path: null | string) => {
|
||||
startFreshSessionDraft()
|
||||
|
|
@ -845,6 +893,8 @@ export function DesktopController() {
|
|||
gatewayState,
|
||||
locationPathname: location.pathname,
|
||||
resumeSession,
|
||||
resumeFailedSessionId,
|
||||
resumeExhaustedSessionId,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
|
|
@ -994,6 +1044,7 @@ export function DesktopController() {
|
|||
void removeSession(selectedStoredSessionId)
|
||||
}
|
||||
}}
|
||||
onDismissError={dismissError}
|
||||
onEdit={editMessage}
|
||||
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
|
||||
onPickFiles={() => void composer.pickContextPaths('file')}
|
||||
|
|
@ -1002,6 +1053,7 @@ export function DesktopController() {
|
|||
onReload={reloadFromMessage}
|
||||
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
||||
onRestoreToMessage={restoreToMessage}
|
||||
onRetryResume={sessionId => void resumeSession(sessionId, true)}
|
||||
onSteer={steerPrompt}
|
||||
onSubmit={submitText}
|
||||
onThreadMessagesChange={handleThreadMessagesChange}
|
||||
|
|
|
|||
|
|
@ -1102,8 +1102,13 @@ export function useMessageStream({
|
|||
|
||||
if (looksLikeProviderSetup) {
|
||||
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({
|
||||
id: `gateway-error:${errorMessage}`,
|
||||
kind: 'error',
|
||||
title: 'Hermes error',
|
||||
message: errorMessage
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { cleanup, render } from '@testing-library/react'
|
|||
import type { MutableRefObject } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $resumeExhaustedSessionId, setResumeExhaustedSessionId } from '@/store/session'
|
||||
|
||||
import { useRouteResume } from './use-route-resume'
|
||||
|
||||
interface HarnessProps {
|
||||
|
|
@ -13,6 +15,8 @@ interface HarnessProps {
|
|||
gatewayState: string
|
||||
locationPathname: string
|
||||
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
||||
resumeFailedSessionId?: null | string
|
||||
resumeExhaustedSessionId?: null | string
|
||||
routedSessionId: null | string
|
||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
||||
selectedStoredSessionId: null | string
|
||||
|
|
@ -20,8 +24,12 @@ interface HarnessProps {
|
|||
startFreshSessionDraft: (focus: boolean) => unknown
|
||||
}
|
||||
|
||||
function RouteResumeHarness(props: HarnessProps) {
|
||||
useRouteResume(props)
|
||||
function RouteResumeHarness({
|
||||
resumeFailedSessionId = null,
|
||||
resumeExhaustedSessionId = null,
|
||||
...props
|
||||
}: HarnessProps) {
|
||||
useRouteResume({ ...props, resumeExhaustedSessionId, resumeFailedSessionId })
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -256,3 +264,212 @@ describe('useRouteResume', () => {
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { type MutableRefObject, useEffect, useRef } from 'react'
|
||||
|
||||
import { isNewChatRoute } from '@/app/routes'
|
||||
import { setResumeExhaustedSessionId } from '@/store/session'
|
||||
|
||||
interface RouteResumeOptions {
|
||||
activeSessionId: string | null
|
||||
|
|
@ -11,6 +12,17 @@ interface RouteResumeOptions {
|
|||
gatewayState: string | undefined
|
||||
locationPathname: string
|
||||
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
|
||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
||||
selectedStoredSessionId: string | null
|
||||
|
|
@ -18,6 +30,19 @@ interface RouteResumeOptions {
|
|||
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
|
||||
// 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.
|
||||
|
|
@ -49,6 +74,8 @@ export function useRouteResume({
|
|||
gatewayState,
|
||||
locationPathname,
|
||||
resumeSession,
|
||||
resumeFailedSessionId,
|
||||
resumeExhaustedSessionId,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
|
|
@ -58,6 +85,16 @@ export function useRouteResume({
|
|||
const lastPathnameRef = useRef<string | null>(null)
|
||||
const seenGatewayStateRef = 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(() => {
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
|
|
@ -139,4 +176,111 @@ export function useRouteResume({
|
|||
selectedStoredSessionIdRef,
|
||||
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 1s–8s 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
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import type { MutableRefObject } from 'react'
|
|||
import { useEffect } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getSessionMessages } from '@/hermes'
|
||||
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'
|
||||
|
||||
|
|
@ -117,3 +118,142 @@ describe('createBackendSessionForSend profile routing', () => {
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import {
|
|||
setFreshDraftReady,
|
||||
setIntroSeed,
|
||||
setMessages,
|
||||
setResumeExhaustedSessionId,
|
||||
setResumeFailedSessionId,
|
||||
setSelectedStoredSessionId,
|
||||
setSessions,
|
||||
setSessionStartedAt,
|
||||
|
|
@ -579,6 +581,15 @@ export function useSessionActions({
|
|||
clearNotifications()
|
||||
setSelectedStoredSessionId(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)
|
||||
|
||||
|
|
@ -769,13 +780,41 @@ export function useSessionActions({
|
|||
return
|
||||
}
|
||||
|
||||
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
|
||||
// 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)
|
||||
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
|
||||
notifyError(err, copy.resumeFailed)
|
||||
} finally {
|
||||
if (isCurrentResume()) {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import { act, cleanup, render } from '@testing-library/react'
|
|||
import type { MutableRefObject } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import {
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$currentServiceTier,
|
||||
$messages,
|
||||
$turnStartedAt,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
|
|
@ -213,3 +215,113 @@ describe('useSessionStateCache — per-session turn timer', () => {
|
|||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -79,6 +79,9 @@ export function useSessionStateCache({
|
|||
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
|
||||
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | 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(() => {
|
||||
activeSessionIdRef.current = activeSessionId
|
||||
|
|
@ -142,12 +145,22 @@ export function useSessionStateCache({
|
|||
// 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.
|
||||
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)) {
|
||||
setMessages(nextMessages)
|
||||
}
|
||||
|
||||
viewSessionIdRef.current = pending.sessionId
|
||||
|
||||
syncRuntimeMetadataToView(pending.state)
|
||||
setBusy(pending.state.busy)
|
||||
setMutableRef(busyRef, pending.state.busy)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn()
|
|||
const setModelAssignment = vi.fn()
|
||||
const getRecommendedDefaultModel = vi.fn()
|
||||
const setEnvVar = vi.fn()
|
||||
const getHermesConfigRecord = vi.fn()
|
||||
const saveHermesConfig = vi.fn()
|
||||
const startManualProviderOAuth = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
|
|
@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({
|
|||
getAuxiliaryModels: () => getAuxiliaryModels(),
|
||||
setModelAssignment: (body: unknown) => setModelAssignment(body),
|
||||
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', () => ({
|
||||
|
|
@ -35,7 +39,13 @@ beforeEach(() => {
|
|||
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
|
||||
getGlobalModelOptions.mockResolvedValue({
|
||||
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.
|
||||
{ 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: [] })
|
||||
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
|
||||
setEnvVar.mockResolvedValue({ ok: true })
|
||||
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
|
||||
saveHermesConfig.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -100,6 +112,31 @@ describe('ModelSettings', () => {
|
|||
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 () => {
|
||||
await renderModelSettings()
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
getAuxiliaryModels,
|
||||
getGlobalModelInfo,
|
||||
getGlobalModelOptions,
|
||||
getHermesConfigRecord,
|
||||
getRecommendedDefaultModel,
|
||||
saveHermesConfig,
|
||||
setEnvVar,
|
||||
setModelAssignment
|
||||
} from '@/hermes'
|
||||
|
|
@ -15,11 +18,26 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
|
|||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { getNested, setNested } from './helpers'
|
||||
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
|
||||
// backend now surfaces the full `hermes model` universe (every canonical
|
||||
// provider), so unconfigured providers come back with `authenticated:false`
|
||||
|
|
@ -97,6 +115,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
const [selectedProvider, setSelectedProvider] = useState('')
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
|
||||
// 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 [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
||||
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
||||
|
|
@ -113,10 +134,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
setError('')
|
||||
|
||||
try {
|
||||
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
|
||||
const [modelInfo, modelOptions, auxiliaryModels, cfg] = await Promise.all([
|
||||
getGlobalModelInfo(),
|
||||
getGlobalModelOptions(),
|
||||
getAuxiliaryModels()
|
||||
getAuxiliaryModels(),
|
||||
getHermesConfigRecord()
|
||||
])
|
||||
|
||||
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
||||
|
|
@ -124,6 +146,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
setSelectedProvider(prev => prev || modelInfo.provider)
|
||||
setSelectedModel(prev => prev || modelInfo.model)
|
||||
setAuxiliary(auxiliaryModels)
|
||||
setConfig(cfg)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
|
|
@ -181,6 +204,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
|
||||
}, [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
|
||||
// refresh so the now-authenticated provider's models populate. Auto-selects
|
||||
// the recommended default model so the user can Apply in one more click.
|
||||
|
|
@ -433,6 +492,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
|
||||
</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>}
|
||||
{switchStaleAux.length > 0 && (
|
||||
<div className="mt-2">
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from '@/store/layout'
|
||||
import { $paneWidthOverride } from '@/store/panes'
|
||||
import { $connection } from '@/store/session'
|
||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
|
||||
|
|
@ -80,7 +80,10 @@ export function AppShell({
|
|||
const connection = useStore($connection)
|
||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||
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)
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||
import type { HermesGateway } from '@/hermes'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
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 { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
|
||||
import {
|
||||
|
|
@ -84,8 +84,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
}
|
||||
})
|
||||
|
||||
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
|
||||
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
|
||||
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
|
||||
!!activeSessionId,
|
||||
{ model: currentModel, provider: currentProvider },
|
||||
modelOptions.data
|
||||
)
|
||||
|
||||
const loading = modelOptions.isPending && !modelOptions.data
|
||||
|
||||
const error = modelOptions.error
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -322,13 +322,29 @@ function shortLabel(type: HermesRefType, id: string): string {
|
|||
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
|
||||
* inline chips. Embedded MEDIA images render below as a thumbnail row.
|
||||
*/
|
||||
export function DirectiveContent({ text }: { text: string }) {
|
||||
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
|
||||
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
|
||||
const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
|
||||
const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
|
||||
|
||||
return (
|
||||
<span className="whitespace-pre-line" data-slot="aui_directive-text">
|
||||
|
|
|
|||
|
|
@ -201,4 +201,13 @@ describe('preprocessMarkdown', () => {
|
|||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ import {
|
|||
useState
|
||||
} from 'react'
|
||||
|
||||
import { ExpandableBlock } from '@/components/chat/expandable-block'
|
||||
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 { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
|
||||
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
|
||||
// module-scope so the prop identity is stable across renders.
|
||||
function preprocessWithTailRepair(text: string): string {
|
||||
return tailBoundedRemend(preprocessMarkdown(text))
|
||||
try {
|
||||
return tailBoundedRemend(preprocessMarkdown(text))
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
// 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)'
|
||||
)
|
||||
|
||||
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) {
|
||||
const { status } = useMessagePartText()
|
||||
const { status, text } = useMessagePartText()
|
||||
const isStreaming = status.type === 'running'
|
||||
|
||||
// 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} />
|
||||
),
|
||||
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.
|
||||
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={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}
|
||||
/>
|
||||
),
|
||||
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={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={cn('leading-(--dt-line-height)', className)} {...props} />
|
||||
|
|
@ -533,6 +583,10 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
|
|||
[isStreaming]
|
||||
)
|
||||
|
||||
if (text.length > MAX_MARKDOWN_CHARS) {
|
||||
return <HugeTextFallback containerClassName={containerClassName} text={text} />
|
||||
}
|
||||
|
||||
return (
|
||||
<StreamdownTextPrimitive
|
||||
components={components}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
beforeEach(() => {
|
||||
resizeObservers.clear()
|
||||
|
|
@ -421,6 +435,23 @@ describe('assistant-ui streaming renderer', () => {
|
|||
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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
resetThreadScroll,
|
||||
setThreadAtBottom
|
||||
} from '@/store/thread-scroll'
|
||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
|
|
@ -134,13 +134,20 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
const hiddenCount = firstVisible
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
const restoreFromBottomRef = useRef<number | null>(null)
|
||||
const newSessionWindow = isNewSessionWindow()
|
||||
const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)'
|
||||
const threadContentTopPad = newSessionWindow
|
||||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
// hide the titlebar tool cluster + session header, but the OS traffic lights
|
||||
// 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)]'
|
||||
: isSecondaryWindow()
|
||||
? 'pt-6'
|
||||
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
|
||||
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
|
||||
|
||||
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
|
||||
useEffect(() => () => resetThreadScroll(), [])
|
||||
|
|
@ -247,10 +254,21 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
style={
|
||||
{
|
||||
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
|
||||
...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {})
|
||||
...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {})
|
||||
} 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
|
||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
data-following={isAtBottom ? 'true' : 'false'}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti
|
|||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { LinkifiedText } from '@/lib/external-link'
|
||||
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 { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -169,6 +169,7 @@ export const Thread: FC<{
|
|||
loading?: ThreadLoadingState
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
onCancel?: () => Promise<void> | void
|
||||
onDismissError?: (messageId: string) => void
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||
sessionId?: string | null
|
||||
sessionKey?: string | null
|
||||
|
|
@ -180,18 +181,19 @@ export const Thread: FC<{
|
|||
loading,
|
||||
onBranchInNewChat,
|
||||
onCancel,
|
||||
onDismissError,
|
||||
onRestoreToMessage,
|
||||
sessionId = null,
|
||||
sessionKey
|
||||
}) => {
|
||||
const messageComponents = useMemo(
|
||||
() => ({
|
||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />,
|
||||
SystemMessage,
|
||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
|
||||
}),
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId]
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId]
|
||||
)
|
||||
|
||||
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 messageRuntime = useMessageRuntime()
|
||||
const { t } = useI18n()
|
||||
|
||||
// PERF: this component must NOT subscribe to the streaming text. Every
|
||||
// selector here returns a value that stays referentially stable across
|
||||
|
|
@ -306,10 +312,20 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
|||
)}
|
||||
<MessagePrimitive.Error>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</MessagePrimitive.Error>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) {
|
|||
return (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
data-slot="code-card-body"
|
||||
|
|
|
|||
52
apps/desktop/src/components/chat/expandable-block.tsx
Normal file
52
apps/desktop/src/components/chat/expandable-block.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
apps/desktop/src/components/chat/shiki-highlighter.test.ts
Normal file
37
apps/desktop/src/components/chat/shiki-highlighter.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
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 {
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
CodeCardSubtitle,
|
||||
CodeCardTitle
|
||||
} from '@/components/chat/code-card'
|
||||
import { ExpandableBlock } from '@/components/chat/expandable-block'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { useI18n } from '@/i18n'
|
||||
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' }
|
||||
}
|
||||
|
||||
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> = ({
|
||||
components: { Pre },
|
||||
language,
|
||||
|
|
@ -64,6 +133,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||
|
||||
const cleanLanguage = sanitizeLanguageTag(language || '')
|
||||
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
||||
const plain = defer || exceedsHighlightBudget(trimmed)
|
||||
|
||||
return (
|
||||
<CodeCard data-streaming={defer ? 'true' : undefined}>
|
||||
|
|
@ -83,24 +153,26 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||
/>
|
||||
</CodeCardHeader>
|
||||
<CodeCardBody>
|
||||
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
|
||||
{defer ? (
|
||||
<code className="block whitespace-pre">{trimmed}</code>
|
||||
) : (
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
|
||||
defaultColor="light-dark()"
|
||||
delay={120}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{trimmed}
|
||||
</ShikiHighlighter>
|
||||
)}
|
||||
</Pre>
|
||||
<ExpandableBlock>
|
||||
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
|
||||
{plain ? (
|
||||
<PlainCode code={trimmed} />
|
||||
) : (
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
|
||||
defaultColor="light-dark()"
|
||||
delay={120}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{trimmed}
|
||||
</ShikiHighlighter>
|
||||
)}
|
||||
</Pre>
|
||||
</ExpandableBlock>
|
||||
</CodeCardBody>
|
||||
</CodeCard>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||
import { useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { currentPickerSelection } from '@/lib/model-status-label'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
|
||||
|
||||
import type { HermesGateway } from '../hermes'
|
||||
|
|
@ -66,8 +67,13 @@ export function ModelPickerDialog({
|
|||
})
|
||||
|
||||
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 error = modelOptions.error
|
||||
|
|
|
|||
|
|
@ -538,6 +538,10 @@ export const en: Translations = {
|
|||
provider: 'Provider',
|
||||
model: 'Model',
|
||||
applying: 'Applying...',
|
||||
defaultsLabel: 'Defaults',
|
||||
reasoning: 'Reasoning',
|
||||
reasoningOff: 'Off',
|
||||
defaultsFailed: 'Failed to save model defaults',
|
||||
auxiliaryTitle: 'Auxiliary models',
|
||||
resetAllToMain: 'Reset all to main',
|
||||
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',
|
||||
|
|
@ -1729,6 +1733,7 @@ export const en: Translations = {
|
|||
refresh: 'Refresh',
|
||||
moreActions: 'More actions',
|
||||
branchNewChat: 'Branch in new chat',
|
||||
dismissError: 'Dismiss error',
|
||||
readAloudFailed: 'Read aloud failed',
|
||||
preparingAudio: 'Preparing audio...',
|
||||
stopReading: 'Stop reading',
|
||||
|
|
@ -1838,6 +1843,9 @@ export const en: Translations = {
|
|||
regenerateFailed: 'Regenerate failed',
|
||||
editFailed: 'Edit 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',
|
||||
branchNeedsChat: 'Start or resume a chat before branching.',
|
||||
sessionBusy: 'Session busy',
|
||||
|
|
|
|||
|
|
@ -1864,6 +1864,7 @@ export const ja = defineLocale({
|
|||
refresh: '更新',
|
||||
moreActions: 'その他のアクション',
|
||||
branchNewChat: '新しいチャットでブランチ',
|
||||
dismissError: 'エラーを閉じる',
|
||||
readAloudFailed: '読み上げに失敗しました',
|
||||
preparingAudio: '音声を準備中...',
|
||||
stopReading: '読み上げを停止',
|
||||
|
|
@ -1973,6 +1974,9 @@ export const ja = defineLocale({
|
|||
regenerateFailed: '再生成に失敗しました',
|
||||
editFailed: '編集に失敗しました',
|
||||
resumeFailed: '再開に失敗しました',
|
||||
resumeStrandedTitle: 'このセッションを読み込めませんでした',
|
||||
resumeStrandedBody: 'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
|
||||
resumeRetry: '再試行',
|
||||
nothingToBranch: 'ブランチするものがありません',
|
||||
branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',
|
||||
sessionBusy: 'セッションが使用中',
|
||||
|
|
|
|||
|
|
@ -430,6 +430,10 @@ export interface Translations {
|
|||
provider: string
|
||||
model: string
|
||||
applying: string
|
||||
defaultsLabel: string
|
||||
reasoning: string
|
||||
reasoningOff: string
|
||||
defaultsFailed: string
|
||||
auxiliaryTitle: string
|
||||
resetAllToMain: string
|
||||
auxiliaryDesc: string
|
||||
|
|
@ -1369,6 +1373,7 @@ export interface Translations {
|
|||
refresh: string
|
||||
moreActions: string
|
||||
branchNewChat: string
|
||||
dismissError: string
|
||||
readAloudFailed: string
|
||||
preparingAudio: string
|
||||
stopReading: string
|
||||
|
|
@ -1476,6 +1481,9 @@ export interface Translations {
|
|||
regenerateFailed: string
|
||||
editFailed: string
|
||||
resumeFailed: string
|
||||
resumeStrandedTitle: string
|
||||
resumeStrandedBody: string
|
||||
resumeRetry: string
|
||||
nothingToBranch: string
|
||||
branchNeedsChat: string
|
||||
sessionBusy: string
|
||||
|
|
|
|||
|
|
@ -1806,6 +1806,7 @@ export const zhHant = defineLocale({
|
|||
refresh: '重新整理',
|
||||
moreActions: '更多動作',
|
||||
branchNewChat: '在新聊天中分支',
|
||||
dismissError: '关闭错误',
|
||||
readAloudFailed: '朗讀失敗',
|
||||
preparingAudio: '正在準備音訊...',
|
||||
stopReading: '停止朗讀',
|
||||
|
|
@ -1913,6 +1914,9 @@ export const zhHant = defineLocale({
|
|||
regenerateFailed: '重新生成失敗',
|
||||
editFailed: '編輯失敗',
|
||||
resumeFailed: '繼續失敗',
|
||||
resumeStrandedTitle: '無法載入此工作階段',
|
||||
resumeStrandedBody: '與此工作階段的連線失敗,自動重試已停止。請確認閘道正在執行,然後重試。',
|
||||
resumeRetry: '重試',
|
||||
nothingToBranch: '沒有可分支的內容',
|
||||
branchNeedsChat: '分支前請先開始或繼續一個聊天。',
|
||||
sessionBusy: '工作階段忙碌中',
|
||||
|
|
|
|||
|
|
@ -733,6 +733,10 @@ export const zh: Translations = {
|
|||
provider: '提供方',
|
||||
model: '模型',
|
||||
applying: '应用中...',
|
||||
defaultsLabel: '默认值',
|
||||
reasoning: '推理',
|
||||
reasoningOff: '关闭',
|
||||
defaultsFailed: '保存模型默认值失败',
|
||||
auxiliaryTitle: '辅助模型',
|
||||
resetAllToMain: '全部重置为主模型',
|
||||
auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。',
|
||||
|
|
@ -1908,6 +1912,7 @@ export const zh: Translations = {
|
|||
refresh: '刷新',
|
||||
moreActions: '更多操作',
|
||||
branchNewChat: '在新对话中分支',
|
||||
dismissError: '关闭错误',
|
||||
readAloudFailed: '朗读失败',
|
||||
preparingAudio: '正在准备音频...',
|
||||
stopReading: '停止朗读',
|
||||
|
|
@ -2016,6 +2021,9 @@ export const zh: Translations = {
|
|||
regenerateFailed: '重新生成失败',
|
||||
editFailed: '编辑失败',
|
||||
resumeFailed: '恢复失败',
|
||||
resumeStrandedTitle: '无法加载此会话',
|
||||
resumeStrandedBody: '与此会话的连接失败,自动重试已停止。请确认网关正在运行,然后重试。',
|
||||
resumeRetry: '重试',
|
||||
nothingToBranch: '没有可分支的内容',
|
||||
branchNeedsChat: '分支前请先开始或恢复一个对话。',
|
||||
sessionBusy: '会话忙碌中',
|
||||
|
|
|
|||
|
|
@ -151,12 +151,18 @@ function normalizeVisibleProse(text: string): string {
|
|||
.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[]) {
|
||||
if (info) {
|
||||
out.push(`${indent}${info}`.trimEnd())
|
||||
}
|
||||
|
||||
out.push(...lines)
|
||||
extend(out, lines)
|
||||
}
|
||||
|
||||
function findClosingFence(lines: string[], start: number, marker: string): number {
|
||||
|
|
@ -241,7 +247,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||
}
|
||||
|
||||
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
|
||||
out.push(...bodyLines)
|
||||
extend(out, bodyLines)
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
|
|
@ -264,10 +270,10 @@ function normalizeFenceBlocks(text: string): string {
|
|||
// any literal `$$` characters in the body don't collide with
|
||||
// an outer math wrapper. No close emitted yet — streaming.
|
||||
out.push(`${indent}${marker}math`)
|
||||
out.push(...bodyLines)
|
||||
extend(out, bodyLines)
|
||||
} else {
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
extend(out, bodyLines)
|
||||
}
|
||||
|
||||
break
|
||||
|
|
@ -288,7 +294,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||
// colliding with our wrapper. Without this rewrite the block
|
||||
// would render as a syntax-highlighted "latex" code listing.
|
||||
out.push(`${indent}${marker}math`)
|
||||
out.push(...bodyLines)
|
||||
extend(out, bodyLines)
|
||||
out.push(`${indent}${marker}`)
|
||||
index = closeIndex + 1
|
||||
|
||||
|
|
@ -296,7 +302,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||
}
|
||||
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
extend(out, bodyLines)
|
||||
out.push(`${indent}${marker}`)
|
||||
index = closeIndex + 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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', () => {
|
||||
it('formats display names consistently', () => {
|
||||
|
|
@ -35,4 +35,25 @@ describe('model-status-label', () => {
|
|||
it('returns just the placeholder name when there is 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,6 +17,22 @@ export function reasoningEffortLabel(effort: string): string {
|
|||
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. */
|
||||
export function modelBaseId(model: string): string {
|
||||
const trimmed = model.trim()
|
||||
|
|
|
|||
|
|
@ -218,6 +218,23 @@ export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageI
|
|||
export const $freshDraftReady = atom(false)
|
||||
export const $busy = atom(false)
|
||||
export const $awaitingResponse = atom(false)
|
||||
// Stored-session id whose most recent resume FAILED terminally (the gateway RPC
|
||||
// rejected AND the REST transcript fallback also failed), leaving the window
|
||||
// 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) ?? '')
|
||||
|
|
@ -262,6 +279,8 @@ export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($
|
|||
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
|
||||
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, 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 setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ vi.mock('@/hermes', () => ({
|
|||
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 status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
|
||||
|
|
@ -95,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', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
|
|
|
|||
|
|
@ -91,26 +91,60 @@ function isUpdateToastSnoozed(): boolean {
|
|||
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
|
||||
const REQUIRED_BACKEND_CONTRACT = 2
|
||||
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
|
||||
* (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
|
||||
* that runs the normal update flow (which self-heals to the right branch).
|
||||
* cryptically downstream, surface a warning with a one-click align that runs
|
||||
* 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 {
|
||||
if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) {
|
||||
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
|
||||
}
|
||||
|
||||
if (isSkewToastSnoozed()) {
|
||||
return
|
||||
}
|
||||
|
||||
notify({
|
||||
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyBackendUpdate() },
|
||||
action: {
|
||||
label: translateNow('notifications.updateHermes'),
|
||||
onClick: () => {
|
||||
snoozeSkewToast()
|
||||
void applyBackendUpdate()
|
||||
}
|
||||
},
|
||||
durationMs: 0,
|
||||
id: SKEW_TOAST_ID,
|
||||
kind: 'warning',
|
||||
message: translateNow('notifications.backendOutOfDateMessage'),
|
||||
onDismiss: () => snoozeSkewToast(),
|
||||
title: translateNow('notifications.backendOutOfDateTitle')
|
||||
})
|
||||
}
|
||||
|
|
|
|||
9
cli.py
9
cli.py
|
|
@ -12024,6 +12024,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
|
||||
# Create the input area with multiline (Alt+Enter), autocomplete, and paste handling
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.completion import ThreadedCompleter
|
||||
|
||||
|
||||
_completer = SlashCommandCompleter(
|
||||
|
|
@ -12039,7 +12040,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
wrap_lines=True,
|
||||
read_only=Condition(lambda: bool(cli_ref._command_running)),
|
||||
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,
|
||||
auto_suggest=SlashCommandAutoSuggest(
|
||||
history_suggest=AutoSuggestFromHistory(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# 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 +
|
||||
# build-tree chown, config seeding) all require root, and they're skipped when
|
||||
# the container starts non-root. The baked image trees (/opt/data, /opt/hermes/
|
||||
# .venv, ui-tui, node_modules) stay owned by the hermes build UID (10000), so an
|
||||
# arbitrary `--user` UID can't write them — the runtime then fails with EACCES
|
||||
# on a bind mount, or hard-crashes on a named volume (Docker initialises the
|
||||
# volume from the image as UID 10000, and the non-root start can't even `cd`
|
||||
# into $HERMES_HOME). See #34837 for the supervision-tree side of this.
|
||||
# Under s6-overlay this no longer works: the bootstrap (UID remap, data-volume
|
||||
# ownership, config seeding) requires root, and it is skipped when the container
|
||||
# starts non-root. The baked install tree under /opt/hermes is intentionally
|
||||
# root-owned and non-writable; mutable runtime state must live under
|
||||
# $HERMES_HOME. An arbitrary `--user` UID therefore cannot repair or populate
|
||||
# the data volume, and startup fails with EACCES. See #34837 for the
|
||||
# supervision-tree side of this.
|
||||
#
|
||||
# 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
|
||||
|
|
@ -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).
|
||||
|
||||
This is not supported under the s6-overlay image. The container bootstrap
|
||||
(UID remap, volume ownership, dependency installs) needs to start as root,
|
||||
and the baked image directories are owned by the hermes user (UID $(id -u hermes)),
|
||||
so a pinned --user UID cannot write them — startup will fail.
|
||||
(UID remap, data-volume ownership, config seeding) needs to start as root,
|
||||
and the baked /opt/hermes install tree is intentionally root-owned and
|
||||
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.
|
||||
Start the container as root (the default) and pass your host UID/GID instead:
|
||||
|
|
@ -207,49 +207,13 @@ if [ "$needs_chown" = true ]; then
|
|||
done
|
||||
fi
|
||||
|
||||
# --- Fix ownership of build trees under $INSTALL_DIR ---
|
||||
# Hermes-owned trees under $INSTALL_DIR must be re-chowned whenever the
|
||||
# runtime hermes UID no longer owns them — otherwise:
|
||||
# - .venv: lazy_deps.py cannot install platform packages (discord.py,
|
||||
# telegram, slack, etc.) with EACCES (#15012, #21100)
|
||||
# - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when
|
||||
# the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD
|
||||
# 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
|
||||
# --- Immutable install tree ---
|
||||
# Do not chown runtime code or dependency trees under $INSTALL_DIR back to the
|
||||
# hermes user. Hosted/container instances keep mutable state under
|
||||
# $HERMES_HOME (/opt/data) and run with PYTHONDONTWRITEBYTECODE plus
|
||||
# HERMES_DISABLE_LAZY_INSTALLS=1. Keeping /opt/hermes root-owned and
|
||||
# non-writable prevents an agent session from self-modifying the installed
|
||||
# source, venv, TUI bundle, or node_modules and bricking the gateway.
|
||||
|
||||
# Always reset ownership of $HERMES_HOME/profiles to hermes on every
|
||||
# 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/platforms/pairing"
|
||||
|
||||
# --- Install-method stamp (read by detect_install_method() in hermes status) ---
|
||||
# Preserved from the tini-era entrypoint (PR #27843). Must be written as
|
||||
# the hermes user so ownership matches the file's documented owner.
|
||||
# tee is invoked directly via s6-setuidgid (no `sh -c` wrapper) for the
|
||||
# same shell-metacharacter safety described above.
|
||||
printf 'docker\n' | as_hermes tee "$HERMES_HOME/.install_method" >/dev/null \
|
||||
|| true
|
||||
# --- Install-method stamp ---
|
||||
# The 'docker' stamp is baked into the immutable install tree at
|
||||
# /opt/hermes/.install_method (see Dockerfile), NOT written here into
|
||||
# $HERMES_HOME. detect_install_method() reads the code-scoped stamp first.
|
||||
#
|
||||
# Why we no longer stamp $HERMES_HOME: it is a shared DATA volume, commonly
|
||||
# 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_one() {
|
||||
|
|
|
|||
BIN
docs/assets/ns504-chat-session-reconnect.png
Normal file
BIN
docs/assets/ns504-chat-session-reconnect.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 389 KiB |
260
docs/relay-connector-contract.md
Normal file
260
docs/relay-connector-contract.md
Normal 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.
|
||||
|
|
@ -164,6 +164,7 @@ class Platform(Enum):
|
|||
BLUEBUBBLES = "bluebubbles"
|
||||
QQBOT = "qqbot"
|
||||
YUANBAO = "yuanbao"
|
||||
RELAY = "relay" # generic relay adapter fronted by the connector (EXPERIMENTAL)
|
||||
@classmethod
|
||||
def _missing_(cls, value):
|
||||
"""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"))
|
||||
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")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
166
gateway/message_timestamps.py
Normal file
166
gateway/message_timestamps.py
Normal 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())
|
||||
|
|
@ -1241,6 +1241,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
message_id = (msg.get("result") or {}).get("message_id")
|
||||
else:
|
||||
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(
|
||||
success=True,
|
||||
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 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
|
||||
from gateway.platforms.base import resolve_channel_prompt
|
||||
|
|
|
|||
398
gateway/relay/__init__.py
Normal file
398
gateway/relay/__init__.py
Normal 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
220
gateway/relay/adapter.py
Normal 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"),
|
||||
)
|
||||
168
gateway/relay/auth.py
Normal file
168
gateway/relay/auth.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Gateway-side relay authentication primitives. EXPERIMENTAL.
|
||||
|
||||
The connector⇄gateway channel is authenticated because a gateway may be
|
||||
customer-managed and internet-exposed (see the connector repo
|
||||
``docs/connector-gateway-auth-design.md``). This module is the **gateway half**
|
||||
of two HMAC schemes whose wire bytes must match the connector's TypeScript
|
||||
exactly:
|
||||
|
||||
1. **WS upgrade auth** (gateway → connector): the gateway presents
|
||||
``Authorization: Bearer <token>`` on the ``/relay`` WebSocket upgrade, where
|
||||
``token = make_upgrade_token(gateway_id, secret)``. Mirrors the connector's
|
||||
``relayAuthToken.ts`` ``makeToken`` (``src/core/relayAuthToken.ts``):
|
||||
``base64url(f"{payload}:{exp}:{sig}")`` with
|
||||
``sig = HMAC_SHA256(f"{payload}:{exp}", secret).hexdigest()`` and
|
||||
``payload == gateway_id``.
|
||||
|
||||
2. **Inbound delivery signature** (connector → gateway): the connector signs
|
||||
each inbound POST with the per-tenant *delivery key*, carried as
|
||||
``x-relay-timestamp`` + ``x-relay-signature`` headers; the gateway verifies
|
||||
before accepting the event. Mirrors the connector's ``deliverySigning.ts``:
|
||||
``sig = HMAC_SHA256(f"{ts}.{body_json}", key).hexdigest()`` over the EXACT
|
||||
request body bytes, with a replay-window skew check.
|
||||
|
||||
Both schemes use a **multi-secret verify list** (primary first, then a secondary
|
||||
during a rotation window), exactly like ``api/src/handlers/stats_oauth.ts`` — so
|
||||
a secret rotation doesn't invalidate outstanding tokens.
|
||||
|
||||
EXPERIMENTAL: may change without a deprecation cycle until ≥2 Class-1 platforms
|
||||
validate the relay contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from typing import Optional, Sequence
|
||||
|
||||
# Header names the connector uses for inbound delivery signatures
|
||||
# (connector ``src/core/deliverySigning.ts`` — DELIVERY_TS_HEADER / SIG_HEADER).
|
||||
DELIVERY_TS_HEADER = "x-relay-timestamp"
|
||||
DELIVERY_SIG_HEADER = "x-relay-signature"
|
||||
|
||||
# Default replay window for an inbound delivery signature (connector default).
|
||||
_DEFAULT_MAX_SKEW_SECONDS = 300
|
||||
# Default TTL for an upgrade token (connector ``makeUpgradeToken`` default).
|
||||
_DEFAULT_UPGRADE_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def _hmac_hex(payload: str, secret: str) -> str:
|
||||
"""HMAC-SHA256 hex digest of ``payload`` under ``secret`` (UTF-8)."""
|
||||
return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def sign(payload: str, secret: str) -> str:
|
||||
"""HMAC-SHA256 hex digest — the connector's ``sign`` (relayAuthToken.ts)."""
|
||||
return _hmac_hex(payload, secret)
|
||||
|
||||
|
||||
def verify_signature(payload: str, sig_hex: str, secrets: Sequence[str]) -> bool:
|
||||
"""Constant-time check that ``sig_hex`` is a valid HMAC of ``payload`` under
|
||||
ANY of ``secrets`` (rotation window). Length-mismatched candidates are
|
||||
skipped without a timing leak. Mirrors ``verifySignature``.
|
||||
"""
|
||||
try:
|
||||
sig_buf = bytes.fromhex(sig_hex)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if len(sig_buf) == 0:
|
||||
return False
|
||||
for secret in secrets:
|
||||
if not secret:
|
||||
continue
|
||||
expected = bytes.fromhex(_hmac_hex(payload, secret))
|
||||
if len(expected) != len(sig_buf):
|
||||
continue
|
||||
if hmac.compare_digest(sig_buf, expected):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def make_token(payload: str, secret: str, ttl_seconds: int = 0) -> str:
|
||||
"""Build a signed, optionally-expiring token — the connector's ``makeToken``.
|
||||
|
||||
``base64url(f"{payload}:{exp}:{sig}")`` where ``exp`` is a unix-seconds
|
||||
expiry (0 = never) and ``sig = HMAC_SHA256(f"{payload}:{exp}", secret)``.
|
||||
base64url is unpadded to match Node's ``Buffer.toString("base64url")``.
|
||||
"""
|
||||
exp = int(time.time()) + ttl_seconds if ttl_seconds > 0 else 0
|
||||
signed = f"{payload}:{exp}"
|
||||
sig = _hmac_hex(signed, secret)
|
||||
raw = f"{signed}:{sig}".encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def make_upgrade_token(
|
||||
gateway_id: str, secret: str, ttl_seconds: int = _DEFAULT_UPGRADE_TTL_SECONDS
|
||||
) -> str:
|
||||
"""The WS-upgrade bearer token a gateway sends: ``payload = gateway_id``.
|
||||
|
||||
The connector peeks ``gateway_id`` (the payload head) to index its secret
|
||||
verify list, then verifies the signature against that gateway's stored
|
||||
secret(s). Mirrors the connector's ``makeUpgradeToken``.
|
||||
"""
|
||||
return make_token(gateway_id, secret, ttl_seconds)
|
||||
|
||||
|
||||
def verify_token(token: str, secrets: Sequence[str]) -> Optional[str]:
|
||||
"""Verify a token built by ``make_token``; return the payload or None.
|
||||
|
||||
Splits from the right so a payload may itself contain colons (mirrors the
|
||||
connector's ``verifyToken``). Rejects an expired token and any signature
|
||||
that doesn't match a secret in the verify list.
|
||||
"""
|
||||
try:
|
||||
# base64url decode with padding restored.
|
||||
padded = token + "=" * (-len(token) % 4)
|
||||
decoded = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
parts = decoded.split(":")
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
sig = parts[-1]
|
||||
try:
|
||||
exp = int(parts[-2])
|
||||
except ValueError:
|
||||
return None
|
||||
payload = ":".join(parts[:-2])
|
||||
if exp != 0 and int(time.time()) > exp:
|
||||
return None
|
||||
signed = f"{payload}:{exp}"
|
||||
return payload if verify_signature(signed, sig, secrets) else None
|
||||
|
||||
|
||||
def _delivery_payload(ts: int, body_json: str) -> str:
|
||||
"""Signed material for an inbound delivery: ``f"{ts}.{body_json}"``."""
|
||||
return f"{ts}.{body_json}"
|
||||
|
||||
|
||||
def verify_delivery_signature(
|
||||
body_json: str,
|
||||
timestamp: Optional[str],
|
||||
signature: Optional[str],
|
||||
verify_keys: Sequence[str],
|
||||
max_skew_seconds: int = _DEFAULT_MAX_SKEW_SECONDS,
|
||||
*,
|
||||
now: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Verify a connector→gateway inbound delivery signature.
|
||||
|
||||
``body_json`` MUST be the exact request body bytes decoded as UTF-8 — the
|
||||
connector signs over the literal serialized body, so the gateway verifies
|
||||
over the literal received body (no re-serialization). Checks the timestamp
|
||||
is within ``max_skew_seconds`` of now and the HMAC matches any key in the
|
||||
rotation verify list. Mirrors the connector's ``verifyDeliverySignature``.
|
||||
"""
|
||||
if not timestamp or not signature:
|
||||
return False
|
||||
try:
|
||||
ts = int(timestamp)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
current = now if now is not None else int(time.time())
|
||||
if abs(current - ts) > max_skew_seconds:
|
||||
return False
|
||||
return verify_signature(_delivery_payload(ts, body_json), signature, verify_keys)
|
||||
118
gateway/relay/descriptor.py
Normal file
118
gateway/relay/descriptor.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""CapabilityDescriptor — the relay handshake payload. EXPERIMENTAL.
|
||||
|
||||
The connector hands a ``CapabilityDescriptor`` to the gateway's ``RelayAdapter``
|
||||
at handshake time; it tells the adapter which platform it is fronting and which
|
||||
capabilities to advertise to the ``GatewayStreamConsumer`` (char limit,
|
||||
draft-streaming, edit/threading support, markdown dialect, length unit). It is
|
||||
the linchpin of the generalization: one gateway adapter serves Discord,
|
||||
Telegram, Matrix, Signal, ... without per-platform branching.
|
||||
|
||||
EXPERIMENTAL: this schema MAY CHANGE without a deprecation cycle until at least
|
||||
two real Class-1 platforms have validated it. Evolution during the experimental
|
||||
phase is additive-only, gated by ``contract_version`` (see
|
||||
docs/relay-connector-contract.md).
|
||||
|
||||
Field origins (most are a wire-serializable projection of ``PlatformEntry`` plus
|
||||
the per-instance capability methods on ``BasePlatformAdapter``):
|
||||
|
||||
- ``max_message_length`` -> ``PlatformEntry.max_message_length`` / adapter
|
||||
``MAX_MESSAGE_LENGTH`` attribute (read by stream_consumer).
|
||||
- ``len_unit`` -> selects which ``message_len_fn`` the adapter installs
|
||||
("chars" = builtin len; "utf16" = Telegram-style UTF-16 code-unit counting).
|
||||
- ``supports_draft_streaming`` -> adapter ``supports_draft_streaming()`` probe.
|
||||
- ``supports_edit`` -> whether edit-based streaming is possible (Discord/
|
||||
Telegram yes; Signal/SMS no -> consumer degrades to one-message-per-segment).
|
||||
- ``supports_threads`` -> ``create_handoff_thread`` capability flag.
|
||||
- ``markdown_dialect`` -> presentation hint (e.g. "markdown_v2", "discord").
|
||||
- ``emoji`` / ``platform_hint`` / ``pii_safe`` -> ``PlatformEntry`` fields of the
|
||||
same name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
# Bump additively (never reinterpret an existing field) during the experimental
|
||||
# phase; a breaking change requires updating both repos in lockstep.
|
||||
CONTRACT_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityDescriptor:
|
||||
"""Immutable capability descriptor negotiated at relay handshake.
|
||||
|
||||
Frozen so a descriptor cannot be mutated after handshake — the adapter
|
||||
advertises a fixed capability profile for the life of the connection.
|
||||
"""
|
||||
|
||||
contract_version: int
|
||||
platform: str
|
||||
label: str
|
||||
max_message_length: int
|
||||
supports_draft_streaming: bool
|
||||
supports_edit: bool
|
||||
supports_threads: bool
|
||||
markdown_dialect: str
|
||||
len_unit: str # "chars" | "utf16"
|
||||
emoji: str = "\U0001f50c" # 🔌 default (matches PlatformEntry default)
|
||||
platform_hint: str = ""
|
||||
pii_safe: bool = False
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Serialize to a compact, stable JSON string for the handshake frame."""
|
||||
return json.dumps(asdict(self), sort_keys=True, ensure_ascii=False)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: str) -> "CapabilityDescriptor":
|
||||
"""Deserialize from a handshake JSON string.
|
||||
|
||||
Unknown keys are ignored (forward-compat: a newer connector may send
|
||||
fields this gateway does not know yet); missing optional keys fall back
|
||||
to dataclass defaults.
|
||||
"""
|
||||
raw = json.loads(data)
|
||||
known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
|
||||
filtered = {k: v for k, v in raw.items() if k in known}
|
||||
return cls(**filtered)
|
||||
|
||||
@classmethod
|
||||
def from_platform_entry(
|
||||
cls,
|
||||
entry,
|
||||
*,
|
||||
len_unit: str = "chars",
|
||||
supports_draft_streaming: bool = False,
|
||||
supports_edit: bool = True,
|
||||
supports_threads: bool = False,
|
||||
markdown_dialect: str = "plain",
|
||||
) -> "CapabilityDescriptor":
|
||||
"""Project a ``gateway.platform_registry.PlatformEntry`` into a descriptor.
|
||||
|
||||
Demonstrates the descriptor is a *subset/projection* of what
|
||||
``PlatformEntry`` already encodes, not a parallel concept: ``label``,
|
||||
``max_message_length``, ``emoji``, ``platform_hint``, ``pii_safe`` and
|
||||
the platform name come straight off the entry. The runtime capability
|
||||
bits that ``PlatformEntry`` does NOT encode (length unit, draft/edit/
|
||||
thread/markdown behavior) are supplied by the caller — in production
|
||||
the connector fills these from the live adapter's capability methods.
|
||||
|
||||
``max_message_length`` of 0 on a ``PlatformEntry`` means "no limit";
|
||||
we map that to the stream_consumer default of 4096 so the descriptor
|
||||
always carries a concrete chunking bound.
|
||||
"""
|
||||
max_len = getattr(entry, "max_message_length", 0) or 4096
|
||||
return cls(
|
||||
contract_version=CONTRACT_VERSION,
|
||||
platform=entry.name,
|
||||
label=entry.label,
|
||||
max_message_length=max_len,
|
||||
supports_draft_streaming=supports_draft_streaming,
|
||||
supports_edit=supports_edit,
|
||||
supports_threads=supports_threads,
|
||||
markdown_dialect=markdown_dialect,
|
||||
len_unit=len_unit,
|
||||
emoji=getattr(entry, "emoji", "\U0001f50c"),
|
||||
platform_hint=getattr(entry, "platform_hint", ""),
|
||||
pii_safe=getattr(entry, "pii_safe", False),
|
||||
)
|
||||
204
gateway/relay/inbound_receiver.py
Normal file
204
gateway/relay/inbound_receiver.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""Gateway-side inbound delivery receiver. EXPERIMENTAL.
|
||||
|
||||
The connector delivers normalized inbound events to a tenant's gateway over a
|
||||
**signed HTTP POST** (connector ``src/relay/httpGatewayDelivery.ts``), NOT over
|
||||
the gateway's outbound ``/relay`` WebSocket: the connector instance that owns a
|
||||
platform socket is generally not the instance a given gateway dialed out to, so
|
||||
inbound is delivered to a tenant ENDPOINT (which may load-balance across gateway
|
||||
instances). Each delivery is HMAC-signed with the per-tenant **delivery key**
|
||||
(``gateway/relay/auth.py``); this receiver verifies the signature over the EXACT
|
||||
raw request bytes before accepting the event.
|
||||
|
||||
Two routes (mirroring the connector's two POST targets):
|
||||
POST {base} {"type":"message", "event": <MessageEvent>, ...}
|
||||
POST {base}/interrupt {"type":"interrupt","session_key": ..., "reason"?}
|
||||
|
||||
The receiver:
|
||||
1. reads the RAW body bytes (never a reparsed/re-serialized form — the HMAC is
|
||||
over the literal bytes the connector signed),
|
||||
2. verifies ``x-relay-signature`` / ``x-relay-timestamp`` against the delivery
|
||||
key verify list (primary + secondary during rotation), within the replay
|
||||
window — rejects 401 on any failure,
|
||||
3. parses the JSON and dispatches: a ``message`` to the inbound handler (the
|
||||
RelayAdapter's ``handle_message`` via the transport's normal path), an
|
||||
``interrupt`` to the interrupt handler.
|
||||
|
||||
EXPERIMENTAL: the transport protocol may change without a deprecation cycle
|
||||
until ≥2 Class-1 platforms validate it. See docs/relay-connector-contract.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Optional, Sequence
|
||||
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.relay.auth import (
|
||||
DELIVERY_SIG_HEADER,
|
||||
DELIVERY_TS_HEADER,
|
||||
verify_delivery_signature,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Callbacks the receiver dispatches verified deliveries to.
|
||||
InboundMessageHandler = Callable[[MessageEvent], Awaitable[None]]
|
||||
InboundInterruptHandler = Callable[[str, str], Awaitable[None]]
|
||||
|
||||
try: # lazy/optional dep — mirrors the other HTTP-receiving adapters
|
||||
from aiohttp import web
|
||||
except ImportError: # pragma: no cover - exercised only when the extra is absent
|
||||
web = None # type: ignore[assignment]
|
||||
|
||||
AIOHTTP_AVAILABLE = web is not None
|
||||
|
||||
|
||||
def _event_from_wire(raw: dict) -> MessageEvent:
|
||||
"""Rebuild a MessageEvent from the connector's normalized inbound payload.
|
||||
|
||||
Identical mapping to the WS transport's ``_event_from_wire`` (the wire shape
|
||||
is the same; only the transport differs). Kept here so the HTTP receiver has
|
||||
no import dependency on the WS transport module.
|
||||
"""
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageType
|
||||
from gateway.session import SessionSource
|
||||
|
||||
src = raw.get("source", {}) or {}
|
||||
platform = src.get("platform", "relay")
|
||||
try:
|
||||
platform_enum = Platform(platform)
|
||||
except ValueError:
|
||||
platform_enum = Platform.RELAY
|
||||
|
||||
source = SessionSource(
|
||||
platform=platform_enum,
|
||||
chat_id=src.get("chat_id", ""),
|
||||
chat_type=src.get("chat_type", "dm"),
|
||||
chat_name=src.get("chat_name"),
|
||||
user_id=src.get("user_id"),
|
||||
user_name=src.get("user_name"),
|
||||
thread_id=src.get("thread_id"),
|
||||
chat_topic=src.get("chat_topic"),
|
||||
user_id_alt=src.get("user_id_alt"),
|
||||
chat_id_alt=src.get("chat_id_alt"),
|
||||
guild_id=src.get("guild_id"),
|
||||
parent_chat_id=src.get("parent_chat_id"),
|
||||
message_id=src.get("message_id"),
|
||||
)
|
||||
try:
|
||||
msg_type = MessageType(raw.get("message_type", "text"))
|
||||
except ValueError:
|
||||
msg_type = MessageType.TEXT
|
||||
|
||||
return MessageEvent(
|
||||
text=raw.get("text", ""),
|
||||
message_type=msg_type,
|
||||
source=source,
|
||||
message_id=raw.get("message_id"),
|
||||
reply_to_message_id=raw.get("reply_to_message_id"),
|
||||
media_urls=raw.get("media_urls") or [],
|
||||
)
|
||||
|
||||
|
||||
class InboundDeliveryReceiver:
|
||||
"""Verifies + dispatches signed connector→gateway inbound deliveries.
|
||||
|
||||
Transport-agnostic core: ``handle_raw`` takes the raw body bytes + headers +
|
||||
which route was hit and returns ``(status, body)``. The aiohttp wiring
|
||||
(``build_app`` / ``serve``) is a thin shell so the verify+dispatch logic is
|
||||
unit-testable without a live socket.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
delivery_key_verify_list: Callable[[], Sequence[str]],
|
||||
on_message: InboundMessageHandler,
|
||||
on_interrupt: Optional[InboundInterruptHandler] = None,
|
||||
max_skew_seconds: int = 300,
|
||||
) -> None:
|
||||
# A callable (not a static list) so a rotated delivery key is picked up
|
||||
# without rebuilding the receiver — mirrors the connector's verify list.
|
||||
self._verify_list = delivery_key_verify_list
|
||||
self._on_message = on_message
|
||||
self._on_interrupt = on_interrupt
|
||||
self._max_skew_seconds = max_skew_seconds
|
||||
|
||||
async def handle_raw(
|
||||
self, *, raw_body: bytes, timestamp: Optional[str], signature: Optional[str], is_interrupt: bool
|
||||
) -> tuple[int, dict]:
|
||||
"""Verify the signature over ``raw_body`` and dispatch. Returns (status, json).
|
||||
|
||||
401 on a missing/invalid/expired signature (never dispatches unverified).
|
||||
400 on malformed JSON. 200 on a verified, dispatched delivery.
|
||||
"""
|
||||
verify_keys = list(self._verify_list() or [])
|
||||
if not verify_keys:
|
||||
# No delivery key provisioned -> we cannot verify -> reject. A gateway
|
||||
# that hasn't enrolled must not accept inbound (fail closed).
|
||||
logger.warning("relay inbound: no delivery key configured; rejecting")
|
||||
return 401, {"error": "no delivery key configured"}
|
||||
|
||||
# Verify over the EXACT raw bytes the connector signed. Decode to text
|
||||
# with the same UTF-8 the connector's JSON.stringify produced; a single
|
||||
# differing byte breaks the HMAC (raw-body-preservation discipline).
|
||||
body_text = raw_body.decode("utf-8", errors="strict")
|
||||
if not verify_delivery_signature(
|
||||
body_text, timestamp, signature, verify_keys, self._max_skew_seconds
|
||||
):
|
||||
return 401, {"error": "invalid delivery signature"}
|
||||
|
||||
try:
|
||||
payload = json.loads(body_text)
|
||||
except json.JSONDecodeError:
|
||||
return 400, {"error": "invalid JSON body"}
|
||||
|
||||
if is_interrupt or payload.get("type") == "interrupt":
|
||||
session_key = str(payload.get("session_key", ""))
|
||||
chat_id = str(payload.get("chat_id", "") or payload.get("reason", "") or "")
|
||||
if self._on_interrupt is not None and session_key:
|
||||
await self._on_interrupt(session_key, chat_id)
|
||||
return 200, {"ok": True}
|
||||
|
||||
# Default: a normalized inbound message event.
|
||||
event_raw = payload.get("event")
|
||||
if not isinstance(event_raw, dict):
|
||||
return 400, {"error": "missing event"}
|
||||
event = _event_from_wire(event_raw)
|
||||
await self._on_message(event)
|
||||
return 200, {"ok": True}
|
||||
|
||||
# ── aiohttp wiring (thin shell over handle_raw) ──────────────────────
|
||||
def build_app(self) -> Any:
|
||||
"""Build an aiohttp Application exposing the delivery + interrupt routes."""
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"InboundDeliveryReceiver requires the 'aiohttp' package "
|
||||
"(install the messaging extra)."
|
||||
)
|
||||
|
||||
async def _deliver(request: Any) -> Any:
|
||||
return await self._respond(request, is_interrupt=False)
|
||||
|
||||
async def _interrupt(request: Any) -> Any:
|
||||
return await self._respond(request, is_interrupt=True)
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/healthz", lambda _: web.Response(text="ok"))
|
||||
app.router.add_post("/", _deliver)
|
||||
app.router.add_post("/interrupt", _interrupt)
|
||||
return app
|
||||
|
||||
async def _respond(self, request: Any, *, is_interrupt: bool) -> Any:
|
||||
# Read the RAW bytes — do NOT use request.json() (it reparses and we'd
|
||||
# verify over a re-serialized form, breaking the HMAC).
|
||||
raw_body = await request.read()
|
||||
status, body = await self.handle_raw(
|
||||
raw_body=raw_body,
|
||||
timestamp=request.headers.get(DELIVERY_TS_HEADER),
|
||||
signature=request.headers.get(DELIVERY_SIG_HEADER),
|
||||
is_interrupt=is_interrupt,
|
||||
)
|
||||
return web.json_response(body, status=status)
|
||||
101
gateway/relay/transport.py
Normal file
101
gateway/relay/transport.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Relay transport protocol — the gateway<->connector wire contract. EXPERIMENTAL.
|
||||
|
||||
The ``RelayAdapter`` (gateway side) delegates all wire I/O to a ``RelayTransport``.
|
||||
The gateway dials OUT to the connector, so a production transport is a WebSocket
|
||||
client; in tests it is an in-memory stub (``tests/gateway/relay/stub_connector.py``).
|
||||
|
||||
This module defines the protocol surface only — no concrete transport. The
|
||||
contract has four concerns:
|
||||
|
||||
1. Lifecycle: ``connect`` / ``disconnect``.
|
||||
2. Handshake: ``handshake`` returns the ``CapabilityDescriptor`` the connector
|
||||
advertises for the platform this adapter fronts.
|
||||
3. Inbound: ``set_inbound_handler`` registers a callback the transport invokes
|
||||
with each normalized ``MessageEvent`` the connector delivers.
|
||||
4. Outbound: ``send_outbound`` carries send/edit/typing actions back to the
|
||||
connector; ``get_chat_info`` proxies a chat-info lookup; ``send_interrupt``
|
||||
routes a mid-turn /stop down the socket that owns the session_key.
|
||||
|
||||
EXPERIMENTAL: may change without a deprecation cycle until >=2 Class-1 platforms
|
||||
validate it. See docs/relay-connector-contract.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.relay.descriptor import CapabilityDescriptor
|
||||
|
||||
# Callback the transport invokes for each inbound normalized event.
|
||||
InboundHandler = Callable[[MessageEvent], Awaitable[None]]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RelayTransport(Protocol):
|
||||
"""Full gateway<->connector transport contract."""
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Open the connection to the connector; return True on success."""
|
||||
...
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Close the connection."""
|
||||
...
|
||||
|
||||
async def handshake(self) -> CapabilityDescriptor:
|
||||
"""Return the capability descriptor the connector advertises."""
|
||||
...
|
||||
|
||||
def set_inbound_handler(self, handler: InboundHandler) -> None:
|
||||
"""Register the callback invoked with each inbound MessageEvent."""
|
||||
...
|
||||
|
||||
async def send_outbound(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Carry an outbound action (send/edit/typing) to the connector.
|
||||
|
||||
Returns a result dict; for ``op == "send"`` it carries
|
||||
``success`` and optionally ``message_id`` / ``error``.
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Proxy a chat-info lookup to the connector."""
|
||||
...
|
||||
|
||||
async def send_interrupt(self, session_key: str, reason: Optional[str] = None) -> None:
|
||||
"""Route a mid-turn /stop to the connector for ``session_key``.
|
||||
|
||||
The connector forwards it down the socket owned by the gateway
|
||||
instance running that session (the /stop routing invariant). On the
|
||||
gateway side this is the OUTBOUND direction; the actual task
|
||||
cancellation happens when the connector echoes an interrupt inbound
|
||||
(handled in Task 1.4).
|
||||
"""
|
||||
...
|
||||
|
||||
async def send_follow_up(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Act on a shared-identity capability bound to a session (A2 outbound).
|
||||
|
||||
Some platforms hand the connector a credential that acts on the SHARED
|
||||
bot identity (e.g. a Discord interaction follow-up token, valid ~15min).
|
||||
Under A2 that credential NEVER reaches the gateway — the connector
|
||||
stripped it at the edge and bound it in its capability vault keyed by
|
||||
the session. To use it, the gateway issues a SEMANTIC action against the
|
||||
session it is already in; it never names or holds a token.
|
||||
|
||||
The action dict carries:
|
||||
``op`` == ``"follow_up"``
|
||||
``session_key`` the session whose bound capability to wield
|
||||
``kind`` the capability kind (e.g. ``"discord.interaction_token"``)
|
||||
``content`` the message content to send via that capability
|
||||
``metadata?`` optional extras
|
||||
|
||||
The connector resolves the real capability (``resolveOutboundCapability``
|
||||
on its side), enforces the tenant match (tenant B can never wield tenant
|
||||
A's capability), and egresses. Returns ``{success, message_id?, error?}``;
|
||||
``success`` is False when the capability is absent/expired or the tenant
|
||||
doesn't match — the gateway then has nothing to retry with (by design: a
|
||||
leaked gateway holds zero capability material).
|
||||
"""
|
||||
...
|
||||
298
gateway/relay/ws_transport.py
Normal file
298
gateway/relay/ws_transport.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"""Production WebSocket RelayTransport — the gateway's live link to the connector.
|
||||
|
||||
The gateway dials OUT to the connector's relay endpoint over a WebSocket and
|
||||
speaks the newline-delimited JSON frame protocol defined in the connector repo
|
||||
(``gateway-gateway`` ``src/relay/protocol.ts``) and mirrored in
|
||||
``docs/relay-connector-contract.md``:
|
||||
|
||||
gateway -> connector : hello, outbound, interrupt
|
||||
connector -> gateway : descriptor, inbound, outbound_result, interrupt_inbound
|
||||
|
||||
Frames:
|
||||
hello {type, platform, botId}
|
||||
descriptor {type, descriptor} (handshake reply)
|
||||
inbound {type, event, bufferId?} (a normalized MessageEvent)
|
||||
outbound {type, requestId, action} (send/edit/typing/follow_up)
|
||||
outbound_result {type, requestId, result}
|
||||
interrupt {type, session_key, reason?} (gateway egresses /stop)
|
||||
interrupt_inbound{type, session_key, chat_id} (connector -> owning gateway)
|
||||
|
||||
This is the concrete transport behind the ``RelayTransport`` Protocol; the
|
||||
``RelayAdapter`` delegates all wire I/O to it. Outbound calls block on a
|
||||
per-request future keyed by ``requestId`` until the matching ``outbound_result``
|
||||
arrives. A background reader task pumps inbound frames to the registered handler
|
||||
and resolves pending outbound futures.
|
||||
|
||||
EXPERIMENTAL: the frame schema may change without a deprecation cycle until at
|
||||
least two Class-1 platforms validate it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.session import SessionSource
|
||||
from gateway.relay.descriptor import CapabilityDescriptor
|
||||
from gateway.relay.transport import InboundHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try: # lazy/optional dep — mirrors gateway/platforms/feishu.py
|
||||
import websockets
|
||||
except ImportError: # pragma: no cover - exercised only when the extra is absent
|
||||
websockets = None # type: ignore[assignment]
|
||||
|
||||
WEBSOCKETS_AVAILABLE = websockets is not None
|
||||
|
||||
# How long to wait for the handshake descriptor and for each outbound result.
|
||||
_HANDSHAKE_TIMEOUT_S = 30.0
|
||||
_OUTBOUND_TIMEOUT_S = 30.0
|
||||
|
||||
|
||||
def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
|
||||
"""Rebuild a MessageEvent from the connector's normalized inbound payload.
|
||||
|
||||
The connector emits SessionSource as the snake_case wire form (§3); map it
|
||||
back onto the gateway dataclasses. Unknown message types fall back to TEXT.
|
||||
"""
|
||||
src = raw.get("source", {}) or {}
|
||||
from gateway.config import Platform
|
||||
|
||||
platform = src.get("platform", "relay")
|
||||
try:
|
||||
platform_enum = Platform(platform)
|
||||
except ValueError:
|
||||
platform_enum = Platform.RELAY
|
||||
|
||||
source = SessionSource(
|
||||
platform=platform_enum,
|
||||
chat_id=src.get("chat_id", ""),
|
||||
chat_type=src.get("chat_type", "dm"),
|
||||
chat_name=src.get("chat_name"),
|
||||
user_id=src.get("user_id"),
|
||||
user_name=src.get("user_name"),
|
||||
thread_id=src.get("thread_id"),
|
||||
chat_topic=src.get("chat_topic"),
|
||||
user_id_alt=src.get("user_id_alt"),
|
||||
chat_id_alt=src.get("chat_id_alt"),
|
||||
guild_id=src.get("guild_id"),
|
||||
parent_chat_id=src.get("parent_chat_id"),
|
||||
message_id=src.get("message_id"),
|
||||
)
|
||||
try:
|
||||
msg_type = MessageType(raw.get("message_type", "text"))
|
||||
except ValueError:
|
||||
msg_type = MessageType.TEXT
|
||||
|
||||
return MessageEvent(
|
||||
text=raw.get("text", ""),
|
||||
message_type=msg_type,
|
||||
source=source,
|
||||
message_id=raw.get("message_id"),
|
||||
reply_to_message_id=raw.get("reply_to_message_id"),
|
||||
media_urls=raw.get("media_urls") or [],
|
||||
)
|
||||
|
||||
|
||||
class WebSocketRelayTransport:
|
||||
"""RelayTransport over a WebSocket connection the gateway dials to the connector."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
platform: str,
|
||||
bot_id: str,
|
||||
*,
|
||||
connect_timeout_s: float = _HANDSHAKE_TIMEOUT_S,
|
||||
outbound_timeout_s: float = _OUTBOUND_TIMEOUT_S,
|
||||
gateway_id: Optional[str] = None,
|
||||
upgrade_secret: Optional[str] = None,
|
||||
) -> None:
|
||||
if not WEBSOCKETS_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"WebSocketRelayTransport requires the 'websockets' package "
|
||||
"(install the messaging extra)."
|
||||
)
|
||||
self._url = url
|
||||
self._platform = platform
|
||||
self._bot_id = bot_id
|
||||
self._connect_timeout_s = connect_timeout_s
|
||||
self._outbound_timeout_s = outbound_timeout_s
|
||||
# Connection auth (Phase 2): when a per-gateway secret is configured the
|
||||
# gateway presents an HMAC bearer on the WS upgrade so the connector can
|
||||
# authenticate it (reject 4401 otherwise). gateway_id identifies the
|
||||
# enrolled instance — the connector peeks it to index its secret verify
|
||||
# list, then verifies the signature. Absent -> unauthenticated upgrade
|
||||
# (dev/test, or a connector that doesn't enforce auth).
|
||||
self._gateway_id = gateway_id
|
||||
self._upgrade_secret = upgrade_secret
|
||||
|
||||
self._ws: Any = None
|
||||
self._reader: Optional[asyncio.Task[None]] = None
|
||||
self._inbound: Optional[InboundHandler] = None
|
||||
self._descriptor: Optional[CapabilityDescriptor] = None
|
||||
self._descriptor_ready: asyncio.Future[CapabilityDescriptor] | None = None
|
||||
# requestId -> future awaiting the matching outbound_result.
|
||||
self._pending: Dict[str, asyncio.Future[Dict[str, Any]]] = {}
|
||||
self._closing = False
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────────────────
|
||||
async def connect(self) -> bool:
|
||||
loop = asyncio.get_running_loop()
|
||||
self._descriptor_ready = loop.create_future()
|
||||
headers = self._upgrade_headers()
|
||||
if headers:
|
||||
self._ws = await websockets.connect(self._url, additional_headers=headers) # type: ignore[union-attr]
|
||||
else:
|
||||
self._ws = await websockets.connect(self._url) # type: ignore[union-attr]
|
||||
self._reader = asyncio.create_task(self._read_loop(), name="relay-ws-reader")
|
||||
# Send hello; the descriptor arrives via the reader and resolves handshake().
|
||||
await self._send({"type": "hello", "platform": self._platform, "botId": self._bot_id})
|
||||
return True
|
||||
|
||||
def _upgrade_headers(self) -> Dict[str, str]:
|
||||
"""Auth headers for the WS upgrade, or {} when no secret is configured.
|
||||
|
||||
Presents ``Authorization: Bearer *** where the token is a signed
|
||||
bearer built with the per-gateway secret (``gateway/relay/auth.py``
|
||||
``make_upgrade_token``), keyed by ``gateway_id`` so the connector can
|
||||
index its verify list. The connector rejects the upgrade (close 4401)
|
||||
when this is missing/invalid/revoked; an unauthenticated connector
|
||||
ignores it.
|
||||
"""
|
||||
if not (self._upgrade_secret and self._gateway_id):
|
||||
return {}
|
||||
from gateway.relay.auth import make_upgrade_token
|
||||
|
||||
token = make_upgrade_token(self._gateway_id, self._upgrade_secret)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._closing = True
|
||||
if self._reader is not None:
|
||||
self._reader.cancel()
|
||||
try:
|
||||
await self._reader
|
||||
except (asyncio.CancelledError, Exception): # noqa: BLE001 - best-effort teardown
|
||||
pass
|
||||
self._reader = None
|
||||
if self._ws is not None:
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._ws = None
|
||||
# Fail any in-flight outbound waiters so callers don't hang.
|
||||
for fut in self._pending.values():
|
||||
if not fut.done():
|
||||
fut.set_exception(RuntimeError("relay transport closed"))
|
||||
self._pending.clear()
|
||||
|
||||
async def handshake(self) -> CapabilityDescriptor:
|
||||
if self._descriptor is not None:
|
||||
return self._descriptor
|
||||
if self._descriptor_ready is None:
|
||||
raise RuntimeError("handshake() called before connect()")
|
||||
return await asyncio.wait_for(self._descriptor_ready, timeout=self._connect_timeout_s)
|
||||
|
||||
def set_inbound_handler(self, handler: InboundHandler) -> None:
|
||||
self._inbound = handler
|
||||
|
||||
# ── outbound ─────────────────────────────────────────────────────────
|
||||
async def send_outbound(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return await self._request_response(action)
|
||||
|
||||
async def send_follow_up(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# follow_up rides the same outbound frame; the connector dispatches by
|
||||
# action.op. Kept as a distinct method to satisfy the transport Protocol
|
||||
# and to make the A2 call site explicit.
|
||||
return await self._request_response(action)
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
result = await self._request_response(
|
||||
{"op": "get_chat_info", "chat_id": chat_id}, frame_type="outbound"
|
||||
)
|
||||
# The connector answers chat-info inside the outbound_result envelope.
|
||||
info = result.get("chat_info") or result
|
||||
return {"name": info.get("name", chat_id), "type": info.get("type", "dm")}
|
||||
|
||||
async def send_interrupt(self, session_key: str, reason: Optional[str] = None) -> None:
|
||||
await self._send({"type": "interrupt", "session_key": session_key, "reason": reason})
|
||||
|
||||
async def _request_response(
|
||||
self, action: Dict[str, Any], frame_type: str = "outbound"
|
||||
) -> Dict[str, Any]:
|
||||
if self._ws is None:
|
||||
return {"success": False, "error": "relay transport not connected"}
|
||||
request_id = uuid.uuid4().hex
|
||||
loop = asyncio.get_running_loop()
|
||||
fut: asyncio.Future[Dict[str, Any]] = loop.create_future()
|
||||
self._pending[request_id] = fut
|
||||
try:
|
||||
await self._send({"type": frame_type, "requestId": request_id, "action": action})
|
||||
return await asyncio.wait_for(fut, timeout=self._outbound_timeout_s)
|
||||
except asyncio.TimeoutError:
|
||||
return {"success": False, "error": "relay outbound timed out"}
|
||||
finally:
|
||||
self._pending.pop(request_id, None)
|
||||
|
||||
# ── wire I/O ─────────────────────────────────────────────────────────
|
||||
async def _send(self, frame: Dict[str, Any]) -> None:
|
||||
if self._ws is None:
|
||||
raise RuntimeError("relay transport not connected")
|
||||
await self._ws.send(json.dumps(frame) + "\n")
|
||||
|
||||
async def _read_loop(self) -> None:
|
||||
assert self._ws is not None
|
||||
buf = ""
|
||||
try:
|
||||
async for chunk in self._ws:
|
||||
buf += chunk if isinstance(chunk, str) else chunk.decode("utf-8")
|
||||
# Newline-delimited frames; keep any trailing partial line.
|
||||
*lines, buf = buf.split("\n")
|
||||
for line in lines:
|
||||
if line.strip():
|
||||
await self._handle_frame(line)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - log + let the task end; reconnection is caller policy
|
||||
if not self._closing:
|
||||
logger.warning("relay ws read loop ended: %s", exc)
|
||||
|
||||
async def _handle_frame(self, line: str) -> None:
|
||||
try:
|
||||
frame = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("relay: skipping malformed frame")
|
||||
return
|
||||
ftype = frame.get("type")
|
||||
if ftype == "descriptor":
|
||||
descriptor = CapabilityDescriptor.from_json(json.dumps(frame.get("descriptor", {})))
|
||||
self._descriptor = descriptor
|
||||
if self._descriptor_ready is not None and not self._descriptor_ready.done():
|
||||
self._descriptor_ready.set_result(descriptor)
|
||||
elif ftype == "inbound":
|
||||
if self._inbound is not None:
|
||||
event = _event_from_wire(frame.get("event", {}))
|
||||
await self._inbound(event)
|
||||
elif ftype == "outbound_result":
|
||||
fut = self._pending.get(frame.get("requestId", ""))
|
||||
if fut is not None and not fut.done():
|
||||
fut.set_result(frame.get("result", {}))
|
||||
elif ftype == "interrupt_inbound":
|
||||
# Bridged into the adapter's interrupt path by the runner wiring.
|
||||
handler = getattr(self, "_interrupt_inbound_handler", None)
|
||||
if handler is not None:
|
||||
await handler(frame.get("session_key", ""), frame.get("chat_id", ""))
|
||||
else:
|
||||
# hello/outbound/interrupt are gateway->connector; ignore if echoed.
|
||||
pass
|
||||
|
||||
def set_interrupt_inbound_handler(self, handler: Any) -> None:
|
||||
"""Register the callback for connector->gateway interrupt_inbound frames."""
|
||||
self._interrupt_inbound_handler = handler
|
||||
80
gateway/rich_sent_store.py
Normal file
80
gateway/rich_sent_store.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Local index of text we've sent via ``sendRichMessage`` (Bot API 10.1).
|
||||
|
||||
Telegram does NOT echo a rich message's content back in ``reply_to_message``
|
||||
when a user replies to it (verified: ``.text``/``.caption`` empty,
|
||||
``.api_kwargs`` None). So replies to the launchd briefings / any rich send
|
||||
arrive with no quotable text and the agent is blind to what was referenced.
|
||||
|
||||
Fix: remember ``message_id -> text`` at send time, look it up by
|
||||
``reply_to_id`` on inbound. This module is the single source of truth for that
|
||||
index.
|
||||
|
||||
Best-effort and dependency-free: every operation swallows errors and degrades
|
||||
to a no-op / ``None`` so it can never break a send or an inbound message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
_MAX_ENTRIES = 1000
|
||||
_MAX_TEXT_CHARS = 2000
|
||||
|
||||
|
||||
def _store_path() -> str:
|
||||
home = os.environ.get("HERMES_HOME") or os.path.expanduser("~/.hermes")
|
||||
return os.path.join(home, "state", "rich_sent_index.json")
|
||||
|
||||
|
||||
def _key(chat_id, message_id) -> str:
|
||||
return f"{chat_id}:{message_id}"
|
||||
|
||||
|
||||
def record(chat_id, message_id, text: Optional[str]) -> None:
|
||||
"""Persist ``text`` for ``(chat_id, message_id)``. No-op on any failure."""
|
||||
if not text or message_id is None or chat_id is None:
|
||||
return
|
||||
path = _store_path()
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except (FileNotFoundError, ValueError):
|
||||
data = {}
|
||||
data[_key(chat_id, message_id)] = {
|
||||
"t": text[:_MAX_TEXT_CHARS],
|
||||
"ts": int(time.time()),
|
||||
}
|
||||
# Trim oldest by timestamp when over cap.
|
||||
if len(data) > _MAX_ENTRIES:
|
||||
for k, _ in sorted(
|
||||
data.items(), key=lambda kv: kv[1].get("ts", 0)
|
||||
)[: len(data) - _MAX_ENTRIES]:
|
||||
data.pop(k, None)
|
||||
tmp = f"{path}.tmp.{os.getpid()}"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False)
|
||||
os.replace(tmp, path) # atomic; tolerates concurrent writers racing
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def lookup(chat_id, message_id) -> Optional[str]:
|
||||
"""Return stored text for ``(chat_id, message_id)`` or ``None``."""
|
||||
if message_id is None or chat_id is None:
|
||||
return None
|
||||
try:
|
||||
with open(_store_path(), "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
entry = data.get(_key(chat_id, message_id))
|
||||
if isinstance(entry, dict):
|
||||
return entry.get("t") or None
|
||||
except (FileNotFoundError, ValueError, AttributeError):
|
||||
return None
|
||||
return None
|
||||
178
gateway/run.py
178
gateway/run.py
|
|
@ -692,10 +692,31 @@ def _uses_telegram_observed_group_context(channel_prompt: Optional[str]) -> bool
|
|||
return bool(channel_prompt and _TELEGRAM_OBSERVED_CONTEXT_PROMPT_MARKER in channel_prompt)
|
||||
|
||||
|
||||
def _message_timestamps_enabled(user_config: Optional[dict]) -> bool:
|
||||
"""True when gateway.message_timestamps.enabled is opted in.
|
||||
|
||||
Default OFF: injecting a ``[Tue 2026-04-28 13:40:53 CEST]`` prefix onto
|
||||
every user message changes what the model sees for all gateway users, so
|
||||
it must be explicitly enabled in config.yaml under
|
||||
``gateway.message_timestamps.enabled``.
|
||||
"""
|
||||
if not isinstance(user_config, dict):
|
||||
return False
|
||||
gw = user_config.get("gateway")
|
||||
if not isinstance(gw, dict):
|
||||
return False
|
||||
mt = gw.get("message_timestamps")
|
||||
if isinstance(mt, dict):
|
||||
return bool(mt.get("enabled", False))
|
||||
# Allow a bare ``message_timestamps: true`` shorthand.
|
||||
return bool(mt)
|
||||
|
||||
|
||||
def _build_gateway_agent_history(
|
||||
history: List[Dict[str, Any]],
|
||||
*,
|
||||
channel_prompt: Optional[str] = None,
|
||||
inject_timestamps: bool = False,
|
||||
) -> tuple[List[Dict[str, Any]], Optional[str]]:
|
||||
"""Convert stored gateway transcript rows into agent replay messages.
|
||||
|
||||
|
|
@ -704,8 +725,18 @@ def _build_gateway_agent_history(
|
|||
turns. Keeping that context out of ``conversation_history`` avoids
|
||||
consecutive-user repair merging it with the live user turn and then hiding
|
||||
the current message behind ``history_offset`` during persistence.
|
||||
|
||||
When ``inject_timestamps`` is True (gateway.message_timestamps.enabled),
|
||||
each replayed user message is rendered with a single human-readable
|
||||
timestamp prefix from its stored metadata.
|
||||
"""
|
||||
|
||||
from hermes_time import get_timezone as _get_msg_tz
|
||||
from gateway.message_timestamps import (
|
||||
render_user_content_with_timestamp as _render_msg_ts,
|
||||
)
|
||||
|
||||
_msg_tz = _get_msg_tz()
|
||||
agent_history: List[Dict[str, Any]] = []
|
||||
observed_group_context: List[str] = []
|
||||
separate_observed_context = _uses_telegram_observed_group_context(channel_prompt)
|
||||
|
|
@ -725,6 +756,8 @@ def _build_gateway_agent_history(
|
|||
continue
|
||||
|
||||
content = msg.get("content")
|
||||
if inject_timestamps and role == "user" and isinstance(content, str):
|
||||
content = _render_msg_ts(content, msg.get("timestamp"), tz=_msg_tz)
|
||||
if separate_observed_context and msg.get("observed") and role == "user" and content:
|
||||
observed_group_context.append(str(content).strip())
|
||||
continue
|
||||
|
|
@ -5077,6 +5110,31 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
"plugin discovery failed at gateway startup", exc_info=True,
|
||||
)
|
||||
|
||||
# Register the generic relay adapter when a connector relay URL is
|
||||
# configured (GATEWAY_RELAY_URL / gateway.relay_url). No URL -> no-op, so
|
||||
# direct/single-tenant deployments are unaffected. When configured, the
|
||||
# adapter dials the connector over a WebSocket, negotiates its capability
|
||||
# descriptor at handshake, and bridges inbound/outbound like any platform.
|
||||
try:
|
||||
from gateway.relay import (
|
||||
register_relay_adapter,
|
||||
relay_url,
|
||||
self_provision_if_managed,
|
||||
)
|
||||
|
||||
# Managed boot: self-provision relay creds in-process (resolve the
|
||||
# agent's NAS token -> POST /relay/provision -> set GATEWAY_RELAY_* in
|
||||
# os.environ) BEFORE registration reads them. No-op when not managed,
|
||||
# relay unconfigured, or a secret is already pinned. Never raises.
|
||||
self_provision_if_managed()
|
||||
|
||||
if register_relay_adapter():
|
||||
logger.info("relay adapter registered (connector at %s)", relay_url())
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"relay adapter registration failed at gateway startup", exc_info=True,
|
||||
)
|
||||
|
||||
# Register declarative shell hooks from cli-config.yaml. Gateway
|
||||
# has no TTY, so consent has to come from one of the three opt-in
|
||||
# channels (--accept-hooks on launch, HERMES_ACCEPT_HOOKS env var,
|
||||
|
|
@ -8259,10 +8317,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
_msg_start_time = time.time()
|
||||
_platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform)
|
||||
_msg_preview = (event.text or "")[:80].replace("\n", " ")
|
||||
_reply_id = getattr(event, "reply_to_message_id", None)
|
||||
_reply_txt = (getattr(event, "reply_to_text", None) or "")[:80].replace("\n", " ")
|
||||
logger.info(
|
||||
"inbound message: platform=%s user=%s chat=%s msg=%r",
|
||||
"inbound message: platform=%s user=%s chat=%s msg=%r reply_to_id=%s reply_to_text=%r",
|
||||
_platform_name, source.user_name or source.user_id or "unknown",
|
||||
source.chat_id or "unknown", _msg_preview,
|
||||
source.chat_id or "unknown", _msg_preview, _reply_id, _reply_txt,
|
||||
)
|
||||
|
||||
# Get or create session
|
||||
|
|
@ -8376,6 +8436,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
|
||||
# Read privacy.redact_pii from config (re-read per message)
|
||||
_redact_pii = False
|
||||
persist_user_message = None
|
||||
persist_user_timestamp = None
|
||||
try:
|
||||
_pcfg = _load_gateway_config()
|
||||
_redact_pii = bool((_pcfg.get("privacy") or {}).get("redact_pii", False))
|
||||
|
|
@ -8900,6 +8962,42 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if message_text is None:
|
||||
return
|
||||
|
||||
# Capture the platform event time as message metadata and keep the
|
||||
# persisted transcript clean (strip any leading timestamp prefix).
|
||||
# This runs regardless of the toggle so storage stays clean and the
|
||||
# send-time is preserved. Only the in-context RENDER (prepending the
|
||||
# human-readable prefix the model sees) is gated behind
|
||||
# gateway.message_timestamps.enabled — default OFF.
|
||||
try:
|
||||
from hermes_time import get_timezone as _get_evt_tz
|
||||
from gateway.message_timestamps import (
|
||||
coerce_message_timestamp as _coerce_msg_ts,
|
||||
render_user_content_with_timestamp as _render_msg_ts,
|
||||
strip_leading_message_timestamps as _strip_msg_ts,
|
||||
)
|
||||
_evt_tz = _get_evt_tz()
|
||||
_evt_ts = getattr(event, "timestamp", None)
|
||||
if message_text and isinstance(message_text, str):
|
||||
_clean_message_text, _embedded_ts = _strip_msg_ts(
|
||||
message_text, tz=_evt_tz)
|
||||
persist_user_message = _clean_message_text
|
||||
_event_epoch = _coerce_msg_ts(_evt_ts, tz=_evt_tz)
|
||||
persist_user_timestamp = (
|
||||
_event_epoch if _event_epoch is not None else _embedded_ts
|
||||
)
|
||||
if _message_timestamps_enabled(_load_gateway_config()):
|
||||
message_text = _render_msg_ts(
|
||||
_clean_message_text,
|
||||
persist_user_timestamp,
|
||||
tz=_evt_tz,
|
||||
)
|
||||
else:
|
||||
# Toggle off: model sees the clean message; the timestamp
|
||||
# is still stored as metadata for later opt-in.
|
||||
message_text = _clean_message_text
|
||||
except Exception as _ts_err:
|
||||
logger.debug("Message timestamp injection failed (non-fatal): %s", _ts_err)
|
||||
|
||||
# Bind this gateway run generation to the adapter's active-session
|
||||
# event so deferred post-delivery callbacks can be released by the
|
||||
# same run that registered them.
|
||||
|
|
@ -8933,6 +9031,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
run_generation=run_generation,
|
||||
event_message_id=self._reply_anchor_for_event(event),
|
||||
channel_prompt=event.channel_prompt,
|
||||
persist_user_message=persist_user_message,
|
||||
persist_user_timestamp=persist_user_timestamp,
|
||||
)
|
||||
|
||||
# Stop persistent typing indicator now that the agent is done
|
||||
|
|
@ -9224,7 +9324,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
"Your next message will start a fresh session."
|
||||
)
|
||||
|
||||
ts = datetime.now().isoformat()
|
||||
ts = time.time() # Unix epoch float — consistent with DB storage
|
||||
|
||||
# If this is a fresh session (no history), write the full tool
|
||||
# definitions as the first entry so the transcript is self-describing
|
||||
|
|
@ -9260,7 +9360,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
# message so the next message can load a transcript that
|
||||
# reflects what was said. Skip the assistant error text since
|
||||
# it's a gateway-generated hint, not model output. (#7100)
|
||||
_user_entry = {"role": "user", "content": message_text, "timestamp": ts}
|
||||
_user_entry = {
|
||||
"role": "user",
|
||||
"content": (
|
||||
persist_user_message
|
||||
if persist_user_message is not None
|
||||
else message_text
|
||||
),
|
||||
"timestamp": (
|
||||
persist_user_timestamp
|
||||
if persist_user_timestamp is not None
|
||||
else ts
|
||||
),
|
||||
}
|
||||
if event.message_id:
|
||||
_user_entry["message_id"] = str(event.message_id)
|
||||
self.session_store.append_to_transcript(
|
||||
|
|
@ -9274,7 +9386,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
|
||||
# If no new messages found (edge case), fall back to simple user/assistant
|
||||
if not new_messages:
|
||||
_user_entry = {"role": "user", "content": message_text, "timestamp": ts}
|
||||
_user_entry = {
|
||||
"role": "user",
|
||||
"content": (
|
||||
persist_user_message
|
||||
if persist_user_message is not None
|
||||
else message_text
|
||||
),
|
||||
"timestamp": (
|
||||
persist_user_timestamp
|
||||
if persist_user_timestamp is not None
|
||||
else ts
|
||||
),
|
||||
}
|
||||
if event.message_id:
|
||||
_user_entry["message_id"] = str(event.message_id)
|
||||
self.session_store.append_to_transcript(
|
||||
|
|
@ -9399,13 +9523,26 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
_recent_transcript = []
|
||||
for _msg in reversed(_recent_transcript[-10:]):
|
||||
if _msg.get("role") == "user":
|
||||
_already_persisted = (_msg.get("content") == message_text)
|
||||
_expected_user_content = (
|
||||
persist_user_message
|
||||
if persist_user_message is not None
|
||||
else message_text
|
||||
)
|
||||
_already_persisted = (_msg.get("content") == _expected_user_content)
|
||||
break
|
||||
if not _already_persisted:
|
||||
_user_entry = {
|
||||
"role": "user",
|
||||
"content": message_text,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"content": (
|
||||
persist_user_message
|
||||
if persist_user_message is not None
|
||||
else message_text
|
||||
),
|
||||
"timestamp": (
|
||||
persist_user_timestamp
|
||||
if persist_user_timestamp is not None
|
||||
else time.time()
|
||||
),
|
||||
}
|
||||
if getattr(event, "message_id", None):
|
||||
_user_entry["message_id"] = str(event.message_id)
|
||||
|
|
@ -13600,6 +13737,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
_interrupt_depth: int = 0,
|
||||
event_message_id: Optional[str] = None,
|
||||
channel_prompt: Optional[str] = None,
|
||||
persist_user_message: Optional[str] = None,
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run the agent with the given message and context.
|
||||
|
|
@ -14368,6 +14507,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
log_message="agent:step hook scheduling error",
|
||||
)
|
||||
|
||||
# Bridge sync event_callback → async hooks.emit for lifecycle events
|
||||
# (e.g. session:compress fires after context compression splits a session)
|
||||
def _event_callback_sync(event_type: str, context: dict) -> None:
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
_hooks_ref.emit(event_type, context),
|
||||
_loop_for_step,
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug("event_callback hook error: %s", _e)
|
||||
|
||||
# Bridge sync status_callback → async adapter.send for context pressure
|
||||
_status_adapter = self.adapters.get(source.platform)
|
||||
_status_chat_id = source.chat_id
|
||||
|
|
@ -14702,15 +14852,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
agent.stream_delta_callback = _stream_delta_cb
|
||||
agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None
|
||||
agent.status_callback = _status_callback_sync
|
||||
|
||||
# Credits / out-of-band notices (usage bands, depletion, restored).
|
||||
# Messaging has no persistent status bar, so each notice is a
|
||||
# standalone push: render to a single plaintext line and deliver via
|
||||
# the shared _deliver_platform_notice rail (honors private/public +
|
||||
# thread metadata). Fires from the agent's sync worker thread, so we
|
||||
# hop onto the gateway loop with safe_schedule_threadsafe — same
|
||||
# hop onto the gateway loop with safe_schedule_threadsafe - same
|
||||
# pattern as _status_callback_sync. The fired-once latch lives on the
|
||||
# cached agent and persists across turns, so a band crosses → one
|
||||
# cached agent and persists across turns, so a band crosses -> one
|
||||
# push (no per-turn re-nag). Recovery ("✓ Credit access restored")
|
||||
# rides the same show path (it's emitted as a success notice, not a
|
||||
# clear). The clear callback is a no-op: a sent platform message
|
||||
|
|
@ -14734,6 +14883,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
|
||||
agent.notice_callback = _notice_callback_sync
|
||||
agent.notice_clear_callback = None
|
||||
agent.event_callback = _event_callback_sync
|
||||
agent.reasoning_config = reasoning_config
|
||||
agent.service_tier = self._service_tier
|
||||
agent.request_overrides = turn_route.get("request_overrides") or {}
|
||||
|
|
@ -14899,6 +15049,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
agent_history, observed_group_context = _build_gateway_agent_history(
|
||||
history,
|
||||
channel_prompt=channel_prompt,
|
||||
inject_timestamps=_message_timestamps_enabled(_load_gateway_config()),
|
||||
)
|
||||
|
||||
# Collect MEDIA paths already in history so we can exclude them
|
||||
|
|
@ -15015,7 +15166,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
# Keep real user text separate from API-only recovery guidance. If
|
||||
# an auto-continue note is prepended below, persist the original
|
||||
# message so stale guidance never replays as user-authored text.
|
||||
_persist_user_message_override: Optional[Any] = None
|
||||
_persist_user_message_override: Optional[Any] = persist_user_message
|
||||
_persist_user_timestamp_override: Optional[float] = persist_user_timestamp
|
||||
|
||||
# Prepend pending model switch note so the model knows about the switch
|
||||
_pending_notes = getattr(self, '_pending_model_notes', {})
|
||||
|
|
@ -15155,6 +15307,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
_conversation_kwargs["persist_user_message"] = _persist_user_message_override
|
||||
elif observed_group_context:
|
||||
_conversation_kwargs["persist_user_message"] = message
|
||||
if _persist_user_timestamp_override is not None:
|
||||
_conversation_kwargs["persist_user_timestamp"] = _persist_user_timestamp_override
|
||||
result = agent.run_conversation(_api_run_message, **_conversation_kwargs)
|
||||
finally:
|
||||
unregister_gateway_notify(_approval_session_key)
|
||||
|
|
|
|||
|
|
@ -386,9 +386,10 @@ def build_session_context_prompt(
|
|||
lines.append("")
|
||||
lines.append(
|
||||
"**Platform notes:** You are running inside Yuanbao. "
|
||||
"You CAN send private (DM) messages via the send_message tool. "
|
||||
"Use target='yuanbao:direct:<account_id>' for DM "
|
||||
"and target='yuanbao:group:<group_code>' for group chat."
|
||||
"To send a private (DM) message to a user in the current group, "
|
||||
"use the yb_send_dm tool (look up the recipient by name or pass "
|
||||
"their user_id). Your normal reply is delivered to the group you "
|
||||
"are responding in."
|
||||
)
|
||||
|
||||
# Connected platforms
|
||||
|
|
@ -1322,6 +1323,7 @@ class SessionStore:
|
|||
message.get("platform_message_id") or message.get("message_id")
|
||||
),
|
||||
observed=bool(message.get("observed")),
|
||||
timestamp=message.get("timestamp"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
|
|
|
|||
|
|
@ -555,6 +555,41 @@ def read_runtime_status() -> Optional[dict[str, Any]]:
|
|||
return _read_json_file(_get_runtime_status_path())
|
||||
|
||||
|
||||
def get_runtime_status_running_pid(
|
||||
runtime: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[int]:
|
||||
"""Return a live gateway PID from the runtime status record, if valid.
|
||||
|
||||
``get_running_pid()`` is the primary liveness source because it verifies the
|
||||
runtime lock and PID file. Launch-service managers can still leave us with
|
||||
a live process and a fresh ``gateway_state.json`` but no ``gateway.pid``; use
|
||||
this as a conservative fallback by checking both the persisted state and the
|
||||
OS process identity.
|
||||
"""
|
||||
payload = runtime if runtime is not None else read_runtime_status()
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if payload.get("gateway_state") in {None, "stopped", "startup_failed"}:
|
||||
return None
|
||||
|
||||
pid = _pid_from_record(payload)
|
||||
if pid is None or not _pid_exists(pid):
|
||||
return None
|
||||
|
||||
recorded_start = payload.get("start_time")
|
||||
current_start = _get_process_start_time(pid)
|
||||
if (
|
||||
recorded_start is not None
|
||||
and current_start is not None
|
||||
and current_start != recorded_start
|
||||
):
|
||||
return None
|
||||
|
||||
if _looks_like_gateway_process(pid) or _record_looks_like_gateway(payload):
|
||||
return pid
|
||||
return None
|
||||
|
||||
|
||||
def remove_pid_file() -> None:
|
||||
"""Remove the gateway PID file, but only if it belongs to this process.
|
||||
|
||||
|
|
|
|||
|
|
@ -7231,23 +7231,61 @@ def _codex_device_code_login() -> Dict[str, Any]:
|
|||
issuer = "https://auth.openai.com"
|
||||
client_id = CODEX_OAUTH_CLIENT_ID
|
||||
|
||||
# Step 1: Request device code
|
||||
try:
|
||||
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
|
||||
resp = client.post(
|
||||
f"{issuer}/api/accounts/deviceauth/usercode",
|
||||
json={"client_id": client_id},
|
||||
headers={"Content-Type": "application/json"},
|
||||
# Step 1: Request device code. OpenAI's auth endpoint rate-limits this
|
||||
# request (HTTP 429) when login is attempted too often from the same
|
||||
# IP/account — retry with capped backoff (honoring ``Retry-After``)
|
||||
# before surfacing a clear, actionable message instead of a bare status.
|
||||
resp = None
|
||||
max_attempts = 4
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
|
||||
resp = client.post(
|
||||
f"{issuer}/api/accounts/deviceauth/usercode",
|
||||
json={"client_id": client_id},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except Exception as exc:
|
||||
raise AuthError(
|
||||
f"Failed to request device code: {exc}",
|
||||
provider="openai-codex", code="device_code_request_failed",
|
||||
)
|
||||
except Exception as exc:
|
||||
|
||||
if resp.status_code != 429:
|
||||
break
|
||||
|
||||
if attempt < max_attempts:
|
||||
retry_after = _parse_retry_after_seconds(
|
||||
getattr(resp, "headers", None)
|
||||
)
|
||||
# Exponential backoff (2s, 4s, 8s) capped, preferring the
|
||||
# server-provided Retry-After when present.
|
||||
delay = retry_after if retry_after is not None else 2 ** attempt
|
||||
delay = max(1, min(int(delay), 60))
|
||||
print(
|
||||
"OpenAI is rate-limiting login requests "
|
||||
f"(429); retrying in {delay}s..."
|
||||
)
|
||||
_time.sleep(delay)
|
||||
|
||||
if resp is not None and resp.status_code == 429:
|
||||
retry_after = _parse_retry_after_seconds(getattr(resp, "headers", None))
|
||||
wait_hint = (
|
||||
f" Try again in about {retry_after}s."
|
||||
if retry_after is not None
|
||||
else " Wait a minute and run the login again."
|
||||
)
|
||||
raise AuthError(
|
||||
f"Failed to request device code: {exc}",
|
||||
provider="openai-codex", code="device_code_request_failed",
|
||||
"OpenAI is rate-limiting Codex login requests (HTTP 429). "
|
||||
"This is a temporary throttle on OpenAI's side, not a credential "
|
||||
f"problem.{wait_hint}",
|
||||
provider="openai-codex", code=CODEX_RATE_LIMITED_CODE,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
if resp is None or resp.status_code != 200:
|
||||
status = resp.status_code if resp is not None else "unknown"
|
||||
raise AuthError(
|
||||
f"Device code request returned status {resp.status_code}.",
|
||||
f"Device code request returned status {status}.",
|
||||
provider="openai-codex", code="device_code_request_error",
|
||||
)
|
||||
|
||||
|
|
@ -7335,6 +7373,22 @@ def _codex_device_code_login() -> Dict[str, Any]:
|
|||
provider="openai-codex", code="token_exchange_failed",
|
||||
)
|
||||
|
||||
if token_resp.status_code == 429:
|
||||
retry_after = _parse_retry_after_seconds(
|
||||
getattr(token_resp, "headers", None)
|
||||
)
|
||||
wait_hint = (
|
||||
f" Try again in about {retry_after}s."
|
||||
if retry_after is not None
|
||||
else " Wait a minute and run the login again."
|
||||
)
|
||||
raise AuthError(
|
||||
"OpenAI is rate-limiting Codex login requests (HTTP 429) during "
|
||||
"token exchange. This is a temporary throttle on OpenAI's side, "
|
||||
f"not a credential problem.{wait_hint}",
|
||||
provider="openai-codex", code=CODEX_RATE_LIMITED_CODE,
|
||||
)
|
||||
|
||||
if token_resp.status_code != 200:
|
||||
raise AuthError(
|
||||
f"Token exchange returned status {token_resp.status_code}.",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,39 @@ _EXCLUDED_NAMES = {
|
|||
"cron.pid",
|
||||
}
|
||||
|
||||
# File names that ``hermes import`` must never overwrite, matched by basename so
|
||||
# they're caught for the root profile (``gateway_state.json``) and for named
|
||||
# profiles alike (``profiles/<name>/gateway_state.json``).
|
||||
#
|
||||
# These hold *volatile gateway/process runtime state that is namespaced to the
|
||||
# machine or container the backup was taken on* — PIDs in a dead process
|
||||
# namespace, a runtime lock, the process registry, and the gateway's last
|
||||
# recorded run/desired state. Restoring them onto a different host (or a hosted
|
||||
# container) is at best meaningless and at worst actively harmful:
|
||||
#
|
||||
# - ``gateway_state.json`` drives the container-boot reconciler
|
||||
# (``container_boot._read_desired_state``), which only auto-starts a
|
||||
# gateway whose recorded state is ``running``. A backup taken from a
|
||||
# machine where the gateway was stopped (or carrying a stale/foreign
|
||||
# value) overwrites the container's own state and leaves the gateway
|
||||
# stuck "starting"/"cooking", disconnecting it from the Nous portal
|
||||
# (NS-508 / the second half of NS-501).
|
||||
# - ``gateway.pid`` / ``cron.pid`` / ``gateway.lock`` / ``processes.json``
|
||||
# reference PIDs and locks in the *source* machine's process namespace; a
|
||||
# numerically-equal PID in the new environment is a different process.
|
||||
# These mirror exactly what ``container_boot._STALE_RUNTIME_FILES`` already
|
||||
# sweeps on every container boot.
|
||||
#
|
||||
# Older backups predate the backup-side exclusions, so we filter on import too
|
||||
# rather than trusting the archive's contents.
|
||||
_IMPORT_SKIP_NAMES = {
|
||||
"gateway_state.json",
|
||||
"gateway.pid",
|
||||
"cron.pid",
|
||||
"gateway.lock",
|
||||
"processes.json",
|
||||
}
|
||||
|
||||
# zipfile.open() drops Unix mode bits on extract; restore tightens these to 0600.
|
||||
_SECRET_FILE_NAMES = {".env", "auth.json", "state.db"}
|
||||
|
||||
|
|
@ -385,6 +418,7 @@ def run_import(args) -> None:
|
|||
|
||||
errors = []
|
||||
restored = 0
|
||||
skipped_runtime: list[str] = []
|
||||
t0 = time.monotonic()
|
||||
|
||||
for member in members:
|
||||
|
|
@ -397,6 +431,16 @@ def run_import(args) -> None:
|
|||
if not rel:
|
||||
continue
|
||||
|
||||
# Never overwrite volatile gateway/process runtime state. These are
|
||||
# namespaced to the machine/container the backup was taken on;
|
||||
# clobbering them (especially gateway_state.json) breaks the gateway
|
||||
# reconciler on the target and disconnects hosted instances from the
|
||||
# Nous portal. Matched by basename so both the root profile and
|
||||
# named profiles (profiles/<name>/gateway_state.json) are covered.
|
||||
if Path(rel).name in _IMPORT_SKIP_NAMES:
|
||||
skipped_runtime.append(rel)
|
||||
continue
|
||||
|
||||
target = hermes_root / rel
|
||||
|
||||
# Security: reject absolute paths and traversals
|
||||
|
|
@ -433,6 +477,16 @@ def run_import(args) -> None:
|
|||
if len(errors) > 10:
|
||||
print(f" ... and {len(errors) - 10} more")
|
||||
|
||||
if skipped_runtime:
|
||||
print(
|
||||
f"\n Preserved {len(skipped_runtime)} runtime state "
|
||||
f"file(s) (kept this machine's, not the backup's):"
|
||||
)
|
||||
for rel in sorted(skipped_runtime)[:10]:
|
||||
print(f" {rel}")
|
||||
if len(skipped_runtime) > 10:
|
||||
print(f" ... and {len(skipped_runtime) - 10} more")
|
||||
|
||||
# Post-import: restore profile wrapper scripts
|
||||
profiles_dir = hermes_root / "profiles"
|
||||
restored_profiles = []
|
||||
|
|
|
|||
|
|
@ -1280,6 +1280,10 @@ class SlashCommandCompleter(Completer):
|
|||
current word doesn't look like a path. A word is path-like when
|
||||
it starts with ``./``, ``../``, ``~/``, ``/``, or contains a
|
||||
``/`` separator (e.g. ``src/main.py``).
|
||||
|
||||
Tokens containing a ``://`` scheme separator (e.g. URLs like
|
||||
``https://example.com/x``) are excluded even though they contain a
|
||||
``/`` — they are never useful local-path completions.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
|
@ -1291,6 +1295,12 @@ class SlashCommandCompleter(Completer):
|
|||
word = text[i + 1:]
|
||||
if not word:
|
||||
return None
|
||||
# URLs contain "/" but are not local paths. Treating them as paths fires
|
||||
# os.listdir on every keystroke while typing/pasting a link (e.g. an
|
||||
# https:// URL becomes a listdir of "https:") — pure latency, never a
|
||||
# useful completion. Skip any token with a scheme separator.
|
||||
if "://" in word:
|
||||
return None
|
||||
# Only trigger path completion for path-like tokens
|
||||
if word.startswith(("./", "../", "~/", "/")) or "/" in word:
|
||||
return word
|
||||
|
|
|
|||
|
|
@ -350,52 +350,124 @@ def get_managed_update_command() -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _install_method_project_root(project_root: Optional[Path] = None) -> Path:
|
||||
"""Resolve the directory that holds the *running code* (the install tree).
|
||||
|
||||
This is the parent of ``hermes_cli/`` — i.e. the git checkout for source
|
||||
installs, ``/opt/hermes`` inside the published image, the venv's
|
||||
site-packages root for pip installs. It is a property of the running
|
||||
interpreter, NOT of ``$HERMES_HOME``, which is why a code-scoped stamp
|
||||
here is immune to two installs sharing one data directory.
|
||||
"""
|
||||
if project_root is not None:
|
||||
return project_root
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
|
||||
def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||
"""Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'.
|
||||
|
||||
Resolution order:
|
||||
1. Stamped ``~/.hermes/.install_method`` file (written by installers)
|
||||
2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
|
||||
3. .git directory presence -> 'git'
|
||||
4. Fallback -> 'pip'
|
||||
1. Code-scoped stamp ``<install tree>/.install_method`` (next to the
|
||||
running code) — the authoritative marker.
|
||||
2. Legacy home-scoped stamp ``$HERMES_HOME/.install_method`` — read for
|
||||
backward compatibility, but a ``docker`` value is IGNORED when we are
|
||||
not actually running inside a container (see below).
|
||||
3. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
|
||||
4. .git directory presence -> 'git'
|
||||
5. Fallback -> 'pip'
|
||||
|
||||
Why the stamp is code-scoped, not home-scoped (issue: shared ``~/.hermes``)
|
||||
--------------------------------------------------------------------------
|
||||
The install method describes *the binary that is running*, but
|
||||
``$HERMES_HOME`` is a shared DATA directory — the Docker docs deliberately
|
||||
bind-mount it (``~/.hermes:/opt/data``) so config/sessions/memory persist
|
||||
and can be shared with a host-side Desktop/CLI install. When a
|
||||
containerised gateway and a host install share one ``$HERMES_HOME``, a
|
||||
home-scoped stamp is a single slot describing two different installs:
|
||||
the container stamps ``docker`` on every boot, the host install then reads
|
||||
``docker`` and ``hermes update`` refuses to run ("doesn't apply inside the
|
||||
Docker container") even though the host binary is a perfectly updatable
|
||||
git/pip install. Scoping the stamp to the install tree gives each install
|
||||
its own truthful marker.
|
||||
|
||||
Self-healing for already-poisoned homes: a legacy ``docker`` value in the
|
||||
home-scoped stamp is only honoured when we are genuinely in a container.
|
||||
On a host install that read a contaminating ``docker`` stamp, we fall
|
||||
through to managed/.git/pip detection instead — so existing shared-home
|
||||
setups recover without the user touching anything.
|
||||
|
||||
Note: running inside a container is NOT treated as "docker" on its own.
|
||||
The two supported install paths both self-identify via the
|
||||
``.install_method`` stamp (caught by step 1), so neither relies on
|
||||
container detection here:
|
||||
The supported installs self-identify via the code-scoped stamp:
|
||||
- the curl installer (scripts/install.sh, the README/website install
|
||||
command) git-clones the repo and stamps ``git``;
|
||||
- the published ``nousresearch/hermes-agent`` image stamps ``docker``
|
||||
at boot via ``docker/stage2-hook.sh``.
|
||||
An unsupported manual install dropped into a container (no stamp) was
|
||||
wrongly classified as the published image by bare container detection,
|
||||
so ``hermes update`` bailed with "doesn't apply inside the Docker
|
||||
container". Without that fallback such installs fall through to the
|
||||
``.git``/pip checks and behave like any off-path install. See issue #34397.
|
||||
command) git-clones the repo and stamps ``git`` next to the code;
|
||||
- the published ``nousresearch/hermes-agent`` image bakes a ``docker``
|
||||
stamp into ``/opt/hermes`` at build time.
|
||||
An unsupported manual install dropped into a container (no stamp) falls
|
||||
through to the ``.git``/pip checks and behaves like any off-path install.
|
||||
See issue #34397.
|
||||
"""
|
||||
stamp = get_hermes_home() / ".install_method"
|
||||
root = _install_method_project_root(project_root)
|
||||
|
||||
# 1. Code-scoped stamp — authoritative, immune to shared $HERMES_HOME.
|
||||
try:
|
||||
method = stamp.read_text(encoding="utf-8").strip().lower()
|
||||
method = (root / ".install_method").read_text(encoding="utf-8").strip().lower()
|
||||
if method:
|
||||
return method
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 2. Legacy home-scoped stamp — back-compat. Ignore a ``docker`` value
|
||||
# when we are not actually containerised: that is the signature of a
|
||||
# host install whose shared $HERMES_HOME was stamped by a co-located
|
||||
# container, and honouring it wrongly blocks ``hermes update``.
|
||||
try:
|
||||
method = (
|
||||
(get_hermes_home() / ".install_method")
|
||||
.read_text(encoding="utf-8")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if method and not (method == "docker" and not _running_in_container()):
|
||||
return method
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
managed = get_managed_system()
|
||||
if managed:
|
||||
return managed.lower().replace(" ", "-")
|
||||
if project_root is None:
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
if (project_root / ".git").is_dir():
|
||||
if (root / ".git").is_dir():
|
||||
return "git"
|
||||
return "pip"
|
||||
|
||||
|
||||
def stamp_install_method(method: str) -> None:
|
||||
"""Write the install method to ~/.hermes/.install_method."""
|
||||
stamp = get_hermes_home() / ".install_method"
|
||||
def _running_in_container() -> bool:
|
||||
"""Thin wrapper around ``hermes_constants.is_container`` (import-safe)."""
|
||||
try:
|
||||
stamp.parent.mkdir(parents=True, exist_ok=True)
|
||||
stamp.write_text(method + "\n", encoding="utf-8")
|
||||
from hermes_constants import is_container
|
||||
|
||||
return is_container()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def stamp_install_method(method: str, project_root: Optional[Path] = None) -> None:
|
||||
"""Write the install method next to the running code (code-scoped stamp).
|
||||
|
||||
The stamp lives in the install tree (``<install tree>/.install_method``),
|
||||
not in ``$HERMES_HOME``, so that two installs sharing one data directory
|
||||
do not overwrite each other's marker. See ``detect_install_method`` for
|
||||
the full rationale.
|
||||
|
||||
Best-effort: if the install tree is read-only (e.g. the immutable
|
||||
``/opt/hermes`` in the published image, which instead bakes the stamp at
|
||||
build time) the write silently no-ops and detection falls back to its
|
||||
other signals.
|
||||
"""
|
||||
root = _install_method_project_root(project_root)
|
||||
try:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / ".install_method").write_text(method + "\n", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
|
@ -1104,6 +1176,14 @@ DEFAULT_CONFIG = {
|
|||
"min_interval_hours": 24,
|
||||
},
|
||||
|
||||
# Hard cap (chars) for a single automatic context file such as SOUL.md,
|
||||
# AGENTS.md, CLAUDE.md, .hermes.md, or .cursorrules before Hermes applies
|
||||
# head/tail truncation. ``null`` (the default) lets the cap scale with the
|
||||
# model's context window (floor 20K, ceiling 500K) so large-context models
|
||||
# rarely truncate a project doc. Set a positive integer to pin a fixed cap
|
||||
# and override the dynamic behavior. Separate from read_file tool limits.
|
||||
"context_file_max_chars": None,
|
||||
|
||||
# Maximum characters returned by a single read_file call. Reads that
|
||||
# exceed this are rejected with guidance to use offset+limit.
|
||||
# 100K chars ≈ 25–35K tokens across typical tokenisers.
|
||||
|
|
@ -1890,6 +1970,14 @@ DEFAULT_CONFIG = {
|
|||
# Archive a skill (move to skills/.archive/) after this many days
|
||||
# without use. Archived skills are recoverable — no auto-deletion.
|
||||
"archive_after_days": 90,
|
||||
# Run the 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 umbrella-building, no aux-model cost.
|
||||
# Set to true to opt back into merging overlapping skills into
|
||||
# class-level umbrellas. `hermes curator run --consolidate` overrides
|
||||
# this for a single invocation.
|
||||
"consolidate": False,
|
||||
# Also prune (archive) bundled built-in skills after the inactivity
|
||||
# period, not just agent-created ones. ON by default. Built-ins are
|
||||
# normally restored on every `hermes update`, so pruning them only
|
||||
|
|
@ -2265,6 +2353,17 @@ DEFAULT_CONFIG = {
|
|||
# Gateway settings — control how messaging platforms (Telegram, Discord,
|
||||
# Slack, etc.) deliver agent-produced files as native attachments.
|
||||
"gateway": {
|
||||
# Inject a human-readable timestamp prefix (e.g.
|
||||
# "[Tue 2026-04-28 13:40:53 CEST]") onto user messages IN THE MODEL'S
|
||||
# CONTEXT so the agent has temporal awareness of when each message was
|
||||
# sent. Off by default — when off, the model sees clean message text.
|
||||
# Persisted transcripts always stay clean (the timestamp is stored as
|
||||
# message metadata regardless of this toggle), so turning it on later
|
||||
# surfaces send-times for past messages too.
|
||||
"message_timestamps": {
|
||||
"enabled": False,
|
||||
},
|
||||
|
||||
# When false (default), any file path the agent emits is delivered
|
||||
# as a native attachment as long as it isn't under the credential /
|
||||
# system-path denylist (/etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env,
|
||||
|
|
@ -2553,7 +2652,7 @@ DEFAULT_CONFIG = {
|
|||
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 29,
|
||||
"_config_version": 30,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -4842,6 +4941,29 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
|||
if not quiet:
|
||||
print(" ✓ Renamed write_mode → write_approval (boolean gate)")
|
||||
|
||||
# ── Version 29 → 30: seed curator.consolidate (default false) ──
|
||||
# Consolidation (the LLM umbrella-building fork) is now an opt-in toggle,
|
||||
# OFF by default. The deterministic inactivity prune still runs whenever
|
||||
# the curator is enabled; only the opinionated, aux-model-cost LLM pass is
|
||||
# gated. The runtime deep-merge already supplies the default, but we seed
|
||||
# the key so it's visible/editable in config.yaml. Existing installs that
|
||||
# WANT the old always-consolidate behavior must set it to true explicitly.
|
||||
# Only add the key when a curator section exists and lacks it — never
|
||||
# clobber a value the user already set.
|
||||
if current_ver < 30:
|
||||
config = read_raw_config()
|
||||
raw_curator = config.get("curator")
|
||||
if isinstance(raw_curator, dict) and "consolidate" not in raw_curator:
|
||||
raw_curator["consolidate"] = False
|
||||
config["curator"] = raw_curator
|
||||
save_config(config)
|
||||
results["config_added"].append("curator.consolidate=false")
|
||||
if not quiet:
|
||||
print(
|
||||
" ✓ Seeded curator.consolidate: false "
|
||||
"(LLM consolidation is now opt-in; pruning stays on)"
|
||||
)
|
||||
|
||||
# ── Post-migration: disable exfiltration-shaped MCP stdio entries ──
|
||||
# Users can hand-edit mcp_servers, and older installs may already contain a
|
||||
# malicious entry. Preserve the stanza for auditability but mark it
|
||||
|
|
|
|||
|
|
@ -77,6 +77,10 @@ def _cmd_status(args) -> int:
|
|||
print(f" interval: every {_interval_label}")
|
||||
print(f" stale after: {curator.get_stale_after_days()}d unused")
|
||||
print(f" archive after: {curator.get_archive_after_days()}d unused")
|
||||
print(
|
||||
f" consolidate: {'on' if curator.get_consolidate() else 'off'}"
|
||||
f"{'' if curator.get_consolidate() else ' (prune-only; LLM merge pass opt-in)'}"
|
||||
)
|
||||
|
||||
rows = skill_usage.agent_created_report()
|
||||
if not rows:
|
||||
|
|
@ -174,10 +178,20 @@ def _cmd_run(args) -> int:
|
|||
dry = bool(getattr(args, "dry_run", False))
|
||||
background = bool(getattr(args, "background", False))
|
||||
synchronous = bool(getattr(args, "synchronous", False)) or not background
|
||||
# --consolidate forces the LLM umbrella-building pass on for this run,
|
||||
# overriding the config default (off). When the flag is absent, pass None
|
||||
# so run_curator_review reads curator.consolidate from config.
|
||||
consolidate = True if bool(getattr(args, "consolidate", False)) else None
|
||||
if dry:
|
||||
print("curator: running DRY-RUN (report only, no mutations)...")
|
||||
else:
|
||||
print("curator: running review pass...")
|
||||
if consolidate is None and not curator.get_consolidate():
|
||||
print(
|
||||
"curator: consolidation is off — running prune-only "
|
||||
"(deterministic stale/archive). Pass --consolidate or set "
|
||||
"`curator.consolidate: true` to enable the LLM merge pass."
|
||||
)
|
||||
|
||||
def _on_summary(msg: str) -> None:
|
||||
print(msg)
|
||||
|
|
@ -186,6 +200,7 @@ def _cmd_run(args) -> int:
|
|||
on_summary=_on_summary,
|
||||
synchronous=synchronous,
|
||||
dry_run=dry,
|
||||
consolidate=consolidate,
|
||||
)
|
||||
auto = result.get("auto_transitions", {})
|
||||
if auto:
|
||||
|
|
@ -503,6 +518,12 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
|||
help="Report only — no state changes, no archives, no consolidation "
|
||||
"(use this to preview what curator would do)",
|
||||
)
|
||||
p_run.add_argument(
|
||||
"--consolidate", dest="consolidate", action="store_true",
|
||||
help="Force the LLM umbrella-building consolidation pass on for this "
|
||||
"run, overriding the config default (off). Without this flag the "
|
||||
"run is prune-only unless `curator.consolidate: true` is set.",
|
||||
)
|
||||
p_run.set_defaults(func=_cmd_run)
|
||||
|
||||
p_pause = subs.add_parser("pause", help="Pause the curator until resumed")
|
||||
|
|
|
|||
|
|
@ -56,6 +56,30 @@ def _get_git_commit(project_root: Path) -> str:
|
|||
return "(unknown)"
|
||||
|
||||
|
||||
def _get_git_commit_date(project_root: Path) -> str:
|
||||
"""Return the date the HEAD commit was authored (YYYY-MM-DD), or ''.
|
||||
|
||||
Resolves live via ``git log`` on source installs. The published Docker
|
||||
image excludes ``.git``, so this returns '' there — the dump line simply
|
||||
drops the date suffix in that case (the baked SHA still identifies the
|
||||
build).
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%cd", "--date=short", "HEAD"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
cwd=str(project_root),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
value = result.stdout.strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(value: str) -> str:
|
||||
"""Redact all but first 4 and last 4 chars.
|
||||
|
||||
|
|
@ -231,12 +255,12 @@ def run_dump(args):
|
|||
hermes_home = get_hermes_home()
|
||||
|
||||
try:
|
||||
from hermes_cli import __version__, __release_date__
|
||||
from hermes_cli import __version__
|
||||
except ImportError:
|
||||
__version__ = "(unknown)"
|
||||
__release_date__ = ""
|
||||
|
||||
commit = _get_git_commit(project_root)
|
||||
commit_date = _get_git_commit_date(project_root)
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
|
|
@ -283,10 +307,14 @@ def run_dump(args):
|
|||
|
||||
lines = []
|
||||
lines.append("--- hermes dump ---")
|
||||
# Identify the build by commit + the date that commit was made, resolved
|
||||
# live via git. __release_date__ (the package release date) is
|
||||
# intentionally NOT shown here — it reads like a wall-clock timestamp and
|
||||
# confuses support triage. The commit date is the real "as-of" date.
|
||||
ver_str = f"{__version__}"
|
||||
if __release_date__:
|
||||
ver_str += f" ({__release_date__})"
|
||||
ver_str += f" [{commit}]"
|
||||
if commit_date:
|
||||
ver_str += f" ({commit_date})"
|
||||
lines.append(f"version: {ver_str}")
|
||||
lines.append(f"os: {os_info}")
|
||||
lines.append(f"python: {sys.version.split()[0]}")
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Handles: hermes gateway [run|start|stop|restart|status|install|uninstall|setup]
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
|
|
@ -3467,14 +3468,60 @@ def refresh_launchd_plist_if_needed() -> bool:
|
|||
|
||||
plist_path.write_text(new_plist, encoding="utf-8")
|
||||
label = get_launchd_label()
|
||||
domain = _launchd_domain()
|
||||
target = f"{domain}/{label}"
|
||||
|
||||
# If this refresh is running INSIDE the gateway's own launchd process tree
|
||||
# (e.g. the agent triggered a self-update via its terminal tool), a direct
|
||||
# `launchctl bootout` tears down the service's process group — which
|
||||
# includes THIS CLI — before the follow-up `bootstrap` can run. The gateway
|
||||
# then stays unloaded and KeepAlive can't revive it (#43842). Detect that
|
||||
# case and hand the reload to a detached session that survives the bootout.
|
||||
gateway_pid = None
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
gateway_pid = get_running_pid()
|
||||
except Exception:
|
||||
gateway_pid = None
|
||||
|
||||
if (
|
||||
gateway_pid is not None
|
||||
and _is_pid_ancestor_of_current_process(gateway_pid)
|
||||
and hasattr(os, "setsid") # POSIX-only; launchd is macOS so always true here
|
||||
):
|
||||
# Delegate to a new session: `start_new_session=True` detaches the
|
||||
# helper from the gateway's process group, so the bootout that kills
|
||||
# the gateway (and us) does not kill the helper before it bootstraps.
|
||||
reload_script = (
|
||||
f"sleep 2; "
|
||||
f"launchctl bootout {shlex.quote(target)} 2>/dev/null; "
|
||||
f"sleep 1; "
|
||||
f"launchctl bootstrap {shlex.quote(domain)} {shlex.quote(str(plist_path))} 2>/dev/null"
|
||||
)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["/bin/bash", "-c", reload_script],
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Deferred launchd reload could not be spawned: %s", e)
|
||||
return False
|
||||
print(
|
||||
"↻ Updated gateway launchd service definition; reload deferred to a "
|
||||
"detached helper (refresh ran inside the gateway process tree)"
|
||||
)
|
||||
return True
|
||||
|
||||
# Bootout/bootstrap so launchd picks up the new definition
|
||||
subprocess.run(
|
||||
["launchctl", "bootout", f"{_launchd_domain()}/{label}"],
|
||||
["launchctl", "bootout", target],
|
||||
check=False,
|
||||
timeout=90,
|
||||
)
|
||||
subprocess.run(
|
||||
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
|
||||
["launchctl", "bootstrap", domain, str(plist_path)],
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
|
|
|
|||
250
hermes_cli/gateway_enroll.py
Normal file
250
hermes_cli/gateway_enroll.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"""``hermes gateway enroll`` — enroll a self-hosted gateway with a relay connector.
|
||||
|
||||
The connector⇄gateway channel is authenticated (the gateway may be
|
||||
customer-managed and internet-exposed). This command is the gateway half of the
|
||||
zero-touch enrollment in the connector repo's
|
||||
``docs/connector-gateway-auth-design.md``:
|
||||
|
||||
1. Resolve a fresh Nous Portal access token from the existing login
|
||||
(``~/.hermes/auth.json``) — the same path ``hermes dashboard register``
|
||||
uses (``resolve_nous_access_token``). This proves *which Nous org (tenant)*
|
||||
the caller owns; the connector derives the authoritative tenant from it via
|
||||
``GET /api/oauth/account`` (never from anything the gateway asserts).
|
||||
2. POST ``{enrollmentToken, gatewayId}`` to the connector's ``/relay/enroll``
|
||||
with that token in the ``Authorization`` header, over TLS.
|
||||
3. The connector verifies the enrollment token (signature + single-use +
|
||||
tenant match), mints a per-gateway secret, get-or-creates the per-tenant
|
||||
delivery key, and returns both ONCE.
|
||||
4. Persist ``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` /
|
||||
``GATEWAY_RELAY_DELIVERY_KEY`` (+ ``GATEWAY_RELAY_URL`` if supplied) into
|
||||
``~/.hermes/.env``. The per-gateway secret authenticates the WS upgrade;
|
||||
the per-tenant delivery key verifies signed inbound deliveries.
|
||||
|
||||
Managed/hosted installs do NOT self-enroll: the orchestrator (NAS) mints the
|
||||
secret directly and stamps it into the container env, so this command refuses to
|
||||
run under ``is_managed()`` (mirrors ``dashboard register``).
|
||||
|
||||
EXPERIMENTAL: the relay auth scheme may change without a deprecation cycle until
|
||||
≥2 Class-1 platforms validate the contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _default_gateway_id() -> str:
|
||||
"""A stable-ish default gateway instance id: ``<hostname>-<pid-free slug>``.
|
||||
|
||||
The gatewayId identifies this enrolled instance for kill-switch granularity
|
||||
(the connector indexes its secret verify list by it). Default to the host
|
||||
name so a human can recognize it; overridable via ``--gateway-id``.
|
||||
"""
|
||||
host = ""
|
||||
try:
|
||||
host = socket.gethostname().strip()
|
||||
except Exception:
|
||||
host = ""
|
||||
return f"gw-{host or 'hermes'}"
|
||||
|
||||
|
||||
def _resolve_connector_url(override: Optional[str]) -> Optional[str]:
|
||||
"""Resolve the connector base URL (no trailing slash) for enrollment.
|
||||
|
||||
Precedence: explicit ``--connector-url`` flag > ``GATEWAY_RELAY_URL`` env >
|
||||
``gateway.relay_url`` in config.yaml. The relay URL is a ``ws(s)://`` dial
|
||||
target; enrollment is an ``http(s)://`` POST to the same host, so we map the
|
||||
scheme. Returns None when nothing is configured (the user must supply one).
|
||||
"""
|
||||
raw = (override or os.environ.get("GATEWAY_RELAY_URL", "")).strip()
|
||||
if not raw:
|
||||
try:
|
||||
from gateway.run import _load_gateway_config # late import to avoid cycle
|
||||
|
||||
cfg = (_load_gateway_config().get("gateway") or {})
|
||||
raw = str(cfg.get("relay_url", "") or "").strip()
|
||||
except Exception:
|
||||
raw = ""
|
||||
if not raw:
|
||||
return None
|
||||
raw = raw.rstrip("/")
|
||||
# The relay dial URL is ws(s)://…/relay; enrollment posts to http(s)://…/relay/enroll.
|
||||
if raw.startswith("ws://"):
|
||||
raw = "http://" + raw[len("ws://"):]
|
||||
elif raw.startswith("wss://"):
|
||||
raw = "https://" + raw[len("wss://"):]
|
||||
# Strip a trailing /relay path segment if the user pasted the dial URL.
|
||||
if raw.endswith("/relay"):
|
||||
raw = raw[: -len("/relay")]
|
||||
return raw
|
||||
|
||||
|
||||
def _post_enroll(
|
||||
*,
|
||||
connector_base_url: str,
|
||||
access_token: str,
|
||||
enrollment_token: str,
|
||||
gateway_id: str,
|
||||
timeout: float = 15.0,
|
||||
) -> dict:
|
||||
"""POST to the connector's ``/relay/enroll`` and return the JSON body.
|
||||
|
||||
Raises RuntimeError with a user-facing message on any non-2xx / transport
|
||||
failure. The connector returns ``{secret, deliveryKey, tenant, gatewayId}``
|
||||
on success, ``{error}`` at 400/401/403.
|
||||
"""
|
||||
url = f"{connector_base_url.rstrip('/')}/relay/enroll"
|
||||
data = json.dumps({"enrollmentToken": enrollment_token, "gatewayId": gateway_id}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
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
|
||||
if exc.code == 401:
|
||||
raise RuntimeError(
|
||||
"Connector rejected the caller identity (401). Your Nous Portal "
|
||||
"token could not be verified — try `hermes auth login nous` and retry."
|
||||
) from exc
|
||||
if exc.code == 403:
|
||||
raise RuntimeError(
|
||||
detail
|
||||
or "Enrollment token invalid, expired, already used, or tenant mismatch (403)."
|
||||
) from exc
|
||||
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 the connector at {connector_base_url}: {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 cmd_gateway_enroll(args) -> None:
|
||||
"""Enroll this gateway with a relay connector; persist the auth creds to .env."""
|
||||
from hermes_cli.auth import AuthError, resolve_nous_access_token
|
||||
from hermes_cli.config import is_managed, save_env_value
|
||||
|
||||
# Managed installs get GATEWAY_RELAY_* stamped in by the orchestrator (NAS
|
||||
# mints the secret directly per the design's managed shape). Self-enrolling
|
||||
# from inside such a container is a mistake — and save_env_value refuses to
|
||||
# write anyway.
|
||||
if is_managed():
|
||||
print(
|
||||
"✗ `hermes gateway enroll` is not available in a managed/hosted install.\n"
|
||||
" The relay gateway secret is provisioned by the hosting platform."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
enrollment_token = (getattr(args, "token", None) or os.environ.get("GATEWAY_RELAY_ENROLL_TOKEN", "")).strip()
|
||||
if not enrollment_token:
|
||||
print(
|
||||
"✗ No enrollment token. Pass --token <token> (or set "
|
||||
"GATEWAY_RELAY_ENROLL_TOKEN).\n"
|
||||
" The connector mints this single-use token when your tenant's route "
|
||||
"is provisioned; it is delivered with your gateway config."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
connector_base_url = _resolve_connector_url(getattr(args, "connector_url", None))
|
||||
if not connector_base_url:
|
||||
print(
|
||||
"✗ No connector URL. Pass --connector-url <url> (or set GATEWAY_RELAY_URL "
|
||||
"/ gateway.relay_url in config.yaml)."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
gateway_id = (getattr(args, "gateway_id", None) or _default_gateway_id()).strip()
|
||||
|
||||
# 1. Resolve a fresh Nous access token (the tenant-proving identity).
|
||||
try:
|
||||
access_token = resolve_nous_access_token()
|
||||
except AuthError as exc:
|
||||
if getattr(exc, "relogin_required", False):
|
||||
print("✗ You're not logged into Nous Portal.")
|
||||
print(" Run `hermes setup` (or `hermes auth login nous`) first, then retry.")
|
||||
else:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
# 2-3. Redeem the enrollment token at the connector.
|
||||
try:
|
||||
result = _post_enroll(
|
||||
connector_base_url=connector_base_url,
|
||||
access_token=access_token,
|
||||
enrollment_token=enrollment_token,
|
||||
gateway_id=gateway_id,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(f"✗ Enrollment failed: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
secret = str(result.get("secret") or "")
|
||||
delivery_key = str(result.get("deliveryKey") or "")
|
||||
tenant = str(result.get("tenant") or "")
|
||||
resolved_gateway_id = str(result.get("gatewayId") or gateway_id)
|
||||
|
||||
# 4. Persist the creds idempotently. The secret + delivery key are sensitive;
|
||||
# save_env_value writes them to ~/.hermes/.env (0600 dir) and never logs.
|
||||
to_write = {
|
||||
"GATEWAY_RELAY_ID": resolved_gateway_id,
|
||||
"GATEWAY_RELAY_SECRET": secret,
|
||||
"GATEWAY_RELAY_DELIVERY_KEY": delivery_key,
|
||||
}
|
||||
# Persist the connector URL too (as the ws(s):// dial target) when supplied
|
||||
# explicitly, so the runtime can dial without re-specifying it.
|
||||
explicit_url = (getattr(args, "connector_url", None) or "").strip()
|
||||
if explicit_url:
|
||||
to_write["GATEWAY_RELAY_URL"] = explicit_url.rstrip("/")
|
||||
|
||||
for key, value in to_write.items():
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
save_env_value(key, value)
|
||||
except Exception as exc:
|
||||
print(f"✗ Failed to write {key} to .env: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
from hermes_cli.config import get_env_path
|
||||
|
||||
print(f'✓ Enrolled gateway "{resolved_gateway_id}"' + (f" for tenant {tenant}" if tenant else ""))
|
||||
print()
|
||||
print(f" Wrote to {get_env_path()}:")
|
||||
print(f" GATEWAY_RELAY_ID={resolved_gateway_id}")
|
||||
print(" GATEWAY_RELAY_SECRET=<hidden>")
|
||||
print(" GATEWAY_RELAY_DELIVERY_KEY=<hidden>")
|
||||
if explicit_url:
|
||||
print(f" GATEWAY_RELAY_URL={explicit_url.rstrip('/')}")
|
||||
print()
|
||||
print(
|
||||
" The gateway now authenticates its relay WS upgrade with the per-gateway\n"
|
||||
" secret and verifies signed inbound deliveries with the tenant delivery\n"
|
||||
" key. Restart the gateway to pick up the new env."
|
||||
)
|
||||
|
|
@ -178,6 +178,14 @@ def build_models_payload(
|
|||
user_models.update(m.lower() for m in (row.get("models") or []))
|
||||
if user_models:
|
||||
for row in rows:
|
||||
# A user's own configured provider is never an "aggregator
|
||||
# duplicate" of itself: user_models is built from these very
|
||||
# rows, and is_aggregator() reports True for every custom:*
|
||||
# slug. Without this guard the dedup strips a user-defined
|
||||
# custom provider's entire model list (all of it lives in
|
||||
# user_models), emptying its picker row.
|
||||
if row.get("is_user_defined"):
|
||||
continue
|
||||
slug = row.get("slug", "")
|
||||
if not _is_aggregator(slug):
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -5110,6 +5110,113 @@ def _purge_electron_build_cache(desktop_dir: Path) -> list[Path]:
|
|||
return removed
|
||||
|
||||
|
||||
# Last-resort Electron mirror after GitHub download fails (#47266). Only used
|
||||
# when the user hasn't pinned ELECTRON_MIRROR.
|
||||
_ELECTRON_FALLBACK_MIRROR = "https://npmmirror.com/mirrors/electron/"
|
||||
|
||||
|
||||
def _electron_dir(project_root: Path) -> Path:
|
||||
"""Return the Electron package directory the desktop workspace installs.
|
||||
|
||||
npm may keep workspace-only dev dependencies under
|
||||
``apps/desktop/node_modules`` instead of hoisting them to the repo root.
|
||||
Which layout you get depends on the npm version and what else is installed,
|
||||
so a build path that assumes one or the other breaks intermittently across
|
||||
machines. ``apps/desktop/package.json`` points electron-builder's
|
||||
``electronDist`` at ``node_modules/electron/dist`` relative to the desktop
|
||||
project, so prefer the workspace-local package and fall back to the root
|
||||
hoist when that's where npm landed it.
|
||||
"""
|
||||
desktop_local = project_root / "apps" / "desktop" / "node_modules" / "electron"
|
||||
if desktop_local.exists():
|
||||
return desktop_local
|
||||
return project_root / "node_modules" / "electron"
|
||||
|
||||
|
||||
def _electron_dist_binary(project_root: Path) -> Path:
|
||||
"""Return the path to the Electron main binary inside the installed package.
|
||||
|
||||
electron-builder reads the binary from ``build.electronDist`` since #38673,
|
||||
so this is the exact file whose absence makes a pack fail with "The
|
||||
specified electronDist does not exist". The basename differs per OS (the
|
||||
platform Electron is named for the host the build runs on).
|
||||
"""
|
||||
dist = _electron_dir(project_root) / "dist"
|
||||
if sys.platform == "darwin":
|
||||
return dist / "Electron.app" / "Contents" / "MacOS" / "Electron"
|
||||
if sys.platform == "win32":
|
||||
return dist / "electron.exe"
|
||||
return dist / "electron"
|
||||
|
||||
|
||||
def _electron_dist_ok(project_root: Path) -> bool:
|
||||
"""True when ``node_modules/electron/dist`` holds a usable Electron binary.
|
||||
|
||||
A directory that exists but is missing the binary (a partial extraction from
|
||||
a corrupt cached zip, or an interrupted postinstall) counts as NOT ok, since
|
||||
that is exactly the shape that makes electron-builder throw on the pinned
|
||||
electronDist.
|
||||
"""
|
||||
try:
|
||||
return _electron_dist_binary(project_root).exists()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _electron_pkg_staged_missing_dist(project_root: Path) -> bool:
|
||||
"""electron staged (package.json + install.js) but dist missing — blocked postinstall."""
|
||||
electron_dir = _electron_dir(project_root)
|
||||
return (
|
||||
(electron_dir / "package.json").is_file()
|
||||
and (electron_dir / "install.js").is_file()
|
||||
and not _electron_dist_ok(project_root)
|
||||
)
|
||||
|
||||
|
||||
def _redownload_electron_dist(
|
||||
project_root: Path,
|
||||
env: dict,
|
||||
*,
|
||||
mirror: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Best-effort: run electron's install.js to populate dist/ (optional mirror)."""
|
||||
if _electron_dist_ok(project_root):
|
||||
return True
|
||||
|
||||
electron_dir = _electron_dir(project_root)
|
||||
installer = electron_dir / "install.js"
|
||||
if not installer.is_file():
|
||||
return False
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
return False
|
||||
|
||||
dist_dir = electron_dir / "dist"
|
||||
shutil.rmtree(dist_dir, ignore_errors=True)
|
||||
try:
|
||||
(electron_dir / "path.txt").unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
dl_env = dict(env)
|
||||
if mirror:
|
||||
dl_env["ELECTRON_MIRROR"] = mirror
|
||||
try:
|
||||
subprocess.run([node, str(installer)], cwd=str(electron_dir), env=dl_env, check=False)
|
||||
except OSError:
|
||||
return False
|
||||
return _electron_dist_ok(project_root)
|
||||
|
||||
|
||||
def _try_redownload_electron_dist(project_root: Path, env: dict) -> bool:
|
||||
"""Canonical download, then fallback mirror unless the user pinned one."""
|
||||
if _redownload_electron_dist(project_root, env):
|
||||
return True
|
||||
if env.get("ELECTRON_MIRROR"):
|
||||
return False
|
||||
return _redownload_electron_dist(project_root, env, mirror=_ELECTRON_FALLBACK_MIRROR)
|
||||
|
||||
|
||||
def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
|
||||
"""Terminate any running desktop app executing from this build's ``release``
|
||||
dir so a rebuild can replace its (otherwise locked) executable.
|
||||
|
|
@ -5303,8 +5410,8 @@ def cmd_gui(args: argparse.Namespace):
|
|||
print(" Pre-build first: cd apps/desktop && npm run build")
|
||||
print(" Or drop --skip-build to install dependencies and build automatically.")
|
||||
sys.exit(1)
|
||||
if not (PROJECT_ROOT / "node_modules" / "electron" / "package.json").exists():
|
||||
print("✗ --skip-build --source requires existing workspace dependencies.")
|
||||
if not (_electron_dir(PROJECT_ROOT) / "package.json").exists():
|
||||
print("✗ --skip-build --source requires existing desktop workspace dependencies.")
|
||||
print(f" Install first: cd {PROJECT_ROOT} && npm ci")
|
||||
print(" Or drop --skip-build to install dependencies and build automatically.")
|
||||
sys.exit(1)
|
||||
|
|
@ -5332,9 +5439,18 @@ def cmd_gui(args: argparse.Namespace):
|
|||
nixos_env = _nixos_build_env()
|
||||
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False, env=nixos_env)
|
||||
if install_result.returncode != 0:
|
||||
print("✗ Desktop dependency install failed")
|
||||
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
||||
sys.exit(install_result.returncode or 1)
|
||||
if not _electron_pkg_staged_missing_dist(PROJECT_ROOT):
|
||||
print("✗ Desktop dependency install failed")
|
||||
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
||||
sys.exit(install_result.returncode or 1)
|
||||
repaired = _try_redownload_electron_dist(PROJECT_ROOT, env)
|
||||
if repaired:
|
||||
print(" ⚠ Dependency install failed with a missing Electron dist; "
|
||||
"repopulated it and continuing.")
|
||||
else:
|
||||
print(" ⚠ Dependency install failed with a missing Electron dist; "
|
||||
"continuing to the build so electron-builder can attempt "
|
||||
"the Electron fetch itself.")
|
||||
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"→ Building desktop {build_label}...")
|
||||
|
|
@ -5350,22 +5466,16 @@ def cmd_gui(args: argparse.Namespace):
|
|||
print(f" ⚠ Stopped running desktop app to free the build output (pid {', '.join(map(str, stopped))})")
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
||||
if build_result.returncode != 0 and not source_mode:
|
||||
# A corrupt cached Electron zip makes `pack` fail with an ENOENT
|
||||
# on the final `electron` -> `Hermes` rename: unpack-electron
|
||||
# extracted a partial tree (missing the 193 MB binary) from the
|
||||
# bad zip. We do NOT try to prove the zip is corrupt ourselves —
|
||||
# stdlib zipfile silently tolerates the prepended/concatenated
|
||||
# junk that is the most common corruption (a partial download
|
||||
# resumed into the same file), so a `testzip()` gate would pass
|
||||
# and never self-heal. Instead, on any packaged-build failure we
|
||||
# purge the version's cached zip + the half-written unpacked dir
|
||||
# and retry once: @electron/get re-downloads with its own SHASUM
|
||||
# verification, which is the real source of truth. If the
|
||||
# failure was something else, the clean re-download is harmless
|
||||
# and the retry fails the same way.
|
||||
purged = _purge_electron_build_cache(desktop_dir)
|
||||
if purged:
|
||||
print(" ⚠ Desktop build failed; cleared cached Electron download and retrying once...")
|
||||
# Corrupt cached Electron zip → partial unpack → ENOENT on rename.
|
||||
# stdlib zipfile won't catch the common concat-junk case, so purge
|
||||
# and retry once; @electron/get SHASUM is the real gate.
|
||||
purged: list[Path] = []
|
||||
restored = False
|
||||
if not _electron_dist_ok(PROJECT_ROOT):
|
||||
purged = _purge_electron_build_cache(desktop_dir)
|
||||
restored = _redownload_electron_dist(PROJECT_ROOT, env)
|
||||
if restored:
|
||||
print(" ⚠ Desktop build failed; refreshed the Electron download and retrying once...")
|
||||
for p in purged:
|
||||
print(f" - {p}")
|
||||
# The purge can't remove a win-unpacked tree whose Hermes.exe
|
||||
|
|
@ -5373,20 +5483,14 @@ def cmd_gui(args: argparse.Namespace):
|
|||
_stop_desktop_processes_locking_build(desktop_dir)
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
||||
if build_result.returncode != 0 and not source_mode and not env.get("ELECTRON_MIRROR"):
|
||||
# Still failing and the user hasn't pinned a mirror: GitHub's
|
||||
# Electron release host is likely blocked/throttled (the repeating
|
||||
# "retrying" download log). Retry once via npmmirror.com — the
|
||||
# de-facto Electron community mirror (Alibaba). @electron/get
|
||||
# SHASUM-checks the download, but the SHASUMS come from the same
|
||||
# mirror, so that guards against a corrupt/partial download, NOT
|
||||
# a compromised mirror: reaching for it is an explicit trust
|
||||
# trade-off we only make AFTER the canonical GitHub download has
|
||||
# failed, and we never override a user-pinned ELECTRON_MIRROR.
|
||||
print(" ⚠ Desktop build still failing; the Electron download from "
|
||||
"GitHub looks blocked. Retrying once via a public mirror "
|
||||
"GitHub looks blocked. Re-downloading via a public mirror "
|
||||
"(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)")
|
||||
mirror = _ELECTRON_FALLBACK_MIRROR
|
||||
mirror_env = dict(env)
|
||||
mirror_env["ELECTRON_MIRROR"] = "https://npmmirror.com/mirrors/electron/"
|
||||
mirror_env["ELECTRON_MIRROR"] = mirror
|
||||
if not _electron_dist_ok(PROJECT_ROOT):
|
||||
_redownload_electron_dist(PROJECT_ROOT, env, mirror=mirror)
|
||||
_stop_desktop_processes_locking_build(desktop_dir)
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False)
|
||||
if build_result.returncode != 0:
|
||||
|
|
@ -10903,6 +11007,13 @@ def cmd_dashboard_register(args):
|
|||
_impl(args)
|
||||
|
||||
|
||||
def cmd_gateway_enroll(args):
|
||||
"""Enroll a self-hosted gateway with a relay connector."""
|
||||
from hermes_cli.gateway_enroll import cmd_gateway_enroll as _impl
|
||||
|
||||
_impl(args)
|
||||
|
||||
|
||||
def cmd_completion(args, parser=None):
|
||||
"""Print shell completion script."""
|
||||
from hermes_cli.completion import generate_bash, generate_zsh, generate_fish
|
||||
|
|
@ -11595,7 +11706,9 @@ def main():
|
|||
# =========================================================================
|
||||
# gateway + proxy commands (parsers built in hermes_cli/subcommands/gateway.py)
|
||||
# =========================================================================
|
||||
build_gateway_parser(subparsers, cmd_gateway=cmd_gateway, cmd_proxy=cmd_proxy)
|
||||
build_gateway_parser(
|
||||
subparsers, cmd_gateway=cmd_gateway, cmd_proxy=cmd_proxy, cmd_gateway_enroll=cmd_gateway_enroll
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# lsp command
|
||||
|
|
|
|||
|
|
@ -517,7 +517,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
|
|||
pass
|
||||
|
||||
models = list(_PROVIDER_MODELS.get("xai-oauth") or _PROVIDER_MODELS.get("xai") or [])
|
||||
selected = _prompt_model_selection(models, current_model=current_model or (models[0] if models else "grok-4.3"))
|
||||
selected = _prompt_model_selection(models, current_model=current_model or (models[0] if models else "grok-build-0.1"))
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("xai-oauth", base_url)
|
||||
|
|
|
|||
|
|
@ -1735,10 +1735,15 @@ def list_authenticated_providers(
|
|||
if fb:
|
||||
models_list = list(fb)
|
||||
|
||||
# Prefer the endpoint's live /models list when credentials are
|
||||
# available, unless the provider explicitly opts out via
|
||||
# discover_models: false (e.g. dedicated endpoints that expose
|
||||
# the entire aggregator catalog via /models).
|
||||
# Prefer the endpoint's live /models list when discoverable,
|
||||
# unless the provider explicitly opts out via discover_models: false.
|
||||
# Policy mirrors Section 4's should_probe logic:
|
||||
# - With an api_key: always probe (user opted into the endpoint).
|
||||
# - Without an api_key but with explicit models: skip — the user
|
||||
# is narrowing a public endpoint to a specific subset.
|
||||
# - Without an api_key AND no explicit models: probe anyway so
|
||||
# bare-endpoint providers (local llama.cpp / Ollama servers)
|
||||
# still show their full model catalog.
|
||||
api_key = str(ep_cfg.get("api_key", "") or "").strip()
|
||||
if not api_key:
|
||||
key_env = str(ep_cfg.get("key_env", "") or "").strip()
|
||||
|
|
@ -1746,7 +1751,11 @@ def list_authenticated_providers(
|
|||
discover = ep_cfg.get("discover_models", True)
|
||||
if isinstance(discover, str):
|
||||
discover = discover.lower() not in {"false", "no", "0"}
|
||||
if api_url and api_key and discover:
|
||||
has_explicit_models = bool(models_list)
|
||||
should_probe = bool(api_url) and discover and (
|
||||
bool(api_key) or not has_explicit_models
|
||||
)
|
||||
if should_probe:
|
||||
try:
|
||||
from hermes_cli.models import fetch_api_models
|
||||
live_models = fetch_api_models(api_key, api_url)
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
|||
# MiniMax
|
||||
("minimax/minimax-m3", ""),
|
||||
# Z-AI
|
||||
("z-ai/glm-5.2", ""),
|
||||
("z-ai/glm-5.1", ""),
|
||||
# Xiaomi
|
||||
("xiaomi/mimo-v2.5-pro", ""),
|
||||
|
|
@ -109,14 +110,20 @@ def _codex_curated_models() -> list[str]:
|
|||
# (grok-4, grok-4-0709, grok-4-fast{,-reasoning,-non-reasoning},
|
||||
# grok-4-1-fast{,-reasoning,-non-reasoning}, grok-code-fast-1 → grok-4.3).
|
||||
_XAI_STATIC_FALLBACK: list[str] = [
|
||||
"grok-build-0.1",
|
||||
"grok-4.3",
|
||||
"grok-4.20-0309-reasoning",
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
"grok-4.20-multi-agent-0309",
|
||||
]
|
||||
|
||||
# Callable via xAI OAuth but omitted from models.dev and /v1/models listings.
|
||||
_XAI_CURATED_EXTRAS: list[str] = [
|
||||
"grok-composer-2.5-fast",
|
||||
]
|
||||
|
||||
_XAI_TOP_MODEL = "grok-4.3"
|
||||
|
||||
_XAI_TOP_MODEL = "grok-build-0.1"
|
||||
|
||||
|
||||
def _xai_promote_top(ids: list[str]) -> list[str]:
|
||||
|
|
@ -126,6 +133,18 @@ def _xai_promote_top(ids: list[str]) -> list[str]:
|
|||
return ids
|
||||
|
||||
|
||||
def _xai_merge_curated_extras(ids: list[str]) -> list[str]:
|
||||
"""Append Hermes-curated xAI models that are missing from models.dev."""
|
||||
out = list(ids)
|
||||
for extra in _XAI_CURATED_EXTRAS:
|
||||
if extra in out:
|
||||
continue
|
||||
# Keep the headline model pinned; slot extras immediately after it.
|
||||
insert_at = 1 if out and out[0] == _XAI_TOP_MODEL else len(out)
|
||||
out.insert(insert_at, extra)
|
||||
return out
|
||||
|
||||
|
||||
def _xai_curated_models() -> list[str]:
|
||||
"""Derive the xAI-direct curated list from models.dev disk cache.
|
||||
|
||||
|
|
@ -145,12 +164,12 @@ def _xai_curated_models() -> list[str]:
|
|||
if isinstance(models, dict) and models:
|
||||
ids = [mid for mid in models.keys() if isinstance(mid, str)]
|
||||
if ids:
|
||||
return _xai_promote_top(sorted(ids))
|
||||
return _xai_merge_curated_extras(_xai_promote_top(sorted(ids)))
|
||||
except Exception:
|
||||
# Any failure (missing file, malformed JSON, import error)
|
||||
# falls through to the static list.
|
||||
pass
|
||||
return list(_XAI_STATIC_FALLBACK)
|
||||
return _xai_merge_curated_extras(list(_XAI_STATIC_FALLBACK))
|
||||
|
||||
|
||||
_PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
|
|
@ -182,6 +201,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
|||
# MiniMax
|
||||
"minimax/minimax-m3",
|
||||
# Z-AI
|
||||
"z-ai/glm-5.2",
|
||||
"z-ai/glm-5.1",
|
||||
# Xiaomi
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
|
|
@ -2368,10 +2388,17 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
|||
if not base_url:
|
||||
base_url = _p.base_url
|
||||
if api_key:
|
||||
live = _p.fetch_models(api_key=api_key)
|
||||
live = _p.fetch_models(api_key=api_key, base_url=base_url or None)
|
||||
if live:
|
||||
if normalized in {"kimi-coding", "kimi-coding-cn"}:
|
||||
curated = list(_PROVIDER_MODELS.get(normalized, []))
|
||||
# Merge static curated list with live API results so
|
||||
# models that the live endpoint omits (stale cache,
|
||||
# partial rollout) still appear in the picker.
|
||||
# Curated entries come first so deliberately-surfaced
|
||||
# newest models (e.g. kimi-k2.7-code, #46309) stay at
|
||||
# the top of the picker; live-only entries are appended
|
||||
# afterwards for discovery. (#46850)
|
||||
curated = list(_PROVIDER_MODELS.get(normalized, []))
|
||||
if curated:
|
||||
merged = list(curated)
|
||||
merged_lower = {m.lower() for m in curated}
|
||||
for m in live:
|
||||
|
|
@ -3934,6 +3961,24 @@ def validate_requested_model(
|
|||
if suggestions:
|
||||
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
|
||||
|
||||
# Model not in live /v1/models — check the curated catalog
|
||||
# before rejecting. Providers may omit models from their live
|
||||
# listing that are still valid (stale cache, partial rollout,
|
||||
# gated previews). Use the pure-catalog helper (no extra live
|
||||
# fetch) so we only accept models Hermes actually ships. (#46850)
|
||||
if _model_in_provider_catalog(
|
||||
requested_for_lookup.lower(), _provider_keys(normalized)
|
||||
):
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": True,
|
||||
"message": (
|
||||
f"Note: `{requested}` was not found in the live /v1/models listing "
|
||||
f"but exists in the curated catalog — accepted."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"accepted": False,
|
||||
"persist": False,
|
||||
|
|
|
|||
|
|
@ -684,10 +684,25 @@ class S6ServiceManager:
|
|||
# start`, etc. See `_gateway_command_inner` for the matching
|
||||
# guard.
|
||||
lines.append("export HERMES_S6_SUPERVISED_CHILD=1")
|
||||
# ``--replace`` makes the supervised gateway authoritative for its
|
||||
# profile's HERMES_HOME. Without it, a gateway started OUTSIDE s6
|
||||
# (a stray ``hermes gateway run`` from a shell, an agent action, or
|
||||
# the Open WebUI helper) grabs the per-HERMES_HOME PID lock first;
|
||||
# the supervised slot then execs a bare ``gateway run``, hits the
|
||||
# "Another gateway instance is already running" guard, exits
|
||||
# non-zero, and s6 restarts it — a restart loop that floods the
|
||||
# log and never binds (NS-505). ``--replace``
|
||||
# instead reaps the stale holder (hardened takeover path: marker +
|
||||
# SIGTERM→SIGKILL-with-confirmation + scoped-lock cleanup, see
|
||||
# gateway/run.py) so s6 always wins. The HERMES_S6_SUPERVISED_CHILD
|
||||
# sentinel above prevents the run→start→run redirect recursion.
|
||||
# Each profile is scoped to its own HERMES_HOME and s6 guarantees a
|
||||
# single supervised instance per slot, so there is no legitimate
|
||||
# supervised sibling for ``--replace`` to clobber.
|
||||
if profile == "default":
|
||||
gateway_cmd = "hermes gateway run"
|
||||
gateway_cmd = "hermes gateway run --replace"
|
||||
else:
|
||||
gateway_cmd = f"hermes -p {shlex.quote(profile)} gateway run"
|
||||
gateway_cmd = f"hermes -p {shlex.quote(profile)} gateway run --replace"
|
||||
# Skip the drop when already non-root (setgroups() lacks CAP_SETGID →
|
||||
# s6 boot-loop).
|
||||
lines.append(f'[ "$(id -u)" = 0 ] || exec {gateway_cmd}')
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ def _add_compat_platform_flag(parser: argparse.ArgumentParser) -> None:
|
|||
)
|
||||
|
||||
|
||||
def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable) -> None:
|
||||
def build_gateway_parser(
|
||||
subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable, cmd_gateway_enroll: Callable
|
||||
) -> None:
|
||||
"""Attach the ``gateway`` and ``proxy`` subcommands to ``subparsers``."""
|
||||
# =========================================================================
|
||||
# gateway command
|
||||
|
|
@ -236,6 +238,52 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
|
|||
help="Skip the confirmation prompt",
|
||||
)
|
||||
|
||||
# gateway enroll — enroll a self-hosted gateway with a relay connector
|
||||
# (connector⇄gateway auth). Redeems a single-use enrollment token for the
|
||||
# per-gateway secret + per-tenant delivery key and writes them to .env.
|
||||
# See docs/relay-connector-contract.md (and the connector repo's
|
||||
# docs/connector-gateway-auth-design.md). EXPERIMENTAL.
|
||||
gateway_enroll = gateway_subparsers.add_parser(
|
||||
"enroll",
|
||||
help="Enroll this gateway with a relay connector (writes relay auth creds to .env)",
|
||||
description=(
|
||||
"Redeem a single-use enrollment token with a relay connector. "
|
||||
"Authenticates as your Nous Portal account (the connector derives the "
|
||||
"authoritative tenant from it), mints this gateway's per-gateway secret "
|
||||
"and per-tenant delivery key, and writes GATEWAY_RELAY_ID / "
|
||||
"GATEWAY_RELAY_SECRET / GATEWAY_RELAY_DELIVERY_KEY into ~/.hermes/.env. "
|
||||
"Requires being logged in (hermes setup). Not available in managed installs."
|
||||
),
|
||||
)
|
||||
gateway_enroll.add_argument(
|
||||
"--token",
|
||||
default=None,
|
||||
help=(
|
||||
"The single-use enrollment token from the connector (delivered with "
|
||||
"your gateway config). Also settable via GATEWAY_RELAY_ENROLL_TOKEN."
|
||||
),
|
||||
)
|
||||
gateway_enroll.add_argument(
|
||||
"--connector-url",
|
||||
dest="connector_url",
|
||||
default=None,
|
||||
help=(
|
||||
"The connector base/relay URL, e.g. wss://connector.example.com/relay "
|
||||
"or https://connector.example.com. Also settable via GATEWAY_RELAY_URL "
|
||||
"/ gateway.relay_url in config.yaml."
|
||||
),
|
||||
)
|
||||
gateway_enroll.add_argument(
|
||||
"--gateway-id",
|
||||
dest="gateway_id",
|
||||
default=None,
|
||||
help=(
|
||||
"A stable id for this gateway instance (kill-switch granularity). "
|
||||
"Defaults to gw-<hostname>."
|
||||
),
|
||||
)
|
||||
gateway_enroll.set_defaults(func=cmd_gateway_enroll)
|
||||
|
||||
# =========================================================================
|
||||
# proxy command — local OpenAI-compatible proxy that attaches the user's
|
||||
# OAuth-authenticated provider credentials to outbound requests. Lets
|
||||
|
|
|
|||
|
|
@ -10,20 +10,40 @@ from typing import Callable
|
|||
|
||||
|
||||
def build_login_parser(subparsers, *, cmd_login: Callable) -> None:
|
||||
"""Attach the ``login`` subcommand to ``subparsers``."""
|
||||
# =========================================================================
|
||||
# login command
|
||||
# =========================================================================
|
||||
"""Attach the deprecated ``login`` subcommand to ``subparsers``.
|
||||
|
||||
``hermes login`` was removed in favor of ``hermes auth`` / ``hermes model``
|
||||
(the runtime handler in ``hermes_cli/auth.py::login_command`` just prints a
|
||||
deprecation message and exits). The subparser is kept registered so that
|
||||
old scripts/aliases invoking ``hermes login [--flags]`` still receive the
|
||||
actionable deprecation message rather than an argparse ``invalid choice:
|
||||
'login'`` error — but:
|
||||
|
||||
- The subparser is registered WITHOUT a ``help=`` kwarg so the row is
|
||||
omitted from ``hermes --help`` (argparse only lists subcommands that
|
||||
have a help string). This hides a command that no longer works (#24756)
|
||||
without the ``help=argparse.SUPPRESS`` ``==SUPPRESS==`` leak that
|
||||
argparse emits for a top-level subparser on Python 3.12+.
|
||||
- ``--provider`` accepts ANY value (no ``choices=``) so that, e.g.,
|
||||
``hermes login --provider anthropic`` reaches the deprecation handler and
|
||||
gets pointed at ``hermes model`` instead of crashing in argparse with
|
||||
``invalid choice: 'anthropic'`` before the handler can run.
|
||||
"""
|
||||
login_parser = subparsers.add_parser(
|
||||
"login",
|
||||
help="Authenticate with an inference provider",
|
||||
description="Run OAuth device authorization flow for Hermes CLI",
|
||||
description=(
|
||||
"Deprecated. Use `hermes auth` to manage credentials, "
|
||||
"`hermes model` to select a provider, or `hermes setup` for full setup."
|
||||
),
|
||||
)
|
||||
# No ``choices=`` on purpose — the handler is a deprecation notice that
|
||||
# ignores the value, and a restrictive list would reject providers the user
|
||||
# legitimately wants (e.g. ``anthropic``) with an argparse error before the
|
||||
# friendly redirect message is ever printed.
|
||||
login_parser.add_argument(
|
||||
"--provider",
|
||||
choices=["nous", "openai-codex", "xai-oauth"],
|
||||
default=None,
|
||||
help="Provider to authenticate with (default: nous)",
|
||||
help="(deprecated) Provider name; ignored — see `hermes model`",
|
||||
)
|
||||
login_parser.add_argument(
|
||||
"--portal-url", help="Portal base URL (default: production portal)"
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ CONFIGURABLE_TOOLSETS = [
|
|||
("clarify", "❓ Clarifying Questions", "clarify"),
|
||||
("delegation", "👥 Task Delegation", "delegate_task"),
|
||||
("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"),
|
||||
("messaging", "📨 Cross-Platform Messaging", "send_message"),
|
||||
("homeassistant", "🏠 Home Assistant", "smart home device control"),
|
||||
("spotify", "🎵 Spotify", "playback, search, playlists, library"),
|
||||
("discord", "💬 Discord (read/participate)", "fetch messages, search members, create thread"),
|
||||
|
|
|
|||
|
|
@ -62,7 +62,11 @@ from hermes_cli.config import (
|
|||
recommended_update_command_for_method,
|
||||
redact_key,
|
||||
)
|
||||
from gateway.status import get_running_pid, read_runtime_status
|
||||
from gateway.status import (
|
||||
get_running_pid,
|
||||
get_runtime_status_running_pid,
|
||||
read_runtime_status,
|
||||
)
|
||||
from utils import env_var_enabled
|
||||
|
||||
try:
|
||||
|
|
@ -678,6 +682,7 @@ class TelegramOnboardingStart(BaseModel):
|
|||
|
||||
class TelegramOnboardingApply(BaseModel):
|
||||
allowed_user_ids: List[str]
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
class AudioTranscriptionRequest(BaseModel):
|
||||
|
|
@ -1609,145 +1614,174 @@ async def fs_default_cwd():
|
|||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status():
|
||||
current_ver, latest_ver = check_config_version()
|
||||
async def get_status(profile: Optional[str] = None):
|
||||
status_scope = None
|
||||
requested_profile = (profile or "").strip()
|
||||
# Plain /api/status stays the machine-level public liveness probe. The
|
||||
# dashboard adds ?profile= when its management switcher targets another
|
||||
# profile, so its gateway badge reflects the selected profile.
|
||||
#
|
||||
# Use the config-only (contextvar) scope, NOT _profile_scope: this handler
|
||||
# awaits the remote-health probe, and _profile_scope swaps process-global
|
||||
# skills-module attributes that a concurrent request would cross-restore
|
||||
# across that await. Status only resolves get_hermes_home() at call time
|
||||
# (config/env/gateway state), which the task-local contextvar covers.
|
||||
if requested_profile and requested_profile.lower() != "current":
|
||||
status_scope = _config_profile_scope(requested_profile)
|
||||
status_scope.__enter__()
|
||||
|
||||
# --- Gateway liveness detection ---
|
||||
# Try local PID check first (same-host). If that fails and a remote
|
||||
# GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the
|
||||
# dashboard works when the gateway runs in a separate container.
|
||||
gateway_pid = get_running_pid()
|
||||
gateway_running = gateway_pid is not None
|
||||
remote_health_body: dict | None = None
|
||||
|
||||
if not gateway_running and _GATEWAY_HEALTH_URL:
|
||||
loop = asyncio.get_running_loop()
|
||||
alive, remote_health_body = await loop.run_in_executor(
|
||||
None, _probe_gateway_health
|
||||
)
|
||||
if alive:
|
||||
gateway_running = True
|
||||
# PID from the remote container (display only — not locally valid)
|
||||
if remote_health_body:
|
||||
gateway_pid = remote_health_body.get("pid")
|
||||
|
||||
gateway_state = None
|
||||
gateway_platforms: dict = {}
|
||||
gateway_exit_reason = None
|
||||
gateway_updated_at = None
|
||||
configured_gateway_platforms: set[str] | None = None
|
||||
try:
|
||||
from gateway.config import load_gateway_config
|
||||
current_ver, latest_ver = check_config_version()
|
||||
# --- Gateway liveness detection ---
|
||||
# Try local PID check first (same-host). If that fails and a remote
|
||||
# GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the
|
||||
# dashboard works when the gateway runs in a separate container.
|
||||
gateway_pid = get_running_pid()
|
||||
gateway_running = gateway_pid is not None
|
||||
remote_health_body: dict | None = None
|
||||
|
||||
gateway_config = load_gateway_config()
|
||||
configured_gateway_platforms = {
|
||||
platform.value for platform in gateway_config.get_connected_platforms()
|
||||
}
|
||||
except Exception:
|
||||
configured_gateway_platforms = None
|
||||
|
||||
# Prefer the detailed health endpoint response (has full state) when the
|
||||
# local runtime status file is absent or stale (cross-container).
|
||||
runtime = read_runtime_status()
|
||||
if runtime is None and remote_health_body and remote_health_body.get("gateway_state"):
|
||||
runtime = remote_health_body
|
||||
|
||||
if runtime:
|
||||
gateway_state = runtime.get("gateway_state")
|
||||
gateway_platforms = runtime.get("platforms") or {}
|
||||
if configured_gateway_platforms is not None:
|
||||
gateway_platforms = {
|
||||
key: value
|
||||
for key, value in gateway_platforms.items()
|
||||
if key in configured_gateway_platforms
|
||||
}
|
||||
gateway_exit_reason = runtime.get("exit_reason")
|
||||
gateway_updated_at = runtime.get("updated_at")
|
||||
if not gateway_running:
|
||||
gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped"
|
||||
gateway_platforms = {}
|
||||
elif gateway_running and remote_health_body is not None:
|
||||
# The health probe confirmed the gateway is alive, but the local
|
||||
# runtime status file may be stale (cross-container). Override
|
||||
# stopped/None state so the dashboard shows the correct badge.
|
||||
if gateway_state in {None, "stopped"}:
|
||||
gateway_state = "running"
|
||||
|
||||
# If there was no runtime info at all but the health probe confirmed alive,
|
||||
# ensure we still report the gateway as running (no shared volume scenario).
|
||||
if gateway_running and gateway_state is None and remote_health_body is not None:
|
||||
gateway_state = "running"
|
||||
|
||||
active_sessions = 0
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
sessions = db.list_sessions_rich(limit=50)
|
||||
now = time.time()
|
||||
active_sessions = sum(
|
||||
1 for s in sessions
|
||||
if s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
if not gateway_running and _GATEWAY_HEALTH_URL:
|
||||
loop = asyncio.get_running_loop()
|
||||
alive, remote_health_body = await loop.run_in_executor(
|
||||
None, _probe_gateway_health
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
if alive:
|
||||
gateway_running = True
|
||||
# PID from the remote container (display only — not locally valid)
|
||||
if remote_health_body:
|
||||
gateway_pid = remote_health_body.get("pid")
|
||||
|
||||
# Dashboard auth gate (Phase 7): surface whether the gate is engaged
|
||||
# and which providers are registered so ``hermes status`` and the
|
||||
# SPA's StatusPage can show "OAuth gate ON via Nous Research" or
|
||||
# "loopback only — no auth gate" with no extra round trips.
|
||||
auth_required = bool(getattr(app.state, "auth_required", False))
|
||||
auth_providers: list[str] = []
|
||||
try:
|
||||
from hermes_cli.dashboard_auth import list_providers as _list_providers
|
||||
auth_providers = [p.name for p in _list_providers()]
|
||||
except Exception:
|
||||
# Module not importable yet (early startup) — leave as [].
|
||||
pass
|
||||
gateway_state = None
|
||||
gateway_platforms: dict = {}
|
||||
gateway_exit_reason = None
|
||||
gateway_updated_at = None
|
||||
configured_gateway_platforms: set[str] | None = None
|
||||
try:
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
# Always-public liveness + auth-gate shape. Safe for external uptime
|
||||
# probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login
|
||||
# bootstrap, and anyone who can curl the host — i.e. exactly the audience
|
||||
# ``PUBLIC_API_PATHS`` documents this endpoint as serving.
|
||||
status = {
|
||||
"version": __version__,
|
||||
"release_date": __release_date__,
|
||||
"config_version": current_ver,
|
||||
"latest_config_version": latest_ver,
|
||||
"can_update_hermes": not _dashboard_local_update_managed_externally(),
|
||||
"gateway_running": gateway_running,
|
||||
"gateway_state": gateway_state,
|
||||
"gateway_platforms": gateway_platforms,
|
||||
"gateway_exit_reason": gateway_exit_reason,
|
||||
"gateway_updated_at": gateway_updated_at,
|
||||
"active_sessions": active_sessions,
|
||||
"auth_required": auth_required,
|
||||
"auth_providers": auth_providers,
|
||||
}
|
||||
gateway_config = load_gateway_config()
|
||||
configured_gateway_platforms = {
|
||||
platform.value for platform in gateway_config.get_connected_platforms()
|
||||
}
|
||||
except Exception:
|
||||
configured_gateway_platforms = None
|
||||
|
||||
# Absolute host paths, the gateway PID, and the internal gateway health
|
||||
# URL are deployment recon a liveness probe never needs. ``/api/status``
|
||||
# is in ``PUBLIC_API_PATHS`` so it bypasses dashboard auth; on a
|
||||
# network-exposed (gated) bind that means *any* unauthenticated caller
|
||||
# reaches it, and leaking host metadata there contradicts the allowlist's
|
||||
# own contract ("version, gateway state, active session count, and the
|
||||
# dashboard auth-gate shape. No bodies, no session content, no secrets").
|
||||
# Surface this detail only on a loopback / ``--insecure`` bind, where the
|
||||
# dashboard is local-only and the caller is already inside the trust
|
||||
# envelope — the same loopback/gated split ``should_require_auth`` draws.
|
||||
if not auth_required:
|
||||
status.update({
|
||||
"hermes_home": str(get_hermes_home()),
|
||||
"config_path": str(get_config_path()),
|
||||
"env_path": str(get_env_path()),
|
||||
"gateway_pid": gateway_pid,
|
||||
"gateway_health_url": _GATEWAY_HEALTH_URL,
|
||||
})
|
||||
# Prefer the detailed health endpoint response (has full state) when the
|
||||
# local runtime status file is absent or stale (cross-container).
|
||||
local_runtime = read_runtime_status()
|
||||
runtime = local_runtime
|
||||
if runtime is None and remote_health_body and remote_health_body.get("gateway_state"):
|
||||
runtime = remote_health_body
|
||||
# The runtime-status PID fallback validates liveness with a local
|
||||
# os.kill() probe, so it must only run against the LOCAL status file —
|
||||
# never the remote health body, whose PID belongs to another host and
|
||||
# is display-only. (Running os.kill on a remote PID is both wrong and
|
||||
# trips the test live-system guard.)
|
||||
if not gateway_running and local_runtime is not None:
|
||||
runtime_pid = get_runtime_status_running_pid(local_runtime)
|
||||
if runtime_pid is not None:
|
||||
gateway_running = True
|
||||
gateway_pid = runtime_pid
|
||||
|
||||
return status
|
||||
if runtime:
|
||||
gateway_state = runtime.get("gateway_state")
|
||||
gateway_platforms = runtime.get("platforms") or {}
|
||||
if configured_gateway_platforms is not None:
|
||||
gateway_platforms = {
|
||||
key: value
|
||||
for key, value in gateway_platforms.items()
|
||||
if key in configured_gateway_platforms
|
||||
}
|
||||
gateway_exit_reason = runtime.get("exit_reason")
|
||||
gateway_updated_at = runtime.get("updated_at")
|
||||
if not gateway_running:
|
||||
gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped"
|
||||
gateway_platforms = {}
|
||||
elif gateway_running and remote_health_body is not None:
|
||||
# The health probe confirmed the gateway is alive, but the local
|
||||
# runtime status file may be stale (cross-container). Override
|
||||
# stopped/None state so the dashboard shows the correct badge.
|
||||
if gateway_state in {None, "stopped"}:
|
||||
gateway_state = "running"
|
||||
|
||||
# If there was no runtime info at all but the health probe confirmed alive,
|
||||
# ensure we still report the gateway as running (no shared volume scenario).
|
||||
if gateway_running and gateway_state is None and remote_health_body is not None:
|
||||
gateway_state = "running"
|
||||
|
||||
active_sessions = 0
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
sessions = db.list_sessions_rich(limit=50)
|
||||
now = time.time()
|
||||
active_sessions = sum(
|
||||
1 for s in sessions
|
||||
if s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Dashboard auth gate (Phase 7): surface whether the gate is engaged
|
||||
# and which providers are registered so ``hermes status`` and the
|
||||
# SPA's StatusPage can show "OAuth gate ON via Nous Research" or
|
||||
# "loopback only — no auth gate" with no extra round trips.
|
||||
auth_required = bool(getattr(app.state, "auth_required", False))
|
||||
auth_providers: list[str] = []
|
||||
try:
|
||||
from hermes_cli.dashboard_auth import list_providers as _list_providers
|
||||
auth_providers = [p.name for p in _list_providers()]
|
||||
except Exception:
|
||||
# Module not importable yet (early startup) — leave as [].
|
||||
pass
|
||||
|
||||
# Always-public liveness + auth-gate shape. Safe for external uptime
|
||||
# probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login
|
||||
# bootstrap, and anyone who can curl the host — i.e. exactly the audience
|
||||
# ``PUBLIC_API_PATHS`` documents this endpoint as serving.
|
||||
status = {
|
||||
"version": __version__,
|
||||
"release_date": __release_date__,
|
||||
"config_version": current_ver,
|
||||
"latest_config_version": latest_ver,
|
||||
"can_update_hermes": not _dashboard_local_update_managed_externally(),
|
||||
"gateway_running": gateway_running,
|
||||
"gateway_state": gateway_state,
|
||||
"gateway_platforms": gateway_platforms,
|
||||
"gateway_exit_reason": gateway_exit_reason,
|
||||
"gateway_updated_at": gateway_updated_at,
|
||||
"active_sessions": active_sessions,
|
||||
"auth_required": auth_required,
|
||||
"auth_providers": auth_providers,
|
||||
}
|
||||
|
||||
# Absolute host paths, the gateway PID, and the internal gateway health
|
||||
# URL are deployment recon a liveness probe never needs. ``/api/status``
|
||||
# is in ``PUBLIC_API_PATHS`` so it bypasses dashboard auth; on a
|
||||
# network-exposed (gated) bind that means *any* unauthenticated caller
|
||||
# reaches it, and leaking host metadata there contradicts the allowlist's
|
||||
# own contract ("version, gateway state, active session count, and the
|
||||
# dashboard auth-gate shape. No bodies, no session content, no secrets").
|
||||
# Surface this detail only on a loopback / ``--insecure`` bind, where the
|
||||
# dashboard is local-only and the caller is already inside the trust
|
||||
# envelope — the same loopback/gated split ``should_require_auth`` draws.
|
||||
if not auth_required:
|
||||
status.update({
|
||||
"hermes_home": str(get_hermes_home()),
|
||||
"config_path": str(get_config_path()),
|
||||
"env_path": str(get_env_path()),
|
||||
"gateway_pid": gateway_pid,
|
||||
"gateway_health_url": _GATEWAY_HEALTH_URL,
|
||||
})
|
||||
|
||||
return status
|
||||
finally:
|
||||
if status_scope is not None:
|
||||
status_scope.__exit__(*sys.exc_info())
|
||||
|
||||
|
||||
_WINDOWS_11_MIN_BUILD = 22000
|
||||
|
|
@ -2095,6 +2129,7 @@ _ACTION_LOG_FILES: Dict[str, str] = {
|
|||
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
|
||||
# report liveness and exit code without shelling out to ``ps``.
|
||||
_ACTION_PROCS: Dict[str, subprocess.Popen] = {}
|
||||
_ACTION_COMMANDS: Dict[str, Tuple[str, ...]] = {}
|
||||
|
||||
# ``name`` → completed synthetic action result for actions the server handled
|
||||
# without spawning a subprocess (for example, unsupported Docker updates).
|
||||
|
|
@ -2114,6 +2149,7 @@ def _record_completed_action(name: str, message: str, exit_code: int = 1) -> Non
|
|||
if not message.endswith("\n"):
|
||||
log_file.write(b"\n")
|
||||
_ACTION_PROCS.pop(name, None)
|
||||
_ACTION_COMMANDS.pop(name, None)
|
||||
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None}
|
||||
|
||||
|
||||
|
|
@ -2154,6 +2190,7 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
|
|||
# fd per spawned action.
|
||||
log_file.close()
|
||||
_ACTION_RESULTS.pop(name, None)
|
||||
_ACTION_COMMANDS[name] = tuple(subcommand)
|
||||
_ACTION_PROCS[name] = proc
|
||||
return proc
|
||||
|
||||
|
|
@ -2172,7 +2209,15 @@ def _tail_lines(path: Path, n: int) -> List[str]:
|
|||
return lines[-n:] if n > 0 else lines
|
||||
|
||||
|
||||
def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
|
||||
def _gateway_subcommand(profile: Optional[str], verb: str) -> List[str]:
|
||||
return _profile_cli_args(profile) + ["gateway", verb]
|
||||
|
||||
|
||||
def _gateway_display_command(profile: Optional[str], verb: str) -> str:
|
||||
return " ".join(["hermes", *_gateway_subcommand(profile, verb)])
|
||||
|
||||
|
||||
def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]:
|
||||
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
|
||||
|
||||
Multiple dashboard paths can request a restart in quick succession
|
||||
|
|
@ -2183,16 +2228,20 @@ def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
|
|||
|
||||
Returns ``(proc, reused)``.
|
||||
"""
|
||||
subcommand = _gateway_subcommand(profile, "restart")
|
||||
existing = _ACTION_PROCS.get("gateway-restart")
|
||||
if existing is not None and existing.poll() is None:
|
||||
return existing, True
|
||||
return _spawn_hermes_action(["gateway", "restart"], "gateway-restart"), False
|
||||
existing_command = _ACTION_COMMANDS.get("gateway-restart")
|
||||
if existing_command is None or existing_command == tuple(subcommand):
|
||||
return existing, True
|
||||
raise RuntimeError("gateway restart already in progress for another profile")
|
||||
return _spawn_hermes_action(subcommand, "gateway-restart"), False
|
||||
|
||||
|
||||
def _restart_gateway_after_webhook_enable() -> dict[str, Any]:
|
||||
def _restart_gateway_after_webhook_enable(profile: Optional[str] = None) -> dict[str, Any]:
|
||||
"""Best-effort gateway restart after enabling the webhook platform."""
|
||||
try:
|
||||
proc, reused = _spawn_gateway_restart()
|
||||
proc, reused = _spawn_gateway_restart(profile)
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to auto-restart gateway after enabling webhooks")
|
||||
return {
|
||||
|
|
@ -2212,10 +2261,12 @@ def _restart_gateway_after_webhook_enable() -> dict[str, Any]:
|
|||
|
||||
|
||||
@app.post("/api/gateway/restart")
|
||||
async def restart_gateway():
|
||||
async def restart_gateway(profile: Optional[str] = None):
|
||||
"""Kick off a ``hermes gateway restart`` in the background."""
|
||||
try:
|
||||
proc, _reused = _spawn_gateway_restart()
|
||||
proc, _reused = _spawn_gateway_restart(profile)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn gateway restart")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
|
||||
|
|
@ -2635,6 +2686,7 @@ async def get_action_status(name: str, lines: int = 200):
|
|||
pass
|
||||
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": pid}
|
||||
_ACTION_PROCS.pop(name, None)
|
||||
_ACTION_COMMANDS.pop(name, None)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
|
|
@ -4346,13 +4398,16 @@ def _messaging_platform_payload(
|
|||
scoped: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
platform_id = entry["id"]
|
||||
gateway_running = get_running_pid() is not None
|
||||
runtime_platforms = runtime.get("platforms") if runtime else {}
|
||||
runtime_platform = (
|
||||
runtime_platforms.get(platform_id, {})
|
||||
if isinstance(runtime_platforms, dict)
|
||||
else {}
|
||||
)
|
||||
gateway_running = (
|
||||
get_running_pid() is not None
|
||||
or get_runtime_status_running_pid(runtime) is not None
|
||||
)
|
||||
env_vars = []
|
||||
|
||||
for key in entry["env_vars"]:
|
||||
|
|
@ -4415,15 +4470,37 @@ def _messaging_platform_payload(
|
|||
state = (
|
||||
runtime_platform.get("state") if isinstance(runtime_platform, dict) else None
|
||||
)
|
||||
runtime_gateway_state = runtime.get("gateway_state") if isinstance(runtime, dict) else None
|
||||
runtime_gateway_error = runtime.get("exit_reason") if isinstance(runtime, dict) else None
|
||||
if not enabled:
|
||||
state = "disabled"
|
||||
elif not configured:
|
||||
state = "not_configured"
|
||||
elif gateway_running and not state:
|
||||
state = "pending_restart"
|
||||
elif (
|
||||
not gateway_running
|
||||
and not state
|
||||
and runtime_gateway_state == "startup_failed"
|
||||
):
|
||||
state = "startup_failed"
|
||||
elif not gateway_running and not state:
|
||||
state = "gateway_stopped"
|
||||
|
||||
error_code = (
|
||||
runtime_platform.get("error_code")
|
||||
if isinstance(runtime_platform, dict)
|
||||
else None
|
||||
)
|
||||
error_message = (
|
||||
runtime_platform.get("error_message")
|
||||
if isinstance(runtime_platform, dict)
|
||||
else None
|
||||
)
|
||||
if state == "startup_failed":
|
||||
error_code = error_code or "startup_failed"
|
||||
error_message = error_message or runtime_gateway_error
|
||||
|
||||
return {
|
||||
"id": platform_id,
|
||||
"name": entry["name"],
|
||||
|
|
@ -4433,16 +4510,8 @@ def _messaging_platform_payload(
|
|||
"configured": configured,
|
||||
"gateway_running": gateway_running,
|
||||
"state": state,
|
||||
"error_code": (
|
||||
runtime_platform.get("error_code")
|
||||
if isinstance(runtime_platform, dict)
|
||||
else None
|
||||
),
|
||||
"error_message": (
|
||||
runtime_platform.get("error_message")
|
||||
if isinstance(runtime_platform, dict)
|
||||
else None
|
||||
),
|
||||
"error_code": error_code,
|
||||
"error_message": error_message,
|
||||
"updated_at": (
|
||||
runtime_platform.get("updated_at")
|
||||
if isinstance(runtime_platform, dict)
|
||||
|
|
@ -4732,7 +4801,7 @@ async def get_telegram_onboarding_status(pairing_id: str):
|
|||
)
|
||||
|
||||
|
||||
def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
|
||||
def _restart_gateway_after_telegram_onboarding(profile: Optional[str] = None) -> dict[str, Any]:
|
||||
"""Best-effort gateway restart after saving Telegram QR onboarding.
|
||||
|
||||
The QR flow naturally pulls users into Telegram on another device. If the
|
||||
|
|
@ -4741,7 +4810,7 @@ def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
|
|||
restart failures so the UI can fall back to the existing manual banner.
|
||||
"""
|
||||
try:
|
||||
proc, reused = _spawn_gateway_restart()
|
||||
proc, reused = _spawn_gateway_restart(profile)
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to auto-restart gateway after Telegram onboarding")
|
||||
return {
|
||||
|
|
@ -4762,7 +4831,7 @@ def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
|
|||
|
||||
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
|
||||
async def apply_telegram_onboarding(
|
||||
pairing_id: str, body: TelegramOnboardingApply
|
||||
pairing_id: str, body: TelegramOnboardingApply, profile: Optional[str] = None
|
||||
):
|
||||
allowed_user_ids = []
|
||||
seen = set()
|
||||
|
|
@ -4798,10 +4867,14 @@ async def apply_telegram_onboarding(
|
|||
detail="Telegram setup is not ready yet.",
|
||||
)
|
||||
|
||||
effective_profile = body.profile or profile
|
||||
try:
|
||||
save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
|
||||
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
|
||||
_write_platform_enabled("telegram", True)
|
||||
with _profile_scope(effective_profile):
|
||||
save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
|
||||
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
|
||||
_write_platform_enabled("telegram", True)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
|
|
@ -4814,7 +4887,7 @@ async def apply_telegram_onboarding(
|
|||
with _telegram_onboarding_lock:
|
||||
_telegram_onboarding_pairings.pop(pairing_id, None)
|
||||
|
||||
restart_result = _restart_gateway_after_telegram_onboarding()
|
||||
restart_result = _restart_gateway_after_telegram_onboarding(effective_profile)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
|
|
@ -4842,6 +4915,8 @@ async def get_messaging_platforms(profile: Optional[str] = None):
|
|||
env_on_disk = load_env()
|
||||
runtime = read_runtime_status()
|
||||
return {
|
||||
"env_path": str(get_env_path()),
|
||||
"gateway_start_command": _gateway_display_command(profile, "start"),
|
||||
"platforms": [
|
||||
_messaging_platform_payload(
|
||||
entry, env_on_disk, runtime, scoped=scoped_dir is not None
|
||||
|
|
@ -7802,9 +7877,11 @@ async def set_webhook_enabled(name: str, body: WebhookEnabledToggle):
|
|||
|
||||
|
||||
@app.post("/api/gateway/start")
|
||||
async def start_gateway():
|
||||
async def start_gateway(profile: Optional[str] = None):
|
||||
try:
|
||||
proc = _spawn_hermes_action(["gateway", "start"], "gateway-start")
|
||||
proc = _spawn_hermes_action(_gateway_subcommand(profile, "start"), "gateway-start")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn gateway start")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start gateway: {exc}")
|
||||
|
|
@ -7812,9 +7889,11 @@ async def start_gateway():
|
|||
|
||||
|
||||
@app.post("/api/gateway/stop")
|
||||
async def stop_gateway():
|
||||
async def stop_gateway(profile: Optional[str] = None):
|
||||
try:
|
||||
proc = _spawn_hermes_action(["gateway", "stop"], "gateway-stop")
|
||||
proc = _spawn_hermes_action(_gateway_subcommand(profile, "stop"), "gateway-stop")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn gateway stop")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to stop gateway: {exc}")
|
||||
|
|
@ -8346,7 +8425,7 @@ def _profile_cli_args(profile: Optional[str]) -> List[str]:
|
|||
profile (no args, legacy behavior).
|
||||
"""
|
||||
requested = (profile or "").strip()
|
||||
if not requested or requested.lower() == "current":
|
||||
if not requested or requested.lower() in {"current", "default"}:
|
||||
return []
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
_resolve_profile_dir(requested)
|
||||
|
|
@ -9432,6 +9511,40 @@ def _profile_scope(profile: Optional[str]):
|
|||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _config_profile_scope(profile: Optional[str]):
|
||||
"""Await-safe, config-only profile scope for handlers that ``await``.
|
||||
|
||||
Unlike ``_profile_scope`` this touches ONLY the context-local
|
||||
``set_hermes_home_override`` contextvar — it does NOT swap the
|
||||
process-global ``skills_tool``/``skill_manager`` module attributes.
|
||||
Those globals are shared across all event-loop tasks, so holding them
|
||||
across an ``await`` lets a concurrent skills request restore THIS
|
||||
request's profile dir on its ``finally`` (cross-contamination). The
|
||||
contextvar override is task-local and survives an ``await`` cleanly,
|
||||
which is all endpoints that resolve ``get_hermes_home()`` at call time
|
||||
(config, env, gateway status) actually need.
|
||||
|
||||
None/""/"current" means the dashboard's own profile — no override.
|
||||
"""
|
||||
requested = (profile or "").strip()
|
||||
if not requested or requested.lower() == "current":
|
||||
yield None
|
||||
return
|
||||
|
||||
from hermes_constants import (
|
||||
set_hermes_home_override,
|
||||
reset_hermes_home_override,
|
||||
)
|
||||
|
||||
profile_dir = _resolve_profile_dir(requested)
|
||||
token = set_hermes_home_override(str(profile_dir))
|
||||
try:
|
||||
yield profile_dir
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
class SkillToggle(BaseModel):
|
||||
name: str
|
||||
enabled: bool
|
||||
|
|
|
|||
|
|
@ -461,11 +461,21 @@ _container_detected: bool | None = None
|
|||
|
||||
|
||||
def is_container() -> bool:
|
||||
"""Return True when running inside a Docker/Podman container.
|
||||
"""Return True when running inside a container.
|
||||
|
||||
Checks ``/.dockerenv`` (Docker), ``/run/.containerenv`` (Podman),
|
||||
and ``/proc/1/cgroup`` for container runtime markers. Result is
|
||||
cached for the process lifetime. Import-safe — no heavy deps.
|
||||
Recognizes Docker (``/.dockerenv``), Podman (``/run/.containerenv``),
|
||||
and — via ``/proc/1/cgroup`` — the docker/podman/lxc cgroup-v1 markers.
|
||||
|
||||
cgroup v2 collapses ``/proc/1/cgroup`` to a single ``0::/`` line with no
|
||||
runtime marker, so containerd/CRI-O runtimes (the common case on
|
||||
Kubernetes/k3s) were previously missed. To cover those, also check:
|
||||
* ``KUBERNETES_SERVICE_HOST`` env var — set in every Kubernetes pod.
|
||||
* ``kubepods`` / ``containerd`` / ``crio`` markers in ``/proc/1/cgroup``.
|
||||
* the same markers in ``/proc/self/mountinfo`` (cgroup-v2 fallback).
|
||||
|
||||
Result is cached for the process lifetime. Import-safe — no heavy deps.
|
||||
|
||||
See: NousResearch/hermes-agent#47111
|
||||
"""
|
||||
global _container_detected
|
||||
if _container_detected is not None:
|
||||
|
|
@ -476,10 +486,26 @@ def is_container() -> bool:
|
|||
if os.path.exists("/run/.containerenv"):
|
||||
_container_detected = True
|
||||
return True
|
||||
# Kubernetes always injects this into pod containers; absent on hosts.
|
||||
if os.environ.get("KUBERNETES_SERVICE_HOST"):
|
||||
_container_detected = True
|
||||
return True
|
||||
_CGROUP_MARKERS = ("docker", "podman", "/lxc/", "kubepods", "containerd", "crio")
|
||||
try:
|
||||
with open("/proc/1/cgroup", "r", encoding="utf-8") as f:
|
||||
cgroup = f.read()
|
||||
if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup:
|
||||
if any(marker in cgroup for marker in _CGROUP_MARKERS):
|
||||
_container_detected = True
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
# cgroup v2: /proc/1/cgroup is just "0::/" with no marker. The container
|
||||
# runtime still shows up in the mount table (overlay rootfs, runtime mount
|
||||
# paths), so scan mountinfo as a last resort.
|
||||
try:
|
||||
with open("/proc/self/mountinfo", "r", encoding="utf-8") as f:
|
||||
mountinfo = f.read()
|
||||
if any(marker in mountinfo for marker in ("kubepods", "containerd", "crio")):
|
||||
_container_detected = True
|
||||
return True
|
||||
except OSError:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue