Merge remote-tracking branch 'origin/main' into pr-46081
# Conflicts: # agent/conversation_loop.py # apps/desktop/src/app/settings/model-settings.tsx # apps/desktop/src/app/shell/model-menu-panel.tsx # gateway/run.py # hermes_cli/commands.py # run_agent.py
This commit is contained in:
commit
481b2b4327
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,6 +5,7 @@
|
|||||||
*.pyc*
|
*.pyc*
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.venv/
|
.venv/
|
||||||
|
.venv
|
||||||
.vscode/
|
.vscode/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.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 node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
|
||||||
FROM debian:13.4
|
FROM debian:13.4
|
||||||
|
|
||||||
# Disable Python stdout buffering to ensure logs are printed immediately
|
# Disable Python stdout buffering to ensure logs are printed immediately.
|
||||||
|
# Do not write .pyc files at runtime: /opt/hermes is immutable in the
|
||||||
|
# published container and writable state belongs under /opt/data.
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
# Store Playwright browsers outside the volume mount so the build-time
|
# Store Playwright browsers outside the volume mount so the build-time
|
||||||
# install survives the /opt/data volume overlay at runtime.
|
# install survives the /opt/data volume overlay at runtime.
|
||||||
@ -186,36 +189,38 @@ RUN cd web && npm run build && \
|
|||||||
|
|
||||||
# ---------- Source code ----------
|
# ---------- Source code ----------
|
||||||
# .dockerignore excludes node_modules, so the installs above survive.
|
# .dockerignore excludes node_modules, so the installs above survive.
|
||||||
COPY --chown=hermes:hermes . .
|
COPY . .
|
||||||
|
|
||||||
# ---------- Permissions ----------
|
# ---------- Permissions ----------
|
||||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
# Link hermes-agent itself (editable). Deps are already installed in the
|
||||||
# The venv needs to be traversable too.
|
# cached layer above; `--no-deps` makes this a fast egg-link creation with no
|
||||||
# node_modules trees additionally need to be writable by the hermes user
|
# resolution or downloads.
|
||||||
# so the runtime `npm install` triggered by _tui_need_npm_install() in
|
RUN uv pip install --no-cache-dir --no-deps -e "."
|
||||||
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
|
|
||||||
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
|
# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
|
||||||
# not chowned here.
|
# instances must not be able to self-edit the installed source or venv; user
|
||||||
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
|
# data, skills, plugins, config, logs, and dashboard uploads live under
|
||||||
# gateway state artifacts beneath the package after services drop privileges,
|
# /opt/data instead. Root can still repair the image during build/boot, but
|
||||||
# especially when the hermes UID is remapped at boot (#27221).
|
# supervised Hermes processes drop to the non-root hermes user.
|
||||||
# The .venv MUST remain hermes-writable so lazy_deps.py can install
|
|
||||||
# remaining optional platform packages and future pin bumps at first use.
|
|
||||||
# Without this, `uv pip install` fails with EACCES and adapters silently
|
|
||||||
# fail to load. See tools/lazy_deps.py.
|
|
||||||
USER root
|
USER root
|
||||||
RUN chmod -R a+rX /opt/hermes && \
|
RUN mkdir -p /opt/hermes/bin && \
|
||||||
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
|
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
|
||||||
|
chmod 0755 /opt/hermes/bin/hermes && \
|
||||||
|
printf 'docker\n' > /opt/hermes/.install_method && \
|
||||||
|
chown -R root:root /opt/hermes && \
|
||||||
|
chmod -R a+rX /opt/hermes && \
|
||||||
|
chmod -R a-w /opt/hermes
|
||||||
|
# The ``.install_method`` stamp is baked next to the running code (the install
|
||||||
|
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
|
||||||
|
# volume that is commonly bind-mounted from the host and even shared with a
|
||||||
|
# host-side Desktop/CLI install; stamping it at boot used to clobber that
|
||||||
|
# host install's marker and wrongly block its ``hermes update``. A code-scoped
|
||||||
|
# stamp is read first by detect_install_method() and is immune to the share.
|
||||||
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
|
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
|
||||||
# the data volume. Each supervised service then drops to the hermes user via
|
# the data volume. Each supervised service then drops to the hermes user via
|
||||||
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
|
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
|
||||||
# run as the default hermes user (UID 10000).
|
# run as the default hermes user (UID 10000).
|
||||||
|
|
||||||
# ---------- Link hermes-agent itself (editable) ----------
|
|
||||||
# Deps are already installed in the cached layer above; `--no-deps` makes
|
|
||||||
# this a fast (~1s) egg-link creation with no resolution or downloads.
|
|
||||||
RUN uv pip install --no-cache-dir --no-deps -e "."
|
|
||||||
|
|
||||||
# ---------- Bake build-time git revision ----------
|
# ---------- Bake build-time git revision ----------
|
||||||
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
|
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
|
||||||
# container always returns nothing — meaning `hermes dump` reports
|
# container always returns nothing — meaning `hermes dump` reports
|
||||||
@ -235,8 +240,9 @@ RUN uv pip install --no-cache-dir --no-deps -e "."
|
|||||||
# every published image has it.
|
# every published image has it.
|
||||||
ARG HERMES_GIT_SHA=
|
ARG HERMES_GIT_SHA=
|
||||||
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
|
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
|
||||||
|
chmod u+w /opt/hermes && \
|
||||||
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
|
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
|
||||||
chown hermes:hermes /opt/hermes/.hermes_build_sha; \
|
chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------- s6-overlay service wiring ----------
|
# ---------- s6-overlay service wiring ----------
|
||||||
@ -282,6 +288,8 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
|||||||
# check. (A separate launcher hardening is tracked independently.)
|
# check. (A separate launcher hardening is tracked independently.)
|
||||||
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
||||||
ENV HERMES_HOME=/opt/data
|
ENV HERMES_HOME=/opt/data
|
||||||
|
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
|
||||||
|
ENV HERMES_DISABLE_LAZY_INSTALLS=1
|
||||||
|
|
||||||
# `docker exec` privilege-drop shim. When operators run
|
# `docker exec` privilege-drop shim. When operators run
|
||||||
# `docker exec <c> hermes ...` they default to root, and any file the
|
# `docker exec <c> hermes ...` they default to root, and any file the
|
||||||
@ -294,7 +302,6 @@ ENV HERMES_HOME=/opt/data
|
|||||||
# Recursion is impossible because the shim exec's the venv binary by
|
# Recursion is impossible because the shim exec's the venv binary by
|
||||||
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
|
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
|
||||||
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
|
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
|
||||||
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
|
|
||||||
|
|
||||||
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
|
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
|
||||||
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
|
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||||
|
|
||||||
from agent.context_compressor import ContextCompressor
|
from agent.context_compressor import ContextCompressor
|
||||||
@ -195,6 +195,7 @@ def init_agent(
|
|||||||
status_callback: callable = None,
|
status_callback: callable = None,
|
||||||
notice_callback: callable = None,
|
notice_callback: callable = None,
|
||||||
notice_clear_callback: callable = None,
|
notice_clear_callback: callable = None,
|
||||||
|
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||||
max_tokens: int = None,
|
max_tokens: int = None,
|
||||||
reasoning_config: Dict[str, Any] = None,
|
reasoning_config: Dict[str, Any] = None,
|
||||||
service_tier: str = None,
|
service_tier: str = None,
|
||||||
@ -426,6 +427,7 @@ def init_agent(
|
|||||||
agent.status_callback = status_callback
|
agent.status_callback = status_callback
|
||||||
agent.notice_callback = notice_callback
|
agent.notice_callback = notice_callback
|
||||||
agent.notice_clear_callback = notice_clear_callback
|
agent.notice_clear_callback = notice_clear_callback
|
||||||
|
agent.event_callback = event_callback
|
||||||
agent.tool_gen_callback = tool_gen_callback
|
agent.tool_gen_callback = tool_gen_callback
|
||||||
|
|
||||||
|
|
||||||
@ -597,6 +599,7 @@ def init_agent(
|
|||||||
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
|
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
|
||||||
agent._persist_user_message_idx = None
|
agent._persist_user_message_idx = None
|
||||||
agent._persist_user_message_override = None
|
agent._persist_user_message_override = None
|
||||||
|
agent._persist_user_message_timestamp = None
|
||||||
|
|
||||||
# Cache anthropic image-to-text fallbacks per image payload/URL so a
|
# Cache anthropic image-to-text fallbacks per image payload/URL so a
|
||||||
# single tool loop does not repeatedly re-run auxiliary vision on the
|
# single tool loop does not repeatedly re-run auxiliary vision on the
|
||||||
@ -1162,6 +1165,9 @@ def init_agent(
|
|||||||
"hermes_home": str(get_hermes_home()),
|
"hermes_home": str(get_hermes_home()),
|
||||||
"agent_context": "primary",
|
"agent_context": "primary",
|
||||||
}
|
}
|
||||||
|
if _init_kwargs["platform"] == "cli":
|
||||||
|
_init_kwargs["warning_callback"] = agent._emit_warning
|
||||||
|
_init_kwargs["status_callback"] = agent._emit_status
|
||||||
# Thread session title for memory provider scoping
|
# Thread session title for memory provider scoping
|
||||||
# (e.g. honcho uses this to derive chat-scoped session keys)
|
# (e.g. honcho uses this to derive chat-scoped session keys)
|
||||||
if agent._session_db:
|
if agent._session_db:
|
||||||
@ -1230,6 +1236,12 @@ def init_agent(
|
|||||||
# targets.
|
# targets.
|
||||||
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
|
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
|
||||||
|
|
||||||
|
# Universal parallel-tool-call guidance toggle. Default True. Separate
|
||||||
|
# flag from task_completion_guidance because a user may want one but not
|
||||||
|
# the other. Steers the model to batch independent tool calls into a
|
||||||
|
# single turn; the runtime already executes such batches concurrently.
|
||||||
|
agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True))
|
||||||
|
|
||||||
# Local Python toolchain probe toggle. Default True. When False,
|
# Local Python toolchain probe toggle. Default True. When False,
|
||||||
# the probe is skipped entirely (no subprocess calls, no system-prompt
|
# the probe is skipped entirely (no subprocess calls, no system-prompt
|
||||||
# line). Useful for users on exotic setups where the probe heuristics
|
# line). Useful for users on exotic setups where the probe heuristics
|
||||||
|
|||||||
@ -1839,21 +1839,35 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||||||
elif function_name == "memory":
|
elif function_name == "memory":
|
||||||
def _execute(next_args: dict) -> Any:
|
def _execute(next_args: dict) -> Any:
|
||||||
target = next_args.get("target", "memory")
|
target = next_args.get("target", "memory")
|
||||||
|
operations = next_args.get("operations")
|
||||||
from tools.memory_tool import memory_tool as _memory_tool
|
from tools.memory_tool import memory_tool as _memory_tool
|
||||||
result = _memory_tool(
|
result = _memory_tool(
|
||||||
action=next_args.get("action"),
|
action=next_args.get("action"),
|
||||||
target=target,
|
target=target,
|
||||||
content=next_args.get("content"),
|
content=next_args.get("content"),
|
||||||
old_text=next_args.get("old_text"),
|
old_text=next_args.get("old_text"),
|
||||||
|
operations=operations,
|
||||||
store=agent._memory_store,
|
store=agent._memory_store,
|
||||||
)
|
)
|
||||||
# Bridge: notify external memory provider of built-in memory writes
|
# Bridge: notify external memory provider of built-in memory writes.
|
||||||
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
|
# Covers both the single-op shape and each add/replace inside a batch.
|
||||||
|
if agent._memory_manager:
|
||||||
|
if operations:
|
||||||
|
_mem_ops = [
|
||||||
|
op for op in operations
|
||||||
|
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
_mem_ops = (
|
||||||
|
[{"action": next_args.get("action"), "content": next_args.get("content")}]
|
||||||
|
if next_args.get("action") in {"add", "replace"} else []
|
||||||
|
)
|
||||||
|
for _op in _mem_ops:
|
||||||
try:
|
try:
|
||||||
agent._memory_manager.on_memory_write(
|
agent._memory_manager.on_memory_write(
|
||||||
next_args.get("action", ""),
|
_op.get("action", ""),
|
||||||
target,
|
target,
|
||||||
next_args.get("content", ""),
|
_op.get("content", "") or "",
|
||||||
metadata=agent._build_memory_write_metadata(
|
metadata=agent._build_memory_write_metadata(
|
||||||
task_id=effective_task_id,
|
task_id=effective_task_id,
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
|
|||||||
@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str:
|
|||||||
|
|
||||||
|
|
||||||
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
|
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||||
_MCP_TOOL_PREFIX = "mcp_"
|
_MCP_TOOL_PREFIX = "mcp__"
|
||||||
|
|
||||||
|
|
||||||
def _get_claude_code_version() -> str:
|
def _get_claude_code_version() -> str:
|
||||||
@ -2349,25 +2349,46 @@ def build_anthropic_kwargs(
|
|||||||
text = text.replace("Nous Research", "Anthropic")
|
text = text.replace("Nous Research", "Anthropic")
|
||||||
block["text"] = text
|
block["text"] = text
|
||||||
|
|
||||||
# 3. Prefix tool names with mcp_ (Claude Code convention)
|
# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
|
||||||
# Skip names that already begin with the marker — native MCP server
|
# single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth
|
||||||
# tools (from mcp_servers: in config.yaml) are registered under their
|
# billing classifier treats a single-underscore ``mcp_`` tool name as
|
||||||
# full mcp_<server>_<tool> name and would double-prefix otherwise,
|
# a third-party-app fingerprint and rejects the request with HTTP 400
|
||||||
# breaking round-trip registry lookup in normalize_response. GH-25255.
|
# "Third-party apps now draw from extra usage, not plan limits"
|
||||||
|
# (verified empirically: a single ``mcp_foo`` tool flips a request
|
||||||
|
# from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted).
|
||||||
|
#
|
||||||
|
# Two cases, both must land on the double-underscore ``mcp__`` form:
|
||||||
|
# a) bare Hermes-native tools (``read_file``) -> ``mcp__read_file``
|
||||||
|
# b) native MCP server tools registered under their full
|
||||||
|
# single-underscore ``mcp_<server>_<tool>`` name
|
||||||
|
# (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue``
|
||||||
|
# Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap
|
||||||
|
# left open: those tools were *skipped* and stayed single-underscore,
|
||||||
|
# so any session with an MCP server configured still tripped the
|
||||||
|
# classifier. normalize_response reverses both forms via registry
|
||||||
|
# lookup so the dispatcher still sees the original name. GH-25255.
|
||||||
|
def _to_oauth_wire_name(name: str) -> str:
|
||||||
|
if name.startswith("mcp__"):
|
||||||
|
return name # already correct, don't double-prefix
|
||||||
|
if name.startswith("mcp_"):
|
||||||
|
# single-underscore native MCP tool -> promote to double
|
||||||
|
return "mcp__" + name[len("mcp_"):]
|
||||||
|
return _MCP_TOOL_PREFIX + name # bare name -> mcp__<name>
|
||||||
|
|
||||||
if anthropic_tools:
|
if anthropic_tools:
|
||||||
for tool in anthropic_tools:
|
for tool in anthropic_tools:
|
||||||
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
|
if "name" in tool:
|
||||||
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
|
tool["name"] = _to_oauth_wire_name(tool["name"])
|
||||||
|
|
||||||
# 4. Prefix tool names in message history (tool_use and tool_result blocks)
|
# 4. Apply the same normalization to tool names in message history
|
||||||
|
# (tool_use blocks) so replayed turns match the wire names above.
|
||||||
for msg in anthropic_messages:
|
for msg in anthropic_messages:
|
||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
for block in content:
|
for block in content:
|
||||||
if isinstance(block, dict):
|
if isinstance(block, dict):
|
||||||
if block.get("type") == "tool_use" and "name" in block:
|
if block.get("type") == "tool_use" and "name" in block:
|
||||||
if not block["name"].startswith(_MCP_TOOL_PREFIX):
|
block["name"] = _to_oauth_wire_name(block["name"])
|
||||||
block["name"] = _MCP_TOOL_PREFIX + block["name"]
|
|
||||||
elif block.get("type") == "tool_result" and "tool_use_id" in block:
|
elif block.get("type") == "tool_result" and "tool_use_id" in block:
|
||||||
pass # tool_result uses ID, not name
|
pass # tool_result uses ID, not name
|
||||||
|
|
||||||
|
|||||||
@ -3079,23 +3079,20 @@ def _try_configured_fallback_chain(
|
|||||||
if not fb_provider or fb_provider.lower() == skip:
|
if not fb_provider or fb_provider.lower() == skip:
|
||||||
continue
|
continue
|
||||||
fb_model = str(entry.get("model", "")).strip() or None
|
fb_model = str(entry.get("model", "")).strip() or None
|
||||||
fb_base_url = str(entry.get("base_url", "")).strip() or None
|
|
||||||
fb_api_key = str(entry.get("api_key", "")).strip() or None
|
|
||||||
|
|
||||||
label = f"fallback_chain[{i}]({fb_provider})"
|
label = f"fallback_chain[{i}]({fb_provider})"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
fb_client = _resolve_single_provider(
|
fb_client, resolved_model = _resolve_fallback_entry(entry)
|
||||||
fb_provider, fb_model, fb_base_url, fb_api_key)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
fb_client = None
|
fb_client, resolved_model = None, None
|
||||||
|
|
||||||
if fb_client is not None:
|
if fb_client is not None:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
|
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
|
||||||
task, reason, failed_provider, label, fb_model or "default",
|
task, reason, failed_provider, label, resolved_model or fb_model or "default",
|
||||||
)
|
)
|
||||||
return fb_client, fb_model, label
|
return fb_client, resolved_model or fb_model, label
|
||||||
tried.append(label)
|
tried.append(label)
|
||||||
|
|
||||||
if tried:
|
if tried:
|
||||||
@ -3106,6 +3103,103 @@ def _try_configured_fallback_chain(
|
|||||||
return None, None, ""
|
return None, None, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""Resolve inline or env-backed API key from a fallback-chain entry."""
|
||||||
|
explicit = str(entry.get("api_key") or "").strip()
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
|
||||||
|
if key_env:
|
||||||
|
return os.getenv(key_env, "").strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:
|
||||||
|
"""Resolve one fallback entry through the central provider router."""
|
||||||
|
provider = str(entry.get("provider") or "").strip()
|
||||||
|
model = str(entry.get("model") or "").strip() or None
|
||||||
|
if not provider or not model:
|
||||||
|
return None, None
|
||||||
|
base_url = str(entry.get("base_url") or "").strip() or None
|
||||||
|
api_key = _fallback_entry_api_key(entry)
|
||||||
|
api_mode = str(entry.get("api_mode") or entry.get("transport") or "").strip() or None
|
||||||
|
return resolve_provider_client(
|
||||||
|
provider,
|
||||||
|
model=model,
|
||||||
|
explicit_base_url=base_url,
|
||||||
|
explicit_api_key=api_key,
|
||||||
|
api_mode=api_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _try_main_fallback_chain(
|
||||||
|
task: Optional[str],
|
||||||
|
failed_provider: str = "",
|
||||||
|
reason: str = "error",
|
||||||
|
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||||
|
"""Try the top-level main-agent fallback chain for an auxiliary call.
|
||||||
|
|
||||||
|
``provider: auto`` auxiliary tasks should respect the user's declared
|
||||||
|
main fallback policy before dropping into Hermes' built-in discovery
|
||||||
|
chain. The top-level chain is read through ``get_fallback_chain`` so
|
||||||
|
both modern ``fallback_providers`` and legacy ``fallback_model`` entries
|
||||||
|
participate in the same order as the main agent.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_cli.config import load_config
|
||||||
|
from hermes_cli.fallback_config import get_fallback_chain
|
||||||
|
|
||||||
|
chain = get_fallback_chain(load_config())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Auxiliary %s: could not load main fallback chain: %s", task or "call", exc)
|
||||||
|
return None, None, ""
|
||||||
|
|
||||||
|
if not chain:
|
||||||
|
return None, None, ""
|
||||||
|
|
||||||
|
failed_norm = (failed_provider or "").strip().lower()
|
||||||
|
main_norm = (_read_main_provider() or "").strip().lower()
|
||||||
|
skip = {p for p in (failed_norm, main_norm, "auto") if p}
|
||||||
|
tried: List[str] = []
|
||||||
|
|
||||||
|
for i, entry in enumerate(chain):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
fb_provider = str(entry.get("provider") or "").strip()
|
||||||
|
fb_model = str(entry.get("model") or "").strip()
|
||||||
|
if not fb_provider or not fb_model:
|
||||||
|
continue
|
||||||
|
fb_norm = fb_provider.lower()
|
||||||
|
label = f"fallback_providers[{i}]({fb_provider})"
|
||||||
|
if fb_norm in skip:
|
||||||
|
tried.append(f"{label} (skipped)")
|
||||||
|
continue
|
||||||
|
if _is_provider_unhealthy(fb_norm):
|
||||||
|
_log_skip_unhealthy(fb_norm, task)
|
||||||
|
tried.append(f"{label} (unhealthy)")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fb_client, resolved_model = _resolve_fallback_entry(entry)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc)
|
||||||
|
fb_client, resolved_model = None, None
|
||||||
|
if fb_client is not None:
|
||||||
|
logger.info(
|
||||||
|
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
|
||||||
|
task or "call", reason, failed_provider or "auto", label,
|
||||||
|
resolved_model or fb_model,
|
||||||
|
)
|
||||||
|
return fb_client, resolved_model or fb_model, fb_provider
|
||||||
|
tried.append(label)
|
||||||
|
|
||||||
|
if tried:
|
||||||
|
logger.debug(
|
||||||
|
"Auxiliary %s: main fallback chain exhausted (tried: %s)",
|
||||||
|
task or "call", ", ".join(tried),
|
||||||
|
)
|
||||||
|
return None, None, ""
|
||||||
|
|
||||||
|
|
||||||
def _resolve_single_provider(
|
def _resolve_single_provider(
|
||||||
provider: str,
|
provider: str,
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
@ -3116,16 +3210,19 @@ def _resolve_single_provider(
|
|||||||
|
|
||||||
Uses the existing provider resolution infrastructure where possible.
|
Uses the existing provider resolution infrastructure where possible.
|
||||||
"""
|
"""
|
||||||
# Reuse resolve_provider_client which handles provider→client mapping
|
# Reuse resolve_provider_client which handles provider→client mapping.
|
||||||
client, resolved_model = resolve_provider_client(
|
client, resolved_model = resolve_provider_client(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
model=model,
|
model=model,
|
||||||
base_url=base_url,
|
explicit_base_url=base_url,
|
||||||
api_key=api_key,
|
explicit_api_key=api_key,
|
||||||
)
|
)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]:
|
def _resolve_auto(
|
||||||
|
main_runtime: Optional[Dict[str, Any]] = None,
|
||||||
|
task: Optional[str] = None,
|
||||||
|
) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||||
"""Full auto-detection chain.
|
"""Full auto-detection chain.
|
||||||
|
|
||||||
Priority:
|
Priority:
|
||||||
@ -3223,7 +3320,22 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
|||||||
main_provider, resolved or main_model)
|
main_provider, resolved or main_model)
|
||||||
return client, resolved or main_model
|
return client, resolved or main_model
|
||||||
|
|
||||||
# ── Step 2: aggregator / fallback chain ──────────────────────────────
|
# ── Step 2: user-configured fallback policy ─────────────────────────
|
||||||
|
# In auto mode, respect the task-specific fallback chain first, then the
|
||||||
|
# main agent's top-level fallback_providers/fallback_model chain. The
|
||||||
|
# hardcoded provider discovery chain below is only the convenience default
|
||||||
|
# for users who have not declared a fallback policy.
|
||||||
|
if task:
|
||||||
|
fb_client, fb_model, _fb_label = _try_configured_fallback_chain(
|
||||||
|
task, main_provider or "auto", reason="main provider unavailable")
|
||||||
|
if fb_client is not None:
|
||||||
|
return fb_client, fb_model
|
||||||
|
fb_client, fb_model, _fb_label = _try_main_fallback_chain(
|
||||||
|
task, main_provider or "auto", reason="main provider unavailable")
|
||||||
|
if fb_client is not None:
|
||||||
|
return fb_client, fb_model
|
||||||
|
|
||||||
|
# ── Step 3: aggregator / fallback chain ──────────────────────────────
|
||||||
tried = []
|
tried = []
|
||||||
for label, try_fn in _get_provider_chain():
|
for label, try_fn in _get_provider_chain():
|
||||||
if _is_provider_unhealthy(label):
|
if _is_provider_unhealthy(label):
|
||||||
@ -3344,6 +3456,7 @@ def resolve_provider_client(
|
|||||||
api_mode: str = None,
|
api_mode: str = None,
|
||||||
main_runtime: Optional[Dict[str, Any]] = None,
|
main_runtime: Optional[Dict[str, Any]] = None,
|
||||||
is_vision: bool = False,
|
is_vision: bool = False,
|
||||||
|
task: Optional[str] = None,
|
||||||
) -> Tuple[Optional[Any], Optional[str]]:
|
) -> Tuple[Optional[Any], Optional[str]]:
|
||||||
"""Central router: given a provider name and optional model, return a
|
"""Central router: given a provider name and optional model, return a
|
||||||
configured client with the correct auth, base URL, and API format.
|
configured client with the correct auth, base URL, and API format.
|
||||||
@ -3464,7 +3577,7 @@ def resolve_provider_client(
|
|||||||
|
|
||||||
# ── Auto: try all providers in priority order ────────────────────
|
# ── Auto: try all providers in priority order ────────────────────
|
||||||
if provider == "auto":
|
if provider == "auto":
|
||||||
client, resolved = _resolve_auto(main_runtime=main_runtime)
|
client, resolved = _resolve_auto(main_runtime=main_runtime, task=task)
|
||||||
if client is None:
|
if client is None:
|
||||||
return None, None
|
return None, None
|
||||||
# When auto-detection lands on a non-OpenRouter provider (e.g. a
|
# When auto-detection lands on a non-OpenRouter provider (e.g. a
|
||||||
@ -4357,11 +4470,16 @@ def _client_cache_key(
|
|||||||
api_mode: Optional[str] = None,
|
api_mode: Optional[str] = None,
|
||||||
main_runtime: Optional[Dict[str, Any]] = None,
|
main_runtime: Optional[Dict[str, Any]] = None,
|
||||||
is_vision: bool = False,
|
is_vision: bool = False,
|
||||||
|
task: Optional[str] = None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
runtime = _normalize_main_runtime(main_runtime)
|
runtime = _normalize_main_runtime(main_runtime)
|
||||||
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
|
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
|
||||||
|
# `auto` can now resolve through task-specific or main fallback policy,
|
||||||
|
# so the task participates in the cache key. Non-auto providers keep the
|
||||||
|
# old cache shape because the explicit provider/model tuple is sufficient.
|
||||||
|
task_key = (task or "") if provider == "auto" else ""
|
||||||
pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)
|
pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)
|
||||||
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, pool_hint)
|
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint)
|
||||||
|
|
||||||
|
|
||||||
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
|
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
|
||||||
@ -4554,6 +4672,7 @@ def _get_cached_client(
|
|||||||
api_mode: str = None,
|
api_mode: str = None,
|
||||||
main_runtime: Optional[Dict[str, Any]] = None,
|
main_runtime: Optional[Dict[str, Any]] = None,
|
||||||
is_vision: bool = False,
|
is_vision: bool = False,
|
||||||
|
task: Optional[str] = None,
|
||||||
) -> Tuple[Optional[Any], Optional[str]]:
|
) -> Tuple[Optional[Any], Optional[str]]:
|
||||||
"""Get or create a cached client for the given provider.
|
"""Get or create a cached client for the given provider.
|
||||||
|
|
||||||
@ -4591,6 +4710,7 @@ def _get_cached_client(
|
|||||||
api_mode=api_mode,
|
api_mode=api_mode,
|
||||||
main_runtime=main_runtime,
|
main_runtime=main_runtime,
|
||||||
is_vision=is_vision,
|
is_vision=is_vision,
|
||||||
|
task=task,
|
||||||
)
|
)
|
||||||
with _client_cache_lock:
|
with _client_cache_lock:
|
||||||
if cache_key in _client_cache:
|
if cache_key in _client_cache:
|
||||||
@ -4635,6 +4755,7 @@ def _get_cached_client(
|
|||||||
api_mode=api_mode,
|
api_mode=api_mode,
|
||||||
main_runtime=runtime,
|
main_runtime=runtime,
|
||||||
is_vision=is_vision,
|
is_vision=is_vision,
|
||||||
|
task=task,
|
||||||
)
|
)
|
||||||
if client is not None:
|
if client is not None:
|
||||||
# For async clients, remember which loop they were created on so we
|
# For async clients, remember which loop they were created on so we
|
||||||
@ -5140,7 +5261,7 @@ def call_llm(
|
|||||||
if not resolved_base_url:
|
if not resolved_base_url:
|
||||||
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
||||||
task or "call", resolved_provider)
|
task or "call", resolved_provider)
|
||||||
client, final_model = _get_cached_client("auto", main_runtime=main_runtime)
|
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
|
||||||
if client is None:
|
if client is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||||
@ -5466,12 +5587,17 @@ def call_llm(
|
|||||||
|
|
||||||
# Fallback order (#26882, #26803):
|
# Fallback order (#26882, #26803):
|
||||||
# 1. User-configured fallback_chain (per-task) if set
|
# 1. User-configured fallback_chain (per-task) if set
|
||||||
# 2. Main agent model (last-resort safety net)
|
# 2. For auto: top-level main fallback_providers/fallback_model
|
||||||
# For auto users (no explicit aux provider), use the full
|
# 3. For auto: built-in auxiliary discovery chain
|
||||||
# auto-detection chain instead — its Step 1 IS the main agent
|
# 4. For explicit aux providers: main agent model safety net
|
||||||
# model, so users on `auto` already get main-model fallback.
|
|
||||||
fb_client, fb_model, fb_label = (None, None, "")
|
fb_client, fb_model, fb_label = (None, None, "")
|
||||||
if is_auto:
|
if is_auto:
|
||||||
|
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||||
|
task, resolved_provider or "auto", reason=reason)
|
||||||
|
if fb_client is None:
|
||||||
|
fb_client, fb_model, fb_label = _try_main_fallback_chain(
|
||||||
|
task, resolved_provider or "auto", reason=reason)
|
||||||
|
if fb_client is None:
|
||||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||||
resolved_provider, task, reason=reason)
|
resolved_provider, task, reason=reason)
|
||||||
else:
|
else:
|
||||||
@ -5636,7 +5762,7 @@ async def async_call_llm(
|
|||||||
if not resolved_base_url:
|
if not resolved_base_url:
|
||||||
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
||||||
task or "call", resolved_provider)
|
task or "call", resolved_provider)
|
||||||
client, final_model = _get_cached_client("auto", async_mode=True)
|
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
|
||||||
if client is None:
|
if client is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||||
@ -5904,11 +6030,17 @@ async def async_call_llm(
|
|||||||
|
|
||||||
# Fallback order (#26882, #26803):
|
# Fallback order (#26882, #26803):
|
||||||
# 1. User-configured fallback_chain (per-task) if set
|
# 1. User-configured fallback_chain (per-task) if set
|
||||||
# 2. Main agent model (last-resort safety net)
|
# 2. For auto: top-level main fallback_providers/fallback_model
|
||||||
# Auto users get the full auto-detection chain instead — its
|
# 3. For auto: built-in auxiliary discovery chain
|
||||||
# Step 1 IS the main agent model.
|
# 4. For explicit aux providers: main agent model safety net
|
||||||
fb_client, fb_model, fb_label = (None, None, "")
|
fb_client, fb_model, fb_label = (None, None, "")
|
||||||
if is_auto:
|
if is_auto:
|
||||||
|
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||||
|
task, resolved_provider or "auto", reason=reason)
|
||||||
|
if fb_client is None:
|
||||||
|
fb_client, fb_model, fb_label = _try_main_fallback_chain(
|
||||||
|
task, resolved_provider or "auto", reason=reason)
|
||||||
|
if fb_client is None:
|
||||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||||
resolved_provider, task, reason=reason)
|
resolved_provider, task, reason=reason)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -300,6 +300,7 @@ def summarize_background_review_actions(
|
|||||||
"target": args.get("target", "memory"),
|
"target": args.get("target", "memory"),
|
||||||
"content": args.get("content", ""),
|
"content": args.get("content", ""),
|
||||||
"old_text": args.get("old_text", ""),
|
"old_text": args.get("old_text", ""),
|
||||||
|
"operations": args.get("operations") or [],
|
||||||
"name": args.get("name", ""),
|
"name": args.get("name", ""),
|
||||||
"old_string": args.get("old_string", ""),
|
"old_string": args.get("old_string", ""),
|
||||||
"new_string": args.get("new_string", ""),
|
"new_string": args.get("new_string", ""),
|
||||||
@ -353,6 +354,7 @@ def summarize_background_review_actions(
|
|||||||
content = detail.get("content", "")
|
content = detail.get("content", "")
|
||||||
old_text = detail.get("old_text", "")
|
old_text = detail.get("old_text", "")
|
||||||
skill_name = detail.get("name", "")
|
skill_name = detail.get("name", "")
|
||||||
|
operations = detail.get("operations") or []
|
||||||
max_preview = 120
|
max_preview = 120
|
||||||
if is_skill:
|
if is_skill:
|
||||||
change = data.get("_change", {})
|
change = data.get("_change", {})
|
||||||
@ -376,6 +378,21 @@ def summarize_background_review_actions(
|
|||||||
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
|
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
|
||||||
else:
|
else:
|
||||||
actions.append(f"📝 {message}" if message else f"Skill {action}")
|
actions.append(f"📝 {message}" if message else f"Skill {action}")
|
||||||
|
elif operations:
|
||||||
|
for op in operations:
|
||||||
|
op = op or {}
|
||||||
|
op_act = op.get("action", "")
|
||||||
|
op_content = (op.get("content") or "")
|
||||||
|
op_old = (op.get("old_text") or "")
|
||||||
|
if op_act == "add" and op_content:
|
||||||
|
preview = op_content[:max_preview] + ("…" if len(op_content) > max_preview else "")
|
||||||
|
actions.append(f"{label} ➕ {preview}")
|
||||||
|
elif op_act == "replace" and op_content:
|
||||||
|
preview = op_content[:max_preview] + ("…" if len(op_content) > max_preview else "")
|
||||||
|
actions.append(f"{label} ✏️ {preview}")
|
||||||
|
elif op_act == "remove" and op_old:
|
||||||
|
preview = op_old[:60] + ("…" if len(op_old) > 60 else "")
|
||||||
|
actions.append(f"{label} ➖ {preview}")
|
||||||
elif action == "add" and content:
|
elif action == "add" and content:
|
||||||
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
||||||
actions.append(f"{label} ➕ {preview}")
|
actions.append(f"{label} ➕ {preview}")
|
||||||
@ -391,6 +408,7 @@ def summarize_background_review_actions(
|
|||||||
"added" in message_lower
|
"added" in message_lower
|
||||||
or "replaced" in message_lower
|
or "replaced" in message_lower
|
||||||
or "removed" in message_lower
|
or "removed" in message_lower
|
||||||
|
or "applied" in message_lower
|
||||||
or (target and "add" in message.lower())
|
or (target and "add" in message.lower())
|
||||||
or "Entry added" in message
|
or "Entry added" in message
|
||||||
):
|
):
|
||||||
|
|||||||
@ -262,6 +262,26 @@ def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[L
|
|||||||
return converted or None
|
return converted or None
|
||||||
|
|
||||||
|
|
||||||
|
# Provider-executed built-in tool *declaration* types accepted on the
|
||||||
|
# Responses ``tools`` array. These are declared by ``type`` alone (no
|
||||||
|
# client-side name/parameters schema) and run server-side — the provider
|
||||||
|
# owns the implementation and reports progress via the matching ``*_call``
|
||||||
|
# output items. Hermes injects xAI's native ``web_search`` for the xAI
|
||||||
|
# transport (see agent/transports/codex.py); the rest are listed so the
|
||||||
|
# preflight validator passes them through rather than rejecting them as
|
||||||
|
# "unsupported type". Mirrors the ``*_call`` item-type set used in
|
||||||
|
# _normalize_codex_response.
|
||||||
|
_RESPONSES_BUILTIN_TOOL_TYPES = {
|
||||||
|
"web_search",
|
||||||
|
"web_search_preview",
|
||||||
|
"file_search",
|
||||||
|
"code_interpreter",
|
||||||
|
"image_generation",
|
||||||
|
"computer_use_preview",
|
||||||
|
"local_shell",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Message format conversion
|
# Message format conversion
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -802,7 +822,22 @@ def _preflight_codex_api_kwargs(
|
|||||||
for idx, tool in enumerate(tools):
|
for idx, tool in enumerate(tools):
|
||||||
if not isinstance(tool, dict):
|
if not isinstance(tool, dict):
|
||||||
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
|
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
|
||||||
if tool.get("type") != "function":
|
|
||||||
|
tool_type = tool.get("type")
|
||||||
|
|
||||||
|
# Provider-executed built-in tools (xAI native web_search, code
|
||||||
|
# interpreter, etc.) are declared by ``type`` alone and carry no
|
||||||
|
# ``name``/``parameters`` schema — the provider owns the
|
||||||
|
# implementation. Pass them through verbatim instead of forcing
|
||||||
|
# them through the function-tool validation below (which would
|
||||||
|
# otherwise reject them with "unsupported type"). See
|
||||||
|
# agent/transports/codex.py for where xAI's native web_search is
|
||||||
|
# injected.
|
||||||
|
if tool_type in _RESPONSES_BUILTIN_TOOL_TYPES:
|
||||||
|
normalized_tools.append(dict(tool))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if tool_type != "function":
|
||||||
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
|
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
|
||||||
|
|
||||||
name = tool.get("name")
|
name = tool.get("name")
|
||||||
@ -1086,6 +1121,33 @@ def _normalize_codex_response(
|
|||||||
saw_final_answer_phase = False
|
saw_final_answer_phase = False
|
||||||
saw_reasoning_item = False
|
saw_reasoning_item = False
|
||||||
|
|
||||||
|
# Server-side built-in tool calls (xAI's native web_search, code
|
||||||
|
# interpreter, etc.) are executed by the provider and reported as
|
||||||
|
# discrete ``*_call`` output items. xAI's /v1/responses surface
|
||||||
|
# (e.g. grok-composer-2.5-fast on SuperGrok OAuth) routinely leaves
|
||||||
|
# these items at ``status="in_progress"`` even when the overall
|
||||||
|
# ``response.status == "completed"`` — the search ran to completion
|
||||||
|
# server-side, the per-item status simply isn't reconciled. These
|
||||||
|
# are NOT a signal that the model's turn is unfinished, so they must
|
||||||
|
# not flip ``has_incomplete_items``. Only the response-level status
|
||||||
|
# and genuine model output items (message/reasoning/function_call)
|
||||||
|
# govern the incomplete verdict. Without this guard, any turn where
|
||||||
|
# grok-composer invokes server-side search is misclassified as
|
||||||
|
# ``finish_reason="incomplete"`` and burns 3 fruitless continuation
|
||||||
|
# retries before failing with "Codex response remained incomplete
|
||||||
|
# after 3 continuation attempts". client-side function/custom tool
|
||||||
|
# calls keep their own in_progress handling below (they are skipped,
|
||||||
|
# not awaited).
|
||||||
|
_SERVER_SIDE_TOOL_CALL_TYPES = {
|
||||||
|
"web_search_call",
|
||||||
|
"file_search_call",
|
||||||
|
"code_interpreter_call",
|
||||||
|
"image_generation_call",
|
||||||
|
"computer_call",
|
||||||
|
"local_shell_call",
|
||||||
|
"mcp_call",
|
||||||
|
}
|
||||||
|
|
||||||
for item in output:
|
for item in output:
|
||||||
item_type = getattr(item, "type", None)
|
item_type = getattr(item, "type", None)
|
||||||
item_status = getattr(item, "status", None)
|
item_status = getattr(item, "status", None)
|
||||||
@ -1094,7 +1156,10 @@ def _normalize_codex_response(
|
|||||||
else:
|
else:
|
||||||
item_status = None
|
item_status = None
|
||||||
|
|
||||||
if item_status in {"queued", "in_progress", "incomplete"}:
|
if (
|
||||||
|
item_status in {"queued", "in_progress", "incomplete"}
|
||||||
|
and item_type not in _SERVER_SIDE_TOOL_CALL_TYPES
|
||||||
|
):
|
||||||
has_incomplete_items = True
|
has_incomplete_items = True
|
||||||
saw_streaming_or_item_incomplete = True
|
saw_streaming_or_item_incomplete = True
|
||||||
|
|
||||||
|
|||||||
@ -603,6 +603,20 @@ def compress_context(
|
|||||||
force=True,
|
force=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Emit session:compress event so hooks (e.g. MemPalace sync) can ingest
|
||||||
|
# the completed old session before its details are lost.
|
||||||
|
_old_sid_for_event = locals().get("old_session_id")
|
||||||
|
if getattr(agent, "event_callback", None):
|
||||||
|
try:
|
||||||
|
agent.event_callback("session:compress", {
|
||||||
|
"platform": agent.platform or "",
|
||||||
|
"session_id": agent.session_id,
|
||||||
|
"old_session_id": _old_sid_for_event or "",
|
||||||
|
"compression_count": agent.context_compressor.compression_count,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("event_callback error on session:compress: %s", e)
|
||||||
|
|
||||||
# Keep the post-compression rough estimate for diagnostics, but do not
|
# Keep the post-compression rough estimate for diagnostics, but do not
|
||||||
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
|
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
|
||||||
# can remain above threshold even after the next real API request fits.
|
# can remain above threshold even after the next real API request fits.
|
||||||
|
|||||||
@ -300,11 +300,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
|
|||||||
agent.session_id, exc,
|
agent.session_id, exc,
|
||||||
)
|
)
|
||||||
|
|
||||||
if stored_prompt:
|
if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt):
|
||||||
# Continuing session — reuse the exact system prompt from the
|
# Continuing session — reuse the exact system prompt from the
|
||||||
# previous turn so the Anthropic cache prefix matches.
|
# previous turn so the Anthropic cache prefix matches.
|
||||||
agent._cached_system_prompt = stored_prompt
|
agent._cached_system_prompt = stored_prompt
|
||||||
return
|
return
|
||||||
|
if stored_prompt:
|
||||||
|
stored_state = "stale_runtime"
|
||||||
|
logger.info(
|
||||||
|
"Stored system prompt for session %s has stale runtime identity; "
|
||||||
|
"rebuilding for model=%s provider=%s.",
|
||||||
|
agent.session_id,
|
||||||
|
getattr(agent, "model", "") or "",
|
||||||
|
getattr(agent, "provider", "") or "",
|
||||||
|
)
|
||||||
|
|
||||||
if conversation_history and stored_state in ("null", "empty"):
|
if conversation_history and stored_state in ("null", "empty"):
|
||||||
# Continuing session whose stored prompt is unusable. The
|
# Continuing session whose stored prompt is unusable. The
|
||||||
@ -366,6 +375,30 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
|
||||||
|
"""Return False when the persisted Model/Provider lines are stale."""
|
||||||
|
|
||||||
|
def line_value(label: str) -> str:
|
||||||
|
prefix = f"{label}:"
|
||||||
|
value = ""
|
||||||
|
for line in prompt.splitlines():
|
||||||
|
if line.startswith(prefix):
|
||||||
|
value = line[len(prefix):].strip()
|
||||||
|
return value
|
||||||
|
|
||||||
|
stored_model = line_value("Model")
|
||||||
|
current_model = str(getattr(agent, "model", "") or "").strip()
|
||||||
|
if stored_model and current_model and stored_model != current_model:
|
||||||
|
return False
|
||||||
|
|
||||||
|
stored_provider = line_value("Provider")
|
||||||
|
current_provider = str(getattr(agent, "provider", "") or "").strip()
|
||||||
|
if stored_provider and current_provider and stored_provider != current_provider:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:
|
def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:
|
||||||
if is_partial_stub and dropped_tools:
|
if is_partial_stub and dropped_tools:
|
||||||
tool_list = ", ".join(dropped_tools[:3])
|
tool_list = ", ".join(dropped_tools[:3])
|
||||||
@ -441,6 +474,7 @@ def run_conversation(
|
|||||||
task_id: str = None,
|
task_id: str = None,
|
||||||
stream_callback: Optional[callable] = None,
|
stream_callback: Optional[callable] = None,
|
||||||
persist_user_message: Optional[str] = None,
|
persist_user_message: Optional[str] = None,
|
||||||
|
persist_user_timestamp: Optional[float] = None,
|
||||||
moa_config: Optional[dict[str, Any]] = None,
|
moa_config: Optional[dict[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@ -457,6 +491,8 @@ def run_conversation(
|
|||||||
persist_user_message: Optional clean user message to store in
|
persist_user_message: Optional clean user message to store in
|
||||||
transcripts/history when user_message contains API-only
|
transcripts/history when user_message contains API-only
|
||||||
synthetic prefixes.
|
synthetic prefixes.
|
||||||
|
persist_user_timestamp: Optional platform event timestamp to store
|
||||||
|
as metadata on that persisted user message.
|
||||||
or queuing follow-up prefetch work.
|
or queuing follow-up prefetch work.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@ -491,6 +527,7 @@ def run_conversation(
|
|||||||
task_id,
|
task_id,
|
||||||
stream_callback,
|
stream_callback,
|
||||||
persist_user_message,
|
persist_user_message,
|
||||||
|
persist_user_timestamp,
|
||||||
restore_or_build_system_prompt=_restore_or_build_system_prompt,
|
restore_or_build_system_prompt=_restore_or_build_system_prompt,
|
||||||
install_safe_stdio=_install_safe_stdio,
|
install_safe_stdio=_install_safe_stdio,
|
||||||
sanitize_surrogates=_sanitize_surrogates,
|
sanitize_surrogates=_sanitize_surrogates,
|
||||||
@ -3756,8 +3793,30 @@ def run_conversation(
|
|||||||
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
|
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
|
||||||
messages.append(assistant_msg)
|
messages.append(assistant_msg)
|
||||||
for tc in assistant_message.tool_calls:
|
for tc in assistant_message.tool_calls:
|
||||||
if tc.function.name not in agent.valid_tool_names:
|
_tc_name = tc.function.name
|
||||||
content = f"Tool '{tc.function.name}' does not exist. Available tools: {available}"
|
if _tc_name not in agent.valid_tool_names:
|
||||||
|
# A blank/whitespace-only name is not a typo the
|
||||||
|
# model can fuzzy-correct toward a real tool — it is
|
||||||
|
# almost always a weak open model echoing tool-call
|
||||||
|
# XML/JSON it saw in file or tool output (#47967:
|
||||||
|
# <tool_call>/<invoke name=...> payloads in a file
|
||||||
|
# prime mimo/nemotron-class models to emit empty
|
||||||
|
# structured calls). Dumping the full tool catalog
|
||||||
|
# in that case feeds the priming loop more names to
|
||||||
|
# mimic and inflates context 3-4x across retries, so
|
||||||
|
# send a terse error that tells the model in-context
|
||||||
|
# tool-call syntax is DATA, not a call to make.
|
||||||
|
if not (_tc_name or "").strip():
|
||||||
|
content = (
|
||||||
|
"Tool call rejected: the tool name was empty. "
|
||||||
|
"If tool-call XML or JSON appeared in file "
|
||||||
|
"contents or tool output, that is data — do "
|
||||||
|
"not re-emit it as a tool call. To call a "
|
||||||
|
"tool, use a valid name from your tool list; "
|
||||||
|
"otherwise reply in plain text."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content = f"Tool '{_tc_name}' does not exist. Available tools: {available}"
|
||||||
else:
|
else:
|
||||||
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
|
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
|
||||||
messages.append({
|
messages.append({
|
||||||
|
|||||||
@ -57,6 +57,11 @@ DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
|
|||||||
DEFAULT_MIN_IDLE_HOURS = 2
|
DEFAULT_MIN_IDLE_HOURS = 2
|
||||||
DEFAULT_STALE_AFTER_DAYS = 30
|
DEFAULT_STALE_AFTER_DAYS = 30
|
||||||
DEFAULT_ARCHIVE_AFTER_DAYS = 90
|
DEFAULT_ARCHIVE_AFTER_DAYS = 90
|
||||||
|
# Consolidation (the LLM umbrella-building fork) is OFF by default. The
|
||||||
|
# deterministic inactivity prune (apply_automatic_transitions) still runs
|
||||||
|
# whenever the curator is enabled; only the opinionated, aux-model-cost
|
||||||
|
# consolidation pass is opt-in.
|
||||||
|
DEFAULT_CONSOLIDATE = False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -182,6 +187,22 @@ def get_prune_builtins() -> bool:
|
|||||||
return bool(cfg.get("prune_builtins", True))
|
return bool(cfg.get("prune_builtins", True))
|
||||||
|
|
||||||
|
|
||||||
|
def get_consolidate() -> bool:
|
||||||
|
"""Whether the curator runs its LLM consolidation (umbrella-building) pass.
|
||||||
|
|
||||||
|
OFF by default. When off, a curator run does ONLY the deterministic
|
||||||
|
inactivity prune (mark stale / archive long-unused skills) and skips the
|
||||||
|
forked aux-model review entirely — no consolidation, no umbrella-building,
|
||||||
|
no aux-model cost. Set ``curator.consolidate: true`` to opt back into the
|
||||||
|
LLM pass that merges overlapping skills into class-level umbrellas.
|
||||||
|
|
||||||
|
The explicit ``hermes curator run --consolidate`` flag overrides this for
|
||||||
|
a single invocation regardless of the config value.
|
||||||
|
"""
|
||||||
|
cfg = _load_config()
|
||||||
|
return bool(cfg.get("consolidate", DEFAULT_CONSOLIDATE))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Idle / interval check
|
# Idle / interval check
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -1408,25 +1429,38 @@ def run_curator_review(
|
|||||||
on_summary: Optional[Callable[[str], None]] = None,
|
on_summary: Optional[Callable[[str], None]] = None,
|
||||||
synchronous: bool = False,
|
synchronous: bool = False,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
|
consolidate: Optional[bool] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Execute a single curator review pass.
|
"""Execute a single curator review pass.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Apply automatic state transitions (pure, no LLM).
|
1. Apply automatic state transitions (pure, no LLM).
|
||||||
2. If there are agent-created skills, spawn a forked AIAgent that runs
|
2. If consolidation is enabled AND there are agent-created skills, spawn
|
||||||
the LLM review prompt against the current candidate list.
|
a forked AIAgent that runs the LLM review prompt against the current
|
||||||
|
candidate list.
|
||||||
3. Update .curator_state with last_run_at and a one-line summary.
|
3. Update .curator_state with last_run_at and a one-line summary.
|
||||||
4. Invoke *on_summary* with a user-visible description.
|
4. Invoke *on_summary* with a user-visible description.
|
||||||
|
|
||||||
If *synchronous* is True, the LLM review runs in the calling thread; the
|
If *synchronous* is True, the LLM review runs in the calling thread; the
|
||||||
default is to spawn a daemon thread so the caller returns immediately.
|
default is to spawn a daemon thread so the caller returns immediately.
|
||||||
|
|
||||||
|
*consolidate* gates the LLM umbrella-building pass. ``None`` (the default)
|
||||||
|
reads ``curator.consolidate`` from config (OFF by default). Passing
|
||||||
|
``True``/``False`` overrides the config for this invocation — used by the
|
||||||
|
``hermes curator run --consolidate`` flag. When consolidation is off, only
|
||||||
|
the deterministic inactivity prune runs and the forked aux-model review is
|
||||||
|
skipped entirely (no aux-model cost).
|
||||||
|
|
||||||
If *dry_run* is True, the automatic stale/archive transitions are SKIPPED
|
If *dry_run* is True, the automatic stale/archive transitions are SKIPPED
|
||||||
and the LLM review pass is instructed to produce a report only — no
|
and the LLM review pass is instructed to produce a report only — no
|
||||||
skill_manage mutations, no terminal archive moves. The REPORT.md still
|
skill_manage mutations, no terminal archive moves. The REPORT.md still
|
||||||
gets written and ``state.last_report_path`` still records it so users
|
gets written and ``state.last_report_path`` still records it so users
|
||||||
can read what the curator WOULD have done.
|
can read what the curator WOULD have done. A dry-run also honors
|
||||||
|
*consolidate*: when consolidation is off, the preview only reports the
|
||||||
|
deterministic prune candidates.
|
||||||
"""
|
"""
|
||||||
|
if consolidate is None:
|
||||||
|
consolidate = get_consolidate()
|
||||||
start = datetime.now(timezone.utc)
|
start = datetime.now(timezone.utc)
|
||||||
if dry_run:
|
if dry_run:
|
||||||
# Count candidates without mutating state.
|
# Count candidates without mutating state.
|
||||||
@ -1489,6 +1523,53 @@ def run_curator_review(
|
|||||||
before_report = []
|
before_report = []
|
||||||
before_names = {r.get("name") for r in before_report if isinstance(r, dict)}
|
before_names = {r.get("name") for r in before_report if isinstance(r, dict)}
|
||||||
|
|
||||||
|
# Consolidation gate. When off (the default), the curator does ONLY the
|
||||||
|
# deterministic inactivity prune above — no forked aux-model review, no
|
||||||
|
# umbrella-building, no aux-model cost. Record the run, write a report
|
||||||
|
# reflecting the prune-only outcome, and return without spawning a fork.
|
||||||
|
if not consolidate:
|
||||||
|
final_summary = (
|
||||||
|
f"{prefix}{auto_summary}; llm: skipped (consolidation off)"
|
||||||
|
)
|
||||||
|
llm_meta = {
|
||||||
|
"final": "",
|
||||||
|
"summary": "skipped (consolidation off)",
|
||||||
|
"model": "",
|
||||||
|
"provider": "",
|
||||||
|
"tool_calls": [],
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
|
||||||
|
state2 = load_state()
|
||||||
|
state2["last_run_duration_seconds"] = elapsed
|
||||||
|
state2["last_run_summary"] = final_summary
|
||||||
|
try:
|
||||||
|
after_report = skill_usage.agent_created_report()
|
||||||
|
except Exception:
|
||||||
|
after_report = []
|
||||||
|
try:
|
||||||
|
report_path = _write_run_report(
|
||||||
|
started_at=start,
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
auto_counts=counts,
|
||||||
|
auto_summary=auto_summary,
|
||||||
|
before_report=before_report,
|
||||||
|
before_names=before_names,
|
||||||
|
after_report=after_report,
|
||||||
|
llm_meta=llm_meta,
|
||||||
|
)
|
||||||
|
if report_path is not None:
|
||||||
|
state2["last_report_path"] = str(report_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Curator report write failed: %s", e, exc_info=True)
|
||||||
|
save_state(state2)
|
||||||
|
if on_summary:
|
||||||
|
try:
|
||||||
|
on_summary(f"curator: {final_summary}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
llm_meta: Dict[str, Any] = {}
|
llm_meta: Dict[str, Any] = {}
|
||||||
try:
|
try:
|
||||||
candidate_list = _render_candidate_list()
|
candidate_list = _render_candidate_list()
|
||||||
|
|||||||
@ -46,7 +46,7 @@ import shutil
|
|||||||
import tarfile
|
import tarfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
from agent.skill_utils import is_excluded_skill_path
|
from agent.skill_utils import is_excluded_skill_path
|
||||||
@ -208,13 +208,17 @@ def _write_manifest(dest: Path, reason: str, archive_path: Path,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def snapshot_skills(reason: str = "manual") -> Optional[Path]:
|
def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] = None) -> Optional[Path]:
|
||||||
"""Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones.
|
"""Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones.
|
||||||
|
|
||||||
Returns the snapshot directory path, or ``None`` if the snapshot was
|
Returns the snapshot directory path, or ``None`` if the snapshot was
|
||||||
skipped (backup disabled, skills dir missing, or an IO error occurred —
|
skipped (backup disabled, skills dir missing, or an IO error occurred —
|
||||||
in which case we log at debug and return None so the curator never
|
in which case we log at debug and return None so the curator never
|
||||||
aborts a pass because of a backup failure).
|
aborts a pass because of a backup failure).
|
||||||
|
|
||||||
|
``protect_ids`` is forwarded to the prune step so callers can guarantee
|
||||||
|
specific snapshot ids survive even when they fall outside the keep
|
||||||
|
window (rollback passes the id it is about to restore from).
|
||||||
"""
|
"""
|
||||||
if not is_enabled():
|
if not is_enabled():
|
||||||
logger.debug("Curator backup disabled by config; skipping snapshot")
|
logger.debug("Curator backup disabled by config; skipping snapshot")
|
||||||
@ -276,15 +280,19 @@ def snapshot_skills(reason: str = "manual") -> Optional[Path]:
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_prune_old(keep=get_keep())
|
_prune_old(keep=get_keep(), protect=protect_ids)
|
||||||
logger.info("Curator snapshot created: %s (%s)", snap_id, reason)
|
logger.info("Curator snapshot created: %s (%s)", snap_id, reason)
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
def _prune_old(keep: int) -> List[str]:
|
def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
|
||||||
"""Delete regular snapshots beyond the newest *keep*. Returns deleted
|
"""Delete regular snapshots beyond the newest *keep*. Returns deleted
|
||||||
ids. Staging dirs (``.rollback-staging-*``) are implementation detail
|
ids. Snapshot ids in *protect* are never deleted even when they fall
|
||||||
and pruned independently on every call."""
|
outside the keep window — rollback() uses this so the mandatory
|
||||||
|
pre-rollback safety snapshot can never evict the very snapshot being
|
||||||
|
restored. Staging dirs (``.rollback-staging-*``) are implementation
|
||||||
|
detail and pruned independently on every call."""
|
||||||
|
protect = protect or set()
|
||||||
backups = _backups_dir()
|
backups = _backups_dir()
|
||||||
if not backups.exists():
|
if not backups.exists():
|
||||||
return []
|
return []
|
||||||
@ -305,6 +313,8 @@ def _prune_old(keep: int) -> List[str]:
|
|||||||
entries.sort(key=lambda t: t[0], reverse=True)
|
entries.sort(key=lambda t: t[0], reverse=True)
|
||||||
deleted: List[str] = []
|
deleted: List[str] = []
|
||||||
for _, path in entries[keep:]:
|
for _, path in entries[keep:]:
|
||||||
|
if path.name in protect:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(path)
|
shutil.rmtree(path)
|
||||||
deleted.append(path.name)
|
deleted.append(path.name)
|
||||||
@ -564,7 +574,13 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
|
|||||||
# out before touching anything — otherwise a failed extract could leave
|
# out before touching anything — otherwise a failed extract could leave
|
||||||
# the user with no skills.
|
# the user with no skills.
|
||||||
try:
|
try:
|
||||||
snapshot_skills(reason=f"pre-rollback to {target.name}")
|
# Protect the target from this snapshot's prune step: at the steady
|
||||||
|
# keep limit, pruning the oldest snapshot would otherwise delete the
|
||||||
|
# very snapshot we are about to extract from.
|
||||||
|
snapshot_skills(
|
||||||
|
reason=f"pre-rollback to {target.name}",
|
||||||
|
protect_ids={target.name},
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return (False, f"pre-rollback safety snapshot failed: {e}", None)
|
return (False, f"pre-rollback safety snapshot failed: {e}", None)
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from agent.memory_provider import MemoryProvider
|
from agent.memory_provider import MemoryProvider
|
||||||
|
from agent.skill_commands import extract_user_instruction_from_skill_message
|
||||||
from tools.registry import tool_error
|
from tools.registry import tool_error
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -430,16 +431,37 @@ class MemoryManager:
|
|||||||
|
|
||||||
# -- Prefetch / recall ---------------------------------------------------
|
# -- Prefetch / recall ---------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_skill_scaffolding(text: str) -> Optional[str]:
|
||||||
|
"""Return memory-worthy user text, or None to skip the turn.
|
||||||
|
|
||||||
|
When a user invokes a /skill or /bundle, Hermes expands the turn into
|
||||||
|
a model-facing message that embeds the entire skill body. Feeding that
|
||||||
|
verbatim to memory providers pollutes their stores/embeddings with
|
||||||
|
prompt scaffolding instead of what the user actually asked. We recover
|
||||||
|
just the user's instruction here, once, for every provider — so this
|
||||||
|
is fixed for the whole provider fan-out, not per backend.
|
||||||
|
|
||||||
|
- Non-skill messages pass through unchanged.
|
||||||
|
- Skill turns with a user instruction return that instruction.
|
||||||
|
- Bare skill invocations (no instruction) return None → callers skip
|
||||||
|
the turn, since there is no user content worth remembering.
|
||||||
|
"""
|
||||||
|
return extract_user_instruction_from_skill_message(text)
|
||||||
|
|
||||||
def prefetch_all(self, query: str, *, session_id: str = "") -> str:
|
def prefetch_all(self, query: str, *, session_id: str = "") -> str:
|
||||||
"""Collect prefetch context from all providers.
|
"""Collect prefetch context from all providers.
|
||||||
|
|
||||||
Returns merged context text labeled by provider. Empty providers
|
Returns merged context text labeled by provider. Empty providers
|
||||||
are skipped. Failures in one provider don't block others.
|
are skipped. Failures in one provider don't block others.
|
||||||
"""
|
"""
|
||||||
|
clean_query = self._strip_skill_scaffolding(query)
|
||||||
|
if not clean_query:
|
||||||
|
return ""
|
||||||
parts = []
|
parts = []
|
||||||
for provider in self._providers:
|
for provider in self._providers:
|
||||||
try:
|
try:
|
||||||
result = provider.prefetch(query, session_id=session_id)
|
result = provider.prefetch(clean_query, session_id=session_id)
|
||||||
if result and result.strip():
|
if result and result.strip():
|
||||||
parts.append(result)
|
parts.append(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -460,10 +482,14 @@ class MemoryManager:
|
|||||||
if not providers:
|
if not providers:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
clean_query = self._strip_skill_scaffolding(query)
|
||||||
|
if not clean_query:
|
||||||
|
return
|
||||||
|
|
||||||
def _run() -> None:
|
def _run() -> None:
|
||||||
for provider in providers:
|
for provider in providers:
|
||||||
try:
|
try:
|
||||||
provider.queue_prefetch(query, session_id=session_id)
|
provider.queue_prefetch(clean_query, session_id=session_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
|
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
|
||||||
@ -515,6 +541,11 @@ class MemoryManager:
|
|||||||
if not providers:
|
if not providers:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
clean_user_content = self._strip_skill_scaffolding(user_content)
|
||||||
|
if not clean_user_content:
|
||||||
|
return
|
||||||
|
user_content = clean_user_content
|
||||||
|
|
||||||
def _run() -> None:
|
def _run() -> None:
|
||||||
for provider in providers:
|
for provider in providers:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -275,6 +275,11 @@ DEFAULT_CONTEXT_LENGTHS = {
|
|||||||
# via a custom provider. Values sourced from models.dev (2026-04).
|
# via a custom provider. Values sourced from models.dev (2026-04).
|
||||||
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
|
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
|
||||||
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
|
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
|
||||||
|
# OAuth-only slug; absent from GET /v1/models. xAI publishes a 200k
|
||||||
|
# usable context window for Composer 2.5 on Grok Build (SuperGrok /
|
||||||
|
# Premium+); /v1/responses additionally enforces a ~262144 input+output
|
||||||
|
# budget, but the usable context (what we track here) is 200k.
|
||||||
|
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
|
||||||
"grok-build": 256000, # grok-build-0.1
|
"grok-build": 256000, # grok-build-0.1
|
||||||
"grok-code-fast": 256000, # grok-code-fast-1
|
"grok-code-fast": 256000, # grok-code-fast-1
|
||||||
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
|
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import contextvars
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -304,6 +305,47 @@ TASK_COMPLETION_GUIDANCE = (
|
|||||||
"is always better than inventing a result."
|
"is always better than inventing a result."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Universal parallel-tool-call guidance — applied to ALL models.
|
||||||
|
#
|
||||||
|
# Why this matters for cost: every assistant turn resends the entire
|
||||||
|
# accumulated conversation (and, on cache-friendly providers, re-reads the
|
||||||
|
# cached prefix and pays for the newly-appended turn). A model that issues
|
||||||
|
# one tool call per turn multiplies the number of round-trips — and therefore
|
||||||
|
# the resent context — for any task that needs several independent reads,
|
||||||
|
# searches, or safe lookups. Batching independent calls into a single
|
||||||
|
# assistant response collapses N turns into one, cutting both latency and the
|
||||||
|
# resent-context cost that compounds over a long conversation.
|
||||||
|
#
|
||||||
|
# The hermes-agent runtime already executes a batch of tool calls
|
||||||
|
# concurrently when they are independent (read-only tools always; path-scoped
|
||||||
|
# file ops when their targets don't overlap — see
|
||||||
|
# run_agent._execute_tool_calls / tool_dispatch_helpers). The missing piece
|
||||||
|
# was telling the *model* to emit those calls together in the first place.
|
||||||
|
# Until now the only batching steer in the prompt lived in
|
||||||
|
# GOOGLE_MODEL_OPERATIONAL_GUIDANCE — Gemini/Gemma got it, every other model
|
||||||
|
# got nothing. This block makes the steer universal; the now-redundant
|
||||||
|
# Google-only bullet has been dropped so no model receives it twice.
|
||||||
|
#
|
||||||
|
# Short on purpose — shipped in the cached system prompt to every user, every
|
||||||
|
# session. Token cost is paid once at install and amortised across all
|
||||||
|
# sessions via prefix caching. Keep it tight.
|
||||||
|
#
|
||||||
|
# Ported from cline/cline#11514 ("encourage parallel tool calls"), adapted
|
||||||
|
# from Cline's TypeScript tool-surface guidance to hermes-agent's Python
|
||||||
|
# prompt-assembly architecture.
|
||||||
|
PARALLEL_TOOL_CALL_GUIDANCE = (
|
||||||
|
"# Parallel tool calls\n"
|
||||||
|
"When you need several pieces of information that don't depend on each "
|
||||||
|
"other, request them together in a single response instead of one tool "
|
||||||
|
"call per turn. Independent reads, searches, web fetches, and read-only "
|
||||||
|
"commands should be batched into the same assistant turn — the runtime "
|
||||||
|
"executes independent calls concurrently, and batching avoids resending "
|
||||||
|
"the whole conversation on every extra round-trip.\n"
|
||||||
|
"Only serialize calls when a later call genuinely depends on an earlier "
|
||||||
|
"call's result (e.g. you must read a file before you can patch it). When "
|
||||||
|
"in doubt and the calls are independent, batch them."
|
||||||
|
)
|
||||||
|
|
||||||
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
|
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
|
||||||
# where GPT models abandon work on partial results, skip prerequisite lookups,
|
# where GPT models abandon work on partial results, skip prerequisite lookups,
|
||||||
# hallucinate instead of using tools, and declare "done" without verification.
|
# hallucinate instead of using tools, and declare "done" without verification.
|
||||||
@ -385,9 +427,10 @@ GOOGLE_MODEL_OPERATIONAL_GUIDANCE = (
|
|||||||
"package.json, requirements.txt, Cargo.toml, etc. before importing.\n"
|
"package.json, requirements.txt, Cargo.toml, etc. before importing.\n"
|
||||||
"- **Conciseness:** Keep explanatory text brief — a few sentences, not "
|
"- **Conciseness:** Keep explanatory text brief — a few sentences, not "
|
||||||
"paragraphs. Focus on actions and results over narration.\n"
|
"paragraphs. Focus on actions and results over narration.\n"
|
||||||
"- **Parallel tool calls:** When you need to perform multiple independent "
|
# Parallel-tool-call steering now lives in the universal
|
||||||
"operations (e.g. reading several files), make all the tool calls in a "
|
# PARALLEL_TOOL_CALL_GUIDANCE block (injected for all models), so it is no
|
||||||
"single response rather than sequentially.\n"
|
# longer duplicated here — keeping it would send Gemini/Gemma the same
|
||||||
|
# instruction twice.
|
||||||
"- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
|
"- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
|
||||||
"to prevent CLI tools from hanging on prompts.\n"
|
"to prevent CLI tools from hanging on prompts.\n"
|
||||||
"- **Keep going:** Work autonomously until the task is fully resolved. "
|
"- **Keep going:** Work autonomously until the task is fully resolved. "
|
||||||
@ -957,6 +1000,80 @@ CONTEXT_FILE_MAX_CHARS = 20_000
|
|||||||
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
|
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
|
||||||
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
|
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
|
||||||
|
|
||||||
|
# Dynamic-cap parameters (used when no explicit context_file_max_chars is set).
|
||||||
|
# The cap scales with the model's context window so large-context models rarely
|
||||||
|
# truncate a project doc, while small-context models stay at the historical
|
||||||
|
# 20K floor. ~4 chars/token is the usual English heuristic; we spend a small
|
||||||
|
# slice of the window on context files since they share the cached prefix with
|
||||||
|
# the system prompt, tools, memory, and the whole conversation.
|
||||||
|
_CONTEXT_FILE_CHARS_PER_TOKEN = 4
|
||||||
|
_CONTEXT_FILE_WINDOW_FRACTION = 0.06
|
||||||
|
_CONTEXT_FILE_DYNAMIC_CEILING = 500_000
|
||||||
|
|
||||||
|
|
||||||
|
def _dynamic_context_file_max_chars(context_length: Optional[int]) -> int:
|
||||||
|
"""Derive a char cap from the model's context window.
|
||||||
|
|
||||||
|
Returns at least ``CONTEXT_FILE_MAX_CHARS`` (the historical 20K floor) and
|
||||||
|
at most ``_CONTEXT_FILE_DYNAMIC_CEILING``. When ``context_length`` is
|
||||||
|
unknown/invalid, returns the flat default so behavior is unchanged.
|
||||||
|
"""
|
||||||
|
if not isinstance(context_length, int) or context_length <= 0:
|
||||||
|
return CONTEXT_FILE_MAX_CHARS
|
||||||
|
budget = int(
|
||||||
|
context_length * _CONTEXT_FILE_CHARS_PER_TOKEN * _CONTEXT_FILE_WINDOW_FRACTION
|
||||||
|
)
|
||||||
|
return max(CONTEXT_FILE_MAX_CHARS, min(budget, _CONTEXT_FILE_DYNAMIC_CEILING))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_context_file_max_chars(context_length: Optional[int] = None) -> int:
|
||||||
|
"""Return the context-file truncation limit.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. Explicit ``context_file_max_chars`` in config.yaml — user knows best,
|
||||||
|
always wins (including over the dynamic cap).
|
||||||
|
2. Dynamic cap derived from the model's ``context_length`` when provided
|
||||||
|
(scales the budget to the window; floor 20K, ceiling 500K).
|
||||||
|
3. ``CONTEXT_FILE_MAX_CHARS`` (20K) as the upstream-compatible fallback.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_cli.config import load_config
|
||||||
|
|
||||||
|
val = load_config().get("context_file_max_chars")
|
||||||
|
if isinstance(val, (int, float)) and val > 0:
|
||||||
|
return int(val)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Could not read context_file_max_chars from config: %s", e)
|
||||||
|
return _dynamic_context_file_max_chars(context_length)
|
||||||
|
|
||||||
|
# Collect truncation warnings so the caller (run_agent) can surface them.
|
||||||
|
# A ContextVar (not a module-global list) isolates accumulation per thread /
|
||||||
|
# per async task, so concurrent gateway-session prompt builds can't drain or
|
||||||
|
# clear each other's pending warnings (cross-session leak). Each build runs in
|
||||||
|
# its own context, collects its own warnings, and drains them synchronously.
|
||||||
|
_truncation_warnings: "contextvars.ContextVar[Optional[list]]" = contextvars.ContextVar(
|
||||||
|
"context_file_truncation_warnings", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_truncation_warning(msg: str) -> None:
|
||||||
|
"""Append a truncation warning to the current context's accumulator."""
|
||||||
|
warnings = _truncation_warnings.get()
|
||||||
|
if warnings is None:
|
||||||
|
warnings = []
|
||||||
|
_truncation_warnings.set(warnings)
|
||||||
|
warnings.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def drain_truncation_warnings() -> list:
|
||||||
|
"""Return and clear any truncation warnings accumulated in this context."""
|
||||||
|
warnings = _truncation_warnings.get()
|
||||||
|
if not warnings:
|
||||||
|
return []
|
||||||
|
drained = list(warnings)
|
||||||
|
warnings.clear()
|
||||||
|
return drained
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Skills prompt cache
|
# Skills prompt cache
|
||||||
@ -1463,19 +1580,47 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
|
|||||||
# Context files (SOUL.md, AGENTS.md, .cursorrules)
|
# Context files (SOUL.md, AGENTS.md, .cursorrules)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str:
|
def _truncate_content(
|
||||||
"""Head/tail truncation with a marker in the middle."""
|
content: str,
|
||||||
|
filename: str,
|
||||||
|
max_chars: Optional[int] = None,
|
||||||
|
context_length: Optional[int] = None,
|
||||||
|
read_path: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Head/tail truncation with a marker in the middle.
|
||||||
|
|
||||||
|
``filename`` is the human label used in warnings. ``read_path`` is the
|
||||||
|
concrete path the agent should ``read_file`` to recover the full content
|
||||||
|
(defaults to ``filename`` when not supplied). ``context_length`` lets the
|
||||||
|
cap scale to the model's window when no explicit config override is set.
|
||||||
|
"""
|
||||||
|
if max_chars is None:
|
||||||
|
max_chars = _get_context_file_max_chars(context_length)
|
||||||
if len(content) <= max_chars:
|
if len(content) <= max_chars:
|
||||||
return content
|
return content
|
||||||
|
target = read_path or filename
|
||||||
|
msg = (
|
||||||
|
f"⚠️ Context file {filename} TRUNCATED: "
|
||||||
|
f"{len(content)} chars exceeds limit of {max_chars} — "
|
||||||
|
f"trim the file, pin a larger context_file_max_chars, or use a "
|
||||||
|
f"larger-context model!"
|
||||||
|
)
|
||||||
|
logger.warning(msg)
|
||||||
|
_record_truncation_warning(msg)
|
||||||
head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO)
|
head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO)
|
||||||
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
|
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
|
||||||
head = content[:head_chars]
|
head = content[:head_chars]
|
||||||
tail = content[-tail_chars:]
|
tail = content[-tail_chars:]
|
||||||
marker = f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of {len(content)} chars. Use file tools to read the full file.]\n\n"
|
marker = (
|
||||||
|
f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of "
|
||||||
|
f"{len(content)} chars. The middle is omitted — if you need the full "
|
||||||
|
f"instructions, read the complete file with the read_file tool: "
|
||||||
|
f"{target}]\n\n"
|
||||||
|
)
|
||||||
return head + marker + tail
|
return head + marker + tail
|
||||||
|
|
||||||
|
|
||||||
def load_soul_md() -> Optional[str]:
|
def load_soul_md(context_length: Optional[int] = None) -> Optional[str]:
|
||||||
"""Load SOUL.md from HERMES_HOME and return its content, or None.
|
"""Load SOUL.md from HERMES_HOME and return its content, or None.
|
||||||
|
|
||||||
Used as the agent identity (slot #1 in the system prompt). When this
|
Used as the agent identity (slot #1 in the system prompt). When this
|
||||||
@ -1496,14 +1641,17 @@ def load_soul_md() -> Optional[str]:
|
|||||||
if not content:
|
if not content:
|
||||||
return None
|
return None
|
||||||
content = _scan_context_content(content, "SOUL.md")
|
content = _scan_context_content(content, "SOUL.md")
|
||||||
content = _truncate_content(content, "SOUL.md")
|
content = _truncate_content(
|
||||||
|
content, "SOUL.md", context_length=context_length,
|
||||||
|
read_path=str(soul_path),
|
||||||
|
)
|
||||||
return content
|
return content
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
|
logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _load_hermes_md(cwd_path: Path) -> str:
|
def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||||
""".hermes.md / HERMES.md — walk to git root."""
|
""".hermes.md / HERMES.md — walk to git root."""
|
||||||
hermes_md_path = _find_hermes_md(cwd_path)
|
hermes_md_path = _find_hermes_md(cwd_path)
|
||||||
if not hermes_md_path:
|
if not hermes_md_path:
|
||||||
@ -1520,13 +1668,16 @@ def _load_hermes_md(cwd_path: Path) -> str:
|
|||||||
pass
|
pass
|
||||||
content = _scan_context_content(content, rel)
|
content = _scan_context_content(content, rel)
|
||||||
result = f"## {rel}\n\n{content}"
|
result = f"## {rel}\n\n{content}"
|
||||||
return _truncate_content(result, ".hermes.md")
|
return _truncate_content(
|
||||||
|
result, ".hermes.md", context_length=context_length,
|
||||||
|
read_path=str(hermes_md_path),
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Could not read %s: %s", hermes_md_path, e)
|
logger.debug("Could not read %s: %s", hermes_md_path, e)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _load_agents_md(cwd_path: Path) -> str:
|
def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||||
"""AGENTS.md — top-level only (no recursive walk)."""
|
"""AGENTS.md — top-level only (no recursive walk)."""
|
||||||
for name in ["AGENTS.md", "agents.md"]:
|
for name in ["AGENTS.md", "agents.md"]:
|
||||||
candidate = cwd_path / name
|
candidate = cwd_path / name
|
||||||
@ -1536,13 +1687,16 @@ def _load_agents_md(cwd_path: Path) -> str:
|
|||||||
if content:
|
if content:
|
||||||
content = _scan_context_content(content, name)
|
content = _scan_context_content(content, name)
|
||||||
result = f"## {name}\n\n{content}"
|
result = f"## {name}\n\n{content}"
|
||||||
return _truncate_content(result, "AGENTS.md")
|
return _truncate_content(
|
||||||
|
result, "AGENTS.md", context_length=context_length,
|
||||||
|
read_path=str(candidate),
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Could not read %s: %s", candidate, e)
|
logger.debug("Could not read %s: %s", candidate, e)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _load_claude_md(cwd_path: Path) -> str:
|
def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||||
"""CLAUDE.md / claude.md — cwd only."""
|
"""CLAUDE.md / claude.md — cwd only."""
|
||||||
for name in ["CLAUDE.md", "claude.md"]:
|
for name in ["CLAUDE.md", "claude.md"]:
|
||||||
candidate = cwd_path / name
|
candidate = cwd_path / name
|
||||||
@ -1552,13 +1706,16 @@ def _load_claude_md(cwd_path: Path) -> str:
|
|||||||
if content:
|
if content:
|
||||||
content = _scan_context_content(content, name)
|
content = _scan_context_content(content, name)
|
||||||
result = f"## {name}\n\n{content}"
|
result = f"## {name}\n\n{content}"
|
||||||
return _truncate_content(result, "CLAUDE.md")
|
return _truncate_content(
|
||||||
|
result, "CLAUDE.md", context_length=context_length,
|
||||||
|
read_path=str(candidate),
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Could not read %s: %s", candidate, e)
|
logger.debug("Could not read %s: %s", candidate, e)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _load_cursorrules(cwd_path: Path) -> str:
|
def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||||
""".cursorrules + .cursor/rules/*.mdc — cwd only."""
|
""".cursorrules + .cursor/rules/*.mdc — cwd only."""
|
||||||
cursorrules_content = ""
|
cursorrules_content = ""
|
||||||
cursorrules_file = cwd_path / ".cursorrules"
|
cursorrules_file = cwd_path / ".cursorrules"
|
||||||
@ -1585,10 +1742,17 @@ def _load_cursorrules(cwd_path: Path) -> str:
|
|||||||
|
|
||||||
if not cursorrules_content:
|
if not cursorrules_content:
|
||||||
return ""
|
return ""
|
||||||
return _truncate_content(cursorrules_content, ".cursorrules")
|
return _truncate_content(
|
||||||
|
cursorrules_content, ".cursorrules", context_length=context_length,
|
||||||
|
read_path=str(cwd_path / ".cursorrules"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = False) -> str:
|
def build_context_files_prompt(
|
||||||
|
cwd: Optional[str] = None,
|
||||||
|
skip_soul: bool = False,
|
||||||
|
context_length: Optional[int] = None,
|
||||||
|
) -> str:
|
||||||
"""Discover and load context files for the system prompt.
|
"""Discover and load context files for the system prompt.
|
||||||
|
|
||||||
Priority (first found wins — only ONE project context type is loaded):
|
Priority (first found wins — only ONE project context type is loaded):
|
||||||
@ -1598,7 +1762,11 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals
|
|||||||
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
|
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
|
||||||
|
|
||||||
SOUL.md from HERMES_HOME is independent and always included when present.
|
SOUL.md from HERMES_HOME is independent and always included when present.
|
||||||
Each context source is capped at 20,000 chars.
|
|
||||||
|
Each context source is capped before injection. The cap defaults to the
|
||||||
|
model's context window (scaled — see ``_dynamic_context_file_max_chars``)
|
||||||
|
when *context_length* is provided, falling back to 20,000 chars otherwise.
|
||||||
|
An explicit ``context_file_max_chars`` in config.yaml always wins.
|
||||||
|
|
||||||
When *skip_soul* is True, SOUL.md is not included here (it was already
|
When *skip_soul* is True, SOUL.md is not included here (it was already
|
||||||
loaded via ``load_soul_md()`` for the identity slot).
|
loaded via ``load_soul_md()`` for the identity slot).
|
||||||
@ -1611,17 +1779,17 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals
|
|||||||
|
|
||||||
# Priority-based project context: first match wins
|
# Priority-based project context: first match wins
|
||||||
project_context = (
|
project_context = (
|
||||||
_load_hermes_md(cwd_path)
|
_load_hermes_md(cwd_path, context_length)
|
||||||
or _load_agents_md(cwd_path)
|
or _load_agents_md(cwd_path, context_length)
|
||||||
or _load_claude_md(cwd_path)
|
or _load_claude_md(cwd_path, context_length)
|
||||||
or _load_cursorrules(cwd_path)
|
or _load_cursorrules(cwd_path, context_length)
|
||||||
)
|
)
|
||||||
if project_context:
|
if project_context:
|
||||||
sections.append(project_context)
|
sections.append(project_context)
|
||||||
|
|
||||||
# SOUL.md from HERMES_HOME only — skip when already loaded as identity
|
# SOUL.md from HERMES_HOME only — skip when already loaded as identity
|
||||||
if not skip_soul:
|
if not skip_soul:
|
||||||
soul_content = load_soul_md()
|
soul_content = load_soul_md(context_length)
|
||||||
if soul_content:
|
if soul_content:
|
||||||
sections.append(soul_content)
|
sections.append(soul_content)
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,91 @@ _skill_commands_platform: Optional[str] = None
|
|||||||
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
|
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
|
||||||
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
|
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Skill-scaffolding markers and the canonical extractor.
|
||||||
|
#
|
||||||
|
# When a user invokes a /skill (or /bundle), Hermes expands the turn into a
|
||||||
|
# model-facing message that embeds the full skill body plus scaffolding. That
|
||||||
|
# expanded text is what flows into the agent loop — and into memory providers
|
||||||
|
# via MemoryManager. Providers that store or embed the raw user turn (mem0,
|
||||||
|
# openviking, hindsight, retaindb, byterover, honcho, supermemory) would
|
||||||
|
# otherwise capture the entire skill body instead of what the user actually
|
||||||
|
# asked. ``extract_user_instruction_from_skill_message`` recovers just the
|
||||||
|
# user's instruction so memory stays clean.
|
||||||
|
#
|
||||||
|
# These markers MUST stay byte-identical to the builders below
|
||||||
|
# (``_build_skill_message`` here, ``build_bundle_invocation_message`` in
|
||||||
|
# agent/skill_bundles.py). They are co-located with the single-skill builder
|
||||||
|
# on purpose, and the bundle markers are asserted against the bundle builder in
|
||||||
|
# tests/openviking_plugin/test_openviking.py::test_skill_markers_match_hermes_scaffolding.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the "
|
||||||
|
_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]"
|
||||||
|
_SINGLE_SKILL_INSTRUCTION = (
|
||||||
|
"The user has provided the following instruction alongside the skill invocation: "
|
||||||
|
)
|
||||||
|
_RUNTIME_NOTE = "\n\n[Runtime note:"
|
||||||
|
_BUNDLE_MARKER = " skill bundle,"
|
||||||
|
_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
|
||||||
|
_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
|
||||||
|
|
||||||
|
|
||||||
|
def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
|
||||||
|
"""Recover the user's instruction from a slash-skill-expanded turn.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- The original string unchanged when it is NOT skill scaffolding
|
||||||
|
(a normal user message passes straight through).
|
||||||
|
- The extracted user instruction when the scaffolding carried one.
|
||||||
|
- ``None`` when the content is skill scaffolding with no user
|
||||||
|
instruction (i.e. a bare ``/skill`` invocation). Callers that feed
|
||||||
|
memory providers should skip the turn in that case — there is no
|
||||||
|
user content worth storing.
|
||||||
|
"""
|
||||||
|
if not isinstance(content, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not content.startswith(_SKILL_INVOCATION_PREFIX):
|
||||||
|
return content
|
||||||
|
|
||||||
|
if _BUNDLE_MARKER in content:
|
||||||
|
return _extract_bundle_user_instruction(content)
|
||||||
|
|
||||||
|
if _SINGLE_SKILL_MARKER in content:
|
||||||
|
return _extract_single_skill_user_instruction(content)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
|
||||||
|
# Single-skill format appends the user instruction after the skill body, so
|
||||||
|
# the last occurrence is the user-provided one; the body may quote this text.
|
||||||
|
marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION)
|
||||||
|
if marker_idx < 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):]
|
||||||
|
runtime_idx = instruction.find(_RUNTIME_NOTE)
|
||||||
|
if runtime_idx >= 0:
|
||||||
|
instruction = instruction[:runtime_idx]
|
||||||
|
instruction = instruction.strip()
|
||||||
|
return instruction or None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bundle_user_instruction(message: str) -> Optional[str]:
|
||||||
|
# Bundle format puts the user instruction before the loaded skills, so the
|
||||||
|
# first occurrence is the user-provided one.
|
||||||
|
marker_idx = message.find(_BUNDLE_USER_INSTRUCTION)
|
||||||
|
if marker_idx < 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):]
|
||||||
|
first_skill_idx = instruction.find(_BUNDLE_FIRST_SKILL_BLOCK)
|
||||||
|
if first_skill_idx >= 0:
|
||||||
|
instruction = instruction[:first_skill_idx]
|
||||||
|
instruction = instruction.strip()
|
||||||
|
return instruction or None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_skill_commands_platform() -> Optional[str]:
|
def _resolve_skill_commands_platform() -> Optional[str]:
|
||||||
"""Return the current platform scope used for disabled-skill filtering.
|
"""Return the current platform scope used for disabled-skill filtering.
|
||||||
|
|||||||
@ -43,14 +43,20 @@ EXCLUDED_SKILL_DIRS = frozenset(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Supporting files live inside a skill package and are loaded explicitly via
|
||||||
|
# skill_view(skill, file_path=...). They are not standalone skills and must not
|
||||||
|
# be scanned for active SKILL.md/DESCRIPTION.md entries, even if a Curator or
|
||||||
|
# archive workflow preserves a complete old skill package under references/.
|
||||||
|
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
|
||||||
|
|
||||||
|
|
||||||
def is_excluded_skill_path(path) -> bool:
|
def is_excluded_skill_path(path) -> bool:
|
||||||
"""True if any component of *path* is in EXCLUDED_SKILL_DIRS.
|
"""True if *path* should be skipped by active skill scanners.
|
||||||
|
|
||||||
Use this on every SKILL.md path produced by ``rglob`` to prune
|
Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
|
||||||
dependency, virtualenv, VCS, and cache directories. Centralising the
|
prune dependency, virtualenv, VCS, cache, and progressive-disclosure
|
||||||
check here keeps every skill-scanning site in sync with the shared
|
support-package paths. Centralising the check here keeps every
|
||||||
exclusion set.
|
skill-scanning site in sync with the shared exclusion set.
|
||||||
|
|
||||||
Accepts a Path or string.
|
Accepts a Path or string.
|
||||||
"""
|
"""
|
||||||
@ -59,7 +65,36 @@ def is_excluded_skill_path(path) -> bool:
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
from pathlib import PurePath
|
from pathlib import PurePath
|
||||||
parts = PurePath(str(path)).parts
|
parts = PurePath(str(path)).parts
|
||||||
return any(part in EXCLUDED_SKILL_DIRS for part in parts)
|
return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path(
|
||||||
|
path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_skill_support_path(path) -> bool:
|
||||||
|
"""True if *path* is under a support dir of an actual skill root.
|
||||||
|
|
||||||
|
``references/``, ``templates/``, ``assets/``, and ``scripts/`` are
|
||||||
|
progressive-disclosure support areas when they sit directly inside a skill
|
||||||
|
directory containing ``SKILL.md``. They are not active discovery roots for
|
||||||
|
standalone skills. A preserved package such as
|
||||||
|
``some-skill/references/old-skill-package/SKILL.md`` is documentation data
|
||||||
|
unless the caller explicitly loads it via ``file_path``.
|
||||||
|
|
||||||
|
Legitimate categories or skill names such as ``skills/scripts/foo`` remain
|
||||||
|
discoverable because their ``scripts`` component is not directly under a
|
||||||
|
directory that contains ``SKILL.md``.
|
||||||
|
"""
|
||||||
|
path_obj = path if isinstance(path, Path) else Path(str(path))
|
||||||
|
parts = path_obj.parts
|
||||||
|
# Last component may be a file or candidate skill directory name. Only
|
||||||
|
# components before the leaf can be containing support directories.
|
||||||
|
for idx, part in enumerate(parts[:-1]):
|
||||||
|
if part not in SKILL_SUPPORT_DIRS or idx == 0:
|
||||||
|
continue
|
||||||
|
skill_root = Path(*parts[:idx])
|
||||||
|
if (skill_root / "SKILL.md").exists():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── Lazy YAML loader ─────────────────────────────────────────────────────
|
# ── Lazy YAML loader ─────────────────────────────────────────────────────
|
||||||
@ -661,12 +696,21 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
|
|||||||
def iter_skill_index_files(skills_dir: Path, filename: str):
|
def iter_skill_index_files(skills_dir: Path, filename: str):
|
||||||
"""Walk skills_dir yielding sorted paths matching *filename*.
|
"""Walk skills_dir yielding sorted paths matching *filename*.
|
||||||
|
|
||||||
Excludes Hermes metadata, VCS, virtualenv/dependency, and cache
|
Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill
|
||||||
directories so dependencies cannot register nested skills.
|
support directories. Support directories (references/templates/assets/
|
||||||
|
scripts) can contain arbitrary markdown and even archived package
|
||||||
|
``SKILL.md`` files, but they are progressive-disclosure data loaded through
|
||||||
|
``skill_view(..., file_path=...)`` rather than active skill roots.
|
||||||
"""
|
"""
|
||||||
matches = []
|
matches = []
|
||||||
for root, dirs, files in os.walk(skills_dir, followlinks=True):
|
for root, dirs, files in os.walk(skills_dir, followlinks=True):
|
||||||
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
|
has_skill_md = "SKILL.md" in files
|
||||||
|
dirs[:] = [
|
||||||
|
d
|
||||||
|
for d in dirs
|
||||||
|
if d not in EXCLUDED_SKILL_DIRS
|
||||||
|
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
|
||||||
|
]
|
||||||
if filename in files:
|
if filename in files:
|
||||||
matches.append(Path(root) / filename)
|
matches.append(Path(root) / filename)
|
||||||
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
|
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
|
||||||
|
|||||||
@ -33,6 +33,7 @@ from agent.prompt_builder import (
|
|||||||
KANBAN_GUIDANCE,
|
KANBAN_GUIDANCE,
|
||||||
MEMORY_GUIDANCE,
|
MEMORY_GUIDANCE,
|
||||||
OPENAI_MODEL_EXECUTION_GUIDANCE,
|
OPENAI_MODEL_EXECUTION_GUIDANCE,
|
||||||
|
PARALLEL_TOOL_CALL_GUIDANCE,
|
||||||
PLATFORM_HINTS,
|
PLATFORM_HINTS,
|
||||||
SESSION_SEARCH_GUIDANCE,
|
SESSION_SEARCH_GUIDANCE,
|
||||||
SKILLS_GUIDANCE,
|
SKILLS_GUIDANCE,
|
||||||
@ -40,6 +41,7 @@ from agent.prompt_builder import (
|
|||||||
TASK_COMPLETION_GUIDANCE,
|
TASK_COMPLETION_GUIDANCE,
|
||||||
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
||||||
TOOL_USE_ENFORCEMENT_MODELS,
|
TOOL_USE_ENFORCEMENT_MODELS,
|
||||||
|
drain_truncation_warnings,
|
||||||
)
|
)
|
||||||
from agent.runtime_cwd import resolve_context_cwd
|
from agent.runtime_cwd import resolve_context_cwd
|
||||||
|
|
||||||
@ -82,6 +84,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||||||
# we resolve through ``_ra()`` to honor those patches.
|
# we resolve through ``_ra()`` to honor those patches.
|
||||||
_r = _ra()
|
_r = _ra()
|
||||||
|
|
||||||
|
# Resolve the model's context window once so context-file caps can scale
|
||||||
|
# to it (dynamic cap — see prompt_builder._dynamic_context_file_max_chars).
|
||||||
|
# None falls back to the historical flat default. This value is stable for
|
||||||
|
# the life of the conversation, so it does not threaten prompt caching.
|
||||||
|
_ctx_len: Optional[int] = None
|
||||||
|
_cc = getattr(agent, "context_compressor", None)
|
||||||
|
if _cc is not None:
|
||||||
|
_cc_len = getattr(_cc, "context_length", None)
|
||||||
|
if isinstance(_cc_len, int) and _cc_len > 0:
|
||||||
|
_ctx_len = _cc_len
|
||||||
|
|
||||||
# ── Stable tier ────────────────────────────────────────────────
|
# ── Stable tier ────────────────────────────────────────────────
|
||||||
stable_parts: List[str] = []
|
stable_parts: List[str] = []
|
||||||
|
|
||||||
@ -90,7 +103,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||||||
# cwd project instructions disabled.
|
# cwd project instructions disabled.
|
||||||
_soul_loaded = False
|
_soul_loaded = False
|
||||||
if agent.load_soul_identity or not agent.skip_context_files:
|
if agent.load_soul_identity or not agent.skip_context_files:
|
||||||
_soul_content = _r.load_soul_md()
|
_soul_content = _r.load_soul_md(_ctx_len)
|
||||||
if _soul_content:
|
if _soul_content:
|
||||||
stable_parts.append(_soul_content)
|
stable_parts.append(_soul_content)
|
||||||
_soul_loaded = True
|
_soul_loaded = True
|
||||||
@ -111,6 +124,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||||||
if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names:
|
if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names:
|
||||||
stable_parts.append(TASK_COMPLETION_GUIDANCE)
|
stable_parts.append(TASK_COMPLETION_GUIDANCE)
|
||||||
|
|
||||||
|
# Universal parallel-tool-call guidance. Tells the model to batch
|
||||||
|
# independent tool calls into one assistant turn rather than emitting one
|
||||||
|
# call per turn — the runtime already runs independent calls concurrently
|
||||||
|
# (read-only tools always; non-overlapping path-scoped file ops), so the
|
||||||
|
# only thing missing was steering the model to produce the batch. Cuts
|
||||||
|
# round-trips and the resent-context cost that compounds over a long
|
||||||
|
# conversation. Gated by config.yaml ``agent.parallel_tool_call_guidance``
|
||||||
|
# (default True) and only injected when tools are actually loaded.
|
||||||
|
if getattr(agent, "_parallel_tool_call_guidance", True) and agent.valid_tool_names:
|
||||||
|
stable_parts.append(PARALLEL_TOOL_CALL_GUIDANCE)
|
||||||
|
|
||||||
# Tool-aware behavioral guidance: only inject when the tools are loaded
|
# Tool-aware behavioral guidance: only inject when the tools are loaded
|
||||||
tool_guidance = []
|
tool_guidance = []
|
||||||
if "memory" in agent.valid_tool_names:
|
if "memory" in agent.valid_tool_names:
|
||||||
@ -333,7 +357,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||||||
# dir — the user's real cwd there, but the install dir for the gateway
|
# dir — the user's real cwd there, but the install dir for the gateway
|
||||||
# daemon, which is why the gateway sets TERMINAL_CWD.
|
# daemon, which is why the gateway sets TERMINAL_CWD.
|
||||||
context_files_prompt = _r.build_context_files_prompt(
|
context_files_prompt = _r.build_context_files_prompt(
|
||||||
cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
|
cwd=resolve_context_cwd(), skip_soul=_soul_loaded,
|
||||||
|
context_length=_ctx_len)
|
||||||
if context_files_prompt:
|
if context_files_prompt:
|
||||||
context_parts.append(context_files_prompt)
|
context_parts.append(context_files_prompt)
|
||||||
|
|
||||||
@ -400,7 +425,14 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
|
|||||||
warm across turns.
|
warm across turns.
|
||||||
"""
|
"""
|
||||||
parts = build_system_prompt_parts(agent, system_message=system_message)
|
parts = build_system_prompt_parts(agent, system_message=system_message)
|
||||||
return "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
|
joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
|
||||||
|
|
||||||
|
# Surface context-file truncation warnings through the normal agent status
|
||||||
|
# channel so gateway/CLI users see them in chat instead of only in logs.
|
||||||
|
for warning in drain_truncation_warnings():
|
||||||
|
agent._emit_status(warning)
|
||||||
|
|
||||||
|
return joined
|
||||||
|
|
||||||
|
|
||||||
def invalidate_system_prompt(agent: Any) -> None:
|
def invalidate_system_prompt(agent: Any) -> None:
|
||||||
|
|||||||
@ -1012,21 +1012,35 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||||||
elif function_name == "memory":
|
elif function_name == "memory":
|
||||||
def _execute(next_args: dict) -> Any:
|
def _execute(next_args: dict) -> Any:
|
||||||
target = next_args.get("target", "memory")
|
target = next_args.get("target", "memory")
|
||||||
|
operations = next_args.get("operations")
|
||||||
from tools.memory_tool import memory_tool as _memory_tool
|
from tools.memory_tool import memory_tool as _memory_tool
|
||||||
result = _memory_tool(
|
result = _memory_tool(
|
||||||
action=next_args.get("action"),
|
action=next_args.get("action"),
|
||||||
target=target,
|
target=target,
|
||||||
content=next_args.get("content"),
|
content=next_args.get("content"),
|
||||||
old_text=next_args.get("old_text"),
|
old_text=next_args.get("old_text"),
|
||||||
|
operations=operations,
|
||||||
store=agent._memory_store,
|
store=agent._memory_store,
|
||||||
)
|
)
|
||||||
# Bridge: notify external memory provider of built-in memory writes
|
# Bridge: notify external memory provider of built-in memory writes.
|
||||||
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
|
# Covers both the single-op shape and each add/replace inside a batch.
|
||||||
|
if agent._memory_manager:
|
||||||
|
if operations:
|
||||||
|
_mem_ops = [
|
||||||
|
op for op in operations
|
||||||
|
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
_mem_ops = (
|
||||||
|
[{"action": next_args.get("action"), "content": next_args.get("content")}]
|
||||||
|
if next_args.get("action") in {"add", "replace"} else []
|
||||||
|
)
|
||||||
|
for _op in _mem_ops:
|
||||||
try:
|
try:
|
||||||
agent._memory_manager.on_memory_write(
|
agent._memory_manager.on_memory_write(
|
||||||
next_args.get("action", ""),
|
_op.get("action", ""),
|
||||||
target,
|
target,
|
||||||
next_args.get("content", ""),
|
_op.get("content", "") or "",
|
||||||
metadata=agent._build_memory_write_metadata(
|
metadata=agent._build_memory_write_metadata(
|
||||||
task_id=effective_task_id,
|
task_id=effective_task_id,
|
||||||
tool_call_id=getattr(tool_call, "id", None),
|
tool_call_id=getattr(tool_call, "id", None),
|
||||||
|
|||||||
@ -88,7 +88,7 @@ class AnthropicTransport(ProviderTransport):
|
|||||||
from agent.transports.types import ToolCall
|
from agent.transports.types import ToolCall
|
||||||
|
|
||||||
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
||||||
_MCP_PREFIX = "mcp_"
|
_MCP_PREFIX = "mcp__"
|
||||||
|
|
||||||
text_parts = []
|
text_parts = []
|
||||||
reasoning_parts = []
|
reasoning_parts = []
|
||||||
@ -132,17 +132,25 @@ class AnthropicTransport(ProviderTransport):
|
|||||||
elif block.type == "tool_use":
|
elif block.type == "tool_use":
|
||||||
name = block.name
|
name = block.name
|
||||||
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
|
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
|
||||||
stripped = name[len(_MCP_PREFIX):]
|
# On the OAuth wire every tool carries a double-underscore
|
||||||
# Only strip the mcp_ prefix for OAuth-injected tools
|
# ``mcp__`` prefix (added in build_anthropic_kwargs to avoid
|
||||||
# (where Hermes adds the prefix when sending to Anthropic
|
# Anthropic's single-underscore third-party classifier).
|
||||||
# and must remove it on the way back). Native MCP server
|
# Reverse it back to the name the registry/dispatcher knows.
|
||||||
# tools (from mcp_servers: in config.yaml) are registered
|
# Two original forms map onto the same ``mcp__`` wire name:
|
||||||
# in the tool registry under their FULL mcp_<server>_<tool>
|
# ``mcp__read_file`` <- bare native tool ``read_file``
|
||||||
# name and must NOT be stripped. GH-25255.
|
# ``mcp__linear_get_issue`` <- MCP server tool
|
||||||
|
# ``mcp_linear_get_issue``
|
||||||
|
# Resolve by registry lookup, preferring whichever original
|
||||||
|
# is actually registered; never rewrite a name the LLM used
|
||||||
|
# that already resolves natively. GH-25255.
|
||||||
from tools.registry import registry as _tool_registry
|
from tools.registry import registry as _tool_registry
|
||||||
if (_tool_registry.get_entry(stripped)
|
if not _tool_registry.get_entry(name):
|
||||||
and not _tool_registry.get_entry(name)):
|
bare = name[len(_MCP_PREFIX):] # read_file
|
||||||
name = stripped
|
single = "mcp_" + bare # mcp_read_file / mcp_linear_get_issue
|
||||||
|
if _tool_registry.get_entry(single):
|
||||||
|
name = single
|
||||||
|
elif _tool_registry.get_entry(bare):
|
||||||
|
name = bare
|
||||||
tool_calls.append(
|
tool_calls.append(
|
||||||
ToolCall(
|
ToolCall(
|
||||||
id=block.id,
|
id=block.id,
|
||||||
|
|||||||
@ -128,6 +128,65 @@ class ResponsesApiTransport(ProviderTransport):
|
|||||||
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
|
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
|
||||||
|
|
||||||
response_tools = _responses_tools(tools)
|
response_tools = _responses_tools(tools)
|
||||||
|
|
||||||
|
# xAI server-side web search.
|
||||||
|
#
|
||||||
|
# grok models on xAI's /v1/responses surface (notably
|
||||||
|
# grok-composer-2.5-fast on SuperGrok OAuth) have a *native*,
|
||||||
|
# server-executed web search. When the model is handed a
|
||||||
|
# client-side function literally named ``web_search``, it routes
|
||||||
|
# the intent to that native engine — but because the tool is
|
||||||
|
# declared as a plain ``function`` rather than xAI's first-class
|
||||||
|
# ``{"type": "web_search"}`` built-in, the server-side search is
|
||||||
|
# dispatched but never reconciled: the response streams reasoning
|
||||||
|
# + ``web_search_call`` progress items, the searches never reach
|
||||||
|
# ``status="completed"`` in the assembled output, no final
|
||||||
|
# message is emitted, and ``_normalize_codex_response`` correctly
|
||||||
|
# sees reasoning-with-no-answer and reports ``incomplete``. The
|
||||||
|
# turn then burns 3 continuation retries and fails with "Codex
|
||||||
|
# response remained incomplete after 3 continuation attempts".
|
||||||
|
# Verified live against grok-composer-2.5-fast (2026-06).
|
||||||
|
#
|
||||||
|
# Fix: when the agent HAS a client-side ``web_search`` function (i.e.
|
||||||
|
# the user enabled the web toolset), declare xAI's native
|
||||||
|
# ``web_search`` built-in instead so the search actually runs to
|
||||||
|
# completion server-side and the model streams a real answer. The
|
||||||
|
# Responses API rejects two tools sharing the name ``web_search``
|
||||||
|
# (HTTP 400 "Duplicate tool names"), so we drop the client-side
|
||||||
|
# ``web_search`` function for the xAI path and let the native tool
|
||||||
|
# satisfy it. All other client-side tools (read_file, terminal,
|
||||||
|
# web_extract, MCP tools, …) are untouched and continue to dispatch
|
||||||
|
# through Hermes's agent loop.
|
||||||
|
#
|
||||||
|
# Scope: we ONLY swap in the native built-in when the client
|
||||||
|
# ``web_search`` was actually present. We do NOT force-enable Grok
|
||||||
|
# server-side search on turns where the user never had web enabled —
|
||||||
|
# that would silently route around Hermes's web-provider config and
|
||||||
|
# tool-trace/citation plumbing for every xai-oauth turn. The swap is
|
||||||
|
# a 1:1 replacement of an already-requested capability, not an
|
||||||
|
# additive grant.
|
||||||
|
#
|
||||||
|
# NOTE: for the swapped case this routes ``web_search`` to Grok's
|
||||||
|
# native search engine for xAI sessions instead of Hermes's
|
||||||
|
# configured web provider (Tavily/etc.), and those results bypass
|
||||||
|
# Hermes's tool-trace / citation plumbing (they arrive baked into the
|
||||||
|
# model's answer rather than as a tool result the loop observes).
|
||||||
|
# Scoped to ``is_xai_responses`` deliberately; narrow to specific
|
||||||
|
# models if a future grok variant should keep the client-side
|
||||||
|
# function.
|
||||||
|
if is_xai_responses and response_tools:
|
||||||
|
has_client_web_search = any(
|
||||||
|
isinstance(t, dict) and t.get("name") == "web_search"
|
||||||
|
for t in response_tools
|
||||||
|
)
|
||||||
|
if has_client_web_search:
|
||||||
|
filtered = [
|
||||||
|
t for t in response_tools
|
||||||
|
if not (isinstance(t, dict) and t.get("name") == "web_search")
|
||||||
|
]
|
||||||
|
filtered.append({"type": "web_search"})
|
||||||
|
response_tools = filtered
|
||||||
|
|
||||||
# ``tools`` MUST be omitted entirely when there are no functions to
|
# ``tools`` MUST be omitted entirely when there are no functions to
|
||||||
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
|
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
|
||||||
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
|
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
|
||||||
@ -218,10 +277,28 @@ class ResponsesApiTransport(ProviderTransport):
|
|||||||
kwargs.pop("timeout", None)
|
kwargs.pop("timeout", None)
|
||||||
|
|
||||||
if is_codex_backend:
|
if is_codex_backend:
|
||||||
# chatgpt.com/backend-api/codex rejects body-level
|
# The Codex backend rejects body-level ``extra_headers`` with
|
||||||
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
|
# HTTP 400, but the OpenAI SDK's ``extra_headers`` kwarg maps
|
||||||
# this backend must not be sent through the Responses payload.
|
# to actual HTTP request headers (not body fields). We need
|
||||||
kwargs.pop("extra_headers", None)
|
# these headers for cache-scope routing so prompt cache hits
|
||||||
|
# remain high. Send session_id / x-client-request-id as HTTP
|
||||||
|
# headers while keeping ``prompt_cache_key`` in the body for
|
||||||
|
# standard OpenAI routing as a belt-and-braces fallback.
|
||||||
|
cache_scope_id = str(session_id or "").strip()
|
||||||
|
if cache_scope_id:
|
||||||
|
existing_extra_headers = kwargs.get("extra_headers")
|
||||||
|
merged_extra_headers: Dict[str, str] = {}
|
||||||
|
if isinstance(existing_extra_headers, dict):
|
||||||
|
merged_extra_headers.update(
|
||||||
|
{
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in existing_extra_headers.items()
|
||||||
|
if key and value is not None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
merged_extra_headers["session_id"] = cache_scope_id
|
||||||
|
merged_extra_headers["x-client-request-id"] = cache_scope_id
|
||||||
|
kwargs["extra_headers"] = merged_extra_headers
|
||||||
|
|
||||||
max_tokens = params.get("max_tokens")
|
max_tokens = params.get("max_tokens")
|
||||||
if max_tokens is not None and not is_codex_backend:
|
if max_tokens is not None and not is_codex_backend:
|
||||||
|
|||||||
@ -69,6 +69,7 @@ def build_turn_context(
|
|||||||
task_id: Optional[str],
|
task_id: Optional[str],
|
||||||
stream_callback,
|
stream_callback,
|
||||||
persist_user_message: Optional[str],
|
persist_user_message: Optional[str],
|
||||||
|
persist_user_timestamp: Optional[float] = None,
|
||||||
*,
|
*,
|
||||||
restore_or_build_system_prompt,
|
restore_or_build_system_prompt,
|
||||||
install_safe_stdio,
|
install_safe_stdio,
|
||||||
@ -121,6 +122,7 @@ def build_turn_context(
|
|||||||
agent._stream_callback = stream_callback
|
agent._stream_callback = stream_callback
|
||||||
agent._persist_user_message_idx = None
|
agent._persist_user_message_idx = None
|
||||||
agent._persist_user_message_override = persist_user_message
|
agent._persist_user_message_override = persist_user_message
|
||||||
|
agent._persist_user_message_timestamp = persist_user_timestamp
|
||||||
# Generate unique task_id if not provided to isolate VMs between tasks.
|
# Generate unique task_id if not provided to isolate VMs between tasks.
|
||||||
effective_task_id = task_id or str(uuid.uuid4())
|
effective_task_id = task_id or str(uuid.uuid4())
|
||||||
agent._current_task_id = effective_task_id
|
agent._current_task_id = effective_task_id
|
||||||
|
|||||||
@ -286,7 +286,7 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
|||||||
emit_stage(&app, "rebuild", StageState::Running, None, None);
|
emit_stage(&app, "rebuild", StageState::Running, None, None);
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
|
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
|
||||||
let rebuild = run_streamed(
|
let mut rebuild = run_streamed(
|
||||||
&app,
|
&app,
|
||||||
&hermes,
|
&hermes,
|
||||||
&rebuild_args,
|
&rebuild_args,
|
||||||
@ -295,6 +295,33 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
|||||||
Some("rebuild"),
|
Some("rebuild"),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Retry-once: the first `--build-only` can return nonzero on a still-settling
|
||||||
|
// post-update tree or a network-blocked Electron fetch that our self-heal
|
||||||
|
// repaired mid-run. A second attempt then builds clean off the healed dist
|
||||||
|
// (the content-hash stamp makes it a near-no-op when the first actually
|
||||||
|
// succeeded). Without this the updater bails here and never reaches the
|
||||||
|
// relaunch below — the app updates but doesn't restart. Matches the
|
||||||
|
// retry-once `hermes update` already does above, and `hermes update`'s own
|
||||||
|
// desktop rebuild in cmd_update.
|
||||||
|
if rebuild_needs_retry(rebuild.exit_code) {
|
||||||
|
emit_log(
|
||||||
|
&app,
|
||||||
|
Some("rebuild"),
|
||||||
|
LogStream::Stdout,
|
||||||
|
"[rebuild] first desktop rebuild failed; retrying once (a self-healed \
|
||||||
|
Electron download builds clean on the second run)…",
|
||||||
|
);
|
||||||
|
rebuild = run_streamed(
|
||||||
|
&app,
|
||||||
|
&hermes,
|
||||||
|
&rebuild_args,
|
||||||
|
&install_root,
|
||||||
|
&child_env,
|
||||||
|
Some("rebuild"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
let rebuild_ms = started.elapsed().as_millis() as u64;
|
let rebuild_ms = started.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
if rebuild.exit_code != Some(0) {
|
if rebuild.exit_code != Some(0) {
|
||||||
@ -533,6 +560,14 @@ fn is_locked(path: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the `desktop --build-only` rebuild should be retried once. Any
|
||||||
|
/// non-success exit qualifies: the common cause is a transient first-attempt
|
||||||
|
/// failure (still-settling tree / self-healed Electron download) that a clean
|
||||||
|
/// second run resolves.
|
||||||
|
fn rebuild_needs_retry(exit_code: Option<i32>) -> bool {
|
||||||
|
exit_code != Some(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
|
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
|
||||||
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
|
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
|
||||||
/// but for an arbitrary command (no install.ps1 -File wrapping).
|
/// but for an arbitrary command (no install.ps1 -File wrapping).
|
||||||
@ -970,6 +1005,16 @@ mod tests {
|
|||||||
assert_eq!(update_branch_from_args(["--update"]), None);
|
assert_eq!(update_branch_from_args(["--update"]), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebuild_retries_only_on_failure() {
|
||||||
|
assert!(!rebuild_needs_retry(Some(0)), "a clean rebuild must not retry");
|
||||||
|
assert!(rebuild_needs_retry(Some(1)), "a failed rebuild retries once");
|
||||||
|
assert!(
|
||||||
|
rebuild_needs_retry(None),
|
||||||
|
"a killed/signalled rebuild (no exit code) retries once"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_only_app_targets() {
|
fn parses_only_app_targets() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@ -28,6 +28,7 @@ const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = requ
|
|||||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||||
const {
|
const {
|
||||||
buildSessionWindowUrl,
|
buildSessionWindowUrl,
|
||||||
|
chatWindowWebPreferences,
|
||||||
createSessionWindowRegistry,
|
createSessionWindowRegistry,
|
||||||
SESSION_WINDOW_MIN_HEIGHT,
|
SESSION_WINDOW_MIN_HEIGHT,
|
||||||
SESSION_WINDOW_MIN_WIDTH
|
SESSION_WINDOW_MIN_WIDTH
|
||||||
@ -44,6 +45,7 @@ const { readDirForIpc } = require('./fs-read-dir.cjs')
|
|||||||
const { gitRootForIpc } = require('./git-root.cjs')
|
const { gitRootForIpc } = require('./git-root.cjs')
|
||||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||||
|
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||||
const {
|
const {
|
||||||
buildPosixCleanupScript,
|
buildPosixCleanupScript,
|
||||||
buildWindowsCleanupScript,
|
buildWindowsCleanupScript,
|
||||||
@ -2008,10 +2010,14 @@ async function applyUpdatesPosixInApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
|
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
|
||||||
const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], {
|
// Retry-once: a first rebuild can fail on a still-settling tree or a
|
||||||
cwd: updateRoot,
|
// self-healed (network-blocked) Electron download; a second run builds clean
|
||||||
env,
|
// off the healed dist so we reach the swap+relaunch below instead of bailing.
|
||||||
stage: 'rebuild'
|
const rebuilt = await runRebuildWithRetry(attempt => {
|
||||||
|
if (attempt > 0) {
|
||||||
|
emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 })
|
||||||
|
}
|
||||||
|
return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' })
|
||||||
})
|
})
|
||||||
if (rebuilt.code !== 0) {
|
if (rebuilt.code !== 0) {
|
||||||
emitUpdateProgress({
|
emitUpdateProgress({
|
||||||
@ -5106,14 +5112,7 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
|
|||||||
// themes/context.tsx, so the window appears already themed.
|
// themes/context.tsx, so the window appears already themed.
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: getWindowBackgroundColor(),
|
backgroundColor: getWindowBackgroundColor(),
|
||||||
webPreferences: {
|
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
|
||||||
preload: path.join(__dirname, 'preload.cjs'),
|
|
||||||
contextIsolation: true,
|
|
||||||
webviewTag: true,
|
|
||||||
sandbox: true,
|
|
||||||
nodeIntegration: false,
|
|
||||||
devTools: true
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (IS_MAC) {
|
if (IS_MAC) {
|
||||||
@ -5180,23 +5179,11 @@ function createWindow() {
|
|||||||
// material before the renderer paints the app theme. See createSessionWindow.
|
// material before the renderer paints the app theme. See createSessionWindow.
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: getWindowBackgroundColor(),
|
backgroundColor: getWindowBackgroundColor(),
|
||||||
webPreferences: {
|
// Shared with the secondary session windows (chatWindowWebPreferences) so
|
||||||
preload: path.join(__dirname, 'preload.cjs'),
|
// both keep `backgroundThrottling: false` — the chat transcript streams via
|
||||||
contextIsolation: true,
|
// a requestAnimationFrame-gated flush that Chromium pauses for blurred
|
||||||
webviewTag: true,
|
// windows, stalling the live answer until refocus. See session-windows.cjs.
|
||||||
sandbox: true,
|
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
|
||||||
nodeIntegration: false,
|
|
||||||
devTools: true,
|
|
||||||
// Keep timers + requestAnimationFrame running at full speed when the
|
|
||||||
// window is blurred/occluded. The chat transcript streams to the screen
|
|
||||||
// through a requestAnimationFrame-gated flush (useSessionStateCache),
|
|
||||||
// so with Chromium's default background throttling the live answer
|
|
||||||
// stalls whenever this window isn't focused (e.g. you switch to your
|
|
||||||
// editor mid-turn, or open detached devtools) and only appears once you
|
|
||||||
// refocus or refresh. A streaming chat app must render in the
|
|
||||||
// background, so opt out — matching the secondary windows above.
|
|
||||||
backgroundThrottling: false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (IS_MAC) {
|
if (IS_MAC) {
|
||||||
@ -6564,6 +6551,12 @@ app.on('before-quit', () => {
|
|||||||
flushDesktopLogBufferSync()
|
flushDesktopLogBufferSync()
|
||||||
closePreviewWatchers()
|
closePreviewWatchers()
|
||||||
|
|
||||||
|
// Kill open PTYs before environment teardown to avoid the node-pty#904
|
||||||
|
// ThreadSafeFunction SIGABRT race.
|
||||||
|
for (const id of [...terminalSessions.keys()]) {
|
||||||
|
disposeTerminalSession(id)
|
||||||
|
}
|
||||||
|
|
||||||
if (hermesProcess && !hermesProcess.killed) {
|
if (hermesProcess && !hermesProcess.killed) {
|
||||||
hermesProcess.kill('SIGTERM')
|
hermesProcess.kill('SIGTERM')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,29 @@ const { pathToFileURL } = require('node:url')
|
|||||||
const SESSION_WINDOW_MIN_WIDTH = 420
|
const SESSION_WINDOW_MIN_WIDTH = 420
|
||||||
const SESSION_WINDOW_MIN_HEIGHT = 620
|
const SESSION_WINDOW_MIN_HEIGHT = 620
|
||||||
|
|
||||||
|
// Shared webPreferences for every window that renders the chat transcript — the
|
||||||
|
// primary window AND the secondary session windows. Keeping it in one place is
|
||||||
|
// the whole point: the two BrowserWindow definitions in main.cjs used to be
|
||||||
|
// hand-copied, and the secondary windows silently lost `backgroundThrottling:
|
||||||
|
// false`, so a streamed answer stalled until the window regained focus.
|
||||||
|
//
|
||||||
|
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
|
||||||
|
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
|
||||||
|
// blurred/occluded windows. A streaming chat app must keep painting in the
|
||||||
|
// background, so every chat window opts out. The preload path is injected
|
||||||
|
// because it depends on the Electron entry's __dirname.
|
||||||
|
function chatWindowWebPreferences(preloadPath) {
|
||||||
|
return {
|
||||||
|
preload: preloadPath,
|
||||||
|
contextIsolation: true,
|
||||||
|
webviewTag: true,
|
||||||
|
sandbox: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
devTools: true,
|
||||||
|
backgroundThrottling: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build the renderer URL for a secondary window. The renderer uses a
|
// Build the renderer URL for a secondary window. The renderer uses a
|
||||||
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
|
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
|
||||||
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
||||||
@ -94,6 +117,7 @@ function createSessionWindowRegistry() {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
buildSessionWindowUrl,
|
buildSessionWindowUrl,
|
||||||
|
chatWindowWebPreferences,
|
||||||
createSessionWindowRegistry,
|
createSessionWindowRegistry,
|
||||||
SESSION_WINDOW_MIN_HEIGHT,
|
SESSION_WINDOW_MIN_HEIGHT,
|
||||||
SESSION_WINDOW_MIN_WIDTH
|
SESSION_WINDOW_MIN_WIDTH
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
const assert = require('node:assert/strict')
|
const assert = require('node:assert/strict')
|
||||||
const test = require('node:test')
|
const test = require('node:test')
|
||||||
|
|
||||||
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
const {
|
||||||
|
buildSessionWindowUrl,
|
||||||
|
chatWindowWebPreferences,
|
||||||
|
createSessionWindowRegistry
|
||||||
|
} = require('./session-windows.cjs')
|
||||||
|
|
||||||
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
|
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
|
||||||
// test fire the 'closed' event, mirroring the slice of the Electron API the
|
// test fire the 'closed' event, mirroring the slice of the Electron API the
|
||||||
@ -175,3 +179,21 @@ test('registry trims the session id before keying', () => {
|
|||||||
|
|
||||||
assert.equal(registry.has('s1'), true)
|
assert.equal(registry.has('s1'), true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
|
||||||
|
// Regression: secondary session windows used to omit this flag, so a streamed
|
||||||
|
// answer stalled until the window regained focus (Chromium pauses the
|
||||||
|
// requestAnimationFrame-gated transcript flush for backgrounded windows).
|
||||||
|
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
|
||||||
|
|
||||||
|
assert.equal(prefs.backgroundThrottling, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('chatWindowWebPreferences passes the preload path through and keeps the hardened defaults', () => {
|
||||||
|
const prefs = chatWindowWebPreferences('/some/preload.cjs')
|
||||||
|
|
||||||
|
assert.equal(prefs.preload, '/some/preload.cjs')
|
||||||
|
assert.equal(prefs.contextIsolation, true)
|
||||||
|
assert.equal(prefs.sandbox, true)
|
||||||
|
assert.equal(prefs.nodeIntegration, false)
|
||||||
|
})
|
||||||
|
|||||||
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",
|
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
|
||||||
"postbuild": "node scripts/assert-dist-built.cjs",
|
"postbuild": "node scripts/assert-dist-built.cjs",
|
||||||
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
|
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
|
||||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
|
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
|
||||||
"pack": "npm run build && npm run builder -- --dir",
|
"pack": "npm run build && npm run builder -- --dir",
|
||||||
"dist": "npm run build && npm run builder",
|
"dist": "npm run build && npm run builder",
|
||||||
"dist:mac": "npm run build && npm run builder -- --mac",
|
"dist:mac": "npm run build && npm run builder -- --mac",
|
||||||
@ -37,7 +37,7 @@
|
|||||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
|
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/windows-user-env.test.cjs",
|
||||||
"typecheck": "tsc -p . --noEmit",
|
"typecheck": "tsc -p . --noEmit",
|
||||||
"lint": "eslint src/ electron/",
|
"lint": "eslint src/ electron/",
|
||||||
"lint:fix": "eslint src/ electron/ --fix",
|
"lint:fix": "eslint src/ electron/ --fix",
|
||||||
@ -117,7 +117,7 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^40.9.3",
|
"electron": "40.10.2",
|
||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "^26.8.1",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-perfectionist": "^5.9.0",
|
"eslint-plugin-perfectionist": "^5.9.0",
|
||||||
@ -134,8 +134,7 @@
|
|||||||
"wait-on": "^9.0.5"
|
"wait-on": "^9.0.5"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"electronVersion": "40.9.3",
|
"electronVersion": "40.10.2",
|
||||||
"electronDist": "../../node_modules/electron/dist",
|
|
||||||
"appId": "com.nousresearch.hermes",
|
"appId": "com.nousresearch.hermes",
|
||||||
"productName": "Hermes",
|
"productName": "Hermes",
|
||||||
"executableName": "Hermes",
|
"executableName": "Hermes",
|
||||||
|
|||||||
@ -24,6 +24,11 @@ const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes cop
|
|||||||
if (!fs.existsSync(bundledElectronBinary)) {
|
if (!fs.existsSync(bundledElectronBinary)) {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
|
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
|
||||||
|
// npm may nest the workspace-only electron devDep under
|
||||||
|
// apps/desktop/node_modules (process.cwd() during pack), or hoist
|
||||||
|
// it to the repo root. Try the workspace-local install first, then
|
||||||
|
// the root hoist, so the fallback works under either layout.
|
||||||
|
path.join(process.cwd(), "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
|
||||||
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
|
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
|
||||||
];
|
];
|
||||||
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
|
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
|
||||||
|
|||||||
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)
|
||||||
@ -9,6 +9,7 @@ import { formatCombo } from '@/lib/keybinds/combo'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||||
|
import { ModelPill } from './model-pill'
|
||||||
import type { ChatBarState, VoiceStatus } from './types'
|
import type { ChatBarState, VoiceStatus } from './types'
|
||||||
|
|
||||||
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
|
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
|
||||||
@ -66,6 +67,7 @@ export function ComposerControls({
|
|||||||
const c = t.composer
|
const c = t.composer
|
||||||
const steerCombo = formatCombo('mod+enter')
|
const steerCombo = formatCombo('mod+enter')
|
||||||
const steerLabel = `${c.steer} (${steerCombo})`
|
const steerLabel = `${c.steer} (${steerCombo})`
|
||||||
|
|
||||||
const steerTip = (
|
const steerTip = (
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
{c.steer}
|
{c.steer}
|
||||||
@ -81,8 +83,10 @@ export function ComposerControls({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
<ModelPill disabled={disabled} model={state.model} />
|
||||||
{canSteer && (
|
{/* While the agent runs and the user is typing, steer takes over the mic's
|
||||||
|
slot rather than crowding the row with an extra button. */}
|
||||||
|
{canSteer ? (
|
||||||
<Tip label={steerTip}>
|
<Tip label={steerTip}>
|
||||||
<Button
|
<Button
|
||||||
aria-label={steerLabel}
|
aria-label={steerLabel}
|
||||||
@ -96,6 +100,8 @@ export function ComposerControls({
|
|||||||
<SteeringWheel size={16} />
|
<SteeringWheel size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tip>
|
</Tip>
|
||||||
|
) : (
|
||||||
|
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||||
)}
|
)}
|
||||||
{showVoicePrimary ? (
|
{showVoicePrimary ? (
|
||||||
<Tip label={c.startVoice}>
|
<Tip label={c.startVoice}>
|
||||||
|
|||||||
86
apps/desktop/src/app/chat/composer/model-pill.tsx
Normal file
86
apps/desktop/src/app/chat/composer/model-pill.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||||
|
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
|
import { ChevronDown } from '@/lib/icons'
|
||||||
|
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
$currentFastMode,
|
||||||
|
$currentModel,
|
||||||
|
$currentProvider,
|
||||||
|
$currentReasoningEffort,
|
||||||
|
setModelPickerOpen
|
||||||
|
} from '@/store/session'
|
||||||
|
|
||||||
|
import type { ChatBarState } from './types'
|
||||||
|
|
||||||
|
const PILL = cn(
|
||||||
|
'h-(--composer-control-size) max-w-40 shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
|
||||||
|
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composer model selector — the relocated status-bar pill. Reuses the live
|
||||||
|
* `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the
|
||||||
|
* full picker when the gateway is closed and no live menu exists.
|
||||||
|
*/
|
||||||
|
export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
|
||||||
|
const copy = useI18n().t.shell.statusbar
|
||||||
|
const currentModel = useStore($currentModel)
|
||||||
|
const currentProvider = useStore($currentProvider)
|
||||||
|
const fastMode = useStore($currentFastMode)
|
||||||
|
const reasoningEffort = useStore($currentReasoningEffort)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
// The model resolves a beat after the gateway/session comes up. Rather than
|
||||||
|
// flash a literal "No model", show a quiet loader (inherits the pill text
|
||||||
|
// color at half opacity) until a model lands.
|
||||||
|
const label = (
|
||||||
|
<>
|
||||||
|
{currentModel.trim() ? (
|
||||||
|
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
|
||||||
|
) : (
|
||||||
|
<GlyphSpinner className="opacity-50" spinner="braille" />
|
||||||
|
)}
|
||||||
|
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
|
||||||
|
|
||||||
|
if (!model.modelMenuContent) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-label={copy.openModelPicker}
|
||||||
|
className={PILL}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setModelPickerOpen(true)}
|
||||||
|
title={copy.openModelPicker}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button aria-label={title} className={PILL} disabled={disabled} title={title} type="button" variant="ghost">
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
|
||||||
|
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
|
||||||
|
{model.modelMenuContent}
|
||||||
|
</ModelMenuCloseContext.Provider>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import type { HermesGateway } from '@/hermes'
|
import type { HermesGateway } from '@/hermes'
|
||||||
import type { ComposerAttachment } from '@/store/composer'
|
import type { ComposerAttachment } from '@/store/composer'
|
||||||
|
|
||||||
@ -22,6 +24,8 @@ export interface ChatBarState {
|
|||||||
canSwitch: boolean
|
canSwitch: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
quickModels?: QuickModelOption[]
|
quickModels?: QuickModelOption[]
|
||||||
|
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
|
||||||
|
modelMenuContent?: ReactNode
|
||||||
}
|
}
|
||||||
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
|
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
|
||||||
voice: { enabled: boolean; active: boolean }
|
voice: { enabled: boolean; active: boolean }
|
||||||
|
|||||||
@ -15,7 +15,9 @@ import { Backdrop } from '@/components/Backdrop'
|
|||||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { ErrorState } from '@/components/ui/error-state'
|
||||||
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import type { ChatMessage } from '@/lib/chat-messages'
|
import type { ChatMessage } from '@/lib/chat-messages'
|
||||||
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
|
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
|
||||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||||
@ -38,11 +40,12 @@ import {
|
|||||||
$lastVisibleMessageIsUser,
|
$lastVisibleMessageIsUser,
|
||||||
$messages,
|
$messages,
|
||||||
$messagesEmpty,
|
$messagesEmpty,
|
||||||
|
$resumeExhaustedSessionId,
|
||||||
$selectedStoredSessionId,
|
$selectedStoredSessionId,
|
||||||
$sessions,
|
$sessions,
|
||||||
sessionPinId
|
sessionPinId
|
||||||
} from '@/store/session'
|
} from '@/store/session'
|
||||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
import { isSecondaryWindow } from '@/store/windows'
|
||||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||||
|
|
||||||
import { routeSessionId } from '../routes'
|
import { routeSessionId } from '../routes'
|
||||||
@ -62,6 +65,7 @@ import { threadLoadingState } from './thread-loading'
|
|||||||
|
|
||||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||||
gateway: HermesGateway | null
|
gateway: HermesGateway | null
|
||||||
|
modelMenuContent?: React.ReactNode
|
||||||
onToggleSelectedPin: () => void
|
onToggleSelectedPin: () => void
|
||||||
onDeleteSelectedSession: () => void
|
onDeleteSelectedSession: () => void
|
||||||
onCancel: () => Promise<void> | void
|
onCancel: () => Promise<void> | void
|
||||||
@ -85,7 +89,9 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
|||||||
onEdit: (message: AppendMessage) => Promise<void>
|
onEdit: (message: AppendMessage) => Promise<void>
|
||||||
onReload: (parentId: string | null) => Promise<void>
|
onReload: (parentId: string | null) => Promise<void>
|
||||||
onRestoreToMessage?: (messageId: string) => Promise<void>
|
onRestoreToMessage?: (messageId: string) => Promise<void>
|
||||||
|
onRetryResume: (sessionId: string) => void
|
||||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||||
|
onDismissError?: (messageId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
@ -120,10 +126,10 @@ function ChatHeader({
|
|||||||
? pinnedSessionIds.includes(selectedSessionId)
|
? pinnedSessionIds.includes(selectedSessionId)
|
||||||
: false
|
: false
|
||||||
|
|
||||||
// A brand-new session has no session to pin/delete/rename, so the header is
|
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||||
// just a dead "New session" label + chevron. Drop it (and its border)
|
// are compact side panels — they drop the session-actions header + border
|
||||||
// entirely until there's a real session to act on.
|
// entirely. A brand-new draft has nothing to pin/delete/rename either.
|
||||||
if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
|
if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -250,6 +256,7 @@ function ChatRuntimeBoundary({
|
|||||||
export function ChatView({
|
export function ChatView({
|
||||||
className,
|
className,
|
||||||
gateway,
|
gateway,
|
||||||
|
modelMenuContent,
|
||||||
onToggleSelectedPin,
|
onToggleSelectedPin,
|
||||||
onDeleteSelectedSession,
|
onDeleteSelectedSession,
|
||||||
onCancel,
|
onCancel,
|
||||||
@ -270,9 +277,12 @@ export function ChatView({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onReload,
|
onReload,
|
||||||
onRestoreToMessage,
|
onRestoreToMessage,
|
||||||
onTranscribeAudio
|
onRetryResume,
|
||||||
|
onTranscribeAudio,
|
||||||
|
onDismissError
|
||||||
}: ChatViewProps) {
|
}: ChatViewProps) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const { t } = useI18n()
|
||||||
const activeSessionId = useStore($activeSessionId)
|
const activeSessionId = useStore($activeSessionId)
|
||||||
const awaitingResponse = useStore($awaitingResponse)
|
const awaitingResponse = useStore($awaitingResponse)
|
||||||
const busy = useStore($busy)
|
const busy = useStore($busy)
|
||||||
@ -294,6 +304,7 @@ export function ChatView({
|
|||||||
const messagesEmpty = useStore($messagesEmpty)
|
const messagesEmpty = useStore($messagesEmpty)
|
||||||
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
|
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
|
||||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||||
|
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||||
const routedSessionId = routeSessionId(location.pathname)
|
const routedSessionId = routeSessionId(location.pathname)
|
||||||
const isRoutedSessionView = Boolean(routedSessionId)
|
const isRoutedSessionView = Boolean(routedSessionId)
|
||||||
|
|
||||||
@ -313,9 +324,21 @@ export function ChatView({
|
|||||||
// session exists — even if it has zero messages (a brand-new routed
|
// session exists — even if it has zero messages (a brand-new routed
|
||||||
// session). The flicker where `busy` flips true briefly during hydrate
|
// session). The flicker where `busy` flips true briefly during hydrate
|
||||||
// is handled by `threadLoadingState`'s last-visible-user gate.
|
// is handled by `threadLoadingState`'s last-visible-user gate.
|
||||||
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
|
//
|
||||||
|
// resumeExhausted: the bounded auto-retry in use-route-resume gave up on this
|
||||||
|
// routed session (gateway RPC + REST fallback failed through every attempt).
|
||||||
|
// Suppress the loader and show an explicit error + manual Retry instead of
|
||||||
|
// spinning forever. Gated on the route matching so a stale latch from another
|
||||||
|
// session can't blank the current one.
|
||||||
|
const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
|
||||||
|
|
||||||
|
const loadingSession =
|
||||||
|
!resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
|
||||||
|
|
||||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
|
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
|
||||||
const showChatBar = !loadingSession
|
// Hide the composer in the exhausted error state too: there's no live runtime
|
||||||
|
// to send to until a retry rebinds one.
|
||||||
|
const showChatBar = !loadingSession && !resumeExhausted
|
||||||
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
||||||
|
|
||||||
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
||||||
@ -346,6 +369,7 @@ export function ChatView({
|
|||||||
provider: currentProvider,
|
provider: currentProvider,
|
||||||
canSwitch: gatewayOpen,
|
canSwitch: gatewayOpen,
|
||||||
loading: !gatewayOpen || (!currentModel && !currentProvider),
|
loading: !gatewayOpen || (!currentModel && !currentProvider),
|
||||||
|
modelMenuContent,
|
||||||
quickModels
|
quickModels
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
@ -358,7 +382,7 @@ export function ChatView({
|
|||||||
active: false
|
active: false
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
|
[contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Drop files anywhere in the conversation area, not just on the composer
|
// Drop files anywhere in the conversation area, not just on the composer
|
||||||
@ -429,6 +453,7 @@ export function ChatView({
|
|||||||
loading={threadLoading}
|
loading={threadLoading}
|
||||||
onBranchInNewChat={onBranchInNewChat}
|
onBranchInNewChat={onBranchInNewChat}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
|
onDismissError={onDismissError}
|
||||||
onRestoreToMessage={onRestoreToMessage}
|
onRestoreToMessage={onRestoreToMessage}
|
||||||
sessionId={activeSessionId}
|
sessionId={activeSessionId}
|
||||||
sessionKey={threadKey}
|
sessionKey={threadKey}
|
||||||
@ -462,6 +487,21 @@ export function ChatView({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</ChatRuntimeBoundary>
|
</ChatRuntimeBoundary>
|
||||||
|
{resumeExhausted && routedSessionId && (
|
||||||
|
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
|
||||||
|
<ErrorState
|
||||||
|
className="max-w-sm"
|
||||||
|
description={t.desktop.resumeStrandedBody}
|
||||||
|
title={t.desktop.resumeStrandedTitle}
|
||||||
|
>
|
||||||
|
<div className="grid justify-items-center">
|
||||||
|
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
|
||||||
|
{t.desktop.resumeRetry}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ErrorState>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showChatBar && <ScrollToBottomButton />}
|
{showChatBar && <ScrollToBottomButton />}
|
||||||
<ChatDropOverlay kind={dragKind} />
|
<ChatDropOverlay kind={dragKind} />
|
||||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { useSkinCommand } from '@/themes/use-skin-command'
|
|||||||
|
|
||||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||||
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
||||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||||
import {
|
import {
|
||||||
isMessagingSource,
|
isMessagingSource,
|
||||||
LOCAL_SESSION_SOURCE_IDS,
|
LOCAL_SESSION_SOURCE_IDS,
|
||||||
@ -52,7 +52,10 @@ import {
|
|||||||
$currentCwd,
|
$currentCwd,
|
||||||
$freshDraftReady,
|
$freshDraftReady,
|
||||||
$gatewayState,
|
$gatewayState,
|
||||||
|
$messages,
|
||||||
$messagingSessions,
|
$messagingSessions,
|
||||||
|
$resumeFailedSessionId,
|
||||||
|
$resumeExhaustedSessionId,
|
||||||
$selectedStoredSessionId,
|
$selectedStoredSessionId,
|
||||||
$sessions,
|
$sessions,
|
||||||
$workingSessionIds,
|
$workingSessionIds,
|
||||||
@ -199,6 +202,8 @@ export function DesktopController() {
|
|||||||
const activeSessionId = useStore($activeSessionId)
|
const activeSessionId = useStore($activeSessionId)
|
||||||
const currentCwd = useStore($currentCwd)
|
const currentCwd = useStore($currentCwd)
|
||||||
const freshDraftReady = useStore($freshDraftReady)
|
const freshDraftReady = useStore($freshDraftReady)
|
||||||
|
const resumeFailedSessionId = useStore($resumeFailedSessionId)
|
||||||
|
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||||
const filePreviewTarget = useStore($filePreviewTarget)
|
const filePreviewTarget = useStore($filePreviewTarget)
|
||||||
const previewTarget = useStore($previewTarget)
|
const previewTarget = useStore($previewTarget)
|
||||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||||
@ -711,7 +716,9 @@ export function DesktopController() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastGatewayProfileRef.current = activeGatewayProfile
|
lastGatewayProfileRef.current = activeGatewayProfile
|
||||||
void refreshCurrentModel()
|
// Force: the new profile has its own default, so reseed even if the composer
|
||||||
|
// already shows the previous profile's model.
|
||||||
|
void refreshCurrentModel(true)
|
||||||
void refreshActiveProfile()
|
void refreshActiveProfile()
|
||||||
}, [activeGatewayProfile, refreshCurrentModel])
|
}, [activeGatewayProfile, refreshCurrentModel])
|
||||||
|
|
||||||
@ -734,6 +741,49 @@ export function DesktopController() {
|
|||||||
[branchCurrentSession, refreshSessions]
|
[branchCurrentSession, refreshSessions]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Clear a failed turn's red error banner from the transcript. Errors are
|
||||||
|
// renderer-local state (never persisted), so dismissing is purely a view +
|
||||||
|
// session-cache edit. A message that errored before emitting any visible
|
||||||
|
// text is a bare error placeholder → drop it entirely; one that streamed
|
||||||
|
// partial output then failed keeps its content and just sheds the error.
|
||||||
|
// Both the per-runtime cache AND the live $messages view must be updated:
|
||||||
|
// `preserveLocalAssistantErrors` re-grafts any still-errored message it
|
||||||
|
// finds in the view onto the next session.info flush, so clearing only the
|
||||||
|
// cache would let the heartbeat resurrect the banner.
|
||||||
|
const dismissError = useCallback(
|
||||||
|
(messageId: string) => {
|
||||||
|
const runtimeSessionId = activeSessionIdRef.current
|
||||||
|
|
||||||
|
if (!runtimeSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] =>
|
||||||
|
messages.flatMap(message => {
|
||||||
|
if (message.id !== messageId || !message.error) {
|
||||||
|
return [message]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ ...message, error: undefined, pending: false }]
|
||||||
|
})
|
||||||
|
|
||||||
|
// View first: the flush below reads $messages as the "current" baseline
|
||||||
|
// for error preservation, so the banner must be gone from it before the
|
||||||
|
// cache update triggers a re-sync.
|
||||||
|
setMessages(clearErrorIn($messages.get()))
|
||||||
|
|
||||||
|
updateSessionState(runtimeSessionId, state => ({
|
||||||
|
...state,
|
||||||
|
messages: clearErrorIn(state.messages)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[activeSessionIdRef, updateSessionState]
|
||||||
|
)
|
||||||
|
|
||||||
const startSessionInWorkspace = useCallback(
|
const startSessionInWorkspace = useCallback(
|
||||||
(path: null | string) => {
|
(path: null | string) => {
|
||||||
startFreshSessionDraft()
|
startFreshSessionDraft()
|
||||||
@ -843,6 +893,8 @@ export function DesktopController() {
|
|||||||
gatewayState,
|
gatewayState,
|
||||||
locationPathname: location.pathname,
|
locationPathname: location.pathname,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
|
resumeFailedSessionId,
|
||||||
|
resumeExhaustedSessionId,
|
||||||
routedSessionId,
|
routedSessionId,
|
||||||
runtimeIdByStoredSessionIdRef,
|
runtimeIdByStoredSessionIdRef,
|
||||||
selectedStoredSessionId,
|
selectedStoredSessionId,
|
||||||
@ -859,7 +911,6 @@ export function DesktopController() {
|
|||||||
gatewayLogLines,
|
gatewayLogLines,
|
||||||
gatewayState,
|
gatewayState,
|
||||||
inferenceStatus,
|
inferenceStatus,
|
||||||
modelMenuContent,
|
|
||||||
openAgents,
|
openAgents,
|
||||||
freshDraftReady,
|
freshDraftReady,
|
||||||
openCommandCenterSection,
|
openCommandCenterSection,
|
||||||
@ -981,6 +1032,7 @@ export function DesktopController() {
|
|||||||
<ChatView
|
<ChatView
|
||||||
gateway={gatewayRef.current}
|
gateway={gatewayRef.current}
|
||||||
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
|
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
|
||||||
|
modelMenuContent={modelMenuContent}
|
||||||
onAddContextRef={composer.addContextRefAttachment}
|
onAddContextRef={composer.addContextRefAttachment}
|
||||||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||||
onAttachDroppedItems={composer.attachDroppedItems}
|
onAttachDroppedItems={composer.attachDroppedItems}
|
||||||
@ -992,6 +1044,7 @@ export function DesktopController() {
|
|||||||
void removeSession(selectedStoredSessionId)
|
void removeSession(selectedStoredSessionId)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDismissError={dismissError}
|
||||||
onEdit={editMessage}
|
onEdit={editMessage}
|
||||||
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
|
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
|
||||||
onPickFiles={() => void composer.pickContextPaths('file')}
|
onPickFiles={() => void composer.pickContextPaths('file')}
|
||||||
@ -1000,6 +1053,7 @@ export function DesktopController() {
|
|||||||
onReload={reloadFromMessage}
|
onReload={reloadFromMessage}
|
||||||
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
||||||
onRestoreToMessage={restoreToMessage}
|
onRestoreToMessage={restoreToMessage}
|
||||||
|
onRetryResume={sessionId => void resumeSession(sessionId, true)}
|
||||||
onSteer={steerPrompt}
|
onSteer={steerPrompt}
|
||||||
onSubmit={submitText}
|
onSubmit={submitText}
|
||||||
onThreadMessagesChange={handleThreadMessagesChange}
|
onThreadMessagesChange={handleThreadMessagesChange}
|
||||||
|
|||||||
@ -9,3 +9,22 @@ export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
|
|||||||
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
|
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
|
||||||
|
|
||||||
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
|
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
|
||||||
|
|
||||||
|
/** A command queued to run in the embedded terminal. The terminal pane flushes
|
||||||
|
* (and clears) it once its session is live, so a value set before the pane
|
||||||
|
* mounts still runs. Cleared after flush so a later remount can't replay it. */
|
||||||
|
export const $terminalInjection = atom<null | string>(null)
|
||||||
|
|
||||||
|
/** Open the terminal pane and run a command in it. Used to disconnect external
|
||||||
|
* (CLI-managed) providers, which Hermes can't clear via the API — the user
|
||||||
|
* sees exactly what runs instead of Hermes silently deleting their creds. */
|
||||||
|
export const runInTerminal = (command: string) => {
|
||||||
|
const trimmed = command.trim()
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTerminalTakeover(true)
|
||||||
|
$terminalInjection.set(trimmed)
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||||||
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
|
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
|
||||||
import { useTheme } from '@/themes/context'
|
import { useTheme } from '@/themes/context'
|
||||||
|
|
||||||
|
import { $terminalInjection } from '../store'
|
||||||
|
|
||||||
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
|
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
|
||||||
import {
|
import {
|
||||||
isAddSelectionShortcut,
|
isAddSelectionShortcut,
|
||||||
@ -675,6 +677,28 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
return () => cancelAnimationFrame(raf)
|
return () => cancelAnimationFrame(raf)
|
||||||
}, [activeTheme, themeName])
|
}, [activeTheme, themeName])
|
||||||
|
|
||||||
|
// Flush a queued command (e.g. a provider-disconnect) into the live session.
|
||||||
|
// Only active while open; the subscribe fires immediately, so a command set
|
||||||
|
// before this pane mounted runs as soon as the session is ready. Clearing the
|
||||||
|
// atom after writing stops a later remount from replaying a stale command.
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'open') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return $terminalInjection.subscribe(command => {
|
||||||
|
const id = sessionIdRef.current
|
||||||
|
|
||||||
|
if (!command || !id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void window.hermesDesktop?.terminal?.write(id, `${command}\r`)
|
||||||
|
$terminalInjection.set(null)
|
||||||
|
termRef.current?.focus()
|
||||||
|
})
|
||||||
|
}, [status])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
addSelectionToChat,
|
addSelectionToChat,
|
||||||
hostRef,
|
hostRef,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
type GatewayEventPayload,
|
type GatewayEventPayload,
|
||||||
reasoningPart,
|
reasoningPart,
|
||||||
renderMediaTags,
|
renderMediaTags,
|
||||||
|
textPart,
|
||||||
upsertToolPart
|
upsertToolPart
|
||||||
} from '@/lib/chat-messages'
|
} from '@/lib/chat-messages'
|
||||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||||
@ -1080,6 +1081,32 @@ export function useMessageStream({
|
|||||||
// completions / watch matches here — re-sync the status stack.
|
// completions / watch matches here — re-sync the status stack.
|
||||||
void refreshBackgroundProcesses(sessionId)
|
void refreshBackgroundProcesses(sessionId)
|
||||||
}
|
}
|
||||||
|
} else if (event.type === 'review.summary') {
|
||||||
|
// Self-improvement background review saved something to memory/skills
|
||||||
|
// and emitted a persistent summary (Python formats it as
|
||||||
|
// "💾 Self-improvement review: …"). The CLI prints this via
|
||||||
|
// prompt_toolkit and the Ink TUI renders it as a system line; the
|
||||||
|
// desktop has neither, so without this handler the skill/memory
|
||||||
|
// change happens silently. Surface it as a persistent system message
|
||||||
|
// in the transcript so the user is always informed — it must not be a
|
||||||
|
// transient toast that can be missed.
|
||||||
|
const text = coerceGatewayText(payload?.text).trim()
|
||||||
|
|
||||||
|
if (text && sessionId) {
|
||||||
|
flushQueuedDeltas(sessionId)
|
||||||
|
updateSessionState(sessionId, state => ({
|
||||||
|
...state,
|
||||||
|
messages: [
|
||||||
|
...state.messages,
|
||||||
|
{
|
||||||
|
id: `review-summary-${Date.now()}`,
|
||||||
|
role: 'system',
|
||||||
|
parts: [textPart(text)],
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
}
|
||||||
} else if (event.type === 'error') {
|
} else if (event.type === 'error') {
|
||||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||||
@ -1102,8 +1129,13 @@ export function useMessageStream({
|
|||||||
|
|
||||||
if (looksLikeProviderSetup) {
|
if (looksLikeProviderSetup) {
|
||||||
requestDesktopOnboarding(errorMessage)
|
requestDesktopOnboarding(errorMessage)
|
||||||
} else if (isActiveEvent) {
|
} else {
|
||||||
|
// Toast globally, not just when the failing thread is focused: a
|
||||||
|
// turn-ending error (e.g. out of funds) blocks every thread, so the
|
||||||
|
// inline error alone is too easy to miss. The stable id collapses the
|
||||||
|
// same error from multiple blocked threads into one toast.
|
||||||
notify({
|
notify({
|
||||||
|
id: `gateway-error:${errorMessage}`,
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
title: 'Hermes error',
|
title: 'Hermes error',
|
||||||
message: errorMessage
|
message: errorMessage
|
||||||
|
|||||||
@ -130,7 +130,6 @@ describe('useModelControls', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
controls.selectModel({
|
controls.selectModel({
|
||||||
model: 'claude-sonnet-4.6',
|
model: 'claude-sonnet-4.6',
|
||||||
persistGlobal: false,
|
|
||||||
provider: 'anthropic'
|
provider: 'anthropic'
|
||||||
})
|
})
|
||||||
).resolves.toBe(true)
|
).resolves.toBe(true)
|
||||||
@ -143,26 +142,57 @@ describe('useModelControls', () => {
|
|||||||
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the global path on setGlobalModel when there is no active session', async () => {
|
it('stores a no-session pick as UI state with no gateway or global write', async () => {
|
||||||
setGlobalModel.mockResolvedValue(undefined)
|
const requestGateway = vi.fn()
|
||||||
let controls!: Controls
|
let controls!: Controls
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Harness
|
<Harness
|
||||||
activeSessionId={null}
|
activeSessionId={null}
|
||||||
onReady={value => (controls = value)}
|
onReady={value => (controls = value)}
|
||||||
requestGateway={vi.fn()}
|
requestGateway={requestGateway}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
controls.selectModel({
|
controls.selectModel({
|
||||||
model: 'claude-sonnet-4.6',
|
model: 'claude-sonnet-4.6',
|
||||||
persistGlobal: false,
|
|
||||||
provider: 'anthropic'
|
provider: 'anthropic'
|
||||||
})
|
})
|
||||||
).resolves.toBe(true)
|
).resolves.toBe(true)
|
||||||
|
|
||||||
expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6')
|
// The pick is plain UI state; session.create ships it later. Nothing touches
|
||||||
|
// the gateway or the profile default here.
|
||||||
|
expect($currentModel.get()).toBe('claude-sonnet-4.6')
|
||||||
|
expect($currentProvider.get()).toBe('anthropic')
|
||||||
|
expect(requestGateway).not.toHaveBeenCalled()
|
||||||
|
expect(setGlobalModel).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seeds an empty composer model from global but never clobbers a pick', async () => {
|
||||||
|
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useModelControls({
|
||||||
|
activeSessionId: null,
|
||||||
|
queryClient: new QueryClient(),
|
||||||
|
requestGateway: vi.fn()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Empty → seeds the default.
|
||||||
|
await result.current.refreshCurrentModel()
|
||||||
|
expect($currentModel.get()).toBe('openai/gpt-5.5')
|
||||||
|
|
||||||
|
// A user pick must survive the lifecycle refreshes that fire on boot / fresh
|
||||||
|
// draft / session events.
|
||||||
|
setCurrentModel('anthropic/claude-sonnet-4.6')
|
||||||
|
setCurrentProvider('anthropic')
|
||||||
|
await result.current.refreshCurrentModel()
|
||||||
|
expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')
|
||||||
|
|
||||||
|
// A profile swap forces a reseed to the new profile's default.
|
||||||
|
await result.current.refreshCurrentModel(true)
|
||||||
|
expect($currentModel.get()).toBe('openai/gpt-5.5')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { type QueryClient } from '@tanstack/react-query'
|
import { type QueryClient } from '@tanstack/react-query'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
import { getGlobalModelInfo } from '@/hermes'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import {
|
import {
|
||||||
@ -15,7 +15,6 @@ import type { ModelOptionsResponse } from '@/types/hermes'
|
|||||||
|
|
||||||
interface ModelSelection {
|
interface ModelSelection {
|
||||||
model: string
|
model: string
|
||||||
persistGlobal: boolean
|
|
||||||
provider: string
|
provider: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,6 +27,7 @@ interface ModelControlsOptions {
|
|||||||
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
|
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const copy = t.desktop
|
const copy = t.desktop
|
||||||
|
|
||||||
const updateModelOptionsCache = useCallback(
|
const updateModelOptionsCache = useCallback(
|
||||||
(provider: string, model: string, includeGlobal: boolean) => {
|
(provider: string, model: string, includeGlobal: boolean) => {
|
||||||
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
|
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
|
||||||
@ -41,14 +41,24 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
|||||||
[activeSessionId, queryClient]
|
[activeSessionId, queryClient]
|
||||||
)
|
)
|
||||||
|
|
||||||
const refreshCurrentModel = useCallback(async () => {
|
// Seed the composer's model state from the profile default. `force` reseeds
|
||||||
|
// for a profile swap (the new profile has its own default); otherwise this
|
||||||
|
// only fills an EMPTY selection so a user's pick (plain UI state in
|
||||||
|
// $currentModel) survives the lifecycle refreshes that fire on boot / fresh
|
||||||
|
// draft / session events. A live session owns the footer, so skip entirely.
|
||||||
|
const refreshCurrentModel = useCallback(async (force = false) => {
|
||||||
try {
|
try {
|
||||||
|
if ($activeSessionId.get()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && $currentModel.get()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const result = await getGlobalModelInfo()
|
const result = await getGlobalModelInfo()
|
||||||
|
|
||||||
// A resumed/live session owns the footer model state. Global config
|
if ($activeSessionId.get() || (!force && $currentModel.get())) {
|
||||||
// refreshes (gateway boot, profile swap, settings save) must not clobber
|
|
||||||
// the active chat's runtime model/provider in the status bar.
|
|
||||||
if ($activeSessionId.get()) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,12 +74,14 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Returns whether the switch succeeded so callers can await it before
|
// Returns whether the switch succeeded so callers can await it before applying
|
||||||
// applying follow-up changes (e.g. editing a model's reasoning/fast must land
|
// follow-up changes. The composer model is plain UI state: with no live
|
||||||
// on the right active model — bail rather than write to the previous one).
|
// session it's just stored (and shipped on the next session.create); with one
|
||||||
|
// it's scoped to that session via config.set. It NEVER writes the profile
|
||||||
|
// default — that lives in Settings → Model — so picking a model here can't
|
||||||
|
// silently mutate global config.
|
||||||
const selectModel = useCallback(
|
const selectModel = useCallback(
|
||||||
async (selection: ModelSelection): Promise<boolean> => {
|
async (selection: ModelSelection): Promise<boolean> => {
|
||||||
const includeGlobal = selection.persistGlobal || !activeSessionId
|
|
||||||
// Snapshot for rollback: the switch is applied optimistically, so a
|
// Snapshot for rollback: the switch is applied optimistically, so a
|
||||||
// failure must restore the prior model/provider (store + query cache)
|
// failure must restore the prior model/provider (store + query cache)
|
||||||
// rather than leave the UI showing a model the backend never selected.
|
// rather than leave the UI showing a model the backend never selected.
|
||||||
@ -78,42 +90,34 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
|||||||
|
|
||||||
setCurrentModel(selection.model)
|
setCurrentModel(selection.model)
|
||||||
setCurrentProvider(selection.provider)
|
setCurrentProvider(selection.provider)
|
||||||
updateModelOptionsCache(selection.provider, selection.model, includeGlobal)
|
updateModelOptionsCache(selection.provider, selection.model, !activeSessionId)
|
||||||
|
|
||||||
try {
|
|
||||||
if (activeSessionId) {
|
|
||||||
await requestGateway('config.set', {
|
|
||||||
session_id: activeSessionId,
|
|
||||||
key: 'model',
|
|
||||||
value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
|
||||||
})
|
|
||||||
|
|
||||||
if (selection.persistGlobal) {
|
|
||||||
void refreshCurrentModel()
|
|
||||||
}
|
|
||||||
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// No live session yet: the pick is pure UI state. session.create reads
|
||||||
|
// $currentModel/$currentProvider and applies it as that session's override.
|
||||||
|
if (!activeSessionId) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
await setGlobalModel(selection.provider, selection.model)
|
try {
|
||||||
void refreshCurrentModel()
|
await requestGateway('config.set', {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
session_id: activeSessionId,
|
||||||
|
key: 'model',
|
||||||
|
value: `${selection.model} --provider ${selection.provider}`
|
||||||
|
})
|
||||||
|
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] })
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCurrentModel(prevModel)
|
setCurrentModel(prevModel)
|
||||||
setCurrentProvider(prevProvider)
|
setCurrentProvider(prevProvider)
|
||||||
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
|
updateModelOptionsCache(prevProvider, prevModel, !activeSessionId)
|
||||||
notifyError(err, copy.modelSwitchFailed)
|
notifyError(err, copy.modelSwitchFailed)
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeSessionId, copy.modelSwitchFailed, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
|
[activeSessionId, copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
|
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
|
||||||
|
|||||||
@ -58,6 +58,7 @@ import { clearSessionTodos } from '@/store/todos'
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
ClientSessionState,
|
ClientSessionState,
|
||||||
|
BrowserManageResponse,
|
||||||
FileAttachResponse,
|
FileAttachResponse,
|
||||||
HandoffFailResponse,
|
HandoffFailResponse,
|
||||||
HandoffRequestResponse,
|
HandoffRequestResponse,
|
||||||
@ -1141,6 +1142,81 @@ export function usePromptActions({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// /browser connect|disconnect|status manages the live CDP connection on
|
||||||
|
// the gateway host, mirroring the TUI's browser.manage RPC. It mutates
|
||||||
|
// BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only
|
||||||
|
// meaningful when that process runs on this machine, so it's gated to
|
||||||
|
// local connections. A remote gateway would act on the wrong host.
|
||||||
|
browser: async ctx => {
|
||||||
|
const resolved = await withSlashOutput(ctx)
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { render: renderSlashOutput, sessionId } = resolved
|
||||||
|
|
||||||
|
if ($connection.get()?.mode === 'remote') {
|
||||||
|
renderSlashOutput(
|
||||||
|
'/browser manages a Chromium-family browser on the gateway host — only available when connected to a local gateway.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
const cmdAction = rawAction.toLowerCase()
|
||||||
|
|
||||||
|
if (!['connect', 'disconnect', 'status'].includes(cmdAction)) {
|
||||||
|
renderSlashOutput(
|
||||||
|
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await requestGateway<BrowserManageResponse>('browser.manage', {
|
||||||
|
action: cmdAction,
|
||||||
|
session_id: sessionId,
|
||||||
|
...(url && { url })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Without a streamed session subscription, the gateway bundles its
|
||||||
|
// progress lines into `messages` — flush them inline.
|
||||||
|
result?.messages?.forEach(message => renderSlashOutput(message))
|
||||||
|
|
||||||
|
if (cmdAction === 'status') {
|
||||||
|
renderSlashOutput(
|
||||||
|
result?.connected
|
||||||
|
? `browser connected: ${result.url || '(url unavailable)'}`
|
||||||
|
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmdAction === 'disconnect') {
|
||||||
|
renderSlashOutput('browser disconnected')
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.connected) {
|
||||||
|
renderSlashOutput('Browser connected to live Chromium-family browser via CDP')
|
||||||
|
renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`)
|
||||||
|
renderSlashOutput('next browser tool call will use this CDP endpoint')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { cleanup, render } from '@testing-library/react'
|
|||||||
import type { MutableRefObject } from 'react'
|
import type { MutableRefObject } from 'react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { $resumeExhaustedSessionId, setResumeExhaustedSessionId } from '@/store/session'
|
||||||
|
|
||||||
import { useRouteResume } from './use-route-resume'
|
import { useRouteResume } from './use-route-resume'
|
||||||
|
|
||||||
interface HarnessProps {
|
interface HarnessProps {
|
||||||
@ -13,6 +15,8 @@ interface HarnessProps {
|
|||||||
gatewayState: string
|
gatewayState: string
|
||||||
locationPathname: string
|
locationPathname: string
|
||||||
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
||||||
|
resumeFailedSessionId?: null | string
|
||||||
|
resumeExhaustedSessionId?: null | string
|
||||||
routedSessionId: null | string
|
routedSessionId: null | string
|
||||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
||||||
selectedStoredSessionId: null | string
|
selectedStoredSessionId: null | string
|
||||||
@ -20,8 +24,12 @@ interface HarnessProps {
|
|||||||
startFreshSessionDraft: (focus: boolean) => unknown
|
startFreshSessionDraft: (focus: boolean) => unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouteResumeHarness(props: HarnessProps) {
|
function RouteResumeHarness({
|
||||||
useRouteResume(props)
|
resumeFailedSessionId = null,
|
||||||
|
resumeExhaustedSessionId = null,
|
||||||
|
...props
|
||||||
|
}: HarnessProps) {
|
||||||
|
useRouteResume({ ...props, resumeExhaustedSessionId, resumeFailedSessionId })
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -256,3 +264,212 @@ describe('useRouteResume', () => {
|
|||||||
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
|
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useRouteResume bounded auto-retry after a failed resume', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
setResumeExhaustedSessionId(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Common stranded-window props: gateway open, route on the session, no runtime
|
||||||
|
// yet, and the ref already synced to the route (resumeSession sets it at entry
|
||||||
|
// before failing) — the exact state that defeats the main effect's self-heal.
|
||||||
|
function strandedProps(resumeSession: (sid: string, focus: boolean) => Promise<unknown>) {
|
||||||
|
return {
|
||||||
|
activeSessionId: null,
|
||||||
|
activeSessionIdRef: { current: null } as MutableRefObject<null | string>,
|
||||||
|
creatingSessionRef: { current: false },
|
||||||
|
currentView: 'chat',
|
||||||
|
freshDraftReady: false,
|
||||||
|
gatewayState: 'open',
|
||||||
|
locationPathname: '/session-1',
|
||||||
|
resumeSession,
|
||||||
|
routedSessionId: 'session-1',
|
||||||
|
runtimeIdByStoredSessionIdRef: { current: new Map<string, string>() },
|
||||||
|
selectedStoredSessionId: 'session-1',
|
||||||
|
// Synced to the route by the failed resume's synchronous entry-write.
|
||||||
|
selectedStoredSessionIdRef: { current: 'session-1' } as MutableRefObject<null | string>,
|
||||||
|
startFreshSessionDraft: vi.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('retries the resume on backoff when the routed session is flagged as failed', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
|
||||||
|
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="session-1" />)
|
||||||
|
|
||||||
|
// The main effect fires one resume on mount (pathname-changed). Clear it so
|
||||||
|
// we assert purely the bounded-retry effect's scheduled retry below.
|
||||||
|
resumeSession.mockClear()
|
||||||
|
|
||||||
|
// No immediate fire — the retry is scheduled behind the backoff timer.
|
||||||
|
expect(resumeSession).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// First backoff window (1s) elapses → one retry.
|
||||||
|
vi.advanceTimersByTime(1_000)
|
||||||
|
expect(resumeSession).toHaveBeenCalledTimes(1)
|
||||||
|
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT retry a failed session that is not the routed one', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
|
||||||
|
// The failure flag points at a different session than the route.
|
||||||
|
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="other-session" />)
|
||||||
|
resumeSession.mockClear() // drop the mount resume
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10_000)
|
||||||
|
expect(resumeSession).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips the scheduled retry if the session already recovered when the timer fires', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
const props = strandedProps(resumeSession)
|
||||||
|
|
||||||
|
render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
||||||
|
resumeSession.mockClear() // drop the mount resume
|
||||||
|
|
||||||
|
// A resume landed while we waited: runtime is now bound.
|
||||||
|
props.activeSessionIdRef.current = 'runtime-1'
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(8_000)
|
||||||
|
expect(resumeSession).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops retrying after MAX_RESUME_RETRIES consecutive failures', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
const props = strandedProps(resumeSession)
|
||||||
|
|
||||||
|
// Model the real re-arm loop: resumeSession clears $resumeFailedSessionId at
|
||||||
|
// entry (null) and a repeat failure re-sets it ('session-1'). That null->id
|
||||||
|
// toggle is what re-runs the effect and advances the bounded counter. The
|
||||||
|
// routed session never changes, so the counter is NOT reset between cycles.
|
||||||
|
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
||||||
|
resumeSession.mockClear() // drop the mount resume; count only the retries
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
vi.advanceTimersByTime(8_000) // fire the scheduled retry (if any)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />) // cleared at entry
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />) // re-armed on failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capped at MAX_RESUME_RETRIES (4): a persistently dead backend can't
|
||||||
|
// hot-loop the resume forever.
|
||||||
|
expect(resumeSession.mock.calls.length).toBe(4)
|
||||||
|
|
||||||
|
// Once auto-retry gives up, the exhausted latch is armed for the routed
|
||||||
|
// session so the chat view can swap the perpetual loader for an explicit
|
||||||
|
// error + manual Retry instead of spinning forever.
|
||||||
|
expect($resumeExhaustedSessionId.get()).toBe('session-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not arm the exhausted latch while retries remain', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
const props = strandedProps(resumeSession)
|
||||||
|
|
||||||
|
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
||||||
|
resumeSession.mockClear()
|
||||||
|
|
||||||
|
// Two failure cycles — still under the 4-retry cap, so the latch must stay
|
||||||
|
// clear and the loader keeps spinning (auto-recovery hasn't given up yet).
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
vi.advanceTimersByTime(8_000)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($resumeExhaustedSessionId.get()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears a stale exhausted latch when the route moves off the stranded session', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
const props = strandedProps(resumeSession)
|
||||||
|
|
||||||
|
// Pre-arm the latch as if this session had exhausted its retries.
|
||||||
|
setResumeExhaustedSessionId('session-1')
|
||||||
|
|
||||||
|
// Route is now on a different, healthy session that is not flagged as
|
||||||
|
// failed — the retry effect's "route moved off" branch clears the latch.
|
||||||
|
render(
|
||||||
|
<RouteResumeHarness
|
||||||
|
{...props}
|
||||||
|
activeSessionId="runtime-2"
|
||||||
|
activeSessionIdRef={{ current: 'runtime-2' }}
|
||||||
|
locationPathname="/session-2"
|
||||||
|
resumeFailedSessionId={null}
|
||||||
|
routedSessionId="session-2"
|
||||||
|
selectedStoredSessionId="session-2"
|
||||||
|
selectedStoredSessionIdRef={{ current: 'session-2' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect($resumeExhaustedSessionId.get()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets the retry counter for a fresh backoff cycle when the exhausted latch clears (manual retry, same session)', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
|
const props = strandedProps(resumeSession)
|
||||||
|
|
||||||
|
// Phase A — exhaust the bounded auto-retry (counter → MAX) like a dead
|
||||||
|
// backend. The resumeExhaustedSessionId prop stays null here: the hook sets
|
||||||
|
// the store, which doesn't feed back into the prop in this harness.
|
||||||
|
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
||||||
|
resumeSession.mockClear()
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
vi.advanceTimersByTime(8_000)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
||||||
|
}
|
||||||
|
expect(resumeSession.mock.calls.length).toBe(4) // capped
|
||||||
|
expect($resumeExhaustedSessionId.get()).toBe('session-1')
|
||||||
|
|
||||||
|
// Phase B — user clicks Retry on the SAME stranded session. resumeSession
|
||||||
|
// clears both latches at entry; the exhausted latch's armed->cleared edge
|
||||||
|
// must reset the attempt counter so a fresh bounded cycle runs, not a single
|
||||||
|
// one-shot attempt that immediately re-arms the error. Model the prop
|
||||||
|
// transitions: reflect the armed latch, then clear it (retry), then re-arm
|
||||||
|
// the failure latch on the fresh failure.
|
||||||
|
resumeSession.mockClear()
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId="session-1" resumeFailedSessionId="session-1" />)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId={null} />)
|
||||||
|
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId="session-1" />)
|
||||||
|
|
||||||
|
// A real retry fires again instead of staying pinned at MAX (which would
|
||||||
|
// dispatch nothing). Without the reset the counter stays >= MAX and this
|
||||||
|
// advance dispatches zero resumes.
|
||||||
|
vi.advanceTimersByTime(8_000)
|
||||||
|
expect(resumeSession.mock.calls.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not burn retry attempts on unrelated re-renders during the backoff window', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const props = strandedProps(vi.fn())
|
||||||
|
|
||||||
|
// Mount schedules the first backoff timer. Then re-render repeatedly with a
|
||||||
|
// fresh resumeSession identity (referential instability — a real dep change
|
||||||
|
// for the retry effect) WITHOUT ever letting the timer fire. The old code
|
||||||
|
// incremented the attempt counter at schedule time, so >= MAX re-renders
|
||||||
|
// armed the exhausted error with zero resumes actually dispatched. The fix
|
||||||
|
// only advances the counter when a timer truly fires, so the latch stays
|
||||||
|
// clear no matter how many spurious re-renders happen mid-backoff.
|
||||||
|
const { rerender } = render(
|
||||||
|
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
|
||||||
|
)
|
||||||
|
for (let j = 0; j < 8; j += 1) {
|
||||||
|
rerender(
|
||||||
|
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($resumeExhaustedSessionId.get()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { type MutableRefObject, useEffect, useRef } from 'react'
|
import { type MutableRefObject, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { isNewChatRoute } from '@/app/routes'
|
import { isNewChatRoute } from '@/app/routes'
|
||||||
|
import { setResumeExhaustedSessionId } from '@/store/session'
|
||||||
|
|
||||||
interface RouteResumeOptions {
|
interface RouteResumeOptions {
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
@ -11,6 +12,17 @@ interface RouteResumeOptions {
|
|||||||
gatewayState: string | undefined
|
gatewayState: string | undefined
|
||||||
locationPathname: string
|
locationPathname: string
|
||||||
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
||||||
|
// Stored-session id whose most recent resume failed terminally (set by
|
||||||
|
// useSessionActions, mirrored from $resumeFailedSessionId). While this equals
|
||||||
|
// routedSessionId the window would otherwise latch on the loader forever, so
|
||||||
|
// the bounded-retry effect below re-attempts the resume.
|
||||||
|
resumeFailedSessionId: string | null
|
||||||
|
// Stored-session id whose bounded auto-retry has EXHAUSTED (mirrored from
|
||||||
|
// $resumeExhaustedSessionId). Only resumeSession clears this latch (manual
|
||||||
|
// Retry / reconnect / reselect) — the auto-retry loop never does — so its
|
||||||
|
// armed->cleared edge is an unambiguous "give me a fresh backoff cycle"
|
||||||
|
// signal the effect below uses to reset the attempt counter.
|
||||||
|
resumeExhaustedSessionId: string | null
|
||||||
routedSessionId: string | null
|
routedSessionId: string | null
|
||||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
||||||
selectedStoredSessionId: string | null
|
selectedStoredSessionId: string | null
|
||||||
@ -18,6 +30,19 @@ interface RouteResumeOptions {
|
|||||||
startFreshSessionDraft: (focus: boolean) => unknown
|
startFreshSessionDraft: (focus: boolean) => unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bounded auto-retry for a stranded session window. A resume can fail terminally
|
||||||
|
// (gateway RPC reject + REST fallback failure) on a transiently wedged backend —
|
||||||
|
// dead provider key, a runaway turn hogging the dispatcher, flaky DNS. Without a
|
||||||
|
// retry the loader latches forever. We retry with backoff, capped, so a
|
||||||
|
// genuinely dead backend doesn't hot-loop the resume.
|
||||||
|
const MAX_RESUME_RETRIES = 4
|
||||||
|
const RESUME_RETRY_BASE_MS = 1_000
|
||||||
|
const RESUME_RETRY_MAX_MS = 8_000
|
||||||
|
|
||||||
|
function resumeRetryDelayMs(attempt: number): number {
|
||||||
|
return Math.min(RESUME_RETRY_MAX_MS, RESUME_RETRY_BASE_MS * 2 ** attempt)
|
||||||
|
}
|
||||||
|
|
||||||
// HashRouter boot edge case: pathname briefly reads `/` before the hash is
|
// HashRouter boot edge case: pathname briefly reads `/` before the hash is
|
||||||
// parsed. If the hash references a real session, defer; resume picks it up
|
// parsed. If the hash references a real session, defer; resume picks it up
|
||||||
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
|
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
|
||||||
@ -49,6 +74,8 @@ export function useRouteResume({
|
|||||||
gatewayState,
|
gatewayState,
|
||||||
locationPathname,
|
locationPathname,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
|
resumeFailedSessionId,
|
||||||
|
resumeExhaustedSessionId,
|
||||||
routedSessionId,
|
routedSessionId,
|
||||||
runtimeIdByStoredSessionIdRef,
|
runtimeIdByStoredSessionIdRef,
|
||||||
selectedStoredSessionId,
|
selectedStoredSessionId,
|
||||||
@ -58,6 +85,16 @@ export function useRouteResume({
|
|||||||
const lastPathnameRef = useRef<string | null>(null)
|
const lastPathnameRef = useRef<string | null>(null)
|
||||||
const seenGatewayStateRef = useRef(false)
|
const seenGatewayStateRef = useRef(false)
|
||||||
const wasGatewayOpenRef = useRef(false)
|
const wasGatewayOpenRef = useRef(false)
|
||||||
|
// Per-session retry bookkeeping for the bounded auto-retry effect below. Keyed
|
||||||
|
// by the session id we're retrying so switching chats resets the counter.
|
||||||
|
const retrySessionIdRef = useRef<string | null>(null)
|
||||||
|
const retryAttemptRef = useRef(0)
|
||||||
|
// Tracks the previous exhausted-latch value so we can detect its armed->cleared
|
||||||
|
// edge. resumeSession clears $resumeExhaustedSessionId on a manual Retry /
|
||||||
|
// reconnect / reselect; that transition is our cue to reset the attempt counter
|
||||||
|
// for a fresh backoff cycle on the SAME session (the auto-retry loop itself
|
||||||
|
// never touches this latch, so it can't spuriously trigger the reset).
|
||||||
|
const prevResumeExhaustedRef = useRef<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const gatewayOpen = gatewayState === 'open'
|
const gatewayOpen = gatewayState === 'open'
|
||||||
@ -139,4 +176,111 @@ export function useRouteResume({
|
|||||||
selectedStoredSessionIdRef,
|
selectedStoredSessionIdRef,
|
||||||
startFreshSessionDraft
|
startFreshSessionDraft
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Bounded auto-retry: when the routed session's resume failed terminally
|
||||||
|
// (resumeFailedSessionId matches the route), schedule a backoff retry so the
|
||||||
|
// window recovers on its own instead of latching the loader forever. This is
|
||||||
|
// the safety net the main effect above can't provide: after a failed resume,
|
||||||
|
// selectedStoredSessionIdRef.current already equals the route (resumeSession
|
||||||
|
// sets it synchronously at entry) and the pathname/gateway are unchanged, so
|
||||||
|
// none of stuckOnRoutedSession / pathnameChanged / gatewayBecameOpen fire
|
||||||
|
// again. resumeSession clears resumeFailedSessionId on its next attempt; a
|
||||||
|
// success keeps it clear (the effect's guard then no-ops), a repeat failure
|
||||||
|
// re-arms it and we back off further, capped at MAX_RESUME_RETRIES.
|
||||||
|
useEffect(() => {
|
||||||
|
// Detect the exhausted-latch armed->cleared edge for the current route. Only
|
||||||
|
// resumeSession clears $resumeExhaustedSessionId (manual Retry / reconnect /
|
||||||
|
// reselect) — the auto-retry loop never touches it — so this transition
|
||||||
|
// uniquely means "the user asked for another go." Reset the attempt counter
|
||||||
|
// for a fresh bounded backoff cycle on the SAME session. Without this,
|
||||||
|
// retryAttemptRef stays pinned at MAX after exhaustion (the !stranded reset
|
||||||
|
// below only fires on a route CHANGE to a different session), so a manual
|
||||||
|
// retry on the same stranded session would get exactly ONE attempt and then
|
||||||
|
// immediately re-arm the exhausted error — never the renewed backoff cycle
|
||||||
|
// the store/session.ts + use-session-actions.ts comments promise. (Point 2)
|
||||||
|
const wasExhausted = prevResumeExhaustedRef.current
|
||||||
|
prevResumeExhaustedRef.current = resumeExhaustedSessionId
|
||||||
|
if (wasExhausted && wasExhausted === routedSessionId && resumeExhaustedSessionId !== wasExhausted) {
|
||||||
|
retrySessionIdRef.current = routedSessionId
|
||||||
|
retryAttemptRef.current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentView !== 'chat' || gatewayState !== 'open') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stranded =
|
||||||
|
Boolean(routedSessionId) &&
|
||||||
|
resumeFailedSessionId === routedSessionId &&
|
||||||
|
!creatingSessionRef.current
|
||||||
|
|
||||||
|
if (!stranded) {
|
||||||
|
// Route moved off the stranded session (or it recovered) — reset the
|
||||||
|
// counter so a future failure on another session starts fresh, and clear
|
||||||
|
// any exhausted-latch armed for a session we're no longer viewing (never
|
||||||
|
// the current route: that's the error state we want to keep showing).
|
||||||
|
// resumeSession also clears it on a fresh attempt; this covers a plain
|
||||||
|
// route-change away from the stranded window.
|
||||||
|
if (retrySessionIdRef.current !== routedSessionId) {
|
||||||
|
retrySessionIdRef.current = null
|
||||||
|
retryAttemptRef.current = 0
|
||||||
|
setResumeExhaustedSessionId(current => (current && current !== routedSessionId ? null : current))
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// New stranded session id → reset the attempt counter.
|
||||||
|
if (retrySessionIdRef.current !== routedSessionId) {
|
||||||
|
retrySessionIdRef.current = routedSessionId
|
||||||
|
retryAttemptRef.current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryAttemptRef.current >= MAX_RESUME_RETRIES) {
|
||||||
|
// Give up auto-retrying a persistently dead backend; the user can still
|
||||||
|
// reconnect / reselect (which resets the counter via the branch above).
|
||||||
|
// Surface an explicit error + manual Retry in the chat view instead of
|
||||||
|
// spinning the loader forever — resumeSession (manual Retry / reconnect /
|
||||||
|
// reselect) clears this latch and resets the counter for a fresh cycle.
|
||||||
|
setResumeExhaustedSessionId(routedSessionId)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt = retryAttemptRef.current
|
||||||
|
const sessionId = routedSessionId as string
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
// Re-check liveness at fire time: a resume may have landed while we waited.
|
||||||
|
if (
|
||||||
|
creatingSessionRef.current ||
|
||||||
|
selectedStoredSessionIdRef.current !== sessionId ||
|
||||||
|
activeSessionIdRef.current !== null
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume an attempt ONLY now that a resume is actually dispatching.
|
||||||
|
// Incrementing at schedule time (the old behavior) let unrelated dep
|
||||||
|
// changes during the 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 { useEffect } from 'react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { getSessionMessages } from '@/hermes'
|
||||||
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
|
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
|
||||||
import { $currentCwd } from '@/store/session'
|
import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
|
||||||
|
|
||||||
import type { ClientSessionState } from '../../types'
|
import type { ClientSessionState } from '../../types'
|
||||||
|
|
||||||
@ -117,3 +118,142 @@ describe('createBackendSessionForSend profile routing', () => {
|
|||||||
expect(params).toMatchObject({ profile: 'default' })
|
expect(params).toMatchObject({ profile: 'default' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Resume failure recovery (the "stuck loading session window" bug) ──────────
|
||||||
|
// When session.resume rejects AND the REST transcript fallback ALSO fails, the
|
||||||
|
// hook must (a) not throw out of the fallback (which stranded the loader), and
|
||||||
|
// (b) arm $resumeFailedSessionId so use-route-resume can retry. A resume that
|
||||||
|
// succeeds must NOT leave the flag armed.
|
||||||
|
function ResumeHarness({
|
||||||
|
onReady,
|
||||||
|
requestGateway
|
||||||
|
}: {
|
||||||
|
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
|
||||||
|
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||||
|
}) {
|
||||||
|
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
|
||||||
|
|
||||||
|
const actions = useSessionActions({
|
||||||
|
activeSessionId: null,
|
||||||
|
activeSessionIdRef: ref<string | null>(null),
|
||||||
|
busyRef: ref(false),
|
||||||
|
creatingSessionRef: ref(false),
|
||||||
|
ensureSessionState: () => ({}) as ClientSessionState,
|
||||||
|
getRouteToken: () => 'token',
|
||||||
|
navigate: vi.fn() as never,
|
||||||
|
requestGateway,
|
||||||
|
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
|
||||||
|
selectedStoredSessionId: null,
|
||||||
|
selectedStoredSessionIdRef: ref<string | null>(null),
|
||||||
|
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
|
||||||
|
syncSessionStateToView: vi.fn(),
|
||||||
|
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onReady(actions.resumeSession)
|
||||||
|
}, [actions.resumeSession, onReady])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resumeSession failure recovery', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
setResumeFailedSessionId(null)
|
||||||
|
setMessages([])
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function runResume(
|
||||||
|
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||||
|
): Promise<void> {
|
||||||
|
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
|
||||||
|
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} />)
|
||||||
|
await waitFor(() => expect(resume).not.toBeNull())
|
||||||
|
await resume!('stored-1', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('arms $resumeFailedSessionId when resume RPC and REST fallback both fail', async () => {
|
||||||
|
// session.resume rejects (e.g. timeout against a wedged backend)...
|
||||||
|
const requestGateway = vi.fn(async (method: string) => {
|
||||||
|
if (method === 'session.resume') {
|
||||||
|
throw new Error('request timed out: session.resume')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
// ...and the REST transcript fallback also rejects (backend unreachable).
|
||||||
|
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
|
||||||
|
|
||||||
|
await runResume(requestGateway)
|
||||||
|
|
||||||
|
// The window is no longer silently stranded: the failure latch is armed for
|
||||||
|
// the stored session, which use-route-resume consumes to retry.
|
||||||
|
expect($resumeFailedSessionId.get()).toBe('stored-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT arm the failure latch when the resume RPC fails but the REST fallback paints history', async () => {
|
||||||
|
// session.resume rejects, but the REST transcript fallback succeeds and
|
||||||
|
// hydrates a readable transcript — the window is NOT stranded.
|
||||||
|
const requestGateway = vi.fn(async (method: string) => {
|
||||||
|
if (method === 'session.resume') {
|
||||||
|
throw new Error('request timed out: session.resume')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(getSessionMessages).mockResolvedValue({
|
||||||
|
messages: [
|
||||||
|
{ content: 'hello', role: 'user', timestamp: 1 },
|
||||||
|
{ content: 'hi there', role: 'assistant', timestamp: 2 }
|
||||||
|
],
|
||||||
|
session_id: 'stored-1'
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
await runResume(requestGateway)
|
||||||
|
|
||||||
|
// Arming here would auto-retry a window that already shows history and,
|
||||||
|
// on exhaustion, blank that transcript behind the error overlay — a
|
||||||
|
// regression vs. plain fallback-success. The latch must stay clear.
|
||||||
|
expect($resumeFailedSessionId.get()).toBeNull()
|
||||||
|
// The fallback transcript is visible.
|
||||||
|
expect($messages.get().length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT throw out of the fallback when REST also fails (no unhandled rejection)', async () => {
|
||||||
|
const requestGateway = vi.fn(async (method: string) => {
|
||||||
|
if (method === 'session.resume') {
|
||||||
|
throw new Error('request timed out: session.resume')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
|
||||||
|
|
||||||
|
// resumeSession must resolve (swallow the fallback failure), not reject.
|
||||||
|
await expect(runResume(requestGateway)).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the failure latch clear when resume succeeds', async () => {
|
||||||
|
// Pre-arm to prove a successful resume clears it (entry-clear path).
|
||||||
|
setResumeFailedSessionId('stored-1')
|
||||||
|
|
||||||
|
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||||
|
if (method === 'session.resume') {
|
||||||
|
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
|
||||||
|
}
|
||||||
|
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
|
||||||
|
|
||||||
|
await runResume(requestGateway)
|
||||||
|
|
||||||
|
expect($resumeFailedSessionId.get()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -15,6 +15,10 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
|
|||||||
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||||
import {
|
import {
|
||||||
$currentCwd,
|
$currentCwd,
|
||||||
|
$currentFastMode,
|
||||||
|
$currentModel,
|
||||||
|
$currentProvider,
|
||||||
|
$currentReasoningEffort,
|
||||||
$messages,
|
$messages,
|
||||||
$sessions,
|
$sessions,
|
||||||
$yoloActive,
|
$yoloActive,
|
||||||
@ -34,6 +38,8 @@ import {
|
|||||||
setFreshDraftReady,
|
setFreshDraftReady,
|
||||||
setIntroSeed,
|
setIntroSeed,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
setResumeExhaustedSessionId,
|
||||||
|
setResumeFailedSessionId,
|
||||||
setSelectedStoredSessionId,
|
setSelectedStoredSessionId,
|
||||||
setSessions,
|
setSessions,
|
||||||
setSessionStartedAt,
|
setSessionStartedAt,
|
||||||
@ -407,13 +413,13 @@ export function useSessionActions({
|
|||||||
})
|
})
|
||||||
setSessionStartedAt(null)
|
setSessionStartedAt(null)
|
||||||
setTurnStartedAt(null)
|
setTurnStartedAt(null)
|
||||||
// New chats start in the configured default project dir when set,
|
// The composer's model/effort/fast is sticky UI state (persisted in
|
||||||
// otherwise the sticky last-used workspace (PR #37586).
|
// localStorage) — a new chat FOLLOWS your last pick instead of snapping
|
||||||
setCurrentModel('')
|
// back to the profile default, so we deliberately don't reset it here. The
|
||||||
setCurrentProvider('')
|
// profile default still owns first-run seeding and profile switches (see
|
||||||
setCurrentReasoningEffort('')
|
// refreshCurrentModel). Only $currentServiceTier (a live-session mirror)
|
||||||
|
// is cleared.
|
||||||
setCurrentServiceTier('')
|
setCurrentServiceTier('')
|
||||||
setCurrentFastMode(false)
|
|
||||||
setYoloActive(false)
|
setYoloActive(false)
|
||||||
setCurrentCwd(workspaceCwdForNewSession())
|
setCurrentCwd(workspaceCwdForNewSession())
|
||||||
setCurrentBranch('')
|
setCurrentBranch('')
|
||||||
@ -443,11 +449,23 @@ export function useSessionActions({
|
|||||||
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
|
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
|
||||||
await ensureGatewayProfile(newChatProfile)
|
await ensureGatewayProfile(newChatProfile)
|
||||||
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
|
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
|
||||||
|
// The composer's model/effort/fast is sticky UI state ($currentModel,
|
||||||
|
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
|
||||||
|
// with every session.create so the new chat opens on whatever the picker
|
||||||
|
// shows — applied as per-session overrides, never written to the profile
|
||||||
|
// default (that lives in Settings → Model).
|
||||||
|
const uiModel = $currentModel.get().trim()
|
||||||
|
const uiProvider = $currentProvider.get().trim()
|
||||||
|
const uiEffort = $currentReasoningEffort.get().trim()
|
||||||
|
const uiFast = $currentFastMode.get()
|
||||||
|
|
||||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||||
cols: 96,
|
cols: 96,
|
||||||
...(cwd && { cwd }),
|
...(cwd && { cwd }),
|
||||||
...(newChatProfile ? { profile: newChatProfile } : {})
|
...(newChatProfile ? { profile: newChatProfile } : {}),
|
||||||
|
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
|
||||||
|
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
|
||||||
|
...(uiFast ? { fast: true } : {})
|
||||||
})
|
})
|
||||||
|
|
||||||
const stored = created.stored_session_id ?? null
|
const stored = created.stored_session_id ?? null
|
||||||
@ -563,6 +581,15 @@ export function useSessionActions({
|
|||||||
clearNotifications()
|
clearNotifications()
|
||||||
setSelectedStoredSessionId(storedSessionId)
|
setSelectedStoredSessionId(storedSessionId)
|
||||||
selectedStoredSessionIdRef.current = storedSessionId
|
selectedStoredSessionIdRef.current = storedSessionId
|
||||||
|
// Optimistically clear any prior resume-failure latch for this session:
|
||||||
|
// we're attempting a fresh resume, so the self-heal in use-route-resume
|
||||||
|
// must not keep treating it as stranded. It's re-armed below only if THIS
|
||||||
|
// attempt fails terminally (RPC reject + REST fallback failure).
|
||||||
|
setResumeFailedSessionId(current => (current === storedSessionId ? null : current))
|
||||||
|
// Also clear the exhausted-latch: a fresh attempt (manual Retry, reconnect,
|
||||||
|
// reselect) gives the bounded auto-retry counter a clean cycle, so the
|
||||||
|
// chat view drops the error state and shows the loader again.
|
||||||
|
setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current))
|
||||||
|
|
||||||
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||||
|
|
||||||
@ -753,6 +780,15 @@ export function useSessionActions({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The gateway resume RPC failed. Try the REST transcript as a fallback
|
||||||
|
// so the window at least shows history. CRITICAL: this fallback must be
|
||||||
|
// wrapped in its own try — if it ALSO throws (wedged/unreachable backend,
|
||||||
|
// the common case when resume failed in the first place), an unguarded
|
||||||
|
// throw here skips setMessages AND leaves activeSessionId null with an
|
||||||
|
// empty transcript. That is the exact state the thread loader latches on
|
||||||
|
// forever (messagesEmpty && !activeSessionId) with no recovery path —
|
||||||
|
// the "open in new window stays stuck loading, even after a nap" bug.
|
||||||
|
try {
|
||||||
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
|
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
|
||||||
|
|
||||||
if (!isCurrentResume()) {
|
if (!isCurrentResume()) {
|
||||||
@ -760,6 +796,25 @@ export function useSessionActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
|
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
|
||||||
|
} catch {
|
||||||
|
// Fallback also failed: nothing to paint. Leave whatever messages are
|
||||||
|
// already shown and fall through to arm the resume-failure latch so
|
||||||
|
// use-route-resume re-attempts the resume on the next render / window
|
||||||
|
// focus / gateway reconnect instead of stranding the loader.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCurrentResume() && $messages.get().length === 0) {
|
||||||
|
// Arm the self-heal ONLY when the window is still empty: the gateway
|
||||||
|
// resume rejected AND the REST fallback failed to paint a transcript.
|
||||||
|
// That is the exact stranded state the loader latches on
|
||||||
|
// (messagesEmpty && !activeSessionId), and matches $resumeFailedSessionId's
|
||||||
|
// documented contract. If the REST fallback DID paint history, the
|
||||||
|
// window is readable — arming here would needlessly auto-retry and,
|
||||||
|
// once retries exhaust, blank that visible transcript behind the
|
||||||
|
// exhausted-state error overlay (a regression vs. plain fallback success).
|
||||||
|
setResumeFailedSessionId(storedSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
notifyError(err, copy.resumeFailed)
|
notifyError(err, copy.resumeFailed)
|
||||||
} finally {
|
} finally {
|
||||||
if (isCurrentResume()) {
|
if (isCurrentResume()) {
|
||||||
|
|||||||
@ -2,12 +2,14 @@ import { act, cleanup, render } from '@testing-library/react'
|
|||||||
import type { MutableRefObject } from 'react'
|
import type { MutableRefObject } from 'react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import type { ChatMessage } from '@/lib/chat-messages'
|
||||||
import {
|
import {
|
||||||
$currentFastMode,
|
$currentFastMode,
|
||||||
$currentModel,
|
$currentModel,
|
||||||
$currentProvider,
|
$currentProvider,
|
||||||
$currentReasoningEffort,
|
$currentReasoningEffort,
|
||||||
$currentServiceTier,
|
$currentServiceTier,
|
||||||
|
$messages,
|
||||||
$turnStartedAt,
|
$turnStartedAt,
|
||||||
setCurrentFastMode,
|
setCurrentFastMode,
|
||||||
setCurrentModel,
|
setCurrentModel,
|
||||||
@ -213,3 +215,113 @@ describe('useSessionStateCache — per-session turn timer', () => {
|
|||||||
expect($currentFastMode.get()).toBe(false)
|
expect($currentFastMode.get()).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function userMessage(id: string, text: string): ChatMessage {
|
||||||
|
return { id, role: 'user', parts: [{ type: 'text', text }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantText(id: string, text: string): ChatMessage {
|
||||||
|
return { id, role: 'assistant', parts: [{ type: 'text', text }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantError(id: string, error: string): ChatMessage {
|
||||||
|
return { id, role: 'assistant', parts: [], error, pending: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewHarnessProps {
|
||||||
|
activeSessionId: string | null
|
||||||
|
onReady: (cache: Cache) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewHarness({ activeSessionId, onReady }: ViewHarnessProps) {
|
||||||
|
const busyRef: MutableRefObject<boolean> = { current: false }
|
||||||
|
const cache = useSessionStateCache({
|
||||||
|
activeSessionId,
|
||||||
|
busyRef,
|
||||||
|
selectedStoredSessionId: null,
|
||||||
|
setAwaitingResponse: () => undefined,
|
||||||
|
setBusy: () => undefined,
|
||||||
|
// Wire the published view back into the real $messages atom the flush
|
||||||
|
// reads from, so the round-trip matches production.
|
||||||
|
setMessages: messages => $messages.set(messages)
|
||||||
|
})
|
||||||
|
|
||||||
|
onReady(cache)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useSessionStateCache — cross-thread error isolation', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
$messages.set([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not leak a failed turn into another thread on switch', () => {
|
||||||
|
$messages.set([])
|
||||||
|
let cache!: Cache
|
||||||
|
const { rerender } = render(<ViewHarness activeSessionId="thread-A" onReady={c => (cache = c)} />)
|
||||||
|
|
||||||
|
// Thread A ends its turn with an out-of-funds error and is on screen.
|
||||||
|
act(() => {
|
||||||
|
cache.updateSessionState(
|
||||||
|
'thread-A',
|
||||||
|
state => ({
|
||||||
|
...state,
|
||||||
|
busy: false,
|
||||||
|
messages: [userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'Out of funds')]
|
||||||
|
}),
|
||||||
|
'stored-A'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect($messages.get().some(message => message.error === 'Out of funds')).toBe(true)
|
||||||
|
|
||||||
|
// Switch to thread B (which completed cleanly). Its cached state syncs to
|
||||||
|
// the view while $messages still holds thread A's transcript.
|
||||||
|
rerender(<ViewHarness activeSessionId="thread-B" onReady={c => (cache = c)} />)
|
||||||
|
act(() => {
|
||||||
|
cache.updateSessionState(
|
||||||
|
'thread-B',
|
||||||
|
state => ({
|
||||||
|
...state,
|
||||||
|
busy: false,
|
||||||
|
messages: [userMessage('user-b', 'hello'), assistantText('assistant-b', 'hi there')]
|
||||||
|
}),
|
||||||
|
'stored-B'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect($messages.get().map(message => message.id)).toEqual(['user-b', 'assistant-b'])
|
||||||
|
expect($messages.get().some(message => message.error === 'Out of funds')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still preserves a same-session local error a heartbeat dropped', () => {
|
||||||
|
$messages.set([])
|
||||||
|
let cache!: Cache
|
||||||
|
render(<ViewHarness activeSessionId="thread-A" onReady={c => (cache = c)} />)
|
||||||
|
|
||||||
|
// First paint establishes thread A as the on-screen session.
|
||||||
|
act(() => {
|
||||||
|
cache.updateSessionState(
|
||||||
|
'thread-A',
|
||||||
|
state => ({ ...state, busy: false, messages: [userMessage('user-a', 'do the thing')] }),
|
||||||
|
'stored-A'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A local error lands in the view (e.g. failAssistantMessage wrote it).
|
||||||
|
$messages.set([userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'OpenRouter 403')])
|
||||||
|
|
||||||
|
// A later same-session heartbeat carries cached state that lost the error.
|
||||||
|
act(() => {
|
||||||
|
cache.updateSessionState('thread-A', state => ({
|
||||||
|
...state,
|
||||||
|
busy: false,
|
||||||
|
messages: [userMessage('user-a', 'do the thing')]
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect($messages.get().some(message => message.error === 'OpenRouter 403')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -79,6 +79,9 @@ export function useSessionStateCache({
|
|||||||
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
|
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
|
||||||
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
|
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
|
||||||
const viewSyncRafRef = useRef<number | null>(null)
|
const viewSyncRafRef = useRef<number | null>(null)
|
||||||
|
// Runtime id whose transcript currently occupies `$messages` — lets the
|
||||||
|
// flush below tell a same-session refresh from a thread switch.
|
||||||
|
const viewSessionIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeSessionIdRef.current = activeSessionId
|
activeSessionIdRef.current = activeSessionId
|
||||||
@ -142,12 +145,22 @@ export function useSessionStateCache({
|
|||||||
// jerks the scroll position while the user is reading. Skip the publish when
|
// jerks the scroll position while the user is reading. Skip the publish when
|
||||||
// the merged result is content-identical to what's already on screen.
|
// the merged result is content-identical to what's already on screen.
|
||||||
const currentMessages = $messages.get()
|
const currentMessages = $messages.get()
|
||||||
const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages)
|
// On a thread switch `$messages` still holds the *previous* thread, so
|
||||||
|
// preserving its local errors would graft that thread's failed turn (e.g.
|
||||||
|
// an out-of-funds error) onto this one — then cascade it everywhere as the
|
||||||
|
// polluted view becomes the next switch's baseline. Only carry errors
|
||||||
|
// across a same-session refresh; our cached state already keeps its own.
|
||||||
|
const nextMessages =
|
||||||
|
viewSessionIdRef.current === pending.sessionId
|
||||||
|
? preserveLocalAssistantErrors(pending.state.messages, currentMessages)
|
||||||
|
: pending.state.messages
|
||||||
|
|
||||||
if (!sameMessageList(nextMessages, currentMessages)) {
|
if (!sameMessageList(nextMessages, currentMessages)) {
|
||||||
setMessages(nextMessages)
|
setMessages(nextMessages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewSessionIdRef.current = pending.sessionId
|
||||||
|
|
||||||
syncRuntimeMetadataToView(pending.state)
|
syncRuntimeMetadataToView(pending.state)
|
||||||
setBusy(pending.state.busy)
|
setBusy(pending.state.busy)
|
||||||
setMutableRef(busyRef, pending.state.busy)
|
setMutableRef(busyRef, pending.state.busy)
|
||||||
|
|||||||
@ -228,7 +228,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||||||
onMainModelChanged={onMainModelChanged}
|
onMainModelChanged={onMainModelChanged}
|
||||||
/>
|
/>
|
||||||
) : activeView === 'providers' ? (
|
) : activeView === 'providers' ? (
|
||||||
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
|
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
|
||||||
) : activeView === 'keys' ? (
|
) : activeView === 'keys' ? (
|
||||||
<KeysSettings view={keysView} />
|
<KeysSettings view={keysView} />
|
||||||
) : activeView === 'mcp' ? (
|
) : activeView === 'mcp' ? (
|
||||||
|
|||||||
@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn()
|
|||||||
const setModelAssignment = vi.fn()
|
const setModelAssignment = vi.fn()
|
||||||
const getRecommendedDefaultModel = vi.fn()
|
const getRecommendedDefaultModel = vi.fn()
|
||||||
const setEnvVar = vi.fn()
|
const setEnvVar = vi.fn()
|
||||||
|
const getHermesConfigRecord = vi.fn()
|
||||||
|
const saveHermesConfig = vi.fn()
|
||||||
const startManualProviderOAuth = vi.fn()
|
const startManualProviderOAuth = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/hermes', () => ({
|
vi.mock('@/hermes', () => ({
|
||||||
@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({
|
|||||||
getAuxiliaryModels: () => getAuxiliaryModels(),
|
getAuxiliaryModels: () => getAuxiliaryModels(),
|
||||||
setModelAssignment: (body: unknown) => setModelAssignment(body),
|
setModelAssignment: (body: unknown) => setModelAssignment(body),
|
||||||
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
|
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
|
||||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value)
|
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
|
||||||
|
getHermesConfigRecord: () => getHermesConfigRecord(),
|
||||||
|
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/store/onboarding', () => ({
|
vi.mock('@/store/onboarding', () => ({
|
||||||
@ -35,7 +39,13 @@ beforeEach(() => {
|
|||||||
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
|
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
|
||||||
getGlobalModelOptions.mockResolvedValue({
|
getGlobalModelOptions.mockResolvedValue({
|
||||||
providers: [
|
providers: [
|
||||||
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true },
|
{
|
||||||
|
name: 'Nous',
|
||||||
|
slug: 'nous',
|
||||||
|
models: ['hermes-4', 'hermes-4-mini'],
|
||||||
|
authenticated: true,
|
||||||
|
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
|
||||||
|
},
|
||||||
// An unconfigured api_key provider — surfaced by the full-universe payload.
|
// An unconfigured api_key provider — surfaced by the full-universe payload.
|
||||||
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
|
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
|
||||||
]
|
]
|
||||||
@ -47,6 +57,8 @@ beforeEach(() => {
|
|||||||
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
|
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
|
||||||
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
|
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
|
||||||
setEnvVar.mockResolvedValue({ ok: true })
|
setEnvVar.mockResolvedValue({ ok: true })
|
||||||
|
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
|
||||||
|
saveHermesConfig.mockResolvedValue({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -100,6 +112,31 @@ describe('ModelSettings', () => {
|
|||||||
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
|
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
|
||||||
|
await renderModelSettings()
|
||||||
|
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
|
||||||
|
|
||||||
|
const fastSwitch = await screen.findByRole('switch')
|
||||||
|
fireEvent.click(fastSwitch)
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(saveHermesConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
|
||||||
|
getGlobalModelOptions.mockResolvedValueOnce({
|
||||||
|
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }]
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderModelSettings()
|
||||||
|
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
|
||||||
|
|
||||||
|
expect(screen.queryByRole('switch')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the auxiliary task rows', async () => {
|
it('renders the auxiliary task rows', async () => {
|
||||||
await renderModelSettings()
|
await renderModelSettings()
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
getAuxiliaryModels,
|
getAuxiliaryModels,
|
||||||
getGlobalModelInfo,
|
getGlobalModelInfo,
|
||||||
@ -10,6 +11,8 @@ import {
|
|||||||
getMoaModels,
|
getMoaModels,
|
||||||
getRecommendedDefaultModel,
|
getRecommendedDefaultModel,
|
||||||
saveMoaModels,
|
saveMoaModels,
|
||||||
|
getHermesConfigRecord,
|
||||||
|
saveHermesConfig,
|
||||||
setEnvVar,
|
setEnvVar,
|
||||||
setModelAssignment
|
setModelAssignment
|
||||||
} from '@/hermes'
|
} from '@/hermes'
|
||||||
@ -17,11 +20,26 @@ import type { AuxiliaryModelsResponse, MoaConfigResponse, MoaModelSlot, ModelOpt
|
|||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { notifyError } from '@/store/notifications'
|
||||||
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
|
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
|
||||||
|
import type { HermesConfigRecord } from '@/types/hermes'
|
||||||
|
|
||||||
import { CONTROL_TEXT } from './constants'
|
import { CONTROL_TEXT } from './constants'
|
||||||
|
import { getNested, setNested } from './helpers'
|
||||||
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
||||||
|
|
||||||
|
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
|
||||||
|
// Empty config = Hermes default (medium), shown as Medium.
|
||||||
|
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
|
||||||
|
|
||||||
|
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
|
||||||
|
// normal (mirrors tui_gateway _load_service_tier).
|
||||||
|
const isFastTier = (tier: unknown): boolean =>
|
||||||
|
['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase())
|
||||||
|
|
||||||
|
// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1).
|
||||||
|
const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal'
|
||||||
|
|
||||||
// A provider row is "ready" to pick a model from when it reports models. The
|
// A provider row is "ready" to pick a model from when it reports models. The
|
||||||
// backend now surfaces the full `hermes model` universe (every canonical
|
// backend now surfaces the full `hermes model` universe (every canonical
|
||||||
// provider), so unconfigured providers come back with `authenticated:false`
|
// provider), so unconfigured providers come back with `authenticated:false`
|
||||||
@ -102,6 +120,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
const [moa, setMoa] = useState<MoaConfigResponse | null>(null)
|
const [moa, setMoa] = useState<MoaConfigResponse | null>(null)
|
||||||
const [selectedMoaPreset, setSelectedMoaPreset] = useState('')
|
const [selectedMoaPreset, setSelectedMoaPreset] = useState('')
|
||||||
const [newMoaPresetName, setNewMoaPresetName] = useState('')
|
const [newMoaPresetName, setNewMoaPresetName] = useState('')
|
||||||
|
// Full profile config, kept so the reasoning/speed defaults round-trip
|
||||||
|
// (read agent.* → write back the whole record) like the generic config page.
|
||||||
|
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||||
const [applying, setApplying] = useState(false)
|
const [applying, setApplying] = useState(false)
|
||||||
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
||||||
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
||||||
@ -118,11 +139,12 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [modelInfo, modelOptions, auxiliaryModels, moaModels] = await Promise.all([
|
const [modelInfo, modelOptions, auxiliaryModels, moaModels, cfg] = await Promise.all([
|
||||||
getGlobalModelInfo(),
|
getGlobalModelInfo(),
|
||||||
getGlobalModelOptions(),
|
getGlobalModelOptions(),
|
||||||
getAuxiliaryModels(),
|
getAuxiliaryModels(),
|
||||||
getMoaModels().catch(() => null)
|
getMoaModels().catch(() => null),
|
||||||
|
getHermesConfigRecord()
|
||||||
])
|
])
|
||||||
|
|
||||||
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
||||||
@ -135,6 +157,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
if (moaModels) {
|
if (moaModels) {
|
||||||
setSelectedMoaPreset(prev => prev && moaModels.presets[prev] ? prev : moaModels.default_preset)
|
setSelectedMoaPreset(prev => prev && moaModels.presets[prev] ? prev : moaModels.default_preset)
|
||||||
}
|
}
|
||||||
|
setConfig(cfg)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
@ -248,6 +271,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
|
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
|
||||||
}, [auxiliary, mainModel])
|
}, [auxiliary, mainModel])
|
||||||
|
|
||||||
|
// Capabilities of the APPLIED main model — gates the profile-default
|
||||||
|
// reasoning/speed controls the same way the composer picker gates per-model
|
||||||
|
// edits (reasoning defaults on, fast defaults off when unreported).
|
||||||
|
const mainCaps = useMemo(() => {
|
||||||
|
const row = providers.find(provider => provider.slug === mainModel?.provider)
|
||||||
|
|
||||||
|
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
|
||||||
|
}, [providers, mainModel])
|
||||||
|
|
||||||
|
const reasoningSupported = mainCaps?.reasoning ?? true
|
||||||
|
const fastSupported = mainCaps?.fast ?? false
|
||||||
|
const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium'
|
||||||
|
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
|
||||||
|
|
||||||
|
// Persist a single agent.* default by round-tripping the whole config record
|
||||||
|
// (PUT /api/config replaces it) — optimistic, with rollback on failure.
|
||||||
|
const writeAgentDefault = useCallback(
|
||||||
|
async (key: string, value: string) => {
|
||||||
|
if (!config) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = config
|
||||||
|
const next = setNested(config, key, value)
|
||||||
|
setConfig(next)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveHermesConfig(next)
|
||||||
|
} catch (err) {
|
||||||
|
setConfig(prev)
|
||||||
|
notifyError(err, m.defaultsFailed)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config, m.defaultsFailed]
|
||||||
|
)
|
||||||
|
|
||||||
// Paste an API key for the selected `api_key` provider, persist it, then
|
// Paste an API key for the selected `api_key` provider, persist it, then
|
||||||
// refresh so the now-authenticated provider's models populate. Auto-selects
|
// refresh so the now-authenticated provider's models populate. Auto-selects
|
||||||
// the recommended default model so the user can Apply in one more click.
|
// the recommended default model so the user can Apply in one more click.
|
||||||
@ -500,6 +559,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
|
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{config && mainModel && (reasoningSupported || fastSupported) && (
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||||
|
<span className="text-xs text-muted-foreground">{m.defaultsLabel}</span>
|
||||||
|
{reasoningSupported && (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
{m.reasoning}
|
||||||
|
<Select onValueChange={value => void writeAgentDefault('agent.reasoning_effort', value)} value={effortValue}>
|
||||||
|
<SelectTrigger className={cn('min-w-28', CONTROL_TEXT)}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EFFORT_VALUES.map(value => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fastSupported && (
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
{t.shell.modelOptions.fast}
|
||||||
|
<Switch
|
||||||
|
checked={fastOn}
|
||||||
|
onCheckedChange={checked => void writeAgentDefault('agent.service_tier', checked ? 'fast' : 'normal')}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
|
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
|
||||||
{switchStaleAux.length > 0 && (
|
{switchStaleAux.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
|
|||||||
@ -55,7 +55,7 @@ afterEach(() => {
|
|||||||
async function renderProvidersSettings() {
|
async function renderProvidersSettings() {
|
||||||
const { ProvidersSettings } = await import('./providers-settings')
|
const { ProvidersSettings } = await import('./providers-settings')
|
||||||
|
|
||||||
return render(<ProvidersSettings onViewChange={vi.fn()} view="accounts" />)
|
return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ProvidersSettings', () => {
|
describe('ProvidersSettings', () => {
|
||||||
@ -95,6 +95,6 @@ describe('ProvidersSettings', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('Qwen Code')).toBeTruthy()
|
expect(await screen.findByText('Qwen Code')).toBeTruthy()
|
||||||
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
|
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
|
||||||
expect(screen.getByText(/managed outside Hermes/)).toBeTruthy()
|
expect(screen.getByText(/managed by its own CLI/)).toBeTruthy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { runInTerminal } from '@/app/right-sidebar/store'
|
||||||
import {
|
import {
|
||||||
FEATURED_ID,
|
FEATURED_ID,
|
||||||
FeaturedProviderRow,
|
FeaturedProviderRow,
|
||||||
@ -23,6 +25,20 @@ import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
|
|||||||
import { providerGroup, providerMeta, providerPriority } from './helpers'
|
import { providerGroup, providerMeta, providerPriority } from './helpers'
|
||||||
import { LoadingState, SettingsContent } from './primitives'
|
import { LoadingState, SettingsContent } from './primitives'
|
||||||
|
|
||||||
|
// The embedded terminal (and thus the "run disconnect command" path) only
|
||||||
|
// exists in the Electron desktop shell, not the web dashboard.
|
||||||
|
const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal)
|
||||||
|
|
||||||
|
// Parallel group headers ("Connected", "Other providers") so the expanded list
|
||||||
|
// reads as its own section instead of bleeding into the connected group.
|
||||||
|
function GroupLabel({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="mt-3 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
|
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
|
||||||
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
|
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
|
||||||
|
|
||||||
@ -90,11 +106,13 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
|
|||||||
function OAuthPicker({
|
function OAuthPicker({
|
||||||
disconnecting,
|
disconnecting,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
|
onTerminalDisconnect,
|
||||||
onWantApiKey,
|
onWantApiKey,
|
||||||
providers
|
providers
|
||||||
}: {
|
}: {
|
||||||
disconnecting: null | string
|
disconnecting: null | string
|
||||||
onDisconnect: (provider: OAuthProvider) => void
|
onDisconnect: (provider: OAuthProvider) => void
|
||||||
|
onTerminalDisconnect: (provider: OAuthProvider) => void
|
||||||
onWantApiKey: () => void
|
onWantApiKey: () => void
|
||||||
providers: OAuthProvider[]
|
providers: OAuthProvider[]
|
||||||
}) {
|
}) {
|
||||||
@ -138,15 +156,14 @@ function OAuthPicker({
|
|||||||
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
|
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
|
||||||
{connected.length > 0 && (
|
{connected.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
<GroupLabel>{p.connected}</GroupLabel>
|
||||||
{p.connected}
|
|
||||||
</p>
|
|
||||||
{connected.map(p => (
|
{connected.map(p => (
|
||||||
<ConnectedProviderRow
|
<ConnectedProviderRow
|
||||||
disconnecting={disconnecting === p.id}
|
disconnecting={disconnecting === p.id}
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onDisconnect={onDisconnect}
|
onDisconnect={onDisconnect}
|
||||||
onSelect={select}
|
onSelect={select}
|
||||||
|
onTerminalDisconnect={onTerminalDisconnect}
|
||||||
provider={p}
|
provider={p}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -154,6 +171,7 @@ function OAuthPicker({
|
|||||||
)}
|
)}
|
||||||
{showOthers && (
|
{showOthers && (
|
||||||
<>
|
<>
|
||||||
|
{connected.length > 0 && <GroupLabel>{p.otherProviders}</GroupLabel>}
|
||||||
{others.map(p => (
|
{others.map(p => (
|
||||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||||
))}
|
))}
|
||||||
@ -180,21 +198,26 @@ function ConnectedProviderRow({
|
|||||||
disconnecting,
|
disconnecting,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onTerminalDisconnect,
|
||||||
provider
|
provider
|
||||||
}: {
|
}: {
|
||||||
disconnecting: boolean
|
disconnecting: boolean
|
||||||
onDisconnect: (provider: OAuthProvider) => void
|
onDisconnect: (provider: OAuthProvider) => void
|
||||||
onSelect: (provider: OAuthProvider) => void
|
onSelect: (provider: OAuthProvider) => void
|
||||||
|
onTerminalDisconnect: (provider: OAuthProvider) => void
|
||||||
provider: OAuthProvider
|
provider: OAuthProvider
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const copy = t.settings.providers
|
||||||
const title = providerTitle(provider)
|
const title = providerTitle(provider)
|
||||||
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
||||||
|
// Hermes can clear this provider's creds via the API.
|
||||||
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
|
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
|
||||||
|
// External (CLI-managed) provider Hermes can't clear via the API, but ships a
|
||||||
const disconnectHint = provider.flow === 'external'
|
// command we can run in the embedded terminal (Electron shell only).
|
||||||
? t.settings.providers.removeExternal(title, provider.cli_command)
|
const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal()
|
||||||
: t.settings.providers.removeKeyManaged(title)
|
// Only fall back to a static "remove it elsewhere" hint when we offer no button.
|
||||||
|
const showHint = !canDisconnect && !terminalDisconnect
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
|
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
|
||||||
@ -203,13 +226,13 @@ function ConnectedProviderRow({
|
|||||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
|
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
|
||||||
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
<Check className="size-3" />
|
<Check className="size-3" />
|
||||||
{t.settings.providers.connected}
|
{copy.connected}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
||||||
{!canDisconnect && (
|
{showHint && (
|
||||||
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
|
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
|
||||||
{disconnectHint}
|
{provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -228,6 +251,18 @@ function ConnectedProviderRow({
|
|||||||
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{terminalDisconnect && (
|
||||||
|
<Button
|
||||||
|
aria-label={`${copy.disconnect} ${title}`}
|
||||||
|
onClick={() => onTerminalDisconnect(provider)}
|
||||||
|
size="icon-xs"
|
||||||
|
title={copy.disconnectInTerminal}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -243,7 +278,7 @@ function NoProviderKeys() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
|
export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { rowProps, vars } = useEnvCredentials()
|
const { rowProps, vars } = useEnvCredentials()
|
||||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||||
@ -282,6 +317,29 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
|||||||
return () => void (cancelled = true)
|
return () => void (cancelled = true)
|
||||||
}, [onboardingActive])
|
}, [onboardingActive])
|
||||||
|
|
||||||
|
// External (CLI-managed) providers can't be cleared via the API by design —
|
||||||
|
// Hermes never deletes creds another tool owns behind a silent API call.
|
||||||
|
// Instead we run the documented removal command in the embedded terminal so
|
||||||
|
// the user sees exactly what executes, then return them to chat to watch it.
|
||||||
|
function handleTerminalDisconnect(provider: OAuthProvider) {
|
||||||
|
const command = provider.disconnect_command
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = providerTitle(provider)
|
||||||
|
|
||||||
|
if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the settings overlay so the terminal pane (chat-only) is visible.
|
||||||
|
onClose()
|
||||||
|
runInTerminal(command)
|
||||||
|
notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) })
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDisconnect(provider: OAuthProvider) {
|
async function handleDisconnect(provider: OAuthProvider) {
|
||||||
const name = providerTitle(provider)
|
const name = providerTitle(provider)
|
||||||
|
|
||||||
@ -341,6 +399,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
|||||||
<OAuthPicker
|
<OAuthPicker
|
||||||
disconnecting={disconnecting}
|
disconnecting={disconnecting}
|
||||||
onDisconnect={provider => void handleDisconnect(provider)}
|
onDisconnect={provider => void handleDisconnect(provider)}
|
||||||
|
onTerminalDisconnect={handleTerminalDisconnect}
|
||||||
onWantApiKey={() => onViewChange('keys')}
|
onWantApiKey={() => onViewChange('keys')}
|
||||||
providers={oauthProviders}
|
providers={oauthProviders}
|
||||||
/>
|
/>
|
||||||
@ -359,6 +418,7 @@ interface ProviderKeyGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ProvidersSettingsProps {
|
interface ProvidersSettingsProps {
|
||||||
|
onClose: () => void
|
||||||
onViewChange: (view: ProviderView) => void
|
onViewChange: (view: ProviderView) => void
|
||||||
view: ProviderView
|
view: ProviderView
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
} from '@/store/layout'
|
} from '@/store/layout'
|
||||||
import { $paneWidthOverride } from '@/store/panes'
|
import { $paneWidthOverride } from '@/store/panes'
|
||||||
import { $connection } from '@/store/session'
|
import { $connection } from '@/store/session'
|
||||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
import { isSecondaryWindow } from '@/store/windows'
|
||||||
|
|
||||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||||
|
|
||||||
@ -80,7 +80,10 @@ export function AppShell({
|
|||||||
const connection = useStore($connection)
|
const connection = useStore($connection)
|
||||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||||
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
||||||
const hideTitlebarControls = isNewSessionWindow()
|
// Every secondary window (new-session scratch, subagent watch, cmd-click
|
||||||
|
// pop-out) is a compact side panel — none of them carry the full titlebar
|
||||||
|
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
|
||||||
|
const hideTitlebarControls = isSecondaryWindow()
|
||||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
||||||
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
|
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
|
||||||
// on macOS, where window controls sit on the left and are reported via
|
// on macOS, where window controls sit on the left and are reported via
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
|
|
||||||
import type { CommandCenterSection } from '@/app/command-center'
|
import type { CommandCenterSection } from '@/app/command-center'
|
||||||
@ -9,7 +8,6 @@ import { useI18n } from '@/i18n'
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ChevronDown,
|
|
||||||
Clock,
|
Clock,
|
||||||
Command,
|
Command,
|
||||||
Hash,
|
Hash,
|
||||||
@ -19,7 +17,6 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
ZapFilled
|
ZapFilled
|
||||||
} from '@/lib/icons'
|
} from '@/lib/icons'
|
||||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
|
||||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@ -30,16 +27,11 @@ import {
|
|||||||
$activeSessionId,
|
$activeSessionId,
|
||||||
$busy,
|
$busy,
|
||||||
$connection,
|
$connection,
|
||||||
$currentFastMode,
|
|
||||||
$currentModel,
|
|
||||||
$currentProvider,
|
|
||||||
$currentReasoningEffort,
|
|
||||||
$currentUsage,
|
$currentUsage,
|
||||||
$sessionStartedAt,
|
$sessionStartedAt,
|
||||||
$turnStartedAt,
|
$turnStartedAt,
|
||||||
$workingSessionIds,
|
$workingSessionIds,
|
||||||
$yoloActive,
|
$yoloActive,
|
||||||
setModelPickerOpen,
|
|
||||||
setYoloActive
|
setYoloActive
|
||||||
} from '@/store/session'
|
} from '@/store/session'
|
||||||
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
|
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
|
||||||
@ -65,7 +57,6 @@ interface StatusbarItemsOptions {
|
|||||||
gatewayLogLines: readonly string[]
|
gatewayLogLines: readonly string[]
|
||||||
gatewayState: string
|
gatewayState: string
|
||||||
inferenceStatus: RuntimeReadinessResult | null
|
inferenceStatus: RuntimeReadinessResult | null
|
||||||
modelMenuContent?: ReactNode
|
|
||||||
openAgents: () => void
|
openAgents: () => void
|
||||||
openCommandCenterSection: (section: CommandCenterSection) => void
|
openCommandCenterSection: (section: CommandCenterSection) => void
|
||||||
freshDraftReady: boolean
|
freshDraftReady: boolean
|
||||||
@ -83,7 +74,6 @@ export function useStatusbarItems({
|
|||||||
gatewayLogLines,
|
gatewayLogLines,
|
||||||
gatewayState,
|
gatewayState,
|
||||||
inferenceStatus,
|
inferenceStatus,
|
||||||
modelMenuContent,
|
|
||||||
openAgents,
|
openAgents,
|
||||||
openCommandCenterSection,
|
openCommandCenterSection,
|
||||||
freshDraftReady,
|
freshDraftReady,
|
||||||
@ -97,10 +87,6 @@ export function useStatusbarItems({
|
|||||||
const terminalTakeover = useStore($terminalTakeover)
|
const terminalTakeover = useStore($terminalTakeover)
|
||||||
const yoloActive = useStore($yoloActive)
|
const yoloActive = useStore($yoloActive)
|
||||||
const busy = useStore($busy)
|
const busy = useStore($busy)
|
||||||
const currentFastMode = useStore($currentFastMode)
|
|
||||||
const currentModel = useStore($currentModel)
|
|
||||||
const currentProvider = useStore($currentProvider)
|
|
||||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
|
||||||
const currentUsage = useStore($currentUsage)
|
const currentUsage = useStore($currentUsage)
|
||||||
const desktopActionTasks = useStore($desktopActionTasks)
|
const desktopActionTasks = useStore($desktopActionTasks)
|
||||||
const previewServerRestartStatus = useStore($previewServerRestartStatus)
|
const previewServerRestartStatus = useStore($previewServerRestartStatus)
|
||||||
@ -416,37 +402,6 @@ export function useStatusbarItems({
|
|||||||
title: yoloActive ? copy.yoloOn : copy.yoloOff,
|
title: yoloActive ? copy.yoloOn : copy.yoloOff,
|
||||||
variant: 'action'
|
variant: 'action'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'model-summary',
|
|
||||||
label: (
|
|
||||||
<span className="inline-flex min-w-0 items-center gap-0.5">
|
|
||||||
<span className="truncate">
|
|
||||||
{formatModelStatusLabel(currentModel, {
|
|
||||||
fastMode: currentFastMode,
|
|
||||||
reasoningEffort: currentReasoningEffort
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
...(modelMenuContent
|
|
||||||
? {
|
|
||||||
menuAlign: 'end' as const,
|
|
||||||
menuClassName: 'w-64',
|
|
||||||
menuContent: modelMenuContent,
|
|
||||||
title: currentProvider
|
|
||||||
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
|
|
||||||
: copy.switchModel,
|
|
||||||
variant: 'menu' as const
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
onSelect: () => setModelPickerOpen(true),
|
|
||||||
title: currentProvider
|
|
||||||
? copy.providerModelTitle(currentProvider, currentModel || copy.noModel)
|
|
||||||
: copy.openModelPicker,
|
|
||||||
variant: 'action' as const
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
|
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
|
||||||
hidden: !chatOpen,
|
hidden: !chatOpen,
|
||||||
@ -465,11 +420,6 @@ export function useStatusbarItems({
|
|||||||
contextBar,
|
contextBar,
|
||||||
contextUsage,
|
contextUsage,
|
||||||
copy,
|
copy,
|
||||||
currentFastMode,
|
|
||||||
currentModel,
|
|
||||||
currentProvider,
|
|
||||||
currentReasoningEffort,
|
|
||||||
modelMenuContent,
|
|
||||||
sessionStartedAt,
|
sessionStartedAt,
|
||||||
showYoloToggle,
|
showYoloToggle,
|
||||||
terminalTakeover,
|
terminalTakeover,
|
||||||
|
|||||||
84
apps/desktop/src/app/shell/model-edit-submenu.test.tsx
Normal file
84
apps/desktop/src/app/shell/model-edit-submenu.test.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu'
|
||||||
|
import { $modelPresets, getModelPreset } from '@/store/model-presets'
|
||||||
|
import { $activeSessionId } from '@/store/session'
|
||||||
|
|
||||||
|
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
|
||||||
|
|
||||||
|
// Radix calls these on open; jsdom doesn't implement them.
|
||||||
|
beforeAll(() => {
|
||||||
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
|
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
||||||
|
Element.prototype.releasePointerCapture = vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
$modelPresets.set({})
|
||||||
|
$activeSessionId.set(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Render the submenu inside an open menu/sub so its content (switches) mounts.
|
||||||
|
function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise<unknown> }) {
|
||||||
|
return render(
|
||||||
|
<DropdownMenu open>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuSub open>
|
||||||
|
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
|
||||||
|
<ModelEditSubmenu
|
||||||
|
effort="medium"
|
||||||
|
fastControl={opts.fastControl}
|
||||||
|
isActive
|
||||||
|
model="m1"
|
||||||
|
onSelectModel={vi.fn()}
|
||||||
|
provider="p1"
|
||||||
|
reasoning={opts.reasoning}
|
||||||
|
requestGateway={opts.requestGateway as never}
|
||||||
|
/>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: editing the active row before a live session exists must stay
|
||||||
|
// preset-only — the gateway's config.set falls back to global config when no
|
||||||
|
// session matches, so it must not be called. (Caught in the second review.)
|
||||||
|
describe('ModelEditSubmenu no-session guard', () => {
|
||||||
|
it('param fast: records the preset but skips the gateway without a session', () => {
|
||||||
|
const requestGateway = vi.fn().mockResolvedValue({})
|
||||||
|
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('switch'))
|
||||||
|
|
||||||
|
expect(getModelPreset('p1', 'm1').fast).toBe(true)
|
||||||
|
expect(requestGateway).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reasoning: records the preset but skips the gateway without a session', () => {
|
||||||
|
const requestGateway = vi.fn().mockResolvedValue({})
|
||||||
|
renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway })
|
||||||
|
|
||||||
|
// Thinking starts on (medium); toggling it off routes through patchReasoning.
|
||||||
|
fireEvent.click(screen.getByRole('switch'))
|
||||||
|
|
||||||
|
expect(getModelPreset('p1', 'm1').effort).toBe('none')
|
||||||
|
expect(requestGateway).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('param fast: pushes to the gateway once a session is active', async () => {
|
||||||
|
const requestGateway = vi.fn().mockResolvedValue({})
|
||||||
|
$activeSessionId.set('sess1')
|
||||||
|
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('switch'))
|
||||||
|
|
||||||
|
expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -12,13 +12,9 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
|
import { setModelPreset } from '@/store/model-presets'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import {
|
import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session'
|
||||||
$activeSessionId,
|
|
||||||
$currentReasoningEffort,
|
|
||||||
setCurrentFastMode,
|
|
||||||
setCurrentReasoningEffort
|
|
||||||
} from '@/store/session'
|
|
||||||
|
|
||||||
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
|
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
|
||||||
// by the Thinking toggle, not the radio.
|
// by the Thinking toggle, not the radio.
|
||||||
@ -76,96 +72,104 @@ export function resolveFastControl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ModelEditSubmenuProps {
|
interface ModelEditSubmenuProps {
|
||||||
|
/** This row's effective reasoning effort (live for the active model, else its
|
||||||
|
* preset) — the submenu shows and edits from this, never the raw session. */
|
||||||
|
effort: string
|
||||||
/** How fast mode is offered for this model (param toggle vs. variant swap). */
|
/** How fast mode is offered for this model (param toggle vs. variant swap). */
|
||||||
fastControl: FastControl
|
fastControl: FastControl
|
||||||
/** Whether this row's model is the active one. */
|
/** Whether this row's model is the active one. */
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
/** Switch to this model (resolves false on failure). Awaited before applying
|
/** This row's model id — edits persist as its global preset. */
|
||||||
* edits when not active so a failed switch doesn't write to the old model. */
|
model: string
|
||||||
onActivate: () => Promise<boolean> | void
|
|
||||||
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
|
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
|
||||||
onSelectModel: (model: string) => Promise<boolean> | void
|
onSelectModel: (model: string) => Promise<boolean> | void
|
||||||
|
/** This row's provider slug — edits persist as its global preset. */
|
||||||
|
provider: string
|
||||||
/** Whether this model supports reasoning effort. */
|
/** Whether this model supports reasoning effort. */
|
||||||
reasoning: boolean
|
reasoning: boolean
|
||||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelEditSubmenu({
|
export function ModelEditSubmenu({
|
||||||
|
effort,
|
||||||
fastControl,
|
fastControl,
|
||||||
isActive,
|
isActive,
|
||||||
onActivate,
|
model,
|
||||||
onSelectModel,
|
onSelectModel,
|
||||||
|
provider,
|
||||||
reasoning,
|
reasoning,
|
||||||
requestGateway
|
requestGateway
|
||||||
}: ModelEditSubmenuProps) {
|
}: ModelEditSubmenuProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const copy = t.shell.modelOptions
|
const copy = t.shell.modelOptions
|
||||||
// Reactive session state comes straight from the stores rather than being
|
|
||||||
// drilled through the panel, so editing it re-renders only this submenu.
|
|
||||||
const activeSessionId = useStore($activeSessionId)
|
const activeSessionId = useStore($activeSessionId)
|
||||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
|
||||||
|
|
||||||
const effort = normalizeEffort(currentReasoningEffort)
|
const effortValue = normalizeEffort(effort)
|
||||||
const thinkingOn = isThinkingEnabled(currentReasoningEffort)
|
const thinkingOn = isThinkingEnabled(effort)
|
||||||
|
|
||||||
// Reasoning/fast are session-scoped (they apply to the active model), so
|
// Editing always records the model's global preset; the active model also gets
|
||||||
// editing a non-active model first switches to it. Returns false if the
|
// it pushed onto the live session. Non-active edits stay preset-only — they do
|
||||||
// switch failed, so callers skip applying to the wrong (previous) model.
|
// not switch you to that model.
|
||||||
const ensureActive = async (): Promise<boolean> => {
|
const patchReasoning = async (next: string) => {
|
||||||
if (isActive) {
|
setModelPreset(provider, model, { effort: next })
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await onActivate()) !== false
|
|
||||||
}
|
|
||||||
|
|
||||||
const patchReasoning = async (next: string, rollback: string) => {
|
|
||||||
setCurrentReasoningEffort(next)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!(await ensureActive())) {
|
|
||||||
setCurrentReasoningEffort(rollback)
|
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await requestGateway('config.set', {
|
setCurrentReasoningEffort(next)
|
||||||
key: 'reasoning',
|
|
||||||
session_id: activeSessionId ?? '',
|
// Preset-only without a session: `isActive` holds for the global/default
|
||||||
value: next
|
// row pre-session, and the gateway's `config.set` falls back to global
|
||||||
})
|
// config when none matches — so don't reach it (preset + optimistic store
|
||||||
|
// are the whole effect). Same guard in applyModelPreset / toggleFast.
|
||||||
|
if (!activeSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCurrentReasoningEffort(rollback)
|
setCurrentReasoningEffort(effort)
|
||||||
|
setModelPreset(provider, model, { effort })
|
||||||
notifyError(err, copy.updateFailed)
|
notifyError(err, copy.updateFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleFast = (enabled: boolean) => {
|
const toggleFast = (enabled: boolean) => {
|
||||||
if (fastControl.kind === 'variant') {
|
if (fastControl.kind === 'variant') {
|
||||||
// Fast is a separate model id — swap to it (or back to the base).
|
// Fast is a separate model id. Record the choice on the base model's
|
||||||
|
// preset (selectFamily picks the `-fast` sibling later when set), and
|
||||||
|
// only swap models now if this is the active row — inactive edits must
|
||||||
|
// stay preset-only, same as the param path below.
|
||||||
|
setModelPreset(provider, fastControl.baseId, { fast: enabled })
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
|
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fastControl.kind === 'param') {
|
if (fastControl.kind === 'param') {
|
||||||
setCurrentFastMode(enabled)
|
setModelPreset(provider, model, { fast: enabled })
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
if (!(await ensureActive())) {
|
|
||||||
setCurrentFastMode(!enabled)
|
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await requestGateway('config.set', {
|
setCurrentFastMode(enabled)
|
||||||
key: 'fast',
|
|
||||||
session_id: activeSessionId ?? '',
|
// Preset-only without a session (see patchReasoning).
|
||||||
value: enabled ? 'fast' : 'normal'
|
if (!activeSessionId) {
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await requestGateway('config.set', { key: 'fast', session_id: activeSessionId, value: enabled ? 'fast' : 'normal' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCurrentFastMode(!enabled)
|
setCurrentFastMode(!enabled)
|
||||||
|
setModelPreset(provider, model, { fast: !enabled })
|
||||||
notifyError(err, copy.fastFailed)
|
notifyError(err, copy.fastFailed)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@ -188,9 +192,7 @@ export function ModelEditSubmenu({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={thinkingOn}
|
checked={thinkingOn}
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
onCheckedChange={checked =>
|
onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')}
|
||||||
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
|
|
||||||
}
|
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -205,10 +207,7 @@ export function ModelEditSubmenu({
|
|||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator className="mx-0" />
|
<DropdownMenuSeparator className="mx-0" />
|
||||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
|
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
|
||||||
<DropdownMenuRadioGroup
|
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
|
||||||
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
|
|
||||||
value={effort}
|
|
||||||
>
|
|
||||||
{EFFORT_OPTIONS.map(option => (
|
{EFFORT_OPTIONS.map(option => (
|
||||||
<DropdownMenuRadioItem
|
<DropdownMenuRadioItem
|
||||||
className={dropdownMenuRow}
|
className={dropdownMenuRow}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useMemo, useState } from 'react'
|
import { createContext, useContext, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import {
|
import {
|
||||||
@ -18,8 +18,9 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import type { HermesGateway } from '@/hermes'
|
import type { HermesGateway } from '@/hermes'
|
||||||
import { getGlobalModelOptions, getMoaModels } from '@/hermes'
|
import { getGlobalModelOptions, getMoaModels } from '@/hermes'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
|
import { currentPickerSelection, displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
|
||||||
import {
|
import {
|
||||||
$visibleModels,
|
$visibleModels,
|
||||||
collapseModelFamilies,
|
collapseModelFamilies,
|
||||||
@ -40,9 +41,14 @@ import type { MoaConfigResponse, ModelOptionProvider, ModelOptionsResponse } fro
|
|||||||
|
|
||||||
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
|
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
|
||||||
|
|
||||||
|
// Lets the host dropdown (model-pill) hand the panel a way to dismiss itself so
|
||||||
|
// clicking a model row commits + closes, while the hover-revealed edit submenu
|
||||||
|
// (reasoning/fast) stays open to play with (its items preventDefault on select).
|
||||||
|
export const ModelMenuCloseContext = createContext<() => void>(() => {})
|
||||||
|
|
||||||
interface ModelMenuPanelProps {
|
interface ModelMenuPanelProps {
|
||||||
gateway?: HermesGateway
|
gateway?: HermesGateway
|
||||||
onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise<boolean> | void
|
onSelectModel: (selection: { model: string; provider: string }) => Promise<boolean> | void
|
||||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,6 +60,7 @@ interface ProviderGroup {
|
|||||||
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
|
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const copy = t.shell.modelMenu
|
const copy = t.shell.modelMenu
|
||||||
|
const closeMenu = useContext(ModelMenuCloseContext)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [activeMoaPreset, setActiveMoaPreset] = useState('')
|
const [activeMoaPreset, setActiveMoaPreset] = useState('')
|
||||||
// Reactive session state is read from the stores here (not drilled in), so
|
// Reactive session state is read from the stores here (not drilled in), so
|
||||||
@ -64,6 +71,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||||||
const currentModel = useStore($currentModel)
|
const currentModel = useStore($currentModel)
|
||||||
const currentProvider = useStore($currentProvider)
|
const currentProvider = useStore($currentProvider)
|
||||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||||
|
const modelPresets = useStore($modelPresets)
|
||||||
const visibleModels = useStore($visibleModels)
|
const visibleModels = useStore($visibleModels)
|
||||||
|
|
||||||
const modelOptions = useQuery({
|
const modelOptions = useQuery({
|
||||||
@ -82,8 +90,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||||||
queryFn: (): Promise<MoaConfigResponse> => getMoaModels()
|
queryFn: (): Promise<MoaConfigResponse> => getMoaModels()
|
||||||
})
|
})
|
||||||
|
|
||||||
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
|
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
|
||||||
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
|
!!activeSessionId,
|
||||||
|
{ model: currentModel, provider: currentProvider },
|
||||||
|
modelOptions.data
|
||||||
|
)
|
||||||
|
|
||||||
const loading = modelOptions.isPending && !modelOptions.data
|
const loading = modelOptions.isPending && !modelOptions.data
|
||||||
|
|
||||||
const error = modelOptions.error
|
const error = modelOptions.error
|
||||||
@ -99,8 +111,35 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||||||
[visibleModels, providers]
|
[visibleModels, providers]
|
||||||
)
|
)
|
||||||
|
|
||||||
const switchTo = (model: string, provider: string) =>
|
// The composer picker never persists the profile default. With a session it
|
||||||
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
|
// scopes the switch to that session; with none it's UI state shipped on the
|
||||||
|
// next session.create (see selectModel). The default lives in Settings → Model.
|
||||||
|
const switchTo = (model: string, provider: string) => onSelectModel({ model, provider })
|
||||||
|
|
||||||
|
// Selecting a model row restores that model's remembered preset onto the
|
||||||
|
// session (effort/fast), gated by capability. Unset → Hermes defaults.
|
||||||
|
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
|
||||||
|
const caps = provider.capabilities?.[family.id]
|
||||||
|
const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {}
|
||||||
|
|
||||||
|
// Variant-fast models (no speed param) express "fast" as a separate `-fast`
|
||||||
|
// id, so honor the saved preset by selecting that sibling. Param-fast is
|
||||||
|
// applied via applyModelPreset below instead.
|
||||||
|
const variantFast = !(caps?.fast ?? false) && !!family.fastId
|
||||||
|
const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
|
||||||
|
|
||||||
|
if ((await switchTo(targetId, provider.slug)) === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyModelPreset(
|
||||||
|
{
|
||||||
|
effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined,
|
||||||
|
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
|
||||||
|
},
|
||||||
|
{ failMessage: t.shell.modelOptions.updateFailed, request: requestGateway, sessionId: activeSessionId }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const toggleMoaPreset = async (preset: string) => {
|
const toggleMoaPreset = async (preset: string) => {
|
||||||
if (!activeSessionId) {
|
if (!activeSessionId) {
|
||||||
@ -168,37 +207,42 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||||||
// -fast variant carries the same param support as its base.
|
// -fast variant carries the same param support as its base.
|
||||||
const caps = group.provider.capabilities?.[family.id]
|
const caps = group.provider.capabilities?.[family.id]
|
||||||
|
|
||||||
// Single source of truth for the active row's fast state — keeps
|
// Effective settings for this row: live session state when it's
|
||||||
// the row label in lock-step with the submenu's Fast toggle and
|
// the active model, otherwise its remembered preset (Hermes
|
||||||
// handles the standalone `-fast` id case.
|
// defaults when unset). Row label AND submenu read from these so
|
||||||
|
// they never disagree.
|
||||||
|
const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {}
|
||||||
|
const effEffort = isCurrent ? currentReasoningEffort : preset.effort ?? ''
|
||||||
|
const effFast = isCurrent ? currentFastMode : preset.fast ?? false
|
||||||
|
|
||||||
const fastControl = resolveFastControl(
|
const fastControl = resolveFastControl(
|
||||||
activeId ?? family.id,
|
activeId ?? family.id,
|
||||||
group.provider.models ?? [],
|
group.provider.models ?? [],
|
||||||
caps?.fast ?? false,
|
caps?.fast ?? false,
|
||||||
currentFastMode
|
effFast
|
||||||
)
|
)
|
||||||
|
|
||||||
// Grayed text is live session state only. Do not label inactive
|
const meta = [
|
||||||
// rows as "Fast" just because they have a fast-capable sibling:
|
|
||||||
// that makes an off Fast toggle look like it is already on.
|
|
||||||
const meta = isCurrent
|
|
||||||
? [
|
|
||||||
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
|
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
|
||||||
reasoningEffortLabel(currentReasoningEffort) || copy.medium
|
(caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
: ''
|
|
||||||
|
|
||||||
// Every row is a hover-Edit submenu trigger. Activating it
|
// Every row is a hover-Edit submenu trigger. Activating it
|
||||||
// (pointer or keyboard) switches to the family's base model;
|
// (pointer or keyboard) switches to the family's base model and
|
||||||
// the Fast toggle inside swaps to the -fast sibling (or flips
|
// restores its preset; the Fast toggle inside swaps to the -fast
|
||||||
// the speed param). The sub-trigger has no `onSelect`, so wire
|
// sibling (or flips the speed param). The sub-trigger has no
|
||||||
// both click and Enter/Space for keyboard parity.
|
// `onSelect`, so wire both click and Enter/Space for keyboard parity.
|
||||||
|
// Clicking the row commits the model and closes the picker; the
|
||||||
|
// edit submenu (reasoning/fast) is reached by HOVER, so you can
|
||||||
|
// still tweak those without the click dismissing everything.
|
||||||
const activate = () => {
|
const activate = () => {
|
||||||
if (!isCurrent) {
|
if (!isCurrent) {
|
||||||
void switchTo(family.id, group.provider.slug)
|
void selectFamily(family, group.provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closeMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -220,10 +264,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||||||
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
|
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<ModelEditSubmenu
|
<ModelEditSubmenu
|
||||||
|
effort={effEffort}
|
||||||
fastControl={fastControl}
|
fastControl={fastControl}
|
||||||
isActive={isCurrent}
|
isActive={isCurrent}
|
||||||
onActivate={() => switchTo(family.id, group.provider.slug)}
|
model={family.id}
|
||||||
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
|
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
|
||||||
|
provider={group.provider.slug}
|
||||||
reasoning={caps?.reasoning ?? true}
|
reasoning={caps?.reasoning ?? true}
|
||||||
requestGateway={requestGateway}
|
requestGateway={requestGateway}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -46,6 +46,12 @@ export interface SlashExecResponse {
|
|||||||
warning?: string
|
warning?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrowserManageResponse {
|
||||||
|
connected?: boolean
|
||||||
|
url?: string
|
||||||
|
messages?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionSteerResponse {
|
export interface SessionSteerResponse {
|
||||||
// 'queued' == accepted into the live turn's steer slot (injected at the next
|
// 'queued' == accepted into the live turn's steer slot (injected at the next
|
||||||
// tool-result boundary); 'rejected' == no live tool window, caller queues.
|
// tool-result boundary); 'rejected' == no live tool window, caller queues.
|
||||||
|
|||||||
@ -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
|
return tail || id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeEmbeddedImages(text: string) {
|
||||||
|
try {
|
||||||
|
return extractEmbeddedImages(text)
|
||||||
|
} catch {
|
||||||
|
return { cleanedText: text, images: [] as string[] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDirectiveSegments(text: string): Unstable_DirectiveSegment[] {
|
||||||
|
try {
|
||||||
|
return [...hermesDirectiveFormatter.parse(text)]
|
||||||
|
} catch {
|
||||||
|
return [{ kind: 'text', text }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
|
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
|
||||||
* inline chips. Embedded MEDIA images render below as a thumbnail row.
|
* inline chips. Embedded MEDIA images render below as a thumbnail row.
|
||||||
*/
|
*/
|
||||||
export function DirectiveContent({ text }: { text: string }) {
|
export function DirectiveContent({ text }: { text: string }) {
|
||||||
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
|
const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
|
||||||
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
|
const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="whitespace-pre-line" data-slot="aui_directive-text">
|
<span className="whitespace-pre-line" data-slot="aui_directive-text">
|
||||||
|
|||||||
@ -201,4 +201,13 @@ describe('preprocessMarkdown', () => {
|
|||||||
|
|
||||||
expect(output).toContain('<https://example.com/a_b/c~d/page>')
|
expect(output).toContain('<https://example.com/a_b/c~d/page>')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('handles a fenced block larger than V8 spread-argument limit', () => {
|
||||||
|
// A single huge code block (e.g. a logged minified bundle) used to throw
|
||||||
|
// `RangeError: Maximum call stack size exceeded` via `out.push(...lines)`.
|
||||||
|
const body = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join('\n')
|
||||||
|
const input = `\`\`\`js\n${body}\n\`\`\``
|
||||||
|
|
||||||
|
expect(() => preprocessMarkdown(input)).not.toThrow()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -19,8 +19,9 @@ import {
|
|||||||
useState
|
useState
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|
||||||
|
import { ExpandableBlock } from '@/components/chat/expandable-block'
|
||||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||||
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
|
import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
|
||||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||||
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
|
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
|
||||||
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
|
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
|
||||||
@ -57,7 +58,11 @@ const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
|
|||||||
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
|
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
|
||||||
// module-scope so the prop identity is stable across renders.
|
// module-scope so the prop identity is stable across renders.
|
||||||
function preprocessWithTailRepair(text: string): string {
|
function preprocessWithTailRepair(text: string): string {
|
||||||
|
try {
|
||||||
return tailBoundedRemend(preprocessMarkdown(text))
|
return tailBoundedRemend(preprocessMarkdown(text))
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
|
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
|
||||||
@ -453,8 +458,35 @@ const MARKDOWN_CONTAINER_CLASS_NAME = cn(
|
|||||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
|
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const MAX_MARKDOWN_CHARS = 200_000
|
||||||
|
|
||||||
|
function HugeTextFallback({ containerClassName, text }: { containerClassName?: string; text: string }) {
|
||||||
|
const chunks = useMemo(() => chunkByLines(text, 200), [text])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'aui-md w-full max-w-none overflow-hidden rounded-[0.625rem] border border-border font-mono text-[0.7rem] leading-relaxed text-foreground/90',
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ExpandableBlock className="p-2">
|
||||||
|
{chunks.map((chunk, index) => (
|
||||||
|
<div
|
||||||
|
className="[content-visibility:auto]"
|
||||||
|
key={index}
|
||||||
|
style={{ containIntrinsicSize: `auto ${chunk.lines * 16}px` }}
|
||||||
|
>
|
||||||
|
{chunk.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ExpandableBlock>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
|
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
|
||||||
const { status } = useMessagePartText()
|
const { status, text } = useMessagePartText()
|
||||||
const isStreaming = status.type === 'running'
|
const isStreaming = status.type === 'running'
|
||||||
|
|
||||||
// Keep code parsing enabled while streaming so incomplete fenced blocks still
|
// Keep code parsing enabled while streaming so incomplete fenced blocks still
|
||||||
@ -484,19 +516,37 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
|
|||||||
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
|
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
|
||||||
),
|
),
|
||||||
a: MarkdownLink,
|
a: MarkdownLink,
|
||||||
|
// Inline code must not vote when an ancestor resolves `dir="auto"`
|
||||||
|
// (HTML's algorithm skips descendants that carry their own dir),
|
||||||
|
// mirroring the CSS isolate that already keeps it out of the
|
||||||
|
// plaintext scan. Fenced code never reaches this override; it goes
|
||||||
|
// through the code plugin's CodeCard path.
|
||||||
|
inlineCode: ({ className, ...props }: ComponentProps<'code'>) => (
|
||||||
|
<code className={className} dir="ltr" {...props} />
|
||||||
|
),
|
||||||
// `---` as quiet spacing, not a heavy full-width rule.
|
// `---` as quiet spacing, not a heavy full-width rule.
|
||||||
hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
|
hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
|
||||||
|
// Lists and blockquotes have chrome that sits *beside* the text
|
||||||
|
// (markers, the quote border), and that side is driven by the CSS
|
||||||
|
// `direction` of the box, which `unicode-bidi: plaintext` never
|
||||||
|
// touches — an RTL list otherwise renders its numbers stranded at
|
||||||
|
// the far left. `dir="auto"` lets the browser resolve the box
|
||||||
|
// direction from content; the plaintext rules in styles.css keep
|
||||||
|
// owning per-line text direction. Inline code carries `dir="ltr"`
|
||||||
|
// (see the `code` override) so it doesn't vote here either, same
|
||||||
|
// contract as the CSS isolate.
|
||||||
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
|
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
|
||||||
<blockquote
|
<blockquote
|
||||||
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
|
className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}
|
||||||
|
dir="auto"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
|
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
|
||||||
<ul className={cn('my-1 gap-0', className)} {...props} />
|
<ul className={cn('my-1 gap-0', className)} dir="auto" {...props} />
|
||||||
),
|
),
|
||||||
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
|
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
|
||||||
<ol className={cn('my-1 gap-0', className)} {...props} />
|
<ol className={cn('my-1 gap-0', className)} dir="auto" {...props} />
|
||||||
),
|
),
|
||||||
li: ({ className, ...props }: ComponentProps<'li'>) => (
|
li: ({ className, ...props }: ComponentProps<'li'>) => (
|
||||||
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
|
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
|
||||||
@ -533,6 +583,10 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
|
|||||||
[isStreaming]
|
[isStreaming]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (text.length > MAX_MARKDOWN_CHARS) {
|
||||||
|
return <HugeTextFallback containerClassName={containerClassName} text={text} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StreamdownTextPrimitive
|
<StreamdownTextPrimitive
|
||||||
components={components}
|
components={components}
|
||||||
|
|||||||
@ -378,6 +378,20 @@ function IntroHarness() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DismissibleErrorHarness({ onDismissError }: { onDismissError: (messageId: string) => void }) {
|
||||||
|
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||||
|
messages: [assistantErrorMessage('OpenRouter rejected the request (403).')],
|
||||||
|
isRunning: false,
|
||||||
|
onNew: async () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
<Thread onDismissError={onDismissError} />
|
||||||
|
</AssistantRuntimeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
describe('assistant-ui streaming renderer', () => {
|
describe('assistant-ui streaming renderer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resizeObservers.clear()
|
resizeObservers.clear()
|
||||||
@ -421,6 +435,23 @@ describe('assistant-ui streaming renderer', () => {
|
|||||||
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
|
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('omits the dismiss control when no onDismissError handler is supplied', () => {
|
||||||
|
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: 'Dismiss error' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invokes onDismissError with the errored message id when the dismiss control is clicked', () => {
|
||||||
|
const onDismissError = vi.fn()
|
||||||
|
render(<DismissibleErrorHarness onDismissError={onDismissError} />)
|
||||||
|
|
||||||
|
const dismiss = screen.getByRole('button', { name: 'Dismiss error' })
|
||||||
|
fireEvent.click(dismiss)
|
||||||
|
|
||||||
|
expect(onDismissError).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onDismissError).toHaveBeenCalledWith('assistant-error-1')
|
||||||
|
})
|
||||||
|
|
||||||
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
|
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
|
||||||
// by the use-stick-to-bottom library and covered by its own test suite. We
|
// by the use-stick-to-bottom library and covered by its own test suite. We
|
||||||
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
|
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import {
|
|||||||
resetThreadScroll,
|
resetThreadScroll,
|
||||||
setThreadAtBottom
|
setThreadAtBottom
|
||||||
} from '@/store/thread-scroll'
|
} from '@/store/thread-scroll'
|
||||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
import { isSecondaryWindow } from '@/store/windows'
|
||||||
|
|
||||||
import { MessageRenderBoundary } from './message-render-boundary'
|
import { MessageRenderBoundary } from './message-render-boundary'
|
||||||
|
|
||||||
@ -134,13 +134,20 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||||||
const hiddenCount = firstVisible
|
const hiddenCount = firstVisible
|
||||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||||
const restoreFromBottomRef = useRef<number | null>(null)
|
const restoreFromBottomRef = useRef<number | null>(null)
|
||||||
const newSessionWindow = isNewSessionWindow()
|
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||||
const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)'
|
// hide the titlebar tool cluster + session header, but the OS traffic lights
|
||||||
const threadContentTopPad = newSessionWindow
|
// still sit in the top-left, so reserve the titlebar gap above the transcript.
|
||||||
|
const secondaryWindow = isSecondaryWindow()
|
||||||
|
// NB: CSS calc() requires whitespace around the +/- operator. This string is
|
||||||
|
// assigned verbatim to the --sticky-human-top inline style below (it does not
|
||||||
|
// go through Tailwind, which would auto-space it), so the spaces are load-
|
||||||
|
// bearing — without them the declaration is invalid, gets dropped, and the
|
||||||
|
// sticky user bubble falls back to its ~4px default and slides under the OS
|
||||||
|
// traffic lights.
|
||||||
|
const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)'
|
||||||
|
const threadContentTopPad = secondaryWindow
|
||||||
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
|
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
|
||||||
: isSecondaryWindow()
|
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
|
||||||
? 'pt-6'
|
|
||||||
: 'pt-[calc(var(--titlebar-height)+1.5rem)]'
|
|
||||||
|
|
||||||
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
|
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
|
||||||
useEffect(() => () => resetThreadScroll(), [])
|
useEffect(() => () => resetThreadScroll(), [])
|
||||||
@ -247,10 +254,21 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
|
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
|
||||||
...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {})
|
...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {})
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{secondaryWindow && (
|
||||||
|
// Secondary windows hide the titlebar chrome, so the scroller runs to
|
||||||
|
// the window's top edge and streamed text slides up under the OS
|
||||||
|
// traffic lights. Content padding alone scrolls away with the text — a
|
||||||
|
// fixed opaque strip (the titlebar's drag region) masks anything behind
|
||||||
|
// it and keeps the window draggable, matching the main window's header.
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-x-0 top-0 z-10 h-(--titlebar-height) bg-background [-webkit-app-region:drag]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||||
data-following={isAtBottom ? 'true' : 'false'}
|
data-following={isAtBottom ? 'true' : 'false'}
|
||||||
|
|||||||
@ -91,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti
|
|||||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||||
import { LinkifiedText } from '@/lib/external-link'
|
import { LinkifiedText } from '@/lib/external-link'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
|
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
|
||||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@ -169,6 +169,7 @@ export const Thread: FC<{
|
|||||||
loading?: ThreadLoadingState
|
loading?: ThreadLoadingState
|
||||||
onBranchInNewChat?: (messageId: string) => void
|
onBranchInNewChat?: (messageId: string) => void
|
||||||
onCancel?: () => Promise<void> | void
|
onCancel?: () => Promise<void> | void
|
||||||
|
onDismissError?: (messageId: string) => void
|
||||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||||
sessionId?: string | null
|
sessionId?: string | null
|
||||||
sessionKey?: string | null
|
sessionKey?: string | null
|
||||||
@ -180,18 +181,19 @@ export const Thread: FC<{
|
|||||||
loading,
|
loading,
|
||||||
onBranchInNewChat,
|
onBranchInNewChat,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onDismissError,
|
||||||
onRestoreToMessage,
|
onRestoreToMessage,
|
||||||
sessionId = null,
|
sessionId = null,
|
||||||
sessionKey
|
sessionKey
|
||||||
}) => {
|
}) => {
|
||||||
const messageComponents = useMemo(
|
const messageComponents = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />,
|
||||||
SystemMessage,
|
SystemMessage,
|
||||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||||
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
|
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
|
||||||
}),
|
}),
|
||||||
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId]
|
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const emptyPlaceholder = intro ? (
|
const emptyPlaceholder = intro ? (
|
||||||
@ -245,9 +247,13 @@ const CenteredThreadSpinner: FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
const AssistantMessage: FC<{
|
||||||
|
onBranchInNewChat?: (messageId: string) => void
|
||||||
|
onDismissError?: (messageId: string) => void
|
||||||
|
}> = ({ onBranchInNewChat, onDismissError }) => {
|
||||||
const messageId = useAuiState(s => s.message.id)
|
const messageId = useAuiState(s => s.message.id)
|
||||||
const messageRuntime = useMessageRuntime()
|
const messageRuntime = useMessageRuntime()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
// PERF: this component must NOT subscribe to the streaming text. Every
|
// PERF: this component must NOT subscribe to the streaming text. Every
|
||||||
// selector here returns a value that stays referentially stable across
|
// selector here returns a value that stays referentially stable across
|
||||||
@ -306,10 +312,20 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
|||||||
)}
|
)}
|
||||||
<MessagePrimitive.Error>
|
<MessagePrimitive.Error>
|
||||||
<ErrorPrimitive.Root
|
<ErrorPrimitive.Root
|
||||||
className="mt-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
|
className="mt-1.5 flex items-start gap-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
<ErrorPrimitive.Message />
|
<ErrorPrimitive.Message className="min-w-0 flex-1" />
|
||||||
|
{onDismissError && (
|
||||||
|
<TooltipIconButton
|
||||||
|
className="-my-0.5 shrink-0 text-current opacity-70 hover:opacity-100"
|
||||||
|
onClick={() => onDismissError(messageId)}
|
||||||
|
side="top"
|
||||||
|
tooltip={t.assistant.thread.dismissError}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</TooltipIconButton>
|
||||||
|
)}
|
||||||
</ErrorPrimitive.Root>
|
</ErrorPrimitive.Root>
|
||||||
</MessagePrimitive.Error>
|
</MessagePrimitive.Error>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -66,7 +66,7 @@ function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-1.5 font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
|
'font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
data-slot="code-card-body"
|
data-slot="code-card-body"
|
||||||
|
|||||||
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'
|
'use client'
|
||||||
|
|
||||||
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
|
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
|
||||||
import type { FC } from 'react'
|
import { type FC, useMemo } from 'react'
|
||||||
import ShikiHighlighter from 'react-shiki'
|
import ShikiHighlighter from 'react-shiki'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
CodeCardSubtitle,
|
CodeCardSubtitle,
|
||||||
CodeCardTitle
|
CodeCardTitle
|
||||||
} from '@/components/chat/code-card'
|
} from '@/components/chat/code-card'
|
||||||
|
import { ExpandableBlock } from '@/components/chat/expandable-block'
|
||||||
import { CopyButton } from '@/components/ui/copy-button'
|
import { CopyButton } from '@/components/ui/copy-button'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||||
@ -43,6 +44,74 @@ const SHIKI_COLOR_REPLACEMENTS: Record<string, Record<string, string>> = {
|
|||||||
'github-light-default': { '#6e7781': '#57606a' }
|
'github-light-default': { '#6e7781': '#57606a' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_HIGHLIGHT_CHARS = 150_000
|
||||||
|
const MAX_HIGHLIGHT_LINES = 3_000
|
||||||
|
const CHUNK_LINES = 200
|
||||||
|
const EST_LINE_PX = 16
|
||||||
|
|
||||||
|
export function exceedsHighlightBudget(code: string): boolean {
|
||||||
|
if (code.length > MAX_HIGHLIGHT_CHARS) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines = 1
|
||||||
|
let idx = code.indexOf('\n')
|
||||||
|
|
||||||
|
while (idx !== -1) {
|
||||||
|
if ((lines += 1) > MAX_HIGHLIGHT_LINES) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
idx = code.indexOf('\n', idx + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeChunk {
|
||||||
|
text: string
|
||||||
|
lines: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chunkByLines(code: string, perChunk: number): CodeChunk[] {
|
||||||
|
const lines = code.split('\n')
|
||||||
|
|
||||||
|
if (lines.length <= perChunk) {
|
||||||
|
return [{ text: code, lines: lines.length }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: CodeChunk[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i += perChunk) {
|
||||||
|
const slice = lines.slice(i, i + perChunk)
|
||||||
|
chunks.push({ text: slice.join('\n'), lines: slice.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlainCode: FC<{ code: string }> = ({ code }) => {
|
||||||
|
const chunks = useMemo(() => chunkByLines(code, CHUNK_LINES), [code])
|
||||||
|
|
||||||
|
if (chunks.length === 1) {
|
||||||
|
return <code className="block whitespace-pre">{code}</code>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{chunks.map((chunk, index) => (
|
||||||
|
<code
|
||||||
|
className="block whitespace-pre [content-visibility:auto]"
|
||||||
|
key={index}
|
||||||
|
style={{ containIntrinsicSize: `auto ${chunk.lines * EST_LINE_PX}px` }}
|
||||||
|
>
|
||||||
|
{chunk.text}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
||||||
components: { Pre },
|
components: { Pre },
|
||||||
language,
|
language,
|
||||||
@ -64,6 +133,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||||||
|
|
||||||
const cleanLanguage = sanitizeLanguageTag(language || '')
|
const cleanLanguage = sanitizeLanguageTag(language || '')
|
||||||
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
||||||
|
const plain = defer || exceedsHighlightBudget(trimmed)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodeCard data-streaming={defer ? 'true' : undefined}>
|
<CodeCard data-streaming={defer ? 'true' : undefined}>
|
||||||
@ -83,9 +153,10 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||||||
/>
|
/>
|
||||||
</CodeCardHeader>
|
</CodeCardHeader>
|
||||||
<CodeCardBody>
|
<CodeCardBody>
|
||||||
|
<ExpandableBlock>
|
||||||
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
|
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
|
||||||
{defer ? (
|
{plain ? (
|
||||||
<code className="block whitespace-pre">{trimmed}</code>
|
<PlainCode code={trimmed} />
|
||||||
) : (
|
) : (
|
||||||
<ShikiHighlighter
|
<ShikiHighlighter
|
||||||
addDefaultStyles={false}
|
addDefaultStyles={false}
|
||||||
@ -101,6 +172,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||||||
</ShikiHighlighter>
|
</ShikiHighlighter>
|
||||||
)}
|
)}
|
||||||
</Pre>
|
</Pre>
|
||||||
|
</ExpandableBlock>
|
||||||
</CodeCardBody>
|
</CodeCardBody>
|
||||||
</CodeCard>
|
</CodeCard>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
|
import { currentPickerSelection } from '@/lib/model-status-label'
|
||||||
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
|
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
|
||||||
|
|
||||||
import type { HermesGateway } from '../hermes'
|
import type { HermesGateway } from '../hermes'
|
||||||
@ -11,7 +12,6 @@ import { startManualOnboarding } from '../store/onboarding'
|
|||||||
|
|
||||||
import { InlineNotice } from './notifications'
|
import { InlineNotice } from './notifications'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
import { Checkbox } from './ui/checkbox'
|
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
|
||||||
import { Skeleton } from './ui/skeleton'
|
import { Skeleton } from './ui/skeleton'
|
||||||
@ -23,7 +23,7 @@ interface ModelPickerDialogProps {
|
|||||||
sessionId?: string | null
|
sessionId?: string | null
|
||||||
currentModel: string
|
currentModel: string
|
||||||
currentProvider: string
|
currentProvider: string
|
||||||
onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void
|
onSelect: (selection: { provider: string; model: string }) => void
|
||||||
/**
|
/**
|
||||||
* Optional class to apply to DialogContent. Use to override z-index when
|
* Optional class to apply to DialogContent. Use to override z-index when
|
||||||
* stacking the picker on top of another fixed overlay (e.g. the desktop
|
* stacking the picker on top of another fixed overlay (e.g. the desktop
|
||||||
@ -45,7 +45,6 @@ export function ModelPickerDialog({
|
|||||||
}: ModelPickerDialogProps) {
|
}: ModelPickerDialogProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const copy = t.modelPicker
|
const copy = t.modelPicker
|
||||||
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
|
|
||||||
// Own the search term so we can filter manually. cmdk's built-in
|
// Own the search term so we can filter manually. cmdk's built-in
|
||||||
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
|
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
|
||||||
// an empty query), which destroys the backend's curated order. We disable
|
// an empty query), which destroys the backend's curated order. We disable
|
||||||
@ -68,8 +67,13 @@ export function ModelPickerDialog({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const providers = modelOptions.data?.providers ?? []
|
const providers = modelOptions.data?.providers ?? []
|
||||||
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
|
|
||||||
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
|
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
|
||||||
|
!!sessionId,
|
||||||
|
{ model: currentModel, provider: currentProvider },
|
||||||
|
modelOptions.data
|
||||||
|
)
|
||||||
|
|
||||||
const loading = modelOptions.isPending && !modelOptions.data
|
const loading = modelOptions.isPending && !modelOptions.data
|
||||||
|
|
||||||
const error = modelOptions.error
|
const error = modelOptions.error
|
||||||
@ -79,11 +83,7 @@ export function ModelPickerDialog({
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
const selectModel = (provider: ModelOptionProvider, model: string) => {
|
const selectModel = (provider: ModelOptionProvider, model: string) => {
|
||||||
onSelect({
|
onSelect({ provider: provider.slug, model })
|
||||||
provider: provider.slug,
|
|
||||||
model,
|
|
||||||
persistGlobal: persistGlobal || !sessionId
|
|
||||||
})
|
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,24 +128,13 @@ export function ModelPickerDialog({
|
|||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
|
|
||||||
<DialogFooter className="flex-row items-center justify-between gap-3 bg-card p-3 sm:justify-between">
|
<DialogFooter className="flex-row items-center justify-end gap-2 bg-card p-3">
|
||||||
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Checkbox
|
|
||||||
checked={persistGlobal || !sessionId}
|
|
||||||
disabled={!sessionId}
|
|
||||||
onCheckedChange={checked => setPersistGlobal(checked === true)}
|
|
||||||
/>
|
|
||||||
{sessionId ? copy.persistGlobalSession : copy.persistGlobal}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button onClick={addProvider} variant="ghost">
|
<Button onClick={addProvider} variant="ghost">
|
||||||
{copy.addProvider}
|
{copy.addProvider}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||||
{t.common.cancel}
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -538,6 +538,10 @@ export const en: Translations = {
|
|||||||
provider: 'Provider',
|
provider: 'Provider',
|
||||||
model: 'Model',
|
model: 'Model',
|
||||||
applying: 'Applying...',
|
applying: 'Applying...',
|
||||||
|
defaultsLabel: 'Defaults',
|
||||||
|
reasoning: 'Reasoning',
|
||||||
|
reasoningOff: 'Off',
|
||||||
|
defaultsFailed: 'Failed to save model defaults',
|
||||||
auxiliaryTitle: 'Auxiliary models',
|
auxiliaryTitle: 'Auxiliary models',
|
||||||
resetAllToMain: 'Reset all to main',
|
resetAllToMain: 'Reset all to main',
|
||||||
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',
|
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',
|
||||||
@ -565,9 +569,14 @@ export const en: Translations = {
|
|||||||
collapse: 'Collapse',
|
collapse: 'Collapse',
|
||||||
connectAnother: 'Connect another provider',
|
connectAnother: 'Connect another provider',
|
||||||
otherProviders: 'Other providers',
|
otherProviders: 'Other providers',
|
||||||
|
disconnect: 'Disconnect',
|
||||||
|
disconnectInTerminal: 'Disconnect (runs the removal command in the terminal)',
|
||||||
removeConfirm: provider => `Remove ${provider}?`,
|
removeConfirm: provider => `Remove ${provider}?`,
|
||||||
removeExternal: (provider, command) => `${provider} is managed outside Hermes. Remove it with ${command}.`,
|
removeExternalGeneric: provider => `${provider} is managed by its own CLI — remove it there.`,
|
||||||
removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`,
|
removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`,
|
||||||
|
removeTerminalConfirm: (provider, command) =>
|
||||||
|
`Disconnect ${provider}? This runs "${command}" in the terminal to clear the credential.`,
|
||||||
|
removeTerminalRunning: provider => `Running ${provider} disconnect in the terminal…`,
|
||||||
removedTitle: 'Account removed',
|
removedTitle: 'Account removed',
|
||||||
removedMessage: provider => `${provider} was removed.`,
|
removedMessage: provider => `${provider} was removed.`,
|
||||||
failedRemove: provider => `Could not remove ${provider}`,
|
failedRemove: provider => `Could not remove ${provider}`,
|
||||||
@ -1498,8 +1507,6 @@ export const en: Translations = {
|
|||||||
unknown: '(unknown)',
|
unknown: '(unknown)',
|
||||||
search: 'Filter providers and models...',
|
search: 'Filter providers and models...',
|
||||||
noModels: 'No models found.',
|
noModels: 'No models found.',
|
||||||
persistGlobalSession: 'Persist globally (otherwise this session only)',
|
|
||||||
persistGlobal: 'Persist globally',
|
|
||||||
addProvider: 'Add provider',
|
addProvider: 'Add provider',
|
||||||
loadFailed: 'Could not load models',
|
loadFailed: 'Could not load models',
|
||||||
noAuthenticatedProviders: 'No authenticated providers.',
|
noAuthenticatedProviders: 'No authenticated providers.',
|
||||||
@ -1726,6 +1733,7 @@ export const en: Translations = {
|
|||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
moreActions: 'More actions',
|
moreActions: 'More actions',
|
||||||
branchNewChat: 'Branch in new chat',
|
branchNewChat: 'Branch in new chat',
|
||||||
|
dismissError: 'Dismiss error',
|
||||||
readAloudFailed: 'Read aloud failed',
|
readAloudFailed: 'Read aloud failed',
|
||||||
preparingAudio: 'Preparing audio...',
|
preparingAudio: 'Preparing audio...',
|
||||||
stopReading: 'Stop reading',
|
stopReading: 'Stop reading',
|
||||||
@ -1835,6 +1843,9 @@ export const en: Translations = {
|
|||||||
regenerateFailed: 'Regenerate failed',
|
regenerateFailed: 'Regenerate failed',
|
||||||
editFailed: 'Edit failed',
|
editFailed: 'Edit failed',
|
||||||
resumeFailed: 'Resume failed',
|
resumeFailed: 'Resume failed',
|
||||||
|
resumeStrandedTitle: "Couldn't load this session",
|
||||||
|
resumeStrandedBody: 'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
|
||||||
|
resumeRetry: 'Retry',
|
||||||
nothingToBranch: 'Nothing to branch',
|
nothingToBranch: 'Nothing to branch',
|
||||||
branchNeedsChat: 'Start or resume a chat before branching.',
|
branchNeedsChat: 'Start or resume a chat before branching.',
|
||||||
sessionBusy: 'Session busy',
|
sessionBusy: 'Session busy',
|
||||||
|
|||||||
@ -695,7 +695,6 @@ export const ja = defineLocale({
|
|||||||
connectAnother: '別のプロバイダーを接続',
|
connectAnother: '別のプロバイダーを接続',
|
||||||
otherProviders: 'その他のプロバイダー',
|
otherProviders: 'その他のプロバイダー',
|
||||||
removeConfirm: provider => `${provider} を削除しますか?`,
|
removeConfirm: provider => `${provider} を削除しますか?`,
|
||||||
removeExternal: (provider, command) => `${provider} は Hermes の外部で管理されています。${command} で削除してください。`,
|
|
||||||
removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`,
|
removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`,
|
||||||
removedTitle: 'アカウントを削除しました',
|
removedTitle: 'アカウントを削除しました',
|
||||||
removedMessage: provider => `${provider} を削除しました。`,
|
removedMessage: provider => `${provider} を削除しました。`,
|
||||||
@ -1638,8 +1637,6 @@ export const ja = defineLocale({
|
|||||||
unknown: '(不明)',
|
unknown: '(不明)',
|
||||||
search: 'プロバイダーとモデルをフィルター...',
|
search: 'プロバイダーとモデルをフィルター...',
|
||||||
noModels: 'モデルが見つかりません。',
|
noModels: 'モデルが見つかりません。',
|
||||||
persistGlobalSession: 'グローバルに保持(それ以外はこのセッションのみ)',
|
|
||||||
persistGlobal: 'グローバルに保持',
|
|
||||||
addProvider: 'プロバイダーを追加',
|
addProvider: 'プロバイダーを追加',
|
||||||
loadFailed: 'モデルを読み込めませんでした',
|
loadFailed: 'モデルを読み込めませんでした',
|
||||||
noAuthenticatedProviders: '認証済みプロバイダーがありません。',
|
noAuthenticatedProviders: '認証済みプロバイダーがありません。',
|
||||||
@ -1867,6 +1864,7 @@ export const ja = defineLocale({
|
|||||||
refresh: '更新',
|
refresh: '更新',
|
||||||
moreActions: 'その他のアクション',
|
moreActions: 'その他のアクション',
|
||||||
branchNewChat: '新しいチャットでブランチ',
|
branchNewChat: '新しいチャットでブランチ',
|
||||||
|
dismissError: 'エラーを閉じる',
|
||||||
readAloudFailed: '読み上げに失敗しました',
|
readAloudFailed: '読み上げに失敗しました',
|
||||||
preparingAudio: '音声を準備中...',
|
preparingAudio: '音声を準備中...',
|
||||||
stopReading: '読み上げを停止',
|
stopReading: '読み上げを停止',
|
||||||
@ -1976,6 +1974,9 @@ export const ja = defineLocale({
|
|||||||
regenerateFailed: '再生成に失敗しました',
|
regenerateFailed: '再生成に失敗しました',
|
||||||
editFailed: '編集に失敗しました',
|
editFailed: '編集に失敗しました',
|
||||||
resumeFailed: '再開に失敗しました',
|
resumeFailed: '再開に失敗しました',
|
||||||
|
resumeStrandedTitle: 'このセッションを読み込めませんでした',
|
||||||
|
resumeStrandedBody: 'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
|
||||||
|
resumeRetry: '再試行',
|
||||||
nothingToBranch: 'ブランチするものがありません',
|
nothingToBranch: 'ブランチするものがありません',
|
||||||
branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',
|
branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',
|
||||||
sessionBusy: 'セッションが使用中',
|
sessionBusy: 'セッションが使用中',
|
||||||
|
|||||||
@ -430,6 +430,10 @@ export interface Translations {
|
|||||||
provider: string
|
provider: string
|
||||||
model: string
|
model: string
|
||||||
applying: string
|
applying: string
|
||||||
|
defaultsLabel: string
|
||||||
|
reasoning: string
|
||||||
|
reasoningOff: string
|
||||||
|
defaultsFailed: string
|
||||||
auxiliaryTitle: string
|
auxiliaryTitle: string
|
||||||
resetAllToMain: string
|
resetAllToMain: string
|
||||||
auxiliaryDesc: string
|
auxiliaryDesc: string
|
||||||
@ -447,9 +451,13 @@ export interface Translations {
|
|||||||
collapse: string
|
collapse: string
|
||||||
connectAnother: string
|
connectAnother: string
|
||||||
otherProviders: string
|
otherProviders: string
|
||||||
|
disconnect: string
|
||||||
|
disconnectInTerminal: string
|
||||||
removeConfirm: (provider: string) => string
|
removeConfirm: (provider: string) => string
|
||||||
removeExternal: (provider: string, command: string) => string
|
removeExternalGeneric: (provider: string) => string
|
||||||
removeKeyManaged: (provider: string) => string
|
removeKeyManaged: (provider: string) => string
|
||||||
|
removeTerminalConfirm: (provider: string, command: string) => string
|
||||||
|
removeTerminalRunning: (provider: string) => string
|
||||||
removedTitle: string
|
removedTitle: string
|
||||||
removedMessage: (provider: string) => string
|
removedMessage: (provider: string) => string
|
||||||
failedRemove: (provider: string) => string
|
failedRemove: (provider: string) => string
|
||||||
@ -1141,8 +1149,6 @@ export interface Translations {
|
|||||||
unknown: string
|
unknown: string
|
||||||
search: string
|
search: string
|
||||||
noModels: string
|
noModels: string
|
||||||
persistGlobalSession: string
|
|
||||||
persistGlobal: string
|
|
||||||
addProvider: string
|
addProvider: string
|
||||||
loadFailed: string
|
loadFailed: string
|
||||||
noAuthenticatedProviders: string
|
noAuthenticatedProviders: string
|
||||||
@ -1367,6 +1373,7 @@ export interface Translations {
|
|||||||
refresh: string
|
refresh: string
|
||||||
moreActions: string
|
moreActions: string
|
||||||
branchNewChat: string
|
branchNewChat: string
|
||||||
|
dismissError: string
|
||||||
readAloudFailed: string
|
readAloudFailed: string
|
||||||
preparingAudio: string
|
preparingAudio: string
|
||||||
stopReading: string
|
stopReading: string
|
||||||
@ -1474,6 +1481,9 @@ export interface Translations {
|
|||||||
regenerateFailed: string
|
regenerateFailed: string
|
||||||
editFailed: string
|
editFailed: string
|
||||||
resumeFailed: string
|
resumeFailed: string
|
||||||
|
resumeStrandedTitle: string
|
||||||
|
resumeStrandedBody: string
|
||||||
|
resumeRetry: string
|
||||||
nothingToBranch: string
|
nothingToBranch: string
|
||||||
branchNeedsChat: string
|
branchNeedsChat: string
|
||||||
sessionBusy: string
|
sessionBusy: string
|
||||||
|
|||||||
@ -672,7 +672,6 @@ export const zhHant = defineLocale({
|
|||||||
connectAnother: '連結其他提供方',
|
connectAnother: '連結其他提供方',
|
||||||
otherProviders: '其他提供方',
|
otherProviders: '其他提供方',
|
||||||
removeConfirm: provider => `移除 ${provider}?`,
|
removeConfirm: provider => `移除 ${provider}?`,
|
||||||
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。請使用 ${command} 移除。`,
|
|
||||||
removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`,
|
removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`,
|
||||||
removedTitle: '帳號已移除',
|
removedTitle: '帳號已移除',
|
||||||
removedMessage: provider => `${provider} 已移除。`,
|
removedMessage: provider => `${provider} 已移除。`,
|
||||||
@ -1582,8 +1581,6 @@ export const zhHant = defineLocale({
|
|||||||
unknown: '(未知)',
|
unknown: '(未知)',
|
||||||
search: '篩選提供方和模型...',
|
search: '篩選提供方和模型...',
|
||||||
noModels: '找不到模型。',
|
noModels: '找不到模型。',
|
||||||
persistGlobalSession: '全域儲存(否則僅限此工作階段)',
|
|
||||||
persistGlobal: '全域儲存',
|
|
||||||
addProvider: '新增提供方',
|
addProvider: '新增提供方',
|
||||||
loadFailed: '無法載入模型',
|
loadFailed: '無法載入模型',
|
||||||
noAuthenticatedProviders: '沒有已驗證的提供方。',
|
noAuthenticatedProviders: '沒有已驗證的提供方。',
|
||||||
@ -1809,6 +1806,7 @@ export const zhHant = defineLocale({
|
|||||||
refresh: '重新整理',
|
refresh: '重新整理',
|
||||||
moreActions: '更多動作',
|
moreActions: '更多動作',
|
||||||
branchNewChat: '在新聊天中分支',
|
branchNewChat: '在新聊天中分支',
|
||||||
|
dismissError: '关闭错误',
|
||||||
readAloudFailed: '朗讀失敗',
|
readAloudFailed: '朗讀失敗',
|
||||||
preparingAudio: '正在準備音訊...',
|
preparingAudio: '正在準備音訊...',
|
||||||
stopReading: '停止朗讀',
|
stopReading: '停止朗讀',
|
||||||
@ -1916,6 +1914,9 @@ export const zhHant = defineLocale({
|
|||||||
regenerateFailed: '重新生成失敗',
|
regenerateFailed: '重新生成失敗',
|
||||||
editFailed: '編輯失敗',
|
editFailed: '編輯失敗',
|
||||||
resumeFailed: '繼續失敗',
|
resumeFailed: '繼續失敗',
|
||||||
|
resumeStrandedTitle: '無法載入此工作階段',
|
||||||
|
resumeStrandedBody: '與此工作階段的連線失敗,自動重試已停止。請確認閘道正在執行,然後重試。',
|
||||||
|
resumeRetry: '重試',
|
||||||
nothingToBranch: '沒有可分支的內容',
|
nothingToBranch: '沒有可分支的內容',
|
||||||
branchNeedsChat: '分支前請先開始或繼續一個聊天。',
|
branchNeedsChat: '分支前請先開始或繼續一個聊天。',
|
||||||
sessionBusy: '工作階段忙碌中',
|
sessionBusy: '工作階段忙碌中',
|
||||||
|
|||||||
@ -733,6 +733,10 @@ export const zh: Translations = {
|
|||||||
provider: '提供方',
|
provider: '提供方',
|
||||||
model: '模型',
|
model: '模型',
|
||||||
applying: '应用中...',
|
applying: '应用中...',
|
||||||
|
defaultsLabel: '默认值',
|
||||||
|
reasoning: '推理',
|
||||||
|
reasoningOff: '关闭',
|
||||||
|
defaultsFailed: '保存模型默认值失败',
|
||||||
auxiliaryTitle: '辅助模型',
|
auxiliaryTitle: '辅助模型',
|
||||||
resetAllToMain: '全部重置为主模型',
|
resetAllToMain: '全部重置为主模型',
|
||||||
auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。',
|
auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。',
|
||||||
@ -759,9 +763,13 @@ export const zh: Translations = {
|
|||||||
collapse: '收起',
|
collapse: '收起',
|
||||||
connectAnother: '连接其他提供方',
|
connectAnother: '连接其他提供方',
|
||||||
otherProviders: '其他提供方',
|
otherProviders: '其他提供方',
|
||||||
|
disconnect: '断开连接',
|
||||||
|
disconnectInTerminal: '断开连接(在终端中运行移除命令)',
|
||||||
removeConfirm: provider => `移除 ${provider}?`,
|
removeConfirm: provider => `移除 ${provider}?`,
|
||||||
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。请使用 ${command} 移除。`,
|
removeExternalGeneric: provider => `${provider} 由其自身的 CLI 管理 — 请在那里移除。`,
|
||||||
removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`,
|
removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`,
|
||||||
|
removeTerminalConfirm: (provider, command) => `断开 ${provider}?这将在终端中运行 "${command}" 以清除凭据。`,
|
||||||
|
removeTerminalRunning: provider => `正在终端中断开 ${provider}…`,
|
||||||
removedTitle: '账号已移除',
|
removedTitle: '账号已移除',
|
||||||
removedMessage: provider => `${provider} 已移除。`,
|
removedMessage: provider => `${provider} 已移除。`,
|
||||||
failedRemove: provider => `无法移除 ${provider}`,
|
failedRemove: provider => `无法移除 ${provider}`,
|
||||||
@ -1679,8 +1687,6 @@ export const zh: Translations = {
|
|||||||
unknown: '(未知)',
|
unknown: '(未知)',
|
||||||
search: '筛选提供方和模型...',
|
search: '筛选提供方和模型...',
|
||||||
noModels: '未找到模型。',
|
noModels: '未找到模型。',
|
||||||
persistGlobalSession: '全局保存 (否则仅当前会话)',
|
|
||||||
persistGlobal: '全局保存',
|
|
||||||
addProvider: '添加提供方',
|
addProvider: '添加提供方',
|
||||||
loadFailed: '无法加载模型',
|
loadFailed: '无法加载模型',
|
||||||
noAuthenticatedProviders: '没有已认证的提供方。',
|
noAuthenticatedProviders: '没有已认证的提供方。',
|
||||||
@ -1906,6 +1912,7 @@ export const zh: Translations = {
|
|||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
moreActions: '更多操作',
|
moreActions: '更多操作',
|
||||||
branchNewChat: '在新对话中分支',
|
branchNewChat: '在新对话中分支',
|
||||||
|
dismissError: '关闭错误',
|
||||||
readAloudFailed: '朗读失败',
|
readAloudFailed: '朗读失败',
|
||||||
preparingAudio: '正在准备音频...',
|
preparingAudio: '正在准备音频...',
|
||||||
stopReading: '停止朗读',
|
stopReading: '停止朗读',
|
||||||
@ -2014,6 +2021,9 @@ export const zh: Translations = {
|
|||||||
regenerateFailed: '重新生成失败',
|
regenerateFailed: '重新生成失败',
|
||||||
editFailed: '编辑失败',
|
editFailed: '编辑失败',
|
||||||
resumeFailed: '恢复失败',
|
resumeFailed: '恢复失败',
|
||||||
|
resumeStrandedTitle: '无法加载此会话',
|
||||||
|
resumeStrandedBody: '与此会话的连接失败,自动重试已停止。请确认网关正在运行,然后重试。',
|
||||||
|
resumeRetry: '重试',
|
||||||
nothingToBranch: '没有可分支的内容',
|
nothingToBranch: '没有可分支的内容',
|
||||||
branchNeedsChat: '分支前请先开始或恢复一个对话。',
|
branchNeedsChat: '分支前请先开始或恢复一个对话。',
|
||||||
sessionBusy: '会话忙碌中',
|
sessionBusy: '会话忙碌中',
|
||||||
|
|||||||
@ -52,6 +52,17 @@ describe('desktop slash command curation', () => {
|
|||||||
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
|
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('treats /browser as an executable action command (local-gateway connect)', () => {
|
||||||
|
// /browser used to be terminal-only; it now resolves to a desktop action
|
||||||
|
// handler that routes browser.manage RPC when the gateway is local.
|
||||||
|
expect(isDesktopSlashCommand('/browser')).toBe(true)
|
||||||
|
expect(isDesktopSlashSuggestion('/browser')).toBe(true)
|
||||||
|
expect(desktopSlashUnavailableMessage('/browser')).toBeNull()
|
||||||
|
expect(resolveDesktopCommand('/browser')?.surface).toEqual({ kind: 'action', action: 'browser' })
|
||||||
|
// Bare /browser expands to its sub-action options in the popover.
|
||||||
|
expect(resolveDesktopCommand('/browser')?.args).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('allows aliases to execute without cluttering the popover', () => {
|
it('allows aliases to execute without cluttering the popover', () => {
|
||||||
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
|
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
|
||||||
expect(isDesktopSlashCommand('/reset')).toBe(true)
|
expect(isDesktopSlashCommand('/reset')).toBe(true)
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export interface DesktopThemeCommandOption {
|
|||||||
*/
|
*/
|
||||||
export type DesktopActionId =
|
export type DesktopActionId =
|
||||||
| 'branch'
|
| 'branch'
|
||||||
|
| 'browser'
|
||||||
| 'handoff'
|
| 'handoff'
|
||||||
| 'help'
|
| 'help'
|
||||||
| 'new'
|
| 'new'
|
||||||
@ -103,6 +104,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
|||||||
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
|
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
|
||||||
{ name: '/title', description: 'Rename the current session', surface: action('title') },
|
{ name: '/title', description: 'Rename the current session', surface: action('title') },
|
||||||
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
|
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
|
||||||
|
{
|
||||||
|
name: '/browser',
|
||||||
|
description: 'Manage browser CDP connection [connect|disconnect|status] (local gateway only)',
|
||||||
|
surface: action('browser'),
|
||||||
|
args: true
|
||||||
|
},
|
||||||
|
|
||||||
// Overlay pickers
|
// Overlay pickers
|
||||||
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
|
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
|
||||||
@ -142,7 +149,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
|||||||
// per reason beats 40 identical object literals.
|
// per reason beats 40 identical object literals.
|
||||||
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
|
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
|
||||||
terminal: [
|
terminal: [
|
||||||
'/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
|
'/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
|
||||||
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
|
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
|
||||||
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
|
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
|
||||||
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
|
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
|
||||||
|
|||||||
@ -151,12 +151,18 @@ function normalizeVisibleProse(text: string): string {
|
|||||||
.join('')
|
.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extend(out: string[], lines: string[]) {
|
||||||
|
for (const line of lines) {
|
||||||
|
out.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
|
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
|
||||||
if (info) {
|
if (info) {
|
||||||
out.push(`${indent}${info}`.trimEnd())
|
out.push(`${indent}${info}`.trimEnd())
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push(...lines)
|
extend(out, lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
function findClosingFence(lines: string[], start: number, marker: string): number {
|
function findClosingFence(lines: string[], start: number, marker: string): number {
|
||||||
@ -241,7 +247,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
|
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
|
||||||
out.push(...bodyLines)
|
extend(out, bodyLines)
|
||||||
index = closeIndex + 1
|
index = closeIndex + 1
|
||||||
|
|
||||||
continue
|
continue
|
||||||
@ -264,10 +270,10 @@ function normalizeFenceBlocks(text: string): string {
|
|||||||
// any literal `$$` characters in the body don't collide with
|
// any literal `$$` characters in the body don't collide with
|
||||||
// an outer math wrapper. No close emitted yet — streaming.
|
// an outer math wrapper. No close emitted yet — streaming.
|
||||||
out.push(`${indent}${marker}math`)
|
out.push(`${indent}${marker}math`)
|
||||||
out.push(...bodyLines)
|
extend(out, bodyLines)
|
||||||
} else {
|
} else {
|
||||||
out.push(`${indent}${marker}${language}`)
|
out.push(`${indent}${marker}${language}`)
|
||||||
out.push(...bodyLines)
|
extend(out, bodyLines)
|
||||||
}
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
@ -288,7 +294,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||||||
// colliding with our wrapper. Without this rewrite the block
|
// colliding with our wrapper. Without this rewrite the block
|
||||||
// would render as a syntax-highlighted "latex" code listing.
|
// would render as a syntax-highlighted "latex" code listing.
|
||||||
out.push(`${indent}${marker}math`)
|
out.push(`${indent}${marker}math`)
|
||||||
out.push(...bodyLines)
|
extend(out, bodyLines)
|
||||||
out.push(`${indent}${marker}`)
|
out.push(`${indent}${marker}`)
|
||||||
index = closeIndex + 1
|
index = closeIndex + 1
|
||||||
|
|
||||||
@ -296,7 +302,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out.push(`${indent}${marker}${language}`)
|
out.push(`${indent}${marker}${language}`)
|
||||||
out.push(...bodyLines)
|
extend(out, bodyLines)
|
||||||
out.push(`${indent}${marker}`)
|
out.push(`${indent}${marker}`)
|
||||||
index = closeIndex + 1
|
index = closeIndex + 1
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
|
import { currentPickerSelection, displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
|
||||||
|
|
||||||
describe('model-status-label', () => {
|
describe('model-status-label', () => {
|
||||||
it('formats display names consistently', () => {
|
it('formats display names consistently', () => {
|
||||||
@ -10,6 +10,11 @@ describe('model-status-label', () => {
|
|||||||
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
|
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('strips trailing date-pin snapshots from the display name', () => {
|
||||||
|
expect(displayModelName('claude-opus-4-5-20251101')).toBe('Opus 4 5')
|
||||||
|
expect(displayModelName('anthropic/claude-haiku-4-5-20251001')).toBe('Haiku 4 5')
|
||||||
|
})
|
||||||
|
|
||||||
it('maps reasoning effort to compact labels', () => {
|
it('maps reasoning effort to compact labels', () => {
|
||||||
expect(reasoningEffortLabel('high')).toBe('High')
|
expect(reasoningEffortLabel('high')).toBe('High')
|
||||||
expect(reasoningEffortLabel('xhigh')).toBe('Max')
|
expect(reasoningEffortLabel('xhigh')).toBe('Max')
|
||||||
@ -30,4 +35,25 @@ describe('model-status-label', () => {
|
|||||||
it('returns just the placeholder name when there is no model', () => {
|
it('returns just the placeholder name when there is no model', () => {
|
||||||
expect(formatModelStatusLabel('')).toBe('No model')
|
expect(formatModelStatusLabel('')).toBe('No model')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('currentPickerSelection', () => {
|
||||||
|
const store = { model: 'opus', provider: 'anthropic' }
|
||||||
|
const options = { model: 'hermes-4', provider: 'nous' }
|
||||||
|
|
||||||
|
it('prefers the sticky composer pick over the profile default pre-session', () => {
|
||||||
|
expect(currentPickerSelection(false, store, options)).toEqual(store)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets the live session model.options win when a session exists', () => {
|
||||||
|
expect(currentPickerSelection(true, store, options)).toEqual(options)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to options when the store is empty', () => {
|
||||||
|
expect(currentPickerSelection(false, { model: '', provider: '' }, options)).toEqual(options)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the store while options are still loading', () => {
|
||||||
|
expect(currentPickerSelection(true, store, undefined)).toEqual(store)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -17,6 +17,22 @@ export function reasoningEffortLabel(effort: string): string {
|
|||||||
return REASONING_LABELS[key] ?? effort
|
return REASONING_LABELS[key] ?? effort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which model/provider a picker should mark "current". With a live session the
|
||||||
|
* gateway's `model.options` is authoritative; pre-session there is no server
|
||||||
|
* "current", so the sticky composer pick wins over the profile default the
|
||||||
|
* global options query returns — else the checkmark snaps back to the default
|
||||||
|
* and the pick looks ignored. */
|
||||||
|
export function currentPickerSelection(
|
||||||
|
hasSession: boolean,
|
||||||
|
store: { model: string; provider: string },
|
||||||
|
options?: { model?: string; provider?: string }
|
||||||
|
): { model: string; provider: string } {
|
||||||
|
return {
|
||||||
|
model: String((hasSession && options?.model) || store.model || options?.model || ''),
|
||||||
|
provider: String((hasSession && options?.provider) || store.provider || options?.provider || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Strip provider prefix and normalize for display. */
|
/** Strip provider prefix and normalize for display. */
|
||||||
export function modelBaseId(model: string): string {
|
export function modelBaseId(model: string): string {
|
||||||
const trimmed = model.trim()
|
const trimmed = model.trim()
|
||||||
@ -68,6 +84,9 @@ export function modelDisplayParts(model: string): { name: string; tag: string }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop a trailing date-pin (`…-20251101`) — snapshot noise, not a name.
|
||||||
|
base = base.replace(/-\d{8}$/, '')
|
||||||
|
|
||||||
return { name: prettifyBase(base) || model.trim() || 'No model', tag }
|
return { name: prettifyBase(base) || model.trim() || 'No model', tag }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
apps/desktop/src/store/model-presets.test.ts
Normal file
51
apps/desktop/src/store/model-presets.test.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { $modelPresets, applyModelPreset, getModelPreset, modelPresetKey, setModelPreset } from './model-presets'
|
||||||
|
|
||||||
|
describe('model presets', () => {
|
||||||
|
beforeEach(() => $modelPresets.set({}))
|
||||||
|
|
||||||
|
it('round-trips a preset and merges patches without dropping prior fields', () => {
|
||||||
|
setModelPreset('anthropic', 'claude-opus-4-8', { effort: 'high' })
|
||||||
|
setModelPreset('anthropic', 'claude-opus-4-8', { fast: true })
|
||||||
|
|
||||||
|
expect(getModelPreset('anthropic', 'claude-opus-4-8')).toEqual({ effort: 'high', fast: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an empty preset for unknown models', () => {
|
||||||
|
expect(getModelPreset('x', 'y')).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keys by provider::model', () => {
|
||||||
|
expect(modelPresetKey('openai', 'gpt-5.5')).toBe('openai::gpt-5.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pushes only the provided dimensions to the gateway', async () => {
|
||||||
|
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||||
|
|
||||||
|
const request = async <T>(method: string, params?: Record<string, unknown>) => {
|
||||||
|
calls.push({ method, params })
|
||||||
|
|
||||||
|
return {} as T
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyModelPreset({ effort: 'high' }, { failMessage: 'x', request, sessionId: 's1' })
|
||||||
|
await applyModelPreset({}, { failMessage: 'x', request, sessionId: 's1' })
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ method: 'config.set', params: { key: 'reasoning', session_id: 's1', value: 'high' } }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no-ops without a session so selecting a model cannot mutate global config', async () => {
|
||||||
|
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||||
|
|
||||||
|
const request = async <T>(method: string, params?: Record<string, unknown>) => {
|
||||||
|
calls.push({ method, params })
|
||||||
|
|
||||||
|
return {} as T
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyModelPreset({ effort: 'high', fast: true }, { failMessage: 'x', request, sessionId: null })
|
||||||
|
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
86
apps/desktop/src/store/model-presets.ts
Normal file
86
apps/desktop/src/store/model-presets.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { atom } from 'nanostores'
|
||||||
|
|
||||||
|
import { persistString, storedString } from '@/lib/storage'
|
||||||
|
|
||||||
|
import { notifyError } from './notifications'
|
||||||
|
import { setCurrentFastMode, setCurrentReasoningEffort } from './session'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'hermes.desktop.model-presets'
|
||||||
|
|
||||||
|
/** Per-model reasoning/fast preset, remembered globally across sessions and
|
||||||
|
* re-applied to the session whenever that model is selected. Unset dimensions
|
||||||
|
* fall back to the Hermes default (medium effort, no fast). */
|
||||||
|
export interface ModelPreset {
|
||||||
|
effort?: string
|
||||||
|
fast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestGateway = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||||
|
|
||||||
|
/** Stable `provider::model` key (matches the visibility-store format). */
|
||||||
|
export const modelPresetKey = (provider: string, model: string): string => `${provider}::${model}`
|
||||||
|
|
||||||
|
function load(): Record<string, ModelPreset> {
|
||||||
|
const raw = storedString(STORAGE_KEY)
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, ModelPreset>) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const $modelPresets = atom<Record<string, ModelPreset>>(load())
|
||||||
|
|
||||||
|
export function getModelPreset(provider: string, model: string): ModelPreset {
|
||||||
|
return $modelPresets.get()[modelPresetKey(provider, model)] ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge a partial preset for one model and persist. */
|
||||||
|
export function setModelPreset(provider: string, model: string, patch: ModelPreset): void {
|
||||||
|
const key = modelPresetKey(provider, model)
|
||||||
|
const next = { ...$modelPresets.get(), [key]: { ...$modelPresets.get()[key], ...patch } }
|
||||||
|
|
||||||
|
$modelPresets.set(next)
|
||||||
|
persistString(STORAGE_KEY, JSON.stringify(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push a model's preset onto the active session (optimistic + gateway).
|
||||||
|
* `undefined` skips that dimension; values are capability-gated upstream.
|
||||||
|
* No-ops without a session — the gateway's `config.set` reasoning/fast fall
|
||||||
|
* back to persistent (global/profile) config when none matches, so selecting
|
||||||
|
* a model must not reach it (else it rewrites `agent.*`, defaults included). */
|
||||||
|
export async function applyModelPreset(
|
||||||
|
{ effort, fast }: ModelPreset,
|
||||||
|
ctx: { failMessage: string; request: RequestGateway; sessionId: null | string }
|
||||||
|
): Promise<void> {
|
||||||
|
if (!ctx.sessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effort !== undefined) {
|
||||||
|
setCurrentReasoningEffort(effort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fast !== undefined) {
|
||||||
|
setCurrentFastMode(fast)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (effort !== undefined) {
|
||||||
|
await ctx.request('config.set', { key: 'reasoning', session_id: ctx.sessionId, value: effort })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fast !== undefined) {
|
||||||
|
await ctx.request('config.set', { key: 'fast', session_id: ctx.sessionId, value: fast ? 'fast' : 'normal' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err, ctx.failMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import type { ModelOptionProvider } from '@/types/hermes'
|
import type { ModelOptionProvider } from '@/types/hermes'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
collapseModelFamilies,
|
||||||
effectiveVisibleKeys,
|
effectiveVisibleKeys,
|
||||||
emptyProviderSentinelKey,
|
emptyProviderSentinelKey,
|
||||||
isProviderSentinel,
|
isProviderSentinel,
|
||||||
@ -78,6 +79,18 @@ describe('model visibility', () => {
|
|||||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
|
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('folds a date-pinned snapshot into its rolling alias when present', () => {
|
||||||
|
const families = collapseModelFamilies(['claude-opus-4-5', 'claude-opus-4-5-20251101'])
|
||||||
|
|
||||||
|
expect(families.map(f => f.id)).toEqual(['claude-opus-4-5'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a date-pinned snapshot standing alone when it has no alias', () => {
|
||||||
|
const families = collapseModelFamilies(['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001'])
|
||||||
|
|
||||||
|
expect(families.map(f => f.id)).toEqual(['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001'])
|
||||||
|
})
|
||||||
|
|
||||||
it('sentinel key helper produces correct format', () => {
|
it('sentinel key helper produces correct format', () => {
|
||||||
expect(emptyProviderSentinelKey('openai')).toBe('openai::')
|
expect(emptyProviderSentinelKey('openai')).toBe('openai::')
|
||||||
expect(isProviderSentinel('openai::')).toBe(true)
|
expect(isProviderSentinel('openai::')).toBe(true)
|
||||||
|
|||||||
@ -51,6 +51,11 @@ export function collapseModelFamilies(models: readonly string[]): ModelFamily[]
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (/-\d{8}$/.test(model) && present.has(model.replace(/-\d{8}$/, ''))) {
|
||||||
|
// A date-pinned snapshot superseded by its rolling alias — drop the dupe.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const fastId = `${model}-fast`
|
const fastId = `${model}-fast`
|
||||||
const hasFast = present.has(fastId)
|
const hasFast = present.has(fastId)
|
||||||
families.push({ fastId: hasFast ? fastId : null, id: model })
|
families.push({ fastId: hasFast ? fastId : null, id: model })
|
||||||
|
|||||||
@ -4,13 +4,23 @@ import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading'
|
|||||||
import type { ContextSuggestion } from '@/app/types'
|
import type { ContextSuggestion } from '@/app/types'
|
||||||
import type { HermesConnection } from '@/global'
|
import type { HermesConnection } from '@/global'
|
||||||
import type { ChatMessage } from '@/lib/chat-messages'
|
import type { ChatMessage } from '@/lib/chat-messages'
|
||||||
import { persistString, storedString } from '@/lib/storage'
|
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
|
||||||
import type { SessionInfo, UsageStats } from '@/types/hermes'
|
import type { SessionInfo, UsageStats } from '@/types/hermes'
|
||||||
|
|
||||||
type Updater<T> = T | ((current: T) => T)
|
type Updater<T> = T | ((current: T) => T)
|
||||||
|
|
||||||
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
|
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
|
||||||
|
|
||||||
|
// The composer's model/effort/fast is sticky UI state, NOT the profile default
|
||||||
|
// (that lives in Settings → Model). Persisting it in localStorage makes a pick
|
||||||
|
// follow across Cmd+N and app restarts instead of snapping back to the default.
|
||||||
|
// It's deliberately global (not per-profile): a profile switch force-reseeds to
|
||||||
|
// that profile's default, while within a profile new chats keep your last pick.
|
||||||
|
const COMPOSER_MODEL_KEY = 'hermes.desktop.composer.model'
|
||||||
|
const COMPOSER_PROVIDER_KEY = 'hermes.desktop.composer.provider'
|
||||||
|
const COMPOSER_EFFORT_KEY = 'hermes.desktop.composer.reasoning-effort'
|
||||||
|
const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast'
|
||||||
|
|
||||||
let configuredDefaultProjectDir = ''
|
let configuredDefaultProjectDir = ''
|
||||||
|
|
||||||
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
|
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
|
||||||
@ -208,11 +218,28 @@ export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageI
|
|||||||
export const $freshDraftReady = atom(false)
|
export const $freshDraftReady = atom(false)
|
||||||
export const $busy = atom(false)
|
export const $busy = atom(false)
|
||||||
export const $awaitingResponse = atom(false)
|
export const $awaitingResponse = atom(false)
|
||||||
export const $currentModel = atom('')
|
// Stored-session id whose most recent resume FAILED terminally (the gateway RPC
|
||||||
export const $currentProvider = atom('')
|
// rejected AND the REST transcript fallback also failed), leaving the window
|
||||||
export const $currentReasoningEffort = atom('')
|
// with no runtime and an empty transcript. Drives use-route-resume's self-heal:
|
||||||
|
// while this matches the routed session the loader would otherwise latch
|
||||||
|
// forever (messagesEmpty && !activeSessionId), so the hook re-attempts the
|
||||||
|
// resume on the next render/focus/reconnect instead of stranding the window.
|
||||||
|
// Null whenever the active route has a healthy (or in-flight) resume.
|
||||||
|
export const $resumeFailedSessionId = atom<string | null>(null)
|
||||||
|
// Stored-session id whose resume has EXHAUSTED its bounded auto-retries (the
|
||||||
|
// terminal-failure latch above kept failing through all MAX_RESUME_RETRIES
|
||||||
|
// attempts). Distinct from $resumeFailedSessionId, which is armed *during* the
|
||||||
|
// backoff window too: this fires only once auto-recovery has given up, so the
|
||||||
|
// chat view can swap the perpetual loader for an explicit error + manual Retry
|
||||||
|
// affordance. A fresh resumeSession() (manual Retry, reconnect, reselect)
|
||||||
|
// clears it and resets the retry counter. Null whenever the active route has a
|
||||||
|
// healthy, in-flight, or still-auto-retrying resume.
|
||||||
|
export const $resumeExhaustedSessionId = atom<string | null>(null)
|
||||||
|
export const $currentModel = atom(storedString(COMPOSER_MODEL_KEY) ?? '')
|
||||||
|
export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '')
|
||||||
|
export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '')
|
||||||
export const $currentServiceTier = atom('')
|
export const $currentServiceTier = atom('')
|
||||||
export const $currentFastMode = atom(false)
|
export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false))
|
||||||
// Effective approval-bypass state mirrored from the gateway (session.info).
|
// Effective approval-bypass state mirrored from the gateway (session.info).
|
||||||
// Persistence lives in the backend config (approvals.mode), so this is a plain
|
// Persistence lives in the backend config (approvals.mode), so this is a plain
|
||||||
// reflection of the truth the gateway reports rather than its own store.
|
// reflection of the truth the gateway reports rather than its own store.
|
||||||
@ -252,13 +279,33 @@ export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($
|
|||||||
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
|
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
|
||||||
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
|
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
|
||||||
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
|
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
|
||||||
|
export const setResumeFailedSessionId = (next: Updater<string | null>) => updateAtom($resumeFailedSessionId, next)
|
||||||
|
export const setResumeExhaustedSessionId = (next: Updater<string | null>) => updateAtom($resumeExhaustedSessionId, next)
|
||||||
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
|
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
|
||||||
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
|
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
|
||||||
export const setCurrentModel = (next: Updater<string>) => updateAtom($currentModel, next)
|
|
||||||
export const setCurrentProvider = (next: Updater<string>) => updateAtom($currentProvider, next)
|
export const setCurrentModel = (next: Updater<string>) => {
|
||||||
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next)
|
updateAtom($currentModel, next)
|
||||||
|
persistString(COMPOSER_MODEL_KEY, $currentModel.get() || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setCurrentProvider = (next: Updater<string>) => {
|
||||||
|
updateAtom($currentProvider, next)
|
||||||
|
persistString(COMPOSER_PROVIDER_KEY, $currentProvider.get() || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setCurrentReasoningEffort = (next: Updater<string>) => {
|
||||||
|
updateAtom($currentReasoningEffort, next)
|
||||||
|
persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)
|
||||||
|
}
|
||||||
|
|
||||||
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
|
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
|
||||||
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
|
|
||||||
|
export const setCurrentFastMode = (next: Updater<boolean>) => {
|
||||||
|
updateAtom($currentFastMode, next)
|
||||||
|
persistBoolean(COMPOSER_FAST_KEY, $currentFastMode.get())
|
||||||
|
}
|
||||||
|
|
||||||
export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive, next)
|
export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive, next)
|
||||||
|
|
||||||
export const setCurrentCwd = (next: Updater<string>) => {
|
export const setCurrentCwd = (next: Updater<string>) => {
|
||||||
|
|||||||
@ -5,6 +5,9 @@ import type { DesktopUpdateStatus } from '@/global'
|
|||||||
const storage = new Map<string, string>()
|
const storage = new Map<string, string>()
|
||||||
|
|
||||||
vi.mock('@/lib/storage', () => ({
|
vi.mock('@/lib/storage', () => ({
|
||||||
|
persistBoolean: (key: string, value: boolean) => {
|
||||||
|
storage.set(key, String(value))
|
||||||
|
},
|
||||||
persistString: (key: string, value: null | string) => {
|
persistString: (key: string, value: null | string) => {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
storage.delete(key)
|
storage.delete(key)
|
||||||
@ -12,6 +15,11 @@ vi.mock('@/lib/storage', () => ({
|
|||||||
storage.set(key, value)
|
storage.set(key, value)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
storedBoolean: (key: string, fallback: boolean) => {
|
||||||
|
const value = storage.get(key)
|
||||||
|
|
||||||
|
return value === undefined ? fallback : value === 'true'
|
||||||
|
},
|
||||||
storedString: (key: string) => storage.get(key) ?? null
|
storedString: (key: string) => storage.get(key) ?? null
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -33,7 +41,7 @@ vi.mock('@/hermes', () => ({
|
|||||||
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
|
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply } = await import('./updates')
|
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply, reportBackendContract } = await import('./updates')
|
||||||
const { setConnection } = await import('./session')
|
const { setConnection } = await import('./session')
|
||||||
|
|
||||||
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
|
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
|
||||||
@ -87,6 +95,61 @@ describe('maybeNotifyUpdateAvailable', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('reportBackendContract', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storage.clear()
|
||||||
|
notifySpy.mockClear()
|
||||||
|
dismissSpy.mockClear()
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dismisses the toast when the backend meets the contract', () => {
|
||||||
|
reportBackendContract(2)
|
||||||
|
expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew')
|
||||||
|
expect(notifySpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warns when the backend is behind (or reports no contract)', () => {
|
||||||
|
reportBackendContract(undefined)
|
||||||
|
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||||
|
reportBackendContract(1)
|
||||||
|
expect(notifySpy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays quiet on later session opens once the user closed it', () => {
|
||||||
|
reportBackendContract(1)
|
||||||
|
lastToast().onDismiss() // user closes it → cooldown starts
|
||||||
|
notifySpy.mockClear()
|
||||||
|
|
||||||
|
// Opening another pre-existing session re-runs the check within cooldown.
|
||||||
|
reportBackendContract(1)
|
||||||
|
expect(notifySpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reminds again after the cooldown elapses', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(0)
|
||||||
|
|
||||||
|
reportBackendContract(1)
|
||||||
|
lastToast().onDismiss()
|
||||||
|
notifySpy.mockClear()
|
||||||
|
|
||||||
|
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
|
||||||
|
reportBackendContract(1)
|
||||||
|
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the snooze once the backend catches up, so a regression warns again', () => {
|
||||||
|
reportBackendContract(1)
|
||||||
|
lastToast().onDismiss()
|
||||||
|
notifySpy.mockClear()
|
||||||
|
|
||||||
|
reportBackendContract(2) // backend updated → satisfied, snooze cleared
|
||||||
|
reportBackendContract(1) // a later regression must warn immediately
|
||||||
|
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('checkBackendUpdates', () => {
|
describe('checkBackendUpdates', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
storage.clear()
|
storage.clear()
|
||||||
|
|||||||
@ -91,26 +91,60 @@ function isUpdateToastSnoozed(): boolean {
|
|||||||
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
|
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
|
||||||
const REQUIRED_BACKEND_CONTRACT = 2
|
const REQUIRED_BACKEND_CONTRACT = 2
|
||||||
const SKEW_TOAST_ID = 'backend-contract-skew'
|
const SKEW_TOAST_ID = 'backend-contract-skew'
|
||||||
|
// The contract check runs on every session.resume (applyRuntimeInfo), so
|
||||||
|
// without a snooze the warning re-popped on every thread the user opened, even
|
||||||
|
// right after they closed it. Mirror the update toast: persist a cooldown when
|
||||||
|
// the user dismisses it. It still reminds again after the window if the backend
|
||||||
|
// is still behind, and clears immediately once the backend catches up.
|
||||||
|
const SKEW_TOAST_SNOOZE_KEY = 'hermes:backend-skew-toast-snooze-until'
|
||||||
|
const SKEW_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function snoozeSkewToast(): void {
|
||||||
|
persistString(SKEW_TOAST_SNOOZE_KEY, String(Date.now() + SKEW_TOAST_COOLDOWN_MS))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSkewToastSnoozed(): boolean {
|
||||||
|
const until = Number(storedString(SKEW_TOAST_SNOOZE_KEY) || 0)
|
||||||
|
|
||||||
|
return Number.isFinite(until) && Date.now() < until
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guard against a desktop GUI talking to a backend that predates its contract
|
* Guard against a desktop GUI talking to a backend that predates its contract
|
||||||
* (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing
|
* (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing
|
||||||
* cryptically downstream, surface a persistent warning with a one-click align
|
* cryptically downstream, surface a warning with a one-click align that runs
|
||||||
* that runs the normal update flow (which self-heals to the right branch).
|
* the normal update flow (which self-heals to the right branch).
|
||||||
|
*
|
||||||
|
* Runs on every session open; closing the toast snoozes it for a cooldown so it
|
||||||
|
* doesn't nag on every thread switch.
|
||||||
*/
|
*/
|
||||||
export function reportBackendContract(contract: number | undefined): void {
|
export function reportBackendContract(contract: number | undefined): void {
|
||||||
if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) {
|
if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) {
|
||||||
dismissNotification(SKEW_TOAST_ID)
|
dismissNotification(SKEW_TOAST_ID)
|
||||||
|
// Backend caught up — forget any prior snooze so a future regression warns
|
||||||
|
// immediately rather than staying silent for the rest of the window.
|
||||||
|
persistString(SKEW_TOAST_SNOOZE_KEY, null)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSkewToastSnoozed()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyBackendUpdate() },
|
action: {
|
||||||
|
label: translateNow('notifications.updateHermes'),
|
||||||
|
onClick: () => {
|
||||||
|
snoozeSkewToast()
|
||||||
|
void applyBackendUpdate()
|
||||||
|
}
|
||||||
|
},
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
id: SKEW_TOAST_ID,
|
id: SKEW_TOAST_ID,
|
||||||
kind: 'warning',
|
kind: 'warning',
|
||||||
message: translateNow('notifications.backendOutOfDateMessage'),
|
message: translateNow('notifications.backendOutOfDateMessage'),
|
||||||
|
onDismiss: () => snoozeSkewToast(),
|
||||||
title: translateNow('notifications.backendOutOfDateTitle')
|
title: translateNow('notifications.backendOutOfDateTitle')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,9 @@ export interface OAuthProviderStatus {
|
|||||||
|
|
||||||
export interface OAuthProvider {
|
export interface OAuthProvider {
|
||||||
cli_command: string
|
cli_command: string
|
||||||
|
/** Shell command that clears an external provider's credentials, run in the
|
||||||
|
* embedded terminal. Null when Hermes doesn't know how to remove it. */
|
||||||
|
disconnect_command?: null | string
|
||||||
disconnect_hint?: null | string
|
disconnect_hint?: null | string
|
||||||
disconnectable?: boolean
|
disconnectable?: boolean
|
||||||
docs_url: string
|
docs_url: string
|
||||||
|
|||||||
9
cli.py
9
cli.py
@ -12082,6 +12082,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
|
|
||||||
# Create the input area with multiline (Alt+Enter), autocomplete, and paste handling
|
# Create the input area with multiline (Alt+Enter), autocomplete, and paste handling
|
||||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||||
|
from prompt_toolkit.completion import ThreadedCompleter
|
||||||
|
|
||||||
|
|
||||||
_completer = SlashCommandCompleter(
|
_completer = SlashCommandCompleter(
|
||||||
@ -12097,7 +12098,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
wrap_lines=True,
|
wrap_lines=True,
|
||||||
read_only=Condition(lambda: bool(cli_ref._command_running)),
|
read_only=Condition(lambda: bool(cli_ref._command_running)),
|
||||||
history=FileHistory(str(self._history_file)),
|
history=FileHistory(str(self._history_file)),
|
||||||
completer=_completer,
|
# complete_while_typing fires the completer on every keystroke. The
|
||||||
|
# completer does blocking work — fuzzy @-file indexing shells out to
|
||||||
|
# rg/fd (up to a 2s timeout) and path completion hits os.listdir/stat
|
||||||
|
# — so running it inline would stall the render loop on each key (very
|
||||||
|
# noticeable on WSL2/slow filesystems). ThreadedCompleter moves it off
|
||||||
|
# the UI event loop, keeping typing responsive.
|
||||||
|
completer=ThreadedCompleter(_completer),
|
||||||
complete_while_typing=True,
|
complete_while_typing=True,
|
||||||
auto_suggest=SlashCommandAutoSuggest(
|
auto_suggest=SlashCommandAutoSuggest(
|
||||||
history_suggest=AutoSuggestFromHistory(),
|
history_suggest=AutoSuggestFromHistory(),
|
||||||
|
|||||||
@ -28,14 +28,13 @@ as_hermes() { [ "$(id -u)" = 0 ] || { "$@"; return; }; s6-setuidgid hermes "$@";
|
|||||||
# arbitrary host UID (the classic `--user $(id -u):$(id -g)` invocation people
|
# arbitrary host UID (the classic `--user $(id -u):$(id -g)` invocation people
|
||||||
# used in the tini era to make container-written files match their host user).
|
# used in the tini era to make container-written files match their host user).
|
||||||
#
|
#
|
||||||
# Under s6-overlay this no longer works: the bootstrap (UID remap, volume +
|
# Under s6-overlay this no longer works: the bootstrap (UID remap, data-volume
|
||||||
# build-tree chown, config seeding) all require root, and they're skipped when
|
# ownership, config seeding) requires root, and it is skipped when the container
|
||||||
# the container starts non-root. The baked image trees (/opt/data, /opt/hermes/
|
# starts non-root. The baked install tree under /opt/hermes is intentionally
|
||||||
# .venv, ui-tui, node_modules) stay owned by the hermes build UID (10000), so an
|
# root-owned and non-writable; mutable runtime state must live under
|
||||||
# arbitrary `--user` UID can't write them — the runtime then fails with EACCES
|
# $HERMES_HOME. An arbitrary `--user` UID therefore cannot repair or populate
|
||||||
# on a bind mount, or hard-crashes on a named volume (Docker initialises the
|
# the data volume, and startup fails with EACCES. See #34837 for the
|
||||||
# volume from the image as UID 10000, and the non-root start can't even `cd`
|
# supervision-tree side of this.
|
||||||
# into $HERMES_HOME). See #34837 for the supervision-tree side of this.
|
|
||||||
#
|
#
|
||||||
# The supported way to match host-side ownership is to start as root (the image
|
# The supported way to match host-side ownership is to start as root (the image
|
||||||
# default) and pass HERMES_UID/HERMES_GID — or the PUID/PGID aliases — which the
|
# default) and pass HERMES_UID/HERMES_GID — or the PUID/PGID aliases — which the
|
||||||
@ -53,9 +52,10 @@ if [ "$cur_uid" != 0 ] && [ "$cur_uid" != "$(id -u hermes)" ]; then
|
|||||||
[stage2] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID).
|
[stage2] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID).
|
||||||
|
|
||||||
This is not supported under the s6-overlay image. The container bootstrap
|
This is not supported under the s6-overlay image. The container bootstrap
|
||||||
(UID remap, volume ownership, dependency installs) needs to start as root,
|
(UID remap, data-volume ownership, config seeding) needs to start as root,
|
||||||
and the baked image directories are owned by the hermes user (UID $(id -u hermes)),
|
and the baked /opt/hermes install tree is intentionally root-owned and
|
||||||
so a pinned --user UID cannot write them — startup will fail.
|
non-writable, so a pinned --user UID cannot repair startup state — startup
|
||||||
|
will fail.
|
||||||
|
|
||||||
To make container-written files match your HOST user, DON'T use --user.
|
To make container-written files match your HOST user, DON'T use --user.
|
||||||
Start the container as root (the default) and pass your host UID/GID instead:
|
Start the container as root (the default) and pass your host UID/GID instead:
|
||||||
@ -207,49 +207,13 @@ if [ "$needs_chown" = true ]; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Fix ownership of build trees under $INSTALL_DIR ---
|
# --- Immutable install tree ---
|
||||||
# Hermes-owned trees under $INSTALL_DIR must be re-chowned whenever the
|
# Do not chown runtime code or dependency trees under $INSTALL_DIR back to the
|
||||||
# runtime hermes UID no longer owns them — otherwise:
|
# hermes user. Hosted/container instances keep mutable state under
|
||||||
# - .venv: lazy_deps.py cannot install platform packages (discord.py,
|
# $HERMES_HOME (/opt/data) and run with PYTHONDONTWRITEBYTECODE plus
|
||||||
# telegram, slack, etc.) with EACCES (#15012, #21100)
|
# HERMES_DISABLE_LAZY_INSTALLS=1. Keeping /opt/hermes root-owned and
|
||||||
# - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when
|
# non-writable prevents an agent session from self-modifying the installed
|
||||||
# the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD
|
# source, venv, TUI bundle, or node_modules and bricking the gateway.
|
||||||
# is set) and writes to ui-tui/dist/. Without this chown the new
|
|
||||||
# hermes UID can't write the build output (#28851).
|
|
||||||
# - gateway: Python writes __pycache__ and runtime artifacts beneath the
|
|
||||||
# gateway package on first import. After a UID remap those source-owned
|
|
||||||
# paths still belong to the build-time UID (10000) unless repaired here,
|
|
||||||
# producing EACCES for the supervised gateway (#27221).
|
|
||||||
# - node_modules: root-level dependencies (puppeteer, web tooling)
|
|
||||||
# that runtime code may walk/update.
|
|
||||||
# The set mirrors the build-time `chown -R hermes:hermes` line in the
|
|
||||||
# Dockerfile — keep them in sync if the Dockerfile chown set changes.
|
|
||||||
# These are under $INSTALL_DIR (not $HERMES_HOME), so the bind-mount
|
|
||||||
# concern doesn't apply — recursive is fine.
|
|
||||||
#
|
|
||||||
# This MUST be gated independently of the $HERMES_HOME ownership check
|
|
||||||
# above. `usermod -u <new> hermes` re-chowns the hermes home dir
|
|
||||||
# ($HERMES_HOME == /opt/data) to the new UID as a side effect, so after a
|
|
||||||
# HERMES_UID/PUID remap `stat $HERMES_HOME` always already matches the new
|
|
||||||
# UID and `needs_chown` is false — but the build trees under /opt/hermes
|
|
||||||
# are NOT touched by usermod and remain owned by the build-time UID
|
|
||||||
# (10000). Gating them on $HERMES_HOME ownership (as #35027 did) silently
|
|
||||||
# skipped this chown on the common PUID/NAS path, regressing lazy installs
|
|
||||||
# and TUI rebuilds. Probe the build trees directly instead: chown only
|
|
||||||
# when the venv is not already owned by the runtime hermes UID. Idempotent
|
|
||||||
# and skips the expensive recursive chown on every restart once ownership
|
|
||||||
# is settled.
|
|
||||||
venv_owner=$(stat -c %u "$INSTALL_DIR/.venv" 2>/dev/null || echo "")
|
|
||||||
if [ -n "$venv_owner" ] && [ "$venv_owner" != "$actual_hermes_uid" ]; then
|
|
||||||
echo "[stage2] Fixing ownership of build trees under $INSTALL_DIR to hermes ($actual_hermes_uid)"
|
|
||||||
chown -R hermes:hermes \
|
|
||||||
"$INSTALL_DIR/.venv" \
|
|
||||||
"$INSTALL_DIR/ui-tui" \
|
|
||||||
"$INSTALL_DIR/gateway" \
|
|
||||||
"$INSTALL_DIR/node_modules" \
|
|
||||||
2>/dev/null || \
|
|
||||||
echo "[stage2] Warning: chown of build trees failed (rootless container?) — continuing"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Always reset ownership of $HERMES_HOME/profiles to hermes on every
|
# Always reset ownership of $HERMES_HOME/profiles to hermes on every
|
||||||
# boot. Profile dirs and files can land owned by root when commands
|
# boot. Profile dirs and files can land owned by root when commands
|
||||||
@ -327,13 +291,25 @@ as_hermes mkdir -p \
|
|||||||
"$HERMES_HOME/pairing" \
|
"$HERMES_HOME/pairing" \
|
||||||
"$HERMES_HOME/platforms/pairing"
|
"$HERMES_HOME/platforms/pairing"
|
||||||
|
|
||||||
# --- Install-method stamp (read by detect_install_method() in hermes status) ---
|
# --- Install-method stamp ---
|
||||||
# Preserved from the tini-era entrypoint (PR #27843). Must be written as
|
# The 'docker' stamp is baked into the immutable install tree at
|
||||||
# the hermes user so ownership matches the file's documented owner.
|
# /opt/hermes/.install_method (see Dockerfile), NOT written here into
|
||||||
# tee is invoked directly via s6-setuidgid (no `sh -c` wrapper) for the
|
# $HERMES_HOME. detect_install_method() reads the code-scoped stamp first.
|
||||||
# same shell-metacharacter safety described above.
|
#
|
||||||
printf 'docker\n' | as_hermes tee "$HERMES_HOME/.install_method" >/dev/null \
|
# Why we no longer stamp $HERMES_HOME: it is a shared DATA volume, commonly
|
||||||
|| true
|
# bind-mounted from the host (~/.hermes:/opt/data) and sometimes shared with a
|
||||||
|
# host-side Desktop/CLI install. Stamping 'docker' here clobbered that host
|
||||||
|
# install's marker, so its in-app updater read 'docker' and refused to run
|
||||||
|
# 'hermes update'. To heal homes already poisoned by older images, remove a
|
||||||
|
# stale 'docker' stamp from $HERMES_HOME if one is present (the host install's
|
||||||
|
# own installer re-creates its code-scoped stamp; a genuine container relies on
|
||||||
|
# the baked /opt/hermes stamp, so deleting the data-dir copy is safe).
|
||||||
|
if [ -f "$HERMES_HOME/.install_method" ]; then
|
||||||
|
stamped="$(tr -d '[:space:]' < "$HERMES_HOME/.install_method" 2>/dev/null || true)"
|
||||||
|
if [ "$stamped" = "docker" ]; then
|
||||||
|
rm -f "$HERMES_HOME/.install_method" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Seed config files (only on first boot) ---
|
# --- Seed config files (only on first boot) ---
|
||||||
seed_one() {
|
seed_one() {
|
||||||
|
|||||||
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"
|
BLUEBUBBLES = "bluebubbles"
|
||||||
QQBOT = "qqbot"
|
QQBOT = "qqbot"
|
||||||
YUANBAO = "yuanbao"
|
YUANBAO = "yuanbao"
|
||||||
|
RELAY = "relay" # generic relay adapter fronted by the connector (EXPERIMENTAL)
|
||||||
@classmethod
|
@classmethod
|
||||||
def _missing_(cls, value):
|
def _missing_(cls, value):
|
||||||
"""Accept unknown platform names only for known plugin adapters.
|
"""Accept unknown platform names only for known plugin adapters.
|
||||||
@ -492,6 +493,13 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
|
|||||||
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
|
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
|
||||||
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
|
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
|
||||||
),
|
),
|
||||||
|
# Relay dials OUT to a connector; it is "connected" once an endpoint URL is
|
||||||
|
# configured (extra["relay_url"] or extra["url"]). The capability descriptor
|
||||||
|
# is negotiated at handshake time, so the URL is the only config-level
|
||||||
|
# signal in the experimental phase. EXPERIMENTAL — may change.
|
||||||
|
Platform.RELAY: lambda cfg: bool(
|
||||||
|
cfg.extra.get("relay_url") or cfg.extra.get("url")
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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())
|
||||||
@ -77,6 +77,13 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)
|
|||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_notify_metadata(metadata: dict | None) -> dict:
|
||||||
|
"""Clone metadata and mark a user-visible reply as notify-worthy."""
|
||||||
|
notify_metadata = dict(metadata) if metadata else {}
|
||||||
|
notify_metadata["notify"] = True
|
||||||
|
return notify_metadata
|
||||||
|
|
||||||
|
|
||||||
def _reply_anchor_for_event(event) -> str | None:
|
def _reply_anchor_for_event(event) -> str | None:
|
||||||
"""Return reply_to id for platforms that need reply semantics.
|
"""Return reply_to id for platforms that need reply semantics.
|
||||||
|
|
||||||
@ -3889,7 +3896,7 @@ class BasePlatformAdapter(ABC):
|
|||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
content=_text,
|
content=_text,
|
||||||
reply_to=_reply_anchor_for_event(event),
|
reply_to=_reply_anchor_for_event(event),
|
||||||
metadata=thread_meta,
|
metadata=_mark_notify_metadata(thread_meta),
|
||||||
)
|
)
|
||||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||||
self._schedule_ephemeral_delete(
|
self._schedule_ephemeral_delete(
|
||||||
@ -3995,7 +4002,7 @@ class BasePlatformAdapter(ABC):
|
|||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
content=_text,
|
content=_text,
|
||||||
reply_to=_reply_anchor_for_event(event),
|
reply_to=_reply_anchor_for_event(event),
|
||||||
metadata=_thread_meta,
|
metadata=_mark_notify_metadata(_thread_meta),
|
||||||
)
|
)
|
||||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||||
self._schedule_ephemeral_delete(
|
self._schedule_ephemeral_delete(
|
||||||
@ -4045,7 +4052,7 @@ class BasePlatformAdapter(ABC):
|
|||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
content=_text,
|
content=_text,
|
||||||
reply_to=_reply_anchor_for_event(event),
|
reply_to=_reply_anchor_for_event(event),
|
||||||
metadata=_thread_meta,
|
metadata=_mark_notify_metadata(_thread_meta),
|
||||||
)
|
)
|
||||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||||
self._schedule_ephemeral_delete(
|
self._schedule_ephemeral_delete(
|
||||||
@ -4268,6 +4275,12 @@ class BasePlatformAdapter(ABC):
|
|||||||
)
|
)
|
||||||
text_content = _recovered
|
text_content = _recovered
|
||||||
|
|
||||||
|
# Final user-visible content (text, TTS, media, files) gets
|
||||||
|
# the existing notify=True marker. Clone once so typing/status
|
||||||
|
# metadata stays unmarked and progress bubbles remain
|
||||||
|
# thread-strict.
|
||||||
|
_final_thread_metadata = _mark_notify_metadata(_thread_metadata)
|
||||||
|
|
||||||
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
|
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
|
||||||
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
|
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
|
||||||
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
|
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
|
||||||
@ -4307,7 +4320,7 @@ class BasePlatformAdapter(ABC):
|
|||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
audio_path=_tts_path,
|
audio_path=_tts_path,
|
||||||
caption=telegram_tts_caption,
|
caption=telegram_tts_caption,
|
||||||
metadata=_thread_metadata,
|
metadata=_final_thread_metadata,
|
||||||
)
|
)
|
||||||
_tts_caption_delivered = bool(
|
_tts_caption_delivered = bool(
|
||||||
telegram_tts_caption and getattr(tts_result, "success", False)
|
telegram_tts_caption and getattr(tts_result, "success", False)
|
||||||
@ -4322,23 +4335,11 @@ class BasePlatformAdapter(ABC):
|
|||||||
if text_content and not _tts_caption_delivered:
|
if text_content and not _tts_caption_delivered:
|
||||||
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
|
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
|
||||||
_reply_anchor = _reply_anchor_for_event(event)
|
_reply_anchor = _reply_anchor_for_event(event)
|
||||||
# Mark final response messages for notification delivery.
|
|
||||||
# Platform adapters that support per-message notification
|
|
||||||
# control (e.g. Telegram's disable_notification) use this
|
|
||||||
# flag to override silent-mode and ensure the final
|
|
||||||
# response triggers a push notification.
|
|
||||||
# Clone to avoid mutating the metadata shared with the
|
|
||||||
# typing-indicator task (which must remain unmarked).
|
|
||||||
if _thread_metadata is not None:
|
|
||||||
_thread_metadata = dict(_thread_metadata)
|
|
||||||
_thread_metadata["notify"] = True
|
|
||||||
else:
|
|
||||||
_thread_metadata = {"notify": True}
|
|
||||||
result = await self._send_with_retry(
|
result = await self._send_with_retry(
|
||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
content=text_content,
|
content=text_content,
|
||||||
reply_to=_reply_anchor,
|
reply_to=_reply_anchor,
|
||||||
metadata=_thread_metadata,
|
metadata=_final_thread_metadata,
|
||||||
)
|
)
|
||||||
_record_delivery(result)
|
_record_delivery(result)
|
||||||
|
|
||||||
@ -4367,7 +4368,7 @@ class BasePlatformAdapter(ABC):
|
|||||||
await self.send_multiple_images(
|
await self.send_multiple_images(
|
||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
images=images,
|
images=images,
|
||||||
metadata=_thread_metadata,
|
metadata=_final_thread_metadata,
|
||||||
human_delay=human_delay,
|
human_delay=human_delay,
|
||||||
)
|
)
|
||||||
except Exception as batch_err:
|
except Exception as batch_err:
|
||||||
@ -4409,7 +4410,7 @@ class BasePlatformAdapter(ABC):
|
|||||||
await self.send_multiple_images(
|
await self.send_multiple_images(
|
||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
images=_batch,
|
images=_batch,
|
||||||
metadata=_thread_metadata,
|
metadata=_final_thread_metadata,
|
||||||
human_delay=human_delay,
|
human_delay=human_delay,
|
||||||
)
|
)
|
||||||
except Exception as batch_err:
|
except Exception as batch_err:
|
||||||
@ -4424,19 +4425,19 @@ class BasePlatformAdapter(ABC):
|
|||||||
media_result = await self.send_voice(
|
media_result = await self.send_voice(
|
||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
audio_path=media_path,
|
audio_path=media_path,
|
||||||
metadata=_thread_metadata,
|
metadata=_final_thread_metadata,
|
||||||
)
|
)
|
||||||
elif ext in _VIDEO_EXTS:
|
elif ext in _VIDEO_EXTS:
|
||||||
media_result = await self.send_video(
|
media_result = await self.send_video(
|
||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
video_path=media_path,
|
video_path=media_path,
|
||||||
metadata=_thread_metadata,
|
metadata=_final_thread_metadata,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
media_result = await self.send_document(
|
media_result = await self.send_document(
|
||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
file_path=media_path,
|
file_path=media_path,
|
||||||
metadata=_thread_metadata,
|
metadata=_final_thread_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not media_result.success:
|
if not media_result.success:
|
||||||
@ -4454,13 +4455,13 @@ class BasePlatformAdapter(ABC):
|
|||||||
await self.send_video(
|
await self.send_video(
|
||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
video_path=file_path,
|
video_path=file_path,
|
||||||
metadata=_thread_metadata,
|
metadata=_final_thread_metadata,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.send_document(
|
await self.send_document(
|
||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
metadata=_thread_metadata,
|
metadata=_final_thread_metadata,
|
||||||
)
|
)
|
||||||
except Exception as file_err:
|
except Exception as file_err:
|
||||||
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
|
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
|
||||||
|
|||||||
@ -678,8 +678,13 @@ class EmailAdapter(BasePlatformAdapter):
|
|||||||
image_url: str,
|
image_url: str,
|
||||||
caption: Optional[str] = None,
|
caption: Optional[str] = None,
|
||||||
reply_to: Optional[str] = None,
|
reply_to: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> SendResult:
|
) -> SendResult:
|
||||||
"""Send an image URL as part of an email body."""
|
"""Send an image URL as part of an email body.
|
||||||
|
|
||||||
|
``metadata`` is accepted to honor the base-class contract; the
|
||||||
|
email body send doesn't use it.
|
||||||
|
"""
|
||||||
text = caption or ""
|
text = caption or ""
|
||||||
text += f"\n\nImage: {image_url}"
|
text += f"\n\nImage: {image_url}"
|
||||||
return await self.send(chat_id, text.strip(), reply_to)
|
return await self.send(chat_id, text.strip(), reply_to)
|
||||||
|
|||||||
@ -1241,6 +1241,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
message_id = (msg.get("result") or {}).get("message_id")
|
message_id = (msg.get("result") or {}).get("message_id")
|
||||||
else:
|
else:
|
||||||
message_id = getattr(msg, "message_id", None)
|
message_id = getattr(msg, "message_id", None)
|
||||||
|
if message_id is not None:
|
||||||
|
# Telegram won't echo rich content in reply_to_message, so remember
|
||||||
|
# what we sent — replies to this message resolve via this index.
|
||||||
|
try:
|
||||||
|
from gateway import rich_sent_store
|
||||||
|
rich_sent_store.record(str(chat_id), str(message_id), content)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return SendResult(
|
return SendResult(
|
||||||
success=True,
|
success=True,
|
||||||
message_id=str(message_id) if message_id is not None else None,
|
message_id=str(message_id) if message_id is not None else None,
|
||||||
@ -6700,6 +6708,19 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
or message.reply_to_message.caption
|
or message.reply_to_message.caption
|
||||||
or None
|
or None
|
||||||
)
|
)
|
||||||
|
if not reply_to_text:
|
||||||
|
# Rich messages (sendRichMessage — the launchd briefings and
|
||||||
|
# the gateway's own rich finals) are NOT echoed with their
|
||||||
|
# content in reply_to_message; Telegram sends no text,
|
||||||
|
# caption, or api_kwargs for them. Recover the text we sent
|
||||||
|
# from our local send-time index, keyed by message id.
|
||||||
|
try:
|
||||||
|
from gateway import rich_sent_store
|
||||||
|
reply_to_text = rich_sent_store.lookup(
|
||||||
|
str(chat.id), reply_to_id
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
reply_to_text = None
|
||||||
|
|
||||||
# Per-channel/topic ephemeral prompt
|
# Per-channel/topic ephemeral prompt
|
||||||
from gateway.platforms.base import resolve_channel_prompt
|
from gateway.platforms.base import resolve_channel_prompt
|
||||||
|
|||||||
@ -846,13 +846,20 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||||||
image_url: str,
|
image_url: str,
|
||||||
caption: Optional[str] = None,
|
caption: Optional[str] = None,
|
||||||
reply_to: Optional[str] = None,
|
reply_to: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> SendResult:
|
) -> SendResult:
|
||||||
"""Download image URL to cache, send natively via bridge."""
|
"""Download image URL to cache, send natively via bridge.
|
||||||
|
|
||||||
|
``metadata`` is accepted to honor the base-class contract — the
|
||||||
|
batch sender ``send_multiple_images`` passes it through to every
|
||||||
|
send path. The bridge media call doesn't use it, matching the
|
||||||
|
sibling overrides (send_video / send_voice / send_document).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
local_path = await cache_image_from_url(image_url)
|
local_path = await cache_image_from_url(image_url)
|
||||||
return await self._send_media_to_bridge(chat_id, local_path, "image", caption)
|
return await self._send_media_to_bridge(chat_id, local_path, "image", caption)
|
||||||
except Exception:
|
except Exception:
|
||||||
return await super().send_image(chat_id, image_url, caption, reply_to)
|
return await super().send_image(chat_id, image_url, caption, reply_to, metadata)
|
||||||
|
|
||||||
async def send_image_file(
|
async def send_image_file(
|
||||||
self,
|
self,
|
||||||
@ -1136,6 +1143,15 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||||||
body = data.get("body", "")
|
body = data.get("body", "")
|
||||||
if data.get("isGroup"):
|
if data.get("isGroup"):
|
||||||
body = self._clean_bot_mention_text(body, data)
|
body = self._clean_bot_mention_text(body, data)
|
||||||
|
|
||||||
|
# If this is a reply, include the quoted message text so the agent
|
||||||
|
# knows exactly what the user is responding to (fixes "approve" context issue)
|
||||||
|
quoted_text = str(data.get("quotedText") or "").strip()
|
||||||
|
if quoted_text and data.get("hasQuotedMessage"):
|
||||||
|
# Truncate long quoted text to keep prompts reasonable
|
||||||
|
if len(quoted_text) > 300:
|
||||||
|
quoted_text = quoted_text[:297] + "..."
|
||||||
|
body = f"[Replying to: \"{quoted_text}\"]\n{body}"
|
||||||
MAX_TEXT_INJECT_BYTES = 100 * 1024
|
MAX_TEXT_INJECT_BYTES = 100 * 1024
|
||||||
if msg_type == MessageType.DOCUMENT and cached_urls:
|
if msg_type == MessageType.DOCUMENT and cached_urls:
|
||||||
for doc_path in cached_urls:
|
for doc_path in cached_urls:
|
||||||
|
|||||||
398
gateway/relay/__init__.py
Normal file
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"),
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user