2026-02-20 23:23:32 -08:00
|
|
|
"""Shared constants for Hermes Agent.
|
|
|
|
|
|
|
|
|
|
Import-safe module with no dependencies — can be imported from anywhere
|
|
|
|
|
without risk of circular imports.
|
|
|
|
|
"""
|
|
|
|
|
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
import os
|
2026-05-26 10:32:03 +08:00
|
|
|
import sys
|
2026-05-18 20:52:29 -07:00
|
|
|
import sysconfig
|
2026-05-14 18:28:51 -03:00
|
|
|
from contextvars import ContextVar, Token
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
2026-05-02 01:49:55 -07:00
|
|
|
_profile_fallback_warned: bool = False
|
2026-05-14 18:28:51 -03:00
|
|
|
_UNSET = object()
|
|
|
|
|
_HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar(
|
|
|
|
|
"_HERMES_HOME_OVERRIDE", default=_UNSET
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_hermes_home_override(path: str | Path | None) -> Token:
|
|
|
|
|
"""Set a context-local Hermes home override and return its reset token.
|
|
|
|
|
|
|
|
|
|
This is for in-process, per-task scoping. It deliberately does not mutate
|
|
|
|
|
``os.environ`` because that is shared by every thread in the process.
|
|
|
|
|
"""
|
|
|
|
|
value: str | object = _UNSET if path is None else str(path)
|
|
|
|
|
return _HERMES_HOME_OVERRIDE.set(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reset_hermes_home_override(token: Token) -> None:
|
|
|
|
|
"""Restore the previous context-local Hermes home override."""
|
|
|
|
|
_HERMES_HOME_OVERRIDE.reset(token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_hermes_home_override() -> str | None:
|
|
|
|
|
"""Return the active context-local Hermes home override, if any."""
|
|
|
|
|
override = _HERMES_HOME_OVERRIDE.get()
|
|
|
|
|
if override is _UNSET or not override:
|
|
|
|
|
return None
|
|
|
|
|
return str(override)
|
2026-05-02 01:49:55 -07:00
|
|
|
|
|
|
|
|
|
2026-05-26 10:32:03 +08:00
|
|
|
def _get_platform_default_hermes_home() -> Path:
|
|
|
|
|
"""Return the platform-native default Hermes home path."""
|
|
|
|
|
if sys.platform == "win32":
|
|
|
|
|
local_appdata = os.environ.get("LOCALAPPDATA", "").strip()
|
|
|
|
|
base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local"
|
|
|
|
|
return base / "hermes"
|
|
|
|
|
return Path.home() / ".hermes"
|
|
|
|
|
|
|
|
|
|
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
def get_hermes_home() -> Path:
|
2026-05-26 10:32:03 +08:00
|
|
|
"""Return the Hermes home directory (default: platform-native path).
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
|
2026-05-26 10:32:03 +08:00
|
|
|
Reads HERMES_HOME env var, falls back to the platform-native default.
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
This is the single source of truth — all other copies should import this.
|
2026-05-02 01:49:55 -07:00
|
|
|
|
|
|
|
|
When ``HERMES_HOME`` is unset but an ``active_profile`` file indicates
|
|
|
|
|
a non-default profile is active, logs a loud one-shot warning to
|
|
|
|
|
``errors.log`` so cross-profile data corruption is diagnosable instead
|
|
|
|
|
of silent. Behavior is unchanged otherwise — we still return
|
2026-05-26 10:32:03 +08:00
|
|
|
the platform-native default — because raising here would brick 30+ module-level
|
2026-05-02 01:49:55 -07:00
|
|
|
callers that import this at load time. Subprocess spawners are
|
|
|
|
|
expected to propagate ``HERMES_HOME`` explicitly (see the systemd
|
|
|
|
|
template in ``hermes_cli/gateway.py`` and the kanban dispatcher in
|
|
|
|
|
``hermes_cli/kanban_db.py``). See https://github.com/NousResearch/hermes-agent/issues/18594.
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
"""
|
2026-05-14 18:28:51 -03:00
|
|
|
override = get_hermes_home_override()
|
|
|
|
|
if override:
|
|
|
|
|
return Path(override)
|
|
|
|
|
|
2026-04-13 14:49:10 -05:00
|
|
|
val = os.environ.get("HERMES_HOME", "").strip()
|
2026-05-02 01:49:55 -07:00
|
|
|
if val:
|
|
|
|
|
return Path(val)
|
|
|
|
|
|
|
|
|
|
# Guard: if a non-default profile is sticky-active, warn once that
|
|
|
|
|
# the fallback to the default profile is almost certainly wrong.
|
|
|
|
|
global _profile_fallback_warned
|
|
|
|
|
if not _profile_fallback_warned:
|
|
|
|
|
try:
|
2026-05-26 10:32:03 +08:00
|
|
|
fallback_home = _get_platform_default_hermes_home()
|
|
|
|
|
active_path = fallback_home / "active_profile"
|
2026-05-02 01:49:55 -07:00
|
|
|
active = active_path.read_text().strip() if active_path.exists() else ""
|
|
|
|
|
except (UnicodeDecodeError, OSError):
|
|
|
|
|
active = ""
|
|
|
|
|
if active and active != "default":
|
|
|
|
|
_profile_fallback_warned = True
|
|
|
|
|
# Write directly to stderr. We intentionally do NOT route this
|
|
|
|
|
# through ``logging`` because (a) this function is called at
|
|
|
|
|
# module-import time from 30+ sites, often before logging is
|
|
|
|
|
# configured, and (b) root-logger propagation would double-emit
|
|
|
|
|
# on consoles where a StreamHandler is already attached.
|
|
|
|
|
msg = (
|
|
|
|
|
f"[HERMES_HOME fallback] HERMES_HOME is unset but active "
|
2026-05-26 10:32:03 +08:00
|
|
|
f"profile is {active!r}. Falling back to {fallback_home}, which "
|
2026-05-02 01:49:55 -07:00
|
|
|
f"is the DEFAULT profile — not {active!r}. Any data this "
|
|
|
|
|
f"process writes will land in the wrong profile. The "
|
|
|
|
|
f"subprocess spawner should pass HERMES_HOME explicitly "
|
|
|
|
|
f"(see issue #18594)."
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
sys.stderr.write(msg + "\n")
|
|
|
|
|
sys.stderr.flush()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-05-26 10:32:03 +08:00
|
|
|
return _get_platform_default_hermes_home()
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
|
|
|
|
|
|
2026-04-10 05:53:10 -07:00
|
|
|
def get_default_hermes_root() -> Path:
|
|
|
|
|
"""Return the root Hermes directory for profile-level operations.
|
|
|
|
|
|
2026-05-26 10:32:03 +08:00
|
|
|
In standard deployments this is the platform-native Hermes home
|
|
|
|
|
(``~/.hermes`` on POSIX, ``%LOCALAPPDATA%\\hermes`` on native Windows).
|
2026-04-10 05:53:10 -07:00
|
|
|
|
|
|
|
|
In Docker or custom deployments where ``HERMES_HOME`` points outside
|
|
|
|
|
``~/.hermes`` (e.g. ``/opt/data``), returns ``HERMES_HOME`` directly
|
|
|
|
|
— that IS the root.
|
|
|
|
|
|
|
|
|
|
In profile mode where ``HERMES_HOME`` is ``<root>/profiles/<name>``,
|
|
|
|
|
returns ``<root>`` so that ``profile list`` can see all profiles.
|
|
|
|
|
Works both for standard (``~/.hermes/profiles/coder``) and Docker
|
|
|
|
|
(``/opt/data/profiles/coder``) layouts.
|
|
|
|
|
|
|
|
|
|
Import-safe — no dependencies beyond stdlib.
|
|
|
|
|
"""
|
2026-05-26 10:32:03 +08:00
|
|
|
native_home = _get_platform_default_hermes_home()
|
2026-04-10 05:53:10 -07:00
|
|
|
env_home = os.environ.get("HERMES_HOME", "")
|
|
|
|
|
if not env_home:
|
|
|
|
|
return native_home
|
|
|
|
|
env_path = Path(env_home)
|
|
|
|
|
try:
|
|
|
|
|
env_path.resolve().relative_to(native_home.resolve())
|
|
|
|
|
# HERMES_HOME is under ~/.hermes (normal or profile mode)
|
|
|
|
|
return native_home
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Docker / custom deployment.
|
|
|
|
|
# Check if this is a profile path: <root>/profiles/<name>
|
|
|
|
|
# If the immediate parent dir is named "profiles", the root is
|
|
|
|
|
# the grandparent — this covers Docker profiles correctly.
|
|
|
|
|
if env_path.parent.name == "profiles":
|
|
|
|
|
return env_path.parent.parent
|
|
|
|
|
|
|
|
|
|
# Not a profile path — HERMES_HOME itself is the root
|
|
|
|
|
return env_path
|
|
|
|
|
|
|
|
|
|
|
2026-05-18 20:52:29 -07:00
|
|
|
def _get_packaged_data_dir(name: str) -> Path | None:
|
|
|
|
|
"""Return an installed data-files directory if one exists.
|
|
|
|
|
|
|
|
|
|
Used to discover bundled skills/optional-skills when Hermes is installed
|
|
|
|
|
from a wheel that emitted them via setuptools data_files.
|
|
|
|
|
"""
|
|
|
|
|
candidates = []
|
|
|
|
|
for scheme in ("data", "purelib", "platlib"):
|
|
|
|
|
raw = sysconfig.get_path(scheme)
|
|
|
|
|
if raw:
|
|
|
|
|
candidates.append(Path(raw) / name)
|
|
|
|
|
for candidate in candidates:
|
|
|
|
|
if candidate.exists():
|
|
|
|
|
return candidate
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-03-30 17:34:43 -07:00
|
|
|
def get_optional_skills_dir(default: Path | None = None) -> Path:
|
|
|
|
|
"""Return the optional-skills directory, honoring package-manager wrappers.
|
|
|
|
|
|
|
|
|
|
Packaged installs may ship ``optional-skills`` outside the Python package
|
|
|
|
|
tree and expose it via ``HERMES_OPTIONAL_SKILLS``.
|
|
|
|
|
"""
|
|
|
|
|
override = os.getenv("HERMES_OPTIONAL_SKILLS", "").strip()
|
|
|
|
|
if override:
|
|
|
|
|
return Path(override)
|
2026-05-18 20:52:29 -07:00
|
|
|
packaged = _get_packaged_data_dir("optional-skills")
|
|
|
|
|
if packaged is not None:
|
|
|
|
|
return packaged
|
2026-03-30 17:34:43 -07:00
|
|
|
if default is not None:
|
|
|
|
|
return default
|
|
|
|
|
return get_hermes_home() / "optional-skills"
|
|
|
|
|
|
|
|
|
|
|
feat(mcp): Nous-approved MCP catalog with interactive picker (#30870)
* feat(mcp): Nous-approved MCP catalog with interactive picker
Adds an optional-mcps/ directory mirroring optional-skills/: curated,
Nous-approved MCP servers shipped with the repo but disabled by default.
Presence in optional-mcps/ = approval. No community tier, no trust signals.
Entries are added by merging a PR.
New surface:
hermes mcp Interactive catalog picker (default)
hermes mcp catalog Plain-text list, scriptable
hermes mcp install <name> Install a catalog entry
Picker behavior:
not installed -> install (clone/bootstrap if needed, prompt for creds)
installed/off -> enable
installed/on -> menu (disable / uninstall / reinstall)
Manifest schema (manifest_version: 1) supports:
- transport: stdio (command/args, ${INSTALL_DIR} substitution) or http (url)
- install: optional git clone + bootstrap commands (for repos that need
local venv setup, like the n8n bridge); omit for npx/uvx servers
- auth: api_key (prompts -> ~/.hermes/.env), oauth (provider-mediated
or native MCP), or none
Catalog entries are never auto-updated. Users re-run `hermes mcp install`
to refresh. Credentials always go to ~/.hermes/.env (the .env-is-for-secrets
rule), never to per-server env blocks.
Ships n8n as the reference manifest (https://github.com/CyberSamuraiX/hermes-n8n-mcp).
Tests: 19 catalog tests + E2E install/uninstall round-trip via the shipped
manifest.
* feat(mcp): tool-selection checklist + Linear catalog entry
Adds install-time tool selection so users only enable the MCP tools they
actually want, and ships Linear as a second reference catalog entry to
demonstrate the http+oauth path alongside n8n's stdio+api_key+git-bootstrap.
Tool selection flow:
install (clone/auth/credentials) ->
probe server for available tools ->
curses checklist with pre-checked rows ->
write mcp_servers.<name>.tools.include
Pre-check priority:
1. user's prior tools.include (reinstall preserves selection)
2. manifest's tools.default_enabled (curated subset)
3. all probed tools (default)
Probe-failure fallback (server unreachable, OAuth not yet complete,
backing service offline):
- manifest declared default_enabled -> applied directly
- no default declared -> no filter written (all-on when reachable)
- both cases point user at hermes mcp configure <name>
Manifest schema additions:
tools:
default_enabled: [list, of, tool, names] # optional
Updates:
- optional-mcps/linear/manifest.yaml -- new reference entry (http+oauth)
- optional-mcps/n8n/manifest.yaml -- tools.default_enabled set to the
8 read-mostly tools; mutating tools (activate/deactivate, container_logs)
pruned by default
- docs: new 'Tool selection at install time' section in features/mcp.md
Tests: 7 new tests in TestToolSelection covering probe-success / probe-fail
matrix, manifest-default filtering, reinstall-preserves-selection, and
invalid-default-enabled rejection. 26 catalog tests + 32 existing
mcp_config tests passing.
* feat(mcp): polish — picker unification, include-mode convergence, hardening
Addresses review findings on PR #30870. Lands all improvements that
belong in this PR before merge; defers separate cleanup (consolidating
two probe implementations, change-detector tests) to follow-ups.
Picker UX (mcp_picker.py)
- Unifies catalog + custom (user-added) MCPs in one view with distinct
status badges (available / enabled / installed (disabled) /
custom — enabled / custom — disabled)
- Adds 'Configure tools (probe server + re-pick)' action to both the
catalog-installed and custom-row submenus — the existing
hermes mcp configure flow was previously unreachable from the picker
- Loops until ESC/q so the user can manage several entries in one
session instead of having to re-launch
- Uninstall message now mentions .env credentials are preserved with a
pointer to clean them up manually if no longer needed
- Surfaces a 'requires a newer Hermes' warning per future-manifest
entry instead of silently hiding it
Catalog (mcp_catalog.py)
- catalog_diagnostics() exposes which manifests were skipped and why
(future_manifest vs invalid) so UIs can give actionable feedback
- _do_git_install detects SHA-shaped refs (regex /[0-9a-f]{7,40}/)
and skips the doomed 'git clone --branch <sha>' attempt — clone --branch
only accepts branches/tags, so SHAs always failed noisily before
falling back to the full-clone path
- Probe-success all-tools-enabled message now mentions that new tools
the server adds later will be auto-enabled (no-filter mode)
Convergence (tools_config.py)
- _configure_mcp_tools_interactive now writes tools.include (whitelist)
instead of tools.exclude (blacklist), matching the catalog flow and
hermes mcp configure. The on-disk config shape no longer depends on
which UI the user touched last
- Two existing tests updated to assert the new include-mode contract
Discoverability
- Setup wizard final step now prints 'Browse curated MCPs: hermes mcp'
- Three tip-corpus entries pointing at the new catalog
- Docs updated with: trust model (manifests run code locally, gated by
PR review, but read before installing), runtime ${ENV_VAR} substitution
semantics, and the manifest_version forward-compat behavior
Tests
- 7 new tests covering future-manifest diagnostics, custom MCP picker
rows, SHA-ref git-install path, branch-ref git-install path, and the
tools_config include-mode write contract
- 80 MCP-related tests passing across test_mcp_catalog.py,
test_mcp_config.py, test_mcp_tools_config.py
* fix(mcp): drop setup-wizard catalog hint to satisfy supply-chain scanner
The wizard line 'Browse curated MCPs: hermes mcp' triggered the
CI supply-chain scanner because it pattern-matches on edits to any
file named hermes_cli/setup.py — that filename matches the Python
'install-hook file' heuristic even though this setup.py is the
user-facing 'hermes setup' wizard, not a packaging install hook.
The catalog is already surfaced via three tip-corpus entries in
hermes_cli/tips.py (which the scanner doesn't flag), so dropping the
wizard mention loses no discoverability. Worth revisiting after a
scanner allowlist for this specific file lands.
2026-05-26 12:48:14 -07:00
|
|
|
def get_optional_mcps_dir(default: Path | None = None) -> Path:
|
|
|
|
|
"""Return the optional-mcps directory, honoring package-manager wrappers.
|
|
|
|
|
|
|
|
|
|
Mirrors :func:`get_optional_skills_dir` for the MCP catalog (Nous-approved
|
|
|
|
|
Model Context Protocol servers shipped with the repo but disabled by
|
|
|
|
|
default). Packaged installs may ship ``optional-mcps`` outside the Python
|
|
|
|
|
package tree and expose it via ``HERMES_OPTIONAL_MCPS``.
|
|
|
|
|
"""
|
|
|
|
|
override = os.getenv("HERMES_OPTIONAL_MCPS", "").strip()
|
|
|
|
|
if override:
|
|
|
|
|
return Path(override)
|
|
|
|
|
packaged = _get_packaged_data_dir("optional-mcps")
|
|
|
|
|
if packaged is not None:
|
|
|
|
|
return packaged
|
|
|
|
|
if default is not None:
|
|
|
|
|
return default
|
|
|
|
|
return get_hermes_home() / "optional-mcps"
|
|
|
|
|
|
|
|
|
|
|
2026-05-18 20:52:29 -07:00
|
|
|
def get_bundled_skills_dir(default: Path | None = None) -> Path:
|
|
|
|
|
"""Return the bundled skills directory for source and packaged installs.
|
|
|
|
|
|
|
|
|
|
Resolution order:
|
|
|
|
|
1. ``HERMES_BUNDLED_SKILLS`` env var (Nix wrapper / explicit override)
|
|
|
|
|
2. Wheel-installed ``<sysconfig data>/skills`` (pip install path)
|
|
|
|
|
3. Caller-supplied ``default`` (typically the source-checkout path)
|
|
|
|
|
4. ``<HERMES_HOME>/skills`` last-resort
|
|
|
|
|
"""
|
|
|
|
|
override = os.getenv("HERMES_BUNDLED_SKILLS", "").strip()
|
|
|
|
|
if override:
|
|
|
|
|
return Path(override)
|
|
|
|
|
packaged = _get_packaged_data_dir("skills")
|
|
|
|
|
if packaged is not None:
|
|
|
|
|
return packaged
|
|
|
|
|
if default is not None:
|
|
|
|
|
return default
|
|
|
|
|
return get_hermes_home() / "skills"
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 15:22:19 -07:00
|
|
|
def get_hermes_dir(new_subpath: str, old_name: str) -> Path:
|
|
|
|
|
"""Resolve a Hermes subdirectory with backward compatibility.
|
|
|
|
|
|
|
|
|
|
New installs get the consolidated layout (e.g. ``cache/images``).
|
|
|
|
|
Existing installs that already have the old path (e.g. ``image_cache``)
|
|
|
|
|
keep using it — no migration required.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
new_subpath: Preferred path relative to HERMES_HOME (e.g. ``"cache/images"``).
|
|
|
|
|
old_name: Legacy path relative to HERMES_HOME (e.g. ``"image_cache"``).
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Absolute ``Path`` — old location if it exists on disk, otherwise the new one.
|
|
|
|
|
"""
|
|
|
|
|
home = get_hermes_home()
|
|
|
|
|
old_path = home / old_name
|
|
|
|
|
if old_path.exists():
|
|
|
|
|
return old_path
|
|
|
|
|
return home / new_subpath
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 23:47:21 -07:00
|
|
|
def display_hermes_home() -> str:
|
|
|
|
|
"""Return a user-friendly display string for the current HERMES_HOME.
|
|
|
|
|
|
|
|
|
|
Uses ``~/`` shorthand for readability::
|
|
|
|
|
|
|
|
|
|
default: ``~/.hermes``
|
|
|
|
|
profile: ``~/.hermes/profiles/coder``
|
|
|
|
|
custom: ``/opt/hermes-custom``
|
|
|
|
|
|
|
|
|
|
Use this in **user-facing** print/log messages instead of hardcoding
|
|
|
|
|
``~/.hermes``. For code that needs a real ``Path``, use
|
|
|
|
|
:func:`get_hermes_home` instead.
|
|
|
|
|
"""
|
|
|
|
|
home = get_hermes_home()
|
|
|
|
|
try:
|
|
|
|
|
return "~/" + str(home.relative_to(Path.home()))
|
|
|
|
|
except ValueError:
|
|
|
|
|
return str(home)
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 00:28:39 +08:00
|
|
|
def secure_parent_dir(path: Path) -> None:
|
|
|
|
|
"""Chmod ``0o700`` on the parent directory of *path*, but only if safe.
|
|
|
|
|
|
2026-05-20 22:55:22 -07:00
|
|
|
Refuses to chmod ``/`` or any top-level directory (resolved parent with
|
|
|
|
|
fewer than 3 parts, i.e. ``/`` or any direct child like ``/usr``) to
|
|
|
|
|
prevent catastrophic host bricking when ``HERMES_HOME`` or other path
|
|
|
|
|
env vars resolve to an unexpected location.
|
2026-05-15 00:28:39 +08:00
|
|
|
|
|
|
|
|
See https://github.com/NousResearch/hermes-agent/issues/25821.
|
|
|
|
|
"""
|
|
|
|
|
parent = path.parent.resolve()
|
|
|
|
|
# Refuse root and its direct children (/usr, /home, /var, /tmp, …).
|
|
|
|
|
if parent == Path("/") or len(parent.parts) < 3:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
os.chmod(parent, 0o700)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-04-10 13:37:45 -07:00
|
|
|
def get_subprocess_home() -> str | None:
|
|
|
|
|
"""Return a per-profile HOME directory for subprocesses, or None.
|
|
|
|
|
|
|
|
|
|
When ``{HERMES_HOME}/home/`` exists on disk, subprocesses should use it
|
|
|
|
|
as ``HOME`` so system tools (git, ssh, gh, npm …) write their configs
|
|
|
|
|
inside the Hermes data directory instead of the OS-level ``/root`` or
|
|
|
|
|
``~/``. This provides:
|
|
|
|
|
|
|
|
|
|
* **Docker persistence** — tool configs land inside the persistent volume.
|
|
|
|
|
* **Profile isolation** — each profile gets its own git identity, SSH
|
|
|
|
|
keys, gh tokens, etc.
|
|
|
|
|
|
|
|
|
|
The Python process's own ``os.environ["HOME"]`` and ``Path.home()`` are
|
|
|
|
|
**never** modified — only subprocess environments should inject this value.
|
|
|
|
|
Activation is directory-based: if the ``home/`` subdirectory doesn't
|
|
|
|
|
exist, returns ``None`` and behavior is unchanged.
|
|
|
|
|
"""
|
2026-05-14 18:28:51 -03:00
|
|
|
hermes_home = get_hermes_home_override() or os.getenv("HERMES_HOME")
|
2026-04-10 13:37:45 -07:00
|
|
|
if not hermes_home:
|
|
|
|
|
return None
|
|
|
|
|
profile_home = os.path.join(hermes_home, "home")
|
|
|
|
|
if os.path.isdir(profile_home):
|
|
|
|
|
return profile_home
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-04-09 13:27:02 -07:00
|
|
|
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_reasoning_effort(effort: str) -> dict | None:
|
|
|
|
|
"""Parse a reasoning effort level into a config dict.
|
|
|
|
|
|
2026-04-09 11:06:39 -05:00
|
|
|
Valid levels: "none", "minimal", "low", "medium", "high", "xhigh".
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
Returns None when the input is empty or unrecognized (caller uses default).
|
|
|
|
|
Returns {"enabled": False} for "none".
|
|
|
|
|
Returns {"enabled": True, "effort": <level>} for valid effort levels.
|
|
|
|
|
"""
|
|
|
|
|
if not effort or not effort.strip():
|
|
|
|
|
return None
|
|
|
|
|
effort = effort.strip().lower()
|
|
|
|
|
if effort == "none":
|
|
|
|
|
return {"enabled": False}
|
|
|
|
|
if effort in VALID_REASONING_EFFORTS:
|
|
|
|
|
return {"enabled": True, "effort": effort}
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-04-09 14:53:02 -07:00
|
|
|
def is_termux() -> bool:
|
|
|
|
|
"""Return True when running inside a Termux (Android) environment.
|
|
|
|
|
|
|
|
|
|
Checks ``TERMUX_VERSION`` (set by Termux) or the Termux-specific
|
|
|
|
|
``PREFIX`` path. Import-safe — no heavy deps.
|
|
|
|
|
"""
|
|
|
|
|
prefix = os.getenv("PREFIX", "")
|
|
|
|
|
return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
|
|
|
|
|
|
|
|
|
|
|
2026-04-10 21:15:47 -07:00
|
|
|
_wsl_detected: bool | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_wsl() -> bool:
|
|
|
|
|
"""Return True when running inside WSL (Windows Subsystem for Linux).
|
|
|
|
|
|
|
|
|
|
Checks ``/proc/version`` for the ``microsoft`` marker that both WSL1
|
|
|
|
|
and WSL2 inject. Result is cached for the process lifetime.
|
|
|
|
|
Import-safe — no heavy deps.
|
|
|
|
|
"""
|
|
|
|
|
global _wsl_detected
|
|
|
|
|
if _wsl_detected is not None:
|
|
|
|
|
return _wsl_detected
|
|
|
|
|
try:
|
codebase: add encoding='utf-8' to all bare open() calls (PLW1514)
Closes the last Python-on-Windows UTF-8 exposure by making every
text-mode open() call explicit about its encoding.
Before: on Windows, bare open(path, 'r') defaults to the system
locale encoding (cp1252 on US-locale installs). That means reading
any config/yaml/markdown/json file with non-ASCII content either
crashes with UnicodeDecodeError or silently mis-decodes bytes.
After: all 89 affected call sites in production code now pass
encoding='utf-8' explicitly. Works identically on every platform
and every locale, no surprise behavior.
Mechanical sweep via:
ruff check --preview --extend-select PLW1514 --unsafe-fixes --fix --exclude 'tests,venv,.venv,node_modules,website,optional-skills, skills,tinker-atropos,plugins' .
All 89 fixes have the same shape: open(x) or open(x, mode) became
open(x, encoding='utf-8') or open(x, mode, encoding='utf-8'). Nothing
else changed. Every modified file still parses and the Windows/sandbox
test suite is still green (85 passed, 14 skipped, 0 failed across
tests/tools/test_code_execution_windows_env.py +
tests/tools/test_code_execution_modes.py + tests/tools/test_env_passthrough.py +
tests/test_hermes_bootstrap.py).
Scope notes:
- tests/ excluded: test fixtures can use locale encoding intentionally
(exercising edge cases). If we want to tighten tests later that's
a separate PR.
- plugins/ excluded: plugin-specific conventions may differ; plugin
authors own their code.
- optional-skills/ and skills/ excluded: skill scripts are user-authored
and we don't want to mass-edit them.
- website/ and tinker-atropos/ excluded: vendored / generated content.
46 files touched, 89 +/- lines (symmetric replacement). No behavior
change on POSIX or on Windows when the file is ASCII; bug fix on
Windows when the file contains non-ASCII.
2026-05-07 19:24:45 -07:00
|
|
|
with open("/proc/version", "r", encoding="utf-8") as f:
|
2026-04-10 21:15:47 -07:00
|
|
|
_wsl_detected = "microsoft" in f.read().lower()
|
|
|
|
|
except Exception:
|
|
|
|
|
_wsl_detected = False
|
|
|
|
|
return _wsl_detected
|
|
|
|
|
|
|
|
|
|
|
2026-04-12 14:42:46 -07:00
|
|
|
_container_detected: bool | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_container() -> bool:
|
|
|
|
|
"""Return True when running inside a Docker/Podman container.
|
|
|
|
|
|
|
|
|
|
Checks ``/.dockerenv`` (Docker), ``/run/.containerenv`` (Podman),
|
|
|
|
|
and ``/proc/1/cgroup`` for container runtime markers. Result is
|
|
|
|
|
cached for the process lifetime. Import-safe — no heavy deps.
|
|
|
|
|
"""
|
|
|
|
|
global _container_detected
|
|
|
|
|
if _container_detected is not None:
|
|
|
|
|
return _container_detected
|
|
|
|
|
if os.path.exists("/.dockerenv"):
|
|
|
|
|
_container_detected = True
|
|
|
|
|
return True
|
|
|
|
|
if os.path.exists("/run/.containerenv"):
|
|
|
|
|
_container_detected = True
|
|
|
|
|
return True
|
|
|
|
|
try:
|
codebase: add encoding='utf-8' to all bare open() calls (PLW1514)
Closes the last Python-on-Windows UTF-8 exposure by making every
text-mode open() call explicit about its encoding.
Before: on Windows, bare open(path, 'r') defaults to the system
locale encoding (cp1252 on US-locale installs). That means reading
any config/yaml/markdown/json file with non-ASCII content either
crashes with UnicodeDecodeError or silently mis-decodes bytes.
After: all 89 affected call sites in production code now pass
encoding='utf-8' explicitly. Works identically on every platform
and every locale, no surprise behavior.
Mechanical sweep via:
ruff check --preview --extend-select PLW1514 --unsafe-fixes --fix --exclude 'tests,venv,.venv,node_modules,website,optional-skills, skills,tinker-atropos,plugins' .
All 89 fixes have the same shape: open(x) or open(x, mode) became
open(x, encoding='utf-8') or open(x, mode, encoding='utf-8'). Nothing
else changed. Every modified file still parses and the Windows/sandbox
test suite is still green (85 passed, 14 skipped, 0 failed across
tests/tools/test_code_execution_windows_env.py +
tests/tools/test_code_execution_modes.py + tests/tools/test_env_passthrough.py +
tests/test_hermes_bootstrap.py).
Scope notes:
- tests/ excluded: test fixtures can use locale encoding intentionally
(exercising edge cases). If we want to tighten tests later that's
a separate PR.
- plugins/ excluded: plugin-specific conventions may differ; plugin
authors own their code.
- optional-skills/ and skills/ excluded: skill scripts are user-authored
and we don't want to mass-edit them.
- website/ and tinker-atropos/ excluded: vendored / generated content.
46 files touched, 89 +/- lines (symmetric replacement). No behavior
change on POSIX or on Windows when the file is ASCII; bug fix on
Windows when the file contains non-ASCII.
2026-05-07 19:24:45 -07:00
|
|
|
with open("/proc/1/cgroup", "r", encoding="utf-8") as f:
|
2026-04-12 14:42:46 -07:00
|
|
|
cgroup = f.read()
|
|
|
|
|
if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup:
|
|
|
|
|
_container_detected = True
|
|
|
|
|
return True
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
_container_detected = False
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
# ─── Well-Known Paths ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_config_path() -> Path:
|
|
|
|
|
"""Return the path to ``config.yaml`` under HERMES_HOME.
|
|
|
|
|
|
|
|
|
|
Replaces the ``get_hermes_home() / "config.yaml"`` pattern repeated
|
|
|
|
|
in 7+ files (skill_utils.py, hermes_logging.py, hermes_time.py, etc.).
|
|
|
|
|
"""
|
|
|
|
|
return get_hermes_home() / "config.yaml"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_skills_dir() -> Path:
|
|
|
|
|
"""Return the path to the skills directory under HERMES_HOME."""
|
|
|
|
|
return get_hermes_home() / "skills"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_env_path() -> Path:
|
|
|
|
|
"""Return the path to the ``.env`` file under HERMES_HOME."""
|
|
|
|
|
return get_hermes_home() / ".env"
|
|
|
|
|
|
|
|
|
|
|
2026-04-11 23:12:11 -07:00
|
|
|
# ─── Network Preferences ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_ipv4_preference(force: bool = False) -> None:
|
|
|
|
|
"""Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 connections.
|
|
|
|
|
|
|
|
|
|
On servers with broken or unreachable IPv6, Python tries AAAA records
|
|
|
|
|
first and hangs for the full TCP timeout before falling back to IPv4.
|
|
|
|
|
This affects httpx, requests, urllib, the OpenAI SDK — everything that
|
|
|
|
|
uses ``socket.getaddrinfo``.
|
|
|
|
|
|
|
|
|
|
When *force* is True, patches ``getaddrinfo`` so that calls with
|
|
|
|
|
``family=AF_UNSPEC`` (the default) resolve as ``AF_INET`` instead,
|
|
|
|
|
skipping IPv6 entirely. If no A record exists, falls back to the
|
|
|
|
|
original unfiltered resolution so pure-IPv6 hosts still work.
|
|
|
|
|
|
|
|
|
|
Safe to call multiple times — only patches once.
|
|
|
|
|
Set ``network.force_ipv4: true`` in ``config.yaml`` to enable.
|
|
|
|
|
"""
|
|
|
|
|
if not force:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
import socket
|
|
|
|
|
|
|
|
|
|
# Guard against double-patching
|
|
|
|
|
if getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
_original_getaddrinfo = socket.getaddrinfo
|
|
|
|
|
|
|
|
|
|
def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
|
|
|
|
|
if family == 0: # AF_UNSPEC — caller didn't request a specific family
|
|
|
|
|
try:
|
|
|
|
|
return _original_getaddrinfo(
|
|
|
|
|
host, port, socket.AF_INET, type, proto, flags
|
|
|
|
|
)
|
|
|
|
|
except socket.gaierror:
|
|
|
|
|
# No A record — fall back to full resolution (pure-IPv6 hosts)
|
|
|
|
|
return _original_getaddrinfo(host, port, family, type, proto, flags)
|
|
|
|
|
return _original_getaddrinfo(host, port, family, type, proto, flags)
|
|
|
|
|
|
|
|
|
|
_ipv4_getaddrinfo._hermes_ipv4_patched = True # type: ignore[attr-defined]
|
|
|
|
|
socket.getaddrinfo = _ipv4_getaddrinfo # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
fix(streaming): route mid-tool-call partial-stream-stub through length continuation (#31998) (#32012)
* fix(streaming): route mid-tool-call partial-stream-stub through length continuation (#31998)
When a stream stalls mid-tool-call (e.g. a large write_file), the
partial-stream-stub recovery used finish_reason='stop' which caused the
conversation loop to treat the turn as complete, returning only the
warning text. When users said 'continue', the model retried the same
large tool call, hit the same stale timeout, and looped indefinitely.
Changes:
- chat_completion_helpers.py: change _stub_finish_reason from 'stop' to
'length' for mid-tool-call partials. The stub still has tool_calls=None
so no tool auto-executes — the model gets a fresh API call through the
existing length-continuation machinery (bounded to 3 retries).
Also attach _dropped_tool_names to the stub for downstream use.
- conversation_loop.py: add a third continuation prompt branch for
partial-stream-stubs with dropped tool calls. Instead of the generic
'continue where you left off' (which would retry the same large call),
tell the model to break the output into smaller tool calls (~8K
tokens each) to avoid stream timeouts.
- test_partial_stream_finish_reason.py: update existing test from
finish_reason='stop' to 'length', add _dropped_tool_names assertion,
add new test_dropped_tool_call_uses_chunking_prompt for the 3-way
prompt branching.
Safety: tool_calls=None is preserved on the stub, so the conversation
loop enters the text-continuation branch (line 1513), NOT the tool-call
execution branch (line 3246). No tool auto-executes. The model simply
gets another API call with targeted guidance.
* refactor: extract constants and continuation prompt helper
- Move magic strings to hermes_constants.py (PARTIAL_STREAM_STUB_ID,
FINISH_REASON_LENGTH)
- Extract _get_continuation_prompt() in conversation_loop.py — DRYs the
3-way prompt branching and lets tests import the real function
- Trim verbose inline comments in chat_completion_helpers.py
- Tests import constants + helper instead of duplicating logic
---------
Co-authored-by: alt-glitch <balyan.sid@gmail.com>
2026-05-25 17:43:10 +05:30
|
|
|
# ─── Streaming Response Constants ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
# Response ID for partial stream stubs used during error recovery
|
|
|
|
|
PARTIAL_STREAM_STUB_ID = "partial-stream-stub"
|
|
|
|
|
|
|
|
|
|
FINISH_REASON_LENGTH = "length"
|
|
|
|
|
|
|
|
|
|
|
2026-02-20 23:23:32 -08:00
|
|
|
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
|
|
|
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
|