Compare commits

...
Author SHA1 Message Date
Teknium e2d80b6885 feat(security): deny writes to startup and global git config
Block write tool edits to secondary shell startup files and global git config paths while preserving project-local `.git/config` writes.
2026-06-13 06:52:11 -07:00
konsisumer 16fb573bae fix(gateway): clear bloated compression binding on compression-exhaustion auto-reset
After compression exhaustion the auto-reset created a fresh session but
discarded reset_session()'s return value and left the Telegram topic
binding pointing at the oversized compressed child. The next inbound
message in that topic healed the binding forward and switch_session'd the
freshly-reset lane back onto the bloated transcript, re-triggering
compression exhaustion in a loop with a new session id each time.

Capture the fresh entry and re-sync the topic binding to it so the next
message starts clean. No-op on non-topic lanes.

Regression of the #9893/#10063 auto-reset fix.

Fixes #35809
2026-06-13 06:38:29 -07:00
Teknium 6f43ff5572 chore(release): map Gemini schema contributor 2026-06-13 06:12:52 -07:00
Henrik Bentel eed61a1251 fix(gemini): add role field to systemInstruction 2026-06-13 06:12:52 -07:00
Teknium 74c5158b10 fix(model): show bare custom endpoints in gateway picker (#45597)
Surface direct model.provider=custom endpoints in /model picker output and keep explicit bare custom switches on the current endpoint instead of requiring a named providers/custom_providers row.
2026-06-13 06:05:30 -07:00
Teknium 6724daa2c2 fix: keep CLI idle timer ticking (#45592) 2026-06-13 05:55:04 -07:00
Teknium aa53a78d67 fix(desktop): hand off Windows bootstrap recovery (#45594) 2026-06-13 05:54:32 -07:00
Teknium 0333a99925 fix: merge session-only model analytics rows (#45582) 2026-06-13 05:52:42 -07:00
Tranquil-Flow 5acd185f7c fix(moonshot): handle union type arrays in tool schemas 2026-06-13 05:51:41 -07:00
Teknium 39a35b784f chore(release): map custom provider resume contributors 2026-06-13 05:51:05 -07:00
Adalsteinn HelgasonandClaude Opus 4.8 2667601c05 fix(tui): keep reasoning-only assistant turns visible on session resume
A thinking-only assistant turn (reasoning present, empty visible text) is
persisted with its reasoning fields and stays recallable from the transcript,
but `_history_to_messages` dropped it as "empty" before its reasoning was
attached. On desktop/TUI resume or reload the turn therefore vanished from the
session view while the agent could still recall it from a fresh session --
exactly the "messages disappear when the LLM uses its thinking block, but a new
session can recall them" symptom reported on #44022.

Keep an assistant turn when it carries reasoning, even with empty text, so the
desktop "Thinking…" disclosure has something to render. Genuinely empty turns
(no text, no reasoning, no tool calls) are still filtered out.

Refs #44022

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 05:51:05 -07:00
Adalsteinn HelgasonandClaude Fable 5 643dc82793 Fix custom provider identity loss in session persistence
_runtime_model_config persisted the live agent's RESOLVED provider into
the session row's model_config JSON. For any named providers:/
custom_providers: entry, agent.provider is the literal string "custom",
so the entry name was lost (and the api_key is deliberately never
persisted). On session.resume or _reset_session_agent the stored
provider="custom" fed resolve_runtime_provider(requested="custom"),
which cannot match a named entry — the rebuild either raised "No LLM
provider configured" or silently resolved placeholder credentials
against the patched-back base_url.

Persist the REQUESTED/entry identity instead: a new reverse lookup
find_custom_provider_identity(base_url) maps the endpoint URL back to
the canonical custom:<name> menu key. _runtime_model_config stores that
key; _make_agent performs the same recovery for rows persisted before
the fix, falling back to passing the stored base_url as
explicit_base_url so the direct-alias branch still targets the
session's endpoint when no entry matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 05:51:05 -07:00
Haozhe Zhang e256f4aae4 fix(gateway): don't restore a bare billing provider as the resumed session's provider
`_stored_session_runtime_overrides` restored the session provider from
`billing_provider` when `model_config` had no explicit provider. For a
`custom:<name>` endpoint that only ran normal turns (no `/model` switch), the
persisted `billing_provider` is the bare billing bucket `"custom"`, which
`agent_init` treats as non-routable, so `session.resume` failed with
"No LLM provider configured" even though new chats and CLI `--resume` work.

Only restore an explicit `model_config.provider`; skip a bare billing bucket
(`auto`/`openrouter`/`custom`) so resume falls back to the configured default,
matching the CLI path.

Fixes #44022
2026-06-13 05:51:05 -07:00
Teknium cb125c2b3f fix(kanban): pin assigned profile toolsets for workers (#45590) 2026-06-13 05:50:09 -07:00
Teknium a59d5e37e8 feat(telegram): make rich messages always on (#45584)
Remove the rich_messages config toggle entirely so Telegram replies always try the Bot API 10.1 rich-message path first, with the existing MarkdownV2 fallback/latch behavior for unsupported endpoints and per-message failures.

Restore the Telegram platform hint to encourage rich Markdown tables/task lists/math now that the rich path is the default, and remove the config/docs surface for the old toggle.
2026-06-13 05:45:11 -07:00
Teknium 4b646bc21e fix(auxiliary): preserve main provider base url (#45587) 2026-06-13 05:44:18 -07:00
Teknium 62b4618e9a fix(dashboard): scope sessions and analytics to selected profile (#45598) 2026-06-13 05:42:38 -07:00
H-Ali13381 2abcae9678 fix(cli): preserve renderer state on resize 2026-06-13 05:40:18 -07:00
xxxigm c814d3d1dd test(installer): regression for unmerged-index update failure
Functional bash test drives install.sh's autostash block against a throwaway
repo with a real conflicted index and asserts the stash now succeeds and the
unmerged entries are cleared (previously `git stash` failed with "could not
write index"). Source-order assertions cover both scripts to ensure the
`git reset` clear runs before `git stash push` (a no-op otherwise).
2026-06-13 05:19:44 -07:00
xxxigm 573b964dc7 fix(installer): clear an unmerged git index before stashing on update
When an existing install at $INSTALL_DIR has an unmerged index (files in a
"needs merge" state left by a previously interrupted update), the update path
ran `git stash` then `git checkout <branch>`. On a conflicted index `git stash`
aborts with "could not write index" and `git checkout` then aborts with "you
need to resolve your current index first" — surfacing to desktop/bootstrap
users as `git checkout main failed (exit 1)` and failing the whole install at
the repository stage.

Mirror the `hermes update` Python path (#4735): detect unmerged entries with
`git ls-files --unmerged` and clear the conflict state with `git reset` before
stashing. Working-tree changes are still captured by the subsequent stash, so
nothing is discarded; only the index-level conflict markers are dropped, which
lets the checkout proceed.

Fixed in both installers (install.sh and install.ps1) so the Windows GUI
installer and the POSIX one share the same recovery behavior.
2026-06-13 05:19:44 -07:00
Teknium aa0798352a fix(auth): self-heal missing Codex access tokens
Recover Codex singleton auth entries that have a refresh token but no access token by adopting a valid Codex CLI token pair, matching the cron-time failure mode before falling back to the credential pool.
2026-06-13 05:15:26 -07:00
Kennedy UmegeandClaude Opus 4.8 311ff967de review: validate refresh_token, path-agnostic recovery log, map author email
Addresses PR review feedback:
- Validate refresh_token (not only access_token) before persisting the
  re-imported Codex token, so a half-token payload can't silently break the
  next refresh cycle.
- Make the recovery log path-agnostic ("Codex CLI auth.json") since
  _import_codex_cli_tokens can read $CODEX_HOME, not only ~/.codex.
- Add regression test: relogin-required + imported token missing refresh_token
  -> re-raise and persist nothing.
- Map kenmege@yahoo.com -> Kenmege in scripts/release.py AUTHOR_MAP
  (fixes the check-attribution job).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 05:15:26 -07:00
Kennedy UmegeandClaude Opus 4.8 bd66e7e3fb fix(auth): self-heal Codex refresh_token rotation by reimporting from ~/.codex
Hermes keeps its own copy of the Codex OAuth token per profile and at the
top level, separate from the Codex CLI's ~/.codex/auth.json. OAuth
refresh_tokens are single-use, so when the Codex CLI (or another Hermes
process) rotates the shared token, the frozen copy's refresh_token goes
stale and refresh_codex_oauth_pure fails with a relogin-required error
(invalid_grant / refresh_token_reused / 401). Today that surfaces as a hard
401 on the turn — idle profiles and desktop sessions 401 "token_expired"
until a manual re-auth — even though ~/.codex/auth.json holds a fresh token.

_refresh_codex_auth_tokens now falls back to _import_codex_cli_tokens() (the
canonical Codex CLI store) when the stored refresh_token is rejected, adopts
and persists the fresh token, and lets the in-flight retry succeed. This
complements PR #6525 (force relogin on 401/403): we attempt automatic
recovery before surfacing a relogin prompt. Transient failures (e.g. 429
quota, relogin_required=False) are never self-healed — the stored token is
still valid there — so they re-raise unchanged, and the happy path is
untouched.

Adds tests/hermes_cli/test_auth_codex_self_heal.py covering: self-heal on
invalid_grant, no self-heal on 429 quota, re-raise when ~/.codex is absent,
and happy-path-unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 05:15:26 -07:00
Teknium 2681c5a12d fix(photon): correct gateway start command (#45566) 2026-06-13 05:14:59 -07:00
xxxigm fa2aba90b4 docs(docker): explain per-profile gateway ports for multi-profile setups
The Multi-profile section never explained how to reach more than one
profile from outside the container, and distinguishes the two surfaces
that people conflate:

- Hermes Desktop's Remote Gateway connects to a `hermes dashboard`
  backend (port 9119), and a single dashboard serves every co-located
  profile via its profile switcher (the target profile is sent per
  request; the backend opens that profile's HERMES_HOME). No per-profile
  port or second connection is needed for Desktop.
- OpenAI-compatible API clients (Open WebUI, LobeChat, /v1) talk to each
  profile's API server, which binds 8642 for every profile with no
  auto-allocation. Reaching a second profile from such a client needs a
  distinct `API_SERVER_PORT` in that profile's own `.env` (and the port
  must NOT go in the container-wide `environment:` block, or every
  profile collides on it).

Adds the create -> set port -> restart flow, the bridge port-publishing
note, and clarifies the default profile's connection is untouched.
2026-06-13 05:13:25 -07:00
xxxigm 5b857201b7 fix(profiles): correct misleading per-profile gateway port docstrings
The s6 profile-gateway docstrings claimed the bind port comes from a
`[gateway] port` key in config.yaml ("the single source of truth"). No such
key exists or is read anywhere — the API server port is resolved by
gateway/config.py from `API_SERVER_PORT` (or `platforms.api_server.extra.port`)
and defaults to 8642. The wrong reference actively misled a Docker user into
setting a non-functional `gateway.port`.

Point both docstrings (`S6ServiceManager._render_run_script`,
`_maybe_register_gateway_service`) at the real knob, and note the practical
consequence: since each supervised profile gateway loads its own HERMES_HOME,
two profiles left at the default both try to bind 8642 — each needs a distinct
`API_SERVER_PORT` in its own `.env`.
2026-06-13 05:13:25 -07:00
Teknium 905ed413d1 fix(doctor): avoid unsafe npm audit fallback
Root-level npm audit fix can crash with isDescendantOf on the same monorepo tree, so workspace audit advisories should explain the lockfile-bump path instead of recommending another manual npm fix command.
2026-06-13 05:09:56 -07:00
xxxigm bea6c1c01f test(doctor): assert audit-fix hint avoids crashing form and explains build-tool advisories 2026-06-13 05:09:56 -07:00
xxxigm a5e9b17ce3 fix(doctor): stop recommending the npm-crashing audit fix, explain build-tool advisories
`hermes doctor` flagged the web/ui-tui workspaces and told the user to run
`npm audit fix --workspace <name>`, which crashes current npm with
"Cannot read properties of null (reading 'edgesOut')" (an arborist bug with
workspace-filtered audit fix). Recommend the root-level `npm audit fix`
instead.

Even the root form can hit a known npm arborist crash (edgesOut /
isDescendantOf) on this monorepo tree, so add a note that these workspace
advisories are build-time tooling (esbuild/vite, etc.) — not runtime code —
and clear via a lockfile bump rather than a manual fix. This keeps doctor
from handing users a command that errors out and from implying a broken
Hermes install.
2026-06-13 05:09:56 -07:00
xxxigm 5d6c16e972 test(desktop): cover the inline command expander on the approval bar
Asserts the full command is absent until the Command toggle is clicked, then
rendered in full — guarding the long-command reveal path.
2026-06-13 05:08:37 -07:00
xxxigm 266b5a19f1 feat(desktop): expand the full command inline from the approval bar
The native desktop approval bar deliberately omits the command because the
pending tool row "already shows it" — but that row only renders a single
truncated line, and a pending row can't be expanded (it has no result yet). So
the full command was only reachable by opening the "Always allow" dropdown,
reading the modal, cancelling, then clicking Run — 4-5 clicks just to see what
you're approving.

Add a "Command" toggle to the approval bar that reveals the full
`request.command` inline (reusing the dialog's pre styling), default collapsed.
Approving a long command is now "expand, Run". Gated on a non-empty command so
zero-command approvals are unaffected.
2026-06-13 05:08:37 -07:00
Black-KylinandHermes 202e318cb1 fix(gateway): sync compression session splits before failures
Salvages PR #25747 by preserving gateway session rotation even when a post-compression model call fails before returning final content.

Co-authored-by: Hermes <127238744+teknium1@users.noreply.github.com>
2026-06-13 04:51:59 -07:00
helix4u 2d474e39c7 fix(acp): preserve memory provider tools 2026-06-13 04:51:44 -07:00
68 changed files with 2791 additions and 518 deletions
+17
View File
@@ -824,6 +824,7 @@ class HermesACPAgent(acp.Agent):
try:
from model_tools import get_tool_definitions
from agent.memory_manager import inject_memory_provider_tools
enabled_toolsets = _expand_acp_enabled_toolsets(
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
@@ -839,6 +840,7 @@ class HermesACPAgent(acp.Agent):
state.agent.valid_tool_names = {
tool["function"]["name"] for tool in state.agent.tools or []
}
inject_memory_provider_tools(state.agent)
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
if callable(invalidate):
invalidate()
@@ -1779,10 +1781,25 @@ class HermesACPAgent(acp.Agent):
def _cmd_tools(self, args: str, state: SessionState) -> str:
try:
from model_tools import get_tool_definitions
from types import SimpleNamespace
from agent.memory_manager import inject_memory_provider_tools
toolsets = _expand_acp_enabled_toolsets(
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
)
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
tool_view = SimpleNamespace(
tools=list(tools or []),
valid_tool_names={
tool.get("function", {}).get("name")
for tool in tools or []
if isinstance(tool, dict)
},
enabled_toolsets=toolsets,
_memory_manager=getattr(state.agent, "_memory_manager", None),
)
inject_memory_provider_tools(tool_view)
tools = tool_view.tools
if not tools:
return "No tools available."
lines = [f"Available tools ({len(tools)}):"]
+2 -32
View File
@@ -1193,38 +1193,8 @@ def init_agent(
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
agent._memory_manager = None
# Inject memory provider tool schemas into the tool surface.
# Skip tools whose names already exist (plugins may register the
# same tools via ctx.register_tool(), which lands in agent.tools
# through _ra().get_tool_definitions()). Duplicate function names cause
# 400 errors on providers that enforce unique names (e.g. Xiaomi
# MiMo via Nous Portal).
#
# Respect the platform's enabled_toolsets configuration (#5544):
# enabled_toolsets is None → no filter, inject (backward compat)
# "memory" in enabled_toolsets → user opted in, inject
# otherwise (incl. []) → user excluded memory, skip injection
#
# Without this gate, `platform_toolsets: telegram: []` still leaks memory
# provider tools (fact_store, etc.) into the tool surface — a 10x latency
# penalty on local models and a frequent trigger of tool-call loops.
if agent._memory_manager and agent.tools is not None and (
agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets
):
_existing_tool_names = {
t.get("function", {}).get("name")
for t in agent.tools
if isinstance(t, dict)
}
for _schema in agent._memory_manager.get_all_tool_schemas():
_tname = _schema.get("name", "")
if _tname and _tname in _existing_tool_names:
continue # already registered via plugin path
_wrapped = {"type": "function", "function": _schema}
agent.tools.append(_wrapped)
if _tname:
agent.valid_tool_names.add(_tname)
_existing_tool_names.add(_tname)
from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools
_inject_memory_provider_tools(agent)
# Skills config: nudge interval for skill creation reminders
agent._skill_nudge_interval = 10
+1 -1
View File
@@ -3190,7 +3190,7 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
if (main_provider and main_model
and main_provider not in {"auto", ""}):
resolved_provider = main_provider
explicit_base_url = None
explicit_base_url = runtime_base_url or None
explicit_api_key = None
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
resolved_provider = "custom"
+5
View File
@@ -51,6 +51,10 @@ def build_write_denied_paths(home: str) -> set[str]:
os.path.join(home, ".profile"),
os.path.join(home, ".bash_profile"),
os.path.join(home, ".zprofile"),
os.path.join(home, ".zshenv"),
os.path.join(home, ".zlogin"),
os.path.join(home, ".bash_login"),
os.path.join(home, ".gitconfig"),
os.path.join(home, ".netrc"),
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),
@@ -78,6 +82,7 @@ def build_write_denied_prefixes(home: str) -> list[str]:
os.path.join(home, ".azure"),
os.path.join(home, ".config", "gh"),
os.path.join(home, ".config", "gcloud"),
os.path.join(home, ".config", "git"),
]
]
+1 -1
View File
@@ -330,7 +330,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
system_instruction = None
joined_system = "\n".join(part for part in system_text_parts if part).strip()
if joined_system:
system_instruction = {"parts": [{"text": joined_system}]}
system_instruction = {"role": "system", "parts": [{"text": joined_system}]}
return contents, system_instruction
+60
View File
@@ -44,6 +44,66 @@ logger = logging.getLogger(__name__)
_SYNC_DRAIN_TIMEOUT_S = 5.0
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
"""Return whether external memory-provider tools should be exposed."""
if enabled_toolsets is None:
return True
if not enabled_toolsets:
return False
if "memory" in enabled_toolsets:
return True
try:
from toolsets import resolve_toolset
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
except Exception:
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
return False
def inject_memory_provider_tools(agent: Any) -> int:
"""Append external memory-provider tool schemas to an agent tool surface."""
memory_manager = getattr(agent, "_memory_manager", None)
tools = getattr(agent, "tools", None)
if not memory_manager or tools is None:
return 0
existing_tool_names = {
tool.get("function", {}).get("name")
for tool in tools
if isinstance(tool, dict)
}
if (
"memory" not in existing_tool_names
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
):
return 0
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
if not callable(get_schemas):
return 0
valid_tool_names = getattr(agent, "valid_tool_names", None)
if valid_tool_names is None:
valid_tool_names = set()
agent.valid_tool_names = valid_tool_names
added = 0
for schema in get_schemas():
if not isinstance(schema, dict):
continue
tool_name = schema.get("name", "")
if not tool_name or tool_name in existing_tool_names:
continue
tools.append({"type": "function", "function": schema})
valid_tool_names.add(tool_name)
existing_tool_names.add(tool_name)
added += 1
return added
# ---------------------------------------------------------------------------
# Context fencing helpers
# ---------------------------------------------------------------------------
+8 -1
View File
@@ -135,7 +135,14 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
"""Infer a reasonable ``type`` if this schema node has none."""
if "type" in node and node["type"] not in {None, ""}:
node_type = node.get("type")
if isinstance(node_type, list):
concrete = next(
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
"string",
)
return {**node, "type": concrete}
if "type" in node and node_type not in {None, ""}:
return node
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``
+8 -5
View File
@@ -508,13 +508,16 @@ PLATFORM_HINTS = {
),
"telegram": (
"You are on a text messaging communication platform, Telegram. "
"Standard markdown is automatically converted to Telegram format. "
"Standard Markdown is automatically converted to Telegram formatting. "
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
"`inline code`, ```code blocks```, [links](url), and ## headers. "
"Telegram has NO table syntax — prefer bullet lists or labeled "
"key: value pairs over pipe tables (any tables you do emit are "
"auto-rewritten into row-group bullets, which you can produce "
"directly for cleaner output). "
"Telegram supports rich Markdown, so when it improves clarity you may "
"use headings, tables (pipe `| col | col |` syntax), task lists "
"(`- [ ]` / `- [x]`), nested blockquotes, collapsible details, "
"footnotes/references, math/formulas (`$...$`, `$$...$$`), underline, "
"subscript/superscript, marked (highlighted) text, and anchors. Prefer "
"real Markdown tables and task lists over hand-built bullet substitutes "
"when presenting structured data. "
"You can send media files natively: to deliver a file to the user, "
"include MEDIA:/absolute/path/to/file in your response. Images "
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
@@ -3,8 +3,9 @@
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
//!
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
//! shim is free; otherwise `hermes update` aborts with exit code 2),
//! 1. wait for the old Hermes desktop process to fully exit (so both the
//! venv shim and packaged app.asar are free; otherwise `hermes update`
//! or repair bootstrap can race locked files),
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
@@ -38,8 +39,8 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
const UPDATE_EXIT_CONCURRENT: i32 = 2;
/// How long to wait for the old desktop process to release the venv shim
/// before giving up and letting `hermes update`'s own guard decide.
/// How long to wait for the old desktop process to release files under the
/// install tree before giving up and letting `hermes update`'s own guard decide.
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
@@ -150,8 +151,10 @@ async fn run_update(app: AppHandle) -> Result<()> {
// ---- pre-step: wait for the old desktop to die -----------------------
// The desktop exec'd us then called app.exit(), but process teardown is
// async on Windows. If it still holds the venv shim, `hermes update`
// aborts with exit 2. Give it a bounded window to clear.
wait_for_venv_free(&install_root, &app).await;
// aborts with exit 2. If it still holds the packaged app.asar,
// install.ps1's repair/re-clone path cannot move/remove the install tree.
// Give both handles a bounded window to clear.
wait_for_install_locks_free(&install_root, &app, "update").await;
// ---- stage 1: hermes update -----------------------------------------
// Pass --branch so `hermes update` targets the branch this installer was
@@ -173,8 +176,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
vec!["update".into(), "--yes".into(), "--gateway".into()];
// --force skips `hermes update`'s Windows running-exe guard (which would
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
// already exited and waited for the venv shim to unlock before launching
// us, and wait_for_venv_free below force-kills any straggler — so by the
// already exited and waited for the install locks to clear before launching
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
// time `hermes update` runs there is no legitimate hermes.exe to protect,
// and the guard would only produce a false "Hermes is still running" stop.
update_args.push("--force".into());
@@ -391,48 +394,57 @@ async fn run_update(app: AppHandle) -> Result<()> {
Ok(())
}
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
/// elapses. On non-Windows this is a short fixed grace since file locking
/// isn't the failure mode there.
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
let shim = venv_hermes(install_root);
/// Poll until the venv shim AND packaged desktop app bundle are no longer locked
/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed
/// grace since file locking isn't the failure mode there.
pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) {
let lock_targets = install_lock_probe_paths(install_root);
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…");
loop {
if !is_locked(&shim) {
let locked = locked_paths(&lock_targets);
if locked.is_empty() {
return;
}
if Instant::now() >= deadline {
// Last resort: a backend hermes.exe (or a grandchild it spawned)
// is still holding the shim. The desktop should have reaped its
// tree before handing off, but SIGTERM races / detached
// grandchildren / AV handles can leave a straggler. Rather than
// "proceed anyway" straight into uv's "Access is denied", force-kill
// every hermes.exe except ourselves, then give the OS a beat to
// unload the image.
// Last resort: a backend hermes.exe (or the desktop Hermes.exe
// itself) is still holding one of the update-sensitive files. The
// desktop should have reaped its tree before handing off, but
// SIGTERM races / detached grandchildren / AV handles can leave a
// straggler. Rather than "proceed anyway" straight into uv's
// "Access is denied" or install.ps1's locked app.asar failure,
// force-kill every Hermes.exe except ourselves, then give the OS a
// beat to unload the image.
emit_log(
app,
Some("update"),
Some(stage),
LogStream::Stdout,
"[update] Hermes still holding the venv shim; force-killing stragglers…",
&format!(
"[handoff] Hermes still holding install files ({}); force-killing stragglers…",
format_locked_paths(&locked)
),
);
force_kill_other_hermes();
tokio::time::sleep(Duration::from_millis(800)).await;
if !is_locked(&shim) {
let locked_after_kill = locked_paths(&lock_targets);
if locked_after_kill.is_empty() {
emit_log(
app,
Some("update"),
Some(stage),
LogStream::Stdout,
"[update] venv shim freed after force-kill",
"[handoff] install files freed after force-kill",
);
} else {
emit_log(
app,
Some("update"),
Some(stage),
LogStream::Stdout,
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
&format!(
"[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)",
format_locked_paths(&locked_after_kill)
),
);
}
return;
@@ -441,13 +453,44 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
}
}
fn install_lock_probe_paths(install_root: &Path) -> Vec<PathBuf> {
let mut paths = vec![venv_hermes(install_root)];
paths.extend(desktop_app_payload_paths(install_root));
paths
}
fn desktop_app_payload_paths(install_root: &Path) -> Vec<PathBuf> {
let release = install_root.join("apps").join("desktop").join("release");
if cfg!(target_os = "windows") {
vec![
release.join("win-unpacked").join("resources").join("app.asar"),
release.join("win-arm64-unpacked").join("resources").join("app.asar"),
]
} else if cfg!(target_os = "macos") {
vec![
release.join("mac").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
release.join("mac-arm64").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
]
} else {
vec![release.join("linux-unpacked").join("resources").join("app.asar")]
}
}
fn locked_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
paths.iter().filter(|p| is_locked(p)).cloned().collect()
}
fn format_locked_paths(paths: &[PathBuf]) -> String {
paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
}
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
/// target "the backend" by PID here — the desktop already exited and we never
/// knew its children — so we kill the whole `hermes.exe` image tree via
/// taskkill, excluding our own PID.
///
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
/// Safe w.r.t. our own update child: this runs inside the install-lock wait,
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
@@ -891,6 +934,29 @@ mod tests {
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
}
#[test]
fn lock_probe_paths_include_desktop_app_payload() {
let root = Path::new("/x/hermes-agent");
let probes = install_lock_probe_paths(root);
assert!(
probes.iter().any(|p| p == &venv_hermes(root)),
"venv shim remains part of the update lock probe"
);
assert!(
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
);
}
#[test]
fn locked_paths_ignores_missing_payloads() {
let root = Path::new("/nonexistent/hermes-agent");
let probes = install_lock_probe_paths(root);
assert!(locked_paths(&probes).is_empty());
}
#[test]
fn parses_update_branch_from_space_or_equals_args() {
assert_eq!(
+46
View File
@@ -1835,6 +1835,44 @@ async function applyUpdates(opts = {}) {
}
}
async function handOffWindowsBootstrapRecovery(reason) {
if (!IS_WINDOWS || !IS_PACKAGED) return false
const updater = resolveUpdaterBinary()
if (!updater) return false
const updateRoot = resolveUpdateRoot()
const { branch: configuredBranch } = readDesktopUpdateConfig()
const branch = directoryExists(path.join(updateRoot, '.git'))
? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
: configuredBranch || DEFAULT_UPDATE_BRANCH
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
await releaseBackendLockForUpdate(updateRoot)
const child = spawn(updater, updaterArgs, {
cwd: HERMES_HOME,
env: {
...process.env,
HERMES_HOME,
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
},
detached: true,
stdio: 'ignore',
windowsHide: false
})
child.unref()
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
setTimeout(() => {
app.quit()
}, 600)
return true
}
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
// the install we're updating, fall back to `hermes` on PATH.
function resolveHermesCliBinary(updateRoot) {
@@ -2432,6 +2470,14 @@ async function ensureRuntime(backend) {
if (backend.kind === 'bootstrap-needed') {
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
handoffError.isBootstrapFailure = true
handoffError.bootstrapHandedOff = true
bootstrapFailure = handoffError
throw handoffError
}
// Eagerly flip the bootstrap UI state to 'active' so the renderer
// shows the install overlay BEFORE the runner finishes fetching the
// manifest (which on slow networks can take tens of seconds and would
@@ -42,6 +42,9 @@ test('intentional or interactive desktop child processes stay documented', () =>
const source = readElectronFile('main.cjs')
assert.match(source, /windowsHide: false/)
assert.match(source, /handOffWindowsBootstrapRecovery/)
assert.match(source, /'--repair', '--branch'/)
assert.match(source, /'--update', '--branch'/)
assert.match(source, /nodePty\.spawn\(command, args/)
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
})
@@ -84,6 +84,19 @@ describe('PendingToolApproval', () => {
expect($approvalRequest.get()).toBeNull()
})
it('reveals the full command inline when the Command toggle is clicked', () => {
const longCommand = 'python -c "' + 'x'.repeat(400) + '"'
setRequest(longCommand)
render(<PendingToolApproval part={part('terminal')} />)
// Collapsed by default: the full command is not in the DOM yet.
expect(screen.queryByText(longCommand)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Command/ }))
expect(screen.getByText(longCommand)).toBeTruthy()
})
it('sends choice "deny" on Reject', async () => {
const request = mockGateway()
setRequest()
@@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
@@ -60,9 +61,15 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
// it goes through a confirm step rather than firing straight from the menu.
const [confirmAlways, setConfirmAlways] = useState(false)
// The pending tool row only shows a single truncated line of the command, and
// a pending row can't be expanded (no result yet), so the full command was
// previously only reachable via the "Always allow" modal. Let the user reveal
// it inline instead — "expand, Run" (2 clicks) rather than the modal dance.
const [showCommand, setShowCommand] = useState(false)
const busy = submitting !== null
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
const allowPermanent = request.allowPermanent !== false
const hasCommand = request.command.trim().length > 0
const respond = useCallback(
async (choice: ApprovalChoice) => {
@@ -119,70 +126,89 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
}, [confirmAlways, respond])
return (
<div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<div className="mt-1 ps-5" data-slot="tool-approval-inline">
<div className="flex items-center gap-2.5">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
onClick={() => void respond('once')}
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={copy.moreOptions}
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
{allowPermanent && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
disabled={busy}
onClick={() => void respond('once')}
onClick={() => void respond('deny')}
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={copy.moreOptions}
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
{allowPermanent && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{hasCommand && (
<Button
aria-expanded={showCommand}
className="h-6 gap-1 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
onClick={() => setShowCommand(value => !value)}
size="xs"
variant="ghost"
>
{copy.command}
<ChevronDown className={cn('size-3 transition-transform', showCommand && 'rotate-180')} />
</Button>
)}
</div>
<Button
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
disabled={busy}
onClick={() => void respond('deny')}
size="xs"
variant="ghost"
>
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
{showCommand && hasCommand && (
<pre className="mt-1.5 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground">
{request.command.trim()}
</pre>
)}
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{copy.alwaysTitle}</DialogTitle>
<DialogDescription>
{copy.alwaysDescription(request.description)}
</DialogDescription>
<DialogDescription>{copy.alwaysDescription(request.description)}</DialogDescription>
</DialogHeader>
{request.command.trim() && (
+1
View File
@@ -1687,6 +1687,7 @@ export const en: Translations = {
gatewayDisconnected: 'Hermes gateway is not connected',
sendFailed: 'Could not send approval response',
run: 'Run',
command: 'Command',
moreOptions: 'More approval options',
allowSession: 'Allow this session',
alwaysAllowMenu: 'Always allow…',
+1
View File
@@ -1827,6 +1827,7 @@ export const ja = defineLocale({
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
sendFailed: '承認応答を送信できませんでした',
run: '実行',
command: 'コマンド',
moreOptions: 'その他の承認オプション',
allowSession: 'このセッションで許可',
alwaysAllowMenu: '常に許可…',
+1
View File
@@ -1346,6 +1346,7 @@ export interface Translations {
gatewayDisconnected: string
sendFailed: string
run: string
command: string
moreOptions: string
allowSession: string
alwaysAllowMenu: string
+1
View File
@@ -1771,6 +1771,7 @@ export const zhHant = defineLocale({
gatewayDisconnected: 'Hermes 閘道未連線',
sendFailed: '無法傳送核准回應',
run: '執行',
command: '指令',
moreOptions: '更多核准選項',
allowSession: '允許本工作階段',
alwaysAllowMenu: '一律允許…',
+1
View File
@@ -1867,6 +1867,7 @@ export const zh: Translations = {
gatewayDisconnected: 'Hermes 网关未连接',
sendFailed: '无法发送审批响应',
run: '运行',
command: '命令',
moreOptions: '更多审批选项',
allowSession: '允许本会话',
alwaysAllowMenu: '始终允许…',
-5
View File
@@ -719,11 +719,6 @@ platform_toolsets:
# # allowed_chats: ["-1001234567890"]
# extra:
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
# # Bot API 10.1 Rich Messages: final replies send raw markdown via
# # sendRichMessage so tables, task lists, collapsible details, math, etc.
# # render natively (with automatic MarkdownV2 fallback). Opt-in while
# # the new endpoint is validated; default false.
# rich_messages: false # Set true to enable native rich rendering
#
# Discord-specific settings (config.yaml top-level, not under platforms:):
#
+67 -27
View File
@@ -2827,6 +2827,53 @@ def _strip_leaked_terminal_responses(text: str) -> str:
return cleaned
def _estimate_tui_input_height(
lines: list[str] | tuple[str, ...],
prompt_text: str,
terminal_columns: int,
*,
max_height: int = 8,
) -> int:
"""Estimate classic prompt_toolkit input rows using live terminal cells.
The TextArea prompt is injected with prompt_toolkit's BeforeInput
processor, which means it consumes cells only on logical line 0. After a
narrow resize, that first row can leave only one input cell beside an icon
prompt such as `` ``, while continuation rows use the full terminal width.
Never substitute a fake wide fallback here: under- or over-allocating the
TextArea height leaves stale prompt/input cells visible at the bottom of the
terminal.
"""
try:
from prompt_toolkit.utils import get_cwidth
except Exception:
get_cwidth = lambda value: len(value or "") # type: ignore[assignment]
try:
columns = int(terminal_columns or 0)
except (TypeError, ValueError):
columns = 0
columns = max(1, columns)
prompt_width = max(0, get_cwidth(prompt_text or ""))
visual_lines = 0
for index, line in enumerate(lines or [""]):
# prompt_toolkit's TextArea injects ``prompt`` via BeforeInput, which
# applies only to logical line 0. Wrapped continuation rows, and later
# logical lines, use the full terminal width. Count the display cells
# after that same transformation rather than subtracting the prompt from
# every wrapped row.
line_width = get_cwidth(line or "")
display_width = line_width + (prompt_width if index == 0 else 0)
if display_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-display_width // columns))
return min(max(visual_lines, 1), max(1, int(max_height or 1)))
def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
"""Collect local image attachments for single-query CLI flows."""
message = query or ""
@@ -3689,9 +3736,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
startup UI and ``_replay_output_history`` cannot reconstruct it
(the banner was never added to ``_OUTPUT_HISTORY``).
Instead we just reset prompt_toolkit's renderer cache so the next
incremental redraw starts from a clean slate, then let
``original_on_resize`` recalculate layout for the new size.
Let prompt_toolkit's own resize path run with its renderer cursor
cache intact. Its Application._on_resize() starts with
renderer.erase(leave_alternate_screen=False), which needs the cached
cursor position to move back to the live prompt origin before
erase_down(). Resetting the renderer before that erase loses the
origin and can leave stale prompt glyphs after a narrow resize.
We also flag ``_status_bar_suppressed_after_resize`` so the dynamic
status bar and input separator rules stay hidden until the next user
@@ -3702,14 +3752,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
next prompt restores the bar cleanly.
"""
self._status_bar_suppressed_after_resize = True
try:
app.renderer.reset(leave_alternate_screen=False)
except Exception:
pass
try:
app.invalidate()
except Exception:
pass
original_on_resize()
def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:
@@ -12004,26 +12046,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
def _input_height():
try:
from prompt_toolkit.application import get_app
from prompt_toolkit.utils import get_cwidth
doc = input_area.buffer.document
prompt_width = max(2, get_cwidth(self._get_tui_prompt_text()))
try:
available_width = get_app().output.get_size().columns - prompt_width
terminal_columns = get_app().output.get_size().columns
except Exception:
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
for line in doc.lines:
# Each logical line takes at least 1 visual row; long lines wrap.
# Use prompt_toolkit's cell width so CJK wide characters count as 2.
line_width = get_cwidth(line)
if line_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-line_width // available_width)) # ceil division
return min(max(visual_lines, 1), 8)
terminal_columns = shutil.get_terminal_size((80, 24)).columns
return _estimate_tui_input_height(
doc.lines,
self._get_tui_prompt_text(),
terminal_columns,
)
except Exception:
return 1
@@ -12765,6 +12798,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
style=style,
full_screen=False,
mouse_support=False,
# The status bar contains wall-clock read-outs (live prompt elapsed
# and idle-since-last-turn). Once a turn finishes there may be no
# further events to invalidate the app, so prompt_toolkit would keep
# rendering the first post-turn value (usually ``✓ 0s``) forever.
# A low-rate refresh keeps the clock honest without reintroducing a
# custom repaint thread or touching conversation state.
refresh_interval=1.0,
# Erase the live bottom chrome (status bar, input box, separator
# rules) on exit instead of freezing a final copy into scrollback.
# Without this, prompt_toolkit's render_as_done teardown repaints
+4 -11
View File
@@ -416,10 +416,8 @@ class TelegramAdapter(BasePlatformAdapter):
self._mention_patterns = self._compile_mention_patterns()
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
# Bot API 10.1 Rich Messages: opportunistically send final replies via
# sendRichMessage with the raw agent markdown so tables/task lists/etc.
# render natively. Opt-out via platforms.telegram.extra.rich_messages.
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
# Bot API 10.1 Rich Messages: send final replies via sendRichMessage
# with the raw agent markdown so tables/task lists/etc. render natively.
# Latched off after a capability failure on sendRichMessage /
# sendRichMessageDraft (e.g. older python-telegram-bot without the
# endpoint) so later sends skip the doomed rich attempt entirely.
@@ -949,12 +947,8 @@ class TelegramAdapter(BasePlatformAdapter):
return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None))
def _should_attempt_rich(self, content: str) -> bool:
# getattr defaults: tests build adapters via object.__new__() (no
# __init__), so the flags may be unset — default rich OFF (the
# feature is opt-in via platforms.telegram.extra.rich_messages).
return bool(
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
not getattr(self, "_rich_send_disabled", False)
and content
and content.strip()
and self._content_fits_rich_limits(content)
@@ -1132,8 +1126,7 @@ class TelegramAdapter(BasePlatformAdapter):
def _should_attempt_rich_draft(self, content: str) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
not getattr(self, "_rich_send_disabled", False)
and not getattr(self, "_rich_draft_disabled", False)
and content
and content.strip()
+75 -59
View File
@@ -8815,12 +8815,29 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Auto-resetting session %s after compression exhaustion.",
session_entry.session_id,
)
self.session_store.reset_session(session_key)
new_entry = self.session_store.reset_session(session_key)
self._evict_cached_agent(session_key)
self._session_model_overrides.pop(session_key, None)
self._set_session_reasoning_override(session_key, None)
if hasattr(self, "_pending_model_notes"):
self._pending_model_notes.pop(session_key, None)
if new_entry is not None:
# Drop the stale reference to the bloated compressed child and
# re-point the Telegram topic binding at the fresh session.
# Compression rotated session_entry.session_id to the oversized
# compressed child earlier this turn (the agent-result sync
# above), and that _sync also rewrote the (chat_id, thread_id)
# -> bloated-child binding. reset_session swaps in a clean,
# parentless session, but without re-syncing the binding the
# next inbound message in this topic gets switch_session'd back
# onto the bloated child by the binding-heal walk, reloads the
# oversized transcript, and re-triggers compression exhaustion
# forever (#35809 — regression of the #9893/#10063 auto-reset).
# No-op on non-topic lanes.
session_entry = new_entry
self._sync_telegram_topic_binding(
source, session_entry, reason="compression-exhausted-reset",
)
response = (response or "") + (
"\n\n🔄 Session auto-reset — the conversation exceeded the "
"maximum context size and could not be compressed further. "
@@ -14565,6 +14582,61 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_context_length = getattr(_agent.context_compressor, "context_length", 0) or 0
_resolved_model = getattr(_agent, "model", None) if _agent else None
# Sync session_id immediately after run_conversation(). Compression
# can rotate before a follow-up model call fails; the failure return
# below must still point the gateway at the compressed child.
agent = agent_holder[0]
_session_was_split = False
agent_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
if agent and session_key and agent_session_id != session_id:
_session_was_split = True
logger.info(
"Session split detected: %s%s (compression)",
session_id, agent_session_id,
)
entry = self.session_store._entries.get(session_key)
if entry:
entry.session_id = agent_session_id
self.session_store._save()
# If this is a Telegram DM and source.thread_id was lost during
# the session split (synthetic / recovered event), restore it
# from the binding so _thread_metadata_for_source produces the
# correct message_thread_id instead of routing to the General
# thread. Failure here is non-fatal — we log and continue;
# worst case the message lands in General, which is the
# pre-fix behaviour.
if (
getattr(source, "platform", None) == Platform.TELEGRAM
and getattr(source, "chat_type", None) == "dm"
and getattr(source, "thread_id", None) is None
and self._session_db is not None
):
try:
_binding = self._session_db.get_telegram_topic_binding_by_session(
session_id=agent_session_id,
)
if _binding and _binding.get("thread_id"):
source.thread_id = str(_binding["thread_id"])
logger.debug(
"Restored source.thread_id=%s from binding after session split %s%s",
source.thread_id,
session_id,
agent_session_id,
)
except Exception:
logger.debug(
"Failed to restore thread_id from binding after session split",
exc_info=True,
)
if entry:
self._sync_telegram_topic_binding(
source, entry, reason="agent-run-compression",
)
effective_session_id = agent_session_id
_effective_history_offset = 0 if _session_was_split else len(agent_history)
if not final_response:
error_msg = f"⚠️ {result['error']}" if result.get("error") else ""
return {
@@ -14579,7 +14651,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"error": result.get("error"),
"compression_exhausted": result.get("compression_exhausted", False),
"tools": tools_holder[0] or [],
"history_offset": len(agent_history),
"history_offset": _effective_history_offset,
"session_id": effective_session_id,
"last_prompt_tokens": _last_prompt_toks,
"input_tokens": _input_toks,
"output_tokens": _output_toks,
@@ -14625,63 +14698,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
unique_tags.insert(0, "[[audio_as_voice]]")
final_response = final_response + "\n" + "\n".join(unique_tags)
# Sync session_id: the agent may have created a new session during
# mid-run context compression (_compress_context splits sessions).
# If so, update the session store entry so the NEXT message loads
# the compressed transcript, not the stale pre-compression one.
agent = agent_holder[0]
_session_was_split = False
if agent and session_key and hasattr(agent, 'session_id') and agent.session_id != session_id:
_session_was_split = True
logger.info(
"Session split detected: %s%s (compression)",
session_id, agent.session_id,
)
entry = self.session_store._entries.get(session_key)
if entry:
entry.session_id = agent.session_id
self.session_store._save()
# If this is a Telegram DM and source.thread_id was lost during
# the session split (synthetic / recovered event), restore it
# from the binding so _thread_metadata_for_source produces the
# correct message_thread_id instead of routing to the General
# thread. Failure here is non-fatal — we log and continue;
# worst case the message lands in General, which is the
# pre-fix behaviour.
if (
getattr(source, "platform", None) == Platform.TELEGRAM
and getattr(source, "chat_type", None) == "dm"
and getattr(source, "thread_id", None) is None
and self._session_db is not None
):
try:
_binding = self._session_db.get_telegram_topic_binding_by_session(
session_id=agent.session_id,
)
if _binding and _binding.get("thread_id"):
source.thread_id = str(_binding["thread_id"])
logger.debug(
"Restored source.thread_id=%s from binding after session split %s%s",
source.thread_id,
session_id,
agent.session_id,
)
except Exception:
logger.debug(
"Failed to restore thread_id from binding after session split",
exc_info=True,
)
effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
# When compression created a new session, the messages list was
# shortened. Using the original history offset would produce an
# empty new_messages slice, causing the gateway to write only a
# user/assistant pair — losing the compressed summary and tail.
# Reset to 0 so the gateway writes ALL compressed messages.
_effective_history_offset = 0 if _session_was_split else len(agent_history)
# Auto-generate session title after first exchange (non-blocking)
if final_response and self._session_db:
try:
+69 -7
View File
@@ -3524,6 +3524,22 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
_save_auth_store(auth_store)
def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]:
"""Adopt a valid Codex CLI token pair into Hermes auth, if available."""
imported = _import_codex_cli_tokens()
# Require BOTH tokens before adopting: persisting a payload without a
# usable refresh_token would only break the next refresh cycle.
if not (
imported
and str(imported.get("access_token", "") or "").strip()
and str(imported.get("refresh_token", "") or "").strip()
):
return None
logger.info("Codex auth recovered from Codex CLI auth.json (%s).", reason)
_save_codex_tokens(imported)
return dict(imported)
def refresh_codex_oauth_pure(
access_token: str,
refresh_token: str,
@@ -3660,11 +3676,34 @@ def _refresh_codex_auth_tokens(
Saves the new tokens to Hermes auth store automatically.
"""
refreshed = refresh_codex_oauth_pure(
str(tokens.get("access_token", "") or ""),
str(tokens.get("refresh_token", "") or ""),
timeout_seconds=timeout_seconds,
)
try:
refreshed = refresh_codex_oauth_pure(
str(tokens.get("access_token", "") or ""),
str(tokens.get("refresh_token", "") or ""),
timeout_seconds=timeout_seconds,
)
except AuthError as exc:
# Self-heal cross-store refresh_token rotation. Hermes keeps its OWN
# Codex OAuth token (per profile + top-level), separate from the Codex
# CLI's ~/.codex/auth.json. OAuth refresh_tokens are single-use, so when
# the Codex CLI (or another Hermes process) rotates the shared token,
# this frozen copy's refresh_token goes stale and the refresh fails with
# a relogin-required error (invalid_grant / refresh_token_reused / 401).
# Before surfacing that as a hard 401 to the turn, adopt the canonical
# fresh token from ~/.codex/auth.json (the Codex CLI keeps it current) so
# idle profiles / desktop sessions recover automatically instead of
# 401'ing until a manual re-auth. Transient failures (e.g. 429 quota)
# keep relogin_required=False — the stored token is still valid there, so
# we never self-heal those and re-raise unchanged.
if not getattr(exc, "relogin_required", False):
raise
imported = _recover_codex_tokens_from_cli(
f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}"
)
if not imported:
raise
return imported
updated_tokens = dict(tokens)
updated_tokens["access_token"] = refreshed["access_token"]
updated_tokens["refresh_token"] = refreshed["refresh_token"]
@@ -3724,9 +3763,25 @@ def resolve_codex_runtime_credentials(
HTTP 401 ``Missing Authentication header`` from the wire instead of a usable
credential. See issue #32992.
"""
read_error: Optional[AuthError] = None
try:
data = _read_codex_tokens()
except AuthError:
except AuthError as exc:
read_error = exc
if getattr(exc, "relogin_required", False) and getattr(exc, "code", None) in {
"codex_auth_missing_access_token",
"codex_auth_missing_refresh_token",
"codex_auth_invalid_shape",
}:
imported = _recover_codex_tokens_from_cli(str(getattr(exc, "code", None) or "auth_error"))
if imported:
data = {"tokens": imported, "last_refresh": imported.get("last_refresh")}
else:
data = None
else:
data = None
if data is None:
pool_token = _pool_codex_access_token()
if pool_token:
base_url = (
@@ -3741,7 +3796,14 @@ def resolve_codex_runtime_credentials(
"last_refresh": None,
"auth_mode": "chatgpt",
}
raise
if read_error is not None:
raise read_error
raise AuthError(
"No Codex credentials stored. Run `hermes auth` to authenticate.",
provider="openai-codex",
code="codex_auth_missing",
relogin_required=True,
)
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
+29 -3
View File
@@ -1543,8 +1543,14 @@ def run_doctor(args):
total = critical + high + moderate
# Determine a scoped fix command for the remediation hint.
if audit_extra and audit_extra[0] == "--workspace":
fix_scope = " ".join(audit_extra)
fix_cmd = f"cd {npm_dir} && npm audit fix {fix_scope}"
# Detection (`npm audit --workspace <name>`) is read-only and
# safe, but `npm audit fix --workspace <name>` crashes on
# current npm with "Cannot read properties of null (reading
# 'edgesOut')" — an arborist bug with workspace-filtered
# audit fix. The root-level `npm audit fix` can crash on the
# same tree with "isDescendantOf", so do not hand the user a
# manual fix command for these build-tool advisories.
fix_cmd = None
elif audit_extra == ["--workspaces=false"]:
fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
else:
@@ -1552,10 +1558,30 @@ def run_doctor(args):
if total == 0:
check_ok(f"{label} deps", "(no known vulnerabilities)")
elif critical > 0 or high > 0:
if fix_cmd:
vuln_detail = (
f"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}"
)
else:
vuln_detail = (
f"{critical} critical, {high} high, {moderate} moderate — "
"build-tool advisory; clears via lockfile bump"
)
check_warn(
f"{label} deps",
f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})"
f"({vuln_detail})"
)
if audit_extra and audit_extra[0] == "--workspace":
# The web/ui-tui workspace advisories are in build-time
# tooling (esbuild/vite, etc.), not runtime code that ships
# to users. Manual npm remediation may error with a known
# arborist crash (edgesOut / isDescendantOf) on this monorepo
# tree — in that case it is an npm bug, not a Hermes one.
check_info(
" ^ build-time tooling (not runtime); if manual npm remediation "
"errors with an arborist crash it's a known npm bug — clears "
"via a lockfile bump"
)
issues.append(
f"{label} has {total} npm "
f"{'vulnerability' if total == 1 else 'vulnerabilities'}"
+37
View File
@@ -6675,6 +6675,40 @@ def _worker_terminal_timeout_env(
return str(desired)
def _resolve_worker_cli_toolsets(hermes_home: Optional[str]) -> Optional[list[str]]:
"""Return the assigned profile's effective CLI toolsets for a worker.
Dispatcher-spawned workers are launched from a long-lived gateway process,
then the child re-enters the CLI with ``-p <assignee>``. Resolve the
assignee profile's CLI tool surface at dispatch time and pass it as an
explicit ``--toolsets`` pin so worker startup cannot fall back to a stale
root/active-profile config or a profile whose top-level ``toolsets`` entry
is only the kanban orchestrator surface. ``model_tools`` still appends the
task-scoped kanban lifecycle tools when ``HERMES_KANBAN_TASK`` is set.
"""
if not hermes_home:
return None
try:
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from hermes_cli.config import load_config
from hermes_cli.tools_config import _get_platform_tools
token = set_hermes_home_override(hermes_home)
try:
cfg = load_config()
toolsets = sorted(_get_platform_tools(cfg, "cli"))
finally:
reset_hermes_home_override(token)
return toolsets or None
except Exception as exc:
_log.debug(
"kanban worker: could not resolve CLI toolsets for HERMES_HOME=%r (%s)",
hermes_home,
exc,
)
return None
def _default_spawn(
task: Task,
workspace: str,
@@ -6808,6 +6842,9 @@ def _default_spawn(
cmd.extend(["--skills", sk])
if task.model_override:
cmd.extend(["-m", task.model_override])
worker_toolsets = _resolve_worker_cli_toolsets(env.get("HERMES_HOME"))
if worker_toolsets:
cmd.extend(["--toolsets", ",".join(worker_toolsets)])
cmd.extend([
"chat",
"-q", prompt,
+63
View File
@@ -26,6 +26,7 @@ from dataclasses import dataclass
from typing import List, NamedTuple, Optional
from hermes_cli.providers import (
ProviderDef,
custom_provider_slug,
determine_api_mode,
get_label,
@@ -46,6 +47,23 @@ from agent.models_dev import (
logger = logging.getLogger(__name__)
def _bare_custom_provider_def(current_base_url: str) -> Optional[ProviderDef]:
"""ProviderDef for a direct ``model.provider: custom`` endpoint."""
base_url = str(current_base_url or "").strip()
if not base_url:
return None
return ProviderDef(
id="custom",
name="Custom endpoint",
transport="openai_chat",
api_key_env_vars=(),
base_url=base_url,
is_aggregator=False,
auth_type="api_key",
source="model-config",
)
# ---------------------------------------------------------------------------
# Non-agentic model warning
# ---------------------------------------------------------------------------
@@ -676,6 +694,8 @@ def switch_model(
user_providers,
custom_providers,
)
if pdef is None and explicit_provider.strip().lower() == "custom":
pdef = _bare_custom_provider_def(current_base_url)
if pdef is None:
_switch_err = (
f"Unknown provider '{explicit_provider}'. "
@@ -881,6 +901,8 @@ def switch_model(
provider_changed = target_provider != current_provider
provider_label = get_label(target_provider)
if target_provider == "custom" and current_base_url:
provider_label = "Custom endpoint"
if target_provider.startswith("custom:"):
custom_pdef = resolve_provider_full(
target_provider,
@@ -932,6 +954,10 @@ def switch_model(
api_key = _ukey
base_url = _user_pdef.base_url
api_mode = ""
elif target_provider == "custom" and current_base_url:
api_key = current_api_key
base_url = current_base_url
api_mode = determine_api_mode(target_provider, base_url)
else:
try:
runtime = resolve_runtime_provider(
@@ -1748,6 +1774,43 @@ def list_authenticated_providers(
if _pair[0] and _pair[1]:
_section3_emitted_pairs.add(_pair)
# --- 3b. Active bare custom endpoint from model config ---
# A config can still use the direct one-off form:
# model.provider: custom
# model.base_url: https://some-openai-compatible/v1
# In that shape there is no named providers:/custom_providers row for the
# picker to render, but the gateway only passes this current model slice to
# list_authenticated_providers(). Surface the active endpoint explicitly so
# /model does not look like it ignored config.yaml.
_current_provider_norm = str(current_provider or "").strip().lower()
if (
_current_provider_norm == "custom"
and current_base_url
and "custom" not in seen_slugs
and not any(
isinstance(_cp, dict)
and str(
_cp.get("base_url", "")
or _cp.get("url", "")
or _cp.get("api", "")
).strip().rstrip("/").lower()
== str(current_base_url).strip().rstrip("/").lower()
for _cp in (custom_providers or [])
)
):
_models = [current_model] if current_model else []
results.append({
"slug": "custom",
"name": "Custom endpoint",
"is_current": True,
"is_user_defined": True,
"models": _models[:max_models] if max_models else _models,
"total_models": len(_models),
"source": "model-config",
"api_url": str(current_base_url).strip().rstrip("/"),
})
seen_slugs.add("custom")
# --- 4. Saved custom providers from config ---
# Each ``custom_providers`` entry represents one model under a named
# provider. Entries sharing the same endpoint, credential identity, and
+10 -4
View File
@@ -1190,10 +1190,16 @@ def _maybe_register_gateway_service(profile_name: str) -> None:
can re-register manually later via the gateway start command,
which goes through the same dispatch path.
Port selection is governed by the profile's ``config.yaml``
(``[gateway] port = ``) there is no Python-side allocator
(PR #30136 review item I5 retired the SHA-256-derived range
[9200, 9800) because it was dead code through the entire stack).
Port selection: each supervised profile gateway loads its own
``HERMES_HOME`` and binds the port resolved by ``gateway/config.py``
from that profile's environment — ``API_SERVER_PORT`` (or
``platforms.api_server.extra.port`` in the profile's
``config.yaml``), defaulting to 8642. There is no ``[gateway] port``
key and no Python-side allocator (PR #30136 review item I5 retired
the SHA-256-derived range [9200, 9800) as dead code), so two
profiles that both leave the port at its default will both try to
bind 8642 give each profile a distinct ``API_SERVER_PORT`` in its
``.env``.
Host short-circuit: check ``detect_service_manager()`` first and
return immediately if it isn't ``"s6"``. This keeps host
+55
View File
@@ -660,6 +660,61 @@ def has_named_custom_provider(requested_provider: str) -> bool:
return False
def find_custom_provider_identity(base_url: str) -> Optional[str]:
"""Map an endpoint URL back to its canonical ``custom:<name>`` menu key.
Returns the ``custom:<normalized-name>`` slug of the first ``providers:``
/ ``custom_providers:`` entry whose base_url matches, or ``None`` when no
entry owns the URL.
Session persistence stores the agent's *resolved* provider, and for every
named custom endpoint that is the literal string ``"custom"`` the entry
name is lost, and the api_key is deliberately never persisted. The
endpoint URL is the one durable fact that survives the round-trip, so
this reverse lookup lets persist/rebuild paths recover the entry identity
(and with it key_env/api_key/api_mode resolution via
:func:`_get_named_custom_provider`) instead of failing with
``auth_unavailable`` or silently rebuilding with placeholder credentials.
"""
target = _normalize_base_url_for_match(base_url)
if not target:
return None
try:
config = load_config()
except Exception:
return None
providers = config.get("providers")
if isinstance(providers, dict):
for ep_name, entry in providers.items():
if not isinstance(entry, dict):
continue
entry_url = (
entry.get("api") or entry.get("url") or entry.get("base_url") or ""
)
if _normalize_base_url_for_match(entry_url) == target:
return f"custom:{_normalize_custom_provider_name(str(ep_name))}"
try:
custom_providers = get_compatible_custom_providers(config)
except Exception:
custom_providers = None
for entry in custom_providers or []:
if not isinstance(entry, dict):
continue
name = entry.get("name")
if not isinstance(name, str) or not name.strip():
continue
if _normalize_base_url_for_match(entry.get("base_url")) == target:
return f"custom:{_normalize_custom_provider_name(name)}"
return None
def _normalize_base_url_for_match(value) -> str:
return str(value or "").strip().rstrip("/").lower()
def _custom_provider_request_overrides(custom_provider: Dict[str, Any]) -> Dict[str, Any]:
extra_body = custom_provider.get("extra_body")
if not isinstance(extra_body, dict) or not extra_body:
+14 -9
View File
@@ -585,15 +585,20 @@ class S6ServiceManager:
would instead look up ``$HERMES_HOME/profiles/default/`` a
completely different (and almost always nonexistent) profile.
Port selection: the gateway picks its bind port from the
profile's ``config.yaml`` (``[gateway] port = ...``) — that
is the single source of truth. Previously this method took a
``port`` parameter that was passed in but never substituted
into the rendered script (it was carried in for "API parity"
with a deterministic SHA-256 allocator in
``hermes_cli.profiles._allocate_gateway_port``). PR #30136
review item I5 retired both the allocator and the parameter
because they were dead code through the entire stack.
Port selection: the gateway binds the port resolved by
``gateway/config.py`` from the profile's own environment —
``API_SERVER_PORT`` (or ``platforms.api_server.extra.port`` in
that profile's ``config.yaml``), defaulting to 8642. There is
no ``[gateway] port`` key and no Python-side allocator: because
each supervised profile gateway loads its own ``HERMES_HOME``,
two profiles that both leave the port unset will both try to
bind 8642 give each profile a distinct ``API_SERVER_PORT`` in
its ``.env``. Previously this method took a ``port`` parameter
that was passed in but never substituted into the rendered
script (carried for "API parity" with a deterministic SHA-256
allocator in ``hermes_cli.profiles._allocate_gateway_port``).
PR #30136 review item I5 retired both the allocator and the
parameter because they were dead code through the entire stack.
"""
import shlex
lines = [
+18
View File
@@ -14,6 +14,21 @@ from typing import Callable
from hermes_cli.subcommands._shared import add_accept_hooks_flag
def _add_compat_platform_flag(parser: argparse.ArgumentParser) -> None:
"""Accept stale `gateway <verb> --platform X` docs without advertising it.
Gateway service lifecycle commands operate on the gateway process, not a
single messaging adapter. Photon briefly printed a per-platform start
command during setup; keep that command parseable so users following the
old hint don't get blocked by argparse before the gateway can start.
"""
parser.add_argument(
"--platform",
dest="platform",
help=argparse.SUPPRESS,
)
def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable) -> None:
"""Attach the ``gateway`` and ``proxy`` subcommands to ``subparsers``."""
# =========================================================================
@@ -75,6 +90,7 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
action="store_true",
help="Kill ALL stale gateway processes across all profiles before starting",
)
_add_compat_platform_flag(gateway_start)
# gateway stop
gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service")
@@ -103,6 +119,7 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
action="store_true",
help="Kill ALL gateway processes across all profiles before restarting",
)
_add_compat_platform_flag(gateway_restart)
# gateway status
gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status")
@@ -118,6 +135,7 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
action="store_true",
help="Target the Linux system-level gateway service",
)
_add_compat_platform_flag(gateway_status)
# gateway install
gateway_install = gateway_subparsers.add_parser(
+97 -33
View File
@@ -2535,6 +2535,7 @@ async def get_sessions(
order: str = "created",
source: str = None,
exclude_sources: str = None,
profile: Optional[str] = None,
):
"""List sessions.
@@ -2558,9 +2559,11 @@ async def get_sessions(
status_code=400,
detail="order must be one of: created, recent",
)
profile_name: Optional[str] = None
if profile:
profile_name, _ = _cron_profile_home(profile)
try:
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
min_message_count = max(0, min_messages)
archived_only = archived == "only"
@@ -2594,11 +2597,16 @@ async def get_sessions(
s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
if profile_name:
s["profile"] = profile_name
s["is_default_profile"] = profile_name == "default"
# SQLite stores the flag as 0/1; expose a real JSON boolean.
s["archived"] = bool(s.get("archived"))
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
finally:
db.close()
except HTTPException:
raise
except Exception:
_log.exception("GET /api/sessions failed")
raise HTTPException(status_code=500, detail="Internal server error")
@@ -2724,7 +2732,7 @@ async def get_profiles_sessions(
@app.get("/api/sessions/search")
async def search_sessions(q: str = "", limit: int = 20):
async def search_sessions(q: str = "", limit: int = 20, profile: Optional[str] = None):
"""Search sessions by ID plus full-text message content using FTS5.
Direct session-id matches are surfaced first, then FTS message-content
@@ -2738,8 +2746,7 @@ async def search_sessions(q: str = "", limit: int = 20):
if not q or not q.strip():
return {"results": []}
try:
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
safe_limit = max(1, min(int(limit or 20), 100))
@@ -2881,6 +2888,8 @@ async def search_sessions(q: str = "", limit: int = 20):
return {"results": list(seen.values())}
finally:
db.close()
except HTTPException:
raise
except Exception:
_log.exception("GET /api/sessions/search failed")
raise HTTPException(status_code=500, detail="Search failed")
@@ -6290,6 +6299,7 @@ def _session_latest_descendant(session_id: str):
# reorder this block, move every route in it together.
class BulkDeleteSessions(BaseModel):
ids: List[str]
profile: Optional[str] = None
@app.post("/api/sessions/bulk-delete")
@@ -6334,8 +6344,7 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
status_code=400,
detail="ids must contain at most 500 entries",
)
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(body.profile)
try:
deleted = db.delete_sessions(body.ids)
return {"ok": True, "deleted": deleted}
@@ -6344,15 +6353,14 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
@app.get("/api/sessions/empty/count")
async def count_empty_sessions_endpoint():
async def count_empty_sessions_endpoint(profile: Optional[str] = None):
"""Return the number of empty, ended, non-archived sessions.
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
UI hides the affordance so users aren't presented with a button
that does nothing. Cheap, single-COUNT query.
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
return {"count": db.count_empty_sessions()}
finally:
@@ -6360,7 +6368,7 @@ async def count_empty_sessions_endpoint():
@app.delete("/api/sessions/empty")
async def delete_empty_sessions_endpoint():
async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
"""Delete every empty (``message_count == 0``), ended,
non-archived session in a single transaction.
@@ -6379,8 +6387,7 @@ async def delete_empty_sessions_endpoint():
prune-on-startup pass. Matching that pre-existing trade-off keeps
the two delete endpoints' DB-vs-disk behaviour consistent.
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
deleted = db.delete_empty_sessions()
return {"ok": True, "deleted": deleted}
@@ -6389,15 +6396,13 @@ async def delete_empty_sessions_endpoint():
@app.get("/api/sessions/stats")
async def get_session_stats():
async def get_session_stats(profile: Optional[str] = None):
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
path isn't captured as a session id by the parameterized route.
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
total = db.session_count(include_archived=True)
active_store = db.session_count(include_archived=False)
@@ -6535,11 +6540,9 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
@app.get("/api/sessions/{session_id}/export")
async def export_session_endpoint(session_id: str):
async def export_session_endpoint(session_id: str, profile: Optional[str] = None):
"""Export a single session (metadata + messages) as JSON."""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
sid = db.resolve_session_id(session_id)
if not sid:
@@ -6555,6 +6558,7 @@ async def export_session_endpoint(session_id: str):
class SessionPrune(BaseModel):
older_than_days: int = 90
source: Optional[str] = None
profile: Optional[str] = None
@app.post("/api/sessions/prune")
@@ -6562,11 +6566,10 @@ async def prune_sessions_endpoint(body: SessionPrune):
"""Delete ended sessions older than N days (mirrors `hermes sessions prune`)."""
if body.older_than_days < 1:
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
from hermes_state import SessionDB
db = SessionDB()
profile_home = _cron_profile_home(body.profile)[1] if body.profile else get_hermes_home()
db = _open_session_db_for_profile(body.profile)
try:
sessions_dir = get_hermes_home() / "sessions"
sessions_dir = profile_home / "sessions"
removed = db.prune_sessions(
older_than_days=body.older_than_days,
source=(body.source or None),
@@ -9612,11 +9615,10 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None
@app.get("/api/analytics/usage")
async def get_usage_analytics(days: int = 30):
from hermes_state import SessionDB
async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):
from agent.insights import InsightsEngine
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
cutoff = time.time() - (days * 86400)
cur = db._conn.execute("""
@@ -9681,15 +9683,13 @@ async def get_usage_analytics(days: int = 30):
@app.get("/api/analytics/models")
async def get_models_analytics(days: int = 30):
async def get_models_analytics(days: int = 30, profile: Optional[str] = None):
"""Rich per-model analytics for the Models dashboard page.
Returns token/cost/session breakdown per model plus capability metadata
from models.dev (context window, vision, tools, reasoning, etc.).
"""
from hermes_state import SessionDB
db = SessionDB()
db = _open_session_db_for_profile(profile)
try:
cutoff = time.time() - (days * 86400)
@@ -9711,7 +9711,71 @@ async def get_models_analytics(days: int = 30):
GROUP BY model, billing_provider
ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
""", (cutoff,))
rows = [dict(r) for r in cur.fetchall()]
raw_rows = [dict(r) for r in cur.fetchall()]
# Session rows can be created before the first billable provider call
# finishes. If that early row records only the model name, and a later
# row for the same model has real accounting + billing_provider, the
# Models page used to show a duplicate "0 tokens / — API calls" card
# next to the real provider card. Fold those session-only rows into
# the single accounted provider row when the ownership is unambiguous.
rows_by_model: Dict[str, List[Dict[str, Any]]] = {}
for row in raw_rows:
rows_by_model.setdefault(row.get("model") or "", []).append(row)
rows: List[Dict[str, Any]] = []
for model_rows in rows_by_model.values():
provider_rows = [r for r in model_rows if r.get("billing_provider")]
if len(provider_rows) == 1:
target = provider_rows[0]
for row in model_rows:
if row is target or row.get("billing_provider"):
continue
has_usage = any(
(row.get(key) or 0) != 0
for key in (
"input_tokens",
"output_tokens",
"cache_read_tokens",
"reasoning_tokens",
"estimated_cost",
"actual_cost",
"api_calls",
"tool_calls",
)
)
if has_usage:
continue
target["sessions"] = (target.get("sessions") or 0) + (row.get("sessions") or 0)
target["last_used_at"] = max(target.get("last_used_at") or 0, row.get("last_used_at") or 0)
total_tokens = (target.get("input_tokens") or 0) + (target.get("output_tokens") or 0)
sessions = target.get("sessions") or 0
target["avg_tokens_per_session"] = total_tokens / sessions if sessions else 0
rows.append(target)
rows.extend(
r for r in model_rows
if r is not target
and (r.get("billing_provider") or any(
(r.get(key) or 0) != 0
for key in (
"input_tokens",
"output_tokens",
"cache_read_tokens",
"reasoning_tokens",
"estimated_cost",
"actual_cost",
"api_calls",
"tool_calls",
)
))
)
else:
rows.extend(model_rows)
rows.sort(
key=lambda r: (r.get("input_tokens") or 0) + (r.get("output_tokens") or 0),
reverse=True,
)
models = []
for row in rows:
+1 -1
View File
@@ -46,7 +46,7 @@ talks to it over loopback.
hermes photon setup --phone +15551234567
# Start the gateway
hermes gateway start --platform photon
hermes gateway start
```
`hermes photon setup` does, in order:
+1 -1
View File
@@ -274,7 +274,7 @@ def _cmd_setup(args: argparse.Namespace) -> int:
print()
print("✓ Photon setup complete.")
print(" Start the gateway: hermes gateway start --platform photon")
print(" Start the gateway: hermes gateway start")
return 0
+14
View File
@@ -1171,6 +1171,20 @@ function Install-Repository {
# agent-created dirs (e.g. tinker-atropos/) survive too.
$statusOut = git -c windows.appendAtomically=false status --porcelain 2>$null
if (-not [string]::IsNullOrWhiteSpace(($statusOut -join "`n"))) {
# A previously interrupted update can leave the index with
# unmerged entries. In that state `git stash` aborts with
# "could not write index" and the following `git checkout`
# aborts with "you need to resolve your current index first"
# -- the GUI "git checkout main failed (exit 1)" install
# failure. Clear the conflict markers with `git reset` first:
# working-tree changes are kept (and stashed just below); only
# the index conflict state is dropped. Mirrors the `hermes
# update` path (#4735).
$unmergedOut = git -c windows.appendAtomically=false ls-files --unmerged 2>$null
if (-not [string]::IsNullOrWhiteSpace(($unmergedOut -join "`n"))) {
Write-Info "Clearing unmerged index entries from a previous conflict..."
git -c windows.appendAtomically=false reset -q 2>$null
}
$stashName = "hermes-install-autostash-" + (Get-Date -Format "yyyyMMdd-HHmmss")
Write-Info "Local changes detected, stashing before update..."
git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName"
+13
View File
@@ -1111,6 +1111,19 @@ clone_repo() {
local autostash_ref=""
if [ -n "$(git status --porcelain)" ]; then
# A previously interrupted update can leave the index with
# unmerged entries. In that state `git stash` aborts with
# "could not write index" and the later `git checkout` aborts
# with "you need to resolve your current index first", failing
# the whole install at the repository stage. Clear the conflict
# markers with `git reset` first -- this keeps working-tree
# changes (they're still stashed just below) and only drops the
# index-level conflict state. Mirrors the `hermes update` path
# (#4735).
if [ -n "$(git ls-files --unmerged)" ]; then
log_info "Clearing unmerged index entries from a previous conflict..."
git reset -q
fi
local stash_name
stash_name="hermes-install-autostash-$(date -u +%Y%m%d-%H%M%S)"
log_info "Local changes detected, stashing before update..."
+5
View File
@@ -45,8 +45,11 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"kenmege@yahoo.com": "Kenmege",
"peterhao@Peters-MacBook-Air.local": "pinguarmy",
"adalsteinnhelgason@Aalsteinns-MacBook-Pro-3.local": "AIalliAI",
"adalsteinnhelgason@users.noreply.github.com": "AIalliAI",
"zhang.hz6666@gmail.com": "HaozheZhang6",
"barronlroth@gmail.com": "barronlroth",
"ondrej.drapalik@gmail.com": "OndrejDrapalik",
"tomasz.panek@gmail.com": "tomekpanek",
@@ -78,6 +81,8 @@ AUTHOR_MAP = {
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
"dirtyren@users.noreply.github.com": "dirtyren",
"hbentel@gmail.com": "hbentel",
"JustinBao@outlook.com": "justinbao19",
"kdunn926@gmail.com": "kdunn926",
"mvanhorn@MacBook-Pro.local": "mvanhorn",
"470766206@qq.com": "youjunxiaji",
+21 -2
View File
@@ -1797,6 +1797,11 @@ class TestRegisterSessionMcpServers:
state.agent.tools = []
state.agent.valid_tool_names = set()
state.agent._cached_system_prompt = "old prompt"
state.agent._memory_manager = SimpleNamespace(
get_all_tool_schemas=lambda: [
{"name": "hindsight_recall", "description": "Recall", "parameters": {}}
]
)
server = McpServerStdio(
name="srv",
@@ -1807,6 +1812,7 @@ class TestRegisterSessionMcpServers:
fake_tools = [
{"function": {"name": "mcp_srv_search"}},
{"function": {"name": "memory"}},
{"function": {"name": "terminal"}},
]
@@ -1820,8 +1826,21 @@ class TestRegisterSessionMcpServers:
quiet_mode=True,
)
assert state.agent.enabled_toolsets == ["hermes-acp", "mcp-srv"]
assert state.agent.tools == fake_tools
assert state.agent.valid_tool_names == {"mcp_srv_search", "terminal"}
assert state.agent.tools is fake_tools
assert state.agent.tools[-1] == {
"type": "function",
"function": {
"name": "hindsight_recall",
"description": "Recall",
"parameters": {},
},
}
assert state.agent.valid_tool_names == {
"hindsight_recall",
"memory",
"mcp_srv_search",
"terminal",
}
# _invalidate_system_prompt should have been called
state.agent._invalidate_system_prompt.assert_called_once()
+29
View File
@@ -161,6 +161,35 @@ class TestResolveAutoMainFirst:
assert mock_resolve.call_args.args[0] == "anthropic"
assert mock_resolve.call_args.args[1] == "runtime-model"
def test_runtime_base_url_passed_for_named_api_key_provider(self):
"""Named API-key providers inherit the live session endpoint for aux work."""
token_plan_url = "https://token-plan-sgp.xiaomimimo.com/v1"
with patch(
"agent.auxiliary_client._read_main_provider",
return_value="openrouter",
), patch(
"agent.auxiliary_client._read_main_model", return_value="config-model",
), patch(
"agent.auxiliary_client.resolve_provider_client"
) as mock_resolve:
mock_resolve.return_value = (MagicMock(), "mimo-v2.5-pro")
from agent.auxiliary_client import _resolve_auto
_resolve_auto(main_runtime={
"provider": "xiaomi",
"model": "mimo-v2.5-pro",
"base_url": token_plan_url,
"api_key": "tp-test-key",
"api_mode": "chat_completions",
})
assert mock_resolve.call_args.args[0] == "xiaomi"
assert mock_resolve.call_args.args[1] == "mimo-v2.5-pro"
assert mock_resolve.call_args.kwargs["explicit_base_url"] == token_plan_url
assert mock_resolve.call_args.kwargs["explicit_api_key"] == "tp-test-key"
assert mock_resolve.call_args.kwargs["api_mode"] == "chat_completions"
# ── Vision — resolve_vision_provider_client ─────────────────────────────────
+19
View File
@@ -328,6 +328,25 @@ def test_stream_event_translation_keeps_identical_calls_in_distinct_parts():
assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id
def test_system_instruction_includes_role_field_and_stays_out_of_contents():
from agent.gemini_native_adapter import build_gemini_request
request = build_gemini_request(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"},
],
tools=[],
tool_choice=None,
)
assert request["systemInstruction"] == {
"role": "system",
"parts": [{"text": "You are a helpful assistant."}],
}
assert all(content.get("role") != "system" for content in request["contents"])
def test_max_tokens_none_defaults_to_gemini_output_ceiling():
"""max_tokens=None must send the model's full output ceiling, not omit it.
+22 -27
View File
@@ -2,10 +2,11 @@
import json
import pytest
from types import SimpleNamespace
from unittest.mock import MagicMock
from agent.memory_provider import MemoryProvider
from agent.memory_manager import MemoryManager
from agent.memory_manager import MemoryManager, inject_memory_provider_tools
# ---------------------------------------------------------------------------
# Concrete test provider
@@ -1320,38 +1321,25 @@ class TestMemoryToolToolsetGate:
causing 10x latency on local models (Qwen3-30B: 1.7s 42s) and
tool-call loops on small models.
These tests mirror the gate logic in agent/agent_init.py around the
memory provider tool injection block. The gate condition is:
These tests exercise the shared gate used by agent init and ACP refreshes.
The gate condition is:
enabled_toolsets is None no filter, inject (backward compat)
"memory" in enabled_toolsets user opted in, inject
selected toolsets include memory user opted in, inject
otherwise (incl. []) skip injection
"""
@staticmethod
def _run_memory_injection(enabled_toolsets, memory_manager):
"""Simulate the gated memory-tool injection block from agent_init.py."""
tools = []
valid_tool_names = set()
if memory_manager and tools is not None and (
enabled_toolsets is None or "memory" in enabled_toolsets
):
_existing = {
t.get("function", {}).get("name")
for t in tools
if isinstance(t, dict)
}
for _schema in memory_manager.get_all_tool_schemas():
_tname = _schema.get("name", "")
if _tname and _tname in _existing:
continue
tools.append({"type": "function", "function": _schema})
if _tname:
valid_tool_names.add(_tname)
_existing.add(_tname)
return tools, valid_tool_names
"""Run the shared memory-tool injection helper against a fake agent."""
fake_agent = SimpleNamespace(
_memory_manager=memory_manager,
enabled_toolsets=enabled_toolsets,
tools=[],
valid_tool_names=set(),
)
inject_memory_provider_tools(fake_agent)
return fake_agent.tools, fake_agent.valid_tool_names
def _mgr_with_tools(self, *tool_names):
"""Build a MemoryManager whose providers expose the named tool schemas."""
@@ -1376,6 +1364,13 @@ class TestMemoryToolToolsetGate:
tools, names = self._run_memory_injection(["terminal", "memory", "web"], mgr)
assert "fact_store" in names
def test_composite_toolset_with_memory_injects(self):
"""Composite toolsets that include memory should inject provider tools."""
mgr = self._mgr_with_tools("hindsight_recall")
tools, names = self._run_memory_injection(["hermes-acp"], mgr)
assert "hindsight_recall" in names
assert any(t["function"]["name"] == "hindsight_recall" for t in tools)
def test_empty_toolsets_blocks_injection(self):
"""`platform_toolsets: telegram: []` must suppress memory tools. (#5544)"""
mgr = self._mgr_with_tools("fact_store")
@@ -1384,7 +1379,7 @@ class TestMemoryToolToolsetGate:
assert names == set()
def test_toolsets_without_memory_blocks_injection(self):
"""Toolset list that doesn't name 'memory' must suppress injection."""
"""Toolsets that don't include memory must suppress injection."""
mgr = self._mgr_with_tools("fact_store")
tools, names = self._run_memory_injection(["terminal", "web"], mgr)
assert tools == []
+49
View File
@@ -397,3 +397,52 @@ class TestEnumNullStripping:
assert db_type["type"] == "string"
assert db_type["enum"] == ["mysql", "postgresql"], \
"null/empty enum values must be stripped after anyOf collapse"
class TestUnionTypeList:
"""Moonshot sanitizer accepts JSON Schema union type arrays."""
def test_union_type_list_normalizes_to_first_concrete_type(self):
params = {
"type": "object",
"properties": {
"limit": {
"type": ["number", "string"],
"description": "Max results",
},
},
}
out = sanitize_moonshot_tool_parameters(params)
assert out["properties"]["limit"]["type"] == "number"
def test_union_type_list_skips_null_type(self):
params = {
"type": "object",
"properties": {
"name": {"type": ["null", "string"]},
},
}
out = sanitize_moonshot_tool_parameters(params)
assert out["properties"]["name"]["type"] == "string"
def test_union_type_list_with_enum_does_not_crash_or_mutate_input(self):
params = {
"type": "object",
"properties": {
"sort": {
"type": ["string", "null"],
"enum": ["asc", "desc", None, ""],
},
},
}
out = sanitize_moonshot_tool_parameters(params)
sort = out["properties"]["sort"]
assert sort["type"] == "string"
assert sort["enum"] == ["asc", "desc"]
assert params["properties"]["sort"]["type"] == ["string", "null"]
+12
View File
@@ -877,6 +877,18 @@ class TestPromptBuilderConstants:
# check that this test is calibrated correctly).
assert "include MEDIA:" in PLATFORM_HINTS["telegram"]
def test_telegram_hint_encourages_rich_markdown(self):
# Telegram Bot API 10.1 rich messages are default-on, so the hint must
# encourage native structured markdown instead of forbidding tables.
hint = PLATFORM_HINTS["telegram"]
lowered = hint.lower()
assert "Telegram has NO table syntax" not in hint
assert "rich markdown" in lowered
assert "table" in lowered
assert "task list" in lowered
assert "math" in lowered
assert "include MEDIA:" in hint
def test_platform_hints_mattermost(self):
hint = PLATFORM_HINTS["mattermost"]
assert "Mattermost" in hint
+10 -16
View File
@@ -71,18 +71,14 @@ class TestForceFullRedraw:
"invalidate",
]
def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeypatch):
"""Resize recovery must NOT erase screen or scrollback.
def test_resize_recovery_uses_prompt_toolkit_original_resize_before_reset(self, bare_cli, monkeypatch):
"""Resize recovery must preserve prompt_toolkit's tracked cursor state.
The startup banner lives in normal terminal scrollback (printed
before prompt_toolkit owns the chrome). Clearing scrollback on
SIGWINCH removes it and ``_replay_output_history`` cannot
reconstruct it. The fix is to only reset the renderer cache and
let ``original_on_resize`` recalculate layout.
Additionally, ``_status_bar_suppressed_after_resize`` must be set
so the input rules and status bar hide until the next user input,
preventing duplicated-bar artifacts on column shrink (#19280).
prompt_toolkit's built-in Application._on_resize() starts with
renderer.erase(leave_alternate_screen=False), which uses the renderer's
cached cursor position to move back to the live prompt origin before
erase_down(). If Hermes resets the renderer first, that cursor position
is lost and stale prompt glyphs can remain after a narrow resize.
"""
app = MagicMock()
events = []
@@ -94,11 +90,9 @@ class TestForceFullRedraw:
bare_cli._status_bar_suppressed_after_resize = False
bare_cli._recover_after_resize(app, original_on_resize)
assert events == [
"renderer_reset",
"invalidate",
"original_resize",
]
assert events == ["original_resize"]
app.renderer.reset.assert_not_called()
app.invalidate.assert_not_called()
# Must NOT clear the screen or scrollback — those destroy the banner.
app.renderer.output.erase_screen.assert_not_called()
app.renderer.output.write_raw.assert_not_called()
+17 -83
View File
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import cli as cli_mod
from cli import HermesCLI
@@ -104,91 +105,24 @@ class TestCLIStatusBar:
assert "-1" not in text
assert "0/200K" in text
def test_input_height_counts_prompt_only_on_first_wrapped_row(self):
# Regression for prompt_toolkit classic CLI resize glitches: the prompt
# is inserted by BeforeInput only on logical line 0. At three terminal
# cells, "⚔ " leaves one cell for the first input character, but
# wrapped continuation rows use the full three cells. Estimating every
# wrapped row as one-cell wide over-allocates the TextArea and can leave
# stale prompt/input cells visible after resize.
assert cli_mod._estimate_tui_input_height(["abcdef"], "", 3) == 3
def test_input_height_counts_wide_characters_using_cell_width(self):
cli_obj = _make_cli()
# Prompt width (2 cells) + ten CJK chars (20 cells) = 22 display cells,
# which wraps to two rows at 14 terminal columns.
assert cli_mod._estimate_tui_input_height(["" * 10], " ", 14) == 2
class _Doc:
lines = ["" * 10]
class _Buffer:
document = _Doc()
input_area = SimpleNamespace(buffer=_Buffer())
def _input_height():
try:
from prompt_toolkit.application import get_app
from prompt_toolkit.utils import get_cwidth
doc = input_area.buffer.document
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
try:
available_width = get_app().output.get_size().columns - prompt_width
except Exception:
import shutil
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
for line in doc.lines:
line_width = get_cwidth(line)
if line_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-line_width // available_width))
return min(max(visual_lines, 1), 8)
except Exception:
return 1
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=14)
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value=" "), \
patch("prompt_toolkit.application.get_app", return_value=mock_app):
assert _input_height() == 2
def test_input_height_uses_prompt_toolkit_width_over_shutil(self):
cli_obj = _make_cli()
class _Doc:
lines = ["" * 10]
class _Buffer:
document = _Doc()
input_area = SimpleNamespace(buffer=_Buffer())
def _input_height():
try:
from prompt_toolkit.application import get_app
from prompt_toolkit.utils import get_cwidth
doc = input_area.buffer.document
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
try:
available_width = get_app().output.get_size().columns - prompt_width
except Exception:
import shutil
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
for line in doc.lines:
line_width = get_cwidth(line)
if line_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-line_width // available_width))
return min(max(visual_lines, 1), 8)
except Exception:
return 1
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=14)
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value=" "), \
patch("prompt_toolkit.application.get_app", return_value=mock_app), \
patch("shutil.get_terminal_size") as mock_shutil:
assert _input_height() == 2
mock_shutil.assert_not_called()
def test_input_height_clamps_zero_width_to_one_cell(self):
# Some terminals briefly report zero columns during resize. Treat that
# as a one-cell terminal rather than falling back to a fake wide width.
assert cli_mod._estimate_tui_input_height(["abcd"], "", 0) == 4
def test_build_status_bar_text_no_cost_in_status_bar(self):
cli_obj = _attach_agent(
@@ -0,0 +1,196 @@
"""Regression tests for #35809 — compression-exhaustion auto-reset loop.
After compression is exhausted the gateway auto-resets the session so the
next message starts on a fresh, empty conversation (#9893 / #10063). That
guarantee regressed once the Telegram topic-binding heal landed
(#20470 / #29712 / #33414):
1. Compression rotates ``session_entry.session_id`` to an oversized
compressed *child* session mid-turn and the agent-result sync rewrites
the ``(chat_id, thread_id) -> child`` topic binding.
2. ``reset_session`` swaps in a clean, parentless session but its return
value was discarded and the topic binding was left pointing at the
bloated child.
3. On the next inbound message in that topic, the binding-heal walk
``switch_session``'d the freshly-reset lane *back* onto the bloated
child, ``load_transcript`` reloaded the oversized transcript, and
compression exhaustion re-fired a new session id every loop.
The fix captures the fresh entry from ``reset_session`` and re-syncs the
topic binding to it (a no-op on non-topic lanes).
Two tests:
* ``TestAutoResetBlockReSyncsBinding`` an AST invariant on
``gateway/run.py`` (mirrors ``test_compression_session_id_persistence.py``):
the compression-exhausted auto-reset block must capture
``reset_session(...)`` and call ``_sync_telegram_topic_binding`` afterward.
This is the load-bearing regression pin.
* ``TestAutoResetLoadsCleanContext`` a behavioral contract on the real
``SessionStore``: after ``reset_session`` the next turn loads an EMPTY
transcript for the new session_id, never the bloated child's transcript.
"""
from __future__ import annotations
import ast
import inspect
from gateway import run as gateway_run
from gateway.config import GatewayConfig, Platform
from gateway.session import SessionSource, SessionStore
from hermes_state import SessionDB
# ---------------------------------------------------------------------------
# AST invariant: the auto-reset block re-syncs the topic binding
# ---------------------------------------------------------------------------
def _find_compression_exhausted_reset_block() -> ast.If:
"""Return the ``if agent_result.get('compression_exhausted') ...`` block."""
tree = ast.parse(inspect.getsource(gateway_run))
for node in ast.walk(tree):
if not isinstance(node, ast.If):
continue
consts = [
n.value
for n in ast.walk(node.test)
if isinstance(n, ast.Constant) and isinstance(n.value, str)
]
# Identify the auto-reset branch by the literal passed to .get(...).
if "compression_exhausted" in consts:
# Only the branch that actually performs the reset, not the
# earlier classifier that merely reads the flag into a bool.
calls = {
sub.func.attr
for sub in ast.walk(node)
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute)
}
if "reset_session" in calls:
return node
raise AssertionError(
"Could not locate the compression-exhausted auto-reset block "
"(if agent_result.get('compression_exhausted') ... reset_session) "
"in gateway/run.py — the structure changed or the AST walker is stale."
)
class TestAutoResetBlockReSyncsBinding:
def test_reset_session_return_is_captured(self):
"""``reset_session`` must be assigned, not called-and-discarded —
the fresh entry is needed to re-point the binding and drop the stale
reference to the bloated compressed child (#35809)."""
block = _find_compression_exhausted_reset_block()
captured = False
for stmt in ast.walk(block):
if isinstance(stmt, ast.Assign):
val = stmt.value
if (
isinstance(val, ast.Call)
and isinstance(val.func, ast.Attribute)
and val.func.attr == "reset_session"
):
captured = True
assert captured, (
"gateway/run.py auto-reset block calls reset_session() but discards "
"its return value. The fresh SessionEntry must be captured so the "
"topic binding can be re-pointed at it; otherwise the next message "
"resolves back to the bloated compressed child (#35809)."
)
def test_topic_binding_is_resynced_after_reset(self):
"""The block must re-sync the topic binding so the next inbound message
cannot ``switch_session`` back onto the bloated compressed child."""
block = _find_compression_exhausted_reset_block()
sync_calls = [
sub
for sub in ast.walk(block)
if isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "_sync_telegram_topic_binding"
]
assert sync_calls, (
"gateway/run.py auto-reset block does not call "
"_sync_telegram_topic_binding after reset_session. Without it the "
"(chat_id, thread_id) -> bloated-child binding survives the reset "
"and the binding-heal walk re-anchors the fresh lane onto the "
"oversized compressed transcript, re-triggering the loop (#35809)."
)
# ---------------------------------------------------------------------------
# Behavioral contract: reset yields a clean next-turn transcript
# ---------------------------------------------------------------------------
def _make_store(tmp_path):
store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig())
# Isolate the SQLite transcript store so we exercise per-session_id
# transcripts without touching the developer's real state.db.
store._db = SessionDB(db_path=tmp_path / "state.db")
return store
def _make_source():
return SessionSource(platform=Platform.TELEGRAM, chat_id="123", user_id="u1")
def _bloat(n):
# Stand-in for the oversized, post-compression "child" transcript that
# could not be compressed any further (#35809).
return [{"role": "user", "content": "x" * 2000} for _ in range(n)]
class TestAutoResetLoadsCleanContext:
"""#35809: after the gateway auto-resets a session because compression
was exhausted, the NEXT turn must load an EMPTY transcript for the new
session_id never the bloated compressed-child transcript."""
def test_next_turn_transcript_is_empty_after_auto_reset(self, tmp_path):
store = _make_store(tmp_path)
source = _make_source()
entry = store.get_or_create_session(source)
session_key = entry.session_key
bloated_sid = entry.session_id
store._db.create_session(
session_id=bloated_sid, source="telegram", user_id="u1"
)
store._db.replace_messages(bloated_sid, _bloat(120))
assert len(store.load_transcript(bloated_sid)) == 120 # precondition
new_entry = store.reset_session(session_key)
assert new_entry is not None
assert new_entry.session_id != bloated_sid
resolved = store.get_or_create_session(source)
assert resolved.session_id == new_entry.session_id
loaded = store.load_transcript(resolved.session_id)
assert loaded == [], (
f"Auto-reset must yield an empty context, got {len(loaded)} "
f"messages — the bloated compressed child leaked into the new session."
)
# The old transcript is still searchable, not destroyed.
assert len(store.load_transcript(bloated_sid)) == 120
def test_clean_context_survives_gateway_restart(self, tmp_path):
"""The fresh, empty session must still be the one loaded after a
gateway restart (sessions.json + state.db round-trip)."""
store = _make_store(tmp_path)
source = _make_source()
entry = store.get_or_create_session(source)
bloated_sid = entry.session_id
store._db.create_session(
session_id=bloated_sid, source="telegram", user_id="u1"
)
store._db.replace_messages(bloated_sid, _bloat(120))
new_entry = store.reset_session(entry.session_key)
new_sid = new_entry.session_id
# Simulate restart: drop in-memory index, reload from disk.
store._loaded = False
store._entries.clear()
reloaded = store.get_or_create_session(source)
assert reloaded.session_id == new_sid
assert store.load_transcript(reloaded.session_id) == []
@@ -0,0 +1,160 @@
import asyncio
import sys
import threading
import types
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import gateway.run as gateway_run
from gateway.config import Platform
from gateway.session import SessionSource
SESSION_KEY = "agent:main:telegram:dm:12345"
class _SessionStore:
def __init__(self):
self.entry = SimpleNamespace(
session_key=SESSION_KEY,
session_id="session-before-compression",
)
self._entries = {SESSION_KEY: self.entry}
self.save_calls = 0
def _save(self):
self.save_calls += 1
class _CompressionThenFailureAgent:
def __init__(self, **kwargs):
self.session_id = kwargs["session_id"]
self.model = kwargs["model"]
self.tools = []
self.context_compressor = SimpleNamespace(
last_prompt_tokens=4321,
context_length=200000,
)
self.session_prompt_tokens = 4321
self.session_completion_tokens = 0
def run_conversation(self, user_message, conversation_history=None, task_id=None, **_kwargs):
self.session_id = "session-after-compression"
return {
"failed": True,
"error": "APIConnectionError: Codex auxiliary Responses stream exceeded 120.0s total timeout",
"messages": [
{"role": "user", "content": "[compressed summary]"},
{"role": "user", "content": user_message},
],
"api_calls": 1,
}
def interrupt(self, *_args, **_kwargs):
pass
class _StreamConsumer:
final_response_sent = False
def __init__(self, *_args, **_kwargs):
pass
async def run(self):
return None
def finish(self):
pass
class _Adapter:
SUPPORTS_MESSAGE_EDITING = True
_pending_messages = {}
def get_pending_message(self, _session_key):
return None
async def send_typing(self, *_args, **_kwargs):
return None
async def stop_typing(self, *_args, **_kwargs):
return None
def _runner(session_store):
runner = object.__new__(gateway_run.GatewayRunner)
runner.adapters = {Platform.TELEGRAM: _Adapter()}
runner.config = SimpleNamespace(streaming=None, group_sessions_per_user=True, thread_sessions_per_user=False)
runner.hooks = SimpleNamespace(loaded_hooks=False, emit=AsyncMock())
runner.session_store = session_store
runner._session_db = MagicMock()
runner._session_db.get_telegram_topic_binding_by_session.return_value = None
runner._agent_cache = {}
runner._agent_cache_lock = threading.Lock()
runner._running_agents = {}
runner._running_agents_ts = {}
runner._session_run_generation = {}
runner._session_model_overrides = {}
runner._pending_model_notes = {}
runner._pending_skills_reload_notes = {}
runner._prefill_messages = []
runner._ephemeral_system_prompt = ""
runner._reasoning_config = None
runner._provider_routing = {}
runner._fallback_model = None
runner._draining = False
runner._get_proxy_url = lambda: None
runner._resolve_session_agent_runtime = lambda **_kwargs: (
"gpt-5.4",
{"provider": "openai-codex", "api_mode": "codex_responses", "base_url": "https://chatgpt.com/backend-api/codex", "api_key": "token"},
)
runner._resolve_session_reasoning_config = lambda **_kwargs: None
runner._resolve_turn_agent_config = lambda message, model, runtime: {"model": model, "runtime": runtime}
runner._load_service_tier = lambda: None
runner._agent_config_signature = lambda *_args, **_kwargs: ("sig",)
runner._extract_cache_busting_config = lambda _config: ()
runner._thread_metadata_for_source = lambda *_args, **_kwargs: None
runner._sync_telegram_topic_binding = MagicMock()
runner._release_running_agent_state = MagicMock()
return runner
def test_failed_turn_still_syncs_compression_session_split(monkeypatch):
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = _CompressionThenFailureAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "off")
monkeypatch.setenv("HERMES_AGENT_TIMEOUT", "0")
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
monkeypatch.setattr("gateway.stream_consumer.GatewayStreamConsumer", _StreamConsumer)
import hermes_cli.tools_config as tools_config
monkeypatch.setattr(tools_config, "_get_platform_tools", lambda *_args, **_kwargs: {"core"})
session_store = _SessionStore()
runner = _runner(session_store)
source = SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm", user_id="user-1")
result = asyncio.run(
asyncio.wait_for(
runner._run_agent(
message="continue",
context_prompt="",
history=[{"role": "user", "content": "old question"}],
source=source,
session_id="session-before-compression",
session_key=SESSION_KEY,
),
timeout=2,
)
)
assert result["failed"] is True
assert result["session_id"] == "session-after-compression"
assert result["history_offset"] == 0
assert session_store.entry.session_id == "session-after-compression"
assert session_store.save_calls == 1
runner._sync_telegram_topic_binding.assert_called_once_with(
source, session_store.entry, reason="agent-run-compression"
)
+9 -41
View File
@@ -27,17 +27,8 @@ RICH_CONTENT = "## Results\n\n| Case | Status |\n|---|---|\n| rich | ✅ |\n\n-
def _make_adapter(extra=None):
"""Build a TelegramAdapter with a mock bot wired for the rich path.
Rich messages are opt-in (default off) while the Bot API 10.1 endpoint
is validated live, so tests that exercise the rich path enable it
explicitly here; opt-out tests pass their own ``extra``.
"""
config = PlatformConfig(
enabled=True,
token="fake-token",
extra={"rich_messages": True} if extra is None else extra,
)
"""Build a TelegramAdapter with a mock bot wired for the rich path."""
config = PlatformConfig(enabled=True, token="fake-token", extra=extra or {})
adapter = TelegramAdapter(config)
bot = MagicMock()
# do_api_request as an AsyncMock makes inspect.iscoroutinefunction(...) True,
@@ -76,24 +67,16 @@ async def test_rich_happy_path_sends_raw_markdown():
@pytest.mark.asyncio
async def test_rich_opt_out_uses_legacy():
async def test_legacy_rich_messages_config_is_ignored():
adapter = _make_adapter(extra={"rich_messages": False})
result = await adapter.send("12345", RICH_CONTENT)
assert result.success is True
adapter._bot.do_api_request.assert_not_called()
adapter._bot.send_message.assert_awaited()
@pytest.mark.asyncio
async def test_rich_opt_out_accepts_string_false():
adapter = _make_adapter(extra={"rich_messages": "false"})
await adapter.send("12345", RICH_CONTENT)
adapter._bot.do_api_request.assert_not_called()
adapter._bot.send_message.assert_awaited()
# The legacy toggle was removed; stale config entries must not disable the
# rich path.
adapter._bot.do_api_request.assert_awaited_once()
adapter._bot.send_message.assert_not_called()
@pytest.mark.asyncio
@@ -265,13 +248,9 @@ async def test_notification_opt_in_drops_disable_flag():
@pytest.mark.asyncio
async def test_rich_gate_tolerates_missing_enabled_attr():
"""Adapters missing _rich_messages_enabled (object.__new__ in some tests)
must not raise the gate reads it via getattr(default=True), and a bot
without an async do_api_request falls through to the legacy path."""
async def test_rich_gate_tolerates_minimal_bot_without_raw_endpoint():
"""A bot without an async do_api_request falls through to the legacy path."""
adapter = _make_adapter()
del adapter._rich_messages_enabled # simulate object.__new__ construction
# SimpleNamespace bot has no do_api_request -> _bot_supports_rich() False.
adapter._bot = SimpleNamespace(
send_message=AsyncMock(return_value=SimpleNamespace(message_id=42)),
send_chat_action=AsyncMock(),
@@ -337,17 +316,6 @@ async def test_rich_draft_transient_failure_does_not_latch_off():
assert adapter._rich_draft_disabled is False
@pytest.mark.asyncio
async def test_rich_draft_opt_out_uses_legacy():
adapter = _make_adapter(extra={"rich_messages": False})
result = await adapter.send_draft("12345", draft_id=7, content=RICH_CONTENT)
assert result.success is True
adapter._bot.do_api_request.assert_not_called()
adapter._bot.send_message_draft.assert_awaited_once()
@pytest.mark.asyncio
async def test_rich_draft_oversized_uses_legacy():
adapter = _make_adapter()
@@ -76,6 +76,7 @@ def test_resolve_codex_runtime_credentials_missing_access_token(tmp_path, monkey
hermes_home = tmp_path / "hermes"
_setup_hermes_auth(hermes_home, access_token="")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "missing-codex"))
with pytest.raises(AuthError) as exc:
resolve_codex_runtime_credentials()
@@ -0,0 +1,208 @@
"""Regression tests for Codex refresh_token self-heal (cross-store rotation).
Hermes keeps its OWN copy of the Codex OAuth token (per profile + top-level),
separate from the Codex CLI's ``~/.codex/auth.json``. OAuth refresh_tokens are
single-use, so when the Codex CLI (or another Hermes process) rotates the shared
token, the frozen copy's refresh_token goes stale and ``refresh_codex_oauth_pure``
fails with a relogin-required error. ``_refresh_codex_auth_tokens`` must then
recover by re-importing the canonical token from ``~/.codex/auth.json`` instead of
surfacing a hard 401 but ONLY for relogin-required failures, never for transient
ones (e.g. 429 quota, where the stored token is still valid).
"""
import json
import pytest
import hermes_cli.auth as auth
from hermes_cli.auth import AuthError, _refresh_codex_auth_tokens, resolve_codex_runtime_credentials
STALE = {"access_token": "stale-access", "refresh_token": "stale-refresh"}
def test_self_heals_on_stale_refresh_token(monkeypatch):
"""invalid_grant (relogin-required) → reimport from ~/.codex and persist it."""
saved = {}
fresh = {
"access_token": "fresh-access",
"refresh_token": "fresh-refresh",
"last_refresh": "2026-06-12T00:00:00Z",
}
def _rejected(*_a, **_k):
raise AuthError(
"refresh token rejected",
provider="openai-codex",
code="invalid_grant",
relogin_required=True,
)
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: dict(fresh))
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
out = _refresh_codex_auth_tokens(STALE, 20.0)
assert out["access_token"] == "fresh-access"
assert out["refresh_token"] == "fresh-refresh"
# the recovered token was persisted to the Hermes auth store
assert saved["access_token"] == "fresh-access"
def test_does_not_self_heal_on_rate_limit(monkeypatch):
"""429 quota keeps relogin_required=False — token still valid, must NOT reimport."""
import_calls = {"n": 0}
def _rate_limited(*_a, **_k):
raise AuthError(
"quota exhausted",
provider="openai-codex",
code="codex_rate_limited",
relogin_required=False,
)
def _import_spy():
import_calls["n"] += 1
return {"access_token": "should-not-be-used"}
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rate_limited)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", _import_spy)
monkeypatch.setattr(auth, "_save_codex_tokens", lambda *a, **k: None)
with pytest.raises(AuthError) as ei:
_refresh_codex_auth_tokens(STALE, 20.0)
assert ei.value.code == "codex_rate_limited"
assert import_calls["n"] == 0 # never touched ~/.codex on a transient failure
def test_reraises_when_codex_cli_token_absent(monkeypatch):
"""relogin-required but ~/.codex unavailable/expired → propagate original error."""
def _reused(*_a, **_k):
raise AuthError(
"refresh token reused",
provider="openai-codex",
code="refresh_token_reused",
relogin_required=True,
)
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _reused)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: None)
monkeypatch.setattr(auth, "_save_codex_tokens", lambda *a, **k: None)
with pytest.raises(AuthError) as ei:
_refresh_codex_auth_tokens(STALE, 20.0)
assert ei.value.code == "refresh_token_reused"
def test_happy_path_unchanged(monkeypatch):
"""Normal refresh succeeds → rotated tokens persisted, ~/.codex never consulted."""
saved = {}
import_calls = {"n": 0}
def _import_spy():
import_calls["n"] += 1
return None
monkeypatch.setattr(
auth,
"refresh_codex_oauth_pure",
lambda *a, **k: {"access_token": "rotated", "refresh_token": "rotated-r"},
)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", _import_spy)
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
out = _refresh_codex_auth_tokens({"access_token": "a", "refresh_token": "b"}, 20.0)
assert out["access_token"] == "rotated"
assert out["refresh_token"] == "rotated-r"
assert saved["access_token"] == "rotated"
assert import_calls["n"] == 0 # happy path must not consult ~/.codex
def test_reraises_when_imported_token_lacks_refresh_token(monkeypatch):
"""relogin-required, but ~/.codex returns an access_token with NO refresh_token →
re-raise rather than persist a half-token that would break the next refresh."""
saved = {}
def _rejected(*_a, **_k):
raise AuthError(
"refresh token rejected",
provider="openai-codex",
code="invalid_grant",
relogin_required=True,
)
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: {"access_token": "fresh-only"})
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
with pytest.raises(AuthError) as ei:
_refresh_codex_auth_tokens(STALE, 20.0)
assert ei.value.code == "invalid_grant"
assert saved == {} # nothing was persisted
def test_self_heals_missing_singleton_access_token_from_codex_cli(tmp_path, monkeypatch):
"""Exact cron failure path: Hermes auth has refresh_token but missing access_token."""
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex"
hermes_home.mkdir()
codex_home.mkdir()
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"refresh_token": "stale-refresh"},
"last_refresh": "2026-06-01T00:00:00Z",
"auth_mode": "chatgpt",
},
},
}))
(codex_home / "auth.json").write_text(json.dumps({
"tokens": {
"access_token": "fresh-access",
"refresh_token": "fresh-refresh",
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
resolved = resolve_codex_runtime_credentials()
assert resolved["api_key"] == "fresh-access"
assert resolved["source"] == "hermes-auth-store"
stored = json.loads((hermes_home / "auth.json").read_text())
tokens = stored["providers"]["openai-codex"]["tokens"]
assert tokens["access_token"] == "fresh-access"
assert tokens["refresh_token"] == "fresh-refresh"
def test_missing_singleton_access_token_reraises_when_codex_cli_half_token(tmp_path, monkeypatch):
"""Missing access_token must not be masked by a malformed Codex CLI import."""
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex"
hermes_home.mkdir()
codex_home.mkdir()
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"refresh_token": "stale-refresh"},
"auth_mode": "chatgpt",
},
},
}))
(codex_home / "auth.json").write_text(json.dumps({
"tokens": {"access_token": "fresh-only"},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
with pytest.raises(AuthError) as ei:
resolve_codex_runtime_credentials()
assert ei.value.code == "codex_auth_missing_access_token"
@@ -0,0 +1,99 @@
"""Unit tests for find_custom_provider_identity (base_url → custom:<name>).
Reverse lookup used by tui_gateway session persistence to recover a named
``providers:`` / ``custom_providers:`` entry from the only durable fact the
session row keeps once the provider has been resolved to the literal string
"custom": the endpoint URL. See
tests/tui_gateway/test_custom_provider_session_persistence.py for the
end-to-end persist/resume round-trip.
"""
import hermes_cli.runtime_provider as rp
def test_matches_legacy_custom_providers_list(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "MiMo v2.5 Pro", "base_url": "https://api.mimo.example/v1"}
]
},
)
assert (
rp.find_custom_provider_identity("https://api.mimo.example/v1")
== "custom:mimo-v2.5-pro"
)
def test_matches_providers_dict_by_key(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {"providers": {"local": {"api": "http://127.0.0.1:8000/v1"}}},
)
assert (
rp.find_custom_provider_identity("http://127.0.0.1:8000/v1")
== "custom:local"
)
def test_match_ignores_trailing_slash_and_case(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "local", "base_url": "http://Localhost:8000/v1/"}
]
},
)
assert (
rp.find_custom_provider_identity("http://localhost:8000/v1")
== "custom:local"
)
def test_no_match_returns_none(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "other", "base_url": "https://elsewhere.example/v1"}
]
},
)
assert rp.find_custom_provider_identity("https://api.mimo.example/v1") is None
def test_empty_base_url_returns_none(monkeypatch):
monkeypatch.setattr(
rp, "load_config", lambda: {"custom_providers": [{"name": "x"}]}
)
assert rp.find_custom_provider_identity("") is None
assert rp.find_custom_provider_identity(None) is None
def test_identity_resolves_back_through_named_lookup(monkeypatch):
"""The returned slug must be accepted by _get_named_custom_provider —
that is the whole point of persisting it."""
config = {
"custom_providers": [
{
"name": "mimo-v2.5-pro",
"base_url": "https://api.mimo.example/v1",
"api_key": "sk-entry",
}
]
}
monkeypatch.setattr(rp, "load_config", lambda: config)
slug = rp.find_custom_provider_identity("https://api.mimo.example/v1")
assert slug == "custom:mimo-v2.5-pro"
entry = rp._get_named_custom_provider(slug)
assert entry is not None
assert entry["base_url"] == "https://api.mimo.example/v1"
assert entry["api_key"] == "sk-entry"
+69
View File
@@ -1406,3 +1406,72 @@ class TestDoctorStaleMaxIterationsDrift:
monkeypatch, tmp_path, fix=False, ghost=None, cfg_turns=400,
)
assert "shadows" not in out
def test_npm_audit_fix_hint_avoids_crashing_workspace_flag(monkeypatch, tmp_path):
"""`hermes doctor` must not hand users `npm audit fix --workspace <name>`:
that exact form crashes npm with "Cannot read properties of null (reading
'edgesOut')" (an arborist bug with workspace-filtered audit fix).
It must not recommend root-level `npm audit fix` for workspace advisories
either: current npm can crash there too with "Cannot read properties of null
(reading 'isDescendantOf')" on this tree. The safe guidance is that these
build-tool advisories clear via the lockfile/package bump.
Regression for user reports where doctor flagged the web/ui-tui workspaces
and the suggested fix command errored out.
"""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
project = tmp_path / "project"
(project / "node_modules").mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
# Only npm is "installed" — keeps the rest of run_doctor's external checks
# quiet without affecting the npm-audit branch under test.
monkeypatch.setattr(
doctor_mod.shutil, "which", lambda cmd: "/usr/bin/npm" if cmd == "npm" else None
)
def mock_run(cmd, **kwargs):
if "audit" in cmd and "--workspace" in cmd:
payload = (
'{"metadata": {"vulnerabilities": '
'{"critical": 0, "high": 2, "moderate": 0}}}'
)
return SimpleNamespace(returncode=1, stdout=payload, stderr="")
if "audit" in cmd:
payload = (
'{"metadata": {"vulnerabilities": '
'{"critical": 0, "high": 0, "moderate": 0}}}'
)
return SimpleNamespace(returncode=0, stdout=payload, stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
import subprocess
monkeypatch.setattr(subprocess, "run", mock_run)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
# The workspace vulnerability is still reported ...
assert "web workspace" in out
# ... but the remediation must NOT use the npm-crashing per-workspace form
# (`npm audit fix --workspace web` / `--workspace ui-tui`).
assert "npm audit fix --workspace web" not in out
assert "npm audit fix --workspace ui-tui" not in out
# ... and it must not point at the root-level form either: npm can crash
# there too with `isDescendantOf` on this monorepo tree.
assert "npm audit fix" not in out
# ... and explains the workspace advisories are build-time tooling whose
# manual remediation may hit a known npm arborist crash, so the user isn't
# left thinking a crashing command means a broken Hermes install.
assert "build-time tooling" in out
assert "known npm bug" in out
assert "lockfile bump" in out
+14
View File
@@ -274,6 +274,20 @@ def test_gateway_start_in_container_with_operational_systemd_uses_systemd(monkey
assert calls == [False]
def test_gateway_start_ignores_legacy_platform_selector(monkeypatch):
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: True)
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
monkeypatch.setattr(gateway, "is_macos", lambda: False)
calls = []
monkeypatch.setattr(gateway, "systemd_start", lambda system=False: calls.append(system))
args = SimpleNamespace(gateway_command="start", system=False, all=False, platform="photon")
gateway.gateway_command(args)
assert calls == [False]
def test_gateway_restart_on_windows_without_service_uses_detached_backend(monkeypatch):
"""Windows manual restart must not fall back to foreground run_gateway().
@@ -0,0 +1,118 @@
from __future__ import annotations
import subprocess
def _make_task(kb, *, assignee: str):
return kb.Task(
id="t_spawn_tools",
title="spawn tools",
body=None,
assignee=assignee,
status="running",
priority=0,
created_by="test",
created_at=1,
started_at=None,
completed_at=None,
workspace_kind="dir",
workspace_path=None,
claim_lock="lock",
claim_expires=None,
tenant=None,
current_run_id=7,
)
def test_default_spawn_pins_assignee_profile_cli_toolsets(monkeypatch, tmp_path):
"""Manual profile assignment should keep that profile's CLI tools.
Regression guard for dispatcher-spawned workers that boot with
HERMES_KANBAN_TASK: the worker must not collapse to only kanban lifecycle
tools when the assigned profile's top-level ``toolsets`` is the default
composite. The spawned CLI gets an explicit --toolsets pin resolved from
platform_toolsets.cli; model_tools appends task-scoped kanban tools later.
"""
root = tmp_path / ".hermes"
profile = root / "profiles" / "elias"
profile.mkdir(parents=True)
profile.joinpath("config.yaml").write_text(
"""
platform_toolsets:
cli:
- clarify
- code_execution
- delegation
- file
- memory
- session_search
- skills
- terminal
- web
toolsets:
- hermes-cli
agent:
disabled_toolsets: []
""".lstrip(),
encoding="utf-8",
)
root.joinpath("config.yaml").write_text("toolsets:\n - kanban\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(root))
from hermes_cli import kanban_db as kb
monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"])
captured = {}
class FakeProc:
pid = 4242
def fake_popen(cmd, *args, **kwargs):
captured["cmd"] = list(cmd)
captured["env"] = dict(kwargs.get("env") or {})
captured["cwd"] = kwargs.get("cwd")
return FakeProc()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
workspace = tmp_path / "workspace"
workspace.mkdir()
pid = kb._default_spawn(_make_task(kb, assignee="elias"), str(workspace))
assert pid == 4242
assert captured["env"]["HERMES_HOME"] == str(profile)
assert captured["env"]["HERMES_KANBAN_TASK"] == "t_spawn_tools"
assert "--toolsets" in captured["cmd"]
pinned = captured["cmd"][captured["cmd"].index("--toolsets") + 1].split(",")
for required in ("terminal", "web", "file", "skills", "code_execution", "delegation"):
assert required in pinned
def test_resolve_worker_cli_toolsets_uses_profile_home_not_parent_config(monkeypatch, tmp_path):
root = tmp_path / ".hermes"
profile = root / "profiles" / "elias"
profile.mkdir(parents=True)
root.joinpath("config.yaml").write_text("platform_toolsets:\n cli:\n - kanban\n", encoding="utf-8")
profile.joinpath("config.yaml").write_text(
"""
platform_toolsets:
cli:
- terminal
- web
toolsets:
- hermes-cli
""".lstrip(),
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(root))
from hermes_cli import kanban_db as kb
resolved = kb._resolve_worker_cli_toolsets(str(profile))
assert resolved is not None
assert "terminal" in resolved
assert "web" in resolved
assert "kanban" in resolved # recovered worker lifecycle surface
assert resolved != ["kanban"]
@@ -65,6 +65,61 @@ def test_resolve_provider_full_finds_named_custom_provider():
assert resolved.source == "user-config"
def test_list_authenticated_providers_includes_active_bare_custom_endpoint(monkeypatch):
"""Bare model.provider=custom + model.base_url should still populate /model.
Users can configure a one-off OpenAI-compatible endpoint directly under
``model:`` without a named ``providers:`` or ``custom_providers:`` row.
The gateway picker receives only the current model/base_url slice, so it
must surface that active endpoint rather than looking like config was
ignored.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom",
current_base_url="https://www.ccsub.net/v1",
current_model="gpt-4o",
user_providers={},
custom_providers=[],
max_models=50,
)
bare_custom = next((p for p in providers if p["slug"] == "custom"), None)
assert bare_custom is not None
assert bare_custom["name"] == "Custom endpoint"
assert bare_custom["is_current"] is True
assert bare_custom["is_user_defined"] is True
assert bare_custom["models"] == ["gpt-4o"]
assert bare_custom["api_url"] == "https://www.ccsub.net/v1"
def test_switch_model_accepts_explicit_bare_custom_current_endpoint(monkeypatch):
"""Picker selections for bare custom endpoints should route to current base_url."""
monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)
result = switch_model(
raw_input="gpt-4o-mini",
current_provider="custom",
current_model="gpt-4o",
current_base_url="https://www.ccsub.net/v1",
current_api_key="sk-test",
explicit_provider="custom",
user_providers={},
custom_providers=[],
)
assert result.success is True
assert result.target_provider == "custom"
assert result.provider_label == "Custom endpoint"
assert result.new_model == "gpt-4o-mini"
assert result.base_url == "https://www.ccsub.net/v1"
assert result.api_key == "sk-test"
def test_is_aggregator_recognizes_named_custom_provider():
assert providers_mod.is_aggregator("custom:hpc-ai") is True
assert providers_mod.is_aggregator("custom:litellm") is True
@@ -81,3 +81,12 @@ def test_gateway_accept_hooks_flag():
p = _gateway_parser()
ns = p.parse_args(["gateway", "run", "--accept-hooks"])
assert ns.accept_hooks is True
def test_gateway_lifecycle_accepts_legacy_platform_flag():
p = _gateway_parser()
for action in ("start", "restart", "status"):
ns = p.parse_args(["gateway", action, "--platform", "photon"])
assert ns.gateway_command == action
assert ns.platform == "photon"
assert ns.func is _h_gateway
+131
View File
@@ -520,6 +520,87 @@ class TestWebServerEndpoints:
resp = self.client.get("/api/profiles/sessions?archived=bogus")
assert resp.status_code == 400
def test_sessions_endpoint_reads_requested_profile(self):
"""The machine dashboard's global profile switcher must retarget
the Sessions page, not just config/skills/model pages."""
from hermes_state import SessionDB
from hermes_cli import profiles as profiles_mod
worker_home = profiles_mod.get_profile_dir("worker")
worker_home.mkdir(parents=True)
default_db = SessionDB()
try:
default_db.create_session(session_id="default-only", source="cli")
default_db.append_message("default-only", role="user", content="default")
finally:
default_db.close()
worker_db = SessionDB(db_path=worker_home / "state.db")
try:
worker_db.create_session(session_id="worker-only", source="cli")
worker_db.append_message("worker-only", role="user", content="worker")
finally:
worker_db.close()
resp = self.client.get("/api/sessions?profile=worker&limit=20&min_messages=0")
assert resp.status_code == 200
data = resp.json()
ids = {s["id"] for s in data["sessions"]}
assert "worker-only" in ids
assert "default-only" not in ids
row = next(s for s in data["sessions"] if s["id"] == "worker-only")
assert row["profile"] == "worker"
assert row["is_default_profile"] is False
stats = self.client.get("/api/sessions/stats?profile=worker").json()
assert stats["total"] == 1
assert stats["messages"] == 1
messages = self.client.get("/api/sessions/worker-only/messages?profile=worker").json()
assert [m["content"] for m in messages["messages"]] == ["worker"]
def test_analytics_endpoints_read_requested_profile(self):
from hermes_state import SessionDB
from hermes_cli import profiles as profiles_mod
worker_home = profiles_mod.get_profile_dir("worker")
worker_home.mkdir(parents=True)
default_db = SessionDB()
try:
default_db.create_session(session_id="default-usage", source="cli", model="default/model")
default_db.update_token_counts("default-usage", input_tokens=10, output_tokens=5)
finally:
default_db.close()
worker_db = SessionDB(db_path=worker_home / "state.db")
try:
worker_db.create_session(session_id="worker-usage", source="cli", model="worker/model")
worker_db.update_token_counts(
"worker-usage",
input_tokens=123,
output_tokens=45,
billing_provider="worker-provider",
)
finally:
worker_db.close()
usage = self.client.get("/api/analytics/usage?days=7&profile=worker").json()
assert usage["totals"]["total_sessions"] == 1
assert usage["totals"]["total_input"] == 123
assert [m["model"] for m in usage["by_model"]] == ["worker/model"]
models = self.client.get("/api/analytics/models?days=7&profile=worker").json()
assert models["totals"]["distinct_models"] == 1
assert models["totals"]["total_input"] == 123
assert models["models"][0]["model"] == "worker/model"
assert models["models"][0]["provider"] == "worker-provider"
default_usage = self.client.get("/api/analytics/usage?days=7").json()
assert default_usage["totals"]["total_input"] == 10
assert default_usage["totals"]["total_output"] == 5
def test_get_sessions_rejects_unknown_archived_value(self):
resp = self.client.get("/api/sessions?archived=bogus")
assert resp.status_code == 400
@@ -3196,6 +3277,56 @@ class TestNewEndpoints:
"top_skills": [],
}
def test_models_analytics_merges_session_only_duplicate_into_accounted_provider(self):
"""Session-only model rows should not render as duplicate zero-token cards.
Direct-provider-on-OpenRouter sessions can leave one row with only
``model`` populated and another row with token/API accounting plus
``billing_provider``. The Models dashboard should show one provider
card, not a real card plus a misleading duplicate empty card.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(
session_id="deepseek-session-only",
source="cli",
model="deepseek/deepseek-v4-flash",
)
db.create_session(
session_id="deepseek-accounted",
source="cli",
model="deepseek/deepseek-v4-flash",
)
db.update_token_counts(
"deepseek-accounted",
input_tokens=20_000,
output_tokens=7_100,
billing_provider="openrouter",
api_call_count=9,
)
finally:
db.close()
resp = self.client.get("/api/analytics/models?days=7")
assert resp.status_code == 200
models = resp.json()["models"]
deepseek_rows = [
row for row in models
if row["model"] == "deepseek/deepseek-v4-flash"
]
assert len(deepseek_rows) == 1
row = deepseek_rows[0]
assert row["provider"] == "openrouter"
assert row["sessions"] == 2
assert row["input_tokens"] == 20_000
assert row["output_tokens"] == 7_100
assert row["api_calls"] == 9
assert row["avg_tokens_per_session"] == 13_550
def test_analytics_usage_includes_skill_breakdown(self):
from hermes_state import SessionDB
@@ -7,6 +7,8 @@ never clobbers a hand-tuned allowlist.
"""
from __future__ import annotations
import argparse
import pytest
from hermes_cli.config import get_env_value, save_env_value
@@ -67,3 +69,44 @@ def test_env_enablement_home_channel_defaults_name(monkeypatch: pytest.MonkeyPat
"chat_id": "+15551234567",
"name": "Home",
}
def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
monkeypatch.setattr(
cli.photon_auth,
"ensure_spectrum_enabled",
lambda token, dashboard_id: {"spectrumProjectId": "project_123"},
)
monkeypatch.setattr(
cli.photon_auth,
"regenerate_project_secret",
lambda token, dashboard_id: "secret_123",
)
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
monkeypatch.setattr(
cli.photon_auth,
"register_user_if_absent",
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+15551234567"}, True),
)
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+15557654321")
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
rc = cli._cmd_setup(
argparse.Namespace(
project_name=None,
phone="+15551234567",
first_name=None,
last_name=None,
email=None,
no_browser=True,
skip_sidecar_install=False,
)
)
assert rc == 0
out = capsys.readouterr().out
assert "Start the gateway: hermes gateway start" in out
assert "--platform photon" not in out
+143
View File
@@ -0,0 +1,143 @@
"""Regression: installer fails when the existing checkout has an unmerged index.
A previously interrupted update can leave ``$INSTALL_DIR`` with unmerged index
entries (files in a conflicted, "needs merge" state). In that state the update
path's ``git stash`` aborts with "could not write index" and the following
``git checkout <branch>`` aborts with "you need to resolve your current index
first" -- surfacing to GUI/bootstrap users as ``git checkout main failed
(exit 1)`` and failing the whole install at the repository stage.
The ``hermes update`` Python path already clears the conflict with ``git reset``
before stashing (#4735); both installer scripts must do the same.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
pytestmark = pytest.mark.skipif(
shutil.which("git") is None or shutil.which("bash") is None,
reason="needs git and bash",
)
def _git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(
["git", "-c", "user.email=t@t", "-c", "user.name=t", *args],
cwd=cwd,
check=check,
capture_output=True,
text=True,
)
def _extract_autostash_block() -> str:
"""Pull the autostash if-block from install.sh's update_repo()."""
text = INSTALL_SH.read_text()
m = re.search(
r'local autostash_ref="".*?\n fi\n',
text,
re.DOTALL,
)
assert m is not None, "autostash block not found in install.sh"
return m.group(0)
def _make_unmerged_repo(repo: Path) -> None:
"""Leave ``repo`` with a conflicted (unmerged) index, as an interrupted
update would."""
_git(repo, "init")
(repo / "f.txt").write_text("base\n")
_git(repo, "add", "f.txt")
_git(repo, "commit", "-m", "base")
# Capture the default branch name only after the first commit exists
# (rev-parse on an unborn HEAD errors).
start = _git(repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
_git(repo, "checkout", "-b", "feature")
(repo / "f.txt").write_text("feature side\n")
_git(repo, "add", "f.txt")
_git(repo, "commit", "-m", "feature")
_git(repo, "checkout", start)
(repo / "f.txt").write_text("main side\n")
_git(repo, "add", "f.txt")
_git(repo, "commit", "-m", "mainside")
# Conflicting merge — exits non-zero and leaves the index unmerged.
_git(repo, "merge", "feature", check=False)
@pytest.mark.live_system_guard_bypass # runs against a dedicated throwaway repo
def test_install_sh_clears_unmerged_index_then_stashes(tmp_path: Path) -> None:
repo = tmp_path / "hermes-agent"
repo.mkdir()
_make_unmerged_repo(repo)
# Sanity: this is exactly the state that breaks `git stash` / `git checkout`.
assert _git(repo, "ls-files", "--unmerged").stdout.strip(), (
"test setup failed to produce an unmerged index"
)
block = _extract_autostash_block()
script = (
"set -e\n"
'log_info() { echo "INFO: $*"; }\n'
"run() {\n"
f"{block}"
"}\n"
"run\n"
"echo BLOCK_OK\n"
)
res = subprocess.run(
["bash", "-c", script], cwd=repo, capture_output=True, text=True
)
# The block must complete (previously `git stash` failed with "could not
# write index" on the unmerged tree).
assert res.returncode == 0, res.stderr
assert "BLOCK_OK" in res.stdout
assert "Clearing unmerged index entries" in res.stdout
# The conflict state is gone ...
assert _git(repo, "ls-files", "--unmerged").stdout.strip() == "", (
"unmerged entries should have been cleared"
)
# ... and the local changes were preserved in a stash, not discarded.
assert _git(repo, "stash", "list").stdout.strip(), (
"local changes should be preserved in a stash"
)
def test_install_ps1_clears_unmerged_index_before_stash() -> None:
"""install.ps1 must clear an unmerged index before stash/checkout, and do
so *before* the stash push (order matters the fix is a no-op otherwise)."""
text = INSTALL_PS1.read_text()
assert "ls-files --unmerged" in text, (
"install.ps1 must detect an unmerged index before updating"
)
idx_unmerged = text.index("ls-files --unmerged")
idx_reset = text.index("reset -q", idx_unmerged)
idx_stash = text.index("stash push --include-untracked")
assert idx_unmerged < idx_stash, (
"the unmerged-index clear must run before `git stash push`"
)
assert idx_reset < idx_stash, "`git reset` must run before `git stash push`"
def test_install_sh_clears_unmerged_index_before_stash_source_order() -> None:
"""Same ordering contract for install.sh's source."""
text = INSTALL_SH.read_text()
assert "ls-files --unmerged" in text
idx_unmerged = text.index("ls-files --unmerged")
idx_stash = text.index("stash push --include-untracked")
assert idx_unmerged < idx_stash
+64
View File
@@ -847,6 +847,41 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display():
]
def test_history_to_messages_keeps_reasoning_only_assistant_turn():
# A thinking-only assistant turn (reasoning present, no visible text) is
# persisted and recallable, but was dropped from the resumed session view
# as "empty" -- so it vanished while the agent could still recall it from
# the transcript. Keep it (with reasoning) so the desktop "Thinking…"
# disclosure renders. (#44022)
history = [
{"role": "user", "content": "think about this"},
{"role": "assistant", "content": "", "reasoning": "step-by-step thoughts"},
{"role": "assistant", "content": "here is the answer"},
]
assert server._history_to_messages(history) == [
{"role": "user", "text": "think about this"},
{"role": "assistant", "text": "", "reasoning": "step-by-step thoughts"},
{"role": "assistant", "text": "here is the answer"},
]
def test_history_to_messages_still_drops_empty_assistant_without_reasoning():
# A genuinely empty assistant turn (no text, no reasoning, no tool calls)
# remains filtered out -- the fix only spares reasoning-bearing turns.
history = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "", "reasoning": ""},
{"role": "assistant", "content": " "},
{"role": "assistant", "content": "real reply"},
]
assert server._history_to_messages(history) == [
{"role": "user", "text": "hi"},
{"role": "assistant", "text": "real reply"},
]
def test_history_to_messages_renders_multimodal_content():
# bb/gui preserves image URLs in the resume payload so the desktop
# renderer's extractEmbeddedImages can pull them back out and display
@@ -971,6 +1006,35 @@ def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):
assert server._sessions[runtime_sid]["model_override"] == captured["model_override"]
def test_stored_session_runtime_overrides_skips_bare_billing_provider():
"""A bare billing bucket ("custom"/"auto"/"openrouter") must not be restored as the
provider identity on resume. A custom endpoint that never used `/model` persists only
`billing_provider="custom"`; restoring that broke `session.resume` with "No LLM provider
configured" (agent_init treats it as non-routable). A real provider, or an explicit
`model_config.provider`, is still restored.
"""
# Bare "custom" bucket, no explicit model_config.provider: no provider override restored.
ov = server._stored_session_runtime_overrides({"model": "my-model", "billing_provider": "custom"})
assert "provider_override" not in ov
assert ov["model_override"]["provider"] is None
for bare in ("auto", "openrouter", "custom"):
ov = server._stored_session_runtime_overrides({"model": "m", "billing_provider": bare})
assert "provider_override" not in ov
# A real provider in billing_provider is still restored.
ov = server._stored_session_runtime_overrides({"model": "m", "billing_provider": "anthropic"})
assert ov["provider_override"] == "anthropic"
assert ov["model_override"]["provider"] == "anthropic"
# An explicit routable provider in model_config wins over the bare billing bucket.
ov = server._stored_session_runtime_overrides(
{"model": "m", "billing_provider": "custom", "model_config": {"provider": "custom:myendpoint"}}
)
assert ov["provider_override"] == "custom:myendpoint"
assert ov["model_override"]["provider"] == "custom:myendpoint"
def test_persist_live_session_runtime_preserves_resume_metadata(monkeypatch):
updates = {}
+16 -1
View File
@@ -69,9 +69,24 @@ class TestWriteDenyExactPaths:
def test_shell_profiles(self):
home = str(Path.home())
for name in [".bashrc", ".zshrc", ".profile", ".bash_profile", ".zprofile"]:
for name in [
".bashrc", ".zshrc", ".profile", ".bash_profile", ".zprofile",
".zshenv", ".zlogin", ".bash_login",
]:
assert _is_write_denied(os.path.join(home, name)) is True, f"{name} should be denied"
def test_global_git_config_paths(self):
home = str(Path.home())
for path in [
os.path.join(home, ".gitconfig"),
os.path.join(home, ".config", "git", "config"),
os.path.join(home, ".config", "git", "hooks", "pre-commit"),
]:
assert _is_write_denied(path) is True, f"{path} should be denied"
def test_project_git_config_allowed(self):
assert _is_write_denied("/tmp/someproject/.git/config") is False
def test_package_manager_configs(self):
home = str(Path.home())
for name in [".npmrc", ".pypirc", ".pgpass"]:
@@ -0,0 +1,198 @@
"""Session persistence must not strip a custom provider's identity.
``_runtime_model_config`` persists the live agent's RESOLVED provider into
the session row's ``model_config`` JSON. For any named ``providers:`` /
``custom_providers:`` entry (e.g. one called "mimo-v2.5-pro"),
``agent.provider`` is the literal string "custom", so the entry name was
lost and the api_key is deliberately never persisted. On ``session.resume``
or ``_reset_session_agent``, ``_stored_session_runtime_overrides`` fed
provider="custom" back into ``_make_agent``
``resolve_runtime_provider(requested="custom")``, which cannot match an entry
named "mimo-v2.5-pro". Depending on config the rebuild either raised
"No LLM provider configured. Run `hermes model`..." (resume failed) or
silently resolved placeholder credentials ("no-key-required") against the
patched-back base_url.
Fix: persist the REQUESTED/entry identity ``_runtime_model_config`` maps
the agent's base_url back to the canonical ``custom:<name>`` menu key via
``find_custom_provider_identity``; ``_make_agent`` performs the same
recovery for rows persisted before the fix (and falls back to handing the
stored base_url to the direct-alias branch when no entry matches).
Related investigation: GH #44070 / PR #44099 (credential-pool base_url
pinning); same family of resolved-vs-requested identity loss.
"""
import json
import types
from unittest.mock import MagicMock, patch
import hermes_cli.runtime_provider as rp
MIMO_URL = "https://token-plan-cn.xiaomimimo.com/v1"
MIMO_KEY = "sk-mimo-entry-key"
LEGACY_LIST_CONFIG = {
"custom_providers": [
{
"name": "mimo-v2.5-pro",
"base_url": MIMO_URL,
"api_key": MIMO_KEY,
"api_mode": "chat_completions",
}
]
}
PROVIDERS_DICT_CONFIG = {
"providers": {
"mimo-v2.5-pro": {
"api": MIMO_URL,
"api_key": MIMO_KEY,
}
}
}
def _custom_agent(base_url=MIMO_URL):
return types.SimpleNamespace(
model="mimo-v2.5-pro",
provider="custom",
base_url=base_url,
api_mode="chat_completions",
reasoning_config=None,
service_tier=None,
)
class TestRuntimeModelConfigPersistsEntryIdentity:
def test_persists_menu_key_instead_of_resolved_custom(self, monkeypatch):
monkeypatch.setattr(rp, "load_config", lambda: LEGACY_LIST_CONFIG)
from tui_gateway.server import _runtime_model_config
config = _runtime_model_config(_custom_agent())
assert config["provider"] == "custom:mimo-v2.5-pro"
assert config["base_url"] == MIMO_URL
# Credentials must keep coming from config/provider resolution,
# never from the session DB.
assert "api_key" not in config
def test_persists_menu_key_for_providers_dict_entry(self, monkeypatch):
monkeypatch.setattr(rp, "load_config", lambda: PROVIDERS_DICT_CONFIG)
from tui_gateway.server import _runtime_model_config
config = _runtime_model_config(_custom_agent())
assert config["provider"] == "custom:mimo-v2.5-pro"
def test_keeps_bare_custom_when_no_entry_matches(self, monkeypatch):
monkeypatch.setattr(rp, "load_config", lambda: {})
from tui_gateway.server import _runtime_model_config
config = _runtime_model_config(_custom_agent())
assert config["provider"] == "custom"
def test_non_custom_provider_untouched(self, monkeypatch):
def _boom():
raise AssertionError("identity lookup must not run for built-ins")
monkeypatch.setattr(rp, "load_config", _boom)
from tui_gateway.server import _runtime_model_config
agent = _custom_agent()
agent.provider = "anthropic"
agent.base_url = "https://api.anthropic.com"
assert _runtime_model_config(agent)["provider"] == "anthropic"
def _make_agent_with_override(override, monkeypatch, config):
"""Run _make_agent through the REAL resolve_runtime_provider against a
patched config, returning the kwargs AIAgent was constructed with."""
monkeypatch.setattr(rp, "load_config", lambda: config)
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
# Keep credential-pool resolution off the developer's real HERMES home.
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
fake_cfg = {"agent": {"system_prompt": ""}, "model": {"default": "unused"}}
with (
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch("tui_gateway.server._load_reasoning_config", return_value=None),
patch("tui_gateway.server._load_service_tier", return_value=None),
patch("tui_gateway.server._load_enabled_toolsets", return_value=None),
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent
_make_agent("sid-custom", "key-custom", model_override=override)
return mock_agent.call_args.kwargs
class TestResumeRoundTrip:
def test_round_trip_restores_entry_credentials(self, monkeypatch):
"""persist → stored-overrides → _make_agent resolves the entry's
api_key again (the exact path that raised "No LLM provider
configured" before the fix)."""
monkeypatch.setattr(rp, "load_config", lambda: LEGACY_LIST_CONFIG)
from tui_gateway.server import (
_runtime_model_config,
_stored_session_runtime_overrides,
)
model_config = _runtime_model_config(_custom_agent())
row = {
"model": "mimo-v2.5-pro",
"model_config": json.dumps(model_config),
}
overrides = _stored_session_runtime_overrides(row)
assert overrides["model_override"]["provider"] == "custom:mimo-v2.5-pro"
kwargs = _make_agent_with_override(
overrides["model_override"], monkeypatch, LEGACY_LIST_CONFIG
)
assert kwargs["provider"] == "custom"
assert kwargs["base_url"] == MIMO_URL
assert kwargs["api_key"] == MIMO_KEY
def test_legacy_row_with_bare_custom_heals_via_base_url(self, monkeypatch):
"""Rows persisted BEFORE the fix stored provider="custom"; the
rebuild must recover the entry identity from the stored base_url."""
override = {
"model": "mimo-v2.5-pro",
"provider": "custom",
"base_url": MIMO_URL,
"api_mode": "chat_completions",
}
kwargs = _make_agent_with_override(override, monkeypatch, LEGACY_LIST_CONFIG)
assert kwargs["base_url"] == MIMO_URL
assert kwargs["api_key"] == MIMO_KEY
def test_legacy_row_without_matching_entry_keeps_endpoint(self, monkeypatch):
"""No config entry owns the stored URL: the direct-alias branch must
still receive the base_url so resolution targets the session's
endpoint instead of raising auth_unavailable."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
override = {
"model": "local-model",
"provider": "custom",
"base_url": "http://127.0.0.1:8000/v1",
"api_mode": "chat_completions",
}
kwargs = _make_agent_with_override(override, monkeypatch, {})
assert kwargs["provider"] == "custom"
assert kwargs["base_url"] == "http://127.0.0.1:8000/v1"
assert kwargs["api_key"] == "no-key-required"
+1 -1
View File
@@ -252,7 +252,7 @@ THREAT_PATTERNS = [
(r'\bcrontab\b',
"persistence_cron", "medium", "persistence",
"modifies cron jobs"),
(r'\.(bashrc|zshrc|profile|bash_profile|bash_login|zprofile|zlogin)\b',
(r'\.(bashrc|zshrc|zshenv|profile|bash_profile|bash_login|zprofile|zlogin)\b',
"shell_rc_mod", "medium", "persistence",
"references shell startup file"),
(r'authorized_keys',
+74 -12
View File
@@ -1477,6 +1477,11 @@ def _resolve_startup_runtime() -> tuple[str, str | None]:
return model, None
# Bare billing buckets are not routable provider identities (kept in parity with the
# provider gate in agent_init). Restoring one as a session provider override breaks resume.
_BARE_BILLING_PROVIDERS = {"auto", "openrouter", "custom"}
def _stored_session_runtime_overrides(row: dict | None) -> dict:
"""Return runtime fields persisted with a stored session.
@@ -1503,12 +1508,18 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict:
overrides: dict = {}
model = str(row.get("model") or model_config.get("model") or "").strip()
provider = str(
model_config.get("provider")
or model_config.get("billing_provider")
or row.get("billing_provider")
or ""
# ``billing_provider`` is only the billing bucket — for a custom endpoint it is the
# bare class ``"custom"``, which agent_init treats as non-routable, so restoring it as
# the provider override makes ``session.resume`` fail with "No LLM provider configured".
# Only restore an explicit provider; otherwise leave it unset so resume falls back to
# the configured default, matching the working CLI path.
explicit_provider = str(model_config.get("provider") or "").strip()
billing_provider = str(
model_config.get("billing_provider") or row.get("billing_provider") or ""
).strip()
provider = explicit_provider
if not provider and billing_provider.lower() not in _BARE_BILLING_PROVIDERS:
provider = billing_provider
base_url = str(model_config.get("base_url") or "").strip()
api_mode = str(model_config.get("api_mode") or "").strip()
reasoning_config = model_config.get("reasoning_config")
@@ -1548,6 +1559,25 @@ def _runtime_model_config(agent, existing: dict | None = None) -> dict:
if model:
config["model"] = model
if provider:
if provider == "custom" and base_url:
# ``agent.provider`` is the RESOLVED provider, and for any named
# ``providers:`` / ``custom_providers:`` entry that is the literal
# string "custom" — persisting it loses the entry identity, so a
# later resume/rebuild cannot re-resolve the entry's credentials
# (the api_key is deliberately never persisted; see
# _stored_session_runtime_overrides). Recover the canonical
# ``custom:<name>`` menu key from the endpoint URL so
# resolve_runtime_provider() can find the entry again.
try:
from hermes_cli.runtime_provider import (
find_custom_provider_identity,
)
provider = find_custom_provider_identity(base_url) or provider
except Exception:
logger.debug(
"custom provider identity lookup failed", exc_info=True
)
config["provider"] = provider
if base_url:
config["base_url"] = base_url
@@ -3299,9 +3329,30 @@ def _make_agent(
override_base_url = model_override.get("base_url")
override_api_key = model_override.get("api_key")
override_api_mode = model_override.get("api_mode")
resolve_kwargs = {}
if (
override_base_url
and str(requested_provider or "").strip().lower() == "custom"
):
# Session rows persisted before the custom-provider identity fix
# (see _runtime_model_config) stored the resolved provider
# "custom", which _get_named_custom_provider cannot match back to
# a named ``providers:`` / ``custom_providers:`` entry — the
# rebuild then either raised auth_unavailable or silently
# resolved placeholder credentials against the patched-back
# base_url. Recover the entry identity from the persisted
# base_url; failing that, hand the base_url to the direct-alias
# branch so pool/env credentials can still be resolved for it.
from hermes_cli.runtime_provider import find_custom_provider_identity
recovered = find_custom_provider_identity(override_base_url)
if recovered:
requested_provider = recovered
resolve_kwargs["explicit_base_url"] = override_base_url
runtime = resolve_runtime_provider(
requested=requested_provider,
target_model=model or None,
**resolve_kwargs,
)
# The switch already resolved concrete credentials/endpoint; honor them
# so a custom/named endpoint survives the rebuild even if global
@@ -3657,16 +3708,27 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
{"role": "tool", "name": name, "context": _tool_ctx(name, args)}
)
continue
if not content_text.strip():
# An assistant turn may carry only reasoning/thinking content with no
# visible text (extended-thinking turns, thinking-only recovery
# responses). Such a turn is persisted with its reasoning fields and is
# recallable from the transcript, but dropping it here as "empty" makes
# it vanish from the resumed/reloaded session view while the desktop's
# reasoning disclosure has nothing to render. Keep it when it carries
# reasoning so the "Thinking…" block still shows. (#44022)
reasoning_keys = (
"reasoning",
"reasoning_content",
"reasoning_details",
"codex_reasoning_items",
)
has_reasoning = role == "assistant" and any(
m.get(key) for key in reasoning_keys
)
if not content_text.strip() and not has_reasoning:
continue
msg = {"role": role, "text": content_text}
if role == "assistant":
for key in (
"reasoning",
"reasoning_content",
"reasoning_details",
"codex_reasoning_items",
):
for key in reasoning_keys:
if key in m and m.get(key) is not None:
msg[key] = m.get(key)
messages.append(msg)
+62 -33
View File
@@ -59,11 +59,12 @@ export function getManagementProfile(): string {
}
// Endpoint families that honor ?profile= on the backend (web_server.py
// _profile_scope). Anything else — sessions, analytics, ops, pairing,
// telegram onboarding, cron (which has its own per-job profile params),
// profiles themselves — is machine-global or self-scoped and must NOT be
// rewritten.
// _profile_scope or explicit per-profile DB opens). Anything else — ops,
// pairing, telegram onboarding, cron (which has its own per-job profile
// params), profiles themselves — is machine-global or self-scoped and must
// NOT be rewritten.
const PROFILE_SCOPED_PREFIXES = [
"/api/analytics",
"/api/skills",
"/api/tools/toolsets",
"/api/config",
@@ -302,6 +303,11 @@ function profileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
function appendProfileParam(url: string, profile?: string): string {
if (!profile || url.includes("profile=")) return url;
return `${url}${url.includes("?") ? "&" : "?"}profile=${encodeURIComponent(profile)}`;
}
export const api = {
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
/**
@@ -336,47 +342,64 @@ export const api = {
window.location.assign("/login");
return r;
}),
getSessions: (limit = 20, offset = 0) =>
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
getSessionMessages: (id: string) =>
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
getSessions: (limit = 20, offset = 0, profile = getManagementProfile()) =>
fetchJSON<PaginatedSessions>(
appendProfileParam(`/api/sessions?limit=${limit}&offset=${offset}`, profile),
),
getSessionMessages: (id: string, profile = getManagementProfile()) =>
fetchJSON<SessionMessagesResponse>(
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/messages`, profile),
),
getSessionLatestDescendant: (id: string) =>
fetchJSON<SessionLatestDescendantResponse>(
`/api/sessions/${encodeURIComponent(id)}/latest-descendant`,
),
deleteSession: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
getEmptySessionsCount: () =>
fetchJSON<{ count: number }>("/api/sessions/empty/count"),
deleteEmptySessions: () =>
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/empty", {
method: "DELETE",
}),
bulkDeleteSessions: (ids: string[]) =>
deleteSession: (id: string, profile = getManagementProfile()) =>
fetchJSON<{ ok: boolean }>(
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile),
{
method: "DELETE",
},
),
getEmptySessionsCount: (profile = getManagementProfile()) =>
fetchJSON<{ count: number }>(
appendProfileParam("/api/sessions/empty/count", profile),
),
deleteEmptySessions: (profile = getManagementProfile()) =>
fetchJSON<{ ok: boolean; deleted: number }>(
appendProfileParam("/api/sessions/empty", profile),
{
method: "DELETE",
},
),
bulkDeleteSessions: (ids: string[], profile = getManagementProfile()) =>
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
body: JSON.stringify({ ids, profile: profile || undefined }),
}),
renameSession: (id: string, title: string) =>
renameSession: (id: string, title: string, profile = getManagementProfile()) =>
fetchJSON<{ ok: boolean; title: string }>(
`/api/sessions/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
body: JSON.stringify({ title, profile: profile || undefined }),
},
),
getSessionStats: () => fetchJSON<SessionStoreStats>("/api/sessions/stats"),
exportSessionUrl: (id: string) =>
`/api/sessions/${encodeURIComponent(id)}/export`,
pruneSessions: (older_than_days: number, source?: string) =>
getSessionStats: (profile = getManagementProfile()) =>
fetchJSON<SessionStoreStats>(appendProfileParam("/api/sessions/stats", profile)),
exportSessionUrl: (id: string, profile = getManagementProfile()) =>
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/export`, profile),
pruneSessions: (
older_than_days: number,
source?: string,
profile = getManagementProfile(),
) =>
fetchJSON<{ ok: boolean; removed: number }>("/api/sessions/prune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ older_than_days, source }),
body: JSON.stringify({ older_than_days, source, profile: profile || undefined }),
}),
listFiles: (path?: string) => {
const query = path ? `?path=${encodeURIComponent(path)}` : "";
@@ -412,10 +435,14 @@ export const api = {
if (params.component && params.component !== "all") qs.set("component", params.component);
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
},
getAnalytics: (days: number) =>
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
getModelsAnalytics: (days: number) =>
fetchJSON<ModelsAnalyticsResponse>(`/api/analytics/models?days=${days}`),
getAnalytics: (days: number, profile = getManagementProfile()) =>
fetchJSON<AnalyticsResponse>(
appendProfileParam(`/api/analytics/usage?days=${days}`, profile),
),
getModelsAnalytics: (days: number, profile = getManagementProfile()) =>
fetchJSON<ModelsAnalyticsResponse>(
appendProfileParam(`/api/analytics/models?days=${days}`, profile),
),
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
@@ -680,8 +707,10 @@ export const api = {
),
// Session search (FTS5)
searchSessions: (q: string) =>
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
searchSessions: (q: string, profile = getManagementProfile()) =>
fetchJSON<SessionSearchResponse>(
appendProfileParam(`/api/sessions/search?q=${encodeURIComponent(q)}`, profile),
),
// OAuth provider management
getOAuthProviders: () =>
+23
View File
@@ -205,6 +205,29 @@ docker exec hermes hermes profile delete coder
Under the hood, `hermes gateway start/stop/restart` inside the container is intercepted and routed to `s6-svc` against the right service directory; you don't need to learn the s6 commands directly. For raw supervisor state, use `/command/s6-svstat /run/service/gateway-<name>` (note `/command/` is on PATH only for processes spawned by the supervision tree — when calling from `docker exec`, pass the absolute path).
### Reaching more than one profile from outside the container
Two different surfaces reach a profile's gateway from outside, and they behave differently — don't conflate them:
**Hermes Desktop (and the web dashboard).** The Desktop app's **Remote Gateway** connection talks to a `hermes dashboard` backend (default **port 9119**, enabled by `HERMES_DASHBOARD=1`) — *not* the OpenAI API server. One dashboard backend serves **every** co-located profile: the app's profile switcher sends the target profile with each request and the backend opens that profile's `HERMES_HOME` on disk. So you do **not** need a second port — or a second connection — per profile for Desktop; one `:9119` connection covers them all through the switcher.
**OpenAI-compatible API clients (Open WebUI, LobeChat, `/v1/...`).** These talk to each profile's **API server**, which binds **port 8642 for every profile** (resolved from `API_SERVER_PORT` / `platforms.api_server.extra.port` — there is no auto-allocation and no `config.yaml`/`gateway.port` key). If you want a client to reach a *specific* second profile, give that profile a distinct `API_SERVER_PORT` in **its own** `.env`, otherwise its gateway tries to bind 8642 too and conflicts with the default profile:
```sh
# Create the profile (registers its gateway-<name> s6 slot)
docker exec hermes hermes profile create work
# Point its API server at a free port (write to the profile's own .env)
cat >> /opt/data/profiles/work/.env <<'EOF'
API_SERVER_ENABLED=true
API_SERVER_PORT=8643
EOF
docker exec hermes hermes -p work gateway restart
```
Keep `API_SERVER_PORT` in each profile's **own** `.env`, never in the container-wide `environment:` block — a global value would force every profile onto the same port and they would collide. With bridge networking, publish the extra port in `docker-compose.yml` (`- "8643:8643"`); with `network_mode: host` it is already reachable on the host. The default profile's 8642 connection is untouched.
### Why one container with many profiles, not many containers
Before the s6 migration, "one container per profile" was the recommended pattern because there was no in-container supervisor to manage multiple gateways. With s6 as PID 1, that's no longer necessary, and the single-container layout is simpler in almost every dimension:
+1 -1
View File
@@ -145,7 +145,7 @@ BlueBubbles iMessage channel uses.
## Start the gateway
```bash
hermes gateway start --platform photon
hermes gateway start
```
You'll see something like:
+2 -10
View File
@@ -900,19 +900,11 @@ gateway:
## Rendering: Rich Messages, Tables and Link Previews
**Rich Messages (Bot API 10.1).** When enabled, final replies are sent with Telegram's native [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) using the agent's **raw markdown**, so tables, task lists, headings, nested blockquotes, collapsible `<details>`, footnotes/references, math/formulas, underline, sub/superscript, marked text, and anchors render natively — no client-side flattening. In DMs the live streaming preview also uses `sendRichMessageDraft`, so the animated draft matches the final rich message. This is **opt-in** (default off) while the new endpoint is validated; enable it per platform:
```yaml
gateway:
platforms:
telegram:
extra:
rich_messages: true
```
**Rich Messages (Bot API 10.1).** Final replies are sent with Telegram's native [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) using the agent's **raw markdown**, so tables, task lists, headings, nested blockquotes, collapsible `<details>`, footnotes/references, math/formulas, underline, sub/superscript, marked text, and anchors render natively — no client-side flattening. In DMs the live streaming preview also uses `sendRichMessageDraft`, so the animated draft matches the final rich message.
The rich path is skipped automatically when content exceeds the 32,768-byte rich text limit, and any rejection from Telegram (unsupported endpoint on an older `python-telegram-bot`, parser error, oversized blocks/columns) **transparently falls back** to the MarkdownV2 path — your message is never lost. Transient/network errors are *not* silently re-sent (no duplicate final message).
**MarkdownV2 fallback.** When the rich path is disabled or unavailable, Hermes converts markdown to MarkdownV2. Since MarkdownV2 has no native table syntax, pipe tables are normalized:
**MarkdownV2 fallback.** When the rich path is unavailable for a message, Hermes converts markdown to MarkdownV2. Since MarkdownV2 has no native table syntax, pipe tables are normalized:
- **Small tables** are flattened into **row-group bullets** — each row becomes a readable bulleted list under the column headings. Good for 24 columns and short cells.
- **Larger or wider tables** fall back to a **fenced code block** with aligned columns so nothing collapses.
@@ -877,19 +877,11 @@ gateway:
## 渲染:富消息、表格和链接预览
**富消息(Bot API 10.1)。** 启用后,最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。此功能为**选择性启用**(默认关闭),在新端点经过验证期间需手动开启;可按平台配置:
```yaml
gateway:
platforms:
telegram:
extra:
rich_messages: true
```
**富消息(Bot API 10.1)。** 最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。
当内容超过 32,768 字节的富文本上限时,富消息路径会自动跳过;Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。
**MarkdownV2 回退。** 当富消息路径被禁用或不可用时,Hermes 会将 markdown 转换为 MarkdownV2。由于 MarkdownV2 没有原生表格语法,管道表格会被规范化:
**MarkdownV2 回退。** 当某条消息无法使用富消息路径时,Hermes 会将 markdown 转换为 MarkdownV2。由于 MarkdownV2 没有原生表格语法,管道表格会被规范化:
- **小表格**被展平为**行组项目符号**——每行在列标题下变为可读的项目符号列表。适合 2-4 列和短单元格。
- **较大或较宽的表格**回退为带对齐列的**围栏代码块**,以防内容折叠。